Skip to content

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.

height Required

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.

width Required

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.

x Required

Type: string (field name)

Field containing the anchor value mapped through the view's x scale.

y Required

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.