Skip to content

Dodge transform ^0.5.0

Given one position dimension (either x or y), the dodge transform computes the other position dimension such that dots are packed densely without overlapping. The dodgeX transform computes x (horizontal position) given y (vertical position), while the dodgeY transform computes y given x.

The dodge transform is commonly used to produce beeswarm 🐝 plots, a way of showing a one-dimensional distribution that preserves the visual identity of individual data points. For example, the dots below represent the weights of cars; the rough shape of the pile gives a sense of the overall distribution (peaking around 2,100 pounds), and you can hover an individual dot to see which car it represents.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.plot({
  height: 160,
  marks: [
    Plot.dotX(cars, Plot.dodgeY({x: "weight (lb)", title: "name", fill: "currentColor"}))
  ]
})

Compare this to a conventional histogram using a rect mark.

020406080100↑ Frequency1,5002,0002,5003,0003,5004,0004,5005,0005,500weight (lb) →Fork
js
Plot.plot({
  height: 180,
  marks: [
    Plot.rectY(cars, Plot.binX({y: "count"}, {x: "weight (lb)"})),
    Plot.ruleY([0])
  ]
})

The dodge transform works with Plot’s faceting system, allowing independent beeswarm plots on discrete partitions of the data. Below, penguins are grouped by species and colored by sex, while vertical↕︎ position (y) encodes body mass.

Fork
js
Plot.plot({
  y: {grid: true},
  color: {legend: true},
  marks: [
    Plot.dot(penguins, Plot.dodgeX("middle", {fx: "species", y: "body_mass_g", fill: "sex"}))
  ]
})

Beeswarm plots avoid the occlusion problem of dense scatterplots and barcode plots.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.dotX(cars, {x: "weight (lb)"}).plot()
2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.ruleX(cars, {x: "weight (lb)"}).plot()

The anchor option specifies the layout baseline: the optimal output position. For the dodgeX transform, the supported anchors are: left (default), middle, right. For the dodgeY transform, the supported anchors are: bottom (default), middle, top. When the middle anchor is used, the dots are placed symmetrically around the baseline.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.plot({
  height: 180,
  marks: [
    Plot.dot(cars, Plot.dodgeY(anchor, {x: "weight (lb)", fill: "currentColor"}))
  ]
})

When using dodgeY, you must typically specify the plot’s height to create suitable space for the layout. The dodge transform is not currently able to set the height automatically. For dodgeX, the default width of 640 is often sufficient, though you may need to adjust it as well depending on your data.

The dodge transform differs from the stack transform in that the dots do not need the exact same input position to avoid overlap; the dodge transform respects the radius r of each dot. Try adjusting the radius below to see the effect.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.plot({
  height: 180,
  marks: [
    Plot.dot(cars, Plot.dodgeY({x: "weight (lb)", r, fill: "currentColor"}))
  ]
})

The dodge transform also supports a padding option (default 1), which specifies the minimum separating distance between dots. Increase it for more breathing room.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.plot({
  height: 180,
  marks: [
    Plot.dot(cars, Plot.dodgeY({x: "weight (lb)", padding, fill: "currentColor"}))
  ]
})

If r is a channel, the dodge transform will position circles of varying radius. The chart below shows twenty years of IPO offerings leading up to Facebook’s $104B offering in 2012; each circle is sized proportionally to the associated company’s valuation at IPO. (This data comes from “The Facebook Offering: How It Compares” by Jeremy Ashkenas, Matthew Bloch, Shan Carter, and Amanda Cox.) Facebook’s valuation was nearly four times that of Google, the previous record. The 2000 dot-com bubble is also visible.

Fork
js
Plot.plot({
  insetRight: 10,
  height: 790,
  marks: [
    Plot.dot(
      ipos,
      Plot.dodgeY({
        x: "date",
        r: "rMVOP",
        title: (d) => `${d.NAME}\n${(d.rMVOP / 1e3).toFixed(1)}B`,
        fill: "currentColor"
      })
    ),
    Plot.text(
      ipos,
      Plot.dodgeY({
        filter: (d) => d.rMVOP > 5e3,
        x: "date",
        r: "rMVOP",
        text: (d) => (d.rMVOP / 1e3).toFixed(),
        fill: "white",
        pointerEvents: "none"
      })
    )
  ]
})

The dodge transform can be used with any mark that supports x and y position. Below, we use the text mark instead to show company valuations (in billions).

Fork
js
Plot.plot({
  insetRight: 10,
  height: 790,
  marks: [
    Plot.text(
      ipos,
      Plot.dodgeY({
        x: "date",
        r: "rMVOP",
        text: (d) => (d.rMVOP / 1e3).toFixed(1),
        title: "NAME",
        fontSize: (d) => Math.min(22, Math.cbrt(d.rMVOP / 1e3) * 6)
      })
    )
  ]
})

The dodge transform places dots sequentially, each time finding the closest position to the baseline that avoids intersection with previously-placed dots. Because this is a greedy algorithm, the resulting layout depends on the input order. When r is a channel, dots are sorted by descending radius by default such that the largest dots are placed closest to the baseline. Otherwise, dots are placed in input order by default.

To adjust the dodge layout, use the sort transform. For example, if the sort option uses the same column as x, the dots are arranged in piles leaning right.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.plot({
  height: 180,
  marks: [
    Plot.dotX(cars, Plot.dodgeY({x: "weight (lb)", title: "name", fill: "currentColor", sort: "weight (lb)"}))
  ]
})

Reversing the sort order produces piles leaning left.

2,0002,5003,0003,5004,0004,5005,000weight (lb) →Fork
js
Plot.plot({
  height: 180,
  marks: [
    Plot.dotX(cars, Plot.dodgeY({x: "weight (lb)", title: "name", fill: "currentColor", sort: "weight (lb)", reverse: true}))
  ]
})

TIP

To avoid repeating a channel definition, you can also specify the sort option as {channel: "x"}.

INFO

Unlike a force-directed beeswarm, the dodge transform exactly preserves the input position dimension, resulting in a more accurate visualization. Also, the dodge transform tends to be faster than the iterative constraint relaxation used in the force-directed approach. We use Mikola Lysenko’s interval-tree-1d library for fast intersection testing.

Dodge options

The dodge transforms accept the following options:

  • padding — a number of pixels added to the radius of the mark to estimate its size
  • anchor - the dodge anchor; defaults to left for dodgeX, or bottom for dodgeY

The anchor option may one of middle, right, and left for dodgeX, and one of middle, top, and bottom for dodgeY. With the middle anchor the piles will grow from the center in both directions; with the other anchors, the piles will grow from the specified anchor towards the opposite direction.

dodgeY(dodgeOptions, options)

js
Plot.dodgeY({x: "date"})

Given marks arranged along the x axis, the dodgeY transform piles them vertically by defining a y position channel that avoids overlapping. The x position channel is unchanged.

dodgeX(dodgeOptions, options)

js
Plot.dodgeX({y: "value"})

Equivalent to Plot.dodgeY, but piling horizontally, creating a new x position channel that avoids overlapping. The y position channel is unchanged.