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/. -->
@@ -65,11 +69,44 @@ export default {
menuWidth: 100,
menuHeight: 100,
maxHeight: window.innerHeight - 30,
- mountTime: new Date().getTime()
+ mountTime: new Date().getTime(),
+
+ subOptionMenu: {
+ shown: false,
+ x: 0,
+ y: 0,
+ options: []
+ }
}
},
methods: {
+ onOptionMouseOver(option, event) {
+ if (option.subOptions) {
+ const li = event.target.closest('li.context-menu-option');
+ if (!li) {
+ return;
+ }
+ const liRect = li.getBoundingClientRect();
+ this.subOptionMenu.x = liRect.right;
+ this.subOptionMenu.y = liRect.top;
+ this.subOptionMenu.options = option.subOptions;
+ this.subOptionMenu.shown = true;
+ this.$nextTick(() => {
+ if (!this.$refs.subOptionMenu) {
+ return;
+ }
+ const rect = this.$refs.subOptionMenu.getBoundingClientRect();
+ if (rect.bottom > window.innerHeight) {
+ this.subOptionMenu.y = window.innerHeight - rect.height;
+ }
+ if (rect.right > window.innerWidth) {
+ this.subOptionMenu.x = window.innerWidth - rect.width;
+ }
+ });
+ }
+ },
+
onDocumentClick(event) {
if (!utils.domHasParentNode(event.target, domElement => domElement.classList.contains('context-menu'))) {
if (new Date().getTime() - this.mountTime > 500) {
diff --git a/src/ui/components/editor/EditBox.vue b/src/ui/components/editor/EditBox.vue
index f5c2515ce..ced87f17a 100644
--- a/src/ui/components/editor/EditBox.vue
+++ b/src/ui/components/editor/EditBox.vue
@@ -148,7 +148,7 @@
-
+
@@ -588,7 +588,7 @@ export default {
customControls: [],
templateControls: [],
colorControlToggled: false,
- draggingFileOver: false
+ draggingFileOver: false,
};
},
@@ -776,10 +776,50 @@ export default {
this.$emit('template-properties-updated-requested');
},
- onTemplateControlClick(idx) {
+ expandTemplateControlChoiceOptions(control, event) {
+ const item = this.editBox.templateItemRoot;
+ let options = control.options;
+ if (control.optionsProvider) {
+ options = control.optionsProvider(item);
+ }
+ this.$emit('choice-control-clicked', {
+ options: options,
+ editBoxId: this.editBox.id,
+ event,
+
+ callback: (selectedOption) => {
+ const originArgs = utils.clone(item.args.templateArgs);
+ const updatedArgs = control.click(item, selectedOption);
+ item.area.w = updatedArgs.width;
+ item.area.h = updatedArgs.height;
+
+ const diff = jsonDiff(originArgs, updatedArgs);
+ if (diff.changes.length > 0) {
+ EditorEventBus.schemeChangeCommitted.$emit(this.editorId);
+ }
+
+ if (item.args && item.args.templateArgs) {
+ for (let key in item.args.templateArgs) {
+ if (updatedArgs.hasOwnProperty(key)) {
+ item.args.templateArgs[key] = updatedArgs[key];
+ }
+ }
+ }
+ this.$emit('template-rebuild-requested', this.editBox.templateItemRoot.id, this.template, item.args.templateArgs);
+ this.$emit('template-properties-updated-requested');
+ },
+ });
+ },
+
+ onTemplateControlClick(idx, event) {
+ const control = this.templateControls[idx];
+ if (control.type === 'choice') {
+ this.expandTemplateControlChoiceOptions(control, event);
+ return;
+ }
const item = this.editBox.templateItemRoot;
const originArgs = utils.clone(item.args.templateArgs);
- const updatedArgs = this.templateControls[idx].click(item);
+ const updatedArgs = control.click(item);
item.area.w = updatedArgs.width;
item.area.h = updatedArgs.height;
diff --git a/src/ui/components/editor/SvgEditor.vue b/src/ui/components/editor/SvgEditor.vue
index 734f4abb5..765c2ba6e 100644
--- a/src/ui/components/editor/SvgEditor.vue
+++ b/src/ui/components/editor/SvgEditor.vue
@@ -204,6 +204,7 @@ import { createMainScriptScope } from '../../userevents/functions/ScriptFunction
import { KeyBinder } from './KeyBinder.js';
import { loadAndMountExternalComponent } from './Component.js';
import { collectItemsHighlightsByCondition, collectItemsHighlightsForClickableMarkers, generateItemHighlight } from './ItemHighlight.js';
+import { mouseCoordsFromEvent } from '../../events.js';
const EMPTY_OBJECT = {type: 'void'};
const LINK_FONT_SYMBOL_SIZE = 10;
@@ -572,16 +573,11 @@ export default {
},
mouseCoordsFromEvent(event) {
- if (event.touches) {
- if (event.touches.length > 0) {
- return this.mouseCoordsFromPageCoords(event.touches[0].pageX, event.touches[0].pageY);
- } else if (event.changedTouches.length > 0) {
- return this.mouseCoordsFromPageCoords(event.changedTouches[0].pageX, event.changedTouches[0].pageY);
- }
- } else {
- return this.mouseCoordsFromPageCoords(event.pageX, event.pageY);
+ const p = mouseCoordsFromEvent(event);
+ if (!p) {
+ return null;
}
- return null;
+ return this.mouseCoordsFromPageCoords(p.x, p.y);
},
mouseCoordsFromPageCoords(pageX, pageY) {
diff --git a/src/ui/components/editor/items/ItemTemplate.js b/src/ui/components/editor/items/ItemTemplate.js
index bc90ec248..54b1bb2a2 100644
--- a/src/ui/components/editor/items/ItemTemplate.js
+++ b/src/ui/components/editor/items/ItemTemplate.js
@@ -5,7 +5,7 @@ import shortid from "shortid";
import { forEachObject } from "../../../collections";
import { traverseItems, traverseItemsConditionally } from "../../../scheme/Item";
import { enrichItemWithDefaults } from "../../../scheme/ItemFixer";
-import { compileJSONTemplate, compileTemplateCall, compileTemplateExpressions } from "../../../templater/templater";
+import { compileJSONTemplate, compileTemplateCall, compileTemplateCallbackExpression, compileTemplateExpressions } from "../../../templater/templater";
import { createTemplateFunctions } from "./ItemTemplateFunctions";
import { List } from "../../../templater/list";
import { parseExpression } from "../../../templater/ast";
@@ -308,6 +308,23 @@ export function compileItemTemplate(editorId, template, templateRef) {
value,
});
};
+ } else if (control.type === 'choice') {
+ /**
+ * @param {Item} item - the root template item
+ * @param {String} value - the text from the input textfield, which was changed by user
+ * @returns {Object} updated data object which can be used to update the template args.
+ * Keep in mind that this object contains not only template args,
+ * but everything that was declared in the global scope of the template script
+ */
+ eventCallback = (item, option) => {
+ return eventExecutor({
+ ...createTemplateFunctions(editorId, item),
+ ...args, width, height,
+ context: new TemplateContext(ContextPhases.EVENT, 'control', control.id),
+ control,
+ option,
+ });
+ };
} else {
/**
* @param {Item} item - the root template item
@@ -329,6 +346,23 @@ export function compileItemTemplate(editorId, template, templateRef) {
...control,
};
enrichedControl[inputHandler] = eventCallback;
+
+ if (control.type === 'choice' && control.optionsProvider) {
+ const providerFullScript = [].concat(initBlock).concat(toExpressionBlock(control.optionsProvider)).join('\n');
+ const providerExecutor = compileTemplateCallbackExpression(providerFullScript);
+ enrichedControl.optionsProvider = (item) => {
+ const options = providerExecutor({
+ ...createTemplateFunctions(editorId, item),
+ ...args, width, height,
+ context: new TemplateContext(ContextPhases.EVENT, 'control', control.id),
+ control,
+ });
+ if (options instanceof List) {
+ return options.items;
+ }
+ return [];
+ }
+ }
return enrichedControl;
});
},
diff --git a/src/ui/events.js b/src/ui/events.js
index 0679e6aa6..a8767d193 100644
--- a/src/ui/events.js
+++ b/src/ui/events.js
@@ -115,5 +115,18 @@ export function simulateKeyPress(key, isControlPressed) {
}
}
+export function mouseCoordsFromEvent(event) {
+ if (event.touches) {
+ if (event.touches.length > 0) {
+ return {x: event.touches[0].pageX, y: event.touches[0].pageY};
+ } else if (event.changedTouches.length > 0) {
+ return {x: event.changedTouches[0].pageX, y: event.changedTouches[0].pageY};
+ }
+ } else {
+ return {x: event.pageX, y: event.pageY};
+ }
+ return null;
+}
+
document.addEventListener('keydown', (event) => handleKeyPress(event, true));
document.addEventListener('keyup', (event) => handleKeyPress(event, false));
\ No newline at end of file
diff --git a/src/ui/templater/templater.js b/src/ui/templater/templater.js
index 577f0e443..f3454c20b 100644
--- a/src/ui/templater/templater.js
+++ b/src/ui/templater/templater.js
@@ -57,6 +57,21 @@ export function compileTemplateExpressions(expression, data = {}) {
};
}
+/**
+ * This function is used when user clicks on choice template control
+ * @param {String} expression - a template expression in SchemioScript
+ * @param {Object} data - an object with initial arguments
+ * @returns {function(Object|undefined): Object} - a function that takes extra data object as an argument, that should be added to the scope and, when invoked, will execute the expressions and will return the updated data with arguments
+ */
+export function compileTemplateCallbackExpression(expression, data = {}) {
+ const expressionNode = parseExpression(expression);
+
+ return (extraData) => {
+ const scope = new Scope({...data, ...(extraData || {})});
+ return expressionNode.evalNode(scope);
+ };
+}
+
/**
* This function is used for processing of template editor panels.