How to implement tree nodes toggling in Vega JS?
Asked Answered
S

3

2

I'm using Vega JS for building a tree chart. In general, my question is the following:

Vega documentation has a great example of tree layout. How can I extend it with an ability to collapse & expand its nodes?

To be more specific, let's consider an example of tree chart that I'm building here in Vega Editor.

If you click on the nodes, they will toggle (expand or collapse), allowing you to see particular branches of the tree. This feature works fine unless you try to collapse the top-level node (region) while keeping the second-level nodes (districts) expanded. In that case the tree will look like this:

broken tree screenshot

It happens because of the way I handle this interaction:

  1. When you click on a node, toggledNode signal is triggered, which in turn triggers toggle action in the expandedNodes data array. I.e. by clicking on a node, I add or remove that node to the expandedNodes array (more precisely, we add/remove a reduced object with only name property)
  2. Thus expandedNodes data contains information about which nodes are explicitly expanded. But it doesn't know if those expanded nodes are inside of a collapsed parent node.
  3. Then, to find out which nodes are actually visible, I use visibleNodes data. There I apply filter transform with the following expression: !datum.parent || indata('expandedNodes', 'name', datum.parent). I.e. I check only one level up: if the node's parent is present in the expandedNodes array , I consider the node as visible.

The problem is the following: I can't find any way of extending this functionality across multiple levels.

Probably I could write some hooks to check the same condition across 2 or 3 levels, e.g:

!datum.parent ||
  indata('expandedNodes', 'name', datum.parent) && 
  indata('expandedNodes', 'name', datum.myCustomFieldWithParentNode.parent) && 
  indata('expandedNodes', 'name', datum.myCustomFieldWithParentNode.myCustomFieldWithParentNode.parent)

But it seems too complex for such a simple problem, and also it's not a final solution. In theory, a tree may contain dozens of nesting levels: what to do then?

I found one useful expression in Vega: treeAncestors. I could easily write a solution in JavaScript, where I have loops and array methods such as .some() and .every(). But apparently Vega doesn't support any expressions to iterate over an array. So even though I can get an array of tree node ancestors with treeAncestors function, I can't do anything with it to verify that all ancestors are expanded.

Either my approach is wrong, and somebody can find a better algorithm for doing the same, which doesn't require iterating over arrays (except for data and indata expressions) - or it's a current limitation of Vega.

Stelmach answered 23/9, 2021 at 15:28 Comment(0)
D
1

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

enter image description here

{
    "$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"
                }
              }
            }
          }
        ]
      }
    ]
  }
Dustheap answered 21/5, 2023 at 14:9 Comment(3)
Thanks, the chart looks amazing! Could you please provide more explanation behind your solution? In particular, please point us to where it solves the core problem in the question so that I can accept it as the correct answer and close the topic.Stelmach
I'll add a comment now.Dustheap
@Dustheap thank you, the chart is amazing, could you please provide a solution to display it Horizontally /Vertically ?Prorogue
C
2

Vega doesn't seem to have a recursive way of solving the problem for your question" hey, if all my parents are expanded, then I am visible as a node ".

You can check indeed conditions for all levels you wish to define.

{
      "type": "filter",
      "expr": "!datum.parent || indata('expandedNodes','name',datum.parent)&&datum.depth==1||(indata('expandedNodes','name',datum.firstParent)&&indata('expandedNodes','name',datum.secondParent)&&datum.depth==2)||(indata('expandedNodes','name',datum.firstParent)&&indata('expandedNodes','name',datum.secondParent)&&indata('expandedNodes','name',datum.thirdParent)&&datum.depth==3)"
}

The code above says to VEGA : " hey check if all of my defined parents are expanded and filter me if any of my parents exist but are not expanded

To see the full solution with your case, please check : spec

Conlee answered 9/8, 2022 at 15:19 Comment(0)
L
1

You can use treeAncestors and then use a flatten transform to get a dataset that you can query. In your case it would look something like:

{
  "transform": [
    {
      "as": "treeAncestors",
      "type": "formula",
      "expr": "treeAncestors('tree', datum.id, 'root')"
    }
  ],
  "name": "tree-ancestors",
  "source": "tree"
},
{
  "transform": [{"fields": ["treeAncestors"], "type": "flatten"}],
  "name": "tree-ancestors-flatt",
  "source": "tree-ancestors"
},
{
  "transform": [
    {
      "type": "filter",
      "expr": "indata('selected', 'value', datum.treeAncestors.id)"
    }
  ],
  "name": "filtered",
  "source": "tree-ancestors-flatt"
},
{
  "transform": [{"type": "aggregate", "groupby": ["id"]}],
  "name": "filtered-aggregate",
  "source": "filtered"
},
{
  "transform": [
    {
      "type": "filter",
      "expr": "indata('filtered-aggregate', 'id', datum.id) "
    }
  ],
  "name": "filtered-tree",
  "source": "tree"
}
Liken answered 23/11, 2021 at 2:18 Comment(3)
It is at first, but that's where the flatten transform comes into play. It takes the initial row and gives you a new row for each field in the array, so that you can just access it easily.Liken
sorry, I've deleted my comment right before you answered it. I got it now, treeAncestors is turned into an object after the flatten transform.Stelmach
So I tried your implementation on my chart, but it doesn't work as expected. Of course, I replaced id fields with name, and made some other renamings to adapt it for my sample. Here is the link: vega.github.io/editor/#/gist/329742ab6fbc3104cb3bfb911fbc4fcb/…. It does hide the lowest nodes when I collapse the root node, but as you can see, when root node is expanded, the lowest nodes are always visible. I.e. it doesn't respect the middle nodes' state. I don't fully understand what's going on with all these filter and aggregate transforms, could you suggest please?Stelmach
D
1

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

enter image description here

{
    "$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"
                }
              }
            }
          }
        ]
      }
    ]
  }
Dustheap answered 21/5, 2023 at 14:9 Comment(3)
Thanks, the chart looks amazing! Could you please provide more explanation behind your solution? In particular, please point us to where it solves the core problem in the question so that I can accept it as the correct answer and close the topic.Stelmach
I'll add a comment now.Dustheap
@Dustheap thank you, the chart is amazing, could you please provide a solution to display it Horizontally /Vertically ?Prorogue

© 2022 - 2024 — McMap. All rights reserved.