Parallel Lines in Vega
I was working on multi-graph visualizations and I wanted to place multiple edges between two nodes. This led me down a rabbit hole that included the atan2 function and a few hours fighting special cases.
Stepping back, I looked at a vector implementation instead and the solution ended up being surprising simple! The magic google words were “parallel translation of lines” that got me the to some useful math & Stack Overflow answers (1 puts it all in one place, but 2 & 3 helped me see the pattern of why that was the answer). Here is a simple visualization that starts with lines and then calculates parallel lines at a given offset (see it in the online Vega editor).
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 300,
"height": 300,
"padding": 0,
"signals": [
{"name": "offset", "value": 8, "bind": {"input": "range", "min": -20, "max": 20, "step": 1}}
],
"data": [
{
"name": "line",
"values": [
{"id": 0, "x": 150, "y": 150, "x2": 300, "y2": 300, "c": "green"},
{"id": 0, "x": 150, "y": 150, "x2": 300, "y2": 0, "c": "purple"},
{"id": 0, "x": 150, "y": 150, "x2": 150, "y2": 0, "c": "red"},
{"id": 0, "x": 150, "y": 150, "x2": 300, "y2": 150, "c": "indigo"},
{"id": 0, "x": 150, "y": 150, "x2": 0, "y2": 150, "c": "yellow"},
{"id": 0, "x": 150, "y": 150, "x2": 150, "y2": 300, "c": "blue"},
{"id": 0, "x": 150, "y": 150, "x2": 0, "y2": 300, "c": "orange"},
{"id": 0, "x": 150, "y": 150, "x2": 0, "y2": 0, "c": "pink"}
]
},
{
"name": "shifted-line",
"source": "line",
"transform": [
{"type": "formula", "as": "dx", "expr": "datum.x-datum.x2"},
{"type": "formula", "as": "dy", "expr": "datum.y-datum.y2"},
{"type": "formula", "as": "length", "expr": "sqrt(pow(datum.dx, 2) + pow(datum.dy, 2))"},
{"type": "formula", "as": "Ax", "expr": "-datum.dy/datum.length"},
{"type": "formula", "as": "Ay", "expr": "datum.dx/datum.length"},
{"type": "formula", "as": "nx", "expr": "datum.x + offset * datum.Ax"},
{"type": "formula", "as": "nx2", "expr": "datum.x2 + offset * datum.Ax"},
{"type": "formula", "as": "ny", "expr": "datum.y + offset * datum.Ay"},
{"type": "formula", "as": "ny2", "expr": "datum.y2 + offset * datum.Ay"}
]
}
],
"marks": [
{
"type": "rule",
"name": "lines",
"from": {"data": "line"},
"encode": {
"enter": {
"stroke": {"field": "c"},
"strokeWidth": {"value": 3}
},
"update": {
"x": {"field": "x"},
"y": {"field": "y"},
"x2": {"field": "x2"},
"y2": {"field": "y2"}
}
}
},
{
"type": "rule",
"name": "shifted-lines",
"from": {"data": "shifted-line"},
"encode": {
"enter": {
"stroke": {"field": "c"},
"opacity": {"value": 0.25},
"strokeWidth": {"value": 3}
},
"update": {
"x": {"field": "nx"},
"y": {"field": "ny"},
"x2": {"field": "nx2"},
"y2": {"field": "ny2"}
}
}
}
]
}
The work is being done in the shifted-lines data entry:
dxanddyare the change in x and y in the original lines.lengthis used to normalize shifts based ondxanddy. The normalization ensures the line shift is the same, regardless of the length of the original line.AxandAyare the most interesting parts.Axis roughly the amount of shift in thexdirection, given how much change inythere to get a single unit of change. This achieves “Parallel translation”, so the shift distance total is the same, regardless of the angle.- Finally
nx,ny,nx2,ny2are the “new” coordinates if you shift the original values by the desired distance.
In a more mathematical form:
- $length = \sqrt{(y_2-y_1)^2 + (x_2-x_1)^2}$
- $nx = x + offset * \frac{-(y_1-y_2)}{length}$
- $ny = y + offset * \frac{(x_1-x_2)}{length}$
- $nx_2$ and $ny_2$ are computed similarly from $x_2$ and $y_2$
Addendum
For the those that find “bad code written in a JSON format” amusing (?), here is the most critical part of my best attempt at the parallel lines using atan2. (Substitute this block for the transform block above and…enjoy a good laugh?)
"transform": [
{"type": "formula", "as": "alpha", "expr": "atan2(datum.y, datum.x)"},
{"type": "formula", "as": "d_intercept", "expr": "offset * sin(datum.alpha)"},
{"type": "formula", "as": "slope", "expr": "(datum.y2 - datum.y)/(datum.x2 - datum.x)"},
{"type": "formula", "as": "new_intercept",
"expr": "datum.d_intercept + (datum.y-(datum.slope*datum.x))"},
{"type": "formula", "as": "_dy",
"expr": "datum.y-(datum.slope*datum.x+datum.new_intercept)"},
{"type": "formula", "as": "_dx",
"expr": "if(datum.slope !=0, datum.x - (datum.y-datum.new_intercept)/datum.slope, 0)"},
{"type": "formula", "as": "dy",
"expr": "if(isFinite(datum._dy), datum._dy, 0)"},
{"type": "formula", "as": "dx",
"expr": "if(isFinite(datum._dx), datum._dx, offset)"},
{"type": "formula", "as": "nx", "expr": "datum.x+datum.dx"},
{"type": "formula", "as": "nx2", "expr": "datum.x2+datum.dx"},
{"type": "formula", "as": "ny", "expr": "datum.y+datum.dy"},
{"type": "formula", "as": "ny2", "expr": "datum.y2+datum.dy"}
]