Here is an example I created for expanding and collapsing tree nodes (also supports pan and zoom).
There is a dataset named treeClickStorePerm which stores the state of the nodes which have been opened on the canvas. This is populated by an intermediate dataset named treeClickStoreTemp which in turn are driven by various signals defined at the top of the spec.
Editor
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "Zoomable, collapsable tree by David Bacci: https://www.linkedin.com/in/davbacci/",
"width": {"signal": "1400"},
"height": {"signal": "1000"},
"background": "#f5f5f5",
"autosize": "pad",
"padding": 5,
"signals": [
{"name": "nodeWidth", "value": 190},
{"name": "nodeHeight", "value": 45},
{
"name": "startingDepth",
"value": 1,
"on": [
{
"events": {
"type": "timer",
"throttle": 0
},
"update": "-1"
}
]
},
{
"name": "node",
"value": 0,
"on": [
{
"events": {
"type": "click",
"markname": "node"
},
"update": "datum.id"
},
{
"events": {
"type": "timer",
"throttle": 10
},
"update": "0"
}
]
},
{
"name": "nodeHighlight",
"value": "[0]",
"on": [
{
"events": {
"type": "mouseover",
"markname": "node"
},
"update": "pluck(treeAncestors('treeCalcs', datum.id), 'id')"
},
{
"events": {
"type": "mouseout"
},
"update": "[0]"
}
]
},
{
"name": "isExpanded",
"value": 0,
"on": [
{
"events": {
"type": "click",
"markname": "node"
},
"update": "datum.children > 0 && indata('treeClickStorePerm', 'id', datum.childrenIds[0])?true:false"
}
]
},
{
"name": "xrange",
"update": "[0, width]"
},
{
"name": "yrange",
"update": "[0, height]"
},
{
"name": "down",
"value": null,
"on": [
{
"events": "mousedown",
"update": "xy()"
}
]
},
{
"name": "xcur",
"value": null,
"on": [
{
"events": "mousedown",
"update": "slice(xdom)"
}
]
},
{
"name": "ycur",
"value": null,
"on": [
{
"events": "mousedown",
"update": "slice(ydom)"
}
]
},
{
"name": "delta",
"value": [0, 0],
"on": [
{
"events": [
{
"source": "window",
"type": "mousemove",
"consume": true,
"between": [
{"type": "mousedown"},
{
"source": "window",
"type": "mouseup"
}
]
}
],
"update": "down ? [down[0]-x(), down[1]-y()] : [0,0]"
}
]
},
{
"name": "anchor",
"value": [0, 0],
"on": [
{
"events": "wheel",
"update": "[invert('xscale', x()), invert('yscale', y())]"
}
]
},
{
"name": "xext",
"update": "[0,width]"
},
{
"name": "yext",
"update": "[0,height]"
},
{
"name": "zoom",
"value": 1,
"on": [
{
"events": "wheel!",
"force": true,
"update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))"
}
]
},
{
"name": "xdom",
"update": "slice(xext)",
"on": [
{
"events": {"signal": "delta"},
"update": "[xcur[0] + span(xcur) * delta[0] / width, xcur[1] + span(xcur) * delta[0] / width]"
},
{
"events": {"signal": "zoom"},
"update": "[anchor[0] + (xdom[0] - anchor[0]) * zoom, anchor[0] + (xdom[1] - anchor[0]) * zoom]"
},
{
"events": "dblclick",
"update": "[0,width]"
}
]
},
{
"name": "ydom",
"update": "slice(yext)",
"on": [
{
"events": {"signal": "delta"},
"update": "[ycur[0] + span(ycur) * delta[1] / height, ycur[1] + span(ycur) * delta[1] / height]"
},
{
"events": {"signal": "zoom"},
"update": "[anchor[1] + (ydom[0] - anchor[1]) * zoom, anchor[1] + (ydom[1] - anchor[1]) * zoom]"
},
{
"events": "dblclick",
"update": "[0,height]"
}
]
},
{
"name": "scaledNodeWidth",
"update": "(nodeWidth/ span(xdom))*width"
},
{
"name": "scaledNodeHeight",
"update": "abs(nodeHeight/ span(ydom))*height"
},
{
"name": "scaledFont13",
"update": "(13/ span(xdom))*width"
},
{
"name": "scaledFont12",
"update": "(12/ span(xdom))*width"
},
{
"name": "scaledFont11",
"update": "(11/ span(xdom))*width"
},
{
"name": "scaledKPIHeight",
"update": "(5/ span(xdom))*width"
},
{
"name": "scaledLimit",
"update": "(20/ span(xdom))*width"
}
],
"data": [
{
"name": "source",
"url": "https://raw.githubusercontent.com/PBI-David/Deneb-Showcase/main/Organisation%20Tree%20Chart/data.json"
},
{
"name": "wideToTall",
"source": "source",
"transform": [
{
"type": "formula",
"expr": "{key: datum.level1,parent: null, person:datum.person, kpi:datum.kpi}",
"as": "l1"
},
{
"type": "formula",
"expr": "{key: datum.level1+ '|'+datum.level2,parent: datum.level1, person:datum.person, kpi:datum.kpi}",
"as": "l2"
},
{
"type": "formula",
"expr": "{key:datum.level1 + '|'+datum.level2+ '|'+datum.level3,parent: datum.level1+ '|'+datum.level2, person:datum.person, kpi:datum.kpi}",
"as": "l3"
},
{
"type": "formula",
"expr": "{key:datum.level1 + '|'+datum.level2+ '|'+datum.level3+ '|'+ datum.level4,parent: datum.level1 + '|'+datum.level2+ '|'+datum.level3, person:datum.person, kpi:datum.kpi}",
"as": "l4"
},
{
"type": "formula",
"expr": "{key:datum.level1 + '|'+datum.level2+ '|'+datum.level3+ '|'+ datum.level4+ '|'+ datum.level5,parent: datum.level1 + '|'+datum.level2+ '|'+datum.level3+ '|'+ datum.level4, person:datum.person, kpi:datum.kpi}",
"as": "l5"
},
{
"type": "fold",
"fields": [
"l1",
"l2",
"l3",
"l4",
"l5"
]
},
{
"type": "project",
"fields": ["key", "value"]
},
{
"type": "formula",
"expr": "datum.value.key",
"as": "id"
},
{
"type": "formula",
"expr": "reverse(split(datum.value.key,'|'))[0]",
"as": "title"
},
{
"type": "formula",
"expr": "datum.value.parent",
"as": "parent"
},
{
"type": "filter",
"expr": "datum.title != 'null' && datum.title != 'undefined'"
},
{
"type": "aggregate",
"groupby": [
"id",
"parent",
"title",
"value"
]
},
{
"type": "formula",
"expr": "datum.value.person",
"as": "person"
},
{
"type": "formula",
"expr": "datum.value.kpi",
"as": "kpi"
}
]
},
{
"name": "treeCalcs",
"source": "wideToTall",
"transform": [
{
"type": "stratify",
"key": "id",
"parentKey": "parent"
},
{
"type": "tree",
"method": {
"signal": "'tidy'"
},
"separation": {
"signal": "false"
},
"as": [
"y",
"x",
"depth",
"children"
]
},
{
"as": "parent",
"type": "formula",
"expr": "datum.parent"
}
]
},
{
"name": "treeChildren",
"source": "treeCalcs",
"transform": [
{
"type": "aggregate",
"groupby": ["parent"],
"fields": ["parent"],
"ops": ["values"],
"as": ["childrenObjects"]
},
{
"type": "formula",
"expr": "pluck(datum.childrenObjects,'id')",
"as": "childrenIds"
}
]
},
{
"name": "treeAncestors",
"source": "treeCalcs",
"transform": [
{
"type": "formula",
"as": "treeAncestors",
"expr": "treeAncestors('treeCalcs', datum.id, 'root')"
},
{
"type": "flatten",
"fields": ["treeAncestors"]
},
{
"type": "formula",
"expr": "datum.treeAncestors.parent",
"as": "allParents"
}
]
},
{
"name": "treeChildrenAll",
"source": "treeAncestors",
"transform": [
{
"type": "project",
"fields": [
"allParents",
"id",
"name",
"parent",
"x",
"y",
"depth",
"children"
]
},
{
"type": "aggregate",
"fields": [
"parent",
"parent",
"id"
],
"ops": [
"values",
"count",
"min"
],
"groupby": ["allParents"],
"as": [
"allChildrenObjects",
"allChildrenCount",
"id"
]
},
{
"type": "formula",
"expr": "pluck(datum.allChildrenObjects,'id')",
"as": "allChildrenIds"
}
]
},
{
"name": "treeClickStoreTemp",
"source": "treeAncestors",
"transform": [
{
"type": "filter",
"expr": "startingDepth!=-1?datum.depth <= startingDepth:node !=0 && !isExpanded? datum.parent == node: node !=0 && isExpanded? datum.allParents == node:false"
},
{
"type": "project",
"fields": [
"id",
"name",
"parent",
"x",
"y",
"depth",
"children"
]
},
{
"type": "aggregate",
"fields": ["id"],
"ops": ["min"],
"groupby": [
"id",
"name",
"parent",
"x",
"y",
"depth",
"children"
]
}
]
},
{
"name": "treeClickStorePerm",
"values": [],
"on": [
{
"trigger": "startingDepth>=0",
"insert": "data('treeClickStoreTemp')"
},
{
"trigger": "node",
"insert": "!isExpanded? data('treeClickStoreTemp'):false"
},
{
"trigger": "node",
"remove": "isExpanded?data('treeClickStoreTemp'):false"
}
]
},
{
"name": "treeLayout",
"source": "wideToTall",
"transform": [
{
"type": "filter",
"expr": "indata('treeClickStorePerm', 'id', datum.id)"
},
{
"type": "stratify",
"key": "id",
"parentKey": "parent"
},
{
"type": "tree",
"method": {
"signal": "'tidy'"
},
"nodeSize": [
{"signal": "nodeHeight+10"},
{"signal": "nodeWidth+140"}
],
"separation": {
"signal": "false"
},
"as": [
"y",
"x",
"depth",
"children"
]
},
{
"type": "formula",
"expr": "datum.y+(height/2)",
"as": "y"
},
{
"type": "formula",
"expr": "scale('xscale',datum.x)",
"as": "xscaled"
},
{
"as": "parent",
"type": "formula",
"expr": "datum.parent"
}
]
},
{
"name": "fullTreeLayout",
"source": "treeLayout",
"transform": [
{
"type": "lookup",
"from": "treeChildren",
"key": "parent",
"fields": ["id"],
"values": [
"childrenObjects",
"childrenIds"
]
},
{
"type": "lookup",
"from": "treeChildrenAll",
"key": "allParents",
"fields": ["id"],
"values": [
"allChildrenIds",
"allChildrenObjects"
]
},
{
"type": "lookup",
"from": "treeCalcs",
"key": "id",
"fields": ["id"],
"values": ["children"]
},
{
"type": "formula",
"expr": "reverse(pluck(treeAncestors('treeCalcs', datum.id), 'id'))[1]",
"as": "treeParent"
}
]
},
{
"name": "visibleNodes",
"source": "fullTreeLayout",
"transform": [
{
"type": "filter",
"expr": "indata('treeClickStorePerm', 'id', datum.id)"
}
]
},
{
"name": "maxWidthAndHeight",
"source": "visibleNodes",
"transform": [
{
"type": "aggregate",
"groupby": ["depth"],
"fields": ["depth", "x", "y"],
"ops": [
"count",
"max",
"max"
],
"as": ["count", "x", "y"]
},
{
"type": "aggregate",
"fields": [
"depth",
"count",
"x",
"y"
],
"ops": [
"max",
"max",
"max",
"max"
],
"as": [
"maxDepth",
"maxNodes",
"maxX",
"maxY"
]
}
]
},
{
"name": "links",
"source": "treeLayout",
"transform": [
{"type": "treelinks"},
{
"type": "linkpath",
"orient": "horizontal",
"shape": "diagonal",
"sourceY": {
"expr": "scale('yscale', datum.source.y)"
},
"sourceX": {
"expr": "scale('xscale', datum.source.x+nodeWidth)"
},
"targetY": {
"expr": "scale('yscale', datum.target.y)"
},
"targetX": {
"expr": "scale('xscale', datum.target.x)"
}
},
{
"type": "filter",
"expr": " indata('treeClickStorePerm', 'id', datum.target.id)"
}
]
}
],
"scales": [
{
"name": "xscale",
"zero": false,
"domain": {"signal": "xdom"},
"range": {"signal": "xrange"}
},
{
"name": "yscale",
"zero": false,
"domain": {"signal": "ydom"},
"range": {"signal": "yrange"}
},
{
"name": "kpiscale",
"zero": false,
"domain": [0, 100],
"range": {
"signal": "[0,scaledNodeWidth]"
}
},
{
"name": "colour",
"type": "ordinal",
"range": [
"#6f6f6f",
"#4472C4",
"#3A8E50",
"#ED7D31",
"#a63939",
"#6338a6",
"#3843a6",
"#38a695"
],
"domain": {
"data": "visibleNodes",
"field": "treeParent"
}
}
],
"marks": [
{
"type": "path",
"interactive": false,
"from": {"data": "links"},
"encode": {
"update": {
"path": {"field": "path"},
"strokeWidth": {
"signal": "indexof(nodeHighlight, datum.target.id)> -1? 2.5:0.4"
},
"stroke": {
"scale": "colour",
"signal": "reverse(pluck(treeAncestors('treeCalcs', datum.target.id), 'id'))[1]"
}
}
}
},
{
"name": "node",
"description": "The parent node",
"type": "group",
"clip": false,
"from": {"data": "visibleNodes"},
"encode": {
"update": {
"x": {
"field": "x",
"scale": "xscale"
},
"width": {
"signal": "scaledNodeWidth"
},
"yc": {
"field": "y",
"scale": "yscale"
},
"height": {
"signal": "scaledNodeHeight"
},
"fill": {
"signal": "merge(hsl(scale('colour', datum.treeParent)), {l:0.94})"
},
"stroke": {
"signal": "merge(hsl(scale('colour', datum.treeParent)), {l:0.79})"
},
"cornerRadius": {"value": 2},
"cursor": {
"signal": "datum.children>0?'pointer':''"
},
"tooltip": {"signal": ""}
}
},
"marks": [
{
"name": "highlight",
"description": "highlight (seems like a Vega bug as this doens't work on the group element)",
"type": "rect",
"interactive": false,
"encode": {
"update": {
"x": {
"signal": "item.mark.group.x1"
},
"y": {"signal": "0"},
"fill": {
"signal": "indexof(nodeHighlight, parent.id)> -1? merge(hsl(scale('colour', parent.treeParent)), {l:0.82}):0"
},
"height": {
"signal": "item.mark.group.height"
},
"width": {
"signal": "item.mark.group.width"
}
}
}
},
{
"name": "KPI background",
"description": "KPI background",
"type": "rect",
"interactive": false,
"clip": true,
"encode": {
"update": {
"x": {
"signal": "item.mark.group.x1"
},
"y": {
"signal": "item.mark.group.height-scaledKPIHeight"
},
"height": {
"signal": "scaledKPIHeight"
},
"width": {
"signal": "(item.mark.group.width)"
},
"fill": {
"scale": "colour",
"signal": "parent.treeParent"
},
"opacity": {"value": 0.2}
}
}
},
{
"name": "KPI",
"description": "KPI",
"type": "rect",
"interactive": false,
"clip": true,
"encode": {
"update": {
"x": {
"signal": "item.mark.group.x1"
},
"y": {
"signal": "item.mark.group.height-scaledKPIHeight"
},
"height": {
"signal": "scaledKPIHeight"
},
"width": {
"signal": "scale('kpiscale',parent.kpi)"
},
"fill": {
"scale": "colour",
"signal": "parent.treeParent"
}
}
}
},
{
"type": "text",
"interactive": false,
"name": "name",
"encode": {
"update": {
"x": {
"signal": "(10/ span(xdom))*width"
},
"y": {
"signal": "(6/ span(xdom))*width"
},
"fontWeight": {
"value": "600"
},
"baseline": {
"value": "top"
},
"fill": {
"scale": "colour",
"signal": "parent.treeParent"
},
"text": {
"signal": "parent.person"
},
"fontSize": {
"signal": "scaledFont13"
},
"limit": {
"signal": "scaledNodeWidth-scaledLimit"
},
"font": {
"value": "Calibri"
}
}
}
},
{
"type": "text",
"interactive": false,
"name": "title",
"encode": {
"update": {
"x": {
"signal": "(10/ span(xdom))*width"
},
"y": {
"signal": "(22/ span(xdom))*width"
},
"align": {
"value": "left"
},
"baseline": {
"value": "top"
},
"fill": {
"signal": "'#4D4B44'"
},
"text": {
"signal": "parent.title"
},
"fontSize": {
"signal": "scaledFont11"
},
"limit": {
"signal": "scaledNodeWidth-scaledLimit"
},
"font": {
"value": "Calibri"
}
}
}
},
{
"type": "text",
"interactive": false,
"name": "node children",
"encode": {
"update": {
"x": {
"signal": "item.mark.group.width - (9/ span(xdom))*width"
},
"y": {
"signal": "item.mark.group.height/2"
},
"align": {
"value": "right"
},
"baseline": {
"value": "middle"
},
"fill": {
"scale": "colour",
"signal": "parent.treeParent"
},
"text": {
"signal": "parent.children>0?parent.children:''"
},
"fontSize": {
"signal": "scaledFont12"
},
"font": {
"value": "Calibri"
}
}
}
}
]
}
]
}