I’m trying to use Vega’s force
transform to create a node-link diagram where nodes are positioned horizontally based on a group
field (e.g., group 1
far left, group 8
far right). At the same time, I want to retain force-directed layout dynamics — i.e., link distances and basic node repulsion.
Is there a supported way to align nodes horizontally (or bias their x-position) based on a field like group, while using the force transform? Or is there a better idiomatic approach in Vega for combining group alignment with force-directed layout?
I have seen there is an open thread here https://groups.google.com/g/vega-js/c/_rtSsc_QnqM/m/sWGr-wnjBwAJ dating a few years back on the same topic with no answer. My basis so far has been the code shared publicly by Davide Bacci (kudos to Davide), available here https://github.com/PBI-David/Deneb-Showcase#force-direct-graph-dynamic.
I have enhanced the code slightly to implement some ad-hoc features, however I am still stuck on the following requirement: initially display nodes from left to right according to the value contained in the field "group". This would help me orienting the force-directed diagram in the right way from the beginning and avoid pulling multiple nodes interactively to make it readable.
My data is pretty much similar to David's structure, hence I am not sure it is worth sharing it here. Anyone having an understanding on the topic and able to spare some time for a clarification?
My initial idea was to use a field-derived force:
{
"force": "x",
"x": {"field": "fx"},
"strength": 0.2
}
And compute fx beforehand via:
{
"type": "formula",
"as": "fx",
"expr": "toNumber(datum.group) * 200"
}
However, when I try this, I encounter runtime errors like:
Cannot set properties of undefined (setting 'index') Invalid SVG path, incorrect parameter type
When I remove the "x" force or simplify it, the error goes away. But this defeats the purpose — I'd really like a way to “nudge” nodes horizontally based on their group, while still keeping force dynamics intact.
This is the full code I have compiled so far (95% from David Bacci's repository)
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "Dataviz by David Bacci: https://www.linkedin.com/in/davbacci/",
"width": 1400,
"height": 500,
"padding": {
"left": 0,
"right": 0,
"top": 0,
"bottom": 0
},
"autosize": "pad",
"signals": [
{
"name": "xrange",
"update": "[0, width]"
},
{
"name": "yrange",
"update": "[height, 0]"
},
{
"name": "xext",
"update": "[-width * 1, width * 2]"
},
{
"name": "yext",
"update": "[height * 2, -height * 1]"
},
{
"name": "down",
"value": null,
"on": [
{
"events": "mouseup,touchend",
"update": "null"
},
{
"events": "mousedown, touchstart",
"update": "xy()"
},
{
"events": "symbol:mousedown, symbol:touchstart",
"update": "null"
}
]
},
{
"name": "xcur",
"value": null,
"on": [
{
"events": "mousedown, touchstart, touchend",
"update": "xdom"
}
]
},
{
"name": "ycur",
"value": null,
"on": [
{
"events": "mousedown, touchstart, touchend",
"update": "ydom"
}
]
},
{
"name": "delta",
"value": [0, 0],
"on": [
{
"events": [
{
"source": "window",
"type": "mousemove",
"consume": true,
"between": [
{"type": "mousedown"},
{
"source": "window",
"type": "mouseup"
}
]
},
{
"type": "touchmove",
"consume": true,
"filter": "event.touches.length === 1"
}
],
"update": "down ? [down[0]-x(), y()-down[1]] : [0,0]"
}
]
},
{
"name": "anchor",
"value": [0, 0],
"on": [
{
"events": "wheel",
"update": "[invert('xscale', x()), invert('yscale', y())]"
},
{
"events": {
"type": "touchstart",
"filter": "event.touches.length===2"
},
"update": "[(xdom[0] + xdom[1]) / 2, (ydom[0] + ydom[1]) / 2]"
}
]
},
{
"name": "zoom",
"value": 1,
"on": [
{
"events": "wheel!",
"force": true,
"update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))"
},
{
"events": {"signal": "dist2"},
"force": true,
"update": "dist1 / dist2"
},
{
"events": [
{
"source": "view",
"type": "dblclick"
}
],
"update": "1"
}
]
},
{
"name": "dist1",
"value": 0,
"on": [
{
"events": {
"type": "touchstart",
"filter": "event.touches.length===2"
},
"update": "pinchDistance(event)"
},
{
"events": {"signal": "dist2"},
"update": "dist2"
}
]
},
{
"name": "dist2",
"value": 0,
"on": [
{
"events": {
"type": "touchmove",
"consume": true,
"filter": "event.touches.length===2"
},
"update": "pinchDistance(event)"
}
]
},
{
"name": "xdom",
"update": "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": [
{
"source": "view",
"type": "dblclick"
}
],
"update": "xrange"
}
]
},
{
"name": "ydom",
"update": "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": [
{
"source": "view",
"type": "dblclick"
}
],
"update": "yrange"
}
]
},
{
"name": "size",
"update": "clamp(20 / span(xdom), 1, 1000)"
},
{
"name": "cx",
"update": "width / 2",
"on": [
{
"events": "[symbol:mousedown, window:mouseup] > window:mousemove",
"update": " cx==width/2?cx+0.001:width/2"
}
]
},
{
"name": "cy",
"update": "height / 2"
},
{
"name": "nodeRadiusKey",
"description": "q=increase size, a=decrease size",
"value": 50,
"on": [
{
"events": "window:keypress",
"update": "event.key=='a'&&nodeRadiusKey>1?nodeRadiusKey-1:event.key=='q'?nodeRadiusKey+1:nodeRadiusKey"
}
]
},
{
"name": "nodeRadius",
"value": 40,
"bind": {
"input": "range",
"min": 1,
"max": 50,
"step": 1
},
"on": [
{
"events": {
"signal": "nodeRadiusKey"
},
"update": "nodeRadiusKey"
}
]
},
{
"name": "nodeCharge",
"value": 0,
"bind": {
"input": "range",
"min": -100,
"max": 700,
"step": 1
}
},
{
"name": "linkDistance",
"value": 500,
"bind": {
"input": "range",
"min": 5,
"max": 700,
"step": 1
}
},
{
"description": "State variable for active node fix status.",
"name": "fix",
"value": false,
"on": [
{
"events": "symbol:mouseout[!event.buttons], window:mouseup",
"update": "false"
},
{
"events": "symbol:mouseover",
"update": "fix || true",
"force": true
},
{
"events": "[symbol:mousedown, window:mouseup] > window:mousemove!",
"update": "xy()",
"force": true
}
]
},
{
"description": "Graph node most recently interacted with.",
"name": "node",
"value": null,
"on": [
{
"events": "symbol:mouseover",
"update": "fix === true ? datum.index : node"
}
]
},
{
"name": "nodeHover",
"value": {
"id": null,
"connections": []
},
"on": [
{
"events": "symbol:mouseover",
"update": "{'id':datum.index, 'connections':split(datum.sources+','+datum.targets,',')}"
},
{
"events": "symbol:mouseout",
"update": "{'id':null, 'connections':[]}"
}
]
},
{
"description": "Flag to restart Force simulation upon data changes.",
"name": "restart",
"value": false,
"on": [
{
"events": {"signal": "fix"},
"update": "fix && fix.length"
}
]
}
],
"data": [
{
"name": "dataset"
},
{
"name": "link-data",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "datum.type == 'link' && datum.DenebFiltering == '1'"
}
]
},
{
"name": "source-connections",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "datum.type == 'link' && datum.DenebFiltering == '1'"
},
{
"type": "aggregate",
"groupby": ["source"],
"ops": ["values"],
"fields": ["target"],
"as": ["connections"]
},
{
"type": "formula",
"as": "targets",
"expr": "pluck(datum.connections, 'target')"
}
]
},
{
"name": "target-connections",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "datum.type == 'link' && datum.DenebFiltering == '1'"
},
{
"type": "aggregate",
"groupby": ["target"],
"ops": ["values"],
"fields": ["source"],
"as": ["connections"]
},
{
"type": "formula",
"as": "sources",
"expr": "pluck(datum.connections, 'source')"
}
]
},
{
"name": "node-data",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "datum.type == 'node' && datum.DenebFiltering == '1'"
},
{
"type": "lookup",
"from": "source-connections",
"key": "source",
"fields": ["name"],
"values": ["targets"],
"as": ["targets"],
"default": [""]
},
{
"type": "lookup",
"from": "target-connections",
"key": "target",
"fields": ["name"],
"values": ["sources"],
"as": ["sources"],
"default": [""]
},
{
"type": "force",
"iterations": 300,
"restart": {
"signal": "restart"
},
"signal": "force",
"forces": [
{
"force": "center",
"x": {"signal": "cx"},
"y": {"signal": "cy"}
},
{
"force": "collide",
"radius": {
"signal": "sqrt(4 * nodeRadius * nodeRadius)"
},
"iterations": 1,
"strength": 0.7
},
{
"force": "nbody",
"strength": {
"signal": "nodeCharge"
}
},
{
"force": "link",
"links": "link-data",
"distance": {
"signal": "linkDistance"
},
"id": "name"
}
]
},
{
"type": "formula",
"as": "fx",
"expr": "fix[0]!=null && node==datum.index ?invert('xscale',fix[0]):null"
},
{
"type": "formula",
"as": "fy",
"expr": "fix[1]!=null && node==datum.index ?invert('yscale',fix[1]):null"
}
]
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"domain": {
"data": "node-data",
"field": "group"
},
"range": [
"#C28B2C", // 1. Muted amber brown (warm earth tone)
"#56B5C9", // 2. Light cyan blue (balanced, fresh)
"#9ACF3C", // 3. Softer chartreuse green (lively but grounded)
"#4C72C1", // 4. Muted royal blue (less saturated, more elegant)
"#7C9A7A", // 5. Refined sage green (professional neutral)
"#6B3FA0", // 6. Deep violet-indigo (serious yet modern)
"#A54448", // 7. Warm red-bordeaux (classic wine tone)
"#842836" // 8. Deep oxblood red (darker and richer)
]
},
{
"name": "xscale",
"zero": false,
"domain": {"signal": "xdom"},
"range": {"signal": "xrange"}
},
{
"name": "yscale",
"zero": false,
"domain": {"signal": "ydom"},
"range": {"signal": "yrange"}
}
],
"marks": [
{
"type": "path",
"name": "links",
"from": {"data": "link-data"},
"interactive": false,
"encode": {
"update": {
"stroke": {
"signal": "datum.source.index!=nodeHover.id && datum.target.index!=nodeHover.id ? '#929399':merge(hsl(scale('color', datum.source.group)), {l:0.64})"
},
"strokeWidth": {
"signal": "datum.source.index!=nodeHover.id && datum.target.index!=nodeHover.id ? 0.5:2"
}
}
},
"transform": [
{
"type": "linkpath",
"require": {
"signal": "force"
},
"shape": "line",
"sourceX": {
"expr": "scale('xscale', datum.datum.source.x)"
},
"sourceY": {
"expr": "scale('yscale', datum.datum.source.y)"
},
"targetX": {
"expr": "scale('xscale', datum.datum.target.x)"
},
"targetY": {
"expr": "scale('yscale', datum.datum.target.y)"
}
},
{
"type": "formula",
"expr": "atan2(datum.datum.target.y - datum.datum.source.y,datum.datum.source.x - datum.datum.target.x)",
"as": "angle1"
},
{
"type": "formula",
"expr": "(datum.angle1>=0?datum.angle1:(2*PI + datum.angle1)) * (360 / (2*PI))",
"as": "angle2"
},
{
"type": "formula",
"expr": "(360-datum.angle2)*(PI/180)",
"as": "angle3"
},
{
"type": "formula",
"expr": "(cos(datum.angle3)*(nodeRadius+5))+(scale('xscale',datum.datum.target.x))",
"as": "arrowX"
},
{
"type": "formula",
"expr": "(sin(datum.angle3)*(nodeRadius+5))+(scale('yscale',datum.datum.target.y))",
"as": "arrowY"
}
]
},
{
"type": "symbol",
"name": "arrows",
"zindex": 1,
"from": {"data": "links"},
"encode": {
"update": {
"shape": {
"value": "triangle"
},
"angle": {
"signal": "-datum.angle2-90"
},
"x": {
"signal": "datum.arrowX"
},
"y": {
"signal": "datum.arrowY"
},
"text": {"signal": "'▲'"},
"fill": {
"signal": "datum.datum.source.index!=nodeHover.id && datum.datum.target.index!=nodeHover.id ? '#929399':merge(hsl(scale('color', datum.datum.source.group)), {l:0.64})"
},
"size": {
"signal": "nodeRadius==1?0:60"
}
}
}
},
{
"name": "nodes",
"type": "symbol",
"zindex": 1,
"from": {"data": "node-data"},
"encode": {
"update": {
"opacity": {"value": 1},
"fill": {
"signal": "nodeHover.id===datum.index || indexof(nodeHover.connections, datum.name)>-1 ?scale('color', datum.group):merge(hsl(scale('color', datum.group)), {l:0.64})"
},
"stroke": {
"signal": "nodeHover.id===datum.index || indexof(nodeHover.connections, datum.name)>-1 ?scale('color', datum.group):merge(hsl(scale('color', datum.group)), {l:0.84})"
},
"strokeWidth": {"value": 3},
"strokeOpacity": {"value": 1},
"size": {
"signal": "4 * nodeRadius * nodeRadius"
},
"cursor": {
"value": "pointer"
},
"x": {
"signal": "fix[0]!=null && node===datum.index ?fix[0]:scale('xscale', datum.x)"
},
"y": {
"signal": "fix[1]!=null && node===datum.index ?fix[1]:scale('yscale', datum.y)"
}
},
"hover": {
"tooltip": {
"signal": "datum.name"
}
}
}
},
{
"type": "text",
"name": "labels",
"from": {"data": "nodes"},
"zindex": 2,
"interactive": false,
"enter": {},
"encode": {
"update": {
"fill": {"signal": "'white'"},
"y": {"field": "y"},
"x": {"field": "x"},
"text": { "field": "datum.nameindeneb"},
"align": {"value": "center"},
"fontSize": {"value": 10},
"baseline": {"value": "middle"},
"limit": {
"signal": "clamp(sqrt(4 * nodeRadius * nodeRadius)-7,1,1000)"
},
"lineBreak": { "value": "\n" },
"ellipsis": {"value": " "}
}
}
}
]
}
EDIT As suggested in the comments by Davide, I am sharing in the following link: https://drive.google.com/file/d/1GrPm1dc2DZ_7lce-trU7HPYMnNLcewsZ/view?usp=drive_link the anonymized .pbix I am working on.
This is what gets displayed by the force-transform diagram by default
What I would like to have is something like this:
I currently obtain this by pulling manually node 54 on the far right and the graph adapts accordingly. However, I am looking for a smart way to have this initialized automatically, pushing nodes with the highest value in the field "group" on the right, and nodes with the lowest value in the same field on the left. One clarification is that my dashboards has filters on the top page, which are passed on to the diagram. Hence this initial arrangement of the nodes shall be done dynamically, as a function of the field group, and cannot be predetermined.
I could find a solution that I wish to share in this forum, even though I am not sure this is the optimal - still serves my case as an MVP.
I have adapted the VEGA script in the data section, for the node-data table as follows
{
"name": "node-data",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "datum.type == 'node' && datum.DenebFiltering == '1'"
},
{
"type": "lookup",
"from": "source-connections",
"key": "source",
"fields": ["name"],
"values": ["targets"],
"as": ["targets"],
"default": [""]
},
{
"type": "lookup",
"from": "target-connections",
"key": "target",
"fields": ["name"],
"values": ["sources"],
"as": ["sources"],
"default": [""]
},
{
"type": "formula",
"as": "pullX",
"expr": "datum.group === 1 ? 200 : datum.group === 2 ? 400 : datum.group === 3 ? 600 : datum.group === 4 ? 800 : datum.group === 5 ? 1000 : datum.group === 6 ? 1200 : datum.group === 7 ? 1400 : datum.group === 8 ? 1600 : datum.x"
},
{
"type": "force",
"iterations": 300,
"restart": {
"signal": "restart"
},
"signal": "force",
"forces": [
{
"force": "center",
"x": {"signal": "cx"},
"y": {"signal": "cy"}
},
{
"force": "collide",
"radius": {
"signal": "sqrt(4 * nodeRadius * nodeRadius)"
},
"iterations": 1,
"strength": 0.7
},
{
"force": "nbody",
"strength": {
"signal": "nodeCharge"
}
},
{
"force": "link",
"links": "link-data",
"distance": {
"signal": "linkDistance"
},
"id": "name"
},
{
"force": "x",
"x": { "field": "pullX" },
"strength": 0.15
}
]
},
{
"type": "formula",
"as": "fx",
"expr": "fix[0]!=null && node==datum.index ?invert('xscale',fix[0]):null"
},
{
"type": "formula",
"as": "fy",
"expr": "fix[1]!=null && node==datum.index ?invert('yscale',fix[1]):null"
}
]
}
I have added a new formula "pullX" in transform where I assign a position for each group and then a new force "x" with a low strength. This overall aligns the nodes as I wanted and still allows me to preserve the force-transform features (dragging and dropping nodes and re-adjust the diagram). I hope this will help others!