Last active
November 3, 2025 13:19
-
-
Save Giammaria/a4fdb5e3aebc15c6b276fb956465bb91 to your computer and use it in GitHub Desktop.
20251031_hierarchical_bar_chart_v_v1.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "$schema": "https://vega.github.io/schema/vega/v6.json", | |
| "width": 800, | |
| "background": "#fff", | |
| "signals": [ | |
| {"name": "padding", "value": 10}, | |
| {"name": "isPowerBIVisual", "value": false}, | |
| {"name": "configDesiredChartHeight", "init": "150"}, | |
| { | |
| "name": "configFields", | |
| "update": "{id: 'id', parentId: 'parentId', name: 'name', label: 'name', 'childQuantity': 'childQuantity', descendantQuantity: 'descendantQuantity'}" | |
| }, | |
| { | |
| "name": "configColorScheme", | |
| "init": "{axes: {x: {text: {fill: '#555'}, grid: {stroke: '#dddddd'}, ticks: {stroke: '#dddddd'}}, y: {text: {fill: '#555'}}}, bars: {parent: {fill: '#eb6123', fillOpacity: 0.35, stroke: '#eb6123', strokeOpacity: 1}, leaf: {'fill': '#eee', fillOpacity: 1, stroke: '#eee', strokeOpacity: 1}}}" | |
| }, | |
| {"name": "configHeader", "init": "{height: 25, verticalOffset: 2.5}"}, | |
| {"name": "configFooter", "init": "{height: 25, verticalOffset: 2.5}"}, | |
| { | |
| "name": "configAxes", | |
| "init": "{x: {height: 40, title: {text: 'Quantity'}}, y: {width: 0.15*width, labels: {padding: 2.5}}}" | |
| }, | |
| {"name": "configIncludeRoot", "value": false}, | |
| { | |
| "name": "configRow", | |
| "description": "configurations for the rows", | |
| "init": "{rowHeight: 25, defaultFill: '#40407d'}" | |
| }, | |
| { | |
| "name": "configAnimationDuration", | |
| "init": "{showDetails: 750, nodeExpandCollapse: 500, sort: 500}" | |
| }, | |
| { | |
| "name": "configVerticalScrollbar", | |
| "description": "configurations for the vertical scroll bar", | |
| "update": "{enabled: actualHeight>adjustedHeight ,innerPadding: 10, track: {width: 10, height: extent([actualHeight, adjustedHeight])[0], fill: '#F3F3F3'}, handle: {height: max((adjustedHeight/actualHeight)*extent([actualHeight, adjustedHeight])[0], 30), fill: '#ddd', hover: {fill: '#888'}}}" | |
| }, | |
| { | |
| "name": "sortIndicatorMouseOver", | |
| "init": "true", | |
| "on": [ | |
| {"events": "@sort-order-group:mouseover", "update": "true"}, | |
| {"events": "@sort-order-group:mouseout", "update": "false"} | |
| ] | |
| }, | |
| { | |
| "name": "sortOrderDescending", | |
| "init": "true", | |
| "on": [ | |
| { | |
| "events": "@sort-order-group:click", | |
| "update": "isAnimating ? sortOrderDescending : !sortOrderDescending" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "sortChangeCount", | |
| "init": "0", | |
| "on": [ | |
| { | |
| "events": {"signal": "sortOrderDescending"}, | |
| "update": "sortChangeCount + (isAnimating ? 0 : 1)" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "sortOrder", | |
| "update": "sortOrderDescending ? 'descending' : 'ascending'" | |
| }, | |
| {"name": "isInitial", "value": true}, | |
| { | |
| "name": "interactionTypeHistory", | |
| "init": "[]", | |
| "on": [ | |
| { | |
| "events": {"signal": "sortChangeCount"}, | |
| "update": "length(interactionTypeHistory) === 0 ? ['sort '+(sortOrder)] : split((('sort '+ (sortOrder))+','+join(interactionTypeHistory)),',',2)" | |
| }, | |
| { | |
| "events": "@node-clickable-rect:pointerdown, @y-axis-label-clickable-rect:pointerdown", | |
| "update": "!datum.hasChildren ? interactionTypeHistory : length(interactionTypeHistory) === 0 ? ['node click'] : split('node click,' + join(interactionTypeHistory), ',',2)" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "interactionType", | |
| "update": "length(interactionTypeHistory) === 0 ? [] : interactionTypeHistory[0]" | |
| }, | |
| { | |
| "name": "plotAreaDimensions", | |
| "update": "{width: width-configAxes.y.width-(actualHeight > adjustedHeight ? configVerticalScrollbar.track.width/2 : 0), height: adjustedHeight}" | |
| }, | |
| { | |
| "name": "scrollY", | |
| "update": "actualHeight > adjustedHeight ? clamp(-verticalScrollPercentage*actualHeight, -(actualHeight-adjustedHeight), 0) : 0" | |
| }, | |
| { | |
| "name": "isAnimating", | |
| "init": "false", | |
| "on": [ | |
| { | |
| "events": {"type": "timer"}, | |
| "update": "!isValid(data('hierarchy-animation-bounds')) || !isValid(data('hierarchy-animation-bounds')[0]) ? false : !(timer > data('hierarchy-animation-bounds')[0].end+configAnimationDuration.nodeExpandCollapse)" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "animStartTick", | |
| "init": "0", | |
| "on": [ | |
| { | |
| "events": {"signal": "sortChangeCount"}, | |
| "update": "!isAnimating ? timer : animStartTick" | |
| }, | |
| { | |
| "events": "@node-clickable-rect:pointerup, @y-axis-label-clickable-rect:pointerup", | |
| "update": "!isAnimating && datum.hasChildren ? timer : animStartTick" | |
| }, | |
| { | |
| "events": "@expand-all-collapse-all-button-background:pointerdown{0,0}", | |
| "update": "rowAnimationTEased === 1 ? timer : animStartTick" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "animateCount", | |
| "init": "0", | |
| "on": [ | |
| { | |
| "events": {"signal": "sortChangeCount"}, | |
| "update": "isAnimating ? animateCount : animateCount + 1" | |
| }, | |
| { | |
| "events": {"signal": "nodeClickedDatum"}, | |
| "update": "isAnimating || !isValid(nodeClickedDatum) || !nodeClickedDatum.datum.hasChildren ? animateCount : animateCount + 1" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "rowAnimation", | |
| "update": "!isValid(interactionType) ? rowAnimation : {active: (timer < (animStartTick + (slice(interactionType, 0, 4)=== 'sort' ? configAnimationDuration.sort : configAnimationDuration.nodeExpandCollapse))), t: clamp((timer - animStartTick) / (slice(interactionType, 0, 4) === 'sort' ? configAnimationDuration.sort : configAnimationDuration.nodeExpandCollapse), 0, 1)}" | |
| }, | |
| { | |
| "name": "rowAnimationTEased", | |
| "update": "rowAnimation.t < 0.5 ? 4*pow(rowAnimation.t,3) : 1 - pow(-2*rowAnimation.t + 2, 3)/2" | |
| }, | |
| { | |
| "name": "timer", | |
| "init": "now()", | |
| "on": [{"events": {"type": "timer"}, "update": "now()"}] | |
| }, | |
| {"name": "initialTimestamp", "init": "now()"}, | |
| { | |
| "name": "nodeClickedDatum", | |
| "init": "null", | |
| "on": [ | |
| { | |
| "events": "@node-clickable-rect:click, @y-axis-label-clickable-rect:click", | |
| "update": "rowAnimationTEased === 1 ? datum : nodeClickedDatum" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "nodeClickStart", | |
| "init": "null", | |
| "on": [ | |
| { | |
| "events": "@node-clickable-rect:pointerdown, @y-axis-label-clickable-rect:pointerdown", | |
| "update": "datum" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "nodeMouseOverDatum", | |
| "init": "null", | |
| "on": [ | |
| { | |
| "events": "@node-clickable-rect:mouseover, @y-axis-label-clickable-rect:mouseover", | |
| "update": "datum" | |
| }, | |
| { | |
| "events": "@node-clickable-rect:mouseout, @y-axis-label-clickable-rect:mouseout", | |
| "update": "null" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "verticalScrollIncrement", | |
| "description": "Defines the vertical scroll step size as 1% of the scrollable height. Updates dynamically based on actualHeight and adjustedHeight.", | |
| "update": "0.1 * (actualHeight-adjustedHeight)/actualHeight" | |
| }, | |
| { | |
| "name": "verticalScrollbarMouseDown", | |
| "value": false, | |
| "on": [ | |
| { | |
| "events": "@vertical-scrollbar-group:pointerdown", | |
| "update": "configVerticalScrollbar.enabled" | |
| }, | |
| { | |
| "events": { | |
| "type": "mouseout", | |
| "scope": "view", | |
| "markname": "vertical-scrollbar-group", | |
| "filter": ["!event.pointerdown"] | |
| }, | |
| "update": "false" | |
| }, | |
| { | |
| "events": { | |
| "type": "mouseover", | |
| "scope": "scope", | |
| "markname": "vertical-scrollbar-group", | |
| "filter": ["event.pointerdown"] | |
| }, | |
| "update": "true" | |
| }, | |
| {"events": {"type": "pointerup"}, "update": "false"} | |
| ] | |
| }, | |
| { | |
| "name": "verticalScrollbarMouseOver", | |
| "description": "A boolean indicating whether the vertical scrollbar is being hovered over. Initialized to false. Updates to true when hovered and resets to false when the cursor leaves.", | |
| "value": false, | |
| "on": [ | |
| { | |
| "events": "@vertical-scrollbar-group:mouseover", | |
| "update": "configVerticalScrollbar.enabled" | |
| }, | |
| {"events": "@vertical-scrollbar-group:mouseout", "update": "false"} | |
| ] | |
| }, | |
| { | |
| "name": "verticalScrollPercentage", | |
| "description": "Tracks the current vertical scroll position as a percentage. Updates on: Mouse wheel events; Arrow key presses (ArrowUp or ArrowDown); Dragging interactions within rect-gantt-background; Clicking the vertical scrollbar; When the scrollbar is disabled, resets to 0.", | |
| "value": 0, | |
| "on": [ | |
| { | |
| "events": { | |
| "type": "wheel", | |
| "consume": true, | |
| "force": true, | |
| "source": "view", | |
| "filter": ["!event.ctrlKey", "!event.shiftKey"] | |
| }, | |
| "update": "clamp(verticalScrollPercentage - (-event.deltaY * pow(4, event.deltaMode) * 0.0015 * adjustedHeight / actualHeight), 0, (actualHeight-adjustedHeight)/actualHeight)" | |
| }, | |
| { | |
| "events": "window:keydown[event.key === 'ArrowUp' || event.key === 'ArrowDown']{0,0}", | |
| "update": "clamp(verticalScrollPercentage + verticalScrollIncrement * (event.key === 'ArrowDown' ? 1 : -1), 0, (actualHeight-adjustedHeight)/actualHeight)" | |
| }, | |
| { | |
| "events": { | |
| "type": "pointermove", | |
| "source": "scope", | |
| "markname": "vertical-scrollbar-group", | |
| "throttle": 50, | |
| "between": [{"type": "pointerdown"}, {"type": "pointerup"}] | |
| }, | |
| "update": "!configVerticalScrollbar.enabled ? 0 : invert('scaleScrollHandleY', y(group()))" | |
| }, | |
| { | |
| "events": {"signal": "!configVerticalScrollbar.enabled"}, | |
| "update": "0" | |
| }, | |
| { | |
| "events": {"signal": "verticalScrollPercentage"}, | |
| "update": "isFinite(verticalScrollPercentage) ? verticalScrollPercentage : 0" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "actualHeight", | |
| "description": "Computes the total height based on the number of all time-series blocks times configRow.rowHeight. Updates dynamically when the dataset changes", | |
| "update": "data('height-animation')[0]['animatedHeight']" | |
| }, | |
| { | |
| "name": "adjustedHeight", | |
| "description": "The initial height of the visualization. Rows that go beyond this height will require scrolling/panning", | |
| "update": "min((isPowerBIVisual ? (containerSize()[1] || windowSize()[1]) : configDesiredChartHeight), actualHeight)" | |
| }, | |
| { | |
| "name": "height", | |
| "update": "configHeader.height+configHeader.verticalOffset+configFooter.height+configFooter.verticalOffset+adjustedHeight" | |
| } | |
| ], | |
| "marks": [ | |
| { | |
| "name": "everything-group", | |
| "type": "group", | |
| "marks": [ | |
| { | |
| "name": "header-group", | |
| "type": "group", | |
| "encode": { | |
| "update": { | |
| "width": {"signal": "width"}, | |
| "height": {"signal": "configHeader.height"}, | |
| "clip": {"value": true} | |
| } | |
| } | |
| }, | |
| { | |
| "name": "static-chart-area-group", | |
| "type": "group", | |
| "from": {"data": "header-group"}, | |
| "encode": { | |
| "update": { | |
| "y": { | |
| "signal": "datum.bounds.y2", | |
| "offset": {"signal": "configHeader.verticalOffset"} | |
| }, | |
| "width": {"signal": "width"}, | |
| "height": { | |
| "signal": "plotAreaDimensions.height+configAxes.x.height" | |
| }, | |
| "clip": {"value": true} | |
| } | |
| }, | |
| "marks": [ | |
| { | |
| "name": "y-axis-group", | |
| "type": "group", | |
| "encode": { | |
| "update": { | |
| "y": {"signal": "configAxes.x.height"}, | |
| "width": {"signal": "configAxes.y.width"}, | |
| "height": {"signal": "plotAreaDimensions.height"}, | |
| "clip": {"value": true} | |
| } | |
| }, | |
| "marks": [ | |
| { | |
| "name": "y-axis-labels-group", | |
| "type": "group", | |
| "encode": {"update": {"y": {"signal": "scrollY"}}}, | |
| "marks": [ | |
| { | |
| "name": "y-axis-label-clickable-rect", | |
| "type": "rect", | |
| "from": {"data": "hierarchy-animation"}, | |
| "encode": { | |
| "update": { | |
| "tooltip": {"signal": "datum.sourceValues"}, | |
| "x": {"value": 0}, | |
| "width": {"signal": "configAxes.y.width"}, | |
| "y": {"field": "y1"}, | |
| "y2": {"field": "y2"}, | |
| "fill": {"value": "transparent"}, | |
| "cursor": { | |
| "signal": "datum.hasChildren ? 'pointer' : 'default'" | |
| } | |
| } | |
| } | |
| }, | |
| { | |
| "name": "y-axis-labels", | |
| "type": "text", | |
| "from": {"data": "hierarchy-animation"}, | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "text": {"field": "label"}, | |
| "y": { | |
| "field": "y1", | |
| "offset": {"signal": "configRow.rowHeight/2"} | |
| }, | |
| "fill": { | |
| "signal": "configColorScheme.axes.y.text.fill" | |
| }, | |
| "limit": { | |
| "signal": "configAxes.y.width-configAxes.y.labels.padding" | |
| }, | |
| "baseline": {"value": "middle"}, | |
| "fontWeight": { | |
| "signal": "isValid(nodeMouseOverDatum) ? nodeMouseOverDatum.id === datum.id ? 600 : 400 : 400" | |
| }, | |
| "opacity": { | |
| "signal": "isValid(nodeMouseOverDatum) ? nodeMouseOverDatum.id === datum.id ? 1 : 0.65 : 1" | |
| } | |
| } | |
| } | |
| } | |
| ] | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "sort-order-group", | |
| "type": "group", | |
| "from": {"data": "y-axis-group"}, | |
| "interactive": true, | |
| "encode": { | |
| "update": { | |
| "tooltip": {"signal": "'Sort ' + sortOrder"}, | |
| "y": {"signal": "datum.bounds.y1-configRow.rowHeight"}, | |
| "y2": {"signal": "datum.bounds.y1"}, | |
| "x": {"value": 0}, | |
| "fill": {"value": "transparent"}, | |
| "width": {"signal": "configAxes.y.width/2"}, | |
| "cursor": {"value": "pointer"} | |
| } | |
| }, | |
| "signals": [ | |
| { | |
| "name": "sortIndicatorAngle", | |
| "update": "sortOrderDescending ? lerp([-180, 0], rowAnimationTEased) : lerp([0, -180], rowAnimationTEased)" | |
| }, | |
| {"name": "x", "update": "4"}, | |
| { | |
| "name": "dy", | |
| "update": "sortOrderDescending ? lerp([2.5, 0], rowAnimationTEased) : lerp([0, 2.5], rowAnimationTEased)" | |
| } | |
| ], | |
| "marks": [ | |
| { | |
| "name": "y-axis-labels", | |
| "type": "text", | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "x": {"signal": "x"}, | |
| "text": {"value": "⮟"}, | |
| "angle": {"signal": "sortIndicatorAngle"}, | |
| "align": {"value": "center"}, | |
| "y": {"signal": "configRow.rowHeight/2"}, | |
| "dy": {"signal": "dy"}, | |
| "fill": {"signal": "configColorScheme.axes.y.text.fill"}, | |
| "opacity": { | |
| "signal": "sortIndicatorMouseOver ? 1 : 0.65" | |
| }, | |
| "baseline": {"value": "middle"} | |
| } | |
| } | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "x-axis-group", | |
| "type": "group", | |
| "from": {"data": "y-axis-group"}, | |
| "encode": { | |
| "update": { | |
| "x": {"signal": "datum.bounds.x2"}, | |
| "y": {"signal": "datum.bounds.y1"}, | |
| "width": {"signal": "plotAreaDimensions.width"} | |
| } | |
| }, | |
| "axes": [ | |
| { | |
| "scale": "scaleX", | |
| "orient": "top", | |
| "grid": true, | |
| "gridColor": { | |
| "signal": "configColorScheme.axes.x.grid.stroke" | |
| }, | |
| "tickColor": { | |
| "signal": "configColorScheme.axes.x.ticks.stroke" | |
| }, | |
| "domain": false, | |
| "title": {"signal": "configAxes.x.title.text"}, | |
| "titleY": {"signal": "-configAxes.x.height"}, | |
| "titleBaseline": "top", | |
| "labelColor": {"signal": "configColorScheme.axes.x.text.fill"} | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "static-plot-area-group", | |
| "type": "group", | |
| "encode": { | |
| "update": { | |
| "x": {"signal": "configAxes.y.width"}, | |
| "y": {"signal": "configAxes.x.height"}, | |
| "width": {"signal": "plotAreaDimensions.width"}, | |
| "height": {"signal": "plotAreaDimensions.height"}, | |
| "clip": {"value": true} | |
| } | |
| }, | |
| "marks": [ | |
| { | |
| "name": "plot-area-group", | |
| "type": "group", | |
| "encode": {"update": {"y": {"signal": "scrollY"}}}, | |
| "interactive": true, | |
| "marks": [ | |
| { | |
| "name": "plot-background-rect", | |
| "type": "rect", | |
| "encode": { | |
| "update": { | |
| "x": {"value": 1}, | |
| "y": {"signal": "-scrollY+1"}, | |
| "width": {"signal": "plotAreaDimensions.width-1"}, | |
| "height": {"signal": "plotAreaDimensions.height-1"}, | |
| "fill": {"value": "transparent"} | |
| } | |
| } | |
| }, | |
| { | |
| "name": "node-clickable-rect", | |
| "type": "rect", | |
| "from": {"data": "hierarchy-animation"}, | |
| "interactive": true, | |
| "encode": { | |
| "update": { | |
| "x": {"value": 0}, | |
| "x2": { | |
| "signal": "scale('scaleX', datum.descendantQuantity)" | |
| }, | |
| "y": {"field": "y1"}, | |
| "y2": {"field": "y2"}, | |
| "fill": {"value": "transparent"}, | |
| "cursor": { | |
| "signal": "datum.hasChildren ? 'pointer' : 'default'" | |
| } | |
| } | |
| } | |
| }, | |
| { | |
| "name": "node-background-rect", | |
| "type": "rect", | |
| "from": {"data": "hierarchy-animation"}, | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "x": {"value": 0}, | |
| "x2": { | |
| "signal": "scale('scaleX', datum.descendantQuantity)" | |
| }, | |
| "y": {"field": "y1WithPadding"}, | |
| "y2": {"field": "y2WithPadding"}, | |
| "fill": {"signal": "background"} | |
| } | |
| } | |
| }, | |
| { | |
| "name": "node-rect", | |
| "type": "rect", | |
| "from": {"data": "hierarchy-animation"}, | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "x": {"value": 0}, | |
| "x2": { | |
| "signal": "scale('scaleX', datum.descendantQuantity)" | |
| }, | |
| "y": {"field": "y1WithPadding"}, | |
| "y2": {"field": "y2WithPadding"}, | |
| "fill": { | |
| "signal": "datum.hasChildren ? configColorScheme.bars.parent.fill : configColorScheme.bars.parent.leaf.fill" | |
| }, | |
| "fillOpacity": { | |
| "signal": "datum.hasChildren ? configColorScheme.bars.parent.fillOpacity : configColorScheme.bars.parent.leaf.fillOpacity" | |
| }, | |
| "stroke": { | |
| "signal": "datum.hasChildren ? configColorScheme.bars.parent.stroke : configColorScheme.bars.parent.leaf.stroke" | |
| }, | |
| "strokeOpacity": { | |
| "signal": "datum.hasChildren ? configColorScheme.bars.parent.strokeOpacity : configColorScheme.bars.parent.leaf.strokeOpacity" | |
| }, | |
| "opacity": { | |
| "signal": "isValid(nodeMouseOverDatum) ? nodeMouseOverDatum.id === datum.id ? 1 : 0.65 : 1" | |
| } | |
| } | |
| } | |
| }, | |
| { | |
| "name": "plot-container-rect", | |
| "type": "rect", | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "x": {"value": 0.5}, | |
| "y": {"signal": "-scrollY+1"}, | |
| "width": {"signal": "plotAreaDimensions.width-1"}, | |
| "height": {"signal": "plotAreaDimensions.height-1"}, | |
| "fill": {"value": "transparent"}, | |
| "stroke": {"value": "#888"} | |
| } | |
| } | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "footer-group", | |
| "type": "group", | |
| "from": {"data": "static-chart-area-group"}, | |
| "encode": { | |
| "update": { | |
| "y": {"signal": "datum.bounds.y2+configHeader.verticalOffset"}, | |
| "width": {"signal": "width"}, | |
| "height": {"signal": "configHeader.height"}, | |
| "clip": {"value": true} | |
| } | |
| } | |
| }, | |
| { | |
| "name": "vertical-scrollbar-group", | |
| "description": "the group of marks that make up the vertical scrollbar", | |
| "type": "group", | |
| "from": {"data": "static-chart-area-group"}, | |
| "interactive": true, | |
| "encode": { | |
| "update": { | |
| "y": { | |
| "signal": "datum.bounds.y1+configAxes.x.height+(verticalScrollbarMouseDown ? -configAxes.x.height-configHeader.height-configHeader.verticalOffset: 1)" | |
| }, | |
| "x": { | |
| "signal": "verticalScrollbarMouseDown ? 0 : datum.bounds.x2" | |
| }, | |
| "width": { | |
| "signal": "actualHeight > adjustedHeight && configVerticalScrollbar.enabled ? verticalScrollbarMouseDown ? datum.bounds.x2+plotAreaDimensions.width+configVerticalScrollbar.track.width : configVerticalScrollbar.track.width : 0" | |
| }, | |
| "height": { | |
| "signal": "actualHeight > adjustedHeight && configVerticalScrollbar.enabled ? ((verticalScrollbarMouseDown ? +configAxes.x.height+configHeader.height+configHeader.verticalOffset+configFooter.height+configFooter.verticalOffset : 0) + plotAreaDimensions.height) : 0" | |
| }, | |
| "fill": {"value": "transparent"}, | |
| "cursor": {"signal": "'pointer'"}, | |
| "zindex": {"value": 999} | |
| } | |
| }, | |
| "marks": [ | |
| { | |
| "name": "rect_verticalScrollbar_track", | |
| "description": "the track for the scrollbar", | |
| "type": "rect", | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "y": { | |
| "signal": "verticalScrollbarMouseDown ? configAxes.x.height+configHeader.height+configHeader.verticalOffset : 0" | |
| }, | |
| "x": { | |
| "signal": "verticalScrollbarMouseDown ? parent.bounds.x2: 0" | |
| }, | |
| "width": { | |
| "signal": "configVerticalScrollbar.enabled ? configVerticalScrollbar.track.width : 0" | |
| }, | |
| "height": { | |
| "signal": "configVerticalScrollbar.enabled ? configVerticalScrollbar.track.height : 0" | |
| }, | |
| "fill": {"signal": "configVerticalScrollbar.track.fill"}, | |
| "stroke": {"value": "#888"}, | |
| "strokeWidth": {"signal": "1"} | |
| } | |
| } | |
| }, | |
| { | |
| "name": "rect_verticalScrollbar_handle", | |
| "description": "the handle for the scrollbar", | |
| "type": "rect", | |
| "interactive": false, | |
| "encode": { | |
| "update": { | |
| "x": { | |
| "signal": "verticalScrollbarMouseDown ? parent.bounds.x2: 0" | |
| }, | |
| "width": { | |
| "signal": "configVerticalScrollbar.enabled ? configVerticalScrollbar.track.width : 0" | |
| }, | |
| "y": { | |
| "signal": "(scale('scaleScrollHandleY', verticalScrollPercentage)-configVerticalScrollbar.handle.height)" | |
| }, | |
| "y2": { | |
| "signal": "scale('scaleScrollHandleY', verticalScrollPercentage)" | |
| }, | |
| "fill": { | |
| "signal": "verticalScrollbarMouseOver || verticalScrollbarMouseDown ? configVerticalScrollbar.handle.hover.fill : configVerticalScrollbar.handle.fill" | |
| } | |
| } | |
| } | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| ], | |
| "scales": [ | |
| { | |
| "name": "scaleX", | |
| "type": "linear", | |
| "domain": {"data": "hierarchy-visible", "field": "descendantQuantity"}, | |
| "range": {"signal": "[0, width-configAxes.y.width]"}, | |
| "clamp": true, | |
| "zero": true | |
| }, | |
| { | |
| "name": "scaleScrollHandleY", | |
| "type": "linear", | |
| "domain": [0, {"signal": "(actualHeight-adjustedHeight)/actualHeight"}], | |
| "range": { | |
| "signal": "[(verticalScrollbarMouseDown ? configAxes.x.height+configHeader.height+configHeader.verticalOffset: 0)+configVerticalScrollbar.handle.height, (verticalScrollbarMouseDown ? configAxes.x.height+configHeader.height+configHeader.verticalOffset : 0)+configVerticalScrollbar.track.height]" | |
| }, | |
| "clamp": true | |
| } | |
| ], | |
| "data": [ | |
| { | |
| "name": "dataset", | |
| "url": "https://raw.githubusercontent.com/Giammaria/PublicFiles/refs/heads/master/data/20251031_halloween_candy_hierarchy" | |
| }, | |
| { | |
| "name": "dataset-formatted", | |
| "source": "dataset", | |
| "transform": [ | |
| {"type": "formula", "expr": "datum[configFields.id]", "as": "id"}, | |
| { | |
| "type": "formula", | |
| "expr": "datum[configFields.parentId]", | |
| "as": "parentId" | |
| }, | |
| {"type": "formula", "expr": "datum[configFields.label]", "as": "label"}, | |
| { | |
| "type": "formula", | |
| "expr": "replace(replace(lower((datum[configFields.name] || '')), /[^a-z0-9]+/g, '_'), /^_+|_+$/g, '')", | |
| "as": "name" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum[configFields.childQuantity]", | |
| "as": "childQuantity" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum[configFields.descendantQuantity]", | |
| "as": "descendantQuantity" | |
| }, | |
| {"type": "stratify", "key": "id", "parentKey": "parentId"}, | |
| { | |
| "type": "formula", | |
| "expr": "{id: datum.id, parentId: datum.parentId}", | |
| "as": "idObj" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-initial", | |
| "source": "dataset-formatted", | |
| "transform": [ | |
| { | |
| "type": "filter", | |
| "expr": "configIncludeRoot ? true : (isValid(datum['parentId']))" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "length(treeAncestors('dataset-formatted', datum['id']))-(configIncludeRoot ? 0 : 1)", | |
| "as": "level" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "indexof(pluck(data('dataset-formatted'), 'parentId'), datum['id'])>=0", | |
| "as": "hasChildren" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "slice(pluck(treeAncestors('dataset-formatted', datum['id']), 'id'), 1)", | |
| "as": "ancestorIds" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "pluck(data('dataset-formatted'), 'idObj')", | |
| "as": "immediateChildrenIds" | |
| }, | |
| { | |
| "type": "flatten", | |
| "fields": ["immediateChildrenIds"], | |
| "as": ["immediateChildrenIds"] | |
| }, | |
| { | |
| "type": "filter", | |
| "expr": "datum.id === datum.immediateChildrenIds.id || datum.id === datum.immediateChildrenIds.parentId" | |
| }, | |
| { | |
| "type": "aggregate", | |
| "ops": ["values"], | |
| "fields": ["idObs"], | |
| "groupby": [ | |
| "id", | |
| "parentId", | |
| "name", | |
| "label", | |
| "childQuantity", | |
| "descendantQuantity", | |
| "level", | |
| "hasChildren", | |
| "ancestorIds" | |
| ], | |
| "as": ["immediateChildrenIds"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "pluck(slice(pluck(datum.immediateChildrenIds, 'immediateChildrenIds'), 1), 'id')", | |
| "as": "immediateChildrenIds" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.level === 1 ? datum.id : length(datum.ancestorIds) === 0 ? null : length(datum.ancestorIds) === 1 ? datum.ancestorIds[0] : slice(datum.ancestorIds,-2)[1-1]", | |
| "as": "firstAncestorId" | |
| }, | |
| {"type": "formula", "expr": "null", "as": "isExpanded"}, | |
| { | |
| "type": "formula", | |
| "expr": "!datum.hasChildren ? null : isInitial ? datum.level < 1 ? 1 : 0 : datum.isExpanded", | |
| "as": "isExpanded" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "!datum.hasChildren ? null : interactionTypeHistory[0] === 'expandAll' ? 1 : interactionTypeHistory[0] === 'collapseAll' ? 0 : datum.isExpanded", | |
| "as": "isExpanded" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "!datum.hasChildren ? null : isValid(nodeClickedDatum) && nodeClickedDatum.datum.id === datum.id ? nodeClickedDatum.datum.isExpanded === 0 ? 1 : 0 : datum.isExpanded", | |
| "as": "isExpanded" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "interactionTypeHistory[0] || 'sort '+ (sortOrder)", | |
| "as": "type" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-current-parent-collapsed-candidates", | |
| "source": "hierarchy-initial", | |
| "transform": [ | |
| {"type": "filter", "expr": "datum.isExpanded === 0"}, | |
| {"type": "filter", "expr": "datum.hasChildren"}, | |
| {"type": "project", "fields": ["id", "name"]} | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-current-parent-expanded-candidates", | |
| "source": "hierarchy-initial", | |
| "transform": [ | |
| {"type": "filter", "expr": "datum.isExpanded === 1"}, | |
| {"type": "filter", "expr": "datum.hasChildren"}, | |
| {"type": "project", "fields": ["id", "name"]} | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-visible", | |
| "source": "hierarchy-initial", | |
| "transform": [ | |
| { | |
| "type": "lookup", | |
| "from": "hierarchy-initial", | |
| "key": "parentId", | |
| "fields": ["id"], | |
| "values": ["isExpanded"], | |
| "as": ["parentIsExpanded"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "pluck(data('hierarchy-current-parent-collapsed-candidates'), 'id')", | |
| "as": "collapsedIds" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "pluck(data('hierarchy-current-parent-expanded-candidates'), 'id')", | |
| "as": "expandedIds" | |
| }, | |
| { | |
| "type": "filter", | |
| "expr": "!test('(\\\\b\\\\d+\\\\b).*\\\\b\\\\1\\\\b', (join(datum.ancestorIds, ',')+','+join(datum.collapsedIds, ','))) || (datum.parentIsExpanded && indexof(datum.expandedIds , datum.parentId) >= 0)" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "1 >= datum.level || indexof(pluck(data('hierarchy-current-parent-expanded-candidates'), 'id'), datum.parentId) >= 0", | |
| "as": "isVisible" | |
| }, | |
| {"type": "filter", "expr": "datum.isVisible"}, | |
| { | |
| "type": "collect", | |
| "sort": {"field": "sort", "order": {"signal": "sortOrder"}} | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["row_number"], | |
| "as": ["index"], | |
| "sort": {"field": "sort", "order": {"signal": "sortOrder"}} | |
| }, | |
| { | |
| "type": "joinaggregate", | |
| "ops": ["min"], | |
| "fields": ["index"], | |
| "as": ["minIndex"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.index-datum.minIndex", | |
| "as": "index" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "(datum.index)*configRow.rowHeight", | |
| "as": "y1" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "isInitial && datum.level <= 1 ? 1 : null", | |
| "as": "opacity" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-last-visible-target", | |
| "on": [ | |
| { | |
| "trigger": "nodeClickStart", | |
| "remove": false, | |
| "insert": "{data: data('hierarchy-visible-target'), timestamp: now(), source: 'hierarchy-visible-target'}" | |
| }, | |
| { | |
| "trigger": "sortChangeCount", | |
| "remove": false, | |
| "insert": "{data: data('hierarchy-visible-target'), timestamp: now(), source: 'hierarchy-visible-target'}" | |
| }, | |
| { | |
| "trigger": "timer < (initialTimestamp + 1000)", | |
| "remove": false, | |
| "insert": "{data: data('hierarchy-initial'), timestamp: now(), source: 'hierarchy-initial'}" | |
| } | |
| ], | |
| "transform": [ | |
| { | |
| "type": "window", | |
| "ops": ["row_number"], | |
| "sort": { "field": "timestamp", "order": "descending" }, | |
| "as": ["index"] | |
| }, | |
| { | |
| "type": "filter", | |
| "expr": "datum.index === 1" | |
| }, | |
| { | |
| "type": "flatten", | |
| "fields": ["data"] | |
| }, | |
| { | |
| "type": "filter", | |
| "expr": "isValid(datum.data)" | |
| }, | |
| /* promote everything downstream expects */ | |
| { "type": "formula", "expr": "datum.data.id", "as": "id" }, | |
| { "type": "formula", "expr": "datum.data.parentId", "as": "parentId" }, | |
| { "type": "formula", "expr": "datum.data.name", "as": "name" }, | |
| { "type": "formula", "expr": "datum.data.level", "as": "level" }, | |
| { "type": "formula", "expr": "datum.data.hasChildren", "as": "hasChildren" }, | |
| { "type": "formula", "expr": "datum.data.ancestorIds", "as": "ancestorIds" }, | |
| { "type": "formula", "expr": "datum.data.immediateChildrenIds", "as": "immediateChildrenIds" }, | |
| { "type": "formula", "expr": "datum.data.isExpanded", "as": "isExpanded" }, | |
| { "type": "formula", "expr": "datum.data.isVisible", "as": "isVisible" }, | |
| /* sorting / positioning fields that the animation lookup uses */ | |
| { "type": "formula", "expr": "datum.data.sort", "as": "sort" }, | |
| { "type": "formula", "expr": "datum.data.sortOverall", "as": "sortOverall" }, | |
| { "type": "formula", "expr": "datum.data.y1", "as": "y1" }, | |
| { "type": "formula", "expr": "datum.data.opacity", "as": "opacity" }, | |
| /* keep quantities since they're in the original hierarchy */ | |
| { "type": "formula", "expr": "datum.data.childQuantity", "as": "childQuantity" }, | |
| { "type": "formula", "expr": "datum.data.descendantQuantity", "as": "descendantQuantity" }, | |
| /* keep index/animateCount if present on the target */ | |
| { "type": "formula", "expr": "datum.data.index", "as": "index" }, | |
| { "type": "formula", "expr": "datum.data.animateCount", "as": "animateCount" } | |
| ] | |
| } | |
| , | |
| { | |
| "name": "hierarchy-visible-source", | |
| "on": [ | |
| { | |
| "trigger": "data('hierarchy-last-visible-target')", | |
| "remove": true, | |
| "insert": "data('hierarchy-last-visible-target')" | |
| } | |
| ], | |
| "transform": [] | |
| }, | |
| { | |
| "name": "height", | |
| "source": "hierarchy-visible", | |
| "transform": [ | |
| {"type": "aggregate", "ops": ["count"], "as": ["height"]}, | |
| { | |
| "type": "formula", | |
| "expr": "(datum.height)*configRow.rowHeight", | |
| "as": "height" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "height-animation", | |
| "values": [{"height": null, "timestamp": null}], | |
| "on": [ | |
| { | |
| "trigger": "animStartTick", | |
| "insert": "{height: data('height')[0].height, timestamp: now()}", | |
| "remove": false | |
| } | |
| ], | |
| "transform": [ | |
| { | |
| "type": "formula", | |
| "expr": "datum.height || data('height')[0].height", | |
| "as": "height" | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["row_number"], | |
| "sort": {"field": "timestamp", "order": "descending"}, | |
| "as": ["index"] | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["lead"], | |
| "fields": ["height"], | |
| "sort": {"field": "index"}, | |
| "as": ["previousHeight"] | |
| }, | |
| {"type": "filter", "expr": "(datum.height !== datum.previousHeight)"}, | |
| { | |
| "type": "formula", | |
| "expr": "datum.previousHeight || datum.height", | |
| "as": "previousHeight" | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["row_number"], | |
| "sort": {"field": "index", "order": "ascending"}, | |
| "as": ["index"] | |
| }, | |
| {"type": "filter", "expr": "datum.index === 1"}, | |
| {"type": "formula", "expr": "datum.timestamp || now()", "as": "start"}, | |
| { | |
| "type": "formula", | |
| "expr": "datum.start + configAnimationDuration.nodeExpandCollapse", | |
| "as": "end" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "timer <= datum.end ? clamp((timer-datum.start)/(datum.end-datum.start), 0,1) : 1", | |
| "as": "t" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.t < 0.5 ? 4 * pow(datum.t, 3) : 1 - pow(-2 * datum.t + 2, 3) / 2", | |
| "as": "tEased" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.previousHeight+(datum.height-datum.previousHeight)*datum.tEased", | |
| "as": "animatedHeight" | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-visible-target", | |
| "source": "hierarchy-initial", | |
| "transform": [ | |
| { | |
| "type": "lookup", | |
| "from": "hierarchy-visible", | |
| "key": "id", | |
| "fields": ["id"], | |
| "as": ["visibleValues"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "isValid(datum.visibleValues)", | |
| "as": "isVisible" | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["row_number"], | |
| "groupby": ["level"], | |
| "sort": { | |
| "field": "descendantQuantity", | |
| "order": {"signal": "sortOrder"} | |
| }, | |
| "as": ["sort"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "slice('000000'+datum.level, -6)+'_'+slice('000000'+datum.sort, -6)", | |
| "as": "sortOverall" | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["row_number"], | |
| "sort": {"field": "sortOverall", "order": {"signal": "sortOrder"}}, | |
| "as": ["sortOverall"] | |
| }, | |
| { | |
| "type": "collect", | |
| "sort": {"field": "sortOverall", "order": {"signal": "sortOrder"}} | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "(datum.sort-1)*configRow.rowHeight", | |
| "as": "y1" | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["max"], | |
| "fields": ["y1"], | |
| "frame": [null, 0], | |
| "sort": {"field": "sort"}, | |
| "as": ["y1"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "(datum.level <= 1 || datum.isVisible) ? 1 : 0", | |
| "as": "opacity" | |
| }, | |
| { | |
| "type": "window", | |
| "ops": ["dense_rank"], | |
| "as": ["index"], | |
| "sort": {"field": "sort", "order": {"signal": "sortOrder"}} | |
| }, | |
| {"type": "formula", "expr": "animateCount", "as": "animateCount"}, | |
| { | |
| "type": "collect", | |
| "sort": {"field": "sortOverall", "order": {"signal": "sortOrder"}} | |
| } | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-pre-animation", | |
| "on": [ | |
| { | |
| "trigger": "animateCount", | |
| "insert": "data('hierarchy-initial')", | |
| "remove": true | |
| }, | |
| { | |
| "trigger": "timer < (initialTimestamp+1000)", | |
| "remove": true, | |
| "insert": "data('hierarchy-initial')" | |
| } | |
| ], | |
| "transform": [ | |
| { | |
| "type": "lookup", | |
| "key": "id", | |
| "fields": ["id"], | |
| "from": "hierarchy-visible-source", | |
| "as": ["sourceValues"] | |
| }, | |
| { | |
| "type": "lookup", | |
| "key": "id", | |
| "fields": ["id"], | |
| "from": "hierarchy-visible-target", | |
| "as": ["targetValues"] | |
| }, | |
| { | |
| "type": "filter", | |
| "expr": "isValid(datum.sourceValues) || isValid(datum.targetValues)" | |
| }, | |
| { | |
| "type": "formula", | |
| "as": "sourceOpacity", | |
| "expr": "isValid(datum.sourceValues) ? datum.sourceValues.opacity : 0" | |
| }, | |
| { | |
| "type": "formula", | |
| "as": "targetOpacity", | |
| "expr": "isValid(datum.targetValues) ? datum.targetValues.opacity : 0" | |
| }, | |
| { | |
| "type": "formula", | |
| "as": "sourceY1", | |
| "expr": "isValid(datum.sourceValues) ? datum.sourceValues.y1 : (isValid(datum.targetValues) ? datum.targetValues.collapseAllY1 : null)" | |
| }, | |
| { | |
| "type": "formula", | |
| "as": "targetY1", | |
| "expr": "isValid(datum.targetValues) ? datum.targetValues.y1 : (isValid(datum.sourceValues) ? datum.sourceValues.collapseAllY1 : null)" | |
| }, | |
| { | |
| "type": "filter", | |
| "expr": "isValid(datum.sourceY1) && isValid(datum.targetY1)" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.targetValues.isExpanded", | |
| "as": "isExpanded" | |
| }, | |
| {"type": "collect", "sort": {"field": "sort"}} | |
| ] | |
| }, | |
| { | |
| "name": "hierarchy-animation-bounds", | |
| "values": [{"start": 0, "end": -1}], | |
| "on": [ | |
| { | |
| "trigger": "animStartTick", | |
| "insert": "{start: animStartTick, end: animStartTick+configAnimationDuration.nodeExpandCollapse}", | |
| "remove": true | |
| } | |
| ], | |
| "transform": [] | |
| }, | |
| { | |
| "name": "hierarchy-animation", | |
| "source": "hierarchy-pre-animation", | |
| "transform": [ | |
| { | |
| "type": "lookup", | |
| "from": "dataset-formatted", | |
| "key": "id", | |
| "fields": ["id"], | |
| "values": ["name", "childQuantity", "descendantQuantity", "label"] | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "scale('scaleXGanttTimeSeries', datum.startDate)", | |
| "as": "x1" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "scale('scaleXGanttTimeSeries', datum.endDate)", | |
| "as": "x2" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "scale('scaleXGanttTimeSeries', span([datum.startDate, datum.endDate])*datum.decimalPercentComplete+datum.startDate)", | |
| "as": "x2Progress" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "lerp([datum.sourceValues.sort, datum.targetValues.sort], rowAnimationTEased)", | |
| "as": "sort" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "(datum.sort-1)*configRow.rowHeight", | |
| "as": "y1" | |
| }, | |
| {"type": "formula", "expr": "datum.y1+configRow.rowHeight", "as": "y2"}, | |
| {"type": "formula", "expr": "scrollY+datum.y2", "as": "bufferHeight"}, | |
| { | |
| "type": "filter", | |
| "expr": "datum.bufferHeight <= (adjustedHeight+configRow.rowHeight) && (datum.bufferHeight) >= 0" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.y1+configRow.rowHeight*(0.225)", | |
| "as": "y1WithPadding" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "datum.y2-configRow.rowHeight*(0.225)", | |
| "as": "y2WithPadding" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "lerp([datum.sourceOpacity, datum.targetOpacity], rowAnimationTEased)*0.35", | |
| "as": "opacity" | |
| }, | |
| { | |
| "type": "formula", | |
| "expr": "lerp([datum.sourceOpacity, datum.targetOpacity], rowAnimationTEased)", | |
| "as": "fullOpacity" | |
| }, | |
| {"type": "filter", "expr": "datum.opacity>0"}, | |
| {"type": "collect", "sort": {"field": "sort"}} | |
| ] | |
| } | |
| ] | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment