Skip to content

Commit

Permalink
axis mark (#1197)
Browse files Browse the repository at this point in the history
* axis mark

* deferred channels

* vector tick

* tickPadding

* grid

* grid

* no rounded stroke

* fix half-pixel offset

* more conditional axes

* grid mark

* DRY tick

* tick[XY]

* explicit ticks

* axisTick[XY]

* axis mark data

* axis insets

* tickFormat

* better default tick format

* simpler inferTickFormat

* auto ticks; expose scale.interval

* anchor

* manual axis label, for now

* tickRotate

* fix crash in sparse excludeIndex

* facetAnchor; fix mark-level missing facet

* simpler facet sorting

* fix #522; better facetAnchor

* fix #1198; better empty facets

* fix for ordinal scales

* soft hyphen

* test label wrapping

* facet grid

* better facet grid

* better facet grid, again

* better grid

* better facet axes; fix font variant

* extract facetSkip

* empty facets

* destructure options

* remove redundant defaults

* fix tickPadding, tickRotate

* better grid defaults

* more empty facets

* simpler facet warning

* decoration

* better empty facets

* better facet sort

* extract facetAnchor

* extract more facet logic

* const facets

* unify code

* more facet anchors

* invert facetAnchor

* checkpoint axis label

* checkpoint axis mark integration

* fix minimum offset margin

* fix for null marks and scales

* better implicit axes

* denote side-effect

* better axis options

* implicit facet axes

* more implicit axis options

* inferAxes

* delete old axes

* minimize diff

* fix default margins

* remove mark.decoration

* facet margin options

* move side effect

* inheritScaleLabels

* remove comment

* better ARIA labels; restore prior axis order

* regenerate tests; smarter partial implicit axes

* more smarter partial implicit axes

* axis line

* text stroke options

* don’t inline text elements

* do inline title elements

* axis label

* fix axes for interval scales

* gridDasharray

* better implicit axis

* x-axis label

* another fancy axis example

* polish example

* fancy multi-line time ticks

* another fancy axis example

* remove secondary grid shorthand

* tweak y-axis label position

* first stab at documentation

* the grid doesn't follow anchor

* better deferred channels

* better explicit built-in axes

* fix metroUnemploymentRidgeline test

* use insetTop for better semantics

* fix label option inheritance

* axis = both

* fix comment

* a few fixes to axis label position

* labelAnchor

* smarter default labelAnchor

* fix googleTrendsRidgeline

* fix longLabels

* non-fix for named time intervals

* fix projectionHeightGeometry

* non-fix for x-axis label position

* better axis label position

* inset axis label

* fix #375; respect facet margins

* fix industryUnemploymentTrack

* restore grid in penguinCulmen

* facet margin collapse

* better facet margin collapse

* drop comment

* labelOffset

* fix auto labels and arrows

* named exports for tests

* enable facet axis labels

* add non-faceted athletesSample test

* better facet axis label position

* cleaner

* enforce constant tick options

* shorter athletesSportWeight

* remove dead code

* checkpoint cross-facet marks

* checkpoint supermarks

* super option

* better super marks

* drop unused mid facet anchors

* disallow superposition, for now

* facet = super

* minimize diff

* fix axis label position, finally?

* better hexbin tests

* polish tests

* fix named time intervals for ticks

* grid xy shorthand

* consistent scale checks

* better axis options

* tickSpacing option

* propagate tickSpacing option

* fix off-by-one with interval ticks

* typo (thanks, @yurivish!)

* only one label for both axes

* a bit of documentation for axisX

* given data, don’t defer channels

* reduce aaplCloseDataTicks test

* implicit grid with explicit axis

* more grid shorthand

* Update README

* fix default facet axis anchor

* axis plot shorthand

* Update README

---------

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil committed Feb 1, 2023
1 parent 7b61534 commit 1da65ff
Show file tree
Hide file tree
Showing 400 changed files with 80,012 additions and 83,679 deletions.
113 changes: 105 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,19 +293,20 @@ For a *band* scale, you can further fine-tune padding:

Align defaults to 0.5 (centered). Band scale padding defaults to 0.1 (10% of available space reserved for separating bands), while point scale padding defaults to 0.5 (the gap between the first point and the edge is half the distance of the gap between points, and likewise for the gap between the last point and the opposite edge). Note that rounding and mark insets (e.g., for bars and rects) also affect separation between adjacent marks.

Plot automatically generates axes for position scales. You can configure these axes with the following options:
Plot automatically generates [axis](#axis) and optionally [grid](#grid) marks for position scales. (For more control, declare these marks explicitly.) You can configure the implicit axes with the following scale options:

* *scale*.**axis** - the orientation: *top* or *bottom* for *x*; *left* or *right* for *y*; null to suppress
* *scale*.**ticks** - the approximate number of ticks to generate
* *scale*.**tickSize** - the size of each tick (in pixels; default 6)
* *scale*.**axis** - the orientation: *top* or *bottom* for *x* and *fx*; *left* or *right* for *y* and *fy*; null to suppress
* *scale*.**ticks** - the approximate number of ticks to generate, or interval, or array of values
* *scale*.**tickSize** - the length of each tick (in pixels; default 6 for *x* and *y*, or 0 for *fx* and *fy*)
* *scale*.**tickSpacing** - the approximate number of pixels between ticks (if *scale*.**ticks** is not specified)
* *scale*.**tickPadding** - the separation between the tick and its label (in pixels; default 3)
* *scale*.**tickFormat** - to format tick values, either a function or format specifier string; see [Formats](#formats)
* *scale*.**tickFormat** - either a function or specifier string to format tick values; see [Formats](#formats)
* *scale*.**tickRotate** - whether to rotate tick labels (an angle in degrees clockwise; default 0)
* *scale*.**grid** - if true, draw grid lines across the plot for each tick
* *scale*.**line** - if true, draw the axis line
* *scale*.**line** - if true, draw the axis line (only for *x* and *y*)
* *scale*.**label** - a string to label the axis
* *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center*
* *scale*.**labelOffset** - the label position offset (in pixels; default 0, typically for facet axes)
* *scale*.**labelOffset** - the label position offset (in pixels; default depends on margins and orientation)
* *scale*.**fontVariant** - the font-variant attribute for axis ticks; defaults to tabular-nums for quantitative axes
* *scale*.**ariaLabel** - a short label representing the axis in the accessibility tree
* *scale*.**ariaDescription** - a textual description for the axis
Expand Down Expand Up @@ -585,6 +586,12 @@ When top-level faceting is used, the default *auto* setting is equivalent to *in

When mark-level faceting is used, the default *auto* setting is equivalent to *include*: the mark will be faceted if either the *mark*.**fx** or *mark*.**fy** channel option (or both) is specified. The null or false option will disable faceting, while *exclude* draws the subset of the mark’s data *not* in the current facet.

The <a name="facetanchor">*mark*.**facetAnchor**</a> option controls TK. It supports the following settings:

* null - display the mark on each non-empty facet (default for all marks, with the exception of axis marks)
* *top*, *right*, *bottom*, or *left* - display the mark on facets on the specified side
* *top-empty*, *right-empty*, *bottom-empty*, or *left-empty* - display the mark on facets that have an empty space on the specified side (the empty space being either the margin, or an empty facet); this is the default for axis marks

## Legends

Plot can generate legends for *color*, *opacity*, and *symbol* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option:
Expand Down Expand Up @@ -958,6 +965,50 @@ Returns a new arrow with the given *data* and *options*.
<!-- jsdocEnd arrow -->
### Axis
[Source](./src/marks/axis.js) · [Examples](https://observablehq.com/@observablehq/plot-axis) · Draws an axis.
Plot automatically generates axes for position scales, and draws them below the other marks. Each axis is composed of up to 5 marks: a grid, and an axis mark which might contain a line, ticks, tick labels, and axis label.
When you need more control, you can add axis and grid marks explicitly in the marks options. Note that Plot’s automatic axis for *x* is disabled when a mark’s aria-label property begins by `x-axis `—and likewise for *y*, *fx*, and *fy*.
The optional *data* is an array of tick values—it defaults to the scale’s ticks.
The axis options described in [position options](#position-options) are all supported, except the **grid** option which is handled by the [grid](#grid) mark. The **y** channel, if any, describes the vertical position of the tick and defaults to the axis anchor.
The **axis** option, in this context, is called **anchor** and is one of *top* or *bottom*.
The **color** option controls both **stroke** for the ticks, and the **fill** for labels. It defaults to currentColor.
The (rarely used) text label’s stroke is controlled by the **textStroke**,**textStrokeOpacity**, and **textStrokeWidth** options.
The **facetAnchor** option defaults to *bottom-empty* if anchor is bottom, and *top-empty* if anchor is top. This ensures the proper positioning of the axes with respect to empty facets.
#### Plot.axisX(*data*, *options*)
<!-- jsdoc axisX -->
```js
Plot.axisX({anchor: "bottom"})
```
Returns a new *x*-axis with the given *options*.
<!-- jsdocEnd axisX -->
#### Plot.axisY(*data*, *options*)
…same…
#### Plot.axisFx(*data*, *options*)
…same…
#### Plot.axisFy(*data*, *options*)
…same…
### Bar
[<img src="./img/bar.png" width="320" height="198" alt="a bar chart">](https://observablehq.com/@observablehq/plot-bar)
Expand Down Expand Up @@ -1358,6 +1409,52 @@ Plot.graticule()
Returns a new geo mark with a [default 10° global graticule](https://github.com/d3/d3-geo/blob/main/README.md#geoGraticule10) geometry object and the given *options*.
### Grid
[Source](./src/marks/axis.js) · [Examples](https://observablehq.com/@observablehq/plot-axis) · Draws an axis-aligned grid.
The optional *data* is an array of tick values—it defaults to the scale’s ticks. The grid mark draws a line for each tick value, across the whole frame.
The following options are supported:
* **strokeDasharray** - the [stroke dasharray](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray) for dashed lines, defaults to null
The following options are supported as constant or data-driven channels:
* **stroke** - the grid color, defaults to currentColor
* **strokeWidth** - the grid’s line width, defaults to 1
* **strokeOpacity** - the stroke opacity, defaults to 0.1
* **y1** - the start of the line, a channel of y positions.
* **y2** - the end of the line, a channel of y positions.
All the other common options are supported when applicable (e.g., **title**).
#### Plot.gridX(*data*, *options*)
<!-- jsdoc gridX -->
```js
Plot.gridX({strokeDasharray: "5,3"})
```
Returns a new *x*-grid with the given *options*.
<!-- jsdocEnd gridX -->
#### Plot.gridY(*data*, *options*)
…same…
#### Plot.gridFx(*data*, *options*)
…same…
#### Plot.gridFy(*data*, *options*)
…same…
### Hexgrid
The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout.
Expand Down Expand Up @@ -1950,7 +2047,7 @@ For example, to draw bars only for letters that commonly form vowels:
Plot.barY(alphabet, {filter: d => /[aeiou]/i.test(d.letter), x: "letter", y: "frequency"})
```
The **filter** transform is similar to filtering the data with [*array*.filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), except that it will preserve [faceting](#faceting) and will not affect inferred [scale domains](#scale-options); domains are inferred from the unfiltered channel values.
The **filter** transform is similar to filtering the data with [*array*.filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), except that it will preserve [faceting](#facet-options) and will not affect inferred [scale domains](#scale-options); domains are inferred from the unfiltered channel values.
```js
Plot.barY(alphabet.filter(d => /[aeiou]/i.test(d.letter)), {x: "letter", y: "frequency"})
Expand Down
192 changes: 21 additions & 171 deletions src/axes.js
Original file line number Diff line number Diff line change
@@ -1,172 +1,22 @@
import {extent} from "d3";
import {AxisX, AxisY} from "./axis.js";
import {formatDefault} from "./format.js";
import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js";
import {position, registry} from "./scales/index.js";

export function Axes(
{x: xScale, y: yScale, fx: fxScale, fy: fyScale},
{
x = {},
y = {},
fx = {},
fy = {},
axis = true,
grid,
line,
label,
facet: {axis: facetAxis = axis, grid: facetGrid, label: facetLabel = label} = {}
} = {}
) {
let {axis: xAxis = axis} = x;
let {axis: yAxis = axis} = y;
let {axis: fxAxis = facetAxis} = fx;
let {axis: fyAxis = facetAxis} = fy;
if (!xScale) xAxis = null;
else if (xAxis === true) xAxis = "bottom";
if (!yScale) yAxis = null;
else if (yAxis === true) yAxis = "left";
if (!fxScale) fxAxis = null;
else if (fxAxis === true) fxAxis = xAxis === "bottom" ? "top" : "bottom";
if (!fyScale) fyAxis = null;
else if (fyAxis === true) fyAxis = yAxis === "left" ? "right" : "left";
return {
...(xAxis && {x: new AxisX(xScale, {grid, line, label, ...x, axis: xAxis})}),
...(yAxis && {y: new AxisY(yScale, {grid, line, label, ...y, axis: yAxis})}),
...(fxAxis && {fx: new AxisX(fxScale, {name: "fx", grid: facetGrid, label: facetLabel, ...fx, axis: fxAxis})}),
...(fyAxis && {fy: new AxisY(fyScale, {name: "fy", grid: facetGrid, label: facetLabel, ...fy, axis: fyAxis})})
};
}

// Mutates axis.ticks!
// TODO Populate tickFormat if undefined, too?
export function autoAxisTicks({x, y, fx, fy}, {x: xAxis, y: yAxis, fx: fxAxis, fy: fyAxis}) {
if (fxAxis) autoAxisTicksK(fx, fxAxis, 80);
if (fyAxis) autoAxisTicksK(fy, fyAxis, 35);
if (xAxis) autoAxisTicksK(x, xAxis, 80);
if (yAxis) autoAxisTicksK(y, yAxis, 35);
}

function autoAxisTicksK(scale, axis, k) {
if (axis.ticks === undefined) {
const interval = scale.interval;
if (interval !== undefined) {
const [min, max] = extent(scale.scale.domain());
axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max)));
} else {
const [min, max] = extent(scale.scale.range());
axis.ticks = (max - min) / k;
}
}
// D3’s ordinal scales simply use toString by default, but if the ordinal
// scale domain (or ticks) are numbers or dates (say because we’re applying a
// time interval to the ordinal scale), we want Plot’s default formatter.
if (axis.tickFormat === undefined && isOrdinalScale(scale)) {
axis.tickFormat = formatDefault;
}
}

// Mutates axis.{label,labelAnchor,labelOffset} and scale.label!
export function autoScaleLabels(channels, scales, {x, y, fx, fy}, dimensions, options) {
if (fx) {
autoAxisLabelsX(fx, scales.fx, channels.get("fx"));
if (fx.labelOffset === undefined) {
const {facetMarginTop, facetMarginBottom} = dimensions;
fx.labelOffset = fx.axis === "top" ? facetMarginTop : facetMarginBottom;
}
}
if (fy) {
autoAxisLabelsY(fy, fx, scales.fy, channels.get("fy"));
if (fy.labelOffset === undefined) {
const {facetMarginLeft, facetMarginRight} = dimensions;
fy.labelOffset = fy.axis === "left" ? facetMarginLeft : facetMarginRight;
}
}
if (x) {
autoAxisLabelsX(x, scales.x, channels.get("x"));
if (x.labelOffset === undefined) {
const {marginTop, marginBottom, facetMarginTop, facetMarginBottom} = dimensions;
x.labelOffset = x.axis === "top" ? marginTop - facetMarginTop : marginBottom - facetMarginBottom;
}
}
if (y) {
autoAxisLabelsY(y, x, scales.y, channels.get("y"));
if (y.labelOffset === undefined) {
const {marginRight, marginLeft, facetMarginLeft, facetMarginRight} = dimensions;
y.labelOffset = y.axis === "left" ? marginLeft - facetMarginLeft : marginRight - facetMarginRight;
}
}
for (const [key, type] of registry) {
if (type !== position && scales[key]) {
// not already handled above
autoScaleLabel(key, scales[key], channels.get(key), options[key]);
}
}
}

// Mutates axis.labelAnchor, axis.label, scale.label!
function autoAxisLabelsX(axis, scale, channels) {
if (axis.labelAnchor === undefined) {
axis.labelAnchor = isOrdinalScale(scale) ? "center" : scaleOrder(scale) < 0 ? "left" : "right";
}
if (axis.label === undefined) {
axis.label = inferLabel(channels, scale, axis, "x");
}
scale.label = axis.label;
}

// Mutates axis.labelAnchor, axis.label, scale.label!
function autoAxisLabelsY(axis, opposite, scale, channels) {
if (axis.labelAnchor === undefined) {
axis.labelAnchor = isOrdinalScale(scale)
? "center"
: opposite && opposite.axis === "top"
? "bottom" // TODO scaleOrder?
: "top";
}
if (axis.label === undefined) {
axis.label = inferLabel(channels, scale, axis, "y");
}
scale.label = axis.label;
}

// Mutates scale.label!
function autoScaleLabel(key, scale, channels, options) {
if (options) {
scale.label = options.label;
}
if (scale.label === undefined) {
scale.label = inferLabel(channels, scale, null, key);
}
}

// Channels can have labels; if all the channels for a given scale are
// consistently labeled (i.e., have the same value if not undefined), and the
// corresponding axis doesn’t already have an explicit label, then the channels’
// label is promoted to the corresponding axis.
function inferLabel(channels = [], scale, axis, key) {
let candidate;
for (const {label} of channels) {
if (label === undefined) continue;
if (candidate === undefined) candidate = label;
else if (candidate !== label) return;
}
if (candidate !== undefined) {
// Ignore the implicit label for temporal scales if it’s simply “date”.
if (isTemporalScale(scale) && /^(date|time|year)$/i.test(candidate)) return;
if (!isOrdinalScale(scale)) {
if (scale.percent) candidate = `${candidate} (%)`;
if (key === "x" || key === "y") {
const order = scaleOrder(scale);
if (order) {
if (key === "x" || (axis && axis.labelAnchor === "center")) {
candidate = (key === "x") === order < 0 ? `← ${candidate}` : `${candidate} →`;
} else {
candidate = `${order < 0 ? "↑ " : "↓ "}${candidate}`;
}
}
}
}
}
return candidate;
import {format, utcFormat} from "d3";
import {formatIsoDate} from "./format.js";
import {constant, isTemporal, string} from "./options.js";
import {isOrdinalScale} from "./scales.js";

export function inferFontVariant(scale) {
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
}

// D3 doesn’t provide a tick format for ordinal scales; we want shorthand when
// an ordinal domain is numbers or dates, and we want null to mean the empty
// string, not the default identity format. TODO Remove this in favor of the
// axis mark’s inferTickFormat.
export function maybeAutoTickFormat(tickFormat, domain) {
return tickFormat === undefined
? isTemporal(domain)
? formatIsoDate
: string
: typeof tickFormat === "function"
? tickFormat
: (typeof tickFormat === "string" ? (isTemporal(domain) ? utcFormat : format) : constant)(tickFormat);
}
Loading

0 comments on commit 1da65ff

Please sign in to comment.