From ab5e96e8915f0d758df395c27f73e7c0f7473850 Mon Sep 17 00:00:00 2001 From: Ivan Shubin Date: Thu, 30 Jan 2025 20:54:07 +0100 Subject: [PATCH] introduced arg groups in template properties and improved sankey diagram --- assets/css/main.css | 3 + assets/templates/diagrams/sankey.json | 2 +- assets/templates/diagrams/sankey.yaml | 94 +++++++++++++------ assets/templates/diagrams/src/sankey.sch | 77 +++++++++------ src/ui/components/editor/ArgumentsEditor.vue | 4 +- .../editor/properties/TemplateProperties.vue | 47 +++++++++- 6 files changed, 166 insertions(+), 61 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 07f9cbc3..fc212238 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -2001,6 +2001,9 @@ ul.button-group.disabled > li, ul.button-group > li.disabled { box-shadow: none !important; margin: auto; } +.vc-chrome-saturation-wrap, .vc-chrome-sliders { + user-select: none; +} .color-picker .color-picker-tooltip { position: fixed; z-index: 1000; diff --git a/assets/templates/diagrams/sankey.json b/assets/templates/diagrams/sankey.json index 9753ddb0..637c2a06 100644 --- a/assets/templates/diagrams/sankey.json +++ b/assets/templates/diagrams/sankey.json @@ -1 +1 @@ -{"name": "Sankey diagram", "description": "", "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}, "font": {"type": "font", "value": "Arial", "name": "Font"}, "colorTheme": {"type": "choice", "value": "default", "options": ["default", "light", "dark"], "name": "Color theme"}, "connectorColorType": {"type": "choice", "value": "source", "options": ["source", "destination", "gradient", "custom"], "name": "Connection color type"}, "connectorColor": {"type": "color", "value": "#aaaaaa", "name": "Connection color", "depends": {"connectorColorType": "custom"}}, "fontSize": {"type": "number", "value": 14, "name": "Font size", "min": 1}, "labelColor": {"type": "color", "value": "#222222", "name": "Label color"}, "magnify": {"type": "number", "value": 0, "name": "Magnify value", "min": -50, "max": 50}, "connectorOpacity": {"type": "number", "value": 60, "name": "Connector opacity", "min": 0, "max": 100}, "valuePrefix": {"type": "string", "value": "", "name": "Value prefix"}, "valueSuffix": {"type": "string", "value": "", "name": "Value suffix"}, "numberFormat": {"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/mind-map.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)"}, "item": {"id": "root", "name": "Sankey diagram", "shape": "rect", "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": {"$-expr": "it.id"}, "tags": ["sankey-connector"], "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)"}, "opacity": {"$-expr": "connectorOpacity"}, "area": {"x": {"$-expr": "it.x"}, "y": {"$-expr": "it.y"}, "w": {"$-expr": "it.w"}, "h": {"$-expr": "it.h"}}, "behavior": {"events": [{"id": "mousein", "event": "mousein", "actions": [{"id": "a1", "element": "self", "method": "set", "args": {"field": "shapeProps.strokeSize", "value": 1, "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": "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": "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": "connectorLabels", "it": "it"}, "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)"}, "visible": false, "opacity": 0, "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}\n\ngapRatio = 0.4\nnodeWidth = 20\nlabelPadding = 5\n\nlabelFontSize = max(1, round(fontSize * (100 - magnify) / 100))\nvalueFontSize = max(1, round(fontSize * (100 + magnify) / 100))\n\nlocal nodesById = Map()\n\ncolorThemes = 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)\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\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\nstruct Connection {\n id: uid()\n srcId: ''\n dstId: ''\n value: 0\n srcNode: null\n dstNode: null\n item: null\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 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 connections = List()\n splitString(text, '\\n').forEach(line => {\n line = line.trim()\n if (line != '' && !line.startsWith('//')) {\n local c = parseConnection(line)\n if (c) {\n c.srcNode = getOrCreateNode(c.srcId)\n c.dstNode = getOrCreateNode(c.dstId)\n if (c) {\n connections.add(c)\n }\n }\n }\n })\n connections\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\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\nstruct Level {\n idx: 0\n nodes: List()\n nodesMap: Map()\n totalValue: 0\n height: 0\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 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', 1)\n nodeItem.shapeProps.set('strokeColor', '#ffffff')\n nodeItem.shapeProps.set('cornerRadius', 2)\n nodeItem.shapeProps.set('fill', Fill.solid(node.color))\n nodeItem.args.set('tplArea', 'controlled')\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\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 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 points = List(\n PathPoint('B', xs, ys1, 0, (ys2 - ys1) / 3, (xd - xs) / 3, 0),\n PathPoint('B', xd, yd1, (xs - xd) / 3, 0, 0, (yd2 - yd1) / 3),\n PathPoint('B', xd, yd2, 0, (yd1 - yd2) / 3, (xs - xd) / 3, 0),\n PathPoint('B', xs, ys2, (xd - xs) / 3, 0, 0, (ys1 - ys2) / 3),\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 (connectorColorType == 'gradient') {\n item.shapeProps.set('fill', Fill.linearGradient(90, 0, srcNode.color, 100, dstNode.color))\n } else if (connectorColorType == 'source') {\n item.shapeProps.set('fill', Fill.solid(srcNode.color))\n } else if (connectorColorType == 'destination') {\n item.shapeProps.set('fill', Fill.solid(dstNode.color))\n } else {\n item.shapeProps.set('fill', Fill.solid(connectorColor))\n }\n item.shapeProps.set('strokeSize', 0)\n item.shapeProps.set('strokeColor', '#000000')\n item.shapeProps.set('paths', List(Map(\n 'id', 'p-' + connector.id,\n 'closed', true,\n 'pos', 'relative',\n 'points', points\n )))\n\n connector.item = item\n item\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\n// \"1000000.00\", \"1000000,00\", \"1,000,000.00\", \"1.000.000,00\", \"1 000 000.00\", \"1 000 000,00\"\n\n/*\n\nen-US 100,000.2\nde-DE 100.000,2\nfi-FI 100 000,2\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 textSize = calculateTextSize(node.name, font, labelFontSize)\n local valueText = formatValue(node.value)\n local valueTextSize = calculateTextSize(valueText, font, valueFontSize)\n local totalHeight = (textSize.h + valueTextSize.h)*1.8 + 8\n local isLeft = node.dstNodes.size == 0\n local halign = 'left'\n if (!isLeft) {\n halign = 'right'\n }\n local item = buildLabel('ln-' + node.id, node.name, font, labelFontSize, halign, 'bottom')\n item.w = textSize.w + 4\n item.h = textSize.h * 1.8 + 4\n\n if (isLeft) {\n item.x = node.position - item.w - labelPadding\n } else {\n item.x = node.position + node.width + labelPadding\n }\n item.x += node.x * width\n item.y = node.offset + node.y * height + node.height / 2 - totalHeight / 2\n\n labelItems.add(item)\n\n local valueLabel = buildLabel('lv-' + node.id, valueText, font, valueFontSize, halign, 'top')\n valueLabel.w = valueTextSize.w + 4\n valueLabel.h = valueTextSize.h * 1.8 + 4\n if (isLeft) {\n valueLabel.x = node.position - valueLabel.w - labelPadding\n } else {\n valueLabel.x = node.position + node.width + labelPadding\n }\n valueLabel.x += node.x * width\n valueLabel.y = item.y + item.h\n\n labelItems.add(valueLabel)\n })\n labelItems\n}\n\n\nfunc buildConnectorLabels(connectors) {\n local labels = List()\n connectors.forEach(c => {\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\n valueLabel.h = valueTextSize.h * 1.8 + 4\n valueLabel.x = c.item.x + c.item.w/2 - valueLabel.w/2\n valueLabel.y = c.item.y + c.item.h/2 - valueLabel.h/2\n labels.add(valueLabel)\n })\n\n labels\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\nlocal nodesDataById = decodeNodesData(nodesData)\nallConnections = parseConnections(diagramCode, nodesDataById)\nallNodes = extractNodesFromConnections(allConnections)\n\nlocal levels = buildLevels(allNodes, allConnections)\n\nnodeItems = buildNodeItems(levels)\nconnectorItems = buildConnectorItems(levels, allConnections, allNodes)\nconnectorLabels = buildConnectorLabels(allConnections)\nnodeLabels = buildNodeLabels(allNodes)\n\n\n"} \ No newline at end of file +{"name": "Sankey diagram", "description": "", "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": "light", "options": ["default", "light", "dark"], "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": "Label 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"}, "conOpacity": {"group": "Connections", "type": "number", "value": 60, "name": "Connector opacity", "min": 0, "max": 100}, "conLabel": {"group": "Connections", "type": "boolean", "value": true, "name": "Connection labels enabled", "description": "Displays the value of the connection"}, "conLabelColor": {"group": "Connections", "type": "advanced-color", "value": {"type": "solid", "color": "rgba(255, 255, 255, 0.3)"}, "name": "Connection label color"}, "conLabelStroke": {"group": "Connections", "type": "color", "value": "rgba(120,120,120,0.6)", "name": "Connection label stroke"}, "conLabelStrokeSize": {"group": "Connections", "type": "number", "value": 1, "name": "Connection label stroke size"}, "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}, "valuePrefix": {"group": "Number formatting", "type": "string", "value": "", "name": "Value prefix"}, "valueSuffix": {"group": "Number formatting", "type": "string", "value": "", "name": "Value suffix"}, "numberFormat": {"group": "Number formatting", "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/mind-map.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)"}, "item": {"id": "root", "name": "Sankey diagram", "shape": "rect", "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": {"$-expr": "it.id"}, "tags": ["sankey-connector"], "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)"}, "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": "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": "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": "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}\n\ngapRatio = nodeSpacing / 100\nlabelPadding = 5\n\nlabelFontSize = max(1, round(fontSize * (100 - magnify) / 100))\nvalueFontSize = max(1, round(fontSize * (100 + magnify) / 100))\n\nlocal nodesById = Map()\n\ncolorThemes = 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)\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\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\nstruct Connection {\n id: uid()\n srcId: ''\n dstId: ''\n value: 0\n srcNode: null\n dstNode: null\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 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 connections = List()\n splitString(text, '\\n').forEach(line => {\n line = line.trim()\n if (line != '' && !line.startsWith('//')) {\n local c = parseConnection(line)\n if (c) {\n c.srcNode = getOrCreateNode(c.srcId)\n c.dstNode = getOrCreateNode(c.dstId)\n if (c) {\n connections.add(c)\n }\n }\n }\n })\n connections\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\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\nstruct Level {\n idx: 0\n nodes: List()\n nodesMap: Map()\n totalValue: 0\n height: 0\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 log('Selected color palette', colorTheme, colorPalette)\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', 'controlled')\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\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 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 points = List(\n PathPoint('B', xs, ys1, 0, (ys2 - ys1) / 3, (xd - xs) / 3, 0),\n PathPoint('B', xd, yd1, (xs - xd) / 3, 0, 0, (yd2 - yd1) / 3),\n PathPoint('B', xd, yd2, 0, (yd1 - yd2) / 3, (xs - xd) / 3, 0),\n PathPoint('B', xs, ys2, (xd - xs) / 3, 0, 0, (ys1 - ys2) / 3),\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 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 + 10\n valueLabel.h = valueTextSize.h * 1.8 + 14\n valueLabel.x = connectorWidth/2 - valueLabel.w/2\n valueLabel.y = connectorHeight/2 - valueLabel.h/2\n valueLabel.name = 'connector-label-' + c.id\n valueLabel.shapeProps.set('fill', conLabelColor)\n valueLabel.shapeProps.set('strokeColor', conLabelStroke)\n valueLabel.shapeProps.set('strokeSize', conLabelStrokeSize)\n valueLabel.shapeProps.set('cornerRadius', 5)\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\n// \"1000000.00\", \"1000000,00\", \"1,000,000.00\", \"1.000.000,00\", \"1 000 000.00\", \"1 000 000,00\"\n\n/*\n\nen-US 100,000.2\nde-DE 100.000,2\nfi-FI 100 000,2\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 textSize = calculateTextSize(node.name, font, labelFontSize)\n local valueText = formatValue(node.value)\n local valueTextSize = calculateTextSize(valueText, font, valueFontSize)\n local totalHeight = (textSize.h + valueTextSize.h)*1.8 + 8\n local isLeft = node.dstNodes.size == 0\n local halign = 'left'\n if (!isLeft) {\n halign = 'right'\n }\n local item = buildLabel('ln-' + node.id, node.name, font, labelFontSize, halign, 'bottom')\n item.w = textSize.w + 4\n item.h = textSize.h * 1.8 + 4\n\n if (isLeft) {\n item.x = node.position - item.w - labelPadding\n } else {\n item.x = node.position + node.width + labelPadding\n }\n item.x += node.x * width\n item.y = node.offset + node.y * height + node.height / 2 - totalHeight / 2\n\n labelItems.add(item)\n\n local valueLabel = buildLabel('lv-' + node.id, valueText, font, valueFontSize, halign, 'top')\n valueLabel.w = valueTextSize.w + 4\n valueLabel.h = valueTextSize.h * 1.8 + 4\n if (isLeft) {\n valueLabel.x = node.position - valueLabel.w - labelPadding\n } else {\n valueLabel.x = node.position + node.width + labelPadding\n }\n valueLabel.x += node.x * width\n valueLabel.y = item.y + item.h\n\n labelItems.add(valueLabel)\n })\n labelItems\n}\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\nlocal nodesDataById = decodeNodesData(nodesData)\nallConnections = parseConnections(diagramCode, nodesDataById)\nallNodes = extractNodesFromConnections(allConnections)\n\nlocal levels = buildLevels(allNodes, allConnections)\n\nnodeItems = buildNodeItems(levels)\nconnectorItems = buildConnectorItems(levels, allConnections, allNodes)\nnodeLabels = buildNodeLabels(allNodes)\n\n\n"} \ No newline at end of file diff --git a/assets/templates/diagrams/sankey.yaml b/assets/templates/diagrams/sankey.yaml index 5c3e17c3..7f70e74d 100644 --- a/assets/templates/diagrams/sankey.yaml +++ b/assets/templates/diagrams/sankey.yaml @@ -2,18 +2,40 @@ name: Sankey diagram description: "" 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} - font: {type: font, value: Arial, name: Font} - colorTheme: {type: "choice", value: "default", options: ["default", "light", "dark"], name: "Color theme"} - connectorColorType: {type: choice, value: source, options: ['source', 'destination', 'gradient', 'custom'], name: 'Connection color type'} - connectorColor: {type: color, value: '#aaaaaa', name: 'Connection color', depends: {connectorColorType: 'custom'}} - fontSize: {type: number, value: 14, name: "Font size", min: 1} - labelColor: {type: color, value: '#222222', name: 'Label color'} - magnify: {type: number, value: 0, name: "Magnify value", min: -50, max: 50} - connectorOpacity: {type: number, value: 60, name: "Connector opacity", min: 0, max: 100} - valuePrefix: {type: string, value: "", name: "Value prefix"} - valueSuffix: {type: string, value: "", name: "Value suffix"} - numberFormat: {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"} + 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: "light", options: ["default", "light", "dark"], 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: 'Label 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"} + + conOpacity: {group: "Connections", type: number, value: 60, name: "Connector opacity", min: 0, max: 100} + conLabel: {group: "Connections", type: boolean, value: true, name: 'Connection labels enabled', description: 'Displays the value of the connection'} + conLabelColor: {group: "Connections", type: advanced-color, value: {type: solid, color: 'rgba(255, 255, 255, 0.3)'}, name: 'Connection label color'} + conLabelStroke: {group: "Connections", type: color, value: 'rgba(120,120,120,0.6)', name: 'Connection label stroke'} + conLabelStrokeSize: {group: "Connections", type: number, value: 1, name: 'Connection label stroke size'} + 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} + + valuePrefix: {group: "Number formatting", type: string, value: "", name: "Value prefix"} + valueSuffix: {group: "Number formatting", type: string, value: "", name: "Value suffix"} + numberFormat: {group: "Number formatting", 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/mind-map.svg" defaultArea: {x: 0, y: 0, w: 200, h: 60} @@ -51,12 +73,40 @@ item: args: {$-expr: "it.getArgs()"} locked: {$-expr: "it.locked"} textSlots: {$-expr: "toJSON(it.textSlots)"} - opacity: {$-expr: connectorOpacity} + 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: "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 @@ -67,7 +117,7 @@ item: method: set args: field: shapeProps.strokeSize - value: 1 + value: {$-expr: conHoverStrokeSize} animated: true animationDuration: 0.2 transition: ease-in-out @@ -125,22 +175,6 @@ item: 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"} - - - $-foreach: {source: "connectorLabels", it: "it"} - 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)"} - visible: false - opacity: 0 area: x: {$-expr: "it.x"} y: {$-expr: "it.y"} diff --git a/assets/templates/diagrams/src/sankey.sch b/assets/templates/diagrams/src/sankey.sch index 239a3cb1..c4cd58d2 100644 --- a/assets/templates/diagrams/src/sankey.sch +++ b/assets/templates/diagrams/src/sankey.sch @@ -1,5 +1,4 @@ -gapRatio = 0.4 -nodeWidth = 20 +gapRatio = nodeSpacing / 100 labelPadding = 5 labelFontSize = max(1, round(fontSize * (100 - magnify) / 100)) @@ -81,7 +80,6 @@ struct Connection { value: 0 srcNode: null dstNode: null - item: null } @@ -180,6 +178,23 @@ struct Level { height: 0 } +func readjustStarterNodeLevels(nodesMap) { + // readjusting node levels for starter nodes + nodesMap.forEach(node => { + if (node.srcNodes.size == 0 && node.dstNodes.size > 0) { + local minDstLevel = node.dstNodes.get(0).level + node.dstNodes.forEach(dstNode => { + if (minDstLevel > dstNode.level) { + minDstLevel = dstNode.level + } + }) + if (minDstLevel - node.level > 1) { + node.level = minDstLevel - 1 + } + } + }) +} + func buildLevels(allNodes, allConnections) { local nodesMap = Map() allNodes.forEach(node => { @@ -214,6 +229,8 @@ func buildLevels(allNodes, allConnections) { } }) + readjustStarterNodeLevels(nodesMap) + local levels = Map() local maxLevel = 0 nodesMap.forEach(node => { @@ -253,6 +270,7 @@ func buildLevels(allNodes, allConnections) { func buildNodeItems(levels) { local colorPalette = colorThemes.get(colorTheme) + log('Selected color palette', colorTheme, colorPalette) if (!colorPalette) { colorPalette = colorThemes.get('default') } @@ -295,9 +313,9 @@ func buildNodeItems(levels) { nodeItem.h = node.height nodeItem.x = node.position + node.x * width nodeItem.y = node.offset + node.y * height - nodeItem.shapeProps.set('strokeSize', 1) - nodeItem.shapeProps.set('strokeColor', '#ffffff') - nodeItem.shapeProps.set('cornerRadius', 2) + nodeItem.shapeProps.set('strokeSize', nodeStrokeSize) + nodeItem.shapeProps.set('strokeColor', nodeStrokeColor) + nodeItem.shapeProps.set('cornerRadius', nodeCornerRadius) nodeItem.shapeProps.set('fill', Fill.solid(node.color)) nodeItem.args.set('tplArea', 'controlled') nodeItem.args.set('tplConnector', 'off') @@ -358,6 +376,7 @@ func buildConnectorItems(levels, allConnections, allNodes) { connectorItems } + struct PathPoint { t: 'B' x: 0 @@ -408,17 +427,17 @@ func buildSingleConnectorItem(connector, srcNode, dstNode) { item.w = dx item.h = dy - if (connectorColorType == 'gradient') { + if (conColorType == 'gradient') { item.shapeProps.set('fill', Fill.linearGradient(90, 0, srcNode.color, 100, dstNode.color)) - } else if (connectorColorType == 'source') { + } else if (conColorType == 'source') { item.shapeProps.set('fill', Fill.solid(srcNode.color)) - } else if (connectorColorType == 'destination') { + } else if (conColorType == 'destination') { item.shapeProps.set('fill', Fill.solid(dstNode.color)) } else { - item.shapeProps.set('fill', Fill.solid(connectorColor)) + item.shapeProps.set('fill', conColor) } item.shapeProps.set('strokeSize', 0) - item.shapeProps.set('strokeColor', '#000000') + item.shapeProps.set('strokeColor', conHoverStroke) item.shapeProps.set('paths', List(Map( 'id', 'p-' + connector.id, 'closed', true, @@ -426,10 +445,28 @@ func buildSingleConnectorItem(connector, srcNode, dstNode) { 'points', points ))) - connector.item = item + if (conLabel) { + item.childItems.add(buildConnectorLabel(connector, item.w, item.h)) + } item } +func buildConnectorLabel(c, connectorWidth, connectorHeight) { + local valueText = formatValue(c.value) + local valueTextSize = calculateTextSize(valueText, font, fontSize) + local valueLabel = buildLabel('cl-' + c.id, valueText, font, fontSize, 'center', 'middle') + valueLabel.w = valueTextSize.w + 4 + 10 + valueLabel.h = valueTextSize.h * 1.8 + 14 + valueLabel.x = connectorWidth/2 - valueLabel.w/2 + valueLabel.y = connectorHeight/2 - valueLabel.h/2 + valueLabel.name = 'connector-label-' + c.id + valueLabel.shapeProps.set('fill', conLabelColor) + valueLabel.shapeProps.set('strokeColor', conLabelStroke) + valueLabel.shapeProps.set('strokeSize', conLabelStrokeSize) + valueLabel.shapeProps.set('cornerRadius', 5) + valueLabel +} + func buildLabel(id, text, font, fontSize, halign, valign) { local item = Item(id, text, 'none') item.args.set('templateForceText', true) @@ -530,21 +567,6 @@ func buildNodeLabels(nodes) { } -func buildConnectorLabels(connectors) { - local labels = List() - connectors.forEach(c => { - local valueText = formatValue(c.value) - local valueTextSize = calculateTextSize(valueText, font, fontSize) - local valueLabel = buildLabel('cl-' + c.id, valueText, font, fontSize, 'center', 'middle') - valueLabel.w = valueTextSize.w + 4 - valueLabel.h = valueTextSize.h * 1.8 + 4 - valueLabel.x = c.item.x + c.item.w/2 - valueLabel.w/2 - valueLabel.y = c.item.y + c.item.h/2 - valueLabel.h/2 - labels.add(valueLabel) - }) - - labels -} func onAreaUpdate(itemId, item, area) { @@ -568,5 +590,4 @@ local levels = buildLevels(allNodes, allConnections) nodeItems = buildNodeItems(levels) connectorItems = buildConnectorItems(levels, allConnections, allNodes) -connectorLabels = buildConnectorLabels(allConnections) nodeLabels = buildNodeLabels(allNodes) diff --git a/src/ui/components/editor/ArgumentsEditor.vue b/src/ui/components/editor/ArgumentsEditor.vue index f0783b82..95156fec 100644 --- a/src/ui/components/editor/ArgumentsEditor.vue +++ b/src/ui/components/editor/ArgumentsEditor.vue @@ -147,6 +147,7 @@ import ScriptEditor from './ScriptEditor.vue'; import PathCapDropdown from './PathCapDropdown.vue'; import Dropdown from '../Dropdown.vue'; import { getAllFonts } from '../../scheme/Fonts'; +import Panel from './Panel.vue'; export default { props: { @@ -162,7 +163,8 @@ export default { components: { Modal, ColorPicker, ElementPicker, Tooltip, NumberTextfield, Dropdown, - AdvancedColorEditor, ScriptEditor, DiagramPicker, PathCapDropdown + AdvancedColorEditor, ScriptEditor, DiagramPicker, PathCapDropdown, + Panel }, beforeMount() { diff --git a/src/ui/components/editor/properties/TemplateProperties.vue b/src/ui/components/editor/properties/TemplateProperties.vue index a82efa80..2eb1be9b 100644 --- a/src/ui/components/editor/properties/TemplateProperties.vue +++ b/src/ui/components/editor/properties/TemplateProperties.vue @@ -9,15 +9,34 @@

{{ template.name }}

{{ template.description }}

- --> + + + + +
  • { if (!this.args.hasOwnProperty(argName)) { this.args[argName] = arg.value; } + if (arg.group) { + if (groupedArgs.has(arg.group)) { + groupedArgs.get(arg.group)[argName] = arg; + } else { + const obj = {}; + obj[argName] = arg; + groupedArgs.set(arg.group, obj); + } + } else { + ungroupedArgs[argName] = arg; + } + }); + this.ungroupedArgs = ungroupedArgs; + const argGroups = []; + groupedArgs.forEach((args, groupName) => { + argGroups.push({ + name: groupName, + args + }); }); + this.argGroups = argGroups; } this.updateEditorPanels(); }).catch(err => {