From 19a743f6013f5cf1005c5489553ec16add72190f Mon Sep 17 00:00:00 2001 From: Ivan Shubin Date: Wed, 5 Feb 2025 20:20:18 +0100 Subject: [PATCH] added controls in sankey diagram for adding new connections and nodes --- assets/css/main.css | 3 +- assets/templates/diagrams/mind-map.json | 2 +- assets/templates/diagrams/sankey.json | 2 +- assets/templates/diagrams/src/control.sch | 1 + assets/templates/diagrams/src/sankey.sch | 122 +++++++++++++++++- src/ui/components/SchemeEditor.vue | 23 +++- src/ui/components/editor/ContextMenu.vue | 57 ++++++-- src/ui/components/editor/EditBox.vue | 50 ++++++- src/ui/components/editor/SvgEditor.vue | 14 +- .../components/editor/items/ItemTemplate.js | 36 +++++- src/ui/events.js | 13 ++ src/ui/templater/templater.js | 15 +++ 12 files changed, 306 insertions(+), 32 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 1fc75e9bd..4089917eb 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -2966,10 +2966,9 @@ ul.script-options .script-option.selected { top: 0; left: 0; z-index: 998; - /* overflow: auto; */ } .context-menu ul { - position: absolute; + position: fixed; margin: 0; padding: 0; list-style: none; diff --git a/assets/templates/diagrams/mind-map.json b/assets/templates/diagrams/mind-map.json index 8202f2833..aaa092161 100644 --- a/assets/templates/diagrams/mind-map.json +++ b/assets/templates/diagrams/mind-map.json @@ -1 +1 @@ -{"name": "Mind map", "description": "", "args": {"nodes": {"type": "string", "value": "root;x=0;y=0;s=uml_start", "name": "Nodes encoded", "hidden": true}, "connectorType": {"type": "choice", "value": "pretty", "options": ["pretty", "linear", "smooth", "step", "step-cut", "step-smooth"], "name": "Connectors Type"}, "capSize": {"type": "number", "value": 15, "name": "Cap size"}, "capType": {"type": "path-cap", "value": "triangle", "name": "Cap type"}, "iconSize": {"type": "number", "value": 20, "name": "Icon size"}, "showProgress": {"type": "boolean", "value": false, "name": "Show Progress"}, "progressSize": {"type": "number", "value": 20, "name": "Progress Icon Size", "depends": {"showProgress": true}}, "gradientProgress": {"type": "boolean", "value": true, "name": "Gradient Progress", "depends": {"showProgress": true}}, "progressColor": {"type": "color", "value": "#F35B3B", "name": "Progress Icon Color", "depends": {"showProgress": true}}, "progressColor2": {"type": "color", "value": "#E5AB2D", "name": "Progress Icon Color 2", "depends": {"showProgress": true}}, "progressColor3": {"type": "color", "value": "#13D481", "name": "Progress Icon Color 3", "depends": {"showProgress": true, "gradientProgress": true}}}, "preview": "/assets/templates/previews/mind-map.svg", "defaultArea": {"x": 0, "y": 0, "w": 200, "h": 60}, "import": ["./src/item.sch", "./src/control.sch", "./src/mind-map.sch"], "handlers": {"delete": "onDeleteItem(itemId, item)", "area": "onAreaUpdate(itemId, item, area)", "copy": "onCopyItem(itemId, item)", "paste": "onPasteItems(itemId, items)"}, "controls": [{"$-foreach": {"source": "controls", "it": "control"}, "$-extend": {"$-expr": "toJSON(control)"}}], "editor": {"panels": [{"id": "node-progress", "condition": "shouldNodeProgressEditorBeDisplayed(selectedItemIds)", "type": "item-menu", "click": "selectProgressForItems(selectedItemIds, panelItem)", "name": "Select progress", "slotSize": {"width": {"$-expr": "progressSize * 2"}, "height": {"$-expr": "progressSize * 2"}}, "items": [{"$-foreach": {"source": "getAllProgressIconItems()", "it": "it"}, "$-extend": {"$-expr": "toJSON(it)"}}]}, {"id": "node-icon-selector", "condition": "shouldNodeIconSelectorBeDisplayed(selectedItemIds)", "type": "item-menu", "click": "selectIconForItems(selectedItemIds, panelItem)", "name": "Choose icons", "slotSize": {"width": 30, "height": 30}, "items": [{"$-foreach": {"source": "getAllAvailableIcons()", "it": "icon"}, "id": {"$-expr": "icon.id"}, "name": {"$-expr": "icon.id"}, "shape": "image", "area": {"x": -2, "y": -2, "w": 26, "h": 26}, "shapeProps": {"image": {"$-expr": "icon.url"}}}]}, {"id": "node-shape-selector", "condition": "shouldNodeShapeSelectorBeDisplayed(selectedItemIds)", "type": "item-menu", "click": "selectShapeForItems(selectedItemIds, panelItem)", "name": "Select shape", "slotSize": {"width": 80, "height": 40}, "items": [{"id": "rect", "shape": "rect", "area": {"w": 72, "h": 32}, "shapeProps": {"cornerRadius": 0, "fill": {"type": "none"}}}, {"id": "rounded-rect", "shape": "rect", "area": {"w": 72, "h": 32}, "shapeProps": {"cornerRadius": 10, "fill": {"type": "none"}}}, {"id": "start", "shape": "uml_start", "area": {"w": 72, "h": 32}, "shapeProps": {"fill": {"type": "none"}}}, {"id": "ellipse", "shape": "ellipse", "area": {"w": 72, "h": 32}, "shapeProps": {"cornerRadius": 5, "fill": {"type": "none"}}}, {"id": "basic_diamond", "shape": "basic_diamond", "area": {"w": 72, "h": 32}, "shapeProps": {"fill": {"type": "none"}}}, {"id": "uml_preparation", "shape": "uml_preparation", "area": {"w": 72, "h": 32}, "shapeProps": {"fill": {"type": "none"}}}, {"id": "label", "shape": "none", "area": {"w": 72, "h": 32}, "textSlots": {"body": {"text": "Simple label"}}}]}, {"id": "node-operations", "condition": "shouldNodeOperationsPanelBeDisplayed(selectedItemIds)", "type": "buttons", "name": "Operations", "click": "onOperationsPanelClick(selectedItemIds, panelItem)", "buttons": [{"id": "insert-new-parent", "name": "Insert new parent"}, {"id": "delete-node-preserve-children", "name": "Delete (preserve children)"}]}]}, "item": {"$-recurse": {"object": {"$-expr": "rootItem"}, "it": "it", "children": "it.childItems", "dstChildren": "childItems"}, "id": {"$-expr": "it.id"}, "name": {"$-expr": "`${it.name}`"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "it.getArgs()"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}}, "init": "\nstruct Item {\n id: uid()\n name: ''\n shape: 'rect'\n x: 0\n y: 0\n w: 100\n h: 50\n shapeProps: Map()\n childItems: List()\n args: Map()\n locked: true\n textSlots: Map()\n description: \"\"\n\n\n traverse(callback) {\n this.childItems.forEach((childItem) => {\n childItem.traverse(callback)\n })\n callback(this)\n }\n\n getArgs() {\n toJSON(this.args)\n }\n\n setText(slotName, text) {\n if (!this.textSlots.has(slotName)) {\n this.textSlots.set(slotName, Map('text', text))\n } else {\n this.textSlots.get(slotName).set('text', text)\n }\n }\n\n toJSON() {\n childItems = this.childItems.map((childItem) => { childItem.toJSON() })\n result = toJSON(Map(\n 'id', this.id,\n 'childItems', childItems,\n 'name', this.name,\n 'description', this.description,\n 'shape', this.shape,\n 'area', Map('x', this.x, 'y', this.y, 'w', this.w, 'h', this.h, 'r', 0, 'sx', 1, 'sy', 1, 'px', 0.5, 'py', 0.5),\n 'shapeProps', this.shapeProps,\n 'args', this.args,\n 'locked', this.locked,\n 'textSlots', this.textSlots,\n ))\n\n result\n }\n}\n\n\n\nstruct Control {\n name: \"\"\n data: Map()\n click: \"log('control clicked')\"\n x: 0\n y: 0\n width: 20\n height: 20\n text: \"+\"\n placement: \"TL\"\n selectedItemId: \"\"\n type: 'button'\n options: List()\n}\n\npadding = 120\ncontrolPadding = 40\ncontrols = List()\nrootNode = null\nABS_POS = 'absolutePosition'\n\nconnectorDefaultColor = 'rgba(80,80,80,1.0)'\n\n// rootItem is used for building all of the template items\nrootItem = null\n\nallIcons = Map(\n 'search', '/assets/art/google-cloud/bigquery/bigquery.svg',\n 'time', '/assets/art/azure/General/10006-icon-service-Recent.svg',\n 'cloud', '/assets/art/google-cloud/my_cloud/my_cloud.svg',\n \"check\", \"/assets/art/icons/check.svg\",\n \"cross\", \"/assets/art/icons/cross.svg\",\n \"depressed\", \"/assets/art/icons/depressed.svg\",\n \"emoji-angry\", \"/assets/art/icons/emoji-angry.svg\",\n \"emoji-angry-2\", \"/assets/art/icons/emoji-angry-2.svg\",\n \"emoji-cry\", \"/assets/art/icons/emoji-cry.svg\",\n \"emoji-dead\", \"/assets/art/icons/emoji-dead.svg\",\n \"emoji-hah\", \"/assets/art/icons/emoji-hah.svg\",\n \"emoji-happy\", \"/assets/art/icons/emoji-happy.svg\",\n \"emoji-heart\", \"/assets/art/icons/emoji-heart.svg\",\n \"emoji-puke\", \"/assets/art/icons/emoji-puke.svg\",\n \"emoji-rest\", \"/assets/art/icons/emoji-rest.svg\",\n \"emoji-rest-2\", \"/assets/art/icons/emoji-rest-2.svg\",\n \"emoji-rich\", \"/assets/art/icons/emoji-rich.svg\",\n \"emoji-smile\", \"/assets/art/icons/emoji-smile.svg\",\n \"emoji-smile-2\", \"/assets/art/icons/emoji-smile-2.svg\",\n \"emoji-suprised\", \"/assets/art/icons/emoji-suprised.svg\",\n \"emoji-tired\", \"/assets/art/icons/emoji-tired.svg\",\n \"emoji-wow\", \"/assets/art/icons/emoji-wow.svg\",\n \"heart\", \"/assets/art/icons/heart.svg\",\n \"number-0\", \"/assets/art/icons/number-0.svg\",\n \"number-1\", \"/assets/art/icons/number-1.svg\",\n \"number-2\", \"/assets/art/icons/number-2.svg\",\n \"number-3\", \"/assets/art/icons/number-3.svg\",\n \"number-4\", \"/assets/art/icons/number-4.svg\",\n \"number-5\", \"/assets/art/icons/number-5.svg\",\n \"number-6\", \"/assets/art/icons/number-6.svg\",\n \"number-7\", \"/assets/art/icons/number-7.svg\",\n \"number-8\", \"/assets/art/icons/number-8.svg\",\n \"number-9\", \"/assets/art/icons/number-9.svg\",\n \"question\", \"/assets/art/icons/question.svg\",\n \"size-l\", \"/assets/art/icons/size-l.svg\",\n \"size-m\", \"/assets/art/icons/size-m.svg\",\n \"size-s\", \"/assets/art/icons/size-s.svg\",\n \"size-xl\", \"/assets/art/icons/size-xl.svg\",\n \"size-xs\", \"/assets/art/icons/size-xs.svg\",\n \"warn\", \"/assets/art/icons/warn.svg\",\n)\n\n\nfunc getAllAvailableIcons() {\n local icons = List()\n\n allIcons.forEach((url, id) => {\n icons.add(toJSON(Map('id', id, 'url', url)))\n })\n icons\n}\n\nfunc getNodeIcons(node) {\n local icons = List()\n if (node.data.has('icons')) {\n local iconSet = Set()\n splitString(node.data.get('icons'), ',').forEach((iconId) => {\n if (iconId && !iconSet.has(iconId)) {\n icons.add(iconId)\n iconSet.add(iconId)\n }\n })\n }\n icons\n}\n\nfunc encodeIcons(icons) {\n local encoded = ''\n icons.forEach((id, idx) => {\n if (idx > 0) {\n encoded += ','\n }\n encoded += id\n })\n encoded\n}\n\n\nfunc setNodeIcon(node, iconId) {\n local icons = getNodeIcons(node)\n\n local idx = icons.findIndex((id) => { id == iconId })\n if (idx < 0) {\n icons.add(iconId)\n } else {\n icons.remove(idx)\n }\n\n node.data.set('icons', encodeIcons(icons))\n encodeMindMap()\n}\n\nfunc removeNodeIcon(node, iconId) {\n local icons = getNodeIcons(node)\n local idx = icons.findIndex((id) => { id == iconId })\n\n if (idx >= 0) {\n icons.remove(idx)\n }\n node.data.set('icons', encodeIcons(icons))\n encodeMindMap()\n}\n\nstruct PinPoint {\n id: 't'\n x: 0\n y: 0\n normal: Vector(0, -1)\n}\n\n// zone points should be arranged in a counter clock wise polygon\nfunc isPointInsideZone(testPoint, zonePoints) {\n if (zonePoints.size < 3) {\n false\n } else {\n local lines = List()\n\n for (i = 0; i < zonePoints.size - 1; i++) {\n local p1 = zonePoints.get(i)\n local p2 = zonePoints.get(i+1)\n lines.add(Math.createLineEquation(p2.x, p2.y, p1.x, p1.y))\n }\n\n local isInZone = true\n for (i = 0; i < lines.size && isInZone; i++) {\n if (Math.sideAgainstLine(testPoint.x, testPoint.y, lines.get(i)) < 0) {\n isInZone = false\n }\n }\n isInZone\n }\n}\n\n\nfunc getPinPointById(pinId, node) {\n if (pinId == 't') {\n PinPoint('t', node.w/2, 0, Vector(0, -1))\n } else if (pinId == 'b') {\n PinPoint('b', node.w/2, node.h, Vector(0, 1))\n } else if (pinId == 'l') {\n PinPoint('l', 0, node.h/2, Vector(-1, 0))\n } else if (pinId == 'r') {\n PinPoint('r', node.w, node.h/2, Vector(1, 0))\n }\n}\n\n\nfunc encodeMindMap() {\n nodes = rootNode.encodeTree(' | ', ';')\n}\n\nstruct SuggestedConnection {\n srcPin: null\n dstPin: null\n zonePoints: List()\n}\n\nfunc findPinsForNodes(node, child) {\n local p1 = Vector(0, 0)\n local p2 = Vector(node.w, 0)\n local p3 = Vector(node.w, node.h)\n local p4 = Vector(0, node.h)\n\n local topRightV = Vector(1, -3)\n local bottomLeftV = -topRightV\n local topLeftV = Vector(-1, -3)\n local bottomRightV = -topLeftV\n\n local srcTopPin = getPinPointById('t', node)\n local srcBottomPin = getPinPointById('b', node)\n local srcLeftPin = getPinPointById('l', node)\n local srcRightPin = getPinPointById('r', node)\n\n local dstTopPin = getPinPointById('t', child)\n local dstBottomPin = getPinPointById('b', child)\n local dstLeftPin = getPinPointById('l', child)\n local dstRightPin = getPinPointById('r', child)\n\n local childOffset = Vector(child.x, child.y)\n\n local t = Vector(node.w/2, 0)\n local t2 = t + Vector(0, -10)\n local b = Vector(node.w/2, node.h)\n local b2 = b + Vector(0, 10)\n\n local connections = List(\n () => { SuggestedConnection(srcTopPin, dstRightPin, List(p1 + topLeftV, p1, t, t2)) },\n () => { SuggestedConnection(srcTopPin, dstLeftPin, List(t2, t, p2, p2 + topRightV)) },\n () => { SuggestedConnection(srcBottomPin, dstLeftPin, List(p3 + bottomRightV, p3, b, b2)) },\n () => { SuggestedConnection(srcBottomPin, dstRightPin, List(b2, b, p4, p4 + bottomLeftV)) },\n\n () => { SuggestedConnection(srcRightPin, dstLeftPin, List(p2 + topRightV, p2, p3, p3 + bottomRightV)) },\n () => { SuggestedConnection(srcLeftPin, dstRightPin, List(p4 + bottomLeftV, p4, p1, p1 + topLeftV)) },\n () => { SuggestedConnection(srcTopPin, dstBottomPin, List(p1 + topLeftV, p1, p2, p2 + topRightV)) },\n () => { SuggestedConnection(srcBottomPin, dstTopPin, List(p3 + bottomRightV, p3, p4, p4 + bottomLeftV)) },\n )\n\n local srcPin = srcRightPin\n local dstPin = dstLeftPin\n local matches = false\n\n for (local i = 0; i < connections.size && !matches; i++) {\n local connection = connections.get(i)()\n srcPin = connection.srcPin\n dstPin = connection.dstPin\n\n local testPoint = Vector(dstPin.x, dstPin.y) + childOffset\n matches = isPointInsideZone(testPoint, connection.zonePoints)\n }\n\n List(srcPin, dstPin)\n}\n\nstruct PathPoint {\n t: 'B'\n x: 0\n y: 0\n x1: 0\n y1: 0\n x2: 0\n y2: 0\n}\n\nfunc updatePrettyConnector(connector, pin1, pin2, childOffset, node, parent) {\n connector.id = `connector-pretty-${parent.id}-${node.id}`\n connector.shape = 'path'\n\n // rotating pin by 90 degrees clockwise\n local Vx = -pin1.normal.y * capSize / 2\n local Vy = pin1.normal.x * capSize / 2\n\n local d1 = 0\n local d2 = 0\n\n if (abs(pin1.normal.x * pin2.normal.x + pin1.normal.x * pin2.normal.x) > 0.5) {\n // the normals are not perpendicular to each other\n // local d = sqrt((pin2.x + childOffset.x - pin1.x) * (pin2.x + childOffset.x - pin1.x) + (pin2.y + childOffset.y - pin1.y) * (pin2.y + childOffset.y - pin1.y)) / 2\n local x2 = pin2.x + childOffset.x\n local y2 = pin2.y + childOffset.y\n // creating line that is perpendicular to the pin1 normal\n local line = Math.createLineEquation(x2, y2, x2 - pin1.normal.y, y2 + pin1.normal.x)\n d1 = Math.distanceFromPointToLine(pin1.x, pin1.y, line) / 2\n d2 = d1\n } else {\n // normals are perpendicular to each other\n local x2 = pin2.x + childOffset.x\n local y2 = pin2.y + childOffset.y\n local line2 = Math.createLineEquation(x2, y2, x2 + pin2.normal.x, y2 + pin2.normal.y)\n d1 = Math.distanceFromPointToLine(pin1.x, pin1.y, line2) / 2\n\n local line1 = Math.createLineEquation(pin1.x, pin1.y, pin1.x + pin1.normal.x, pin1.y + pin1.normal.y)\n d2 = Math.distanceFromPointToLine(x2, y2, line1) / 2\n }\n\n local Nx = pin1.normal.x * d1\n local Ny = pin1.normal.y * d1\n local Mx = pin2.normal.x * d2\n local My = pin2.normal.y * d2\n\n local Ax = pin1.x + Vx\n local Ay = pin1.y + Vy\n local Bx = pin1.x - Vx\n local By = pin1.y - Vy\n\n local minX = min(Ax, Bx, pin1.x, pin2.x + childOffset.x)\n local maxX = max(Ax, Bx, pin1.x, pin2.x + childOffset.x)\n local minY = min(Ay, By, pin1.y, pin2.y + childOffset.y)\n local maxY = max(Ay, By, pin1.y, pin2.y + childOffset.y)\n\n local points = List(\n PathPoint('B', Ax, Ay, Nx, Ny, -Vx, -Vy),\n PathPoint('B', Bx, By, Vx, Vy, Nx, Ny),\n PathPoint('B', pin2.x + childOffset.x, pin2.y + childOffset.y, Mx, My, Mx, My),\n )\n\n local dx = maxX - minX\n local dy = maxY - minY\n\n if (dx > 0.0001 && dy > 0.0001) {\n points.forEach((p) => {\n p.x = 100 * (p.x - minX) / dx\n p.y = 100 * (p.y - minY) / dy\n p.x1 = 100 * p.x1 / dx\n p.y1 = 100 * p.y1 / dy\n p.x2 = 100 * p.x2 / dx\n p.y2 = 100 * p.y2 / dy\n })\n }\n\n connector.x = minX\n connector.y = minY\n connector.w = dx\n connector.h = dy\n\n connector.shapeProps.set('paths', List(\n Map(\n 'id', 'a',\n 'closed', true,\n 'pos', 'relative',\n 'points', points\n )\n ))\n connector.shapeProps.set('strokeColor', connectorDefaultColor)\n connector.shapeProps.set('strokeSize', 2)\n connector.shapeProps.set('fill', Map('type', 'solid', 'color', connectorDefaultColor))\n}\n\nfunc updateConnector(connector, pin1, pin2, childOffset, node, parent) {\n connector.args.set('templateIgnoredProps', List('name', 'shapeProps.fill', 'shapeProps.stroke*'))\n\n if (connectorType == 'pretty') {\n updatePrettyConnector(connector, pin1, pin2, childOffset, node, parent)\n } else {\n connector.shapeProps.set('points', List(\n Map('id', pin1.id, 'x', pin1.x, 'y', pin1.y, 'nx', pin1.normal.x, 'ny', pin1.normal.y),\n Map('id', pin2.id, 'x', pin2.x + childOffset.x, 'y', pin2.y + childOffset.y, 'nx', pin2.normal.x, 'ny', pin2.normal.y)\n ))\n\n connector.shapeProps.set('strokeColor', connectorDefaultColor)\n connector.shapeProps.set('sourceItem', `#${parent.id}`)\n connector.shapeProps.set('sourcePin', pin1.id)\n connector.shapeProps.set('destinationItem', `#${node.id}`)\n connector.shapeProps.set('destinationPin', pin2.id)\n connector.shapeProps.set('sourceCap', 'empty')\n connector.shapeProps.set('destinationCap', capType)\n connector.shapeProps.set('destinationCapSize', capSize)\n connector.shapeProps.set('smoothing', connectorType)\n }\n}\n\n\nfunc updateConnectorForMovingNode(node) {\n local parent = node.parent\n local connectorId = if (connectorType == 'pretty') {\n `connector-pretty-${parent.id}-${node.id}`\n } else {\n `connector-${parent.id}-${node.id}`\n }\n\n local connector = Item(connectorId, `${node.id} -> ${parent.id}`, 'connector', 0, 0, 100, 50, Map())\n local childOffset = Vector(node.x, node.y)\n local pins = findPinsForNodes(parent, node)\n local pin1 = pins.get(0)\n local pin2 = pins.get(1)\n updateConnector(connector, pin1, pin2, childOffset, node, parent)\n\n updateItem(connectorId, (item) => {\n item.shape = connector.shape\n item.area.x = connector.x\n item.area.y = connector.y\n item.area.w = connector.w\n item.area.h = connector.h\n item.shapeProps = toJSON(connector.shapeProps)\n })\n}\n\nfunc prepareConnectorForNode(node, parent) {\n local connector = Item(`connector-${parent.id}-${node.id}`, `${node.id} -> ${parent.id}`, 'connector', 0, 0, 100, 50, Map())\n\n local childOffset = Vector(node.x, node.y)\n\n local pins = findPinsForNodes(parent, node)\n local pin1 = pins.get(0)\n local pin2 = pins.get(1)\n\n updateConnector(connector, pin1, pin2, childOffset, node, parent)\n\n if (parent.tempData.has('connectors')) {\n parent.tempData.get('connectors').add(connector)\n } else {\n parent.tempData.set('connectors', List(connector))\n }\n\n pin2.id\n}\n\n\nfunc updateAbsoluteNodePosition(node) {\n local x = parseInt(node.data.get('x'))\n local y = parseInt(node.data.get('y'))\n local localPos = Vector(x, y)\n\n local pos = (if (node.parent) {\n if (node.parent.tempData.has(ABS_POS)) {\n node.parent.tempData.get(ABS_POS) + localPos\n } else {\n parentPos = updateAbsoluteNodePosition(node.parent)\n parentPos + localPos\n }\n } else {\n localPos\n })\n\n node.tempData.set(ABS_POS, pos)\n\n pos\n}\n\n\nfunc createProgressIconItems(nodeId, percent, color, x, y) {\n local items = List()\n\n if (gradientProgress) {\n local t = if (percent <= 50) { percent / 50 } else { (percent - 50) / 50 }\n local c1 = decodeColor(if (percent <= 50) { (progressColor) } else { progressColor2 })\n local c2 = decodeColor(if (percent <= 50) { (progressColor2) } else { progressColor3 })\n color = c1.gradient(c2, t).encode()\n }\n\n local isNotFull = percent < 99.5\n\n items.add(Item(\n `${nodeId}_progress_stroke`, 'progress container', 'ellipse', x, y, progressSize, progressSize, Map(\n 'fill', if (isNotFull) { Map('type', 'none') } else { Map('type', 'solid', 'color', color) },\n 'strokeColor', color,\n 'strokeSize', 3\n ), List(), Map(\n 'mindMapType', 'progress',\n 'mindMapNodeId', nodeId,\n )\n ))\n if (percent > 0.5 && isNotFull) {\n items.add(Item(\n `${nodeId}_progress`, 'progress', 'pie_segment', x, y, progressSize, progressSize, Map(\n 'strokeSize', 0,\n 'strokeColor', color,\n 'fill', Map('type', 'solid', 'color', color),\n 'percent', percent\n ), List(), Map(\n 'mindMapType', 'progress',\n 'mindMapNodeId', nodeId,\n )\n ))\n }\n items\n}\n\nfunc createNodeIconItems(node) {\n local items = List()\n\n local margin = 5\n\n if (showProgress) {\n local percent = 0\n local color = progressColor\n if (node.children.size == 0) {\n color = progressColor2\n }\n if (node.data.has('p')) {\n percent = max(0, min(parseInt(node.data.get('p')), 100))\n }\n local x = 10\n local y = node.h / 2 - progressSize / 2\n\n items.extendList(createProgressIconItems(node.id, percent, color, x, y))\n }\n\n local progressOffset = if (showProgress) { margin + progressSize } else { 0 }\n\n getNodeIcons(node).forEach((iconId, idx) => {\n local x = (iconSize + margin) * idx + 10 + progressOffset\n local y = node.h / 2 - iconSize / 2\n items.add(Item(\n `${node.id}_icon_${iconId}`, iconId, 'image', x, y, iconSize, iconSize, Map('image', allIcons.get(iconId)), List(), Map(\n 'mindMapType', 'icon',\n 'mindMapIcon', iconId,\n 'mindMapNodeId', node.id,\n )\n ))\n })\n\n items\n}\n\nfunc buildTemplateItems(rootNode) {\n rootNode.map((node, childItems) => {\n local x = node.data.get('x')\n local y = node.data.get('y')\n local w = node.data.get('w')\n local h = node.data.get('h')\n\n childItems.extendList()\n\n createNodeIconItems(node).forEach((iconItem, i) => {\n childItems.insert(i, iconItem)\n })\n\n if (node.tempData.has('connectors')) {\n local connectors = node.tempData.get('connectors')\n if (connectors) {\n connectors.forEach((connector, i) => {\n childItems.insert(i, connector)\n })\n }\n }\n\n local shape = if (node.data.has('s')) { node.data.get('s') } else { 'rect' }\n\n local item = Item(node.id, `item ${node.id}`, shape, x, y, w, h, Map(), childItems, Map(\n 'mindMapType', 'node'\n ))\n\n if (node.tempData.has('sourceItem')) {\n srcItem = node.tempData.get('sourceItem')\n if (srcItem) {\n item.shape = srcItem.shape\n item.shapeProps = fromJSON(srcItem.shapeProps)\n item.textSlots = fromJSON(srcItem.textSlots)\n }\n } else {\n item.textSlots = Map('body', Map(\n 'text', '',\n 'paddingLeft', 5,\n 'paddingRight', 5,\n 'paddingTop', 5,\n 'paddingBottom', 5,\n ))\n }\n\n item.args.set('templateIgnoredProps', List('name', 'shape', 'shapeProps.*'))\n item.locked = false\n if (node.id != rootNode.id) {\n item.args.set('tplArea', 'controlled')\n }\n item.args.set('tplRotation', 'off')\n item.args.set('tplConnector', 'off')\n\n if (shape == 'none') {\n item.setText('body', 'Add your text...')\n }\n item\n })\n}\n\nfunc findProperPositionValue(node, placement) {\n local values = List()\n\n node.children.forEach((childNode) => {\n if (placement == 'top') {\n if (childNode.y < 0) {\n values.add(childNode.y)\n }\n } else if (placement == 'bottom') {\n if (childNode.y > 0) {\n values.add(childNode.y)\n }\n } else if (placement == 'left') {\n if (childNode.x < 0) {\n values.add(childNode.x)\n }\n } else if (placement == 'right') {\n if (childNode.x > 0) {\n values.add(childNode.x)\n }\n }\n })\n\n values.sort((a, b) => { if (a < b) { -1 } else { 1 } })\n\n local w = max(1, node.w)\n local h = max(1, node.h)\n\n if (values.size == 0) {\n if (placement == 'top') {\n - padding - h\n } else if (placement == 'bottom') {\n node.h + padding\n } else if (placement == 'left') {\n - padding - w\n } else if (placement == 'right') {\n node.w + padding\n }\n } else {\n local i = floor(values.size / 2)\n values.get(i)\n }\n}\n\nfunc createNewChildFor(nodeId, placement) {\n local node = rootNode.findById(nodeId)\n if (node) {\n local x = 0\n local y = 0\n local w = max(1, node.w)\n local h = max(1, node.h)\n local shape = if (node.data.has('s')) { node.data.get('s') } else { 'rect' }\n\n if (node.children.size > 0) {\n local childNode = node.children.get(0)\n if (childNode.data.has('s')) {\n shape = if (childNode.data.has('s')) { childNode.data.get('s') } else { shape }\n }\n\n w = max(1, childNode.w)\n h = max(1, childNode.h)\n }\n\n local correctiveVector = Vector(0, h * 0.1)\n\n if (placement == 'top') {\n y = findProperPositionValue(node, placement)\n x = node.w / 2 - w / 2\n correctiveVector = Vector(w * 0.2, 0)\n } else if (placement == 'bottom') {\n y = findProperPositionValue(node, placement)\n x = node.w / 2 - w / 2\n correctiveVector = Vector(w * 0.2, 0)\n } else if (placement == 'left') {\n x = findProperPositionValue(node, placement)\n y = node.h / 2 - h / 2\n } else if (placement == 'right') {\n x = findProperPositionValue(node, placement)\n y = node.h / 2 - h / 2\n }\n\n local childAreas = node.children.map((childNode) => { Area(childNode.x, childNode.y, childNode.w, childNode.h) })\n\n func overlapsChildren(area) {\n local overlaps = false\n for (local i = 0; !overlaps && i < childAreas.size; i++) {\n overlaps = area.overlaps(childAreas.get(i))\n }\n overlaps\n }\n\n local overlaps = true\n local tries = 0\n\n local area\n\n while(overlaps && tries < 1000) {\n local direction = if (tries % 2 == 0) { 1 } else { -1 }\n local displacement = correctiveVector * tries * direction\n area = Area(x + displacement.x, y + displacement.y, w, h)\n overlaps = overlapsChildren(area)\n tries++\n }\n\n local childNode = TreeNode(uid(), Map('x', area.x, 'y', area.y, 'w', w, 'h', h, 's', shape, 'p', 0))\n node.children.add(childNode)\n\n updateProgress(rootNode)\n\n encodeMindMap()\n }\n}\n\nfunc shouldNodeShapeSelectorBeDisplayed(selectedItemIds) {\n local shown = false\n selectedItemIds.forEach((itemId) => {\n if (rootNode.findById(itemId)) {\n shown = true\n }\n })\n shown\n}\n\nfunc selectShapeForItems(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n updateItem(itemId, (item) => {\n item.shape = panelItem.shape\n forEach(panelItem.shapeProps, (value, name) => {\n if (name != 'fill') {\n setObjectField(item.shapeProps, name, value)\n }\n })\n local node = rootNode.findById(itemId)\n if (node) {\n node.data.set('s', panelItem.shape)\n encodeMindMap()\n }\n\n if (panelItem.shape == 'none') {\n if (!item.textSlots.body) {\n setObjectField(item.textSlots, 'body', toJSON(Map('text', 'Add your text...')))\n }\n if (item.textSlots.body) {\n if (!item.textSlots.body.text || item.textSlots.body.text == '

') {\n setObjectField(item.textSlots.body, 'text', 'Ad your text...')\n }\n }\n }\n })\n })\n}\n\nfunc shouldNodeIconSelectorBeDisplayed(selectedItemIds) {\n shouldNodeShapeSelectorBeDisplayed(selectedItemIds)\n}\n\n\nfunc selectIconForItems(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n local node = rootNode.findById(itemId)\n if (node) {\n setNodeIcon(node, panelItem.id)\n }\n })\n}\n\nfunc shouldNodeProgressEditorBeDisplayed(selectedItemIds) {\n if (showProgress) {\n local showPanel = false\n selectedItemIds.forEach((itemId) => {\n if (itemId.endsWith('_progress')) {\n itemId = itemId.substring(0, itemId.length - 9)\n } else if (itemId.endsWith('_progress_stroke')) {\n itemId = itemId.substring(0, itemId.length - 16)\n }\n local node = rootNode.findById(itemId)\n if (node && node.children.size == 0) {\n showPanel = true\n }\n })\n showPanel\n } else {\n false\n }\n}\n\nfunc getAllProgressIconItems() {\n List(0, 25, 50, 75, 100).map((percent) => {\n items = createProgressIconItems(`icon-${percent}`, percent, progressColor2, 0, 0)\n Item(`progress_${percent}_container`, `${percent}`, 'none', 2, 2, progressSize, progressSize, Map(), items, Map(\n 'mindMapProgress', percent\n )).toJSON()\n })\n}\n\nfunc updateProgress(node) {\n if (node.children.size > 0) {\n local sumProgress = 0\n node.children.forEach((childNode) => {\n sumProgress += updateProgress(childNode)\n })\n\n local totalProgress = sumProgress / node.children.size\n node.data.set('p', totalProgress)\n totalProgress\n } else {\n if (node.data.has('p')) {\n node.data.get('p')\n } else {\n 0\n }\n }\n}\n\nfunc selectProgressForItems(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n if (itemId.endsWith('_progress')) {\n itemId = itemId.substring(0, itemId.length - 9)\n } else if (itemId.endsWith('_progress_stroke')) {\n itemId = itemId.substring(0, itemId.length - 16)\n }\n local node = rootNode.findById(itemId)\n if (node && node.children.size == 0) {\n node.data.set('p', panelItem.args.mindMapProgress)\n }\n })\n\n updateProgress(rootNode)\n\n encodeMindMap()\n}\n\n// triggered when item area changes as a result of edit box modifications\n// the handler is supposed to mutate the area object in case the area is changed\nfunc onAreaUpdate(itemId, item, area) {\n local node = rootNode.findById(itemId)\n if (node) {\n node.data.set('x', area.x)\n node.data.set('y', area.y)\n node.data.set('w', area.w)\n node.data.set('h', area.h)\n\n if (node.parent) {\n updateConnectorForMovingNode(node)\n }\n encodeMindMap()\n }\n}\n\n// Template special function. Triggered when user deletes templated item\nfunc onDeleteItem(itemId, item) {\n local node\n if (item.args.mindMapType == 'icon') {\n node = rootNode.findById(item.args.mindMapNodeId)\n if (node) {\n removeNodeIcon(node, item.args.mindMapIcon)\n }\n } else if (item.args.mindMapType == 'node') {\n node = rootNode.findById(itemId)\n if (node && node.parent) {\n node.parent.children.remove(node.siblingIdx)\n updateProgress(rootNode)\n encodeMindMap()\n }\n }\n}\n\nfunc onCopyItem(itemId, item) {\n local node = rootNode.findById(itemId)\n if (node) {\n encodedNode = node.encodeTree(' | ', ';')\n setObjectField(item.args, 'mindMap_EncodedNode', encodedNode)\n }\n}\n\nfunc onPasteItems(itemId, items) {\n local dstNode = rootNode.findById(itemId)\n if (dstNode) {\n items.forEach((item) => {\n if (item.args && item.args.mindMap_EncodedNode) {\n local newRootNode = decodeTree(item.args.mindMap_EncodedNode, ' | ', ';')\n\n local nodesById = Map()\n newRootNode.traverse((node) => {\n nodesById.set(node.id, node)\n node.x = parseInt(node.data.get('x'))\n node.y = parseInt(node.data.get('y'))\n node.w = parseInt(node.data.get('w'))\n node.h = parseInt(node.data.get('h'))\n node.id = uid()\n })\n\n local traverseItems = (item) => {\n if (item.args.templated && item.args.templatedId && nodesById.has(item.args.templatedId)) {\n local node = nodesById.get(item.args.templatedId)\n // this will be used when building items to make sure that all shapeProps and textSlots fields are copied as well\n // as those could be overriden by the user\n node.tempData.set('sourceItem', item)\n }\n if (item.childItems) {\n item.childItems.forEach(traverseItems)\n }\n }\n traverseItems(item)\n newRootNode.attachTo(dstNode)\n\n if (dstNode.parent && dstNode.x < 0) {\n newRootNode.x = - padding - newRootNode.w\n newRootNode.y = 0\n } else {\n newRootNode.x = dstNode.w + padding\n newRootNode.y = 0\n }\n newRootNode.data.set('x', newRootNode.x)\n newRootNode.data.set('y', newRootNode.y)\n autoAlignChildNodes(newRootNode, newRootNode.x < 0)\n }\n })\n\n reindexTree(rootNode)\n updateProgress(rootNode)\n rootItem = buildTemplateItems(rootNode)\n rootItem.name = 'Mind map'\n rootItem.w = width\n rootItem.h = height\n\n encodeMindMap()\n }\n}\n\n\nfunc autoAlignChildNodes(node, toLeft) {\n local vPad = 20\n local totalHeight = node.children.size * vPad\n\n node.children.forEach((childNode) => {\n totalHeight += childNode.h\n })\n\n local y = node.h/2 - totalHeight/2\n\n node.children.forEach((childNode) => {\n childNode.y = y\n y = y + childNode.h + vPad\n\n if (toLeft) {\n childNode.x = -childNode.w - padding\n } else {\n childNode.x = node.w + padding\n }\n\n childNode.data.set('x', childNode.x)\n childNode.data.set('y', childNode.y)\n autoAlignChildNodes(childNode, toLeft)\n })\n}\n\n\nfunc reindexTree(rootNode) {\n rootNode.traverse((node, parent) => {\n local x = parseInt(node.data.get('x'))\n local y = parseInt(node.data.get('y'))\n local w = parseInt(node.data.get('w'))\n local h = parseInt(node.data.get('h'))\n local p = 0\n if (node.data.has('p')) {\n p = parseInt(node.data.get('p'))\n }\n\n node.data.set('x', x)\n node.data.set('y', y)\n node.data.set('w', w)\n node.data.set('h', h)\n node.data.set('p', p)\n\n node.x = x\n node.y = y\n local pos = updateAbsoluteNodePosition(node)\n\n local addingControl = (location, placement, cx, cy) => {\n Control(`add_child_${location}`, Map('nodeId', node.id), `createNewChildFor(control.data.nodeId, '${location}')`, cx, cy, 20, 20, '+', placement, node.id)\n }\n\n if (parent) {\n node.w = w\n node.h = h\n local srcPinId = prepareConnectorForNode(node, parent)\n if (srcPinId == 't') {\n controls.add(addingControl('bottom', 'TL', pos.x + node.w / 2 - 10, pos.y + node.h + controlPadding))\n } else if (srcPinId == 'b') {\n controls.add(addingControl('top', 'BL', pos.x + node.w / 2 - 10, pos.y - controlPadding))\n } else if (srcPinId == 'l') {\n controls.add(addingControl('right', 'TL', pos.x + node.w + controlPadding, pos.y + node.h / 2 - 10))\n } else if (srcPinId == 'r') {\n controls.add(addingControl('left', 'TR', pos.x - controlPadding, pos.y + node.h / 2 - 10))\n }\n } else {\n node.w = width\n node.h = height\n\n controls.extendList(List(\n addingControl('left', 'TR', -controlPadding, node.h / 2 - 10),\n addingControl('right', 'TL', node.w + controlPadding, node.h / 2 - 10)\n ))\n }\n })\n}\n\n\nfunc shouldNodeOperationsPanelBeDisplayed(selectedItemIds) {\n shouldNodeShapeSelectorBeDisplayed(selectedItemIds)\n local shown = false\n selectedItemIds.forEach((itemId) => {\n node = rootNode.findById(itemId)\n if (node && node.parent) {\n shown = true\n }\n })\n shown\n}\n\nfunc onOperationsPanelClick(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n node = rootNode.findById(itemId)\n if (node) {\n if (panelItem.id == 'insert-new-parent' && node.parent) {\n insertNewParentFor(node)\n } else if (panelItem.id == 'delete-node-preserve-children' && node.parent) {\n deleteNodePreservingChildren(node)\n }\n }\n })\n}\n\nfunc insertNewParentFor(node) {\n local oldParent = node.parent\n local dx = node.x - node.parent.x\n local dy = node.y - node.parent.y\n\n local found = false\n for (local i = 0; !found && i < node.parent.children.size; i++) {\n local childNode = node.parent.children.get(i)\n if (childNode.id == node.id) {\n found = true\n node.parent.children.remove(i)\n }\n }\n\n local newParent = TreeNode(uid())\n node.data.forEach((value, name) => { newParent.data.set(name, value) })\n newParent.x = node.x\n newParent.y = node.y\n newParent.w = node.w\n newParent.h = node.h\n\n node.attachTo(newParent)\n newParent.attachTo(oldParent)\n updateProgress(rootNode)\n encodeMindMap()\n}\n\nfunc deleteNodePreservingChildren(node) {\n local found = false\n local parent = node.parent\n for (local i = 0; !found && i < parent.children.size; i++) {\n if (node.id == parent.children.get(i).id) {\n parent.children.remove(i)\n local oldSize = parent.children.size - 1\n parent.children.extendList(node.children)\n node.children.forEach((childNode, idx) => {\n childNode.parent = parent\n childNode.siblingIdx = oldSize + idx\n })\n }\n }\n\n updateProgress(rootNode)\n encodeMindMap()\n}\n\nrootNode = decodeTree(nodes, ' | ', ';')\nreindexTree(rootNode)\n\n if (context.phase == 'build') {\n rootItem = buildTemplateItems(rootNode)\n rootItem.name = 'Mind map'\n rootItem.w = width\n rootItem.h = height\n}\n\n\n"} \ No newline at end of file +{"name": "Mind map", "description": "", "args": {"nodes": {"type": "string", "value": "root;x=0;y=0;s=uml_start", "name": "Nodes encoded", "hidden": true}, "connectorType": {"type": "choice", "value": "pretty", "options": ["pretty", "linear", "smooth", "step", "step-cut", "step-smooth"], "name": "Connectors Type"}, "capSize": {"type": "number", "value": 15, "name": "Cap size"}, "capType": {"type": "path-cap", "value": "triangle", "name": "Cap type"}, "iconSize": {"type": "number", "value": 20, "name": "Icon size"}, "showProgress": {"type": "boolean", "value": false, "name": "Show Progress"}, "progressSize": {"type": "number", "value": 20, "name": "Progress Icon Size", "depends": {"showProgress": true}}, "gradientProgress": {"type": "boolean", "value": true, "name": "Gradient Progress", "depends": {"showProgress": true}}, "progressColor": {"type": "color", "value": "#F35B3B", "name": "Progress Icon Color", "depends": {"showProgress": true}}, "progressColor2": {"type": "color", "value": "#E5AB2D", "name": "Progress Icon Color 2", "depends": {"showProgress": true}}, "progressColor3": {"type": "color", "value": "#13D481", "name": "Progress Icon Color 3", "depends": {"showProgress": true, "gradientProgress": true}}}, "preview": "/assets/templates/previews/mind-map.svg", "defaultArea": {"x": 0, "y": 0, "w": 200, "h": 60}, "import": ["./src/item.sch", "./src/control.sch", "./src/mind-map.sch"], "handlers": {"delete": "onDeleteItem(itemId, item)", "area": "onAreaUpdate(itemId, item, area)", "copy": "onCopyItem(itemId, item)", "paste": "onPasteItems(itemId, items)"}, "controls": [{"$-foreach": {"source": "controls", "it": "control"}, "$-extend": {"$-expr": "toJSON(control)"}}], "editor": {"panels": [{"id": "node-progress", "condition": "shouldNodeProgressEditorBeDisplayed(selectedItemIds)", "type": "item-menu", "click": "selectProgressForItems(selectedItemIds, panelItem)", "name": "Select progress", "slotSize": {"width": {"$-expr": "progressSize * 2"}, "height": {"$-expr": "progressSize * 2"}}, "items": [{"$-foreach": {"source": "getAllProgressIconItems()", "it": "it"}, "$-extend": {"$-expr": "toJSON(it)"}}]}, {"id": "node-icon-selector", "condition": "shouldNodeIconSelectorBeDisplayed(selectedItemIds)", "type": "item-menu", "click": "selectIconForItems(selectedItemIds, panelItem)", "name": "Choose icons", "slotSize": {"width": 30, "height": 30}, "items": [{"$-foreach": {"source": "getAllAvailableIcons()", "it": "icon"}, "id": {"$-expr": "icon.id"}, "name": {"$-expr": "icon.id"}, "shape": "image", "area": {"x": -2, "y": -2, "w": 26, "h": 26}, "shapeProps": {"image": {"$-expr": "icon.url"}}}]}, {"id": "node-shape-selector", "condition": "shouldNodeShapeSelectorBeDisplayed(selectedItemIds)", "type": "item-menu", "click": "selectShapeForItems(selectedItemIds, panelItem)", "name": "Select shape", "slotSize": {"width": 80, "height": 40}, "items": [{"id": "rect", "shape": "rect", "area": {"w": 72, "h": 32}, "shapeProps": {"cornerRadius": 0, "fill": {"type": "none"}}}, {"id": "rounded-rect", "shape": "rect", "area": {"w": 72, "h": 32}, "shapeProps": {"cornerRadius": 10, "fill": {"type": "none"}}}, {"id": "start", "shape": "uml_start", "area": {"w": 72, "h": 32}, "shapeProps": {"fill": {"type": "none"}}}, {"id": "ellipse", "shape": "ellipse", "area": {"w": 72, "h": 32}, "shapeProps": {"cornerRadius": 5, "fill": {"type": "none"}}}, {"id": "basic_diamond", "shape": "basic_diamond", "area": {"w": 72, "h": 32}, "shapeProps": {"fill": {"type": "none"}}}, {"id": "uml_preparation", "shape": "uml_preparation", "area": {"w": 72, "h": 32}, "shapeProps": {"fill": {"type": "none"}}}, {"id": "label", "shape": "none", "area": {"w": 72, "h": 32}, "textSlots": {"body": {"text": "Simple label"}}}]}, {"id": "node-operations", "condition": "shouldNodeOperationsPanelBeDisplayed(selectedItemIds)", "type": "buttons", "name": "Operations", "click": "onOperationsPanelClick(selectedItemIds, panelItem)", "buttons": [{"id": "insert-new-parent", "name": "Insert new parent"}, {"id": "delete-node-preserve-children", "name": "Delete (preserve children)"}]}]}, "item": {"$-recurse": {"object": {"$-expr": "rootItem"}, "it": "it", "children": "it.childItems", "dstChildren": "childItems"}, "id": {"$-expr": "it.id"}, "name": {"$-expr": "`${it.name}`"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "it.getArgs()"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}}, "init": "\nstruct Item {\n id: uid()\n name: ''\n shape: 'rect'\n x: 0\n y: 0\n w: 100\n h: 50\n shapeProps: Map()\n childItems: List()\n args: Map()\n locked: true\n textSlots: Map()\n description: \"\"\n\n\n traverse(callback) {\n this.childItems.forEach((childItem) => {\n childItem.traverse(callback)\n })\n callback(this)\n }\n\n getArgs() {\n toJSON(this.args)\n }\n\n setText(slotName, text) {\n if (!this.textSlots.has(slotName)) {\n this.textSlots.set(slotName, Map('text', text))\n } else {\n this.textSlots.get(slotName).set('text', text)\n }\n }\n\n toJSON() {\n childItems = this.childItems.map((childItem) => { childItem.toJSON() })\n result = toJSON(Map(\n 'id', this.id,\n 'childItems', childItems,\n 'name', this.name,\n 'description', this.description,\n 'shape', this.shape,\n 'area', Map('x', this.x, 'y', this.y, 'w', this.w, 'h', this.h, 'r', 0, 'sx', 1, 'sy', 1, 'px', 0.5, 'py', 0.5),\n 'shapeProps', this.shapeProps,\n 'args', this.args,\n 'locked', this.locked,\n 'textSlots', this.textSlots,\n ))\n\n result\n }\n}\n\n\n\nstruct Control {\n name: \"\"\n data: Map()\n click: \"log('control clicked')\"\n x: 0\n y: 0\n width: 20\n height: 20\n text: \"+\"\n placement: \"TL\"\n selectedItemId: \"\"\n type: 'button'\n options: List()\n optionsProvider: null\n}\n\npadding = 120\ncontrolPadding = 40\ncontrols = List()\nrootNode = null\nABS_POS = 'absolutePosition'\n\nconnectorDefaultColor = 'rgba(80,80,80,1.0)'\n\n// rootItem is used for building all of the template items\nrootItem = null\n\nallIcons = Map(\n 'search', '/assets/art/google-cloud/bigquery/bigquery.svg',\n 'time', '/assets/art/azure/General/10006-icon-service-Recent.svg',\n 'cloud', '/assets/art/google-cloud/my_cloud/my_cloud.svg',\n \"check\", \"/assets/art/icons/check.svg\",\n \"cross\", \"/assets/art/icons/cross.svg\",\n \"depressed\", \"/assets/art/icons/depressed.svg\",\n \"emoji-angry\", \"/assets/art/icons/emoji-angry.svg\",\n \"emoji-angry-2\", \"/assets/art/icons/emoji-angry-2.svg\",\n \"emoji-cry\", \"/assets/art/icons/emoji-cry.svg\",\n \"emoji-dead\", \"/assets/art/icons/emoji-dead.svg\",\n \"emoji-hah\", \"/assets/art/icons/emoji-hah.svg\",\n \"emoji-happy\", \"/assets/art/icons/emoji-happy.svg\",\n \"emoji-heart\", \"/assets/art/icons/emoji-heart.svg\",\n \"emoji-puke\", \"/assets/art/icons/emoji-puke.svg\",\n \"emoji-rest\", \"/assets/art/icons/emoji-rest.svg\",\n \"emoji-rest-2\", \"/assets/art/icons/emoji-rest-2.svg\",\n \"emoji-rich\", \"/assets/art/icons/emoji-rich.svg\",\n \"emoji-smile\", \"/assets/art/icons/emoji-smile.svg\",\n \"emoji-smile-2\", \"/assets/art/icons/emoji-smile-2.svg\",\n \"emoji-suprised\", \"/assets/art/icons/emoji-suprised.svg\",\n \"emoji-tired\", \"/assets/art/icons/emoji-tired.svg\",\n \"emoji-wow\", \"/assets/art/icons/emoji-wow.svg\",\n \"heart\", \"/assets/art/icons/heart.svg\",\n \"number-0\", \"/assets/art/icons/number-0.svg\",\n \"number-1\", \"/assets/art/icons/number-1.svg\",\n \"number-2\", \"/assets/art/icons/number-2.svg\",\n \"number-3\", \"/assets/art/icons/number-3.svg\",\n \"number-4\", \"/assets/art/icons/number-4.svg\",\n \"number-5\", \"/assets/art/icons/number-5.svg\",\n \"number-6\", \"/assets/art/icons/number-6.svg\",\n \"number-7\", \"/assets/art/icons/number-7.svg\",\n \"number-8\", \"/assets/art/icons/number-8.svg\",\n \"number-9\", \"/assets/art/icons/number-9.svg\",\n \"question\", \"/assets/art/icons/question.svg\",\n \"size-l\", \"/assets/art/icons/size-l.svg\",\n \"size-m\", \"/assets/art/icons/size-m.svg\",\n \"size-s\", \"/assets/art/icons/size-s.svg\",\n \"size-xl\", \"/assets/art/icons/size-xl.svg\",\n \"size-xs\", \"/assets/art/icons/size-xs.svg\",\n \"warn\", \"/assets/art/icons/warn.svg\",\n)\n\n\nfunc getAllAvailableIcons() {\n local icons = List()\n\n allIcons.forEach((url, id) => {\n icons.add(toJSON(Map('id', id, 'url', url)))\n })\n icons\n}\n\nfunc getNodeIcons(node) {\n local icons = List()\n if (node.data.has('icons')) {\n local iconSet = Set()\n splitString(node.data.get('icons'), ',').forEach((iconId) => {\n if (iconId && !iconSet.has(iconId)) {\n icons.add(iconId)\n iconSet.add(iconId)\n }\n })\n }\n icons\n}\n\nfunc encodeIcons(icons) {\n local encoded = ''\n icons.forEach((id, idx) => {\n if (idx > 0) {\n encoded += ','\n }\n encoded += id\n })\n encoded\n}\n\n\nfunc setNodeIcon(node, iconId) {\n local icons = getNodeIcons(node)\n\n local idx = icons.findIndex((id) => { id == iconId })\n if (idx < 0) {\n icons.add(iconId)\n } else {\n icons.remove(idx)\n }\n\n node.data.set('icons', encodeIcons(icons))\n encodeMindMap()\n}\n\nfunc removeNodeIcon(node, iconId) {\n local icons = getNodeIcons(node)\n local idx = icons.findIndex((id) => { id == iconId })\n\n if (idx >= 0) {\n icons.remove(idx)\n }\n node.data.set('icons', encodeIcons(icons))\n encodeMindMap()\n}\n\nstruct PinPoint {\n id: 't'\n x: 0\n y: 0\n normal: Vector(0, -1)\n}\n\n// zone points should be arranged in a counter clock wise polygon\nfunc isPointInsideZone(testPoint, zonePoints) {\n if (zonePoints.size < 3) {\n false\n } else {\n local lines = List()\n\n for (i = 0; i < zonePoints.size - 1; i++) {\n local p1 = zonePoints.get(i)\n local p2 = zonePoints.get(i+1)\n lines.add(Math.createLineEquation(p2.x, p2.y, p1.x, p1.y))\n }\n\n local isInZone = true\n for (i = 0; i < lines.size && isInZone; i++) {\n if (Math.sideAgainstLine(testPoint.x, testPoint.y, lines.get(i)) < 0) {\n isInZone = false\n }\n }\n isInZone\n }\n}\n\n\nfunc getPinPointById(pinId, node) {\n if (pinId == 't') {\n PinPoint('t', node.w/2, 0, Vector(0, -1))\n } else if (pinId == 'b') {\n PinPoint('b', node.w/2, node.h, Vector(0, 1))\n } else if (pinId == 'l') {\n PinPoint('l', 0, node.h/2, Vector(-1, 0))\n } else if (pinId == 'r') {\n PinPoint('r', node.w, node.h/2, Vector(1, 0))\n }\n}\n\n\nfunc encodeMindMap() {\n nodes = rootNode.encodeTree(' | ', ';')\n}\n\nstruct SuggestedConnection {\n srcPin: null\n dstPin: null\n zonePoints: List()\n}\n\nfunc findPinsForNodes(node, child) {\n local p1 = Vector(0, 0)\n local p2 = Vector(node.w, 0)\n local p3 = Vector(node.w, node.h)\n local p4 = Vector(0, node.h)\n\n local topRightV = Vector(1, -3)\n local bottomLeftV = -topRightV\n local topLeftV = Vector(-1, -3)\n local bottomRightV = -topLeftV\n\n local srcTopPin = getPinPointById('t', node)\n local srcBottomPin = getPinPointById('b', node)\n local srcLeftPin = getPinPointById('l', node)\n local srcRightPin = getPinPointById('r', node)\n\n local dstTopPin = getPinPointById('t', child)\n local dstBottomPin = getPinPointById('b', child)\n local dstLeftPin = getPinPointById('l', child)\n local dstRightPin = getPinPointById('r', child)\n\n local childOffset = Vector(child.x, child.y)\n\n local t = Vector(node.w/2, 0)\n local t2 = t + Vector(0, -10)\n local b = Vector(node.w/2, node.h)\n local b2 = b + Vector(0, 10)\n\n local connections = List(\n () => { SuggestedConnection(srcTopPin, dstRightPin, List(p1 + topLeftV, p1, t, t2)) },\n () => { SuggestedConnection(srcTopPin, dstLeftPin, List(t2, t, p2, p2 + topRightV)) },\n () => { SuggestedConnection(srcBottomPin, dstLeftPin, List(p3 + bottomRightV, p3, b, b2)) },\n () => { SuggestedConnection(srcBottomPin, dstRightPin, List(b2, b, p4, p4 + bottomLeftV)) },\n\n () => { SuggestedConnection(srcRightPin, dstLeftPin, List(p2 + topRightV, p2, p3, p3 + bottomRightV)) },\n () => { SuggestedConnection(srcLeftPin, dstRightPin, List(p4 + bottomLeftV, p4, p1, p1 + topLeftV)) },\n () => { SuggestedConnection(srcTopPin, dstBottomPin, List(p1 + topLeftV, p1, p2, p2 + topRightV)) },\n () => { SuggestedConnection(srcBottomPin, dstTopPin, List(p3 + bottomRightV, p3, p4, p4 + bottomLeftV)) },\n )\n\n local srcPin = srcRightPin\n local dstPin = dstLeftPin\n local matches = false\n\n for (local i = 0; i < connections.size && !matches; i++) {\n local connection = connections.get(i)()\n srcPin = connection.srcPin\n dstPin = connection.dstPin\n\n local testPoint = Vector(dstPin.x, dstPin.y) + childOffset\n matches = isPointInsideZone(testPoint, connection.zonePoints)\n }\n\n List(srcPin, dstPin)\n}\n\nstruct PathPoint {\n t: 'B'\n x: 0\n y: 0\n x1: 0\n y1: 0\n x2: 0\n y2: 0\n}\n\nfunc updatePrettyConnector(connector, pin1, pin2, childOffset, node, parent) {\n connector.id = `connector-pretty-${parent.id}-${node.id}`\n connector.shape = 'path'\n\n // rotating pin by 90 degrees clockwise\n local Vx = -pin1.normal.y * capSize / 2\n local Vy = pin1.normal.x * capSize / 2\n\n local d1 = 0\n local d2 = 0\n\n if (abs(pin1.normal.x * pin2.normal.x + pin1.normal.x * pin2.normal.x) > 0.5) {\n // the normals are not perpendicular to each other\n // local d = sqrt((pin2.x + childOffset.x - pin1.x) * (pin2.x + childOffset.x - pin1.x) + (pin2.y + childOffset.y - pin1.y) * (pin2.y + childOffset.y - pin1.y)) / 2\n local x2 = pin2.x + childOffset.x\n local y2 = pin2.y + childOffset.y\n // creating line that is perpendicular to the pin1 normal\n local line = Math.createLineEquation(x2, y2, x2 - pin1.normal.y, y2 + pin1.normal.x)\n d1 = Math.distanceFromPointToLine(pin1.x, pin1.y, line) / 2\n d2 = d1\n } else {\n // normals are perpendicular to each other\n local x2 = pin2.x + childOffset.x\n local y2 = pin2.y + childOffset.y\n local line2 = Math.createLineEquation(x2, y2, x2 + pin2.normal.x, y2 + pin2.normal.y)\n d1 = Math.distanceFromPointToLine(pin1.x, pin1.y, line2) / 2\n\n local line1 = Math.createLineEquation(pin1.x, pin1.y, pin1.x + pin1.normal.x, pin1.y + pin1.normal.y)\n d2 = Math.distanceFromPointToLine(x2, y2, line1) / 2\n }\n\n local Nx = pin1.normal.x * d1\n local Ny = pin1.normal.y * d1\n local Mx = pin2.normal.x * d2\n local My = pin2.normal.y * d2\n\n local Ax = pin1.x + Vx\n local Ay = pin1.y + Vy\n local Bx = pin1.x - Vx\n local By = pin1.y - Vy\n\n local minX = min(Ax, Bx, pin1.x, pin2.x + childOffset.x)\n local maxX = max(Ax, Bx, pin1.x, pin2.x + childOffset.x)\n local minY = min(Ay, By, pin1.y, pin2.y + childOffset.y)\n local maxY = max(Ay, By, pin1.y, pin2.y + childOffset.y)\n\n local points = List(\n PathPoint('B', Ax, Ay, Nx, Ny, -Vx, -Vy),\n PathPoint('B', Bx, By, Vx, Vy, Nx, Ny),\n PathPoint('B', pin2.x + childOffset.x, pin2.y + childOffset.y, Mx, My, Mx, My),\n )\n\n local dx = maxX - minX\n local dy = maxY - minY\n\n if (dx > 0.0001 && dy > 0.0001) {\n points.forEach((p) => {\n p.x = 100 * (p.x - minX) / dx\n p.y = 100 * (p.y - minY) / dy\n p.x1 = 100 * p.x1 / dx\n p.y1 = 100 * p.y1 / dy\n p.x2 = 100 * p.x2 / dx\n p.y2 = 100 * p.y2 / dy\n })\n }\n\n connector.x = minX\n connector.y = minY\n connector.w = dx\n connector.h = dy\n\n connector.shapeProps.set('paths', List(\n Map(\n 'id', 'a',\n 'closed', true,\n 'pos', 'relative',\n 'points', points\n )\n ))\n connector.shapeProps.set('strokeColor', connectorDefaultColor)\n connector.shapeProps.set('strokeSize', 2)\n connector.shapeProps.set('fill', Map('type', 'solid', 'color', connectorDefaultColor))\n}\n\nfunc updateConnector(connector, pin1, pin2, childOffset, node, parent) {\n connector.args.set('templateIgnoredProps', List('name', 'shapeProps.fill', 'shapeProps.stroke*'))\n\n if (connectorType == 'pretty') {\n updatePrettyConnector(connector, pin1, pin2, childOffset, node, parent)\n } else {\n connector.shapeProps.set('points', List(\n Map('id', pin1.id, 'x', pin1.x, 'y', pin1.y, 'nx', pin1.normal.x, 'ny', pin1.normal.y),\n Map('id', pin2.id, 'x', pin2.x + childOffset.x, 'y', pin2.y + childOffset.y, 'nx', pin2.normal.x, 'ny', pin2.normal.y)\n ))\n\n connector.shapeProps.set('strokeColor', connectorDefaultColor)\n connector.shapeProps.set('sourceItem', `#${parent.id}`)\n connector.shapeProps.set('sourcePin', pin1.id)\n connector.shapeProps.set('destinationItem', `#${node.id}`)\n connector.shapeProps.set('destinationPin', pin2.id)\n connector.shapeProps.set('sourceCap', 'empty')\n connector.shapeProps.set('destinationCap', capType)\n connector.shapeProps.set('destinationCapSize', capSize)\n connector.shapeProps.set('smoothing', connectorType)\n }\n}\n\n\nfunc updateConnectorForMovingNode(node) {\n local parent = node.parent\n local connectorId = if (connectorType == 'pretty') {\n `connector-pretty-${parent.id}-${node.id}`\n } else {\n `connector-${parent.id}-${node.id}`\n }\n\n local connector = Item(connectorId, `${node.id} -> ${parent.id}`, 'connector', 0, 0, 100, 50, Map())\n local childOffset = Vector(node.x, node.y)\n local pins = findPinsForNodes(parent, node)\n local pin1 = pins.get(0)\n local pin2 = pins.get(1)\n updateConnector(connector, pin1, pin2, childOffset, node, parent)\n\n updateItem(connectorId, (item) => {\n item.shape = connector.shape\n item.area.x = connector.x\n item.area.y = connector.y\n item.area.w = connector.w\n item.area.h = connector.h\n item.shapeProps = toJSON(connector.shapeProps)\n })\n}\n\nfunc prepareConnectorForNode(node, parent) {\n local connector = Item(`connector-${parent.id}-${node.id}`, `${node.id} -> ${parent.id}`, 'connector', 0, 0, 100, 50, Map())\n\n local childOffset = Vector(node.x, node.y)\n\n local pins = findPinsForNodes(parent, node)\n local pin1 = pins.get(0)\n local pin2 = pins.get(1)\n\n updateConnector(connector, pin1, pin2, childOffset, node, parent)\n\n if (parent.tempData.has('connectors')) {\n parent.tempData.get('connectors').add(connector)\n } else {\n parent.tempData.set('connectors', List(connector))\n }\n\n pin2.id\n}\n\n\nfunc updateAbsoluteNodePosition(node) {\n local x = parseInt(node.data.get('x'))\n local y = parseInt(node.data.get('y'))\n local localPos = Vector(x, y)\n\n local pos = (if (node.parent) {\n if (node.parent.tempData.has(ABS_POS)) {\n node.parent.tempData.get(ABS_POS) + localPos\n } else {\n parentPos = updateAbsoluteNodePosition(node.parent)\n parentPos + localPos\n }\n } else {\n localPos\n })\n\n node.tempData.set(ABS_POS, pos)\n\n pos\n}\n\n\nfunc createProgressIconItems(nodeId, percent, color, x, y) {\n local items = List()\n\n if (gradientProgress) {\n local t = if (percent <= 50) { percent / 50 } else { (percent - 50) / 50 }\n local c1 = decodeColor(if (percent <= 50) { (progressColor) } else { progressColor2 })\n local c2 = decodeColor(if (percent <= 50) { (progressColor2) } else { progressColor3 })\n color = c1.gradient(c2, t).encode()\n }\n\n local isNotFull = percent < 99.5\n\n items.add(Item(\n `${nodeId}_progress_stroke`, 'progress container', 'ellipse', x, y, progressSize, progressSize, Map(\n 'fill', if (isNotFull) { Map('type', 'none') } else { Map('type', 'solid', 'color', color) },\n 'strokeColor', color,\n 'strokeSize', 3\n ), List(), Map(\n 'mindMapType', 'progress',\n 'mindMapNodeId', nodeId,\n )\n ))\n if (percent > 0.5 && isNotFull) {\n items.add(Item(\n `${nodeId}_progress`, 'progress', 'pie_segment', x, y, progressSize, progressSize, Map(\n 'strokeSize', 0,\n 'strokeColor', color,\n 'fill', Map('type', 'solid', 'color', color),\n 'percent', percent\n ), List(), Map(\n 'mindMapType', 'progress',\n 'mindMapNodeId', nodeId,\n )\n ))\n }\n items\n}\n\nfunc createNodeIconItems(node) {\n local items = List()\n\n local margin = 5\n\n if (showProgress) {\n local percent = 0\n local color = progressColor\n if (node.children.size == 0) {\n color = progressColor2\n }\n if (node.data.has('p')) {\n percent = max(0, min(parseInt(node.data.get('p')), 100))\n }\n local x = 10\n local y = node.h / 2 - progressSize / 2\n\n items.extendList(createProgressIconItems(node.id, percent, color, x, y))\n }\n\n local progressOffset = if (showProgress) { margin + progressSize } else { 0 }\n\n getNodeIcons(node).forEach((iconId, idx) => {\n local x = (iconSize + margin) * idx + 10 + progressOffset\n local y = node.h / 2 - iconSize / 2\n items.add(Item(\n `${node.id}_icon_${iconId}`, iconId, 'image', x, y, iconSize, iconSize, Map('image', allIcons.get(iconId)), List(), Map(\n 'mindMapType', 'icon',\n 'mindMapIcon', iconId,\n 'mindMapNodeId', node.id,\n )\n ))\n })\n\n items\n}\n\nfunc buildTemplateItems(rootNode) {\n rootNode.map((node, childItems) => {\n local x = node.data.get('x')\n local y = node.data.get('y')\n local w = node.data.get('w')\n local h = node.data.get('h')\n\n childItems.extendList()\n\n createNodeIconItems(node).forEach((iconItem, i) => {\n childItems.insert(i, iconItem)\n })\n\n if (node.tempData.has('connectors')) {\n local connectors = node.tempData.get('connectors')\n if (connectors) {\n connectors.forEach((connector, i) => {\n childItems.insert(i, connector)\n })\n }\n }\n\n local shape = if (node.data.has('s')) { node.data.get('s') } else { 'rect' }\n\n local item = Item(node.id, `item ${node.id}`, shape, x, y, w, h, Map(), childItems, Map(\n 'mindMapType', 'node'\n ))\n\n if (node.tempData.has('sourceItem')) {\n srcItem = node.tempData.get('sourceItem')\n if (srcItem) {\n item.shape = srcItem.shape\n item.shapeProps = fromJSON(srcItem.shapeProps)\n item.textSlots = fromJSON(srcItem.textSlots)\n }\n } else {\n item.textSlots = Map('body', Map(\n 'text', '',\n 'paddingLeft', 5,\n 'paddingRight', 5,\n 'paddingTop', 5,\n 'paddingBottom', 5,\n ))\n }\n\n item.args.set('templateIgnoredProps', List('name', 'shape', 'shapeProps.*'))\n item.locked = false\n if (node.id != rootNode.id) {\n item.args.set('tplArea', 'controlled')\n }\n item.args.set('tplRotation', 'off')\n item.args.set('tplConnector', 'off')\n\n if (shape == 'none') {\n item.setText('body', 'Add your text...')\n }\n item\n })\n}\n\nfunc findProperPositionValue(node, placement) {\n local values = List()\n\n node.children.forEach((childNode) => {\n if (placement == 'top') {\n if (childNode.y < 0) {\n values.add(childNode.y)\n }\n } else if (placement == 'bottom') {\n if (childNode.y > 0) {\n values.add(childNode.y)\n }\n } else if (placement == 'left') {\n if (childNode.x < 0) {\n values.add(childNode.x)\n }\n } else if (placement == 'right') {\n if (childNode.x > 0) {\n values.add(childNode.x)\n }\n }\n })\n\n values.sort((a, b) => { if (a < b) { -1 } else { 1 } })\n\n local w = max(1, node.w)\n local h = max(1, node.h)\n\n if (values.size == 0) {\n if (placement == 'top') {\n - padding - h\n } else if (placement == 'bottom') {\n node.h + padding\n } else if (placement == 'left') {\n - padding - w\n } else if (placement == 'right') {\n node.w + padding\n }\n } else {\n local i = floor(values.size / 2)\n values.get(i)\n }\n}\n\nfunc createNewChildFor(nodeId, placement) {\n local node = rootNode.findById(nodeId)\n if (node) {\n local x = 0\n local y = 0\n local w = max(1, node.w)\n local h = max(1, node.h)\n local shape = if (node.data.has('s')) { node.data.get('s') } else { 'rect' }\n\n if (node.children.size > 0) {\n local childNode = node.children.get(0)\n if (childNode.data.has('s')) {\n shape = if (childNode.data.has('s')) { childNode.data.get('s') } else { shape }\n }\n\n w = max(1, childNode.w)\n h = max(1, childNode.h)\n }\n\n local correctiveVector = Vector(0, h * 0.1)\n\n if (placement == 'top') {\n y = findProperPositionValue(node, placement)\n x = node.w / 2 - w / 2\n correctiveVector = Vector(w * 0.2, 0)\n } else if (placement == 'bottom') {\n y = findProperPositionValue(node, placement)\n x = node.w / 2 - w / 2\n correctiveVector = Vector(w * 0.2, 0)\n } else if (placement == 'left') {\n x = findProperPositionValue(node, placement)\n y = node.h / 2 - h / 2\n } else if (placement == 'right') {\n x = findProperPositionValue(node, placement)\n y = node.h / 2 - h / 2\n }\n\n local childAreas = node.children.map((childNode) => { Area(childNode.x, childNode.y, childNode.w, childNode.h) })\n\n func overlapsChildren(area) {\n local overlaps = false\n for (local i = 0; !overlaps && i < childAreas.size; i++) {\n overlaps = area.overlaps(childAreas.get(i))\n }\n overlaps\n }\n\n local overlaps = true\n local tries = 0\n\n local area\n\n while(overlaps && tries < 1000) {\n local direction = if (tries % 2 == 0) { 1 } else { -1 }\n local displacement = correctiveVector * tries * direction\n area = Area(x + displacement.x, y + displacement.y, w, h)\n overlaps = overlapsChildren(area)\n tries++\n }\n\n local childNode = TreeNode(uid(), Map('x', area.x, 'y', area.y, 'w', w, 'h', h, 's', shape, 'p', 0))\n node.children.add(childNode)\n\n updateProgress(rootNode)\n\n encodeMindMap()\n }\n}\n\nfunc shouldNodeShapeSelectorBeDisplayed(selectedItemIds) {\n local shown = false\n selectedItemIds.forEach((itemId) => {\n if (rootNode.findById(itemId)) {\n shown = true\n }\n })\n shown\n}\n\nfunc selectShapeForItems(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n updateItem(itemId, (item) => {\n item.shape = panelItem.shape\n forEach(panelItem.shapeProps, (value, name) => {\n if (name != 'fill') {\n setObjectField(item.shapeProps, name, value)\n }\n })\n local node = rootNode.findById(itemId)\n if (node) {\n node.data.set('s', panelItem.shape)\n encodeMindMap()\n }\n\n if (panelItem.shape == 'none') {\n if (!item.textSlots.body) {\n setObjectField(item.textSlots, 'body', toJSON(Map('text', 'Add your text...')))\n }\n if (item.textSlots.body) {\n if (!item.textSlots.body.text || item.textSlots.body.text == '

') {\n setObjectField(item.textSlots.body, 'text', 'Ad your text...')\n }\n }\n }\n })\n })\n}\n\nfunc shouldNodeIconSelectorBeDisplayed(selectedItemIds) {\n shouldNodeShapeSelectorBeDisplayed(selectedItemIds)\n}\n\n\nfunc selectIconForItems(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n local node = rootNode.findById(itemId)\n if (node) {\n setNodeIcon(node, panelItem.id)\n }\n })\n}\n\nfunc shouldNodeProgressEditorBeDisplayed(selectedItemIds) {\n if (showProgress) {\n local showPanel = false\n selectedItemIds.forEach((itemId) => {\n if (itemId.endsWith('_progress')) {\n itemId = itemId.substring(0, itemId.length - 9)\n } else if (itemId.endsWith('_progress_stroke')) {\n itemId = itemId.substring(0, itemId.length - 16)\n }\n local node = rootNode.findById(itemId)\n if (node && node.children.size == 0) {\n showPanel = true\n }\n })\n showPanel\n } else {\n false\n }\n}\n\nfunc getAllProgressIconItems() {\n List(0, 25, 50, 75, 100).map((percent) => {\n items = createProgressIconItems(`icon-${percent}`, percent, progressColor2, 0, 0)\n Item(`progress_${percent}_container`, `${percent}`, 'none', 2, 2, progressSize, progressSize, Map(), items, Map(\n 'mindMapProgress', percent\n )).toJSON()\n })\n}\n\nfunc updateProgress(node) {\n if (node.children.size > 0) {\n local sumProgress = 0\n node.children.forEach((childNode) => {\n sumProgress += updateProgress(childNode)\n })\n\n local totalProgress = sumProgress / node.children.size\n node.data.set('p', totalProgress)\n totalProgress\n } else {\n if (node.data.has('p')) {\n node.data.get('p')\n } else {\n 0\n }\n }\n}\n\nfunc selectProgressForItems(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n if (itemId.endsWith('_progress')) {\n itemId = itemId.substring(0, itemId.length - 9)\n } else if (itemId.endsWith('_progress_stroke')) {\n itemId = itemId.substring(0, itemId.length - 16)\n }\n local node = rootNode.findById(itemId)\n if (node && node.children.size == 0) {\n node.data.set('p', panelItem.args.mindMapProgress)\n }\n })\n\n updateProgress(rootNode)\n\n encodeMindMap()\n}\n\n// triggered when item area changes as a result of edit box modifications\n// the handler is supposed to mutate the area object in case the area is changed\nfunc onAreaUpdate(itemId, item, area) {\n local node = rootNode.findById(itemId)\n if (node) {\n node.data.set('x', area.x)\n node.data.set('y', area.y)\n node.data.set('w', area.w)\n node.data.set('h', area.h)\n\n if (node.parent) {\n updateConnectorForMovingNode(node)\n }\n encodeMindMap()\n }\n}\n\n// Template special function. Triggered when user deletes templated item\nfunc onDeleteItem(itemId, item) {\n local node\n if (item.args.mindMapType == 'icon') {\n node = rootNode.findById(item.args.mindMapNodeId)\n if (node) {\n removeNodeIcon(node, item.args.mindMapIcon)\n }\n } else if (item.args.mindMapType == 'node') {\n node = rootNode.findById(itemId)\n if (node && node.parent) {\n node.parent.children.remove(node.siblingIdx)\n updateProgress(rootNode)\n encodeMindMap()\n }\n }\n}\n\nfunc onCopyItem(itemId, item) {\n local node = rootNode.findById(itemId)\n if (node) {\n encodedNode = node.encodeTree(' | ', ';')\n setObjectField(item.args, 'mindMap_EncodedNode', encodedNode)\n }\n}\n\nfunc onPasteItems(itemId, items) {\n local dstNode = rootNode.findById(itemId)\n if (dstNode) {\n items.forEach((item) => {\n if (item.args && item.args.mindMap_EncodedNode) {\n local newRootNode = decodeTree(item.args.mindMap_EncodedNode, ' | ', ';')\n\n local nodesById = Map()\n newRootNode.traverse((node) => {\n nodesById.set(node.id, node)\n node.x = parseInt(node.data.get('x'))\n node.y = parseInt(node.data.get('y'))\n node.w = parseInt(node.data.get('w'))\n node.h = parseInt(node.data.get('h'))\n node.id = uid()\n })\n\n local traverseItems = (item) => {\n if (item.args.templated && item.args.templatedId && nodesById.has(item.args.templatedId)) {\n local node = nodesById.get(item.args.templatedId)\n // this will be used when building items to make sure that all shapeProps and textSlots fields are copied as well\n // as those could be overriden by the user\n node.tempData.set('sourceItem', item)\n }\n if (item.childItems) {\n item.childItems.forEach(traverseItems)\n }\n }\n traverseItems(item)\n newRootNode.attachTo(dstNode)\n\n if (dstNode.parent && dstNode.x < 0) {\n newRootNode.x = - padding - newRootNode.w\n newRootNode.y = 0\n } else {\n newRootNode.x = dstNode.w + padding\n newRootNode.y = 0\n }\n newRootNode.data.set('x', newRootNode.x)\n newRootNode.data.set('y', newRootNode.y)\n autoAlignChildNodes(newRootNode, newRootNode.x < 0)\n }\n })\n\n reindexTree(rootNode)\n updateProgress(rootNode)\n rootItem = buildTemplateItems(rootNode)\n rootItem.name = 'Mind map'\n rootItem.w = width\n rootItem.h = height\n\n encodeMindMap()\n }\n}\n\n\nfunc autoAlignChildNodes(node, toLeft) {\n local vPad = 20\n local totalHeight = node.children.size * vPad\n\n node.children.forEach((childNode) => {\n totalHeight += childNode.h\n })\n\n local y = node.h/2 - totalHeight/2\n\n node.children.forEach((childNode) => {\n childNode.y = y\n y = y + childNode.h + vPad\n\n if (toLeft) {\n childNode.x = -childNode.w - padding\n } else {\n childNode.x = node.w + padding\n }\n\n childNode.data.set('x', childNode.x)\n childNode.data.set('y', childNode.y)\n autoAlignChildNodes(childNode, toLeft)\n })\n}\n\n\nfunc reindexTree(rootNode) {\n rootNode.traverse((node, parent) => {\n local x = parseInt(node.data.get('x'))\n local y = parseInt(node.data.get('y'))\n local w = parseInt(node.data.get('w'))\n local h = parseInt(node.data.get('h'))\n local p = 0\n if (node.data.has('p')) {\n p = parseInt(node.data.get('p'))\n }\n\n node.data.set('x', x)\n node.data.set('y', y)\n node.data.set('w', w)\n node.data.set('h', h)\n node.data.set('p', p)\n\n node.x = x\n node.y = y\n local pos = updateAbsoluteNodePosition(node)\n\n local addingControl = (location, placement, cx, cy) => {\n Control(`add_child_${location}`, Map('nodeId', node.id), `createNewChildFor(control.data.nodeId, '${location}')`, cx, cy, 20, 20, '+', placement, node.id)\n }\n\n if (parent) {\n node.w = w\n node.h = h\n local srcPinId = prepareConnectorForNode(node, parent)\n if (srcPinId == 't') {\n controls.add(addingControl('bottom', 'TL', pos.x + node.w / 2 - 10, pos.y + node.h + controlPadding))\n } else if (srcPinId == 'b') {\n controls.add(addingControl('top', 'BL', pos.x + node.w / 2 - 10, pos.y - controlPadding))\n } else if (srcPinId == 'l') {\n controls.add(addingControl('right', 'TL', pos.x + node.w + controlPadding, pos.y + node.h / 2 - 10))\n } else if (srcPinId == 'r') {\n controls.add(addingControl('left', 'TR', pos.x - controlPadding, pos.y + node.h / 2 - 10))\n }\n } else {\n node.w = width\n node.h = height\n\n controls.extendList(List(\n addingControl('left', 'TR', -controlPadding, node.h / 2 - 10),\n addingControl('right', 'TL', node.w + controlPadding, node.h / 2 - 10)\n ))\n }\n })\n}\n\n\nfunc shouldNodeOperationsPanelBeDisplayed(selectedItemIds) {\n shouldNodeShapeSelectorBeDisplayed(selectedItemIds)\n local shown = false\n selectedItemIds.forEach((itemId) => {\n node = rootNode.findById(itemId)\n if (node && node.parent) {\n shown = true\n }\n })\n shown\n}\n\nfunc onOperationsPanelClick(selectedItemIds, panelItem) {\n selectedItemIds.forEach((itemId) => {\n node = rootNode.findById(itemId)\n if (node) {\n if (panelItem.id == 'insert-new-parent' && node.parent) {\n insertNewParentFor(node)\n } else if (panelItem.id == 'delete-node-preserve-children' && node.parent) {\n deleteNodePreservingChildren(node)\n }\n }\n })\n}\n\nfunc insertNewParentFor(node) {\n local oldParent = node.parent\n local dx = node.x - node.parent.x\n local dy = node.y - node.parent.y\n\n local found = false\n for (local i = 0; !found && i < node.parent.children.size; i++) {\n local childNode = node.parent.children.get(i)\n if (childNode.id == node.id) {\n found = true\n node.parent.children.remove(i)\n }\n }\n\n local newParent = TreeNode(uid())\n node.data.forEach((value, name) => { newParent.data.set(name, value) })\n newParent.x = node.x\n newParent.y = node.y\n newParent.w = node.w\n newParent.h = node.h\n\n node.attachTo(newParent)\n newParent.attachTo(oldParent)\n updateProgress(rootNode)\n encodeMindMap()\n}\n\nfunc deleteNodePreservingChildren(node) {\n local found = false\n local parent = node.parent\n for (local i = 0; !found && i < parent.children.size; i++) {\n if (node.id == parent.children.get(i).id) {\n parent.children.remove(i)\n local oldSize = parent.children.size - 1\n parent.children.extendList(node.children)\n node.children.forEach((childNode, idx) => {\n childNode.parent = parent\n childNode.siblingIdx = oldSize + idx\n })\n }\n }\n\n updateProgress(rootNode)\n encodeMindMap()\n}\n\nrootNode = decodeTree(nodes, ' | ', ';')\nreindexTree(rootNode)\n\n if (context.phase == 'build') {\n rootItem = buildTemplateItems(rootNode)\n rootItem.name = 'Mind map'\n rootItem.w = width\n rootItem.h = height\n}\n\n\n"} \ No newline at end of file diff --git a/assets/templates/diagrams/sankey.json b/assets/templates/diagrams/sankey.json index 759511638..b94dcae14 100644 --- a/assets/templates/diagrams/sankey.json +++ b/assets/templates/diagrams/sankey.json @@ -1 +1 @@ -{"name": "Sankey diagram", "description": "This template converts your diagram code below into an interactive Sankey diagram.\nTo define a connection in your diagram between the two nodes \"A\" and \"B\" type it like this \"A [150] B\".\nEvery connection should be defined in a separate line.\n", "args": {"nodesData": {"type": "string", "value": "a1_1;x=0;y=0;|a1_2|a2", "name": "Nodes encoded", "hidden": true}, "diagramCode": {"type": "string", "value": "Wages [2000] Budget\nOther [120] Budget\nBudget [1000] Housing\nBudget [450] Taxes\n", "name": "Diagram", "textarea": true, "rows": 15}, "colorTheme": {"group": "Theme & Colors", "type": "choice", "value": "default", "options": ["default", "light", "dark", "air-force-blue", "gray"], "name": "Color theme"}, "conColorType": {"group": "Theme & Colors", "type": "choice", "value": "source", "options": ["source", "destination", "gradient", "custom"], "name": "Connection color type"}, "conColor": {"group": "Theme & Colors", "type": "advanced-color", "value": {"type": "solid", "color": "#aaaaaa"}, "name": "Connection color", "depends": {"conColorType": "custom"}}, "labelColor": {"group": "Theme & Colors", "type": "color", "value": "#222222", "name": "Text color"}, "nodeWidth": {"group": "Nodes", "type": "number", "value": 20, "name": "Node width", "min": 1}, "nodeSpacing": {"group": "Nodes", "type": "number", "value": 40, "name": "Nodes empty space (%)", "min": 0, "max": 90}, "nodeCornerRadius": {"group": "Nodes", "type": "number", "value": 5, "name": "Corner radius", "min": 0}, "nodeStrokeSize": {"group": "Nodes", "type": "number", "value": 1, "name": "Stroke size", "min": 0}, "nodeStrokeColor": {"group": "Nodes", "type": "color", "value": "rgba(255,255,255,1)", "name": "Stroke color"}, "curviness": {"group": "Connections", "type": "number", "value": 80, "name": "Curviness (%)", "min": 0, "max": 100}, "conOpacity": {"group": "Connections", "type": "number", "value": 60, "name": "Connector opacity", "min": 0, "max": 100}, "conHoverStroke": {"group": "Connections", "type": "color", "value": "rgba(30,30,30,1)", "name": "Connection hover stroke"}, "conHoverStrokeSize": {"group": "Connections", "type": "number", "value": 1, "name": "Connection hover stroke size"}, "font": {"group": "Labels & Text", "type": "font", "value": "Arial", "name": "Font"}, "fontSize": {"group": "Labels & Text", "type": "number", "value": 14, "name": "Font size", "min": 1}, "magnify": {"group": "Labels & Text", "type": "number", "value": 0, "name": "Magnify value", "min": -50, "max": 50}, "conLabel": {"group": "Labels & Text", "type": "boolean", "value": true, "name": "Connection labels enabled", "description": "Displays the value of the connection"}, "showLabelFill": {"group": "Labels & Text", "type": "boolean", "value": true, "name": "Show label fill"}, "labelFill": {"group": "Labels & Text", "type": "advanced-color", "value": {"type": "solid", "color": "#FCE6AC82"}, "name": "Background", "depends": {"showLabelFill": true}}, "labelStroke": {"group": "Labels & Text", "type": "color", "value": "#97654299", "name": "Label stroke", "depends": {"showLabelFill": true}}, "labelStrokeSize": {"group": "Labels & Text", "type": "number", "value": 1, "name": "Label stroke size", "depends": {"showLabelFill": true}}, "labelCornerRadius": {"group": "Labels & Text", "type": "number", "value": 4, "name": "Label corner radius", "min": 0, "depends": {"showLabelFill": true}}, "labelPadding": {"group": "Labels & Text", "type": "number", "value": 5, "name": "Label padding", "depends": {"showLabelFill": true}}, "showNodeValues": {"group": "Values", "type": "boolean", "value": true, "name": "Show node values"}, "valuePrefix": {"group": "Values", "type": "string", "value": "", "name": "Value prefix"}, "valueSuffix": {"group": "Values", "type": "string", "value": "", "name": "Value suffix"}, "numberFormat": {"group": "Values", "type": "choice", "value": "1000000.00", "options": ["1000000.00", "1000000,00", "1,000,000.00", "1.000.000,00", "1 000 000.00", "1 000 000,00"], "name": "Number format"}}, "preview": "/assets/templates/previews/sankey.svg", "defaultArea": {"x": 0, "y": 0, "w": 200, "h": 60}, "import": ["./src/item.sch", "./src/control.sch", "./src/sankey.sch"], "handlers": {"area": "onAreaUpdate(itemId, item, area)", "text": "onTextUpdate(itemId, item, text)", "delete": "onDeleteItem(itemId, item)"}, "controls": [{"$-foreach": {"source": "allConnections", "it": "c"}, "data": {"connectionId": {"$-expr": "c.id"}}, "selectedItemId": {"$-expr": "c.id"}, "name": "connectionValue", "type": "textfield", "text": {"$-str": "${c.value}"}, "placement": {"$-expr": "if (c.srcNode.offset > c.dstNode.offset) { 'TL' } else { 'BL' }"}, "x": {"$-expr": "c.item.x"}, "y": {"$-expr": "if (c.srcNode.offset > c.dstNode.offset) { c.item.y + c.item.h } else { c.item.y }"}, "width": 180, "height": 30, "input": ["onConnectionValueInput(control.data.connectionId, value)"]}, {"$-foreach": {"source": "nodeControls", "it": "control"}, "$-extend": {"$-expr": "toJSON(control)"}}], "item": {"id": "root", "name": "Sankey diagram", "shape": "dummy", "shapeProps": {"fill": {"type": "none"}, "strokeColor": "rgba(200,200,200,1)"}, "locked": false, "area": {"x": 0, "y": 0, "w": {"$-expr": "width"}, "h": {"$-expr": "height"}}, "childItems": [{"$-foreach": {"source": "connectorItems", "it": "it"}, "id": {"$-str": "c-${it.id}"}, "tags": ["sankey-connector"], "name": {"$-expr": "it.name"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "toJSON(it.getArgs())"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "selfOpacity": {"$-expr": "conOpacity"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}, "childItems": [{"$-foreach": {"source": "it.childItems", "it": "label"}, "id": {"$-expr": "label.id"}, "name": {"$-expr": "label.name"}, "shape": "rect", "tags": ["connector-label"], "shapeProps": {"$-expr": "toJSON(label.shapeProps)"}, "args": {"$-expr": "toJSON(label.getArgs())"}, "locked": {"$-expr": "label.locked"}, "textSlots": {"$-expr": "toJSON(label.textSlots)"}, "visible": true, "opacity": 100, "area": {"x": {"$-expr": "label.x"}, "y": {"$-expr": "label.y"}, "w": {"$-expr": "label.w"}, "h": {"$-expr": "label.h"}}, "behavior": {"events": [{"id": "init", "event": "init", "actions": [{"id": "a1", "element": "self", "method": "hide", "args": {"animated": false}}]}]}}], "behavior": {"events": [{"id": "mousein", "event": "mousein", "actions": [{"id": "a1", "element": "self", "method": "set", "args": {"field": "shapeProps.strokeSize", "value": {"$-expr": "conHoverStrokeSize"}, "animated": true, "animationDuration": 0.2, "transition": "ease-in-out", "inBackground": true}}, {"id": "a2", "element": {"$-str": "#cl-${it.id}"}, "method": "show", "args": {"animated": true, "animationDuration": 0.2}}]}, {"id": "mouseout", "event": "mouseout", "actions": [{"id": "a1", "element": "self", "method": "set", "args": {"field": "shapeProps.strokeSize", "value": 0, "animated": true, "animationDuration": 0.2, "transition": "ease-in-out", "inBackground": true}}, {"id": "a2", "element": {"$-str": "#cl-${it.id}"}, "method": "hide", "args": {"animated": true, "animationDuration": 0.2}}]}]}}, {"$-foreach": {"source": "nodeItems", "it": "it"}, "id": {"$-expr": "it.id"}, "tags": ["sankey-node"], "name": {"$-expr": "it.name"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "toJSON(it.getArgs())"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}}, {"$-foreach": {"source": "nodeLabels", "it": "it"}, "id": {"$-expr": "it.id"}, "name": {"$-expr": "it.name"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "toJSON(it.getArgs())"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}, "childItems": [{"$-foreach": {"source": "it.childItems", "it": "child"}, "id": {"$-expr": "child.id"}, "name": {"$-expr": "child.name"}, "shape": {"$-expr": "child.shape"}, "shapeProps": {"$-expr": "toJSON(child.shapeProps)"}, "args": {"$-expr": "toJSON(child.getArgs())"}, "locked": {"$-expr": "child.locked"}, "textSlots": {"$-expr": "toJSON(child.textSlots)"}, "area": {"x": {"$-expr": "child.x"}, "y": {"$-expr": "child.y"}, "w": {"$-expr": "child.w"}, "h": {"$-expr": "child.h"}}}]}]}, "init": "\nstruct Item {\n id: uid()\n name: ''\n shape: 'rect'\n x: 0\n y: 0\n w: 100\n h: 50\n shapeProps: Map()\n childItems: List()\n args: Map()\n locked: true\n textSlots: Map()\n description: \"\"\n\n\n traverse(callback) {\n this.childItems.forEach((childItem) => {\n childItem.traverse(callback)\n })\n callback(this)\n }\n\n getArgs() {\n toJSON(this.args)\n }\n\n setText(slotName, text) {\n if (!this.textSlots.has(slotName)) {\n this.textSlots.set(slotName, Map('text', text))\n } else {\n this.textSlots.get(slotName).set('text', text)\n }\n }\n\n toJSON() {\n childItems = this.childItems.map((childItem) => { childItem.toJSON() })\n result = toJSON(Map(\n 'id', this.id,\n 'childItems', childItems,\n 'name', this.name,\n 'description', this.description,\n 'shape', this.shape,\n 'area', Map('x', this.x, 'y', this.y, 'w', this.w, 'h', this.h, 'r', 0, 'sx', 1, 'sy', 1, 'px', 0.5, 'py', 0.5),\n 'shapeProps', this.shapeProps,\n 'args', this.args,\n 'locked', this.locked,\n 'textSlots', this.textSlots,\n ))\n\n result\n }\n}\n\n\n\nstruct Control {\n name: \"\"\n data: Map()\n click: \"log('control clicked')\"\n x: 0\n y: 0\n width: 20\n height: 20\n text: \"+\"\n placement: \"TL\"\n selectedItemId: \"\"\n type: 'button'\n options: List()\n}\n\nlocal gapRatio = nodeSpacing / 100\nlocal labelFontSize = max(1, round(fontSize * (100 - magnify) / 100))\nlocal valueFontSize = max(1, round(fontSize * (100 + magnify) / 100))\n\nlocal nodesById = Map()\n\nlocal colorThemes = Map(\n 'default', List('#F16161', '#F1A261', '#F1EB61', '#71EB57', '#57EBB1', '#57C2EB', '#576BEB', '#A557EB', '#EB57C8', '#EB578E'),\n 'light', List('#FD9999', '#FDCA99', '#F9FD99', '#C2FD99', '#99FDA6', '#99FDE2', '#99EAFD', '#99BEFD', '#AE99FD', '#FD99F6'),\n 'dark', List('#921515', '#924E15', '#899215', '#4E9215', '#15922B', '#15926B', '#157F92', '#153F92', '#491592', '#921575'),\n 'air-force-blue', List('#5d8aa8'),\n 'gray', List('#6B6B64'),\n)\n\nstruct Node {\n id: uid()\n name: 'Unnamed'\n value: 0\n inValue: 0\n outValue: 0\n color: '#FD9999'\n x: 0\n y: 0\n level: 0\n sortOrder: 0 // Position inside of its level\n srcNodes: List()\n dstNodes: List()\n width: 0\n height: 0\n position: 0\n offset: 0\n unitSize: 1\n reservedIn: 0\n reservedOut: 0\n}\n\nstruct Connection {\n id: uid()\n srcId: ''\n dstId: ''\n value: 0\n srcNode: null\n dstNode: null\n item: null\n}\n\nstruct CodeLine {\n text: ''\n connection: null\n}\n\nstruct Level {\n idx: 0\n nodes: List()\n nodesMap: Map()\n totalValue: 0\n height: 0\n}\n\nstruct PathPoint {\n t: 'B'\n x: 0\n y: 0\n x1: 0\n y1: 0\n x2: 0\n y2: 0\n}\n\n\nfunc encodeNodes(nodes) {\n local result = ''\n\n nodes.forEach(n => {\n if (result != '') {\n result += '|'\n }\n result += `${n.id};x=${n.x};y=${n.y}`\n })\n result\n}\n\nfunc decodeNodesData(text) {\n local nodesById = Map()\n splitString(text, '|').forEach(singleNodeText => {\n local node = Node()\n local parts = splitString(singleNodeText, ';')\n for (local i = 0; i < parts.size; i++) {\n if (i == 0) {\n node.id = parts.get(0)\n } else {\n local varValue = splitString(parts.get(i), '=')\n if (varValue.size == 2) {\n local name = varValue.get(0)\n local value = varValue.get(1)\n if (name == 'x') {\n node.x = value\n } else if (name == 'y') {\n node.y = value\n }\n }\n }\n }\n nodesById.set(node.id, node)\n })\n nodesById\n}\n\n\nfunc parseConnection(line) {\n local s1 = line.indexOf('[')\n local s2 = line.indexOf(']')\n\n if (s1 > 0 && s2 > s1) {\n local nodeName1 = line.substring(0, s1).trim()\n local nodeName2 = line.substring(s2+1).trim()\n local valueText = line.substring(s1+1, s2)\n\n if (nodeName1 != '' && nodeName2 != '') {\n local value = parseFloat(valueText)\n local id = nodeName1 + '[]' + nodeName2\n Connection(id, nodeName1, nodeName2, value)\n } else {\n null\n }\n } else {\n null\n }\n}\n\nfunc parseConnections(text, nodesData) {\n local getOrCreateNode = (id) => {\n local node = nodesById.get(id)\n if (!node) {\n node = Node(id, id)\n nodesById.set(id, node)\n }\n local nData = nodesData.get(id)\n if (nData) {\n node.x = nData.x\n node.y = nData.y\n }\n node\n }\n\n local lines = List()\n splitString(text, '\\n').forEach(rawLine => {\n local line = rawLine.trim()\n local c = null\n if (line != '' && !line.startsWith('//')) {\n c = parseConnection(line)\n }\n if (c) {\n c.srcNode = getOrCreateNode(c.srcId)\n c.dstNode = getOrCreateNode(c.dstId)\n lines.add(CodeLine(rawLine, c))\n } else {\n lines.add(CodeLine(rawLine, null))\n }\n })\n lines\n}\n\nfunc extractNodesFromConnections(connections) {\n local nodeIds = Set()\n local list = List()\n\n connections.forEach(c => {\n if (!nodeIds.has(c.srcNode.id)) {\n nodeIds.add(c.srcNode.id)\n list.add(c.srcNode)\n }\n if (!nodeIds.has(c.dstNode.id)) {\n nodeIds.add(c.dstNode.id)\n list.add(c.dstNode)\n }\n })\n\n list\n}\n\n\n// Performs a recursive tree iteration and updates the levels in nodes\n// maxVisitCount is used in order to prevent from infinite loop in case there is a cyclic dependency\nfunc updateLevels(node, maxVisitCount) {\n if (maxVisitCount >= 0) {\n node.dstNodes.forEach(dstNode => {\n local newLevel = node.level + 1\n if (dstNode.level < newLevel) {\n dstNode.level = newLevel\n updateLevels(dstNode, maxVisitCount - 1)\n }\n })\n }\n}\n\nfunc readjustStarterNodeLevels(nodesMap) {\n // readjusting node levels for starter nodes\n nodesMap.forEach(node => {\n if (node.srcNodes.size == 0 && node.dstNodes.size > 0) {\n local minDstLevel = node.dstNodes.get(0).level\n node.dstNodes.forEach(dstNode => {\n if (minDstLevel > dstNode.level) {\n minDstLevel = dstNode.level\n }\n })\n if (minDstLevel - node.level > 1) {\n node.level = minDstLevel - 1\n }\n }\n })\n}\n\nfunc buildLevels(allNodes, allConnections) {\n local nodesMap = Map()\n allNodes.forEach(node => {\n nodesMap.set(node.id, node)\n })\n\n allConnections.forEach(c => {\n if (c.srcId != c.dstId) {\n local srcNode = nodesMap.get(c.srcId)\n if (!srcNode) {\n srcNode = Node(c.srcId)\n nodesMap.set(c.srcId, srcNode)\n }\n local dstNode = nodesMap.get(c.dstId)\n if (!dstNode) {\n dstNode = Node(c.dstId)\n nodesMap.set(c.dstId, dstNode)\n }\n\n local value = abs(c.value)\n srcNode.outValue += value\n dstNode.inValue += value\n srcNode.dstNodes.add(dstNode)\n dstNode.srcNodes.add(srcNode)\n }\n })\n\n nodesMap.forEach(node => {\n if (node.srcNodes.size == 0) {\n node.level = 0\n updateLevels(node, nodesMap.size)\n }\n })\n\n readjustStarterNodeLevels(nodesMap)\n\n local levels = Map()\n local maxLevel = 0\n nodesMap.forEach(node => {\n node.value = max(node.inValue, node.outValue)\n local level = levels.get(node.level)\n if (level) {\n level.nodes.add(node)\n } else {\n levels.set(node.level, Level(node.level, List(node), nodesMap))\n }\n if (maxLevel < node.level) {\n maxLevel = node.level\n }\n })\n\n local allLevels = List()\n for (local i = 0; i <= maxLevel; i++) {\n local level = levels.get(i)\n if (level) {\n level.nodes.sort((a, b) => {\n b.value - a.value\n })\n level.nodes.forEach((n, idx) => {\n n.sortOrder = idx\n })\n allLevels.add(level)\n level.totalValue = 0\n level.nodes.forEach(node => {\n level.totalValue += node.value\n })\n }\n }\n\n allLevels\n}\n\n\nfunc buildNodeItems(levels) {\n local colorPalette = colorThemes.get(colorTheme)\n if (!colorPalette) {\n colorPalette = colorThemes.get('default')\n }\n\n local maxLevelValue = 0\n local maxNodesPerLevel = 0\n levels.forEach(level => {\n if (maxLevelValue < level.totalValue) {\n maxLevelValue = level.totalValue\n }\n if (maxNodesPerLevel < level.nodes.size) {\n maxNodesPerLevel = level.nodes.size\n }\n })\n\n local nodeItems = List()\n\n if (maxLevelValue > 0 && maxNodesPerLevel > 0) {\n local unitSize = height * (1 - gapRatio) / maxLevelValue\n local singleGap = height * gapRatio / maxNodesPerLevel\n\n levels.forEach(level => {\n local levelPosition = level.idx * max(1, width - nodeWidth) / (levels.size - 1)\n local levelSize = level.totalValue * unitSize + singleGap * (level.nodes.size - 1)\n local levelOffset = height / 2 - levelSize / 2\n\n local currentY = levelOffset\n level.nodes.forEach(node => {\n node.unitSize = unitSize\n node.height = unitSize * node.value\n node.width = nodeWidth\n node.position = levelPosition\n node.offset = currentY\n local hashCode = Strings.hashCode(node.name)\n node.color = colorPalette.get(abs(hashCode) % colorPalette.size)\n\n\n local nodeItem = Item('n-' + node.id, node.name, 'rect')\n nodeItem.w = node.width\n nodeItem.h = node.height\n nodeItem.x = node.position + node.x * width\n nodeItem.y = node.offset + node.y * height\n nodeItem.shapeProps.set('strokeSize', nodeStrokeSize)\n nodeItem.shapeProps.set('strokeColor', nodeStrokeColor)\n nodeItem.shapeProps.set('cornerRadius', nodeCornerRadius)\n nodeItem.shapeProps.set('fill', Fill.solid(node.color))\n nodeItem.args.set('tplArea', 'movable')\n nodeItem.args.set('tplConnector', 'off')\n nodeItem.args.set('tplRotation', 'off')\n nodeItem.locked = false\n nodeItems.add(nodeItem)\n\n currentY += nodeItem.h + singleGap\n })\n })\n }\n nodeItems\n}\n\nfunc buildConnectorItems(levels, allConnections, allNodes) {\n local k2 = max(allConnections.size, allNodes.size)\n local k1 = k2 ^ 2\n local connectorItems = List()\n local connectionsBySource = Map()\n allConnections.forEach(c => {\n if (c.srcId != c.dstId) {\n local cs = connectionsBySource.get(c.srcId)\n if (cs) {\n cs.add(c)\n } else {\n connectionsBySource.set(c.srcId, List(c))\n }\n }\n })\n\n local cs = List()\n\n levels.forEach(level => {\n level.nodes.forEach(node => {\n local connections = connectionsBySource.get(node.id)\n if (connections) {\n connections.sort((a, b) => {\n a.dstNode.offset + a.dstNode.y * height - (b.dstNode.offset + b.dstNode.y * height)\n })\n connections.forEach(c => {\n local dstNode = level.nodesMap.get(c.dstId)\n if (dstNode) {\n cs.add(c)\n }\n })\n }\n })\n })\n\n cs.sort((a, b) => {\n a.srcNode.offset + a.srcNode.y * height - (b.srcNode.offset + b.srcNode.y * height)\n })\n\n cs.forEach(c => {\n connectorItems.add(buildSingleConnectorItem(c, c.srcNode, c.dstNode))\n })\n\n connectorItems\n}\n\n\nfunc buildSingleConnectorItem(connector, srcNode, dstNode) {\n local item = Item(connector.id, `${srcNode.id} -> ${dstNode.id}`, 'path')\n local connectorSize = srcNode.unitSize * connector.value\n\n local xs = srcNode.position + srcNode.x * width + srcNode.width\n local ys1 = srcNode.offset + srcNode.y * height + srcNode.reservedOut\n local ys2 = ys1 + connectorSize\n srcNode.reservedOut += connectorSize\n\n local xd = dstNode.position + dstNode.x * width\n local yd1 = dstNode.offset + dstNode.y * height + dstNode.reservedIn\n local yd2 = yd1 + connectorSize\n dstNode.reservedIn += connectorSize\n\n local minX = min(xs, xd)\n local maxX = max(xs, xd)\n local minY = min(ys1, ys2, yd1, yd2)\n local maxY = max(ys1, ys2, yd1, yd2)\n\n local dx = max(0.001, maxX - minX)\n local dy = max(0.001, maxY - minY)\n\n local t = curviness / 100\n\n local points = List(\n PathPoint('B', xs, ys1, 0, t * (ys2 - ys1) / 2, t * (xd - xs) / 2, 0),\n PathPoint('B', xd, yd1, t * (xs - xd) / 2, 0, 0, t * (yd2 - yd1) / 2),\n PathPoint('B', xd, yd2, 0, t * (yd1 - yd2) / 2, t * (xs - xd) / 2, 0),\n PathPoint('B', xs, ys2, t * (xd - xs) / 2, 0, 0, t * (ys1 - ys2) / 2),\n ).map(p => {\n PathPoint('B',\n 100 * (p.x - minX) / dx, 100 * (p.y - minY) / dy,\n 100 * p.x1 / dx, 100 * p.y1 / dy,\n 100 * p.x2 / dx, 100 * p.y2 / dy\n )\n })\n\n item.x = minX\n item.y = minY\n item.w = dx\n item.h = dy\n\n if (conColorType == 'gradient') {\n item.shapeProps.set('fill', Fill.linearGradient(90, 0, srcNode.color, 100, dstNode.color))\n } else if (conColorType == 'source') {\n item.shapeProps.set('fill', Fill.solid(srcNode.color))\n } else if (conColorType == 'destination') {\n item.shapeProps.set('fill', Fill.solid(dstNode.color))\n } else {\n item.shapeProps.set('fill', conColor)\n }\n item.shapeProps.set('strokeSize', 0)\n item.shapeProps.set('strokeColor', conHoverStroke)\n item.shapeProps.set('paths', List(Map(\n 'id', 'p-' + connector.id,\n 'closed', true,\n 'pos', 'relative',\n 'points', points\n )))\n\n if (conLabel) {\n item.childItems.add(buildConnectorLabel(connector, item.w, item.h))\n }\n connector.item = item\n item\n}\n\nfunc buildConnectorLabel(c, connectorWidth, connectorHeight) {\n local valueText = formatValue(c.value)\n local valueTextSize = calculateTextSize(valueText, font, fontSize)\n local valueLabel = buildLabel('cl-' + c.id, valueText, font, fontSize, 'center', 'middle')\n valueLabel.w = valueTextSize.w + 4 + 2 * labelPadding\n valueLabel.h = valueTextSize.h * 1.8 + 2 * labelPadding\n valueLabel.x = connectorWidth/2 - valueLabel.w/2 - labelPadding\n valueLabel.y = connectorHeight/2 - valueLabel.h/2 - labelPadding\n valueLabel.name = 'connector-label-' + c.id\n valueLabel.shapeProps.set('strokeColor', labelStroke)\n valueLabel.args.set('tplText', Map('body', '' + c.value))\n if (showLabelFill) {\n valueLabel.shapeProps.set('fill', labelFill)\n valueLabel.shapeProps.set('strokeColor', labelStroke)\n valueLabel.shapeProps.set('strokeSize', labelStrokeSize)\n valueLabel.shapeProps.set('cornerRadius', labelCornerRadius)\n } else {\n valueLabel.shapeProps.set('fill', Fill.none())\n valueLabel.shapeProps.set('strokeSize', 0)\n valueLabel.shapeProps.set('cornerRadius', 0)\n }\n valueLabel\n}\n\nfunc buildLabel(id, text, font, fontSize, halign, valign) {\n local item = Item(id, text, 'none')\n item.args.set('templateForceText', true)\n item.textSlots.set('body', Map(\n 'text', text,\n 'font', font,\n 'color', labelColor,\n 'fontSize', fontSize,\n 'halign', halign,\n 'valign', valign,\n 'paddingLeft', 0,\n 'paddingRight', 0,\n 'paddingTop', 0,\n 'paddingBottom', 0,\n 'whiteSpace', 'nowrap'\n ))\n item\n}\n\nlocal valueFormaters = Map(\n '1000000.00', (value) => {\n numberToLocaleString(value, 'en-US').replaceAll(',', '')\n },\n '1000000,00', (value) => {\n numberToLocaleString(value, 'de-DE').replaceAll('.', '')\n },\n '1,000,000.00', (value) => {\n numberToLocaleString(value, 'en-US')\n },\n '1.000.000,00', (value) => {\n numberToLocaleString(value, 'de-DE')\n },\n '1 000 000.00', (value) => {\n numberToLocaleString(value, 'fi-FI').replaceAll(',', '.')\n },\n '1 000 000,00', (value) => {\n numberToLocaleString(value, 'fi-FI')\n },\n)\n\nfunc formatValue(value) {\n local valueText = value\n if (valueFormaters.has(numberFormat)) {\n valueText = valueFormaters.get(numberFormat)(value)\n }\n valuePrefix + valueText + valueSuffix\n}\n\nfunc buildNodeLabels(nodes) {\n local labelItems = List()\n nodes.forEach(node => {\n local lines = splitString(node.name, '\\\\n')\n local textWidth = 0\n local textHeight = 0\n local nodeText = ''\n lines.forEach(line => {\n local textSize = calculateTextSize(line, font, labelFontSize)\n textWidth = max(textWidth, textSize.w)\n textHeight += textSize.h\n nodeText += `

${line}

`\n })\n\n\n local valueText = formatValue(node.value)\n local valueTextSize = calculateTextSize(valueText, font, valueFontSize)\n local totalHeight = if (showNodeValues) { (textHeight + valueTextSize.h)*1.8 + 8 } else { textHeight*1.8 + 8 }\n local isLeft = node.dstNodes.size == 0\n local halign = 'right'\n if (!isLeft) {\n halign = 'left'\n }\n\n local label = buildLabel('ln-' + node.id, nodeText, font, labelFontSize, halign, 'middle')\n label.w = textWidth + 4\n label.h = textHeight * 1.8\n\n local valueLabel = null\n if (showNodeValues) {\n valueLabel = buildLabel('lv-' + node.id, valueText, font, valueFontSize, halign, 'middle')\n valueLabel.w = valueTextSize.w + 4\n valueLabel.h = valueTextSize.h * 1.8\n }\n\n if (showLabelFill) {\n local rect = Item('lc-'+node.id, 'Label ' + node.id, 'rect')\n if (valueLabel) {\n rect.w = max(label.w, valueLabel.w) + 2 * labelPadding\n rect.h = label.h + valueLabel.h + 2 * labelPadding\n } else {\n rect.w = label.w + 2 * labelPadding\n rect.h = label.h + 2 * labelPadding\n }\n if (isLeft) {\n rect.x = node.position + node.x * width - rect.w - labelPadding\n } else {\n rect.x = node.position + node.width + labelPadding + node.x * width\n }\n rect.shapeProps.set('fill', labelFill)\n rect.shapeProps.set('strokeColor', labelStroke)\n rect.shapeProps.set('strokeSize', labelStrokeSize)\n rect.shapeProps.set('cornerRadius', labelCornerRadius)\n rect.y = node.offset + node.y * height + node.height / 2 - totalHeight / 2\n label.x = labelPadding\n label.y = labelPadding\n rect.childItems.add(label)\n\n if (valueLabel) {\n valueLabel.x = labelPadding\n valueLabel.y = label.y + label.h\n rect.childItems.add(valueLabel)\n }\n labelItems.add(rect)\n } else {\n if (isLeft) {\n label.x = node.position - label.w - labelPadding\n if (valueLabel) {\n valueLabel.x = node.position - valueLabel.w - labelPadding\n }\n } else {\n label.x = node.position + node.width + labelPadding\n if (valueLabel) {\n valueLabel.x = node.position + node.width + labelPadding\n }\n }\n label.x += node.x * width\n label.y = node.offset + node.y * height + node.height / 2 - totalHeight / 2\n labelItems.add(label)\n\n if (valueLabel) {\n valueLabel.x += node.x * width\n valueLabel.y = label.y + label.h\n labelItems.add(valueLabel)\n }\n }\n })\n labelItems\n}\n\n\n\nfunc onAreaUpdate(itemId, item, area) {\n local node = null\n if (itemId.startsWith('n-')) {\n node = nodesById.get(itemId.substring(2))\n }\n if (node) {\n node.x = (area.x - node.position) / max(1, width)\n node.y = (area.y - node.offset) / max(1, height)\n\n nodesData = encodeNodes(nodesById)\n }\n}\n\n\nfunc onConnectionValueInput(connectionId, value) {\n allConnections.forEach(c => {\n if (c.id == connectionId) {\n c.value = value\n }\n })\n diagramCode = encodeDiagram()\n}\n\n\nfunc encodeDiagram() {\n local code = ''\n codeLines.forEach((line, idx) => {\n if (idx > 0) {\n code += '\\n'\n }\n if (line.connection) {\n code += line.connection.srcId + ' [' + line.connection.value + '] ' + line.connection.dstId\n } else {\n code += line.text\n }\n })\n\n code\n}\n\n\nfunc onTextUpdate(itemId, item, text) {\n if (itemId.startsWith('ln-')) {\n text = stripHTML(text.replaceAll('

', '

\\n')).trim().replaceAll('\\n', '\\\\n')\n local oldNodeId = itemId.substring(3)\n local newNodeId = text\n\n local foundClashingId = false\n\n for (local i = 0; i < allConnections.size && !foundClashingId; i++) {\n local c = allConnections.get(i)\n if (c.srcId == newNodeId && c.dstId == newNodeId) {\n foundClashingId = true\n }\n }\n\n if (!foundClashingId) {\n allConnections.forEach(c => {\n if (c.srcId == oldNodeId) {\n c.srcId = newNodeId\n c.srcNode.id = newNodeId\n }\n if (c.dstId == oldNodeId) {\n c.dstId = newNodeId\n c.dstNode.id = newNodeId\n }\n })\n }\n\n diagramCode = encodeDiagram()\n } else if (itemId.startsWith('cl-')) {\n text = stripHTML(text).replaceAll('\\n', '').trim()\n local value = parseFloat(text)\n local connectionId = itemId.substring(3)\n local line = codeLines.find(line => { line.connection && line.connection.id == connectionId})\n if (line) {\n line.connection.value = value\n diagramCode = encodeDiagram()\n }\n }\n}\n\n\nfunc onDeleteItem(itemId, item) {\n local nodeId = null\n local connectionId = null\n\n if (itemId.startsWith('n-')) {\n nodeId = itemId.substring(2)\n } else if (itemId.startsWith('c-')) {\n connectionId = itemId.substring(2)\n }\n\n if (nodeId) {\n local node = allNodes.find(node => {node.id == nodeId})\n if (node && node.level == levels.size - 1) {\n if (levels.size > 2 && levels.get(node.level).nodes.size == 1) {\n width -= max(1, width - nodeWidth) / max(1, (levels.size - 1))\n }\n }\n }\n\n if (connectionId || nodeId) {\n for (local i = codeLines.size - 1; i >= 0; i--) {\n local line = codeLines.get(i)\n if (line.connection) {\n if (connectionId && line.connection.id == connectionId) {\n codeLines.remove(i)\n } else if (nodeId && (line.connection.srcId == nodeId || line.connection.dstId == nodeId)) {\n codeLines.remove(i)\n }\n }\n }\n if (codeLines.filter(line => {line.connection}).size > 0) {\n diagramCode = encodeDiagram()\n }\n }\n}\n\n\nfunc generateNodeControls(nodes) {\n local controls = List()\n nodes.forEach(node => {\n if (node.srcNodes.size > 0 && node.dstNodes.size == 0) {\n controls.add(Control(\n \"+\",\n Map(\n 'nodeId', node.id\n ),\n \"addDstNodeForNode(control.data.nodeId)\",\n node.position + node.x * width + node.width + 20,\n node.offset + node.y * height + node.height / 2 - 10,\n 20, 20,\n '+',\n 'TL',\n 'n-' + node.id,\n 'button'\n ))\n }\n })\n controls\n}\n\n\nfunc addDstNodeForNode(nodeId) {\n local allNodeIds = Set()\n local selectedNode = null\n\n allNodes.forEach(node => {\n allNodeIds.add(node.id)\n if (node.id == nodeId) {\n selectedNode = node\n }\n })\n if (selectedNode) {\n local idx = 2\n local foundUniqueId = false\n local newId = null\n while(!foundUniqueId && idx < 1000) {\n newId = selectedNode.id + ' ' + idx\n if (!allNodeIds.has(newId)) {\n foundUniqueId = true\n }\n }\n if (foundUniqueId) {\n codeLines.add(CodeLine(`${selectedNode.id} [${selectedNode.value}] ${newId}`, null))\n diagramCode = encodeDiagram()\n if (selectedNode.level == levels.size - 1) {\n width += max(1, width - nodeWidth) / max(1, (levels.size - 1))\n }\n }\n }\n}\n\n\nlocal nodesDataById = decodeNodesData(nodesData)\n\nlocal codeLines = parseConnections(diagramCode, nodesDataById)\nlocal allConnections = codeLines.filter(cl => { cl.connection != null }).map(cl => { cl.connection })\nlocal allNodes = extractNodesFromConnections(allConnections)\n\nlocal levels = buildLevels(allNodes, allConnections)\n\nlocal nodeItems = buildNodeItems(levels)\nlocal connectorItems = buildConnectorItems(levels, allConnections, allNodes)\nlocal nodeLabels = buildNodeLabels(allNodes)\n\nlocal nodeControls = generateNodeControls(allNodes)\n\n"} \ No newline at end of file +{"name": "Sankey diagram", "description": "This template converts your diagram code below into an interactive Sankey diagram.\nTo define a connection in your diagram between the two nodes \"A\" and \"B\" type it like this \"A [150] B\".\nEvery connection should be defined in a separate line.\n", "args": {"nodesData": {"type": "string", "value": "a1_1;x=0;y=0;|a1_2|a2", "name": "Nodes encoded", "hidden": true}, "diagramCode": {"type": "string", "value": "Wages [2000] Budget\nOther [120] Budget\nBudget [1000] Housing\nBudget [450] Taxes\n", "name": "Diagram", "textarea": true, "rows": 15}, "colorTheme": {"group": "Theme & Colors", "type": "choice", "value": "default", "options": ["default", "light", "dark", "air-force-blue", "gray"], "name": "Color theme"}, "conColorType": {"group": "Theme & Colors", "type": "choice", "value": "source", "options": ["source", "destination", "gradient", "custom"], "name": "Connection color type"}, "conColor": {"group": "Theme & Colors", "type": "advanced-color", "value": {"type": "solid", "color": "#aaaaaa"}, "name": "Connection color", "depends": {"conColorType": "custom"}}, "labelColor": {"group": "Theme & Colors", "type": "color", "value": "#222222", "name": "Text color"}, "nodeWidth": {"group": "Nodes", "type": "number", "value": 20, "name": "Node width", "min": 1}, "nodeSpacing": {"group": "Nodes", "type": "number", "value": 40, "name": "Nodes empty space (%)", "min": 0, "max": 90}, "nodeCornerRadius": {"group": "Nodes", "type": "number", "value": 5, "name": "Corner radius", "min": 0}, "nodeStrokeSize": {"group": "Nodes", "type": "number", "value": 1, "name": "Stroke size", "min": 0}, "nodeStrokeColor": {"group": "Nodes", "type": "color", "value": "rgba(255,255,255,1)", "name": "Stroke color"}, "curviness": {"group": "Connections", "type": "number", "value": 80, "name": "Curviness (%)", "min": 0, "max": 100}, "conOpacity": {"group": "Connections", "type": "number", "value": 60, "name": "Connector opacity", "min": 0, "max": 100}, "conHoverStroke": {"group": "Connections", "type": "color", "value": "rgba(30,30,30,1)", "name": "Connection hover stroke"}, "conHoverStrokeSize": {"group": "Connections", "type": "number", "value": 1, "name": "Connection hover stroke size"}, "font": {"group": "Labels & Text", "type": "font", "value": "Arial", "name": "Font"}, "fontSize": {"group": "Labels & Text", "type": "number", "value": 14, "name": "Font size", "min": 1}, "magnify": {"group": "Labels & Text", "type": "number", "value": 0, "name": "Magnify value", "min": -50, "max": 50}, "conLabel": {"group": "Labels & Text", "type": "boolean", "value": true, "name": "Connection labels enabled", "description": "Displays the value of the connection"}, "showLabelFill": {"group": "Labels & Text", "type": "boolean", "value": true, "name": "Show label fill"}, "labelFill": {"group": "Labels & Text", "type": "advanced-color", "value": {"type": "solid", "color": "#FCE6AC82"}, "name": "Background", "depends": {"showLabelFill": true}}, "labelStroke": {"group": "Labels & Text", "type": "color", "value": "#97654299", "name": "Label stroke", "depends": {"showLabelFill": true}}, "labelStrokeSize": {"group": "Labels & Text", "type": "number", "value": 1, "name": "Label stroke size", "depends": {"showLabelFill": true}}, "labelCornerRadius": {"group": "Labels & Text", "type": "number", "value": 4, "name": "Label corner radius", "min": 0, "depends": {"showLabelFill": true}}, "labelPadding": {"group": "Labels & Text", "type": "number", "value": 5, "name": "Label padding", "depends": {"showLabelFill": true}}, "showNodeValues": {"group": "Values", "type": "boolean", "value": true, "name": "Show node values"}, "valuePrefix": {"group": "Values", "type": "string", "value": "", "name": "Value prefix"}, "valueSuffix": {"group": "Values", "type": "string", "value": "", "name": "Value suffix"}, "numberFormat": {"group": "Values", "type": "choice", "value": "1000000.00", "options": ["1000000.00", "1000000,00", "1,000,000.00", "1.000.000,00", "1 000 000.00", "1 000 000,00"], "name": "Number format"}}, "preview": "/assets/templates/previews/sankey.svg", "defaultArea": {"x": 0, "y": 0, "w": 200, "h": 60}, "import": ["./src/item.sch", "./src/control.sch", "./src/sankey.sch"], "handlers": {"area": "onAreaUpdate(itemId, item, area)", "text": "onTextUpdate(itemId, item, text)", "delete": "onDeleteItem(itemId, item)"}, "controls": [{"$-foreach": {"source": "allConnections", "it": "c"}, "data": {"connectionId": {"$-expr": "c.id"}}, "selectedItemId": {"$-expr": "c.id"}, "name": "connectionValue", "type": "textfield", "text": {"$-str": "${c.value}"}, "placement": {"$-expr": "if (c.srcNode.offset > c.dstNode.offset) { 'TL' } else { 'BL' }"}, "x": {"$-expr": "c.item.x"}, "y": {"$-expr": "if (c.srcNode.offset > c.dstNode.offset) { c.item.y + c.item.h } else { c.item.y }"}, "width": 180, "height": 30, "input": ["onConnectionValueInput(control.data.connectionId, value)"]}, {"$-foreach": {"source": "nodeControls", "it": "control"}, "$-extend": {"$-expr": "toJSON(control)"}}], "item": {"id": "root", "name": "Sankey diagram", "shape": "dummy", "shapeProps": {"fill": {"type": "none"}, "strokeColor": "rgba(200,200,200,1)"}, "locked": false, "area": {"x": 0, "y": 0, "w": {"$-expr": "width"}, "h": {"$-expr": "height"}}, "childItems": [{"$-foreach": {"source": "connectorItems", "it": "it"}, "id": {"$-str": "c-${it.id}"}, "tags": ["sankey-connector"], "name": {"$-expr": "it.name"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "toJSON(it.getArgs())"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "selfOpacity": {"$-expr": "conOpacity"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}, "childItems": [{"$-foreach": {"source": "it.childItems", "it": "label"}, "id": {"$-expr": "label.id"}, "name": {"$-expr": "label.name"}, "shape": "rect", "tags": ["connector-label"], "shapeProps": {"$-expr": "toJSON(label.shapeProps)"}, "args": {"$-expr": "toJSON(label.getArgs())"}, "locked": {"$-expr": "label.locked"}, "textSlots": {"$-expr": "toJSON(label.textSlots)"}, "visible": true, "opacity": 100, "area": {"x": {"$-expr": "label.x"}, "y": {"$-expr": "label.y"}, "w": {"$-expr": "label.w"}, "h": {"$-expr": "label.h"}}, "behavior": {"events": [{"id": "init", "event": "init", "actions": [{"id": "a1", "element": "self", "method": "hide", "args": {"animated": false}}]}]}}], "behavior": {"events": [{"id": "mousein", "event": "mousein", "actions": [{"id": "a1", "element": "self", "method": "set", "args": {"field": "shapeProps.strokeSize", "value": {"$-expr": "conHoverStrokeSize"}, "animated": true, "animationDuration": 0.2, "transition": "ease-in-out", "inBackground": true}}, {"id": "a2", "element": {"$-str": "#cl-${it.id}"}, "method": "show", "args": {"animated": true, "animationDuration": 0.2}}]}, {"id": "mouseout", "event": "mouseout", "actions": [{"id": "a1", "element": "self", "method": "set", "args": {"field": "shapeProps.strokeSize", "value": 0, "animated": true, "animationDuration": 0.2, "transition": "ease-in-out", "inBackground": true}}, {"id": "a2", "element": {"$-str": "#cl-${it.id}"}, "method": "hide", "args": {"animated": true, "animationDuration": 0.2}}]}]}}, {"$-foreach": {"source": "nodeItems", "it": "it"}, "id": {"$-expr": "it.id"}, "tags": ["sankey-node"], "name": {"$-expr": "it.name"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "toJSON(it.getArgs())"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}}, {"$-foreach": {"source": "nodeLabels", "it": "it"}, "id": {"$-expr": "it.id"}, "name": {"$-expr": "it.name"}, "shape": {"$-expr": "it.shape"}, "shapeProps": {"$-expr": "toJSON(it.shapeProps)"}, "args": {"$-expr": "toJSON(it.getArgs())"}, "locked": {"$-expr": "it.locked"}, "textSlots": {"$-expr": "toJSON(it.textSlots)"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}, "childItems": [{"$-foreach": {"source": "it.childItems", "it": "child"}, "id": {"$-expr": "child.id"}, "name": {"$-expr": "child.name"}, "shape": {"$-expr": "child.shape"}, "shapeProps": {"$-expr": "toJSON(child.shapeProps)"}, "args": {"$-expr": "toJSON(child.getArgs())"}, "locked": {"$-expr": "child.locked"}, "textSlots": {"$-expr": "toJSON(child.textSlots)"}, "area": {"x": {"$-expr": "child.x"}, "y": {"$-expr": "child.y"}, "w": {"$-expr": "child.w"}, "h": {"$-expr": "child.h"}}}]}]}, "init": "\nstruct Item {\n id: uid()\n name: ''\n shape: 'rect'\n x: 0\n y: 0\n w: 100\n h: 50\n shapeProps: Map()\n childItems: List()\n args: Map()\n locked: true\n textSlots: Map()\n description: \"\"\n\n\n traverse(callback) {\n this.childItems.forEach((childItem) => {\n childItem.traverse(callback)\n })\n callback(this)\n }\n\n getArgs() {\n toJSON(this.args)\n }\n\n setText(slotName, text) {\n if (!this.textSlots.has(slotName)) {\n this.textSlots.set(slotName, Map('text', text))\n } else {\n this.textSlots.get(slotName).set('text', text)\n }\n }\n\n toJSON() {\n childItems = this.childItems.map((childItem) => { childItem.toJSON() })\n result = toJSON(Map(\n 'id', this.id,\n 'childItems', childItems,\n 'name', this.name,\n 'description', this.description,\n 'shape', this.shape,\n 'area', Map('x', this.x, 'y', this.y, 'w', this.w, 'h', this.h, 'r', 0, 'sx', 1, 'sy', 1, 'px', 0.5, 'py', 0.5),\n 'shapeProps', this.shapeProps,\n 'args', this.args,\n 'locked', this.locked,\n 'textSlots', this.textSlots,\n ))\n\n result\n }\n}\n\n\n\nstruct Control {\n name: \"\"\n data: Map()\n click: \"log('control clicked')\"\n x: 0\n y: 0\n width: 20\n height: 20\n text: \"+\"\n placement: \"TL\"\n selectedItemId: \"\"\n type: 'button'\n options: List()\n optionsProvider: null\n}\n\nlocal gapRatio = nodeSpacing / 100\nlocal labelFontSize = max(1, round(fontSize * (100 - magnify) / 100))\nlocal valueFontSize = max(1, round(fontSize * (100 + magnify) / 100))\n\nlocal nodesById = Map()\n\nlocal colorThemes = Map(\n 'default', List('#F16161', '#F1A261', '#F1EB61', '#71EB57', '#57EBB1', '#57C2EB', '#576BEB', '#A557EB', '#EB57C8', '#EB578E'),\n 'light', List('#FD9999', '#FDCA99', '#F9FD99', '#C2FD99', '#99FDA6', '#99FDE2', '#99EAFD', '#99BEFD', '#AE99FD', '#FD99F6'),\n 'dark', List('#921515', '#924E15', '#899215', '#4E9215', '#15922B', '#15926B', '#157F92', '#153F92', '#491592', '#921575'),\n 'air-force-blue', List('#5d8aa8'),\n 'gray', List('#6B6B64'),\n)\n\nstruct Node {\n id: uid()\n name: 'Unnamed'\n value: 0\n inValue: 0\n outValue: 0\n color: '#FD9999'\n x: 0\n y: 0\n level: 0\n sortOrder: 0 // Position inside of its level\n srcNodes: List()\n dstNodes: List()\n width: 0\n height: 0\n position: 0\n offset: 0\n unitSize: 1\n reservedIn: 0\n reservedOut: 0\n}\n\nstruct Connection {\n id: uid()\n srcId: ''\n dstId: ''\n value: 0\n srcNode: null\n dstNode: null\n item: null\n}\n\nstruct CodeLine {\n text: ''\n connection: null\n}\n\nstruct Level {\n idx: 0\n nodes: List()\n nodesMap: Map()\n totalValue: 0\n height: 0\n}\n\nstruct PathPoint {\n t: 'B'\n x: 0\n y: 0\n x1: 0\n y1: 0\n x2: 0\n y2: 0\n}\n\n\nfunc encodeNodes(nodes) {\n local result = ''\n\n nodes.forEach(n => {\n if (result != '') {\n result += '|'\n }\n result += `${n.id};x=${n.x};y=${n.y}`\n })\n result\n}\n\nfunc decodeNodesData(text) {\n local nodesById = Map()\n splitString(text, '|').forEach(singleNodeText => {\n local node = Node()\n local parts = splitString(singleNodeText, ';')\n for (local i = 0; i < parts.size; i++) {\n if (i == 0) {\n node.id = parts.get(0)\n } else {\n local varValue = splitString(parts.get(i), '=')\n if (varValue.size == 2) {\n local name = varValue.get(0)\n local value = varValue.get(1)\n if (name == 'x') {\n node.x = value\n } else if (name == 'y') {\n node.y = value\n }\n }\n }\n }\n nodesById.set(node.id, node)\n })\n nodesById\n}\n\n\nfunc parseConnection(line) {\n local s1 = line.indexOf('[')\n local s2 = line.indexOf(']')\n\n if (s1 > 0 && s2 > s1) {\n local nodeName1 = line.substring(0, s1).trim()\n local nodeName2 = line.substring(s2+1).trim()\n local valueText = line.substring(s1+1, s2)\n\n if (nodeName1 != '' && nodeName2 != '') {\n local value = parseFloat(valueText)\n local id = nodeName1 + '[]' + nodeName2\n Connection(id, nodeName1, nodeName2, value)\n } else {\n null\n }\n } else {\n null\n }\n}\n\nfunc parseConnections(text, nodesData) {\n local getOrCreateNode = (id) => {\n local node = nodesById.get(id)\n if (!node) {\n node = Node(id, id)\n nodesById.set(id, node)\n }\n local nData = nodesData.get(id)\n if (nData) {\n node.x = nData.x\n node.y = nData.y\n }\n node\n }\n\n local lines = List()\n splitString(text, '\\n').forEach(rawLine => {\n local line = rawLine.trim()\n local c = null\n if (line != '' && !line.startsWith('//')) {\n c = parseConnection(line)\n }\n if (c) {\n c.srcNode = getOrCreateNode(c.srcId)\n c.dstNode = getOrCreateNode(c.dstId)\n lines.add(CodeLine(rawLine, c))\n } else {\n lines.add(CodeLine(rawLine, null))\n }\n })\n lines\n}\n\nfunc extractNodesFromConnections(connections) {\n local nodeIds = Set()\n local list = List()\n\n connections.forEach(c => {\n if (!nodeIds.has(c.srcNode.id)) {\n nodeIds.add(c.srcNode.id)\n list.add(c.srcNode)\n }\n if (!nodeIds.has(c.dstNode.id)) {\n nodeIds.add(c.dstNode.id)\n list.add(c.dstNode)\n }\n })\n\n list\n}\n\n\n// Performs a recursive tree iteration and updates the levels in nodes\n// maxVisitCount is used in order to prevent from infinite loop in case there is a cyclic dependency\nfunc updateLevels(node, maxVisitCount) {\n if (maxVisitCount >= 0) {\n node.dstNodes.forEach(dstNode => {\n local newLevel = node.level + 1\n if (dstNode.level < newLevel) {\n dstNode.level = newLevel\n updateLevels(dstNode, maxVisitCount - 1)\n }\n })\n }\n}\n\nfunc readjustStarterNodeLevels(nodesMap) {\n // readjusting node levels for starter nodes\n nodesMap.forEach(node => {\n if (node.srcNodes.size == 0 && node.dstNodes.size > 0) {\n local minDstLevel = node.dstNodes.get(0).level\n node.dstNodes.forEach(dstNode => {\n if (minDstLevel > dstNode.level) {\n minDstLevel = dstNode.level\n }\n })\n if (minDstLevel - node.level > 1) {\n node.level = minDstLevel - 1\n }\n }\n })\n}\n\nfunc buildLevels(allNodes, allConnections) {\n local nodesMap = Map()\n allNodes.forEach(node => {\n nodesMap.set(node.id, node)\n })\n\n allConnections.forEach(c => {\n if (c.srcId != c.dstId) {\n local srcNode = nodesMap.get(c.srcId)\n if (!srcNode) {\n srcNode = Node(c.srcId)\n nodesMap.set(c.srcId, srcNode)\n }\n local dstNode = nodesMap.get(c.dstId)\n if (!dstNode) {\n dstNode = Node(c.dstId)\n nodesMap.set(c.dstId, dstNode)\n }\n\n local value = abs(c.value)\n srcNode.outValue += value\n dstNode.inValue += value\n srcNode.dstNodes.add(dstNode)\n dstNode.srcNodes.add(srcNode)\n }\n })\n\n nodesMap.forEach(node => {\n if (node.srcNodes.size == 0) {\n node.level = 0\n updateLevels(node, nodesMap.size)\n }\n })\n\n readjustStarterNodeLevels(nodesMap)\n\n local levels = Map()\n local maxLevel = 0\n nodesMap.forEach(node => {\n node.value = max(node.inValue, node.outValue)\n local level = levels.get(node.level)\n if (level) {\n level.nodes.add(node)\n } else {\n levels.set(node.level, Level(node.level, List(node), nodesMap))\n }\n if (maxLevel < node.level) {\n maxLevel = node.level\n }\n })\n\n local allLevels = List()\n for (local i = 0; i <= maxLevel; i++) {\n local level = levels.get(i)\n if (level) {\n level.nodes.sort((a, b) => {\n b.value - a.value\n })\n level.nodes.forEach((n, idx) => {\n n.sortOrder = idx\n })\n allLevels.add(level)\n level.totalValue = 0\n level.nodes.forEach(node => {\n level.totalValue += node.value\n })\n }\n }\n\n allLevels\n}\n\n\nfunc buildNodeItems(levels) {\n local colorPalette = colorThemes.get(colorTheme)\n if (!colorPalette) {\n colorPalette = colorThemes.get('default')\n }\n\n local maxLevelValue = 0\n local maxNodesPerLevel = 0\n levels.forEach(level => {\n if (maxLevelValue < level.totalValue) {\n maxLevelValue = level.totalValue\n }\n if (maxNodesPerLevel < level.nodes.size) {\n maxNodesPerLevel = level.nodes.size\n }\n })\n\n local nodeItems = List()\n\n if (maxLevelValue > 0 && maxNodesPerLevel > 0) {\n local unitSize = height * (1 - gapRatio) / maxLevelValue\n local singleGap = height * gapRatio / maxNodesPerLevel\n\n levels.forEach(level => {\n local levelPosition = level.idx * max(1, width - nodeWidth) / (levels.size - 1)\n local levelSize = level.totalValue * unitSize + singleGap * (level.nodes.size - 1)\n local levelOffset = height / 2 - levelSize / 2\n\n local currentY = levelOffset\n level.nodes.forEach(node => {\n node.unitSize = unitSize\n node.height = unitSize * node.value\n node.width = nodeWidth\n node.position = levelPosition\n node.offset = currentY\n local hashCode = Strings.hashCode(node.name)\n node.color = colorPalette.get(abs(hashCode) % colorPalette.size)\n\n\n local nodeItem = Item('n-' + node.id, node.name, 'rect')\n nodeItem.w = node.width\n nodeItem.h = node.height\n nodeItem.x = node.position + node.x * width\n nodeItem.y = node.offset + node.y * height\n nodeItem.shapeProps.set('strokeSize', nodeStrokeSize)\n nodeItem.shapeProps.set('strokeColor', nodeStrokeColor)\n nodeItem.shapeProps.set('cornerRadius', nodeCornerRadius)\n nodeItem.shapeProps.set('fill', Fill.solid(node.color))\n nodeItem.args.set('tplArea', 'movable')\n nodeItem.args.set('tplConnector', 'off')\n nodeItem.args.set('tplRotation', 'off')\n nodeItem.locked = false\n nodeItems.add(nodeItem)\n\n currentY += nodeItem.h + singleGap\n })\n })\n }\n nodeItems\n}\n\nfunc buildConnectorItems(levels, allConnections, allNodes) {\n local k2 = max(allConnections.size, allNodes.size)\n local k1 = k2 ^ 2\n local connectorItems = List()\n local connectionsBySource = Map()\n allConnections.forEach(c => {\n if (c.srcId != c.dstId) {\n local cs = connectionsBySource.get(c.srcId)\n if (cs) {\n cs.add(c)\n } else {\n connectionsBySource.set(c.srcId, List(c))\n }\n }\n })\n\n local cs = List()\n\n levels.forEach(level => {\n level.nodes.forEach(node => {\n local connections = connectionsBySource.get(node.id)\n if (connections) {\n connections.sort((a, b) => {\n a.dstNode.offset + a.dstNode.y * height - (b.dstNode.offset + b.dstNode.y * height)\n })\n connections.forEach(c => {\n local dstNode = level.nodesMap.get(c.dstId)\n if (dstNode) {\n cs.add(c)\n }\n })\n }\n })\n })\n\n cs.sort((a, b) => {\n a.srcNode.offset + a.srcNode.y * height - (b.srcNode.offset + b.srcNode.y * height)\n })\n\n cs.forEach(c => {\n connectorItems.add(buildSingleConnectorItem(c, c.srcNode, c.dstNode))\n })\n\n connectorItems\n}\n\n\nfunc buildSingleConnectorItem(connector, srcNode, dstNode) {\n local item = Item(connector.id, `${srcNode.id} -> ${dstNode.id}`, 'path')\n local connectorSize = srcNode.unitSize * connector.value\n\n local xs = srcNode.position + srcNode.x * width + srcNode.width\n local ys1 = srcNode.offset + srcNode.y * height + srcNode.reservedOut\n local ys2 = ys1 + connectorSize\n srcNode.reservedOut += connectorSize\n\n local xd = dstNode.position + dstNode.x * width\n local yd1 = dstNode.offset + dstNode.y * height + dstNode.reservedIn\n local yd2 = yd1 + connectorSize\n dstNode.reservedIn += connectorSize\n\n local minX = min(xs, xd)\n local maxX = max(xs, xd)\n local minY = min(ys1, ys2, yd1, yd2)\n local maxY = max(ys1, ys2, yd1, yd2)\n\n local dx = max(0.001, maxX - minX)\n local dy = max(0.001, maxY - minY)\n\n local t = curviness / 100\n\n local points = List(\n PathPoint('B', xs, ys1, 0, t * (ys2 - ys1) / 2, t * (xd - xs) / 2, 0),\n PathPoint('B', xd, yd1, t * (xs - xd) / 2, 0, 0, t * (yd2 - yd1) / 2),\n PathPoint('B', xd, yd2, 0, t * (yd1 - yd2) / 2, t * (xs - xd) / 2, 0),\n PathPoint('B', xs, ys2, t * (xd - xs) / 2, 0, 0, t * (ys1 - ys2) / 2),\n ).map(p => {\n PathPoint('B',\n 100 * (p.x - minX) / dx, 100 * (p.y - minY) / dy,\n 100 * p.x1 / dx, 100 * p.y1 / dy,\n 100 * p.x2 / dx, 100 * p.y2 / dy\n )\n })\n\n item.x = minX\n item.y = minY\n item.w = dx\n item.h = dy\n\n if (conColorType == 'gradient') {\n item.shapeProps.set('fill', Fill.linearGradient(90, 0, srcNode.color, 100, dstNode.color))\n } else if (conColorType == 'source') {\n item.shapeProps.set('fill', Fill.solid(srcNode.color))\n } else if (conColorType == 'destination') {\n item.shapeProps.set('fill', Fill.solid(dstNode.color))\n } else {\n item.shapeProps.set('fill', conColor)\n }\n item.shapeProps.set('strokeSize', 0)\n item.shapeProps.set('strokeColor', conHoverStroke)\n item.shapeProps.set('paths', List(Map(\n 'id', 'p-' + connector.id,\n 'closed', true,\n 'pos', 'relative',\n 'points', points\n )))\n\n if (conLabel) {\n item.childItems.add(buildConnectorLabel(connector, item.w, item.h))\n }\n connector.item = item\n item\n}\n\nfunc buildConnectorLabel(c, connectorWidth, connectorHeight) {\n local valueText = formatValue(c.value)\n local valueTextSize = calculateTextSize(valueText, font, fontSize)\n local valueLabel = buildLabel('cl-' + c.id, valueText, font, fontSize, 'center', 'middle')\n valueLabel.w = valueTextSize.w + 4 + 2 * labelPadding\n valueLabel.h = valueTextSize.h * 1.8 + 2 * labelPadding\n valueLabel.x = connectorWidth/2 - valueLabel.w/2 - labelPadding\n valueLabel.y = connectorHeight/2 - valueLabel.h/2 - labelPadding\n valueLabel.name = 'connector-label-' + c.id\n valueLabel.shapeProps.set('strokeColor', labelStroke)\n valueLabel.args.set('tplText', Map('body', '' + c.value))\n if (showLabelFill) {\n valueLabel.shapeProps.set('fill', labelFill)\n valueLabel.shapeProps.set('strokeColor', labelStroke)\n valueLabel.shapeProps.set('strokeSize', labelStrokeSize)\n valueLabel.shapeProps.set('cornerRadius', labelCornerRadius)\n } else {\n valueLabel.shapeProps.set('fill', Fill.none())\n valueLabel.shapeProps.set('strokeSize', 0)\n valueLabel.shapeProps.set('cornerRadius', 0)\n }\n valueLabel\n}\n\nfunc buildLabel(id, text, font, fontSize, halign, valign) {\n local item = Item(id, text, 'none')\n item.args.set('templateForceText', true)\n item.textSlots.set('body', Map(\n 'text', text,\n 'font', font,\n 'color', labelColor,\n 'fontSize', fontSize,\n 'halign', halign,\n 'valign', valign,\n 'paddingLeft', 0,\n 'paddingRight', 0,\n 'paddingTop', 0,\n 'paddingBottom', 0,\n 'whiteSpace', 'nowrap'\n ))\n item\n}\n\nlocal valueFormaters = Map(\n '1000000.00', (value) => {\n numberToLocaleString(value, 'en-US').replaceAll(',', '')\n },\n '1000000,00', (value) => {\n numberToLocaleString(value, 'de-DE').replaceAll('.', '')\n },\n '1,000,000.00', (value) => {\n numberToLocaleString(value, 'en-US')\n },\n '1.000.000,00', (value) => {\n numberToLocaleString(value, 'de-DE')\n },\n '1 000 000.00', (value) => {\n numberToLocaleString(value, 'fi-FI').replaceAll(',', '.')\n },\n '1 000 000,00', (value) => {\n numberToLocaleString(value, 'fi-FI')\n },\n)\n\nfunc formatValue(value) {\n local valueText = value\n if (valueFormaters.has(numberFormat)) {\n valueText = valueFormaters.get(numberFormat)(value)\n }\n valuePrefix + valueText + valueSuffix\n}\n\nfunc buildNodeLabels(nodes) {\n local labelItems = List()\n nodes.forEach(node => {\n local lines = splitString(node.name, '\\\\n')\n local textWidth = 0\n local textHeight = 0\n local nodeText = ''\n lines.forEach(line => {\n local textSize = calculateTextSize(line, font, labelFontSize)\n textWidth = max(textWidth, textSize.w)\n textHeight += textSize.h\n nodeText += `

${line}

`\n })\n\n\n local valueText = formatValue(node.value)\n local valueTextSize = calculateTextSize(valueText, font, valueFontSize)\n local totalHeight = if (showNodeValues) { (textHeight + valueTextSize.h)*1.8 + 8 } else { textHeight*1.8 + 8 }\n local isLeft = node.dstNodes.size == 0\n local halign = 'right'\n if (!isLeft) {\n halign = 'left'\n }\n\n local label = buildLabel('ln-' + node.id, nodeText, font, labelFontSize, halign, 'middle')\n label.w = textWidth + 4\n label.h = textHeight * 1.8\n\n local valueLabel = null\n if (showNodeValues) {\n valueLabel = buildLabel('lv-' + node.id, valueText, font, valueFontSize, halign, 'middle')\n valueLabel.w = valueTextSize.w + 4\n valueLabel.h = valueTextSize.h * 1.8\n }\n\n if (showLabelFill) {\n local rect = Item('lc-'+node.id, 'Label ' + node.id, 'rect')\n if (valueLabel) {\n rect.w = max(label.w, valueLabel.w) + 2 * labelPadding\n rect.h = label.h + valueLabel.h + 2 * labelPadding\n } else {\n rect.w = label.w + 2 * labelPadding\n rect.h = label.h + 2 * labelPadding\n }\n if (isLeft) {\n rect.x = node.position + node.x * width - rect.w - labelPadding\n } else {\n rect.x = node.position + node.width + labelPadding + node.x * width\n }\n rect.shapeProps.set('fill', labelFill)\n rect.shapeProps.set('strokeColor', labelStroke)\n rect.shapeProps.set('strokeSize', labelStrokeSize)\n rect.shapeProps.set('cornerRadius', labelCornerRadius)\n rect.y = node.offset + node.y * height + node.height / 2 - totalHeight / 2\n label.x = labelPadding\n label.y = labelPadding\n rect.childItems.add(label)\n\n if (valueLabel) {\n valueLabel.x = labelPadding\n valueLabel.y = label.y + label.h\n rect.childItems.add(valueLabel)\n }\n labelItems.add(rect)\n } else {\n if (isLeft) {\n label.x = node.position - label.w - labelPadding\n if (valueLabel) {\n valueLabel.x = node.position - valueLabel.w - labelPadding\n }\n } else {\n label.x = node.position + node.width + labelPadding\n if (valueLabel) {\n valueLabel.x = node.position + node.width + labelPadding\n }\n }\n label.x += node.x * width\n label.y = node.offset + node.y * height + node.height / 2 - totalHeight / 2\n labelItems.add(label)\n\n if (valueLabel) {\n valueLabel.x += node.x * width\n valueLabel.y = label.y + label.h\n labelItems.add(valueLabel)\n }\n }\n })\n labelItems\n}\n\n\n\nfunc onAreaUpdate(itemId, item, area) {\n local node = null\n if (itemId.startsWith('n-')) {\n node = nodesById.get(itemId.substring(2))\n }\n if (node) {\n node.x = (area.x - node.position) / max(1, width)\n node.y = (area.y - node.offset) / max(1, height)\n\n if (abs(node.x*width) < 7) {\n node.x = 0\n }\n\n nodesData = encodeNodes(nodesById)\n }\n}\n\n\nfunc onConnectionValueInput(connectionId, value) {\n allConnections.forEach(c => {\n if (c.id == connectionId) {\n c.value = value\n }\n })\n diagramCode = encodeDiagram()\n}\n\n\nfunc encodeDiagram() {\n local code = ''\n codeLines.forEach((line, idx) => {\n if (idx > 0) {\n code += '\\n'\n }\n if (line.connection) {\n code += line.connection.srcId + ' [' + line.connection.value + '] ' + line.connection.dstId\n } else {\n code += line.text\n }\n })\n\n code\n}\n\n\nfunc onTextUpdate(itemId, item, text) {\n if (itemId.startsWith('ln-')) {\n text = stripHTML(text.replaceAll('

', '

\\n')).trim().replaceAll('\\n', '\\\\n')\n local oldNodeId = itemId.substring(3)\n local newNodeId = text\n\n local foundClashingId = false\n\n for (local i = 0; i < allConnections.size && !foundClashingId; i++) {\n local c = allConnections.get(i)\n if (c.srcId == newNodeId && c.dstId == newNodeId) {\n foundClashingId = true\n }\n }\n\n if (!foundClashingId) {\n allConnections.forEach(c => {\n if (c.srcId == oldNodeId) {\n c.srcId = newNodeId\n c.srcNode.id = newNodeId\n }\n if (c.dstId == oldNodeId) {\n c.dstId = newNodeId\n c.dstNode.id = newNodeId\n }\n })\n }\n\n diagramCode = encodeDiagram()\n } else if (itemId.startsWith('cl-')) {\n text = stripHTML(text).replaceAll('\\n', '').trim()\n local value = parseFloat(text)\n local connectionId = itemId.substring(3)\n local line = codeLines.find(line => { line.connection && line.connection.id == connectionId})\n if (line) {\n line.connection.value = value\n diagramCode = encodeDiagram()\n }\n }\n}\n\n\nfunc onDeleteItem(itemId, item) {\n local nodeId = null\n local connectionId = null\n\n if (itemId.startsWith('n-')) {\n nodeId = itemId.substring(2)\n } else if (itemId.startsWith('c-')) {\n connectionId = itemId.substring(2)\n }\n\n if (nodeId) {\n local node = allNodes.find(node => {node.id == nodeId})\n if (node && node.level == levels.size - 1) {\n if (levels.size > 2 && levels.get(node.level).nodes.size == 1) {\n width -= max(1, width - nodeWidth) / max(1, (levels.size - 1))\n }\n }\n }\n\n if (connectionId || nodeId) {\n for (local i = codeLines.size - 1; i >= 0; i--) {\n local line = codeLines.get(i)\n if (line.connection) {\n if (connectionId && line.connection.id == connectionId) {\n codeLines.remove(i)\n } else if (nodeId && (line.connection.srcId == nodeId || line.connection.dstId == nodeId)) {\n codeLines.remove(i)\n }\n }\n }\n if (codeLines.filter(line => {line.connection}).size > 0) {\n diagramCode = encodeDiagram()\n }\n }\n}\n\n\nfunc provideOptionsForDstNode(nodeId) {\n local options = List('-- Create new node --')\n\n local node = allNodes.find(n => {n.id == nodeId})\n if (node) {\n allNodes.forEach(anotherNode => {\n if (anotherNode.level > node.level) {\n options.add(anotherNode.id)\n }\n })\n }\n options\n}\n\nfunc provideOptionsForSrcNode(nodeId) {\n local options = List('-- Create new node --')\n\n local node = allNodes.find(n => {n.id == nodeId})\n if (node) {\n allNodes.forEach(anotherNode => {\n if (anotherNode.level < node.level) {\n options.add(anotherNode.id)\n }\n })\n }\n options\n}\n\nfunc generateNodeControls(nodes) {\n local controls = List()\n nodes.forEach(node => {\n if (node.level == levels.size - 1) {\n controls.add(Control(\n \"+\",\n Map(\n 'nodeId', node.id\n ),\n \"addDstNodeForNode(control.data.nodeId)\",\n node.position + node.x * width + node.width + 20,\n node.offset + node.y * height + node.height / 2 - 10,\n 20, 20,\n '+',\n 'TL',\n 'n-' + node.id,\n 'button'\n ))\n } else {\n local control = Control(\n \"+\",\n Map(\n 'nodeId', node.id\n ),\n \"addDstConnection(control.data.nodeId, option)\",\n node.position + node.x * width + node.width + 20,\n node.offset + node.y * height + node.height / 2 - 10,\n 20, 20,\n '+',\n 'TL',\n 'n-' + node.id,\n 'choice'\n )\n\n control.optionsProvider = `provideOptionsForDstNode(control.data.nodeId)`\n controls.add(control)\n }\n\n if (node.level > 0) {\n local control = Control(\n \"+\",\n Map(\n 'nodeId', node.id\n ),\n \"addSrcConnection(control.data.nodeId, option)\",\n node.position + node.x * width - 20,\n node.offset + node.y * height + node.height / 2 - 10,\n 20, 20,\n '+',\n 'TR',\n 'n-' + node.id,\n 'choice'\n )\n\n control.optionsProvider = `provideOptionsForSrcNode(control.data.nodeId)`\n controls.add(control)\n }\n })\n controls\n}\n\nfunc addDstConnection(nodeId, option) {\n if (option == '-- Create new node --') {\n addDstNodeForNode(nodeId)\n } else {\n local srcNode = allNodes.find(node => {node.id == nodeId})\n local dstNode = allNodes.find(node => {node.id == option})\n if (srcNode && dstNode) {\n local value = srcNode.value\n if (srcNode.value - srcNode.outValue > 0) {\n value = srcNode.value - srcNode.outValue\n }\n codeLines.add(CodeLine(`${srcNode.id} [${value}] ${dstNode.id}`, null))\n diagramCode = encodeDiagram()\n }\n }\n}\n\nfunc addSrcConnection(nodeId, option) {\n if (option == '-- Create new node --') {\n addSrcNodeForNode(nodeId)\n } else {\n local srcNode = allNodes.find(node => {node.id == option})\n local dstNode = allNodes.find(node => {node.id == nodeId})\n if (srcNode && dstNode) {\n local value = srcNode.value\n if (srcNode.value - srcNode.outValue > 0) {\n value = srcNode.value - srcNode.outValue\n }\n codeLines.add(CodeLine(`${srcNode.id} [${value}] ${dstNode.id}`, null))\n diagramCode = encodeDiagram()\n }\n }\n}\n\n\n\nfunc addDstNodeForNode(nodeId) {\n addNewNodeForNode(nodeId, true)\n}\n\nfunc addSrcNodeForNode(nodeId) {\n addNewNodeForNode(nodeId, false)\n}\n\nfunc addNewNodeForNode(nodeId, isDst) {\n local allNodeIds = Set()\n local selectedNode = null\n\n allNodes.forEach(node => {\n allNodeIds.add(node.id)\n if (node.id == nodeId) {\n selectedNode = node\n }\n })\n if (selectedNode) {\n local idx = 2\n local foundUniqueId = false\n local newId = null\n while(!foundUniqueId && idx < 1000) {\n newId = selectedNode.id + ' ' + idx\n if (!allNodeIds.has(newId)) {\n foundUniqueId = true\n }\n idx += 1\n }\n if (foundUniqueId) {\n if (isDst) {\n codeLines.add(CodeLine(`${selectedNode.id} [${selectedNode.value}] ${newId}`, null))\n } else {\n codeLines.add(CodeLine(`${newId} [${selectedNode.value}] ${selectedNode.id}`, null))\n }\n diagramCode = encodeDiagram()\n if (selectedNode.level == levels.size - 1) {\n width += max(1, width - nodeWidth) / max(1, (levels.size - 1))\n }\n }\n }\n}\n\n\nlocal nodesDataById = decodeNodesData(nodesData)\n\nlocal codeLines = parseConnections(diagramCode, nodesDataById)\nlocal allConnections = codeLines.filter(cl => { cl.connection != null }).map(cl => { cl.connection })\nlocal allNodes = extractNodesFromConnections(allConnections)\n\nlocal levels = buildLevels(allNodes, allConnections)\n\nlocal nodeItems = buildNodeItems(levels)\nlocal connectorItems = buildConnectorItems(levels, allConnections, allNodes)\nlocal nodeLabels = buildNodeLabels(allNodes)\n\nlocal nodeControls = generateNodeControls(allNodes)\n\n"} \ No newline at end of file diff --git a/assets/templates/diagrams/src/control.sch b/assets/templates/diagrams/src/control.sch index 0b7a026e7..f33a28037 100644 --- a/assets/templates/diagrams/src/control.sch +++ b/assets/templates/diagrams/src/control.sch @@ -11,4 +11,5 @@ struct Control { selectedItemId: "" type: 'button' options: List() + optionsProvider: null } \ No newline at end of file diff --git a/assets/templates/diagrams/src/sankey.sch b/assets/templates/diagrams/src/sankey.sch index 4611a5650..b12040374 100644 --- a/assets/templates/diagrams/src/sankey.sch +++ b/assets/templates/diagrams/src/sankey.sch @@ -632,6 +632,10 @@ func onAreaUpdate(itemId, item, area) { node.x = (area.x - node.position) / max(1, width) node.y = (area.y - node.offset) / max(1, height) + if (abs(node.x*width) < 7) { + node.x = 0 + } + nodesData = encodeNodes(nodesById) } } @@ -743,10 +747,38 @@ func onDeleteItem(itemId, item) { } +func provideOptionsForDstNode(nodeId) { + local options = List('-- Create new node --') + + local node = allNodes.find(n => {n.id == nodeId}) + if (node) { + allNodes.forEach(anotherNode => { + if (anotherNode.level > node.level) { + options.add(anotherNode.id) + } + }) + } + options +} + +func provideOptionsForSrcNode(nodeId) { + local options = List('-- Create new node --') + + local node = allNodes.find(n => {n.id == nodeId}) + if (node) { + allNodes.forEach(anotherNode => { + if (anotherNode.level < node.level) { + options.add(anotherNode.id) + } + }) + } + options +} + func generateNodeControls(nodes) { local controls = List() nodes.forEach(node => { - if (node.srcNodes.size > 0 && node.dstNodes.size == 0) { + if (node.level == levels.size - 1) { controls.add(Control( "+", Map( @@ -761,13 +793,94 @@ func generateNodeControls(nodes) { 'n-' + node.id, 'button' )) + } else { + local control = Control( + "+", + Map( + 'nodeId', node.id + ), + "addDstConnection(control.data.nodeId, option)", + node.position + node.x * width + node.width + 20, + node.offset + node.y * height + node.height / 2 - 10, + 20, 20, + '+', + 'TL', + 'n-' + node.id, + 'choice' + ) + + control.optionsProvider = `provideOptionsForDstNode(control.data.nodeId)` + controls.add(control) + } + + if (node.level > 0) { + local control = Control( + "+", + Map( + 'nodeId', node.id + ), + "addSrcConnection(control.data.nodeId, option)", + node.position + node.x * width - 20, + node.offset + node.y * height + node.height / 2 - 10, + 20, 20, + '+', + 'TR', + 'n-' + node.id, + 'choice' + ) + + control.optionsProvider = `provideOptionsForSrcNode(control.data.nodeId)` + controls.add(control) } }) controls } +func addDstConnection(nodeId, option) { + if (option == '-- Create new node --') { + addDstNodeForNode(nodeId) + } else { + local srcNode = allNodes.find(node => {node.id == nodeId}) + local dstNode = allNodes.find(node => {node.id == option}) + if (srcNode && dstNode) { + local value = srcNode.value + if (srcNode.value - srcNode.outValue > 0) { + value = srcNode.value - srcNode.outValue + } + codeLines.add(CodeLine(`${srcNode.id} [${value}] ${dstNode.id}`, null)) + diagramCode = encodeDiagram() + } + } +} + +func addSrcConnection(nodeId, option) { + if (option == '-- Create new node --') { + addSrcNodeForNode(nodeId) + } else { + local srcNode = allNodes.find(node => {node.id == option}) + local dstNode = allNodes.find(node => {node.id == nodeId}) + if (srcNode && dstNode) { + local value = srcNode.value + if (srcNode.value - srcNode.outValue > 0) { + value = srcNode.value - srcNode.outValue + } + codeLines.add(CodeLine(`${srcNode.id} [${value}] ${dstNode.id}`, null)) + diagramCode = encodeDiagram() + } + } +} + + func addDstNodeForNode(nodeId) { + addNewNodeForNode(nodeId, true) +} + +func addSrcNodeForNode(nodeId) { + addNewNodeForNode(nodeId, false) +} + +func addNewNodeForNode(nodeId, isDst) { local allNodeIds = Set() local selectedNode = null @@ -786,9 +899,14 @@ func addDstNodeForNode(nodeId) { if (!allNodeIds.has(newId)) { foundUniqueId = true } + idx += 1 } if (foundUniqueId) { - codeLines.add(CodeLine(`${selectedNode.id} [${selectedNode.value}] ${newId}`, null)) + if (isDst) { + codeLines.add(CodeLine(`${selectedNode.id} [${selectedNode.value}] ${newId}`, null)) + } else { + codeLines.add(CodeLine(`${newId} [${selectedNode.value}] ${selectedNode.id}`, null)) + } diagramCode = encodeDiagram() if (selectedNode.level == levels.size - 1) { width += max(1, width - nodeWidth) / max(1, (levels.size - 1)) diff --git a/src/ui/components/SchemeEditor.vue b/src/ui/components/SchemeEditor.vue index c3e171cd1..4777cdefa 100644 --- a/src/ui/components/SchemeEditor.vue +++ b/src/ui/components/SchemeEditor.vue @@ -130,6 +130,7 @@ :zoom="schemeContainer.screenTransform.scale" :boundaryBoxColor="schemeContainer.scheme.style.boundaryBoxColor" :controlPointsColor="schemeContainer.scheme.style.controlPointsColor" + @choice-control-clicked="onEditBoxChoiceControlClicked" @custom-control-clicked="onEditBoxCustomControlClicked" @template-rebuild-requested="onEditBoxTemplateRebuildRequested" @template-properties-updated-requested="onEditBoxTemplatePropertiesUpdateRequested" @@ -618,7 +619,7 @@ import shortid from 'shortid'; import utils from '../utils.js'; import {dragAndDropBuilder} from '../dragndrop.js'; import myMath from '../myMath'; -import { Keys, registerKeyPressHandler, deregisterKeyPressHandler } from '../events'; +import { Keys, registerKeyPressHandler, deregisterKeyPressHandler, mouseCoordsFromEvent } from '../events'; import DrawingControlsPanel from './DrawingControlsPanel.vue'; import {applyStyleFromAnotherItem, defaultItem, defaultTextSlotProps } from '../scheme/Item'; @@ -3334,6 +3335,26 @@ export default { this.schemeContainer.updateEditBox(); }, + mouseCoordsFromEvent(event) { + const p = mouseCoordsFromEvent(event); + if (!p) { + return this.mouseCoordsFromPageCoords(0, 0); + } + return this.mouseCoordsFromPageCoords(p.x, p.y); + }, + + onEditBoxChoiceControlClicked({options, editBoxId, event, callback}) { + const p = this.mouseCoordsFromEvent(event); + this.$emit('context-menu-requested', p.x, p.y, options.map(option => { + return { + name: option, + clicked: () => { + callback(option); + } + } + })); + }, + onWindowResize() { const minExpectedGap = 20; if (this.sidePanelRightWidth + minExpectedGap > window.innerWidth / 2) { diff --git a/src/ui/components/editor/ContextMenu.vue b/src/ui/components/editor/ContextMenu.vue index 8cc1081d6..09e77cfa0 100644 --- a/src/ui/components/editor/ContextMenu.vue +++ b/src/ui/components/editor/ContextMenu.vue @@ -3,22 +3,26 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/. -->