Displace 2D¶
The "displace2d" transform reduces overlap between axis-aligned rectangles
while keeping them near their anchors. It preserves every input row and writes
signed horizontal and vertical pixel offsets to two fields.
displace2d operates on generic rectangle geometry. For text annotations,
measure the label width with measureText, include the
desired spacing in the collision dimensions, and apply the output fields with
unscaled offset channels.
Parameters¶
anchorHeight-
Type: number | string (field name) | ExprRef
Height in logical pixels of an obstacle centered on the anchor. A number or expression supplies one value for all rows; a field supplies per-row values. Setting either anchor dimension to zero disables the obstacle for that row.
Default value:
0 anchorWidth-
Type: number | string (field name) | ExprRef
Width in logical pixels of an obstacle centered on the anchor. A number or expression supplies one value for all rows; a field supplies per-row values. Setting either anchor dimension to zero disables the obstacle for that row.
Default value:
0 as-
Type: array
Names of the output fields for signed horizontal and vertical pixel offsets. Positive values move right and down, respectively. Neither name may overwrite
key.Default value:
["xDisplacement", "yDisplacement"] description-
Type: string
A description of the transform step. Can be used for documentation and agent context.
heightRequired-
Type: number | string (field name) | ExprRef
Collision height in logical pixels, including any desired vertical spacing. A number or expression supplies one value for all rows; a field supplies per-row values. Values must be non-negative. Setting either collision dimension to zero disables displacement for that row.
key-
Type: string (field name)
Field containing a unique string or finite numeric identifier. Use a key to preserve placement when upstream transforms replace, filter, or reorder rows. Without a key, placement state follows row object identity.
widthRequired-
Type: number | string (field name) | ExprRef
Collision width in logical pixels, including any desired horizontal spacing. A number or expression supplies one value for all rows; a field supplies per-row values. Values must be non-negative. Setting either collision dimension to zero disables displacement for that row.
xRequired-
Type: string (field name)
Field containing the anchor value mapped through the view's x scale.
yRequired-
Type: string (field name)
Field containing the anchor value mapped through the view's y scale.
Example¶
Zoom the scatterplot to see the labels recompute and move smoothly. Leader lines stop at the edge of the centered text instead of continuing underneath it.
{
"description": [
"Displaced scatterplot labels",
"Measures and separates overlapping labels while leader lines preserve their connection to the original points."
],
"data": {
"values": [
{ "x": 4.82, "y": 5.08, "label": "TP53" },
{ "x": 4.96, "y": 5.12, "label": "EGFR" },
{ "x": 5.08, "y": 4.98, "label": "BRCA1" },
{ "x": 4.91, "y": 4.89, "label": "PIK3CA" },
{ "x": 5.18, "y": 5.09, "label": "MYC" },
{ "x": 5.02, "y": 5.24, "label": "KRAS" },
{ "x": 4.74, "y": 4.95, "label": "PTEN" },
{ "x": 5.23, "y": 4.86, "label": "CDKN2A" },
{ "x": 4.85, "y": 5.28, "label": "ERBB2" },
{ "x": 5.3, "y": 5.2, "label": "BRAF" }
]
},
"layer": [
{
"name": "scatterplot",
"mark": { "type": "point", "filled": true, "size": 45 },
"encoding": {
"x": {
"field": "x",
"type": "quantitative",
"scale": { "domain": [3.5, 6.5], "zoom": true }
},
"y": {
"field": "y",
"type": "quantitative",
"scale": { "domain": [3.5, 6.5], "zoom": true }
}
}
},
{
"name": "labels-and-leaders",
"transform": [
{
"type": "measureText",
"field": "label",
"fontSize": 14,
"fontWeight": "bold",
"as": "_labelWidth"
},
{
"type": "formula",
"expr": "datum._labelWidth + 10",
"as": "_labelWidth"
},
{
"type": "collect",
"description": "Caches label geometry so scale changes do not repeat text measurement."
},
{
"type": "filter",
"expr": "inrange(datum.x, domain('x')) && inrange(datum.y, domain('y'))"
},
{
"type": "displace2d",
"key": "label",
"x": "x",
"y": "y",
"width": "_labelWidth",
"height": 18,
"anchorWidth": 10,
"anchorHeight": 10,
"as": ["_labelDx", "_labelDy"]
},
{
"type": "formula",
"expr": "max(0, 1 - min(datum._labelWidth / 2 / max(abs(datum._labelDx), 1e-6), 8 / max(abs(datum._labelDy), 1e-6)))",
"as": "_leaderScale"
},
{
"type": "formula",
"expr": "datum._labelDx * datum._leaderScale",
"as": "_leaderDx"
},
{
"type": "formula",
"expr": "datum._labelDy * datum._leaderScale",
"as": "_leaderDy"
}
],
"encoding": {
"x": { "field": "x", "type": "quantitative" },
"y": { "field": "y", "type": "quantitative" }
},
"layer": [
{
"name": "leader-lines",
"mark": { "type": "rule", "color": "#606770", "size": 1 },
"encoding": {
"x2": { "field": "x" },
"y2": { "field": "y" },
"xOffset": {
"field": "_leaderDx",
"type": "quantitative",
"scale": null
},
"yOffset": {
"field": "_leaderDy",
"type": "quantitative",
"scale": null
}
}
},
{
"name": "labels",
"mark": {
"type": "text",
"align": "center",
"baseline": "middle",
"size": 14,
"fontWeight": "bold"
},
"encoding": {
"text": { "field": "label", "type": "nominal" },
"xOffset": {
"field": "_labelDx",
"type": "quantitative",
"scale": null
},
"yOffset": {
"field": "_labelDy",
"type": "quantitative",
"scale": null
}
}
}
]
}
]
}
Placement model¶
Each row defines a collision rectangle anchored at x and y. The transform
maps these values through the view's positional scales, including reversed,
nonlinear, and zoomed scales. Discrete values are placed at the center of their
scale bands. Collision dimensions and output offsets are in logical pixels.
The rectangle is centered on its anchor, so the displaced mark should use centered alignment. Apply the output fields with unscaled offset channels:
"xOffset": { "field": "labelDx", "type": "quantitative", "scale": null },
"yOffset": { "field": "labelDy", "type": "quantitative", "scale": null }
anchorWidth and anchorHeight can reserve a rectangle around each original
center. Every displaced rectangle avoids every reserved anchor, including its
own. Use the rendered point dimensions plus the desired clearance. Setting
either dimension to zero disables the anchor for that row.
The transform processes rows in input order and gives earlier rectangles higher
placement priority. Use a collect transform immediately before displace2d
to sort important annotations first. Placement is best-effort: dense or
infeasible arrangements may retain overlaps.
Set key when upstream transforms may replace row objects or change their
order or membership between updates. Rows with the same key retain their
progressive placement through cloning, filtering, and reordering. Without a
key, placement state follows object identity only.
The solver considers only the supplied collision rectangles, anchor obstacles, and viewport bounds. It does not inspect marks, measure text, avoid unrelated geometry, or route leader lines.
Viewport participation¶
Rows with offscreen anchors receive zero offsets and do not participate in
placement. To remove them from downstream processing as well, filter them
before displace2d using inrange with the current scale domains. Configure
key so the remaining labels retain placement when filter membership changes.
inrange also supports reversed domains.
Smooth updates¶
In interactive views, displace2d performs a bounded amount of work per frame
and eases displayed positions toward the evolving placement. It resumes after
data, scale, or layout changes and stops requesting frames after settling.
Headless rendering and disabled transitions solve the same constraints
synchronously.
Algorithm¶
Each solver sweep pulls rectangles toward their anchors, keeps them within the viewport, and projects overlapping pairs apart along their shallowest axis. Occasional deterministic searches help escape poor local arrangements. Because every sweep compares every pair of rectangles, the work grows quadratically with the number of rows and is best suited to a moderate number of annotations.