Skip to content

Dot mark

The dot mark draws circles or other symbols positioned in x and y as in a scatterplot. For example, the chart below shows the roughly-inverse relationship between car horsepower in y↑ and fuel efficiency in miles per gallon in x→.

6080100120140160180200220↑ power (hp)1015202530354045economy (mpg) →Fork
js
Plot.dot(cars, {x: "economy (mpg)", y: "power (hp)"}).plot({grid: true})

Using a function for x, we can instead plot the roughly-linear relationship when fuel efficiency is represented as gallons per 100 miles. (For fans of the metric system, 1 gallon per 100 miles is roughly 2.4 liters per 100 km.)

6080100120140160180200220↑ Horsepower34567891011Fuel consumption (gallons per 100 miles) →Fork
js
Plot.plot({
  grid: true,
  inset: 10,
  x: {label: "Fuel consumption (gallons per 100 miles)"},
  y: {label: "Horsepower"},
  marks: [
    Plot.dot(cars, {x: (d) => 100 / d["economy (mpg)"], y: "power (hp)"})
  ]
})

Dots support stroke and fill channels in addition to position along x and y. Below, color is used as a redundant encoding to emphasize the rising trend in average global surface temperatures. A diverging color scale encodes values below zero blue and above zero red.

Fork
js
Plot.plot({
  y: {
    grid: true,
    tickFormat: "+f",
    label: "Surface temperature anomaly (°F)"
  },
  color: {
    scheme: "BuRd"
  },
  marks: [
    Plot.ruleY([0]),
    Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"})
  ]
})

Dots also support an r channel allowing dot size to represent quantitative value. Below, each dot represents a day of trading; the x-position represents the day’s change, while the y-position and area (r) represent the day’s trading volume. As you might expect, days with higher volatility have higher trading volume.

Fork
js
Plot.plot({
  grid: true,
  x: {
    label: "Daily change (%)",
    tickFormat: "+f",
    percent: true
  },
  y: {
    type: "log",
    label: "Daily trading volume"
  },
  marks: [
    Plot.ruleX([0]),
    Plot.dot(aapl, {x: (d) => (d.Close - d.Open) / d.Open, y: "Volume", r: "Volume"})
  ]
})

With the bin transform, sized dots can also be used as an alternative to a rect-based heatmap to show a two-dimensional distribution.

Fork
js
Plot.plot({
  height: 640,
  marginLeft: 60,
  grid: true,
  x: {label: "Carats"},
  y: {label: "Price ($)"},
  r: {range: [0, 20]},
  marks: [
    Plot.dot(diamonds, Plot.bin({r: "count"}, {x: "carat", y: "price", thresholds: 100}))
  ]
})

TIP

For hexagonal binning, use the hexbin transform instead of the bin transform.

While dots are typically positioned in two dimensions (x and y), one-dimensional dots (only x or only y) are also supported. Below, dot area is used to represent the frequency of letters in the English language as a compact alternative to a bar chart.

ABCDEFGHIJKLMNOPQRSTUVWXYZletterFork
js
Plot.dot(alphabet, {x: "letter", r: "frequency"}).plot()

Dots, together with rules, can be used as a stylistic alternative to bars to produce a lollipop 🍭 chart. (Sadly these lollipops cannot be eaten.)

0123456789101112↑ frequency (%)ABCDEFGHIJKLMNOPQRSTUVWXYZFork
js
Plot.plot({
  x: {label: null, tickPadding: 6, tickSize: 0},
  y: {percent: true},
  marks: [
    Plot.ruleX(alphabet, {x: "letter", y: "frequency", strokeWidth: 2}),
    Plot.dot(alphabet, {x: "letter", y: "frequency", fill: "currentColor", r: 4})
  ]
})

A dot may have an ordinal dimension on either x and y, as in the plot below comparing the demographics of states: color represents age group, y represents the state, and x represents the proportion of the state’s population in that age group. The normalize transform is used to compute the relative proportion of each age group within each state, while the group transform is used to pull out the min and max values for each state for a horizontal rule.

Fork
js
Plot.plot({
  height: 660,
  axis: null,
  grid: true,
  x: {
    axis: "top",
    label: "Population (%)",
    percent: true
  },
  color: {
    scheme: "spectral",
    domain: stateage.ages, // in age order
    legend: true
  },
  marks: [
    Plot.ruleX([0]),
    Plot.ruleY(stateage, Plot.groupY({x1: "min", x2: "max"}, {...xy, sort: {y: "x1"}})),
    Plot.dot(stateage, {...xy, fill: "age", title: "age"}),
    Plot.text(stateage, Plot.selectMinX({...xy, textAnchor: "end", dx: -6, text: "state"}))
  ]
})
js
xy = Plot.normalizeX("sum", {x: "population", y: "state", z: "state"})

TIP

To reduce code duplication, pull shared options out into an object (here xy) and then merge them into each mark’s options using the spread operator (...).

To improve accessibility, particularly for readers with color vision deficiency, the symbol channel can be used in addition to color (or instead of it) to represent ordinal data.

Fork
js
Plot.plot({
  grid: true,
  x: {label: "Body mass (g)"},
  y: {label: "Flipper length (mm)"},
  symbol: {legend: true},
  marks: [
    Plot.dot(penguins, {x: "body_mass_g", y: "flipper_length_mm", stroke: "species", symbol: "species"})
  ]
})

Plot uses the following default symbols for filled dots:

circlecrossdiamondsquarestartrianglewye
js
Plot.dotX([
  "circle",
  "cross",
  "diamond",
  "square",
  "star",
  "triangle",
  "wye"
], {fill: "currentColor", symbol: Plot.identity}).plot()

There is a separate set of default symbols for stroked dots:

asteriskcirclediamond2plussquare2timestriangle2
js
Plot.dotX([
  "circle",
  "plus",
  "times",
  "triangle2",
  "asterisk",
  "square2",
  "diamond2",
], {stroke: "currentColor", symbol: Plot.identity}).plot()

INFO

The stroked symbols are based on Heman Robinson’s research. There is also a hexagon symbol; it is primarily intended for the hexbin transform. You can even specify a D3 or custom symbol type as an object that implements the symbol.draw(context, size) method.

The dot mark can be combined with the stack transform. The diverging stacked dot plot below shows the age and gender distribution of the U.S. Congress in 2023.

Fork
js
Plot.plot({
  aspectRatio: 1,
  x: {label: "Age (years)"},
  y: {
    grid: true,
    label: "← Women · Men →",
    labelAnchor: "center",
    tickFormat: Math.abs
  },
  marks: [
    Plot.dot(
      congress,
      Plot.stackY2({
        x: (d) => 2023 - d.birthday.getUTCFullYear(),
        y: (d) => d.gender === "M" ? 1 : -1,
        fill: "gender",
        title: "full_name"
      })
    ),
    Plot.ruleY([0])
  ]
})

INFO

The stackY2 transform places each dot at the upper bound of the associated stacked interval, rather than the middle of the interval as when using stackY. Hence, the first male dot is placed at y = 1, and the first female dot is placed at y = -1.

TIP

The dodge transform can also be used to produce beeswarm plots; this is particularly effective when dots have varying radius.

Dots are sorted by descending radius by default ^0.5.0 to mitigate occlusion; the smallest dots are drawn on top. Set the sort option to null to draw them in input order. Use the checkbox below to see the effect of sorting on a bubble map of U.S. county population.

Fork
js
Plot.plot({
  projection: "albers-usa",
  marks: [
    Plot.geo(statemesh, {strokeOpacity: 0.4}),
    Plot.dot(counties, Plot.geoCentroid({
      r: (d) => d.properties.population,
      fill: "currentColor",
      stroke: "white",
      strokeWidth: 1,
      sort: sorted ? undefined : null
    }))
  ]
})

The dot mark can also be used to construct a quantile-quantile (QQ) plot for comparing two univariate distributions.

Dot options

In addition to the standard mark options, the following optional channels are supported:

  • x - the horizontal position; bound to the x scale
  • y - the vertical position; bound to the y scale
  • r - the radius (area); bound to the r (radius) scale, which defaults to sqrt
  • rotate - the rotation angle in degrees clockwise
  • symbol - the categorical symbol; bound to the symbol scale ^0.4.0

If either of the x or y channels are not specified, the corresponding position is controlled by the frameAnchor option.

The following dot-specific constant options are also supported:

  • r - the effective radius (length); a number in pixels
  • rotate - the rotation angle in degrees clockwise; defaults to 0
  • symbol - the categorical symbol; defaults to circle ^0.4.0
  • frameAnchor - how to position the dot within the frame; defaults to middle

The r option can be specified as either a channel or constant. When the radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. The radius defaults to 4.5 pixels when using the symbol channel, and otherwise 3 pixels. Dots with a nonpositive radius are not drawn.

The stroke defaults to none. The fill defaults to currentColor if the stroke is none, and to none otherwise. The strokeWidth defaults to 1.5. The rotate and symbol options can be specified as either channels or constants. When rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When symbol is a valid symbol name or symbol object (implementing the draw method), it is interpreted as a constant; otherwise it is interpreted as a channel. If the symbol channel’s values are all symbols, symbol names, or nullish, the channel is unscaled (values are interpreted literally); otherwise, the channel is bound to the symbol scale.

dot(data, options)

js
Plot.dot(sales, {x: "units", y: "fruit"})

Returns a new dot with the given data and options. If neither the x nor y nor frameAnchor options are specified, data is assumed to be an array of pairs [[x₀, y₀], [x₁, y₁], [x₂, y₂], …] such that x = [x₀, x₁, x₂, …] and y = [y₀, y₁, y₂, …].

dotX(data, options)

js
Plot.dotX(cars.map((d) => d["economy (mpg)"]))

Equivalent to dot except that if the x option is not specified, it defaults to the identity function and assumes that data = [x₀, x₁, x₂, …].

If an interval is specified, such as d3.utcDay, y is transformed to (interval.floor(y) + interval.offset(interval.floor(y))) / 2. If the interval is specified as a number n, y will be the midpoint of two consecutive multiples of n that bracket y. Named UTC intervals such as day are also supported; see scale options.

dotY(data, options)

js
Plot.dotY(cars.map((d) => d["economy (mpg)"]))

Equivalent to dot except that if the y option is not specified, it defaults to the identity function and assumes that data = [y₀, y₁, y₂, …].

If an interval is specified, such as d3.utcDay, x is transformed to (interval.floor(x) + interval.offset(interval.floor(x))) / 2. If the interval is specified as a number n, x will be the midpoint of two consecutive multiples of n that bracket x. Named UTC intervals such as day are also supported; see scale options.

circle(data, options) ^0.5.0

Equivalent to dot except that the symbol option is set to circle.

hexagon(data, options) ^0.5.0

Equivalent to dot except that the symbol option is set to hexagon.