diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 447c0268..33eb1e85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,21 +6,44 @@ on: pull_request: branches: [main] +env: + # The Observable Plot commit whose committed snapshots are the parity oracle + # (v0.6.17-41-g1c6b239d). Bumping it means refreshing parity-allow.json in the + # same commit: yarn parity --allow parity-allow.json --update. + UPSTREAM_COMMIT: 1c6b239d1e42eb5c0d8c5b09f51b0c6ce51bea69 + jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The Node LTS lines that are still maintained. + node-version: [22, 24] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: yarn - run: yarn --frozen-lockfile + # The snapshot oracle is byte comparison against committed files, and one + # plot is not byte-stable across V8 lines: projectionDomainRatioNC renders + # the same polygon starting from a different vertex on Node 24 than on 22, + # because d3-geo's clipping is sensitive to floating-point differences in + # the V8 the tests run on. The baselines are therefore generated and + # checked on the primary line only, and the other lines run everything + # except the snapshot harness, so a Node-specific breakage in the library + # still shows up here. - run: yarn test:mocha + if: matrix.node-version == 22 + - run: yarn test:units + if: matrix.node-version != 22 - run: yarn test:tsc - run: yarn test:lint - run: yarn test:prettier - run: yarn prepublishOnly + - run: yarn test:smoke - run: yarn test:publint - run: yarn test:attw - run: yarn docs:build @@ -29,3 +52,29 @@ jobs: with: name: test-output-changes path: test/output/*-changed.* + + # A green test job says Replot's snapshots have not changed; it does not say + # they match Observable Plot's. This job compares the committed snapshots + # with upstream's at the pinned commit, and fails on any plot that differs in + # a way parity-allow.json does not already record. + parity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + - run: yarn --frozen-lockfile + # Upstream's snapshots are the whole oracle, so fetch its test/output at + # that one commit: shallow, blobless (no file contents until the checkout + # asks for them) and sparse. + - name: Fetch the pinned Observable Plot snapshots + run: | + git init ../observablehq-plot + git -C ../observablehq-plot remote add origin https://github.com/observablehq/plot.git + git -C ../observablehq-plot config core.sparseCheckout true + echo 'test/output/*' > ../observablehq-plot/.git/info/sparse-checkout + git -C ../observablehq-plot fetch --depth 1 --filter=blob:none origin "$UPSTREAM_COMMIT" + git -C ../observablehq-plot checkout FETCH_HEAD + - run: yarn -s parity --upstream ../observablehq-plot --allow parity-allow.json --fail diff --git a/.gitignore b/.gitignore index 2de393ef..753197ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ .DS_Store +.claude/settings.local.json +.claude/worktrees/ +.playwright-cli/ coverage/ dist/ -docs/.vitepress/cache -docs/.vitepress/dist node_modules/ test/output/*-changed.svg test/output/*-changed.html diff --git a/CHANGELOG-2021.md b/CHANGELOG-2021.md deleted file mode 100644 index 70accdc0..00000000 --- a/CHANGELOG-2021.md +++ /dev/null @@ -1,419 +0,0 @@ -# Replot - Changelog [2021] - -Year: [Current (2025)](./CHANGELOG.md) · [2024](./CHANGELOG-2024.md) · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · **2021** - -## 0.3.2 - -[Released December 10, 2021.](https://github.com/observablehq/plot/releases/tag/v0.3.2) - -The font-variant now only defaults to *tabular-nums* for axis and legend tick labels on non-ordinal scales; on ordinal scales it defaults to *normal*. - -If a scale has an implicit label (such as inferred from a channel), the legend will now inherit the label. - -## 0.3.1 - -[Released December 10, 2021.](https://github.com/observablehq/plot/releases/tag/v0.3.1) - -The normalize transform now also supports the *min*, *max*, and *deviation* basis methods. - -The generated figure element now resets the max-width style to initial. - -Update D3 to 7.2.1. - -## 0.3.0 - -[Released December 4, 2021.](https://github.com/observablehq/plot/releases/tag/v0.3.0) - -Plot can now produce [legends for *color* and *opacity* scales](https://observablehq.com/plot/features/legends)! - -[a scatterplot with a color legend](https://observablehq.com/plot/features/legends) - -```js -Plot.plot({ - color: { - legend: true - }, - marks: [ - Plot.dot(athletes, {x: "weight", y: "height", stroke: "sex"}) - ] -}) -``` - -The top-level plot *scale*.**legend** option generates an inline legend for the given *scale* (*color* or *opacity*). Alternatively, the new *plot*.legend(*name*) function returns a legend for the scale with the given *name*. The new standalone Plot.**legend**(*options*) function also allows you to create a legend independently of a chart. Two forms of color legend are provided: *swatches* for ordinal or discrete scales (*e.g.*, threshold color scales), and *ramp* for continuous scales. - -The new [Plot.image](https://observablehq.com/plot/marks/image) mark centers an image on the given *xy* position. - -[a scatterplot of U.S. presidents](https://observablehq.com/plot/marks/image) - -```js -Plot.plot({ - x: { - label: "Date of first inauguration →" - }, - y: { - grid: true, - label: `↑ Net favorability (%)`, - percent: true, - tickFormat: "+f" - }, - marks: [ - Plot.ruleY([0]), - Plot.image(favorability, {x: "First Inauguration Date", y: "Net favorability", src: "Portrait URL"}) - ] -}) -``` - -All marks now support a new *opacity* option, either as a constant or a channel bound to the *opacity* scale, which sets the mark’s object opacity. The *strokeOpacity* or *fillOpacity* options should be preferred for performance, but the *opacity* option is needed for some cases (such as images). - -All quantitative scales now have a default domain (typically [0, 1]). Ordinal scales now allow a *range* option to be specified in conjunction with a continuous *scheme*; in this case, the *range* should be a subset of the unit interval [0, 1], and the scheme’s interpolator will be truncated accordingly. The *plot*.**scale** method now correctly represents the symmetric transform for diverging scales. The scale definition returned by *plot*.**scale**(*name*) now includes a *scale*.apply function, allowing you to apply the scale’s encoding directly on the given data value. When applicable, a *scale*.invert function is also returned, which can be useful for interaction. - -Plot’s stylesheet is now inlined in the generated SVG element rather than injected into the document head. To avoid namespace collisions, we use a randomly-generated class name; use the top-level **className** option to specify a custom class name. The default style now uses font-variant: tabular-nums for a better alignment of numbers. - -A new top-level *margin* option allows to set all of marginLeft, marginRight, marginTop, and marginBottom at once. This shorthand notation is also available as a facet option. - -Update D3 to 7.2.0. - -## 0.2.9 - -[Released October 12, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.9) - -Update D3 to 7.1.1. - -## 0.2.8 - -[Released October 1, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.8) - -The map transform now supports *rank* and *quantile* map methods. These compute the 0-based ordinal rank or the *p*-quantile of the data, respectively. - -The bin transform now allows bin options (*domain*, *cumulative*, and *thresholds*) to be specified on the *outputs* object; this avoids ambiguity when composing multiple bin transforms. To facilitate transform composition, the bin and group transforms now consume the *x1* and *x2* channels when *x* is an output, and the *y1* and *y2* channels when *y* is an output. Also, the bin, group, and stack transforms now consume their respective transform options rather than propagating them in the returned options. The bin and group transform no longer use the channel name as the default label for the *count* reducer. The bin transform now automatically coerces values to dates when the *thresholds* are specified as a time interval or an array of dates. - -The new shuffle transform randomizes the order of data. It takes an optional *seed* for deterministic order. - -Position scales (*x*, *y*, *fx*, and *fy*) now support asymmetric insets via the *scale*.**insetLeft**, *scale*.**insetRight**, *scale*.**insetTop**, and *scale*.**insetBottom** options. Threshold and quantile scales can now be used as position scales without requiring an explicit range. - -The radius scale (*r*) now limits the maximum default radius to 30 pixels. - -Text marks now default strokeLinejoin to “round” to reduce the effect of miter joins. - -## 0.2.7 - -[Released September 27, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.7) - -Implement symmetric diverging scales by extending the domain instead of slicing the interpolator. - -## 0.2.6 - -[Released September 26, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.6) - -Fix the symmetric transform for reversed diverging scales. - -## 0.2.5 - -[Released September 26, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.5) - -The new *plot*.**scale**(*name*) method exposes scales used by a particular plot. This method on returned plots takes the *name* of a scale and returns an object specifying the options that fully describe the scale’s behavior. The returned object represents the actual options in-use, including *scale*.domain, *scale*.range, *scale*.interpolate, *etc.* The *scale*.label, if any, is also returned; however, note that other axis options are not. The scale object is undefined if the associated plot has no scale with the given *name*, and throws an error if the *name* is invalid (*i.e.*, not one of the known scale names: *x*, *y*, *fx*, *fy*, *r*, *color*, or *opacity*). For example, given a plot: - -```js -myplot = Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}).plot() -``` - -You can retrieve the color scale like so: - -```js -mycolor = myplot.scale("color") -``` - -The resulting object is: - -```js -({ - type: "linear", - domain: [-0.78, 1.35], - range: [0, 1], - interpolate: d3.interpolateTurbo, - clamp: false, - label: "Anomaly" -}) -``` - -Inspecting materialized scales is useful for debugging and for generating color legends. (For now, we recommend using the [D3 color legend](https://observablehq.com/@d3/color-legend); in the near future Plot will have built-in support for color legends.) Scales can also now be shared between plots, ensuring consistent encoding. To reuse a scale, pass the scale from one plot into the specification for another: - -```js -Plot.plot({…, color: myplot.scale("color")}) -``` - -The new *scale*.**unknown** option provides more control over how unexpected, undefined, or invalid values are displayed. For quantitative and temporal scales, the unknown value is returned whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then affected input values will be considered undefined and filtered from the output. Hence, by setting *scale*.unknown to a defined value, you can ensure that these values are plotted. - -Plot now supports piecewise (a.k.a. “polylinear”) scales. If a single-argument *interpolate* function is specified (such as d3.interpolateWarm, or equivalently if you specify a color *scheme* such as *warm*), and the *range* is undefined, and the *domain* has more than two elements, the range [0, 1] will be partitioned into *domain*.length - 1 equal-size segments. The default scale range for *x* and *y* now supports piecewise scales, as does the default axis tick count. Additionally, ordinal color schemes now return the correct number of colors when the natural scheme size is not equal to the desired scheme size; and the *rainbow* and *sinebow* cyclical color schemes, when used with an ordinal color scale, no longer duplicate the first color as the last color. - -Plot now automatically detects “reversed” quantitative or temporal scales with descending domains or ranges. Reversed scales are detected by comparing the natural order of the domain and range. This improves the default axis label arrow orientation and the behavior of the *zero* option. - -The *interval* option for rect and bar now works with the default identity accessor. Also, the *interval* option is now ignored if the necessary value channel (*e.g.*, *x*) is not defined, rather than throwing an error. For example, you no longer have to define *x* as the identity function here: - -```js -Plot.barX(d3.utcDays(start, end), {interval: d3.utcDay, fill: d => d}) -``` - -Plot’s default rounding behavior for band and point scales is now slightly smarter. Previously, automatic rounding could introduce substantial empty space on tall or wide plots; now Plot disables default rounding if it would introduce more than 30px of empty space. - -The *percent* and *transform* options are now supported on all scales, including diverging scales. - -The *center* and *wiggle* stack offsets now center across facets, facilitating comparison. - -Internal string coercion now uses *object*.toString instead of *object*.valueOf. - -## 0.2.4 - -[Released September 24, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.4) - -Update [Observable Hypertext Literal](https://github.com/observablehq/htl) to [0.3.1](https://github.com/observablehq/htl/releases/tag/v0.3.1). - -## 0.2.3 - -[Released September 24, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.3) - -Rect, bar, and rule marks now accept an *interval* option that allows you to derive *x1* and *x2* from *x*, or *y1* and *y2* from *y*, where appropriate. For example, using d3.utcDay as the interval creates rects that span from UTC midnight to UTC midnight, bounding the associated time instant. The interval is typically specifed as a [D3 time interval](https://github.com/d3/d3-time/blob/main/README.md), but may be any compatible object which implements *interval*.floor and *interval*.offset: *interval*.floor(*x*) returns the start of the interval *x1* for the given *x*, while *interval*.offset(*x*) returns the end of the interval *x2* for the given interval start *x*. If the interval is specified as a number *n*, *x1* and *x2* are the two consecutive multiples of *n* that bracket *x*. - -The new Plot.normalize and Plot.window methods return map methods for use in conjunction with Plot.map. This allows greater flexibility; for example, you can apply separate window methods to *y1* and *y2* to produce Bollinger bands. - -Update [D3](https://github.com/d3/d3) to [7.0.4](https://github.com/d3/d3/releases/tag/v7.0.4). - -## 0.2.2 - -[Released September 19, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.2) - -Fix a crash with the scale.tickRotate option when there are no ticks to rotate. - -## 0.2.1 - -[Released September 19, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.1) - -The constant *dx* and *dy* options have been extended to all marks, allowing to shift the mark by *dx* pixels horizontally and *dy* pixels vertically. Since only text elements accept the dx and dy properties, in all the other marks these are rendered as a transform (2D transformation) property of the mark’s parent, possibly including a 0.5px offset on low-density screens. - -Quantitative scales, as well as identity position scales, now coerce channel values to numbers; both null and undefined are coerced to NaN. Similarly, time scales coerce channel values to dates; numbers are assumed to be milliseconds since UNIX epoch, while strings are assumed to be in [ISO 8601 format](https://github.com/mbostock/isoformat/blob/main/README.md#parsedate-fallback). - -Bin transform reducers now receive the extent of the current bin as an argument after the data. For example, it allows to create meaningful titles: - -```js -Plot.rect( - athletes, - Plot.bin( - { - fill: "count", - title: (bin, {x1, x2, y1, y2}) => `${bin.length} athletes weighing between ${x1} and ${x2} and with a height between ${y1} and ${y2}` - }, - { - x: "weight", - y: "height", - inset: 0 - } - ) -).plot() -``` - -The *x1* and *x2* outputs now default to undefined if *x* is explicitly defined; similary, the *y1* and *y2* outputs now default to undefined if *y* is explicitly defined. - -## 0.2.0 - -[Released August 20, 2021.](https://github.com/observablehq/plot/releases/tag/v0.2.0) - -[breaking] Plot is now published as an ES module and requires Node 12 or higher. For more, please read [Sindre Sorhus’s FAQ](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c). - -[breaking] Plot now depends on [D3 7.0](https://github.com/d3/d3/releases/tag/v7.0.0) or higher. - -### Marks - -The [*marks* option](https://observablehq.com/plot/features/plots#marks-option) now accepts render functions, null, and undefined as shorthand mark definitions. Nullish marks produce no output and are useful for conditional display (equivalent to the empty array). Render functions are invoked when plotting and may return an SVG element to insert into the plot, such as a legend or annotation. - -a line chart of Apple, Inc.’s daily closing stock price from 2013 to 2018, with a red ‘hello world’ label - -```js -Plot.marks( - Plot.line(aapl, {x: "Date", y: "Close"}), - () => svg`Hello, world!` -).plot() -``` - -The [Plot.marks(...*marks*)](https://observablehq.com/plot/features/marks#marks) function provides [*mark*.plot](https://observablehq.com/plot/features/plots#mark_plot) shorthand for array marks. This is useful for composite marks, such as [boxes](https://github.com/observablehq/plot/blob/8fef4fa52a4cca4135f5f964e3c328ef8f18f672/test/plots/morley-boxplot.js#L18-L23). - -All marks now support the [shapeRendering](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/shape-rendering) option. (This is a constant; it may not vary across marks.) All marks now allow strokeWidth to be specified as a channel. (The strokeWidth channel is unscaled; values are specified in literal pixels.) Text marks now also allow stroke and strokeOpacity to be specified as channels. If its fill is not *none*, a line’s default stroke is now *none* rather than *currentColor*, making it consistent with dot and other marks. When a fill or fillOpacity channel is used with a link, or when a stroke or strokeOpacity channel is used with a rule, undefined values will now be filtered. The text mark now uses attributes instead of styles for font rendering properties, improving compatibility with Firefox. - -a series of dots with an increasing stroke width - -```js -Plot.dotX(d3.range(41), {strokeWidth: d => (1 + d) / 15}).plot() -``` - -Marks that represent continuous intervals (rect, bar, and rule) now handle collapsed domains during rendering. A domain is considered collapsed when it contains only a single value. A collapsed domain can occur when all input values to the bin transform are equal (*e.g.*, [1, 1, 1, …] produces a domain of [1, 1]). Previously a collapsed domain would result in an invisible zero-width mark; now the mark spans the full extent of the chart. - -a histogram with a collapsed domain showing a single bar that represents the value 1 with frequency 3 - -```js -Plot.rectY([1, 1, 1], Plot.binX()).plot() -``` - -The link mark now supports *x* or *y* shorthand for one-dimensional links, equivalent to rule. The rect mark now supports one-dimensional (and zero-dimensional) rects: the *x1*, *x2*, *y1* and *y2* channels are now optional. - -### Scales - -The new [*sort* mark option](https://observablehq.com/plot/features/scales#sort-mark-option) allows convenient control over the order of ordinal domains, including the *fx* and *fy* facet domains. The aggregation method can be controlled via the *reduce* option, which defaults to *max*. The *reverse* and *limit* options are also supported. For example, a bar chart can be sorted by descending value like so: - -a bar chart showing the frequency of letters in the English language in order of descending frequency, starting with E at 13% and ending with Z at almost 0% - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reverse: true}}) -``` - -Color scales now support the *threshold* scale type, allowing you to specify a set of *n* - 1 discrete (typically numeric) thresholds to produce *n* discrete colors. The new *quantile* color scale type will automatically compute *n* - 1 thresholds for *n* quantiles based on the data. - -a histogram showing the forecast probabilities of Democratic electoral votes in the 2016 U.S. presidential election, with outcomes of 270 votes highlighted in blue - -```js -Plot.plot({ - y: { - percent: true - }, - color: { - type: "threshold", - domain: [270] - }, - marks: [ - Plot.ruleX(data, {x: "dem_electoral_votes", y: "probability", stroke: "dem_electoral_votes", strokeWidth: 1.5}), - Plot.ruleX([270]) - ] -}) -``` - -Diverging scales now support a *symmetric* option, which defaults to true, to ensure that differences above and below the pivot are equally apparent. For example, the choropleth below gives equal visual weight to West Virginia’s population decline of −3% and Alaska’s gain of +3%. If *symmetric* is false, as before, then −3% is mapped to the darkest purple. (Diverging scales should always use balanced interpolators where the negative and positive extremes have equal weight; this is true of Plot’s built-in diverging color schemes from ColorBrewer.) - -a choropleth with symmetric diverging scale, showing the change in population of the fifty U.S. states between 2010 and 2019; the change in negative values is commensurate with the change in positive values - -Diverging color scales now also support transformations via four new scale types: *diverging-sqrt*, *diverging-pow*, *diverging-log*, and *diverging-symlog*, corresponding to the *sqrt*, *pow*, *log*, and *symlog* quantitative scale types respectively. (The above choropleth uses a *diverging-log* scale to show relative change.) - -The new axis *line* option, which defaults to false, can be used to show a continuous line along the *x* or *y* axis to denote the extent. This is most useful when the opposite axis is ordinal and thus a rule cannot annotate a meaningful value such as zero; if the opposite axis is quantitative, a rule is generally preferred. If set at the top level, applies to both *x* and *y*. - -an empty plot showing a vertical line along the quantitative y-axis; the opposite x-axis is categorical - -```js -Plot.plot({ - grid: true, - inset: 6, - x: { - domain: "ABCDEFGH" - }, - y: { - line: true, - domain: [0, 1] - } -}) -``` - -The *axis* and *label* options (along with *grid*) can now be set as a top-level option, or a facet-level option, and inherited by *x* and *y* or *fx* and *fy*. This is most often useful for turning off all default axes or labels by setting the respective option to null. - -### Facets - -The mark *facet* option can be used to control whether or not a mark is faceted. The supported values are *auto*, *include*, *exclude*, and null. True is an alias for *include* and false is an alias for null. The default is *auto*, which facets a mark if and only if its data is strictly equal to the facet data. The *include* facet mode allows a mark with different data to be faceted; however, it requires that the mark’s data be parallel with the facet data (*i.e.*, have the same length and order). The *exclude* facet mode shows all data that are not present in the current facet; this can provide shared context across facets without overdrawing. The null mode disables faceting, replicating the mark across all facets. - -a faceted scatterplot showing the correlation between culmen depth and length across sex and species; each facet shows the current sex and species with black dots and all other penguins as pale gray dots - -```js -Plot.plot({ - facet: { - data: penguins, - x: "sex", - y: "species", - marginRight: 80 - }, - marks: [ - Plot.frame(), - Plot.dot(penguins, { - facet: "exclude", - x: "culmen_depth_mm", - y: "culmen_length_mm", - r: 2, - fill: "#ddd" - }), - Plot.dot(penguins, { - x: "culmen_depth_mm", - y: "culmen_length_mm" - }) - ] -}) -``` - -Empty facets, which can occur when faceting in both *x* and *y*, or when the specified *fx* or *fy* domain includes values not present in the data, are no longer rendered. When the facet *data* is null, a better error message is thrown. - -### Transforms - -The bin and group transforms now support new *filter*, *sort* and *reverse* options on the *outputs* object. By setting the *filter* to null, the bin transform will now return all bins, even if empty; this is useful with marks such as lines and areas that require zeroes to be present, rather than interpolating across the missing bins. (The *z*, *fill* or *stroke* channels, when used for grouping, are propagated to empty bins.) - -a stacked area chart showing the count of covid cases resulting in deaths over time for San Francisco, broken down by transmission category; if empty bins were not present, this chart would render incorrectly - -```js -Plot.plot({ - marks: [ - Plot.areaY(cases, Plot.binX({y: "sum", filter: null}, { - x: "specimen_collection_date", - y: "case_count", - filter: d => d.case_disposition === "Death", - fill: "transmission_category", - curve: "step", - thresholds: d3.utcWeek - })), - Plot.ruleY([0]) - ] -}) -``` - -The *outputs* argument to the bin and group transforms is now optional; it defaults to the *count* reducer for *y*, *x* and *fill* for Plot.binX, Plot.binY, and Plot.bin respectively, and the same for the group transforms. - -The bin and group transforms now support new *distinct*, *mode*, *min-index*, and *max-index* reducers. The *distinct* reducer counts the number of distinct values in each group, while the *mode* reducer returns the most frequent value in each group. The *min-index* and *max-index* reducers are similar to *min* and *max*, except they return the zero-based index of the minimum and maximum value, respectively; for example, this is useful to sort time series by the date of each series’ peak. - -a small-multiples area chart of Google search trends for the year 2020; trends are ordered chronologically by their peak, starting with Australian bushfires and ending with COVID-19 vaccine - -```js -Plot.plot({ - height: 900, - label: null, - x: {axis: "top"}, - y: {axis: null}, - facet: { - data: trends, - y: "search", - marginLeft: 160 - }, - marks: [ - Plot.areaY(trends, {x: "week", y: "interest", sort: {fy: "y", reduce: "max-index"}}) - ] -}) -``` - -The default *thresholds* option for the bin transforms is now *auto* instead of *scott*. The *auto* option applies a maximum limit of 200 bins to Scott’s rule. This reduces the risk of producing vanishing rects when they are too numerous and thin to be visible. (Note, however, that it is still possible to produce invisible rects if the insets are larger than the width.) - -The normalize, window, and stack transforms can now accept a transform *options* argument in addition to an *inputs* argument that specifies the input channels. This allows makes these transforms more consistent with the other transforms, reduces ambiguity, and allows for additional shorthand. For example, you can pass *k* as the first argument to the window transform, here for a 24-month moving average: - -a smoothed line chart showing the rise of the average global surface temperature anomaly from 1880 to the present - -```js -Plot.plot({ - y: { - label: "↑ Temperature anomaly (°C)", - tickFormat: "+f", - grid: true - }, - marks: [ - Plot.ruleY([0]), - Plot.line(gistemp, Plot.windowY(24, {x: "Date", y: "Anomaly"})) - ] -}) -``` - -The *offset* stack options have been renamed: *offset* = *expand* is now *offset* = *normalize*, and *offset* = *silhouette* is *offset* = *center*. The *shift* window option has been replaced with the *anchor* option: *shift* = *centered* is now *anchor* = *middle*, *shift* = *leading* is *anchor* = *start*, and *shift* = *trailing* is *anchor* = *end*. The old names are supported for backwards compatibility. - -The basic transforms are now available as explicit option transforms: Plot.filter, Plot.sort, and Plot.reverse. These are useful when you wish to control the order of these transforms with respect to other transforms such as Plot.bin and Plot.stack. - -The select transforms now throw better error messages when required input channels are missing. When mark *transform* option is null, it is considered equivalent to undefined and no transform is applied instead of throwing an error. - -## 0.1.0 - -[Released May 3, 2021.](https://github.com/observablehq/plot/releases/tag/v0.1.0) diff --git a/CHANGELOG-2022.md b/CHANGELOG-2022.md deleted file mode 100644 index 5106cf0a..00000000 --- a/CHANGELOG-2022.md +++ /dev/null @@ -1,666 +0,0 @@ -# Replot - Changelog [2022] - -Year: [Current (2025)](./CHANGELOG.md) · [2024](./CHANGELOG-2024.md) · [2023](./CHANGELOG-2023.md) · **2022** · [2021](./CHANGELOG-2021.md) - -## 0.6.1 - -[Released December 12, 2022.](https://github.com/observablehq/plot/releases/tag/v0.6.1) - -The new [geo mark](https://observablehq.com/plot/marks/geo) renders GeoJSON geometries such as polygons, lines, and points. Together with Plot’s new [projection system](https://observablehq.com/plot/features/projections), Plot can now produce [thematic maps](https://observablehq.com/@observablehq/plot-mapping). For example, the choropleth map below shows unemployment rates by U.S. county. - -[A choropleth of unemployment rate by U.S. county](https://observablehq.com/plot/marks/geo) - -```js -Plot.geo(counties, {fill: (d) => d.properties.unemployment}).plot({ - projection: "albers-usa", - color: { - type: "quantile", - n: 8, - scheme: "blues", - label: "Unemployment (%)", - legend: true - } -}) -``` - -The new top-level [**projection** option](https://observablehq.com/plot/features/projections) controls how geometric coordinates are transformed to the screen and supports a variety of common geographic projections, including the composite U.S. Albers projection shown above, the Equal Earth projection, the Mercator projection, the orthographic and stereographic projections, several conic and azimuthal projections, among others. Projections can be fit to geometry using the projection.**domain** option, and rotated to an arbitrary aspect using the projection.**rotate** option. - -[A world map using the orthographic projection, centered somewhere around San Antonio, Texas](https://observablehq.com/plot/features/projections) - -```js -Plot.plot({ - height: 640, - inset: 1, - projection: {type: "orthographic", rotate: [100, -30]}, - marks: [ - Plot.graticule(), - Plot.geo(land, {fill: "currentColor"}), - Plot.sphere() - ] -}) -``` - -The new graticule convenience mark renders meridians and parallels (lines of constant longitude and latitude), while the sphere convenience mark draws the outline of the sphere. - -Plot’s projection system works automatically with most of Plot’s mark types, including dots, vectors, lines, and rects. For geographic projections, *x* represents longitude and *y* represents latitude. For example, the map below uses vectors to show county-level vote margins in the 2020 U.S. presidential election: a margin for Biden is shown as a blue left-pointing arrow, while a margin for Trump is shown as a red right-pointing arrow; the length of the arrow is proportional to the margin. - -[An arrow map showing the county-level vote margins in the 2020 U.S. presidential election; a margin for Biden is shown as a blue left-pointing arrow, and a margin for Trump is shown as a red right-pointing arrow](https://observablehq.com/@observablehq/plot-mapping) - -```js -Plot.plot({ - width: 975, - projection: "albers-usa", - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.25}), - Plot.geo(nation), - Plot.vector(elections, { - filter: (d) => d.votes > 0, - anchor: "start", - x: (d) => centroids.get(d.fips)?.[0], - y: (d) => centroids.get(d.fips)?.[1], - sort: (d) => Math.abs(d.results_trumpd - d.results_bidenj), - stroke: (d) => (d.results_trumpd > d.results_bidenj ? "red" : "blue"), - length: (d) => Math.sqrt(Math.abs(d.margin2020 * d.votes)), - rotate: (d) => (d.results_bidenj < d.results_trumpd ? 60 : -60) - }) - ] -}) -``` - -For the [line mark](https://observablehq.com/plot/marks/line), the specified projection doesn’t simply project control points; the projection has full control over how geometry is transformed from its native coordinate system (often spherical) to the screen. This allows line geometry to be represented as [geodesics](https://en.wikipedia.org/wiki/Geodesic), which are sampled and clipped during projection. For example, the map below shows the route of Charles Darwin’s voyage on the HMS *Beagle*; note that the line is cut when it crosses the antimeridian in the Pacific ocean. (Also note the use of the *stroke* channel to vary color.) - -[A map of the route of the HMS Beagle, 1831–1836; color indicates direction, with the ship initially departing London and heading southwest before circumnavigating the globe](https://observablehq.com/plot/marks/geo) - -```js -Plot.plot({ - projection: "equal-earth", - marks: [ - Plot.geo(land, {fill: "currentColor"}), - Plot.graticule(), - Plot.line(beagle, {stroke: (d, i) => i, z: null, strokeWidth: 2}), - Plot.sphere() - ] -}) -``` - -Plot’s new geo mark and projection system work with Plot’s other core features, including scales, legends, faceting, and transforms. For example, here is a faceted dot map showing openings of Walmart stores over five decades. - -[A dot map of Walmart store openings faceted by decade](https://observablehq.com/@observablehq/plot-mapping) - -```js -Plot.plot({ - width: 975, - projection: "albers", - fx: {tickFormat: (d) => `${d}’s`}, - facet: {data: walmarts, x: (d) => Math.floor(d.date.getUTCFullYear() / 10) * 10}, - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.1}), - Plot.dot(walmarts, {x: "longitude", y: "latitude", r: 1, fill: "currentColor"}), - Plot.geo(nation) - ] -}) -``` - -As another example using the same dataset, the map below uses the hexbin transform to aggregate Walmart store opening into local hexagons. - -[A bivariate hexbin map of Walmart store openings; within each hexagonal area, size indicates the number of Walmart store openings, and color indicates the year of the first opening](https://observablehq.com/@observablehq/plot-mapping) - -```js -Plot.plot({ - projection: "albers", - color: { - legend: true, - label: "First year opened", - scheme: "spectral" - }, - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.25}), - Plot.geo(nation), - Plot.dot(walmarts, Plot.hexbin({r: "count", fill: "min"}, {x: "longitude", y: "latitude", fill: "date"})) - ] -}) -``` - -In addition to the included basic projections, Plot’s projection system can be extended using any projection implementation compatible with D3’s [projection stream interface](https://d3js.org/d3-geo/stream). This includes all the projections provided by the [d3-geo-projection](https://github.com/d3/d3-geo-projection) and [d3-geo-polygon](https://github.com/d3/d3-geo-polygon) libraries! For example, here is a world map using Goode’s interrupted homolosine projection. - -[A world map using Goode’s interrupted homolosine projection](https://observablehq.com/@observablehq/plot-extended-projections) - -```js -Plot.plot({ - width: 975, - height: 424, - inset: 1, - projection: { - type: d3.geoInterruptedHomolosine, - domain: {type: "Sphere"} - }, - marks: [ - Plot.geo(land, {clip: "sphere", fill: "currentColor"}), - Plot.graticule({clip: "sphere"}), - Plot.sphere() - ] -}) -``` - -Plot now supports [mark-level faceting](https://observablehq.com/plot/features/facets#mark-facet-options) via the new *mark*.**fx** and *mark*.**fy** options. Mark-level faceting makes it easier to control which marks are faceted (versus repeated across facets), especially when combining multiple datasets or specifying faceted annotations. - -[A faceted scatterplot of Anscombe’s quartet](https://observablehq.com/plot/features/facets) - -```js -Plot.plot({ - grid: true, - height: 180, - marks: [Plot.frame(), Plot.dot(anscombe, {x: "x", y: "y", fx: "series"})] -}) -``` - -When mark-level faceting is used, the mark will be faceted if either the *mark*.**fx** or *mark*.**fy** channel option (or both) is specified. As before, you can set the *mark*.**facet** option to null or false option will disable faceting, or to *exclude* to draw the subset of the mark’s data not in the current facet. - -In addition to the above new features, this release also includes a variety of bug fixes. The *fx* and *fy* scales now support the *scale*.**transform** and *scale*.**percent** options. The *quantize* scale now respects the *scale*.**unknown** option. Initializers (including dodge and hexbin) no longer unintentionally drop the *mark*.**sort** option when being used to sort a scale’s domain. The error message when an invalid color scheme is specified has been improved. Plot no longer warns about empty strings appearing to be numbers. The *mean* and *median* reducers now return dates if the data is temporal. The default **height** now adjusts automatically to preserve the inner size of the plot when margins are specified. Fix the position of the frame anchor when either the *x* or *y* scale is ordinal (band or point). Dots with a negative constant radius *r* are no longer rendered rather than generating invalid SVG. - -## 0.6.0 - -[Released September 7, 2022.](https://github.com/observablehq/plot/releases/tag/v0.6.0) - -[A smoothed line chart of Apple’s stock price](https://observablehq.com/plot/transforms/window) - -```js -Plot.plot({ - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close", stroke: "#ccc", strokeWidth: 1}), - Plot.lineY(aapl, Plot.windowY({k: 20, strict: false}, {x: "Date", y: "Close", stroke: "red"})), - Plot.lineY(aapl, Plot.windowY({k: 20, strict: true}, {x: "Date", y: "Close"})) - ] -}) -``` - -[breaking] [Plot.window](https://observablehq.com/plot/transforms/window#window), [Plot.windowX](https://observablehq.com/plot/transforms/window#windowX) and [Plot.windowY](https://observablehq.com/plot/transforms/window#windowY) now return an aggregate value even when the window contains undefined values, for example at the beginning or end of a series. Set the new **strict** option to true to instead return undefined if the window contains any undefined values. - -Parts of the README have been incorporated throughout the codebase as JSDoc comments. This allows IDEs to display the documentation as tooltips. - -## 0.5.2 - -[Released July 4, 2022.](https://github.com/observablehq/plot/releases/tag/v0.5.2) - -Swatches legends are now rendered in SVG, supporting patterns and gradients. Swatches legends now require an *ordinal*, *categorical*, or *threshold* color scale and will throw an error if you attempt to use them with an unsupported color scale type (such as a *linear* or *diverging* scale). - -The new top-level **document** option specifies the [document](https://developer.mozilla.org/en-US/docs/Web/API/Document) used to create plot elements. It defaults to window.document, but can be changed to another document, say when using a virtual DOM library for server-side rendering in Node. - -Plot now uses D3 7.6.1, using [d3.blur2](https://observablehq.com/@d3/d3-blur) for a faster blur operator supporting fractional bandwidths when computing density contours. Plot now uses a duck test to detect marks (rather than strict instanceof), allowing marks from different versions of Plot to be combined into a single plot. Plot is now partially written in TypeScript. In the future, Plot will be written entirely in TypeScript and will export TypeScript type definition files to assist Plot development. - -## 0.5.1 - -[Released June 27, 2022.](https://github.com/observablehq/plot/releases/tag/v0.5.1) - -The new [density mark](https://observablehq.com/plot/marks/density) creates contours representing the [estimated density](https://en.wikipedia.org/wiki/Multivariate_kernel_density_estimation) of two-dimensional point clouds. The **bandwidth** and number of **thresholds** are configurable. - -[A scatterplot showing the relationship between the idle duration and eruption duration for Old Faithful](https://observablehq.com/plot/marks/density) - -```js -Plot.plot({ - inset: 20, - marks: [ - Plot.density(faithful, {x: "waiting", y: "eruptions", stroke: "steelblue", strokeWidth: 0.25}), - Plot.density(faithful, {x: "waiting", y: "eruptions", stroke: "steelblue", thresholds: 4}), - Plot.dot(faithful, {x: "waiting", y: "eruptions", fill: "currentColor", r: 1.5}) - ] -}) -``` - -By default, as shown above, the density is represented by contour lines. By setting the **fill** option to *density*, you can draw filled regions with a sequential color encoding instead. - -[A contour plot showing the relationship between diamond price and weight](https://observablehq.com/plot/marks/density) - -```js -Plot.density(diamonds, {x: "carat", y: "price", fill: "density"}).plot({ - height: 500, - grid: true, - x: {type: "log"}, - y: {type: "log"}, - color: {scheme: "ylgnbu"} -}) -``` - -The new [linear regression marks](https://observablehq.com/plot/marks/linear-regression) produce [linear regressions](https://en.wikipedia.org/wiki/Linear_regression) with [confidence interval](https://en.wikipedia.org/wiki/Confidence_interval) bands, representing the estimated relation of a dependent variable (typically *y*) on an independent variable (typically *x*). - -[a scatterplot of penguin culmens, showing the length and depth of several species, with linear regression models by species and for the whole population, illustrating Simpson’s paradox](https://observablehq.com/plot/marks/linear-regression) - -```js -Plot.plot({ - grid: true, - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species"}), - Plot.linearRegressionY(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), - Plot.linearRegressionY(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` - -The new [Delaunay and Voronoi marks](https://observablehq.com/plot/marks/delaunay) produce Delaunay triangulations and Voronoi tesselations: [Plot.delaunayLink](https://observablehq.com/plot/marks/delaunay#delaunayLink) draws links for each edge of the Delaunay triangulation of the given points, [Plot.delaunayMesh](https://observablehq.com/plot/marks/delaunay#delaunayMesh) draws a mesh of the Delaunay triangulation of the given points, [Plot.hull](https://observablehq.com/plot/marks/delaunay#hull) draws a convex hull around the given points, [Plot.voronoi](https://observablehq.com/plot/marks/delaunay#voronoi) draws polygons for each cell of the Voronoi tesselation of the given points, and [Plot.voronoiMesh](https://observablehq.com/plot/marks/delaunay#voronoiMesh) draws a mesh for the cell boundaries of the Voronoi tesselation of the given points. - -[a Voronoi diagram of penguin culmens, showing the length and depth of several species](https://observablehq.com/plot/marks/delaunay) - -```js -Plot.plot({ - marks: [ - Plot.voronoi(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species", fillOpacity: 0.2, stroke: "white"}), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species"}) - ] -}) -``` - -For data at regular intervals, such as integer values or daily samples, the new [*scale*.**interval** option](https://observablehq.com/plot/features/scales#interval) can be used to enforce uniformity. The specified *interval*—such as d3.utcMonth—sets the default *scale*.transform to the given interval’s *interval*.floor function. In addition, for ordinal scales the default *scale*.**domain** is an array of uniformly-spaced values spanning the extent of the values associated with the scale. - -All marks now support the **pointerEvents** option to set the [pointer-events attribute](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events). The frame decoration mark now supports the **rx** and **ry** options. The cell mark now respects the **dx** and **dy** options. - -Fix a bug where arrow heads would not render correctly when the **strokeWidth** was exactly one. Fix the *scale*.**zero** option when the domain is negative. Fix the **clip** mark option when *x* or *y* is a *band* scale. Fix the fill color of text marks using the **href** option. Fix a crash in the bar and tick mark when the associated band scale is not present, as when these marks are used (erroneously) with the dodge transform. Use *element*.appendChild instead of *element*.append for the benefit of DOM implementations that do not support the full DOM standard. - -Improve the error message when the **facet** option is used without **data**. Throw an error if initializers attempt to create position scales. Throw an error if an implicit ordinal position domain has more than 10,000 values. - -[breaking] Plot now requires [D3 ^7.5.0](https://github.com/d3/d3/releases/tag/v7.5.0). - -## 0.5.0 - -[Released June 7, 2022.](https://github.com/observablehq/plot/releases/tag/v0.5.0) - -Plot now supports [mark initializers](https://observablehq.com/plot/features/transforms#custom-initializers) via the **initializer** option. Initializers can transform data, channels, and indexes. Unlike [data transforms](https://observablehq.com/plot/features/transforms) which operate in abstract data space, initializers can operate in screen space such as pixel coordinates and colors. For example, initializers can modify a marks’ positions to avoid occlusion. The new hexbin and dodge transforms are implemented as mark initializers. - -The new [hexbin transform](https://observablehq.com/plot/transforms/hexbin) functions similarly to the bin transform, except it aggregates both *x* and *y* into hexagonal bins before reducing. The size of the hexagons can be specified with the **binWidth** option, which controls the width of the (pointy-topped) hexagons. - -[a chart showing the inverse relationship of fuel economy to engine displacement, and the positive correlation of engine displacement and weight; hexagonal bins of varying size represent the number of cars at each location, while color encodes the mean weight of nearby cars](https://observablehq.com/plot/transforms/hexbin) - -```js -Plot.plot({ - color: { - legend: true - }, - marks: [ - Plot.hexagon( - cars, - Plot.hexbin( - {r: "count", fill: "mean"}, - {x: "displacement (cc)", y: "economy (mpg)", fill: "weight (lb)"} - ) - ) - ] -}) -``` - -The new [dodge transform](https://observablehq.com/plot/transforms/dodge) can be used to produce beeswarm plots. Given an *x* channel representing the desired horizontal position of circles, the dodgeY transform derives a new *y* (vertical position) channel such that the circles do not overlap; the dodgeX transform similarly derives a new *x* channel given a *y* channel. - -[a beeswarm chart showing a random normal distribution; each of 800 samples is represented by a dot positioned along the x-axis, stacked on top of the y-axis like grains of sand](https://observablehq.com/plot/transforms/dodge) - -```js -Plot.plot({ - height: 320, - x: { - domain: [-3, 3] - }, - marks: [ - Plot.dotX(Array.from({length: 800}, d3.randomNormal()), Plot.dodgeY()) - ] -}) -``` - -If an *r* channel is specified, the circles may have varying radius. By default, the dodge transform sorts the input data by descending radius, such that the largest circles are placed first. The order of placement greatly affects the resulting layout; to change the placement order, use the standard mark **sort** option. - -[a chart showing the monthly percent change in travel by U.S. county in March 2020 after the coronavirus outbreak; each county is represented as a circle with area proportional to its population, positioned according to the change in travel; most counties, and especially those with stay-at-home orders, show a significant reduction in travel](https://observablehq.com/plot/transforms/dodge) - -```js -Plot.plot({ - height: 400, - x: { - domain: [-100, -20], - percent: true, - label: "← Reduction in travel (%)" - }, - r: { - range: [0, 20] - }, - color: { - legend: true, - tickFormat: d => d ? "lockdown" : "no lockdown" - }, - marks: [ - Plot.dot(lockdown, Plot.dodgeY("middle", {x: "pct_change", r: "pop", fill: "in_lockdown"})) - ] -}) -``` - -When using the dodgeY transform, you should set the height of your plot explicitly; otherwise dots may be drawn outside the canvas. You can also adjust the **range** of the *r* scale to produce denser beeswarms. - -[breaking] Color scales with diverging color schemes now default to the *diverging* scale type instead of the *linear* scale type. This includes the *brbg*, *prgn*, *piyg*, *puor*, *rdbu*, *rdgy*, *rdylbu*, *rdylgn*, *spectral*, *burd*, and *buylrd* schemes. If you want to use a diverging color scheme with a linear color scale, set the scale **type** option to *linear*. Color scales will also default to diverging if the scale **pivot** option is set. (For diverging scales, the pivot defaults to zero.) - -The [sort transform](https://observablehq.com/plot/transforms/sort) now supports sorting on an existing channel, avoiding the need to duplicate the channel definition. For example, to sort dots by ascending radius: - -~~~js -Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: {channel: "r"}}) -~~~ - -The [dot mark](https://observablehq.com/plot/marks/dot) now sorts by descending radius by default to reduce occlusion. The dot mark now supports the *hexagon* symbol type for pointy-topped hexagons. The new [circle](https://observablehq.com/plot/marks/dot#circle) and [hexagon](https://observablehq.com/plot/marks/dot#hexagon) marks are convenience shorthand for dot marks with the *circle* and *hexagon* symbol, respectively. The dotX, dotY, textX, and textY marks now support the **interval** option. The rule mark now correctly respects the **dx** and **dy** options. The new [hexgrid decoration mark](https://observablehq.com/plot/marks/hexgrid) draws a hexagonal grid; it is intended to be used with the hexbin transform as an alternative to the default horizontal and vertical axis grid. - -The **zero** scale option (like the **nice** and **clamp** scale options) may now be specified as a top-level option, applying to all quantitative scales. - -Marks can now define a channel hint to set the default range of the *r* scale. This is used by the hexbin transform when producing an *r* output channel. - -Improve the performance of internal array operations, including type coercion. Thanks, @yurivish! - -Fix a crash when using the [area mark](https://observablehq.com/plot/marks/area) shorthand. - -[breaking] The return signature of the internal *mark*.initialize method has changed. It now returns a {data, facets, channels} object instead of {index, channels}, and *channels* is now represented as an object with named properties representing channels rather than an iterable of [*name*, *channel*]. - -## 0.4.3 - -[Released April 12, 2022.](https://github.com/observablehq/plot/releases/tag/v0.4.3) - -The new [tree mark and transforms](https://observablehq.com/plot/marks/tree) can generate hierarchical node-link diagrams using D3’s [“tidy” tree](https://observablehq.com/@d3/tree) or [cluster (dendrogram)](https://observablehq.com/@d3/cluster) layout. The tree transform uses [d3.stratify](https://observablehq.com/@d3/d3-stratify) to convert tabular data into a hierarchy by parsing a slash-separated **path** for each row. - -a node-link tree diagram representing a software hierarchy - -```js -Plot.plot({ - axis: null, - inset: 10, - insetRight: 120, - height: 500, - marks: Plot.tree(plotsrc, {markerEnd: "arrow"}) -}) -``` - -The [line](https://observablehq.com/plot/marks/line) and [area](https://observablehq.com/plot/marks/area) marks (specifically lineX, lineY, areaX, and areaY) now support an implicit [bin transform](https://observablehq.com/plot/transforms/bin) with the **interval** option. This can be used to “regularize” time series data, say to show gaps or default to zero when data is missing, rather than interpolating across missing data. This is also useful for stacking time series data that is sampled at irregular intervals or with missing samples. - -a time-series area chart showing downloads per day with gaps for missing data - -```js -Plot.plot({ - marks: [ - Plot.lineY(downloads, {x: "date", y: "downloads", interval: d3.utcDay, curve: "step"}), - Plot.areaY(downloads, {x: "date", y: "downloads", interval: d3.utcDay, fill: "#eee", curve: "step"}), - Plot.ruleY([0]) - ] -}) -``` - -The default **reduce** is *first*, picking the first value in each interval. If there is no data for a given interval, the value is undefined, resulting in a visible gap in the line or area. By using *sum* instead, you can default to zero when data is missing (and sum values if the data contains more than one observation per time interval). - -a time-series area chart showing downloads per day with zeroes for missing data - -```js -Plot.plot({ - marks: [ - Plot.lineY(downloads, {x: "date", y: "downloads", interval: d3.utcDay, reduce: "sum", curve: "step"}), - Plot.areaY(downloads, {x: "date", y: "downloads", interval: d3.utcDay, reduce: "sum", fill: "#eee", curve: "step"}), - Plot.ruleY([0]) - ] -}) -``` - -The [stack transform](https://observablehq.com/plot/transforms/stack) now allows the **offset** option to be specified as a function. For example, this can be used to visualize Likert survey results with a neutral category as a [diverging stacked bar chart](https://observablehq.com/@observablehq/plot-diverging-stacked-bar). - -a diverging bar chart of responses to a Likert survey question - -```js -function Likert( - responses = [ - ["Strongly Disagree", -1], - ["Disagree", -1], - ["Neutral", 0], - ["Agree", 1], - ["Strongly Agree", 1] - ] -) { - const map = new Map(responses); - return { - order: Array.from(map.keys()), - offset(facetstacks, X1, X2, Z) { - for (const stacks of facetstacks) { - for (const stack of stacks) { - const k = d3.sum(stack, i => (X2[i] - X1[i]) * (1 - map.get(Z[i]))) / 2; - for (const i of stack) { - X1[i] -= k; - X2[i] -= k; - } - } - } - } - }; -} -``` - -The new [_quantize_ scale type](https://observablehq.com/plot/features/scales#color-scale-options) transforms a continuous domain into discrete, evenly-spaced thresholds. The _threshold_ scale type now supports domains in descending order (in addition to ascending order), such as [20, 10, 5, 0] instead of [0, 5, 10, 20]. - -a scatterplot of Simpsons episodes showing the correlation between number of U.S. viewers and IMDb rating; the decline of the Simspons over time is shown with a quantized color encoding by season - -```js -Plot.plot({ - grid: true, - color: { - type: "quantize", - legend: true - }, - marks: [ - Plot.ruleY([0]), - Plot.dot(simpsons, {x: "imdb_rating", y: "us_viewers_in_millions", fill: "season"}) - ] -}) -``` - -The [bin transform](https://observablehq.com/plot/transforms/bin) now coerces the input channel (the quantity being binned) to numbers as necessary. In addition, the bin transform now correctly handles typed array input channels representing temporal data. The [rect mark](https://observablehq.com/plot/marks/rect) now promotes the _x_ channel to _x1_ and _x2_ if the latter two are not specified, and likewise the _y_ channel to _y1_ and _y2_. - -Fix a crash when **text** or **title** channels contain heterogenous types; each value is now independently formatted in a type-appropriate default formatter. Fix a rendering bug with one-dimensional rects whose opposite dimension is a band scale. Fix a rendering bug with swoopy arrows. Improve error messages to give more context. - -New helpers make it easier to implement custom transforms. [Plot.column](https://observablehq.com/plot/features/transforms#column) constructs lazily-evaluated columns for derived channels, and [Plot.transform](https://observablehq.com/plot/features/transforms#transform) composes a [custom data transform](https://observablehq.com/plot/features/transforms#custom-transforms) with any of Plot’s built-in [basic transforms](https://observablehq.com/plot/features/transforms). - -## 0.4.2 - -[Released February 26, 2022.](https://github.com/observablehq/plot/releases/tag/v0.4.2) - -The new [box mark](https://observablehq.com/plot/marks/box) generates a horizontal or vertical boxplot suitable for visualizing one-dimensional distributions. It is a convenience mark that composites a rule, bar, tick, and dot. - -a boxplot of Michelson’s 1879 measurements of the speed of light - -```js -Plot.boxX(morley, {x: "Speed", y: "Expt"}).plot({x: {grid: true, inset: 6}}) -``` - -[Plot’s shorthand syntax](https://observablehq.com/plot/features/shorthand) has been expanded. The [bar mark](https://observablehq.com/plot/marks/bar) now supports one-dimensional shorthand: if no *options* are specified, then Plot.barX and Plot.barY can be used to visualize an array of numbers. This shorthand also now applies to the [rect mark](https://observablehq.com/plot/marks/rect) and the [vector mark](https://observablehq.com/plot/marks/vector). The [area mark](https://observablehq.com/plot/marks/area) now supports two-dimensional shorthand: if no *options* are specified, then Plot.area can be used to visualize an array of *xy*-tuples, similar to Plot.line. - -a bar chart of twenty random values - -```js -Plot.barY(d3.range(20).map(Math.random)).plot() -``` - -The mark [sort option](https://observablehq.com/plot/features/scales#sort-mark-option) now supports implicit “width” and “height” channels, defined as |*x2* - *x1*| and |*y2* - *y1*| respectively. These channels are useful for sorting rects and bars by length. The *reverse* option defaults to true when sorting by these channels. When sorting by *y* and no *y* channel is available, sorting will now fallback to *y2* if available; the same fallback logic applies to *x* and *x2*. (This behavior was previously supported on marks that support implicit stacking but now applies universally to all marks.) - -a bar chart of energy production by source from 1949 to present, with categorical colors assigned in order of the tallest bar - -```js -Plot.rectY(energy, {x: "Year", interval: 1, y: "Value", fill: "Description", sort: {color: "height"}}) -``` - -The [bin transform](https://observablehq.com/plot/transforms/bin) now supports *x* and *y* reducers which represent the midpoint of the bin: (*x1* + *x2*) / 2 and (*y1* + *y2*) / 2 respectively. The [bin](https://observablehq.com/plot/transforms/bin), [group](https://observablehq.com/plot/transforms/group), and [window](https://observablehq.com/plot/transforms/window) transforms now support percentile reducers of the form *pXX* where *XX* is a number in [00, 99]; for example *p25* represents the first quartile and *p75* represents the third quartile. - -The error message when attempting to create a standalone legend without a valid scale definition has been improved. The high cardinality warning for the implicit *z* channel has been relaxed; it is now only triggered if more than half of the values are distinct. When the axis *ticks* option is specified as null, no ticks are generated. When the axis *tickFormat* option is specified as null, no tick labels are generated. - -## 0.4.1 - -[Released February 17, 2022.](https://github.com/observablehq/plot/releases/tag/v0.4.1) - -The [area](https://observablehq.com/plot/marks/area) and [line marks](https://observablehq.com/plot/marks/line) now support varying fill, stroke, title, and other channels within series. For example, this chart of unemployment rates by metro area highlights increases in red and decreases in blue using a window transform with the *difference* reducer. - -a line chart of unemployment rates by metro area; increases are shown in red, and decreases in blue - -```js -Plot.line(bls, Plot.map({stroke: Plot.window({k: 2, reduce: "difference"})}, {x: "date", y: "unemployment", z: "division", stroke: "unemployment"})) -``` - -The new *clip* mark option enables clipping to the plot frame. For example, this can be used to clip overlapping areas and produce a horizon chart of hourly traffic patterns. - -a horizon chart of traffic volume over time - -```js -d3.ticks(0, max, bands).map(t => Plot.areaY(traffic, {x: "date", y: d => d.value - t, fill: t, clip: true})) -``` - -Plot can now generate helpful warnings for common mistakes. Warnings ⚠️ are indicated in the top-right corner of the plot; open your browser’s developer console to read the warnings. For example, if you use data with date strings resulting in an *ordinal* scale rather than the desired *utc* scale, Plot will advise you to parse strings to dates. - -a line chart with an unreadable ordinal x-axis due to incorrect data typing - -```js -Plot.line(aapl, {x: "Date", y: "Close"}) // 🌶 Oops, Date is a string! -``` - -> Warning: some data associated with the x scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "point" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the x scale to "point". - -We will add [more warnings](https://github.com/observablehq/plot/issues/755) in the future. If Plot did something you didn’t expect, please [let us know](https://github.com/observablehq/plot/discussions); perhaps it will inspire a new warning that will help other users. - -The [text mark](https://observablehq.com/plot/marks/text) now supports automatic wrapping for easier annotation. The new **lineWidth** option specifies the desired length of a line in ems. The line breaking, wrapping, and text metrics implementations are all rudimentary, but they should be acceptable for text that is mostly ASCII. (For more control, you can hard-wrap text manually.) The **monospace** option now provides convenient defaults for monospaced text. - -a snippet of Moby Dick demonstrating line wrapping - -```js -Plot.text([mobydick], {dx: 6, dy: 6, fontSize: 12, lineWidth: 80, lineHeight: 1.2, frameAnchor: "top-left", monospace: true}) -``` - -The line and link marks now support [marker options](https://observablehq.com/plot/features/markers) for drawing a shape such as a dot or arrowhead on each vertex. Circle and arrow markers are provided, or you can implement a custom marker function that returns an SVG marker element. Markers automatically inherit the stroke color of the associated mark. - -a line chart with circle markers overlaid on each data point - -```js -Plot.lineY(crimea, {x: "date", y: "deaths", stroke: "cause", marker: "circle"}) -``` - -The *fill* and *stroke* mark options can now be expressed as patterns or gradients using funciri color definitions, *e.g.* “url(#pattern)”. Colors can now also be expressed as CSS variables, *e.g.*, “var(--blue)”. All marks now support the *strokeDashoffset* option (for use with *strokeDasharray*). - -a bar chart with bars that fade from blue to purple - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", fill: "url(#gradient)"}) -``` - -The bin transform now supports the *interval* option, allowing numeric intervals such as integer binning with a nice default domain that aligns with interval boundaries. (The bin transform already supported time intervals as the *thresholds* option; time intervals can now also be specified as the *interval* option.) For example, if you want to bin by hour of the day, use an *interval* of 1: - -a histogram of average traffic per hour of day - -```js -Plot.rectY(traffic, Plot.binX({y: "mean"}, {x: d => d.date.getUTCHours(), interval: 1, y: "value"})) -``` - -Plot now supports ARIA attributes for improved accessibility: aria-label, aria-description, aria-hidden. The top-level **ariaLabel** and **ariaDescription** options apply to the root SVG element. The new **ariaLabel** and **ariaDescription** scale options apply to axes; the label defaults to *e.g.* “y-axis” and the description defaults to the scale’s label (*e.g.*, “↑ temperature”). Marks define a group-level aria-label (*e.g.*, “dot”). There is also an optional **ariaLabel** channel for labeling data (*e.g.*, “E 12.7%”), and a group-level **ariaDescription** option for a human-readable description. The **ariaHidden** mark option allows the hiding of decorative elements from the accessibility tree. - -The new **paintOrder** mark option controls the [paint order](https://developer.mozilla.org/en-US/docs/Web/CSS/paint-order). The text mark’s paint order now defaults to *stroke*, with a stroke width of 3px and a stroke linejoin of *round*, making it easier to create a halo for separating labels from a busy background, improving legibility. - -Fix a crash in default tuple accessors for *x* and *y* when data is undefined. Fix a bug where “none” with surrounding whitespace or capital letters would not be recognized as a valid color. When a channel is specified as a boolean value (*e.g.*, `fill: true`), it is now considered a constant value rather than undefined. Fix a bug where an identity color legend would be rendered as the text “undefined” instead of showing nothing. If scale options are declared, but the scale has no defined type, domain, or data, a scale is no longer constructed rather than a default linear scale. The vector mark now respects the *frameAnchor* option. The default boolean color schemes have been adjusted slightly so that the false value is slightly darker, improving contrast against a white background. The returned scale object now exposes *bandwidth* and *step* values for *band* and *point* scales. - -## 0.4.0 - -[Released January 20, 2022.](https://github.com/observablehq/plot/releases/tag/v0.4.0) - -The new [arrow mark](https://observablehq.com/plot/marks/arrow) draws arrows between pairs of points. It is similar to the [link mark](https://observablehq.com/plot/marks/link), except it is suitable for directed edges (say for representing change over time) and supports a configurable arrowhead. It also supports “swoopy” arrows with the *bend* option, and insets for arrows to shorten the arrow’s start or end. - -[a scatterplot with arrows](https://observablehq.com/plot/marks/arrow) - -```js -Plot.arrow(data, { - x1: "POP_1980", - y1: "R90_10_1980", - x2: "POP_2015", - y2: "R90_10_2015", - bend: true, - stroke: d => d.R90_10_2015 - d.R90_10_1980 -}) -``` - -The new [vector mark](https://observablehq.com/plot/marks/vector) similarly draws arrows at the given position (*x* and *y*) with the given magnitude (*length*) and direction (*rotate*). It is intended to visualize vector fields, such as a map of wind speed and direction. - -[a vector field](https://observablehq.com/plot/marks/vector) - -```js -Plot.vector((T => d3.cross(T, T))(d3.ticks(0, 2 * Math.PI, 20)), { - length: ([x, y]) => (x + y) * 2 + 2, - rotate: ([x, y]) => (Math.sin(x) - Math.sin(y)) * 60 -}) -``` - -The [dot mark](https://observablehq.com/plot/marks/dot) now supports a *symbol* option to control the displayed shape, which defaults to *circle*. The *symbol* channel (and associated *symbol* scale) can also be used as an categorical encoding. The default symbol set is based on whether symbols are stroked or filled, improving differentiability and giving uniform weight. Plot supports all of D3’s built-in symbol types: *circle*, *cross*, *diamond*, *square*, *star*, *triangle*, and *wye* (for fill) and *circle*, *plus*, *times*, *triangle2*, *asterisk*, *square2*, and *diamond2* (for stroke, based on [Heman Robinson’s research](https://www.tandfonline.com/doi/abs/10.1080/10618600.2019.1637746)); you can also implement a [custom symbol type](https://d3js.org/d3-shape/symbol#custom-symbols). - -[a scatterplot of penguins by mass and flipper length](https://observablehq.com/plot/marks/dot) - -```js -Plot.dot(penguins, {x: "body_mass_g", y: "flipper_length_mm", stroke: "species", symbol: "species"}) -``` - -The [text mark](https://observablehq.com/plot/marks/text) now supports multiline text! When a text value contains `\r`, `\r\n`, or `\n`, it will be split into multiple lines using tspan elements. The new *lineAnchor* and *lineHeight* options control how the lines are positioned relative to the given *xy* position. The text, dot, and image marks now also support a *frameAnchor* option for positioning relative to the frame rather than according to data. This is particularly useful for annotations. - -[This Is Just To Say, by William Carlos Williams](https://observablehq.com/plot/marks/text) - -```js -Plot.plot({ - height: 200, - marks: [ - Plot.frame(), - Plot.text([`This Is Just To Say\nWilliam Carlos Williams, …`], {frameAnchor: "middle"}) - ] -}) -``` - -When a text mark’s *text* channel, or the *title* channel on any mark, is specified as numbers or dates, the values are now automatically formatted (in the U.S. English locale) to improve readability. For the *text* channel, the default *fontVariant* option additionally changes to tabular-nums. The text mark now also allows *fontSize* to be specified as a CSS length (*e.g.*, “12pt”), keyword (*e.g.*, “x-large”), or percentage. - -All marks now support the new standard *href* channel and *target* option, turning the mark into a clickable link. - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", href: d => `https://en.wikipedia.org/wiki/${d.letter}`}) -``` - -The [bin](https://observablehq.com/plot/transforms/bin) and [group](https://observablehq.com/plot/transforms/group) transforms now propagate the *title* and *href* channels, if present, by default. The default reducer for the *title* channel automatically selects the top five distinct title values by count, making it easier to inspect the contents of a given bin or group. - -[a histogram of penguins by species](https://observablehq.com/plot/transforms/bin) - -```js -Plot.rectY(data, Plot.binX({y: "count"}, {x: "body_mass_g", fill: "species", title: d => `${d.species} ${d.sex}`})) -``` - -The bin transform now supports shorthand reducers for the bin extent: *x1*, *x2*, *y1*, and *y2*. The window transform now supports the *first* and *last* reducers to select the first or last element of the window, respectively. - -The new generalized [select transform](https://observablehq.com/plot/transforms/select) can now call a custom selector function, or the shorthand *min* and *max*, to select the points to display. The selector function is passed two arguments: the index of the current group (*e.g.*, [0, 1, 2, …]) and the given channel’s values. For example, to select the dot with the greatest *fill* value: - -```js -Plot.dotX(data, Plot.select({fill: "max"}, {x: "letter", fill: "frequency", stroke: "black"}) -``` - -The *color* scale now defaults to an *identity* scale if all associated defined values are valid CSS colors, rather than defaulting to the tableau10 categorical color scheme. The new *symbol* scale similarly defaults to *identity* if all associated defined values are valid symbol names (or symbol type objects). - -[a chart with red and black bars](https://observablehq.com/plot/marks/bar) - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", fill: d => /[AEIOU]/.test(d.letter) ? "red" : "black"}) -``` - -The *color* scale now has a special default range for boolean data, encoding false as light gray and true as dark gray. If you’d prefer more color, specify a sequential scheme such as *reds* or *blues*. (You can opt-out of the special boolean range by setting the scale type to *categorical* or by specifying an explicit *range*.) - -[a chart with grey and black bars](https://observablehq.com/plot/marks/bar) - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", fill: d => /[AEIOU]/.test(d.letter)}) -``` - -The new [Plot.scale](https://observablehq.com/plot/features/scales#scale) method allows you to construct a standalone scale for use independent of any chart, or across charts. The returned object has the same form as *plot*.scale(*name*), allowing you to inspect the scale options and invoke the scale programmatically with *scale*.apply (and *scale*.invert, where applicable). - -```js -const scale = Plot.scale({color: {type: "linear"}}); -console.log(scale.domain); // [0, 1] -console.log(scale.apply(0.5)); // "rgb(149, 251, 81)" -``` - -This release includes various minor new features and bug fixes. The new top-level *clamp* option applies to all scales. When margins or insets would result in a scale’s range being inverted, Plot now collapses the range instead of producing confusing output. When the *buylrd* color scheme is applied to a (discrete) ordinal scale, it now has the expected colors (not *rdgy*). Plot now ignores non-finite values when inferring the default domain for quantitative scales. The *swatches* legend now wraps correctly in narrow windows. When the *tickFormat* option is null, ticks will now be unlabeled (rather than using the default format). Plot no longer crashes when you try to display a legend on an identity color scale. - -To improve compatibility with popular bundlers such as webpack and Rollup, Plot no longer uses circular ES module imports and thereby avoids the dreaded temporal dead zone. 😱 Plot now uses [vite](https://vitejs.dev) for local development instead of [Snowpack](https://snowpack.dev). - -[breaking] For consistency with other marks, the text mark now requires the *dx* and *dy* to be specified as numbers in pixels rather than typographic units such as ems; in addition, the *dx* and *dy* translation now happens prior to rotation (if any). To affect the typographic layout, use the new *lineAnchor* and *lineHeight* options. - -[breaking] Plot now requires [D3 ^7.3.0](https://github.com/d3/d3/releases/tag/v7.3.0). - ---- - -For earlier changes, continue to the [2021 CHANGELOG](./CHANGELOG-2021.md). diff --git a/CHANGELOG-2023.md b/CHANGELOG-2023.md deleted file mode 100644 index 8e5d068e..00000000 --- a/CHANGELOG-2023.md +++ /dev/null @@ -1,855 +0,0 @@ -# Replot - Changelog [2023] - -Year: [Current (2025)](./CHANGELOG.md) · [2024](./CHANGELOG-2024.md) · **2023** · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md) - -## 0.6.13 - -[Released December 13, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.13) - -Fixes the *observable10* categorical color scheme. In 0.6.12, we inadvertently published an earlier draft; this fixed version better aligns the order of colors with *tableau10*. - -We reduced the specificity of selectors in the generated stylesheets to 0, allowing consumers of Plot to much more easily override these styles using their own stylesheets while still enjoying the default styling. Thanks, @mstade! - -## 0.6.12 - -[Released December 7, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.12) - -To better support dark mode, we’ve made a breaking change to default styles: the background color is now `unset` (transparent) instead of `white`. Additionally, marks that expect to use the same color as the background (for instance, the tip mark) now use the CSS custom property `--plot-background` instead of `white`. - -As since [version 0.6.6](#066), you can override Plot’s styles via the `replot-d6a7b5` class. For example, the following stylesheet applies a dark background and white foreground: - -```css -svg.replot-d6a7b5 { - --plot-background: #333; - background: var(--plot-background); - color: white; -} -``` - -Previously Plot forced you to choose between [rect](https://observablehq.com/plot/marks/rect), [bar](https://observablehq.com/plot/marks/bar), and [cell](https://observablehq.com/plot/marks/cell) based on whether the *x* and *y* scales were ordinal or quantitative. Rejoice: the [rect mark](https://observablehq.com/plot/marks/rect) now supports *band* scales so you can simply use rect in all cases! 🎉 (The bar and cell marks still offer conveniences for ordinal scales, so use them if you like.) - -A bar chart showing the relative frequency of English letters. - -```js -Plot.rectY(alphabet, {x: "letter", y: "frequency"}).plot() -``` - -Categorical color scales now default to the new *observable10* color scheme by [Jeff Pettiross](https://github.com/pettiross). These colors are intended as a drop-in replacement for *tableau10* with similar ease of discrimination and ordering, but with a slightly more saturated vibe that helps charts pop. - -Color swatches of the ten colors in the observable10 color scheme: blue, yellow, red, teal, purple, pink, brown, light blue, green, and gray. - -```js -Plot.cellX(d3.range(10)).plot({color: {type: "categorical"}}) -``` - -The new [difference mark](https://observablehq.com/plot/marks/difference) puts a metric in context by comparing it to another metric or constant value. Like the [area mark](https://observablehq.com/plot/marks/area), the region between two lines is filled; unlike the area mark, alternating color shows when the metric is above or below the comparison. - -A difference chart showing a moving average of global temperature anomaly; positive anomalies are shown in green, and negative anomalies are shown in blue. - -```js -Plot.differenceY(gistemp, Plot.windowY(28, {x: "Date", y: "Anomaly"})).plot() -``` - -The chart above shows a moving average of global temperature anomaly; above-average temperatures are shown in green and below-average temperatures are shown in blue. - -The difference mark can also compare a metric to its earlier self, showing change over time. The chart below shows the year-over-year change in Apple stock price; the gray region represents an increase over last year while the red region represents a decrease. - -A difference chart showing the year-over-year change in Apple stock price; the gray region represents an increase over last year, while the red region represents a decrease. - -```js -Plot.differenceY( - aapl, - Plot.shiftX("year", { - x: "Date", - y: "Close", - positiveFillOpacity: 0.2, - positiveFill: "currentColor", - negativeFillOpacity: 0.8, - negativeFill: "red" - }) -).plot() -``` - -The chart above is constructed using the new [shift transform](https://observablehq.com/plot/transforms/shift), which derives a time-shifted copy of a metric for the given time interval, enabling year–over-year, month-over-month, or any other period-over-period comparison. - -The new [find reducer](https://observablehq.com/plot/transforms/group#find) allows you to pivot data with the [bin](https://observablehq.com/plot/transforms/bin) and [group](https://observablehq.com/plot/transforms/group) transforms, effectively turning “tall” data (with fewer columns) into “wide” data (with more columns). For example, say you have time-series data that has separate rows for observed daily temperatures in San Francisco (SF) and San Jose (SJ): - -```csv -date,station,tmax,tmin -2020-12-31,SJ,59,43 -2020-12-31,SF,60,47 -2020-12-30,SJ,58,33 -2020-12-30,SF,57,40 -2020-12-29,SJ,62,38 -2020-12-29,SF,61,41 -2020-12-28,SJ,57,43 -2020-12-28,SF,56,47 -2020-12-27,SJ,62,43 -2020-12-27,SF,57,46 -2020-12-26,SJ,60,46 -2020-12-26,SF,61,49 -``` - -To compare the average minimum temperatures of San Francisco and San Jose as another difference chart: - -A difference chart showing moving averages of daily minimum temperatures of San Francisco and San Jose; the green region represents when San Francisco was warmer than San Jose, and the blue region when San Francisco was cooler than San Jose. - -```js -Plot.plot({ - x: {tickFormat: "%b"}, - y: {grid: true}, - marks: [ - Plot.ruleY([32]), - Plot.differenceY( - temperature, - Plot.windowY( - 14, - Plot.groupX( - {y1: Plot.find((d) => d.station === "SJ"), y2: Plot.find((d) => d.station === "SF")}, - {x: "date", y: "tmin"} - ) - ) - ) - ] -}) -``` - -The green region above represents when San Francisco was warmer than San Jose, and the blue region when San Francisco was cooler than San Jose. - -The [tip mark](https://observablehq.com/plot/marks/tip) now supports a **preferredAnchor** option, providing greater control over tip placement: if the tip fits within the frame at the preferred anchor, this anchor will be used. (In contrast, the **anchor** option uses the specified anchor regardless of whether it will fit.) The tip mark now also prefers the *bottom* anchor by default for a more traditional, less comic-like appearance. - -The [**marker** option](https://observablehq.com/plot/features/markers) now supports new marker types: *tick*, *tick-x*, and *tick-y*. The [bin](https://observablehq.com/plot/transforms/bin) and [group](https://observablehq.com/plot/transforms/group) transforms now pass _data_ to reducers. The [group](https://observablehq.com/plot/transforms/group) and [hexbin](https://observablehq.com/plot/transforms/hexbin) transforms now support _x_ and _y_ reducers. - -This release includes several additional fixes: - -* The default axis for a *time* scale now uses local time. -* The text mark’s **lineWidth** option is now more accurate when **monospace** is true. -* The tip mark no longer truncates **title** text. -* The scale **type** option is now case-insensitive. -* Transform type definitions have correct overload precedence. - -## 0.6.11 - -[Released September 20, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.11) - -The **tip** mark option can now pass options to the derived [tip mark](https://observablehq.com/plot/marks/tip); the options object can also specify the **pointer** option to control the derived tip’s pointer mode (_x_, _y_, or _xy_). The new **format** tip mark option enables greater control over order and formatting of channels. - -A tip with a custom order and formatting of the channel values. - -```js -Plot.dot(olympians, { - x: "weight", - y: "height", - stroke: "sex", - channels: { - name: "name", - nationality: "nationality", - sport: "sport" - }, - tip: { - format: { - name: true, // show name first - y: (d) => `${d}m`, // units in meters - x: (d) => `${d}kg`, // units in kilograms - stroke: false // suppress stroke channel - } - } -}).plot() -``` - -Axes for ordinal scales now generalize the scale’s temporal or quantitative **interval** if any, resulting in more readable ticks. For instance, the bar chart below of monthly values now sports multi-line tick labels. - -A temporal bar chart with a multi-line axis. - -```js -Plot.plot({ - x: {interval: "month"}, - marks: [ - Plot.barY(aapl, Plot.groupX({y: "median"}, {x: "Date", y: "Close"})) - ] -}) -``` - -Plot now recognizes CSS Color Module [Level 4](https://www.w3.org/TR/css-color-4/) and [Level 5](https://www.w3.org/TR/css-color-5/) syntax as literal colors, making it easier to use modern color syntax such as [oklab()](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklab), [color-mix()](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix), and alternative color spaces such as [display-p3](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color). - -A channel value can now be given a label by specifying it as a {value, label} object; this may affect the label used in axes, legends, and tips. - -This release also includes numerous bug fixes: -- exposed ordinal domains are now correctly deduplicated; -- the default symbol set is now inferred correctly when **fill** is *currentColor*; -- the **fontVariant** axis option now applies to the axis label in addition to ticks; -- the tip mark is no longer briefly visible before asynchronous rendering; -- the bin transform no longer generates undefined colors for empty bins; -- the bin transform now uses the **interval** option to reduce *x1* & *x2* (and *y1* & *y2*); -- the stack transform now correctly handles the *exclude* **facet** option; -- the tree transform now correctly handles escaping with the **delimiter** option. - -## 0.6.10 - -[Released August 14, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.10) - -The new **title** and **subtitle** [plot options](https://observablehq.com/plot/features/plots#other-options) specify a primary and secondary heading. Headings are implemented as h2 and h3 elements by default, but you can provide existing elements instead of text for greater control. Like the existing **caption** option, headings add context and assist interpretation. - -A chart with a title, subtitle, and caption. - -```js -Plot.plot({ - title: "For charts, an informative title", - subtitle: "Subtitle to follow with additional context", - caption: "Figure 1. A chart with a title, subtitle, and caption.", - marks: [ - Plot.frame(), - Plot.text(["Titles, subtitles, captions, and annotations assist inter­pretation by telling the reader what’s interesting. Don’t make the reader work to find what you already know."], {lineWidth: 30, frameAnchor: "middle"}) - ] -}) -``` - -When a chart has a title, subtitle, caption, or legend, Plot automatically wraps the chart’s SVG element with an HTML figure element. The new **figure** plot option, if true, wraps the chart in a figure even if it doesn’t have these other elements; likewise, if false, Plot ignores these other elements and returns a bare SVG element. The figure element now has an associated class (`replot-d6a7b5-figure`). - -The new **clip** plot option determines the default clipping behavior if the [**clip** mark option](https://observablehq.com/plot/features/marks#mark-options) is not specified; set it to true to enable clipping. This option does not affect axis, grid, and frame marks, whose **clip** option defaults to false. - -A line chart of the AAPL ticker, clipped to the frame. - -```js -Plot.plot({ - clip: true, - x: {domain: [new Date(2015, 0, 1), new Date(2015, 3, 1)]}, - y: {grid: true}, - marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.1}), - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.ruleY([0], {clip: false}) - ] -}); -``` - -The new [bollinger mark](https://observablehq.com/plot/marks/bollinger) composes a line representing a moving average and an area representing volatility as a band; the band thickness is proportional to the deviation of nearby values. The bollinger mark is built on the new [bollinger map method](https://observablehq.com/plot/marks/bollinger#bollinger), and is often used to analyze the price of financial instruments such as stocks. - -A bollinger chart of the AAPL ticker, computed on a window of the 20 most recent values and a bandwidth of 2 standard deviations. - -```js -Plot.bollingerY(aapl, {x: "Date", y: "Close", n: 20, k: 2}).plot() -``` - -The [arrow mark](https://observablehq.com/plot/marks/arrow) supports a new **sweep** option to control the bend orientation. Below, we set this option to *-y* to draw arrows bulging right, independent of the relative vertical positions of its source and target. - -[Detail of an arc diagram connecting characters in Les Misérables that appear in the same chapters.](https://observablehq.com/@observablehq/plot-arc-diagram) - -```js -Plot.plot({ - height: 1080, - marginLeft: 100, - axis: null, - x: {domain: [0, 1]}, // see https://github.com/observablehq/plot/issues/1541 - color: {domain: d3.range(10), unknown: "#ccc"}, - marks: [ - Plot.dot(miserables.nodes, {x: 0, y: "id", fill: "group", sort: {y: "fill"}}), - Plot.text(miserables.nodes, {x: 0, y: "id", text: "id", textAnchor: "end", dx: -6, fill: "group"}), - Plot.arrow(miserables.links, {x: 0, y1: "source", y2: "target", sweep: "-y", bend: 90, headLength: 0, stroke: samegroup, sort: samegroup, reverse: true}) - ] -}) -``` - -The [auto mark](https://observablehq.com/plot/marks/auto) now does a better job determining the appropriate bar mark implementation, such as with ordinal time series bar charts. - -A stacked bar chart from a time series. - -```js -Plot.auto(timeSeries, {x: "date", y: {value: "value", reduce: "sum"}, color: "type", mark: "bar"}).plot() -``` - -The [pointerX and pointerY transform](https://observablehq.com/plot/interactions/pointer) now use unscaled distance to decide the closest point across facets, preventing points from distant facets from being considered closest. The pointer transform now correctly reports the closest point when moving between facets, and no longer reports multiple closest points if they are the same distance across facets. - -Plot’s documentation now has an [API index](https://observablehq.com/plot/api), version badges pointing to the release notes for a particular feature (or to the pull request for a prerelease feature), and shorter anchors. - -The [tip mark](https://observablehq.com/plot/marks/tip) now shows both labels for paired channels such as *y1*–*y2* or *x1*–*y2* when the channel labels differ. When the **tip** option is set to true on a [geo mark](https://observablehq.com/plot/marks/geo) without a projection, as when using preprojected planar geometry, the display no longer collapses. - -The [stack transform](https://observablehq.com/plot/transforms/stack) now emits a friendlier error message when the supplied value is null. - -## 0.6.9 - -[Released June 27, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.9) - -Time [axes](https://observablehq.com/plot/marks/axis) now default to multi-line ticks, greatly improving readability. When a tick has the same second field value as the previous tick (*e.g.*, “19 Jan” after “17 Jan”), only the first field (“19”) is shown for brevity. The tick format is now based on the tick interval and hence is always consistent, whereas the prior “multi-scale” format varied based on the date, such as “Jan 29” (for Sunday, January 29) and “Tue 31” (for Tuesday, January 31). The new ticks are similar to [Datawrapper](https://blog.datawrapper.de/new-axis-ticks/). - -Before:
-A horizontal time axis showing dates “Wed 25”, “Fri 27”, “Jan 29”, “Tue 31”, and so on. - -After:
-A horizontal time axis showing dates “17 Jan”, “19”, “21”, through “2 Feb”, “4”, and so on. When the month name is shown, it is on a second line below the date. - -It is now easier to construct a “piecewise” [continuous scale](https://observablehq.com/plot/features/scales#continuous-scales) with more than two elements in the **domain** or **range**. This is most often used for a custom color scheme interpolating through fixed colors, such as this pleasing rainbow (sometimes used by artist [Dave Whyte](https://beesandbombs.com/), *a.k.a.* beesandbombs). - -A one-dimensional cell plot of the numbers 0 through 39, laid out horizontally, with color varying smoothly through red, yellow, green-blue, and purple. - -```js -Plot.plot({ - color: { - type: "linear", - range: ["#d70441", "#f4e904", "#009978", "#5e3688"] - }, - marks: [ - Plot.cellX(d3.range(40), {fill: Plot.identity}) - ] -}) -``` - -The [tree mark](https://observablehq.com/plot/marks/tree) now supports a **textLayout** option, which defaults to *mirrored* to alternate the orientation of labels for internal (non-leaf) *vs.* external (leaf) nodes. The treeNode and treeLink marks now also support a new **treeFilter** option, allowing these marks to be filtered without affecting the tree layout. - -A small family tree diagram of Greek gods. Chaos beget Eros, Erebus, Tartarus, and Gaia; Gaia beget Mountains, Pontus, and Uranus. - -```js -Plot.plot({ - axis: null, - height: 100, - margin: 10, - marginLeft: 40, - marginRight: 120, - marks: [ - Plot.tree(gods, {textStroke: "white"}) - ] -}) -``` - -The [barycentric interpolator](https://observablehq.com/plot/marks/raster#interpolatorBarycentric) used by the [raster](https://observablehq.com/plot/marks/raster) and [contour](https://observablehq.com/plot/marks/contour) marks now behaves correctly outside the convex hull of samples. The new algorithm (below right) radiates outwards from the hull, ensuring a continuous image; the old algorithm (below left) radiated inwards from values imputed on the frame’s edges, producing discontinuities. - -A before-and-after comparison of the barycentric interpolator applied to three sample points; in the new algorithm, lines radiate outward perpendicular from the triangle’s sides, producing a more coherent and understandable image. - -The [tip mark](https://observablehq.com/plot/marks/tip) now automatically sets the pointer-events attribute to *none* when associated with the [pointer transform](https://observablehq.com/plot/interactions/pointer) when the the pointer is not sticky, as when hovering a chart without clicking to lock the pointer. This prevents the tip mark from interfering with interaction on other marks, such as clickable links. - -The [auto mark](https://observablehq.com/plot/marks/auto) now renders as a cell, instead of a degenerate invisible rect, when **x** and **y** are both ordinal and the **mark** option is set to *bar*. The [tree mark](https://observablehq.com/plot/marks/tree) no longer produces duplicate tips with the **tip** option. The [rule mark](https://observablehq.com/plot/marks/rule) now respects the top-level **document** option, if any, when using the **clip** option. The [axis mark](https://observablehq.com/plot/marks/axis) now correctly handles the **sort**, **filter**, **reverse**, and **initializer** options. - -The **title**, **ariaLabel**, and **href** channels no longer filter by default; these channels may now be sparsely defined and the associated mark instance will still render. - -The [pointer transform](https://observablehq.com/plot/interactions/pointer) now handles non-faceted marks in faceted plots. The [window transform](https://observablehq.com/plot/transforms/window)’s *median*, *deviation*, *variance*, and percentile reducers have been fixed. - -## 0.6.8 - -[Released June 2, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.8) - -The *x* and *y* scale default domains now incorporate geometry. This allows arbitrary polygonal geometry to be defined in abstract coordinates using the [geo mark](https://observablehq.com/plot/marks/geo) and then displayed using scales. - -A line chart of Apple stock price from 2013 to 2018. - -```js -Plot.geo({type: "LineString", coordinates: aapl.map((d) => [d.Date, d.Close])}).plot() -``` - -The stack transform’s **order** option now supports *-order* descending shorthand or a two-argument comparator. For example, you can use *-appearance* to sort by descending appearance. Below, a custom comparator is used to sort by ascending *group* and then descending *revenue*. - -A line chart of Apple stock price from 2013 to 2018. - -```js -Plot.plot({ - y: { - grid: true, - label: "Annual revenue (billions, adj.)", - transform: (d) => d / 1000 - }, - marks: [ - Plot.areaY( - riaa, - Plot.stackY({ - x: "year", - y: "revenue", - z: "format", - order: (a, b) => d3.ascending(a.group, b.group) || d3.descending(a.revenue, b.revenue), - fill: "group", - stroke: "white" - }) - ), - Plot.ruleY([0]) - ] -}) -``` - -Fix the default **stroke** with the hexbin transform when used with the tip mark. Fix spurious high-cardinality warning with an odd number of elements when using varying aesthetics with the area or line mark. - -Fix *color* legends when the **domain** and **range** have different lengths: extra elements in the range are now ignored by the color legend, and a warning is issued. - -Fix duplicate application of scale transforms with the **tip** mark option. Fix erroneous implicit **title** channel with the **tip** mark option. - -## 0.6.7 - -[Released May 24, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.7) - -The new [tip mark](https://observablehq.com/plot/marks/tip) displays text, or name-value pairs derived from channels, in a floating box anchored to a given position in **x** and **y**. The tip mark is often paired with the new [pointer interaction](https://observablehq.com/plot/interactions/pointer) such that only the point closest to the pointer is rendered, allowing the tip mark to reveal details interactively by hovering the chart. - -A line chart of Apple stock price from 2013 to 2018, with an annotation on May 12, 2016, showing the two-year low closing price of $90.34. - -```js -Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot() -``` - -The new **tip** mark option adds an implicit tip mark with pointer interaction derived from the current mark. The line chart above can be written more explicitly as: - -```js -Plot.plot({ - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"})) - ] -}) -``` - -The tip mark can be also used to draw attention to points of interest and add commentary. When used with the **title** channel, the tip mark supports text wrapping and multi-line text. - -A line chart of Apple stock price from 2013 to 2018, with an annotation describing the decline from 2015 through mid-2016, followed by a recovery. - -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.tip( - [`Apple stock reaches a new high of $133 on Feb. 23, 2015. The release of the first Apple Watch, slated for April, is hotly anticipated.`], - {x: new Date("2015-02-23"), y: 133, dy: -3, anchor: "bottom"} - ), - Plot.tip( - [`Apple stock drops 8% after the company misses Q2 revenue targets and reports declining iPhone sales. It reaches a two-year low of $90.34 on May 12.`], - {x: new Date("2016-05-12"), y: 90.34, dy: 3, anchor: "top"} - ) - ] -}) -``` - -The pointer interaction can be paired with any mark, not just a tip: a red dot, say, to emphasize the focused point, or a rule to highlight its *x* or *y* position, or text to show a value. You can independently control the target position from the display using the **px** and **py** channels, say to show the currently-focused point’s value in the top-left corner. - -A line chart of Apple stock price from 2013 to 2018; as the pointer moves over the chart, the date and close are shown in the top-left corner, while a red rule and dot highlights the focused point. - -```js -Plot.plot({ - height: 160, - y: {axis: "right", grid: true, nice: true}, - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.ruleX(aapl, Plot.pointerX({x: "Date", py: "Close", stroke: "red"})), - Plot.dot(aapl, Plot.pointerX({x: "Date", y: "Close", stroke: "red"})), - Plot.text(aapl, Plot.pointerX({px: "Date", py: "Close", dy: -17, frameAnchor: "top-left", fontVariant: "tabular-nums", text: (d) => [`Date ${Plot.formatIsoDate(d.Date)}`, `Close ${d.Close.toFixed(2)}`].join(" ")})) - ] -}) -``` - -The pointer interaction supports both two-dimensional (pointer) and one-dimensional (pointerX and pointerY) pointing modes. Above, one-dimensional pointing is used for a time-series chart to find the closest *x*-value; below, two-dimensional pointing is used for a scatterplot to find the closest point in *x* and *y*. - -The pointer interaction also powers the new [crosshair mark](https://observablehq.com/plot/interactions/crosshair) which shows the *x* (horizontal↔︎ position) and *y* (vertical↕︎ position) value of the point closest to the pointer on the bottom and left sides of the frame, respectively. - -A scatterplot of penguins, comparing culmen depth (y) and culmen length (x); a pointer moves around the chart highlighting the x and y values of the closest point. - -```js -Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"}), - Plot.crosshair(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` - -The pointer interaction (and by extension the crosshair mark and **tip** mark option) supports “click-to-stick”: if you click on the chart, the currently-focused point will remain locked until you click again. By temporarily locking the pointer, you can select text from the tip for copy and paste. - -The pointer interaction also emits an [input event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) when the focused point changes, and assigns the generated plot’s value to the corresponding data. This allows event listeners to react to pointing, and supports [Observable views](https://observablehq.com/@observablehq/views). - -In addition to these exciting new interaction features, Plot 0.6.7 includes a variety of improvements and bug fixes. - -The **sort** mark option now supports *-channel* descending shorthand for [imputed scales domains](https://observablehq.com/plot/features/scales#sort-mark-option) and the [sort transform](https://observablehq.com/plot/transforms/sort). For example, sorting *x* by *-y* orders a bar chart by descending value: - -A bar chart showing English letters by descending frequency, starting with E (12.7%), T (9.1%), down to Z (0.7%). - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "-y"}}).plot() -``` - -This is equivalent to: - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", order: "descending"}}).plot() -``` - -The **sort** option for imputed channel domains now also takes an **order** option which can be *ascending*, *descending*, a custom comparator, or null. (This more closely matches the **order** option for the sort transform.) The **reverse** option now reverses the order rather than using descending natural order, placing nulls first instead of last. - -Previously setting the scale **label** option would disable Plot’s automatic directional arrow in axis labels (↑, →, ↓, or ←); now Plot implicitly adds an arrow to the label you provide, unless the label already has an arrow or you set the **labelArrow** option to *none* or false. You can also force an arrow by setting this option to true, or *up*, *right*, *down*, or *left*. In Plot’s code base, we were able to remove 161 Unicode arrows thanks to this change! 😌 - -Color scales now default to a *categorical* scale when a categorical color scheme is specified. For example, this no longer throws a “unknown quantitative scheme” error: - -A row of 10 rectangular swatches showing the Tableau10 color scheme. - -```js -Plot.cellX(d3.range(10)).plot({color: {scheme: "Tableau10"}}) -``` - -Time intervals can now be specified as integer multiples of a base time interval, such as *3 months* or *10 years*. Ordinal scales are now smarter about choosing a default time format when the **interval** option is a yearly interval: the four-digit year is (YYYY) shown instead of year, month, and day (YYYY-01-01). - -Mark transforms are now passed the plot’s *options* as a third argument; this allows the group, bin, and stack transforms (and other transforms) to check if the scale has a declared **interval** option, and if so, apply that interval before grouping. For example, to count athletes by age at 5-year intervals: - -A bar chart showing the frequency of athletes by age, grouped at 5-year intervals; the chart peaks at 1990 with more than 4,500 athletes, following a skewed bell curve. - -```js -Plot.barY(olympians, Plot.groupX({y: "count"}, {x: "date_of_birth"})).plot({x: {interval: "5 years"}}) -``` - -The new **imageFilter** mark option applies a [CSS filter effect](https://developer.mozilla.org/en-US/docs/Web/CSS/filter) to the mark, such as a drop shadow or blur. The [rule](https://observablehq.com/plot/marks/rule) and [tick](https://observablehq.com/plot/marks/tick) marks now support **marker** options. - -The [normalize](https://observablehq.com/plot/transforms/normalize) and [window](https://observablehq.com/plot/transforms/window) transforms now accept two-argument “index-aware” reducers; the [map](https://observablehq.com/plot/transforms/map) transform now also accepts a two-argument index-aware map method. The mapX transform now defaults **x** to identity if none of **x**, **x1**, and **x2** are specified; the mapY transform does the same for **y**. - -When faceting, the plot dimensions now includes **facet**.**width** and **facet**.**height** for the plot’s outer dimensions. The plot context now includes the ownerSVGElement. - -Fix a bug where the *opacity* channel didn’t automatically opt-out of the *opacity* scale when all values are in the interval [0,1]. - -Fix a bug where the frame mark would crash if a channel-eligible option such as **fill** were specified as an invalid CSS color string, such as *bleu*. The frame mark now supports channel-eligible options to be specified as abstract values, in which case the abstract values will be encoded using a scale; for example, setting **fill** to *Biscoe* would apply an *ordinal* *color* scale. - -Fix a bug where the position defaults for the rectX, rectY, barX, and barY marks were only applied if the *options* object was strictly undefined; now the position defaults apply if the relevant position options are undefined. - -Fix a bug when filtering facets and computing facet anchors with non-primitive facet domains, such as dates. Fix *z*-order across facets: each mark now draws atop all earlier marks across facets. Fix *z*-order of facet axes, which should be drawn below rather than above other marks, as other axes do. - -Fix the auto mark to choose the rect mark instead of rectX or rectY when appropriate, and likewise choose correctly between line, lineX, and lineY, and areaX and areaY. Also, the autoSpec method now returns the name of the explicit mark and transform implementations as **markImpl** and **transformImpl** respectively, along with the **markOptions** and **transformOptions** needed to instantiate them. - - - - - - - -## 0.6.6 - -[Released April 26, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.6) - -**Plot has a new documentation website!** - -👉 https://observablehq.com/plot 👈 - -The [image mark](https://observablehq.com/plot/marks/image) can now generate circular images with the **r** channel, and rotate images with the **rotate** channel. - -The [axis mark](https://observablehq.com/plot/marks/axis) now properly respects the **margin** shorthand option, changing the default for **marginTop**, **marginRight**, **marginBottom**, and **marginLeft**. The axis mark now correctly renders the axis label when the **href** option is used, or any other option that may be interpreted as a channel. - -Facet scale domains are now imputed correctly when the **sort** mark option is used with a **limit**, or otherwise causing the facet domain to be truncated. Plot no longer generates a spurious warning when faceting and using non-array data, such as an Arquero table. The **interval** scale option, when expressed as a fractional number such as 0.2, now has better floating point precision. - -The [Plot.indexOf](https://observablehq.com/plot/features/transforms#indexOf) channel transform, used internally by some mark shorthand, is now exported. - -Plot has a few improvements for server-side rendering. Plot now assumes a high pixel density display when headless. The default class name for plots is now deterministically generated (`replot-d6a7b5`) rather than randomly generated; this makes it easier to apply overrides to Plot’s default styles with an external stylesheet. (The default class name will change if Plot’s default styles change in a future release.) The **className** plot option is now inherited by a plot’s legends, if any. The density mark now respects the Plot’s **document** option, and the **caption** option now uses a duck test instead of testing against the global Node. - -## 0.6.5 - -[Released April 1, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.5) - -**TypeScript!** The feature you asked for most is here. 🎉 Plot now exports TypeScript type declarations (.d.ts) with precise types and documentation for all of Plot’s public API. This allows code editors such as VS Code to offer better suggestions, complete with inline documentation. For example, if you type “Plot.g”, Plot.geo is now suggested: - -A screenshot of VS Code, showing suggested completions for Plot.g, including geo, geoCentroid, graticule, and other methods. A details panel showing documentation for Plot.geo, the top suggestion, is also shown. - -Function calls also have hints, showing their expected arguments. - -A screenshot of VS Code, showing documentation for calling Plot.geo, including the data and options arguments. - -Types are especially useful for suggesting well-known names, such as projections, scale types, and color schemes. - -A screenshot of VS Code, showing suggested completions for the color scale scheme option. An open menu lists color schemes, including Accent, Blues, BrBG, BuGn, and more. - -TypeScript also helps for reading code; hover any symbol and immediately see the relevant documentation. - -A screenshot of VS Code, with the mouse hovering the color scale’s unknown option. A panel shows documentation explaining the option. - -Lastly, if you’re using Plot in a TypeScript project, you can now benefit from additional static type checking to find errors. - -A screenshot of VS Code, showing a red squiggly underline under a typo in the code, which accidentally calls mark.plots. A panel suggests calling mark.plot instead. - -If you have suggestions or clarifications on how to improve the documentation, please open an issue or discussion, or send us a pull request. - -The **strokeOpacity**, **fillOpacity**, and **opacity** channels now opt-out of the *opacity* scale by default when all values are numbers in the range [0, 1]; to opt-in to the *opacity* scale, specify the channel as {value, scale: true}. The raster mark now also supports scale overrides. - -The scale **nice** option now accepts named time intervals such as *day* and *week*. The new *quarter* and *half* time intervals represent quarters (three months) and half-years (six months). The quantile scale now supports the **unknown** option. BigInt channels are now supported; values are coerced to numbers. Color legends now support the **opacity** option. - -The text mark no longer crashes when a NaN tick value is specified for an axis. The **className** option now allows mixed-case class names. Channel-derived scale domains now correctly handle non-orderable values, duplicate values, and mark transforms. Plot.auto now renders correctly when a non-zero **reduce** is used in conjunction with the *bar* **mark**, and in some cases when the **zero** option is specified. The **zero** option no longer changes the default mark type, and Plot.autoSpec now materializes the **zero** option. - -The interfaces for reduce and map implementations have changed. To better disambiguate from arrays, reduce implementations now have a *reduceIndex* method instead of a *reduce* method, and map implementations now have a *mapIndex* method instead of a *map* method. The old interfaces are still supported, but now deprecated. - -## 0.6.4 - -[Released February 28, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.4) - -The new top-level [**aspectRatio** option](https://observablehq.com/plot/features/plots#aspectRatio) changes the default plot **height** such that, assuming both *x* and *y* are *linear* scales, a scaled unit distance in *x* divided by a scaled unit distance in *y* is the given aspect ratio. For example, if *x* and *y* represent the same units (say, degrees Fahrenheit), and if the **aspectRatio** is one, then scaled distances in *x* and *y* will be equivalent. - -A scatterplot of daily temperature variation (y) vs. daily low temperature (x). - -```js -Plot.plot({ - aspectRatio: 1, - grid: true, - x: {label: "Daily low temperature (°F) →", tickSpacing: 40}, - y: {label: "↑ Daily temperature variation (Δ°F)", tickSpacing: 40}, - color: {scheme: "rainbow", nice: true, legend: true, tickFormat: "%b"}, - marks: [ - Plot.ruleY([0]), - Plot.dot(weather, { - x: (d) => d.temp_min, - y: (d) => d.temp_max - d.temp_min, - fill: (d) => (d = new Date(d.date), d.setUTCFullYear(2020), d) - }) - ] -}) -``` - -The new **textOverflow** option for the [text mark](https://observablehq.com/plot/marks/text) allows text to be truncated when a line of text is longer than the specified **lineWidth**. Overflowing characters can either be clipped (*clip*) or replaced with an ellipsis (*ellipsis*), either at the start, middle, or end of each line. - -A demonstration of Plot’s text overflow methods, including clip-start, clip-end, ellipsis-start, ellipsis-middle, ellipsis-end, applied to titles of Hayao Miyazaki films. - -When wrapping or truncating, the text mark now more accurately estimates the width of ellipses and emojis, and no longer separates combining marks or emoji character sequences such as 👨‍👩‍👧‍👦. - -The [link mark](https://observablehq.com/plot/marks/link) now respects the current [**projection**](https://observablehq.com/plot/features/projections), if any, given the default [**curve**](https://observablehq.com/plot/features/curves) of *auto*. This matches the behavior of the line mark. To opt-out of the projection and draw a straight line, set the **curve** to *linear*. - -The [image mark](https://observablehq.com/plot/marks/image) now supports the **imageRendering** option. (Note: Safari currently ignores the SVG image-rendering attribute.) - -You can now override the scale for a given [mark channel](https://observablehq.com/plot/features/marks#marks-have-channels) by specifying the corresponding option as a {value, scale} object. For example, to force the **stroke** channel to be unscaled, interpreting the associated values as literal color strings: - -```js -Plot.dot(data, {stroke: {value: "foo", scale: null}}) -``` - -To instead force the **stroke** channel to be bound to the *color* scale regardless of the provided values, say: - -```js -Plot.dot(data, {stroke: {value: "foo", scale: "color"}}) -``` - -Color channels (**fill** and **stroke**) are bound to the *color* scale by default, unless the provided values are all valid CSS color strings or nullish, in which case the values are interpreted literally and unscaled. Likewise, if the dot mark’s **symbol** channel values are all symbols, symbol names, or nullish, values are interpreted literally and unscaled; otherwise, the channel is bound to the *symbol* scale. (If some color channels are literal values while other color channels are not, the channels with literal values will now automatically opt-out of the color scale; the same goes for symbol channels. This deviates from the previous behavior, where *all* channels associated with a scale were required to be literal values in order to have the scale default to an *identity* scale.) - -The mark [**facetAnchor** option](https://observablehq.com/plot/features/facets#facetAnchor) can now be set to *empty* such that a mark is only rendered on empty facets. This is typically used for annotation. - -The new Plot.autoSpec method takes *data* and *options* suitable for [Plot.auto](https://observablehq.com/plot/marks/auto) and returns a corresponding *options* object with default options realized. While intended primarily as an internal helper, Plot.autoSpec may be useful for debugging by letting you inspect which mark and reducers are chosen by Plot.auto. - -Fix Plot.auto to only default to the *bar* mark if *x* or *y* is zeroed. Fix Plot.auto’s support for the *area* mark. Fix Plot.auto’s use of the *bar* mark with possibly ordinal reducers. Fix a bug where arrays of values could be erroneously interpreted as reducers. Fix a crash when the mark **facet** option is set to *exclude*, but the mark is not faceted; the option is now ignored. Fix a crash coercing BigInt values to numbers. - -## 0.6.3 - -[Released February 6, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.3) - -The new [auto mark](https://observablehq.com/plot/marks/auto) ([Plot.auto](https://observablehq.com/plot/marks/auto#auto)) automatically selects a mark type that best represents the given dimensions of data according to some simple heuristics. For example, - -[A scatterplot height and weight of olympic athletes.](https://observablehq.com/plot/marks/auto) - -```js -Plot.auto(olympians, {x: "height", y: "weight"}).plot() -``` - -makes a scatterplot (equivalent to [dot](https://observablehq.com/plot/marks/dot)); adding **color** as - -[A heatmap of .](https://observablehq.com/plot/marks/auto) - -```js -Plot.auto(olympians, {x: "height", y: "weight", color: "count"}).plot() -``` - -makes a heatmap (equivalent to [rect](https://observablehq.com/plot/marks/rect) and [bin](https://observablehq.com/plot/transforms/bin); chosen since _height_ and _weight_ are quantitative); switching to - -[A line chart of Apple stock price.](https://observablehq.com/plot/marks/auto) - -```js -Plot.auto(aapl, {x: "Date", y: "Close"}).plot() -``` - -makes a line chart (equivalent to [lineY](https://observablehq.com/plot/marks/line#lineY); chosen because the selected *x* dimension *Date* is temporal and monotonic, _i.e._, the data is in chronological order); - -[A histogram of penguin body mass.](https://observablehq.com/plot/marks/auto) - -```js -Plot.auto(penguins, {x: "body_mass_g"}).plot() -``` - -makes a histogram (equivalent to [rectY](https://observablehq.com/plot/marks/rect#rectY) and [binX](https://observablehq.com/plot/transforms/bin#binX); chosen because the _body_mass_g_ column is quantitative); and - -[A vertical bar chart of penguins by island.](https://observablehq.com/plot/marks/auto) - -```js -Plot.auto(penguins, {x: "island"}).plot() -``` - -makes a bar chart (equivalent to [barY](https://observablehq.com/plot/marks/bar#barY) and [groupX](https://observablehq.com/plot/transforms/group#groupX); chosen because the _island_ column is categorical). The auto mark is intended to support fast exploratory analysis where the goal is to get a useful plot as quickly as possible. It’s also great if you’re new to Plot, since you can get started with a minimal API. - -Plot’s new [axis](https://observablehq.com/plot/marks/axis) and [grid](https://observablehq.com/plot/marks/grid) marks allow customization and styling of axes. This has been one of our most asked-for features, closing more than a dozen feature requests (see [#1197](https://github.com/observablehq/plot/pull/1197))! The new axis mark composes a [vector](https://observablehq.com/plot/marks/vector) for tick marks and a [text](https://observablehq.com/plot/marks/text) for tick and axis labels. As such, you can use the rich capabilities of these marks, such the **lineWidth** option to wrap long text labels. - -[A bar chart of parodical survey responses demonstrating text wrapping of long axis labels.](https://observablehq.com/plot/marks/auto) - -```js -Plot.plot({ - y: {percent: true}, - marks: [ - Plot.axisX({label: null, lineWidth: 8, marginBottom: 40}), - Plot.axisY({label: "↑ Responses (%)"}), - Plot.barY(responses, {x: "name", y: "value"}), - Plot.ruleY([0]) - ] -}) -``` - -And since axes and grids are now proper marks, you can interleave them with other marks, for example to produce ggplot2-style axes with a gray background and white grid lines. - -[A line chart of Apple’s stock price demonstrating styled axes with a gray background overlaid with white grid lines.](https://observablehq.com/plot/marks/auto) - -```js -Plot.plot({ - inset: 10, - marks: [ - Plot.frame({fill: "#eaeaea"}), - Plot.gridY({stroke: "#fff", strokeOpacity: 1}), - Plot.gridX({stroke: "#fff", strokeOpacity: 1}), - Plot.line(aapl, {x: "Date", y: "Close"}) - ] -}) -``` - -The *x* and *y* axes are now automatically repeated in empty facets, improving readability by reducing eye travel to read tick values. Below, note that the *x* axis for culmen depth (with ticks at 15 and 20 mm) is rendered below the Adelie/null-sex facet in the top-right. - -[A scatterplot showing the culmen length and depth of various penguins, faceted by species and sex; the facets are arranged in a grid, with the y-axis on the left and the x-axis on the bottom.](ttps://observablehq.com/plot/marks/axis) - -```js -Plot.plot({ - facet: {marginRight: 80}, - marks: [ - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", stroke: "#ddd"}), - Plot.frame(), - Plot.gridX(), - Plot.gridY(), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fx: "sex", fy: "species"}) - ] -}) -``` - -See [Plot: Axes](https://observablehq.com/plot/marks/axis) for more examples, including the new _both_ **axis** option to repeat axes on both sides of the plot, dashed grid lines via the **strokeDasharray** option, data-driven tick placement, and layering axes to show hierarchical time intervals (years, months, weeks). - -Marks can now declare default margins via the **marginTop**, **marginRight**, **marginBottom**, and **marginLeft** options, and the **margin** shorthand. For each side, the maximum corresponding margin across marks becomes the plot’s default. While most marks default to zero margins (because they are drawn inside the chart area), Plot‘s axis mark provides default margins depending on their anchor. The facet margin options (*e.g.*, facet.**marginRight**) now correctly affect the positioning of the *x* and *y* axis labels. - -The new [*mark*.**facetAnchor**](https://observablehq.com/plot/features/facets#facetAnchor) mark option controls the facets in which the mark will appear when faceting. It defaults to null for all marks except for axis marks, where it defaults to *top-empty* if the axis anchor is *top*, *right-empty* if anchor is *right*, *bottom-empty* if anchor is *bottom*, and *left-empty* if anchor is *left*. This ensures the proper positioning of the axes with respect to empty facets. - -The [frame mark](https://observablehq.com/plot/marks/frame)’s new **anchor** option allows you to draw a line on one side of the frame (as opposed to the default behavior where a rect is drawn around all four sides); this feature is now used by the *scale*.**line** option for *x* and *y* scales. The [text mark](https://observablehq.com/plot/marks/text) now supports soft hyphens (`\xad`); lines are now eligible to break at soft hyphens, in which case a hyphen (-) will appear at the end of the line before the break. The [raster mark](https://observablehq.com/plot/marks/raster) no longer crashes with an _identity_ color scale. The [voronoi mark](https://observablehq.com/plot/marks/delaunay#voronoi) now correctly respects the **target**, **mixBlendMode**, and **opacity** options. - -## 0.6.2 - -[Released January 18, 2023.](https://github.com/observablehq/plot/releases/tag/v0.6.2) - -The new [raster mark](https://observablehq.com/plot/marks/raster) and [contour mark](https://observablehq.com/plot/marks/contour) generate a raster image and smooth contours, respectively, from spatial samples. For example, the plot below shows a gridded digital elevation model of Maungawhau (R’s [`volcano` dataset](./test/data/volcano.json)) with contours every 10 meters: - -[A heatmap of Maungawhau’s topography, showing the circular caldera and surrounding slopes](https://observablehq.com/plot/marks/raster) - -```js -Plot.plot({ - color: {legend: true, label: "Height (m)"}, - marks: [ - Plot.raster(volcano.values, {width: volcano.width, height: volcano.height}), - Plot.contour(volcano.values, {width: volcano.width, height: volcano.height, interval: 10}) - ] -}) -``` - -For non-gridded or sparse data, the raster and contour marks implement a variety of [spatial interpolation methods](https://observablehq.com/plot/marks/raster#spatial-interpolators) to populate the raster grid. The *barycentric* interpolation method, shown below with data from the [Great Britain aeromagnetic survey](https://www.bgs.ac.uk/datasets/gb-aeromagnetic-survey/), uses barycentric coordinates from a Delaunay triangulation of the samples (small black dots). - -[A map showing the varying intensity of the magnetic field as periodically observed from an airplane flying in an approximate grid pattern](https://observablehq.com/plot/marks/raster) - -```js -Plot.plot({ - width: 640, - height: 484, - inset: 4, - x: {tickFormat: "s"}, - y: {tickFormat: "s", ticks: 5}, - color: {type: "diverging", legend: true}, - marks: [ - Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: "barycentric"}), - Plot.dot(ca55, {x: "LONGITUDE", y: "LATITUDE", r: 0.75, fill: "currentColor"}) - ] -}) -``` - -The same data, with a smidge of blur, as filled contours in projected coordinates: - -[A map showing the varying intensity of the magnetic field as periodically observed from an airplane flying in an approximate grid pattern](https://observablehq.com/plot/marks/contour) - -```js -Plot.plot({ - width: 640, - height: 484, - color: {type: "diverging", legend: true}, - projection: {type: "reflect-y", domain: {type: "MultiPoint", coordinates: ca55.map((d) => [d.GRID_EAST, d.GRID_NORTH])}}, - marks: [Plot.contour(ca55, {x: "GRID_EAST", y: "GRID_NORTH", fill: "MAG_IGRF90", stroke: "currentColor", blur: 2})] -}) -``` - -Naturally, the raster and contour mark are compatible with Plot’s [projection system](https://observablehq.com/plot/features/projections), allowing spatial samples to be shown in any geographic projection and in conjunction with other geographic data. The *equirectangular* projection is the natural choice for this gridded global water vapor dataset from [NASA Earth Observations](https://neo.gsfc.nasa.gov/view.php?datasetId=MYDAL2_M_SKY_WV&date=2022-11-01). - -[A map of global atmospheric water vapor, showing a higher concentration of water vapor near the equator](https://observablehq.com/plot/marks/raster) - -```js -Plot.plot({ - projection: "equirectangular", - color: { - scheme: "ylgnbu", - unknown: "#ccc", - legend: true, - label: "Water vapor (cm)" - }, - marks: [ - Plot.raster(vapor, { - x1: -180, - y1: 90, - x2: 180, - y2: -90, - width: 720, - height: 360 - }), - Plot.graticule(), - Plot.frame() - ] -}) -``` - -The raster and contour mark also support sampling continuous spatial functions *f*(*x*, *y*). For example, here is the famous Mandelbrot set, with color encoding the number of iterations before the point escapes: - -[The Mandelbrot set](https://observablehq.com/plot/marks/raster) - -```js -Plot.plot({ - height: 500, - marks: [ - Plot.raster({ - fill: (x, y) => { - for (let n = 0, zr = 0, zi = 0; n < 80; ++n) { - [zr, zi] = [zr * zr - zi * zi + x, 2 * zr * zi + y]; - if (zr * zr + zi * zi > 4) return n; - } - }, - x1: -2, - y1: -1.164, - x2: 1, - y2: 1.164, - pixelSize: 0.5 - }) - ] -}) -``` - -The [vector mark](https://observablehq.com/plot/marks/vector) now supports the **shape** constant option; the built-in shapes are *arrow* (default) and *spike*. A custom shape can also be implemented, returning the corresponding SVG path data for the desired shape. The new [spike convenience constructor](https://observablehq.com/plot/marks/vector#spike) creates a vector suitable for spike maps. The vector mark also now supports an **r** constant option to set the shape radius. - -[A spike map of U.S. county population](https://observablehq.com/@observablehq/plot-spike) - -```js -Plot.plot({ - width: 960, - height: 600, - projection: "albers-usa", - length: { - range: [0, 200] - }, - marks: [ - Plot.geo(nation, {fill: "#e0e0e0"}), - Plot.geo(statemesh, {stroke: "white"}), - Plot.spike(counties.features, Plot.geoCentroid({stroke: "red", length: (d) => population.get(d.id)})) - ] -}); -``` - -The new [geoCentroid transform](https://observablehq.com/plot/transforms/centroid#geoCentroid) and [centroid initializer](https://observablehq.com/plot/transforms/centroid#centroid) compute the spherical and projected planar centroids of geometry, respectively. The new [identity](https://observablehq.com/plot/features/transforms#identity) channel helper returns a source array as-is, avoiding an extra copy. - -The **interval** option now supports named time intervals such as “sunday” and “hour”, equivalent to the corresponding d3-time interval (_e.g._, d3.utcSunday and d3.utcHour). The [bin transform](https://observablehq.com/plot/transforms/bin) is now many times faster, especially when there are many bins and when binning temporal data. - -Diverging scales now correctly handle descending domains. When the stack **order** option is used without a *z* channel, a helpful error message is now thrown. The **clip** option *frame* now correctly handles band scales. Using D3 7.8, generated SVG path data is now rounded to three decimal points to reduce output size. Fix a crash when a facet scale’s domain includes a value for which there is no corresponding facet data. The bin, group, and hexbin transforms now correctly ignore undefined outputs. Upgrade D3 to 7.8.2. - ---- - -For earlier changes, continue to the [2022 CHANGELOG](./CHANGELOG-2022.md). diff --git a/CHANGELOG-2024.md b/CHANGELOG-2024.md deleted file mode 100644 index 672969ed..00000000 --- a/CHANGELOG-2024.md +++ /dev/null @@ -1,129 +0,0 @@ -# Replot - Changelog [2024] - -Year: [Current (2025)](./CHANGELOG.md) · **2024** · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md) - -## 0.6.16 - -[Released August 6, 2024.](https://github.com/observablehq/plot/releases/tag/v0.6.16) - -The new [waffle mark](https://observablehq.com/plot/marks/waffle) 🧇 displays a quantity (or quantitative extent) for a given category; unlike a [bar](https://observablehq.com/plot/marks/bar), a waffle is subdivided into cells that allow easier counting, making waffles useful for reading and comparing exact quantities. Plot’s waffle mark is highly configurable: it supports stacking, positive and negative values, rounded corners, partial cells for fractional counts, automatic row or column size determination (with optional override), and more! - -[a waffle chart of Olympic athletes by weight](https://observablehq.com/plot/marks/waffle) - -```js -Plot.plot({ - fx: {interval: 10}, - color: {legend: true}, - marks: [Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx: "weight", unit: 10}))] -}) -``` - -All marks now support GeoJSON data and GeoJSON property shorthand, making it easier to work with GeoJSON. For example, below the data `counties` is a GeoJSON FeatureCollection, and `unemployment` refers to a property on each feature; the **fill** option is thus shorthand for `(d) => d.properties.unemployment`. The [geo mark](https://observablehq.com/plot/marks/geo) now also supports the **tip** option (via an implicit [centroid transform](https://observablehq.com/plot/transforms/centroid)), making it easier to use Plot’s [interactive tooltips](https://observablehq.com/plot/interactions/pointer). - -[a choropleth map of unemployment by U.S. county](https://observablehq.com/plot/marks/geo) - -```js -Plot.plot({ - projection: "albers-usa", - color: { - type: "quantile", - n: 9, - scheme: "blues", - label: "Unemployment (%)", - legend: true - }, - marks: [ - Plot.geo(counties, { - fill: "unemployment", - title: (d) => `${d.properties.name} ${d.properties.unemployment}%`, - tip: true - }) - ] -}) -``` - -All marks now also support column name channel shorthand when using Apache Arrow tables as data, and we’ve added detection of Arrow date-type columns. (Arrow represents temporal data using BigInt rather than Date.) - -```js -Plot.dot(gistemp, {x: "Date", y: "Anomaly"}).plot() // gistemp is an Arrow Table! -``` - -The rect-like marks ([rect](https://observablehq.com/plot/marks/rect), [bar](https://observablehq.com/plot/marks/bar), [cell](https://observablehq.com/plot/marks/cell), and [frame](https://observablehq.com/plot/marks/frame)) now support individual rounding options for each side (**rx1**, **ry1**, *etc.*) and corner (**rx1y1**, **rx2y1**, *etc.*). This allows you to round just the top side of rects. You can even use a negative corner radius on the bottom side for seamless stacking, as in the histogram of Olympic athletes below. - -[a histogram of Olympic athletes by weight](https://observablehq.com/plot/marks/rect) - -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", ry2: 4, ry1: -4, clip: "frame"})), - Plot.ruleY([0]) - ] -}) -``` - -Plot now respects the projection **domain** when determining the default plot height. Previously, the map below would use a default square aspect ratio for the *conic-conformal* projection regardless of the specified **domain**, but now the map is perfectly sized to fit North Carolina. (Plot also now chooses a smarter default plot height when the ordinal *y* scale domain is empty.) - -an unlabeled map showing the outline and counties of North Carolina - -```js -Plot.plot({ - projection: {. - type: "conic-conformal", - parallels: [34 + 20 / 60, 36 + 10 / 60], - rotate: [79, 0], - domain: state - }, - marks: [ - Plot.geo(counties, {strokeOpacity: 0.2}), - Plot.geo(state) - ] -}) -``` - -The [marker options](https://observablehq.com/plot/features/markers) now render as intended on marks with varying aesthetics, such as the spiraling arrows of varying thickness and color below. - -several spiraling lines emanate from the center of the image, with rainbow color and increasing thickness, each capped with a pointed arrow at the end - -```js -Plot.plot({ - inset: 40, - axis: null, - marks: [ - Plot.line(d3.range(400), { - x: (i) => i * Math.sin(i / 100 + ((i % 5) * 2 * Math.PI) / 5), - y: (i) => i * Math.cos(i / 100 + ((i % 5) * 2 * Math.PI) / 5), - z: (i) => i % 5, - stroke: (i) => -i, - strokeWidth: (i) => i ** 1.1 / 100, - markerEnd: "arrow" - }) - ] -}) -``` - -This release includes a few more new features, bug fixes, and improvements: - -The new **className** [mark option](https://observablehq.com/plot/features/marks#mark-options) specifies an optional `class` attribute for rendered marks, allowing styling of marks via external stylesheets or easier selection via JavaScript; thanks, @RLesser! Plot now reuses `clipPath` elements, when possible, when the **clip** mark option is set to *frame* or *projection*. - -The [difference mark](https://observablehq.com/plot/marks/difference) now supports a horizontal orientation via [differenceX](https://observablehq.com/plot/marks/difference#differenceX), and the [shift transform](https://observablehq.com/plot/transforms/shift) now likewise supports [shiftY](https://observablehq.com/plot/transforms/shift#shiftY). The [Voronoi mark](https://observablehq.com/plot/marks/delaunay) is now compatible with the pointer transform: only the pointed Voronoi cell is rendered; the Voronoi mark now also renders as intended with non-exclusive facets (as when using the *exclude* facet mode). The [tip mark](https://observablehq.com/plot/marks/tip) no longer displays channels containing literal color values by default. - -## 0.6.15 - -[Released June 11, 2024.](https://github.com/observablehq/plot/releases/tag/v0.6.15) - -## 0.6.14 - -[Released March 12, 2024.](https://github.com/observablehq/plot/releases/tag/v0.6.14) - -Changes the default categorical color scheme to *Observable10*. - -The group transform now preserves the input order of groups by default, making it easier to sort groups by using the **sort** option. The group and bin transforms now support the *z* reducer. - -Improves the accessibility of axes by hidding tick marks and grid lines from the accessibility tree. - -Upgrades D3 to 7.9.0. - ---- - -For earlier changes, continue to the [2023 CHANGELOG](./CHANGELOG-2023.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce91643..04523938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,66 +1,83 @@ # Replot - Changelog -Year: **Current (2025)** · [2024](./CHANGELOG-2024.md) · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md) +## Unreleased -## 0.6.17 +The pointer transform now reports the focused datum the way Observable Plot does: the plot element (the `
` when there is one, else the ``) carries the focused datum as its `value`, and a bubbling *input* event fires whenever that value changes. The **onValue** prop follows the same contract and is called once per value change, rather than once per pointer-driven mark. -[Released February 14, 2025.](https://github.com/observablehq/plot/releases/tag/v0.6.17) +A pointer selection now survives a re-render that rebuilds the mark's data array around the same rows, so a controlled plot whose state is driven by **onValue** keeps its tip. What survives a recompute is the pointer position rather than the index, so the tip follows the datum drawn under it — a reorder, a filter or a transform moves it with the data — and it is dropped, clearing the reported value with it, only when nothing is drawn under the pointer any more. -The [**clip** mark option](https://observablehq.com/plot/features/marks#clip) now supports GeoJSON objects 🌎 in addition to the named *frame* and *sphere* clipping methods, allowing the visual extent of marks to be limited to arbitrary polygons. For instance, this Voronoi mesh of world airports is clipped to land boundaries: +**Breaking:** `` no longer attaches a React `onInput` handler to its ``, so a React `onInput` on a wrapper around the plot no longer fires; `element.addEventListener("input", …)` still receives the event, which is what Observable Plot dispatches. -[a map of world airports with a Voronoi mesh clipped to land](https://observablehq.com/@observablehq/plot-world-airports) +**Breaking:** the pointer hook and the helpers that went with it are gone — **usePointer**, **findNearest**, the **PointerState** and **UsePointerOptions** types, and **formatTip** are no longer exported from `replot/react`. A pointer consumer is declared the way Observable Plot declares one: the **tip** option on a mark, or a **``** or **``** component, with the tooltip’s contents coming from the tip mark’s own **channels** and **format** options. -```js -Plot.plot({ - projection: {type: "orthographic", rotate: [110, -50]}, - marks: [ - Plot.dot(airports, {x: "longitude", y: "latitude", fill: "red", r: 1}), - Plot.voronoiMesh(airports, {x: "longitude", y: "latitude", clip: land}), - Plot.sphere(), - Plot.geo(land) - ] -}) -``` +### React integration -The GeoJSON object passed to the **clip** option is rendered as a [`clipPath` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/clipPath) using the same path data that a [geo mark](https://observablehq.com/plot/marks/geo) would produce, respecting the plot’s top-level **projection** option, if any. For performance, `clipPath` elements are shared by marks clipped with the same GeoJSON object. For example, the [raster mark](https://observablehq.com/plot/marks/raster) and [contour mark](https://observablehq.com/plot/marks/contour) below show atmospheric water vapor measurements across the United States from [NASA Earth Observations](https://neo.gsfc.nasa.gov/view.php?datasetId=MYDAL2_M_SKY_WV); both marks are clipped to the nation’s boundary, censoring the (absurd) values that would otherwise be interpolated between Alaska, Southern California, and Hawai’i. +Plots now render on the server. `renderToString` returns the whole plot rather than an empty host: with no document available, marks, scales and legends register during render, and the plot computes the same tree the client would draw. A hydrating client redraws it, because on the client registration happens in an effect and the first render has nothing to draw yet. -[a map of water vapor measurements in the United States](https://observablehq.com/@observablehq/plot-us-water-vapor) +An accessor that closes over React state now recomputes the plot. Function props were left out of the mark stamp on the assumption that the factory is re-evaluated on every run, which is only true when a run happens: changing `k` in `y={(d) => d.y * k}` left the dots and the axis where they were. Functions, and the other values a stamp cannot read by content — Map, Set, typed arrays, d3 scales and intervals, class instances, and arrays past the element cap — are now stamped by identity. The cost is that an inline accessor recomputes the plot on each render of its owner; give the accessor a stable identity, at module level or with `useCallback`, to avoid that. -```js -Plot.raster(vapor, { - fill: Plot.identity, - width: 360, - height: 180, - x1: -180, y1: 90, x2: 180, y2: -90, - interpolate: "barycentric", - blur: 10, - clip: nation -}).plot() -``` +`svg.scale` is the lookup function `plot()` exposes rather than the raw scales object, so `svg.scale("x")` answers with the scale instead of throwing. The plot’s className and style are set as React props instead of through the DOM, so a style key that is dropped is actually removed. -[The code for the map above is too long to reproduce here in its entirety; click the image above for the complete code.] +Per-datum event handlers now attach to the datum the element was drawn for. Coverage was decided by comparing the child count to the index length, which a `` child broke: a line with three points in two series matched the counts, and every datum was shifted by one, so the defs received datum 0. -The **clip** mark option can also be used to clip against arbitrary polygons, not just geographic boundaries. For example, to show the value of [Math.atan2](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2) over the unit circle: +A warning raised while a mark renders now belongs to the plot that raised it. The warning indicator drains the counter after the marks have rendered, where it used to drain before, which let a render-time warning leak into the next plot’s indicator. -[the value of atan2 across the unit disc, encoded as color](https://observablehq.com/@observablehq/plot-color-angle) +### Marks -```js -Plot.raster({ - x1: -1, x2: 1, y1: -1, y2: 1, - fill: (x, y) => Math.atan2(y, x), - clip: { - type: "Polygon", - coordinates: [ - d3.range(0, 2 * Math.PI, 0.1).map((angle) => [Math.cos(angle), Math.sin(angle)]) - ] - } -}).plot({width: 300, aspectRatio: 1}) -``` +`` and `` now draw an area’s topline: a stroke-only path over the fill, carrying the mark’s markers, as upstream’s **line** option does. The option was accepted and ignored, so a translucent area could not get a crisp edge without a second mark. -The interactive **tip** associated with a [waffle mark](https://observablehq.com/plot/marks/waffle) is now anchored to the “center” of the visual representation of the associated datum. That center depends on the shape that is referenced. For fun, here’s a chart from our unit tests showing these anchoring points for various amounts of waffling. Baffling! +Custom marker functions now draw. A **marker** given as a function was accepted and then dropped, because the resolver only knew marker names. Marker defs are also scoped to the mark that draws them: two marks sharing a marker name and colour used to share one def, and since a def resolves **currentColor** against its own ancestors rather than against the path that references it, the second mark drew the first mark’s colours. Marker ids change shape accordingly, to **plot-marker-N**. -waffle mark with the anchor position of each datum marked with its value +A mark’s channel-driven `` is now a child of the element it describes rather than a sibling of it. Hovering a contour band showed the group’s first title, or none at all, and a frame title, the frame having no group, landed on the `<svg>` and described the whole plot. A titled dot no longer emits a wrapping `<g>`: the title goes on the circle, as upstream writes it. ---- +The shared title, href and marker helpers now match upstream as well. A mark with a **tip** set omits its native `<title>`, so the two no longer compete on hover. An **href** channel is written on the wrapping `<a>` instead of on the shape inside it as well. The arrow, circle and tick markers carry `stroke-dasharray="none"`, so they no longer inherit a dashed line’s pattern. -For earlier changes, continue to the [2024 CHANGELOG](./CHANGELOG-2024.md). +`linearRegressionX` and `linearRegressionY` now carry their title on the confidence band as well as on the line, and `hexgrid` applies its channel styles, title and href to the grid path. A **rotate** channel with a zero value, and a zero-length vector, now emit their (identity) **rotate** and anchor **translate** as upstream writes them; replot tested the values for truthiness and silently omitted them. + +A faceted mark now announces itself once rather than once per facet. Its label, description, hidden state and transform move onto a single group per mark, as upstream writes them, so a screen reader hears the mark rather than the mark repeated for every facet. + +### Dark mode + +The **boxX**/**boxY** default fill and the **tree** link default stroke were fixed light-only greys, so they drew at the wrong contrast on a dark page. Both now answer the colour scheme with **light-dark()**, as upstream’s do. + +### Tips + +A **format** that returns null for one datum no longer throws as soon as that datum is hovered. Every datum is formatted first and the nulls dropped, and a tip whose items all format to null renders empty rather than hidden. + +The tip box is no longer styled by the channels that feed it: those channels choose the tip’s contents, not its appearance, so a mark with a **fill** channel and **tip** set no longer colours its own tooltip background with the focused datum’s colour. + +The box is measured where a layout engine exists. Each item renders once from an estimate and again from the text’s `getBBox`, which re-runs the fit: the box is oriented to stay inside the frame, the lines shift to the measured left edge, and a faceted tip is fitted to its own cell. The estimate remains what jsdom, the server and a static render see, so those outputs keep a stable box. + +### Raster + +The **colorSpace** option is now supported. Colours are parsed through a 1x1 canvas instead of **d3-color**, which cannot express a wide-gamut colour space, so syntax the CSS parser knows and d3-color does not, such as **oklch**, survives. The converter is cached on the mark, so a raster of one colour costs a single parse per render. Colour parsing previously ran per pixel on every render, and the image-rendering attribute was built as a hyphenated key, which React rejects on an SVG element and warns about on the console. + +### Difference + +The **render** option on a difference mark now composes onto the two areas only, as upstream composes it, rather than reaching the line as well; a difference mark with a render transform used to transform the wrong mark. The clip ids a difference mark allocates are stable across a React re-render instead of being rewritten on every pass. + +### Scales and options + +An aspect ratio that cannot be met because a domain is empty now warns, rather than falling back silently. + +`plot.scale("projection")` answers with the projection, applying and inverting as the other scales do. **hasX**, **hasY** and **hasXY** no longer throw when asked about an options object that was never given: a missing object reads as none of these channels. + +### Packaging + +**react** and **react-dom** are now required peers. `plot()` and `legend()` statically import `react-dom/server`, so importing the package at all needs both, and an optional peer only deferred the failure to runtime. + +Two entry-point bugs are fixed, both of which broke the published package for consumers: `main`, `module` and the default export condition pointed at `src/index.js`, which does not exist, and the **jsdelivr**, **unpkg** and **umd** conditions pointed at `dist/plot.umd.min.js` while the bundle is built as `dist/replot.umd.min.js`. A pack-and-install smoke test now imports both entry points through a bundler, through TypeScript and from the UMD bundle, and runs as part of `yarn test`. + +The `replot/react` entry file is `src/react/api.tsx` rather than `src/react/index.tsx`. The published entry point is unchanged, so this affects only a deep import of the old path. **LegendProps** is now `LegendScales & {scale?: string}`: the **scale** prop was always read at runtime, and is now part of the type. + +### Documentation + +`plot()` is documented as static by design. It renders through `renderToStaticMarkup`, so it has no React root, no pointer listeners are attached, a **tip** or **crosshair** renders with nothing selected, and the returned element never carries a `.value` or dispatches a bubbling *input* event. Upstream Observable Plot is interactive here; in replot that belongs to the React API and its **onValue** prop. + +Server rendering is now described as it works: `renderToString` returns the whole plot, and a hydrating client redraws it. The README’s claim of server-side rendering out of the box, and the same claim on the docs index and `what-is-plot`, overstated it. + +The docs are React-only again: the imperative API sections, the `useRef` pane and the remaining imperative code fences are gone, along with the inherited Observable Plot changelogs and their images, the duplicated `.md` twins of every docs page, and the VitePress site config and theme. PLAN.md is cut to the design notes that are still current. + +### Repository + +Coverage now measures the TypeScript sources as well as the JavaScript ones, and the React layer is linted with the React hooks rules. The dead rollup config and its plugins are gone, agent scratch directories such as `.claude/worktrees/` are ignored, and the docs conversion script and VitePress leftovers that the MDX port orphaned have been removed. A `yarn parity` report canonicalises each committed snapshot against its Observable Plot counterpart and reports what still differs. The comparison now runs in CI against a pinned upstream checkout, and `parity-allow.json` records each plot’s expected divergence as a machine-checked signature rather than a bare plot name, so a listed plot that diverges further fails the build. `yarn parity --allow parity-allow.json --update` refreshes the signatures when a change legitimately moves them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a55415c5..d3f310c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,9 +65,11 @@ Replot’s unit tests are written with [Mocha](https://mochajs.org). If you like, you can also run Mocha in watch mode for a specific file, so that unit tests re-run automatically when you make changes. For example: ```bash -yarn run mocha --conditions=mocha --parallel --watch test/marks/bar-test.js +TZ=America/Los_Angeles yarn tsx node_modules/.bin/mocha --watch test/marks/bar-test.js ``` +The tests import `replot`, which package.json maps to `src/index.js`, a file that exists only as the TypeScript source `src/index.ts`; tsx resolves that, which is why Mocha has to run through it here, as it does in `yarn test:mocha`. + ### Snapshot tests Snapshot tests live in `test/plots` and are registered in [`test/plots/index.ts`](./test/plots/index.ts); see [`test/plots/aapl-bollinger.ts`](./test/plots/aapl-bollinger.ts) for example. Unlike unit tests which only test individual methods, snapshot tests actually visualize data—they’re more representative of how we expect people will use Replot. Snapshot tests can also serve as examples of how to use the Replot API, though note that some of the examples intentionally test edge case of the API and may not embody best practices. Each snapshot test defines a plot by exporting a default async function. For example, here’s a line chart using BLS unemployment data: @@ -114,6 +116,52 @@ rm -rf test/output yarn test ``` +### Parity with Observable Plot + +The snapshots in `test/output` are Replot’s own, so a green run shows that Replot’s output has not changed, not that it matches Observable Plot. To check that, compare the snapshots against the ones committed in an Observable Plot checkout (expected as a sibling directory, `../observablehq-plot`, or pass `--upstream`): + +```bash +yarn parity # every plot, summarised by cause +yarn parity --filter tip # plots whose name contains "tip" +yarn parity --show tipDotFacets # unified diff of one plot +``` + +The comparison canonicalises both sides (attribute order, generated ids and class names, numeric precision, whitespace), so what remains is a real structural or attribute difference. The per-plot line names what differs, for example `g +74` for extra wrapper groups or `image[href] −1, image[xlink:href] +1` for a renamed attribute. With `--allow <file> --fail` it exits non-zero when a plot’s divergence is not what that file records, which lets CI hold the line on known divergences. + +### The parity allow list + +`parity-allow.json` records what every plot that differs from upstream is expected to differ by, keyed by plot name. It is a ratchet, not a target: it records the parity debt as it stands today so that a *new* or *changed* divergence is caught immediately. + +Each entry is the plot’s difference in the same form `yarn parity` prints. `differs` carries a machine-checked signature; `onlyUpstream` and `onlyReplot` carry the reason a plot exists on one side only. An optional `note` beside a signature records the cause in prose, and is never checked. + +```json +{ + "athletesSampleFacet": {"differs": "g +81"}, + "differenceX": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "rasterVaporP3": {"onlyUpstream": "node-canvas drops color(display-p3 …), so this baseline cannot be reproduced here"} +} +``` + +```bash +yarn parity --allow parity-allow.json --fail +``` + +That is the command CI runs. It exits non-zero when a differing plot has no entry, when a signature has changed, when an entry is stale (its plot is now identical), or when an entry names a plot that does not exist. A signature that changes means the two renderers now disagree about something else, so it fails the build rather than passing quietly. That closes the blind spot the name-only list had, where a listed plot could diverge further without anyone noticing. + +When a change legitimately moves plots, refresh the signatures and review what you are accepting: + +```bash +yarn parity --allow parity-allow.json --update +``` + +`--update` rewrites signatures in place, keeps existing notes, and drops entries for plots that no longer differ. It will not invent a reason for a plot that has newly gone missing on one side, so add those entries by hand first. Review the diff it writes and commit it with the change that caused it: a refreshed signature nobody can explain is how this list stops meaning anything. + +Do not add an entry to make a build green. A new differing plot, or a signature that grew, is a regression to fix, not debt to record. + +Run `yarn test:mocha` before `yarn parity`, so the comparison sees the snapshots the current tree actually produces (adopt any `*-changed` files first). + +CI runs the same command against a pinned upstream checkout, the `UPSTREAM_COMMIT` in `.github/workflows/test.yml`, fetched sparsely (upstream’s `test/output` only). Changing that pin changes the comparison, so refresh the allow list in the same commit. + ## Documentation When submitting a pull request, please remember to update Replot’s documentation to reflect changes to the public API. You are also welcome to edit Replot’s [CHANGELOG.md](./CHANGELOG.md) to assist with writing future release notes. In addition, please reference any related [issues](https://github.com/dave-hillier/replot/issues) (or discussions) in your pull request description. diff --git a/PLAN.md b/PLAN.md index e1455656..7c11a943 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,578 +1,75 @@ -# Replot: Implementation Plan (Replot → React Port) - -## 1. Analysis Summary - -Replot is a ~81-file JavaScript library implementing a grammar-of-graphics -visualization system. It renders SVG imperatively via D3 selections. - -### Core architectural challenges for React: - -| Current (Imperative) | Target (React/Declarative) | -|-----------------------------------------------|-----------------------------------------------------| -| `plot()` monolithic function | `<Plot>` component tree | -| D3 `selection.append().attr()` DOM building | JSX `<circle cx={...} />` | -| Mark classes with `render()` → DOM node | Mark components returning JSX | -| Mutable state maps (`stateByMark`) | React Context + `useMemo` | -| Sequential pipeline in one function call | Multi-phase render with context propagation | -| `plot()` returns an SVG element | `<Plot>` renders into the React tree | - -### What stays the same: - -- **D3 scales** (d3-scale) — used as pure functions, no DOM involvement -- **D3 shapes** (d3-shape for line/area generators) — return path `d` strings -- **D3 geo** projections — pure coordinate transforms -- **Transform functions** (bin, stack, group, etc.) — pure data transforms -- **Channel/scale inference logic** — pure computation -- **Dimension calculation** — pure computation - ---- - -## 2. Target React API - -### 2.1 Basic Usage - -```jsx -import { Plot, Dot, Line, BarY, AxisX, AxisY } from "@dave-hillier/replot"; - -function Chart({ data }) { - return ( - <Plot width={640} height={400} color={{ scheme: "category10" }}> - <Dot data={data} x="weight" y="height" fill="species" /> - <AxisX label="Weight (kg)" /> - <AxisY label="Height (cm)" /> - </Plot> - ); -} -``` - -### 2.2 With Transforms - -Transforms compose by nesting wrapper components, mirroring Observable Plot's -functional composition (`binX(outputs, stackY(options))` ≡ nested JSX): - -```jsx -import { Plot, BarY, BinX } from "@dave-hillier/replot"; - -function Histogram({ data }) { - return ( - <Plot> - <BinX y="count"> - <BarY data={data} x="value" /> - </BinX> - </Plot> - ); -} -``` - -The functional form remains supported as an escape hatch (and for conditional -transform application, which is awkward in JSX): - -```jsx -<BarY data={data} {...binX({ y: "count" }, { x: "value" })} /> -``` - -Both forms flow through the same transform functions and must produce -identical output. See section 8.3 for the design. - -### 2.3 Faceting - -```jsx -<Plot fx={{ domain: ["A", "B"] }}> - <Dot data={data} x="x" y="y" fx="category" /> -</Plot> -``` - -### 2.4 Interactions (Pointer/Tooltips) - -```jsx -<Plot> - <Dot data={data} x="x" y="y" tip /> - {/* or controlled: */} - <Dot data={data} x="x" y="y" /> - <Tip data={data} x="x" y="y" pointer="xy" /> -</Plot> -``` - -### 2.5 Legends - -```jsx -<Plot> - <Dot data={data} x="x" y="y" fill="species" /> - <Legend scale="color" /> -</Plot> -``` - -### 2.6 Custom Render - -```jsx -<Dot data={data} x="x" y="y" - render={(index, scales, values, dimensions) => ( - <g> - {index.map(i => ( - <circle key={i} cx={values.x[i]} cy={values.y[i]} r={3} - onClick={() => handleClick(data[i])} /> - ))} - </g> - )} -/> -``` - ---- - -## 3. Component Architecture - -``` -<Plot> // Root: orchestrates scales, dimensions - ├── PlotContext.Provider // Provides scales, dimensions, projection - ├── <style> // Scoped CSS - ├── <defs> // Clip paths, patterns - ├── <AxisX /> // Implicit or explicit axis marks - ├── <AxisY /> - ├── <FacetGroup> // (conditional) facet wrapper - │ ├── <g transform="..."> // Per-facet translation - │ │ ├── <Dot /> // Mark components - │ │ ├── <Line /> - │ │ └── ... - │ └── ... - └── <Dot /> // Non-faceted marks -``` - -### 3.1 Key Components - -| Component | Responsibility | -|----------------|---------------------------------------------------| -| `<Plot>` | Collect children, compute scales/dims, render SVG | -| `<Dot>` | Register channels, render circles/paths | -| `<Line>` | Register channels, render grouped paths | -| `<BarX/BarY>` | Register channels, render rects | -| `<Rect>` | Register channels, render rects | -| `<Area>` | Register channels, render area paths | -| `<Rule>` | Register channels, render lines | -| `<Text>` | Register channels, render text elements | -| `<AxisX/Y>` | Render tick marks and labels | -| `<Frame>` | Render plot frame | -| `<Tip>` | Render tooltips | -| `<Legend>` | Render color/symbol/opacity legends | -| `<FacetGroup>` | Manage facet layout and translation | - ---- - -## 4. Internal Architecture: The Two-Phase Render - -The fundamental challenge is that Replot's pipeline is **sequential and -interdependent**: scales depend on all marks' channels, and marks depend on -computed scales. In React, children can't easily communicate upward before -rendering. - -### Solution: Two-phase rendering pattern - -**Phase 1 — Collection (invisible):** Each mark child calls a registration -function (via context) during render, declaring its data, channels, and -transform requirements. The `<Plot>` component collects these declarations. - -**Phase 2 — Rendering (visible):** After all marks have registered, `<Plot>` -computes scales, dimensions, and projections, then provides them via context. -Marks read computed values from context and return their SVG JSX. - -#### Implementation approach: `useLayoutEffect` + state - -``` -Render 1: <Plot> renders children in "registration mode" - → Each mark registers channel specs via context callback - → useLayoutEffect fires: Plot computes scales from collected channels - → setState triggers re-render - -Render 2: <Plot> renders children in "render mode" with computed scales - → Each mark reads scales from context and returns SVG elements -``` - -This is a well-established React pattern (used by Recharts, Victory, visx). - ---- - -## 5. Context Design - -### 5.1 PlotContext (registration phase) - -```ts -interface PlotRegistration { - registerMark(id: string, spec: MarkSpec): void; - unregisterMark(id: string): void; - phase: "register" | "render"; -} -``` - -### 5.2 PlotScalesContext (render phase) - -```ts -interface PlotScales { - scales: Record<string, ScaleFunction>; - dimensions: Dimensions; - projection: Projection | null; - className: string; - facets: Facet[] | undefined; -} -``` - -### 5.3 FacetContext (within faceted rendering) - -```ts -interface FacetContext { - facetIndex: number; - fx: any; - fy: any; - translate: { x: number; y: number }; -} -``` - ---- - -## 6. File Structure - -``` -src/ -├── react/ -│ ├── index.ts # Public exports -│ ├── Plot.tsx # Root <Plot> component -│ ├── PlotContext.ts # React contexts -│ ├── usePlotScales.ts # Hook: compute scales from registrations -│ ├── useChannels.ts # Hook: create channels from mark props -│ ├── useMark.ts # Hook: register mark + get scaled values -│ │ -│ ├── marks/ -│ │ ├── Dot.tsx -│ │ ├── Line.tsx -│ │ ├── Area.tsx -│ │ ├── Bar.tsx # BarX, BarY -│ │ ├── Rect.tsx # Rect, Cell -│ │ ├── Rule.tsx # RuleX, RuleY -│ │ ├── Tick.tsx # TickX, TickY -│ │ ├── Text.tsx -│ │ ├── Link.tsx -│ │ ├── Arrow.tsx -│ │ ├── Image.tsx -│ │ ├── Vector.tsx -│ │ ├── Frame.tsx -│ │ ├── Geo.tsx -│ │ ├── Density.tsx -│ │ ├── Contour.tsx -│ │ ├── Delaunay.tsx -│ │ ├── Raster.tsx -│ │ ├── Waffle.tsx -│ │ ├── Box.tsx -│ │ ├── Hexgrid.tsx -│ │ └── Axis.tsx # AxisX, AxisY, GridX, GridY -│ │ -│ ├── legends/ -│ │ ├── Legend.tsx -│ │ ├── Ramp.tsx -│ │ └── Swatches.tsx -│ │ -│ ├── interactions/ -│ │ ├── Tip.tsx -│ │ ├── Crosshair.tsx -│ │ └── usePointer.ts # Pointer interaction hook -│ │ -│ └── facets/ -│ └── FacetGroup.tsx -│ -├── core/ # Reused pure logic (extracted from src/) -│ ├── channel.ts # Channel creation, inference (from channel.js) -│ ├── scales.ts # Scale creation (from scales.js) -│ ├── dimensions.ts # Dimension computation (from dimensions.js) -│ ├── facet.ts # Facet logic (from facet.js) -│ ├── projection.ts # Projection support (from projection.js) -│ ├── style.ts # Style defaults/inference (from style.js) -│ ├── transforms/ # All transform functions (unchanged) -│ │ ├── bin.ts -│ │ ├── stack.ts -│ │ ├── group.ts -│ │ ├── dodge.ts -│ │ ├── normalize.ts -│ │ ├── window.ts -│ │ ├── select.ts -│ │ ├── map.ts -│ │ ├── hexbin.ts -│ │ └── ... -│ ├── scales/ # Scale implementations (unchanged) -│ │ ├── quantitative.ts -│ │ ├── ordinal.ts -│ │ ├── temporal.ts -│ │ ├── diverging.ts -│ │ └── schemes.ts -│ └── utils/ # Shared utilities -│ ├── options.ts -│ ├── defined.ts -│ ├── format.ts -│ ├── curve.ts -│ ├── symbol.ts -│ ├── marker.ts -│ └── ... -``` - ---- - -## 7. Implementation Steps - -### Step 1: Project Setup - -- Add React 18+, TypeScript, and JSX support to the build -- Create `src/react/` and `src/core/` directory structure -- Configure dual exports: `replot` (legacy) and - `replot/react` - -### Step 2: Extract Pure Core Logic - -Extract non-DOM code from existing modules into `src/core/`: - -- `channel.js` → `core/channel.ts` (remove D3 selection references) -- `scales.js` → `core/scales.ts` (already pure) -- `dimensions.js` → `core/dimensions.ts` (already pure) -- `facet.js` → `core/facet.ts` (already pure) -- `projection.js` → `core/projection.ts` (already pure) -- `style.js` → `core/style.ts` (extract style defaults; remove D3 selection - application) -- All transforms → `core/transforms/` (already pure data transforms) -- All scale implementations → `core/scales/` (already pure) - -This step is low-risk since these modules are already mostly pure functions. - -### Step 3: Implement PlotContext and Registration - -Create the context system: - -- `PlotContext.ts`: Define `PlotRegistrationContext` and `PlotScalesContext` -- Registration protocol: marks call `registerMark()` with their channel specs -- The Plot component collects registrations and triggers scale computation - -### Step 4: Implement `<Plot>` Component - -The root orchestrator: - -1. Renders children in registration phase (hidden) -2. Collects mark registrations -3. Computes scales, dimensions, projections via `useMemo` -4. Infers implicit axes -5. Provides computed values via context -6. Renders SVG wrapper with styles - -Key decisions: -- Use `useRef` + `useLayoutEffect` for the two-phase approach -- Or: use a synchronous registration pattern where marks register during - render (safer for concurrent mode) - -### Step 5: Implement `useMark` Hook - -Shared hook used by all mark components: - -```ts -function useMark(data, channelSpecs, options) { - const { phase, registerMark, scales, dimensions } = usePlotContext(); - - // Phase 1: register channels - useEffect(() => { - registerMark(id, { data, channels: channelSpecs, transform, ... }); - return () => unregisterMark(id); - }, [data, channelSpecs]); - - // Phase 2: compute scaled values - const values = useMemo(() => { - if (!scales) return null; - return computeScaledValues(data, channelSpecs, scales); - }, [scales, data, channelSpecs]); - - return { values, dimensions, scales, index }; -} -``` - -### Step 6: Implement Core Mark Components - -Port each mark's `render()` method from D3 selections to JSX. Example for Dot: - -**Current** (`src/marks/dot.js`): -```js -render(index, scales, channels, dimensions, context) { - return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) - .call(g => g.selectAll().data(index).enter() - .append("circle") - .attr("cx", i => X[i]) - .attr("cy", i => Y[i]) - .attr("r", i => R[i]) - .call(applyChannelStyles, this, channels)) - .node(); -} -``` - -**React** (`src/react/marks/Dot.tsx`): -```tsx -function Dot({ data, x, y, r, fill, stroke, ...options }) { - const { values, index, dimensions, scales } = useMark(data, { - x: { value: x, scale: "x", optional: true }, - y: { value: y, scale: "y", optional: true }, - r: { value: r, scale: "r", optional: true }, - }, options); - - if (!values) return null; // registration phase - - const [cx, cy] = applyFrameAnchor(options, dimensions); - const { x: X, y: Y, r: R, fill: F, stroke: S } = values; - - return ( - <g {...indirectStyles(options, dimensions)}> - {index.map(i => ( - <circle - key={i} - cx={X ? X[i] : cx} - cy={Y ? Y[i] : cy} - r={R ? R[i] : options.r ?? 3} - fill={F?.[i]} - stroke={S?.[i]} - {...channelStyles(i, values)} - /> - ))} - </g> - ); -} -``` - -Priority order for mark implementation: -1. **Dot** — simplest, most common -2. **Line** — introduces grouping (z channel) -3. **BarY/BarX** — introduces stacking/intervals -4. **Rect/Cell** — rectangle marks -5. **Rule** — simple lines -6. **Text** — text rendering -7. **Area** — area paths -8. **Axis** (X, Y, Fx, Fy) + Grid — critical for usability -9. **Frame** — plot frame -10. **Tip** — tooltips/interactions -11. **Arrow, Link, Vector, Tick** — straightforward marks -12. **Image, Waffle, Box** — specialized marks -13. **Geo, Density, Contour, Delaunay, Raster, Hexgrid** — complex marks - -### Step 7: Implement Axis Components - -Axes are "implicit marks" in the current system — auto-generated based on -scales. In React, two approaches: - -**Option A (recommended):** Auto-insert axes as the current library does, via -the `<Plot>` component. If no explicit `<AxisX>` child is found, Plot -renders one. - -**Option B:** Require explicit axis components (simpler, more React-like, but -breaking API change). - -We'll go with Option A to match the current behavior, with Option B available. - -### Step 8: Implement Faceting - -Faceting requires: -1. `<Plot>` detects `fx`/`fy` channels from registered marks -2. Creates facet scales (always band scales) -3. Renders a `<FacetGroup>` that iterates over facet domains -4. Each facet cell gets a translated `<g>` containing the marks -5. Marks receive filtered data indices per facet - -### Step 9: Implement Legends - -Legends can be either: -- Auto-generated by `<Plot>` (current behavior) -- Explicitly placed via `<Legend scale="color" />` component - -Both should be supported. The Legend component reads scale info from context. - -### Step 10: Implement Interactions - -- `usePointer` hook: tracks mouse/touch position relative to SVG -- `<Tip>` component: renders tooltip based on nearest data point -- `<Crosshair>` component: renders crosshair lines -- Event dispatch for `viewof` compatibility (Observable notebooks) - -### Step 11: TypeScript Types - -Create comprehensive prop types for all components: - -```ts -interface DotProps<T = any> { - data?: Iterable<T>; - x?: ChannelValue<T>; - y?: ChannelValue<T>; - r?: ChannelValue<T> | number; - fill?: ChannelValue<T> | string; - stroke?: ChannelValue<T> | string; - symbol?: ChannelValue<T> | SymbolType; - tip?: boolean | TipOptions; - // ... all mark options -} -``` - -### Step 12: Transform Wrapper Components - -Implement the nesting API of section 8.3: - -1. `TransformContext` (`{wrap, stamp}`) and the `useMark` change: fold - `ctx.stamp` into the registration stamp, apply `ctx.wrap(options)` inside - the factory closure. -2. Fix invalidation first: include primitive values in stamps and make the - `<Plot>` recompute effect re-run on registration-version changes — the - wrappers depend on this path. -3. First slice: `StackY` + `BinX`, validated by rewriting existing snapshot - plots (fruit-sales-date, athletes-weight) in nested form and asserting - byte-identical SVG against the spread form. -4. Remaining wrappers follow mechanically from the table in 8.3, each with - typed props. - -### Step 13: Testing - -- Port snapshot tests: render React components to SVG strings and compare -- Unit test hooks and context system -- Integration tests for full plot rendering -- Use React Testing Library + jsdom - ---- - -## 8. Key Design Decisions - -### 8.1 D3 selections → JSX mapping - -| D3 Pattern | React Equivalent | -|-----------------------------------------|-----------------------------------------------------| -| `selection.data(arr).enter().append()` | `{arr.map(i => <element key={i} />)}` | -| `selection.attr("cx", i => X[i])` | `cx={X[i]}` on each element | -| `selection.call(fn)` | Direct function call or component composition | -| `selection.selectAll().data().join()` | `{data.map(...)}` | -| `create("svg:g", context)` | `<g>` | -| `applyIndirectStyles(sel, mark, ...)` | `<g {...indirectStyles(mark, ...)}>` | -| `applyChannelStyles(sel, mark, ch)` | spread per-element: `{...channelStyles(i, ch)}` | - -### 8.2 Grouped marks (Line, Area) - -Lines and areas group data by z/fill/stroke channels. In React: - -```tsx -function Line({ data, x, y, z, curve, ...options }) { - const { values, groups } = useMark(data, channels, options); - if (!values) return null; - const { x: X, y: Y } = values; - const lineGen = d3.line().curve(curve).x(i => X[i]).y(i => Y[i]); - - return ( - <g {...indirectStyles(options)}> - {groups.map((group, j) => ( - <path key={j} d={lineGen(group)} {...groupStyles(group[0], values)} /> - ))} - </g> - ); -} -``` - -### 8.3 Transforms compose as wrapper components - -Observable Plot's transforms are its one genuinely compositional construct: -each is a pure function `(config, markOptions) => markOptions`, and nesting -calls composes them (`binX(outputs, stackY(options))`). The JSX analogue is -nesting wrapper components, so element structure mirrors call structure: +# Replot: design notes + +Replot ports [Observable Plot](https://observablehq.com/plot/) to React. These +notes record the decisions behind the port: what it keeps from Plot, how a plot +and its marks cooperate, and the rules to follow when adding a mark or a +transform. Usage is documented in the [README](./README.md) and under +[`docs/`](./docs); where these notes and the code disagree, the code is the +authority. + +Two entry points share one implementation. `replot` is the imperative API, +ported from Plot with its surface intact, and `replot/react` is the component +API. Both run the same `computePlot` and the same marks. + +## One renderer, two entry points + +Marks render through `renderJSX`, which returns React elements; no +d3-selection rendering path is left in the codebase. `plot()` runs +`computePlot`, serialises the same element tree to markup and reparses it into +the document it was given, while `<Replot>` renders that tree into the React +root. The two APIs cannot drift in what they compute or in what they draw, +because there is one renderer and two ways to reach it. + +The thin seam between them is the mark component. `<Dot>` and its siblings call +`useMark` and return `null`; the SVG comes from the mark's own `renderJSX`, +colocated with its channel definitions. A mark is written once, against the +plot's computed channels and scales, and is then available to both APIs. + +## What the port keeps + +These parts of Plot are pure computation with no DOM involvement, so the port +reuses them unchanged: + +- **D3 scales** (d3-scale), used as pure functions +- **D3 shapes** (d3-shape, for line and area generators), returning path `d` strings +- **D3 geo projections**, pure coordinate transforms +- **Transform functions** (bin, stack, group and the rest), pure data transforms +- **Channel and scale inference**, and **dimension computation** + +What changes is how the results reach the page: marks produce React elements +rather than building the DOM, and the arrangement of marks in a plot is +expressed by composing components rather than by an options object. + +## Marks register, the plot computes + +Plot's pipeline is sequential and interdependent: scales depend on every mark's +channels, and marks depend on the computed scales. React children cannot pass +data upward before they render, so Replot splits a render in two: + +- **Registration.** Each mark component calls `useMark`, which registers a + factory and a stamp with the enclosing `<Replot>`. The component renders + nothing. +- **Computation and render.** A layout effect runs `computePlot` over the + registered factories, stores the result in state and re-renders. Each mark + then builds its elements from the computed channels, scales and dimensions. + +Registering a factory, rather than a description of the mark's channels, keeps +the React layer ignorant of what a mark is made of: it hands the plot a way to +build one, not a schema to interpret. It is also what lets an enclosing +transform wrapper apply its rewrite inside the factory, at the point where the +mark is constructed for that run. + +Scales and legends register the same way, which is why `<ScaleY>` and +`<Legend>` can be composed anywhere beneath the plot and still reach it. +`<Replot>` merges those registrations with its own props, and an explicit prop +wins on conflict. + +## Transforms compose as wrapper components + +Plot's transforms are its one genuinely compositional construct: each is a pure +function `(config, markOptions) => markOptions`, and nesting calls composes +them, as in `binX(outputs, stackY(options))`. The JSX analogue is nesting +wrapper components, so element structure mirrors call structure: ```tsx // binX({y: "count"}, stackY({x: "v", fill: "sex"})) @@ -584,245 +81,125 @@ nesting wrapper components, so element structure mirrors call structure: ``` The rule for what belongs where: things Plot expresses by **wrapping** -(transforms) nest as components; things Plot expresses as **options** +(transforms) nest as components, and things Plot expresses as **options** (channels, styles) stay as props on the mark. -#### Mechanism +Both forms are supported and must render the same plot; the React transform +tests assert that by comparing the two renderings as equal strings. The +functional form remains available as an escape hatch, and for conditional +application, which is awkward in JSX. + +### Mechanism -A `TransformContext` carries `{wrap: (options) => options, stamp: string}` -(identity/empty by default). Each wrapper composes with its parent context — -delegating outward so inner transforms apply first: +A `TransformContext` carries `{wrap, stamp}`, identity and empty by default. +Each wrapper composes with its parent context, delegating outward so inner +transforms apply first: ```tsx -function StackY({offset, order, reverse, children}: StackYProps) { - const parent = useContext(TransformContext); - const value = { - wrap: (o) => parent.wrap(stackY({offset, order, reverse}, o)), - stamp: parent.stamp + stampTransform("stackY", {offset, order, reverse}) +export function StackY({children, ...config}: StackYProps) { + const parent = useTransformContext(); + const value: TransformContextValue = { + wrap: (o) => parent.wrap(stackY(config, o)), + stamp: parent.stamp + stampOptions("stackY", null, config) }; return <TransformContext.Provider value={value}>{children}</TransformContext.Provider>; } ``` -Marks (via `useMark`) fold `ctx.stamp` into their registration stamp and -apply `ctx.wrap(options)` **inside the factory closure** — never at component -render time. Transforms allocate lazy `column()` cells that `computePlot` -fills per run; factories are re-evaluated each rebuild precisely so those -columns are fresh, and the wrap must happen inside that re-evaluation. +Marks fold `ctx.stamp` into their registration stamp and apply +`ctx.wrap(options)` **inside the mark factory**, never at component render +time. Transforms allocate lazy `column()` cells that `computePlot` fills per +run; factories are re-evaluated on each rebuild precisely so those columns are +fresh, and the wrap has to happen inside that re-evaluation. -Multiple marks under one wrapper each get the transform applied -independently — exactly equivalent to calling e.g. `stackY(options)` per mark -in the functional API. +Multiple marks under one wrapper each get the transform applied independently, +which is exactly equivalent to calling e.g. `stackY(options)` per mark in the +functional API. -#### Wrapper props per transform +### Wrapper props per transform -Every transform fits the `(config, options)` mold, so wrapper props map +Every transform fits the `(config, options)` mould, so wrapper props map mechanically onto the first argument: | Wrapper | Props (first argument) | |---|---| -| `BinX`/`BinY`/`Bin` | output reducers (`y="count"`) + `thresholds`/`interval`/`domain`/`cumulative` — bin's own `mergeOptions` routes config keys | +| `BinX`/`BinY`/`Bin` | output reducers (`y="count"`) plus `thresholds`/`interval`/`domain`/`cumulative`; bin's own `mergeOptions` routes config keys | | `GroupX`/`GroupY`/`GroupZ`/`Group` | output reducers | -| `StackY`/`StackX` (+`1`/`2` variants) | `offset`/`order`/`reverse` | +| `StackY`/`StackX` (and the `1`/`2` variants) | `offset`/`order`/`reverse` | | `WindowX`/`WindowY` | `k`/`reduce`/`anchor`/`strict` | | `NormalizeX`/`NormalizeY` | `basis` | | `MapX`/`MapY` | `map` | | `ShiftX`/`ShiftY` | `interval` | -| `SelectFirst`/`SelectLast`/`SelectMinX`/… | none (selector in the name) | +| `SelectFirst`/`SelectLast`/`SelectMinX`/`SelectMaxX`/... | none (the selector is in the name) | | `DodgeX`/`DodgeY` (initializer) | `anchor`/`padding`/`r` | -| `Hexbin` (initializer) | output reducers + `binWidth` | +| `Hexbin` (initializer) | output reducers plus `binWidth` | | `Centroid`/`GeoCentroid` (initializer) | `geometry` | -Not componentized: `treeNode`/`treeLink` (already inside `TreeMark`/ -`ClusterMark`) and `pointer*` (already `Tip`/`Crosshair`). `sort`/`filter`/ -`reverse` work as plain mark props already; wrappers for them are optional -sugar. Bare `map`/`select`/`window`/`filter` collide with JS/HTML names — -those stay function-only or take distinct names. +Not componentised: `treeNode`/`treeLink`, which are already inside +`TreeMark`/`ClusterMark`, and `pointer*`, which are already `<Tip>` and +`<Crosshair>`. `sort`/`filter`/`reverse` work as plain mark props already, so +wrappers for them would be optional sugar. Bare `map`, `select`, `window` and +`filter` collide with JS and HTML names, so those stay function-only or take a +distinct name (`mapTransform`, `windowMap`). -#### Constraints and known hazards +### Constraints and known hazards -- **Ordering**: Plot forbids transforms after initializers; nesting order +- **Ordering**: Plot forbids transforms after initializers, and nesting order maps directly onto call order, so wrong nesting fails with the same runtime error as wrong functional composition. Wrappers may pre-validate to give a clearer message. -- **Output vs input channels**: on a wrapper, `y="count"` is an output +- **Output versus input channels**: on a wrapper, `y="count"` is an output reducer; on the mark inside, `x="weight"` is an input channel. Typed props (reducer types on wrappers, channel types on marks) are the guard against putting a prop on the wrong element. -- **Invalidation**: wrapper config changes must flow through the mark stamp. - Stamps must include primitive *values* (not just key/typeof shape), or a - `thresholds` change won't trigger a rebuild. The `<Plot>` recompute effect - must also re-run when the registration version changes, not only when +- **Invalidation**: wrapper config changes must flow through the mark stamp, so + stamps have to include primitive *values* rather than just a key and a type, + or a `thresholds` change will not trigger a rebuild. The `<Replot>` compute + effect must also re-run when the registration version changes, not only when plot-level options change. -- **Equivalence tests**: nested-wrapper and functional-spread forms must - produce byte-identical SVG for the same plot (verify against existing - snapshot tests, e.g. fruit-sales-date, athletes-weight). - -Transforms should never become hooks — `useBin(data, ...)` is the wrong -abstraction level; the transform contract is an options rewrite, not a data -computation the component observes. - -### 8.4 Style application - -Replace D3 `applyAttr`/`applyStyle` calls with a utility that returns a -props object: - -```ts -function indirectStyleProps(mark, dimensions, context) { - return { - fill: mark.fill, - fillOpacity: mark.fillOpacity, - stroke: mark.stroke, - strokeWidth: mark.strokeWidth, - // ... - }; -} - -function channelStyleProps(i, values) { - return { - ...(values.fill && { fill: values.fill[i] }), - ...(values.stroke && { stroke: values.stroke[i] }), - // ... - }; -} -``` - -### 8.5 Clip paths and patterns - -These require `<defs>` in SVG. The `<Plot>` component manages a shared -`<defs>` section. Marks that need clips register their requirements via -context, and Plot renders the necessary `<clipPath>` elements. - -### 8.6 Performance considerations - -- **`useMemo` aggressively** on scale computation and data transforms -- **Virtualize large datasets** — for 10k+ points, consider only rendering - visible elements -- **`React.memo`** on mark components to prevent unnecessary re-renders -- **Keys on SVG elements** — use data index as key for efficient reconciliation - ---- - -## 9. Migration Path - -The React version will be a **separate export** so both APIs coexist: - -```js -// Existing imperative API (unchanged) -import * as Plot from "@dave-hillier/replot"; -const svg = Plot.plot({ marks: [Plot.dot(data, { x: "a", y: "b" })] }); - -// New React API -import { Plot, Dot } from "@dave-hillier/replot/react"; -<Plot><Dot data={data} x="a" y="b" /></Plot> -``` - -The shared `core/` modules ensure both APIs stay in sync. - ---- - -## 10. Observable Framework Considerations - -This port is part of a broader effort to also port **Observable Framework** to -React. This affects several design decisions: - -### 10.1 Reactivity Model - -Observable Framework uses Observable's reactive runtime (`viewof`, cell -dependencies, `Inputs.bind`). In React: - -- **Observable cells** → React state (`useState`, `useReducer`) -- **`viewof` pattern** → Controlled components with `value`/`onChange` props -- **Cell dependencies** → Component props / context / derived state (`useMemo`) - -Plot components must expose clean **callback props** for Framework integration: - -```tsx -<Plot onPointerMove={handlePointer} onSelect={handleSelection}> - <Dot data={data} x="x" y="y" - onClick={(event, datum) => setSelected(datum)} - onPointerEnter={(event, datum) => setHovered(datum)} - /> -</Plot> -``` - -### 10.2 Data Loaders - -Framework's data loaders (`FileAttachment`, `DuckDB`, SQL cells) would become -React patterns. Plot must accept data purely via props: - -```tsx -// Framework's data loader → React hook -const data = useDataLoader("data/weather.csv", { typed: true }); - -// Plot is a pure consumer — no opinion on loading -<Plot> - <Suspense fallback={<PlotSkeleton />}> - <Line data={data} x="date" y="temperature" /> - </Suspense> -</Plot> -``` - -### 10.3 SSR Compatibility - -Framework does server-side rendering. All React Plot components must be -**SSR-safe**: - -- No `window`/`document` access during render (only in `useEffect`) -- Support React Server Components where possible (marks are mostly pure) -- Hydration-safe: server and client must produce identical SVG - -### 10.4 Layout Integration - -Framework's dashboard layout (cards, grids, sidebars) would become React -layout components. Plot should work seamlessly within these: - -```tsx -<DashboardGrid> - <Card title="Sales"> - <Plot> - <BarY data={sales} x="month" y="revenue" /> - </Plot> - </Card> - <Card title="Users"> - <Plot> - <Line data={users} x="date" y="count" /> - </Plot> - </Card> -</DashboardGrid> -``` - -### 10.5 Responsive/Auto-sizing - -Framework auto-sizes plots to their container. The React Plot should support: - -```tsx -// Auto-size to container (via ResizeObserver) -<Plot width="auto" height={400}>...</Plot> - -// Or via a hook -const { width } = useContainerSize(containerRef); -<Plot width={width}>...</Plot> -``` - -### 10.6 Theme System - -Framework has light/dark theme support. Plot components should respect a -theme context: - -```tsx -<PlotTheme mode="dark" colors={{ background: "#1a1a1a" }}> - <Plot>...</Plot> -</PlotTheme> -``` - ---- - -## 11. What NOT to Port - -Some Observable-specific features may be deferred or dropped: - -- `mark.plot()` fluent method — not idiomatic React -- `figure.value` / `dispatchEvent` — Observable notebook specific -- Auto-wrapping in `<figure>` with `<h2>` title — use React composition instead -- The `Render` class (function-as-mark) — replaced by custom render prop +- **Equivalence**: the nested-wrapper and functional-spread forms must render + the same plot, which the React transform tests check by comparing the two + renderings as equal strings. + +Transforms should never become hooks: `useBin(data, ...)` is the wrong +abstraction level, because the transform contract is an options rewrite, not a +data computation the component observes. + +## Clip paths and defs + +The `<clipPath>` defs have to exist before the elements that reference them, so +clipping is arranged before the SVG renders. `<Replot>` owns a clip registry +for each render and collects the defs into one `<defs>`; marks are wrapped or +annotated as they render. For `clip: "frame"` the mark is wrapped in an outer, +untransformed `<g>` that carries the aria attributes and the clip, leaving the +inner `<g>` free to be transformed; for a GeoJSON geometry the clip is applied +to the mark's own `<g>`, with spheres coalesced to a single clip. A GeoJSON clip +is rendered from the same path data a geo mark would produce, respecting the +plot's projection, and `clipPath` elements are shared between marks clipped +with the same object. + +## When the plot recomputes + +`<Replot>` recomputes only when something that feeds the computation changed, +and otherwise reuses the previous result. What decides that is the stamp: every +registration carries a string summarising the values that affect the output, +and the compute effect joins the mark stamps, the scale stamps, the plot's +options, the class name and the style into one inputs key, comparing it against +the previous key held in a ref. A mark re-rendering with the same stamp +refreshes the stored factory and its closures without rebuilding anything. + +Numbers, strings and booleans are stamped by value, and arrays and plain +objects are walked into, up to a depth and entry cap beyond which only their +shape is compared, so a changed `thresholds` or domain array still forces a +rebuild. Functions are excluded from the stamp: event handlers and `onValue` +are read through refs at dispatch time, so a fresh closure identity has to be +able to update the handler without forcing a recompute. Data identity is the +deliberate exception, since array and object contents cannot be hashed cheaply: +a new data reference bumps a sequence number in the stamp, so its contents are +assumed to have changed. + +The computation itself is deliberately not memoised. There is no `useMemo` in +the React layer and no `React.memo` on the components, because the inputs key +already answers the only question a cache would ask, and a cache in front of it +would be a second, weaker answer to the same question, making the invalidation +path harder to follow. diff --git a/README.md b/README.md index 57abf169..28a207b7 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ function Chart({data}) { - **Native React integration** — Use composable React components (`<Replot>`, `<Dot>`, `<Line>`, etc.) that render directly into the React tree. - **Declarative API** — Define charts with JSX, making them easier to read, compose, and maintain alongside other React code. -- **React ecosystem compatibility** — Works with React state, context, hooks, Suspense, and server-side rendering out of the box. +- **React ecosystem compatibility** — Works with React state, context, hooks, Suspense, and server rendering, where `renderToString` returns the whole plot. A hydrating client redraws it, because marks register with the plot from an effect. - **No manual DOM management** — No need for refs, effects, or manual cleanup. - **Built on Observable Plot** — All the power of Observable Plot's scales, transforms, and mark system. @@ -210,6 +210,14 @@ Or the imperative API: import * as Replot from "@dave-hillier/replot"; ``` +The imperative API renders statically. `Replot.plot` returns a detached SVG +element and attaches no pointer listeners, so marks that depend on the pointer, +such as a `tip` or a `crosshair`, render with nothing selected, and the returned +element never gets a `.value` or a bubbling `input` event. Upstream Observable +Plot is interactive here. In Replot that behaviour belongs to the React API, +which mounts a real React root and reports the selection through the `onValue` +prop on `<Replot>`. + ## Based on Observable Plot Replot is a fork of [Observable Plot](https://observablehq.com/plot/), ported to provide a first-class React component API. See the [Observable Plot documentation](https://observablehq.com/plot/) for full details on scales, marks, transforms, and projections. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts deleted file mode 100644 index b6441431..00000000 --- a/docs/.vitepress/config.ts +++ /dev/null @@ -1,163 +0,0 @@ -import {fileURLToPath, URL} from "node:url"; -import path from "node:path"; -import {defineConfig} from "vitepress"; -import plot from "./markdown-it-plot.js"; - -// https://vitepress.dev/reference/site-config -// prettier-ignore -export default defineConfig({ - title: "Plot", - description: "The JavaScript library for exploratory data visualization", - appearance: "force-auto", - base: "/plot/", - cleanUrls: true, - vite: { - resolve: { - alias: [ - {find: "@dave-hillier/replot", replacement: path.resolve("./src/index.js")}, - {find: /^.*\/VPFooter\.vue$/, replacement: fileURLToPath(new URL("./theme/CustomFooter.vue", import.meta.url))} - ] - }, - define: { - __APP_VERSION__: JSON.stringify(process.env.npm_package_version) - } - }, - vue: { - template: { - compilerOptions: { - isCustomElement: (tag) => tag.startsWith("observable-") - } - } - }, - markdown: { - config: (md) => { - plot(md); - } - }, - head: [ - ["link", {rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: ""}], - ["link", {rel: "preload", as: "style", href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Spline+Sans+Mono:ital,wght@0,300..700;1,300..700&display=swap"}], - ["link", {rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Spline+Sans+Mono:ital,wght@0,300..700;1,300..700&display=swap"}], - ["link", {rel: "apple-touch-icon", href: "https://static.observablehq.com/favicon-512.0667824687f99c942a02e06e2db1a060911da0bf3606671676a255b1cf97b4fe.png"}], - ["link", {rel: "icon", type: "image/png", href: "https://static.observablehq.com/favicon-512.0667824687f99c942a02e06e2db1a060911da0bf3606671676a255b1cf97b4fe.png", sizes: "512x512"}], - ["script", {async: "", src: "https://www.googletagmanager.com/gtag/js?id=G-9B88TP6PKQ"}], - ["script", {}, "window.dataLayer=window.dataLayer||[];\nfunction gtag(){dataLayer.push(arguments);}\ngtag('js',new Date());\ngtag('config','G-9B88TP6PKQ');"], - ["script", {async: "", defer: "", src: "https://static.observablehq.com/assets/components/observable-made-by.js"}], - ], - sitemap: { - hostname: "https://observablehq.com/plot/" - }, - themeConfig: { - // https://vitepress.dev/reference/default-theme-config - // Theme related configurations. - logo: { - light: "/observable-light.svg", - dark: "/observable-dark.svg" - }, - sidebar: [ - { - text: "Introduction", - items: [ - {text: "What is Plot?", link: "/what-is-plot"}, - {text: "Why Plot?", link: "/why-plot"}, - {text: "Getting started", link: "/getting-started"}, - {text: "React examples", link: "/react"}, - {text: "Examples", link: "https://observablehq.com/@observablehq/plot-gallery"} - ] - }, - { - text: "Features", - collapsed: false, - items: [ - {text: "Plots", link: "/features/plots"}, - {text: "Marks", link: "/features/marks"}, - {text: "Scales", link: "/features/scales"}, - {text: "Projections", link: "/features/projections"}, - {text: "Transforms", link: "/features/transforms"}, - {text: "Interactions", link: "/features/interactions"}, - {text: "Facets", link: "/features/facets"}, - {text: "Legends", link: "/features/legends"}, - {text: "Curves", link: "/features/curves"}, - {text: "Formats", link: "/features/formats"}, - {text: "Intervals", link: "/features/intervals"}, - {text: "Markers", link: "/features/markers"}, - {text: "Shorthand", link: "/features/shorthand"}, - {text: "Accessibility", link: "/features/accessibility"} - ] - }, - { - text: "Marks", - collapsed: true, - items: [ - {text: "Area", link: "/marks/area"}, - {text: "Arrow", link: "/marks/arrow"}, - {text: "Auto", link: "/marks/auto"}, - {text: "Axis", link: "/marks/axis"}, - {text: "Bar", link: "/marks/bar"}, - {text: "Bollinger", link: "/marks/bollinger"}, - {text: "Box", link: "/marks/box"}, - {text: "Cell", link: "/marks/cell"}, - {text: "Contour", link: "/marks/contour"}, - {text: "Delaunay", link: "/marks/delaunay"}, - {text: "Density", link: "/marks/density"}, - {text: "Difference", link: "/marks/difference"}, - {text: "Dot", link: "/marks/dot"}, - {text: "Frame", link: "/marks/frame"}, - {text: "Geo", link: "/marks/geo"}, - {text: "Grid", link: "/marks/grid"}, - {text: "Hexgrid", link: "/marks/hexgrid"}, - {text: "Image", link: "/marks/image"}, - {text: "Line", link: "/marks/line"}, - {text: "Linear regression", link: "/marks/linear-regression"}, - {text: "Link", link: "/marks/link"}, - {text: "Raster", link: "/marks/raster"}, - {text: "Rect", link: "/marks/rect"}, - {text: "Rule", link: "/marks/rule"}, - {text: "Text", link: "/marks/text"}, - {text: "Tick", link: "/marks/tick"}, - {text: "Tip", link: "/marks/tip"}, - {text: "Tree", link: "/marks/tree"}, - {text: "Vector", link: "/marks/vector"}, - {text: "Waffle", link: "/marks/waffle"} - ] - }, - { - text: "Transforms", - collapsed: true, - items: [ - {text: "Bin", link: "/transforms/bin"}, - {text: "Centroid", link: "/transforms/centroid"}, - {text: "Dodge", link: "/transforms/dodge"}, - {text: "Filter", link: "/transforms/filter"}, - {text: "Group", link: "/transforms/group"}, - {text: "Hexbin", link: "/transforms/hexbin"}, - {text: "Interval", link: "/transforms/interval"}, - {text: "Map", link: "/transforms/map"}, - {text: "Normalize", link: "/transforms/normalize"}, - {text: "Select", link: "/transforms/select"}, - {text: "Shift", link: "/transforms/shift"}, - {text: "Sort", link: "/transforms/sort"}, - {text: "Stack", link: "/transforms/stack"}, - {text: "Tree", link: "/transforms/tree"}, - {text: "Window", link: "/transforms/window"} - ] - }, - { - text: "Interactions", - collapsed: true, - items: [ - {text: "Crosshair", link: "/interactions/crosshair"}, - {text: "Pointer", link: "/interactions/pointer"} - ] - }, - {text: "API index", link: "/api"} - ], - search: { - provider: "local" - }, - footer: { - message: "Library released under <a style='text-decoration:underline;' href='https://github.com/observablehq/plot/blob/main/LICENSE'>ISC License</a>.", - copyright: `Copyright 2020–${new Date().getUTCFullYear()} Observable, Inc.` - } - } -}); diff --git a/docs/.vitepress/markdown-it-plot.ts b/docs/.vitepress/markdown-it-plot.ts deleted file mode 100644 index c4f101fe..00000000 --- a/docs/.vitepress/markdown-it-plot.ts +++ /dev/null @@ -1,56 +0,0 @@ -import container from "markdown-it-container"; - -export default function plot(md) { - md.use(container, "plot", { - render(tokens, idx) { - if (tokens[idx].nesting === 1) { - const directives = tokens[idx].info.split(/\s+/).slice(1); - const token = tokens[idx + 1]; - if (token.type !== "fence" || token.tag !== "code") throw new Error("missing fenced code block"); - // TODO use acorn to parse and recut - let content = token.content; - if (/"var\(--vp-c-bg(?:-alt)?\)"/g.test(content)) { - token.content = content.replace(/"var\(--vp-c-bg(-alt)?\)"/g, `"{{$dark ? 'black' : 'white'}}"`); - token.info = "js-vue"; - } - if (token.info === "js-vue") content = content.replace(/"{{([^}]*)}}"/g, "$1"); - content = content.replace(/\bMath\.random\b/g, "d3.randomLcg(42)"); - content = content.replace(/\bd3\.(random(?!Lcg)\w+)\b/g, "d3.$1.source(d3.randomLcg(42))"); - content = content.replace(/\bd3\.shuffle\b/g, "d3.shuffler(d3.randomLcg(42))"); - content = content.replace(/"red"/g, '"var(--vp-c-red)"'); - content = content.replace(/"green"/g, '"var(--vp-c-green)"'); - content = content.replace(/"blue"/g, '"var(--vp-c-blue)"'); - content = content.replace(/"purple"/g, '"var(--vp-c-purple)"'); - const href = directives.find((d) => d.startsWith("https://")); - const suffix = `\n${ - directives.includes("hidden") - ? `<div style="display: none;">\n` - : href - ? `<a class="plot-fork no-icon" href="${md.utils.escapeHtml(href)}" target="_blank" title="Open on Observable">Fork</a>` - : "" - }`; - if (/^Plot\.plot\(/.test(content)) { - const options = content.slice(9); - return `<PlotRender - ${directives.includes("defer") ? "defer" : ""} - :options='${md.utils.escapeHtml(options)}' - />${suffix}`; - } else { - const re = /\.plot\((.*)\)/s; - const match = re.exec(content); - if (!match) throw new Error("mark.plot not found"); - const mark = content.replace(re, ""); - const options = `(${match[1] || "{}"})`; - return `<PlotRender - ${directives.includes("defer") ? "defer" : ""} - :mark='${md.utils.escapeHtml(mark)}' - :options='${md.utils.escapeHtml(options)}' - />${suffix}`; - } - } else { - const directives = tokens[idx - 2].info.split(/\s+/).slice(1); - return `${directives.includes("hidden") ? `</div>` : ""}\n`; - } - } - }); -} diff --git a/docs/.vitepress/theme/CustomFooter.vue b/docs/.vitepress/theme/CustomFooter.vue deleted file mode 100644 index e026f219..00000000 --- a/docs/.vitepress/theme/CustomFooter.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> - <footer class="bg-alt f12 lh-normal"> - <div class="flex-l"> - <div class="ma4"> - <a title="Home" target="_blank" href="https://observablehq.com/"> - <svg role="img" width="173" height="24" viewBox="0 0 173 24" aria-label="Observable" fill="currentColor"> - <path d="M40.8496 20.6083C39.7407 20.6083 38.6757 20.4234 37.6548 20.0538C36.6339 19.6842 35.7186 19.1209 34.9089 18.364C34.1168 17.607 33.4919 16.6565 33.0343 15.5124C32.5766 14.3683 32.3478 13.0218 32.3478 11.4727C32.3478 9.92375 32.5766 8.57718 33.0343 7.43305C33.5095 6.2889 34.1432 5.33839 34.9353 4.5815C35.7274 3.82462 36.6339 3.26135 37.6548 2.8917C38.6757 2.52205 39.7407 2.33723 40.8496 2.33723C41.9585 2.33723 43.0234 2.52205 44.0444 2.8917C45.0653 3.26135 45.9718 3.82462 46.7639 4.5815C47.556 5.33839 48.1809 6.2889 48.6386 7.43305C49.1138 8.57718 49.3514 9.92375 49.3514 11.4727C49.3514 13.0218 49.1225 14.3683 48.665 15.5124C48.2073 16.6565 47.5736 17.607 46.7639 18.364C45.9718 19.1209 45.0653 19.6842 44.0444 20.0538C43.0234 20.4234 41.9585 20.6083 40.8496 20.6083ZM40.8496 18.9977C41.6065 18.9977 42.2049 18.6896 42.645 18.0736C43.1027 17.4398 43.4283 16.5598 43.622 15.4332C43.8332 14.3067 43.9388 12.9865 43.9388 11.4727C43.9388 9.95894 43.8332 8.63879 43.622 7.51226C43.4283 6.38572 43.1027 5.50562 42.645 4.87193C42.2049 4.23826 41.6065 3.92142 40.8496 3.92142C40.1103 3.92142 39.5118 4.23826 39.0542 4.87193C38.5965 5.50562 38.2621 6.38572 38.0509 7.51226C37.8572 8.63879 37.7604 9.95894 37.7604 11.4727C37.7604 12.9865 37.8572 14.3067 38.0509 15.4332C38.2621 16.5598 38.5965 17.4398 39.0542 18.0736C39.5118 18.6896 40.1103 18.9977 40.8496 18.9977ZM50.5773 20.1066V18.76L51.7655 18.496C51.7831 17.8622 51.7919 17.1846 51.7919 16.4629C51.7919 15.7413 51.7919 15.1076 51.7919 14.5619V3.60459L50.3661 3.41977V2.23162L56.386 1.04348L56.8349 1.33392L56.7293 5.00396V8.33075C57.7678 7.30983 59.0175 6.79937 60.4785 6.79937C61.4466 6.79937 62.3267 7.0546 63.1188 7.56506C63.9109 8.05792 64.5358 8.80601 64.9934 9.80933C65.4687 10.7951 65.7063 12.0448 65.7063 13.5586C65.7063 15.002 65.4335 16.2429 64.8878 17.2814C64.3422 18.3199 63.6293 19.1209 62.7491 19.6842C61.8867 20.2298 60.9626 20.5026 59.9768 20.5026C59.22 20.5026 58.5599 20.3706 57.9966 20.1066C57.4333 19.8601 56.9405 19.4993 56.518 19.0241L55.99 20.5026L50.5773 20.1066ZM58.0494 9.41329C57.8206 9.41329 57.6006 9.44848 57.3893 9.5189C57.1957 9.5893 57.0021 9.68612 56.8085 9.80933V17.5982C57.1605 17.8447 57.5742 17.968 58.0494 17.968C58.7535 17.968 59.3168 17.607 59.7392 16.8853C60.1616 16.1637 60.3729 15.0548 60.3729 13.5586C60.3729 12.0448 60.1616 10.9799 59.7392 10.3638C59.3168 9.73012 58.7535 9.41329 58.0494 9.41329ZM71.7275 20.5026C69.8968 20.5026 68.2775 20.0714 66.8693 19.2089L67.0013 15.8292H69.404L69.8264 18.5752C70.1257 18.6984 70.4337 18.7952 70.7505 18.8657C71.0674 18.9185 71.3931 18.9449 71.7275 18.9449C72.3964 18.9449 72.9156 18.8393 73.2852 18.628C73.6549 18.4168 73.8397 18.0647 73.8397 17.5718C73.8397 17.2198 73.699 16.903 73.4173 16.6213C73.1532 16.3397 72.5812 16.0845 71.7011 15.8556L70.1961 15.4596C69.1575 15.178 68.3654 14.6851 67.8198 13.981C67.2742 13.2594 67.0013 12.3881 67.0013 11.3671C67.0013 10.047 67.503 8.95563 68.5063 8.09312C69.5272 7.23062 70.9882 6.79937 72.8892 6.79937C73.699 6.79937 74.447 6.88738 75.1335 7.06341C75.8376 7.23942 76.5505 7.50345 77.2721 7.8555L77.0609 10.839H74.6318L74.0509 8.48917C73.8749 8.45397 73.6813 8.42757 73.4701 8.40996C73.2765 8.37476 73.0389 8.35715 72.7572 8.35715C72.2644 8.35715 71.8419 8.47157 71.4898 8.70039C71.1554 8.91162 70.9882 9.24606 70.9882 9.70372C70.9882 9.98535 71.1115 10.2582 71.3578 10.5222C71.6043 10.7862 72.1851 11.0414 73.1004 11.2879L74.579 11.6839C75.8112 12.0184 76.6912 12.5377 77.2193 13.2417C77.765 13.9459 78.0379 14.8172 78.0379 15.8556C78.0379 17.4047 77.4658 18.5664 76.3216 19.3409C75.1951 20.1153 73.6637 20.5026 71.7275 20.5026ZM85.8767 8.22514C85.4718 8.22514 85.1286 8.52438 84.847 9.12285C84.5653 9.72132 84.4069 10.839 84.3717 12.476H85.6127C86.2464 12.476 86.6688 12.3528 86.88 12.1064C87.1088 11.8424 87.2233 11.3759 87.2233 10.707C87.2233 9.79173 87.0824 9.14926 86.8008 8.7796C86.5368 8.40996 86.2287 8.22514 85.8767 8.22514ZM85.9559 20.5026C84.6358 20.5026 83.4564 20.2298 82.4179 19.6842C81.3969 19.1384 80.5873 18.3552 79.9887 17.3342C79.3903 16.2957 79.091 15.0548 79.091 13.6114C79.091 12.4673 79.2935 11.4727 79.6983 10.6278C80.1032 9.78293 80.6488 9.07884 81.3353 8.51557C82.0219 7.93471 82.7876 7.50345 83.6325 7.22182C84.4774 6.94018 85.331 6.79937 86.1936 6.79937C87.4609 6.79937 88.5082 7.06341 89.3355 7.59147C90.1804 8.10193 90.8141 8.79721 91.2365 9.67732C91.659 10.5398 91.8702 11.5079 91.8702 12.5816C91.8702 12.8633 91.8614 13.1097 91.8438 13.3209C91.8262 13.5146 91.791 13.7346 91.7382 13.981H84.3981C84.5389 15.266 84.9174 16.1989 85.5335 16.7797C86.1672 17.3606 86.8712 17.651 87.6457 17.651C88.3145 17.651 88.8867 17.5367 89.3619 17.3078C89.8547 17.0614 90.286 16.7622 90.6557 16.4101L91.6854 17.4134C91.1221 18.4872 90.3477 19.2704 89.3619 19.7634C88.3937 20.2562 87.2584 20.5026 85.9559 20.5026ZM92.9624 20.0802V18.7336L94.2298 18.4432C94.2473 17.8094 94.2562 17.1406 94.2562 16.4365C94.2562 15.7149 94.2562 15.0812 94.2562 14.5355V12.8721C94.2562 12.3792 94.2473 11.9832 94.2298 11.6839C94.2298 11.3848 94.221 11.1119 94.2034 10.8654C94.2034 10.6014 94.1945 10.2934 94.177 9.94135L92.7512 9.70372V8.62118L98.5863 6.79937L99.0615 7.08981L99.2727 10.1526C99.6248 9.00844 100.127 8.16353 100.778 7.61787C101.447 7.0722 102.098 6.79937 102.732 6.79937C103.383 6.79937 103.929 6.99299 104.369 7.38024C104.826 7.74988 105.099 8.33956 105.187 9.14925C105.152 9.85333 104.941 10.4078 104.553 10.8126C104.166 11.1998 103.709 11.3935 103.18 11.3935C102.371 11.3935 101.631 10.9094 100.963 9.94135L100.831 9.75652C100.514 10.1086 100.197 10.5662 99.88 11.1295C99.5808 11.6928 99.3784 12.2736 99.2727 12.8721V14.5355C99.2727 15.046 99.2727 15.6444 99.2727 16.3309C99.2727 17.0173 99.2816 17.6687 99.2991 18.2848L101.359 18.7336V20.0802H92.9624ZM114.743 8.46277V7.22182H119.707V8.46277L118.123 8.7268L113.766 20.0802H111.39L106.69 8.7268L105.423 8.46277V7.22182H113.37V8.46277L111.971 8.7796L114.162 14.8523L116.195 8.7532L114.743 8.46277ZM129.974 20.5026C129.147 20.5026 128.478 20.3442 127.967 20.0274C127.475 19.6929 127.114 19.2265 126.885 18.628C126.374 19.1912 125.855 19.6489 125.327 20.001C124.817 20.3354 124.086 20.5026 123.136 20.5026C122.097 20.5026 121.261 20.2034 120.627 19.6049C120.011 19.0064 119.703 18.1703 119.703 17.0966C119.703 16.4101 119.853 15.8028 120.152 15.2747C120.469 14.7291 121.015 14.2363 121.789 13.7962C122.581 13.3386 123.69 12.9249 125.116 12.5552C125.327 12.5024 125.574 12.4409 125.855 12.3704C126.137 12.2825 126.427 12.2033 126.727 12.1328V11.1823C126.727 10.0558 126.594 9.28127 126.33 8.85881C126.084 8.43636 125.547 8.22514 124.72 8.22514C124.649 8.22514 124.579 8.22514 124.509 8.22514C124.456 8.22514 124.394 8.22514 124.324 8.22514V9.12285C124.324 10.1966 124.104 10.9622 123.664 11.4199C123.224 11.86 122.722 12.08 122.159 12.08C121.085 12.08 120.416 11.6047 120.152 10.6542C120.152 9.51009 120.689 8.58599 121.763 7.8819C122.854 7.16021 124.465 6.79937 126.594 6.79937C128.425 6.79937 129.71 7.20422 130.449 8.01391C131.206 8.80601 131.585 10.1086 131.585 11.9216V17.968C131.585 18.2671 131.734 18.4168 132.034 18.4168C132.139 18.4168 132.245 18.3816 132.35 18.3112C132.456 18.2232 132.588 18.0647 132.746 17.8358L133.512 18.2584C133.195 19.0681 132.755 19.6489 132.192 20.001C131.646 20.3354 130.907 20.5026 129.974 20.5026ZM124.245 16.2781C124.245 16.9294 124.377 17.4134 124.641 17.7302C124.905 18.0472 125.23 18.2056 125.618 18.2056C125.741 18.2056 125.873 18.1792 126.014 18.1264C126.172 18.0559 126.41 17.9152 126.727 17.7038V13.3737C126.462 13.4442 126.207 13.541 125.961 13.6642C125.591 13.8403 125.213 14.1394 124.825 14.5619C124.438 14.9843 124.245 15.5564 124.245 16.2781ZM134.041 20.1066V18.76L135.229 18.496C135.247 17.8622 135.256 17.1846 135.256 16.4629C135.256 15.7413 135.256 15.1076 135.256 14.5619V3.60459L133.83 3.41977V2.23162L139.85 1.04348L140.299 1.33392L140.193 5.00396V8.33075C141.232 7.30983 142.481 6.79937 143.942 6.79937C144.91 6.79937 145.791 7.0546 146.583 7.56506C147.375 8.05792 148 8.80601 148.457 9.80933C148.933 10.7951 149.17 12.0448 149.17 13.5586C149.17 15.002 148.898 16.2429 148.352 17.2814C147.806 18.3199 147.093 19.1209 146.213 19.6842C145.351 20.2298 144.426 20.5026 143.441 20.5026C142.684 20.5026 142.024 20.3706 141.461 20.1066C140.897 19.8601 140.405 19.4993 139.982 19.0241L139.454 20.5026L134.041 20.1066ZM141.513 9.41329C141.284 9.41329 141.064 9.44848 140.853 9.5189C140.659 9.5893 140.466 9.68612 140.272 9.80933V17.5982C140.625 17.8447 141.038 17.968 141.513 17.968C142.217 17.968 142.781 17.607 143.203 16.8853C143.626 16.1637 143.837 15.0548 143.837 13.5586C143.837 12.0448 143.626 10.9799 143.203 10.3638C142.781 9.73012 142.217 9.41329 141.513 9.41329ZM150.465 20.0802V18.7336L151.653 18.4696C151.671 17.8007 151.679 17.1406 151.679 16.4893C151.697 15.838 151.707 15.1868 151.707 14.5355V3.6838L150.28 3.41977V2.23162L156.379 1.04348L156.828 1.33392L156.723 5.00396V14.5355C156.723 15.1868 156.723 15.8469 156.723 16.5157C156.74 17.167 156.758 17.8271 156.776 18.496L157.963 18.7336V20.0802H150.465ZM165.938 8.22514C165.533 8.22514 165.189 8.52438 164.908 9.12285C164.626 9.72132 164.467 10.839 164.432 12.476H165.674C166.307 12.476 166.73 12.3528 166.94 12.1064C167.169 11.8424 167.284 11.3759 167.284 10.707C167.284 9.79173 167.143 9.14926 166.861 8.7796C166.597 8.40996 166.289 8.22514 165.938 8.22514ZM166.016 20.5026C164.696 20.5026 163.517 20.2298 162.478 19.6842C161.457 19.1384 160.647 18.3552 160.049 17.3342C159.45 16.2957 159.152 15.0548 159.152 13.6114C159.152 12.4673 159.354 11.4727 159.759 10.6278C160.164 9.78293 160.71 9.07884 161.395 8.51557C162.082 7.93471 162.848 7.50345 163.693 7.22182C164.537 6.94018 165.392 6.79937 166.254 6.79937C167.522 6.79937 168.568 7.06341 169.396 7.59147C170.241 8.10193 170.874 8.79721 171.297 9.67732C171.719 10.5398 171.93 11.5079 171.93 12.5816C171.93 12.8633 171.922 13.1097 171.904 13.3209C171.886 13.5146 171.851 13.7346 171.799 13.981H164.459C164.6 15.266 164.978 16.1989 165.594 16.7797C166.228 17.3606 166.932 17.651 167.706 17.651C168.375 17.651 168.947 17.5367 169.423 17.3078C169.915 17.0614 170.346 16.7622 170.716 16.4101L171.746 17.4134C171.182 18.4872 170.408 19.2704 169.423 19.7634C168.454 20.2562 167.319 20.5026 166.016 20.5026Z"></path> - <path d="M11.4413 19.7265C10.3849 19.7265 9.46964 19.4977 8.69544 19.04C7.92403 18.5841 7.30727 17.9383 6.91536 17.1761C6.49867 16.3829 6.19365 15.5442 6.00767 14.6801C5.81035 13.743 5.71422 12.7903 5.72068 11.8359C5.72068 11.0802 5.77027 10.3577 5.87038 9.66687C5.96953 8.97688 6.15355 8.27548 6.42147 7.56358C6.68939 6.85167 7.03454 6.23445 7.45501 5.71104C7.89276 5.17482 8.45875 4.73835 9.10924 4.4354C9.79191 4.10837 10.568 3.9453 11.4413 3.9453C12.4977 3.9453 13.4131 4.17413 14.1873 4.63178C14.9587 5.08762 15.5755 5.73343 15.9674 6.49571C16.3811 7.28039 16.6834 8.11242 16.875 8.99178C17.0658 9.87026 17.1621 10.8189 17.1621 11.8359C17.1621 12.5917 17.1125 13.3141 17.0123 14.0049C16.9057 14.7222 16.7171 15.4272 16.4498 16.1082C16.1733 16.8201 15.8301 17.4374 15.4162 17.9608C15.0025 18.4842 14.4552 18.9094 13.7735 19.2364C13.0918 19.5634 12.3147 19.7265 11.4413 19.7265ZM13.4608 13.9249C14.0079 13.3785 14.3122 12.6225 14.3017 11.8359C14.3017 11.0232 14.0281 10.3268 13.4817 9.74686C12.9344 9.1669 12.2547 8.87692 11.4413 8.87692C10.628 8.87692 9.94827 9.1669 9.40099 9.74686C8.86507 10.2993 8.56912 11.0534 8.58102 11.8359C8.58102 12.6486 8.85467 13.3449 9.40099 13.9249C9.94827 14.5049 10.628 14.7949 11.4413 14.7949C12.2547 14.7949 12.9278 14.5049 13.4608 13.9249ZM11.4413 23.6718C17.7599 23.6718 22.8828 18.3723 22.8828 11.8359C22.8828 5.29952 17.7599 0 11.4413 0C5.12287 0 0 5.29952 0 11.8359C0 18.3723 5.12287 23.6718 11.4413 23.6718Z"></path> - </svg> - </a> - </div> - <div class="flex flex-grow-1 ma4"> - <div class="fb6"> - <div class="mb2 fw6 f14">Resources</div> - <ul> - <li class="mb2"><a target="_blank" href="https://talk.observablehq.com">Forum</a></li> - <li class="mb2"><a target="_blank" href="https://observablehq.com/slack/join">Slack</a></li> - <li class="mb2"><a target="_blank" href="https://github.com/observablehq/plot/releases">Releases</a></li> - </ul> - </div> - <div class="fb6 flex-grow-1"> - <div class="mb2 fw6 f14">Observable</div> - <ul> - <li class="mb2"><a target="_blank" href="https://observablehq.com/product">Product</a></li> - <li class="mb2"><a target="_blank" href="https://observablehq.com/plot">Plot</a></li> - <li class="mb2"><a target="_blank" href="https://observablehq.com/data-integrations">Integrations</a></li> - <li class="mb2"><a target="_blank" href="https://observablehq.com/pricing">Pricing</a></li> - </ul> - </div> - </div> - </div> - <div class="flex ma4"> - © {{new Date().getUTCFullYear()}} Observable, Inc. - <a class="ml1" target="_blank" href="https://github.com/observablehq/plot/blob/main/LICENSE">ISC License</a> - </div> - </footer> -</template> - -<style> - -.has-sidebar ~ footer { - display: none; -} - -</style> diff --git a/docs/.vitepress/theme/CustomLayout.vue b/docs/.vitepress/theme/CustomLayout.vue deleted file mode 100644 index 8cbd0f98..00000000 --- a/docs/.vitepress/theme/CustomLayout.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script setup> - -import DefaultTheme from "vitepress/theme-without-fonts"; -import ExamplesGrid from "./ExamplesGrid.vue"; -import ObservablePromo from "./ObservablePromo.vue"; -import VersionAndStars from "./VersionAndStars.vue"; - -const {Layout} = DefaultTheme; - -</script> - -<template> - <Layout> - <template #home-features-before> - <ExamplesGrid /> - </template> - <template #home-features-after> - <ObservablePromo /> - </template> - <template #nav-bar-content-after> - <div style="display: flex; gap: 1rem;"> - <VersionAndStars /> - <observable-made-by /> - </div> - </template> - </Layout> -</template> - -<style> - -.VPHome { - overflow-x: hidden; /* iOS */ -} - -.VPHome .VPFeature { - background-color: rgba(246, 246, 247, 0.5); - -webkit-backdrop-filter: blur(10px); /* Safari */ - backdrop-filter: blur(10px); -} - -.dark .VPHome .VPFeature { - background-color: rgba(37, 37, 41, 0.5); -} - -/* Remove unnecessary elements that are empty in our implementation */ -.VPNavBarExtra, -.VPNavBarHamburger { - display: none !important; -} - -/* rounded corners for search field */ -@media (min-width: 768px) { - .DocSearch-Button { - border-radius: 1000px; - padding-right: 0.75rem; - height: 2rem; - } -} - -</style> diff --git a/docs/.vitepress/theme/ExamplesGrid.vue b/docs/.vitepress/theme/ExamplesGrid.vue deleted file mode 100644 index 8074ac9d..00000000 --- a/docs/.vitepress/theme/ExamplesGrid.vue +++ /dev/null @@ -1,132 +0,0 @@ -<script setup> - -import * as d3 from "d3"; -import {ref, shallowRef, onMounted, onUnmounted} from "vue"; -import {data} from "./gallery.data.js"; - -let observer; -let pointerframe; -let clientX; - -const n = 60; // maximum number of examples to show -const slice = d3.shuffler(d3.randomLcg(d3.utcDay()))(data.slice()).slice(0, n); -const sample = shallowRef(slice.slice(0, 55)); // initial guess -const container = ref(); -const xn = ref(10); // number of rows -const yn = ref(5); // number of columns -const x = ref(0.5); // normalized horizontal pointer position - -// Some browsers trigger pointermove more frequently than desirable, so we -// debounce events for a smooth transitions. -function onpointermove(event) { - if (!pointerframe) pointerframe = requestAnimationFrame(afterpointermove); - clientX = event.clientX; -} - -function afterpointermove() { - pointerframe = null; - x.value = clientX / document.body.clientWidth; -} - -onMounted(() => { - observer = new ResizeObserver(() => { - const w = parseFloat(getComputedStyle(container.value).getPropertyValue("--grid-width")); - xn.value = Math.ceil(document.body.clientWidth / w) + 3; // overflow columns - yn.value = Math.min(Math.round(640 / w + 2), Math.floor(n / xn.value)); // 640 is grid height - sample.value = slice.slice(0, yn.value * xn.value); - }); - observer.observe(document.body); - addEventListener("pointermove", onpointermove); -}); - -onUnmounted(() => { - observer.disconnect(); - removeEventListener("pointermove", onpointermove); -}); - -</script> - -<template> - <div :class="$style.examples" ref="container" :style="`transform: translate(${60 - x * 10}vw, 33%);`"> - <div v-for="(d, i) in sample" :style="`--delay: ${((i % xn) / xn + (d3.randomLcg(1 / i)()) - 0.4) * 1}s;`"> - <a :href="`https://observablehq.com/${d.path}`" :title="[d.title, d.author].filter(Boolean).join('\n')" target="_blank" :style="`--x: ${(i % xn) - xn / 2 + (Math.floor(i / xn) % 2) * 0.5}; --y: ${Math.floor(i / xn) - yn / 2};`"> - <img :src="`https://static.observableusercontent.com/thumbnail/${d.thumbnail}.jpg`" width="640" height="400" /> - </a> - </div> - </div> -</template> - -<style module> - -.examples { - position: relative; - height: 640px; - transition: transform 150ms ease-out; - filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); - --grid-width: 140px; -} - -@media (min-width: 640px) { - .examples { - --grid-width: 160px; - } -} - -@media (min-width: 960px) { - .examples { - --grid-width: 200px; - } -} - -/* The drop-shadow should not be affected by the hexagon clip-path. */ -.examples div { - position: relative; - transition: filter 250ms ease-out; - animation: fade-in 350ms cubic-bezier(0.215, 0.610, 0.355, 1.000) var(--delay) backwards; -} - -.examples div:hover { - filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); - z-index: 3; -} - -.examples a { - position: absolute; - --transform: perspective(75em) rotateX(30deg) rotateZ(-7deg) translate(calc(var(--x) * 100%), calc(var(--y) * 86.67%)) scale(1.145); - transform: var(--transform); - animation: drop-in 350ms cubic-bezier(0.215, 0.610, 0.355, 1.000) var(--delay) backwards; - transition: transform 250ms ease-out; - clip-path: polygon(50.0% 100.0%, 93.3% 75.0%, 93.3% 25.0%, 50.0% 0.0%, 6.7% 25.0%, 6.7% 75.0%); -} - -.examples a:hover { - transform: var(--transform) translateZ(10px) scale(1.1); -} - -.examples img { - aspect-ratio: 1; - object-fit: cover; - width: var(--grid-width); -} - -@keyframes fade-in { - from { - filter: blur(20px); - opacity: 0; - } - to { - filter: none; - opacity: 1; - } -} - -@keyframes drop-in { - from { - transform: var(--transform) translateY(-100px) translateZ(400px); - } - to { - transform: var(--transform); - } -} - -</style> diff --git a/docs/.vitepress/theme/ObservablePromo.vue b/docs/.vitepress/theme/ObservablePromo.vue deleted file mode 100644 index 1ca79f42..00000000 --- a/docs/.vitepress/theme/ObservablePromo.vue +++ /dev/null @@ -1,134 +0,0 @@ -<template> - <div :class="`vp-doc ${$style.promo}`"> - <h1 :class="$style.hed">Build your best work with Plot on Observable</h1> - <div :class="$style.subhed">The only data workflow platform capable of supporting the full power of Plot</div> - <div :class="$style.columns"> - <div :class="$style.column"> - <span :class="$style['column-header']">Connect to your data instantly</span> - <span :class="$style['column-body']">Pull live data from the cloud, files, and databases into one secure place — without installing anything, ever.</span> - </div> - <div :class="$style.column"> - <span :class="$style['column-header']">Code faster than you thought possible</span> - <span :class="$style['column-body']">Get everything you need and none of what you don’t with lightweight automatic versioning, instant sharing, and real-time multiplayer editing.</span> - </div> - <div :class="$style.column"> - <span :class="$style['column-header']">Accelerate your team’s analysis</span> - <span :class="$style['column-body']">Create a home for your team’s data analysis where you can spin up charts, maps, and data apps to explore, analyze, and iterate on together.</span> - </div> - </div> - <a :class="$style.button" href="https://observablehq.com" target="_blank">Build with Plot on Observable →</a> - </div> -</template> - -<style scoped> - -h1 { - border-top: none; -} - -</style> - -<style module> - -.promo { - margin: 96px auto 0 auto; - max-width: 1152px; - padding: 0 24px; - text-align: center; -} - -.column { - margin: 2rem 0; -} - -.column-header { - font-weight: 600; -} - -.hed, -.subhed, -.column { - padding: 0 24px; -} - -.hed, -.subhed, -.column-header { - text-wrap: balance; -} - -.subhed { - margin-top: 1rem; - opacity: 0.7; -} - -.promo a.button { - display: inline-block; - border: 1px solid transparent; - text-align: center; - text-decoration: none; - font-weight: 600; - white-space: nowrap; - background-color: var(--vp-c-neutral); - color: var(--vp-c-neutral-inverse); - border-radius: 20px; - padding: 0 20px; - line-height: 38px; - font-size: 14px; - margin: 0 24px; - opacity: 0.9; -} - -a.button:hover { - opacity: 1; - color: var(--vp-c-neutral-inverse); -} - -@media (min-width: 640px) { - .promo { - padding: 0 48px; - } -} - -@media (min-width: 768px) { - .promo { - margin-top: 128px; - } -} - -@media (max-width: 959px) { - .promo { - max-width: 768px; - } - .column-header::after { - content: " · "; - } - .columns { - text-align: initial; - } -} - -@media (min-width: 960px) { - .promo { - padding: 0 64px; - } - .subhed { - margin-top: 0.5rem; - } - .columns { - display: grid; - grid-template-columns: repeat(3, 1fr); - } - .column { - margin: 4rem 0; - } - .column-header { - margin-bottom: 0.5rem; - } - .column-header, - .column-body { - display: block; - } -} - -</style> diff --git a/docs/.vitepress/theme/VersionAndStars.vue b/docs/.vitepress/theme/VersionAndStars.vue deleted file mode 100644 index 0522b556..00000000 --- a/docs/.vitepress/theme/VersionAndStars.vue +++ /dev/null @@ -1,82 +0,0 @@ -<script setup> - -import {formatPrefix} from "d3-format"; -import {data} from "./stargazers.data"; - -const formattedStarCount = data ? formatPrefix(".1s", 1000)(data) : ""; -const version = __APP_VERSION__; - -</script> - -<template> - <a target="_blank" :title="version.concat(' release notes')" href="https://github.com/observablehq/plot/releases"> - <span>{{ version }}</span> - </a> - <a - target="_blank" - data-decoration="★" - :title="data.toLocaleString('en-US').concat(' GitHub stars')" - href="https://github.com/observablehq/plot" - > - <span>GitHub️ {{ formattedStarCount }}</span> - </a> - <a - class="mobile" - target="_blank" - :title="data.toLocaleString('en-US').concat(' GitHub stars')" - href="https://github.com/observablehq/plot" - > - <svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21" fill="none"> - <path - d="M19.625 5.60534C18.7083 4.03477 17.4649 2.79135 15.8945 1.87479C14.3238 0.958185 12.6091 0.5 10.7492 0.5C8.88947 0.5 7.17422 0.958325 5.60388 1.87479C4.0333 2.7913 2.78997 4.03477 1.87332 5.60534C0.956814 7.17587 0.498535 8.89089 0.498535 10.7504C0.498535 12.984 1.15021 14.9926 2.4539 16.7766C3.75744 18.5607 5.44142 19.7952 7.50571 20.4803C7.746 20.5249 7.92388 20.4936 8.03954 20.387C8.15524 20.2804 8.21302 20.1467 8.21302 19.9868C8.21302 19.9601 8.21073 19.7199 8.20629 19.266C8.20171 18.8122 8.19956 18.4162 8.19956 18.0783L7.89256 18.1315C7.69682 18.1673 7.44989 18.1825 7.15178 18.1782C6.8538 18.174 6.54446 18.1428 6.22419 18.0847C5.90377 18.0272 5.60575 17.8937 5.32988 17.6846C5.05416 17.4755 4.85842 17.2018 4.74272 16.8639L4.60925 16.5568C4.52029 16.3523 4.38023 16.1251 4.18888 15.8761C3.99754 15.6269 3.80405 15.458 3.60831 15.369L3.51486 15.3021C3.45259 15.2577 3.39481 15.204 3.34138 15.1418C3.28799 15.0796 3.24802 15.0173 3.22132 14.955C3.19458 14.8926 3.21674 14.8414 3.28804 14.8012C3.35933 14.761 3.48817 14.7416 3.67512 14.7416L3.94196 14.7814C4.11993 14.8171 4.34007 14.9236 4.60266 15.1017C4.86511 15.2796 5.08085 15.5109 5.24994 15.7956C5.4547 16.1605 5.7014 16.4385 5.99072 16.6299C6.27982 16.8212 6.5713 16.9167 6.86488 16.9167C7.15846 16.9167 7.41203 16.8945 7.62567 16.8502C7.83908 16.8057 8.0393 16.7388 8.22625 16.6499C8.30633 16.0535 8.52437 15.5953 8.88017 15.275C8.37304 15.2217 7.9171 15.1414 7.51212 15.0347C7.10736 14.9278 6.6891 14.7544 6.25761 14.5139C5.82589 14.2738 5.46774 13.9756 5.18309 13.6198C4.89839 13.2639 4.66474 12.7966 4.48247 12.2183C4.3001 11.6399 4.20889 10.9726 4.20889 10.2163C4.20889 9.13941 4.56044 8.22304 5.26341 7.46665C4.93411 6.65705 4.96519 5.74947 5.35676 4.744C5.61482 4.66382 5.9975 4.72399 6.50463 4.92412C7.01186 5.12434 7.38323 5.29587 7.61912 5.43808C7.85502 5.58024 8.04402 5.70071 8.18642 5.79842C9.01411 5.56715 9.86825 5.45149 10.7491 5.45149C11.6299 5.45149 12.4843 5.56715 13.312 5.79842L13.8192 5.47823C14.166 5.26459 14.5756 5.06881 15.0469 4.89083C15.5185 4.71295 15.8791 4.66396 16.1284 4.74414C16.5286 5.74966 16.5643 6.65719 16.2349 7.46679C16.9378 8.22318 17.2895 9.13978 17.2895 10.2164C17.2895 10.9727 17.198 11.6421 17.0159 12.225C16.8336 12.808 16.5979 13.2749 16.3088 13.6265C16.0194 13.9781 15.659 14.274 15.2275 14.5141C14.7959 14.7544 14.3775 14.9278 13.9728 15.0347C13.5678 15.1415 13.1119 15.2219 12.6047 15.2752C13.0673 15.6755 13.2986 16.3073 13.2986 17.1704V19.9864C13.2986 20.1464 13.3542 20.2799 13.4656 20.3867C13.5768 20.4932 13.7524 20.5246 13.9927 20.4799C16.0573 19.7949 17.7413 18.5603 19.0448 16.7762C20.3481 14.9922 21 12.9837 21 10.75C20.9996 8.89075 20.541 7.17587 19.625 5.60534Z" - fill="currentColor" - /> - </svg> - </a> -</template> - -<style scoped> - -a { - font-size: 14px; - font-weight: 500; - text-wrap: nowrap; - display: flex; - align-items: center; - gap: 4px; -} - -a[target="_blank"] { - align-items: center; - gap: 0.25rem; - text-decoration: none; -} - -a[target="_blank"]:hover span, -a[target="_blank"][data-decoration]::after { - color: var(--vp-c-brand); - transition: color 0.25s; -} - -a[target="_blank"][data-decoration]::after { - content: attr(data-decoration); -} - -a[target="_blank"]:not(:hover, :focus)::after { - color: inherit; -} - -a.mobile { - display: none; -} - -@media screen and (max-width: 560px) { - a { - display: none; - } - a.mobile { - display: flex; - } -} - -</style> diff --git a/docs/.vitepress/theme/VersionBadge.vue b/docs/.vitepress/theme/VersionBadge.vue deleted file mode 100644 index f58d251a..00000000 --- a/docs/.vitepress/theme/VersionBadge.vue +++ /dev/null @@ -1,12 +0,0 @@ -<script setup> - -const {version, pr} = defineProps(["version", "pr"]); - -</script> -<template> - <Badge v-if="version || pr" :type="version ? `tip` : `warning`"> - <a :href="version ? `https://github.com/observablehq/plot/releases/tag/v${version}` : `https://github.com/observablehq/plot/pull/${pr}`" :title="version ? `added in v${version}` : `added in #${pr}`" target="_blank" rel="external" style="color: inherit;"> - {{ version ? `^${version}` : "prerelease" }} - </a> - </Badge> -</template> diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css deleted file mode 100644 index d17de04e..00000000 --- a/docs/.vitepress/theme/custom.css +++ /dev/null @@ -1,201 +0,0 @@ -:root { - --vp-c-red: #f43f5e; - --vp-c-green: #10b981; - --vp-c-blue: #0092ff; - --vp-c-purple: #a463f2; - --theme-phosphate: #148576; - --theme-phosphate-2: #1da492; - --theme-phosphate-3: #26c1ad; - --theme-phosphate-soft: #d7fbf7; - --vp-c-brand-1: var(--theme-phosphate); /* link and brand color */ - --vp-c-brand-2: var(--theme-phosphate-2); - --vp-c-brand-3: var(--theme-phosphate-3); - --hero-brand-contrast: rgb(243, 139, 233); /* home page alt color */ - --vp-c-brand-soft: var(--theme-phosphate-soft); - --mono-heading: "Spline Sans Mono", monospace; - --vp-font-family-mono: var(--mono-heading); - --vp-font-family-base: Inter, -apple-system, BlinkMacSystemFont, "avenir next", avenir, helvetica, "helvetica neue", ubuntu, roboto, noto, "segoe ui", arial, sans-serif; - --vp-code-color: inherit; - --vp-code-font-size: 14px; - --vp-code-line-height: 1.5; -} - -.dark { - --theme-phosphate: #37d5be; - --theme-phosphate-2: #28b39e; - --theme-phosphate-3: #1b9583; - --hero-brand-contrast: rgb(183, 41, 169); - --theme-phosphate-soft: #033a32; - --vp-c-text-1: #f5f5f5; -} - -.vp-doc h1 { - font-family: var(--mono-heading); - font-weight: 500; -} - -.vp-doc p { - line-height: 1.5; -} - -.vp-doc a { - color: var(--vp-c-text-1); -} - -.vp-doc figcaption { - line-height: 20px; - font-size: 12px; - font-weight: 500; - color: var(--vp-c-text-2); -} - -.VPMenuGroup .title { - line-height: 28px; - font-size: 12px; - font-weight: 500; - color: var(--vp-c-text-2); -} - -.vp-doc .plot, -.vp-doc .plot-ramp { - --plot-background: var(--vp-c-bg); - overflow: visible; - position: relative; - z-index: 1; -} - -.vp-doc .plot [aria-label="tip"] { - --plot-background: var(--vp-c-bg-alt); -} - -.vp-doc .plot-figure { - margin: 16px 0 0; -} - -.vp-doc .plot-figure h2, -.vp-doc .plot-figure h3 { - all: unset; - display: block; -} - -.vp-doc .plot-figure h2 { - line-height: 28px; - font-size: 20px; - font-weight: 600; - letter-spacing: -0.01em; -} - -.vp-doc .plot a { - text-decoration: none; -} - -.vp-doc .plot a:hover { - text-decoration: initial; -} - -.vp-doc :not(pre) > code { - line-height: 1.5; -} - -.label-input { - font-size: smaller; - display: inline-flex; - align-items: center; - margin-right: 1em; -} - -.label-input > label { - display: inline-flex; - align-items: center; -} - -.label-input > input, -.label-input > select { - all: revert; - margin: 0 0.5em !important; - accent-color: var(--vp-c-brand); -} - -.vp-doc td { - white-space: nowrap; -} - -a.plot-fork { - font-size: 12px; - position: absolute; - left: 100%; - margin-left: 8px; - margin-top: 18px; - color: var(--vp-c-text-2); - text-decoration: none; -} - -a.plot-fork::after { - content: " ↗︎"; - opacity: 0.5; -} - -a.plot-fork:hover { - color: var(--vp-c-brand); - text-decoration: none; -} - -@media (max-width: 820px) { - a.plot-fork { - display: none; - } -} - -.vp-doc p:hover .header-anchor { - opacity: 1; -} - -.bg-alt { - background: var(--vp-c-bg-alt); -} - -.lh-normal { - line-height: normal; -} - -.flex { - display: flex; -} - -.flex-grow-1 { - flex-grow: 1; -} - -.ma4 { - margin: 2rem; -} - -.ml1 { - margin-left: 1rem; -} - -.mb2 { - margin-bottom: 0.5rem; -} - -.f12 { - font-size: 12px; -} - -.f14 { - font-size: 14px; -} - -.fw6 { - font-weight: 600; -} - -.fb6 { - flex-basis: 10rem; -} - -@media screen and (min-width: 960px) { - .flex-l { - display: flex; - } -} diff --git a/docs/.vitepress/theme/gallery.data.js b/docs/.vitepress/theme/gallery.data.js deleted file mode 100644 index 7f0b62d0..00000000 --- a/docs/.vitepress/theme/gallery.data.js +++ /dev/null @@ -1,28 +0,0 @@ -import {Runtime} from "@observablehq/runtime"; -import {Library} from "@observablehq/stdlib"; - -async function importUrl(url) { - const response = await fetch(url); - if (!response.ok) throw new Error("unable to fetch"); - const source = await response.text(); - return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`); -} - -export default { - async load() { - const {default: gallery} = await importUrl("https://api.observablehq.com/@observablehq/plot-gallery.js?v=4"); - const runtime = new Runtime(new Library()); - const module = runtime.module(gallery); - const data = []; - module.define("md", () => String.raw); - module.redefine("previews", () => (chunk) => data.push(...chunk)); - const values = []; - for (const output of module._resolve("previews")._outputs) { - if (output._name) { - values.push(module.value(output._name)); - } - } - await Promise.all(values); - return data; - } -}; diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts deleted file mode 100644 index fd9606bd..00000000 --- a/docs/.vitepress/theme/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import DefaultTheme from "vitepress/theme-without-fonts"; -import {useData} from "vitepress"; -import {watch} from "vue"; -import PlotRender from "../../components/PlotRender.js"; -import CustomLayout from "./CustomLayout.vue"; -import VersionBadge from "./VersionBadge.vue"; -import "./custom.css"; - -export default { - extends: DefaultTheme, - Layout: CustomLayout, - enhanceApp({app, router}) { - Object.defineProperty(app.config.globalProperties, "$dark", {get: () => useData().isDark.value}); - app.component("PlotRender", PlotRender); - app.component("VersionBadge", VersionBadge); - enableAnalytics(router); - } -}; - -function enableAnalytics(router) { - if (typeof location === "undefined" || location.origin !== "https://observablehq.com") return; - let pageLoaded = false; - watch(router.route, async () => { - const {pageLoad, routeChanged} = await import("https://events.observablehq.com/client.js"); - if (pageLoaded) { - routeChanged(); - } else { - pageLoad(); - pageLoaded = true; - } - }); -} \ No newline at end of file diff --git a/docs/.vitepress/theme/stargazers.data.ts b/docs/.vitepress/theme/stargazers.data.ts deleted file mode 100644 index fabf38df..00000000 --- a/docs/.vitepress/theme/stargazers.data.ts +++ /dev/null @@ -1,28 +0,0 @@ -const REPO = "observablehq/plot"; - -export default { - async load() { - let stargazers_count; - try { - ({stargazers_count} = await github(`/repos/${REPO}`)); - } catch (error) { - if (process.env.CI) throw error; - stargazers_count = NaN; - } - return stargazers_count; - } -}; - -async function github( - path, - { - authorization = process.env.GITHUB_TOKEN && `token ${process.env.GITHUB_TOKEN}`, - accept = "application/vnd.github.v3+json" - } = {} -) { - const url = new URL(path, "https://api.github.com"); - const headers = {...(authorization && {authorization}), accept}; - const response = await fetch(url, {headers}); - if (!response.ok) throw new Error(`fetch error: ${response.status} ${url}`); - return await response.json(); -} diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index fc5b9149..00000000 --- a/docs/api.md +++ /dev/null @@ -1,34 +0,0 @@ -<script setup> - -import {data} from "./data/api.data"; - -</script> - -# API index - -## Methods - -<ul :class="$style.oneline"> - <li v-for="({name, href, comment}) in data.methods"> - <span><a :href="`${href}#${name}`">{{ name }}</a> - {{ comment }}</span> - </li> -</ul> - -## Options - -<ul> - <li v-for="[name, contexts] in data.options"> - <b>{{ name }}</b> - <span v-for="({name: context, href}, index) in contexts"><a :href="href">{{ context }}</a><span v-if="index < contexts.length - 1">, </span></span> - </li> -</ul> - -<style module> - -ul.oneline span { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -</style> diff --git a/docs/community.md b/docs/community.md deleted file mode 100644 index 6070f286..00000000 --- a/docs/community.md +++ /dev/null @@ -1,44 +0,0 @@ -# Community 🏠 {#community} - -Learning Plot? Love data visualization? Don’t go it alone! Join our community to get help, be inspired, and do the same for others. - -## Staying up-to-date - -:::tip -Please star ⭐️ our [GitHub repo](https://github.com/observablehq/plot) to show your support for us on GitHub! -::: - -Plot is getting better all the time; catch up on [recent releases](https://github.com/observablehq/plot/releases) by reading our [CHANGELOG](https://github.com/observablehq/plot/blob/main/CHANGELOG.md). - -And of course, follow us on [Observable](https://observablehq.com/@observablehq?tab=profile), [Mastodon](https://vis.social/@observablehq), [Twitter](https://twitter.com/observablehq), and [LinkedIn](https://www.linkedin.com/company/observable)! - -## Getting help - -We recommend asking for help on [GitHub discussions](https://github.com/observablehq/plot/discussions). - -We encourage you to share your work, no matter how messy, on [Observable](https://observablehq.com). Sharing live code is the easiest way to let people see what you see, and to debug your problem. Strive for a [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) — it helps people hone in on your problem more quickly. - -When asking for help, don’t just post your code and ask people to fix it. Provide context, and say what you want help with. For example: - -- What are you trying to achieve? What is your goal? -- What other solutions have you tried? -- What behavior are you currently seeing? -- Is the current behavior not what you expect? - -If you think you’ve found a bug in Plot, please file a [GitHub issue](https://github.com/observablehq/plot/issues). But don’t use an issue to ask for help — you’ll have better luck on the forum or Slack. - -## Getting involved - -We’d love for you to join the community! Here are some ways to participate: - -* Share your work on [Observable](https://observablehq.com). Working in public is a great way to help others learn and be inspired. Don’t worry if your code is messy or unfinished; sharing drafts normalizes the challenges that everyone experiences doing data visualization. - -* Upvote 👍 or comment on [GitHub issues](https://github.com/observablehq/plot/issues). We’d love your input on what to build next. If your desired feature isn’t already there, or if you’ve found a bug, file an issue and tell us about it. - -* Answer questions or participate in discussions on [GitHub](https://github.com/observablehq/plot/discussions). You’ll help others, and might learn something yourself, too. - -* Join the [Observable community Slack](https://observablehq.com/slack/join) to meet others using Plot. - -* Open a pull request! Read our [guide to contributing](https://github.com/observablehq/plot/blob/main/CONTRIBUTING.md). - -Please help us maintain a positive environment for all by adhering to our [code of conduct](https://github.com/observablehq/.github/blob/master/CODE_OF_CONDUCT.md). Thank you! diff --git a/docs/components/PlotRender.js b/docs/components/PlotRender.js deleted file mode 100644 index 2a0c0f67..00000000 --- a/docs/components/PlotRender.js +++ /dev/null @@ -1,237 +0,0 @@ -import * as Plot from "@dave-hillier/replot"; -import {h, withDirectives} from "vue"; - -class Event {} - -class Document { - constructor() { - this.documentElement = new Element(this, "html"); - this.defaultView = {Event}; - } - createElementNS(namespace, tagName) { - return new Element(this, tagName); - } - createElement(tagName) { - return new Element(this, tagName); - } - createTextNode(value) { - return new TextNode(this, value); - } - querySelector() { - return null; - } - querySelectorAll() { - return []; - } -} - -class Style { - static empty = new Style(); - setProperty() {} - removeProperty() {} -} - -class Element { - constructor(ownerDocument, tagName) { - this.ownerDocument = ownerDocument; - this.tagName = tagName; - this.attributes = {}; - this.children = []; - this.parentNode = null; - } - setAttribute(name, value) { - this.attributes[name] = String(value); - } - setAttributeNS(namespace, name, value) { - this.setAttribute(name, value); - } - getAttribute(name) { - return this.attributes[name]; - } - getAttributeNS(name) { - return this.getAttribute(name); - } - hasAttribute(name) { - return name in this.attributes; - } - hasAttributeNS(name) { - return this.hasAttribute(name); - } - removeAttribute(name) { - delete this.attributes[name]; - } - removeAttributeNS(namespace, name) { - this.removeAttribute(name); - } - addEventListener() { - // ignored; interaction needs real DOM - } - removeEventListener() { - // ignored; interaction needs real DOM - } - dispatchEvent() { - // ignored; interaction needs real DOM - } - append(...children) { - for (const child of children) { - this.appendChild(child?.ownerDocument ? child : this.ownerDocument.createTextNode(child)); - } - } - appendChild(child) { - this.children.push(child); - child.parentNode = this; - return child; - } - insertBefore(child, after) { - if (after == null) { - this.children.push(child); - } else { - const i = this.children.indexOf(after); - if (i < 0) throw new Error("insertBefore reference node not found"); - this.children.splice(i, 0, child); - } - child.parentNode = this; - return child; - } - cloneNode(deep) { - const clone = new Element(this.ownerDocument, this.tagName); - clone.attributes = {...this.attributes}; - if (deep) clone.children = this.children.map((child) => child.cloneNode(deep)); - return clone; - } - querySelector() { - return null; - } - querySelectorAll() { - return []; - } - set textContent(value) { - this.children = [this.ownerDocument.createTextNode(value)]; - } - set style(value) { - this.attributes.style = value; - } - get style() { - return Style.empty; - } - toHyperScript() { - return h( - this.tagName, - this.attributes, - this.children.map((c) => c.toHyperScript()) - ); - } -} - -class TextNode { - constructor(ownerDocument, nodeValue) { - this.ownerDocument = ownerDocument; - this.nodeValue = String(nodeValue); - } - toHyperScript() { - return this.nodeValue; - } -} - -// Converts the real DOM to virtual DOM (for client-side hydration). -function toHyperScript(node) { - if (node.nodeType === 3) return node.nodeValue; // TextNode - const props = {}; - for (const name of node.getAttributeNames()) props[name] = node.getAttribute(name); - const children = []; - for (let child = node.firstChild; child; child = child.nextSibling) children.push(toHyperScript(child)); - return h(node.tagName, props, children); -} - -export default { - props: { - options: Object, - mark: Object, - defer: Boolean, - method: {type: String, default: "plot"} - }, - render() { - const {method} = this; - const options = { - ...(method === "plot" && { - marks: this.mark == null ? [] : [this.mark], - width: 688 // better default for VitePress - }), - ...this.options, - className: "plot" - }; - if (this.defer) { - const mounted = (el) => { - disconnect(); // remove old listeners - function observed() { - unmounted(el); // remove old plot (and listeners) - el.append(Plot[method](options)); - } - const rect = el.getBoundingClientRect(); - if (rect.bottom > 0 && rect.top < window.innerHeight) { - observed(); - } else { - this._observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) observed(); - }, - {rootMargin: "100px"} - ); - this._observer.observe(el); - if (typeof requestIdleCallback === "function") { - this._idling = requestIdleCallback(observed); - } - } - }; - const unmounted = (el) => { - while (el.lastChild) el.lastChild.remove(); - disconnect(); - }; - const disconnect = () => { - if (this._observer !== undefined) { - this._observer.disconnect(); - this._observer = undefined; - } - if (this._idling !== undefined) { - cancelIdleCallback(this._idling); - this._idling = undefined; - } - }; - const {height = 400} = this.options; - return withDirectives( - h( - "span", - method === "plot" - ? [ - h("div", { - style: { - maxWidth: "100%", - width: `688px`, - aspectRatio: `688 / ${height}` - } - }) - ] - : [] - ), - [ - [ - { - mounted, - updated: mounted, - unmounted - } - ] - ] - ); - } - if (typeof document !== "undefined") { - const plot = Plot[method](options); - const replace = (el) => { - while (el.lastChild) el.lastChild.remove(); - el.append(plot); - }; - return withDirectives(h("span", [toHyperScript(plot)]), [[{mounted: replace, updated: replace}]]); - } - return h("span", [Plot[method]({...options, document: new Document()}).toHyperScript()]); - } -}; diff --git a/docs/features/accessibility.md b/docs/features/accessibility.md deleted file mode 100644 index 59053522..00000000 --- a/docs/features/accessibility.md +++ /dev/null @@ -1,11 +0,0 @@ -# Accessibility - -Plot uses [ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) to make plots more **accessible** through assistive technology such as screen readers, browser add-ons, and browser developer tools. - -The [aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) and [aria-description](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description) attributes on the root SVG element can be set via the top-level **ariaLabel** and **ariaDescription** [plot options](./plots.md). These default to null. - -[Marks](./marks.md) automatically generate an aria-label attribute on the rendered SVG G element; this attribute includes the mark’s type, such as “dot”. The [axis mark](../marks/axis.md) and [grid mark](../marks/grid.md) also include the associated scale’s name, such as “y-axis tick”, “y-axis label”, or “x-grid”. - -Use the **ariaLabel** mark option to apply per-instance aria-label attributes (*e.g.*, on individual dots in a scatterplot), say for a short, human-readable textual representation of each displayed data point. Use the **ariaDescription** mark option for a longer description; this is applied to the mark’s G element. These options both default to null. - -Setting the **ariaHidden** mark option to true hides the mark from the accessibility tree. This is useful for decorative or redundant marks (such as rules or lines between dots). diff --git a/docs/features/curves.md b/docs/features/curves.md deleted file mode 100644 index bb175131..00000000 --- a/docs/features/curves.md +++ /dev/null @@ -1,86 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; - -const curve = ref("catmull-rom"); -const numbers = d3.range(20).map(d3.randomLcg(42)); - -</script> - -# Curves - -A **curve** defines how to turn a discrete representation of a line as a sequence of points [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] into a continuous path; *i.e.*, how to interpolate between points. Curves are used by the [line](../marks/line.md), [area](../marks/area.md), and [link](../marks/link.md) marks, and are implemented by [d3-shape](https://d3js.org/d3-shape/curve). - -<p> - <label class="label-input"> - Curve: - <select v-model="curve"> - <option>basis</option> - <option>basis-open</option> - <option>basis-closed</option> - <option>bump-x</option> - <option>bump-y</option> - <option>bundle</option> - <option>cardinal</option> - <option>cardinal-open</option> - <option>cardinal-closed</option> - <option>catmull-rom</option> - <option>catmull-rom-open</option> - <option>catmull-rom-closed</option> - <option>linear</option> - <option>linear-closed</option> - <option>monotone-x</option> - <option>monotone-y</option> - <option>natural</option> - <option>step</option> - <option>step-after</option> - <option>step-before</option> - </select> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-curve-option -```js-vue -Plot.plot({ - marks: [ - Plot.lineY(numbers, {curve: "{{curve}}"}), - Plot.dotY(numbers, {x: (d, i) => i}) - ] -}) -``` -::: - -The supported curve options are: - -* **curve** - the curve method, either a string or a function -* **tension** - the curve tension (for fine-tuning) - -The following named curve methods are supported: - -* *basis* - a cubic basis spline (repeating the end points) -* *basis-open* - an open cubic basis spline -* *basis-closed* - a closed cubic basis spline -* *bump-x* - a Bézier curve with horizontal tangents -* *bump-y* - a Bézier curve with vertical tangents -* *bundle* - a straightened cubic basis spline (suitable for lines only, not areas) -* *cardinal* - a cubic cardinal spline (with one-sided differences at the ends) -* *cardinal-open* - an open cubic cardinal spline -* *cardinal-closed* - an closed cubic cardinal spline -* *catmull-rom* - a cubic Catmull–Rom spline (with one-sided differences at the ends) -* *catmull-rom-open* - an open cubic Catmull–Rom spline -* *catmull-rom-closed* - a closed cubic Catmull–Rom spline -* *linear* - a piecewise linear curve (*i.e.*, straight line segments) -* *linear-closed* - a closed piecewise linear curve (*i.e.*, straight line segments) -* *monotone-x* - a cubic spline that preserves monotonicity in *x* -* *monotone-y* - a cubic spline that preserves monotonicity in *y* -* *natural* - a natural cubic spline -* *step* - a piecewise constant function where *y* changes at the midpoint of *x* -* *step-after* - a piecewise constant function where *y* changes after *x* -* *step-before* - a piecewise constant function where *x* changes after *y* -* *auto* - like *linear*, but use the (possibly spherical) [projection](./projections.md), if any <VersionBadge version="0.6.1" /> - -If **curve** is a function, it will be invoked with a given *context* in the same fashion as a [D3 curve factory](https://d3js.org/d3-shape/curve#custom-curves). The *auto* curve is only available for the [line mark](../marks/line.md) and [link mark](../marks/link.md) and is typically used in conjunction with a spherical [projection](./projections.md) to interpolate along [geodesics](https://en.wikipedia.org/wiki/Geodesic). - -The tension option only has an effect on bundle, cardinal and Catmull–Rom splines (*bundle*, *cardinal*, *cardinal-open*, *cardinal-closed*, *catmull-rom*, *catmull-rom-open*, and *catmull-rom-closed*). For bundle splines, it corresponds to [beta](https://d3js.org/d3-shape/curve#curveBundle_beta); for cardinal splines, [tension](https://d3js.org/d3-shape/curve#curveCardinal_tension); for Catmull–Rom splines, [alpha](https://d3js.org/d3-shape/curve#curveCatmullRom_alpha). diff --git a/docs/features/facets.md b/docs/features/facets.md deleted file mode 100644 index aec145b8..00000000 --- a/docs/features/facets.md +++ /dev/null @@ -1,308 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import anscombe from "../data/anscombe.ts"; -import barley from "../data/barley.ts"; -import industries from "../data/bls-industry-unemployment.ts"; -import penguins from "../data/penguins.ts"; - -const olympians = shallowRef([ - {weight: 31, height: 1.21, sex: "female"}, - {weight: 170, height: 2.21, sex: "male"} -]); - -const scheme = Plot.scale({color: {type: "categorical"}}).range; - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -</script> - -# Facets - -Faceting partitions data by ordinal or categorical value and then repeats a plot for each partition (each **facet**), producing [small multiples](https://en.wikipedia.org/wiki/Small_multiple) for comparison. Faceting is typically enabled by declaring the horizontal↔︎ facet channel **fx**, the vertical↕︎ facet channel **fy**, or both for two-dimensional faceting. - -For example, below we recreate the Trellis display (“reminiscent of garden trelliswork”) of [Becker *et al.*](https://hci.stanford.edu/courses/cs448b/papers/becker-trellis-jcgs.pdf) using the dot’s **fy** channel to declare vertical↕︎ facets, showing the yields of several varieties of barley across several sites for the years <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">1931</span> and <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">1932</span>. - -:::plot https://observablehq.com/@observablehq/plot-trellis -```js -Plot.plot({ - height: 800, - marginRight: 90, - marginLeft: 110, - grid: true, - x: {nice: true}, - y: {inset: 5}, - color: {type: "categorical"}, - marks: [ - Plot.frame(), - Plot.dot(barley, { - x: "yield", - y: "variety", - fy: "site", - stroke: "year", - sort: {y: "-x", fy: "-x", reduce: "median"} - }) - ] -}) -``` -::: - -:::tip -This plot uses the [**sort** mark option](./scales.md#sort-mark-option) to order the *y* and *fy* scale domains by descending median yield (the associated *x* values). Without this option, the domains would be sorted alphabetically. -::: - -:::tip -Use the [frame mark](../marks/frame.md) for stronger visual separation of facets. -::: - -The chart above reveals a likely data collection error: the years appear to be reversed for the Morris site as it is the only site where the yields in <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">1932</span> were higher than in <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">1931</span>. The anomaly in Morris is more obvious if we use directed arrows to show the year-over-year change. The [group transform](../transforms/group.md) groups the observations by site and variety to compute the change. - -:::plot defer https://observablehq.com/@observablehq/plot-trellis-anomaly -```js -Plot.plot({ - height: 800, - marginLeft: 110, - grid: true, - x: {nice: true}, - y: {inset: 5}, - color: {scheme: "spectral", label: "Change in yield", tickFormat: "+f", legend: true}, - facet: {marginRight: 90}, - marks: [ - Plot.frame(), - Plot.arrow(barley, Plot.groupY({ - x1: "first", - x2: "last", - stroke: ([x1, x2]) => x2 - x1 // year-over-year difference - }, { - x: "yield", - y: "variety", - fy: "site", - stroke: "yield", - strokeWidth: 2, - sort: {y: "-x1", fy: "-x1", reduce: "median"} - })) - ] -}) -``` -::: - -:::info -Here the sort order has changed slightly: the *y* and *fy* domains are sorted by the median **x1** values, which are the yields for 1931. -::: - -Faceting requires ordinal or categorical data because there are a discrete number of facets; the associated *fx* and *fy* scales are [band scales](./scales.md#discrete-scales). Quantitative or temporal data can be made ordinal by binning, say using [Math.floor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/floor). Or, use the [**interval** scale option](./scales.md#scale-transforms) on the *fx* or *fy* scale. Below, we produce a [box plot](../marks/box.md) of the weights (in kilograms) of Olympic athletes, faceted by height binned at a 10cm (0.1 meter) interval. - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-box-plot -```js -Plot.plot({ - fy: { - grid: true, - tickFormat: ".1f", - interval: 0.1, // 10cm - reverse: true - }, - marks: [ - Plot.boxX(olympians.filter((d) => d.height), {x: "weight", fy: "height"}) - ] -}) -``` -::: - -:::tip -If you are interested in automatic faceting for quantitative data, please upvote [#14](https://github.com/observablehq/plot/issues/14). -::: - -When both **fx** and **fy** channels are specified, two-dimensional faceting results, as in the faceted scatterplot of penguin culmen measurements below. The horizontal↔︎ facet shows sex (with the rightmost column representing penguins whose *sex* field is null, indicating missing data), while the vertical↕︎ facet shows species. - -:::plot defer https://observablehq.com/@observablehq/plot-two-dimensional-faceting -```js -Plot.plot({ - grid: true, - marginRight: 60, - facet: {label: null}, - marks: [ - Plot.frame(), - Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fx: "sex", - fy: "species" - }) - ] -}) -``` -::: - -You can mix-and-match faceted and non-faceted marks within the same plot. The non-faceted marks will be repeated across all facets. This is useful for decoration marks, such as a [frame](../marks/frame.md), and also for context: below, the entire population of penguins is repeated in each facet as small gray dots, making it easier to see how each facet compares to the whole. - -:::plot defer https://observablehq.com/@observablehq/plot-non-faceted-marks -```js -Plot.plot({ - grid: true, - marginRight: 60, - facet: {label: null}, - marks: [ - Plot.frame(), - Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fill: "#aaa", - r: 1 - }), - Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fx: "sex", - fy: "species" - }) - ] -}) -``` -::: - -:::tip -Set the [**facet** mark option](#mark-facet-options) to *exclude* to draw only the dots *not* in the current facet. -::: - -When there are many facets, facets may be small and hard to read; you may need to increase the plot’s **width** or **height**. Alternatively, you can wrap facets by computing a row and column number as **fy** and **fx**. Below, small multiples of varying unemployment counts across industries are shown in a three-column display. - -:::plot defer https://observablehq.com/@observablehq/plot-facet-wrap -```js -Plot.plot((() => { - const n = 3; // number of facet columns - const keys = Array.from(d3.union(industries.map((d) => d.industry))); - const index = new Map(keys.map((key, i) => [key, i])); - const fx = (key) => index.get(key) % n; - const fy = (key) => Math.floor(index.get(key) / n); - return { - height: 300, - axis: null, - y: {insetTop: 10}, - fx: {padding: 0.03}, - marks: [ - Plot.areaY(industries, Plot.normalizeY("extent", { - x: "date", - y: "unemployed", - fx: (d) => fx(d.industry), - fy: (d) => fy(d.industry) - })), - Plot.text(keys, {fx, fy, frameAnchor: "top-left", dx: 6, dy: 6}), - Plot.frame() - ] - }; -})()) -``` -::: - -:::tip -If you are interested in automatic facet wrapping, please upvote [#277](https://github.com/observablehq/plot/issues/277). -::: - -:::info -This example uses an [immediately-invoked function expression (IIFE)](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) to declare local variables. -::: - -The above chart also demonstrates faceted annotations, using a [text mark](../marks/text.md) to label the facet in lieu of facet axes. Below, we apply a single text annotation to the *Adelie* facet by setting the **fy** channel to a single-element array parallel to the data. - -:::plot defer https://observablehq.com/@observablehq/plot-annotated-facets -```js -Plot.plot({ - marginLeft: 60, - marginRight: 60, - grid: true, - y: {label: null}, - fy: {label: null}, - color: {legend: true}, - marks: [ - Plot.barX(penguins, Plot.groupY({x: "count"}, {fy: "species", y: "island", fill: "sex"})), - Plot.text([`While Chinstrap and Gentoo penguins were each observed on only one island, Adelie penguins were observed on all three islands.`], { - fy: ["Adelie"], - frameAnchor: "top-right", - lineWidth: 18, - dx: -6, - dy: 6 - }), - Plot.frame() - ] -}) -``` -::: - -## Mark facet options - -Facets can be defined for each mark via the **fx** or **fy** channels. <VersionBadge version="0.6.1" /> The **fx** and **fy** channels are computed prior to the [mark’s transform](./transforms.md), if any (*i.e.*, facet channels are not transformed). Alternatively, the [**facet** plot option](#plot-facet-options) allows top-level faceting based on data. - -Faceting can be explicitly enabled or disabled on a mark with the **facet** option, which accepts the following values: - -* *auto* (default) - automatically determine if this mark should be faceted -* *include* (or true) - draw the subset of the mark’s data in the current facet -* *exclude* - draw the subset of the mark’s data *not* in the current facet -* *super* - draw this mark in a single frame that covers all facets -* null (or false) - repeat this mark’s data across all facets (*i.e.*, no faceting) - -When mark-level faceting is used, the default *auto* setting is equivalent to *include*: the mark will be faceted if either the **fx** or **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. When a mark uses *super* faceting, it is not allowed to use position scales (*x*, *y*, *fx*, or *fy*); *super* faceting is intended for decorations, such as labels and legends. - -The **facetAnchor** option<a id="facetAnchor" href="#facetAnchor" aria-label="Permalink to "facetAnchor""></a> <VersionBadge version="0.6.3" /> controls the placement of the mark with respect to the facets. Based on the value, the mark will be displayed on: - -* null - non-empty facets -* *top*, *right*, *bottom*, or *left* - the given side -* *top-empty*, *right-empty*, *bottom-empty*, or *left-empty* - adjacent empty facet or side -* *empty* - empty facets - -The **facetAnchor** option defaults to null for all marks except axis marks, whose default depends on the axis orientation and associated scale. - -When using top-level faceting, if the mark data is parallel to the facet data (*i.e.*, it has the same length and order), but is not strictly equal (`===`), you can enable faceting by specifying the **facet** option to *include* (or equivalently true). Likewise you can disable faceting by setting the **facet** option to null or false. Finally, the **facet** option supports the _exclude_ option to select all data points that are _not_ part of the current facet, allowing “background” marks for context. - -When top-level faceting is used, the default *auto* setting is equivalent to *include* when the mark data is strictly equal to the top-level facet data; otherwise it is equivalent to null. When the *include* or *exclude* facet mode is chosen, the mark data must be parallel to the top-level facet data: the data must have the same length and order. If the data are not parallel, then the wrong data may be shown in each facet. The default *auto* therefore requires strict equality (`===`) for safety, and using the facet data as mark data is recommended when using the *exclude* facet mode. (To construct parallel data safely, consider using [*array*.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) on the facet data.) - -## Plot facet options - -The **facet** plot option provides addition control over facet position scales and axes: - -* **marginTop** - the top margin -* **marginRight** - the right margin -* **marginBottom** - the bottom margin -* **marginLeft** - the left margin -* **margin** - shorthand for the four margins -* **grid** - if true, draw grid lines for each facet -* **label** - if null, disable default facet axis labels - -The **facet** margin options behave largely the same as the margin [plot options](./plots.md); the only difference is the positioning of the associated scale label for the *x* and *y* scales. Likewise, the **grid** and **label** options have the same meaning as the plot options, except the facet options only apply to the *fx* and *fy* scales. - -The **facet** plot option is also an alternative to the **fx** and **fy** mark options. It is useful when multiple marks share the same data; the **x** and **y** facet channels are then shared by all marks that use the facet data. (Other marks will be repeated across facets.) For example, we can visualize the famous [Anscombe’s quartet](https://en.wikipedia.org/wiki/Anscombe's_quartet) as a scatterplot with horizontal facets. - -:::plot https://observablehq.com/@observablehq/plot-anscombes-quartet -```js -Plot.plot({ - grid: true, - aspectRatio: 0.5, - facet: {data: anscombe, x: "series"}, - marks: [ - Plot.frame(), - Plot.line(anscombe, {x: "x", y: "y"}), - Plot.dot(anscombe, {x: "x", y: "y"}) - ] -}) -``` -::: - -For top-level faceting, these **facet** options determine the facets: - -* **data** - the data to be faceted -* **x** - the horizontal↔︎ position; bound to the *fx* scale -* **y** - the vertical↕︎ position; bound to the *fy* scale - -With top-level faceting, any mark that uses the specified facet data will be faceted by default, whereas marks that use different data will be repeated across all facets. Use the mark **facet** option to change the behavior. - -## Facet scales - -When faceting, two additional [band scales](./scales.md#discrete-scales) may be configured: - -* *fx* - the horizontal↔︎ position, a *band* scale -* *fy* - the vertical↕︎ position, a *band* scale - -You can adjust the space between facets using the **padding**, **round**, and **align** scale options. diff --git a/docs/features/formats.md b/docs/features/formats.md deleted file mode 100644 index c0441ad0..00000000 --- a/docs/features/formats.md +++ /dev/null @@ -1,54 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; - -</script> - -# Formats - -These helper functions are provided for convenience as a **tickFormat** option for the [axis mark](../marks/axis.md), as the **text** option for a [text mark](../marks/text.md), or other use. See also [d3-format](https://d3js.org/d3-format), [d3-time-format](https://d3js.org/d3-time-format), and JavaScript’s built-in [date formatting](https://observablehq.com/@mbostock/date-formatting) and [number formatting](https://observablehq.com/@mbostock/number-formatting). - -## formatNumber(*locale*) <VersionBadge version="0.6.15" pr="2078" /> {#formatNumber} - -```js -Plot.formatNumber("en-US")(Math.PI) // "3.142" -``` - -Returns a function that formats a given number according to the specified *locale*. The *locale* is a [BCP 47 language tag](https://tools.ietf.org/html/bcp47) and defaults to U.S. English. - -## formatIsoDate(*date*) {#formatIsoDate} - -```js -Plot.formatIsoDate(new Date("2020-01-01T00:00:00.000Z")) // "2020-01-01" -``` - -Given a *date*, returns the shortest equivalent ISO 8601 UTC string. If the given *date* is not valid, returns `"Invalid Date"`. See [isoformat](https://github.com/mbostock/isoformat). - -## formatWeekday(*locale*, *format*) {#formatWeekday} - -:::plot https://observablehq.com/@observablehq/plot-format-helpers -```js -Plot.textX(d3.range(7)).plot({x: {tickFormat: Plot.formatWeekday()}}) -``` -::: - -```js -Plot.formatWeekday("es-MX", "long")(0) // "domingo" -``` - -Returns a function that formats a given week day number (from 0 = Sunday to 6 = Saturday) according to the specified *locale* and *format*. The *locale* is a [BCP 47 language tag](https://tools.ietf.org/html/bcp47) and defaults to U.S. English. The *format* is a [weekday format](https://tc39.es/ecma402/#datetimeformat-objects): either *narrow*, *short*, or *long*; if not specified, it defaults to *short*. - -## formatMonth(*locale*, *format*) {#formatMonth} - -:::plot https://observablehq.com/@observablehq/plot-format-helpers -```js -Plot.textX(d3.range(12)).plot({x: {tickFormat: Plot.formatMonth(), ticks: 12}}) -``` -::: - -```js -Plot.formatMonth("es-MX", "long")(0) // "enero" -``` - -Returns a function that formats a given month number (from 0 = January to 11 = December) according to the specified *locale* and *format*. The *locale* is a [BCP 47 language tag](https://tools.ietf.org/html/bcp47) and defaults to U.S. English. The *format* is a [month format](https://tc39.es/ecma402/#datetimeformat-objects): either *2-digit*, *numeric*, *narrow*, *short*, *long*; if not specified, it defaults to *short*. diff --git a/docs/features/interactions.md b/docs/features/interactions.md deleted file mode 100644 index d9890ce4..00000000 --- a/docs/features/interactions.md +++ /dev/null @@ -1,75 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; - -const olympians = shallowRef([ - {weight: 31, height: 1.21, sex: "female"}, - {weight: 170, height: 2.21, sex: "male"} -]); - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -</script> - -# Interactions - -Interaction allows reading values out of a plot (details on demand), or fluidly changing a view of data without editing code (zoom and filter). There are a variety of ways to achieve interaction with Plot, including built-in interaction features and development techniques with frameworks such as Observable and React. - -## Pointing - -When looking at a scatterplot, the reader may wonder, *what abstract values does this dot represent?* - -The [pointer transform](../interactions/pointer.md) can provide an answer: it dynamically [filters](../transforms/filter.md) a mark such that only the data closest to the pointer (such as the mouse) is rendered. The pointer transform is often paired with the [tip mark](../marks/tip.md) for interactive tooltips, revealing exact values as the pointer moves over the plot. The tip can show additional fields not otherwise visible, such as the *name* and *sport* of Olympic athletes below. - -:::plot defer https://observablehq.com/@observablehq/scatterplot-with-interactive-tips -```js -Plot.dot(olympians, { - x: "weight", - y: "height", - stroke: "sex", - channels: {name: "name", sport: "sport"}, - tip: true -}).plot() -``` -::: - -The [crosshair mark](../interactions/crosshair.md) uses the pointer transform internally to display a [rule](../marks/rule.md) and a [text](../marks/text.md) showing the **x** (horizontal↔︎ position) and **y** (vertical↕︎ position) value of the nearest data. - -:::plot defer https://observablehq.com/@observablehq/plot-crosshair -```js -Plot.plot({ - marks: [ - Plot.dot(olympians, {x: "weight", y: "height", stroke: "sex"}), - Plot.crosshair(olympians, {x: "weight", y: "height"}) - ] -}) -``` -::: - -These values are displayed atop the axes on the edge of the frame; unlike the tip mark, the crosshair mark will not obscure other marks in the plot. - -## Selecting - -Support for selecting points within a plot through direct manipulation is under development. If you are interested in this feature, please upvote [#5](https://github.com/observablehq/plot/issues/5). See [#721](https://github.com/observablehq/plot/pull/721) for some early work on brushing. - -## Zooming - -Support for interactive panning and zooming is planned for a future release. If you are interested in this feature, please upvote [#1590](https://github.com/observablehq/plot/issues/1590). - -## Animation - -Support for declarative animation is planned for a future release. If you are interested in this feature, please upvote [#166](https://github.com/observablehq/plot/issues/166). See [#995](https://github.com/observablehq/plot/pull/995) for some early work on a **time** channel. - -## Custom reactivity - -With the exception of render transforms (see the [pointer transform](https://github.com/observablehq/plot/blob/main/src/interactions/pointer.js) implementation), Plot does not currently provide incremental re-rendering (partial updates to previously-rendered plots) or animated transitions between views. - -That said, you can simply throw away an old plot and replace it with a new one! This allows plotting of dynamic data: data which can change in real-time as it streams in, or because it is derived in response to external inputs such as range sliders and search boxes. - -On Observable, you can use [viewof](https://observablehq.com/@observablehq/views) in conjunction with [Observable Inputs](https://observablehq.com/@observablehq/inputs) (or other plots!) for interactivity. If your cell references another cell, it will automatically re-run whenever the upstream cell's value changes. For example, try dragging the slider in this [hexbin example](https://observablehq.com/@observablehq/plot-hexbin-binwidth). In React, Plot's [component API](../getting-started.md#plot-in-react) makes reactivity automatic — when your data or props change, the plot re-renders as part of the normal React rendering cycle, with no refs or effects needed. In Vue, use [ref](https://vuejs.org/api/reactivity-core.html#ref). For more, see our [getting started guide](../getting-started.md). - -You can also manipulate the SVG that Plot creates, if you are comfortable using lower-level APIs; see examples by [Mike Freeman](https://observablehq.com/@mkfreeman/plot-animation) and [Philippe Rivière](https://observablehq.com/@fil/plot-animate-a-bar-chart). diff --git a/docs/features/intervals.md b/docs/features/intervals.md deleted file mode 100644 index 6b74c0b3..00000000 --- a/docs/features/intervals.md +++ /dev/null @@ -1,61 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; - -</script> - -# Intervals <VersionBadge version="0.6.15" pr="2075" /> - -Plot provides several built-in interval implementations for use with the **tick** option for [scales](./scales.md), as the **thresholds** option for a [bin transform](../transforms/bin.md), or other use. See also [d3-time](https://d3js.org/d3-time). You can also implement custom intervals. - -At a minimum, intervals implement *interval*.**floor** and *interval*.**offset**. Range intervals additionally implement *interval*.**range**, and nice intervals additionally implement *interval*.**ceil**. These latter implementations are required in some contexts; see Plot’s TypeScript definitions for details. - -The *interval*.**floor** method takes a *value* and returns the corresponding value representing the greatest interval boundary less than or equal to the specified *value*. For example, for the “day” time interval, it returns the preceding midnight: - -```js -Plot.utcInterval("day").floor(new Date("2013-04-12T12:34:56Z")) // 2013-04-12 -``` - -The *interval*.**offset** method takes a *value* and returns the corresponding value equal to *value* plus *step* intervals. If *step* is not specified it defaults to 1. If *step* is negative, then the returned value will be less than the specified *value*. For example: - -```js -Plot.utcInterval("day").offset(new Date("2013-04-12T12:34:56Z"), 1) // 2013-04-13T12:34:56Z -Plot.utcInterval("day").offset(new Date("2013-04-12T12:34:56Z"), -2) // 2013-04-10T12:34:56Z -``` - -The *interval*.**range** method returns an array of values representing every interval boundary greater than or equal to *start* (inclusive) and less than *stop* (exclusive). The first value in the returned array is the least boundary greater than or equal to *start*; subsequent values are offset by intervals and floored. - -```js -Plot.utcInterval("week").range(new Date("2013-04-12T12:34:56Z"), new Date("2013-05-12T12:34:56Z")) // [2013-04-14, 2013-04-21, 2013-04-28, 2013-05-05, 2013-05-12] -``` - -The *interval*.**ceil** method returns the value representing the least interval boundary value greater than or equal to the specified *value*. For example, for the “day” time interval, it returns the preceding midnight: - -```js -Plot.utcInterval("day").ceil(new Date("2013-04-12T12:34:56Z")) // 2013-04-13 -``` - -## numberInterval(*period*) {#numberInterval} - -```js -Plot.numberInterval(2) -``` - -Given a number *period*, returns a corresponding range interval implementation. If *period* is a negative number, the resulting interval uses 1 / -*period*; this allows more precise results when *period* is a negative integer. The returned interval implements the *interval*.range, *interval*.floor, and *interval*.offset methods. - -## timeInterval(*period*) {#timeInterval} - -```js -Plot.timeInterval("2 days") -``` - -Given a string *period* describing a local time interval, returns a corresponding nice interval implementation. The period can be *second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, or *sunday*, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. The returned interval implements the *interval*.range, *interval*.floor, *interval*.ceil, and *interval*.offset methods. - -## utcInterval(*period*) {#utcInterval} - -```js -Plot.utcInterval("2 days") -``` - -Given a string *period* describing a UTC time interval, returns a corresponding nice interval implementation. The period can be *second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, or *sunday*, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. The returned interval implements the *interval*.range, *interval*.floor, *interval*.ceil, and *interval*.offset methods. diff --git a/docs/features/legends.md b/docs/features/legends.md deleted file mode 100644 index 180a7436..00000000 --- a/docs/features/legends.md +++ /dev/null @@ -1,141 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; - -const penguins = shallowRef([]); - -const olympians = shallowRef([ - {weight: 31, height: 1.21, sex: "female"}, - {weight: 170, height: 2.21, sex: "male"} -]); - -const gistemp = shallowRef([ - {Date: new Date("1880-01-01"), Anomaly: -0.78}, - {Date: new Date("2016-12-01"), Anomaly: 1.35} -]); - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); - d3.csv("../data/gistemp.csv", d3.autoType).then((data) => (gistemp.value = data)); - d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data)); -}); - -</script> - -# Legends <VersionBadge version="0.3.0" /> - -Plot can generate **legends** for *color*, *opacity*, and *symbol* [scales](./scales.md). For example, the scatterplot below of body measurements of Olympic athletes includes a legend for its *color* scale, allowing the meaning of color to be interpreted by the reader. (The axes similarly document the meaning of the *x* and *y* position scales.) - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-scatterplot -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.dot(olympians, {x: "weight", y: "height", stroke: "sex"}) - ] -}) -``` -::: - -The legend above is a *swatches* legend because the *color* scale is *ordinal* (with a *categorical* scheme). When the *color* scale is continuous, a *ramp* legend with a smooth gradient is generated instead. The plot below of global average surface temperature ([GISTEMP](https://data.giss.nasa.gov/gistemp/)) uses a *diverging* *color* scale to indicate the deviation from the 1951–1980 average in degrees Celsius. - -:::plot defer https://observablehq.com/@observablehq/plot-diverging-color-scatterplot -```js -Plot.plot({ - color: { - scheme: "BuRd", - legend: true - }, - marks: [ - Plot.ruleY([0]), - Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}) - ] -}) -``` -::: - -When an ordinal *color* scale is used redundantly with a *symbol* scale, the *symbol* legend will incorporate the color encoding. This is more accessible than using color alone, particularly for readers with color vision deficiency. - -:::plot defer https://observablehq.com/@observablehq/plot-symbol-channel -```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 does not yet generate legends for the *r* (radius) scale or the *length* scale. If you are interested in this feature, please upvote [#236](https://github.com/observablehq/plot/issues/236). In the meantime, you can implement a legend using marks as demonstrated in the [spike map](https://observablehq.com/@observablehq/plot-spike) example. - -## Legend options - -If the **legend** [scale option](./scales.md#scale-options) is true, the default legend will be produced for the scale; otherwise, the meaning of the **legend** option depends on the scale: for quantitative color scales, it defaults to *ramp* but may be set to *swatches* for a discrete scale (most commonly for *threshold* color scales); for *ordinal* *color* scales and *symbol* scales, only the *swatches* value is supported. If the **legend* scale option is undefined, it will be inherited from the top-level **legend** plot option. <VersionBadge pr="2249" /> - -<!-- TODO Describe the color and opacity options. --> - -Categorical and ordinal color legends are rendered as swatches, unless the **legend** option is set to *ramp*. The swatches can be configured with the following options: - -* **tickFormat** - a format function for the labels -* **swatchSize** - the size of the swatch (if square) -* **swatchWidth** - the swatches’ width -* **swatchHeight** - the swatches’ height -* **columns** - the number of swatches per row -* **marginLeft** - the legend’s left margin -* **className** - a class name, that defaults to a randomly generated string scoping the styles -* **opacity** - the swatch fill opacity <VersionBadge version="0.6.5" /> -* **width** - the legend’s width (in pixels) - -Symbol legends are rendered as swatches and support the options above in addition to the following options: - -* **fill** - the symbol fill color -* **fillOpacity** - the symbol fill opacity; defaults to 1 -* **stroke** - the symbol stroke color -* **strokeOpacity** - the symbol stroke opacity; defaults to 1 -* **strokeWidth** - the symbol stroke width; defaults to 1.5 -* **r** - the symbol radius; defaults to 4.5 pixels - -The **fill** and **stroke** symbol legend options can be specified as “color” to apply the color scale when the symbol scale is a redundant encoding. The **fill** defaults to none. The **stroke** defaults to currentColor if the fill is none, and to none otherwise. The **fill** and **stroke** options may also be inherited from the corresponding options on an associated dot mark. - -Continuous color legends are rendered as a ramp, and can be configured with the following options: - -* **label** - the scale’s label -* **ticks** - the desired number of ticks, or an array of tick values -* **tickFormat** - a format function for the legend’s ticks -* **tickSize** - the tick size -* **round** - if true (default), round tick positions to pixels -* **width** - the legend’s width -* **height** - the legend’s height -* **marginTop** - the legend’s top margin -* **marginRight** - the legend’s right margin -* **marginBottom** - the legend’s bottom margin -* **marginLeft** - the legend’s left margin -* **opacity** - the ramp’s fill opacity - -The **style** legend option allows custom styles to override Plot’s defaults; it has the same behavior as in Plot’s top-level [plot options](./plots.md). The **className** option is suffixed with *-ramp* or *-swatches*, reflecting the **legend** type. - -## legend(*options*) {#legend} - -Renders a standalone legend for the scale defined by the given *options* object, returning a SVG or HTML figure element. This element can then be inserted into the page as described in the [getting started guide](../getting-started.md). The *options* object must define at least one scale; see [scale options](./scales.md) for how to define a scale. - -For example, here is a *ramp* legend of a *linear* *color* scale with the default domain of [0, 1] and default scheme *turbo*: - -<PlotRender :options='{color: {type: "linear"}}' defer method="legend" /> - -```js -Plot.legend({color: {type: "linear"}}) -``` - -The *options* object may also include any additional legend options described in the previous section. For example, to make the above legend slightly wider: - -<PlotRender :options='{width: 320, color: {type: "linear"}}' defer method="legend" /> - -```js -Plot.legend({width: 320, color: {type: "linear"}}) -``` diff --git a/docs/features/markers.md b/docs/features/markers.md deleted file mode 100644 index df4e11ad..00000000 --- a/docs/features/markers.md +++ /dev/null @@ -1,67 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import crimea from "../data/crimea.ts"; - -const marker = ref("circle"); - -</script> - -# Markers - -A **marker** defines a graphic drawn on vertices of a [line](../marks/line.md) or [link](../marks/link.md) mark. - -<p> - <label class="label-input"> - Marker: - <select v-model="marker"> - <option>none</option> - <option>arrow</option> - <option>arrow-reverse</option> - <option>dot</option> - <option>circle</option> - <option>circle-stroke</option> - <option>tick</option> - <option>tick-x</option> - <option>tick-y</option> - </select> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-line-chart-with-markers -```js-vue -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.lineY(crimea, {x: "date", y: "deaths", stroke: "cause", marker: "{{marker}}"}) - ] -}) -``` -::: - -The supported marker options are: - -* **markerStart** - the marker for the starting point of a line segment -* **markerMid** - the marker for any intermediate point of a line segment -* **markerEnd** - the marker for the end point of a line segment -* **marker** - shorthand for setting the marker on all points - -The following named markers are supported: - -* *none* (default) - no marker -* *arrow* - an arrowhead with *auto* orientation -* *arrow-reverse* - an arrowhead with *auto-start-reverse* orientation -* *dot* - a filled *circle* without a stroke and 2.5px radius -* *circle*, equivalent to *circle-fill* - a filled circle with a white stroke and 3px radius -* *circle-stroke* - a hollow circle with a colored stroke and a white fill and 3px radius -* *tick* <VersionBadge version="0.6.12" pr="1872" /> - a small opposing line -* *tick-x* <VersionBadge version="0.6.12" pr="1872" /> - a small horizontal line -* *tick-y* <VersionBadge version="0.6.12" pr="1872" /> - a small vertical line - -If **marker** is true, it defaults to *circle*. If **marker** is a function, it will be called with a given *color* and must return an [SVG marker element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/marker). - -The primary color of a marker is inherited from the *stroke* of the associated mark. The *arrow* marker is [automatically oriented](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/orient) such that it points in the tangential direction of the path at the position the marker is placed. The *circle* markers are centered around the given vertex. - -For lines whose [curve](./curves.md) is not *linear*, markers are not necessarily drawn at the data positions given by **x** and **y**; marker placement is determined by the (possibly Bézier) path segments generated by the curve. To ensure that symbols are drawn at a given **x** and **y** position, consider using a [dot mark](../marks/dot.md) instead. diff --git a/docs/features/marks.md b/docs/features/marks.md deleted file mode 100644 index 47e4c241..00000000 --- a/docs/features/marks.md +++ /dev/null @@ -1,594 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as htl from "htl"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; -import gistemp from "../data/gistemp.ts"; - -const sales = [ - {units: 10, fruit: "peach"}, - {units: 20, fruit: "pear"}, - {units: 40, fruit: "plum"}, - {units: 30, fruit: "plum"} -]; - -const linedata = [ - {hour: 0, value: 8, sensor: "A"}, - {hour: 0, value: 6, sensor: "B"}, - {hour: 1, value: 7, sensor: "A"}, - {hour: 1, value: 5, sensor: "B"}, - {hour: 2, value: 3, sensor: "A"}, - {hour: 2, value: 0, sensor: "B"}, - {hour: 3, value: 9, sensor: "A"}, - {hour: 3, value: 2, sensor: "B"} -]; - -const timeseries = [ - {year: 2014, population: 7295.290765}, - {year: 2015, population: 7379.797139}, - {year: 2016, population: 7464.022049}, - {year: 2017, population: 7547.858925}, - // {year: 2018, population: 7631.091040}, - {year: 2019, population: 7713.468100}, - {year: 2020, population: 7794.798739} -]; - -const area = ref(true); -const aapl = shallowRef([]); -const goog = shallowRef([]); -const bls = shallowRef([]); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/goog.csv", d3.autoType).then((data) => (goog.value = data)); - d3.csv("../data/bls-metro-unemployment.csv", d3.autoType).then((data) => (bls.value = data)); -}); - -</script> - -# Marks {#Marks} - -:::tip -If you aren’t yet up and running with Plot, please read our [getting started guide](../getting-started.md) first. Tinkering with the code below will give a better sense of how Plot works. -::: - -Plot doesn't have chart types; instead, you construct charts by layering **marks**. - -:::tip -Every mark is available both as an imperative function (e.g., `Plot.dot(data, options)`) and as a [React component](../getting-started.md#plot-in-react) (e.g., `<Dot data={data} x="x" y="y" />`). The examples on this page use the imperative API, but all the same options and behaviors apply to the React components. -::: - -## Marks are geometric shapes - -Plot provides a variety of mark types. Think of marks as the “visual vocabulary” — the painter’s palette 🎨, but of shapes instead of colors — that you pull from when composing a chart. Each mark type produces a certain type of geometric shape. - -For example, the [dot mark](../marks/dot.md) draws stroked circles (by default). - -:::plot https://observablehq.com/@observablehq/plot-temporal-scatterplot -```js -Plot.dot(gistemp, {x: "Date", y: "Anomaly"}).plot() -``` -::: - -The [line mark](../marks/line.md) draws connected line segments (also known as a *polyline* or *polygonal chain*). - -:::plot https://observablehq.com/@observablehq/plot-temporal-line-chart -```js -Plot.lineY(gistemp, {x: "Date", y: "Anomaly"}).plot() -``` -::: - -And the [bar mark](../marks/bar.md) draws rectangular bars in either a horizontal (barX→) or vertical (barY↑) orientation. - -:::plot https://observablehq.com/@observablehq/plot-alphabet-bar-chart -```js -Plot.barX(alphabet, {x: "frequency", y: "letter"}).plot() -``` -::: - -So instead of looking for a chart type, consider the shape of the primary graphical elements in your chart, and look for the corresponding mark type. If a chart has only a single mark, the mark type *is* effectively the chart type: the bar mark is used to make a bar chart, the area mark is used to make an area chart, and so on. - -## Marks are layered - -The big advantage of mark types over chart types is that you can compose multiple marks of different types into a single [plot](./plots.md). For example, below an [area](../marks/area.md) and [line](../marks/line.md) are used to plot the same sequence of values, while a [rule](../marks/rule.md) emphasizes *y* = 0. - -:::plot defer https://observablehq.com/@observablehq/plot-layered-marks-2 -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.2}), - Plot.lineY(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -Each mark supplies its own data; a quick way to combine multiple datasets into a chart is to declare a separate mark for each. You can even use [*array*.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) to create multiple marks from nested data. - -:::plot defer https://observablehq.com/@observablehq/plot-layered-marks-2 -```js -Plot.plot({ - marks: [ - [goog, aapl].map((stock) => Plot.lineY(stock, {x: "Date", y: "Close"})) - ] -}) -``` -::: - -Marks may also be a function which returns an [SVG element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element), if you wish to insert arbitrary content. (Here we use [Hypertext Literal](https://github.com/observablehq/htl) to generate an SVG gradient.) - -:::plot defer https://observablehq.com/@observablehq/plot-gradient-bars -```js -Plot.plot({ - marks: [ - () => htl.svg`<defs> - <linearGradient id="gradient" gradientTransform="rotate(90)"> - <stop offset="15%" stop-color="purple" /> - <stop offset="75%" stop-color="red" /> - <stop offset="100%" stop-color="gold" /> - </linearGradient> - </defs>`, - Plot.barY(alphabet, {x: "letter", y: "frequency", fill: "url(#gradient)"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -And marks may be null or undefined, which produce no output; this is useful for showing marks conditionally (*e.g.*, when a box is checked). - -<p> - <label class="label-input"> - Show area: - <input type="checkbox" v-model="area"> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-optional-marks -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - area ? Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.2}) : null, - Plot.lineY(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -## Marks use scales - -Marks are (typically) not positioned in literal pixels, or colored in literal colors, as in a conventional graphics system. Instead you provide abstract values such as time and temperature — marks are drawn “in data space” — and [scales](./scales.md) encode these into visual values such as position and color. And best of all, Plot automatically creates [axes](../marks/axis.md) and [legends](./legends.md) to document the scales’ encodings. - -Data is passed through scales automatically during rendering; the mark controls which scales are used. The **x** and **y** options are typically bound to the *x* and *y* scales, respectively, while the **fill** and **stroke** options are typically bound to the *color* scale. Changing a scale’s definition, say by overriding its **domain** (the extent of abstract input values) or **type**, affects the appearance of all marks that use the scale. - -:::plot defer https://observablehq.com/@observablehq/plot-aapl-log-scale -```js {2-6} -Plot.plot({ - y: { - type: "log", - domain: [30, 300], - grid: true - }, - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -## Marks have tidy data - -A single mark can draw multiple shapes. A mark generally produces a shape — such as a rectangle or circle — for each element in the data. - -:::plot defer https://observablehq.com/@observablehq/plot-tidy-data -```js -Plot.dot(aapl, {x: "Date", y: "Close"}).plot() -``` -::: - -It’s more complicated than that, though, since some marks produce shapes that incorporate *multiple* data points. Pass the same data to a [line](../marks/line.md) and you’ll get a single polyline. - -:::plot defer https://observablehq.com/@observablehq/plot-tidy-data -```js -Plot.lineY(aapl, {x: "Date", y: "Close"}).plot() -``` -::: - -And a line mark isn’t even guaranteed to produce a single polyline — there can be multiple polylines, as in a line chart with multiple series (using **z**). - -:::plot defer https://observablehq.com/@observablehq/plot-multiple-series-line-chart -```js -Plot.lineY(bls, {x: "date", y: "unemployment", z: "division"}).plot() -``` -::: - -Plot favors [tidy data](http://vita.had.co.nz/papers/tidy-data.html) structured as an array of objects, where each object represents an observation (a row), and each object property represents an observed value; all objects in the array should have the same property names (the columns). - -For example, say we have hourly readings from two sensors *A* and *B*. You can represent the sensor log as an array of objects like so: - -```js -linedata = [ - {hour: 0, value: 8, sensor: "A"}, - {hour: 0, value: 6, sensor: "B"}, - {hour: 1, value: 7, sensor: "A"}, - {hour: 1, value: 5, sensor: "B"}, - {hour: 2, value: 3, sensor: "A"}, - {hour: 2, value: 0, sensor: "B"}, - {hour: 3, value: 9, sensor: "A"}, - {hour: 3, value: 2, sensor: "B"} -] -``` - -:::tip -For larger datasets, you can more efficiently pass data using an [Apache Arrow](https://arrow.apache.org/docs/js/) table as a columnar data representation. <VersionBadge version="0.6.16" pr="2115" /> -::: - -Then you can pass the data to the line mark, and extract named columns from the data for the desired options: - -:::plot https://observablehq.com/@observablehq/plot-accessors -```js -Plot.lineY(linedata, {x: "hour", y: "value", stroke: "sensor"}).plot() -``` -::: - -Another common way to extract a column from tabular data is an accessor function. This function is invoked for each element in the data (each row), and returns the corresponding observed value, as with [*array*.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). - -:::plot https://observablehq.com/@observablehq/plot-accessors -```js -Plot.lineY(linedata, { - x: (d) => d.hour, - y: (d) => d.value, - stroke: (d) => d.sensor -}).plot() -``` -::: - -For greater efficiency, Plot also supports columnar data: you can use an [Apache Arrow](https://arrow.apache.org/docs/js/) table as data instead of an array of objects. <VersionBadge version="0.6.16" pr="2115" /> You can even pass parallel arrays of values, or Apache Arrow vectors, to each channel. - -```js -Plot.lineY({length: linedata.length}, { - x: linedata.map((d) => d.hour), - y: linedata.map((d) => d.value), - stroke: linedata.map((d) => d.sensor) -}).plot() -``` - -:::tip -Note that when accessor functions or parallel arrays are used instead of field names, automatic axis labels (*hour* and *value*) are lost. These can be restored using the **label** option on the *x* and *y* scales. -::: - -## Marks imply data types - -Data comes in different types: quantitative (or temporal) values can be subtracted, ordinal values can be ordered, and nominal (or categorical) values can only be the same or different. - -:::info -Because nominal values often need some arbitrary order for display purposes — often alphabetical — Plot uses the term *ordinal* to refer to both ordinal and nominal data. -::: - -Some marks work with any type of data, while other marks have certain requirements or assumptions of data. For example, a line should only be used when both *x* and *y* are quantitative or temporal, and when the data is in a meaningful order (such as chronological). This is because the line mark will interpolate between adjacent points to draw line segments. If *x* or *y* is nominal — say the names of countries — it doesn’t make sense to use a line because there is no half-way point between two nominal values. - -:::plot https://observablehq.com/@observablehq/plot-dont-do-this -```js -Plot.lineY(["please", "don’t", "do", "this"]).plot() // 🌶️ -``` -::: - -:::warning -While Plot aspires to give good defaults and helpful warnings, Plot won’t prevent you from creating a meaningless chart. *Only you* can prevent bogus charts! -::: - -In particular, beware the simple “bar”! A bar mark is used for a bar chart, but a rect mark is needed for a histogram. Plot has four different mark types for drawing rectangles: - -- use [rect](../marks/rect.md) when both *x* and *y* are quantitative -- use [barX](../marks/bar.md) when *x* is quantitative and *y* is ordinal -- use [barY](../marks/bar.md) when *x* is ordinal and *y* is quantitative -- use [cell](../marks/cell.md) when both *x* and *y* are ordinal - -Plot encourages you to think about data types as you visualize because data types often imply semantics. For example, do you notice anything strange about the bar chart below? - -:::plot https://observablehq.com/@observablehq/plot-the-missing-bar -```js -Plot - .barY(timeseries, {x: "year", y: "population"}) // 🌶️ - .plot({x: {tickFormat: ""}}) -``` -::: - -Here’s the underlying data: - -```js -timeseries = [ - {year: 2014, population: 7295.290765}, - {year: 2015, population: 7379.797139}, - {year: 2016, population: 7464.022049}, - {year: 2017, population: 7547.858925}, - {year: 2019, population: 7713.468100}, - {year: 2020, population: 7794.798739} -] -``` - -The data is missing the population for the year 2018! Because the barY mark implies an ordinal *x* scale, the gap is hidden. Switching to the rectY mark (with the **interval** option to indicate that these are annual observations) reveals the missing data. - -:::plot https://observablehq.com/@observablehq/plot-the-missing-bar -```js -Plot - .rectY(timeseries, {x: "year", y: "population", interval: 1}) - .plot({x: {tickFormat: ""}}) -``` -::: - -Alternatively, you can keep the barY mark and apply the **interval** option to the *x* scale. - -:::plot https://observablehq.com/@observablehq/plot-the-missing-bar -```js -Plot - .barY(timeseries, {x: "year", y: "population"}) - .plot({x: {tickFormat: "", interval: 1}}) -``` -::: - -## Marks have options - -When constructing a mark, you can specify options to change the mark’s appearance. These options are passed as a second argument to the mark constructor. (The first argument is the required data.) For example, if you want filled dots instead of stroked ones, pass the desired color to the **fill** option: - -:::plot https://observablehq.com/@observablehq/plot-marks-have-options -```js -Plot.dot(gistemp, {x: "Date", y: "Anomaly", fill: "red"}).plot() -``` -::: - -As the name suggests, options are generally optional; Plot tries to provide good defaults for whatever you don’t specify. Plot even has [shorthand](./shorthand.md) for various common forms of data. Below, we extract an array of numbers from the `gistemp` dataset, and use the line mark shorthand to set *x* = index and *y* = identity. - -:::plot https://observablehq.com/@observablehq/plot-marks-have-options -```js -Plot.lineY(gistemp.map((d) => d.Anomaly)).plot() -``` -::: - -Some marks even provide default [transforms](./transforms.md), say for [stacking](../transforms/stack.md)! - -:::tip -Because Plot strives to be concise, there are many default behaviors, some of which can be subtle. If Plot isn’t doing what you expect, try disabling the defaults by specifying options explicitly. -::: - -In addition to the standard options such as **fill** and **stroke** that are supported by all mark types, each mark type can support options unique to that type. For example, the dot mark takes a **symbol** option so you can draw things other than circles. See the documentation for each mark type to see what it supports. - -## Marks have channels - -Channels are mark options that can be used to encode data. These options allow the value to vary with the data, such as a different position or color for each dot. To use a channel, supply it with a column of data, typically as: - -* a field (column) name, -* an accessor function, or -* an array of values of the same length and order as the data. - -Not all mark options can be expressed as channels. For example, **stroke** can be a channel but **strokeDasharray** cannot. This is mostly a pragmatic limitation — it would be harder to implement Plot if every option were expressible as a channel — but it also serves to guide you towards options that are intended for encoding data. - -:::tip -To vary the definition of a constant option with data, create multiple marks with your different constant options, and then filter the data for each mark to achieve the desired result. -::: - -Some options can be either a channel or a constant depending on the provided value. For example, if you set the **fill** option to *purple*, Plot interprets it as a literal color. - -:::plot https://observablehq.com/@observablehq/plot-marks-have-channels -```js -Plot - .barX(timeseries, {x: "population", y: "year", fill: "purple"}) - .plot({y: {label: null, tickFormat: ""}}) -``` -::: - -Whereas if the **fill** option is a string but *not* a valid CSS color, Plot assumes you mean the corresponding column of the data and interprets it as a channel. - -:::plot https://observablehq.com/@observablehq/plot-marks-have-channels -```js -Plot - .barX(timeseries, {x: "population", y: "year", fill: "year"}) - .plot({y: {label: null, tickFormat: ""}}) -``` -::: - -If the **fill** option is a function, it is interpreted as a channel. - -:::plot https://observablehq.com/@observablehq/plot-marks-have-channels -```js -Plot - .barX(timeseries, {x: "population", y: "year", fill: (d) => d.year}) - .plot({y: {label: null, tickFormat: ""}}) -``` -::: - -Lastly, note that while channels are normally bound to a [scale](#marks-use-scales), you can bypass the *color* scale here by supplying literal color values to the **fill** channel. - -:::plot https://observablehq.com/@observablehq/plot-marks-have-channels -```js -Plot - .barX(timeseries, {x: "population", y: "year", fill: (d) => d.year & 1 ? "red" : "currentColor"}) - .plot({y: {label: null, tickFormat: ""}}) -``` -::: - -But rather than supplying literal values, it is more semantic to provide abstract values and use scales. In addition to centralizing the encoding definition (if used by multiple marks), it allows Plot to generate a legend. - -:::plot https://observablehq.com/@observablehq/plot-marks-have-channels -```js -Plot - .barX(timeseries, {x: "population", y: "year", fill: (d) => d.year & 1 ? "odd" : "even"}) - .plot({y: {label: null, tickFormat: ""}, color: {legend: true}}) -``` -::: - -You can then specify the *color* scale’s **domain** and **range** to control the encoding. - -## Mark options - -Mark constructors take two arguments: **data** and **options**. Together these describe a tabular dataset and how to visualize it. Option values that must be the same for all of a mark’s generated shapes are known as *constants*, whereas option values that may vary across a mark’s generated shapes are known as *channels*. Channels are typically bound to [scales](./scales.md) and encode abstract data values, such as time or temperature, as visual values, such as position or color. (Channels can also be used to order ordinal domains; see the [**sort** option](./scales.md#sort-mark-option).) - -A mark’s data is most commonly an array of objects representing a tabular dataset, such as the result of loading a CSV file, while a mark’s options bind channels (such as *x* and *y*) to columns in the data (such as *units* and *fruit*). - -```js -sales = [ - {units: 10, fruit: "peach"}, - {units: 20, fruit: "pear"}, - {units: 40, fruit: "plum"}, - {units: 30, fruit: "plum"} -] -``` -```js -Plot.dot(sales, {x: "units", y: "fruit"}) -``` - -While a column name such as `"units"` is the most concise way of specifying channel values, values can also be specified as functions for greater flexibility, say to transform data or derive a new column on the fly. Channel functions are invoked for each datum (*d*) in the data and return the corresponding channel value. (This is similar to how D3’s [*selection*.attr](https://d3js.org/d3-selection/modifying#selection_attr) accepts functions, though note that Plot channel functions should return abstract values, not visual values.) - -```js -Plot.dot(sales, {x: (d) => d.units * 1000, y: (d) => d.fruit}) -``` - -Plot also supports columnar data for greater efficiency with bigger datasets; for example, data can be specified as any array of the appropriate length (or any iterable or value compatible with [Array.from](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from)), and then separate arrays of values can be passed as *options*. - -```js -index = [0, 1, 2, 3] -``` -```js -units = [10, 20, 40, 30] -``` -```js -fruits = ["peach", "pear", "plum", "plum"] -``` -```js -Plot.dot(index, {x: units, y: fruits}) -``` - -Channel values can also be specified as numbers for constant values, say for a fixed baseline with an [area](../marks/area.md). - -```js -Plot.area(aapl, {x1: "Date", y1: 0, y2: "Close"}) -``` - -Missing and invalid data are handled specifically for each mark type and channel. In most cases, if the provided channel value for a given datum is null, undefined, or (strictly) NaN, the mark will implicitly filter the datum and not generate a corresponding output. In some cases, such as the radius (*r*) of a dot, the channel value must additionally be positive. Plot.line and Plot.area will stop the path before any invalid point and start again at the next valid point, thus creating interruptions rather than interpolating between valid points. Titles will only be added if they are non-empty. - -All marks support the following style options: - -* **fill** - fill color -* **fillOpacity** - fill opacity (a number between 0 and 1) -* **stroke** - stroke color -* **strokeWidth** - stroke width (in pixels) -* **strokeOpacity** - stroke opacity (a number between 0 and 1) -* **strokeLinejoin** - how to join lines (*bevel*, *miter*, *miter-clip*, or *round*) -* **strokeLinecap** - how to cap lines (*butt*, *round*, or *square*) -* **strokeMiterlimit** - to limit the length of *miter* joins -* **strokeDasharray** - a comma-separated list of dash lengths (typically in pixels) -* **strokeDashoffset** - the [stroke dash offset](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dashoffset) (typically in pixels) -* **opacity** - object opacity (a number between 0 and 1) -* **mixBlendMode** - the [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) (*e.g.*, *multiply*) -* **imageFilter** - a CSS [filter](https://developer.mozilla.org/en-US/docs/Web/CSS/filter) (*e.g.*, *blur(5px)*) <VersionBadge version="0.6.7" /> -* **shapeRendering** - the [shape-rendering mode](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/shape-rendering) (*e.g.*, *crispEdges*) -* **paintOrder** - the [paint order](https://developer.mozilla.org/en-US/docs/Web/CSS/paint-order) (*e.g.*, *stroke*) -* **dx** - horizontal offset (in pixels; defaults to 0) -* **dy** - vertical offset (in pixels; defaults to 0) -* **target** - link target (e.g., “_blank” for a new window); for use with the **href** channel -* **className** - the [class attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class), if any (defaults to null) <VersionBadge version="0.6.16" pr="1098" /> -* **ariaDescription** - a textual description of the mark’s contents -* **ariaHidden** - if true, hide this content from the accessibility tree -* **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*) -* **clip** - whether and how to clip the mark -* **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md) <VersionBadge version="0.6.7" /> - -If the **clip** option<a id="clip" href="#clip" aria-label="Permalink to "clip""></a> is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions. If the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection); a [geographic projection](./projections.md) is required in this case. Lastly if the **clip** option is a GeoJSON object <VersionBadge version="0.6.17" pr="2243" />, the mark will be clipped to the projected geometry. - -If the **tip** option is true, a [tip mark](../marks/tip.md) with the [pointer transform](../interactions/pointer.md) will be derived from this mark and placed atop all other marks, offering details on demand. If the **tip** option is set to an options object, these options will be passed to the derived tip mark. If the **tip** option (or, if an object, its **pointer** option) is set to *x*, *y*, or *xy*, [pointerX](../interactions/pointer.md#pointerX), [pointerY](../interactions/pointer.md#pointerY), or [pointer](../interactions/pointer.md#pointer) will be used, respectively; otherwise the pointing mode will be chosen automatically. (If the **tip** mark option is truthy, the **title** channel is no longer applied using an SVG title element as this would conflict with the tip mark.) - -For all marks except [text](../marks/text.md), the **dx** and **dy** options are rendered as a transform property, possibly including a 0.5px offset on low-density screens. - -All marks support the following optional channels: - -* **fill** - a fill color; bound to the *color* scale -* **fillOpacity** - a fill opacity; bound to the *opacity* scale -* **stroke** - a stroke color; bound to the *color* scale -* **strokeOpacity** - a stroke opacity; bound to the *opacity* scale -* **strokeWidth** - a stroke width (in pixels) -* **opacity** - an object opacity; bound to the *opacity* scale -* **title** - an accessible, short-text description (a string of text, possibly with newlines) -* **href** - a URL to link to -* **ariaLabel** - a short label representing the value in the accessibility tree - -The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. - -The scale associated with any channel can be overridden by specifying the channel as an object with a *value* property specifying the channel values and a *scale* property specifying the desired scale name or null for an unscaled channel. For example, to force the **stroke** channel to be unscaled, interpreting the associated values as literal color strings: - -```js -Plot.dot(data, {stroke: {value: "fieldName", scale: null}}) -``` - -To instead force the **stroke** channel to be bound to the *color* scale regardless of the provided values, say: - -```js -Plot.dot(data, {stroke: {value: "fieldName", scale: "color"}}) -``` - -The color channels (**fill** and **stroke**) are bound to the *color* scale by default, unless the provided values are all valid CSS color strings or nullish, in which case the values are interpreted literally and unscaled. - -In addition to functions of data, arrays, and column names, channel values can be specified as an object with a *transform* method; this transform method is passed the mark’s array of data and must return the corresponding array of channel values. (Whereas a channel value specified as a function is invoked repeatedly for each element in the mark’s data, similar to *array*.map, the transform method is invoked only once being passed the entire array of data.) For example, to pass the mark’s data directly to the **x** channel, equivalent to [Plot.identity](./transforms.md#identity): - -```js -Plot.dot(numbers, {x: {transform: (data) => data}}) -``` - -The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`. - -For marks that support the **frameAnchor** option, it may be specified as one of the four sides (*top*, *right*, *bottom*, *left*), one of the four corners (*top-left*, *top-right*, *bottom-right*, *bottom-left*), or the *middle* of the frame. - -All marks support the following [transform](./transforms.md) options: - -* **filter** - apply the [filter transform](../transforms/filter.md) -* **sort** - apply the [sort transform](../transforms/sort.md) -* **reverse** - apply the [reverse transform](../transforms/sort.md#reverse) -* **transform** - apply a [custom transform](./transforms.md#custom-transforms) -* **initializer** - apply a [custom initializer](./transforms.md#custom-initializers) - -The **sort** option, when not specified as a channel value (such as a field name or an accessor function), can also be used to [impute ordinal scale domains](./scales.md#sort-mark-option). - -### Insets - -Rect-like marks support insets: a positive inset moves the respective side in (towards the opposing side), whereas a negative inset moves the respective side out (away from the opposing side). Insets are specified in pixels using the following options: - -* **inset** - shorthand for all four insets -* **insetTop** - inset the top edge -* **insetRight** - inset the right edge -* **insetBottom** - inset the bottom edge -* **insetLeft** - inset the left edge - -Insets default to zero. Insets are commonly used to create a one-pixel gap between adjacent bars in histograms; the [bin transform](../transforms/bin.md) provides default insets. (Note that the [band scale padding](./scales.md#position-scale-options) defaults to 0.1 as an alternative to insets.) - -### Rounded corners - -Rect-like marks support rounded corners. Each corner (or side) is individually addressable <VersionBadge version="0.6.16" pr="2099" /> using the following options: - -* **r** - the radius for all four corners -* **rx1** - the radius for the **x1**-**y1** and **x1**-**y2** corners -* **rx2** - the radius for the **x2**-**y1** and **x2**-**y2** corners -* **ry1** - the radius for the **x1**-**y1** and **x2**-**y1** corners -* **ry2** - the radius for the **x1**-**y2** and **x2**-**y2** corners -* **rx1y1** - the radius for the **x1**-**y1** corner -* **rx1y2** - the radius for the **x1**-**y2** corner -* **rx2y1** - the radius for the **x2**-**y1** corner -* **rx2y2** - the radius for the **x2**-**y2** corner -* **rx** - the [*x*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for elliptical corners -* **ry** - the [*y*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for elliptical corners - -Corner radii are specified in either pixels or, for **rx** and **ry**, as percentages (strings) or the keyword *auto*. If the corner radii are too big, they are reduced proportionally. - -## marks(...*marks*) <VersionBadge version="0.2.0" /> {#marks} - -```js -Plot.marks( - Plot.ruleY([0]), - Plot.areaY(data, {fill: color, fillOpacity, ...options}), - Plot.lineY(data, {stroke: color, ...options}) -) -``` - -A convenience method for composing a mark from a series of other marks. Returns an array of marks that implements the *mark*.plot function. See the [box mark](../marks/box.md) implementation for an example. diff --git a/docs/features/plots.md b/docs/features/plots.md deleted file mode 100644 index e461715f..00000000 --- a/docs/features/plots.md +++ /dev/null @@ -1,325 +0,0 @@ ---- -prev: - text: Getting started - link: /getting-started ---- - -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as htl from "htl"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; - -const marginTop = ref(20); -const marginRight = ref(20); -const marginBottom = ref(30); -const marginLeft = ref(40); -const fixed = ref(true); -const aapl = shallowRef([]); -const goog = shallowRef([]); -const penguins = shallowRef([]); -const stocks = computed(() => [...aapl.value.map((d) => ({...d, Symbol: "AAPL"})), ...goog.value.map((d) => ({...d, Symbol: "GOOG"}))]); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/goog.csv", d3.autoType).then((data) => (goog.value = data)); - d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data)); -}); - -</script> - -# Plots - -To render a **plot** in Replot, call [plot](#plot) (typically as `Plot.plot`), passing in the desired *options*. This function returns an SVG or HTML figure element. - -:::plot https://observablehq.com/@observablehq/plot-hello-world -```js -Plot.plot({ - marks: [ - Plot.frame(), - Plot.text(["Hello, world!"], {frameAnchor: "middle"}) - ] -}) -``` -::: - -:::tip -With the imperative API, the returned plot element is detached; it must be inserted into the page to be visible. With the [React component API](../getting-started.md#plot-in-react), plots render directly into the React tree — no manual DOM insertion needed. For help, see the [getting started guide](../getting-started.md). -::: - -## Marks option - -The **marks** option specifies an array of [marks](./marks.md) to render. Above, there are two marks: a [frame](../marks/frame.md) to draw the outline of the plot frame, and a [text](../marks/text.md) to say hello. 👋 - -Each mark supplies its own tabular data. For example, the table below shows the first five rows of a daily dataset of Apple stock price (`aapl`). - -| Date | Open | High | Low | Close | Volume | -| ---------- | --------: | --------: | --------: | --------: | --------: | -| 2013-05-13 | 64.501427 | 65.414284 | 64.500000 | 64.962860 | 79237200 | -| 2013-05-14 | 64.835716 | 65.028572 | 63.164288 | 63.408573 | 111779500 | -| 2013-05-15 | 62.737144 | 63.000000 | 60.337143 | 61.264286 | 185403400 | -| 2013-05-16 | 60.462856 | 62.549999 | 59.842857 | 62.082859 | 150801000 | -| 2013-05-17 | 62.721428 | 62.869999 | 61.572857 | 61.894287 | 106976100 | - -In JavaScript, we can represent tabular data as an array of objects. Each object records a daily observation, with properties *Date*, *Open*, *High*, and so on. This is known as a “row-based” format since each object corresponds to a row in the table. - -```js-vue -aapl = [ - {Date: new Date("2013-05-13"), Open: 64.501427, High: 65.414284, Low: 64.500000, Close: 64.962860, Volume: 79237200}, - {Date: new Date("2013-05-14"), Open: 64.835716, High: 65.028572, Low: 63.164288, Close: 63.408573, Volume: 111779500}, - {Date: new Date("2013-05-15"), Open: 62.737144, High: 63.000000, Low: 60.337143, Close: 61.264286, Volume: 185403400}, - {Date: new Date("2013-05-16"), Open: 60.462856, High: 62.549999, Low: 59.842857, Close: 62.082859, Volume: 150801000}, - {Date: new Date("2013-05-17"), Open: 62.721428, High: 62.869999, Low: 61.572857, Close: 61.894287, Volume: 106976100} -] -``` - -:::tip -Rather than baking data into JavaScript, use [JSON](https://en.wikipedia.org/wiki/JSON) or [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) files to store data. You can use [d3.json](https://d3js.org/d3-fetch#json), [d3.csv](https://d3js.org/d3-fetch#csv), or [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to load a file. On Observable, you can also use a [file attachment](https://observablehq.com/@observablehq/file-attachments) or [SQL cell](https://observablehq.com/@observablehq/sql-cell). -::: - -To use data with Plot, pass the data as the first argument to the mark constructor. We can then assign columns of data such as *Date* and *Close* to visual properties of the mark (or “channels”) such as horizontal↔︎ position **x** and vertical↕︎ position **y**. - -:::plot defer https://observablehq.com/@observablehq/plot-first-line-chart -```js -Plot.plot({ - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -A plot can have multiple marks, and each mark has its own data. For example, say we had a similar table `goog` representing the daily price of Google stock for the same period. Below, the <span style="border-bottom: solid 2px var(--vp-c-red);">red</span> line represents Google stock, while the <span style="border-bottom: solid 2px var(--vp-c-blue);">blue</span> line represents Apple stock. - -:::plot defer https://observablehq.com/@observablehq/plot-layered-marks -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.lineY(goog, {x: "Date", y: "Close", stroke: "red"}), - Plot.lineY(aapl, {x: "Date", y: "Close", stroke: "blue"}) - ] -}) -``` -::: - -:::tip -When comparing the performance of different stocks, we typically want to normalize the return relative to a purchase price. See the [normalize transform](../transforms/normalize.md) for an example. -::: - -Alternatively, the tables can be combined, say with a *Symbol* column to distinguish AAPL from GOOG. This allows the use of a categorical *color* scale and legend. - -:::plot defer https://observablehq.com/@observablehq/plot-stocks-multiline-chart -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.ruleY([0]), - Plot.lineY(stocks, {x: "Date", y: "Close", stroke: "Symbol"}) - ] -}) -``` -::: - -Each mark has its own options, and different mark types support different options. See the respective mark type (such as [bar](../marks/bar.md) or [dot](../marks/dot.md)) for details. - -Marks are drawn in the given order, with the last mark drawn on top. For example, below <span style="border-bottom: solid 2px var(--vp-c-green);">green</span> bars are drawn on top of <span style="border-bottom: solid 2px;">{{$dark ? "white" : "black"}}</span> bars. - -:::plot https://observablehq.com/@observablehq/plot-marks-z-order -```js -Plot.plot({ - x: {padding: 0.4}, - marks: [ - Plot.barY(alphabet, {x: "letter", y: "frequency", dx: 2, dy: 2}), - Plot.barY(alphabet, {x: "letter", y: "frequency", fill: "green", dx: -2, dy: -2}) - ] -}) -``` -::: - -## Layout options - -The layout options determine the overall size of the plot; all are specified as numbers in pixels: - -* **marginTop** - the top margin -* **marginRight** - the right margin -* **marginBottom** - the bottom margin -* **marginLeft** - the left margin -* **margin** - shorthand for the four margins -* **width** - the outer width of the plot (including margins) -* **height** - the outer height of the plot (including margins) - -Experiment with the margins by adjusting the sliders below. Note that because the *x* scale is a *band* scale, the **round** option defaults to true, so the bars may jump when you adjust the horizontal margins to snap to crisp edges. - -<p> - <label class="label-input" style="display: flex;"> - <span style="display: inline-block; width: 7em;">marginTop:</span> - <input type="range" v-model.number="marginTop" min="0" max="60" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{marginTop}}</span> - </label> - <label class="label-input" style="display: flex;"> - <span style="display: inline-block; width: 7em;">marginRight:</span> - <input type="range" v-model.number="marginRight" min="0" max="60" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{marginRight}}</span> - </label> - <label class="label-input" style="display: flex;"> - <span style="display: inline-block; width: 7em;">marginBottom:</span> - <input type="range" v-model.number="marginBottom" min="0" max="60" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{marginBottom}}</span> - </label> - <label class="label-input" style="display: flex;"> - <span style="display: inline-block; width: 7em;">marginLeft:</span> - <input type="range" v-model.number="marginLeft" min="0" max="60" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{marginLeft}}</span> - </label> -</p> - -:::plot hidden defer -```js -Plot.plot({ - marginTop, - marginRight, - marginBottom, - marginLeft, - grid: true, - marks: [ - Plot.frame({ - stroke: "var(--vp-c-text-2)", - strokeOpacity: 0.5, - insetTop: -marginTop, - insetRight: -marginRight, - insetBottom: -marginBottom, - insetLeft: -marginLeft, - }), - Plot.barY(alphabet, {x: "letter", y: "frequency", fill: "green"}), - Plot.frame() - ] -}) -``` -::: - -```js-vue -Plot.plot({ - marginTop: {{marginTop}}, - marginRight: {{marginRight}}, - marginBottom: {{marginBottom}}, - marginLeft: {{marginLeft}}, - grid: true, - marks: [ - Plot.barY(alphabet, {x: "letter", y: "frequency", fill: "green"}), - Plot.frame() - ] -}) -``` - -:::info -To assist the explanation, the plot above is drawn with a light gray border. -::: - -The default **width** is 640. On Observable, the width can be set to the [standard width](https://github.com/observablehq/stdlib/blob/main/README.md#width) to make responsive plots. The default **height** is chosen automatically based on the plot’s associated scales; for example, if *y* is linear and there is no *fy* scale, it might be 396. The default margins depend on the maximum margins of the plot’s constituent [marks](#marks-option). While most marks default to zero margins (because they are drawn inside the chart area), Plot’s [axis mark](../marks/axis.md) has non-zero default margins. - -:::tip -Plot does not adjust margins automatically to make room for long tick labels. If your *y* axis labels are too long, you can increase the **marginLeft** to make more room. Also consider using a different **tickFormat** for short labels (*e.g.*, `s` for SI prefix notation), or a scale **transform** (say to convert units to millions or billions). -::: - -The **aspectRatio** option<a id="aspectRatio" href="#aspectRatio" aria-label="Permalink to "aspectRatio""></a> <VersionBadge version="0.6.4" />, if not null, computes a default **height** such that a variation of one unit in the *x* dimension is represented by the corresponding number of pixels as a variation in the *y* dimension of one unit. The **aspectRatio** option is recommended only when *x* and *y* domains share the same units, such as millimeters. When a position scale is [ordinal](./scales.md#discrete-scales) (*point* or *band*), consecutive domain values are treated as one unit length apart; for example, if both *x* and *y* are ordinal, then an aspect ratio of one produces a square grid. - -<p> - <label class="label-input"> - Use fixed aspect ratio: - <input type="checkbox" v-model="fixed"> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-intro-to-aspectratio -```js -Plot.plot({ - grid: true, - inset: 10, - aspectRatio: fixed ? 1 : undefined, - color: {legend: true}, - marks: [ - Plot.frame(), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}) - ] -}) -``` -::: - -:::tip -When using facets, set the *fx* and *fy* scales’ **round** option to false if you need an exact aspect ratio. -::: - -## Other options - -By default, [plot](#plot) returns an SVG element; however, if the plot includes a title, subtitle, [legend](./legends.md), or caption, plot wraps the SVG element with an HTML figure element. You can also force Plot to generate a figure element by setting the **figure** option <VersionBadge version="0.6.10" pr="1761" /> to true. - -The **title** & **subtitle** options <VersionBadge version="0.6.10" pr="1761" /> and the **caption** option accept either a string or an HTML element. If given an HTML element, say using the [`html` tagged template literal](http://github.com/observablehq/htl), the title and subtitle are used as-is while the caption is wrapped in a figcaption element; otherwise, the specified text will be escaped and wrapped in an h2, h3, or figcaption, respectively. - -:::plot https://observablehq.com/@observablehq/plot-caption -```js -Plot.plot({ - title: "For charts, an informative title", - subtitle: "Subtitle to follow with additional context", - caption: "Figure 1. A chart with a title, subtitle, and caption.", - marks: [ - Plot.frame(), - Plot.text(["Titles, subtitles, captions, and annotations assist inter­pretation by telling the reader what’s interesting. Don’t make the reader work to find what you already know."], {lineWidth: 30, frameAnchor: "middle"}) - ] -}) -``` -::: - -The **style** option allows custom styles to override Plot’s defaults. It may be specified either as a string of inline styles (*e.g.*, `"color: red;"`, in the same fashion as assigning [*element*.style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style)) or an object of properties (*e.g.*, `{color: "red"}`, in the same fashion as assigning [*element*.style properties](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration)). By default, the returned plot has a max-width of 100%, and the system-ui font. Plot’s marks and axes default to [currentColor](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#currentcolor_keyword), meaning that they will inherit the surrounding content’s color. - -:::warning CAUTION -Unitless numbers ([quirky lengths](https://www.w3.org/TR/css-values-4/#deprecated-quirky-length)) such as `{padding: 20}` are not supported by some browsers; you should instead specify a string with units such as `{padding: "20px"}`. -::: - -The generated SVG element has a class name which applies a default stylesheet. Use the top-level **className** option to specify that class name. - -The **clip** option <VersionBadge version="0.6.10" pr="1792" /> determines the default clipping behavior if the [mark **clip** option](./marks.md#mark-options) is not specified; set it to true to enable clipping. This option does not affect [axis](../marks/axis.md), [grid](../marks/grid.md), and [frame](../marks/frame.md) marks, whose **clip** option defaults to false. - -The **document** option specifies the [document](https://developer.mozilla.org/en-US/docs/Web/API/Document) used to create plot elements. It defaults to window.document, but can be changed to another document, say when using a virtual DOM implementation for server-side rendering in Node. - -## plot(*options*) {#plot} - -```js -Plot.plot({ - height: 200, - marks: [ - Plot.barY(alphabet, {x: "letter", y: "frequency"}) - ] -}) -``` - -Renders a new plot with the specified *options*, returning a SVG or HTML figure element. This element can then be inserted into the page as described in the [getting started guide](../getting-started.md). - -## *mark*.plot(*options*) {#mark_plot} - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency"}).plot({height: 200}) -``` - -Given a [*mark*](./marks.md), this is a convenience shorthand for calling [plot](#plot) where the **marks** option includes this *mark*. Any additional **marks** in *options* are drawn on top of this *mark*. - -## *plot*.scale(*name*) {#plot_scale} - -```js -const plot = Plot.plot(options); // render a plot -const color = plot.scale("color"); // get the color scale -console.log(color.range); // inspect the scale’s range -``` - -Returns the [scale object](./scales.md#scale-options) for the scale with the specified *name* (such as *x* or *color*) on the given *plot*, where *plot* is a rendered plot element returned by [plot](#plot). If the associated *plot* has no scale with the given *name*, returns undefined. - -## *plot*.legend(*name*, *options*) {#plot_legend} - -```js -const plot = Plot.plot(options); // render a plot -const legend = plot.legend("color"); // render a color legend -``` - -Renders a standalone legend for the scale with the specified *name* (such as *x* or *color*) on the given *plot*, where *plot* is a rendered plot element returned by [plot](#plot), returning a SVG or HTML figure element. This element can then be inserted into the page as described in the [getting started guide](../getting-started.md). If the associated *plot* has no scale with the given *name*, returns undefined. Legends are currently only supported for *color*, *opacity*, and *symbol* scales. diff --git a/docs/features/projections.md b/docs/features/projections.md deleted file mode 100644 index 2acbdb6f..00000000 --- a/docs/features/projections.md +++ /dev/null @@ -1,276 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, shallowRef, onMounted} from "vue"; - -const longitude = ref(90); -const radius = ref(30); -const circle = computed(() => d3.geoCircle().center([9, 34]).radius(radius.value)()); -const projection = ref("equirectangular"); -const westport = shallowRef({type: null}); -const earthquakes = shallowRef([]); -const walmarts = shallowRef([]); -const world = shallowRef(null); -const land = computed(() => world.value ? topojson.feature(world.value, world.value.objects.land) : {type: null}); -const us = shallowRef(null); -const nation = computed(() => us.value ? topojson.feature(us.value, us.value.objects.nation) : {type: null}); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states, (a, b) => a !== b) : {type: null}); - -onMounted(() => { - d3.json("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson").then((data) => (earthquakes.value = data.features.map((f) => ({longitude: f.geometry.coordinates[0], latitude: f.geometry.coordinates[1], magnitude: f.properties.mag})))); - d3.json("../data/countries-110m.json").then((data) => (world.value = data)); - d3.tsv("../data/walmarts.tsv", d3.autoType).then((data) => (walmarts.value = data)); - d3.json("../data/westport-house.json").then((data) => (westport.value = data)); - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); -}); - -</script> - -# Projections <VersionBadge version="0.6.1" /> - -A **projection** maps abstract coordinates in *x* and *y* to pixel positions on screen. Most often, abstract coordinates are spherical (degrees longitude and latitude), as when rendering a geographic map. For example, below we show earthquakes in the last seven days with a magnitude of 2.5 or higher as reported by the [USGS](https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php). Use the slider to adjust the *orthographic* projection’s center of longitude. - -<p> - <label class="label-input"> - Longitude: - <input type="range" v-model.number="longitude" min="-180" max="180" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{longitude}}°</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-earthquake-globe -```js -Plot.plot({ - projection: {type: "orthographic", rotate: [-longitude, -30]}, - r: {transform: (d) => Math.pow(10, d)}, // convert Richter to amplitude - marks: [ - Plot.geo(land, {fill: "currentColor", fillOpacity: 0.2}), - Plot.sphere(), - Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "magnitude", stroke: "red", fill: "red", fillOpacity: 0.2}) - ] -}) -``` -::: - -Above, a [geo mark](../marks/geo.md) draws polygons representing land and a [sphere mark](../marks/geo.md#sphere) draws the outline of the globe. A [dot mark](../marks/dot.md) draws earthquakes as circles sized by magnitude. - -The geo mark is “projection aware” so that it can handle all the nuances of projecting spherical polygons to the screen — leaning on [d3-geo](https://d3js.org/d3-geo) to provide [adaptive sampling](https://observablehq.com/@d3/adaptive-sampling) with configurable precision, [antimeridian cutting](https://observablehq.com/@d3/antimeridian-cutting), and clipping. The dot mark is not; instead, Plot applies the projection in place of the *x* and *y* scales. Hence, projections work with any mark that consumes continuous **x** and **y** channels — as well as marks that use **x1** & **y1** and **x2** & **y2**. Each mark implementation decides whether to handle projections specially or to treat the projection as any other position scale. (For example, the [line mark](../marks/line.md) is projection-aware to draw geodesics.) - -:::info -Marks that require *band* scales (bars, cells, and ticks) cannot be used with projections. Likewise one-dimensional marks such as rules cannot be used, though see [#1164](https://github.com/observablehq/plot/issues/1164). -::: - -Plot provides a variety of built-in projections. And as above, all world projections can be rotated to show a different aspect. - -<p> - <label class="label-input"> - Projection: - <select v-model="projection"> - <!-- <option>albers-usa</option> --> - <!-- <option>albers</option> --> - <option>azimuthal-equal-area</option> - <option>azimuthal-equidistant</option> - <!-- <option>conic-conformal</option> --> - <option>conic-equal-area</option> - <option>conic-equidistant</option> - <option>equal-earth</option> - <option>equirectangular</option> - <option>gnomonic</option> - <!-- <option>identity</option> --> - <!-- <option>reflect-y</option> --> - <option>mercator</option> - <option>orthographic</option> - <option>stereographic</option> - <option>transverse-mercator</option> - </select> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-world-projections -```js-vue -Plot.plot({ - projection: "{{projection}}", - marks: [ - Plot.graticule(), - Plot.geo(land, {fill: "currentColor"}), - Plot.sphere() - ] -}) -``` -::: - -Why so many? Each projection has its strengths and weaknesses: - -- _conformal_ projections preserve angles and local shape, -- _equal-area_ projections preserve area (use these for choropleths), -- _equidistant_ projections preserve distance from one (or two) points, -- _azimuthal_ projections expand radially from a central feature, -- _cylindrical_ projections have symmetry around the axis of rotation, -- the _stereographic_ projection preserves circles, and -- the _gnomonic_ projection displays all great circles as straight lines! - -No single projection is best at everything. It is impossible, for example, for a projection to be both conformal and equal-area. - -In addition to world projections, Plot provides the U.S.-centric *albers-usa* conic equal-area projection with an inset of Alaska and Hawaii. (Note that the scale for Alaska is diminished: it is projected at 0.35× its true relative area.) - -:::plot defer https://observablehq.com/@observablehq/plot-albers-usa-projection -```js -Plot.plot({ - projection: "albers-usa", - marks: [ - Plot.geo(nation), - Plot.geo(statemesh, {strokeOpacity: 0.2}) - ] -}) -``` -::: - -:::tip -Use the *albers-usa* projection for U.S.-centric choropleth maps. -::: - -For maps that focus on a specific region, use the **domain** option to zoom in. This object should be a GeoJSON object. For example, you can use [d3.geoCircle](https://d3js.org/d3-geo/shape#geoCircle) to generate a circle of a given radius centered at a given longitude and latitude. You can also use the **inset** options for a bit of padding around the **domain**. - -<p> - <label class="label-input"> - Radius: - <input type="range" v-model.number="radius" min="10" max="50" step="0.1"> - <span style="font-variant-numeric: tabular-nums;">{{radius.toFixed(1)}}°</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-projection-domain -```js -Plot.plot({ - projection: { - type: "azimuthal-equidistant", - rotate: [-9, -34], - domain: circle, - inset: 10 - }, - marks: [ - Plot.graticule(), - Plot.geo(land, {fill: "currentColor", fillOpacity: 0.3}), - Plot.geo(circle, {stroke: "red", strokeWidth: 2}), - Plot.frame() - ] -}) -``` - -```js -circle = d3.geoCircle().center([9, 34]).radius(radius)() -``` - -If none of Plot’s built-in projections meet your needs, you can use any of [D3’s extended projections](https://github.com/d3/d3-geo-projection) by specifying the **projection** option as a function that returns a D3 projection. Below, a map of Antarctica in a polar aspect of the *azimuthal-equidistant* projection. - -:::plot defer https://observablehq.com/@observablehq/plot-polar-projection -```js -Plot.plot({ - width: 688, - height: 688, - projection: ({width, height}) => d3.geoAzimuthalEquidistant() - .rotate([0, 90]) - .translate([width / 2, height / 2]) - .scale(width) - .clipAngle(40), - marks: [ - Plot.graticule(), - Plot.geo(land, {fill: "currentColor"}), - Plot.frame() - ] -}) -``` -::: - -While this notebook mostly details spherical projections, you can use the *identity* projection to display planar geometry. For example, below we draw a schematic of the second floor of the [Westport House](https://en.wikipedia.org/wiki/Westport_House) in Dundee, Ireland. - -:::plot defer https://observablehq.com/@observablehq/plot-floor-plan -```js -Plot.geo(westport).plot({projection: {type: "identity", domain: westport}}) -``` -::: - -:::tip -There’s also a *reflect-y* projection in case *y* points up↑, which is often the case with [projected reference systems](https://en.wikipedia.org/wiki/Projected_coordinate_system). -::: - -Naturally, Plot’s projection system is compatible with its [faceting system](./facets.md). Below, a comic strip of sorts shows the locations of Walmart store openings in past decades. - -:::plot defer https://observablehq.com/@observablehq/plot-map-small-multiples -```js -Plot.plot({ - marginLeft: 0, - marginRight: 0, - projection: "albers", - fx: { - interval: "10 years", - tickFormat: (d) => `${d.getUTCFullYear()}’s`, - label: null - }, - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.1}), - Plot.geo(nation), - Plot.dot(walmarts, {fx: "date", x: "longitude", y: "latitude", r: 1, fill: "currentColor"}) - ] -}) -``` -::: - -:::info -This uses the [**interval** scale option](./scales.md#scale-transforms) to bin temporal data into facets by decade. -::: - -To learn more about mapping with Plot, see our hands-on tutorials: - -* [Build your first map with Replot](https://observablehq.com/@observablehq/build-your-first-map-with-observable-plot) -* [Build your first choropleth map with Replot](https://observablehq.com/@observablehq/build-your-first-choropleth-map-with-observable-plot) - -## Projection options - -The **projection** [plot option](./plots.md) applies a two-dimensional (often geographic) projection in place of **x** and **y** scales. It is typically used in conjunction with a [geo mark](../marks/geo.md) to produce a map, but can be used with any mark that supports **x** and **y** channels, such as [dot](../marks/dot.md), [text](../marks/text.md), [arrow](../marks/arrow.md), and [rect](../marks/rect.md). For marks that use **x1**, **y1**, **x2**, and **y2** channels, the two projected points are ⟨*x1*, *y1*⟩ and ⟨*x2*, *y2*⟩; otherwise, the projected point is ⟨*x*, *y*⟩. - -The following built-in named projections are supported: - -* *equirectangular* - the equirectangular, or *plate carrée*, projection -* *orthographic* - the orthographic projection -* *stereographic* - the stereographic projection -* *mercator* - the Mercator projection -* *equal-earth* - the [Equal Earth projection](https://en.wikipedia.org/wiki/Equal_Earth_projection) by Šavrič *et al.* -* *azimuthal-equal-area* - the azimuthal equal-area projection -* *azimuthal-equidistant* - the azimuthal equidistant projection -* *conic-conformal* - the conic conformal projection -* *conic-equal-area* - the conic equal-area projection -* *conic-equidistant* - the conic equidistant projection -* *gnomonic* - the gnomonic projection -* *transverse-mercator* - the transverse Mercator projection -* *albers* - the Albers’ conic equal-area projection -* *albers-usa* - a composite Albers conic equal-area projection suitable for the United States -* *identity* - the identity projection for planar geometry -* *reflect-y* - like the identity projection, but *y* points up -* null (default) - the null projection for pre-projected geometry in screen coordinates - -In addition to these named projections, the **projection** option may be specified as a [D3 projection](https://d3js.org/d3-geo/projection), or any custom projection that implements [*projection*.stream](https://d3js.org/d3-geo/stream), or a function that receives a configuration object ({*width*, *height*, ...*options*}) and returns such a projection. In the last case, the width and height represent the frame dimensions minus any insets. - -If the **projection** option is specified as an object, the following additional projection options are supported: - -* **type** - one of the projection names above -* **parallels** - the [standard parallels](https://d3js.org/d3-geo/conic#conic_parallels) (for conic projections only) -* **precision** - the [sampling threshold](https://d3js.org/d3-geo/projection#projection_precision) -* **rotate** - a two- or three- element array of Euler angles to rotate the sphere -* **domain** - a GeoJSON object to fit in the center of the (inset) frame -* **inset** - inset by the given amount in pixels when fitting to the frame (default zero) -* **insetLeft** - inset from the left edge of the frame (defaults to inset) -* **insetRight** - inset from the right edge of the frame (defaults to inset) -* **insetTop** - inset from the top edge of the frame (defaults to inset) -* **insetBottom** - inset from the bottom edge of the frame (defaults to inset) -* **clip** - the projection clipping method - -The following projection clipping methods are supported for **clip**: - -* *frame* or true (default) - clip to the extent of the frame (including margins but not insets) -* a number - clip to a great circle of the given radius in degrees centered around the origin -* null or false - do not clip - -Whereas the **clip** [mark option](./marks.md#mark-options) is implemented using SVG clipping, the **clip** projection option affects the generated geometry and typically produces smaller SVG output. diff --git a/docs/features/scales.md b/docs/features/scales.md deleted file mode 100644 index 8210c382..00000000 --- a/docs/features/scales.md +++ /dev/null @@ -1,1051 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; -import gistemp from "../data/gistemp.ts"; - -const intervaled = ref(true); -const padding = ref(0.1); -const align = ref(0.5); -const radius = ref(8); -const schemeq = ref("turbo"); -const schemed = ref("rdbu"); -const schemeo = ref("Observable10"); -const interpolateq = ref("rgb"); -const anomaly = gistemp.map((d) => d.Anomaly); -const aapl = shallowRef([]); -const goog = shallowRef([]); -const sftemp = shallowRef([]); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/goog.csv", d3.autoType).then((data) => (goog.value = data)); - d3.csv("../data/sf-temperatures.csv", d3.autoType).then((data) => (sftemp.value = data)); -}); - -</script> - -# Scales - -**Scales** convert an abstract value such as time or temperature to a visual value such as *x*→ or *y*↑ position or color. For example, say we have a dataset (`gistemp`) containing monthly observations of [global average surface temperature](https://data.giss.nasa.gov/gistemp/) from 1880 to 2016, represented as the “anomaly” (or difference) relative to the 1951–1980 average. The first few rows are: - -| Date | Anomaly | -|------------|--------:| -| 1880-01-01 | -0.3 | -| 1880-02-01 | -0.21 | -| 1880-03-01 | -0.18 | -| 1880-04-01 | -0.27 | -| 1880-05-01 | -0.14 | -| 1880-06-01 | -0.29 | - -When visualizing this data with a [line](../marks/line.md), the *x* scale is responsible for mapping dates to horizontal↔︎ positions. For example, 1880-01-01 might be mapped to *x* = 40 (on the left) and 2016-12-01 might be mapped to *x* = 620 (on the right). Likewise, the *y* scale maps temperature anomalies to vertical↕︎ positions. - -:::plot https://observablehq.com/@observablehq/plot-scales-intro -```js -Plot.lineY(gistemp, {x: "Date", y: "Anomaly"}).plot() -``` -::: - -In Plot, the [mark](./marks.md) binds channels to scales; for example, the line’s **x** channel is bound to the *x* scale. The channel name and the scale name are often the same, but not always; for example, an area’s **y1** and **y2** channels are both bound to the *y* scale. (You can opt-out of a scale for a particular channel using [scale overrides](./marks.md#mark-options) if needed.) - -Think of a scale as a function that takes an abstract value and returns the corresponding visual value. For the *y* scale above, that might look like this: - -```js -function y(anomaly) { - const t = (anomaly - minAnomaly) / (maxAnomaly - minAnomaly); // t in [0, 1] - return height - marginBottom - t * (height - marginTop - marginBottom); -} -``` - -The function `y` depends on a few additional details: the chart’s size and margins, and the minimum and maximum temperatures in the data: - -```js -const marginTop = 20; -const marginBottom = 30; -const height = 400; -const minAnomaly = d3.min(gistemp, (d) => d.Anomaly); -const maxAnomaly = d3.max(gistemp, (d) => d.Anomaly); -``` - -Scales aren’t limited to horizontal and vertical position. They can also output to color, radius, length, opacity, and more. For example if we switch to a [rule](../marks/rule.md) and use the **stroke** channel instead of **y**, we get a one-dimensional heatmap: - -:::plot https://observablehq.com/@observablehq/plot-scales-intro -```js -Plot.ruleX(gistemp, {x: "Date", stroke: "Anomaly"}).plot() -``` -::: - -While the resulting chart looks different, the *color* scale here behaves similarly to the `y` function above — the only difference is that it interpolates colors (using [d3.interpolateTurbo](https://d3js.org/d3-scale-chromatic/sequential#interpolateTurbo)) instead of numbers (the top and bottom sides of the plot frame): - -```js -function color(anomaly) { - const t = (anomaly - minAnomaly) / (maxAnomaly - minAnomaly); // t in [0, 1] - return d3.interpolateTurbo(t); -} -``` - -Within a given [plot](./plots.md), marks share scales. For example, if a plot has two line marks, such as the lines below visualizing the daily closing price of <span style="border-bottom: solid 2px var(--vp-c-red);">Google</span> and <span style="border-bottom: solid 2px var(--vp-c-blue);">Apple</span> stock, both share the same *x* and *y* scales for a consistent encoding. - -:::plot defer https://observablehq.com/@observablehq/plot-layered-marks -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.lineY(goog, {x: "Date", y: "Close", stroke: "red"}), - Plot.lineY(aapl, {x: "Date", y: "Close", stroke: "blue"}) - ] -}) -``` -::: - -:::tip -When comparing the performance of different stocks, we typically want to normalize the return relative to a purchase price; see the [normalize transform](../transforms/normalize.md) for an example. Also, not that we recommend them, but if you are interested in dual-axis charts, please upvote [#147](https://github.com/observablehq/plot/issues/147). -::: - -Plot has many different scales; we categorize them by their _input_ (**domain**) and _output_ (**range**). - -The **domain** is the abstract values that the scale expects as input. For quantitative or temporal data, it is typically expressed as an extent such as [*start*, *end*], [*cold*, *hot*], or [*min*, *max*]. For ordinal or nominal data, it is an array of values such as names or categories. The type of input values corresponds to the **type** scale option (_e.g._, *linear* or *ordinal*). - -The **range** is the visual values that the scale generates as output. For position scales, it is typically an extent such as [*left*, *right*] or [*bottom*, *top*]; for color scales, it might be a continuous extent [*blue*, *red*] or an array of discrete colors. The type of values that a scale outputs corresponds to the *name* of the scale (_e.g._, *x* or *color*). - -<!-- Position, color, radius, length, angle. In many cases this is just what the underlying interpolator is. For position, the output is a number, so we interpolate from the left to the right side. Whereas for color, the output is a color, we need a color space such as RGB or LCh to interpolate, or a fixed color ramp such as *turbo*. --> - -<!-- There are also some special transforms we can apply as part of the visual encoding. For example, continuous transforms such as log and sqrt. And sometimes converting continuous values into discrete values, with quantile, quantize, threshold. The latter transforms especially are usually for color. --> - -Let’s look at some examples to make this less abstract. - -## Continuous scales - -The domain of a quantitative scale is a continuous extent [*min*, *max*] where *min* and *max* are numbers, such as temperatures. Below, the first domain value (*x* = 0) corresponds to the left side of the plot while the second (*x* = 100) corresponds to the right side. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {domain: [0, 100], grid: true}}) -``` -::: - -Flipping the domain reverses the scale so that +*x* points ←left instead of right→. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {domain: [100, 0], grid: true}}) -``` -::: - -Alternatively, use the **reverse** option; this is convenient when the domain is implied from data rather than specified explicitly. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {domain: [0, 100], reverse: true, grid: true}}) -``` -::: - -If the domain is dates, Plot will default to a UTC scale. This is a linear scale with ticks based on the Gregorian calendar. - -<!-- Plot doesn’t parse dates; convert your strings to [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) instances with [d3.utcParse](https://d3js.org/d3-time-format#utcParse) or [d3.autoType](https://d3js.org/d3-dsv#autoType). --> - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {domain: [new Date("2021-01-01"), new Date("2022-01-01")], grid: true}}) -``` -::: - -To force a UTC scale, say when the data is milliseconds since UNIX epoch rather than Date instances, pass *utc* as the **type** option. Though we recommend coercing strings and numbers to more specific types when you load data, rather than relying on scales to do it. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "utc", domain: [1609459200000, 1640995200000], grid: true}}) -``` -::: - -If the scale **type** is *time*, the ticks will be in local time — as with the dates below — rather than UTC. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "time", domain: [new Date(2021, 0, 1), new Date(2022, 0, 1)], grid: true}}) -``` -::: - -When plotting values that vary widely, such as the luminosity of stars in an [HR diagram](https://observablehq.com/@mbostock/hertzsprung-russell-diagram), a *log* scale may improve readability. Log scales default to base-10 ticks with SI-prefix notation. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "log", domain: [1e0, 1e5], grid: true}}) -``` -::: - -If you prefer conventional notation, you can specify the **tickFormat** option to change the behavior of the axis. The **tickFormat** option can either be a [d3.format](https://d3js.org/d3-format) string or a function that takes a tick value and returns the corresponding string. Note, however, that this may result in overlapping text. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "log", domain: [1e0, 1e5], tickFormat: ",", grid: true}}) -``` -::: - -Log scales also support a **base** option, say for powers of two. This does not affect the scale’s encoding, but it does change where ticks are shown. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "log", base: 2, domain: [1e0, 1e4], ticks: 20, grid: true}}) -``` -::: - -The domain of a log scale cannot include (or cross) zero; for this, consider a [bi-symmetric log](https://d3js.org/d3-scale/symlog) scale instead. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "symlog", domain: [-10, 10], grid: true}}) -``` -::: - -Power scales and square-root scales are also supported. The *pow* scale supports the **exponent** option, which defaults to 1 (for a linear scale). The *sqrt* scale is shorthand for a *pow* scale with exponent 0.5. - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "sqrt", domain: [0, 100], grid: true}}) -``` -::: - -:::plot https://observablehq.com/@observablehq/plot-continuous-scales -```js -Plot.plot({x: {type: "pow", exponent: 1 / 3, domain: [0, 100], grid: true}}) -``` -::: - -Continuous scales also support a **clamp** option which, if true, clamps input values to the scale’s domain before scaling. This is useful for preventing marks from escaping the chart area. - -Continuous scales support an **interpolate** option specified either as a function that takes a single argument *t* in [0, 1] and returns the corresponding value from the **range**, or as a two-argument function that takes a pair of values [*start*, *end*] from the range and returns the corresponding interpolator from [0, 1], typically mapping 0 to *start*, and 1 to *end*. - -Continuous scales support a piecewise **domain** specified as an array of _n_ domain values (with _n_ greater than or equal to two), with a corresponding **range** having the same number of values; each segment of the domain is mapped to the matching segment of the range using the scale’s interpolator. When the domain has *n* > 2 elements and the range has two elements (for example, when using the default range on a *x* or *y* scale), the latter is automatically split into _n_ − 1 segments of equal size. Note that in addition to the domain, you must specify the scale’s continuous **type** since a scale specified with a domain having more than two elements otherwise defaults to an ordinal scale. (You will often have to specify the **ticks** manually, too.) For an example, see the [Polylinear axis](https://observablehq.com/@observablehq/polylinear-axis) notebook. - -## Discrete scales - -Sadly, not all data is continuous: some data is merely ordinal, such as t-shirt sizes; and some categorical (*a.k.a.* nominal), such as brands of clothing. To encode such data as position, a *point* or *band* scale is required. - -A *point* scale divides space into uniformly-spaced discrete values. It is commonly used for scatterplots (a [dot mark](../marks/dot.md)) of ordinal data. It is the default scale type for ordinal data on the *x* and *y* scale. - -:::plot https://observablehq.com/@observablehq/plot-discrete-scales -```js -Plot.plot({x: {type: "point", domain: "ABCDEFGHIJ", grid: true}}) -``` -::: - -A band scale divides space into uniformly-spaced and -sized discrete intervals. It is commonly used for bar charts (bar marks). To show the bands below, we use a [cell](../marks/cell.md) instead of a [grid](../marks/grid.md). - -:::plot https://observablehq.com/@observablehq/plot-discrete-scales -```js -Plot - .cell("ABCDEFGHIJ", {x: Plot.identity, stroke: "currentColor", strokeOpacity: 0.1}) - .plot({x: {type: "band", domain: "ABCDEFGHIJ"}}) -``` -::: - -While *point* and *band* scales appear visually similar when only the grid is visible, the two are not identical — they differ respective to padding. Play with the options below to get a sense of their effect on the scale’s behavior. - -<p> - <label class="label-input"> - <span>Padding:</span> - <input type="range" v-model.number="padding" min="0" max="1" step="0.01"> - <span style="font-variant-numeric: tabular-nums;">{{padding.toFixed(2)}}</span> - </label> - <label class="label-input"> - <span>Align:</span> - <input type="range" v-model.number="align" min="0" max="1" step="0.01"> - <span style="font-variant-numeric: tabular-nums;">{{align.toFixed(2)}}</span> - </label> -</p> - -:::plot hidden https://observablehq.com/@observablehq/plot-discrete-scales -```js -Plot.plot({ - grid: true, - marginTop: 0.5, - x: { - padding, - align, - round: false - }, - marks: [ - Plot.frame({strokeOpacity: 0.3}), - Plot.tickX("ABCDEFGHIJ", {x: Plot.identity, stroke: "currentColor"}) - ] -}) -``` -::: - -:::plot hidden https://observablehq.com/@observablehq/plot-discrete-scales -```js -Plot.plot({ - grid: true, - marginTop: 0.5, - x: { - padding, - align, - round: false - }, - marks: [ - Plot.frame({strokeOpacity: 0.3}), - Plot.cell("ABCDEFGHIJ", {x: Plot.identity, stroke: "currentColor"}) - ] -}) -``` -::: - -Position scales also have a **round** option which forces the scale to snap to integer pixels. This defaults to true for point and band scales, and false for quantitative scales. Use caution with high-cardinality ordinal domains (*i.e.*, a point or band scale used to encode many different values), as rounding can lead to “wasted” space or even zero-width bands. - -## Color scales - -While position is the most salient (and important) encoding, color matters too! The default quantitative color scale **type** is *linear*, and the default **scheme** is [*turbo*](https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html). A wide variety of sequential, diverging, and cyclical schemes are supported, including ColorBrewer and [*viridis*](http://bids.github.io/colormap/). - -<p> - <label class="label-input"> - Color scheme: - <select v-model="schemeq"> - <optgroup label="sequential, single-hue"> - <option value="blues">Blues</option> - <option value="greens">Greens</option> - <option value="greys">Greys</option> - <option value="purples">Purples</option> - <option value="reds">Reds</option> - <option value="oranges">Oranges</option> - </optgroup> - <optgroup label="sequential, multi-hue"> - <option value="turbo" selected>Turbo</option> - <option value="viridis">Viridis</option> - <option value="magma">Magma</option> - <option value="inferno">Inferno</option> - <option value="plasma">Plasma</option> - <option value="cividis">Cividis</option> - <option value="cubehelix">Cubehelix</option> - <option value="warm">Warm</option> - <option value="cool">Cool</option> - <option value="bugn">BuGn</option> - <option value="bupu">BuPu</option> - <option value="gnbu">GnBu</option> - <option value="orrd">OrRd</option> - <option value="pubugn">PuBuGn</option> - <option value="pubu">PuBu</option> - <option value="purd">PuRd</option> - <option value="rdpu">RdPu</option> - <option value="ylgnbu">YlGnBu</option> - <option value="ylgn">YlGn</option> - <option value="ylorbr">YlOrBr</option> - <option value="ylorrd">YlOrRd</option> - </optgroup> - <optgroup label="cyclical"> - <option value="rainbow">Rainbow</option> - <option value="sinebow">Sinebow</option> - </optgroup> - </select> - </label> -</p> - -:::plot hidden -```js -Plot.plot({ - axis: null, - padding: 0, - color: { - scheme: schemeq - }, - marks: [ - Plot.cell(d3.range(40), {x: Plot.identity, fill: Plot.identity, inset: -0.5}) - ] -}) -``` -::: - -You can implement a custom color scheme by specifying the scale’s **range**, or by passing an **interpolate** function that takes a parameter *t* in [0, 1]. The **interpolate** option can specify a color space such as *rgb*, or a two-argument function that takes a pair of values from the range. - -<p> - <label class="label-input"> - Color interpolate: - <select v-model="interpolateq"> - <option value="rgb">rgb</option> - <option value="lab">lab</option> - <option value="hcl">hcl</option> - <option value="hsl">hsl</option> - <option value="rgb-gamma">d3.interpolateRgb.gamma(2)</option> - <option value="angry-rainbow">(t) => `hsl(${t * 360},100%,50%)`</option> - </select> - </label> -</p> - -:::plot hidden -```js -Plot.plot({ - axis: null, - padding: 0, - color: { - type: "linear", - ...interpolateq === "angry-rainbow" - ? {interpolate: (t) => `hsl(${t * 360},100%,50%)`} - : interpolateq === "rgb-gamma" - ? {range: ["steelblue", "orange"], interpolate: d3.interpolateRgb.gamma(2)} - : {range: ["steelblue", "orange"], interpolate: interpolateq} - }, - marks: [ - Plot.cell(d3.range(40), {x: Plot.identity, fill: Plot.identity, inset: -0.5}) - ] -}) -``` -::: - -And like position scales, you can apply a *sqrt*, *pow*, *log*, or *symlog* transform; these are often useful when working with non-uniformly distributed data. - -Diverging color scales are intended to show positive and negative values, or more generally values above or below some **pivot** value. Diverging color scales default to the *RdBu* (red–blue) color scheme. The pivot defaults to zero, but you can change it with the **pivot** option, which should ideally be a value near the middle of the domain. - -<p> - <label class="label-input"> - Color scheme: - <select v-model="schemed"> - <optgroup label="diverging"> - <option value="brbg">BrBG</option> - <option value="prgn">PRGn</option> - <option value="piyg">PiYG</option> - <option value="puor">PuOr</option> - <option value="rdbu">RdBu</option> - <option value="rdgy">RdGy</option> - <option value="rdylbu">RdYlBu</option> - <option value="rdylgn">RdYlGn</option> - <option value="spectral">Spectral</option> - <option value="burd">BuRd</option> - <option value="buylrd">BuYlRd</option> - </optgroup> - </select> - </label> -</p> - -:::plot hidden -```js -Plot.plot({ - axis: null, - padding: 0, - color: { - type: "linear", - scheme: schemed - }, - marks: [ - Plot.cell(d3.range(40), {x: Plot.identity, fill: Plot.identity, inset: -0.5}) - ] -}) -``` -::: - -Below we again show observed global surface temperatures. The reversed *BuRd* color scheme is used since <span :style="{borderBottom: `solid 2px ${d3.interpolateRdBu(0.9)}`}">blue</span> and <span :style="{borderBottom: `solid 2px ${d3.interpolateRdBu(0.1)}`}">red</span> are semantically associated with cold and hot, respectively. - -:::plot https://observablehq.com/@observablehq/plot-diverging-color-scatterplot -```js -Plot.plot({ - grid: true, - color: { - type: "diverging", - scheme: "BuRd" - }, - marks: [ - Plot.ruleY([0]), - Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}) - ] -}) -``` -::: - -Plot also provides color schemes for discrete data. Use the *categorical* type for categorical (nominal) unordered data, and the *ordinal* type for ordered data. - -<p> - <label class="label-input"> - Color scheme: - <select v-model="schemeo"> - <optgroup label="categorical"> - <option>Accent</option> - <option>Category10</option> - <option>Dark2</option> - <option>Observable10</option> - <option>Paired</option> - <option>Pastel1</option> - <option>Pastel2</option> - <option>Set1</option> - <option>Set2</option> - <option>Set3</option> - <option>Tableau10</option> - </optgroup> - <optgroup label="sequential, single-hue"> - <option value="blues">Blues</option> - <option value="greens">Greens</option> - <option value="greys">Greys</option> - <option value="purples">Purples</option> - <option value="reds">Reds</option> - <option value="oranges">Oranges</option> - </optgroup> - <optgroup label="sequential, multi-hue"> - <option value="turbo" selected>Turbo</option> - <option value="viridis">Viridis</option> - <option value="magma">Magma</option> - <option value="inferno">Inferno</option> - <option value="plasma">Plasma</option> - <option value="cividis">Cividis</option> - <option value="cubehelix">Cubehelix</option> - <option value="warm">Warm</option> - <option value="cool">Cool</option> - <option value="bugn">BuGn</option> - <option value="bupu">BuPu</option> - <option value="gnbu">GnBu</option> - <option value="orrd">OrRd</option> - <option value="pubugn">PuBuGn</option> - <option value="pubu">PuBu</option> - <option value="purd">PuRd</option> - <option value="rdpu">RdPu</option> - <option value="ylgnbu">YlGnBu</option> - <option value="ylgn">YlGn</option> - <option value="ylorbr">YlOrBr</option> - <option value="ylorrd">YlOrRd</option> - </optgroup> - <optgroup label="cyclical"> - <option value="rainbow">Rainbow</option> - <option value="sinebow">Sinebow</option> - </optgroup> - </select> - </label> -</p> - -:::plot hidden -```js -Plot.plot({ - color: { - type: "ordinal", - scheme: schemeo - }, - marks: [ - Plot.cell("ABCDEFGHIJ", {x: Plot.identity, fill: Plot.identity}) - ] -}) -``` -::: - -:::warning CAUTION -Discrete color schemes are intended for data that has only a few unique values. If the size of the categorical domain exceeds the number of colors in the scheme, colors will be reused; combining values into an “other” category is recommended. -::: - -## Other scales - -But wait, there’s more! 😅 Plot has *opacity*, *r*, *symbol*, and *length* scales, too. For example, the *r* scale **type** defaults to *sqrt* such that when used with the [dot mark](../marks/dot.md), the resulting area is proportional to the **r** channel value. You can adjust the effective dot size by specifying an explicit **range**, as below. - -<p> - <label class="label-input"> - Radius: - <input type="range" v-model.number="radius" min="1" max="20" step="0.1"> - <span style="font-variant-numeric: tabular-nums;">{{radius.toFixed(1)}}</span> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-radius-scale-range -```js -Plot.plot({ - r: {range: [0, radius]}, - marks: [ - Plot.dot(d3.range(1, 11), {x: Plot.identity, r: Plot.identity, fill: "currentColor"}) - ] -}) -``` -::: - -The default **range** for the associated *r* scale is constructed such that a zero value maps to zero for an accurate areal encoding, while the first quartile of values is mapped to a radius of three pixels; this tends to be more stable with varying data. - -## Type inference - -Plot strives to be concise: rather than you laboriously specifying everything, Plot can guess by inspecting the data so you don’t have to set the **type**, **domain**, and **range** (and for color, **scheme**) of scales explicitly. But for Plot’s guesses to be accurate, your data must match Plot’s expectations. Here they are. - -A scale’s **type** is most often inferred from associated marks’ channel values: strings and booleans imply an *ordinal* scale; dates imply a *utc* scale; anything else is *linear*. Plot assumes that your data is consistently typed, so inference is based solely on the first non-null, non-undefined value. We recommend typed CSV (passing `{typed: true}` to Observable’s FileAttachment csv method) or explicitly coercing types when loading data (*e.g.*, d3.autoType). - -If a scale’s **domain** is specified explicitly, the scale’s **type** is inferred from the **domain** values rather than channels as described above. However, if the **domain** or **range** has more than two elements, the *ordinal* type (or *point* for position scales) is used. - -Finally, some marks declare the scale **type** for associated channels. For example, [barX](../marks/bar.md) requires *y* to be a *band* scale. Further, the facet scales *fx* and *fy* are always *band* scales, and the *r* (radius) scale is implicitly a *sqrt* scale. - -If you don’t specify a quantitative scale’s **domain**, it is the extent (minimum and maximum) of associated channel values, except for the *r* (radius) scale where it goes from zero to the maximum. A quantitative domain can be extended to “nice” human-readable values with the **nice** option. For an ordinal scale, the domain defaults to the sorted union (all distinct values in natural order) of associated values; see the [**sort** mark option](#sort-mark-option) to change the order. - -All position scales (*x*, *y*, *fx*, and *fy*) have implicit automatic ranges based on the chart dimensions. The *x* scale ranges from the left to right edge, while the *y* scale ranges from the bottom to top edge, accounting for margins. - -## Scale transforms - -The **transform** scale option allows you to apply a function to all values before they are passed through the scale. This is convenient for transforming a scale’s data, say to convert to thousands or between temperature units. - -:::plot defer https://observablehq.com/@observablehq/plot-fahrenheit-to-celsius-scale-transform -```js{5} -Plot.plot({ - y: { - grid: true, - label: "Temperature (°C)", - transform: (f) => (f - 32) * (5 / 9) // convert Fahrenheit to Celsius - }, - marks: [ - Plot.ruleY([32]), // 32°F - Plot.lineY(sftemp, Plot.windowY(7, {x: "date", y: "high"})) - ] -}) -``` -::: - -The **percent** scale option is shorthand for a **transform** that multiplies values by 100; it also adds a percent symbol (%) to the default label. - -:::plot https://observablehq.com/@observablehq/plot-percent-scale-transform -```js{2} -Plot.plot({ - y: {percent: true}, // convert proportion [0, 1] to percent [0, 100] - color: {scheme: "BuRd"}, - marks: [ - Plot.rectY(gistemp, Plot.binX({y: "proportion", fill: "x"}, {x: "Anomaly", fill: "Anomaly"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::warning CAUTION -[Mark transforms](./transforms.md) typically consume values *before* they are passed through scales (_e.g._, when binning). In this case the mark transforms will see the values prior to the scale transform as input, and the scale transform will apply to the *output* of the mark transform. -::: - -The **interval** scale option<a id="interval" href="#interval" aria-label="Permalink to "interval""></a> <VersionBadge version="0.5.1" /> sets an ordinal scale’s **domain** to the start of every interval within the extent of the data. In addition, it implicitly sets the **transform** of the scale to *interval*.floor, rounding values down to the start of each interval. For example, below we generate a time-series bar chart; when an **interval** is specified, missing days are visible. - -<p> - <label class="label-input"> - Use interval: - <input type="checkbox" v-model="intervaled"> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-band-scale-interval -```js -Plot.plot({ - marginBottom: 80, - x: { - tickRotate: -90, - interval: intervaled ? "day" : null, - label: null - }, - y: { - transform: (d) => d / 1e6, - label: "Daily trade volume (millions)" - }, - marks: [ - Plot.barY(aapl.slice(-40), {x: "Date", y: "Volume"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -As an added bonus, the **fontVariant** and **type** options are no longer needed because Plot now understands that the *x* scale, despite being *ordinal*, represents daily observations. -::: - -While the example above relies on the **interval** being promoted to the scale’s **transform**, the [stack](../transforms/stack.md), [bin](../transforms/bin.md), and [group](../transforms/group.md) transforms are also interval-aware: they apply the scale’s **interval**, if any, *before* grouping values. (This results in the interval being applied twice, both before and after the mark transform, but the second application has no effect since interval application is idempotent.) - -The **interval** option can also be used for quantitative and temporal scales. This enforces uniformity, say rounding timed observations down to the nearest hour, which may be helpful for the [stack transform](../transforms/stack.md) among other uses. - -## Scale options - -Each scale’s options are specified as a nested options object with the corresponding scale name within the top-level [plot options](./plots.md): - -* **x** - horizontal position -* **y** - vertical position -* **r** - radius (size) -* **color** - fill or stroke -* **opacity** - fill or stroke opacity -* **length** - linear length (for [vectors](../marks/vector.md)) -* **symbol** - categorical symbol (for [dots](../marks/dot.md)) - -For example, to set the domain for the *x* scale: - -:::plot -```js -Plot.plot({x: {domain: [new Date("1880-01-01"), new Date("2016-11-01")]}}) -``` -::: - -Plot supports many scale types. Some scale types are for quantitative data: values that can be added or subtracted, such as temperature or time. Other scale types are for ordinal or categorical data: unquantifiable values that can only be ordered, such as t-shirt sizes, or values with no inherent order that can only be tested for equality, such as types of fruit. Some scale types are further intended for specific visual encodings: for example, as position or color. - -You can set the scale type explicitly via the **type** scale option, though typically the scale type is inferred automatically. Some marks mandate a particular scale type: for example, [barY](../marks/bar.md) requires that the *x* scale is a *band* scale. Some scales have a default type: for example, the *r* (radius) scale defaults to *sqrt* and the *opacity* scale defaults to *linear*. Most often, the scale type is inferred from associated data, pulled either from the domain (if specified) or from associated channels. Strings and booleans imply an ordinal scale; dates imply a UTC scale; and anything else is linear. Unless they represent text, we recommend explicitly converting strings to more specific types when loading data (*e.g.*, with d3.autoType or Observable’s FileAttachment). For simplicity’s sake, Plot assumes that data is consistently typed; type inference is based solely on the first non-null, non-undefined value. - -For quantitative data (*i.e.* numbers), a mathematical transform may be applied to the data by changing the scale type: - -* *linear* (default) - linear transform (translate and scale) -* *pow* - power (exponential) transform -* *sqrt* - square-root transform (*pow* transform with exponent = 0.5) -* *log* - logarithmic transform -* *symlog* - bi-symmetric logarithmic transform per [Webber *et al.*](https://www.researchgate.net/publication/233967063_A_bi-symmetric_log_transformation_for_wide-range_data) - -The appropriate transform depends on the data’s distribution and what you wish to know. A *sqrt* transform exaggerates differences between small values at the expense of large values; it is a special case of the *pow* transform which has a configurable *scale*.**exponent** (0.5 for *sqrt*). A *log* transform is suitable for comparing orders of magnitude and can only be used when the domain does not include zero. The base defaults to 10 and can be specified with the *scale*.**base** option; note that this only affects the axis ticks and not the scale’s behavior. A *symlog* transform is more elaborate, but works well with wide-range values that include zero; it can be configured with the *scale*.**constant** option (default 1). - -For temporal data (*i.e.* dates), two variants of a *linear* scale are also supported: - -* *utc* (default, recommended) - UTC time -* *time* - local time - -UTC is recommended over local time as charts in UTC time are guaranteed to appear consistently to all viewers whereas charts in local time will depend on the viewer’s time zone. Due to limitations in JavaScript’s Date class, Plot does not yet support an explicit time zone other than UTC. - -For ordinal data (*e.g.*, strings), use the *ordinal* scale type or the *point* or *band* position scale types. The *categorical* scale type is also supported; it is equivalent to *ordinal* except as a color scale, where it provides a different default color scheme. (Since position is inherently ordinal or even quantitative, categorical data must be assigned an effective order when represented as position, and hence *categorical* and *ordinal* may be considered synonymous in context.) - -You can opt-out of a scale using the *identity* scale type. This is useful if you wish to specify literal colors or pixel positions within a mark channel rather than relying on the scale to convert abstract values into visual values. For position scales (*x* and *y*), an *identity* scale is still quantitative and may produce an axis, yet unlike a *linear* scale the domain and range are fixed based on the plot layout. - -:::tip -To opt-out of a scale for a single channel, you can specify the channel values as a `{value, scale}` object; see [mark options](./marks.md#mark-options). -::: - -Quantitative scales, as well as identity position scales, coerce channel values to numbers; both null and undefined are coerced to NaN. Similarly, time scales coerce channel values to dates; numbers are assumed to be milliseconds since UNIX epoch, while strings are assumed to be in [ISO 8601 format](https://github.com/mbostock/isoformat/blob/main/README.md#parsedate-fallback). - -A scale’s domain (the extent of its inputs, abstract values) and range (the extent of its outputs, visual values) are typically inferred automatically. You can set them explicitly using these options: - -* **domain** - typically [*min*, *max*], or an array of ordinal or categorical values -* **range** - typically [*min*, *max*], or an array of ordinal or categorical values -* **unknown** - the desired output value (defaults to undefined) for invalid input values -* **reverse** - reverses the domain (or the range), say to flip the chart along *x* or *y* -* **interval** - an interval or time interval (for interval data; see below) - -For most quantitative scales, the default domain is the [*min*, *max*] of all values associated with the scale. For the *radius* and *opacity* scales, the default domain is [0, *max*] to ensure a meaningful value encoding. For ordinal scales, the default domain is the set of all distinct values associated with the scale in natural ascending order; for a different order, set the domain explicitly or add a [**sort** option](#sort-mark-option) to an associated mark. For threshold scales, the default domain is [0] to separate negative and non-negative values. For quantile scales, the default domain is the set of all defined values associated with the scale. If a scale is reversed, it is equivalent to setting the domain as [*max*, *min*] instead of [*min*, *max*]. - -The default range depends on the scale: for position scales (*x*, *y*, *fx*, and *fy*), the default range depends on the [plot’s size and margins](./plots.md). For color scales, there are default color schemes for quantitative, ordinal, and categorical data. For opacity, the default range is [0, 1]. And for radius, the default range is designed to produce dots of “reasonable” size assuming a *sqrt* scale type for accurate area representation: zero maps to zero, the first quartile maps to a radius of three pixels, and other values are extrapolated. This convention for radius ensures that if the scale’s data values are all equal, dots have the default constant radius of three pixels, while if the data varies, dots will tend to be larger. - -The behavior of the **unknown** scale option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output. - -For data at regular intervals, such as integer values or daily samples, the [**interval** option](#scale-transforms) can be used to enforce uniformity. The specified *interval* — such as d3.utcMonth — must expose an *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) functions. The option can also be specified as a number, in which case it will be promoted to a numeric interval with the given step. The option can alternatively be specified as a string (*second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) <VersionBadge version="0.6.2" /> naming the corresponding time interval, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. This option sets the default *scale*.transform to the given interval’s *interval*.floor function. In addition, the default *scale*.domain is an array of uniformly-spaced values spanning the extent of the values associated with the scale. - -Quantitative scales can be further customized with additional options: - -* **clamp** - if true, clamp input values to the scale’s domain -* **nice** - if true (or a tick count), extend the domain to nice round values -* **zero** - if true, extend the domain to include zero if needed -* **percent** - if true, transform proportions in [0, 1] to percentages in [0, 100] - -Clamping is typically used in conjunction with setting an explicit domain since if the domain is inferred, no values will be outside the domain. Clamping is useful for focusing on a subset of the data while ensuring that extreme values remain visible, but use caution: clamped values may need an annotation to avoid misinterpretation. Top-level **clamp**, **nice**, and **zero** options are supported as shorthand for setting the respective option on all scales. - -The **transform** option allows you to apply a function to all values before they are passed through the scale. This is convenient for transforming a scale’s data, say to convert to thousands or between temperature units. - -```js -Plot.plot({ - y: { - label: "Temperature (°F)", - transform: (f) => f * 9 / 5 + 32 // convert Celsius to Fahrenheit - }, - marks: … -}) -``` - -### Color scale options - -The normal scale types — *linear*, *sqrt*, *pow*, *log*, *symlog*, and *ordinal* — can be used to encode color. In addition, Plot supports special scale types for color: - -* *categorical* - like *ordinal*, but defaults to *observable10* -* *sequential* - like *linear* -* *cyclical* - like *linear*, but defaults to *rainbow* -* *threshold* - discretizes using thresholds given as the **domain**; defaults to *rdylbu* -* *quantile* - discretizes by computing quantile thresholds; defaults to *rdylbu* -* *quantize* - discretizes by computing uniform thresholds; defaults to *rdylbu* <VersionBadge version="0.4.3" /> -* *diverging* - like *linear*, but with a pivot; defaults to *rdbu* -* *diverging-log* - like *log*, but with a pivot that defaults to 1; defaults to *rdbu* -* *diverging-pow* - like *pow*, but with a pivot; defaults to *rdbu* -* *diverging-sqrt* - like *sqrt*, but with a pivot; defaults to *rdbu* -* *diverging-symlog* - like *symlog*, but with a pivot; defaults to *rdbu* - -For a *threshold* scale, the **domain** represents *n* (typically numeric) thresholds which will produce a **range** of *n* + 1 output colors; the *i*th color of the **range** applies to values that are smaller than the *i*th element of the domain and larger or equal to the *i* - 1th element of the domain. For a *quantile* scale, the **domain** represents all input values to the scale, and the **n** option specifies how many quantiles to compute from the **domain**; **n** quantiles will produce **n** - 1 thresholds, and an output range of **n** colors. For a *quantize* scale, the domain will be transformed into approximately **n** quantized values, where **n** is an option that defaults to 5. - -By default, all diverging color scales are symmetric around the pivot; set **symmetric** to false if you want to cover the whole extent on both sides. - -Color scales support two additional options: - -* **scheme** - a named color scheme in lieu of a range, such as *reds* -* **interpolate** - in conjunction with a range, how to interpolate colors - -For quantile and quantize color scales, the **scheme** option is used in conjunction with **n**, which determines how many quantiles or quantized values to compute, and thus the number of elements in the scale’s range; it defaults to 5 (for quintiles in the case of a quantile scale). - -The following sequential scale schemes are supported for both quantitative and ordinal data: - -:::plot defer hidden -```js -Plot.plot({ - width: 322, - height: 25 * 27, - margin: 0, - marginRight: 70, - padding: 0, - x: {axis: null}, - y: {axis: "right", tickSize: 0}, - color: {type: "identity"}, - marks: [ - Plot.cell([ - ["Blues", d3.interpolateBlues], - ["Greens", d3.interpolateGreens], - ["Greys", d3.interpolateGreys], - ["Purples", d3.interpolatePurples], - ["Reds", d3.interpolateReds], - ["Oranges", d3.interpolateOranges], - ["Turbo", d3.interpolateTurbo], - ["Viridis", d3.interpolateViridis], - ["Magma", d3.interpolateMagma], - ["Inferno", d3.interpolateInferno], - ["Plasma", d3.interpolatePlasma], - ["Cividis", d3.interpolateCividis], - ["Cubehelix", d3.interpolateCubehelixDefault], - ["Warm", d3.interpolateWarm], - ["Cool", d3.interpolateCool], - ["BuGn", d3.interpolateBuGn], - ["BuPu", d3.interpolateBuPu], - ["GnBu", d3.interpolateGnBu], - ["OrRd", d3.interpolateOrRd], - ["PuBuGn", d3.interpolatePuBuGn], - ["PuBu", d3.interpolatePuBu], - ["PuRd", d3.interpolatePuRd], - ["RdPu", d3.interpolateRdPu], - ["YlGnBu", d3.interpolateYlGnBu], - ["YlGn", d3.interpolateYlGn], - ["YlOrBr", d3.interpolateYlOrBr], - ["YlOrRd", d3.interpolateYlOrRd], - ].flatMap(([name, i]) => d3.ticks(0, 1, 20).map((t) => [t, name, String(i(t))])), {fill: "2", insetTop: 0.5, insetBottom: 0.5}) - ] -}) -``` -::: - -The default color scheme, *turbo*, was chosen primarily to ensure high-contrast visibility. Color schemes such as *blues* make low-value marks difficult to see against a white background, for better or for worse. To use a subset of a continuous color scheme (or any single-argument *interpolate* function), set the *scale*.range property to the corresponding subset of [0, 1]; for example, to use the first half of the *rainbow* color scheme, use a range of [0, 0.5]. By default, the full range [0, 1] is used. If you wish to encode a quantitative value without hue, consider using *opacity* rather than *color* (e.g., use Plot.dot’s *strokeOpacity* instead of *stroke*). - -The following diverging scale schemes are supported: - -:::plot defer hidden -```js -Plot.plot({ - width: 322, - height: 25 * 11, - margin: 0, - marginRight: 70, - padding: 0, - x: {axis: null}, - y: {axis: "right", tickSize: 0}, - color: {type: "identity"}, - marks: [ - Plot.cell([ - ["BrBG", d3.interpolateBrBG], - ["PRGn", d3.interpolatePRGn], - ["PiYG", d3.interpolatePiYG], - ["PuOr", d3.interpolatePuOr], - ["RdBu", d3.interpolateRdBu], - ["RdGy", d3.interpolateRdGy], - ["RdYlBu", d3.interpolateRdYlBu], - ["RdYlGn", d3.interpolateRdYlGn], - ["Spectral", d3.interpolateSpectral], - ["BuRd", (t) => d3.interpolateRdBu(1 - t)], - ["BuYlRd", (t) => d3.interpolateRdYlBu(1 - t)], - ].flatMap(([name, i]) => d3.ticks(0, 1, 30).map((t) => [t, name, String(i(t))])), {fill: "2", insetTop: 0.5, insetBottom: 0.5}) - ] -}) -``` -::: - -Picking a diverging color scheme name defaults the scale type to *diverging*; set the scale type to *linear* to treat the color scheme as sequential instead. Diverging color scales support a *scale*.**pivot** option, which defaults to zero. Values below the pivot will use the lower half of the color scheme (*e.g.*, reds for the *rdgy* scheme), while values above the pivot will use the upper half (grays for *rdgy*). - -The following cylical color schemes are supported: - -:::plot defer hidden -```js -Plot.plot({ - width: 322, - height: 25 * 2, - margin: 0, - marginRight: 70, - padding: 0, - x: {axis: null}, - y: {axis: "right", tickSize: 0}, - color: {type: "identity"}, - marks: [ - Plot.cell([ - ["rainbow", d3.interpolateRainbow], - ["sinebow", d3.interpolateSinebow], - ].flatMap(([name, i]) => d3.ticks(0, 1, 30).map((t) => [t, name, String(i(t))])), {fill: "2", insetTop: 0.5, insetBottom: 0.5}) - ] -}) -``` -::: - -The following categorical color schemes are supported: - -:::plot defer hidden -```js -Plot.plot({ - width: 322, - height: 25 * 10, - margin: 0, - marginRight: 70, - padding: 0, - x: {axis: null}, - y: {axis: "right", tickSize: 0}, - color: {type: "identity"}, - marks: [ - Plot.cell([ - ["Accent", d3.schemeAccent], - ["Category10", d3.schemeCategory10], - ["Dark2", d3.schemeDark2], - ["Observable10", Plot.scale({color: {type: "categorical"}}).range], - ["Paired", d3.schemePaired], - ["Pastel1", d3.schemePastel1], - ["Pastel2", d3.schemePastel2], - ["Set1", d3.schemeSet1], - ["Set2", d3.schemeSet2], - ["Set3", d3.schemeSet3], - ["Tableau10", d3.schemeTableau10], - ].flatMap(([name, scheme]) => scheme.map((s, i) => [i, name, s])), {fill: "2", inset: 0.5}) - ] -}) -``` -::: - -The following color interpolators are supported: - -* *rgb* - RGB (red, green, blue) -* *hsl* - HSL (hue, saturation, lightness) -* *lab* - CIELAB (*a.k.a.* “Lab”) -* *hcl* - CIELCh<sub>ab</sub> (*a.k.a.* “LCh” or “HCL”) - -### Position scale options - -The position scales (*x*, *y*, *fx*, and *fy*) support additional options: - -* **inset** - inset the default range by the specified amount in pixels -* **round** - round the output value to the nearest integer (whole pixel) - -The *x* and *fx* scales support asymmetric insets for more precision. Replace inset by: - -* **insetLeft** - insets the start of the default range by the specified number of pixels -* **insetRight** - insets the end of the default range by the specified number of pixels - -Similarly, the *y* and *fy* scales support asymmetric insets with: - -* **insetTop** - insets the top of the default range by the specified number of pixels -* **insetBottom** - insets the bottom of the default range by the specified number of pixels - -The inset scale options can provide “breathing room” to separate marks from axes or the plot’s edge. For example, in a scatterplot with a Plot.dot with the default 3-pixel radius and 1.5-pixel stroke width, an inset of 5 pixels prevents dots from overlapping with the axes. The *scale*.round option is useful for crisp edges by rounding to the nearest pixel boundary. - -In addition to the generic *ordinal* scale type, which requires an explicit output range value for each input domain value, Plot supports special *point* and *band* scale types for encoding ordinal data as position. These scale types accept a [*min*, *max*] range similar to quantitative scales, and divide this continuous interval into discrete points or bands based on the number of distinct values in the domain (*i.e.*, the domain’s cardinality). If the associated marks have no effective width along the ordinal dimension — such as a dot, rule, or tick — then use a *point* scale; otherwise, say for a bar, use a *band* scale. - -Ordinal position scales support additional options, all specified as proportions in [0, 1]: - -* **padding** - how much of the range to reserve to inset first and last point or band -* **align** - where to distribute points or bands (0 = at start, 0.5 = at middle, 1 = at end) - -For a *band* scale, you can further fine-tune padding: - -* **paddingInner** - how much of the range to reserve to separate adjacent bands -* **paddingOuter** - how much of the range to reserve to inset first and last band - -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 implicitly generates an [axis mark](../marks/axis.md) for position scales if one is not explicitly declared. (For more control, declare the axis mark explicitly.) The following [axis mark options](../marks/axis.md#axis-options) are also available as scale options, applying to the implicit axis: - -* **axis** - the axis **anchor**: *top*, *bottom* (*x* or *fx*); *left*, *right* (*y* or *fy*); *both*; null to suppress -* **ticks** - the approximate number of ticks to generate, or interval, or array of values -* **tickSpacing** - the approximate number of pixels between ticks (if **ticks** is not specified) -* **tickSize** - the length of each tick (in pixels; default 6 for *x* and *y*, or 0 for *fx* and *fy*) -* **tickPadding** - the separation between the tick and its label (in pixels; default 3) -* **tickFormat** - either a function or specifier string to format tick values; see [Formats](./formats.md) -* **tickRotate** - whether to rotate tick labels (an angle in degrees clockwise; default 0) -* **fontVariant** - the font-variant attribute; defaults to *tabular-nums* if quantitative -* **label** - a string to label the axis -* **labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center* -* **labelArrow** - the label arrow: *auto* (default), *up*, *right*, *down*, *left*, *none*, or true <VersionBadge version="0.6.7" /> -* **labelOffset** - the label position offset (in pixels; default depends on margins and orientation) -* **ariaLabel** - a short label representing the axis in the accessibility tree -* **ariaDescription** - a textual description for the axis - -For an implicit [grid mark](../marks/grid.md), use the **grid** option. For an implicit [frame mark](../marks/frame.md) along one edge of the frame, use the **line** option. - -* **grid** - whether to draw grid lines across the plot for each tick -* **line** - if true, draw the axis line (only for *x* and *y*) - -Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facets](./facets.md)), **label**, **axis**, **inset**, **round**, **align**, and **padding**. If the **grid** option is true, show a grid using *currentColor*; if specified as a string, show a grid with the specified color; if an approximate number of ticks, an interval, or an array of tick values, show corresponding grid lines. - -## Sort mark option <VersionBadge version="0.2.0" /> - -If an ordinal scale’s domain is not set, it defaults to natural ascending order; to order the domain by associated values in another dimension, either compute the domain manually (consider [d3.groupSort](https://d3js.org/d3-array/group#groupSort)) or use an associated mark’s **sort** option. For example, to sort bars by ascending frequency rather than alphabetically by letter: - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y"}}) -``` - -The sort option is an object whose keys are ordinal scale names, such as *x* or *fx*, and whose values are mark channel names, such as **y**, **y1**, or **y2**. By specifying an existing channel rather than a new value, you avoid repeating the order definition and can refer to channels derived by [transforms](./transforms.md) (such as [stack](../transforms/stack.md) or [bin](../transforms/bin.md)). When sorting the *x* domain, if no **x** channel is defined, **x2** will be used instead if available, and similarly for *y* and **y2**; this is useful for marks that implicitly stack such as [area](../marks/area.md), [bar](../marks/bar.md), and [rect](../marks/rect.md). A sort value may also be specified as *width* or *height* <VersionBadge version="0.4.2" />, representing derived channels |*x2* - *x1*| and |*y2* - *y1*| respectively. - -Note that there may be multiple associated values in the secondary dimension for a given value in the primary ordinal dimension. The secondary values are therefore grouped for each associated primary value, and each group is then aggregated by applying a reducer. The default reducer is *max*, but may be changed by specifying the **reduce** option. Lastly the primary values are by default sorted based on the associated reduced value in natural ascending order to produce the domain. The above code is shorthand for: - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reduce: "max", order: "ascending"}}) -``` - -Generally speaking, a reducer only needs to be specified when there are multiple secondary values for a given primary value. See the [group transform](../transforms/group.md) for the list of supported reducers. - -For descending rather than ascending order, set the **order** option to *descending*: - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", order: "descending"}}) -``` - -Alternatively, the *-channel* shorthand option, which changes the default **order** to *descending*: - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "-y"}}) -``` - -Setting **order** to null will disable sorting, preserving the order of the data. (When an aggregating transform is used, such as [group](../transforms/group.md) or [bin](../transforms/bin.md), note that the data may already have been sorted and thus the order may differ from the input data.) - -Alternatively, set the **reverse** option to true. This produces a different result than descending order for null or unorderable values: descending order puts nulls last, whereas reversed ascending order puts nulls first. - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reverse: true}}) -``` - -An additional **limit** option truncates the domain to the first *n* values after ordering. If **limit** is negative, the last *n* values are used instead. Hence, a positive **limit** with **reverse** = true will return the top *n* values in descending order. If **limit** is an array [*lo*, *hi*], the *i*th values with *lo* ≤ *i* < *hi* will be selected. (Note that like the [basic filter transform](../transforms/filter.md), limiting the *x* domain here does not affect the computation of the *y* domain, which is computed independently without respect to filtering.) - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", limit: 5}}) -``` - -If different sort options are needed for different ordinal scales, the channel name can be replaced with a *value* object with additional per-scale options. - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: {value: "y", order: "descending"}}}) -``` - -If the input channel is *data*, then the reducer is passed groups of the mark’s data; this is typically used in conjunction with a custom reducer function, as when the built-in single-channel reducers are insufficient. - -Note: when the value of the sort option is a string or a function, it is interpreted as a mark [sort transform](../transforms/sort.md). To use both sort options and a mark sort transform, use [Plot.sort](../transforms/sort.md#sort). - -## scale(*options*) <VersionBadge version="0.4.0" /> {#scale} - -You can also create a standalone scale with Plot.**scale**(*options*). The *options* object must define at least one scale; see [Scale options](#scale-options) for how to define a scale. For example, here is a categorical color scale with the *Tableau10* color scheme and a domain of fruits: - -```js -const color = Plot.scale({color: {scheme: "Tableau10", domain: ["apple", "orange", "pear"]}}); -``` - -Both [*plot*.scale](./plots.md#plot_scale) and [Plot.scale](#scale) return scale objects. These objects represent the actual (or “materialized”) scale options used by Plot, including the domain, range, interpolate function, *etc.* The scale’s label, if any, is also returned; however, note that other axis properties are not currently exposed. Point and band scales also expose their materialized bandwidth and step. - -```js -color.domain // ["apple", "orange", "pear"] -``` - -For convenience, scale objects expose a *scale*.**apply**(*input*) method which returns the scale’s output for the given *input* value. When applicable, scale objects also expose a *scale*.**invert**(*output*) method which returns the corresponding input value from the scale’s domain for the given *output* value. - -```js -color.apply("apple") // "#4e79a7" -``` - -To apply a standalone scale object to a plot, pass it to Plot.plot as the corresponding scale options, such as **color**: - -:::plot -```js -Plot.cellX(["apple", "apple", "orange", "pear", "orange"]).plot({color}) -``` -::: - -As another example, below are two plots with different options where the second plot uses the *color* scale from the first plot: - -```js -const plot1 = Plot.plot({...options1}); -const plot2 = Plot.plot({...options2, color: plot1.scale("color")}); -``` diff --git a/docs/features/shorthand.md b/docs/features/shorthand.md deleted file mode 100644 index 8467e10a..00000000 --- a/docs/features/shorthand.md +++ /dev/null @@ -1,330 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; - -const numbers = [ - 170.16, 172.53, 172.54, 173.44, 174.35, 174.55, 173.16, 174.59, 176.18, 177.90, - 176.15, 179.37, 178.61, 177.30, 177.30, 177.25, 174.51, 172.00, 170.16, 165.53, - 166.87, 167.17, 166.00, 159.10, 154.83, 163.09, 160.29, 157.07, 158.50, 161.95, - 163.04, 169.79, 172.36, 172.05, 172.83, 171.80, 173.67, 176.35, 179.10, 179.26 -]; - -const timeSeries = [ - [new Date("2018-01-02"), 170.160004], - [new Date("2018-01-03"), 172.529999], - [new Date("2018-01-04"), 172.539993], - [new Date("2018-01-05"), 173.440002], - [new Date("2018-01-08"), 174.350006], - [new Date("2018-01-09"), 174.550003], - [new Date("2018-01-10"), 173.160004], - [new Date("2018-01-11"), 174.589996], - [new Date("2018-01-12"), 176.179993], - [new Date("2018-01-16"), 177.899994], - [new Date("2018-01-17"), 176.149994], - [new Date("2018-01-18"), 179.369995], - [new Date("2018-01-19"), 178.610001], - [new Date("2018-01-22"), 177.300003], - [new Date("2018-01-23"), 177.300003], - [new Date("2018-01-24"), 177.250000], - [new Date("2018-01-25"), 174.509995], - [new Date("2018-01-26"), 172.000000], - [new Date("2018-01-29"), 170.160004], - [new Date("2018-01-30"), 165.529999], - [new Date("2018-01-31"), 166.869995], - [new Date("2018-02-01"), 167.169998], - [new Date("2018-02-02"), 166.000000], - [new Date("2018-02-05"), 159.100006], - [new Date("2018-02-06"), 154.830002], - [new Date("2018-02-07"), 163.089996], - [new Date("2018-02-08"), 160.289993], - [new Date("2018-02-09"), 157.070007], - [new Date("2018-02-12"), 158.500000], - [new Date("2018-02-13"), 161.949997], - [new Date("2018-02-14"), 163.039993], - [new Date("2018-02-15"), 169.789993], - [new Date("2018-02-16"), 172.360001], - [new Date("2018-02-20"), 172.050003], - [new Date("2018-02-21"), 172.830002], - [new Date("2018-02-22"), 171.800003], - [new Date("2018-02-23"), 173.669998], - [new Date("2018-02-26"), 176.350006], - [new Date("2018-02-27"), 179.100006], - [new Date("2018-02-28"), 179.259995] -]; - -const matrix = [ - ["Jacob", "Olivia"], - ["Mia", "Noah"], - ["Noah", "Ava"], - ["Ava", "Mason"], - ["Olivia", "Noah"], - ["Jacob", "Emma"], - ["Ava", "Noah"], - ["Noah", "Jacob"], - ["Olivia", "Ava"], - ["Mason", "Emma"], - ["Jacob", "Mia"], - ["Mia", "Jacob"], - ["Emma", "Jacob"] -]; - -const gene = "AAAAGAGTGAAGATGCTGGAGACGAGTGAAGCATTCACTTTAGGGAAAGCGAGGCAAGAGCGTTTCAGAAGACGAAACCTGGTAGGTGCACTCACCACAG"; - -</script> - -# Shorthand <VersionBadge version="0.4.2" /> - -The most concise form of Plot is its **shorthand** syntax where no options are specified — only data. To use this shorthand, the data must have a specific structure: either a one-dimensional array of values [*v₀*, *v₁*, *v₂*, …] or a two-dimensional array of tuples [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …]. - -While none of these charts are particularly groundbreaking, we hope you find this shorthand convenient the next time you want a quick look at some data. And if the shorthand view is useful, you can then enhance it by adding options! - -## One dimension - -Let’s start with the one-dimensional form: an array of numbers. - -```js -numbers = [ - 170.16, 172.53, 172.54, 173.44, 174.35, 174.55, 173.16, 174.59, 176.18, 177.90, - 176.15, 179.37, 178.61, 177.30, 177.30, 177.25, 174.51, 172.00, 170.16, 165.53, - 166.87, 167.17, 166.00, 159.10, 154.83, 163.09, 160.29, 157.07, 158.50, 161.95, - 163.04, 169.79, 172.36, 172.05, 172.83, 171.80, 173.67, 176.35, 179.10, 179.26 -] -``` - -These numbers represent the daily opening price of Apple stock starting on January 1, 2018. For a simple line chart, we can pass the data to [Plot.lineY](../marks/line.md) to construct a line mark, and then call *line*.plot. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-line -```js -Plot.lineY(numbers).plot() -``` -::: - -The *y*-axis above represents price in U.S. dollars. The *x*-axis represents the index of the data: the first value {{numbers[0]}} is shown at *x* = 0, the second value {{numbers[1]}} at *x* = 1, and so on. In other words, *x* represents the number of (trading) days since January 1, 2018. It’d be nicer to have an *x*-axis that shows dates here, but it’s still convenient to see the trend in stock price quickly. - -If we pass the numbers to [Plot.areaY](../marks/area.md) instead, we’ll get a simple area chart with a baseline implicitly at *y* = 0. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-area -```js -Plot.areaY(numbers).plot() -``` -::: - -Similarly if we use [Plot.rectY](../marks/rect.md), we’ll get a series of vertical bars. This implicitly uses the [interval transform](../transforms/interval.md) such that the first rect spans from *x* = 0 to *x* = 1, the second from *x* = 1 to *x* = 2, and so on, with a horizontal inset to separate adjacent rects. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-rect -```js -Plot.rectY(numbers).plot() -``` -::: - -[Plot.barY](../marks/bar.md) produces a visually similar result but with different semantics: *x* is now ordinal (a *band* scale) rather than quantitative (*linear*). An ordinal axis labels every tick, which appear at the middle of each bar rather than between rects. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-bar -```js -Plot.barY(numbers).plot() -``` -::: - -Like Plot.barY, [Plot.cellX](../marks/cell.md) implies that *x* is ordinal. But now instead of a *y* channel the numeric value is encoded as the *fill* color. The default quantitative color scheme is *turbo*; higher values are reddish, and lower values blueish. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-cell -```js -Plot.cellX(numbers).plot() -``` -::: - -If we don’t care about the order of our data and we instead just want to look at the one-dimensional distribution of values, we can use [Plot.dotX](../marks/dot.md). - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-dot -```js -Plot.dotX(numbers).plot() -``` -::: - -Alternatively, we can use [Plot.ruleX](../marks/rule.md) to draw a vertical rule at each value. In this case, Plot.ruleX behaves identically to [Plot.tickX](../marks/tick.md). (If there *were* a *y* channel, then Plot.tickX would imply that *y* is ordinal whereas Plot.ruleX would imply that *y* is quantitative.) It is common to use the rule shorthand to annotate special *x* or *y* values in plots, such as *y* = 0, in conjunction with other marks. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-rule -```js -Plot.ruleX(numbers).plot() -``` -::: - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-tick -```js -Plot.tickX(numbers).plot() -``` -::: - -We could even use [Plot.vectorX](../marks/vector.md) here to draw little up-pointing arrows. (Typically the vector mark is used in conjunction with the **rotate** and **length** options to control the direction and magnitude of each vector.) - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-vector -```js -Plot.vectorX(numbers).plot() -``` -::: - -While not particularly readable due to occlusion, we can use [Plot.textX](../marks/text.md) to draw a label at each value, too. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-text -```js -Plot.textX(numbers).plot() -``` -::: - -For a more formal method of summarizing a one-dimensional distribution, we can use [Plot.boxX](../marks/box.md) to create a horizontal boxplot. The gray band represents the interquartile range; the black whiskers show the extrema (not including outliers); and the thick black stroke represents the median; any outliers (none in this dataset) are drawn as dots. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-box -```js -Plot.boxX(numbers).plot() -``` -::: - -Some of Plot’s transforms support shorthand syntax, too. For example, we can use Plot.rectY with [Plot.binX](../transforms/bin.md) to generate a histogram — another common way to visualize a one-dimensional distribution. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-histogram -```js -Plot.rectY(numbers, Plot.binX()).plot() -``` -::: - -Similarly [Plot.groupX](../transforms/group.md) can be used to group and count ordinal data, such as the frequency of bases in a random DNA sequence. - -```js -gene = "AAAAGAGTGAAGATGCTGGAGACGAGTGAAGCATTCACTTTAGGGAAAGCGAGGCAAGAGCGTTTCAGAAGACGAAACCTGGTAGGTGCACTCACCACAG" -``` - -:::plot https://observablehq.com/@observablehq/plot-shorthand-group -```js -Plot.barY(gene, Plot.groupX()).plot() -``` -::: - -And here’s the [dodge transform](../transforms/dodge.md) for a beeswarm plot: - -:::plot https://observablehq.com/@observablehq/plot-shorthand-dodge -```js -Plot.dotX(numbers, Plot.dodgeY()).plot() -``` -::: - -## Two dimensions - -Now let’s switch to a two-dimensional array of tuples [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …]. The *x*-values here are times (Date instances at UTC midnight); the *y*-values again are the daily opening price of Apple stock. - -```js -timeSeries = [ - [new Date("2018-01-02"), 170.160004], - [new Date("2018-01-03"), 172.529999], - [new Date("2018-01-04"), 172.539993], - [new Date("2018-01-05"), 173.440002], - [new Date("2018-01-08"), 174.350006], - [new Date("2018-01-09"), 174.550003], - [new Date("2018-01-10"), 173.160004], - [new Date("2018-01-11"), 174.589996], - [new Date("2018-01-12"), 176.179993], - [new Date("2018-01-16"), 177.899994], - [new Date("2018-01-17"), 176.149994], - [new Date("2018-01-18"), 179.369995], - [new Date("2018-01-19"), 178.610001], - [new Date("2018-01-22"), 177.300003], - [new Date("2018-01-23"), 177.300003], - [new Date("2018-01-24"), 177.250000], - [new Date("2018-01-25"), 174.509995], - [new Date("2018-01-26"), 172.000000], - [new Date("2018-01-29"), 170.160004], - [new Date("2018-01-30"), 165.529999], - [new Date("2018-01-31"), 166.869995], - [new Date("2018-02-01"), 167.169998], - [new Date("2018-02-02"), 166.000000], - [new Date("2018-02-05"), 159.100006], - [new Date("2018-02-06"), 154.830002], - [new Date("2018-02-07"), 163.089996], - [new Date("2018-02-08"), 160.289993], - [new Date("2018-02-09"), 157.070007], - [new Date("2018-02-12"), 158.500000], - [new Date("2018-02-13"), 161.949997], - [new Date("2018-02-14"), 163.039993], - [new Date("2018-02-15"), 169.789993], - [new Date("2018-02-16"), 172.360001], - [new Date("2018-02-20"), 172.050003], - [new Date("2018-02-21"), 172.830002], - [new Date("2018-02-22"), 171.800003], - [new Date("2018-02-23"), 173.669998], - [new Date("2018-02-26"), 176.350006], - [new Date("2018-02-27"), 179.100006], - [new Date("2018-02-28"), 179.259995] -] -``` - -If we pass this to Plot.line (*not* Plot.lineY as before), we’ll get another line chart, but now the *x*-axis shows the date rather than the zero-based index. Also, the *x*-values are no longer uniformly spaced, as there are gaps on the weekends and holidays when the markets are closed. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-temporal-line -```js -Plot.line(timeSeries).plot() -``` -::: - -Similarly Plot.area will produce the equivalent area chart, again with an implicit baseline at *y* = 0. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-temporal-area -```js -Plot.area(timeSeries).plot() -``` -::: - -There’s currently no two-dimensional shorthand for rect or bar, though you can use these marks to display time series data with options. - -Plot.dot will produce a scatterplot… - -:::plot https://observablehq.com/@observablehq/plot-shorthand-temporal-dot -```js -Plot.dot(timeSeries).plot() -``` -::: - -As will Plot.vector… - -:::plot https://observablehq.com/@observablehq/plot-shorthand-temporal-vector -```js -Plot.vector(timeSeries).plot() -``` -::: - -Plot.text also produces a scatterplot with labels showing the zero-based index of the data. Perhaps not very useful, but it at least shows the data’s order. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-temporal-text -```js -Plot.text(timeSeries).plot() -``` -::: - -Plot.cell also supports two-dimensional shorthand. As we saw above, Plot.cell implies that *x* and *y* are ordinal, so we shouldn’t pass temporal (dates) and quantitative (numbers) data; here’s a matrix diagram that shows which pairs exist in the dataset. You might use this, for example, to visualize who reviewed whose code. - -```js -matrix = [ - ["Jacob", "Olivia"], - ["Mia", "Noah"], - ["Noah", "Ava"], - ["Ava", "Mason"], - ["Olivia", "Noah"], - ["Jacob", "Emma"], - ["Ava", "Noah"], - ["Noah", "Jacob"], - ["Olivia", "Ava"], - ["Mason", "Emma"], - ["Jacob", "Mia"], - ["Mia", "Jacob"], - ["Emma", "Jacob"] -] -``` - -:::plot https://observablehq.com/@observablehq/plot-shorthand-cell -```js -Plot.cell(matrix).plot() -``` -::: - -## Caveats - -Plot has a few marks that don’t currently provide meaningful shorthand. The [arrow](../marks/arrow.md) and [link](../marks/link.md) marks both require a start (*x1*, *y1*) and end (*x2*, *y2*) point; and the [image](../marks/image.md) mark requires a source URL (*src*). diff --git a/docs/features/transforms.md b/docs/features/transforms.md deleted file mode 100644 index 4069fdbf..00000000 --- a/docs/features/transforms.md +++ /dev/null @@ -1,255 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {computed, shallowRef, onMounted} from "vue"; -import penguins from "../data/penguins.ts"; - -const bls = shallowRef([]); -const olympians = shallowRef([]); -const traffic = shallowRef(["Saarbrücken-Neuhaus", "Oldenburg (Holstein)", "Holz", "Göttelborn", "Riegelsberg", "Kastel", "Neustadt i. H.-Süd", "Nettersheim", "Hasborn", "Laufeld", "Otzenhausen", "Nonnweiler", "Kirschheck", "AS Eppelborn", "Bierfeld", "Von der Heydt", "Illingen", "Hetzerath", "Groß Ippener", "Bockel", "Ladbergen", "Dibbersen", "Euskirchen/Bliesheim", "Hürth", "Lotte", "Ascheberg", "Bad Schwartau", "Schloss Burg", "Uphusen", "HB-Silbersee", "Barsbüttel", "HB-Mahndorfer See", "Glüsingen", "HB-Weserbrücke", "Hengsen", "Köln-Nord", "Hagen-Vorhalle", "Unna"].map((location, i) => ({location, date: new Date(Date.UTC(2000, 0, 1, i)), vehicles: (10 + i) ** 2.382}))); -const bins = computed(() => d3.bin().thresholds(80).value((d) => d.weight)(olympians.value)); - -const scheme = Plot.scale({color: {type: "categorical"}}).range; - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); - d3.csv("../data/bls-metro-unemployment.csv", d3.autoType).then((data) => (bls.value = data)); - d3.csv("../data/traffic.csv", d3.autoType).then((data) => (traffic.value = data)); -}); - -</script> - -# Transforms - -**Transforms** derive data as part of the plot specification. This accelerates what is often the most onerous task in visualization: getting data into the right shape. - -For example, given a [dataset of highway traffic](https://gist.github.com/chrtze/c74efb46cadb6a908bbbf5227934bfea) measured as vehicles per hour by location, plotting every observation is straightforward: use a [tick](../marks/tick.md) (or [dot](../marks/dot.md)) and assign **x** = vehicles per hour and **y** = location. But to draw a quantifiable insight, we may want a summary statistic such as the *median* traffic by location. 👩‍💻 Below we use the [group transform](../transforms/group.md) to group by location and apply a *median* reducer to position the <span style="border-bottom: solid 2px var(--vp-c-red);">red</span> tick. - -:::plot defer https://observablehq.com/@observablehq/plot-sorted-groups -```js -Plot.plot({ - marginLeft: 120, - x: {label: "Vehicles per hour (thousands)", transform: (x) => x / 1000}, - y: {label: null}, - marks: [ - Plot.ruleX([0]), - Plot.tickX( - traffic, - {x: "vehicles", y: "location", strokeOpacity: 0.3} - ), - Plot.tickX( - traffic, - Plot.groupY( - {x: "median"}, - {x: "vehicles", y: "location", stroke: "red", strokeWidth: 4, sort: {y: "x"}} - ) - ) - ] -}) -``` -::: - -As you might expect, traffic varies significantly throughout the day, so perhaps it would be better to look at the median by hour by location? Instead of grouping only by **y**, we can group by both **x** and **y** to produce a heatmap. - -:::plot defer https://observablehq.com/@observablehq/plot-sorted-heatmap -```js-vue -Plot.plot({ - marginLeft: 120, - padding: 0, - y: {label: null}, - color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}", legend: true, zero: true}, - marks: [ - Plot.cell( - traffic, - Plot.group( - {fill: "median"}, - {x: (d) => d.date.getUTCHours(), y: "location", fill: "vehicles", inset: 0.5, sort: {y: "fill"}} - ) - ) - ] -}) -``` -::: - -Plot includes many useful transforms! For example, you can compute a [rolling average](../transforms/window.md) to smooth a noisy signal, [stack layers](../transforms/stack.md) for a streamgraph, or [dodge dots](../transforms/dodge.md) for a beeswarm. Plot’s various built-in transforms include: [bin](../transforms/bin.md), [centroid](../transforms/centroid.md), [dodge](../transforms/dodge.md), [filter](../transforms/filter.md), [group](../transforms/group.md), [hexbin](../transforms/hexbin.md), [interval](../transforms/interval.md), [map](../transforms/map.md), [normalize](../transforms/normalize.md), [reverse](../transforms/sort.md#reverse), [select](../transforms/select.md), [shuffle](../transforms/sort.md#shuffle), [sort](../transforms/sort.md), [stack](../transforms/stack.md), [tree](../transforms/tree.md), and [window](../transforms/window.md). If these don’t meet your needs, you can even implement a [custom transform](#custom-transforms). - -Transforms are never required — you can always aggregate and derive data yourself outside of Plot, and then pass in the binned values. For example, we could use [d3.bin](https://d3js.org/d3-array/bin) to compute a histogram of athletes’ weights as an array of {*x0*, *x1*, *length*} objects. - -```js -bins = d3.bin().thresholds(80).value((d) => d.weight)(olympians) -``` - -We can then pass that to the [rect mark](../marks/rect.md), assigning to the **x1**, **x2**, and **y2** channels: - -:::plot defer -```js -Plot.rectY(bins, {x1: "x0", x2: "x1", y2: "length"}).plot() -``` -::: - -:::info -This is for demonstration only; you wouldn’t normally bin “by hand” as shown here. -::: - -But Plot’s transforms are often more convenient, especially in conjunction with Plot’s other features such as [faceting](./facets.md) and automatic grouping by **z**. For example, if we want to add a color encoding to our histogram, we simply add the **fill** option and the bin transform partitions each bin accordingly; doing this with d3.bin would be a lot more work! - -:::plot defer https://observablehq.com/@observablehq/plot-vertical-histogram -```js -Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex"})).plot({color: {legend: true}}) -``` -::: - -Plot’s transforms typically take two *options* objects as arguments: the first object contains the *transform* options (above, `{y: "count"}`), while the second object contains *mark* options to be “passed through” to the mark (`{x: "weight", fill: "sex"}`). The transform returns a new options object representing the *transformed* mark options to be passed to a mark. - -Breaking down the above code: - -```js -const options = {x: "weight", fill: "sex"}; // initial mark options -const binOptions = {y: "count"}; // bin transform options -const binned = Plot.binX(binOptions, options); // transformed mark options -const rect = Plot.rectY(olympians, binned); // rect mark -const plot = rect.plot({color: {legend: true}}); // plot! -``` - -:::tip -If a transform isn’t doing what you expect, try inspecting the options object returned by the transform. Does it contain the options you expect? -::: - -Transforms can derive channels (such as **y** above) as well as changing the default options. For example, the bin transform sets default insets for a one-pixel gap between adjacent rects. - -Transforms are composable: you can pass *options* through more than one transform before passing it to a mark. For example, above it’s a bit difficult to compare the weight distribution by sex because there are fewer <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">female</span> than <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">male</span> athletes in the data. We can remove this effect using the [normalize transform](../transforms/normalize.md) with the *sum* reducer. - -:::plot defer https://observablehq.com/@observablehq/plot-overlapping-relative-histogram -```js-vue -Plot.plot({ - y: {percent: true}, - marks: [ - Plot.rectY( - olympians, - Plot.normalizeY( - "sum", // normalize each series by the sum per series - Plot.binX( - {y2: "count"}, // disable implicit stack transform - {x: "weight", fill: "sex", mixBlendMode: "{{$dark ? "screen" : "multiply"}}"} - ) - ) - ) - ] -}) -``` -::: - -And, as you may have wondered above, many of Plot’s [marks](./marks.md) provide implicit transforms: for example, the [rectY mark](../marks/rect.md) applies an implicit [stackY transform](../transforms/stack.md) if you use the **y** option, and the [dot mark](../marks/dot.md) applies an implicit [sort transform](../transforms/sort.md) to mitigate the effect of occlusion by drawing the smallest dots on top. - -## Custom transforms - -For greater control, you can also implement a custom **transform** function, allowing data, indexes, or channels to be derived prior to rendering. Custom transforms are rarely implemented directly; see the built-in transforms above. For example, below we implement the [filter transform](../transforms/filter.md) “by hand” as a custom transform to show the unemployment rates only in Michigan metropolitan divisions. - -:::plot defer https://observablehq.com/@observablehq/plot-custom-transform-example -```js{16-23} -Plot.plot({ - y: { - grid: true, - label: "Unemployment (%)" - }, - color: { - domain: [false, true], - range: ["#ccc", "red"] - }, - marks: [ - Plot.ruleY([0]), - Plot.line(bls, { - x: "date", - y: "unemployment", - z: "division", - transform: (data, facets) => ({ - data, - facets: facets.map((facet) => { - return facet.filter((i) => { - return /, MI /.test(data[i].division); - }); - }) - }) - }) - ] -}) -``` -::: - -The **transform** function is passed three arguments, *data*, *facets*, and *options* representing the mark’s data and facet indexes, and the plot’s options; it must then return a {*data*, *facets*} object with the transformed data and facet indexes. The *facets* are represented as a nested array of arrays such as [[0, 1, 3, …], [2, 5, 10, …], …]; each element in *facets* specifies the zero-based indexes of elements in *data* that are in a given facet (*i.e.*, have a distinct value in the associated *fx* or *fy* dimension). - -If the **transform** option is specified, it supersedes any basic transforms (*i.e.*, the **filter**, **sort** and **reverse** options are ignored). However, the **transform** option is rarely used directly; instead one of Plot’s built-in transforms are used, and these transforms automatically compose with the basic **filter**, **sort** and **reverse** transforms. - -While transform functions often produce new *data* or *facets*, they may return the passed-in *data* and *facets* as-is, and often have a side effect of constructing derived channels. For example, the count of elements in a [groupX transform](../transforms/group.md) might be returned as a new *y* channel. In this case, the transform is typically expressed as an options transform: a function that takes a mark *options* object and returns a new, transformed options object, where the returned options object implements the **transform** option. Transform functions should not mutate the input *data* or *facets*. Likewise options transforms should not mutate the input *options* object. - -When implementing a custom transform for generic usage, keep in mind that it needs to be compatible with Plot’s [faceting system](./facets.md), which partitions the original dataset into discrete subsets. - -## Custom initializers <VersionBadge version="0.5.0" /> - -Initializers are a special class of transform; whereas transforms operate in abstract data space, initializers operate in screen space such as pixel coordinates and colors. For example, initializers can modify a marks’ positions to avoid occlusion. Initializers are invoked *after* the initial scales are constructed and can modify the channels or derive new channels; these in turn may (or may not, as desired) be passed to scales. Plot’s [hexbin](../transforms/hexbin.md) and [dodge](../transforms/dodge.md) transforms are initializers. - -You can specify a custom initializer by specifying a function as the mark **initializer** option. This function is called after the scales have been computed, and receives as inputs the (possibly transformed) array of *data*, the *facets* index of elements of this array that belong to each facet, the input *channels* (as an object of named channels), the *scales*, and the *dimensions*. The mark itself is the *this* context. The initializer function must return an object with *data*, *facets*, and new *channels*. Any new channels are merged with existing channels, replacing channels of the same name. - -If an initializer desires a channel that is not supported by the downstream mark, additional channels can be declared using the mark **channels** option. - -## transform(*options*, *transform*) <VersionBadge version="0.4.3" /> {#transform} - -```js -Plot.transform(options, (data, facets) => { - return { - data, - facets: facets.map((I) => I.filter(() => Math.random() > 0.5)) - }; -}) -``` -Given an *options* object that may specify some basic transforms (**filter**, **sort**, or **reverse**) or a custom **transform** function, composes those transforms if any with the given *transform* function, returning a new *options* object. If a custom **transform** function is present on the given *options*, any basic transforms are ignored. Any additional input *options* are passed through in the returned *options* object. This method facilitates applying the basic transforms prior to applying the given custom *transform* and is used internally by Plot’s built-in transforms. - -## initializer(*options*, *initializer*) <VersionBadge version="0.5.0" /> {#initializer} - -This helper composes the *initializer* function with any other transforms present in the *options*, and returns a new *options* object. It is used internally by Plot’s built-in initializer transforms. - -## valueof(*data*, *value*, *type*) {#valueof} - -```js -Plot.valueof(aapl, "Close") -``` - -Given an iterable *data* and some *value* accessor, returns an array (a column) of the specified *type* with the corresponding value of each element of the data. The *value* accessor may be one of the following types: - -* a string - corresponding to the field accessor (`(d) => d[value]`) -* an accessor function - called as *type*.from(*data*, *value*) -* a number, Date, or boolean — resulting in an array uniformly filled with the *value* -* an object with a **transform** method — called as *value*.transform(*data*) -* an array of values - returning the same -* null or undefined - returning the same - -If *type* is specified, it must be Array or a similar class that implements the [Array.from](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from) interface such as a typed array. When *type* is Array or a typed array class, the return value of valueof will be an instance of the same (or null or undefined). When *type* is a typed array, values will be implicitly coerced numbers, and if *type* is Float64Array, Float32Array, or a subclass of the same, null values will be implicitly replaced with NaN. If *type* is not specified, valueof may return either an array or a typed array (or null or undefined). - -valueof is not guaranteed to return a new array. When a transform method is used, or when the given *value* is an array that is compatible with the requested *type*, the array may be returned as-is without making a copy. - -## column(*source*) <VersionBadge version="0.4.3" /> {#column} - -```js -const [X, setX] = Plot.column(); -``` - -This helper for constructing derived columns returns a [*column*, *setColumn*] array. The *column* object implements *column*.transform, returning whatever value was most recently passed to *setColumn*. If *setColumn* is not called, then *column*.transform returns undefined. If a *source* is specified, then *column*.label exposes the given *source*’s label, if any: if *source* is a string as when representing a named field of data, then *column*.label is *source*; otherwise *column*.label propagates *source*.label. This allows derived columns to propagate a human-readable axis or legend label. - -This method is used by Plot’s transforms to derive channels; the associated columns are populated (derived) when the **transform** option function is invoked. - -## identity <VersionBadge version="0.6.2" /> {#identity} - -```js -Plot.contour(data, {width: w, height: h, fill: Plot.identity}) -``` - -This channel helper returns a source array as-is, avoiding an extra copy when defining a channel as being equal to the data. - -## indexOf <VersionBadge version="0.6.6" /> {#indexOf} - -```js -Plot.lineY(numbers, {x: Plot.indexOf, y: Plot.identity}) -``` - -This channel helper returns an array of numbers [0, 1, 2, 3, …]. It is used internally by marks with zero-based index defaults for channels. diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 6f13bfd9..00000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,486 +0,0 @@ ---- -next: - text: Plots - link: /features/plots ---- - -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; - -</script> - -# Getting started - -Replot supports a variety of environments, including a first-class [React component API](#plot-in-react) for building charts with declarative JSX. - -## Try Plot online - -The fastest way to get started (and get help) with Replot is on [Observable](https://observablehq.com)! Plot is available by default in notebooks as part of Observable’s standard library. To use Plot, simply return the generated plot from a cell like so: - -:::plot https://observablehq.com/@observablehq/plot-normal-histogram -```js -Plot.rectY({length: 10000}, Plot.binX({y: "count"}, {x: d3.randomNormal()})).plot() -``` -::: - -Observable includes a variety of Plot snippets when you click **+** to add a cell, as well as convenient [sample datasets](https://observablehq.com/@observablehq/sample-datasets) to try out Plot features. Or upload a CSV or JSON file to start playing with your data. You can even use [Observable’s chart cell](https://observablehq.com/@observablehq/chart-cell), which uses Plot’s [auto mark](./marks/auto.md) under the hood, to create quick charts without writing code! You can then eject to JavaScript by clicking **+** to see the equivalent Plot code. - -<figure> - <video autoplay loop muted playsinline style="width: 688px; max-width: 100%; aspect-ratio: 688 / 488; border: solid 1px var(--vp-c-text-3); display: inline;"> - <source src="https://videos.ctfassets.net/uklh5xrq1p2j/14CmTWsGQifvA5jZ8s0Usw/6efc7defa063038f8eb65bb269cb3823/Chart_Cell_Demo_Take_2_shorter.mp4" type="video/mp4"> - </video> - <figcaption>Observable’s chart cell lets you quickly create charts and then eject to Plot code.</figcaption> -</figure> - -Observable is free for public use. Sign up for a [Pro account](https://observablehq.com/pricing) to connect to private databases, collaborate on private notebooks, and more. - -## Plot in vanilla HTML - -In vanilla HTML, you can load Plot from a CDN such as jsDelivr or you can download it locally. We recommend using the CDN-hosted ES module bundle as it automatically loads Plot’s dependency on [D3](https://d3js.org). But for those who need it, we also provide a UMD bundle that exports the `Plot` global when loaded as a plain script. - -:::code-group -```html [ESM + CDN] -<!DOCTYPE html> -<div id="myplot"></div> -<script type="module"> - -import * as Plot from "https://cdn.jsdelivr.net/npm/@dave-hillier/replot/+esm"; - -const plot = Plot.rectY({length: 10000}, Plot.binX({y: "count"}, {x: Math.random})).plot(); -const div = document.querySelector("#myplot"); -div.append(plot); - -</script> -``` - -```html [UMD + CDN] -<!DOCTYPE html> -<div id="myplot"></div> -<script src="https://cdn.jsdelivr.net/npm/d3@7"></script> -<script src="https://cdn.jsdelivr.net/npm/@dave-hillier/replot"></script> -<script type="module"> - -const plot = Plot.rectY({length: 10000}, Plot.binX({y: "count"}, {x: Math.random})).plot(); -const div = document.querySelector("#myplot"); -div.append(plot); - -</script> -``` - -```html [UMD + local] -<!DOCTYPE html> -<div id="myplot"></div> -<script src="d3.js"></script> -<script src="plot.js"></script> -<script type="module"> - -const plot = Plot.rectY({length: 10000}, Plot.binX({y: "count"}, {x: Math.random})).plot(); -const div = document.querySelector("#myplot"); -div.append(plot); - -</script> -``` -::: - -Plot returns a detached DOM element — either an [SVG](https://developer.mozilla.org/en-US/docs/Web/SVG) or [HTML figure](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/figure) element. In vanilla web development, this means you need to insert the generated plot into the page to see it. Typically this is done by selecting a DOM element (such as a DIV with a unique identifier, like `myplot` above), and then calling [*element*.append](https://developer.mozilla.org/en-US/docs/Web/API/Element/append). - -If you’d prefer to run Plot locally (or entirely offline), you can download the UMD bundle of Plot along with its dependency, D3, here: - -- <a href="./d3.js" download>d3.js</a> -- <a href="./plot.js" download>plot.js</a> - -Then, create an `index.html` file as shown above in the **UMD + local** tab. If you prefer smaller minified files, you can download <a href="./d3.min.js" download>d3.min.js</a> and <a href="./plot.min.js" download>plot.min.js</a>, and then update the `src` attributes above accordingly. - -A note on size: because the imperative `plot()` API renders through React (via `react-dom/server`), the UMD bundle ships React and is considerably larger than upstream Observable Plot’s — roughly 1 MB unminified, 426 kB minified, and 139 kB gzipped. Script-tag usage works fine, but if bundle size matters, install from npm and use a bundler instead: your application then shares a single copy of React, and unused code can be tree-shaken. - -## Installing from npm - -If you’re developing a web application using Node, you can install Plot via yarn, npm, pnpm, or your preferred package manager. Replot is published under the scoped name `@dave-hillier/replot`, because the bare name `replot` belongs to an unrelated package on npm. While the API is still settling, releases are prereleases published under the `next` dist-tag, so add `@next` to the install commands below until 0.1.0 is out. - -:::code-group - -```bash [yarn] -yarn add @dave-hillier/replot -``` - -```bash [npm] -npm install @dave-hillier/replot -``` - -```bash [pnpm] -pnpm add @dave-hillier/replot -``` - -::: - -You can then load Plot into your app. For **React**, import from `@dave-hillier/replot/react`: - -```js -import {Plot, Dot, Line, BarY, AxisX, AxisY} from "@dave-hillier/replot/react"; -``` - -For the **imperative API** (vanilla JS or non-React frameworks), import from the main entry point: - -```js -import * as Plot from "@dave-hillier/replot"; -``` - -You can instead import specific symbols if you prefer: - -```js -import {barY, groupX} from "@dave-hillier/replot"; -``` - -Plot includes TypeScript declarations with extensive documentation. We highly recommend using an editor with enhanced code completion such as Visual Studio Code or Observable. - -<figure> - <img style="border: solid 1px var(--vp-c-text-3); display: inline; width: 688px; max-width: 100%; aspect-ratio: 420 / 197;" src="./ts-property.png"> - <figcaption>Modern editors surface documentation and type hints as you write Plot code.</figcaption> -</figure> - -## Plot in React - -Plot provides a first-class React component API via `@dave-hillier/replot/react`. Instead of imperatively building SVG with D3 selections, you define charts declaratively with JSX that integrates natively with React's rendering model, state management, and component lifecycle. - -### Basic usage - -Import the `Plot` root component and mark components, then compose them as JSX: - -:::code-group -```jsx [App.jsx] -import {Plot, Dot, AxisX, AxisY} from "@dave-hillier/replot/react"; -import penguins from "./penguins.json"; - -export default function App() { - return ( - <div> - <h1>Penguins</h1> - <Plot width={640} height={400}> - <Dot data={penguins} x="culmen_length_mm" y="culmen_depth_mm" fill="species" /> - <AxisX /> - <AxisY /> - </Plot> - </div> - ); -} -``` -::: - -The `<Plot>` component handles scale inference, axis generation, and layout automatically. Pass scale options as props on `<Plot>`, and channel mappings as props on mark components. Every mark available in the imperative API has a corresponding React component: `<Dot>`, `<Line>`, `<BarX>`, `<BarY>`, `<Area>`, `<Rect>`, `<Text>`, `<Rule>`, `<Tick>`, and [many more](./api.md). - -### With transforms - -Transforms such as binning, grouping, and stacking are available as wrapper components: nest a mark inside the wrapper, with the transform options as props on the wrapper and the input channels on the mark. - -:::code-group -```jsx [Histogram.jsx] -import {Plot, BarY, RuleY, BinX} from "@dave-hillier/replot/react"; - -export default function Histogram({data}) { - return ( - <Plot> - <BinX y="count"> - <BarY data={data} x="value" fill="steelblue" /> - </BinX> - <RuleY data={[0]} /> - </Plot> - ); -} -``` -::: - -Transforms are also available as pure functions (`binX`, `groupX`, `stackY`, …) whose result you can spread onto a mark: `<BarY data={data} {...binX({y: "count"}, {x: "value"})} />`. - -### With scale options - -Configure scales by passing options directly to `<Plot>`: - -:::code-group -```jsx [TemperatureChart.jsx] -import {Plot, Dot, RuleY} from "@dave-hillier/replot/react"; - -export default function TemperatureChart({data}) { - return ( - <Plot y={{grid: true}} color={{scheme: "burd"}}> - <RuleY data={[0]} /> - <Dot data={data} x="Date" y="Anomaly" stroke="Anomaly" /> - </Plot> - ); -} -``` -::: - -### With facets - -Use the `fx` or `fy` props on mark components to create small multiples: - -:::code-group -```jsx [FacetedChart.jsx] -import {Plot, Dot} from "@dave-hillier/replot/react"; - -export default function FacetedChart({data}) { - return ( - <Plot> - <Dot data={data} x="culmen_length_mm" y="culmen_depth_mm" fx="species" /> - </Plot> - ); -} -``` -::: - -### With tooltips - -Add interactive tooltips with the `tip` prop on any mark, or use the `<Tip>` component for more control: - -:::code-group -```jsx [InteractiveChart.jsx] -import {Plot, Dot} from "@dave-hillier/replot/react"; - -export default function InteractiveChart({data}) { - return ( - <Plot> - <Dot data={data} x="weight" y="height" fill="species" tip /> - </Plot> - ); -} -``` -::: - -### With legends - -Use the `<Legend>` component to add a legend for any scale: - -:::code-group -```jsx [LegendChart.jsx] -import {Plot, Dot, Legend} from "@dave-hillier/replot/react"; - -export default function LegendChart({data}) { - return ( - <Plot color={{legend: true}}> - <Dot data={data} x="weight" y="height" fill="species" /> - </Plot> - ); -} -``` -::: - -### Figure wrapping - -Use the `title`, `subtitle`, and `caption` props on `<Plot>` to wrap the chart in a `<figure>` element: - -:::code-group -```jsx [FigureChart.jsx] -import {Plot, Line} from "@dave-hillier/replot/react"; - -export default function FigureChart({data}) { - return ( - <Plot title="Temperature over time" subtitle="Global average" caption="Source: NASA"> - <Line data={data} x="date" y="temperature" /> - </Plot> - ); -} -``` -::: - -### Async data loading - -Combine React's `useState` and `useEffect` with Plot components: - -:::code-group -```jsx [AsyncChart.jsx] -import {Plot, Dot, RuleY} from "@dave-hillier/replot/react"; -import * as d3 from "d3"; -import {useEffect, useState} from "react"; - -export default function AsyncChart() { - const [data, setData] = useState(); - - useEffect(() => { - d3.csv("/gistemp.csv", d3.autoType).then(setData); - }, []); - - if (!data) return <p>Loading…</p>; - - return ( - <Plot y={{grid: true}} color={{scheme: "burd"}}> - <RuleY data={[0]} /> - <Dot data={data} x="Date" y="Anomaly" stroke="Anomaly" /> - </Plot> - ); -} -``` -::: - -### Imperative approach (legacy) - -If you prefer the imperative API, you can still use `Plot.plot()` with `useRef` and `useEffect`: - -:::code-group -```jsx [LegacyApp.jsx] -import * as Plot from "@dave-hillier/replot"; -import {useEffect, useRef} from "react"; - -export default function LegacyApp({data}) { - const containerRef = useRef(); - - useEffect(() => { - const plot = Plot.plot({ - y: {grid: true}, - marks: [ - Plot.dot(data, {x: "x", y: "y"}) - ] - }); - containerRef.current.append(plot); - return () => plot.remove(); - }, [data]); - - return <div ref={containerRef} />; -} -``` -::: - -The declarative React component API is preferred for most applications because it integrates naturally with React's state management, avoids manual DOM manipulation, and updates automatically when your data or props change. The imperative approach generates a detached SVG element and requires manual cleanup, so it is best reserved for integrating with existing imperative Plot code. - -## Plot in Vue - -As with React, you can use either server- or client-side rendering with Plot and Vue. - -For server-side rendering (SSR), use the [**document** plot option](./features/plots.md) to render to Vue’s virtual DOM. For example, here is a PlotFigure component: - -:::code-group -```js [PlotFigure.js] -import * as Plot from "@dave-hillier/replot"; -import {h} from "vue"; - -export default { - props: { - options: Object - }, - render() { - return Plot.plot({ - ...this.options, - document: new Document() - }).toHyperScript(); - } -}; -``` -::: - -:::info -For brevity, the virtual `Document` implementation is not shown. You’ll find it linked below. -::: - -Then, to use: - -:::code-group -```vue [App.vue] -<script setup> -import * as Plot from "@dave-hillier/replot"; -import PlotFigure from "./components/PlotFigure.js"; -import penguins from "./assets/penguins.json"; -</script> - -<template> - <h1>Plot + Vue</h1> - <PlotFigure - :options="{ - marks: [ - Plot.dot(penguins, {x: 'culmen_length_mm', y: 'culmen_depth_mm'}), - ], - }" - /> -</template> -``` -::: - -See our [Plot + Vue CodeSandbox](https://codesandbox.io/p/sandbox/plot-vue-jlgg2w?file=/src/App.vue) for details. - -For client-side rendering, use a [render function](https://vuejs.org/guide/extras/render-function.html) with a [mounted](https://vuejs.org/api/options-lifecycle.html#mounted) lifecycle directive. After the component mounts, render the plot and then insert it into the page. - -```js -import * as Plot from "@dave-hillier/replot"; -import {h, withDirectives} from "vue"; - -export default { - props: ["options"], - render() { - const {options} = this; - return withDirectives(h("div"), [ - [ - { - mounted(el) { - el.append(Plot.plot(options)); - } - } - ] - ]); - } -}; -``` - -As with React, to update your plot for whatever reason, simply render a new one and replace the old one. You can find more examples on [our GitHub](https://github.com/observablehq/plot/tree/main/docs) as this documentation site is built with VitePress and uses both client- and server-side rendering for plots! - -## Plot in Svelte - -Here’s an example of client-side rendering in Svelte. For server-side rendering, see [#1759](https://github.com/observablehq/plot/discussions/1759). - -:::code-group -```svelte [App.svelte] -<script lang="ts"> - import * as Plot from '@dave-hillier/replot'; - import * as d3 from 'd3'; - - let div: HTMLElement | undefined = $state(); - let data = $state(d3.ticks(-2, 2, 200).map(Math.sin)); - - function onMousemove(event: MouseEvent) { - const [x, y] = d3.pointer(event); - data = data.slice(-200).concat(Math.atan2(x, y)); - } - - $effect(() => { - div?.firstChild?.remove(); // remove old chart, if any - div?.append(Plot.lineY(data).plot({ grid: true })); // add the new chart - }); -</script> - -<div onmousemove={onMousemove} bind:this={div} role="img"></div> -``` -::: - -See our [Plot + Svelte REPL](https://svelte.dev/playground/e65b5c87ae7e44239cef41ec3df28f52?version=5.2.7) for details. - -## Plot in Node.js - -You can use Plot to server-side render SVG or PNG in Node.js. Use [JSDOM](https://github.com/jsdom/jsdom) for a DOM implementation via the **document** option, then serialize the generated plot using **outerHTML**. - -```js -import {readFile} from "node:fs/promises"; -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {JSDOM} from "jsdom"; - -const penguins = d3.csvParse(await readFile("./penguins.csv", "utf-8"), d3.autoType); - -const plot = Plot.plot({ - document: new JSDOM("").window.document, - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}) - ] -}); - -plot.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); -plot.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); - -process.stdout.write(plot.outerHTML); -``` - -To rasterize SVG as PNG, you could use [canvg](https://github.com/canvg/canvg) and [node-canvas](https://github.com/Automattic/node-canvas), or [sharp](https://sharp.pixelplumbing.com/): - -```js -process.stdout.write(await sharp(Buffer.from(plot.outerHTML, "utf-8")).png().toBuffer()); -``` - -For better font rendering, consider [Puppeteer](https://pptr.dev/). diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index db5ffaa8..00000000 --- a/docs/index.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -# https://vitepress.dev/reference/default-theme-home-page -layout: home -titleTemplate: "The JavaScript library for exploratory data visualization" - -head: - - - link - - rel: canonical - href: https://observablehq.com/plot/ - - - meta - - name: title - content: Replot - - - meta - - name: description - content: The JavaScript library for exploratory data visualization - - - meta - - name: twitter:card - content: summary_large_image - - - meta - - name: twitter:site - content: "@observablehq" - - - meta - - property: og:description - content: The JavaScript library for exploratory data visualization - - - meta - - property: og:image - content: https://static.observableusercontent.com/thumbnail/64f414fef8a91248865f5759641b0cf537bc87c0aaf57dc368ffe673013eccaa.jpg - - - meta - - property: og:site_name - content: Observable - - - meta - - property: og:title - content: Replot - - - meta - - property: og:type - content: article - - - meta - - property: og:url - content: https://observablehq.com/plot/ - -hero: - name: "Replot" - text: "The JavaScript library for exploratory data visualization" - tagline: "Create expressive charts with concise code" - image: - src: /plot.svg - alt: Replot - actions: - - theme: brand - text: Get started - link: /getting-started - - theme: alt - text: What is Plot? - link: /what-is-plot - - theme: alt - text: Examples - link: https://observablehq.com/@observablehq/plot-gallery - -features: - - title: Marks - details: "Plot doesn’t have chart types. Instead, it has layered geometric shapes such as bars, dots, and lines." - link: /features/marks - - title: Scales - details: "Scales map an abstract value such as time or temperature to a visual value such as position or color." - link: /features/scales - - title: Transforms - details: "Derive data on-the-fly while plotting, say to bin quantitative values or compute a rolling average." - link: /features/transforms - - title: Facets - details: "Small multiples facilitate comparison by repeating a plot across partitions of data." - link: /features/facets - - title: Projections - details: "Plot supports GeoJSON and D3’s spherical projection system for geographic maps." - link: /features/projections - - title: React components - details: "Use Plot natively in React with declarative JSX components like <Plot>, <Dot>, and <Line> — no refs or effects needed." - link: /getting-started#plot-in-react - - title: Built with D3 - details: "Plot is built by the same team as D3. If you know some D3, you'll be right at home with Plot." - link: https://d3js.org - linkText: Visit D3 - - title: Built by Observable - details: Plot is developed by Observable, the platform for collaborative data analysis. - link: https://observablehq.com - linkText: Visit Observable ---- - -<style> - -:root { - --vp-home-hero-name-color: transparent; - --vp-home-hero-name-background: linear-gradient(-30deg, var(--hero-brand-contrast), var(--vp-c-brand-1)); -} - -:root.dark .VPHero .VPImage { - filter: drop-shadow(0 4px 8px black); -} - -</style> - -<script setup> - -import {onMounted} from "vue"; - -onMounted(() => { - const p = document.querySelector(".VPHero .text"); - const s = document.querySelector("#hero-text"); - if (!p || !s) return; - while (p.lastChild) p.lastChild.remove(); - p.append(s); -}); - -</script> - -<template> - <div id="hero-text">The JavaScript library for - <span style="display: inline-block; position: relative;">exploratory<svg style="color: var(--vp-c-brand-3); position: absolute; z-index: -1; top: 1em; left: 0.2em; width: calc(100% - 0.7em); height: auto;" width="240" height="11" viewBox="0 0 240 11" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M20.766 10.187c.939-.024.386-.885.552-1.401 1.105-.301.553.626.962 1.061.685-.263 1.171-1.1 1.696-1.085.044.144.15.191.044.378.697-.736 2.21-.134 2.995-1.052a.55.55 0 0 1 .127.215 3.35 3.35 0 0 1-.204-1.204c.42-.034.751-.593.94 0-.255 0-.266.23-.377.416l.426-.273c.448.813-.586.316-.553.927.84-.306 1.802-1.037 2.476-.831.182.803-1.525.339-.608 1.023l-1.033-.268c.85 1.248-.625-.057.171 1.276 1.348.177 1.47-.478 2.818-.3.276-.479-.132-.66.144-1.124 1.857-.885 1.602 1.984 2.94.846.337-.555.42-1.582 1.442-1.08l-.276.889c1.298.038.668-1.348 2.06-.784-.226.368-1.005.344-.8.444.917.689.59-.545 1.27-.569l.16.827c1.371-.181 2.863-.827 4.388-1.037-.072.249-.326.512.044.746 1.912-.478 4.123-.058 6.007.368l.68-.727c.05.015.095.04.132.074a.275.275 0 0 1 .077.118c.014.044.015.091.004.136a.27.27 0 0 1-.07.122c.74.243 0-.445.354-.732.414-.062.552.383.315.603 1.248-.636 3.586-1.401 4.973-.694l-.254.22c1.06.249 1.105-1.477 2.127-.855l-.182.129c2.293.23 4.785-.478 6.564.52.293-1.017 2.272.393 2.365-1.022 1.327.664.967.927 2.813 1.348.492.052.702-.899 1.299-1.061l.044.731.79-.794.47.87.552-.205a.66.66 0 0 1-.332-.2.517.517 0 0 1-.132-.33c.873-.354 2.177.477 2.21.831l2.078-.679c-.039.301-.387.411-.657.607 1.105-.779.226.77 1.232.053-.144-.163.06-.44.077-.588.553.435 1.691.416 2.547.205l-.149.512c1.558.1 3.271-.31 5.018-.335-.636-.224-.514-1.109 0-1.204l.226.774c.32-.478-.552-.282.122-.884.652.076.464.875.094 1.138l.784-.287c.056.23-.127.358-.165.655.309-.478 1.387.75 1.834-.096l.05.23c1.746-.03 2.53-.316 3.95-.383 0-.674.553-.535.984-1.085 1.05.196 2.21.707 3.482.63.878-.343.243-.568.635-.955.077.612 1.332.535.69.985a15.353 15.353 0 0 0 3.83-.68c-.21-.243-.447-.353-.331-.563a.738.738 0 0 1 .275.01c.09.02.173.058.245.11a.58.58 0 0 1 .169.188c.04.072.061.151.062.232l-.088.067c2.127-.956 4.973 1.706 6.669.41l-.099.068 1.763-.684c.817.1-.481.478.127.842 1.9-1.043 3.022.12 4.586-.574 1.243 1.793 4.327-.167 5.979.956l-.1-.42c.426-.421.52.234.835.33-.05-.33-.464-.378-.205-.613 3.598-.545 7.438.598 11.129.956 1.348.11.757-2.203 2.465-1.195l-.481.794c2.719-.956 5.564 0 8.233-.77-.154.182-.16.416-.425.416.552.574 2.083.034 2.094-.435.42.053.1.425.354.665.552.339 1.42-.732 1.718-.158.05.09-.16.186-.265.23.37-.278 1.719.076 1.365-.589 1 .32 1.917-.287 2.713.105.553-.736 1.713.364 1.884-.683-.077 1.08 1.752.875 2.387.377-.215.326.553.345.299.794.718 0 1.381-.206 1.265-.76 1.315 1.305 2.686-1.018 3.415.645a45.888 45.888 0 0 1 6.078-1.17c-.082 1.075-2.138.09-2.066 1.218 1.834-.425 2.906-1.343 4.719-1.066.47.153-.276.478-.437.65 1.835-.43 3.537.148 5.172-.42 0 .1-.182.21-.348.291.321-.033.741.167.713-.325l-.315.13c-.497-.718 1.304-1.468 1.365-1.841-.553 1.396 1.602.377.707 2.137a.73.73 0 0 0 .337-.263.58.58 0 0 0 .1-.383c.315.1.409.297.083.665 1.155-.254.757-.78 1.801-.75 0 .233-.221.324-.337.601.553-.478 1.078-.908 1.951-.697-.056.143.044.33-.216.325 1.509-.048 2.603-1.195 4.249-.722-.513 1.023.553.349.625 1.243l.895-.254-.348-.44c.785.034 1.492-.602 2.155-.296l-.591.354 1.47-.139-.824-.354c.807-.444-.055-1.132.978-.86-.21.086.785.029 1.177.56.398-.278.801-.57 1.376-.335.138.291-.149.984-.055 1.176.398-.736 1.834-.168 2.337-.956-.143.227-.192.49-.138.745l.337-.597c.359.2.409.296.337.669 1.105.134-.309-1.138.967-.626-.105.048-.055.138-.27.23 1.287.277 2.519-.335 3.702 0 .326.903-1.05.195-.669.955 1.724-.129 3.592-.999 5.25-.74l-.31-.106c.277-1.262 1.221.66 2.083.086-.21.086-.298.693-.237.555 1.105.234 2.343-.249 4.083-.603l-.226.32c.657.311 1.763.216 2.481.383.226-.315.641-.253.403-.731 2.166 1.912 4.305-.89 6.228.726-.238 0-.553.268-.387.273l1.702-.244c-.111-.554-.21-.34-.553-.784.124-.163.292-.298.489-.392.198-.094.419-.145.644-.148-.774.34-.028.884.287 1.205-.049-.173.072-.354.05-.526.846 1.008.199-1.11 1.376-.407l-.077.287c.458-.134.889-.478 1.37-.401.177.645-.492.282-.552.803.685 0 1.403-1.162 1.994-.507-.298.167-.718.158-1.016.325.641.77.729.583 1.221.717h-.044l1.138.378-.282-.21c.928-1.635 1.752-.25 2.951-1.3-1.166.994-.21.592-.332 1.309.288.21.724.454.586.65.553-.564.89.478 1.696-.34 0 .235.581.044.431.627.713-.163-.149-.411-.077-.703 1.133-.76 2.514 1.061 4.139.029 1.376-.397 1.658-1.171 2.94-1.515.403.392-.393.836-.393.836.267.161.581.255.906.27a1.97 1.97 0 0 0 .934-.184c-.138.196 0 .373.172.64.519-.038.386-.831 1.05-.477a3.24 3.24 0 0 1-.553.918c.619-.192 1.243-.603 1.884-.79.149.412-.409.603-.646.856.718-.153 1.851-.296 2.105-.927l-.442-.248c.26 0 .105.559-.094.669-.63.478-.862-.258-.884-.478l.459-.134c-.387-1.382-1.818.148-2.719.033l.431-.956-.973.784c-.182-.263-.287-.822.166-.956-.624-.516-.591.33-1.105-.239-.055-.086-.028-.134.033-.172l-.646.273c.132-.201-.072-.703.309-.545-1.105-.617-1.873.674-2.26-.096l.099-.057c-1.596.272-.193.721-1.414 1.534l-.713-1.83-.188.721c-.16-.033-.481-.1-.409-.387-.63.478.089.32-.287.78-.752-.699-2.172.229-2.293-.957-.31.545.729.478-.127.813-.183-1.258-.978.181-1.658-.416.254-.636.917-.273.226-.875-.486 1.076-1.386-.282-2-.096-.066.87-1.332.32-2.354.579.078-.292-1.89-.54-2.818-.885l.033-.148c-.221.87-1.182.674-1.901.832a.906.906 0 0 1 .132-.55c.102-.169.258-.31.449-.406h-.669a.979.979 0 0 1-.34.327 1.167 1.167 0 0 1-.478.151l.194-.65c-.885 0-1.813.712-2.94.244-.083.607.84 1.725-.381 2.103-.034-.335-.056-.899.27-1.028-.105.043-.381.263-.585.12l.502-.545c-.508-.258-.287.478-.701.397 0-.478-.293-.35-.221-.722.11-.038.359.205.525 0a1.931 1.931 0 0 1-.691-.264 1.649 1.649 0 0 1-.503-.487c.028.268-.028.636-.37.684-.89 0-.282-.574-.79-.832-.227.325-.78-.033-.824.674-.259 0-.293-.34-.387-.535-.469.3-2.149.033-1.657.793l.116.053s-.05 0-.078.033c-1.525.66-3.105-.478-4.608-.224V3.34c-.895.244-1.984.106-2.636.593a.711.711 0 0 1-.402-.28.553.553 0 0 1-.084-.442c-.691.158-.774.416-1.746 0 .701-.396-.221-.373.713-.287-.879-.224-1.067-.607-2.039 0 .342-.597-.641-.774-1.067-.602l.608.445c-.436.053-.88.039-1.31-.043l.254-.794c-1.784-1.004-3.315 1.578-4.647-.067-.497.545.973.411.553 1.052-.829-.124-1.658-1.286-1.929-1.29-1.132-.479-1.105 1.137-2.282.812a.818.818 0 0 1 .031.774.938.938 0 0 1-.264.323 1.11 1.11 0 0 1-.397.198c-.829-.124-.994-1.214-.464-1.434.205 0 .299.072.288.168.27-.096.629-.21.303-.526l-.116.282c-.403-.297-1.552-.292-1.271-.75-.635.257-.281.477.183.616-1.061-.435-1.658-.053-2.763-.344.171.162.326.478.155.478-1.608-.378-.724.526-1.824.636-.608-.445.249-1.033-.862-.684-.668-.306-.127-.755.149-.985-1.016.536-1.867-.387-2.442-.478l.553-.22a1.892 1.892 0 0 1-.846.12l.293.573c-.309-.105-.553-.11-.553-.348-.326.368.227.956-.42 1.434-.403-.297-1.265.286-1.392-.478 1.298.272-.127-.76.978-.866a1.102 1.102 0 0 1-.851.024c-.044-.086.044-.157.133-.2-1.233-.689-.592.846-1.879.807.171-.42-.287-.808-.497-.721.519 0 .237.712-.249 1.027-.823-.34-.906.235-1.337.187l.491.162c-.176.426-.585.364-1.165.478-.045-.33.524-.22.326-.368-.652.736-1.437-.793-2.338-.306-.409-.291-.027-.798-.387-.999-1.011.54-1.077-.588-2.133-.148.293.574.349.435-.403.985l1.735-.387-1.105.822c.525 0 1.105-.35 1.42-.249-.553.478-.481.316-.238.794-.701-.86-1.425.478-2.21-.1l.044-1.41c-1.232-.641-2.21.702-3.823.334l.513.248c-.221.56-.994.072-1.519.292.055-.478-.271-.645-.492-.956.028.349-1.177-.043-1.337.899l-.707-.627c-1.305-.267-1.503 1.33-2.763 1.157.381-.507-.183-.846.657-1.21-.414 0-.79-.095-.801.23-.276-.263-1.199.646-1.575.215-.182.206-.243.698-.713.655a.337.337 0 0 1 0-.234c0 .234-.735.31-.331.837-1.271-1.478-3.592.095-4.708-1.172-.936.165-1.883.277-2.835.335.05-.139 0-.234.16-.186-1.143-.44-.707 1.352-2.005.86-.664-.765.69-.411.276-.703-.171-1.553-1.564.21-2.437-.702l.21-.091c-.663-.555-1.608.564-2.713.454a.326.326 0 0 0 0-.234c-.746.784-2.155 1.051-3.205 1.271.326-.607.475-.32.276-.956-.47.091.138.99-.801 1.167-.304-.33-.984-.622-1.078-1.282l.89-.019c-.459-.85-1.149.034-1.613-.114l.055-.368c-1.36.124-1.376 1.06-2.835.999l.155.282c-.796.956-.674-.521-1.465.172l-.248-.956c-.871.453-1.797.82-2.763 1.094.552-.698 1.658-1.06 2.315-1.477-.519 0-1.774.072-2.044.54.21-.09.475-.325.685-.181a2.832 2.832 0 0 1-1.094.83 3.298 3.298 0 0 1-1.42.27c.171-1.832-2.713-.455-3.482-1.865-1.834.693-3.652-.258-5.796-.13.774 1.435-.625.049-.481 1.507-.497.1-.685.076-.729 0l-1.525-.86c-.365-.421.469-.326.42-.65-1.106-.106-.465-.618-1.194-1 .155.521-.37.75-1 .56l.901.659c-1.52.793-1.338-1.214-2.868-.43l.48-.478c-.79.277-2.917 0-3.674 1.204-.144-.167-.332-.564 0-.674-1.89-.148-4.183 1.31-5.664.612l.138-.358c-.348.105-.602.678-1.05.325 0-.148.138-.359 0-.378-.182.124-.923.64-1.392.44l.386-.411c-1.85-.44-2.807 1.023-4.343 1.29 0-1.051-1.475-1.376-2.21-1.53V.685c-2.15-.086-3.625.956-5.598 1.4-1.265-1.118-4.188-.392-6.194-.99.31.182 0 .818-.37.957-.475-.206-1.266.755-1.221-.21h.165c-.375-.957-1.326-.67-2.072-.675l-.083 1.267c-2.006-1.778-5.106.813-6.227-.803-.459.33-1.045.34-1.498.67v-.68a12.396 12.396 0 0 0-3.575 0l.31-.478c-.912 0-1.072 1.98-1.912 2.042l-.288-1c-1.591.053-3.232-.774-4.763.192 0-.148.055-.445.31-.478-.746 0-2.918-.588-2.587.788-.06-.903-1.657-.038-2.48.388l.104-.689c-.685.875-.701 1.11-1.696 1.377-.243-.076-.238-.526.088-.368-.812-.32-.59.655-1.574.33l.342-.435c-.823-.029-.746.2-1.177.707-.503.287-1.564-.114-1.713-.712-.094.368-.52.875-1.011.717a.38.38 0 0 1 .013-.245.442.442 0 0 1 .164-.2c-1.393-.406-2 .851-2.973.235a.553.553 0 0 0-.182-.392 9.431 9.431 0 0 1 1.89.028c0-.616-.912-.688-.255-1.563-.685.478-1.845 1.54-2.713 1.286a.84.84 0 0 1-.1-.215l.061-.072a.668.668 0 0 0-.295 0 .61.61 0 0 0-.257.125 1.992 1.992 0 0 0-.718-.158c-.128-.507-1.023-.234-1.465-.244.072.67-.508.583.06 1.119-.07-.048.078-.086.366-.125a.528.528 0 0 0 .188-.076l-.028.062c.287-.033.663-.062 1.105-.09-.332.358-.68.654-1.183.3-.204.445-.43.894-.552 1.11-.647-.914-1.83-1.377-2.022-1.946-1.321.43-3.145.368-3.918 1.663-.376.177-.459-.344-.614-.535.216-.139.476-.13.586-.316-.74.354-2.249.216-2.381 1.105-.984-.364.491-.837-.818-.636l.166-.277c-2.675-1.291-4.09 2.433-7.068.755.204.105.304.148.354.296-3.316-.645-6.709 1.038-10.018-.062-.94-.205-1 .359-1.531.818l-.249-.713-.906.88c-1.315.679-2.47-1.65-4.117-.411l.254.478c-.624-.058-1.939.387-1.873-.177-.055.09-.166.516-.425.272l-.044-.372-1.487.712c-1.199-.215.078-1.506-1.658-1.492C.895 5.105-.22 6.114.04 6.362c.178.01.347.073.478.179a.645.645 0 0 1 .24.4l-.558.225C.17 8.279-.194 9.44 1.304 10.144l.917-.732.36.521-.818.1c.513.479.784 0 1.105-.305.07.225.233.42.458.55l.907-1.114c.149.43-.376.884.292 1.094.426-.516-.502-.956.233-1.314.513.478.403.898.933.44a.447.447 0 0 1 .012.336.525.525 0 0 1-.233.27c.476-.367 1.304-.214 1.525-.817.553.598 1.658-.248 1.691.808.29-.433.74-.77 1.277-.956-.752 1.3 1.724 0 1.591 1.348.553-1.162 2.21-.617 3.255-1.3-.055.095-.16.282-.265.23.624.061.823.391 1.237.592 0-.956.967-1.195 1.448-1.797.812.87-.392 1.118-.1 1.974-.082-.755 1.272-.813.973-1.434.614.53.514.248.862 1.008.028-1.17.553-.22.962-.956.873.54.282 1.086 1.182.689.453.354-.342.808-.342.808Zm21.793-2.93-.447.057.447-.058Zm1.818-.091a7.552 7.552 0 0 0-.801 0c-.072-.23 0-.478.171-.478-.083.186.348.305.63.478Zm-4.128-4.49c.288-.109.393 0 .442.159-.172.02-.343.053-.508.1v.081a.973.973 0 0 1 .066-.34Z"/></svg> - </span> data visualization</div> -</template> diff --git a/docs/index.mdx b/docs/index.mdx index 1d185a51..dda88146 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -34,7 +34,7 @@ Marks are components, layered as siblings. Transforms are components too — the <RuleY data={[0]} /> </Replot> -Everything participates in the React tree: state, context, Suspense, and server-side rendering work as you'd expect, with no refs, effects, or manual DOM management. +Everything participates in the React tree: state, context, Suspense, and server rendering work as you'd expect, with no manual DOM management. `renderToString` returns the whole plot, and a hydrating client redraws it once the marks have registered. ## Start here diff --git a/docs/interactions/crosshair.md b/docs/interactions/crosshair.md deleted file mode 100644 index 52d7047c..00000000 --- a/docs/interactions/crosshair.md +++ /dev/null @@ -1,109 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import alphabet from "../data/alphabet.ts"; -import aapl from "../data/aapl.ts"; -import penguins from "../data/penguins.ts"; - -</script> - -# Crosshair mark <VersionBadge version="0.6.7" /> - -The **crosshair mark** shows the *x* (horizontal↔︎ position) and *y* (vertical↕︎ position) value of the point closest to the [pointer](./pointer.md) on the bottom and left sides of the frame, respectively. - -:::plot defer https://observablehq.com/@observablehq/plot-crosshair -```js -Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"}), - Plot.crosshair(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` -::: - -For charts which have a “dominant” dimension, such as time in a time-series chart, use the crosshairX or crosshairY mark for the [pointerX](./pointer.md#pointerX) or [pointerY](./pointer.md#pointerY) transform as appropriate. - -:::plot defer https://observablehq.com/@observablehq/plot-crosshairx -```js -Plot.plot({ - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.crosshairX(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -If either **x** or **y** is not specified, the crosshair is one-dimensional. - -:::plot defer https://observablehq.com/@observablehq/plot-one-dimensional-crosshair -```js -Plot.plot({ - marks: [ - Plot.tickX(penguins, {x: "body_mass_g"}), - Plot.crosshairX(penguins, {x: "body_mass_g"}) - ] -}) -``` -::: - -The **color** option sets the fill color of the text and the stroke color of the rule. This option can be specified as a channel to reinforce a color encoding. - -:::plot defer https://observablehq.com/@observablehq/plot-color-crosshair -```js -Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"}), - Plot.crosshair(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", color: "sex", opacity: 0.5}) - ] -}) -``` -::: - -The crosshair mark does not currently support any format options; values are displayed with the default format. If you are interested in this feature, please upvote [#1596](https://github.com/observablehq/plot/issues/1596). In the meantime, you can implement a custom crosshair using the [pointer transform](./pointer.md) and a [text mark](../marks/text.md). - -## Crosshair options - -The following options are supported: - -- **x** - the horizontal position; bound to the *x* scale -- **y** - the vertical position; bound to the *y* scale -- **color** - shorthand for setting both **ruleStroke** and **textFill** -- **opacity** - shorthand for setting **ruleStrokeOpacity** -- **ruleStroke** - the rule stroke color -- **ruleStrokeOpacity** - the rule stroke opacity; defaults to 0.2 -- **ruleStrokeWidth** - the rule stroke width; defaults to 1 -- **textFill** - the text fill color -- **textFillOpacity** - the text fill opacity -- **textStroke** - the text stroke color; defaults to *white* to improve legibility -- **textStrokeOpacity** - the text stroke opacity; defaults to 1 -- **textStrokeWidth** - the text stroke width; defaults to 5 -- **maxRadius** - the maximum pointing distance, in pixels; defaults to 40 - -The crosshair mark supports faceting, but most other mark options are ignored. - -## crosshair(*data*, *options*) {#crosshair} - -```js -Plot.crosshair(cars, {x: "economy (mpg)", y: "cylinders"}) -``` - -Returns a new crosshair for the given *data* and *options*, drawing horizontal and vertical rules. The corresponding **x** and **y** values are also drawn just outside the bottom and left sides of the frame, respectively, typically on top of the axes. If either **x** or **y** is not specified, the crosshair will be one-dimensional. - -## crosshairX(*data*, *options*) {#crosshairX} - -```js -Plot.crosshairX(aapl, {x: "Date", y: "Close"}) -``` - -Like crosshair, but using [pointerX](./pointer.md#pointerX) when *x* is the dominant dimension, like time in a time-series chart. - -## crosshairY(*data*, *options*) {#crosshairY} - -```js -Plot.crosshairY(aapl, {x: "Date", y: "Close"}) -``` - -Like crosshair, but using [pointerY](./pointer.md#pointerY) when *y* is the dominant dimension. diff --git a/docs/interactions/pointer.md b/docs/interactions/pointer.md deleted file mode 100644 index d5002ef1..00000000 --- a/docs/interactions/pointer.md +++ /dev/null @@ -1,213 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; - -const pointered = ref(true); -const aapl = shallowRef([]); -const industries = shallowRef([]); -const olympians = shallowRef([]); -const penguins = shallowRef([]); -const linetip = ref("x"); -const recttip = ref("x"); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); - d3.csv("../data/bls-industry-unemployment.csv", d3.autoType).then((data) => (industries.value = data)); - d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data)); -}); - -</script> - -# Pointer transform <VersionBadge version="0.6.7" /> - -The **pointer transform** filters a mark interactively such that only the point closest to the pointer is rendered. It is typically used to show details on hover, often with a [tip](../marks/tip.md) or [crosshair](./crosshair.md) mark, but it can be paired with any mark. - -To demonstrate, below the pointer transform filters a filled <span style="border-bottom: solid 2px var(--vp-c-red);">red</span> dot behind a stroked <span style="border-bottom: solid 2px currentColor;">{{ $dark ? "white" : "black"}}</span> dot. As you hover the chart, only the closest red dot to the pointer is rendered. If you remove the pointer transform by toggling the checkbox, all the red dots will be visible. - -<p> - <label class="label-input"> - Use pointer: - <input type="checkbox" v-model="pointered"> - </label> -</p> - -:::plot defer hidden https://observablehq.com/@observablehq/plot-pointer-transform -```js -Plot.plot({ - marks: [ - Plot.dot(penguins, (pointered ? Plot.pointer : (o) => o)({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "red", r: 8})), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` -::: - -<div v-if="pointered"> - -```js-vue -Plot.plot({ - marks: [ - Plot.dot(penguins, Plot.pointer({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "red", r: 8})), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` - -</div> -<div v-else> - -```js-vue -Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "red", r: 8}), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` - -</div> - -The pointer transform is similar to the [filter](../transforms/filter.md) and [select](../transforms/select.md) transforms: it filters the mark’s index to show a subset of the data. The difference is that the pointer transform is *interactive*: it listens to [pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) and re-renders the mark as the closest point changes. Since the mark is lazily rendered during interaction, it is fast: only the visible elements are rendered as needed. And, like the filter and select transforms, unfiltered channel values are incorporated into default scale domains. - -The pointer transform supports both one- and two-dimensional pointing modes. The two-dimensional mode, [pointer](#pointer), is used above and is suitable for scatterplots and the general case: it finds the point closest to the pointer by measuring distance in *x* and *y*. The one-dimensional modes, [pointerX](#pointerX) and [pointerY](#pointerY), in contrast only consider distance in one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. - -Try the different modes on the line chart below to get a feel for their behavior. - -<p> - <span class="label-input"> - Pointing mode: - <label style="margin-left: 0.5em;"><input type="radio" name="linetip" value="xy" v-model="linetip" /> pointer</label> - <label style="margin-left: 0.5em;"><input type="radio" name="linetip" value="x" v-model="linetip" /> pointerX</label> - <label style="margin-left: 0.5em;"><input type="radio" name="linetip" value="y" v-model="linetip" /> pointerY</label> - </span> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-pointer-modes-x-y-and-xy -```js-vue -Plot.lineY(aapl, {x: "Date", y: "Close", tip: "{{linetip}}"}).plot() -``` -::: - -“One-dimensional” is a slight misnomer: the pointerX and pointerY transforms consider distance in both dimensions, but the distance along the non-dominant dimension is divided by 100. Below, the pointerX transform is applied to a multi-series line chart; the closest point in *x* is chosen, while *y* is used to “break ties” such that you can focus different series by moving the mouse vertically. - -:::plot defer https://observablehq.com/@observablehq/plot-multi-series-line-chart-interactive-tips -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.lineY(industries, {x: "date", y: "unemployed", stroke: "industry", tip: "x"}) - ] -}) -``` -::: - -One-dimensional pointing makes even small bars or rects easily hoverable. If you switch the histogram below to two-dimensional pointing, you must hover near a rect’s centroid (shown in <span style="border-bottom: solid 2px var(--vp-c-red);">red</span>) to trigger a tip, whereas one-dimensional pointing triggers the tip anywhere in the chart. - -<p> - <span class="label-input"> - Pointing mode: - <label style="margin-left: 0.5em;"><input type="radio" name="recttip" value="xy" v-model="recttip" /> pointer</label> - <label style="margin-left: 0.5em;"><input type="radio" name="recttip" value="x" v-model="recttip" /> pointerX</label> - </span> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-one-dimensional-pointing -```js-vue -Plot.plot({ - x: {label: "Daily volume (log₁₀)"}, - marks: [ - Plot.rectY(aapl, Plot.binX({y: "count"}, {x: (d) => Math.log10(d.Volume), thresholds: 40, tip: "{{recttip}}"})), - Plot.dot(aapl, Plot.stackY(Plot.binX({y: "count"}, {x: (d) => Math.log10(d.Volume), thresholds: 40, stroke: "red"}))) - ] -}) -``` -::: - -This reveals an important caveat: the pointer transform understands only points and not the arbitrary geometry of marks. By default, the pointer transform only focuses the closest point if it is within 40 pixels of the pointer (in either one or two dimensions, depending on the pointing mode). With large marks, there may be “dead spots” that do not trigger pointing even when the pointer is within the displayed mark. You can mitigate dead spots either by switching to one-dimensional pointing, if appropriate, or by setting the **maxRadius** option to increase the pointing distance cutoff. - -Another caveat is that since the pointer transform only focuses one point at a time, if points are coincident (or nearly so), some points may not be focusable. In the future, the pointer transform might allow focusing multiple points simultaneously, or some method of cycling through nearby points. If you are interested in this feature, please upvote [#1621](https://github.com/observablehq/plot/issues/1621). - -The pointer transform will prefer the midpoint of the **x1** and **x2** channels, if present, to the **x** channel, and likewise for **y1**, **y2**, and **y**; this allows the pointer transform to be applied to a rect, bar, area, or other mark with paired channels. It also enables these marks to support the **tip** mark option. (If no *x* or *y* channels are specified, the pointer transform respects the **frameAnchor** option.) - -The **px** and **py** channels may be used to specify pointing target positions independent of the displayed mark. Below, text in the top-left corner shows the focused date and closing price. The focused point is also highlighted with a red dot and rule. - -:::plot defer https://observablehq.com/@observablehq/plot-pointer-target-position -```js -Plot.plot({ - height: 160, - y: {axis: "right", grid: true, nice: true}, - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.ruleX(aapl, Plot.pointerX({x: "Date", py: "Close", stroke: "red"})), - Plot.dot(aapl, Plot.pointerX({x: "Date", y: "Close", stroke: "red"})), - Plot.text(aapl, Plot.pointerX({px: "Date", py: "Close", dy: -17, frameAnchor: "top-left", fontVariant: "tabular-nums", text: (d) => [`Date ${Plot.formatIsoDate(d.Date)}`, `Close ${d.Close.toFixed(2)}`].join(" ")})) - ] -}) -``` -::: - -As the above chart shows, a plot can have multiple pointer transforms. Each pointer transform functions independently (with the exception of *click-to-stick*, described next), though we recommend configuring them with the same target positions and pointing mode so that the same point is focused across marks. - -The pointer transform supports “click-to-stick”: clicking on the chart locks the currently-focused point until you click again. By locking the pointer, you can select text from the tip for copy and paste. If a plot has multiple pointer transforms, clicking will lock all pointer transforms. - -The pointer transform emits an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the focused points changes, and sets the value of the plot element to the focused data. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views) (viewof), or to register an *input* event listener to react to pointing. - -```js -const plot = Plot.plot(options); - -plot.addEventListener("input", (event) => { - console.log(plot.value); -}); -``` - -## Pointer options - -The following options control the pointer transform: - -- **px** - the horizontal↔︎ target position; bound to the *x* scale -- **py** - the vertical↕︎ target position; bound to the *y* scale -- **x** - the fallback horizontal↔︎ target position; bound to the *x* scale -- **y** - the fallback vertical↕︎ target position; bound to the *y* scale -- **x1** - the starting horizontal↔︎ target position; bound to the *x* scale -- **y1** - the starting vertical↕︎ target position; bound to the *y* scale -- **x2** - the ending horizontal↔︎ target position; bound to the *x* scale -- **y2** - the ending vertical↕︎ target position; bound to the *y* scale -- **maxRadius** - the reach, or maximum distance, in pixels; defaults to 40 -- **frameAnchor** - how to position the target within the frame; defaults to *middle* - -To resolve the horizontal target position, the pointer transform applies the following order of precedence: - -1. the **px** channel, if present; -2. the midpoint of the **x1** and **x2** channels, if both are present; -3. the **x** channel, if present; -4. the **x1** channel, if present; -5. and lastly the position given by the **frameAnchor**. - -The same precedence applies to the **py**, **y**, **y1**, and **y2** channels. - -## pointer(*options*) {#pointer} - -```js -Plot.tip(penguins, Plot.pointer({x: "culmen_length_mm", y: "culmen_depth_mm"})) -``` - -Applies the pointer render transform to the specified *options* to filter the mark index such that only the point closest to the pointer is rendered; the mark will re-render interactively in response to pointer events. - -## pointerX(*options*) {#pointerX} - -```js -Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"})) -``` - -Like [pointer](#pointer), except the determination of the closest point considers mostly the *x* (horizontal↔︎) position; this should be used for plots where *x* is the dominant dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. - -## pointerY(*options*) {#pointerY} - -```js -Plot.tip(alphabet, Plot.pointerY({x: "frequency", y: "letter"})) -``` - -Like [pointer](#pointer), except the determination of the closest point considers mostly the *y* (vertical↕︎) position; this should be used for plots where *y* is the dominant dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart. diff --git a/docs/marks/area.md b/docs/marks/area.md deleted file mode 100644 index 031ac098..00000000 --- a/docs/marks/area.md +++ /dev/null @@ -1,335 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import aapl from "../data/aapl.ts"; -import industries from "../data/bls-industry-unemployment.ts"; -import sftemp from "../data/sf-temperatures.ts"; - -</script> - -# Area mark - -The **area mark** draws the region between a baseline (**x1**, **y1**) and a topline (**x2**, **y2**) as in an area chart. Often the baseline represents *y* = 0, and because the area mark interpolates between adjacent data points, typically both the *x* and *y* scales are quantitative or temporal. - -:::plot https://observablehq.com/@observablehq/plot-area-simple -```js -Plot.areaY(aapl, {x: "Date", y: "Close"}).plot() -``` -::: - -The area mark has three constructors: [areaY](#areaY) for when the baseline and topline share *x* values, as in a time-series area chart where time goes right→ (or ←left); [areaX](#areaX) for when the baseline and topline share *y* values, as in a time-series area chart where time goes up↑ (or down↓); and lastly the rarely-used [area](#area) where the baseline and topline share neither *x* nor *y* values. - -The area mark is often paired with a [line](./line.md) and [rule](./rule.md) mark to accentuate the topline and baseline. - -:::plot https://observablehq.com/@observablehq/plot-area-and-line -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -With the default definitions of **x** = index and **y** = [identity](../features/transforms.md#identity), you can pass an array of numbers as data. Below, a random walk is constructed with [d3.cumsum](https://observablehq.com/@d3/d3-cumsum?collection=@d3/d3-array) and [d3.randomNormal](https://observablehq.com/@d3/d3-random?collection=@d3/d3-random). - -:::plot defer https://observablehq.com/@observablehq/plot-random-walk-area -```js -Plot.areaY(d3.cumsum({length: 600}, d3.randomNormal())).plot() -``` -::: - -As with [lines](./line.md), points in areas are connected in input order: the first point is connected to the second point, the second is connected to the third, and so on. Area data is typically in chronological order. Unsorted data may produce gibberish. - -:::plot defer https://observablehq.com/@observablehq/plot-area-sort -```js -Plot.areaY(d3.shuffle(aapl.slice()), {x: "Date", y: "Close"}).plot() // 🌶️ -``` -::: - -If your data isn’t sorted, use the [sort transform](../transforms/sort.md). - -:::plot defer https://observablehq.com/@observablehq/plot-area-sort -```js -Plot.areaY(d3.shuffle(aapl.slice()), {x: "Date", y: "Close", sort: "Date"}).plot() -``` -::: - -When the baseline is not *y* = 0 but instead represents another dimension of data as in a band chart, specify **y1** and **y2** instead of **y**. - -:::plot defer https://observablehq.com/@observablehq/plot-temperature-band -```js -Plot.plot({ - y: { - label: "Temperature (°F)", - grid: true - }, - marks: [ - Plot.areaY(sftemp, {x: "date", y1: "low", y2: "high"}) - ] -}) -``` -::: - -:::tip -Since **y1** and **y2** refer to different fields here, a *y*-scale label is specified to improve readability. Also, the band above is spiky; you can smooth it by applying a [window transform](../transforms/window.md). -::: - -While charts typically put *y* = 0 on the bottom edge, such that the area grows up↑, this is not required; reversing the *y* scale will produce a “hanging” area that grows down↓. - -:::plot defer https://observablehq.com/@observablehq/plot-top-down-area-chart -```js -Plot.plot({ - x: { - label: null - }, - y: { - grid: true, - reverse: true - }, - marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -For a vertically-oriented baseline and topline, such as when time goes up↑ instead of right→, use [areaX](#areaX) instead of [areaY](#areaY) and swap **x** and **y**. - -:::plot defer https://observablehq.com/@observablehq/plot-vertical-area-chart -```js -Plot.plot({ - x: { - grid: true - }, - marks: [ - Plot.areaX(aapl, {y: "Date", x: "Close", fillOpacity: 0.3}), - Plot.lineX(aapl, {y: "Date", x: "Close"}), - Plot.ruleX([0]) - ] -}) -``` -::: - -If some channel values are undefined (or null or NaN), gaps will appear between adjacent points. To demonstrate, below we set the **y** value to NaN for the first three months of each year. - -:::plot defer https://observablehq.com/@observablehq/plot-area-chart-with-missing-data -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.areaY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close, fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close}), - Plot.ruleY([0]) - ] -}) -``` -::: - -Supplying undefined values is not the same as filtering the data: the latter will interpolate between the data points. Observe the conspicuous straight lines below! - -:::plot defer -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.areaY(aapl, {filter: (d) => d.Date.getUTCMonth() >= 3, x: "Date", y: "Close", fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close}), - Plot.ruleY([0]) - ] -}) -``` -::: - -If a **fill** channel is specified, it is assumed to be ordinal or nominal; data is grouped into series and then implicitly [stacked](../transforms/stack.md). - -:::plot defer https://observablehq.com/@observablehq/plot-stacked-areas -```js -Plot.plot({ - y: { - transform: (d) => d / 1000, - label: "Unemployed (thousands)" - }, - marks: [ - Plot.areaY(industries, {x: "date", y: "unemployed", fill: "industry"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::warning CAUTION -This area chart uses color but does not include a [legend](../features/legends.md). This should usually be avoided because color cannot be interpreted without a legend, titles, or labels. -::: - -Or, as a streamgraph with the **offset** stack transform option: - -:::plot defer https://observablehq.com/@observablehq/plot-centered-streamgraph -```js -Plot.plot({ - y: { - transform: (d) => d / 1000, - label: "Unemployed (thousands)" - }, - marks: [ - Plot.areaY(industries, {x: "date", y: "unemployed", fill: "industry", offset: "wiggle"}), - ] -}) -``` -::: - -The **z** channel determines how data is grouped: if the **z** channel is not specified, but a varying **fill** channel is, the **fill** channel is used for **z**; the **z** channel will further fallback to a varying **stroke** channel if needed. - -The **z** channel (either implicitly or explicitly) is typically used with the [stack transform](../transforms/stack.md) for a stacked area chart or streamgraph. You can disable the implicit stack transform and produce overlapping areas by setting **y2** instead of **y**. - -:::plot defer https://observablehq.com/@observablehq/plot-overlapping-areas -```js -Plot.plot({ - marks: [ - Plot.areaY(industries, {x: "date", y2: "unemployed", z: "industry", fillOpacity: 0.1}), - Plot.lineY(industries, {x: "date", y: "unemployed", z: "industry", strokeWidth: 1}) - ] -}) -``` -::: - -To vary **fill** within a single series, set the **z** option to null. - -:::plot defer https://observablehq.com/@observablehq/plot-variable-fill-area -```js -Plot.plot({ - color: { - type: "log", - legend: true - }, - marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fill: "Volume", z: null}), - Plot.ruleY([0]) - ] -}) -``` -::: - -As an alternative to overlapping or stacking, [faceting](../features/facets.md) will produce small multiples, here arranged vertically with a shared *x*-axis. - -:::plot defer https://observablehq.com/@observablehq/plot-faceted-areas -```js -Plot.plot({ - height: 720, - axis: null, - marks: [ - Plot.areaY(industries, {x: "date", y: "unemployed", fy: "industry"}), - Plot.text(industries, Plot.selectFirst({text: "industry", fy: "industry", frameAnchor: "top-left", dx: 6, dy: 6})), - Plot.frame() - ] -}) -``` -::: - -:::tip -Above, smaller industries such as agriculture and mining & extraction are dwarfed by larger industries such as wholesale & retail trade. To emphasize each industry’s trend, instead of comparing absolute numbers across industries, you could use the [normalize transform](../transforms/normalize.md). -::: - -Or, as a [horizon chart](https://observablehq.com/@observablehq/plot-horizon), where the area is repeated at different scales with different colors, showing both small-scale variation in position and large-scale variation in color: - -:::plot defer https://observablehq.com/@observablehq/plot-unemployment-horizon-chart -```js-vue -Plot.plot((() => { - const bands = 7; - const step = d3.max(industries, (d) => d.unemployed) / bands; - return { - height: 720, - axis: null, - y: {domain: [0, step]}, - color: {scheme: "{{$dark ? "viridis" : "YlGnBu"}}"}, - facet: {data: industries, y: "industry"}, - marks: [ - d3.range(bands).map((i) => Plot.areaY(industries, {x: "date", y: (d) => d.unemployed - i * step, fill: i, clip: true})), - Plot.text(industries, Plot.selectFirst({text: "industry", frameAnchor: "top-left", dx: 6, dy: 6})), - Plot.frame() - ] - }; -})()) -``` -::: - -See also the [ridgeline chart](https://observablehq.com/@observablehq/plot-ridgeline) example. - -Interpolation is controlled by the [**curve** option](../features/curves.md). The default curve is *linear*, which draws straight line segments between pairs of adjacent points. A *step* curve is nice for emphasizing when the value changes, while *basis* and *catmull–rom* are nice for smoothing. - -## Area options - -The following channels are required: - -* **x1** - the horizontal position of the baseline; bound to the *x* scale -* **y1** - the vertical position of the baseline; bound to the *y* scale - -In addition to the [standard mark options](../features/marks.md#mark-options), the following optional channels are supported: - -* **x2** - the horizontal position of the topline; bound to the *x* scale -* **y2** - the vertical position of the topline; bound to the *y* scale -* **z** - a categorical value to group data into series - -If **x2** is not specified, it defaults to **x1**. If **y2** is not specified, it defaults to **y1**. These defaults facilitate sharing *x* or *y* coordinates between the baseline and topline. See also the implicit stack transform and shorthand **x** and **y** options supported by [areaY](#areaY) and [areaX](#areaX). - -By default, the data is assumed to represent a single series (*i.e.*, a single value that varies over time). If the **z** channel is specified, data is grouped by **z** to form separate series. Typically **z** is a categorical value such as a series name. If **z** is not specified, it defaults to **fill** if a channel, or **stroke** if a channel. - -The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. If the fill is defined as a channel, the area will be broken into contiguous overlapping segments when the fill color changes; the fill color will apply to the interval spanning the current data point and the following data point. This behavior also applies to the **fillOpacity**, **stroke**, **strokeOpacity**, **strokeWidth**, **opacity**, **href**, **title**, and **ariaLabel** channels. When any of these channels are used, setting an explicit **z** channel (possibly to null) is strongly recommended. The **strokeLinecap** and **strokeLinejoin** default to *round*, and the **strokeMiterlimit** defaults to 1. - -Points along the baseline and topline are connected in input order. Likewise, if there are multiple series via the **z**, **fill**, or **stroke** channel, the series are drawn in input order such that the last series is drawn on top. Typically, the data is already in sorted order, such as chronological for time series; if sorting is needed, consider a [sort transform](../transforms/sort.md). - -The area mark supports [curve options](../features/curves.md) to control interpolation between points. If any of the **x1**, **y1**, **x2**, or **y2** values are invalid (undefined, null, or NaN), the baseline and topline will be interrupted, resulting in a break that divides the area shape into multiple segments. (See [d3-shape’s *area*.defined](https://d3js.org/d3-shape/area#area_defined) for more.) If an area segment consists of only a single point, it may appear invisible unless rendered with rounded or square line caps. In addition, some curves such as *cardinal-open* only render a visible segment if it contains multiple points. - -## areaY(*data*, *options*) {#areaY} - -```js -Plot.areaY(aapl, {x: "Date", y: "Close"}) -``` - -Returns a new area with the given *data* and *options*. This constructor is used when the baseline and topline share *x* values, as in a time-series area chart where time goes right→. If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for an area chart with a baseline at *y* = 0. If the **y** option is not specified, it defaults to the identity function. The **x** option specifies the **x1** channel; and the **x1** and **x2** options are ignored. - -If the **interval** option is specified, the [binX transform](../transforms/bin.md) is implicitly applied to the specified *options*. The reducer of the output *y* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer. - -```js -Plot.areaY(observations, {x: "date", y: "temperature", interval: "day"}) -``` - -The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval. - -The **areaY** mark draws the region between a baseline (*y1*) and a topline (*y2*) as in an area chart. When the baseline is *y* = 0, the *y* channel can be specified instead of *y1* and *y2*. - -## areaX(*data*, *options*) {#areaX} - -```js -Plot.areaX(aapl, {y: "Date", x: "Close"}) -``` - -Returns a new area with the given *data* and *options*. This constructor is used when the baseline and topline share *y* values, as in a time-series area chart where time goes up↑. If neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for an area chart with a baseline at *x* = 0. If the **x** option is not specified, it defaults to the identity function. The **y** option specifies the **y1** channel; and the **y1** and **y2** options are ignored. - -If the **interval** option is specified, the [binY transform](../transforms/bin.md) is implicitly applied to the specified *options*. The reducer of the output *x* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer. - -```js -Plot.areaX(observations, {y: "date", x: "temperature", interval: "day"}) -``` - -The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval. - -## area(*data*, *options*) {#area} - -```js -Plot.area(aapl, {x1: "Date", y1: 0, y2: "Close"}) -``` - -Returns a new area with the given *data* and *options*. This method is rarely used directly; it is only needed when the baseline and topline have neither common **x** nor **y** values. [areaY](#areaY) is used in the common horizontal orientation where the baseline and topline share **x** values, while [areaX](#areaX) is used in the vertical orientation where the baseline and topline share **y** values. diff --git a/docs/marks/arrow.md b/docs/marks/arrow.md deleted file mode 100644 index ca099b56..00000000 --- a/docs/marks/arrow.md +++ /dev/null @@ -1,157 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import metros from "../data/metros.ts"; -import miserables from "../data/miserables.ts"; - -const markov = (() => { - const matrix = [[3, 2, 5], [1, 7, 2], [1, 1, 8]]; - const nodes = matrix.map((m, i) => d3.pointRadial(((2 - i) * 2 * Math.PI) / matrix.length, 100)); - const edges = matrix.flatMap((m, i) => m.map((value, j) => ([nodes[i], nodes[j], value]))); - return {nodes, edges}; -})(); - -function samegroup({source, target}) { - source = miserables.groups.get(source); - target = miserables.groups.get(target); - return source === target ? source : null; -} - -</script> - -# Arrow mark <VersionBadge version="0.4.0" /> - -:::tip -See also the [vector mark](./vector.md), which draws arrows of a given length and direction. -::: - -The **arrow mark** draws arrows between two points [**x1**, **y1**] and [**x2**, **y2**] in quantitative dimensions. It is similar to the [link mark](./link.md), except it draws an arrowhead and is suitable for directed edges. With the **bend** option, it can be swoopy.⤵︎ - -For example, below we show the rising inequality (and population) in various U.S. cities from 1980 to 2015. Each arrow represents two observations of a city: the city’s population (**x**) and inequality (**y**) in 1980, and the same in 2015. The arrow’s **stroke** redundantly encodes the change in inequality: red indicates rising inequality, while blue (there are only four) indicates declining inequality. - -:::plot defer https://observablehq.com/@observablehq/plot-arrow-variation-chart -```js -Plot.plot({ - grid: true, - inset: 10, - x: { - type: "log", - label: "Population" - }, - y: { - label: "Inequality", - ticks: 4 - }, - color: { - scheme: "BuRd", - label: "Change in inequality from 1980 to 2015", - legend: true, - tickFormat: "+f" - }, - marks: [ - Plot.arrow(metros, { - x1: "POP_1980", - y1: "R90_10_1980", - x2: "POP_2015", - y2: "R90_10_2015", - bend: true, - stroke: (d) => d.R90_10_2015 - d.R90_10_1980 - }), - Plot.text(metros, { - x: "POP_2015", - y: "R90_10_2015", - filter: "highlight", - text: "nyt_display", - fill: "currentColor", - stroke: "var(--vp-c-bg)", - dy: -6 - }) - ] -}) -``` -::: - -The arrow mark is also useful for drawing directed graph edges, say representing transition frequencies in a finite state machine. - -:::plot https://observablehq.com/@observablehq/plot-finite-state-machine -```js -Plot.plot({ - inset: 60, - aspectRatio: 1, - axis: null, - marks: [ - Plot.dot(markov.nodes, {r: 40}), - Plot.arrow(markov.edges, { - x1: ([[x1]]) => x1, - y1: ([[, y1]]) => y1, - x2: ([, [x2]]) => x2, - y2: ([, [, y2]]) => y2, - bend: true, - strokeWidth: ([,, value]) => value, - strokeLinejoin: "miter", - headLength: 24, - inset: 48 - }), - Plot.text(markov.nodes, {text: ["A", "B", "C"], dy: 12}), - Plot.text(markov.edges, { - x: ([[x1, y1], [x2, y2]]) => (x1 + x2) / 2 + (y1 - y2) * 0.15, - y: ([[x1, y1], [x2, y2]]) => (y1 + y2) / 2 - (x1 - x2) * 0.15, - text: ([,, value]) => value - }) - ] -}) -``` -::: - -For undirected edges, as in the arc diagram of character co-occurrence in *Les Misérables* below, set the **sweep** option to the desired orientation: *-y* for right-bulging links whose endpoints are vertically separated. - -:::plot https://observablehq.com/@observablehq/plot-arc-diagram -```js -Plot.plot({ - height: 1080, - marginLeft: 100, - axis: null, - x: {domain: [0, 1]}, // see https://github.com/observablehq/plot/issues/1541 - color: {domain: d3.range(10), unknown: "#ccc"}, - marks: [ - Plot.dot(miserables.nodes, {x: 0, y: "id", fill: "group", sort: {y: "fill"}}), - Plot.text(miserables.nodes, {x: 0, y: "id", text: "id", textAnchor: "end", dx: -6, fill: "group"}), - Plot.arrow(miserables.links, {x: 0, y1: "source", y2: "target", sweep: "-y", bend: 90, headLength: 0, stroke: samegroup, sort: samegroup, reverse: true}) - ] -}) -``` -::: - -## Arrow options - -The following channels are required: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **y1** - the starting vertical position; bound to the *y* scale -* **x2** - the ending horizontal position; bound to the *x* scale -* **y2** - the ending vertical position; bound to the *y* scale - -For vertical or horizontal arrows, the **x** option can be specified as shorthand for **x1** and **x2**, and the **y** option can be specified as shorthand for **y1** and **y2**, respectively. - -The arrow mark supports the [standard mark options](../features/marks.md#mark-options). The **stroke** defaults to *currentColor*. The **fill** defaults to *none*. The **strokeWidth** defaults to 1.5, and the **strokeMiterlimit** defaults to 1. The following additional options are supported: - -* **bend** - the bend angle, in degrees; defaults to 0°; true for 22.5° -* **headAngle** - the arrowhead angle, in degrees; defaults to 60° -* **headLength** - the arrowhead scale; defaults to 8 -* **insetEnd** - inset at the end of the arrow (useful if the arrow points to a dot) -* **insetStart** - inset at the start of the arrow -* **inset** - shorthand for the two insets -* **sweep** - the sweep order - -The **bend** option sets the angle between the straight line connecting the two points and the outgoing direction of the arrow from the start point. It must be within ±90°. A positive angle will produce a clockwise curve; a negative angle will produce a counterclockwise curve; zero will produce a straight line. The **headAngle** determines how pointy the arrowhead is; it is typically between 0° and 180°. The **headLength** determines the scale of the arrowhead relative to the stroke width. Assuming the default of stroke width 1.5px, the **headLength** is the length of the arrowhead’s side in pixels. - -The **sweep** option <VersionBadge version="0.6.10" pr="1740" /> controls the bend orientation. It defaults to 1 indicating a positive (clockwise) bend angle; -1 indicates a negative (anticlockwise) bend angle; 0 effectively clears the bend angle. If *-x*, the bend angle is flipped when the ending point is to the left of the starting point — ensuring all arrows bulge up (down if bend is negative); if *-y*, the bend angle is flipped when the ending point is above the starting point — ensuring all arrows bulge right (left if bend is negative); the sign is negated for *+x* and *+y*. - -## arrow(*data*, *options*) {#arrow} - -```js -Plot.arrow(inequality, {x1: "POP_1980", y1: "R90_10_1980", x2: "POP_2015", y2: "R90_10_2015", bend: true}) -``` - -Returns a new arrow with the given *data* and *options*. diff --git a/docs/marks/auto.md b/docs/marks/auto.md deleted file mode 100644 index 956760c4..00000000 --- a/docs/marks/auto.md +++ /dev/null @@ -1,237 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import aapl from "../data/aapl.ts"; -import industries from "../data/bls-industry-unemployment.ts"; -import penguins from "../data/penguins.ts"; - -const olympians = shallowRef([{weight: 31, height: 1.21, sex: "female"}, {weight: 170, height: 2.21, sex: "male"}]); - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -</script> - -# Auto mark <VersionBadge version="0.6.3" /> - -The magic ✨ **auto mark** automatically selects a mark type that best represents the given dimensions of the data according to some simple heuristics. The auto mark — which powers [Observable’s chart cell](https://observablehq.com/@observablehq/chart-cell) — is intended to support fast exploratory analysis where the goal is to get a useful plot as quickly as possible. For example, two quantitative dimensions make a scatterplot: - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-scatterplot -```js -Plot.auto(penguins, {x: "body_mass_g", y: "flipper_length_mm"}).plot() -``` -::: - -:::tip -The auto mark is supposed to be fast and fluid, so don’t overthink it. If you need precise control, use explicit marks instead. -::: - -:::warning CAUTION -While the auto mark will respect the options you provide, you shouldn’t rely on its behavior being stable over time. The auto mark may get smarter and take advantage of new features. -Because its heuristics are likely to evolve, they are not explicitly documented; see the [source code](https://github.com/observablehq/plot/blob/main/src/marks/auto.js) for details. -::: - -A monotonically increasing dimension (here *Date*, as the data is ordered chronologically), paired with a numeric column (*Close*), makes a line chart: - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-line-chart -```js -Plot.auto(aapl, {x: "Date", y: "Close"}).plot() -``` -::: - -Given only one dimension of data, it makes a histogram: - -:::plot defer https://observablehq.com/@observablehq/plot-auto-mark-quantitative-histogram -```js -Plot.auto(olympians, {x: "weight"}).plot() -``` -::: - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-ordinal-histogram -```js -Plot.auto(penguins, {x: "island"}).plot() -``` -::: - -This is easier than deciding whether to use bin and rect, or group and bar: the auto mark chooses the right one based on whether the data is quantitative or ordinal. - -If you’d like to explicitly avoid grouping the data, you can opt out of the reducer, and get a one-dimensional plot: - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-barcode -```js -Plot.auto(penguins, {x: "body_mass_g", y: {reduce: null}}).plot() -``` -::: - -As you can see from that **reduce** property, the auto mark has some special syntax that lets you specify a reducer without explicitly specifying a transform. For example, the scatterplot above can be made into a heatmap by adding a color reducer. You can pass the name of a reducer to that property, or pass a shorthand string: - -:::plot defer https://observablehq.com/@observablehq/plot-auto-mark-heatmap -```js -Plot.auto(olympians, {x: "weight", y: "height", color: "count"}).plot() -``` -::: - -That’s equivalent to this: - -```js -Plot.rect(olympians, Plot.bin({fill: "count"}, {x: "weight", y: "height"})).plot() -``` - -Notice that the code above makes you think about nested functions and two different options objects, which the auto mark flattens. The auto mark infers that it should use a [rect](./rect.md); that it should [bin](../transforms/bin.md) on **x** and **y**; that the kind of color should be a **fill**; and that **fill** is an “output” of the reducer, whereas **x** and **y** are “inputs”. - -This saves you a little bit of typing, but, more importantly, it means that switching from showing one dimension to another only involves changing _one thing_. In the code above, if you change **y** from *weight* to *sex*, it’ll break, because *sex* is ordinal instead of quantitative. (You’d also have to change [rect](./rect.md) to [barX](./bar.md#barX), and [bin](../transforms/bin.md#bin) to [binX](../transforms/bin.md#binX).) With the auto mark, it just works: - -:::plot defer https://observablehq.com/@observablehq/plot-auto-mark-heatmap-2 -```js -Plot.auto(olympians, {x: "weight", y: "sex", color: "count"}).plot() -``` -::: - -Similarly, with explicit marks and transforms, changing a vertical histogram to a horizontal histogram involves switching [rectY](./rect.md#rectY) to [rectX](./rect.md#rectX), [binX](../transforms/bin.md#binX) to [binY](../transforms/bin.md#binY), **x** to **y**, and **y** to **x**. With the auto mark, just specify **y** instead of **x**: - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-horizontal-histogram -```js -Plot.auto(penguins, {y: "island"}).plot() -``` -::: - -For the sake of seamless switching, the auto mark has just one color channel, which it assigns to either **fill** or **stroke** depending on the mark. We can see that clearly by overriding a line chart with the **mark** option to make an area chart: - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-color-channel -```js -Plot.auto(industries, {x: "date", y: "unemployed", color: "industry"}).plot() -``` -::: - -:::plot -```js -Plot.auto(industries, {x: "date", y: "unemployed", color: "industry", mark: "area"}).plot() -``` -::: - -The **mark** override option supports [dot](./dot.md), [line](./line.md), [area](./area.md), [rule](./rule.md), and [bar](./bar.md) (which automatically chooses among barX, barY, rectX, rectY, rect, and cell). - -You can get a more elaborate aggregated chart by passing an object with both a **value** (the input dimension) and a **reduce** (the reducer). For example, here’s the average heights of Olympians over time by sex: - -:::plot defer https://observablehq.com/@observablehq/plot-auto-mark-with-value-and-reduce -```js -Plot - .auto(olympians, {x: "date_of_birth", y: {value: "height", reduce: "mean"}, color: "sex", mark: "line"}) - .plot({color: {legend: true}}) -``` -::: - -You can similarly pass a **zero** option to indicate that zero is meaningful for either **x** or **y**. This adds a corresponding rule to the returned mark. - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-zero-option -```js -Plot.auto(industries, {x: "date", y: {value: "unemployed", zero: true}, color: "industry"}).plot() -``` -::: - -The auto mark has a **size** channel, which (currently) always results in a dot. For now, it’s an alias for the dot’s **r** channel; in the future it will also represent a vector’s **length** channel. - -:::plot https://observablehq.com/@observablehq/plot-auto-mark-size-channel -```js -Plot.auto(aapl, {x: "Date", y: "Close", size: "Volume"}).plot() -``` -::: - -Like with any other mark, you can also use **fx** or **fy**, and pass additional global options in the plot method. - -:::plot defer https://observablehq.com/@observablehq/plot-auto-mark-faceted -```js -Plot.auto(penguins, { - x: "body_mass_g", - y: "culmen_length_mm", - fx: "island", - fy: "species" -}).plot({ - grid: true, - x: {ticks: 5}, - marginRight: 70 -}) -``` -::: - -:::warning Caution -You can combine the auto mark with other marks, but the combination may be brittle because the auto mark may pick encodings that don’t play well with others. -::: - -## Auto options - -The auto mark currently supports only a subset of the standard [mark options](../features/marks.md#mark-options). You must provide at least one position channel: - -* **x** - horizontal position -* **y** - vertical position - -You may also provide one or more visual encoding channels: - -* **color** - corresponds to **stroke** or **fill** (depending on the chosen mark type) -* **size** - corresponds to **r** (and in future, possibly **length**) - -And you may specify the standard mark-level facet channels: - -* **fx** - horizontal facet position (column) -* **fy** - vertical facet position (row) - -In addition to channel values, the **x**, **y**, **color**, and **size** options may specify reducers. Setting a reducer on **x** implicitly groups or bins on **y**, and likewise setting a reducer on **y** implicitly groups or bins on **x**. Setting a reducer on **color** or **size** groups or bins in both **x** and **y**. Setting a reducer on both **x** and **y** throws an error. To specify a reducer, simply pass the reducer name to the corresponding option. For example: - -```js -Plot.auto(penguins, {x: "body_mass_g", y: "count"}) -``` - -To pass both a value and a reducer, or to disambiguate whether the given string represents a field name or a reducer name, the **x**, **y**, **color**, and **size** options can also be specified as an object with separate **value** and **reduce** properties. For example, to compute the total weight of the penguins in each bin: - -```js -Plot.auto(penguins, {x: "body_mass_g", y: {value: "body_mass_g", reduce: "sum"}}) -``` - -If the **color** channel is specified as a string that is also a valid CSS color, it is interpreted as a constant color. For example, for red bars: - -```js -Plot.auto(penguins, {x: "body_mass_g", color: "red"}) -``` - -This is shorthand for: - -```js -Plot.auto(penguins, {x: "body_mass_g", color: {color: "red"}}) -``` - -To reference a field name instead as a variable color encoding, specify the **color** option as an object with a **value** property: - -```js -Plot.auto(penguins, {x: "body_mass_g", color: {value: "red"}}) -``` - -Alternatively, you can specify a function of data or an array of values, as with a standard mark channel. - -The auto mark chooses the mark type automatically based on several simple heuristics. For more control, you can specify the desired mark type using the **mark** option, which supports the following names: - -* *area* - areaY or areaX (or sometimes area) -* *bar* - barY or barX; or rectY, rectX, or rect; or cell -* *dot* - dot -* *line* - lineY or lineX (or sometimes line) -* *rule* - ruleY or ruleX - -The chosen mark type depends both on the options you provide (*e.g.*, whether you specified **x** or **y** or both) and the inferred type of the corresponding data values (whether the associated dimension of data is quantitative, categorical, monotonic, *etc.*). - -## auto(*data*, *options*) {#auto} - -```js -Plot.auto(olympians, {x: "weight", y: "height", color: "count"}) // equivalent to rect + bin, say -``` - -Returns an automatically-chosen mark with the given *data* and *options*, suitable for a quick view of the data. - -## autoSpec(*data*, *options*) <VersionBadge version="0.6.4" /> {#autoSpec} - -```js -Plot.autoSpec(olympians, {x: "weight", y: "height", color: "count"}) -``` - -Returns an auto mark *options* object with no option undefined; the mark type, reducers, and other options are all populated. diff --git a/docs/marks/axis.md b/docs/marks/axis.md deleted file mode 100644 index 11f9638c..00000000 --- a/docs/marks/axis.md +++ /dev/null @@ -1,400 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import aapl from "../data/aapl.ts"; -import alphabet from "../data/alphabet.ts"; -import penguins from "../data/penguins.ts"; - -const anchor = ref("bottom"); -const facetAnchor = ref("auto"); - -const responses = [ - {name: "Family in feud with Zucker­bergs", value: 0.17}, - {name: "Committed 671 birthdays to memory", value: 0.19}, - {name: "Ex is doing too well", value: 0.10}, - {name: "High school friends all dead now", value: 0.15}, - {name: "Discovered how to “like” things mentally", value: 0.27}, - {name: "Not enough politics", value: 0.12} -]; - -</script> - -# Axis mark <VersionBadge version="0.6.3" /> - -The **axis mark** conveys the meaning of a position [scale](../features/scales.md): _x_ or _y_, and _fx_ or _fy_ when [faceting](../features/facets.md). Plot automatically adds default axis marks as needed, but you can customize the appearance of axes either through scale options or by explicitly declaring an axis mark. - -For example, the **axis** scale option specifies the side of the frame to draw the axis. Setting it to *both* will repeat the axis on both sides. - -:::plot https://observablehq.com/@observablehq/plot-axis-both -```js -Plot.plot({ - x: {percent: true, grid: true, axis: "both"}, - marks: [ - Plot.barX(alphabet, {x: "frequency", y: "letter"}), - Plot.ruleX([0]) - ] -}) -``` -::: - -The above is equivalent to declaring two explicit axis marks, one with the *top* **anchor** and the other with the *bottom* **anchor**, and one explicit [grid mark](./grid.md). A benefit of declaring explicit axes is that you can draw them atop other marks. - -:::plot https://observablehq.com/@observablehq/plot-axis-both -```js -Plot.plot({ - x: {percent: true}, - marks: [ - Plot.axisX({anchor: "top"}), - Plot.axisX({anchor: "bottom", label: null}), - Plot.barX(alphabet, {x: "frequency", y: "letter"}), - Plot.gridX({interval: 1, stroke: "var(--vp-c-bg)", strokeOpacity: 0.5}), - Plot.ruleX([0]) - ] -}) -``` -::: - -:::info -The **interval** option above instructs the grid lines to be drawn at unit intervals, _i.e._ whole percentages. As an alternative, you can use the **ticks** option to specify the desired number of ticks or the **tickSpacing** option to specify the desired separation between adjacent ticks in pixels. -::: - -If you don’t declare an axis mark for a position scale, Plot will implicitly add one for you below (before) all other marks. To disable an implicit axis, set the _scale_.**axis** option to null for the corresponding scale; or, set the top-level **axis** option to null to disable all implicit axes. - -Plot’s axis mark is a composite mark comprised of: - -* a [vector](./vector.md) for ticks -* a [text](./text.md) for tick labels -* a [text](./text.md) for an axis label - -As such, you can take advantage of the full capabilities of these marks. For example, you can use the text mark’s **lineWidth** option to wrap long tick labels (and even soft hyphens). Note this option is expressed in ems, not pixels, and you may have to reserve additional **marginBottom** to make room for multiple lines. - -:::plot https://observablehq.com/@observablehq/plot-wrap-tick-labels -```js -Plot.plot({ - y: {percent: true}, - marks: [ - Plot.axisX({label: null, lineWidth: 8, marginBottom: 40}), - Plot.axisY({label: "Responses (%)"}), - Plot.barY(responses, {x: "name", y: "value"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -Or, you can use the **textAnchor** option to extend the *y*-axis tick labels to the right and into the frame, and the **fill** option to specify the color of the text. - -:::plot https://observablehq.com/@observablehq/plot-anchor-tick-labels -```js -Plot.plot({ - marginTop: 0, - marginLeft: 4, - x: {ticks: 4, label: "Yield (kg)"}, - marks: [ - Plot.barX([42, 17, 32], {y: ["🍌 banana", "🍎 apple", "🍐 pear"]}), - Plot.axisY({textAnchor: "start", fill: "var(--vp-c-bg)", dx: 14}) - ] -}) -``` -::: - -Layering several marks makes it possible to create [ggplot2-style axes](https://ggplot2.tidyverse.org/reference/guide_axis.html) with a filled [frame](./frame.md) and white grid lines. - -:::plot https://observablehq.com/@observablehq/plot-ggplot2-style-axes -```js -Plot.plot({ - inset: 10, - marks: [ - Plot.frame({fill: "#eaeaea"}), - Plot.gridY({stroke: "white", strokeOpacity: 1}), - Plot.gridX({stroke: "white", strokeOpacity: 1}), - Plot.line(aapl, {x: "Date", y: "Close", stroke: "black"}) - ] -}) -``` -::: - -Or you could emulate the style of *The New York Times*, with tick labels above dashed grid lines, and a custom tick format to show units (here dollars) on the first tick. - -:::plot https://observablehq.com/@observablehq/plot-nyt-style-axes -```js -Plot.plot({ - round: true, - marginLeft: 0, // don’t need left-margin since labels are inset - x: {label: null, insetLeft: 36}, // reserve space for inset labels - marks: [ - Plot.gridY({ - strokeDasharray: "0.75,2", // dashed - strokeOpacity: 1 // opaque - }), - Plot.axisY({ - tickSize: 0, // don’t draw ticks - dx: 38, // offset right - dy: -6, // offset up - lineAnchor: "bottom", // draw labels above grid lines - tickFormat: (d, i, _) => (i === _.length - 1 ? `$${d}` : d) - }), - Plot.ruleY([0]), - Plot.line(aapl, {x: "Date", y: "Close", markerEnd: "dot"}) - ] -}) -``` -::: - -Time axes default to a consistent multi-line tick format <VersionBadge version="0.6.9" />, [à la Datawrapper](https://blog.datawrapper.de/new-axis-ticks/), for example showing the first month of each quarter, and the year: - -:::plot https://observablehq.com/@observablehq/plot-datawrapper-style-date-axis -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.axisX({ticks: "3 months"}), - Plot.gridX(), - Plot.line(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -The format is inferred from the tick interval, and consists of two fields (*e.g.*, month and year, day and month, minutes and hours); when a tick has the same second field value as the previous tick (*e.g.*, “19 Jan” after “17 Jan”), only the first field (“19”) is shown for brevity. Alternatively, you can specify multiple explicit axes with options for hierarchical time intervals, here showing weeks, months, and years. - -:::plot https://observablehq.com/@observablehq/plot-multiscale-date-axis -```js -Plot.plot({ - x: {round: true, nice: d3.utcWeek}, - y: {inset: 6}, - marks: [ - Plot.frame({fill: "currentColor", fillOpacity: 0.1}), - Plot.frame({anchor: "bottom"}), - Plot.axisX({ticks: "year", tickSize: 28, tickPadding: -11, tickFormat: " %Y", textAnchor: "start"}), - Plot.axisX({ticks: "month", tickSize: 16, tickPadding: -11, tickFormat: " %b", textAnchor: "start"}), - Plot.gridX({ticks: "week", stroke: "var(--vp-c-bg)", strokeOpacity: 1, insetBottom: -0.5}), - Plot.line(aapl.slice(-340, -10), {x: "Date", y: "Close", curve: "step"}) - ] -}) -``` -::: - -You can even style an axis dynamically based on data! The data associated with an axis or grid mark are the tick values sampled from the associated scale’s domain. If you don’t specify the data explicitly, the ticks will be chosen through a combination of the **ticks**, **tickSpacing**, and **interval** options. - -:::plot https://observablehq.com/@observablehq/plot-data-based-axis -```js -Plot.plot({ - marginRight: 0, - marks: [ - Plot.ruleY([0]), - Plot.line(aapl, {x: "Date", y: "Close"}), - Plot.gridY({x: (y) => aapl.find((d) => d.Close >= y)?.Date, insetLeft: -6}), - Plot.axisY({x: (y) => aapl.find((d) => d.Close >= y)?.Date, insetLeft: -6, textStroke: "var(--vp-c-bg)"}) - ] -}) -``` -::: - -The color of an axis can be controlled with the **color**, **stroke**, and **fill** options, which affect the axis’ component marks differently. The **stroke** option affects the tick vector; the **fill** option affects the label texts. The **color** option is shorthand for setting both **fill** and **stroke**. While these options are typically set to constant colors (such as _red_ or the default _currentColor_), they can be specified as channels to assign colors dynamically based on the associated tick value. - -:::plot https://observablehq.com/@observablehq/plot-axes-with-color -```js -Plot.axisX(d3.ticks(0, 1, 10), {color: "red"}).plot() // text fill and tick stroke -``` -::: - -:::plot https://observablehq.com/@observablehq/plot-axes-with-color -```js -Plot.axisX(d3.ticks(0, 1, 10), {stroke: Plot.identity, strokeWidth: 3, tickSize: 10}).plot() // tick stroke -``` -::: - -:::plot https://observablehq.com/@observablehq/plot-axes-with-color -```js -Plot.axisX(d3.ticks(0, 1, 10), {fill: "red"}).plot() // text fill -``` -::: - -To draw an outline around the tick labels, say to improve legibility when drawing an axes atop other marks, use the **textStroke** (default _none_), **textStrokeWidth** (default 3), and **textStrokeOpacity** (default 1) options. - -:::plot https://observablehq.com/@observablehq/plot-axes-with-color -```js -Plot.plot({ - height: 40, - style: "background: #777;", - x: {domain: [0, 100]}, - marks: [ - Plot.axisX({ - fill: "black", - stroke: "white", - textStroke: "white", - textStrokeWidth: 3, - textStrokeOpacity: 0.6 - }) - ] -}) -``` -::: - -When faceting, the *x*- and *y*-axes are typically repeated across facets. A *bottom*-anchored *x*-axis is by default drawn on any facet _with empty space below it_; conversely, a *top*-anchored *x*-axis is drawn on any facet _with empty space above it_. Similarly, a *left*-anchored *y*-axis is drawn on facets with empty space to the left, and a *right*-anchored *y*-axis is drawn on facets with empty space to the right. - -If the default behavior isn’t what you want, use the *mark*.**facetAnchor** option to control which facets show an axis. (This option applies not just to Plot’s axis and grid mark, but any mark; for example, you can use it to place a text mark at the bottom of each facet column.) The supported values for this option are: - -* _top_ - show only on the top facets -* _right_ - show only on the right facets -* _bottom_ - show only on the bottom facets -* _left_ - show only on the left facets -* _top-empty_ - show on any facet with space above (a superset of _top_) -* _right-empty_ - show on any facet with space to the right (a superset of _right_) -* _bottom-empty_ - show on any facet with space to below (a superset of _below_) -* _left-empty_ - show on any facet with space to the left (a superset of _left_) -* null - show on every facet - -The interactive chart below shows the different possibilities. Note that we place the facet *fx*-axis (in <span style="border-bottom: solid 2px var(--vp-c-blue);">blue</span>) opposite the *x*-axis (in <span style="border-bottom: solid 2px var(--vp-c-red);">red</span>). - -<p> - <label class="label-input"> - anchor: - <select v-model="anchor"> - <option>bottom</option> - <option>top</option> - </select> - </label> - <label class="label-input"> - facetAnchor: - <select v-model="facetAnchor"> - <option>auto</option> - <option>bottom-empty</option> - <option>bottom</option> - <option>top-empty</option> - <option>top</option> - <option>null</option> - </select> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-facetanchor -```js -Plot.plot({ - facet: {marginRight: 80}, - grid: true, - marks: [ - Plot.frame(), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "sex", fy: "species"}), - Plot.axisX({color: "red", anchor, facetAnchor: facetAnchor === "auto" ? undefined : facetAnchor === "null" ? null : facetAnchor}), - Plot.axisFx({color: "blue", anchor: anchor === "top" ? "bottom" : "top"}) // place fx axis opposite x - ] -}) -``` -::: - -The **labelAnchor** option controls the position of the axis label. For the *x* or *fx* axis, the label anchor may be *left*, *center*, or *right*. It defaults to *center* for ordinal scales and *right* for quantitative scales. - -:::plot https://observablehq.com/@observablehq/plot-labelanchor -```js -Plot.plot({ - height: 80, - grid: true, - x: {type: "linear"}, - marks: [ - Plot.axisX({anchor: "top", label: "top-left", labelAnchor: "left"}), - Plot.axisX({anchor: "top", label: "top-center", labelAnchor: "center", ticks: []}), - Plot.axisX({anchor: "top", label: "top-right", labelAnchor: "right", ticks: []}), - Plot.axisX({anchor: "bottom", label: "bottom-left", labelAnchor: "left"}), - Plot.axisX({anchor: "bottom", label: "bottom-center", labelAnchor: "center", ticks: []}), - Plot.axisX({anchor: "bottom", label: "bottom-right", labelAnchor: "right", ticks: []}) - ] -}) -``` -::: - -For the *y* and *fy* axis, the label anchor may be *top*, *center*, or *bottom*. It defaults to *center* for ordinal scales and *top* for quantitative scales. When the label anchor is *center*, the label is rotated by 90° to fit, though you may need to adjust the margins to avoid overlap between the tick labels and the axis label. - -:::plot https://observablehq.com/@observablehq/plot-labelanchor -```js -Plot.plot({ - grid: true, - y: {type: "linear"}, - marks: [ - Plot.axisY({anchor: "left", label: "left-top", labelAnchor: "top"}), - Plot.axisY({anchor: "left", label: "left-center", labelAnchor: "center", ticks: []}), - Plot.axisY({anchor: "left", label: "left-bottom", labelAnchor: "bottom", ticks: []}), - Plot.axisY({anchor: "right", label: "right-top", labelAnchor: "top"}), - Plot.axisY({anchor: "right", label: "right-center", labelAnchor: "center", ticks: []}), - Plot.axisY({anchor: "right", label: "right-bottom", labelAnchor: "bottom", ticks: []}) - ] -}) -``` -::: - -## Axis options - -By default, the *data* for an axis mark are tick values sampled from the associated scale’s domain. If desired, you can specify the *data* explicitly (_e.g._ as an array of numbers), or use one of the following options: - -* **ticks** - the approximate number of ticks to generate, or interval, or array of values -* **tickSpacing** - the approximate number of pixels between ticks (if **ticks** is not specified) -* **interval** - an interval or time interval - -Note that when an axis mark is declared explicitly (via the [**marks** plot option](../features/plots.md#marks-option), as opposed to an implicit axis), the corresponding scale’s *scale*.ticks and *scale*.tickSpacing options are not automatically inherited by the axis mark; however, the *scale*.interval option *is* inherited, as is the *scale*.label option. You can declare multiple axis marks for the same scale with different ticks, and styles, as desired. - -In addition to the [standard mark options](../features/marks.md), the axis mark supports the following options: - -* **anchor** - the axis orientation: *top* or *bottom* for *x* or *fx*; *left* or *right* for *y* or *fy* -* **tickSize** - the length of the tick vector (in pixels; default 6 for *x* or *y*, or 0 for *fx* or *fy*) -* **tickPadding** - the separation between the tick vector and its label (in pixels; default 3) -* **tickFormat** - either a function or specifier string to format tick values; see [Formats](../features/formats.md) -* **tickRotate** - whether to rotate tick labels (an angle in degrees clockwise; default 0) -* **fontVariant** - the ticks’ font-variant; defaults to *tabular-nums* for quantitative axes -* **label** - a string to label the axis; defaults to the scale’s label, perhaps with an arrow -* **labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center* -* **labelArrow** - the label arrow: *auto* (default), *up*, *right*, *down*, *left*, *none*, or true <VersionBadge version="0.6.7" /> -* **labelOffset** - the label position offset (in pixels; default depends on margins and orientation) -* **color** - the color of the ticks and labels (defaults to *currentColor*) -* **textStroke** - the color of the stroke around tick labels (defaults to *none*) -* **textStrokeOpacity** - the opacity of the stroke around tick labels -* **textStrokeWidth** - the thickness of the stroke around tick labels (in pixels) - -The **labelArrow** option controls the arrow (↑, →, ↓, or ←) added to the axis label indicating the direction of ascending value; for example, horizontal position *x* typically increases in value going right→, while vertical position *y* typically increases in value going up↑. If *auto* (the default), the arrow will be added only if the scale is quantitative or temporal; if true, the arrow will also apply to ordinal scales, provided the domain is consistently ordered. - -As a composite mark, the **stroke** option affects the color of the tick vector, while the **fill** option affects the color the text labels; both default to the **color** option, which defaults to *currentColor*. The **x** and **y** channels, if specified, position the ticks; if not specified, the tick positions depend on the axis **anchor**. The orientation of the tick labels likewise depends on the **anchor**. See the [text mark](./text.md) for details on available options for the tick and axis labels. - -The axis mark’s [**facetAnchor**](../features/facets.md) option defaults to *top-empty* if anchor is *top*, *right-empty* if anchor is *right*, *bottom-empty* if anchor is *bottom*, and *left-empty* if anchor is *left*. This ensures the proper positioning of the axes with respect to empty facets. - -The axis mark’s default margins depend on its orientation (**anchor**) as follows, in order of **marginTop**, **marginRight**, **marginBottom**, and **marginLeft**, in pixels: - -* *top* - 30, 20, 0, 20 -* *right* - 20, 40, 20, 0 -* *bottom* - 0, 20, 30, 20 -* *left* - 20, 0, 20, 40 - -For simplicity’s sake and for consistent layout across plots, axis margins are not automatically sized to make room for tick labels; instead, shorten your tick labels (for example using the *k* SI-prefix tick format, or setting a *scale*.transform to show thousands or millions, or setting the **textOverflow** option to *ellipsis* and the **lineWidth** option to clip long labels) or increase the margins as needed. - -## axisX(*data*, *options*) {#axisX} - -```js -Plot.axisX({anchor: "bottom", tickSpacing: 80}) -``` - -Returns a new *x* axis with the given *options*. - -## axisY(*data*, *options*) {#axisY} - -```js -Plot.axisY({anchor: "left", tickSpacing: 35}) -``` - -Returns a new *y* axis with the given *options*. - -## axisFx(*data*, *options*) {#axisFx} - -```js -Plot.axisFx({anchor: "top", label: null}) -``` - -Returns a new *fx* axis with the given *options*. - -## axisFy(*data*, *options*) {#axisFy} - -```js -Plot.axisFy({anchor: "right", label: null}) -``` - -Returns a new *fy* axis with the given *options*. diff --git a/docs/marks/bar.md b/docs/marks/bar.md deleted file mode 100644 index 8b7943b3..00000000 --- a/docs/marks/bar.md +++ /dev/null @@ -1,254 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import alphabet from "../data/alphabet.ts"; -import civilizations from "../data/civilizations.ts"; -import hadcrut from "../data/hadcrut.ts"; -import penguins from "../data/penguins.ts"; -import statepop from "../data/us-state-population-2010-2019.ts"; - -const checked = ref(true); - -const timeseries = [ - {year: 2014, population: 7295.290765}, - {year: 2015, population: 7379.797139}, - {year: 2016, population: 7464.022049}, - {year: 2017, population: 7547.858925}, - // {year: 2018, population: 7631.091040}, - {year: 2019, population: 7713.468100}, - {year: 2020, population: 7794.798739} -]; - -</script> - -# Bar mark - -:::tip -The bar mark is a variant of the [rect mark](./rect.md) for use when one dimension is ordinal and the other is quantitative. See also the [cell mark](./cell.md). -::: - -The **bar mark** comes in two orientations: [barY](#barY) extends vertically↑ as in a vertical bar chart or column chart, while [barX](#barX) extends horizontally→. For example, the bar chart below shows the frequency of letters in the English language. - -:::plot https://observablehq.com/@observablehq/plot-vertical-bars -```js -Plot.barY(alphabet, {x: "letter", y: "frequency"}).plot() -``` -::: - -Ordinal domains are sorted naturally (alphabetically) by default. Either set the [scale **domain**](../features/scales.md) explicitly to change the order, or use the mark [**sort** option](../features/scales.md#sort-mark-option) to derive the scale domain from a channel. For example, to sort **x** by descending **y**: - -:::plot https://observablehq.com/@observablehq/plot-vertical-bars -```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "-y"}}).plot() -``` -::: - -There is typically one ordinal value associated with each bar, such as a name (or above, letter), and two quantitative values defining a lower and upper bound; the lower bound is often not specified (as above) because it defaults to zero. For barY, **x** is ordinal and **y1** & **y2** are quantitative, whereas for barX, **y** is ordinal and **x1** & **x2** are quantitative. - -Above, since **y** was specified instead of **y1** and **y2**, the bar spans from zero to the given *y* value: if you only specify a single quantitative value, barY applies an implicit [stackY transform](../transforms/stack.md) and likewise barX implicitly applies stackX. The stacked horizontal bar chart below draws one bar (of unit width in **x**) per penguin, colored and sorted by the penguin’s body mass, and grouped by species along **y**. - -:::plot defer https://observablehq.com/@observablehq/plot-stacked-unit-chart -```js -Plot.plot({ - marginLeft: 60, - x: {label: "Frequency"}, - y: {label: null}, - color: {legend: true}, - marks: [ - Plot.barX(penguins, {y: "species", x: 1, inset: 0.5, fill: "body_mass_g", sort: "body_mass_g"}), - Plot.ruleX([0]) - ] -}) -``` -::: - -:::tip -The [group transform](../transforms/group.md) with the *count* reducer could be used to produce one bar per species. -::: - -You can opt-out of the implicit stack transform by specifying the bar’s extent with two quantitative values: **x1** and **x2** for barX, or **y1** and **y2** for barY. For example, here is a historical timeline of civilizations, where each has a beginning and an end. - -:::plot https://observablehq.com/@observablehq/plot-civilizations-timeline -```js -Plot.plot({ - marginLeft: 130, - axis: null, - x: { - axis: "top", - grid: true, - tickFormat: (x) => x < 0 ? `${-x} BC` : `${x} AD` - }, - marks: [ - Plot.barX(civilizations, { - x1: "start", - x2: "end", - y: "civilization", - sort: {y: "x1"} - }), - Plot.text(civilizations, { - x: "start", - y: "civilization", - text: "civilization", - textAnchor: "end", - dx: -3 - }) - ] -}) -``` -::: - -:::tip -This uses a [text mark](../marks/text.md) to label the bars directly instead of a *y* axis. It also uses a custom tick format for the *x* axis to show the calendar era. -::: - -For a diverging bar chart, simply specify a negative value. The chart below shows change in population from 2010 to 2019. States whose population increased are <span :style="{borderBottom: `solid ${d3.schemePiYG[3][2]} 3px`}">green</span>, while states whose population decreased are <span :style="{borderBottom: `solid ${d3.schemePiYG[3][0]} 3px`}">pink</span>. (Puerto Rico’s population declined sharply after hurricanes Maria and Irma.) - -:::plot https://observablehq.com/@observablehq/plot-state-population-change -```js -Plot.plot({ - label: null, - x: { - axis: "top", - label: "← decrease · Change in population, 2010–2019 (%) · increase →", - labelAnchor: "center", - tickFormat: "+", - percent: true - }, - color: { - scheme: "PiYg", - type: "ordinal" - }, - marks: [ - Plot.barX(statepop, {y: "State", x: (d) => (d[2019] - d[2010]) / d[2010], fill: (d) => Math.sign(d[2019] - d[2010]), sort: {y: "x"}}), - Plot.gridX({stroke: "var(--vp-c-bg)", strokeOpacity: 0.5}), - Plot.axisY({x: 0}), - Plot.ruleX([0]) - ] -}) -``` -::: - -:::tip -The **percent** scale option is useful for showing percentages; it applies a [scale transform](../features/scales.md#scale-transforms) that multiplies associated channel values by 100. -::: - -When ordinal data is regular, such as the yearly observations of the time-series bar chart of world population below, use the **interval** option to enforce uniformity and show gaps for missing data. It can be set to a named interval such as *hour* or *day*, a number for numeric intervals, a [d3-time interval](https://d3js.org/d3-time#_interval), or a custom implementation. - -<p> - <label class="label-input"> - Use interval: - <input type="checkbox" v-model="checked"> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-ordinal-scale-interval -```js -Plot - .barY(timeseries, {x: "year", y: "population"}) - .plot({x: {tickFormat: "", interval: checked ? 1 : undefined}}) -``` -::: - -:::tip -You can also make a time-series bar chart with a [rect mark](./rect.md), possibly with the [bin transform](../transforms/bin.md) to bin observations at regular intervals. -::: - -A bar’s ordinal dimension is optional; if missing, the bar spans the chart along this dimension. Such bars typically also have a color encoding. For example, here are [warming stripes](https://showyourstripes.info/) showing the increase in average temperature globally over the last 172 years. - -:::plot https://observablehq.com/@observablehq/plot-warming-stripes-2 -```js -Plot.plot({ - x: {round: true, tickFormat: "d"}, - color: {scheme: "BuRd"}, - marks: [ - Plot.barX(hadcrut, { - x: "year", - fill: "anomaly", - interval: 1, // annual observations - inset: 0 // no gaps - }) - ] -}) -``` -::: - -With the [stack transform](../transforms/stack.md), a one-dimensional bar can show the proportions of each value relative to the whole, as a compact alternative to a pie or donut chart. - -:::plot https://observablehq.com/@observablehq/plot-stacked-percentages -```js -Plot.plot({ - x: {percent: true}, - marks: [ - Plot.barX(alphabet, Plot.stackX({x: "frequency", fillOpacity: 0.3, inset: 0.5})), - Plot.textX(alphabet, Plot.stackX({x: "frequency", text: "letter", inset: 0.5})), - Plot.ruleX([0, 1]) - ] -}) -``` -::: - -:::tip -Although barX applies an implicit stackX transform, [textX](./text.md) does not; this example uses an explicit stackX transform in both cases for clarity. -::: - -For a grouped bar chart, use [faceting](../features/facets.md). The chart below uses **fy** to partition the bar chart of penguins by island. - -:::plot defer https://observablehq.com/@observablehq/plot-grouped-unit-chart -```js -Plot.plot({ - marginLeft: 60, - marginRight: 60, - label: null, - x: {label: "Frequency"}, - y: {padding: 0}, - marks: [ - Plot.barX(penguins, {fy: "island", y: "sex", x: 1, inset: 0.5}), - Plot.ruleX([0]) - ] -}) -``` -::: - -## Bar options - -For required channels, see [barX](#barX) and [barY](#barY). The bar mark supports the [standard mark options](../features/marks.md), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners). The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. - -## barX(*data*, *options*) {#barX} - -```js -Plot.barX(alphabet, {y: "letter", x: "frequency"}) -``` - -Returns a new horizontal→ bar with the given *data* and *options*. The following channels are required: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **x2** - the ending horizontal position; bound to the *x* scale - -The following optional channels are supported: - -* **y** - the vertical position; bound to the *y* scale, which must be *band* - -If neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for a horizontal bar chart with bars aligned at *x* = 0. If the **x** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **x2** as identity and **y** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to barX to make a quick sequential bar chart. If the **y** channel is not specified, the bar will span the full vertical extent of the plot (or facet). - -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). - -## barY(*data*, *options*) {#barY} - -```js -Plot.barY(alphabet, {x: "letter", y: "frequency"}) -``` - -Returns a new vertical↑ bar with the given *data* and *options*. The following channels are required: - -* **y1** - the starting vertical position; bound to the *y* scale -* **y2** - the ending vertical position; bound to the *y* scale - -The following optional channels are supported: - -* **x** - the horizontal position; bound to the *x* scale, which must be *band* - -If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for a vertical bar chart with bars aligned at *y* = 0. If the **y** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **y2** as identity and **x** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to barY to make a quick sequential bar chart. If the **x** channel is not specified, the bar will span the full horizontal extent of the plot (or facet). - -If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). diff --git a/docs/marks/bollinger.md b/docs/marks/bollinger.md deleted file mode 100644 index 7ba0671c..00000000 --- a/docs/marks/bollinger.md +++ /dev/null @@ -1,132 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import aapl from "../data/aapl.ts"; - -const n = ref(20); -const k = ref(2); - -</script> - -# Bollinger mark <VersionBadge version="0.6.10" pr="1772" /> - -The **bollinger mark** is a [composite mark](../features/marks.md#marks) consisting of a [line](./line.md) representing a moving average and an [area](./area.md) representing volatility as a band; the band thickness is proportional to the deviation of nearby values. The bollinger mark is often used to [analyze the price](https://en.wikipedia.org/wiki/Bollinger_Bands) of financial instruments such as stocks. - -For example, the chart below shows the price of Apple stock from 2013 to 2018, with a window size *n* of {{n}} days and radius *k* of {{k}} standard deviations. - -<p> - <label class="label-input"> - <span>Window size (n):</span> - <input type="range" v-model.number="n" min="1" max="100" step="1" /> - <span style="font-variant-numeric: tabular-nums;">{{n.toLocaleString("en-US")}}</span> - </label> - <label class="label-input"> - <span>Radius (k):</span> - <input type="range" v-model.number="k" min="0" max="10" step="0.1" /> - <span style="font-variant-numeric: tabular-nums;">{{k.toLocaleString("en-US")}}</span> - </label> -</p> - -:::plot hidden -```js -Plot.bollingerY(aapl, {x: "Date", y: "Close", n, k}).plot() -``` -::: - -```js-vue -Plot.bollingerY(aapl, {x: "Date", y: "Close", n: {{n}}, k: {{k}}}).plot() -``` - -For more control, you can also use the [bollinger map method](#bollinger) directly with the [map transform](../transforms/map.md). - -:::plot -```js -Plot.plot({ - marks: [ - Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20, k: -2}), {x: "Date", y: "Close", stroke: "red"})), - Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20, k: 2}), {x: "Date", y: "Close", stroke: "green"})), - Plot.lineY(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -Below a candlestick chart is constructed from two [rule marks](./rule.md), with a bollinger mark underneath to emphasize the days when the stock was more volatile. - -:::plot -```js -Plot.plot({ - x: {domain: [new Date("2014-01-01"), new Date("2014-06-01")]}, - y: {domain: [68, 92], grid: true}, - color: {domain: [-1, 0, 1], range: ["red", "black", "green"]}, - marks: [ - Plot.bollingerY(aapl, {x: "Date", y: "Close", stroke: "none", clip: true}), - Plot.ruleX(aapl, {x: "Date", y1: "Low", y2: "High", strokeWidth: 1, clip: true}), - Plot.ruleX(aapl, {x: "Date", y1: "Open", y2: "Close", strokeWidth: 3, stroke: (d) => Math.sign(d.Close - d.Open), clip: true}) - ] -}) -``` -::: - -The bollinger mark has two constructors: the common [bollingerY](#bollingerY) for when time goes right→ (or ←left); and the rare [bollingerX](#bollingerX) for when time goes up↑ (or down↓). - -:::plot -```js -Plot.bollingerX(aapl, {y: "Date", x: "Close"}).plot() -``` -::: - -As [shorthand](../features/shorthand.md), you can pass an array of numbers as data. Below, the *x* axis represents the zero-based index into the data (*i.e.*, trading days since May 13, 2013). - -:::plot -```js -Plot.bollingerY(aapl.map((d) => d.Close)).plot() -``` -::: - -## Bollinger options - -The bollinger mark is a [composite mark](../features/marks.md#marks) consisting of two marks: - -* an [area](../marks/area.md) representing volatility as a band, and -* a [line](../marks/line.md) representing a moving average - -The bollinger mark supports the following special options: - -* **n** - the window size (the window transform’s **k** option), an integer; defaults to 20 -* **k** - the band radius, a number representing a multiple of standard deviations; defaults to 2 -* **color** - the fill color of the area, and the stroke color of the line; defaults to *currentColor* -* **opacity** - the fill opacity of the area; defaults to 0.2 -* **fill** - the fill color of the area; defaults to **color** -* **fillOpacity** - the fill opacity of the area; defaults to **opacity** -* **stroke** - the stroke color of the line; defaults to **color** -* **strokeOpacity** - the stroke opacity of the line; defaults to 1 -* **strokeWidth** - the stroke width of the line in pixels; defaults to 1.5 - -Any additional options are passed through to the underlying [line mark](./line.md), [area mark](./area.md), and [window transform](../transforms/window.md). Unlike the window transform, the **strict** option defaults to true, and the **anchor** option defaults to *end* (which assumes that the data is in chronological order). - -## bollingerX(*data*, *options*) {#bollingerX} - -```js -Plot.bollingerX(aapl, {y: "Date", x: "Close"}) -``` - -Returns a bollinger mark for when time goes up↑ (or down↓). If the **x** option is not specified, it defaults to the identity function, as when *data* is an array of numbers [*x₀*, *x₁*, *x₂*, …]. If the **y** option is not specified, it defaults to [0, 1, 2, …]. - -## bollingerY(*data*, *options*) {#bollingerY} - -```js -Plot.bollingerY(aapl, {x: "Date", y: "Close"}) -``` - -Returns a bollinger mark for when time goes right→ (or ←left). If the **y** option is not specified, it defaults to the identity function, as when *data* is an array of numbers [*y₀*, *y₁*, *y₂*, …]. If the **x** option is not specified, it defaults to [0, 1, 2, …]. - -## bollinger(*options*) {#bollinger} - -```js -Plot.lineY(data, Plot.map({y: Plot.bollinger({n: 20})}, {x: "Date", y: "Close"})) -``` - -Returns a bollinger map method for use with the [map transform](../transforms/map.md). The **k** option here defaults to zero instead of two. diff --git a/docs/marks/box.md b/docs/marks/box.md deleted file mode 100644 index 6ec49a45..00000000 --- a/docs/marks/box.md +++ /dev/null @@ -1,140 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import morley from "../data/morley.ts"; - -const diamonds = shallowRef([]); - -onMounted(() => { - d3.csv("../data/diamonds.csv", d3.autoType).then((data) => (diamonds.value = data)); -}); - -</script> - -# Box mark <VersionBadge version="0.4.2" /> - -The **box mark** summarizes one-dimensional distributions as boxplots. It is a [composite mark](../features/marks.md#marks) consisting of a [rule](./rule.md) to represent the extreme values (not including outliers), a [bar](./bar.md) to represent the interquartile range (trimmed to the data), a [tick](./tick.md) to represent the median value, and a [dot](./dot.md) to represent any outliers. The [group transform](../transforms/group.md) is used to group and aggregate data. - -For example, the boxplot below shows [A.A. Michelson’s experimental measurements](https://stat.ethz.ch/R-manual/R-devel/library/datasets/html/morley.html) of the speed of light. (Speed is in km/sec minus 299,000.) - -:::plot https://observablehq.com/@observablehq/plot-vertical-box-plot -```js -Plot.plot({ - y: { - grid: true, - inset: 6 - }, - marks: [ - Plot.boxY(morley, {x: "Expt", y: "Speed"}) - ] -}) -``` -::: - -[boxY](#boxY) produces vertical boxplots; for horizontal boxplots, use [boxX](#boxX) and swap **x** and **y**. - -:::plot https://observablehq.com/@observablehq/plot-horizontal-box-plot -```js -Plot.plot({ - x: { - grid: true, - inset: 6 - }, - marks: [ - Plot.boxX(morley, {x: "Speed", y: "Expt"}) - ] -}) -``` -::: - -As [shorthand](../features/shorthand.md), you can pass an array of numbers for a single boxplot. - -:::plot https://observablehq.com/@observablehq/plot-shorthand-box-plot -```js -Plot.boxX([0, 3, 4.4, 4.5, 4.6, 5, 7]).plot() -``` -::: - -Since the box mark uses the [group transform](../transforms/group.md), the secondary dimension must be ordinal. To group quantitative values, bin manually, say with [Math.floor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/floor); see [#1330](https://github.com/observablehq/plot/issues/1330). - -:::plot defer https://observablehq.com/@observablehq/plot-binned-box-plot -```js -Plot.plot({ - marginLeft: 60, - y: { - grid: true, - label: "Price" - }, - x: { - interval: 0.5, - label: "Carats", - labelAnchor: "right", - tickFormat: (x) => x.toFixed(1) - }, - marks: [ - Plot.ruleY([0]), - Plot.boxY(diamonds, {x: (d) => Math.floor(d.carat * 2) / 2, y: "price"}) - ] -}) -``` -::: - -This chart is slightly easier to construct with [faceting](../features/facets.md) using the [**interval** scale option](../features/scales.md#scale-transforms) on the *fx* scale. (This technique cannot be used with the *x* scale above because the scale interval transform is applied *after* the box mark applies the group transform.) - -:::plot defer https://observablehq.com/@observablehq/plot-binned-box-plot -```js -Plot.plot({ - marginLeft: 60, - y: { - grid: true, - label: "Price" - }, - fx: { - interval: 0.5, - label: "Carats", - labelAnchor: "right", - tickFormat: (x) => x.toFixed(1) - }, - marks: [ - Plot.ruleY([0]), - Plot.boxY(diamonds, {fx: "carat", y: "price"}) - ] -}) -``` -::: - -## Box options - -The box mark is a [composite mark](../features/marks.md#marks) consisting of four marks: - -* a [rule](../marks/rule.md) representing the extreme values (not including outliers) -* a [bar](../marks/bar.md) representing the interquartile range (trimmed to the data) -* a [tick](../marks/tick.md) representing the median value, and -* a [dot](../marks/dot.md) representing outliers, if any - -The given *options* are passed through to these underlying marks, with the exception of the following options: - -* **fill** - the fill color of the bar; defaults to #ccc -* **fillOpacity** - the fill opacity of the bar; defaults to 1 -* **stroke** - the stroke color of the rule, tick, and dot; defaults to *currentColor* -* **strokeOpacity** - the stroke opacity of the rule, tick, and dot; defaults to 1 -* **strokeWidth** - the stroke width of the tick; defaults to 1 -* **r** - the radius of the dot; defaults to 3 - -## boxX(*data*, *options*) {#boxX} - -```js -Plot.boxX(simpsons.map((d) => d.imdb_rating)) -``` - -Returns a horizontal box mark. If the **x** option is not specified, it defaults to the identity function, as when *data* is an array of numbers. If the **y** option is not specified, it defaults to null; if the **y** option is specified, it should represent an ordinal (discrete) value. - -## boxY(*data*, *options*) {#boxY} - -```js -Plot.boxY(simpsons.map((d) => d.imdb_rating)) -``` - -Returns a vertical box mark. If the **y** option is not specified, it defaults to the identity function, as when *data* is an array of numbers. If the **x** option is not specified, it defaults to null; if the **x** option is specified, it should represent an ordinal (discrete) value. diff --git a/docs/marks/cell.md b/docs/marks/cell.md deleted file mode 100644 index 4e47c567..00000000 --- a/docs/marks/cell.md +++ /dev/null @@ -1,183 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; -import hadcrut from "../data/hadcrut.ts"; - -const dji = shallowRef([]); -const seattle = shallowRef([]); -const simpsons = shallowRef(d3.cross(d3.range(1, 29), d3.range(1, 26), (x, y) => ({season: x, number_in_season: y}))); - -onMounted(() => { - d3.csv("../data/dji.csv", d3.autoType).then((data) => (dji.value = data)); - d3.csv("../data/seattle-weather.csv", d3.autoType).then((data) => (seattle.value = data)); - d3.csv("../data/simpsons.csv", d3.autoType).then((data) => (simpsons.value = data)); -}); - -</script> - -# Cell mark - -:::tip -The cell mark is a variant of the [rect mark](./rect.md) for use when both dimensions are ordinal. See also the [bar mark](./bar.md). -::: - -The **cell mark** draws rectangles positioned in two ordinal dimensions. Hence, the plot’s *x* and *y* scales are [band scales](../features/scales.md). Cells typically also have a **fill** color encoding. - -For example, the heatmap below shows the decline of *The Simpsons* after Season 9: high IMDb ratings are dark green, while low ratings are dark pink. (The worst episode ever — cue Comic Book Guy — is season 23’s [“Lisa Goes Gaga”](https://en.wikipedia.org/wiki/Lisa_Goes_Gaga).) - -:::plot defer https://observablehq.com/@observablehq/plot-simpsons-ratings -```js -Plot.plot({ - padding: 0, - grid: true, - x: {axis: "top", label: "Season"}, - y: {label: "Episode"}, - color: {type: "linear", scheme: "PiYG"}, - marks: [ - Plot.cell(simpsons, {x: "season", y: "number_in_season", fill: "imdb_rating", inset: 0.5}), - Plot.text(simpsons, {x: "season", y: "number_in_season", text: (d) => d.imdb_rating?.toFixed(1), fill: "black", title: "title"}) - ] -}) -``` -::: - -With [faceting](../features/facets.md), we can produce a calendar of multiple years, where **x** represents week-of-year and **y** represents day-of-week. Below shows almost twenty years of daily changes of the Dow Jones Industrial Average. - -:::plot defer https://observablehq.com/@observablehq/plot-dow-jones-calendar -```js -Plot.plot({ - padding: 0, - x: {axis: null}, - y: {tickFormat: Plot.formatWeekday("en", "narrow"), tickSize: 0}, - fy: {tickFormat: ""}, - color: {scheme: "PiYG"}, - marks: [ - Plot.cell(dji, { - x: (d) => d3.utcWeek.count(d3.utcYear(d.Date), d.Date), - y: (d) => d.Date.getUTCDay(), - fy: (d) => d.Date.getUTCFullYear(), - fill: (d, i) => i > 0 ? (d.Close - dji[i - 1].Close) / dji[i - 1].Close : NaN, - title: (d, i) => i > 0 ? ((d.Close - dji[i - 1].Close) / dji[i - 1].Close * 100).toFixed(1) : NaN, - inset: 0.5 - }) - ] -}) -``` -::: - -The cell mark can be combined with the [group transform](../transforms/group.md), which groups data by ordinal value. (The [bin transform](../transforms/bin.md), on the other hand, is intended for quantitative data and is typically paired with the [rect mark](./rect.md).) The heatmap below shows the maximum observed temperature by month (**y**) and date (**x**) in Seattle from 2012 through 2015. - -:::plot defer https://observablehq.com/@observablehq/plot-seattle-temperature-heatmap -```js -Plot.plot({ - padding: 0, - y: {tickFormat: Plot.formatMonth("en", "short")}, - marks: [ - Plot.cell(seattle, Plot.group({fill: "max"}, { - x: (d) => d.date.getUTCDate(), - y: (d) => d.date.getUTCMonth(), - fill: "temp_max", - inset: 0.5 - })) - ] -}) -``` -::: - -A one-dimensional cell is produced by specifying only **x** or only **y**. The plot below collapses the history of *The Simpsons* to a single line. - -:::plot defer https://observablehq.com/@observablehq/plot-simpsons-barcode -```js -Plot.plot({ - x: { - ticks: simpsons.filter((d) => d.number_in_season === 1).map((d) => d.id), - tickFormat: (x) => simpsons.find((d) => d.id === x).season, - label: "Season", - labelAnchor: "right", - labelArrow: true - }, - color: { - type: "linear", - scheme: "PiYG" - }, - marks: [ - Plot.cell(simpsons, {x: "id", fill: "imdb_rating"}) - ] -}) -``` -::: - -:::info -Here the *x*-scale domain contains the *id* of every episode. An ordinal scale by default draws a tick for every domain value; setting **ticks** to just the first episode of each season prevents overlapping labels. The **tickFormat** function finds the row corresponding to the episode id and returns the corresponding *season* number. -::: - -One-dimensional cells can be a compact alternative to a bar chart, where the *fill* color of the cell replaces the length of the bar. However, position is a more salient encoding and should be preferred to color if space is available. - -:::plot https://observablehq.com/@observablehq/plot-color-cells -```js -Plot.cell(alphabet, {x: "letter", fill: "frequency"}).plot() -``` -::: - -When ordinal data is regular, such as the yearly observations of the warming stripes below, use the **interval** scale option to enforce uniformity and show gaps for missing data. It can be set to a named interval such as *hour* or *day*, a number for numeric intervals, a [d3-time interval](https://d3js.org/d3-time#_interval), or a custom implementation. - -:::plot https://observablehq.com/@observablehq/plot-ordinal-scale-interval-2 -```js{5} -Plot.plot({ - x: { - ticks: d3.ticks(...d3.extent(hadcrut, (d) => d.year), 10), - tickFormat: "d", - interval: 1, // recommended in case of missing data - label: null - }, - color: { - scheme: "BuRd" - }, - marks: [ - Plot.cell(hadcrut, {x: "year", fill: "anomaly"}) - ] -}) -``` -::: - -:::tip -When an ordinal scale domain has high cardinality, the **ticks** scale option can be used to specify which ticks to label. Alternatively, consider using a quantitative or temporal scale instead, as by switching to a [bar mark](./bar.md). -::: - -## Cell options - -In addition to the [standard mark options](../features/marks.md#mark-options), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners), the following optional channels are supported: - -* **x** - the horizontal position; bound to the *x* scale, which must be *band* -* **y** - the vertical position; bound to the *y* scale, which must be *band* - -If **x** is not specified, the cell will span the full horizontal extent of the plot (or facet). Likewise if **y** is not specified, the cell will span the full vertical extent of the plot (or facet). Typically either **x**, **y**, or both are specified; use a [frame mark](./frame.md) to decorate the plot’s frame. - -The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. - -## cell(*data*, *options*) {#cell} - -```js -Plot.cell(simpsons, {x: "number_in_season", y: "season", fill: "imdb_rating"}) -``` - -Returns a new cell with the given *data* and *options*. If neither the **x** nor **y** 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₂*, …]. - -## cellX(*data*, *options*) {#cellX} - -```js -Plot.cellX(simpsons.map((d) => d.imdb_rating)) -``` - -Equivalent to [cell](#cell), except that if the **x** option is not specified, it defaults to [0, 1, 2, …], and if the **fill** option is not specified and **stroke** is not a channel, the fill defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. - -## cellY(*data*, *options*) {#cellY} - -```js -Plot.cellY(simpsons.map((d) => d.imdb_rating)) -``` - -Equivalent to [cell](#cell), except that if the **y** option is not specified, it defaults to [0, 1, 2, …], and if the **fill** option is not specified and **stroke** is not a channel, the fill defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. diff --git a/docs/marks/contour.md b/docs/marks/contour.md deleted file mode 100644 index c6977c52..00000000 --- a/docs/marks/contour.md +++ /dev/null @@ -1,280 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import volcano from "../data/volcano.ts"; - -const ca55 = shallowRef([]); -const vapor = shallowRef([]); -const grid = {"width": 10, "height": 10, "values": d3.cross(d3.range(10), d3.range(10), (x, y) => x * y)}; - -onMounted(() => { - d3.csv("../data/ca55-south.csv", d3.autoType).then((data) => (ca55.value = data)); - d3.text("../data/MYDAL2_M_SKY_WV_2022-11-01_rgb_360x180.csv").then((text) => (vapor.value = d3.csvParseRows(text).flat().map((x) => (x === "99999.0" ? NaN : +x)))); -}); - -function mandelbrot(x, y) { - for (let n = 0, zr = 0, zi = 0; n < 80; ++n) { - [zr, zi] = [zr * zr - zi * zi + x, 2 * zr * zi + y]; - if (zr * zr + zi * zi > 4) return n; - } -} - -</script> - -# Contour mark <VersionBadge version="0.6.2" /> - -:::tip -To produce a heatmap instead of contours, see the [raster mark](./raster.md). For contours of estimated point density, see the [density mark](./density.md). -::: - -The **contour mark** draws [isolines](https://en.wikipedia.org/wiki/Contour_line) to delineate regions above and below a particular continuous value. These contours are computed by applying the [marching squares algorithm](https://en.wikipedia.org/wiki/Marching_squares) to a discrete grid. Like the [raster mark](./raster.md), the grid can be constructed either by [interpolating spatial samples](./raster.md#spatial-interpolators) (arbitrary points in **x** and **y**) or by sampling a continuous function *f*(*x*,*y*) along the grid. - -For example, the contours below show the topography of the [Maungawhau volcano](https://en.wikipedia.org/wiki/Maungawhau), produced from a {{volcano.width}}×{{volcano.height}} grid of elevation samples. - -:::plot defer https://observablehq.com/@observablehq/plot-stroked-contours -```js -Plot.contour(volcano.values, {width: volcano.width, height: volcano.height}).plot() -``` -::: - -Whereas the **value** option produces isolines suitable for stroking, the **fill** option produces filled contours. Setting the **fill** to [identity](../features/transforms.md#identity) will apply a color encoding to the contour values, allowing the contour values to be read via a *color* legend. - -:::plot defer https://observablehq.com/@observablehq/plot-filled-contours -```js -Plot.plot({ - color: { - legend: true, - label: "Elevation (m)" - }, - marks: [ - Plot.contour(volcano.values, { - width: volcano.width, - height: volcano.height, - fill: Plot.identity, - stroke: "black" - }) - ] -}) -``` -::: - -:::info -Contours are drawn in ascending value order, with the highest value on top; hence, filled contour polygons overlap! If you are interested in isobands, please upvote [#1420](https://github.com/observablehq/plot/issues/1420). -::: - -The grid (`volcano.values` above) is a list of numbers `[103, 104, 104, …]`. The first number `103` is the elevation of the bottom-left corner. This grid is in [row-major order](https://en.wikipedia.org/wiki/Row-_and_column-major_order), meaning that the elevations of the first row are followed by the second row, then the third, and so on. Here’s a smaller grid to demonstrate the concept. - -```js -grid = ({ - "width": 10, - "height": 10, - "values": [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, - 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, - 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, - 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, - 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, - 0, 6, 12, 18, 24, 30, 36, 42, 48, 54, - 0, 7, 14, 21, 28, 35, 42, 49, 56, 63, - 0, 8, 16, 24, 32, 40, 48, 56, 64, 72, - 0, 9, 18, 27, 36, 45, 54, 63, 72, 81 - ] -}) -``` - -We can visualize this small grid directly with a [text mark](./text.md) using the same color encoding. Notice that the image below is flipped vertically relative to the data: the first row of the data is the *bottom* of the image because below *y* points up↑. - -:::plot https://observablehq.com/@observablehq/plot-small-grid-contours -```js -Plot.plot({ - grid: true, - x: {domain: [0, grid.width], label: "column"}, - y: {domain: [0, grid.height], label: "row"}, - marks: [ - Plot.text(grid.values, { - text: Plot.identity, - fill: Plot.identity, - x: (d, i) => i % grid.width + 0.5, - y: (d, i) => Math.floor(i / grid.width) + 0.5 - }) - ] -}) -``` -::: - -Also notice that the grid points are offset by 0.5: they represent the *middle* of each pixel rather than the corner. Below, the contour mark is laid under the text mark to show filled contours. - -:::plot defer https://observablehq.com/@observablehq/plot-small-grid-contours -```js -Plot.plot({ - marks: [ - Plot.contour(grid.values, { - width: grid.width, - height: grid.height, - fill: Plot.identity, - interval: 5 - }), - Plot.text(grid.values, { - text: Plot.identity, - fill: "white", - x: (d, i) => i % grid.width + 0.5, - y: (d, i) => Math.floor(i / grid.width) + 0.5 - }) - ] -}) -``` -::: - -Similar to the [bin transform](../transforms/bin.md), contour levels can be specified either with the **interval** option (above, a contour at each multiple of 5) or with the **thresholds** option (either a count of thresholds or an explicit array of values). - -While the contour mark provides convenient shorthand for strictly gridded data, as above, it *also* works with samples in arbitrary positions and arbitrary order. For example, in 1955 the [Great Britain aeromagnetic survey](https://www.bgs.ac.uk/datasets/gb-aeromagnetic-survey/) measured the Earth’s magnetic field by plane. Each sample recorded the longitude and latitude alongside the strength of the [IGRF](https://www.ncei.noaa.gov/products/international-geomagnetic-reference-field) in [nanoteslas](https://en.wikipedia.org/wiki/Tesla_(unit)). - -``` -LONGITUDE,LATITUDE,MAG_IGRF90 --2.36216,51.70945,7 --2.36195,51.71727,6 --2.36089,51.72404,9 --2.35893,51.73758,12 --2.35715,51.7532,18 --2.35737,51.76636,24 -``` - -Using a [dot mark](./dot.md), we can make a quick scatterplot to see the irregular grid. We’ll use a *diverging* color scale to distinguish positive and negative values. - -:::plot defer https://observablehq.com/@observablehq/plot-igrf90-dots -```js -Plot.dot(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90"}).plot({color: {type: "diverging"}}) -``` -::: - -Pass the same arguments to the contour mark for continuous contours. - -:::plot defer https://observablehq.com/@observablehq/plot-igrf90-contours -```js -Plot.contour(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90"}).plot({color: {type: "diverging"}}) -``` -::: - -As with the raster mark, the **blur** option applies a Gaussian blur to the underlying raster grid, resulting in smoother contours. - -:::plot defer https://observablehq.com/@observablehq/plot-blurred-contours -```js -Plot.contour(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", blur: 4}).plot({color: {type: "diverging"}}) -``` -::: - -:::tip -The contour mark also supports the **interpolate** option for control over [spatial interpolation](./raster.md#spatial-interpolators). -::: - -The contour mark supports Plot’s [projection system](../features/projections.md). The chart below shows global atmospheric water vapor measurements from [NASA Earth Observations](https://neo.gsfc.nasa.gov/view.php?datasetId=MYDAL2_M_SKY_WV). - -:::plot defer https://observablehq.com/@observablehq/plot-contours-projection -```js -Plot.plot({ - projection: "equal-earth", - color: { - scheme: "BuPu", - domain: [0, 6], - legend: true, - label: "Water vapor (cm)" - }, - marks: [ - Plot.contour(vapor, { - fill: Plot.identity, - width: 360, - height: 180, - x1: -180, - y1: 90, - x2: 180, - y2: -90, - blur: 1, - stroke: "black", - strokeWidth: 0.5, - clip: "sphere" - }), - Plot.sphere({stroke: "black"}) - ] -}) -``` -::: - -As an alternative to interpolating discrete samples, you can supply values as a continuous function *f*(*x*,*y*); the contour mark will invoke this function for the midpoint of each pixel in the raster grid, similar to a WebGL fragment shader. For example, below we visualize the trigonometric function sin(*x*) cos(*y*), producing a checkerboard-like pattern. - -:::plot defer https://observablehq.com/@observablehq/plot-function-contour-2 -```js -Plot.plot({ - aspectRatio: 1, - x: {tickSpacing: 80, label: "x"}, - y: {tickSpacing: 80, label: "y"}, - color: {type: "diverging", legend: true, label: "sin(x) cos(y)"}, - marks: [ - Plot.contour({ - fill: (x, y) => Math.sin(x) * Math.cos(y), - x1: 0, - y1: 0, - x2: 6 * Math.PI, - y2: 4 * Math.PI - }) - ] -}) -``` -::: - -:::tip -When faceting, the sample function *f*(*x*,*y*) is passed a third argument of the facet values {*fx*, *fy*}. -::: - -## Contour options - -If *data* is provided, it represents discrete samples in abstract coordinates **x** and **y**; the **value** channel specifies further abstract quantitative values (_e.g._, height in a topographic map) to be [spatially interpolated](./raster.md#spatial-interpolators) to produce the underlying raster grid. - -```js -Plot.contour(volcano.values, {width: volcano.width, height: volcano.height, value: Plot.identity}) -``` - -The **value** channel may alternatively be specified as a continuous function *f*(*x*,*y*) to be evaluated at each pixel centroid of the raster grid (without interpolation). - -```js -Plot.contour({x1: 0, y1: 0, x2: 4, y2: 4, value: (x, y) => Math.sin(x) * Math.cos(y)}) -``` - -The resolution of the raster grid may be specified with the following options: - -* **width** - the number of pixels on each horizontal line -* **height** - the number of lines; a positive integer - -Alternatively, the raster dimensions may be imputed from the extent of *x* and *y* and a pixel size: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **x2** - the ending horizontal position; bound to the *x* scale -* **y1** - the starting vertical position; bound to the *y* scale -* **y2** - the ending vertical position; bound to the *y* scale -* **pixelSize** - the screen size of a raster pixel; defaults to 1 - -If **width** is specified, **x1** defaults to 0 and **x2** defaults to **width**; likewise, if **height** is specified, **y1** defaults to 0 and **y2** defaults to **height**. Otherwise, if **data** is specified, **x1**, **y1**, **x2**, and **y2** respectively default to the frame’s left, top, right, and bottom coordinates. Lastly, if **data** is not specified (as when **value** is a function of *x* and *y*), you must specify all of **x1**, **x2**, **y1**, and **y2** to define the raster domain (see below). - -The contour mark shares many options with the [raster mark](./raster.md). The **interpolate** option is ignored when the **value** channel is a continuous function of *x* and *y*, and otherwise defaults to *nearest*. For smoother contours, the **blur** option (default 0) specifies a non-negative pixel radius for smoothing prior to applying marching squares. The **smooth** option (default true) specifies whether to apply linear interpolation after marching squares when computing contour polygons. The **thresholds** and **interval** options specify the contour thresholds; see the [bin transform](../transforms/bin.md) for details. - -With the exception of the **x**, **y**, **x1**, **y1**, **x2**, **y2**, and **value** channels, the contour mark’s channels are not evaluated on the initial *data* but rather on the contour multipolygons generated in the initializer. For example, to generate filled contours where the color corresponds to the contour threshold value: - -```js -Plot.contour(volcano.values, {width: volcano.width, height: volcano.height, value: Plot.identity, fill: "value"}) -``` - -As shorthand, a single channel may be specified, in which case it is promoted to the *value* channel. - -```js -Plot.contour(volcano.values, {width: volcano.width, height: volcano.height, fill: Plot.identity}) -``` - -## contour(*data*, *options*) {#contour} - -```js -Plot.contour(volcano.values, {width: volcano.width, height: volcano.height, fill: Plot.identity}) -``` - -Returns a new contour mark with the given (optional) *data* and *options*. diff --git a/docs/marks/delaunay.md b/docs/marks/delaunay.md deleted file mode 100644 index d3ee438c..00000000 --- a/docs/marks/delaunay.md +++ /dev/null @@ -1,180 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import penguins from "../data/penguins.ts"; - -const walmarts = shallowRef([]); -const us = shallowRef(null); -const nation = computed(() => us.value ? topojson.feature(us.value, us.value.objects.nation) : {type: null}); - -onMounted(() => { - d3.tsv("../data/walmarts.tsv", d3.autoType).then((data) => (walmarts.value = data)); - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); -}); - -</script> - -# Delaunay marks <VersionBadge version="0.5.1" /> - -Given set of points in **x** and **y**, the **Delaunay marks** compute the [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation), its dual the [Voronoi tessellation](https://en.wikipedia.org/wiki/Voronoi_diagram), and the [convex hull](https://en.wikipedia.org/wiki/Convex_hull). - -The [voronoi mark](#voronoi) computes the region closest to each point (its *Voronoi cell*). The cell can be empty if another point shares the exact same coordinates. Together, the cells cover the entire plot. Voronoi diagrams can group related points with color, for example. - -:::plot https://observablehq.com/@observablehq/plot-voronoi-scatterplot -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.voronoi(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species", fillOpacity: 0.2, stroke: "var(--vp-c-bg)"}), - Plot.frame(), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species"}) - ] -}) -``` -::: - -Each cell is associated with a particular data point, and channels such as **stroke**, **fill**, **fillOpacity**, **strokeOpacity**, **href**, _etc._, work as they do on other marks, such as [dots](./dot.md). - -To show the local density of a scatterplot, one can draw the whole boundary at once with [voronoiMesh](#voronoiMesh). Whereas the [voronoi mark](#voronoi) will draw shared cell boundaries twice, the mesh will draw them only once. - -:::plot https://observablehq.com/@observablehq/plot-voronoi-mesh -```js -Plot.plot({ - marks: [ - Plot.voronoiMesh(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species"}) - ] -}) -``` -::: - -The boundary between two neighboring Voronoi cells is a line segment defined by equal distance from their two respective points. The construction of the Voronoi diagram involves the computation of the Delaunay graph, which defines these neighbors. Use [delaunayMesh](#delaunayMesh) to draw the graph. - -:::plot https://observablehq.com/@observablehq/plot-delaunay-mesh -```js -Plot.plot({ - marks: [ - Plot.delaunayMesh(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", z: "species", stroke: "species", strokeOpacity: 0.5}), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species"}) - ] -}) -``` -::: - -As shown above, the Delaunay graph is computed separately for each color; specifying **z**, **stroke**, or **fill** creates independent series. - -Another derivative of the Delaunay graph is the convex hull of a set of points: the polygon with the minimum perimeter that contains all the points. The [hull mark](#hull) will draw this hull. - -:::plot defer https://observablehq.com/@observablehq/plot-convex-hull -```js -Plot.plot({ - marks: [ - Plot.hull(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species", fillOpacity: 0.2}), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", stroke: "species"}) - ] -}) -``` -::: - -Using independent series is not recommended in the case of the voronoi and voronoiMesh marks as it will result in an unreadable chart due to overlapping Voronoi diagrams, but it can be useful to color the links of the Delaunay graph based on some property of data, such as the body mass of penguins below. - -:::plot defer https://observablehq.com/@observablehq/plot-delaunay-links -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.delaunayLink(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", stroke: "body_mass_g", strokeWidth: 1.5}) - ] -}) -``` -::: - -:::warning CAUTION -The link color is driven by one arbitrary extremity of each edge; this might change in the future. -::: - -The Delaunay marks can be one-dimensional, too. - -:::plot defer https://observablehq.com/@observablehq/plot-one-dimensional-voronoi -```js -Plot.plot({ - marks: [ - Plot.voronoi(penguins, {x: "body_mass_g", fill: "species"}), - Plot.voronoiMesh(penguins, {x: "body_mass_g", stroke: "white", strokeOpacity: 1}) - ] -}) -``` -::: - -The [Delaunay marks](../marks/delaunay.md) also work with Plot’s [projection system](../features/projections.md), as in this Voronoi diagram showing the distribution of Walmart stores in the contiguous United States. - -:::plot defer https://observablehq.com/@observablehq/plot-walmart-voronoi -```js -Plot.plot({ - projection: "albers", - marks: [ - Plot.geo(nation), - Plot.dot(walmarts, {x: "longitude", y: "latitude", fill: "currentColor", r: 1}), - Plot.voronoiMesh(walmarts, {x: "longitude", y: "latitude"}) - ] -}) -``` -::: - -:::warning CAUTION -Distances between projected points are not exactly proportional to the corresponding distances on the sphere. This [creates a discrepancy](https://observablehq.com/@observablehq/planar-vs-spherical-voronoi) between the planar Voronoi diagram and its spherical counterpart. For greater accuracy, use [d3-geo-voronoi](https://github.com/Fil/d3-geo-voronoi) with the [geo mark](../marks/geo.md). -::: - - -## delaunayLink(*data*, *options*) {#delaunayLink} - -```js -Plot.delaunayLink(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) -``` - -Draws links for each edge of the Delaunay triangulation of the points given by the **x** and **y** channels. Supports the same options as the [link mark](./link.md), except that **x1**, **y1**, **x2**, and **y2** are derived automatically from **x** and **y**. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the link inherits the corresponding channel value from one of its two endpoints arbitrarily. - -If a **z** channel is specified, the input points are grouped by *z*, and separate Delaunay triangulations are constructed for each group. - -## delaunayMesh(*data*, *options*) {#delaunayMesh} - -```js -Plot.delaunayMesh(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) -``` - -Draws a mesh of the Delaunay triangulation of the points given by the **x** and **y** channels. The **stroke** option defaults to _currentColor_, and the **strokeOpacity** defaults to 0.2. The **fill** option is not supported. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the mesh inherits the corresponding channel value from one of its constituent points arbitrarily. - -If a **z** channel is specified, the input points are grouped by *z*, and separate Delaunay triangulations are constructed for each group. - -## hull(*data*, *options*) {#hull} - -```js -Plot.hull(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) -``` - -Draws a convex hull around the points given by the **x** and **y** channels. The **stroke** option defaults to _currentColor_ and the **fill** option defaults to _none_. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the hull inherits the corresponding channel value from one of its constituent points arbitrarily. - -If a **z** channel is specified, the input points are grouped by *z*, and separate convex hulls are constructed for each group. If the **z** channel is not specified, it defaults to either the **fill** channel, if any, or the **stroke** channel, if any. - -## voronoi(*data*, *options*) {#voronoi} - -```js -Plot.voronoi(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) -``` - -Draws polygons for each cell of the Voronoi tessellation of the points given by the **x** and **y** channels. - -If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tessellations are constructed for each group. - -## voronoiMesh(*data*, *options*) {#voronoiMesh} - -```js -Plot.voronoiMesh(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) -``` - -Draws a mesh for the cell boundaries of the Voronoi tessellation of the points given by the **x** and **y** channels. The **stroke** option defaults to _currentColor_, and the **strokeOpacity** defaults to 0.2. The **fill** option is not supported. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the mesh inherits the corresponding channel value from one of its constituent points arbitrarily. - -If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tessellations are constructed for each group. diff --git a/docs/marks/density.md b/docs/marks/density.md deleted file mode 100644 index cb5953ce..00000000 --- a/docs/marks/density.md +++ /dev/null @@ -1,254 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import faithful from "../data/faithful.ts"; -import penguins from "../data/penguins.ts"; - -const walmarts = shallowRef([]); -const us = shallowRef(null); -const nation = computed(() => us.value ? topojson.feature(us.value, us.value.objects.nation) : {type: null}); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states, (a, b) => a !== b) : {type: null}); -const skew = ref(0); -const bandwidth = ref(20); -const thresholds = ref(20); -const diamonds = shallowRef([]); - -onMounted(() => { - d3.csv("../data/diamonds.csv", d3.autoType).then((data) => (diamonds.value = data)); - d3.tsv("../data/walmarts.tsv", d3.autoType).then((data) => (walmarts.value = data)); - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); -}); - -</script> - -# Density mark <VersionBadge version="0.5.1" /> - -:::tip -For contours of spatially-distributed quantitative values, see the [contour mark](./contour.md). -::: - -The **density mark** shows the [estimated density](https://en.wikipedia.org/wiki/Multivariate_kernel_density_estimation) of two-dimensional point clouds. Contours guide the eye towards the local peaks of concentration of the data, much like a topographic map does with elevation. This is especially useful given overplotting in dense datasets. - -:::plot https://observablehq.com/@observablehq/plot-point-cloud-density -```js -Plot.plot({ - inset: 10, - marks: [ - Plot.density(faithful, {x: "waiting", y: "eruptions", stroke: "blue", strokeWidth: 0.25}), - Plot.density(faithful, {x: "waiting", y: "eruptions", stroke: "blue", thresholds: 4}), - Plot.dot(faithful, {x: "waiting", y: "eruptions", fill: "currentColor", r: 1.5}) - ] -}) -``` -::: - -The **bandwidth** option specifies the radius of the [Gaussian kernel](https://en.wikipedia.org/wiki/Gaussian_function) describing the influence of each point as a function of distance; this kernel is summed over a discrete grid covering the plot, and then contours (*isolines*) are derived for values between 0 (exclusive) and the maximum density (exclusive) using the [marching squares algorithm](https://en.wikipedia.org/wiki/Marching_squares). - -<p> - <label class="label-input"> - Bandwidth: - <input type="range" v-model.number="bandwidth" min="0" max="40" step="0.2"> - <span style="font-variant-numeric: tabular-nums;">{{bandwidth.toLocaleString("en-US", {minimumFractionDigits: 1})}}</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-density-options -```js -Plot.plot({ - inset: 20, - marks: [ - Plot.density(faithful, {x: "waiting", y: "eruptions", bandwidth}), - Plot.dot(faithful, {x: "waiting", y: "eruptions"}) - ] -}) -``` -::: - -The **thresholds** option specifies the number of contour lines (minus one) to be computed, or an explicit array of threshold values. For example, with 4 thresholds and a maximum density of 10, contour lines would be drawn for the values 2.5, 5, and 7.5. The default number of thresholds is 20. - -<p> - <label class="label-input"> - Thresholds: - <input type="range" v-model.number="thresholds" min="1" max="40" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{thresholds}}</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-density-options -```js -Plot.plot({ - inset: 20, - marks: [ - Plot.density(faithful, {x: "waiting", y: "eruptions", thresholds}), - Plot.dot(faithful, {x: "waiting", y: "eruptions"}) - ] -}) -``` -::: - -The density mark also works with one-dimensional values: - -:::plot defer https://observablehq.com/@observablehq/plot-one-dimensional-density -```js -Plot.plot({ - height: 100, - inset: 10, - marks: [ - Plot.density(faithful, {x: "waiting", stroke: "blue", strokeWidth: 0.25, bandwidth: 10}), - Plot.density(faithful, {x: "waiting", stroke: "blue", thresholds: 4, bandwidth: 10}), - Plot.dot(faithful, {x: "waiting", fill: "currentColor", r: 1.5}) - ] -}) -``` -::: - -The density mark supports Plot’s [projection system](../features/projections.md), as in this heatmap showing the density of Walmart stores across the contiguous United States (which is a decent proxy for population density). - -:::plot defer https://observablehq.com/@observablehq/plot-walmart-density -```js-vue -Plot.plot({ - projection: "albers", - color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [ - Plot.density(walmarts, {x: "longitude", y: "latitude", bandwidth: 10, fill: "density"}), - Plot.geo(statemesh, {strokeOpacity: 0.3}), - Plot.geo(nation), - Plot.dot(walmarts, {x: "longitude", y: "latitude", r: 1, fill: "currentColor"}) - ] -}) -``` -::: - -:::tip -Use an equal-area projection with the density mark. -::: - -By using the _density_ keyword as a **fill** or **stroke** color, you can draw regions with a sequential color encoding. - -:::plot defer https://observablehq.com/@observablehq/plot-density-stroke -```js -Plot.plot({ - inset: 10, - grid: true, - x: {type: "log"}, - y: {type: "log"}, - marks: [ - Plot.density(diamonds, {x: "carat", y: "price", stroke: "density"}) - ] -}) -``` -::: - -To facilitate comparison across facets (**fx** or **fy**) and series (**z**, **stroke**, or **fill**), the thresholds are determined by the series with the highest density. For instance, the chart below shows the highest concentration of penguins, arranged by flipper length and culmen length, on Biscoe island; the contours in the other facets use the same thresholds. - -<!-- ```js -Plot.plot({ - axis: null, - marks: [ - Plot.dot(penguins, {x: "flipper_length_mm", y: "culmen_length_mm"}), - Plot.density(penguins, {x: "flipper_length_mm", y: "culmen_length_mm"}) - ] -}) -``` --> - -:::plot defer https://observablehq.com/@observablehq/plot-density-faceted -```js -Plot.plot({ - marks: [ - Plot.density(penguins, {fx: "island", x: "flipper_length_mm", y: "culmen_length_mm", stroke: "density", clip: true}), - Plot.frame() - ] -}) -``` -::: - -<!-- With the default settings, the density is the local average number of dots on an area of ${tex`100\text{px}^2`} — a square of 10px by 10px. This can be multiplied by the dots’ weights. --> - -The **weight** channel specifies the contribution of each data point to the estimated density; it defaults to 1, weighing each point equally. This can be used to give some points more influence than others. Try adjusting the skew slider below to transition between female- and male-weighted density. - -<p> - <label class="label-input"> - Skew (-F/+M): - <input type="range" v-model.number="skew" min="-1" max="1" step="0.01"> - <span style="font-variant-numeric: tabular-nums;">{{skew.toLocaleString("en-US", {minimumFractionDigits: 2, signDisplay: "always"})}}</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-density-weighted -```js -Plot.plot({ - inset: 10, - color: {legend: true}, - marks: [ - Plot.density(penguins.filter((d) => d.sex), { - weight: (d) => d.sex === "FEMALE" ? 1 - skew : 1 + skew, - x: "flipper_length_mm", - y: "culmen_length_mm", - strokeOpacity: 0.5, - clip: true - }), - Plot.dot(penguins.filter((d) => d.sex), { - x: "flipper_length_mm", - y: "culmen_length_mm", - stroke: "sex", - strokeOpacity: (d) => d.sex === "FEMALE" ? 1 - skew : 1 + skew - }), - Plot.frame() - ] -}) -``` -::: - -You can specify a negative weight for points that the density contours should avoid, resulting in regions of influence that do not overlap. - -:::plot defer https://observablehq.com/@observablehq/plot-non-overlapping-density-regions -```js -Plot.plot({ - inset: 10, - color: {legend: true}, - marks: [ - d3.groups(penguins, (d) => d.species).map(([s]) => - Plot.density(penguins, { - x: "flipper_length_mm", - y: "culmen_length_mm", - weight: (d) => d.species === s ? 1 : -1, - fill: () => s, - fillOpacity: 0.2, - thresholds: [0.05] - }) - ), - Plot.dot(penguins, { - x: "flipper_length_mm", - y: "culmen_length_mm", - stroke: "species" - }), - Plot.frame() - ] -}) -``` -::: - -## Density options - -In addition to the [standard mark options](../features/marks.md#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 -* **weight** - the contribution to the estimated density - -If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option. - -The **thresholds** option, which defaults to 20, specifies one more than the number of contours that will be computed at uniformly-spaced intervals between 0 (exclusive) and the maximum density (exclusive). The **thresholds** option may also be specified as an array or iterable of explicit density values. The **bandwidth** option, which defaults to 20, specifies the standard deviation of the Gaussian kernel used for estimation in pixels. - -If a **z**, **stroke** or **fill** channel is specified, the input points are grouped by series, and separate sets of contours are generated for each series. If the **stroke** or **fill** is specified as *density*, a color channel is constructed with values representing the density threshold value of each contour. - -## density(*data*, *options*) {#density} - -```js -Plot.density(faithful, {x: "waiting", y: "eruptions"}) -``` - -Returns a new density mark for the given *data* and *options*. diff --git a/docs/marks/difference.md b/docs/marks/difference.md deleted file mode 100644 index ca4ceafa..00000000 --- a/docs/marks/difference.md +++ /dev/null @@ -1,153 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {computed, shallowRef, onMounted} from "vue"; - -const aapl = shallowRef([]); -const gistemp = shallowRef([]); -const tsa = shallowRef([{Date: new Date("2020-01-01")}]); -const temperature = shallowRef([{date: new Date("2020-01-01")}]); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/gistemp.csv", d3.autoType).then((data) => (gistemp.value = data)); - d3.csv("../data/tsa.csv",d3.autoType).then((data) => (tsa.value = data)); - d3.csv("../data/sf-sj-temperatures.csv", d3.autoType).then((data) => (temperature.value = data.filter((d) => d.date.getUTCFullYear() === 2020))); -}); - -</script> - -# Difference mark <VersionBadge version="0.6.12" pr="1896" /> - -The **difference mark** puts a metric in context by comparing it. Like the [area mark](./area.md), the region between two lines is filled; unlike the area mark, alternating color shows when the metric is above or below the comparison value. - -In the simplest case, the difference mark compares a metric to a constant. For example, the plot below shows the [global surface temperature anomaly](https://data.giss.nasa.gov/gistemp/) from 1880–2016; 0° represents the 1951–1980 average; above-average temperatures are in <span style="border-bottom: solid var(--vp-c-red) 3px;">red</span>, while below-average temperatures are in <span style="border-bottom: solid var(--vp-c-blue) 3px;">blue</span>. (It’s getting hotter.) - -:::plot -```js -Plot.differenceY(gistemp, { - x: "Date", - y: "Anomaly", - positiveFill: "red", - negativeFill: "blue", - tip: true -}).plot({y: {grid: true}}) -``` -::: - -A 24-month [moving average](../transforms/window.md) improves readability by smoothing out the noise. - -:::plot -```js -Plot.differenceY( - gistemp, - Plot.windowY(12 * 2, { - x: "Date", - y: "Anomaly", - positiveFill: "red", - negativeFill: "blue", - tip: true - }) -).plot({y: {grid: true}}) -``` -::: - -More powerfully, the difference mark compares two metrics. For example, the plot below shows the number of travelers per day through TSA checkpoints in 2020 compared to 2019. (This in effect compares a metric against itself, but as the data represents each year as a separate column, it is equivalent to two metrics.) In the first two months of 2020, there were on average <span style="border-bottom: solid #01ab63 3px;">more travelers</span> per day than 2019; yet when COVID-19 hit, there were many <span style="border-bottom: solid #4269d0 3px;">fewer travelers</span> per day, dropping almost to zero. - -:::plot -```js -Plot.plot({ - x: {tickFormat: "%b"}, - y: {grid: true, label: "Travelers"}, - marks: [ - Plot.axisY({label: "Travelers per day (thousands, 2020 vs. 2019)", tickFormat: (d) => d / 1000}), - Plot.ruleY([0]), - Plot.differenceY(tsa, {x: "Date", y1: "2019", y2: "2020", tip: {format: {x: "%B %-d"}}}) - ] -}) -``` -::: - -If the data is “tall” rather than “wide” — that is, if the two metrics we wish to compare are represented by separate *rows* rather than separate *columns* — we can use the [group transform](../transforms/group.md) with the [find reducer](../transforms/group.md#find): group the rows by **x** (date), then find the desired **y1** and **y2** for each group. The plot below shows daily minimum temperature for San Francisco compared to San Jose. Notice how the insulating fog keeps San Francisco <span style="border-bottom: solid #01ab63 3px;">warmer</span> in winter and <span style="border-bottom: solid #4269d0 3px;">cooler</span> in summer, reducing seasonal variation. - -:::plot -```js -Plot.plot({ - x: {tickFormat: "%b"}, - y: {grid: true}, - marks: [ - Plot.ruleY([32]), - Plot.differenceY( - temperature, - Plot.windowY( - 14, - Plot.groupX( - { - y1: Plot.find((d) => d.station === "SJ"), - y2: Plot.find((d) => d.station === "SF") - }, - { - x: "date", - y: "tmin", - tip: true - } - ) - ) - ) - ] -}) -``` -::: - -The difference mark can also be used to compare a metric to itself using the [shift transform](../transforms/shift.md). The chart below shows year-over-year growth in the price of Apple stock. - -:::plot -```js -Plot.differenceY(aapl, Plot.shiftX("+1 year", {x: "Date", y: "Close"})).plot({y: {grid: true}}) -``` -::: - -For most of the covered time period, you would have <span style="border-bottom: solid #01ab63 3px;">made a profit</span> by holding Apple stock for a year; however, if you bought in 2015 and sold in 2016, you would likely have <span style="border-bottom: solid #4269d0 3px;">lost money</span>. - -## Difference options - -The following channels are required: - -* **x2** - the horizontal position of the metric; bound to the *x* scale -* **y2** - the vertical position of the metric; bound to the *y* scale - -In addition to the [standard mark options](../features/marks.md#mark-options), the following optional channels are supported: - -* **x1** - the horizontal position of the comparison; bound to the *x* scale -* **y1** - the vertical position of the comparison; bound to the *y* scale - -If **x1** is not specified, it defaults to **x2**. If **y1** is not specified, it defaults to 0 if **x1** and **x2** are equal, and to **y2** otherwise. These defaults facilitate sharing *x* or *y* coordinates between the metric and its comparison. - -The standard **fill** option is ignored; instead, there are separate channels based on the sign of the difference: - -* **positiveFill** - the color for when the metric is greater, defaults to <span style="border-bottom:solid #01ab63 3px;">green</span> -* **negativeFill** - the color for when the comparison is greater, defaults to <span style="border-bottom:solid #4269d0 3px;">blue</span> -* **fillOpacity** - the areas’ opacity, defaults to 1 -* **positiveFillOpacity** - the positive area’s opacity, defaults to *opacity* -* **negativeFillOpacity** - the negative area’s opacity, defaults to *opacity* -* **stroke** - the metric line’s stroke color, defaults to currentColor -* **strokeOpacity** - the metric line’s opacity, defaults to 1 - -These options are passed to the underlying area and line marks; in particular, when they are defined as a channel, the underlying marks are broken into contiguous overlapping segments when the values change. When any of these channels are used, setting an explicit **z** channel (possibly to null) is strongly recommended. - -## differenceY(*data*, *options*) {#differenceY} - -```js -Plot.differenceY(gistemp, {x: "Date", y: "Anomaly"}) -``` - -Returns a new vertical difference with the given *data* and *options*. The mark is a composite of a positive area, negative area, and line. The positive area extends from the bottom of the frame to the line, and is clipped by the area extending from the comparison to the top of the frame. The negative area conversely extends from the top of the frame to the line, and is clipped by the area extending from the comparison to the bottom of the frame. - -## differenceX(*data*, *options*) <VersionBadge version="0.6.16" pr="1922" /> {#differenceX} - -```js -Plot.differenceX(gistemp, {y: "Date", x: "Anomaly"}) -``` - -Returns a new horizontal difference with the given *data* and *options*. See [differenceY](#differenceY) for more. diff --git a/docs/marks/dot.md b/docs/marks/dot.md deleted file mode 100644 index 4090b96d..00000000 --- a/docs/marks/dot.md +++ /dev/null @@ -1,370 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; -import cars from "../data/cars.ts"; -import penguins from "../data/penguins.ts"; - -const sorted = ref(true); -const aapl = shallowRef([]); -const congress = shallowRef([]); -const diamonds = shallowRef([]); -const gistemp = shallowRef([{Date: new Date("1880-01-01"), Anomaly: -0.78}, {Date: new Date("2016-12-01"), Anomaly: 1.35}]); -const stateage = shallowRef([]); -const us = shallowRef(null); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states) : {type: null}); -const counties = computed(() => us.value ? topojson.feature(us.value, us.value.objects.counties).features : []); - -const xy = Plot.normalizeX({basis: "sum", z: "state", x: "population", y: "state"}); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/us-congress-2023.csv", d3.autoType).then((data) => (congress.value = data)); - d3.csv("../data/diamonds.csv", d3.autoType).then((data) => (diamonds.value = data)); - d3.csv("../data/gistemp.csv", d3.autoType).then((data) => (gistemp.value = data)); - Promise.all([ - d3.json("../data/us-counties-10m.json"), - d3.csv("../data/us-county-population.csv") - ]).then(([_us, _population]) => { - const map = new Map(_population.map((d) => [d.state + d.county, +d.population])); - _us.objects.counties.geometries.forEach((g) => (g.properties.population = map.get(g.id))); - us.value = _us; - }); - d3.csv("../data/us-population-state-age.csv", d3.autoType).then((data) => { - const ages = data.columns.slice(1); // convert wide data to tidy data - stateage.value = Object.assign(ages.flatMap((age) => data.map((d) => ({state: d.name, age, population: d[age]}))), {ages}); - }); -}); - -</script> - -# 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*→. - -:::plot https://observablehq.com/@observablehq/plot-basic-scatterplot -```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.) - -:::plot https://observablehq.com/@observablehq/plot-derived-value-scatterplot -```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. - -:::plot defer https://observablehq.com/@observablehq/plot-diverging-color-scatterplot -```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. - -:::plot defer https://observablehq.com/@observablehq/plot-proportional-symbol-scatterplot -```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](../transforms/bin.md), sized dots can also be used as an alternative to a [rect-based](./rect.md) heatmap to show a two-dimensional distribution. - -:::plot defer https://observablehq.com/@observablehq/plot-proportional-dot-heatmap -```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](../transforms/hexbin.md) 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. - -:::plot https://observablehq.com/@observablehq/plot-dot-area-chart -```js -Plot.dot(alphabet, {x: "letter", r: "frequency"}).plot() -``` -::: - -Dots, together with [rules](./rule.md), can be used as a stylistic alternative to [bars](./bar.md) to produce a lollipop 🍭 chart. (Sadly these lollipops cannot be eaten.) - -:::plot https://observablehq.com/@observablehq/plot-lollipop -```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](../transforms/normalize.md) is used to compute the relative proportion of each age group within each state, while the [group transform](../transforms/group.md) is used to pull out the *min* and *max* values for each state for a horizontal [rule](./rule.md). - -:::plot defer https://observablehq.com/@observablehq/plot-dot-plot -```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. - -:::plot defer https://observablehq.com/@observablehq/plot-symbol-channel -```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: - -:::plot -```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: - -:::plot -```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](https://www.tandfonline.com/doi/abs/10.1080/10618600.2019.1637746). There is also a *hexagon* symbol; it is primarily intended for the [hexbin transform](../transforms/hexbin.md). You can even specify a D3 or custom symbol type as an object that implements the [*symbol*.draw(*context*, *size*)](https://d3js.org/d3-shape/symbol#symbolType_draw) method. -::: - -The dot mark can be combined with the [stack transform](../transforms/stack.md). The diverging stacked dot plot below shows the age and gender distribution of the U.S. Congress in 2023. - -:::plot defer https://observablehq.com/@observablehq/plot-stacked-dots -```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](../transforms/dodge.md) 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 <VersionBadge version="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. - -<p> - <label class="label-input"> - Use default sort: - <input type="checkbox" v-model="sorted"> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-dot-sort -```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: "var(--vp-c-bg)", - strokeWidth: 1, - sort: sorted ? undefined : null - })) - ] -}) -``` -::: - -The dot mark can also be used to construct a [quantile-quantile (QQ) plot](https://observablehq.com/@observablehq/qq-plot) for comparing two univariate distributions. - -## Dot options - -In addition to the [standard mark options](../features/marks.md#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 <VersionBadge version="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* <VersionBadge version="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*) {#dot} - -```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*) {#dotX} - -```js -Plot.dotX(cars.map((d) => d["economy (mpg)"])) -``` - -Equivalent to [dot](#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](../features/scales.md#scale-options). - -## dotY(*data*, *options*) {#dotY} - -```js -Plot.dotY(cars.map((d) => d["economy (mpg)"])) -``` - -Equivalent to [dot](#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](../features/scales.md#scale-options). - -## circle(*data*, *options*) <VersionBadge version="0.5.0" /> {#circle} - -Equivalent to [dot](#dot) except that the **symbol** option is set to *circle*. - -## hexagon(*data*, *options*) <VersionBadge version="0.5.0" /> {#hexagon} - -Equivalent to [dot](#dot) except that the **symbol** option is set to *hexagon*. diff --git a/docs/marks/frame.md b/docs/marks/frame.md deleted file mode 100644 index c619ce55..00000000 --- a/docs/marks/frame.md +++ /dev/null @@ -1,124 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; -import penguins from "../data/penguins.ts"; - -const framed = ref(true); - -const faithful = shallowRef([]); - -onMounted(() => { - d3.tsv("../data/faithful.tsv", d3.autoType).then((data) => (faithful.value = data)); -}); - -</script> - -# Frame mark - -The **frame mark** draws a rectangle around the plot area. - -:::plot -```js -Plot.frame().plot({x: {domain: [0, 1], grid: true}}) -``` -::: - -Frames are most commonly used in conjunction with facets to provide better separation (Gestalt grouping) of faceted marks. Without a frame, it can be hard to tell where one facet ends and the next begins. - -<p> - <label class="label-input"> - Show frame: - <input type="checkbox" v-model="framed"> - </label> -</p> - -:::plot -```js -Plot.plot({ - grid: true, - inset: 10, - marks: [ - framed ? Plot.frame() : null, - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#eee"}), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species"}) - ] -}) -``` -::: - -Unlike most marks, a frame never takes *data*; the first argument to [frame](#frame) is the *options* object. (For data-driven rectangles, see the [rect mark](./rect.md).) - -:::plot -```js -Plot.frame({stroke: "red"}).plot({x: {domain: [0, 1], grid: true}}) -``` -::: - -While options are often specified in literal values, such as <span style="border-bottom: solid 2px var(--vp-c-red);">*red*</span> above, the standard [mark channels](../features/marks.md#mark-options) such as **fill** and **stroke** can also be specified as abstract values. For example, in the density heatmap below comparing the delay between eruptions of the Old Faithful geyser (*waiting*) in *x*→ and the duration of the eruption (*eruptions*) in *y*↑, both in minutes, we fill the frame with <span :style="{borderBottom: `solid 2px ${d3.interpolateTurbo(0)}`}">black</span> representing zero density. - -:::plot defer -```js -Plot.plot({ - inset: 30, - marks: [ - Plot.frame({fill: 0}), - Plot.density(faithful, {x: "waiting", y: "eruptions", fill: "density"}) - ] -}) -``` -::: - -:::tip -This is equivalent to a [rect](./rect.md): `Plot.rect({length: 1}, {fill: 0})`. -::: - -You can also place a frame on a specific facet using the **fx** or **fy** option. Below, a frame emphasizes the *Gentoo* facet, say to draw attention to how much bigger they are. 🐧 - -:::plot -```js -Plot.plot({ - marginLeft: 80, - inset: 10, - marks: [ - Plot.frame({fy: "Gentoo"}), - Plot.dot(penguins, {x: "body_mass_g", fy: "species"}) - ] -}) -``` -::: - -:::tip -Or: `Plot.rect({length: 1}, {fy: ["Gentoo"], stroke: "currentColor"})`. -::: - -The **anchor** option <VersionBadge version="0.6.3" />, if specified to a value of *left*, *right*, *top* or *bottom*, draws only that side of the frame. In that case, the **fill** and **rx**, **ry** options are ignored. - -:::plot -```js -Plot.plot({ - x: { - domain: [0, 1], - grid: true - }, - marks: [ - Plot.frame({stroke: "red", anchor: "bottom"}) - ] -}) -``` -::: - -## Frame options - -The frame mark supports the [standard mark options](../features/marks.md#mark-options), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners). It does not accept any data. The default **stroke** is *currentColor*, and the default **fill** is *none*. - -If the **anchor** option is specified as one of *left*, *right*, *top*, or *bottom*, that side is rendered as a single line (and the **fill**, **fillOpacity**, **rx**, and **ry** options are ignored). - -## frame(*options*) {#frame} - -```js -Plot.frame({stroke: "red"}) -``` - -Returns a new frame mark with the specified *options*. diff --git a/docs/marks/geo.md b/docs/marks/geo.md deleted file mode 100644 index 8aa585f2..00000000 --- a/docs/marks/geo.md +++ /dev/null @@ -1,203 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, shallowRef, onMounted} from "vue"; - -const us = shallowRef(null); -const earthquakes = shallowRef([]); -const walmarts = shallowRef({type: "FeatureCollection", features: []}); -const world = shallowRef(null); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states, (a, b) => a !== b) : {type: null}); -const nation = computed(() => us.value ? topojson.feature(us.value, us.value.objects.nation) : {type: null}); -const states = computed(() => us.value ? topojson.feature(us.value, us.value.objects.states) : {type: null}); -const counties = computed(() => us.value ? topojson.feature(us.value, us.value.objects.counties) : {type: null}); -const land = computed(() => world.value ? topojson.feature(world.value, world.value.objects.land) : {type: null}); - -onMounted(() => { - d3.json("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson").then((data) => (earthquakes.value = data)); - d3.json("../data/countries-110m.json").then((data) => (world.value = data)); - d3.tsv("../data/walmarts.tsv", d3.autoType).then((data) => (walmarts.value = {type: "FeatureCollection", features: data.map((d) => ({type: "Feature", properties: {date: d.date}, geometry: {type: "Point", coordinates: [d.longitude, d.latitude]}}))})); - Promise.all([ - d3.json("../data/us-counties-10m.json"), - d3.csv("../data/us-county-unemployment.csv") - ]).then(([_us, _unemployment]) => { - const map = new Map(_unemployment.map((d) => [d.id, +d.rate])); - _us.objects.counties.geometries.forEach((g) => (g.properties.unemployment = map.get(g.id))); - us.value = _us; - }); -}); - -</script> - -# Geo mark <VersionBadge version="0.6.1" /> - -The **geo mark** draws geographic features — polygons, lines, points, and other geometry — often as thematic maps. It works with Plot’s [projection system](../features/projections.md). For example, the [choropleth map](https://en.wikipedia.org/wiki/Choropleth_map) below shows unemployment by county in the United States. - -:::plot defer https://observablehq.com/@observablehq/plot-us-choropleth -```js -Plot.plot({ - projection: "albers-usa", - color: { - type: "quantile", - n: 9, - scheme: "blues", - label: "Unemployment (%)", - legend: true - }, - marks: [ - Plot.geo(counties, { - fill: "unemployment", - title: (d) => `${d.properties.name} ${d.properties.unemployment}%`, - tip: true - }) - ] -}) -``` -::: - -A geo mark’s data is typically [GeoJSON](https://geojson.org/). You can pass a single GeoJSON object, a feature or geometry collection, or an array or iterable of GeoJSON objects; Plot automatically normalizes these into an array of features or geometries. When a mark’s data is GeoJSON, Plot will look for the specified field name (such as _unemployment_ above, for **fill**) in the GeoJSON object’s `properties` if the object does not have this property directly. <VersionBadge version="0.6.16" pr="2092" /> - -The size of Point and MultiPoint geometries is controlled by the **r** option. For example, below we show earthquakes in the last seven days with a magnitude of 2.5 or higher as reported by the [USGS](https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php). As with the [dot mark](./dot.md), the effective radius is controlled by the *r* scale, which is by default a *sqrt* scale such that the area of a point is proportional to its value. And likewise point geometries are by default sorted by descending radius to reduce occlusion, drawing the smallest circles on top. Set the **sort** option to null to use input order instead. - -:::plot defer https://observablehq.com/@observablehq/plot-live-earthquake-map -```js -Plot.plot({ - projection: "equirectangular", - r: {transform: (r) => Math.pow(10, r)}, // Richter to amplitude - marks: [ - Plot.geo(land, {fill: "currentColor", fillOpacity: 0.2}), - Plot.sphere(), - Plot.geo(earthquakes, { - r: "mag", - fill: "red", - fillOpacity: 0.2, - stroke: "red", - title: "title", - href: "url", - target: "_blank" - }) - ] -}) -``` -::: - -:::tip -Click on any of the earthquakes above to see details. -::: - -The [graticule](#graticule) helper draws a uniform grid of meridians (lines of constant longitude) and parallels (lines of constant latitude) every 10° between ±80° latitude; for the polar regions, meridians are drawn every 90°. The [sphere](#sphere) helper draws the outline of the projected sphere. - -:::plot https://observablehq.com/@observablehq/plot-sphere-and-graticule -```js -Plot.plot({ - inset: 2, - projection: {type: "orthographic", rotate: [0, -30, 20]}, - marks: [ - Plot.sphere({fill: "var(--vp-c-bg-alt)", stroke: "currentColor"}), - Plot.graticule({strokeOpacity: 0.3}) - ] -}) -``` -::: - -The geo mark’s **geometry** channel can be used to generate geometry from a non-GeoJSON data source. For example, below we visualize the shockwave created by the explosion of the [Hunga Tonga–Hunga Haʻapai volcano](https://en.wikipedia.org/wiki/2021–22_Hunga_Tonga–Hunga_Haʻapai_eruption_and_tsunami) on January 15, 2022 with a series of geodesic circles of increasing radius. - -:::plot defer https://observablehq.com/@observablehq/plot-shockwave -```js -Plot.plot({ - projection: { - type: "equal-earth", - rotate: [90, 0] - }, - color: { - legend: true, - label: "Distance from Tonga (km)", - transform: (d) => 111.2 * d, // degrees to km - zero: true - }, - marks: [ - Plot.geo(land), - Plot.geo([0.5, 179.5].concat(d3.range(10, 171, 10)), { - geometry: d3.geoCircle().center([-175.38, -20.57]).radius((r) => r), - stroke: (r) => r, - strokeWidth: 2 - }), - Plot.sphere() - ] -}) -``` -::: - -By default, the geo mark doesn’t have **x** and **y** channels; when you use the [**tip** option](./tip.md), the [centroid transform](../transforms/centroid.md) is implicitly applied on the geometries to compute the tip position by generating **x** and **y** channels. <VersionBadge version="0.6.16" pr="2088" /> You can alternatively specify these channels explicitly. The centroids are shown below in red. - -:::plot defer https://observablehq.com/@observablehq/plot-state-centroids -```js -Plot.plot({ - projection: "albers-usa", - marks: [ - Plot.geo(states, {strokeOpacity: 0.1, tip: true, title: "name"}), - Plot.geo(nation), - Plot.dot(states, Plot.centroid({fill: "red", stroke: "var(--vp-c-bg-alt)"})) - ] -}) -``` -::: - -The geo mark supports [faceting](../features/facets.md). Below, a comic strip of sorts shows the locations of Walmart store openings in past decades. - -:::plot defer https://observablehq.com/@observablehq/plot-map-large-multiples -```js -Plot.plot({ - margin: 0, - padding: 0, - projection: "albers", - fy: {interval: "10 years"}, - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.2}), - Plot.geo(nation), - Plot.geo(walmarts, {fy: "date", r: 1.5, fill: "blue", tip: true, title: "date"}), - Plot.axisFy({frameAnchor: "top", dy: 30, tickFormat: (d) => `${d.getUTCFullYear()}’s`}) - ] -}) -``` -::: - -:::info -This uses the [**interval** scale option](../features/scales.md#scale-transforms) to bin temporal data into facets by decade. -::: - -Lastly, the geo mark is not limited to spherical geometries! [Plot’s projection system](../features/projections.md) includes planar projections, which allow you to work with shapes — such as contours — generated on an arbitrary flat surface. - -## Geo options - -The **geometry** channel specifies the geometry (GeoJSON object) to draw; if not specified, the mark’s *data* is assumed to be GeoJSON. - -In addition to the [standard mark options](../features/marks.md#mark-options), the **r** option controls the size of Point and MultiPoint geometries. It can be specified as either a channel or constant. When **r** is specified as a number, it is interpreted as a constant radius in pixels; otherwise it is interpreted as a channel and the effective radius is controlled by the *r* scale. If the **r** option is not specified it defaults to 3 pixels. Geometries with a nonpositive radius are not drawn. If **r** is a channel, geometries will be sorted by descending radius by default. - -The **x** and **y** position channels may also be specified in conjunction with the **tip** option. <VersionBadge version="0.6.16" pr="2088" /> These are bound to the *x* and *y* scale (or projection), respectively. - -## geo(*data*, *options*) {#geo} - -```js -Plot.geo(counties, {fill: "rate"}) -``` - -Returns a new geo mark with the given *data* and *options*. If *data* is a GeoJSON feature collection, then the mark’s data is *data*.features; if *data* is a GeoJSON geometry collection, then the mark’s data is *data*.geometries; if *data* is some other GeoJSON object, then the mark’s data is the single-element array [*data*]. If the **geometry** option is not specified, *data* is assumed to be a GeoJSON object or an iterable of GeoJSON objects. - -## sphere(*options*) <VersionBadge version="0.6.1" /> {#sphere} - -```js -Plot.sphere() -``` - -Returns a new geo mark with a *Sphere* geometry object and the given *options*. - -## graticule(*options*) <VersionBadge version="0.6.1" /> {#graticule} - -```js -Plot.graticule() -``` - -Returns a new geo mark with a [10° global graticule](https://d3js.org/d3-geo/shape#geoGraticule10) geometry object and the given *options*. diff --git a/docs/marks/grid.md b/docs/marks/grid.md deleted file mode 100644 index 4b324631..00000000 --- a/docs/marks/grid.md +++ /dev/null @@ -1,110 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import alphabet from "../data/alphabet.ts"; - -const atop = ref(true); - -</script> - -# Grid mark <VersionBadge version="0.6.3" /> - -The **grid mark** is a specially-configured [rule](./rule.md) for drawing an axis-aligned grid. Like the [axis mark](./axis.md), a grid mark is automatically generated by Plot when you use the **grid** scale option. But you can also declare a grid mark explicitly, for example to draw grid lines atop rather than below bars. - -<p> - <label class="label-input"> - Show grid on top: - <input type="checkbox" v-model="atop"> - </label> -</p> - -:::plot -```js -Plot.plot({ - x: {axis: "top", percent: true, grid: !atop}, - marks: [ - Plot.barX(alphabet, {x: "frequency", y: "letter", sort: {y: "width"}}), - atop ? Plot.gridX({interval: 1, stroke: "var(--vp-c-bg)", strokeOpacity: 0.5}) : null, - Plot.ruleX([0]) - ] -}) -``` -::: - -The **interval** option above instructs the grid lines to be drawn at unit intervals, _i.e._ whole percentages. As an alternative, you can use the **ticks** option to specify the desired number of ticks or the **tickSpacing** option to specify the desired separation between adjacent ticks in pixels. - -:::plot -```js -Plot.gridX().plot({x: {type: "linear"}}) -``` -::: - -The color of the grid lines can be controlled with the **stroke** option (or the alias **color**). While this option is are typically set to a constant color (such as _red_ or the default _currentColor_), it can be specified as a channel to assign colors dynamically based on the associated tick value. - -:::plot -```js -Plot.gridX(d3.range(101), {stroke: Plot.identity, strokeOpacity: 1}).plot() -``` -::: - -You can set other [stroke options](../features/marks.md#mark-options) to further customize the appearance, say for dashed strokes. - -:::plot -```js -Plot.gridX({strokeDasharray: "2", strokeOpacity: 1}).plot({x: {type: "linear"}}) -``` -::: - -See the [axis mark](./axis.md) for more details and examples. - -## Grid options - -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**). - -## gridX(*data*, *options*) {#gridX} - -```js -Plot.gridX({strokeDasharray: "5,3"}) -``` - -Returns a new *x* grid with the given *options*. - -## gridY(*data*, *options*) {#gridY} - -```js -Plot.gridY({strokeDasharray: "5,3"}) -``` - -Returns a new *y* grid with the given *options*. - -## gridFx(*data*, *options*) {#gridFx} - -```js -Plot.gridFx({strokeDasharray: "5,3"}) -``` - -Returns a new *fx* grid with the given *options*. - -## gridFy(*data*, *options*) {#gridFy} - -```js -Plot.gridFy({strokeDasharray: "5,3"}) -``` - -Returns a new *fy* grid with the given *options*. diff --git a/docs/marks/hexgrid.md b/docs/marks/hexgrid.md deleted file mode 100644 index dfff48fa..00000000 --- a/docs/marks/hexgrid.md +++ /dev/null @@ -1,34 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import penguins from "../data/penguins.ts"; - -</script> - -# Hexgrid mark <VersionBadge version="0.5.0" /> - -The **hexgrid mark** draws a hexagonal grid spanning the frame. It can be used with the [hexbin transform](../transforms/hexbin.md) to show how points are binned. The **binWidth** option specifies the distance between centers of neighboring hexagons in pixels; it defaults to 20, matching the hexbin transform. - -:::plot https://observablehq.com/@observablehq/plot-hexgrid-example -```js -Plot.plot({ - marks: [ - Plot.hexgrid(), - Plot.dot(penguins, Plot.hexbin({r: "count"}, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "currentColor"})) - ] -}) -``` -::: - -## Hexgrid options - -The hexgrid mark supports the [standard mark options](../features/marks.md#mark-options). It does not accept any data or support channels. The default **stroke** is *currentColor*, the default **strokeOpacity** is 0.1, and the default **clip** is true. The **binWidth** defaults to 20, matching the [hexbin transform](../transforms/hexbin.md). The **fill** option is not supported, but a [frame mark](./frame.md) can be used to the same effect. - -## hexgrid(*options*) {#hexgrid} - -```js -Plot.hexgrid({stroke: "red"}) -``` - -Returns a new hexgrid mark with the specified *options*. diff --git a/docs/marks/image.md b/docs/marks/image.md deleted file mode 100644 index dbe081b1..00000000 --- a/docs/marks/image.md +++ /dev/null @@ -1,172 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import penguins from "../data/penguins.ts"; - -const presidents = shallowRef([]); - -onMounted(() => { - d3.csv("../data/us-president-favorability.csv", d3.autoType).then((data) => (presidents.value = data)); -}); - -</script> - -# Image mark <VersionBadge version="0.3.0" /> - -The **image mark** draws images centered at the given position in **x** and **y**. It is often used to construct scatterplots in place of a [dot mark](./dot.md). For example, the chart below, based on one by [Robert Lesser](https://observablehq.com/@rlesser/when-presidents-fade-away), shows the favorability of U.S. presidents over time alongside their portraits. - -:::plot defer https://observablehq.com/@observablehq/plot-image-scatterplot -```js -Plot.plot({ - inset: 20, - x: {label: "First inauguration date"}, - y: {grid: true, label: "Net favorability (%)", tickFormat: "+f"}, - marks: [ - Plot.ruleY([0]), - Plot.image(presidents, { - x: "First Inauguration Date", - y: (d) => d["Very Favorable %"] + d["Somewhat Favorable %"] - d["Very Unfavorable %"] - d["Somewhat Unfavorable %"], - src: "Portrait URL", - width: 40, - title: "Name" - }) - ] -}) -``` -::: - -Images are drawn in input order by default. This dataset is ordered chronologically, and hence above the more recent presidents are drawn on top. You can change the order with the [sort transform](../transforms/sort.md). - -With the **r** option, images will be clipped to circles of the given radius. Use the [**preserveAspectRatio** option](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio) to control which part of the image appears within the circle; below, we favor the top part of the image to show the presidential head. - -:::plot defer https://observablehq.com/@observablehq/plot-image-medals -```js -Plot.plot({ - x: {inset: 20, label: "First inauguration date"}, - y: {insetTop: 4, grid: true, label: "Any opinion (%)", tickFormat: "+f"}, - marks: [ - Plot.ruleY([0]), - Plot.image(presidents, { - x: "First Inauguration Date", - y: (d) => d["Very Favorable %"] + d["Somewhat Favorable %"] + d["Very Unfavorable %"] + d["Somewhat Unfavorable %"], - src: "Portrait URL", - r: 20, - preserveAspectRatio: "xMidYMin slice", - title: "Name" - }) - ] -}) -``` -::: - -:::tip -You can also use the **r** channel as a size encoding, and the **rotate** channel, as with dots. -::: - -The **r** option works well with the [dodge transform](../transforms/dodge.md) for an image beeswarm plot. This chart isn’t particularly interesting because new presidents are inaugurated at a fairly consistent rate, but at least it avoids overlapping portraits. - -:::plot defer https://observablehq.com/@observablehq/plot-image-dodge -```js -Plot.plot({ - inset: 20, - height: 280, - marks: [ - Plot.image( - presidents, - Plot.dodgeY({ - x: "First Inauguration Date", - r: 20, // clip to a circle - preserveAspectRatio: "xMidYMin slice", // try not to clip heads - src: "Portrait URL", - title: "Name" - }) - ) - ] -}) -``` -::: - -The default size of an image is only 16×16 pixels. This may be acceptable if the image is a small glyph, such as a categorical symbol in a scatterplot. But often you will want to set **width**, **height**, or **r** to increase the image size. - -:::plot defer https://observablehq.com/@observablehq/plot-image-scatterplot-2 -```js -Plot.plot({ - aspectRatio: 1, - grid: true, - x: {label: "Favorable opinion (%)"}, - y: {label: "Unfavorable opinion (%)"}, - marks: [ - Plot.ruleY([0]), - Plot.ruleX([0]), - Plot.image(presidents, { - x: (d) => d["Very Favorable %"] + d["Somewhat Favorable %"], - y: (d) => d["Very Unfavorable %"] + d["Somewhat Unfavorable %"], - src: "Portrait URL", - title: "Name" - }) - ] -}) -``` -::: - -If — *for reasons* — you want to style the plot with a background image, you can do that using the top-level **style** option rather than an image mark. Below, Kristen Gorman’s penguins dataset is visualized atop her photograph of sea ice near Palmer Station on the Antarctic peninsula, where she collected the measurements. - -:::plot defer https://observablehq.com/@observablehq/plot-background-image -```js -Plot.plot({ - margin: 30, - inset: 10, - grid: true, - style: { - padding: "10px", - color: "black", - background: "url(../sea-ice.jpg)", - backgroundSize: "cover" - }, - marks: [ - Plot.frame(), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "white", stroke: "black"}) - ] -}) -``` -::: - -## Image options - -The required **src** option specifies the URL (or relative path) of each image. If **src** is specified as a string that starts with a dot, slash, or URL protocol (*e.g.*, “https:”) it is assumed to be a constant; otherwise it is interpreted as a channel. - -In addition to the [standard mark options](../features/marks.md#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 -* **width** - the image width (in pixels) -* **height** - the image height (in pixels) -* **r** - the image radius; bound to the *r* scale <VersionBadge version="0.6.6" /> -* **rotate** - the rotation angle in degrees clockwise <VersionBadge version="0.6.6" /> - -If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option. - -The **width** and **height** options default to 16 pixels (unless **r** is specified) and can be specified as either a channel or constant. When the width or height is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. Images with a nonpositive width or height are not drawn. If a **width** is specified but not a **height**, or vice versa, the one defaults to the other. Images do not support either a fill or a stroke. - -The **r** option, if not null (the default), enables circular clipping; it may be specified as a constant in pixels or a channel. Use the **preserveAspectRatio** option to control which part of the image is clipped. Also defaults the **width** and **height** to twice the effective radius. - -The following image-specific constant options are also supported: - -* **frameAnchor** - how to position the image within the frame; defaults to *middle* -* **preserveAspectRatio** - the [aspect ratio](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio); defaults to *xMidYMid meet* -* **crossOrigin** - the [cross-origin](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/crossorigin) behavior -* **imageRendering** - the [image-rendering attribute](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/image-rendering); defaults to *auto* (bilinear) <VersionBadge version="0.6.4" /> - -To crop the image instead of scaling it to fit, set **preserveAspectRatio** to *xMidYMid slice*. The **imageRendering** option may be set to *pixelated* to disable bilinear interpolation on enlarged images; however, note that this is not supported in WebKit. - -Images are drawn in input order, with the last data drawn on top. If sorting is needed, say to mitigate overplotting, consider a [sort transform](../transforms/sort.md). - -## image(*data*, *options*) {#image} - -```js -Plot.image(presidents, {x: "inauguration", y: "favorability", src: "portrait"}) -``` - -Returns a new image 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₂*, …]. diff --git a/docs/marks/line.md b/docs/marks/line.md deleted file mode 100644 index 46e4831f..00000000 --- a/docs/marks/line.md +++ /dev/null @@ -1,402 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, shallowRef, onMounted} from "vue"; -import aapl from "../data/aapl.ts"; -import driving from "../data/driving.ts"; -import sftemp from "../data/sf-temperatures.ts"; -import tdf from "../data/tdf.ts"; - -const beagle = shallowRef([]); -const bls = shallowRef([]); -const stateage = shallowRef([]); -const stocks = shallowRef([]); -const world = shallowRef(null); -const land = computed(() => world.value ? topojson.feature(world.value, world.value.objects.land) : {type: null}); - -onMounted(() => { - d3.text("../data/beagle.csv").then((text) => (beagle.value = d3.csvParseRows(text).map(d3.autoType))); - d3.csv("../data/bls-metro-unemployment.csv", d3.autoType).then((data) => (bls.value = data)); - d3.json("../data/countries-110m.json").then((data) => (world.value = data)); - d3.csv("../data/us-population-state-age.csv", d3.autoType).then((data) => { - const ages = data.columns.slice(1); // convert wide data to tidy data - stateage.value = Object.assign(ages.flatMap((age) => data.map((d) => ({state: d.name, age, population: d[age]}))), {ages}); - }); - Promise.all([ - d3.csv("../data/amzn.csv", d3.autoType), - d3.csv("../data/goog.csv", d3.autoType), - d3.csv("../data/ibm.csv", d3.autoType) - ]).then((datas) => { - stocks.value = d3.zip(["AAPL", "AMZN", "GOOG", "IBM"], [aapl].concat(datas)).flatMap(([Symbol, data]) => data.map((d) => ({Symbol, ...d}))); - }); -}); - -</script> - -# Line mark - -The **line mark** draws two-dimensional lines as in a line chart. Because the line mark interpolates between adjacent data points, typically both the *x* and *y* scales are quantitative or temporal. For example, below is a line chart of the closing price of Apple stock. - -:::plot https://observablehq.com/@observablehq/plot-simple-line-chart -```js -Plot.line(aapl, {x: "Date", y: "Close"}).plot({y: {grid: true}}) -``` -::: - -If the **x** and **y** options are not defined, the line mark assumes that the data is an iterable of points [[*x₁*, *y₁*], [*x₂*, *y₂*], …], allowing for [shorthand](../features/shorthand.md). - -:::plot https://observablehq.com/@observablehq/plot-shorthand-line-chart -```js -Plot.line(aapl.map((d) => [d.Date, d.Close])).plot() -``` -::: - -:::tip -This shorthand loses the automatic *x*- and *y*-axis labels, reducing legibility. Use the **label** [scale option](../features/scales.md) to restore them. -::: - -The [lineY constructor](#lineY) provides default channel definitions of **x** = index and **y** = [identity](../features/transforms.md#identity), letting you pass an array of numbers as data. The [lineX constructor](#lineX) similarly provides **x** = identity and **y** = index defaults for lines that go up↑ instead of to the right→. Below, a random walk is made using [d3.cumsum](https://observablehq.com/@d3/d3-cumsum?collection=@d3/d3-array) and [d3.randomNormal](https://observablehq.com/@d3/d3-random?collection=@d3/d3-random). - -:::plot defer https://observablehq.com/@observablehq/plot-shorthand-liney -```js -Plot.lineY(d3.cumsum({length: 600}, d3.randomNormal())).plot() -``` -::: - -As with [areas](./area.md), points in lines are connected in input order: the first point is connected to the second point, the second is connected to the third, and so on. Line data is typically in chronological order. Unsorted data may produce gibberish. - -:::plot defer https://observablehq.com/@observablehq/plot-line-sort -```js -Plot.lineY(d3.shuffle(aapl.slice()), {x: "Date", y: "Close"}).plot() // 🌶️ -``` -::: - -If your data isn’t sorted, use the [sort transform](../transforms/sort.md). - -:::plot defer https://observablehq.com/@observablehq/plot-line-sort -```js -Plot.lineY(d3.shuffle(aapl.slice()), {x: "Date", y: "Close", sort: "Date"}).plot() -``` -::: - -While the *x* scale of a line chart often represents time, this is not required. For example, we can plot the elevation profile of a Tour de France stage — and imagine how tiring it must be to start a climb after riding 160km! ⛰🚴💦 - -:::plot defer https://observablehq.com/@observablehq/plot-tour-de-france-elevation-profile -```js -Plot.plot({ - x: { - label: "Distance from stage start (km)" - }, - y: { - label: "Elevation (m)", - grid: true - }, - marks: [ - Plot.ruleY([0]), - Plot.line(tdf, {x: "distance", y: "elevation"}) - ] -}) -``` -::: - -There is no requirement that **y** be dependent on **x**; lines can be used in connected scatterplots to show two independent (but often correlated) variables. (See also [phase plots](https://en.wikipedia.org/wiki/Phase_portrait).) The chart below recreates Hannah Fairfield’s [“Driving Shifts Into Reverse”](http://www.nytimes.com/imagepages/2010/05/02/business/02metrics.html) from 2009. - -:::plot defer https://observablehq.com/@observablehq/plot-connected-scatterplot -```js -Plot.plot({ - inset: 10, - grid: true, - x: {label: "Miles driven (per person-year)"}, - y: {label: "Cost of gasoline ($ per gallon)"}, - marks: [ - Plot.line(driving, {x: "miles", y: "gas", curve: "catmull-rom", marker: true}), - Plot.text(driving, {filter: (d) => d.year % 5 === 0, x: "miles", y: "gas", text: (d) => `${d.year}`, dy: -8}) - ] -}) -``` -::: - -To draw multiple lines, use the **z** channel to group [tidy data](https://r4ds.had.co.nz/tidy-data.html) into series. For example, the chart below shows unemployment rates of various metro areas from the Bureau of Labor Statistics; the **z** value is the metro division name. - -:::plot defer https://observablehq.com/@observablehq/plot-multiple-line-chart -```js -Plot.plot({ - y: { - grid: true, - label: "Unemployment (%)" - }, - marks: [ - Plot.ruleY([0]), - Plot.line(bls, {x: "date", y: "unemployment", z: "division"}) - ] -}) -``` -::: - -:::tip -If your data is not tidy, you can use [*array*.flatMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap) to pivot. -::: - -<!-- Below, we load additional CSV files for other stocks to compare their performance. --> - -If a **stroke** (or **fill**) channel is specified, the **z** option defaults to the same, automatically grouping series. For this reason, both **stroke** and **z** are typically ordinal or categorical. - -:::plot defer https://observablehq.com/@observablehq/plot-index-chart -```js -Plot.plot({ - y: { - type: "log", - grid: true, - label: "Change in price (%)", - tickFormat: ((f) => (x) => f((x - 1) * 100))(d3.format("+d")) - }, - marks: [ - Plot.ruleY([1]), - Plot.line(stocks, Plot.normalizeY({ - x: "Date", - y: "Close", - stroke: "Symbol" - })), - Plot.text(stocks, Plot.selectLast(Plot.normalizeY({ - x: "Date", - y: "Close", - z: "Symbol", - text: "Symbol", - textAnchor: "start", - dx: 3 - }))) - ] -}) -``` -::: - -:::info -Here the [normalize transform](../transforms/normalize.md) normalizes each time series (**z**) relative to its initial value, while the [select transform](../transforms/select.md) extracts the last point for labeling. A custom tick format converts multiples to percentage change (*e.g.*, 1.6× = +60%). -::: - -Varying-color lines are supported. If the **stroke** value varies within series, the line will be segmented by color. (The same behavior applies to other channels, such as **strokeWidth** and **title**.) Specifying the **z** channel (say to null for a single series) is recommended. - -:::plot defer https://observablehq.com/@observablehq/plot-varying-stroke-line -```js -Plot.plot({ - x: { - label: null - }, - y: { - grid: true, - label: "Unemployment (%)" - }, - marks: [ - Plot.ruleY([0]), - Plot.line(bls, { - x: "date", - y: "unemployment", - z: "division", - stroke: "unemployment" - }) - ] -}) -``` -::: - -Color encodings can also be used to highlight specific series, such as here to emphasize high unemployment in Michigan. - -:::plot defer https://observablehq.com/@observablehq/plot-multiple-line-highlight -```js -Plot.plot({ - y: { - grid: true, - label: "Unemployment (%)" - }, - color: { - domain: [false, true], - range: ["#ccc", "red"] - }, - marks: [ - Plot.ruleY([0]), - Plot.line(bls, { - x: "date", - y: "unemployment", - z: "division", - stroke: (d) => /, MI /.test(d.division), - sort: {channel: "stroke"} - }) - ] -}) -``` -::: - -When using **z**, lines are drawn in input order. The [sort transform](../transforms/sort.md) above places the red lines on top of the gray ones to improve readability. - -As an alternative to **z**, you can render multiple lines using multiple marks. While more verbose, this allows you to choose different options for each line. For example, below we plot the a 14-day moving average of the daily highs and lows in temperate San Francisco using the [window transform](../transforms/window.md). - -:::plot defer https://observablehq.com/@observablehq/plot-moving-average-line -```js -Plot.plot({ - y: { - grid: true, - label: "Temperature (°F)" - }, - marks: [ - Plot.line(sftemp, Plot.windowY(14, {x: "date", y: "low", stroke: "#4e79a7"})), - Plot.line(sftemp, Plot.windowY(14, {x: "date", y: "high", stroke: "#e15759"})), - Plot.ruleY([32]) // freezing - ] -}) -``` -::: - -If some channel values are undefined (or null or NaN), gaps will appear between adjacent points. To demonstrate, below we set the **y** value to NaN for the first three months of each year. - -:::plot defer https://observablehq.com/@observablehq/plot-line-chart-with-gaps -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.lineY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close}) - ] -}) -``` -::: - -Supplying undefined values is not the same as filtering the data: the latter will interpolate between the data points. Observe the conspicuous straight lines below! - -:::plot defer https://observablehq.com/@observablehq/plot-line-chart-with-gaps -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.lineY(aapl, {filter: (d) => d.Date.getUTCMonth() >= 3, x: "Date", y: "Close", strokeOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close}) - ] -}) -``` -::: - -While uncommon, you can draw a line with ordinal position values. For example below, each line represents a U.S. state; **x** represents an (ordinal) age group while **y** represents the proportion of the state’s population in that age group. This chart emphasizes the overall age distribution of the United States, while giving a hint to variation across states. - -:::plot defer https://observablehq.com/@observablehq/plot-ordinal-line -```js -Plot.plot({ - x: { - domain: stateage.ages, // in age order - label: "Age range (years)", - labelAnchor: "right", - labelArrow: true - }, - y: { - label: "Population (%)", - percent: true, - grid: true - }, - marks: [ - Plot.ruleY([0]), - Plot.line(stateage, Plot.normalizeY("sum", {x: "age", y: "population", z: "state", strokeWidth: 1})) - ] -}) -``` -::: - -With a [spherical projection](../features/projections.md), line segments become [geodesics](https://en.wikipedia.org/wiki/Great-circle_distance), taking the shortest path between two points on the sphere and wrapping around the antimeridian at 180° longitude. The line below shows Charles Darwin’s voyage on HMS _Beagle_. (Data via [Benjamin Schmidt](https://observablehq.com/@bmschmidt/data-driven-projections-darwins-world).) - -:::plot defer https://observablehq.com/@observablehq/plot-spherical-line -```js -Plot.plot({ - projection: "equirectangular", - marks: [ - Plot.geo(land), // MultiPolygon - Plot.line(beagle, {stroke: "red"}), // [[lon, lat], …] - Plot.geo({type: "Point", coordinates: [-0.13, 51.5]}, {fill: "red"}) // London - ] -}) -``` -::: - -:::tip -Disable spherical interpolation by setting the **curve** option to *linear* instead of the default *auto*. -::: - -A projected line can use varying color, too. Below, color reveals the westward direction of the Beagle’s journey around the world, starting and ending in London. - -:::plot defer https://observablehq.com/@observablehq/plot-spherical-line-with-a-varying-stroke -```js -Plot.plot({ - projection: "equirectangular", - marks: [ - Plot.geo(land), - Plot.line(beagle, {stroke: (d, i) => i, z: null}) - ] -}) -``` -::: - -:::info -Setting **z** to null forces a single line; we want the **stroke** to vary within the line instead of producing a separate line for each color. -::: - -Interpolation is controlled by the [**curve** option](../features/curves.md). The default curve is *linear*, which draws straight line segments between pairs of adjacent points. A *step* curve is nice for emphasizing when the value changes, while *basis* and *catmull–rom* are nice for smoothing. - -## Line options - -The following channels are required: - -* **x** - the horizontal position; bound to the *x* scale -* **y** - the vertical position; bound to the *y* scale - -In addition to the [standard mark options](../features/marks.md#mark-options), the following optional channels are supported: - -* **z** - a categorical value to group data into series - -By default, the data is assumed to represent a single series (a single value that varies over time, *e.g.*). If the **z** channel is specified, data is grouped by **z** to form separate series. Typically **z** is a categorical value such as a series name. If **z** is not specified, it defaults to **stroke** if a channel, or **fill** if a channel. - -The **fill** defaults to *none*. The **stroke** defaults to *currentColor* if the fill is *none*, and to *none* otherwise. If the stroke is defined as a channel, the line will be broken into contiguous overlapping segments when the stroke color changes; the stroke color will apply to the interval spanning the current data point and the following data point. This behavior also applies to the **fill**, **fillOpacity**, **strokeOpacity**, **strokeWidth**, **opacity**, **href**, **title**, and **ariaLabel** channels. When any of these channels are used, setting an explicit **z** channel (possibly to null) is strongly recommended. The **strokeWidth** defaults to 1.5, the **strokeLinecap** and **strokeLinejoin** default to *round*, and the **strokeMiterlimit** defaults to 1. - -Points along the line are connected in input order. Likewise, if there are multiple series via the **z**, **fill**, or **stroke** channel, the series are drawn in input order such that the last series is drawn on top. Typically, the data is already in sorted order, such as chronological for time series; if sorting is needed, consider a [sort transform](../transforms/sort.md). - -The line mark supports [curve options](../features/curves.md) to control interpolation between points, and [marker options](../features/markers.md) to add a marker (such as a dot or an arrowhead) on each of the control points. The default curve is *auto*, which is equivalent to *linear* if there is no [projection](../features/projections.md), and otherwise uses the associated projection. If any of the **x** or **y** values are invalid (undefined, null, or NaN), the line will be interrupted, resulting in a break that divides the line shape into multiple segments. (See [d3-shape’s *line*.defined](https://d3js.org/d3-shape/line#line_defined) for more.) If a line segment consists of only a single point, it may appear invisible unless rendered with rounded or square line caps. In addition, some curves such as *cardinal-open* only render a visible segment if it contains multiple points. - -## line(*data*, *options*) {#line} - -```js -Plot.line(aapl, {x: "Date", y: "Close"}) -``` - -Returns a new line with the given *data* and *options*. If neither the **x** nor **y** 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₂*, …]. - -## lineX(*data*, *options*) {#lineX} - -```js -Plot.lineX(aapl.map((d) => d.Close)) -``` - -Similar to [line](#line) except that if the **x** option is not specified, it defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. If the **y** option is not specified, it defaults to [0, 1, 2, …]. - -If the **interval** option is specified, the [binY transform](../transforms/bin.md) is implicitly applied to the specified *options*. The reducer of the output *x* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer. - -```js -Plot.lineX(observations, {y: "date", x: "temperature", interval: "day"}) -``` - -The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval. - -## lineY(*data*, *options*) {#lineY} - -```js -Plot.lineY(aapl.map((d) => d.Close)) -``` - -Similar to [line](#line) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. If the **x** option is not specified, it defaults to [0, 1, 2, …]. - -If the **interval** option is specified, the [binX transform](../transforms/bin.md) is implicitly applied to the specified *options*. The reducer of the output *y* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer. - -```js -Plot.lineY(observations, {x: "date", y: "temperature", interval: "day"}) -``` - -The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval. diff --git a/docs/marks/linear-regression.md b/docs/marks/linear-regression.md deleted file mode 100644 index 95b9ce0b..00000000 --- a/docs/marks/linear-regression.md +++ /dev/null @@ -1,116 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import cars from "../data/cars.ts"; -import penguins from "../data/penguins.ts"; - -const m = ref(0); - -</script> - -# Linear regression mark <VersionBadge version="0.5.1" /> - -The **linear regression** mark draws [linear regression](https://en.wikipedia.org/wiki/Linear_regression) lines with confidence bands, representing the estimated linear relation of a dependent variable (typically **y**) on an independent variable (typically **x**). Below we can see that, in this example dataset at least, the weight of a car is a good linear predictor of its power. - -:::plot https://observablehq.com/@observablehq/plot-cars-linear-regression -```js -Plot.plot({ - marks: [ - Plot.dot(cars, {x: "weight (lb)", y: "power (hp)"}), - Plot.linearRegressionY(cars, {x: "weight (lb)", y: "power (hp)", stroke: "red"}) - ] -}) -``` -::: - -A linear model posits that _y_ is determined by an underlying affine function *y* = *a* ﹢ *b x*, where _a_ is a constant (intercept of the line on the _y_-axis when _x_ = 0) and _b_ is the slope. Given a set of points in **x** and **y**, the linear regression method returns the most likely parameters _a_ and _b_ for the linear model, as well as a confidence band showing the range where these parameters lie with a certain probability, called **ci** (for confidence interval), which defaults to 0.95. - -:::info -The regression line is fit using the [least squares](https://en.wikipedia.org/wiki/Least_squares) approach. See Torben Jansen’s [“Linear regression with confidence bands”](https://observablehq.com/@toja/linear-regression-with-confidence-bands) and [this StatExchange question](https://stats.stackexchange.com/questions/101318/understanding-shape-and-calculation-of-confidence-bands-in-linear-regression) for details. -::: - -Use the slider below to build a linear model from a subset of the data with **m** points. As you can see, the model gives a line as soon as two points are available, and gets more refined and stable as the size of the subset increases. - -<p> - <label class="label-input"> - Number of points (m): - <input type="range" v-model.number="m" min="0" :max="cars.length" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{m.toLocaleString("en-US")}}</span> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-linear-regression-confidence-band -```js -Plot.plot({ - marks: [ - Plot.dot(cars, {x: "weight (lb)", y: "power (hp)", fill: "currentColor", fillOpacity: 0.2}), - Plot.dot(cars.slice(0, m), {x: "weight (lb)", y: "power (hp)"}), - Plot.linearRegressionY(cars.slice(0, m), {x: "weight (lb)", y: "power (hp)", stroke: "red"}) - ] -}) -``` -::: - -:::tip -When operating on a subset of the data (the “training dataset”, in machine learning parlance), randomly shuffling the data can reduce bias. -::: - -This type of model is regularly criticized for pushing people to the wrong conclusions about their data when the actual underlying structure or process is nonlinear. For example, if you measure the relationship between culmen depth and length in a mixed population of penguins, it is positively correlated in each of the three species (bigger penguins with the longer culmens also tend to have the deeper ones); however, the Gentoo population has a smaller aspect ratio of depth against length, and the overall correlation across the three species is negative. This is called [Simpson’s paradox](https://en.wikipedia.org/wiki/Simpson%27s_paradox), and it applies to any data that contains underlying populations with different properties or outcomes. - -:::plot https://observablehq.com/@observablehq/plot-linear-regression-simpson -```js -Plot.plot({ - grid: true, - color: {legend: true}, - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species"}), - Plot.linearRegressionY(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), - Plot.linearRegressionY(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` -::: - -Finally, note that regression is not a symmetric method: the model computed to express _y_ as a function of _x_ (linearRegressionY) doesn’t give the same result as the regression of _x_ as a function of _y_ (linearRegressionX) unless the points are all perfectly aligned. In the worst case, where the two variables are statistically independent, the linear regression of _y_ against _x_ is an horizontal line, whereas the linear regression of _x_ against _y_ is a vertical line. - -:::plot https://observablehq.com/@observablehq/plot-linear-regression-is-not-symmetric -```js -Plot.plot({ - marks: [ - Plot.dot(cars, {x: "weight (lb)", y: "power (hp)", strokeOpacity: 0.5, r: 2}), - Plot.linearRegressionY(cars, {x: "weight (lb)", y: "power (hp)", stroke: "steelblue"}), - Plot.linearRegressionX(cars, {x: "weight (lb)", y: "power (hp)", stroke: "orange"}) - ] -}) -``` -::: - -## Linear regression options - -The given *options* are passed through to these underlying marks, with the exception of the following options: - -* **stroke** - the stroke color of the regression line; defaults to *currentColor* -* **fill** - the fill color of the confidence band; defaults to the line’s *stroke* -* **fillOpacity** - the fill opacity of the confidence band; defaults to 0.1 -* **ci** - the confidence interval in [0, 1), or 0 to hide bands; defaults to 0.95 -* **precision** - the distance (in pixels) between samples of the confidence band; defaults to 4 - -Multiple regressions can be defined by specifying **z**, **fill**, or **stroke**. - -## linearRegressionX(*data*, *options*) {#linearRegressionX} - -```js -Plot.linearRegressionX(mtcars, {y: "wt", x: "hp"}) -``` - -Returns a linear regression mark where **x** is the dependent variable and **y** is the independent variable. (This is the uncommon orientation.) - -## linearRegressionY(*data*, *options*) {#linearRegressionY} - -```js -Plot.linearRegressionY(mtcars, {x: "wt", y: "hp"}) -``` - -Returns a linear regression mark where **y** is the dependent variable and **x** is the independent variable. (This is the common orientation.) diff --git a/docs/marks/link.md b/docs/marks/link.md deleted file mode 100644 index 4c5c0023..00000000 --- a/docs/marks/link.md +++ /dev/null @@ -1,179 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, shallowRef, onMounted} from "vue"; -import income from "../data/income-gender.ts"; -import metros from "../data/metros.ts"; - -const xy = {x1: -122.4194, y1: 37.7749, x2: 2.3522, y2: 48.8566}; -const gods = ["Chaos/Gaia/Mountains", "Chaos/Gaia/Pontus", "Chaos/Gaia/Uranus", "Chaos/Eros", "Chaos/Erebus", "Chaos/Tartarus"]; -const world = shallowRef(null); -const land = computed(() => world.value ? topojson.feature(world.value, world.value.objects.land) : null); - -onMounted(() => { - d3.json("../data/countries-110m.json").then((data) => (world.value = data)); -}); - -</script> - -# Link mark - -The **link mark** draws straight lines between two points [**x1**, **y1**] and [**x2**, **y2**] in quantitative dimensions. It is similar to the [arrow mark](./arrow.md), except it draws a straight line — or geodesic when used with a [spherical projection](../features/projections.md). - -For example, the chart below shows the rising inequality (and population) in various U.S. cities from 1980 to 2015. Each link represents two observations of a city: the city’s population (**x**) and inequality (**y**) in 1980, and the same in 2015. The link’s **stroke** redundantly encodes the change in inequality: red indicates rising inequality, while blue (there are only four) indicates declining inequality. - -:::plot defer https://observablehq.com/@observablehq/plot-link-variation-chart -```js -Plot.plot({ - grid: true, - inset: 10, - x: { - type: "log", - label: "Population" - }, - y: { - label: "Inequality", - ticks: 4 - }, - color: { - scheme: "BuRd", - label: "Change in inequality from 1980 to 2015", - legend: true, - tickFormat: "+f" - }, - marks: [ - Plot.link(metros, { - x1: "POP_1980", - y1: "R90_10_1980", - x2: "POP_2015", - y2: "R90_10_2015", - stroke: (d) => d.R90_10_2015 - d.R90_10_1980, - markerEnd: "arrow" - }), - Plot.text(metros, { - x: "POP_2015", - y: "R90_10_2015", - filter: "highlight", - text: "nyt_display", - fill: "currentColor", - stroke: "var(--vp-c-bg)", - dy: -8 - }) - ] -}) -``` -::: - -The link mark is used by the composite [tree mark](./tree.md) to render a link from parent to child in a hierarchy. The [treeLink transform](../transforms/tree.md) sets the default [curve option](../features/curves.md) to *bump-x*. - -:::plot https://observablehq.com/@observablehq/plot-tree-and-link -```js -Plot.plot({ - axis: null, - height: 120, - inset: 20, - insetRight: 120, - marks: [ - Plot.link(gods, Plot.treeLink({stroke: "node:internal"})), - Plot.dot(gods, Plot.treeNode({fill: "node:internal"})), - Plot.text(gods, Plot.treeNode({text: "node:name", stroke: "var(--vp-c-bg)", fill: "currentColor", dx: 6})) - ] -}) -``` -::: - -In this example, `gods` is an array of slash-separated paths representing the ancestry of mythological Greek gods. - -```js -gods = [ - "Chaos/Gaia/Mountains", - "Chaos/Gaia/Pontus", - "Chaos/Gaia/Uranus", - "Chaos/Eros", - "Chaos/Erebus", - "Chaos/Tartarus" -] -``` - -With a [spherical projection](../features/projections.md) and the default [*auto* curve](../features/curves.md), the link mark will render a geodesic: the shortest path between two points on the surface of the sphere. Setting the **curve** to *linear* will instead draw a straight line between the projected points. For example, below we draw two links from San Francisco to Paris. - -:::plot defer https://observablehq.com/@observablehq/plot-projected-link -```js -Plot.plot({ - projection: "equal-earth", - marks: [ - Plot.sphere(), - Plot.geo(land, {fill: "currentColor", fillOpacity: 0.3}), - Plot.link({length: 1}, {curve: "linear", stroke: "red", ...xy}), - Plot.link({length: 1}, {markerStart: "dot", markerEnd: "arrow", strokeWidth: 1.5, ...xy}) - ] -}) -``` -::: - -```js -xy = ({x1: -122.4194, y1: 37.7749, x2: 2.3522, y2: 48.8566}) -``` - -Like a [rule](./rule.md), a link can also serve as annotation. Whereas a rule is strictly horizontal or vertical, however, a link can generate [diagonal lines](http://kelsocartography.com/blog/?p=2074). The following chart depicts the gender gap in wages, segmented by education and age, in the U.S. A regular grid would make the gender disparity much less clear, even with the domains explicitly set to be equal. - -:::plot https://observablehq.com/@observablehq/plot-gender-income-inequality -```js -Plot.plot({ - aspectRatio: 1, - marginRight: 40, - x: { - label: "Median annual income (men, thousands)", - transform: (d) => d / 1000, - tickSpacing: 60 - }, - y: { - label: "Median annual income (women, thousands)", - transform: (d) => d / 1000, - tickSpacing: 60 - }, - marks: [ - Plot.link([0.6, 0.7, 0.8, 0.9, 1], { - x1: 0, - y1: 0, - x2: 102000, - y2: (k) => 102000 * k, - strokeOpacity: (k) => k === 1 ? 1 : 0.2 - }), - Plot.text([0.6, 0.7, 0.8, 0.9, 1], { - x: 102000, - y: (k) => 102000 * k, - text: ((f) => (k) => k === 1 ? "Equal" : f(k - 1))(d3.format("+.0%")), - textAnchor: "start", - dx: 6 - }), - Plot.dot(income, {x: "m", y: "f"}) - ] -}) -``` -::: - -## Link options - -The following channels are required: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **y1** - the starting vertical position; bound to the *y* scale -* **x2** - the ending horizontal position; bound to the *x* scale -* **y2** - the ending vertical position; bound to the *y* scale - -For vertical or horizontal links, the **x** option can be specified as shorthand for **x1** and **x2**, and the **y** option can be specified as shorthand for **y1** and **y2**, respectively. - -The link mark supports the [standard mark options](../features/marks.md). The **stroke** defaults to currentColor. The **fill** defaults to none. The **strokeWidth** and **strokeMiterlimit** default to one. - -The link mark supports [curve options](../features/curves.md) to control interpolation between points, and [marker options](../features/markers.md) to add a marker (such as a dot or an arrowhead) on each of the control points. Since a link always has two points by definition, only the following curves (or a custom curve) are recommended: *linear*, *step*, *step-after*, *step-before*, *bump-x*, or *bump-y*. Note that the *linear* curve is incapable of showing a fill since a straight line has zero area. For a curved link, you can use a bent [arrow](./arrow.md) (with no arrowhead, if desired). - -## link(*data*, *options*) {#link} - -```js -Plot.link(inequality, {x1: "POP_1980", y1: "R90_10_1980", x2: "POP_2015", y2: "R90_10_2015"}) -``` - -Returns a new link with the given *data* and *options*. diff --git a/docs/marks/raster.md b/docs/marks/raster.md deleted file mode 100644 index 2356a4f8..00000000 --- a/docs/marks/raster.md +++ /dev/null @@ -1,353 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import penguins from "../data/penguins.ts"; -import volcano from "../data/volcano.ts"; - -const ca55 = shallowRef([]); -const vapor = shallowRef([]); -const grid = {"width": 10, "height": 10, "values": d3.cross(d3.range(10), d3.range(10), (x, y) => x * y)}; - -onMounted(() => { - d3.csv("../data/ca55-south.csv", d3.autoType).then((data) => (ca55.value = data)); - d3.text("../data/MYDAL2_M_SKY_WV_2022-11-01_rgb_360x180.csv").then((text) => (vapor.value = d3.csvParseRows(text).flat().map((x) => (x === "99999.0" ? NaN : +x)))); -}); - -function mandelbrot(x, y) { - for (let n = 0, zr = 0, zi = 0; n < 80; ++n) { - [zr, zi] = [zr * zr - zi * zi + x, 2 * zr * zi + y]; - if (zr * zr + zi * zi > 4) return n; - } -} - -</script> - -# Raster mark <VersionBadge version="0.6.2" /> - -:::tip -To produce contours instead of a heatmap, see the [contour mark](./contour.md). -::: - -The **raster mark** renders a [raster image](https://en.wikipedia.org/wiki/Raster_graphics) — that is, an image formed by discrete pixels in a grid, not a vector graphic like other marks. And whereas the [image mark](./image.md) shows an *existing* image, the raster mark *creates* one from abstract data, either by [interpolating spatial samples](#spatial-interpolators) (arbitrary points in **x** and **y**) or by sampling a function *f*(*x*,*y*) along the grid. - -For example, the heatmap below shows the topography of the [Maungawhau volcano](https://en.wikipedia.org/wiki/Maungawhau), produced from a {{volcano.width}}×{{volcano.height}} grid of elevation samples. - -:::plot defer https://observablehq.com/@observablehq/plot-volcano-raster -```js -Plot.plot({ - color: {label: "Elevation (m)", legend: true}, - marks: [ - Plot.raster(volcano.values, {width: volcano.width, height: volcano.height}) - ] -}) -``` -::: - -The grid (`volcano.values` above) is a list of numbers `[103, 104, 104, …]`. The first number `103` is the elevation of the bottom-left corner. This grid is in [row-major order](https://en.wikipedia.org/wiki/Row-_and_column-major_order), meaning that the elevations of the first row are followed by the second row, then the third, and so on. Here’s a smaller grid to demonstrate the concept. - -```js -grid = ({ - "width": 10, - "height": 10, - "values": [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, - 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, - 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, - 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, - 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, - 0, 6, 12, 18, 24, 30, 36, 42, 48, 54, - 0, 7, 14, 21, 28, 35, 42, 49, 56, 63, - 0, 8, 16, 24, 32, 40, 48, 56, 64, 72, - 0, 9, 18, 27, 36, 45, 54, 63, 72, 81 - ] -}) -``` - -We can visualize this small grid directly with a [text mark](./text.md) using the same color encoding. Notice that the image below is flipped vertically relative to the data: the first row of the data is the *bottom* of the image because below *y* points up↑. - -:::plot https://observablehq.com/@observablehq/plot-small-grid-raster -```js -Plot.plot({ - grid: true, - x: {domain: [0, grid.width], label: "column"}, - y: {domain: [0, grid.height], label: "row"}, - marks: [ - Plot.text(grid.values, { - text: Plot.identity, - fill: Plot.identity, - x: (d, i) => i % grid.width + 0.5, - y: (d, i) => Math.floor(i / grid.width) + 0.5 - }) - ] -}) -``` -::: - -Also notice that the grid points are offset by 0.5: they represent the *middle* of each pixel rather than the corner. Below, the raster mark is laid under the text mark to show the raster image. - -:::plot defer https://observablehq.com/@observablehq/plot-small-grid-raster -```js -Plot.plot({ - marks: [ - Plot.raster(grid.values, { - width: grid.width, - height: grid.height, - imageRendering: "pixelated" // to better show the grid - }), - Plot.text(grid.values, { - text: Plot.identity, - fill: "white", - x: (d, i) => i % grid.width + 0.5, - y: (d, i) => Math.floor(i / grid.width) + 0.5 - }) - ] -}) -``` -::: - -:::warning CAUTION -Safari does not currently support the **imageRendering** option. -::: - -While the raster mark provides convenient shorthand for strictly gridded data, as above, it *also* works with samples in arbitrary positions and arbitrary order. For example, in 1955 the [Great Britain aeromagnetic survey](https://www.bgs.ac.uk/datasets/gb-aeromagnetic-survey/) measured the Earth’s magnetic field by plane. Each sample recorded the longitude and latitude alongside the strength of the [IGRF](https://www.ncei.noaa.gov/products/international-geomagnetic-reference-field) in [nanoteslas](https://en.wikipedia.org/wiki/Tesla_(unit)). - -``` -LONGITUDE,LATITUDE,MAG_IGRF90 --2.36216,51.70945,7 --2.36195,51.71727,6 --2.36089,51.72404,9 --2.35893,51.73758,12 --2.35715,51.7532,18 --2.35737,51.76636,24 -``` - -Using a [dot mark](./dot.md), we can make a quick scatterplot to see the irregular grid. We’ll use a *diverging* color scale to distinguish positive and negative values. - -:::plot defer https://observablehq.com/@observablehq/plot-igrf90-dots -```js -Plot.dot(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90"}).plot({color: {type: "diverging"}}) -``` -::: - -And using a [line mark](./line.md), we can connect the line segments to reveal the flight paths. - -:::plot defer https://observablehq.com/@observablehq/plot-igrf90-flight-paths -```js -Plot.line(ca55, {x: "LONGITUDE", y: "LATITUDE", stroke: "MAG_IGRF90", z: "LINE_NUMB-SEG"}).plot({color: {type: "diverging"}}) -``` -::: - -The image above starts to be readable, but it would be frustrating to not do more with this data given all the effort that went into collecting it! Fortunately the raster mark’s **interpolate** option can quickly produce a continuous image. - -The *nearest* interpolator assigns the value of each pixel in the grid using the nearest sample in the data. In effect, this produces a Voronoi diagram. - -:::plot defer https://observablehq.com/@observablehq/plot-igfr90-raster -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: "nearest"}).plot({color: {type: "diverging"}}) -``` -::: - -:::tip -You can also make this Voronoi diagram with the [voronoi mark](./delaunay.md). -::: - -If the observed phenomenon is continuous, we can use the _barycentric_ interpolator. This constructs a Delaunay triangulation of the samples, and then paints each triangle by interpolating the values of the triangle’s vertices in [barycentric coordinates](https://en.wikipedia.org/wiki/Barycentric_coordinate_system). (Points outside the convex hull are extrapolated.) - -:::plot defer https://observablehq.com/@observablehq/plot-igfr90-barycentric -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: "barycentric"}).plot({color: {type: "diverging"}}) -``` -::: - -Finally, the _random-walk_ interpolator assigns the value at each grid location simply by taking a random walk that stops after reaching a minimum distance from any sample! The interpolator uses the [walk on spheres](https://observablehq.com/@fil/walk-on-spheres) algorithm, limited to 2 consecutive jumps. - -:::plot defer https://observablehq.com/@observablehq/plot-igrf90-random-walk -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: "random-walk"}).plot({color: {type: "diverging"}}) -``` -::: - -With the _random-walk_ method, the image is grainy, reflecting the uncertainty of the random walk. Use the **blur** option to make it smoother. - -:::plot defer https://observablehq.com/@observablehq/plot-igrf90-random-walk -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: "random-walk", blur: 5}).plot({color: {type: "diverging"}}) -``` -::: - -:::tip -If none of the built-in [spatial interpolators](#spatial-interpolators) suffice, you can write your own as a custom function! -::: - -The raster mark can interpolate categorical values, too! Below, this creates an interesting “map” of penguin species in the space of culmen length _vs._ depth. - -:::plot defer https://observablehq.com/@observablehq/plot-nominal-random-walk -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.raster(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", interpolate: "random-walk"}), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` -::: - -As an alternative to interpolating discrete samples, you can supply values as a continuous function *f*(*x*,*y*); the raster mark will invoke this function for the midpoint of each pixel in the raster grid, similar to a WebGL fragment shader. For example, below we visualize the [Mandelbrot set](https://en.wikipedia.org/wiki/Mandelbrot_set) by counting the number of iterations needed until the point “escapes”. - -:::plot defer https://observablehq.com/@observablehq/plot-mandelbrot-set -```js -Plot.raster({fill: mandelbrot, x1: -2, x2: 1, y1: -1.164, y2: 1.164}).plot({aspectRatio: 1}) -``` -::: - -```js -function mandelbrot(x, y) { - for (let n = 0, zr = 0, zi = 0; n < 80; ++n) { - [zr, zi] = [zr * zr - zi * zi + x, 2 * zr * zi + y]; - if (zr * zr + zi * zi > 4) return n; - } -} -``` - -Or to visualize the arctangent function: - -:::plot defer https://observablehq.com/@observablehq/plot-arctangent-raster -```js -Plot.raster({x1: -1, x2: 1, y1: -1, y2: 1, fill: (x, y) => Math.atan2(y, x)}).plot() -``` -::: - -:::tip -When faceting, the sample function *f*(*x*,*y*) is passed a third argument of the facet values {*fx*, *fy*}. -::: - -The raster mark supports Plot’s [projection system](../features/projections.md). The chart below shows global atmospheric water vapor measurements from [NASA Earth Observations](https://neo.gsfc.nasa.gov/view.php?datasetId=MYDAL2_M_SKY_WV). - -:::plot defer https://observablehq.com/@observablehq/plot-raster-projection -```js -Plot.plot({ - projection: "equal-earth", - color: { - scheme: "BuPu", - domain: [0, 6], - legend: true, - label: "Water vapor (cm)" - }, - marks: [ - Plot.raster(vapor, { - fill: Plot.identity, - width: 360, - height: 180, - x1: -180, - y1: 90, - x2: 180, - y2: -90, - interpolate: "barycentric", - clip: "sphere" - }), - Plot.sphere({stroke: "black"}) - ] -}) -``` -::: - -## Raster options - -If *data* is provided, it represents discrete samples in abstract coordinates **x** and **y**; the **fill** and **fillOpacity** channels specify further abstract values (_e.g._, height in a topographic map) to be [spatially interpolated](#spatial-interpolators) to produce an image. - -```js -Plot.raster(volcano.values, {width: volcano.width, height: volcano.height}) -``` - -The **fill** and **fillOpacity** channels may alternatively be specified as continuous functions *f*(*x*,*y*) to be evaluated at each pixel centroid of the raster grid (without interpolation). - -```js -Plot.raster({x1: -1, x2: 1, y1: -1, y2: 1, fill: (x, y) => Math.atan2(y, x)}) -``` - -The resolution of the rectangular raster image may be specified with the following options: - -* **width** - the number of pixels on each horizontal line -* **height** - the number of lines; a positive integer - -The raster dimensions may also be imputed from the extent of *x* and *y* and a pixel size: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **x2** - the ending horizontal position; bound to the *x* scale -* **y1** - the starting vertical position; bound to the *y* scale -* **y2** - the ending vertical position; bound to the *y* scale -* **pixelSize** - the screen size of a raster pixel; defaults to 1 - -If **width** is specified, **x1** defaults to 0 and **x2** defaults to **width**; likewise, if **height** is specified, **y1** defaults to 0 and **y2** defaults to **height**. Otherwise, if **data** is specified, **x1**, **y1**, **x2**, and **y2** respectively default to the frame’s left, top, right, and bottom coordinates. Lastly, if **data** is not specified (as when **fill** or **fillOpacity** is a function of *x* and *y*), you must specify all of **x1**, **x2**, **y1**, and **y2** to define the raster domain (see below). The **pixelSize** may be set to the inverse of the devicePixelRatio for a sharper image. - -The following raster-specific constant options are supported: - -* **interpolate** - the [spatial interpolation method](#spatial-interpolators) -* **imageRendering** - the [image-rendering attribute](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/image-rendering); defaults to *auto* (bilinear) -* **blur** - a non-negative pixel radius for smoothing; defaults to 0 - -The **imageRendering** option may be set to *pixelated* for a sharper image. The **interpolate** option is ignored when **fill** or **fillOpacity** is a function of *x* and *y*. - -## raster(*data*, *options*) {#raster} - -```js -Plot.raster(volcano.values, {width: volcano.width, height: volcano.height}) -``` - -Returns a new raster mark with the given (optional) *data* and *options*. - -## Spatial interpolators - -The [raster](#raster-mark) and [contour](./contour.md) marks use **spatial interpolators** to populate a raster grid from a discrete set of (often ungridded) spatial samples. The **interpolate** option controls how these marks compute the raster grid. The following built-in methods are provided: - -* *none* (or null) - assign each sample to the containing pixel -* *nearest* - assign each pixel to the closest sample’s value (Voronoi diagram) -* *barycentric* - apply barycentric interpolation over the Delaunay triangulation -* *random-walk* - apply a random walk from each pixel, stopping when near a sample - -The **interpolate** option can also be specified as a function with the following arguments: - -* *index* - an array of numeric indexes into the channels *x*, *y*, *value* -* *width* - the width of the raster grid; a positive integer -* *height* - the height of the raster grid; a positive integer -* *x* - an array of values representing the *x*-position of samples -* *y* - an array of values representing the *y*-position of samples -* *value* - an array of values representing the sample’s observed value - -So, *x*[*index*[0]] represents the *x*-position of the first sample, *y*[*index*[0]] its *y*-position, and *value*[*index*[0]] its value (*e.g.*, the observed height for a topographic map). - -## interpolateNone(*index*, *width*, *height*, *x*, *y*, *value*) {#interpolateNone} - -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: Plot.interpolateNone}) -``` - -Applies a simple forward mapping of samples, binning them into pixels in the raster grid without any blending or interpolation. If multiple samples map to the same pixel, the last one wins; this can introduce bias if the points are not in random order, so use [Plot.shuffle](../transforms/sort.md#shuffle) to randomize the input if needed. - -## interpolateNearest(*index*, *width*, *height*, *x*, *y*, *value*) {#interpolateNearest} - -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: Plot.interpolateNearest}) -``` - -Assigns each pixel in the raster grid the value of the closest sample; effectively a Voronoi diagram. - -## interpolatorBarycentric(*options*) {#interpolatorBarycentric} - -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: Plot.interpolatorBarycentric()}) -``` - -Constructs a Delaunay triangulation of the samples, and then for each pixel in the raster grid, determines the triangle that covers the pixel’s centroid and interpolates the values associated with the triangle’s vertices using [barycentric coordinates](https://en.wikipedia.org/wiki/Barycentric_coordinate_system). If the interpolated values are ordinal or categorical (_i.e._, anything other than numbers or dates), then one of the three values will be picked randomly weighted by the barycentric coordinates; the given **random** number generator will be used, which defaults to a [linear congruential generator](https://d3js.org/d3-random#randomLcg) with a fixed seed (for deterministic results). - -## interpolatorRandomWalk(*options*) {#interpolatorRandomWalk} - -```js -Plot.raster(ca55, {x: "LONGITUDE", y: "LATITUDE", fill: "MAG_IGRF90", interpolate: Plot.interpolatorRandomWalk()}) -``` - -For each pixel in the raster grid, initiates a random walk, stopping when either the walk is within a given distance (**minDistance**) of a sample or the maximum allowable number of steps (**maxSteps**) have been taken, and then assigning the current pixel the closest sample’s value. The random walk uses the “walk on spheres” algorithm in two dimensions described by [Sawhney and Crane](https://www.cs.cmu.edu/~kmcrane/Projects/MonteCarloGeometryProcessing/index.html), SIGGRAPH 2020; the given **random** number generator will be used, which defaults to a [linear congruential generator](https://d3js.org/d3-random#randomLcg) with a fixed seed (for deterministic results). diff --git a/docs/marks/rect.md b/docs/marks/rect.md deleted file mode 100644 index fd7575f8..00000000 --- a/docs/marks/rect.md +++ /dev/null @@ -1,284 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, shallowRef, onMounted} from "vue"; - -const r = ref(4); -const diamonds = shallowRef([]); -const seattle = shallowRef([]); -const olympians = shallowRef([{weight: 31, height: 1.21, sex: "female"}, {weight: 170, height: 2.21, sex: "male"}]); -const povcalnet = shallowRef([]); -const us = shallowRef(null); -const counties = computed(() => us.value ? topojson.feature(us.value, us.value.objects.counties).features : []); -const countyboxes = computed(() => counties.value.map((d) => d3.geoBounds(d).flat())); -const bins = d3.bin()(d3.range(1000).map(d3.randomNormal.source(d3.randomLcg(42))())); - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); - d3.csv("../data/diamonds.csv", d3.autoType).then((data) => (diamonds.value = data)); - d3.csv("../data/seattle-weather.csv", d3.autoType).then((data) => (seattle.value = data)); - d3.csv("../data/povcalnet.csv", d3.autoType).then((data) => (povcalnet.value = data)); - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); -}); - -</script> - -# Rect mark - -The **rect mark** draws axis-aligned rectangles defined by **x1**, **y1**, **x2**, and **y2**. For example, here we display geographic bounding boxes of U.S. counties represented as [*x1*, *y1*, *x2*, *y2*] tuples, where *x1* & *x2* are degrees longitude and *y1* & *y2* are degrees latitude. - -:::plot defer https://observablehq.com/@observablehq/plot-county-boxes -```js -Plot.plot({ - projection: "albers-usa", - marks: [ - Plot.rect(countyboxes, { - x1: "0", // or ([x1]) => x1 - y1: "1", // or ([, y1]) => y1 - x2: "2", // or ([,, x2]) => x2 - y2: "3", // or ([,,, y2]) => y2 - stroke: "currentColor" - }) - ] -}) -``` -::: - -The rect mark is often used to produce histograms or heatmaps of quantitative data. For example, given some binned observations computed by [d3.bin](https://d3js.org/d3-array/bin), we can produce a basic histogram with [rectY](#rectY) as follows: - -:::plot https://observablehq.com/@observablehq/plot-rects-and-bins -```js -Plot.rectY(bins, {x1: "x0", x2: "x1", y: "length"}).plot({round: true}) -``` -::: - -```js -bins = d3.bin()(d3.range(1000).map(d3.randomNormal())) -``` - -:::info -d3.bin uses *x0* and *x1* to represent the lower and upper bound of each bin, whereas the rect mark uses **x1** and **x2**. The *length* field is the count of values in each bin, which is encoded as **y**. -::: - -More commonly, the rect mark is paired with the [bin transform](../transforms/bin.md) to bin quantitative values automatically. As an added bonus, this sets default [inset options](../features/marks.md#mark-options) for a 1px gap separating adjacent rects, improving readability. - -:::plot https://observablehq.com/@observablehq/plot-rects-and-bins -```js -Plot.rectY(d3.range(1000).map(d3.randomNormal()), Plot.binX()).plot() -``` -::: - -Like the [bar mark](./bar.md), the rect mark has two convenience constructors for common orientations: [rectX](#rectX) is for horizontal→ rects with an implicit [stackX transform](../transforms/stack.md#stackX), while [rectY](#rectY) is for vertical↑ rects with an implicit [stackY transform](../transforms/stack.md#stackY). - -:::plot defer https://observablehq.com/@observablehq/plot-vertical-histogram -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -For overlapping rects, you can opt-out of the implicit stack transform by specifying either **x1** or **x2** for rectX, and likewise either **y1** or **y2** for rectY. - -:::plot defer https://observablehq.com/@observablehq/plot-overlapping-histogram -```js-vue -Plot.plot({ - round: true, - color: {legend: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y2: "count"}, {x: "weight", fill: "sex", mixBlendMode: "{{$dark ? "screen" : "multiply"}}"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::warning CAUTION -While the **mixBlendMode** option is useful for mitigating occlusion, it can be slow to render if there are many elements. More than two overlapping histograms may also be hard to read. -::: - -The rect mark and bin transform naturally support [faceting](../features/facets.md), too. - -:::plot defer https://observablehq.com/@observablehq/plot-overlapping-histogram -```js -Plot.plot({ - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fy: "sex"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -The [rect constructor](#rect), again with the [bin transform](../transforms/bin.md), can produce two-dimensional histograms (heatmaps) where density is represented by the **fill** color encoding. - -:::plot defer https://observablehq.com/@observablehq/plot-continuous-dimensions-heatmap -```js-vue -Plot.plot({ - height: 640, - marginLeft: 60, - color: { - scheme: "{{$dark ? "turbo" : "YlGnBu"}}", - type: "symlog" - }, - marks: [ - Plot.rect(diamonds, Plot.bin({fill: "count"}, {x: "carat", y: "price", thresholds: 100})) - ] -}) -``` -::: - -:::tip -A similar plot can be made with the [dot mark](./dot.md), if you’d prefer a size encoding. -::: - -Below we recreate an uncommon [chart by Max Roser](https://ourworldindata.org/poverty-minimum-growth-needed) that visualizes global poverty. Each rect represents a country: *x* encodes the country’s population, while *y* encodes the proportion of that population living in poverty; hence area represents the number of people living in poverty. Rects are [stacked](../transforms/stack.md) along *x* in order of descending *y*. - -:::plot defer https://observablehq.com/@observablehq/plot-cumulative-distribution-of-poverty -```js -Plot.plot({ - x: {label: "Population (millions)"}, - y: {percent: true, label: "Proportion living on less than $30 per day (%)"}, - marks: [ - Plot.rectY(povcalnet, Plot.stackX({ - filter: (d) => ["N", "A"].includes(d.CoverageType), - x: "ReqYearPopulation", - order: "HeadCount", - reverse: true, - y2: "HeadCount", // y2 to avoid stacking by y - title: (d) => `${d.CountryName}\n${(d.HeadCount * 100).toFixed(1)}%`, - insetLeft: 0.2, - insetRight: 0.2 - })), - Plot.ruleY([0]) - ] -}) -``` -::: - -The [interval transform](../transforms/interval.md) may be used to convert a single value in **x** or **y** (or both) into an extent. (Unlike the bin transform, the interval transform will produce overlapping rects if multiple points have the same position.) The chart below shows the observed daily maximum temperature in Seattle for the year 2015. The day-in-month and month-in-year numbers are expanded to unit intervals by setting the **interval** option to 1. - -:::plot defer https://observablehq.com/@observablehq/plot-seattle-heatmap-quantitative -```js -Plot.plot({ - aspectRatio: 1, - y: {ticks: 12, tickFormat: Plot.formatMonth("en", "narrow")}, - marks: [ - Plot.rect(seattle.filter((d) => d.date.getUTCFullYear() === 2015), { - x: (d) => d.date.getUTCDate(), - y: (d) => d.date.getUTCMonth(), - interval: 1, - fill: "temp_max", - inset: 0.5 - }) - ] -}) -``` -::: - -:::tip -A similar chart could be made with the [cell mark](./cell.md) using ordinal *x* and *y* scales instead, or with the [dot mark](./dot.md) as a scatterplot. -::: - -To round corners, use the **r** option. If the combined corner radii exceed the width or height of the rect, the radii are proportionally reduced to produce a pill shape with circular caps. Try increasing the radii below. - -<p> - <label class="label-input" style="display: flex;"> - <span style="display: inline-block; width: 7em;">r:</span> - <input type="range" v-model.number="r" min="0" max="25" step="0.2"> - <span style="font-variant-numeric: tabular-nums;">{{r}}</span> - </label> -</p> - -:::plot hidden defer https://observablehq.com/@observablehq/plot-rounded-rects -```js -Plot.plot({ - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", r, thresholds: 10})), - Plot.ruleY([0]) - ] -}) -``` -::: - -```js-vue -Plot.plot({ - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", r: {{r}}, thresholds: 10})), - Plot.ruleY([0]) - ] -}) -``` - -To round corners on a specific side, use the **rx1**, **ry1**, **rx2**, or **ry2** options. When stacking rounded rects vertically, use a positive **ry2** and a corresponding negative **ry1**; likewise for stacking rounded rects horizontally, use a positive **rx2** and a negative **rx1**. Use the **clip** option to hide the “wings” below zero. - -:::plot defer https://observablehq.com/@observablehq/plot-rounded-rects -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", ry2: 4, ry1: -4, clip: "frame"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -You can even round specific corners using the **rx1y1**, **rx2y1**, **rx2y2**, and **rx1y2** options. - -:::plot defer https://observablehq.com/@observablehq/plot-rounded-rects -```js -Plot.plot({ - color: {legend: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", rx1y2: 10, rx1y1: -10, clip: "frame"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -## Rect options - -The following channels are optional: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **y1** - the starting vertical position; bound to the *y* scale -* **x2** - the ending horizontal position; bound to the *x* scale -* **y2** - the ending vertical position; bound to the *y* scale - -If **x1** is specified but **x2** is not specified, then *x* must be a *band* scale; if **y1** is specified but **y2** is not specified, then *y* must be a *band* scale. - -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each **x** to produce **x1**, and *interval*.offset(*x1*) is invoked for each **x1** to produce **x2**. The same is true for **y**, **y1**, and **y2**, respectively. If the interval is specified as a number *n*, **x1** and **x2** are taken as the two consecutive multiples of *n* that bracket **x**. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). - -The rect mark supports the [standard mark options](../features/marks.md#mark-options), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners). The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. - -## rect(*data*, *options*) {#rect} - -```js -Plot.rect(olympians, Plot.bin({fill: "count"}, {x: "weight", y: "height"})) -``` - -Returns a new rect with the given *data* and *options*. - -## rectX(*data*, *options*) {#rectX} - -```js -Plot.rectX(olympians, Plot.binY({x: "count"}, {y: "weight"})) -``` - -Equivalent to [rect](#rect), except that if neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for a histogram with horizontal→ rects aligned at *x* = 0. If the **x** option is not specified, it defaults to the identity function. - -## rectY(*data*, *options*) {#rectY} - -```js -Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight"})) -``` - -Equivalent to [rect](#rect), except that if neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for a histogram with vertical↑ rects aligned at *y* = 0. If the **y** option is not specified, it defaults to the identity function. diff --git a/docs/marks/rule.md b/docs/marks/rule.md deleted file mode 100644 index deb9bf98..00000000 --- a/docs/marks/rule.md +++ /dev/null @@ -1,199 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import aapl from "../data/aapl.ts"; -import alphabet from "../data/alphabet.ts"; - -const seattle = shallowRef([]); -const simpsons = shallowRef(d3.cross(d3.range(1, 29), d3.range(1, 26), (x, y) => ({season: x, number_in_season: y}))); - -onMounted(() => { - d3.csv("../data/seattle-weather.csv", d3.autoType).then((data) => (seattle.value = data)); - d3.csv("../data/simpsons.csv", d3.autoType).then((data) => (simpsons.value = data)); -}); - -</script> - -# Rule mark - -:::tip -The rule mark is one of two marks in Plot for drawing horizontal or vertical lines; it should be used when the secondary position dimension, if any, is quantitative. When it is ordinal, use a [tick](./tick.md). -::: - -The **rule mark** comes in two orientations: [ruleY](#ruleY) draws a horizontal↔︎ line with a given *y* value, while [ruleX](#ruleX) draws a vertical↕︎ line with a given *x* value. Rules are often used as annotations, say to mark the *y* = 0 baseline (in red below for emphasis) in a line chart. - -:::plot https://observablehq.com/@observablehq/plot-rule-zero -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.ruleY([0], {stroke: "red"}), - Plot.line(aapl, {x: "Date", y: "Close"}) - ] -}) -``` -::: - -As annotations, rules often have a hard-coded array of literal values as data. This [shorthand](../features/shorthand.md) utilizes the default [identity](../features/transforms.md#identity) definition of the rule’s position (**y** for ruleY and **x** for ruleX). - -:::plot https://observablehq.com/@observablehq/plot-rule-one -```js -Plot.plot({ - y: { - type: "log", - grid: true, - label: "Change in price (%)", - tickFormat: ((f) => (d) => f((d - 1) * 100))(d3.format("+d")) - }, - marks: [ - Plot.ruleY([1], {stroke: "red"}), - Plot.line(aapl, Plot.normalizeY("first", {x: "Date", y: "Close"})) - ] -}) -``` -::: - -Yet rules can also be used to visualize data. Below, a random normal distribution is plotted with rules, looking a bit like the [emission spectrum of Hydrogen](https://en.wikipedia.org/wiki/Hydrogen_spectral_series). - -:::plot https://observablehq.com/@observablehq/plot-rule-random -```js -Plot.plot({ - x: {domain: [-4, 4]}, - marks: [ - Plot.ruleX({length: 500}, {x: d3.randomNormal(), strokeOpacity: 0.2}) - ] -}) -``` -::: - -:::tip -Reducing opacity allows better perception of density when rules overlap. -::: - -Rules can also serve as an alternative to an [area mark](./area.md) as in a band chart, provided the data is sufficiently dense: you can limit the extent of a rule along the secondary dimension (**y1** and **y2** channels for ruleX, and **x1** and **x2** channels for ruleY) rather than having it span the frame. And rules support a **stroke** color encoding. The rules below plot the daily minimum and maximum temperature for Seattle. - -:::plot defer -```js -Plot.plot({ - y: {grid: true, label: "Temperature (°C)"}, - color: {scheme: "BuRd"}, - marks: [ - Plot.ruleY([0]), - Plot.ruleX(seattle, {x: "date", y1: "temp_min", y2: "temp_max", stroke: "temp_min"}) - ] -}) -``` -::: - -In the dense [candlestick chart](https://observablehq.com/@observablehq/observable-plot-candlestick) below, three rules are drawn for each trading day: a gray rule spans the chart, showing gaps for weekends and holidays; a <span style="border-bottom: solid 2px currentColor;">{{$dark ? "white" : "black"}}</span> rule spans the day’s low and high; and a <span style="border-bottom: solid 2px var(--vp-c-green);">green</span> or <span style="border-bottom: solid 2px var(--vp-c-red);">red</span> rule spans the day’s open and close. - -:::plot defer https://observablehq.com/@observablehq/plot-candlestick-chart -```js -Plot.plot({ - inset: 6, - label: null, - y: {grid: true, label: "Stock price ($)"}, - color: {type: "threshold", range: ["red", "green"]}, - marks: [ - Plot.ruleX(aapl, {x: "Date", y1: "Low", y2: "High"}), - Plot.ruleX(aapl, {x: "Date", y1: "Open", y2: "Close", stroke: (d) => d.Close - d.Open, strokeWidth: 4}) - ] -}) -``` -::: - -Rules can be used to connect graphical elements, such as in the [dot plot](./dot.md) below showing the decline of *The Simpsons*. The rules indicate the extent (minimum and maximum) for each season, computed via the [group transform](../transforms/group.md), while a red line shows the median rating trend. - -:::plot defer https://observablehq.com/@observablehq/plot-simpsons-decline -```js -Plot.plot({ - marks: [ - Plot.ruleX(simpsons, Plot.groupX({y1: "min", y2: "max"}, {x: "season", y: "imdb_rating"})), - Plot.dot(simpsons, {x: "season", y: "imdb_rating", fill: "currentColor", stroke: "var(--vp-c-bg)"}), - Plot.lineY(simpsons, Plot.groupX({y: "median"}, {x: "season", y: "imdb_rating", stroke: "red"})) - ] -}) -``` -::: - -Rules can indicate uncertainty or error by setting the [**marker** option](../features/markers.md) to *tick*; this draws a small perpendicular line at the start and end of the rule. For example, to simulate ±10% error: - -:::plot -```js -Plot.plot({ - x: {label: null}, - y: {percent: true}, - marks: [ - Plot.barY(alphabet, {x: "letter", y: "frequency", fill: "blue"}), - Plot.ruleX(alphabet, {x: "letter", y1: (d) => d.frequency * 0.9, y2: (d) => d.frequency * 1.1, marker: "tick"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -Rules can also be a stylistic choice, as in the lollipop 🍭 chart below, serving the role of a skinny [bar](./bar.md) topped with a [*dot* marker](../features/markers.md). - -:::plot https://observablehq.com/@observablehq/plot-lollipop -```js -Plot.plot({ - x: {label: null, tickPadding: 6, tickSize: 0}, - y: {percent: true}, - marks: [ - Plot.ruleX(alphabet, {x: "letter", y: "frequency", strokeWidth: 2, markerEnd: "dot"}) - ] -}) -``` -::: - -Rules are also used by the [grid mark](./grid.md) to draw grid lines. - -## Rule options - -For the required channels, see [ruleX](#ruleX) and [ruleY](#ruleY). The rule mark supports the [standard mark options](../features/marks.md#mark-options), including insets along its secondary dimension, and [marker options](../features/markers.md) to add a marker (such as a dot or an arrowhead) to the start or end of the rule. The **stroke** defaults to *currentColor*. - -## ruleX(*data*, *options*) {#ruleX} - -```js -Plot.ruleX([0]) // as annotation -``` -```js -Plot.ruleX(alphabet, {x: "letter", y: "frequency"}) // like barY -``` - -Returns a new vertical↕︎ rule with the given *data* and *options*. The following channels are optional: - -* **x** - the horizontal position; bound to the *x* scale -* **y1** - the starting vertical position; bound to the *y* scale -* **y2** - the ending vertical position; bound to the *y* scale - -If **x** is not specified, it defaults to [identity](../features/transforms.md#identity) and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. If **x** is null, the rule will be centered horizontally in the plot frame. - -If **y** is specified, it is shorthand for **y2** with **y1** equal to zero; this is the typical configuration for a vertical lollipop chart with rules aligned at *y* = 0. If **y1** is not specified, the rule will start at the top of the plot (or facet). If **y2** is not specified, the rule will end at the bottom of the plot (or facet). - -If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). - -## ruleY(*data*, *options*) {#ruleY} - -```js -Plot.ruleY([0]) // as annotation -``` -```js -Plot.ruleY(alphabet, {y: "letter", x: "frequency"}) // like barX -``` - -Returns a new horizontal↔︎ rule with the given *data* and *options*. The following channels are optional: - -* **y** - the vertical position; bound to the *y* scale -* **x1** - the starting horizontal position; bound to the *x* scale -* **x2** - the ending horizontal position; bound to the *x* scale - -If **y** is not specified, it defaults to [identity](../features/transforms.md#identity) and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. If **y** is null, the rule will be centered vertically in the plot frame. - -If **x** is specified, it is shorthand for **x2** with **x1** equal to zero; this is the typical configuration for a horizontal lollipop chart with rules aligned at *x* = 0. If **x1** is not specified, the rule will start at the left edge of the plot (or facet). If **x2** is not specified, the rule will end at the right edge of the plot (or facet). - -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). diff --git a/docs/marks/text.md b/docs/marks/text.md deleted file mode 100644 index c562408c..00000000 --- a/docs/marks/text.md +++ /dev/null @@ -1,269 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; -import caltrain from "../data/caltrain.ts"; -import driving from "../data/driving.ts"; - -const travelers = shallowRef([]); - -onMounted(() => { - d3.csv("../data/travelers.csv", d3.autoType).then((data) => (travelers.value = data)); -}); - -</script> - -# Text mark - -The **text mark** draws text at the given position in **x** and **y**. It is often used to label other marks, such as to show the value of a [bar](./bar.md). When space is available, direct labeling can allow faster and more accurate reading of values than an axis alone (or a tooltip). - -:::plot https://observablehq.com/@observablehq/plot-labeled-bars -```js -Plot.plot({ - label: null, - y: { - grid: true, - label: "Frequency (%)", - percent: true - }, - marks: [ - Plot.barY(alphabet, {x: "letter", y: "frequency"}), - Plot.text(alphabet, {x: "letter", y: "frequency", text: (d) => (d.frequency * 100).toFixed(1), dy: -6, lineAnchor: "bottom"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -For formatting numbers and dates, consider [*number*.toLocaleString](https://observablehq.com/@mbostock/number-formatting), [*date*.toLocaleString](https://observablehq.com/@mbostock/date-formatting), [d3-format](https://d3js.org/d3-format), or [d3-time-format](https://d3js.org/d3-time-format). -::: - -If there are too many data points, labels may overlap, making them hard to read. Use the [filter transform](../transforms/filter.md) to choose which points to label. In the connected scatterplot below, recreating Hannah Fairfield’s [“Driving Shifts Into Reverse”](http://www.nytimes.com/imagepages/2010/05/02/business/02metrics.html) from 2009, every fifth year is labeled. - -:::plot https://observablehq.com/@observablehq/plot-connected-scatterplot -```js -Plot.plot({ - inset: 10, - grid: true, - x: {label: "Miles driven (per person-year)"}, - y: {label: "Cost of gasoline ($ per gallon)"}, - marks: [ - Plot.line(driving, {x: "miles", y: "gas", curve: "catmull-rom", marker: true}), - Plot.text(driving, {filter: (d) => d.year % 5 === 0, x: "miles", y: "gas", text: (d) => `${d.year}`, dy: -6, lineAnchor: "bottom"}) - ] -}) -``` -::: - -:::tip -If you’d like automatic labeling, please upvote [#27](https://github.com/observablehq/plot/issues/27). -::: - -For line charts with multiple series, you may wish to label only the start or end of each series; this can be done using the [select transform](../transforms/select.md), as shown in the chart below comparing the number of daily travelers at airports in the U.S. between 2019 and 2020. The impact of the COVID-19 pandemic is dramatic. - -:::plot defer https://observablehq.com/@observablehq/plot-labeled-line-chart -```js -Plot.plot({ - y: { - grid: true, - label: "Travelers per day (millions)", - transform: (d) => d / 1e6 // convert to millions - }, - marks: [ - Plot.ruleY([0]), - Plot.line(travelers, {x: "date", y: "previous", strokeOpacity: 0.5}), - Plot.line(travelers, {x: "date", y: "current"}), - Plot.text(travelers, Plot.selectFirst({x: "date", y: "previous", text: ["2019"], fillOpacity: 0.5, lineAnchor: "bottom", dy: -6})), - Plot.text(travelers, Plot.selectFirst({x: "date", y: "current", text: ["2020"], lineAnchor: "top", dy: 6})) - ] -}) -``` -::: - -:::warning CAUTION -The select transform uses input order, not natural order by value, to determine the meaning of *first* and *last*. Since this dataset is in reverse chronological order, the first element is the most recent. -::: - -A text mark can also be used to visualize data directly, similar to a [dot mark](./dot.md) in a scatterplot. Below a “stem and leaf” plot of Caltrain’s Palo Alto station schedule uses [stacked](../transforms/stack.md) text. The **fill** channel provides a color encoding to distinguish trains that make every stop (<span style="border-bottom: solid currentColor 3px;">N</span>), limited trains that make fewer stops (<span style="border-bottom: solid peru 3px;">L</span>), and “baby bullet” trains that make the fewest stops (<span style="border-bottom: solid brown 3px;">B</span>). - -:::plot https://observablehq.com/@observablehq/plot-caltrain-schedule -```js -Plot.plot({ - width: 240, - axis: null, - x: {type: "point"}, - y: {type: "point", domain: d3.range(4, 25)}, - color: {domain: "NLB", range: ["currentColor", "peru", "brown"], legend: true}, - marks: [ - Plot.text([[0.5, 4]], {text: ["Northbound"], textAnchor: "start", dx: 16}), - Plot.text([[-0.5, 4]], {text: ["Southbound"], textAnchor: "end", dx: -16}), - Plot.text(d3.range(5, 25), {x: 0, y: Plot.identity, text: (y) => `${y % 12 || 12}${y % 24 >= 12 ? "p": "a"}`}), - Plot.text(caltrain, Plot.stackX2({x: (d) => d.orientation === "N" ? 1 : -1, y: "hours", fill: "type", text: "minutes"})), - Plot.ruleX([-0.5, 0.5]) - ] -}) -``` -::: - -:::info -Since the **textAnchor** option is a constant rather than a channel, separate text marks are used for the *Northbound* and *Southbound* labels. -::: - -The **x** and **y** channels are optional; a one-dimensional text mark can be produced by specifying only one position dimension. If both **x** and **y** are not defined, the text mark assumes that the data is an iterable of points [[*x₁*, *y₁*], [*x₂*, *y₂*], …], allowing for [shorthand](../features/shorthand.md). Furthermore, the default **text** channel is the associated datum’s index. (This is rarely what you want, but at least it gets something on the screen.) - -:::plot https://observablehq.com/@observablehq/plot-text-spiral -```js -Plot.plot({ - aspectRatio: 1, - inset: 10, - grid: true, - marks: [ - Plot.text(d3.range(151).map((i) => [ - Math.sqrt(i) * Math.sin(i / 10), - Math.sqrt(i) * Math.cos(i / 10) - ])) - ] -}) -``` -::: - -The text mark will generate multiple lines if the **text** contains newline characters (`\n`). This may be useful for longer annotations. - -:::plot https://observablehq.com/@observablehq/plot-this-is-just-to-say -```js -Plot.plot({ - height: 200, - marks: [ - Plot.frame(), - Plot.text([`This Is Just To Say -William Carlos Williams, 1934 - -I have eaten -the plums -that were in -the icebox - -and which -you were probably -saving -for breakfast - -Forgive me -they were delicious -so sweet -and so cold`], {frameAnchor: "middle"}) - ] -}) -``` -::: - -Alternatively, the **lineWidth** option enables automatic line wrapping. This option must be specified as a number in [ems](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Values_and_units). When a word contains a [soft-hyphen](https://en.wikipedia.org/wiki/Soft_hyphen) (`\xad`), it may be replaced by a hyphen when wrapping. The **textOverflow** option can also be used to truncate lines that exceed the specified line width, like in the incipit of Herman Melville’s *Moby-Dick* (1851). - -:::plot https://observablehq.com/@observablehq/plot-moby-dick -```js -Plot.plot({ - height: 320, - x: {type: "point", align: 0, axis: "top", tickSize: 0}, - marks: [ - Plot.text( - [ - "Call me Ishmael. Some years ago — never mind how long precisely — having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before cof\xadfin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people’s hats off — then, I account it high time to get to sea as soon as I can. This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me.", - "There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs — commerce surrounds it with her surf. Right and left, the streets take you waterward. Its extreme downtown is the battery, where that noble mole is washed by waves, and cooled by breezes, which a few hours previous were out of sight of land. Look at the crowds of water-gazers there.", - "Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears Hook to Coenties Slip, and from thence, by Whitehall, northward. What do you see? — Posted like silent sentinels all around the town, stand thousands upon thousands of mortal men fixed in ocean reveries. Some leaning against the spiles; some seated upon the pier-heads; some looking over the bulwarks of ships from China; some high aloft in the rigging, as if striving to get a still better seaward peep. But these are all landsmen; of week days pent up in lath and plaster — tied to counters, nailed to benches, clinched to desks. How then is this? Are the green fields gone? What do they here?" - ], - { - x: (d, i) => 1 + i, // paragraph number - lineWidth: 20, - frameAnchor: "top", - textAnchor: "start" - } - ) - ] -}) -``` -::: - -:::warning CAUTION -For performance and simplicity, Plot does not measure text exactly and instead uses an approximate heuristic. If Plot’s automatic wrapping is not doing what you want, consider hard wrapping with manual newlines (`\n`) instead. There is also a **monospace** option suitable for fixed-width fonts. -::: - -## Text options - -The following channels are required: - -* **text** - the text contents (a string, possibly with multiple lines) - -If the **text** contains `\n`, `\r\n`, or `\r`, it will be rendered as multiple lines. If the **text** is specified as numbers or dates, a default formatter will automatically be applied, and the **fontVariant** will default to *tabular-nums* instead of *normal*. If **text** is not specified, it defaults to [identity](../features/transforms.md#identity) for primitive data (such as numbers, dates, and strings), and to the zero-based index [0, 1, 2, …] for objects (so that something identifying is visible by default). - -In addition to the [standard mark options](../features/marks.md#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 -* **fontSize** - the font size in pixels -* **rotate** - the rotation angle in degrees clockwise - -If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option. - -The following text-specific constant options are also supported: - -* **textAnchor** - the [text anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) for horizontal position; *start*, *end*, or *middle* -* **lineAnchor** - the line anchor for vertical position; *top*, *bottom*, or *middle* -* **lineHeight** - the line height in ems; defaults to 1 -* **lineWidth** - the line width in ems, for wrapping; defaults to Infinity -* **textOverflow** - how to wrap or clip lines longer than the specified line width <VersionBadge version="0.6.4" /> -* **monospace** - if true, changes the default **fontFamily** and metrics to monospace -* **fontFamily** - the font name; defaults to [*system-ui*](https://drafts.csswg.org/css-fonts-4/#valdef-font-family-system-ui) -* **fontSize** - the font size in pixels; defaults to 10 -* **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to *normal* -* **fontVariant** - the [font variant](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant); defaults to *normal* -* **fontWeight** - the [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight); defaults to *normal* -* **frameAnchor** - how to position the text within the frame; defaults to *middle* -* **rotate** - the rotation angle in degrees clockwise; defaults to 0 - -If a **lineWidth** is specified, input text values will be wrapped as needed to fit while preserving existing newlines. The line wrapping implementation is rudimentary: it replaces the space before the word that overflows with a line feed (`\n`). Lines might also be split on words that contain a soft-hyphen (`\xad`), replacing it with a hyphen (-). For non-ASCII, non-U.S. English text, or for when a different font is used, you may get better results by hard-wrapping the text yourself (by supplying line feeds in the input). If the **monospace** option is truthy, the default **fontFamily** changes to monospace and the **lineWidth** option is interpreted as characters (ch) rather than ems. - -The **textOverflow** option can be used to truncate lines of text longer than the given **lineWidth**. If the mark does not have a **title** channel, a title with the non-truncated text is also added. The following **textOverflow** values are supported: - -* null (default) - preserve overflowing characters -* *clip* or *clip-end* - remove characters from the end -* *clip-start* - remove characters from the start -* *ellipsis* or *ellipsis-end* - replace characters from the end with an ellipsis (…) -* *ellipsis-start* - replace characters from the start with an ellipsis (…) -* *ellipsis-middle* - replace characters from the middle with an ellipsis (…) - -The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. - -If the **frameAnchor** option is not specified, then **textAnchor** and **lineAnchor** default to middle. Otherwise, **textAnchor** defaults to start if **frameAnchor** is on the left, end if **frameAnchor** is on the right, and otherwise middle. Similarly, **lineAnchor** defaults to top if **frameAnchor** is on the top, bottom if **frameAnchor** is on the bottom, and otherwise middle. - -The **paintOrder** option defaults to *stroke* and the **strokeWidth** option defaults to 3. By setting **fill** to the foreground color and **stroke** to the background color (such as *black* and *white*, respectively), you can surround text with a “halo” which may improve legibility against a busy background. - -## text(*data*, *options*) {#text} - -```js -Plot.text(driving, {x: "miles", y: "gas", text: "year"}) -``` - -Returns a new text mark 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₂*, …]. - -## textX(*data*, *options*) {#textX} - -```js -Plot.textX(alphabet.map((d) => d.frequency)) -``` - -Equivalent to [text](#text), except **x** defaults to [identity](../features/transforms.md#identity) 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](../features/scales.md#scale-options). - -## textY(*data*, *options*) {#textY} - -```js -Plot.textY(alphabet.map((d) => d.frequency)) -``` - -Equivalent to [text](#text), except **y** defaults to [identity](../features/transforms.md#identity) 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](../features/scales.md#scale-options). diff --git a/docs/marks/tick.md b/docs/marks/tick.md deleted file mode 100644 index 84d0bd02..00000000 --- a/docs/marks/tick.md +++ /dev/null @@ -1,121 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import aapl from "../data/aapl.ts"; -import alphabet from "../data/alphabet.ts"; - -const stateage = shallowRef([]); - -onMounted(() => { - d3.csv("../data/us-population-state-age.csv", d3.autoType).then((data) => { - const ages = data.columns.slice(1); // convert wide data to tidy data - stateage.value = Object.assign(ages.flatMap((age) => data.map((d) => ({state: d.name, age, population: d[age]}))), {ages}); - }); -}); - -</script> - -# Tick mark - -:::tip -The tick mark is one of two marks in Plot for drawing horizontal or vertical lines; it should be used when the secondary position dimension, if any, is ordinal. When it is quantitative, use a [rule](./rule.md). -::: - -The **tick mark** comes in two orientations: [tickY](#tickY) draws a horizontal↔︎ line with a given *y* value, while [tickX](#tickX) draws a vertical↕︎ line with a given *x* value. Ticks have an optional secondary position dimension (**x** for tickY and **y** for tickX); this second dimension is ordinal, unlike a [rule](./rule.md), and requires a corresponding [band scale](../features/scales.md). - -Ticks are often used to show one-dimensional distributions, as in the “barcode” plot below showing the proportion of the population in each age bracket across U.S. states. - -:::plot defer https://observablehq.com/@observablehq/plot-barcode -```js -Plot.plot({ - x: { - grid: true, - label: "Population (%)", - percent: true - }, - y: { - domain: stateage.ages, // in age order - reverse: true, - label: "Age (years)", - labelAnchor: "top" - }, - marks: [ - Plot.ruleX([0]), - Plot.tickX(stateage, Plot.normalizeX("sum", {z: "state", x: "population", y: "age"})) - ] -}) -``` -::: - -Both ticks and [bars](./bar.md) have an ordinal secondary position dimension; a tick is therefore convenient for stroking the upper bound of a bar for emphasis, as in the bar chart below. A separate [rule](./rule.md) is also used to denote *y* = 0. - -:::plot https://observablehq.com/@observablehq/plot-bar-and-tick -```js -Plot.plot({ - x: {label: null}, - y: {percent: true}, - marks: [ - Plot.barY(alphabet, {x: "letter", y: "frequency", fillOpacity: 0.2}), - Plot.tickY(alphabet, {x: "letter", y: "frequency"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -When there is no secondary position dimension, a tick behaves identically to a [rule](./rule.md). While a one-dimensional rule and tick are equivalent, a one-dimensional rule is generally preferred, if only because the name “rule” is more descriptive. But as an example below, a random normal distribution is plotted below with ticks. - -:::plot https://observablehq.com/@observablehq/plot-random-ticks -```js -Plot.plot({ - x: {domain: [-4, 4]}, - marks: [ - Plot.tickX({length: 500}, {x: d3.randomNormal(), strokeOpacity: 0.2}) - ] -}) -``` -::: - -:::tip -Reducing opacity allows better perception of density when ticks overlap. -::: - -Ticks are also used by the [box mark](./box.md) to denote the median value for each group. - -## Tick options - -For the required channels, see [tickX](#tickX) and [tickY](#tickY). The tick mark supports the [standard mark options](../features/marks.md#mark-options), including insets, and [marker options](../features/markers.md) to add a marker (such as a dot or an arrowhead) to the start or end of the rule. The **stroke** defaults to *currentColor*. - -## tickX(*data*, *options*) {#tickX} - -```js -Plot.tickX(stateage, {x: "population", y: "age"}) -``` - -Returns a new vertical↕︎ tick with the given *data* and *options*. The following channels are required: - -* **x** - the horizontal position; bound to the *x* scale - -The following optional channels are supported: - -* **y** - the vertical position; bound to the *y* scale, which must be *band* - -If the **y** channel is not specified, the tick will span the full vertical extent of the frame. - -## tickY(*data*, *options*) {#tickY} - -```js -Plot.tickY(stateage, {y: "population", x: "age"}) -``` - -Returns a new horizontal↔︎ tick with the given *data* and *options*. The following channels are required: - -* **y** - the vertical position; bound to the *y* scale - -The following optional channels are supported: - -* **x** - the horizontal position; bound to the *x* scale, which must be *band* - -If the **x** channel is not specified, the tick will span the full vertical extent of the frame. diff --git a/docs/marks/tip.md b/docs/marks/tip.md deleted file mode 100644 index a1e53966..00000000 --- a/docs/marks/tip.md +++ /dev/null @@ -1,262 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, shallowRef, onMounted} from "vue"; - -const aapl = shallowRef([]); -const us = shallowRef(null); -const states = computed(() => us.value ? topojson.feature(us.value, us.value.objects.states).features : []); - -const olympians = shallowRef([ - {weight: 31, height: 1.21, sex: "female"}, - {weight: 170, height: 2.21, sex: "male"} -]); - -const scheme = Plot.scale({color: {type: "categorical"}}).range; - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); // TODO us-states? -}); - -</script> - -# Tip mark <VersionBadge version="0.6.7" /> - -The **tip mark** displays text, or several name-value pairs, in a floating box anchored to a given position in **x** and **y**. The tip mark is often paired with the [pointer transform](../interactions/pointer.md) to reveal details on demand when hovering over a chart, as in this line chart of Apple stock price: - -:::plot defer https://observablehq.com/@observablehq/plot-line-chart-interactive-tip -```js -Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot({y: {grid: true}}) -``` -::: - -The above code uses the **tip** [mark option](../features/marks.md#mark-options); the code can be written more explicitly with a tip mark and a pointer transform. - -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"})) - ] -}) -``` - -The tip mark can also be used for static annotations, say to draw attention to elements of interest or to add context. The tip text is supplied via the **title** channel. If the tip mark‘s data is an array of strings, the **title** channel defaults to [identity](../features/transforms.md#identity). - -:::plot defer https://observablehq.com/@observablehq/plot-static-annotations -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.tip( - [`Apple stock reaches a new high of $133 on Feb. 23, 2015. The release of the first Apple Watch, slated for April, is hotly anticipated.`], - {x: new Date("2015-02-23"), y: 133, dy: -3, anchor: "bottom"} - ), - Plot.tip( - [`Apple stock drops 8% after the company misses Q2 revenue targets and reports declining iPhone sales. It reaches a two-year low of $90.34 on May 12.`], - {x: new Date("2016-05-12"), y: 90.34, dy: 3, anchor: "top"} - ) - ] -}) -``` -::: - -When using the **title** channel, the tip mark wraps text to 20 ems by default, and preserves newlines in the provided text. Use the **lineWidth** option to adjust the width, along with other text options such as **lineHeight**, **textOverflow**, **fontFamily**, and **fontSize**; see the [text mark](../marks/text.md) for more details. - -The **title** channel can be used with interactive tips, too. If you have a few moments, hover the chart below to read about various athletes who competed at Rio 2016. - -:::plot defer https://observablehq.com/@observablehq/plot-tips-longer-text -```js -Plot.plot({ - grid: true, - marks: [ - Plot.dot(olympians, { - x: "weight", - y: "height", - fy: "sex", - sort: (d) => !!d.info, - stroke: (d) => d.info ? "currentColor" : "#aaa" - }), - Plot.tip(olympians, Plot.pointer({ - x: "weight", - y: "height", - fy: "sex", - filter: (d) => d.info, - title: (d) => [d.name, d.info].join("\n\n") - })) - ] -}) -``` -::: - -If no **title** channel is supplied, the tip mark displays all channel values. You can supply additional name-value pairs by registering extra channels using the **channels** mark option. In the scatterplot of Olympic athletes below, you can hover to see the *name* and *sport* of each athlete. This is helpful for noticing patterns — tall basketball players, giant weightlifters and judoka, diminutive gymnasts — and for seeing individuals. - -:::plot defer https://observablehq.com/@observablehq/plot-tips-additional-channels -```js -Plot.dot(olympians, { - x: "weight", - y: "height", - stroke: "sex", - channels: {name: "name", sport: "sport"}, - tip: true -}).plot() -``` -::: - -:::info -The tallest athlete in this dataset, swimmer [Kevin Cordes](https://en.wikipedia.org/wiki/Kevin_Cordes), is likely an error: his official height is 1.96m (6′ 5″) not 2.21m (7′ 3″). Basketball player [Li Muhao](https://en.wikipedia.org/wiki/Li_Muhao) is likely the true tallest. -::: - -If a channel is bound to the *color* or *opacity* scale, the tip mark displays a swatch to reinforce the encoding, such as female <span :style="{color: scheme[0]}">■</span> or male <span :style="{color: scheme[1]}">■</span>. - -The tip mark recognizes that **x1** & **x2** and **y1** & **y2** are paired channels. Below, observe that the *weight* shown in the tip is a range such as 64–66 kg; however, the *frequency* is shown as the difference between the **y1** and **y2** channels. The latter is achieved by the stack transform setting a channel hint to indicate that **y1** and **y2** represent a length. - -:::plot defer https://observablehq.com/@observablehq/plot-tips-paired-channels -```js -Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", tip: true})).plot() -``` -::: - -This even works when stacking negative values, say to mirror the histogram instead of stacking it. (The tip displays negative frequency, but this is consistent with the *y* axis.) - -:::plot defer https://observablehq.com/@observablehq/plot-tips-paired-channels -```js -Plot.rectY(olympians, Plot.binX({y: "sum"}, {x: "weight", y: (d) => d.sex === "male" ? 1 : -1, fill: "sex", tip: true})).plot({y: {label: "Frequency"}}) -``` -::: - -The order and formatting of channels in the tip can be customized with the **format** option <VersionBadge version="0.6.11" pr="1823" />, which accepts a key-value object mapping channel names to formats. Each [format](../features/formats.md) can be a string (for number or time formats), a function that receives the value as input and returns a string, true to use the default format, and null or false to suppress. The order of channels in the tip follows their order in the format object followed by any additional channels. When using the **title** channel, the **format** option may be specified as a string or a function; the given format will then apply to the **title** channel. <VersionBadge version="0.6.15" pr="2074" /> - -A channel’s label can be specified alongside its value as a {value, label} object; if a channel label is not specified, the associated scale’s label is used, if any; if there is no associated scale, or if the scale has no label, the channel name is used instead. - -:::plot defer https://observablehq.com/@observablehq/plot-tip-format -```js -Plot.dot(olympians, { - x: "weight", - y: "height", - stroke: "sex", - channels: { - name: "name", - nationality: { - value: "nationality", - label: "country" - }, - sport: "sport" - }, - tip: { - format: { - name: true, - sport: true, - nationality: true, - y: (d) => `${d}m`, - x: (d) => `${d}kg`, - stroke: false - } - } -}).plot() -``` -::: - -The tip mark supports nine different orientations specified by the **anchor** option: the four sides (*top*, *right*, *bottom*, *left*), the four corners (*top-left*, *top-right*, *bottom-right*, *bottom-left*), and *middle*. Note that when *middle* is used, the tip will obscure its anchor point. - -:::plot defer -```js -Plot.plot({ - height: 160, - marks: [ - Plot.frame({strokeOpacity: 0.2}), - [ - "top", "right", "bottom", "left", // sides - "top-left", "top-right", "bottom-right", "bottom-left", // corners - "middle" - ].map((anchor) => [ - Plot.dot({length: 1}, {frameAnchor: anchor, fill: "blue"}), - Plot.tip([anchor], {frameAnchor: anchor, anchor}) - ]) - ] -}) -``` -::: - -If you don’t specify an explicit **anchor**, the tip mark will choose one automatically, using the **preferredAnchor** <VersionBadge version="0.6.12" pr="1872" /> if it fits. The preferred anchor defaults to *bottom*, except when using the **tip** option and the [pointerY pointing mode](../interactions/pointer.md), in which case it defaults to *left*. In some cases, it may not be possible to fit the tip within the plot’s frame; consider setting the plot’s **style** to `overflow: visible;` to prevent the tip from being truncated. - -The tip mark is compatible with transforms that derive **x** and **y** dynamically from data, such as the [centroid transform](../transforms/centroid.md) which computes polygon centroids. Below, a map of the United States shows state names. We reduce the size of the tips by setting the **textPadding** option to 3 pixels instead of the default 8. - -:::plot defer https://observablehq.com/@observablehq/plot-maps-tips -```js -Plot.plot({ - projection: "albers-usa", - marks: [ - Plot.geo(states), - Plot.tip(states, Plot.geoCentroid({title: (d) => d.properties.name, anchor: "bottom", textPadding: 3})) - ] -}) -``` -::: - -:::tip -When multiple tips are visible simultaneously, some may collide; consider using the pointer interaction to show only the one closest to the pointer, or use multiple tip marks and adjust the **anchor** option for each to minimize occlusion. -::: - -## Tip options - -The following position options are supported: - -- **x**, **x1**, or **x2** - the horizontal↔︎ position channel -- **y**, **y1**, or **y2** - the vertical↕︎ position channel -- **frameAnchor** - fallback position if *x* or *y* are otherwise unspecified - -To resolve the anchor position, the tip mark applies the following order of precedence: - -1. the midpoint of **x1** and **x2**, if both are present; -2. otherwise **x**, if present; -3. otherwise **x1**, if present; -4. and lastly the position given by the **frameAnchor**. - -The same precedence applies to the **y**, **y1**, and **y2** channels. - -These tip-specific options control the tip appearance: - -- **anchor** - *top*, *right*, *bottom*, *left*, *top-left*, *top-right*, *bottom-right*, *bottom-left*, or *middle* -- **pointerSize** - the size of the tip’s pointer in pixels; defaults to 12 -- **pathFilter** - the image filter for the tip’s box; defaults to a drop shadow -- **textPadding** - the padding around the text in pixels; defaults to 8 - -The tip mark does not support the [standard style channels](../features/marks.md#mark-options) such as varying **fill** or **stroke**; channels are used exclusively to control the displayed values rather than the tip’s appearance. You can however use the these options for a constant **fill**, **fillOpacity**, **stroke**, **strokeOpacity**, or **strokeWidth** on the path element surrounding the tip text. - -:::plot defer -```js -Plot.tip(["Danger! This tip is red."], { - anchor: "bottom", - frameAnchor: "bottom", - fill: "red" -}).plot() -``` -::: - -These [standard text options](./text.md#text-options) control the display of text within the tip: - -- **monospace** - if true, changes the default **fontFamily** and metrics to monospace -- **fontFamily** - the font name; defaults to [*system-ui*](https://drafts.csswg.org/css-fonts-4/#valdef-font-family-system-ui) -- **fontSize** - the font size in pixels; defaults to 10 -- **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to *normal* -- **fontVariant** - the [font variant](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant); defaults to *normal* -- **fontWeight** - the [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight); defaults to *normal* -- **textAnchor** - the [text anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) for horizontal position; *start*, *end*, or *middle* -- **lineHeight** - the line height in ems; defaults to 1 -- **lineWidth** - the line width in ems, for wrapping; defaults to 20 -- **textOverflow** - how to wrap or clip lines longer than the specified line width - -## tip(*data*, *options*) {#tip} - -```js -Plot.tip(aapl, {x: "Date", y: "Close"}) -``` - -Returns a new tip mark with the given *data* and *options*. diff --git a/docs/marks/tree.md b/docs/marks/tree.md deleted file mode 100644 index 7ed2b394..00000000 --- a/docs/marks/tree.md +++ /dev/null @@ -1,181 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; - -const flare = shallowRef([{name: "empty"}]); - -const gods = [ - "Chaos/Gaia/Mountains", - "Chaos/Gaia/Pontus", - "Chaos/Gaia/Uranus", - "Chaos/Eros", - "Chaos/Erebus", - "Chaos/Tartarus" -]; - -onMounted(() => { - d3.csv("../data/flare.csv", d3.autoType).then((data) => (flare.value = data)); -}); - -function indent() { - return (root) => { - root.eachBefore((node, i) => { - node.y = node.depth; - node.x = i; - }); - }; -} - -</script> - -# Tree mark <VersionBadge version="0.4.3" /> - -The **tree mark** produces tree diagrams using the [tree transform](../transforms/tree.md). It is a [composite mark](../features/marks.md#marks), consisting of a [link](./link.md) to render links from parent to child, an optional [dot](./dot.md) for nodes, and one or two [text](./text.md) for node labels. The link mark uses the [treeLink transform](../transforms/tree.md#treeLink), while the dot and text marks use the [treeNode transform](../transforms/tree.md#treeNode). - -For example, here is a little family tree of Greek gods. - -:::plot https://observablehq.com/@observablehq/plot-tree-and-link -```js -Plot.plot({ - axis: null, - height: 100, - margin: 10, - marginLeft: 40, - marginRight: 120, - marks: [ - Plot.tree(gods, {textStroke: "var(--vp-c-bg)"}) - ] -}) -``` -::: - -Here `gods` is an array of slash-separated paths, similar to paths in a file system. Each path represents the hierarchical position of a node in the tree. - -```js-vue -gods = {{JSON.stringify(gods, null, 2)}} -``` - -As a more complete example, here is a visualization of a software package hierarchy. - -:::plot defer https://observablehq.com/@observablehq/plot-tree-flare -```js -Plot.plot({ - axis: null, - margin: 10, - marginLeft: 30, - marginRight: 160, - width: 688, - height: 1800, - marks: [ - Plot.tree(flare, {path: "name", delimiter: ".", textStroke: "var(--vp-c-bg)"}) - ] -}) -``` -::: - -The **treeLayout** option specifies the layout algorithm. The tree mark uses the Reingold–Tilford “tidy” tree algorithm, [d3.tree](https://d3js.org/d3-hierarchy/tree), by default. The [cluster](#cluster) convenience method sets **treeLayout** to [d3.cluster](https://d3js.org/d3-hierarchy/cluster), aligning the leaf nodes. - -:::plot https://observablehq.com/@observablehq/plot-cluster-flare -```js -Plot.plot({ - axis: null, - margin: 10, - marginLeft: 30, - marginRight: 160, - width: 688, - height: 2400, - marks: [ - Plot.cluster(flare, {path: "name", treeSort: "node:height", delimiter: ".", textStroke: "var(--vp-c-bg)"}) - ] -}) -``` -::: - -Here is an example of a custom **treeLayout** implementation, assigning *node*.x and *node*.y. - -```js -function indent() { - return (root) => { - root.eachBefore((node, i) => { - node.y = node.depth; - node.x = i; - }); - }; -} -``` - -This produces an indented tree layout. - -:::plot defer https://observablehq.com/@observablehq/plot-custom-tree-layout -```js -Plot.plot({ - axis: null, - inset: 10, - insetRight: 120, - round: true, - width: 200, - height: 3600, - marks: Plot.tree(flare, { - path: "name", - delimiter: ".", - treeLayout: indent, - strokeWidth: 1, - curve: "step-before", - textStroke: "none" - }) -}) -``` -::: - -The tree mark currently does not inform the default layout; you may find it necessary to set the **height** and **margin** [layout options](../features/plots.md#layout-options) for readability. - -## Tree options - -The following options are supported: - -* **fill** - the dot and text fill color; defaults to *node:internal* -* **stroke** - the link stroke color; inherits **fill** by default -* **strokeWidth** - the link stroke width -* **strokeOpacity** - the link stroke opacity -* **strokeLinejoin** - the link stroke linejoin -* **strokeLinecap** - the link stroke linecap -* **strokeMiterlimit** - the link stroke miter limit -* **strokeDasharray** - the link stroke dash array -* **strokeDashoffset** - the link stroke dash offset -* **marker** - the link start and end marker -* **markerStart** - the link start marker -* **markerEnd** - the link end marker -* **dot** - if true, whether to render a dot; defaults to false if no link marker -* **title** - the text and dot title; defaults to *node:path* -* **text** - the text label; defaults to *node:name* -* **textStroke** - the text stroke; defaults to *white* -* **textLayout** - the text anchoring layout -* **dx** - the text horizontal offset; defaults to 6 -* **dy** - the text vertical offset; defaults to 0 - -Any additional *options* are passed through to the constituent link, dot, and text marks and their corresponding treeLink or treeNode transform. - -The **textLayout** option <VersionBadge version="0.6.9" /> controls how text labels are anchored to the node. Two layouts are supported: - -* *mirrored* - leaf-node labels are left-anchored, and non-leaf nodes right-anchored -* *normal* - all labels are left-anchored - -If the **treeLayout** is d3.tree or d3.cluster, the **textLayout** defaults to *mirrored*; otherwise it defaults to *normal*. - -## tree(*data*, *options*) {#tree} - -```js -Plot.tree(flare, {path: "name", delimiter: "."}) -``` - -Returns a new tree mark with the given *data* and *options*. - -## cluster(*data*, *options*) {#cluster} - -```js -Plot.cluster(flare, {path: "name", delimiter: "."}) -``` - -Like [tree](#tree), except sets the **treeLayout** option to [d3.cluster](https://d3js.org/d3-hierarchy/cluster), aligning leaf nodes, and defaults the **textLayout** option to *mirrored*. diff --git a/docs/marks/vector.md b/docs/marks/vector.md deleted file mode 100644 index 3fae7307..00000000 --- a/docs/marks/vector.md +++ /dev/null @@ -1,205 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, shallowRef, onMounted} from "vue"; -import {poisson} from "../components/poisson.js"; -import {octave, perlin2} from "../components/perlin.js"; - -const noise = octave(perlin2, 2); -const wind = shallowRef([{longitude: -9.875, latitude: 45.125}, {longitude: 9.875, latitude: 59.875}, {u: 0, v: 0}, {u: 0, v: 12.184501776503668}]); -const us = shallowRef(null); -const nation = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.nation) : {type: null}); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states, (a, b) => a !== b) : {type: null}); -const counties = computed(() => us.value ? topojson.feature(us.value, us.value.objects.counties).features : []); - -onMounted(() => { - d3.csv("../data/wind.csv", d3.autoType).then((data) => (wind.value = data)); - Promise.all([ - d3.json("../data/us-counties-10m.json"), - d3.csv("../data/us-county-population.csv"), - d3.csv("../data/us-presidential-election-2020.csv") - ]).then(([_us, _population, _election]) => { - const population = new Map(_population.map((d) => [d.state + d.county, +d.population])); - const election = new Map(_election.map((d) => [d.fips, d])); - for (const g of _us.objects.counties.geometries) { - g.properties.population = population.get(g.id); - const e = election.get(g.id); - if (e) { - g.properties.margin2020 = +e.margin2020; - g.properties.votes = +e.votes; - } - } - us.value = _us; - }); -}); - -</script> - -# Vector mark <VersionBadge version="0.4.0" /> - -:::tip -See also the [arrow mark](./arrow.md), which draws arrows between two points. -::: - -The **vector mark** draws little arrows, typically positioned in **x** and **y** quantitative dimensions, with an optional magnitude (**length**) and direction (**rotate**), as in a vector field. For example, the chart below, based on a [LitVis example](https://github.com/gicentre/litvis/blob/main/examples/windVectors.md), shows wind speed and direction for a section of western Europe. - -:::plot defer https://observablehq.com/@observablehq/plot-wind-map -```js -Plot.plot({ - inset: 10, - aspectRatio: 1, - color: {label: "Speed (m/s)", zero: true, legend: true}, - marks: [ - Plot.vector(wind, { - x: "longitude", - y: "latitude", - rotate: ({u, v}) => Math.atan2(u, v) * 180 / Math.PI, - length: ({u, v}) => Math.hypot(u, v), - stroke: ({u, v}) => Math.hypot(u, v) - }) - ] -}) -``` -::: - -:::info -Regarding this data, [Remote Sensing Systems](https://www.remss.com/measurements/ccmp/) says: *“Standard U and V coordinates apply, meaning the positive U is to the right and positive V is above the axis. U and V are relative to true north. CCMP winds are expressed using the oceanographic convention, meaning a wind blowing toward the Northeast has a positive U component and a positive V component… Longitude is given in degrees East from 0.125 to 359.875 and latitude is given in degrees North with negative values representing southern locations.”* -::: - -Vectors can be used with Plot’s [projection system](../features/projections.md). The map below shows the margin by which the winner of the US presidential election of 2020 won the vote in each county. The arrow’s length encodes the difference in votes, and the orientation and color show who won (<svg width=12 height=12 viewBox="-11 -11 12 12" style="display: inline-block"><path d="M0,0l-10,-6m1,3.28l-1,-3.28l3.28,-1" stroke="var(--vp-c-blue)" stroke-width="1.5"></path></svg> for the Democratic candidate, and <svg width=12 height=12 viewBox="0 -11 12 12" style="display: inline-block"><path d="M0,0l10,-6m-1,3.28l1,-3.28l-3.28,-1" stroke="var(--vp-c-red)" stroke-width="1.5"></path></svg> for the Republican candidate). - -:::plot defer https://observablehq.com/@observablehq/plot-election-wind-map -```js -Plot.plot({ - projection: "albers-usa", - length: {type: "sqrt", transform: Math.abs}, - marks: [ - Plot.geo(statemesh, {strokeWidth: 0.5}), - Plot.geo(nation), - Plot.vector( - counties, - Plot.centroid({ - anchor: "start", - length: (d) => d.properties.margin2020 * d.properties.votes, - stroke: (d) => d.properties.margin2020 > 0 ? "red" : "blue", - rotate: (d) => d.properties.margin2020 > 0 ? 60 : -60 - }) - ) - ] -}) -``` -::: - -The **shape** option <VersionBadge version="0.6.2" /> controls the vector’s appearance, while the **anchor** option positions the vector relative to its anchor point specified in **x** and **y**. The [spike constructor](#spike) sets the **shape** to *spike* and the **anchor** to *start*. For example, this can be used to produce a [spike map](https://observablehq.com/@observablehq/plot-spike) of U.S. county population. - -:::plot defer https://observablehq.com/@observablehq/plot-spike-map-example -```js -Plot.plot({ - width: 688, - projection: "albers-usa", - length: {range: [0, 200]}, - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.5}), - Plot.geo(nation), - Plot.spike(counties, Plot.geoCentroid({length: (d) => d.properties.population, stroke: "green"})) - ] -}) -``` -::: - -You can even implement a custom **shape** by supplying an object with a **draw** method. This method takes a *context* for drawing paths and the *length* of the vector. See the [moon phase calendar](https://observablehq.com/@observablehq/plot-phases-of-the-moon) for an example. - -Lastly, here is an example showing a Perlin noise field, just because it’s pretty: - -:::plot defer https://observablehq.com/@observablehq/plot-perlin-noise -```js -Plot.plot({ - inset: 6, - width: 1024, - aspectRatio: 1, - axis: null, - marks: [ - Plot.vector(poisson([0, 0, 2, 2], 4000), { - length: ([x, y]) => (noise(x + 2, y) + 0.5) * 24, - rotate: ([x, y]) => noise(x, y) * 360 - }) - ] -}) -``` -::: - -This example uses a noise(*x*, *y*) function defined as: - -```js -noise = octave(perlin2, 2) -``` - -See Mike Bostock’s [Perlin Noise](https://observablehq.com/@mbostock/perlin-noise) and [Poisson Disk Sampling](https://observablehq.com/@mbostock/poisson-disk-sampling) notebooks for source code. - -## Vector options - -In addition to the [standard mark options](../features/marks.md#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 -* **length** - the length in pixels; bound to the *length* scale; defaults to 12 -* **rotate** - the rotation angle in degrees clockwise; defaults to 0 - -If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option. - -The following constant options are also supported: - -* **shape** - the shape of the vector; defaults to *arrow* -* **r** - a radius in pixels; defaults to 3.5 -* **anchor** - one of *start*, *middle*, or *end*; defaults to *middle* -* **frameAnchor** - how to position the vector within the frame; defaults to *middle* - -The **shape** option controls the visual appearance (path geometry) of the vector and supports the following values: - -* *arrow* (default) - an arrow with head size proportional to its length -* *spike* - an isosceles triangle with open base -* any object with a **draw** method; it receives a *context*, *length*, and *radius* - -If the **anchor** is *start*, the arrow will start at the given *xy* position and point in the direction given by the rotation angle. If the **anchor** is *end*, the arrow will maintain the same orientation, but be positioned such that it ends in the given *xy* position. If the **anchor** is *middle*, the arrow will be likewise be positioned such that its midpoint intersects the given *xy* position. - -If the **x** channel is not specified, vectors will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, vectors will be vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified. - -The **rotate** and **length** options can be specified as either channels or constants. When specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. The length defaults to 12 pixels, and the rotate defaults to 0 degrees (pointing up↑). Vectors with a negative length will be drawn inverted. Positive angles proceed clockwise from noon. - -The **stroke** defaults to *currentColor*. The **strokeWidth** defaults to 1.5, and the **strokeLinecap** defaults to *round*. - -Vectors are drawn in input order, with the last data drawn on top. If sorting is needed, say to mitigate overplotting by drawing the smallest vectors on top, consider a [sort transform](../transforms/sort.md). - -## vector(*data*, *options*) {#vector} - -```js -Plot.vector(wind, {x: "longitude", y: "latitude", length: "speed", rotate: "direction"}) -``` - -Returns a new vector with the given *data* and *options*. If neither the **x** nor **y** 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₂*, …]. - -## vectorX(*data*, *options*) {#vectorX} - -```js -Plot.vectorX(cars.map((d) => d["economy (mpg)"])) -``` - -Equivalent to [vector](#vector) except that if the **x** option is not specified, it defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. - -## vectorY(*data*, *options*) {#vectorY} - -```js -Plot.vectorY(cars.map((d) => d["economy (mpg)"])) -``` - -Equivalent to [vector](#vector) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. - -## spike(*data*, *options*) <VersionBadge version="0.6.2" /> {#spike} - -```js -Plot.spike(counties, Plot.geoCentroid({length: (d) => d.properties.population})) -``` - -Equivalent to [vector](#vector) except that the **shape** defaults to *spike*, the **stroke** defaults to *currentColor*, the **strokeWidth** defaults to 1, the **fill** defaults to **stroke**, the **fillOpacity** defaults to 0.3, and the **anchor** defaults to *start*. diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md deleted file mode 100644 index 27763977..00000000 --- a/docs/marks/waffle.md +++ /dev/null @@ -1,179 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; - -const apples = ref(512); -const unit = ref(10); - -const olympians = shallowRef([ - {weight: 31, height: 1.21, sex: "female"}, - {weight: 170, height: 2.21, sex: "male"} -]); - -const survey = [ - {question: "don’t go out after dark", yes: 96}, - {question: "do no activities other than school", yes: 89}, - {question: "engage in political discussion and social movements, including online", yes: 10}, - {question: "would like to do activities but are prevented by safety concerns", yes: 73} -]; - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -</script> - -# Waffle mark <VersionBadge version="0.6.16" pr="2040" /> - -The **waffle mark** is similar to the [bar mark](./bar.md) in that it displays a quantity (or quantitative extent) for a given category; but unlike a bar, a waffle is subdivided into square cells that allow easier counting. Waffles are useful for reading exact quantities. How quickly can you count the pears 🍐 below? How many more apples 🍎 are there than bananas 🍌? - -:::plot https://observablehq.com/@observablehq/plot-simple-waffle -```js -Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]}).plot({height: 420}) -``` -::: - -The waffle mark is often used with the [group transform](../transforms/group.md) to compute counts. The chart below compares the number of female and male athletes in the 2012 Olympics. - -:::plot https://observablehq.com/@observablehq/plot-waffle-group -```js -Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sex"})).plot({x: {label: null}}) -``` -::: - -:::info -Waffles are rendered using SVG patterns, making them more performant than alternatives such as the [dot mark](./dot.md) for rendering many points. -::: - -The **unit** option determines the quantity each waffle cell represents; it defaults to one. The unit may be set to a value greater than one for large quantities, or less than one (but greater than zero) for small fractional quantities. Try changing the unit below to see its effect. - -<p> - <span class="label-input"> - Unit: - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="1" v-model="unit" /> 1</label> - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="2" v-model="unit" /> 2</label> - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="5" v-model="unit" /> 5</label> - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="10" v-model="unit" /> 10</label> - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="25" v-model="unit" /> 25</label> - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="50" v-model="unit" /> 50</label> - <label style="margin-left: 0.5em;"><input type="radio" name="unit" value="100" v-model="unit" /> 100</label> - </span> -</p> - -:::plot https://observablehq.com/@observablehq/plot-waffle-unit -```js -Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fx: "date_of_birth", unit})).plot({fx: {interval: "5 years", label: null}}) -``` -::: - -:::tip -Use [faceting](../features/facets.md) as an alternative to supplying an ordinal channel (_i.e._, *fx* instead of *x* for a vertical waffleY). The facet scale’s **interval** option then allows grouping by a quantitative or temporal variable, such as the athlete’s year of birth in the chart below. -::: - -While waffles typically represent integer quantities, say to count people or days, they can also encode fractional values with a partial first or last cell. Set the **round** option to true to disable partial cells, or to Math.ceil or Math.floor to round up or down. - -Like bars, waffles can be [stacked](../transforms/stack.md), and implicitly apply the stack transform when only a single quantitative channel is supplied. - -:::plot https://observablehq.com/@observablehq/plot-stacked-waffles -```js -Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx: "weight", unit: 10})).plot({fx: {interval: 10}, color: {legend: true}}) -``` -::: - -Waffles can also be used to highlight a proportion of the whole. The chart below recreates a graphic of survey responses from [“Teens in Syria”](https://www.economist.com/graphic-detail/2015/08/19/teens-in-syria) by _The Economist_ (August 19, 2015); positive responses are in orange, while negative responses are in gray. The **rx** option is used to produce circles instead of squares. - -:::plot https://observablehq.com/@observablehq/plot-survey-waffle -```js -Plot.plot({ - axis: null, - label: null, - height: 260, - marginTop: 20, - marginBottom: 70, - title: "Subdued", - subtitle: "Of 120 surveyed Syrian teenagers:", - marks: [ - Plot.axisFx({lineWidth: 10, anchor: "bottom", dy: 20}), - Plot.waffleY({length: 1}, {y: 120, fillOpacity: 0.4, rx: "100%"}), - Plot.waffleY(survey, {fx: "question", y: "yes", rx: "100%", fill: "orange"}), - Plot.text(survey, {fx: "question", text: (d) => (d.yes / 120).toLocaleString("en-US", {style: "percent"}), frameAnchor: "bottom", lineAnchor: "top", dy: 6, fill: "orange", fontSize: 24, fontWeight: "bold"}) - ] -}) -``` -::: - -The waffle mark comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. The waffle mark automatically determines the appropriate number of cells per row or per column (depending on orientation) such that the cells are square, don’t overlap, and are consistent with position scales. - -<p> - <label class="label-input"> - <span>Apples:</span> - <input type="range" v-model.number="apples" min="10" max="1028" step="1" /> - <span style="font-variant-numeric: tabular-nums;">{{apples}}</span> - </label> -</p> - -:::plot -```js -Plot.waffleX([apples], {y: ["apples"]}).plot({height: 240}) -``` -::: - -:::info -The number of rows in the waffle above is guaranteed to be an integer, but it might not be a multiple or factor of the *x*-axis tick interval. For example, the waffle might have 15 rows while the *x*-axis shows ticks every 100 units. -::: -:::tip -To set the number of rows (or columns) directly, use the **multiple** option, though note that manually setting the multiple may result in non-square cells if there isn’t enough room. Alternatively, you can bias the automatic multiple while preserving square cells by setting the **padding** option on the corresponding band scale: padding defaults to 0.1; a higher value may produce more rows, while a lower (or zero) value may produce fewer rows. -::: - -## Waffle options - -For required channels, see the [bar mark](./bar.md). The waffle mark supports the [standard mark options](../features/marks.md), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners). The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. - -The waffle mark supports a few additional options to control the rendering of cells: - -* **unit** - the quantity each cell represents; defaults to 1 -* **multiple** - the number of cells per row (or column); defaults to undefined -* **gap** - the separation between adjacent cells, in pixels; defaults to 1 -* **round** - whether to round values to avoid partial cells; defaults to false - -If **multiple** is undefined (the default), the waffle mark will use as many cells per row (or column) that fits within the available bandwidth while ensuring that the cells are square, or one cell per row if square cells are not possible. You can change the rounding behavior by specifying **round** as a function, such as Math.floor or Math.ceil; true is equivalent to Math.round. - -## waffleX(*data*, *options*) {#waffleX} - -```js -Plot.waffleX(olympians, Plot.groupY({x: "count"}, {y: "sport"})) -``` - -Returns a new horizontal→ waffle with the given *data* and *options*. The following channels are required: - -* **x1** - the starting horizontal position; bound to the *x* scale -* **x2** - the ending horizontal position; bound to the *x* scale - -The following optional channels are supported: - -* **y** - the vertical position; bound to the *y* scale, which must be *band* - -If neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for a horizontal waffle chart with columns aligned at *x* = 0. If the **x** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **x2** as identity and **y** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to waffleX to make a quick sequential waffle chart. If the **y** channel is not specified, the column will span the full vertical extent of the plot (or facet). - -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). - -## waffleY(*data*, *options*) {#waffleY} - -```js -Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sport"})) -``` - -Returns a new vertical↑ waffle with the given *data* and *options*. The following channels are required: - -* **y1** - the starting vertical position; bound to the *y* scale -* **y2** - the ending vertical position; bound to the *y* scale - -The following optional channels are supported: - -* **x** - the horizontal position; bound to the *x* scale, which must be *band* - -If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for a vertical waffle chart with columns aligned at *y* = 0. If the **y** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **y2** as identity and **x** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to waffleY to make a quick sequential waffle chart. If the **x** channel is not specified, the column will span the full horizontal extent of the plot (or facet). - -If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). diff --git a/docs/react.md b/docs/react.md deleted file mode 100644 index 433a0e35..00000000 --- a/docs/react.md +++ /dev/null @@ -1,508 +0,0 @@ -# React examples - -Replot provides a native React component API. Instead of the imperative `Plot.plot()` function, you compose charts declaratively with JSX using the `<Plot>` root component and mark components like `<Dot>`, `<Line>`, `<BarY>`, and others. - -All components are imported from `@dave-hillier/replot/react`. Transforms are components too — `<BinX>`, `<GroupX>`, `<StackY>`, and friends wrap marks to transform them; the underlying transform functions (`binX`, `groupX`, `stackY`, ...) are also re-exported from the same entry point. - -```jsx -import {Plot, Dot, Line, BarY, BinX, StackY} from "@dave-hillier/replot/react"; -``` - -## Scatterplot - -A basic scatterplot with the `<Dot>` mark. Map data fields to the `x`, `y`, and `fill` channels. - -:::code-group -```jsx [Imperative] -Plot.plot({ - width: 640, - height: 400, - color: {scheme: "category10"}, - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species"}) - ] -}) -``` - -```jsx [React] -<Plot width={640} height={400} color={{scheme: "category10"}}> - <Dot data={penguins} x="culmen_length_mm" y="culmen_depth_mm" fill="species" /> -</Plot> -``` -::: - -## Line chart - -A line chart using the `<Line>` mark. When multiple series are present, use the `stroke` channel to distinguish them. - -:::code-group -```jsx [Imperative] -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.line(data, {x: "date", y: "temperature", stroke: "city"}) - ] -}) -``` - -```jsx [React] -<Plot y={{grid: true}}> - <Line data={data} x="date" y="temperature" stroke="city" /> -</Plot> -``` -::: - -## Area chart - -A filled area chart with the `<AreaY>` mark. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.areaY(data, {x: "date", y: "value", fill: "steelblue", fillOpacity: 0.5}), - Plot.line(data, {x: "date", y: "value", stroke: "steelblue"}) - ] -}) -``` - -```jsx [React] -<Plot> - <AreaY data={data} x="date" y="value" fill="steelblue" fillOpacity={0.5} /> - <Line data={data} x="date" y="value" stroke="steelblue" /> -</Plot> -``` -::: - -## Bar chart - -A bar chart using the `<BarY>` mark with an ordinal x scale. - -:::code-group -```jsx [Imperative] -Plot.plot({ - x: {label: "Category"}, - y: {label: "Count"}, - marks: [ - Plot.barY(data, {x: "category", y: "count", fill: "steelblue"}) - ] -}) -``` - -```jsx [React] -<Plot x={{label: "Category"}} y={{label: "Count"}}> - <BarY data={data} x="category" y="count" fill="steelblue" /> -</Plot> -``` -::: - -## Histogram - -A histogram using the `<BinX>` transform component with `<BarY>`. Nest the mark inside the wrapper; output reducers such as `y="count"` go on the wrapper, input channels stay on the mark. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.barY(data, Plot.binX({y: "count"}, {x: "value", fill: "steelblue"})), - Plot.ruleY([0]) - ] -}) -``` - -```jsx [React] -<Plot> - <BinX y="count"> - <BarY data={data} x="value" fill="steelblue" /> - </BinX> - <RuleY data={[0]} /> -</Plot> -``` -::: - -## Grouped bar chart - -Use the `<GroupX>` transform component to aggregate data by category. - -:::code-group -```jsx [Imperative] -Plot.plot({ - x: {label: "Category"}, - y: {label: "Count"}, - color: {legend: true}, - marks: [ - Plot.barY(data, Plot.groupX({y: "count"}, {x: "category", fill: "group"})) - ] -}) -``` - -```jsx [React] -<Plot x={{label: "Category"}} y={{label: "Count"}} color={{legend: true}}> - <GroupX y="count"> - <BarY data={data} x="category" fill="group" /> - </GroupX> -</Plot> -``` -::: - -## Stacked area chart - -Use the `<StackY>` transform component with `<AreaY>` to create stacked areas. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.areaY(data, Plot.stackY({x: "date", y: "value", fill: "category"})) - ] -}) -``` - -```jsx [React] -<Plot> - <StackY> - <AreaY data={data} x="date" y="value" fill="category" /> - </StackY> -</Plot> -``` -::: - -## Dot plot with rule - -Combine `<Dot>` marks with `<RuleY>` for reference lines. - -:::code-group -```jsx [Imperative] -Plot.plot({ - y: {grid: true}, - color: {scheme: "burd"}, - marks: [ - Plot.ruleY([0]), - Plot.dot(data, {x: "date", y: "anomaly", stroke: "anomaly"}) - ] -}) -``` - -```jsx [React] -<Plot y={{grid: true}} color={{scheme: "burd"}}> - <RuleY data={[0]} /> - <Dot data={data} x="date" y="anomaly" stroke="anomaly" /> -</Plot> -``` -::: - -## Faceted chart (small multiples) - -Use the `fx` or `fy` props on marks to split the chart into facets. - -:::code-group -```jsx [Imperative] -Plot.plot({ - facet: {marginRight: 60}, - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species"}) - ] -}) -``` - -```jsx [React] -<Plot facet={{marginRight: 60}}> - <Dot data={penguins} x="culmen_length_mm" y="culmen_depth_mm" fx="species" /> -</Plot> -``` -::: - -## Tooltips - -Add the `tip` prop to any mark to enable tooltips on hover. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", tip: true}) - ] -}) -``` - -```jsx [React] -<Plot> - <Dot data={penguins} x="culmen_length_mm" y="culmen_depth_mm" fill="species" tip /> -</Plot> -``` -::: - -## Crosshair - -Use the `<Crosshair>` component for guided hover interactions. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.line(data, {x: "date", y: "value"}), - Plot.crosshair(data, {x: "date", y: "value"}) - ] -}) -``` - -```jsx [React] -<Plot> - <Line data={data} x="date" y="value" /> - <Crosshair data={data} x="date" y="value" /> -</Plot> -``` -::: - -## Rule marks - -Use `<RuleX>` and `<RuleY>` to draw reference lines. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.dot(data, {x: "x", y: "y"}), - Plot.ruleX([0]), - Plot.ruleY([0]) - ] -}) -``` - -```jsx [React] -<Plot> - <Dot data={data} x="x" y="y" /> - <RuleX data={[0]} /> - <RuleY data={[0]} /> -</Plot> -``` -::: - -## Text labels - -Use the `<Text>` mark to add labels to data points. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.dot(data, {x: "x", y: "y"}), - Plot.text(data, {x: "x", y: "y", text: "label", dy: -8}) - ] -}) -``` - -```jsx [React] -<Plot> - <Dot data={data} x="x" y="y" /> - <Text data={data} x="x" y="y" text="label" dy={-8} /> -</Plot> -``` -::: - -## Cell heatmap - -Use the `<Cell>` mark for heatmaps. - -:::code-group -```jsx [Imperative] -Plot.plot({ - color: {scheme: "blues"}, - marks: [ - Plot.cell(data, {x: "column", y: "row", fill: "value"}) - ] -}) -``` - -```jsx [React] -<Plot color={{scheme: "blues"}}> - <Cell data={data} x="column" y="row" fill="value" /> -</Plot> -``` -::: - -## Rect chart - -Use the `<Rect>` mark for two-dimensional binning or rectangles. - -:::code-group -```jsx [Imperative] -Plot.plot({ - color: {scheme: "ylgnbu"}, - marks: [ - Plot.rect(data, Plot.bin({fill: "count"}, {x: "x", y: "y"})) - ] -}) -``` - -```jsx [React] -<Plot color={{scheme: "ylgnbu"}}> - <Bin fill="count"> - <Rect data={data} x="x" y="y" /> - </Bin> -</Plot> -``` -::: - -## Tick marks - -Use `<TickX>` and `<TickY>` for one-dimensional data. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.tickX(data, {x: "value", y: "category"}) - ] -}) -``` - -```jsx [React] -<Plot> - <TickX data={data} x="value" y="category" /> -</Plot> -``` -::: - -## Box plot - -Use `<BoxY>` for box-and-whisker plots. - -:::code-group -```jsx [Imperative] -Plot.plot({ - marks: [ - Plot.boxY(data, {x: "category", y: "value"}) - ] -}) -``` - -```jsx [React] -<Plot> - <BoxY data={data} x="category" y="value" /> -</Plot> -``` -::: - -## Figure wrapping - -Use `title`, `subtitle`, and `caption` props to wrap the chart in a `<figure>`. - -:::code-group -```jsx [Imperative] -Plot.plot({ - title: "Penguin measurements", - subtitle: "Palmer Archipelago, Antarctica", - caption: "Source: palmerpenguins", - marks: [ - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) - ] -}) -``` - -```jsx [React] -<Plot - title="Penguin measurements" - subtitle="Palmer Archipelago, Antarctica" - caption="Source: palmerpenguins" -> - <Dot data={penguins} x="culmen_length_mm" y="culmen_depth_mm" /> -</Plot> -``` -::: - -## Multiple marks - -Compose multiple marks in a single chart by nesting them inside `<Plot>`. - -:::code-group -```jsx [Imperative] -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.areaY(data, {x: "date", y: "value", fill: "#e0e0e0"}), - Plot.line(data, {x: "date", y: "value", stroke: "steelblue"}), - Plot.dot(data, {x: "date", y: "value", fill: "steelblue"}), - Plot.ruleY([0]) - ] -}) -``` - -```jsx [React] -<Plot y={{grid: true}}> - <AreaY data={data} x="date" y="value" fill="#e0e0e0" /> - <Line data={data} x="date" y="value" stroke="steelblue" /> - <Dot data={data} x="date" y="value" fill="steelblue" /> - <RuleY data={[0]} /> -</Plot> -``` -::: - -## Using with React state - -React components work naturally with React state and hooks: - -```jsx -import {Plot, Dot} from "@dave-hillier/replot/react"; -import {useState} from "react"; - -function FilterableChart({data}) { - const [species, setSpecies] = useState("all"); - const filtered = species === "all" ? data : data.filter((d) => d.species === species); - - return ( - <div> - <select value={species} onChange={(e) => setSpecies(e.target.value)}> - <option value="all">All species</option> - <option value="Adelie">Adelie</option> - <option value="Chinstrap">Chinstrap</option> - <option value="Gentoo">Gentoo</option> - </select> - <Plot width={640} height={400}> - <Dot data={filtered} x="culmen_length_mm" y="culmen_depth_mm" fill="species" /> - </Plot> - </div> - ); -} -``` - -## Component reference - -All mark components accept the same options as their imperative counterparts, passed as JSX props. The main differences: - -| Imperative | React | Notes | -|---|---|---| -| `Plot.plot({marks: [...]})` | `<Plot>...</Plot>` | Marks are children, scale options are props | -| `Plot.dot(data, {x, y})` | `<Dot data={data} x="x" y="y" />` | All options become props | -| `Plot.binX({y: "count"}, opts)` | `<BinX y="count">...</BinX>` | Transforms nest as wrapper components | -| `Plot.line(data, {...})` | `<Line data={data} ... />` | Same pattern for all marks | - -### Available mark components - -**Basic marks:** `Dot`, `DotX`, `DotY`, `Circle`, `Hexagon`, `Line`, `LineX`, `LineY`, `Area`, `AreaX`, `AreaY`, `BarX`, `BarY`, `Rect`, `RectX`, `RectY`, `Cell`, `CellX`, `CellY`, `RuleX`, `RuleY`, `Text`, `TextX`, `TextY`, `TickX`, `TickY`, `Frame` - -**Geometric marks:** `Geo`, `Sphere`, `Graticule`, `DelaunayLink`, `DelaunayMesh`, `Hull`, `Voronoi`, `VoronoiMesh`, `Density`, `Contour`, `Raster`, `Hexgrid` - -**Composite marks:** `BoxX`, `BoxY`, `TreeMark`, `ClusterMark`, `BollingerX`, `BollingerY`, `DifferenceX`, `DifferenceY`, `LinearRegressionX`, `LinearRegressionY`, `WaffleX`, `WaffleY` - -**Axes and grid:** `AxisX`, `AxisY`, `GridX`, `GridY`, `AxisFx`, `AxisFy`, `GridFx`, `GridFy` - -**Interactions:** `Tip`, `Crosshair`, `CrosshairX`, `CrosshairY` - -**Legends:** `Legend` - -### Transform components - -Transforms nest as wrapper components around marks, so element structure mirrors the functional call structure: `<BinX y="count"><StackY><RectY ... /></StackY></BinX>` is equivalent to `binX({y: "count"}, stackY(options))`. The inner wrapper applies first, and each mark inside a wrapper gets the transform applied independently. - -**Stack:** `StackX`, `StackX1`, `StackX2`, `StackY`, `StackY1`, `StackY2` - -**Bin and group:** `BinX`, `Group`, `GroupX`, `GroupY`, `GroupZ` - -**Series:** `WindowX`, `WindowY`, `NormalizeX`, `NormalizeY`, `MapX`, `MapY`, `ShiftX`, `ShiftY` - -**Select:** `SelectFirst`, `SelectLast`, `SelectMinX`, `SelectMinY`, `SelectMaxX`, `SelectMaxY` - -**Initializers:** `DodgeX`, `DodgeY`, `Hexbin`, `Centroid`, `GeoCentroid` - -The functional spread form remains supported: `<BarY data={data} {...binX({y: "count"}, {x: "value"})} />` produces the same output as the nested form. diff --git a/docs/transforms/bin.md b/docs/transforms/bin.md deleted file mode 100644 index 2e2e3e96..00000000 --- a/docs/transforms/bin.md +++ /dev/null @@ -1,372 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {computed, ref, shallowRef, onMounted} from "vue"; - -const cumulatives = ref("+1"); -const cumulative = computed(() => +cumulatives.value); -const olympians = shallowRef([{weight: 31, height: 1.21, sex: "female"}, {weight: 170, height: 2.21, sex: "male"}]); - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -</script> - -# Bin transform - -:::tip -The bin transform is for aggregating quantitative or temporal data. For ordinal or nominal data, use the [group transform](./group.md). See also the [hexbin transform](./hexbin.md). -::: - -The **bin transform** groups quantitative or temporal data — continuous measurements such as heights, weights, or temperatures — into discrete bins. You can then compute summary statistics for each bin, such as a count, sum, or proportion. The bin transform is most often used to make histograms or heatmaps with the [rect mark](../marks/rect.md). - -For example, here is a histogram showing the distribution of weights of Olympic athletes. - -:::plot defer https://observablehq.com/@observablehq/plot-a-simple-histogram -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -The binX transform takes **x** as input and outputs **x1** and **x2** representing the extent of each bin in *x*. The *outputs* argument (here `{y: "count"}`) declares additional output channels (**y**) and the associated reducer (*count*). Hence the height of each rect above represents the number of athletes in the corresponding bin, *i.e.*, the number of athletes with a similar weight. - -While the binX transform is often used to generate **y**, it can output any channel. Below, the **fill** channel represents count per bin, resulting in a one-dimensional heatmap. - -:::plot defer https://observablehq.com/@observablehq/plot-color-bins -```js-vue -Plot - .rect(olympians, Plot.binX({fill: "count"}, {x: "weight"})) - .plot({color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}}) -``` -::: - -You can partition bins using **z**. If **z** is undefined, it defaults to **fill** or **stroke**, if any. In conjunction with the rectY mark’s implicit [stackY transform](./stack.md), this will produce a stacked histogram. - -:::plot defer https://observablehq.com/@observablehq/plot-vertical-histogram -```js -Plot.plot({ - y: {grid: true}, - color: {legend: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -You can invoke the stack transform explicitly as `Plot.stackY(Plot.binX({y: "count"}, {x: "weight", fill: "sex"}))` to produce an identical chart. -::: - -You can opt-out of the implicit stackY transform by having binX generate **y1** or **y2** instead of **y** (and similarly **x1** or **x2** for stackX and binY). When overlapping marks, use either opacity or blending to make the overlap visible. - -:::plot defer https://observablehq.com/@observablehq/plot-overlapping-histogram -```js-vue -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y2: "count"}, {x: "weight", fill: "sex", mixBlendMode: "{{$dark ? "screen" : "multiply"}}"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::warning CAUTION -While the **mixBlendMode** option is useful for mitigating occlusion, it can be slow to render if there are many elements. More than two overlapping histograms may also be hard to read. -::: - -The bin transform comes in three orientations: - -- [binX](#binX) bins on **x**, and often outputs **y** as in a histogram with vertical↑ rects; -- [binY](#binY) bins on **y**, and often outputs **x** as in a histogram with horizontal→ rects; and -- [bin](#bin) bins on both **x** and **y**, and often outputs to **fill** or **r** as in a heatmap. - -As you might guess, the binY transform with the rectX mark produces a histogram with horizontal→ rects. - -:::plot defer https://observablehq.com/@observablehq/plot-horizontal-histogram -```js -Plot.plot({ - x: {grid: true}, - marks: [ - Plot.rectX(olympians, Plot.binY({x: "count"}, {y: "weight"})), - Plot.ruleX([0]) - ] -}) -``` -::: - -You can produce a two-dimensional heatmap with bin transform and a rect mark by generating a **fill** output channel. Below, color encodes the number of athletes in each bin (of similar height and weight). - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-heatmap -```js-vue -Plot - .rect(olympians, Plot.bin({fill: "count"}, {x: "weight", y: "height"})) - .plot({color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}}) -``` -::: - -The bin transform also outputs **x** and **y** channels representing bin centers. These can be used to place a [dot mark](../marks/dot.md) whose size again represents the number of athletes in each bin. - -:::plot defer https://observablehq.com/@observablehq/plot-dot-heatmap -```js -Plot.plot({ - r: {range: [0, 6]}, // generate slightly smaller dots - marks: [ - Plot.dot(olympians, Plot.bin({r: "count"}, {x: "weight", y: "height"})) - ] -}) -``` -::: - -We can add the **stroke** channel to show overlapping distributions by sex. - -:::plot https://observablehq.com/@observablehq/plot-dot-heatmap -```js -Plot.plot({ - r: {range: [0, 6]}, - marks: [ - Plot.dot(olympians, Plot.bin({r: "count"}, {x: "weight", y: "height", stroke: "sex"})) - ] -}) -``` -::: - -Similarly the binX and binY transforms generate respective **x** and **y** channels for one-dimensional binning. - -:::plot https://observablehq.com/@observablehq/plot-dot-bins -```js -Plot.plot({ - r: {range: [0, 14]}, - marks: [ - Plot.dot(olympians, Plot.binX({r: "count"}, {x: "weight"})) - ] -}) -``` -::: - -In addition to rect and dot, you can even use continuous marks such as [area](../marks/area.md) and [line](../marks/line.md). In this case you should set the bin transform’s **filter** option to null so that empty bins are included in the output; otherwise, the area or line would mislead by interpolating over missing bins. - -:::plot defer https://observablehq.com/@observablehq/plot-density-estimation -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.areaY(olympians, Plot.binX({y: "count", filter: null}, {x: "weight", fillOpacity: 0.2})), - Plot.lineY(olympians, Plot.binX({y: "count", filter: null}, {x: "weight"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -The **cumulative** option produces a cumulative distribution. Below, each bin represents the number of athletes with the given weight *or less*. To have each bin represent the number of athletes with the given weight *or more*, set **cumulative** to −1. - -<p> - <span class="label-input"> - Cumulative: - <label style="margin-left: 0.5em; font-variant: tabular-nums;"><input type="radio" name="cumulative" value="-1" v-model="cumulatives" /> -1 (reverse)</label> - <label style="margin-left: 0.5em; font-variant: tabular-nums;"><input type="radio" name="cumulative" value="+1" v-model="cumulatives" /> +1 (true)</label> - </span> -</p> - -:::plot -```js -Plot.plot({ - marginLeft: 60, - y: {grid: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", cumulative})), - Plot.ruleY([0]) - ] -}) -``` -::: - -The bin transform works with Plot’s [faceting system](../features/facets.md), partitioning bins by facet. Below, we compare the weight distributions of athletes within each sport using the *proportion-facet* reducer. Sports are sorted by median weight: gymnasts tend to be the lightest, and basketball players the heaviest. - -:::plot defer -```js-vue -Plot.plot({ - marginLeft: 100, - padding: 0, - x: {grid: true}, - fy: {domain: d3.groupSort(olympians.filter((d) => d.weight), (g) => d3.median(g, (d) => d.weight), (d) => d.sport)}, - color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [Plot.rect(olympians, Plot.binX({fill: "proportion-facet"}, {x: "weight", fy: "sport", inset: 0.5}))] -}) -``` -::: - -The bin transform sets default insets for a one-pixel gap between rects. You can set explicit insets if you prefer, say if you want the rects to touch. In this case we recommend rounding on the _x_ scale to avoid antialiasing artifacts between rects. - -:::plot defer -```js -Plot.plot({ - x: {round: true}, - y: {grid: true}, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", inset: 0})), - Plot.ruleY([0]) - ] -}) -``` -::: - -## Bin options - -Given input *data* = [*d₀*, *d₁*, *d₂*, …], by default the resulting binned data is an array of arrays where each inner array is a subset of the input data [[*d₁*, *d₂*, …], [*d₀*, …], …]. Each inner array is in input order. The outer array is in ascending order according to the associated dimension (*x* then *y*). - -By specifying a reducer for the **data** output, as described below, you can change how the binned data is computed. The outputs may also include **filter** and **sort** options specified as reducers, and a **reverse** option to reverse the order of generated bins. By default, empty bins are omitted, and non-empty bins are generated in ascending threshold order. - -In addition to data, the following channels are automatically output: - -* **x1** - the starting horizontal position of the bin -* **x2** - the ending horizontal position of the bin -* **x** - the horizontal center of the bin -* **y1** - the starting vertical position of the bin -* **y2** - the ending vertical position of the bin -* **y** - the vertical center of the bin -* **z** - the first value of the *z* channel, if any -* **fill** - the first value of the *fill* channel, if any -* **stroke** - the first value of the *stroke* channel, if any - -The **x1**, **x2**, and **x** output channels are only computed by the binX and bin transform; similarly the **y1**, **y2**, and **y** output channels are only computed by the binY and bin transform. The **x** and **y** output channels are lazy: they are only computed if needed by a downstream mark or transform. Conversely, the **x1** and **x2** outputs default to undefined if **x** is explicitly defined; and the **y1** and **y2** outputs default to undefined if **y** is explicitly defined. - -You can declare additional output channels by specifying the channel name and desired reducer in the *outputs* object which is the first argument to the transform. For example, to use binX to generate a **y** channel of bin counts as in a frequency histogram: - -```js -Plot.binX({y: "count"}, {x: "culmen_length_mm"}) -``` - -The following named reducers are supported: - -* *first* - the first value, in input order -* *last* - the last value, in input order -* *count* - the number of elements (frequency) -* *distinct* - the number of distinct values -* *sum* - the sum of values -* *proportion* - the sum proportional to the overall total (weighted frequency) -* *proportion-facet* - the sum proportional to the facet total -* *min* - the minimum value -* *min-index* - the zero-based index of the minimum value -* *max* - the maximum value -* *max-index* - the zero-based index of the maximum value -* *mean* - the mean value (average) -* *median* - the median value -* *mode* - the value with the most occurrences -* *pXX* - the percentile value, where XX is a number in [00,99] -* *deviation* - the standard deviation -* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) -* *identity* - the array of values -* *x* - the middle of the bin’s *x* extent (when binning on *x*) -* *x1* - the lower bound of the bin’s *x* extent (when binning on *x*) -* *x2* - the upper bound of the bin’s *x* extent (when binning on *x*) -* *y* - the middle of the bin’s *y* extent (when binning on *y*) -* *y1* - the lower bound of the bin’s *y* extent (when binning on *y*) -* *y2* - the upper bound of the bin’s *y* extent (when binning on *y*) -* *z* <VersionBadge version="0.6.14" pr="1959" /> - the bin’s *z* value (*z*, *fill*, or *stroke*) - -In addition, a reducer may be specified as: - -* a function to be passed the array of values for each bin and the extent of the bin -* an object with a **reduceIndex** method, and optionally a **scope** - -In the last case, the **reduceIndex** method is repeatedly passed three arguments: the index for each bin (an array of integers), the input channel’s array of values, and the extent of the bin (an object {data, x1, x2, y1, y2}); it must then return the corresponding aggregate value for the bin. - -If the reducer object’s **scope** is *data*, then the **reduceIndex** method is first invoked for the full data; the return value of the **reduceIndex** method is then made available as a third argument (making the extent the fourth argument). Similarly if the **scope** is *facet*, then the **reduceIndex** method is invoked for each facet, and the resulting reduce value is made available while reducing the facet’s bins. (This optional **scope** is used by the *proportion* and *proportion-facet* reducers.) - -Most reducers require binding the output channel to an input channel; for example, if you want the **y** output channel to be a *sum* (not merely a count), there should be a corresponding **y** input channel specifying which values to sum. If there is not, *sum* will be equivalent to *count*. - -```js -Plot.binX({y: "sum"}, {x: "culmen_length_mm", y: "body_mass_g"}) -``` - -You can control whether a channel is computed before or after binning. If a channel is declared only in *options* (and it is not a special group-eligible channel such as **x**, **y**, **z**, **fill**, or **stroke**), it will be computed after binning and be passed the binned data: each datum is the array of input data corresponding to the current bin. - -```js -Plot.binX({y: "count"}, {x: "economy (mpg)", title: (data) => data.map((d) => d.name).join("\n")}) -``` - -This is equivalent to declaring the channel only in *outputs*. - -```js -Plot.binX({y: "count", title: (data) => data.map((d) => d.name).join("\n")}, {x: "economy (mpg)"}) -``` - -However, if a channel is declared in both *outputs* and *options*, then the channel in *options* is computed before binning and can then be aggregated using any built-in reducer (or a custom reducer function) during the bin transform. - -```js -Plot.binX({y: "count", title: (names) => names.join("\n")}, {x: "economy (mpg)", title: "name"}) -``` - -To control how the quantitative dimensions *x* and *y* are divided into bins, the following options are supported: - -* **thresholds** - the threshold values; see below -* **interval** - an alternative method of specifying thresholds -* **domain** - values outside the domain will be omitted -* **cumulative** - if positive, each bin will contain all lesser bins - -These options may be specified either on the *options* or *outputs* object. If the **domain** option is not specified, it defaults to the minimum and maximum of the corresponding dimension (*x* or *y*), possibly niced to match the threshold interval to ensure that the first and last bin have the same width as other bins. If **cumulative** is negative (-1 by convention), each bin will contain all *greater* bins rather than all *lesser* bins, representing the [complementary cumulative distribution](https://en.wikipedia.org/wiki/Cumulative_distribution_function#Complementary_cumulative_distribution_function_.28tail_distribution.29). - -To pass separate binning options for **x** and **y**, use an object with the options above and a **value** option to specify the input channel values. - -```js -Plot.binX({y: "count"}, {x: {thresholds: 20, value: "culmen_length_mm"}}) -``` - -The **thresholds** option may be specified as a named method or a variety of other ways: - -* *auto* (default) - Scott’s rule, capped at 200 -* *freedman-diaconis* - the [Freedman–Diaconis rule](https://en.wikipedia.org/wiki/Freedman–Diaconis_rule) -* *scott* - [Scott’s normal reference rule](https://en.wikipedia.org/wiki/Histogram#Scott.27s_normal_reference_rule) -* *sturges* - [Sturges’ formula](https://en.wikipedia.org/wiki/Histogram#Sturges.27_formula) -* a count (hint) representing the desired number of bins -* an array of *n* threshold values for *n* - 1 bins -* an interval or time interval (for temporal binning; see below) -* a function that returns an array, count, or time interval - -If the **thresholds** option is specified as a function, it is passed three arguments: the array of input values, the domain minimum, and the domain maximum. If a number, [d3.ticks](https://d3js.org/d3-array/ticks) or [d3.utcTicks](https://d3js.org/d3-time#utcTicks) is used to choose suitable nice thresholds. If an interval, it must expose an *interval*.floor(*value*), *interval*.ceil(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) methods. If the interval is a time interval such as "day" (equivalently, d3.utcDay), or if the thresholds are specified as an array of dates, then the binned values are implicitly coerced to dates. Time intervals are intervals that are also functions that return a Date instance when called with no arguments. - -If the **interval** option is used instead of **thresholds**, it may be either an interval, a time interval, or a number. If a number *n*, threshold values are consecutive multiples of *n* that span the domain; otherwise, the **interval** option is equivalent to the **thresholds** option. When the thresholds are specified as an interval, and the default **domain** is used, the domain will automatically be extended to start and end to align with the interval. - -The bin transform supports grouping in addition to binning: you can subdivide bins by up to two additional ordinal or categorical dimensions (not including faceting). If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. Similarly, binX will group on **y** if **y** is not an output channel, and binY will group on **x** if **x** is not an output channel. For example, for a stacked histogram: - -```js -Plot.binX({y: "count"}, {x: "body_mass_g", fill: "species"}) -``` - -Lastly, the bin transform changes the default [mark insets](../features/marks.md#mark-options): binX changes the defaults for **insetLeft** and **insetRight**; binY changes the defaults for **insetTop** and **insetBottom**; bin changes all four. - -## bin(*outputs*, *options*) {#bin} - -```js -Plot.rect(olympians, Plot.bin({fill: "count"}, {x: "weight", y: "height"})) -``` - -Bins on **x** and **y**. Also groups on the first channel of **z**, **fill**, or **stroke**, if any. - -## binX(*outputs*, *options*) {#binX} - -```js -Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight"})) -``` - -Bins on **x**. Also groups on **y** and the first channel of **z**, **fill**, or **stroke**, if any. - -## binY(*outputs*, *options*) {#binY} - -```js -Plot.rectX(olympians, Plot.binY({x: "count"}, {y: "weight"})) -``` - -Bins on **y**. Also groups on **x** and first channel of **z**, **fill**, or **stroke**, if any. diff --git a/docs/transforms/centroid.md b/docs/transforms/centroid.md deleted file mode 100644 index 9b603904..00000000 --- a/docs/transforms/centroid.md +++ /dev/null @@ -1,101 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, shallowRef, onMounted} from "vue"; - -const us = shallowRef(null); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states) : {type: null}); -const states = computed(() => us.value ? topojson.feature(us.value, us.value.objects.states).features : []); -const counties = computed(() => us.value ? topojson.feature(us.value, us.value.objects.counties).features : []); -const nation = computed(() => us.value ? topojson.feature(us.value, us.value.objects.nation) : []); - -onMounted(() => { - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); -}); - -</script> - -# Centroid transform <VersionBadge version="0.6.2" /> - -Plot offers two transforms that derive centroids from GeoJSON geometries: [centroid](#centroid) and [geoCentroid](#geoCentroid). These transforms can be used by any mark that accepts **x** and **y** channels. Below, a [text mark](../marks/text.md) labels the U.S. states. - -:::plot defer https://observablehq.com/@observablehq/plot-state-labels -```js -Plot.plot({ - projection: "albers-usa", - marks: [ - Plot.geo(statemesh), - Plot.text(states, Plot.centroid({text: (d) => d.properties.name, fill: "currentColor", stroke: "var(--vp-c-bg)"})) - ] -}) -``` -::: - -For fun, we can pass county centroids to the [voronoi mark](../marks/delaunay.md). - -:::plot defer https://observablehq.com/@observablehq/plot-centroid-voronoi -```js -Plot.voronoi(counties, Plot.centroid()).plot({projection: "albers"}) -``` -::: - -While the centroid transform computes the centroid of a geometry _after_ projection, the geoCentroid transform computes it _before_ projection, then projects the resulting coordinates. This difference has a few implications, as follows. - -As an [initializer](../features/transforms.md#custom-initializers), the centroid transform operates _after_ the geometries have been projected to screen coordinates. The resulting **x** and **y** channels reference the pixel coordinates of the planar centroid of the _projected_ shapes. No assumption is made about the geometries: they can be in any coordinate system, and the returned value is in the frame — as long as the projected geometry returns at least one visible point. - -:::plot defer https://observablehq.com/@observablehq/plot-centroid-dot -```js -Plot.dot(counties, Plot.centroid()).plot({projection: "albers-usa"}) -``` -::: - - -The geoCentroid transform is more specialized as the **x** and **y** channels it derives represent the longitudes and latitudes of the centroids of the given GeoJSON geometries, before projection. It expects the geometries to be specified in _spherical_ coordinates. It is more correct, in a geospatial sense — for example, the spherical centroid always represents the center of mass of the original shape, and it will be rotated exactly in line with the projection’s rotate argument. However, this also means that it might land outside the frame if only a part of the land mass is visible, and might be clipped by the projection. In practice, the difference is generally imperceptible. - -:::plot defer https://observablehq.com/@observablehq/plot-centroid-dot -```js -Plot.dot(counties, Plot.geoCentroid()).plot({projection: "albers-usa"}) -``` -::: - -The geoCentroid transform is slightly faster than the centroid initializer — which might be useful if you have tens of thousands of features and want to show their density on a [hexbin map](../transforms/hexbin.md): - -:::plot defer https://observablehq.com/@observablehq/plot-centroid-hexbin -```js -Plot.dot(counties, Plot.hexbin({r:"count"}, Plot.geoCentroid())).plot({projection: "albers"}) -``` -::: - -Combined with the [pointer transform](../interactions/pointer.md), the centroid transform can add [interactive tips](../marks/tip.md) on a map: - -:::plot defer https://observablehq.com/@observablehq/plot-state-centroids -```js -Plot.plot({ - projection: "albers-usa", - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.2}), - Plot.geo(nation), - Plot.dot(states, Plot.centroid({fill: "red", stroke: "var(--vp-c-bg-alt)"})), - Plot.tip(states, Plot.pointer(Plot.centroid({title: (d) => d.properties.name}))) - ] -}) -``` -::: - -## centroid(*options*) {#centroid} - -```js -Plot.centroid({geometry: Plot.identity}) -``` - -The centroid initializer derives **x** and **y** channels representing the planar (projected) centroids for the given GeoJSON geometry. If the **geometry** option is not specified, the mark’s data is assumed to be GeoJSON objects. - -## geoCentroid(*options*) {#geoCentroid} - -```js -Plot.geoCentroid({geometry: Plot.identity}) -``` - -The geoCentroid transform derives **x** and **y** channels representing the spherical centroids for the given GeoJSON geometry. If the **geometry** option is not specified, the mark’s data is assumed to be GeoJSON objects. diff --git a/docs/transforms/dodge.md b/docs/transforms/dodge.md deleted file mode 100644 index 31b109f3..00000000 --- a/docs/transforms/dodge.md +++ /dev/null @@ -1,262 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; -import cars from "../data/cars.ts"; -import penguins from "../data/penguins.ts"; - -const anchor = ref("middle"); -const r = ref(3); -const padding = ref(2); -const ipos = shallowRef([]); - -onMounted(() => { - d3.csv("../data/ipos.csv", d3.autoType).then((data) => (ipos.value = data)); -}); - -</script> - -# Dodge transform <VersionBadge version="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](#dodgeX) computes **x** (horizontal position) given **y** (vertical position), while the [dodgeY transform](#dodgeY) 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. - -:::plot https://observablehq.com/@observablehq/plot-dodge-cars -```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](../marks/rect.md). - -:::plot https://observablehq.com/@observablehq/plot-dodge-cars -```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](../features/facets.md), 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. - -:::plot defer https://observablehq.com/@observablehq/plot-dodge-penguins -```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. - -:::plot https://observablehq.com/@observablehq/plot-dodge-cars -```js -Plot.dotX(cars, {x: "weight (lb)"}).plot() -``` -::: - -:::plot https://observablehq.com/@observablehq/plot-dodge-cars -```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. - -<p> - <label class="label-input"> - Anchor: - <select v-model="anchor"> - <option>top</option> - <option>middle</option> - <option>bottom</option> - </select> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-dodge-cars -```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](./stack.md) 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. - -<p> - <label class="label-input"> - Radius (r): - <input type="range" v-model.number="r" min="0.5" max="10" step="0.1"> - <span style="font-variant-numeric: tabular-nums;">{{r.toLocaleString("en-US", {minimumFractionDigits: 1})}}</span> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-variable-radius-dodge -```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. - -<p> - <label class="label-input"> - Padding: - <input type="range" v-model.number="padding" min="-1" max="5" step="0.1"> - <span style="font-variant-numeric: tabular-nums;">{{padding.toLocaleString("en-US", {minimumFractionDigits: 1})}}</span> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-variable-radius-dodge -```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”](https://archive.nytimes.com/www.nytimes.com/interactive/2012/05/17/business/dealbook/how-the-facebook-offering-compares.html?hp) 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](https://en.wikipedia.org/wiki/Dot-com_bubble) is also visible. - -:::plot defer https://observablehq.com/@observablehq/plot-the-facebook-ipo -```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: "var(--vp-c-bg)", - pointerEvents: "none" - }) - ) - ] -}) -``` -::: - -The dodge transform can be used with any mark that supports **x** and **y** position. Below, we use the [text mark](../marks/text.md) instead to show company valuations (in billions). - -:::plot defer https://observablehq.com/@observablehq/plot-text-dodge -```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](https://en.wikipedia.org/wiki/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](./sort.md). For example, if the **sort** option uses the same column as **x**, the dots are arranged in piles leaning right. - -:::plot https://observablehq.com/@observablehq/plot-dodge-sort -```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. - -:::plot https://observablehq.com/@observablehq/plot-dodge-sort -```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](https://observablehq.com/@harrystevens/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](https://github.com/mikolalysenko/interval-tree-1d) 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*) {#dodgeY} - -```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*) {#dodgeX} - -```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. diff --git a/docs/transforms/filter.md b/docs/transforms/filter.md deleted file mode 100644 index 41cc30bc..00000000 --- a/docs/transforms/filter.md +++ /dev/null @@ -1,113 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; -import metros from "../data/metros.ts"; - -const filtered = ref(true); - -</script> - -# Filter transform - -The **filter transform** filters a mark’s index to show a subset of the data. For example, below the **filter** option controls which text labels are displayed in a dense scatterplot. - -<p> - <label class="label-input"> - Use filter: - <input type="checkbox" v-model="filtered"> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-filter-demo -```js{10} -Plot.plot({ - grid: true, - x: {type: "log"}, - marks: [ - Plot.dot(metros, { - x: "POP_2015", - y: "R90_10_2015" - }), - Plot.text(metros, { - filter: filtered ? "highlight" : null, - x: "POP_2015", - y: "R90_10_2015", - text: "nyt_display", - frameAnchor: "bottom", - dy: -6 - }) - ] -}) -``` -::: - -:::tip -As an alternative to the filter transform here, you could set the **text** channel value to null using a function: `text: (d) => d.highlight ? d.nyt_display : null`. -::: - -The filter transform can be applied either via the **filter** [mark option](../features/marks.md#mark-options), as above, or as an explicit [filter transform](#filter). The latter is generally only needed when composing multiple transforms. - -To highlight the vowels in a bar chart of English letter frequency, you can use a filtered bar with a <span style="border-bottom: solid 2px var(--vp-c-red);">red</span> stroke. A filtered mark allows you to set options on a subset of the data, even if those options — such as mark insets — are not expressible as a channels. - -:::plot https://observablehq.com/@observablehq/plot-filtered-bars -```js{8} -Plot.plot({ - marks: [ - Plot.barY(alphabet, { - x: "letter", - y: "frequency" - }), - Plot.barY(alphabet, { - filter: (d) => /[aeiouy]/i.test(d.letter), - x: "letter", - y: "frequency", - stroke: "red", - strokeWidth: 3, - inset: -3 // expand the bars - }) - ] -}) -``` -::: - -Since the filter transform only affects the mark’s index and not the channel values, it does not affect the default scale domains. Below, the *x* scale contains every English letter, even though the only the bars for the vowels are shown. - -:::plot https://observablehq.com/@observablehq/plot-filtered-bars -```js -Plot.plot({ - marks: [ - Plot.barY(alphabet, { - filter: (d) => /[aeiouy]/i.test(d.letter), - x: "letter", - y: "frequency" - }) - ] -}) -``` -::: - -If you want to drop values completely, you can filter the data with [*array*.filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter). - -:::plot https://observablehq.com/@observablehq/plot-filtered-bars -```js{4} -Plot.plot({ - marks: [ - Plot.barY( - alphabet.filter((d) => /[aeiouy]/i.test(d.letter)), - {x: "letter", y: "frequency"} - ) - ] -}) -``` -::: - -## filter(*test*, *options*) {#filter} - -```js -Plot.filter((d) => /[aeiouy]/i.test(d.letter), {x: "letter", y: "frequency"}) -``` - -Filters the data given the specified *test*. The test can be given as an accessor function (which receives the datum and index), or as a channel value definition such as a field name; truthy values are retained. diff --git a/docs/transforms/group.md b/docs/transforms/group.md deleted file mode 100644 index c45abaf4..00000000 --- a/docs/transforms/group.md +++ /dev/null @@ -1,453 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; - -const olympians = shallowRef([{weight: 31, height: 1.21, sex: "female"}, {weight: 170, height: 2.21, sex: "male"}]); - -const scheme = Plot.scale({color: {type: "categorical"}}).range; - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -</script> - -# Group transform - -:::tip -The group transform is for aggregating ordinal or nominal data. For quantitative or temporal data, use the [bin transform](./bin.md). -::: - -The **group transform** groups ordinal or nominal data — discrete values such as name, type, or category. You can then compute summary statistics for each group, such as a count, sum, or proportion. The group transform is most often used to make bar charts with the [bar mark](../marks/bar.md). - -For example, the bar chart below shows a distribution of Olympic athletes by sport. - -:::plot defer https://observablehq.com/@observablehq/plot-group-olympic-athletes-by-sport -```js -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {grid: true}, - marks: [ - Plot.barY(olympians, Plot.groupX({y: "count"}, {x: "sport"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -Ordinal domains are sorted naturally (alphabetically) by default. Either set the [scale **domain**](../features/scales.md) explicitly to change the order, or use the mark [**sort** option](../features/scales.md#sort-mark-option) to derive the scale domain from a channel. -::: - -The groupX transform groups on **x**. The *outputs* argument (here `{y: "count"}`) declares desired output channels (**y**) and the associated reducer (*count*). Hence the height of each bar above represents the number of Olympic athletes by sport. - -<!-- For example, to sort **x** by descending **y**: --> - -<!-- :::plot -```js -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {grid: true}, - marks: [ - Plot.barY(olympians, Plot.groupX({y: "count"}, {x: "sport", sort: {x: "-y"}})), - Plot.ruleY([0]) - ] -}) -``` -::: --> - -While the groupX transform is often used to generate **y**, it can output to any channel. For example, by declaring **r** in *outputs*, we can generate dots of size proportional to the number of athletes in each sport. - -:::plot https://observablehq.com/@observablehq/plot-groups-as-dots -```js -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - r: {range: [0, 14]}, - marks: [ - Plot.dot(olympians, Plot.groupX({r: "count"}, {x: "sport"})) - ] -}) -``` -::: - -The **fill** channel meanwhile will produce a one-dimensional heatmap. Since there is no **y** channel below, we use a [cell](../marks/cell.md) instead of a bar. - -:::plot defer https://observablehq.com/@observablehq/plot-groups-as-cells -```js-vue -Plot.plot({ - marginBottom: 80, - x: {tickRotate: 90}, - color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [ - Plot.cell(olympians, Plot.groupX({fill: "count"}, {x: "sport"})) - ] -}) -``` -::: - -We aren’t limited to the *count* reducer. We can use the *mode* reducer, for example, to show which sex is more prevalent in each sport: <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">men</span> are represented more often than <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">women</span> in every sport except gymnastics and fencing. - -:::plot defer https://observablehq.com/@observablehq/plot-group-and-mode-reducer -```js -Plot.plot({ - marginBottom: 80, - x: {tickRotate: 90}, - marks: [ - Plot.cell(olympians, Plot.groupX({fill: "mode"}, {fill: "sex", x: "sport"})) - ] -}) -``` -::: - -You can partition groups using **z**. If **z** is undefined, it defaults to **fill** or **stroke**, if any. In conjunction with the barY mark’s implicit [stackY transform](./stack.md), this will produce stacked bars. - -:::plot defer https://observablehq.com/@observablehq/plot-two-class-stacked-bars -```js -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {grid: true}, - color: {legend: true}, - marks: [ - Plot.barY(olympians, Plot.groupX({y: "count"}, {x: "sport", fill: "sex"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -You can invoke the stack transform explicitly as `Plot.stackY(Plot.groupX({y: "count"}, {x: "sport", fill: "sex"}))`, producing an identical chart. -::: - -You can opt-out of the implicit stackY transform by having groupX generate **y1** or **y2** instead of **y** (and similarly **x1** or **x2** for stackX and groupY). When overlapping marks, use either opacity or blending to make the overlap visible. - -:::plot defer https://observablehq.com/@observablehq/plot-two-class-overlapping-bars -```js-vue -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {grid: true}, - color: {legend: true}, - marks: [ - Plot.barY(olympians, Plot.groupX({y2: "count"}, {x: "sport", fill: "sex", mixBlendMode: "{{$dark ? "screen" : "multiply"}}"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::warning CAUTION -While the **mixBlendMode** option is useful for mitigating occlusion, it can be slow to render if there are many elements. More than two overlapping histograms may also be hard to read. -::: - -Perhaps better would be to make a grouped bar chart using [faceting](../features/facets.md). This is accomplished by setting the **fx** channel to facet horizontally on *sport*, while the **x** channel is used within each facet to draw side-by-side bars for each *sex*. The group transform automatically partitions groups by facet. - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-grouped-bar-chart -```js -Plot.plot({ - marginBottom: 100, - fx: {padding: 0, label: null, tickRotate: 90, tickSize: 6}, - x: {axis: null, paddingOuter: 0.2}, - y: {grid: true}, - color: {legend: true}, - marks: [ - Plot.barY(olympians, Plot.groupX({y2: "count"}, {x: "sex", fx: "sport", fill: "sex"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -Alternatively, below we use directional arrows (a [link mark](../marks/link.md) with [markers](../features/markers.md)) to indicate the difference in counts of <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">male</span> and <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">female</span> athletes by sport. The color of the arrow indicates which sex is more prevalent, while its length is proportional to the difference. - -:::plot defer https://observablehq.com/@observablehq/plot-difference-arrows -```js -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {grid: true, label: "Frequency"}, - color: {type: "categorical", domain: [-1, 1], unknown: "#aaa", transform: Math.sign}, - marks: [ - Plot.ruleY([0]), - Plot.link( - olympians, - Plot.groupX( - { - y1: (D) => d3.sum(D, (d) => d === "female"), - y2: (D) => d3.sum(D, (d) => d === "male"), - stroke: (D) => d3.sum(D, (d) => d === "male") - d3.sum(D, (d) => d === "female") - }, - { - x: "sport", - y1: "sex", - y2: "sex", - markerStart: "dot", - markerEnd: "arrow", - stroke: "sex", - strokeWidth: 2 - } - ) - ) - ] -}) -``` -::: - -The group transform comes in four orientations: - -- [groupX](#groupX) groups on **x**, and often outputs **y** as in a vertical↑ bar chart; -- [groupY](#groupY) groups on **y**, and often outputs **x** as in a horizontal→ bar chart; -- [groupZ](#groupZ) groups on *neither* **x** nor **y**, combining everything into one group; and -- [group](#group) groups on *both* **x** and **y**, and often outputs to **fill** or **r** as in a heatmap. - -As you might guess, the groupY transform with the barX mark produces a horizontal→ bar chart. (We must increase the **marginLeft** to avoid the *y* axis labels from being cut off.) - -:::plot defer https://observablehq.com/@observablehq/plot-sorted-horizontal-bars -```js -Plot.plot({ - marginLeft: 100, - x: {grid: true}, - y: {label: null}, - marks: [ - Plot.barX(olympians, Plot.groupY({x: "count"}, {y: "sport", sort: {y: "x"}})), - Plot.ruleX([0]) - ] -}) -``` -::: - -You can produce a two-dimensional heatmap with group transform and a cell mark by generating a **fill** output channel. For example, we could show the median weight of athletes by sport (**x**) and sex (**y**). - -:::plot defer https://observablehq.com/@observablehq/plot-grouped-olympians-heatmap -```js-vue -Plot.plot({ - marginBottom: 80, - x: {label: null, tickRotate: 90}, - y: {label: null}, - color: {label: "Median weight (kg)", legend: true, scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [ - Plot.cell(olympians, Plot.group({fill: "median"}, {fill: "weight", x: "sport", y: "sex"})) - ] -}) -``` -::: - -Or, we could group athletes by sport and the number of gold medals 🥇 won. ([Michael Phelps](https://en.wikipedia.org/wiki/Michael_Phelps), the most decorated Olympian of all time, won five gold medals in the 2016 Summer Olympics. [Simone Biles](https://en.wikipedia.org/wiki/Simone_Biles) and [Katie Ledecky](https://en.wikipedia.org/wiki/Katie_Ledecky) each won four.) - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-by-gold-medals -```js-vue -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {label: "gold", labelAnchor: "top", labelArrow: true, reverse: true}, - color: {type: "sqrt", scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [ - Plot.cell(olympians, Plot.group({fill: "count"}, {x: "sport", y: "gold"})) - ] -}) -``` -::: - -We could instead output **r** and use a [dot mark](../marks/dot.md) whose size again represents the number of athletes in each group. - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-by-gold-medals-proportional-dots -```js-vue -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {type: "point", label: "gold", labelAnchor: "top", labelArrow: true, reverse: true}, - r: {range: [0, 12]}, - marks: [ - Plot.dot(olympians, Plot.group({r: "count"}, {x: "sport", y: "gold"})) - ] -}) -``` -::: - -We can add the **stroke** channel to show overlapping distributions by sex. - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-by-gold-medals-overlapping-dots -```js-vue -Plot.plot({ - marginBottom: 100, - x: {label: null, tickRotate: 90}, - y: {type: "point", label: "gold", labelAnchor: "top", labelArrow: true, reverse: true}, - r: {range: [0, 12]}, - marks: [ - Plot.dot(olympians, Plot.group({r: "count"}, {x: "sport", y: "gold", stroke: "sex"})) - ] -}) -``` -::: - -To group solely on **z** (or **fill** or **stroke**), use [groupZ](#groupZ). The single stacked bar chart below (an alternative to a pie chart) shows the proportion of athletes by sport. The *proportion* reducer converts counts into normalized proportions adding up to 1, while the *first* reducer pulls out the name of the sport for labeling. - -:::plot defer https://observablehq.com/@observablehq/plot-single-stacked-bar -```js -Plot.plot({ - height: 100, - x: {percent: true}, - marks: [ - Plot.barX( - olympians, - Plot.stackX( - {order: "x", reverse: true}, - Plot.groupZ( - {x: "proportion"}, - {z: "sport", fillOpacity: 0.2, inset: 0.5} - ) - ) - ), - Plot.text( - olympians, - Plot.filter( - (D) => D.length > 200, - Plot.stackX( - {order: "x", reverse: true}, - Plot.groupZ( - {x: "proportion", text: "first"}, - {z: "sport", text: "sport", rotate: 90} - ) - ) - ) - ), - Plot.ruleX([0, 1]) - ] -}) -``` -::: - -:::info -Although barX applies an implicit stackX transform, [textX](../marks/text.md) does not; this example uses an explicit stackX transform in both cases for clarity, and to pass the additional **order** and **reverse** options to place the largest sport on the left. The [filter transform](./filter.md) is applied after the stack transform to hide the labels on the smallest sports where the bars are too thin. -::: - -## Group options - -Given input *data* = [*d₀*, *d₁*, *d₂*, …], by default the resulting grouped data is an array of arrays where each inner array is a subset of the input data such as [[*d₁*, *d₂*, …], [*d₀*, …], …]. Each inner array is in input order. The outer array is in input order according to the first element of each group. - -By specifying a different reducer for the **data** output, as described below, you can change how the grouped data is computed. The outputs may also include **filter** and **sort** options specified as reducers, and a **reverse** option to reverse the order of generated groups. By default, empty groups are omitted, and non-empty groups are generated in ascending (natural) order. - -In addition to data, the following channels are automatically output: - -* **x** - the horizontal position of the group -* **y** - the vertical position of the group -* **z** - the first value of the *z* channel, if any -* **fill** - the first value of the *fill* channel, if any -* **stroke** - the first value of the *stroke* channel, if any - -The **x** output channel is only computed by the groupX and group transform; similarly the **y** output channel is only computed by the groupY and group transform. - -You can declare additional output channels by specifying the channel name and desired reducer in the *outputs* object which is the first argument to the transform. For example, to use groupX to generate a **y** channel of group counts as in a frequency histogram: - -```js -Plot.groupX({y: "count"}, {x: "species"}) -``` - -The following named reducers are supported: - -* *first* - the first value, in input order -* *last* - the last value, in input order -* *count* - the number of elements (frequency) -* *sum* - the sum of values -* *proportion* - the sum proportional to the overall total (weighted frequency) -* *proportion-facet* - the sum proportional to the facet total -* *min* - the minimum value -* *min-index* - the zero-based index of the minimum value -* *max* - the maximum value -* *max-index* - the zero-based index of the maximum value -* *mean* - the mean value (average) -* *median* - the median value -* *mode* - the value with the most occurrences -* *pXX* - the percentile value, where XX is a number in [00,99] -* *deviation* - the standard deviation -* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) -* *identity* - the array of values -* *x* <VersionBadge version="0.6.12" pr="1916" /> - the group’s *x* value (when grouping on *x*) -* *y* <VersionBadge version="0.6.12" pr="1916" /> - the group’s *y* value (when grouping on *y*) -* *z* <VersionBadge version="0.6.14" pr="1959" /> - the group’s *z* value (*z*, *fill*, or *stroke*) - -In addition, a reducer may be specified as: - -* a function to be passed the array of values for each group and the extent of the group -* an object with a **reduceIndex** method, an optionally a **scope** - -In the last case, the **reduceIndex** method is repeatedly passed three arguments: the index for each group (an array of integers), the input channel’s array of values, and the extent of the group (an object {data, x, y}); it must then return the corresponding aggregate value for the group. - -If the reducer object’s **scope** is *data*, then the **reduceIndex** method is first invoked for the full data; the return value of the **reduceIndex** method is then made available as a third argument (making the extent the fourth argument). Similarly if the **scope** is *facet*, then the **reduceIndex** method is invoked for each facet, and the resulting reduce value is made available while reducing the facet’s groups. (This optional **scope** is used by the *proportion* and *proportion-facet* reducers.) - -Most reducers require binding the output channel to an input channel; for example, if you want the **y** output channel to be a *sum* (not merely a count), there should be a corresponding **y** input channel specifying which values to sum. If there is not, *sum* will be equivalent to *count*. - -```js -Plot.groupX({y: "sum"}, {x: "species", y: "body_mass_g"}) -``` - -You can control whether a channel is computed before or after grouping. If a channel is declared only in *options* (and it is not a special group-eligible channel such as **x**, **y**, **z**, **fill**, or **stroke**), it will be computed after grouping and be passed the grouped data: each datum is the array of input data corresponding to the current group. - -```js -Plot.groupX({y: "count"}, {x: "species", title: (group) => group.map((d) => d.body_mass_g).join("\n")}) -``` - -This is equivalent to declaring the channel only in *outputs*. - -```js -Plot.groupX({y: "count", title: (group) => group.map((d) => d.body_mass_g).join("\n")}, {x: "species"}) -``` - -However, if a channel is declared in both *outputs* and *options*, then the channel in *options* is computed before grouping and can be aggregated using any built-in reducer (or a custom reducer function) during the group transform. - -```js -Plot.groupX({y: "count", title: (masses) => masses.join("\n")}, {x: "species", title: "body_mass_g"}) -``` - -If any of **z**, **fill**, or **stroke** is a channel, the first of these channels is considered the *z* dimension and will be used to subdivide groups. - -The default reducer for the **title** channel returns a summary list of the top 5 values with the corresponding number of occurrences. - -## group(*outputs*, *options*) {#group} - -```js -Plot.group({fill: "count"}, {x: "island", y: "species"}) -``` - -Groups on **x**, **y**, and the first channel of **z**, **fill**, or **stroke**, if any. - -## groupX(*outputs*, *options*) {#groupX} - -```js -Plot.groupX({y: "sum"}, {x: "species", y: "body_mass_g"}) -``` - -Groups on **x** and the first channel of **z**, **fill**, or **stroke**, if any. - -## groupY(*outputs*, *options*) {#groupY} - -```js -Plot.groupY({x: "sum"}, {y: "species", x: "body_mass_g"}) -``` - -Groups on **y** and the first channel of **z**, **fill**, or **stroke**, if any. - -## groupZ(*outputs*, *options*) {#groupZ} - -```js -Plot.groupZ({x: "proportion"}, {fill: "species"}) -``` - -Groups on the first channel of **z**, **fill**, or **stroke**, if any. If none of **z**, **fill**, or **stroke** are channels, then all data (within each facet) is placed into a single group. - -## find(*test*) <VersionBadge version="0.6.12" pr="1914" /> {#find} - -```js -Plot.groupX( - {y1: Plot.find((d) => d.sex === "F"), y2: Plot.find((d) => d.sex === "M")}, - {x: "date", y: "value"} -) -``` - -Returns a reducer that finds the first datum for which the given *test* function returns a truthy value, and returns the corresponding channel value. This may be used with the group or bin transform to implement a “pivot wider” transform; for example, a “tall” dataset with separate rows for male and female observations may be transformed into a “wide” dataset with separate columns for male and female values. diff --git a/docs/transforms/hexbin.md b/docs/transforms/hexbin.md deleted file mode 100644 index 63ef3f34..00000000 --- a/docs/transforms/hexbin.md +++ /dev/null @@ -1,218 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, watchEffect, shallowRef, onMounted} from "vue"; -import cars from "../data/cars.ts"; - -const binWidth = ref(20); -const olympians = shallowRef([{weight: 31, height: 1.21, sex: "female"}, {weight: 170, height: 2.21, sex: "male"}]); -const walmarts = shallowRef([]); -const us = shallowRef(null); -const nation = computed(() => us.value ? topojson.feature(us.value, us.value.objects.nation) : {type: null}); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states, (a, b) => a !== b) : {type: null}); - -const scheme = Plot.scale({color: {type: "categorical"}}).range; - -onMounted(() => { - d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); - d3.tsv("../data/walmarts.tsv", d3.autoType).then((data) => (walmarts.value = data)); - d3.json("../data/us-counties-10m.json").then((data) => (us.value = data)); -}); - -</script> - -# Hexbin transform <VersionBadge version="0.5.0" /> - -The **hexbin transform** groups two-dimensional quantitative or temporal data — continuous measurements such as heights, weights, or temperatures — into discrete hexagonal bins. You can then compute summary statistics for each bin, such as a count, sum, or proportion. The hexbin transform is most often used to make heatmaps with the [dot mark](../marks/dot.md). - -For example, the heatmap below shows the weights and heights of Olympic athletes. The color of each hexagon represents the number (*count*) of athletes with similar weight and height. - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-hexbin -```js-vue -Plot - .dot(olympians, Plot.hexbin({fill: "count"}, {x: "weight", y: "height"})) - .plot({color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}}) -``` -::: - -Whereas the [bin transform](./bin.md) produces rectangular bins and operates on abstract data, the hexbin transform produces hexagonal bins and operates in “screen space” (_i.e._, pixel coordinates) after the *x* and *y* scales have been applied to the data. And whereas the bin transform produces **x1**, **y1**, **x2**, **y2** representing rectangular extents, the hexbin transform produces **x** and **y** representing hexagon centers. - -To produce an areal encoding as in a bubble map, output **r**. In this case, the default range of the *r* scale is set such that the hexagons do not overlap. The **binWidth** option, which defaults to 20, specifies the distance between centers of neighboring hexagons in pixels. - -<p> - <label class="label-input"> - Bin width: - <input type="range" v-model.number="binWidth" min="0" max="40" step="0.1"> - <span style="font-variant-numeric: tabular-nums;">{{binWidth.toLocaleString("en-US", {minimumFractionDigits: 1})}}</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-binwidth -```js -Plot - .dot(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height", binWidth})) - .plot() -``` -::: - -If desired, you can output both **fill** and **r** for a redundant encoding. - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-redundant -```js-vue -Plot - .dot(olympians, Plot.hexbin({fill: "count", r: "count"}, {x: "weight", y: "height", stroke: "currentColor"})) - .plot({color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}}) -``` -::: - -:::tip -Setting a **stroke** ensures that the smallest hexagons are visible. -::: - -Alternatively, the **fill** and **r** channels can encode independent (or “bivariate”) dimensions of data. Below, the **r** channel uses *count* as before, while the **fill** channel uses *mode* to show the most frequent sex of athletes in each hexagon. The larger athletes are more likely to be <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">male</span>, while the smaller athletes are more likely to be <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">female</span>. - -:::plot defer https://observablehq.com/@observablehq/plot-bivariate-hexbin -```js -Plot - .dot(olympians, Plot.hexbin({fill: "mode", r: "count"}, {x: "weight", y: "height", fill: "sex"})) - .plot() -``` -::: - -Using **z**, the hexbin transform will partition hexagons by ordinal value. If **z** is not specified, it defaults to **fill** (if there is no **fill** output channel) or **stroke** (if there is no **stroke** output channel). Setting **z** to *sex* in the chart above, and switching to **stroke** instead of **fill**, produces separate overlapping hexagons for each sex. - -:::plot defer https://observablehq.com/@observablehq/plot-overlapping-hexbin -```js -Plot - .dot(olympians, Plot.hexbin({stroke: "mode", r: "count"}, {x: "weight", y: "height", z: "sex", stroke: "sex"})) - .plot() -``` -::: - -The hexbin transform can be paired with any mark that supports **x** and **y** channels (which is almost all of them). The [text mark](../marks/text.md) is useful for labelling. By setting the **text** output channel, you can derive the text from the binned contents. - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-text -```js -Plot - .text(olympians, Plot.hexbin({text: "count"}, {x: "weight", y: "height"})) - .plot() -``` -::: - -The hexbin transform also works with Plot’s [projection system](../features/projections.md). Below, hexagon size represents the number of nearby Walmart stores, while color represents the date the first nearby Walmart store opened. (The first Walmart opened in Rogers, Arkansas.) - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-map -```js -Plot.plot({ - projection: "albers", - r: {range: [0, 16]}, - color: {scheme: "spectral", label: "First year opened", legend: true}, - marks: [ - Plot.geo(statemesh, {strokeOpacity: 0.5}), - Plot.geo(nation), - Plot.dot(walmarts, Plot.hexbin({r: "count", fill: "min"}, {x: "longitude", y: "latitude", fill: "date"})) - ] -}) -``` -::: - -:::warning CAUTION -Beware the [modifiable areal unit problem](https://en.wikipedia.org/wiki/Modifiable_areal_unit_problem). On a small scale map, this is compounded by the Earth’s curvature, which makes it impossible to create an accurate and regular grid. Use an equal-area projection when binning. -::: - -The [hexgrid mark](../marks/hexgrid.md) draws the base hexagonal grid as a mesh. This is useful for showing the empty hexagons, since the hexbin transform does not output empty bins (and unlike the bin transform, the hexbin transform does not currently support the **filter** option). - -:::plot defer https://observablehq.com/@observablehq/plot-hexgrid-demo -```js -Plot.plot({ - marks: [ - Plot.hexgrid(), - Plot.dot(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height", fill: "currentColor"})) - ] -}) -``` -::: - -The hexbin transform defaults the **symbol** option to *hexagon*, but you can override it. The [circle constructor](../marks/dot.md#circle) changes it to *circle*. - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-circle -```js -Plot.circle(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height"})).plot() -``` -::: - -Hexbins work best when there is an interesting density of dots in the center of the chart, but sometimes hexagons “escape” the edge of the frame and cover the axes. To prevent this, you can use the **inset** [scale option](../features/scales.md) to reserve space on the edges of the frame. - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-inset -```js-vue -Plot - .dot(olympians, Plot.hexbin({fill: "count"}, {x: "weight", y: "height"})) - .plot({inset: 10, color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}}) -``` -::: - -:::tip -You can also set the dot’s **clip** option to true to prevent the hexagons from escaping. -::: - -Alternatively, use the [axis mark](../marks/axis.md) to draw axes on top of the hexagons. - -:::plot defer https://observablehq.com/@observablehq/plot-hexbin-and-axes -```js-vue -Plot.plot({ - color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [ - Plot.dot(olympians, Plot.hexbin({fill: "count"}, {x: "weight", y: "height"})), - Plot.axisX(), - Plot.axisY() - ] -}) -``` -::: - -## Hexbin options - -The *options* must specify the **x** and **y** channels. The **binWidth** option (default 20) defines the distance between centers of neighboring hexagons in pixels. If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. - -The *outputs* options are similar to the [bin transform](./bin.md); for each hexagon, an output channel value is derived by reducing the corresponding binned input channel values. The *outputs* object specifies the reducer for each output channel. - -The following named reducers are supported: - -* *first* - the first value, in input order -* *last* - the last value, in input order -* *count* - the number of elements (frequency) -* *distinct* - the number of distinct values -* *sum* - the sum of values -* *proportion* - the sum proportional to the overall total (weighted frequency) -* *proportion-facet* - the sum proportional to the facet total -* *min* - the minimum value -* *min-index* - the zero-based index of the minimum value -* *max* - the maximum value -* *max-index* - the zero-based index of the maximum value -* *mean* - the mean value (average) -* *median* - the median value -* *deviation* - the [standard deviation](https://d3js.org/d3-array/summarize#deviation) -* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) -* *mode* - the value with the most occurrences -* *identity* - the array of values -* *x* <VersionBadge version="0.6.12" pr="1916" /> - the hexagon’s *x* center -* *y* <VersionBadge version="0.6.12" pr="1916" /> - the hexagon’s *y* center - -In addition, a reducer may be specified as: - -* a function to be passed the array of values for each bin and the center of the bin -* an object with a *reduceIndex* method - -In the last case, the **reduceIndex** method is repeatedly passed three arguments: the index for each bin (an array of integers), the input channel’s array of values, and the center of the bin (an object {data, x, y}); it must then return the corresponding aggregate value for the bin. - -Most reducers require binding the output channel to an input channel; for example, if you want the **y** output channel to be a *sum* (not merely a count), there should be a corresponding **y** input channel specifying which values to sum. If there is not, *sum* will be equivalent to *count*. - -## hexbin(*outputs*, *options*) {#hexbin} - -```js -Plot.dot(olympians, Plot.hexbin({fill: "count"}, {x: "weight", y: "height"})) -``` - -Bins hexagonally on **x** and **y**. Also groups on the first channel of **z**, **fill**, or **stroke**, if any. diff --git a/docs/transforms/interval.md b/docs/transforms/interval.md deleted file mode 100644 index 5ec4d948..00000000 --- a/docs/transforms/interval.md +++ /dev/null @@ -1,78 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import aapl from "../data/aapl.ts"; - -</script> - -# Interval transform - -:::tip -There’s also an [**interval** scale option](../features/scales.md#scale-transforms) for quantizing continuous data. -::: - -The **interval transform** turns a quantitative or temporal *value* into a continuous extent [*start*, *stop*]. For example, if *value* is an instant in time, the interval transform could return a *start* of UTC midnight and a *stop* of the UTC midnight the following day. - -The interval transform is often used for time-series bar charts. For example, consider the chart below of the daily trade volume of Apple stock. Because of the [barY mark](../marks/bar.md), the *x* scale is ordinal (*band*). And because the regularity of the data is not specified (*i.e.*, because Plot has no way of knowing that this is daily data), every distinct value must have its own label, leading to crowding. If a day were missing data, it would be difficult to spot! 👓 - -:::plot https://observablehq.com/@observablehq/plot-band-scale-interval -```js -Plot.plot({ - marginBottom: 80, - x: {type: "band"}, // ⚠️ not utc - y: { - transform: (d) => d / 1e6, - label: "Daily trade volume (millions)" - }, - marks: [ - Plot.barY(aapl.slice(-40), {x: "Date", y: "Volume"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -In contrast, a [rectY mark](../marks/rect.md) with the **interval** option and the *day* interval produces a temporal (*utc*) *x* scale. This allows Plot to compute ticks at meaningful intervals: here weekly boundaries, UTC midnight on Sundays. Furthermore, we can see that this isn’t truly daily data — it’s missing weekends and holidays when the stock market was closed. - -:::plot https://observablehq.com/@observablehq/plot-temporal-interval-option -```js -Plot.plot({ - y: { - grid: true, - transform: (d) => d / 1e6, - label: "Daily trade volume (millions)" - }, - marks: [ - Plot.rectY(aapl.slice(-40), {x: "Date", interval: "day", y: "Volume"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::info -The interval transform is not a standalone transform, but an option on marks and scales. -::: - -The meaning of the **interval** mark option depends on the associated mark, such as line, bar, rect, or dot. For example, for the [barY mark](../marks/bar.md), the **interval** option affects converts a singular *y* value into an interval [*y1*, *y2*]. In the contrived example below, notice that the vertical↕︎ extent of each bar spans an interval of 5 million, rather than extending to *y* = 0. - -:::plot https://observablehq.com/@observablehq/plot-interval-bars -```js -Plot.plot({ - marginBottom: 80, - x: {type: "band"}, // ⚠️ not utc - y: { - grid: true, - transform: (d) => d / 1e6, - label: "Daily trade volume (millions)" - }, - marks: [ - Plot.barY(aapl.slice(-40), {x: "Date", y: "Volume", interval: 5e6}), - Plot.ruleY([0]) - ] -}) -``` -::: - -While the **interval** option is most commonly specified as a named time interval or a number, it can also be specified as a [D3 time interval](https://d3js.org/d3-time#_interval) or any object that implements *interval*.floor and *interval*.offset. diff --git a/docs/transforms/map.md b/docs/transforms/map.md deleted file mode 100644 index 9772cd67..00000000 --- a/docs/transforms/map.md +++ /dev/null @@ -1,125 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref} from "vue"; -import aapl from "../data/aapl.ts"; - -const n = ref(20); -const k = ref(2); - -</script> - -# Map transform - -The **map transform** groups data into series and then transforms each series’ values, say to normalize them relative to some basis or to apply a moving average. For example, below the map transform computes a cumulative sum (*cumsum*) of a series of random numbers sampled from a normal distribution — in other words, a random walk. - -:::plot https://observablehq.com/@observablehq/plot-random-walk-cumsum-map -```js -Plot.plot({ - marks: [ - Plot.ruleY([0]), - Plot.lineY({length: 600}, Plot.mapY("cumsum", {y: d3.randomNormal()})) - ] -}) -``` -::: - -As another example, we can map the daily trading volume of Apple stock to a [*p*-quantile](https://en.wikipedia.org/wiki/Quantile) in [0, 1] using the *quantile* map method, where 0 represents the minimum daily trade volume and 1 represents the maximum, and then apply a 30-day rolling mean with the [window transform](./window.md) to smooth out the noise. - -:::plot https://observablehq.com/@observablehq/plot-quantile-map-transform -```js -Plot.plot({ - marks: [ - Plot.ruleY([0, 1]), - Plot.lineY(aapl, Plot.mapY("quantile", {x: "Date", y: "Volume", strokeOpacity: 0.2})), - Plot.lineY(aapl, Plot.windowY(30, Plot.mapY("quantile", {x: "Date", y: "Volume"}))) - ] -}) -``` -::: - -The [mapY transform](#mapY) above is shorthand for applying the given map method to all *y* channels. There’s also a less-common [mapX transform](#mapX) for *x* channels. - -The more explicit [map](#map) transform lets you specify which channels to map, and what map method to use for each channel. Like the [group](./group.md) and [bin](./bin.md) transforms, it takes two arguments: an *outputs* object that describes the output channels to compute, and an *options* object that describes the input channels and additional options. So this: - -```js -Plot.mapY("cumsum", {y: d3.randomNormal()}) -``` - -Is shorthand for this: - -```js -Plot.map({y: "cumsum"}, {y: d3.randomNormal()}) -``` - -As a more practical example, we can use the map transform to construct [Bollinger bands](../marks/bollinger.md), showing both the price and volatility of Apple stock. - -<p> - <label class="label-input"> - <span>Window size (n):</span> - <input type="range" v-model.number="n" min="1" max="100" step="1" /> - <span style="font-variant-numeric: tabular-nums;">{{n.toLocaleString("en-US")}}</span> - </label> - <label class="label-input"> - <span>Radius (k):</span> - <input type="range" v-model.number="k" min="0" max="10" step="0.1" /> - <span style="font-variant-numeric: tabular-nums;">{{k.toLocaleString("en-US")}}</span> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-bollinger-band -```js -Plot.plot({ - y: { - grid: true - }, - marks: [ - Plot.areaY(aapl, Plot.map({y1: Plot.bollinger({n, k: -k}), y2: Plot.bollinger({n, k})}, {x: "Date", y: "Close", fillOpacity: 0.2})), - Plot.lineY(aapl, Plot.map({y: Plot.bollinger({n})}, {x: "Date", y: "Close", stroke: "blue"})), - Plot.lineY(aapl, {x: "Date", y: "Close", strokeWidth: 1}) - ] -}) -``` -::: - -The [bollinger map method](../marks/bollinger.md#bollinger) is implemented atop the [window map method](./window.md#window), computing the mean of values within the rolling window, and then offsetting the mean by a multiple of the rolling deviation. - -The map transform is akin to running [*array*.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) on the input channel’s values with the given map method. However, the map transform is series-aware: the data are first grouped into series using the **z**, **fill**, or **stroke** channel in the same fashion as the [area](../marks/area.md) and [line](../marks/line.md) marks so that series are processed independently. - -## Map options - -The following map methods are supported: - -* *cumsum* - a cumulative sum -* *rank* - the rank of each value in the sorted array -* *quantile* - the rank, normalized between 0 and 1 -* a function to be passed an array of values, returning new values -* a function to be passed an index and array of channel values, returning new values -* an object that implements the *mapIndex* method - -If a function is used, it must return an array of the same length as the given input. If a *mapIndex* method is used, it is repeatedly passed the index for each series (an array of integers), the corresponding input channel’s array of values, and the output channel’s array of values; it must populate the slots specified by the index in the output array. - -## map(*outputs*, *options*) {#map} - -```js -Plot.map({y: "cumsum"}, {y: d3.randomNormal()}) -``` - -Groups on the first channel of **z**, **fill**, or **stroke**, if any, and then for each channel declared in the specified *outputs* object, applies the corresponding map method. Each channel in *outputs* must have a corresponding input channel in *options*. - -## mapX(*map*, *options*) {#mapX} - -```js -Plot.mapX("cumsum", {x: d3.randomNormal()}) -``` - -Equivalent to Plot.map({x: *map*, x1: *map*, x2: *map*}, *options*), but ignores any of **x**, **x1**, and **x2** not present in *options*. In addition, if none of **x**, **x1**, or **x2** are specified, then **x** defaults to [identity](../features/transforms.md#identity). - -## mapY(*map*, *options*) {#mapY} - -```js -Plot.mapY("cumsum", {y: d3.randomNormal()}) -``` - -Equivalent to Plot.map({y: *map*, y1: *map*, y2: *map*}, *options*), but ignores any of **y**, **y1**, and **y2** not present in *options*. In addition, if none of **y**, **y1**, or **y2** are specified, then **y** defaults to [identity](../features/transforms.md#identity). diff --git a/docs/transforms/normalize.md b/docs/transforms/normalize.md deleted file mode 100644 index 519e1b14..00000000 --- a/docs/transforms/normalize.md +++ /dev/null @@ -1,158 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; - -const stateage = shallowRef([]); -const stocks = shallowRef([]); -const xy = Plot.normalizeX("sum", {z: "state", x: "population", y: "state"}); - -onMounted(() => { - Promise.all([ - d3.csv("../data/aapl.csv", d3.autoType), - d3.csv("../data/amzn.csv", d3.autoType), - d3.csv("../data/goog.csv", d3.autoType), - d3.csv("../data/ibm.csv", d3.autoType) - ]).then((datas) => { - stocks.value = d3.zip(["AAPL", "AMZN", "GOOG", "IBM"], datas).flatMap(([Symbol, data]) => data.map((d) => ({Symbol, ...d}))); - }); - d3.csv("../data/us-population-state-age.csv", d3.autoType).then((data) => { - const ages = data.columns.slice(1); // convert wide data to tidy data - stateage.value = Object.assign(ages.flatMap((age) => data.map((d) => ({state: d.name, age, population: d[age]}))), {ages}); - }); -}); - -</script> - -# Normalize transform - -The **normalize transform** is a specialized [map transform](./map.md) that normalizes series values relative to some basis, say to convert absolute values into relative values. For example, here is an index chart — a type of multi-series line chart — showing the return of several stocks relative to their closing price on a particular date. - -:::plot defer https://observablehq.com/@observablehq/plot-index-chart -```js -Plot.plot({ - y: { - type: "log", - grid: true, - label: "Change in price (%)", - tickFormat: ((f) => (x) => f((x - 1) * 100))(d3.format("+d")) - }, - marks: [ - Plot.ruleY([1]), - Plot.line(stocks, Plot.normalizeY({ - x: "Date", - y: "Close", - stroke: "Symbol" - })), - Plot.text(stocks, Plot.selectLast(Plot.normalizeY({ - x: "Date", - y: "Close", - z: "Symbol", - text: "Symbol", - textAnchor: "start", - dx: 3 - }))) - ] -}) -``` -::: - -:::tip -The [select transform](./select.md) is used to label the endpoints of each line. -::: - -:::info -This example uses an [immediately-invoked function expression (IIFE)](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) for the **tickFormat** option so that the [d3.format](https://d3js.org/d3-format) only needs to be constructed once. -::: - -The normalize transform converts absolute values into relative ones. So, if **y** is [*y₀*, *y₁*, *y₂*, …] and the *first* basis is used with [normalizeY](#normalizeY), the resulting output **y** channel is [*y₀* / *y₀*, *y₁* / *y₀*, *y₂* / *y₀*, …]. But it’s a bit more complicated than this in practice since **y** is first grouped by **z**, **fill**, or **stroke** into separate series. - -As another example, the normalize transform can be used to compute proportional demographics from absolute populations. The plot below compares the demographics of U.S. states: color represents age group, **y** represents the state, and **x** represents the proportion of the state’s population in that age group. - -:::plot defer https://observablehq.com/@observablehq/plot-dot-plot -```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", {z: "state", x: "population", y: "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 (`...`). -::: - -## Normalize options - -The **basis** option specifies how to normalize the series values; it is one of: - -* *first* - the first value, as in an index chart; the default -* *last* - the last value -* *min* - the minimum value -* *max* - the maximum value -* *mean* - the mean value (average) -* *median* - the median value -* *pXX* - the percentile value, where XX is a number in [00,99] -* *sum* - the sum of values -* *extent* - the minimum is mapped to zero, and the maximum to one -* *deviation* - subtract the mean, then divide by the standard deviation -* a function to be passed an array of values, returning the desired basis -* a function to be passed an index and channel value array, returning the desired basis - -## normalize(*basis*) <VersionBadge version="0.2.3" /> {#normalize} - -```js -Plot.map({y: Plot.normalize("first")}, {x: "Date", y: "Close", stroke: "Symbol"}) -``` - -Returns a normalize map method for the given *basis*, suitable for use with the [map transform](./map.md). - -## normalizeX(*basis*, *options*) {#normalizeX} - -```js -Plot.normalizeX("first", {y: "Date", x: "Close", stroke: "Symbol"}) -``` - -Like [mapX](./map.md#mapX), but applies the normalize map method with the given *basis*. The **basis** option can also be mixed into the specified *options* like so: - -```js -Plot.normalizeX({basis: "first", y: "Date", x: "Close", stroke: "Symbol"}) -``` - -If not specified, the *basis* defaults to *first*. - -## normalizeY(*basis*, *options*) {#normalizeY} - -```js -Plot.normalizeY("first", {x: "Date", y: "Close", stroke: "Symbol"}) -``` - -Like [mapY](./map.md#mapY), but applies the normalize map method with the given *basis*. The **basis** option can also be mixed into the specified *options* like so: - -```js -Plot.normalizeY({basis: "first", x: "Date", y: "Close", stroke: "Symbol"}) -``` - -If not specified, the *basis* defaults to *first*. diff --git a/docs/transforms/select.md b/docs/transforms/select.md deleted file mode 100644 index 39a7a7f2..00000000 --- a/docs/transforms/select.md +++ /dev/null @@ -1,179 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; -import aapl from "../data/aapl.ts"; - -const stocks = shallowRef([]); - -onMounted(() => { - Promise.all([ - d3.csv("../data/amzn.csv", d3.autoType), - d3.csv("../data/goog.csv", d3.autoType), - d3.csv("../data/ibm.csv", d3.autoType) - ]).then((datas) => { - stocks.value = d3.zip(["AAPL", "AMZN", "GOOG", "IBM"], [aapl].concat(datas)).flatMap(([Symbol, data]) => data.map((d) => ({Symbol, ...d}))); - }); -}); - -</script> - -# Select transform <VersionBadge version="0.4.0" /> - -The **select transform** filters a mark’s index to show a subset of the data. It is a specialized [filter transform](./filter.md) that pulls a single value or a sample subset out of each series. For example, below selectLast is used to label the last value in a line chart. - -:::plot https://observablehq.com/@observablehq/plot-value-labeled-line-chart -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.ruleY([0]), - Plot.line(aapl, {x: "Date", y: "Close"}), - Plot.text(aapl, Plot.selectLast({x: "Date", y: "Close", text: "Close", frameAnchor: "bottom", dy: -6})) - ] -}) -``` -::: - -The select transform uses input order, not natural order by value, to determine the meaning of *first* and *last*. Since this dataset is in reverse chronological order, the first element is the most recent. - -Using selectMinY and selectMaxY, you can label the extreme values. - -:::plot https://observablehq.com/@observablehq/plot-value-labeled-line-chart -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.ruleY([0]), - Plot.line(aapl, {x: "Date", y: "Close"}), - Plot.text(aapl, Plot.selectMinY({x: "Date", y: "Close", text: "Close", frameAnchor: "top", dy: 6})), - Plot.text(aapl, Plot.selectMaxY({x: "Date", y: "Close", text: "Close", frameAnchor: "bottom", dy: -6})) - ] -}) -``` -::: - -The select transform groups data into series using the **z**, **fill**, or **stroke** channel in the same fashion as the [area](../marks/area.md) and [line](../marks/line.md) marks. Below, the select transform is used to label the last point in each series of a multi-series line chart. - -:::plot defer https://observablehq.com/@observablehq/plot-labeled-multi-line-chart -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.ruleY([0]), - Plot.line(stocks, {x: "Date", y: "Close", stroke: "Symbol"}), - Plot.text(stocks, Plot.selectLast({x: "Date", y: "Close", z: "Symbol", text: "Symbol", textAnchor: "start", dx: 3})) - ] -}) -``` -::: - -## select(*selector*, *options*) {#select} - -```js -Plot.select("first", {x: "Date", y: "Close"}) // selectFirst -``` -```js -Plot.select({y: "min"}, {x: "Date", y: "Close"}) // selectMinY -``` - -Selects the points in each series determined by the given *selector*, which is one of: - -- a named selector, either *first* or *last*, -- a function which receives as input the series index, or -- a {*name*: *value*} object (with exactly one *name*). - -In the last case, *name* is the name of a channel and *value* is a value selector, which is one of: - -- a named selector, either *min* or *max*, or -- a function which receives as input the series index and the channel values. - -For example, to select the point in each city with the highest temperature (“selectMaxFill”): - -```js -Plot.select({fill: "max"}, {x: "date", y: "city", z: "city", fill: "temperature"}) -``` - -A selector function must return the selected index: a subset of the passed-in series index. For example, selectFirst and selectMinY can be implemented using functions like so: - -```js -Plot.select((I) => [I[0]], {x: "Date", y: "Close"}) // selectFirst -``` -```js -Plot.select({y: (I, Y) => [d3.least(I, (i) => Y[i])]}, {x: "Date", y: "Close"}) // selectMinY -``` - -Or, to select the point within each series that is the closest to the median of **y**: - -```js -Plot.select({y: selectorMedian}, {x: "year", y: "revenue", fill: "format"}) -``` - -```js -function selectorMedian(I, V) { - const median = d3.median(I, (i) => V[i]); - const i = d3.least(I, (i) => Math.abs(V[i] - median)); - return [i]; -} -``` - -The following selects a sample of 10% of the data: - -```js -Plot.select({y: selectorSample}, {x: "Date", y: "Close"}) -``` - -```js -function selectorSample(I) { - return I.filter((i, j) => j % 10 === 0); -} -``` - -## selectFirst(*options*) {#selectFirst} - -```js -Plot.selectFirst({x: "Date", y: "Close"}) -``` - -Selects the first point of each series according to input order. - -## selectLast(*options*) {#selectLast} - -```js -Plot.selectLast({x: "Date", y: "Close"}) -``` - -Selects the last point of each series according to input order. - -## selectMinX(*options*) {#selectMinX} - -```js -Plot.selectMinX({x: "Date", y: "Close"}) -``` - -Selects the leftmost point of each series. - -## selectMinY(*options*) {#selectMinY} - -```js -Plot.selectMinY({x: "Date", y: "Close"}) -``` - -Selects the lowest point of each series. - -## selectMaxX(*options*) {#selectMaxX} - -```js -Plot.selectMaxX({x: "Date", y: "Close"}) -``` - -Selects the rightmost point of each series. - -## selectMaxY(*options*) {#selectMaxY} - -```js -Plot.selectMaxY({x: "Date", y: "Close"}) -``` - -Selects the highest point of each series. diff --git a/docs/transforms/shift.md b/docs/transforms/shift.md deleted file mode 100644 index 746b6059..00000000 --- a/docs/transforms/shift.md +++ /dev/null @@ -1,58 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; - -const shift = ref(365); -const aapl = shallowRef([]); - -onMounted(() => { - d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data)); -}); - -</script> - -# Shift transform <VersionBadge version="0.6.12" pr="1896" /> - -The **shift transform** is a specialized [map transform](./map.md) that derives an output **x1** channel by shifting the **x** channel; it can be used with the [difference mark](../marks/difference.md) to show change over time. For example, the chart below shows the price of Apple stock. The <span style="border-bottom: solid #01ab63 3px;">green region</span> shows when the price went up over the given interval, while the <span style="border-bottom: solid #4269d0 3px;">blue region</span> shows when the price went down. - -<p> - <label class="label-input" style="display: flex;"> - <span style="display: inline-block; width: 7em;">Shift (days):</span> - <input type="range" v-model.number="shift" min="0" max="1000" step="1"> - <span style="font-variant-numeric: tabular-nums;">{{shift}}</span> - </label> -</p> - -:::plot hidden -```js -Plot.differenceY(aapl, Plot.shiftX(`${shift} days`, {x: "Date", y: "Close"})).plot({y: {grid: true}}) -``` -::: - -```js-vue -Plot.differenceY(aapl, Plot.shiftX("{{shift}} days", {x: "Date", y: "Close"})).plot({y: {grid: true}}) -``` - -When looking at year-over-year growth, the chart is mostly green, implying that you would make a profit by holding Apple stock for a year. However, if you bought in 2015 and sold in 2016, you would likely have lost money. Try adjusting the slider to a shorter or longer interval: how does that affect the typical return? - -## shiftX(*interval*, *options*) {#shiftX} - -```js -Plot.shiftX("7 days", {x: "Date", y: "Close"}) -``` - -Derives an **x1** channel from the input **x** channel by shifting values by the given [*interval*](../features/intervals.md). The *interval* may be specified as: a name (*second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) with an optional number and sign (*e.g.*, *+3 days* or *-1 year*); or as a number; or as an implementation — such as d3.utcMonth — with *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) methods. - -The shiftX transform also aliases the **x** channel to **x2** and applies a domain hint to the **x2** channel such that by default the plot shows only the intersection of **x1** and **x2**. For example, if the interval is *+1 year*, the first year of the data is not shown. - -## shiftY(*interval*, *options*) <VersionBadge version="0.6.16" pr="1922" /> {#shiftY} - -```js -Plot.shiftY("7 days", {y: "Date", x: "Close"}) -``` - -Derives a **y1** channel from the input **y** channel by shifting values by the given [*interval*](../features/intervals.md). See [shiftX](#shiftX) for more. - -The shiftY transform also aliases the **y** channel to **y2** and applies a domain hint to the **y2** channel such that by default the plot shows only the intersection of **y1** and **y2**. For example, if the interval is *+1 year*, the first year of the data is not shown. diff --git a/docs/transforms/sort.md b/docs/transforms/sort.md deleted file mode 100644 index 2367812e..00000000 --- a/docs/transforms/sort.md +++ /dev/null @@ -1,155 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import * as topojson from "topojson-client"; -import {computed, ref, shallowRef, onMounted} from "vue"; -import cars from "../data/cars.ts"; - -const sorted = ref(true); -const order = ref("ascending"); -const bls = shallowRef([]); -const us = shallowRef(null); -const statemesh = computed(() => us.value ? topojson.mesh(us.value, us.value.objects.states) : {type: null}); -const counties = computed(() => us.value ? topojson.feature(us.value, us.value.objects.counties).features : []); - -onMounted(() => { - d3.csv("../data/bls-metro-unemployment.csv", d3.autoType).then((data) => (bls.value = data)); - Promise.all([ - d3.json("../data/us-counties-10m.json"), - d3.csv("../data/us-county-population.csv") - ]).then(([_us, _population]) => { - const map = new Map(_population.map((d) => [d.state + d.county, +d.population])); - _us.objects.counties.geometries.forEach((g) => (g.properties.population = map.get(g.id))); - us.value = _us; - }); -}); - -</script> - -# Sort transform - -The **sort transform** sorts a mark’s index to change the effective order of data. The sort transform affects the order in which a mark’s graphical elements are drawn ([z-order](https://en.wikipedia.org/wiki/Z-order)), which can have a dramatic effect when these elements overlap. For example, see the bubble map of U.S. county population below; when the null sort order is used for input order, many small dots are hidden underneath larger ones. - -<p> - <label class="label-input"> - Sort by descending radius (r): - <input type="checkbox" v-model="sorted"> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-dot-sort -```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: "var(--vp-c-bg)", - strokeWidth: 1, - sort: sorted ? {channel: "-r"} : null - })) - ] -}) -``` -::: - -:::tip -Dots are sorted by descending **r** by default, so you may not need the **sort** option. -::: - -The sort transform can be applied either via the **sort** [mark option](../features/marks.md#mark-options), as above, or as an explicit [sort transform](#sort). The latter is generally only needed when composing multiple transforms, or to disambiguate the sort transform from imputed ordinal scale domains, *i.e.*, [scale sorting](../features/scales.md#sort-mark-option). - -As another example, in the line chart of unemployment rates below, lines for metropolitan areas in Michigan (which saw exceptionally high unemployment following the [financial crisis of 2008](https://en.wikipedia.org/wiki/2007–2008_financial_crisis), in part due to the [auto industry collapse](https://en.wikipedia.org/wiki/2008–2010_automotive_industry_crisis)) are highlighted in <span style="border-bottom: solid 2px var(--vp-c-red);">red</span>, and the **sort** option is used to draw them on top of other series. - -:::plot https://observablehq.com/@observablehq/plot-multiple-line-highlight -```js -Plot.plot({ - y: { - grid: true, - label: "Unemployment (%)" - }, - color: { - domain: [false, true], - range: ["#ccc", "red"] - }, - marks: [ - Plot.ruleY([0]), - Plot.line(bls, { - x: "date", - y: "unemployment", - z: "division", - sort: (d) => /, MI /.test(d.division), - stroke: (d) => /, MI /.test(d.division) - }) - ] -}) -``` -::: - -:::tip -You could say `sort: {channel: "stroke"}` here to avoid repeating the test function. -::: - -The index order also affects the behavior of certain transforms such as [stack](./stack.md) and [dodge](./dodge.md). - -<p> - <span class="label-input"> - Sort x order: - <label style="margin-left: 0.5em;"><input type="radio" name="order" value="ascending" v-model="order" /> ascending</label> - <label style="margin-left: 0.5em;"><input type="radio" name="order" value="descending" v-model="order" /> descending</label> - </span> -</p> - -:::plot https://observablehq.com/@observablehq/plot-dodge-cars-2 -```js -Plot.plot({ - height: 180, - marks: [ - Plot.dotX(cars, Plot.dodgeY({ - x: "weight (lb)", - title: "name", - fill: "currentColor", - sort: {channel: "x", order} - })) - ] -}) -``` -::: - -The closely-related [reverse transform](#reverse) likewise reverses the mark index, while the [shuffle transform](#shuffle) for randomizes the mark index’s order. - -## sort(*order*, *options*) {#sort} - -```js -Plot.sort("body_mass_g", {x: "culmen_length_mm", y: "culmen_depth_mm"}) -``` - -Sorts the data by the specified *order*, which is one of: - -- a comparator function, as with [*array*.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) -- an accessor function -- a field name -- a {*channel*, *order*} object - -In the object case, the **channel** option specifies the name of the channel, while the **order** option specifies *ascending* (the default) or *descending* order. You can also use the shorthand <span style="white-space: nowrap;">*-name* <VersionBadge version="0.6.7" /></span> to sort by descending order of the channel with the given *name*. For example, `sort: {channel: "-r"}` will sort by descending radius (**r**). - -In the function case, if the sort function does not take exactly one argument, it is interpreted as a comparator function; otherwise it is interpreted as an accessor function. - -## shuffle(*options*) {#shuffle} - -```js -Plot.shuffle({x: "culmen_length_mm", y: "culmen_depth_mm"}) -``` - -Shuffles the data randomly. If a **seed** option is specified, a [linear congruential generator](https://d3js.org/d3-random#randomLcg) with the given seed is used to generate random numbers; otherwise, Math.random is used. - -## reverse(*options*) {#reverse} - -```js -Plot.reverse({x: "culmen_length_mm", y: "culmen_depth_mm"}) -``` - -Reverses the order of the data. diff --git a/docs/transforms/stack.md b/docs/transforms/stack.md deleted file mode 100644 index b75375a6..00000000 --- a/docs/transforms/stack.md +++ /dev/null @@ -1,441 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import alphabet from "../data/alphabet.ts"; -import crimea from "../data/crimea.ts"; -import {computed, ref, shallowRef, onMounted} from "vue"; - -const congress = shallowRef([]); -const offsets = ref("wiggle"); -const offset = computed(() => offsets.value === "null" ? null : offsets.value); -const orders = ref("appearance"); -const order = computed(() => orders.value === "null" ? null : orders.value); -const reverse = ref(true); -const riaa = shallowRef([]); -const survey = shallowRef([]); -const scheme = Plot.scale({color: {type: "categorical"}}).range; - -onMounted(() => { - d3.csv("../data/riaa-us-revenue.csv", d3.autoType).then((data) => (riaa.value = data)); - d3.csv("../data/survey.csv", d3.autoType).then((data) => (survey.value = data)); - d3.csv("../data/us-congress-2023.csv", d3.autoType).then((data) => (congress.value = data)); -}); - -function Likert(responses) { - const map = new Map(responses); - return { - order: Array.from(map.keys()), - offset(I, X1, X2, Z) { - for (const stacks of I) { - for (const stack of stacks) { - const k = d3.sum(stack, (i) => (X2[i] - X1[i]) * (1 - map.get(Z[i]))) / 2; - for (const i of stack) { - X1[i] -= k; - X2[i] -= k; - } - } - } - } - }; -} - -const likert = Likert([ - ["Strongly Disagree", -1], - ["Disagree", -1], - ["Neutral", 0], - ["Agree", 1], - ["Strongly Agree", 1] -]); - -</script> - -# Stack transform - -The **stack transform** comes in two orientations: [stackY](#stackY) replaces **y** with **y1** and **y2** to form vertical↑ stacks grouped on **x**, while [stackX](#stackX) replaces **x** with **x1** and **x2** for horizontal→ stacks grouped on **y**. In effect, stacking transforms a *length* into *lower* and *upper* positions: the upper position of each element equals the lower position of the next element in the stack. Stacking makes it easier to perceive a total while still showing its parts. - -For example, below is a stacked area chart of [deaths in the Crimean War](https://en.wikipedia.org/wiki/Florence_Nightingale#Crimean_War) — predominantly from <span :style="{borderBottom: `solid ${scheme[0]} 3px`}">disease</span> — using Florence Nightingale’s data. - -:::plot https://observablehq.com/@observablehq/plot-crimean-war-casualties -```js -Plot.plot({ - y: {grid: true}, - color: {legend: true}, - marks: [ - Plot.areaY(crimea, {x: "date", y: "deaths", fill: "cause"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -The [areaY mark](../marks/area.md) applies the stackY transform implicitly if you do not specify either **y1** or **y2**. The same applies to [barY](../marks/bar.md) and [rectY](../marks/rect.md). You can invoke the stack transform explicitly as `Plot.stackY({x: "date", y: "deaths", fill: "cause"})` to produce an identical chart. -::: - -The stack transform works with any mark that consumes **y1** & **y2** or **x1** & **x2**, so you can stack rects, too. - -:::plot https://observablehq.com/@observablehq/plot-crimean-war-recty -```js -Plot.plot({ - y: {grid: true}, - marks: [ - Plot.rectY(crimea, {x: "date", y: "deaths", interval: "month", fill: "cause"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::info -The [**interval** mark option](./interval.md) specifies the periodicity of the data; without it, Plot wouldn’t know how wide to make the rects. -::: - -And you can stack bars if you’d prefer to treat *x* as ordinal. - -:::plot https://observablehq.com/@observablehq/plot-crimean-war-bary -```js -Plot.plot({ - x: { - interval: "month", - tickFormat: (d) => d.toLocaleString("en", {month: "narrow"}), - label: null - }, - y: {grid: true}, - marks: [ - Plot.barY(crimea, {x: "date", y: "deaths", fill: "cause"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::info -The [**interval** scale option](../features/scales.md#scale-transforms) specifies the periodicity of the data; without it, any gaps in the data would not be visible since barY implies that *x* is ordinal. -::: - -The stackY transform also outputs **y** representing the midpoint of **y1** and **y2**, and likewise stackX outputs **x** representing the midpoint of **x1** and **x2**. This is useful for point-based marks such as [text](../marks/text.md) and [dot](../marks/dot.md). Below, a single stacked horizontal [bar](../marks/bar.md) shows the relative frequency of English letters; this form is a compact alternative to a pie 🥧 or donut 🍩 chart. - -:::plot https://observablehq.com/@observablehq/plot-stacked-percentages -```js -Plot.plot({ - x: {percent: true}, - marks: [ - Plot.barX(alphabet, Plot.stackX({x: "frequency", fillOpacity: 0.3, inset: 0.5})), - Plot.textX(alphabet, Plot.stackX({x: "frequency", text: "letter", inset: 0.5})), - Plot.ruleX([0, 1]) - ] -}) -``` -::: - -The **order** option controls the order in which the layers are stacked. It defaults to null, meaning to respect the input order of the data. The *appearance* order excels when each series has a prominent peak, as in the chart below of [recording industry](https://en.wikipedia.org/wiki/Recording_Industry_Association_of_America) revenue. <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">Compact disc</span> sales started declining well before the rise of <span :style="{borderBottom: `solid 2px ${scheme[1]}`}">downloads</span> and <span :style="{borderBottom: `solid 2px ${scheme[3]}`}">streaming</span>, suggesting that the industry was slow to provide a convenient digital product and hence lost revenue to piracy. - -<p> - <label class="label-input"> - Order: - <select v-model="orders"> - <option>null</option> - <option>appearance</option> - <option>inside-out</option> - <option>sum</option> - <option>group</option> - <option>z</option> - </select> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-stacking-order -```js -Plot.plot({ - y: { - grid: true, - label: "Annual revenue (billions, adj.)", - transform: (d) => d / 1000 // convert millions to billions - }, - color: {legend: true}, - marks: [ - Plot.areaY(riaa, {x: "year", y: "revenue", z: "format", fill: "group", order}), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::info -In this data, the *group* field is a supercategory of the *format* field, which is useful to avoid overwhelming the color encoding with too many categories. For example, the *Vinyl* group includes both the *LP/EP* and *Vinyl Single* formats. -::: - -The **reverse** option reverses the order of layers. In conjunction with the *appearance* order, now layers enter from the bottom rather than the top. - -<p> - <label class="label-input"> - Reverse: - <input type="checkbox" v-model="reverse"> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-stacking-order-and-reverse -```js -Plot.plot({ - y: { - grid: true, - label: "Annual revenue (billions, adj.)", - transform: (d) => d / 1000 // convert millions to billions - }, - color: {legend: true}, - marks: [ - Plot.areaY(riaa, Plot.stackY({order: "appearance", reverse}, {x: "year", y: "revenue", z: "format", fill: "group"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -:::tip -The **reverse** option is also used by the [sort transform](./sort.md). To disambiguate, pass the *stack* options separately using the two-argument form of the stack transform as above. -::: - -The *value* **order** is worth special mention: it sorts each stack by value independently such that the order of layers can change, emphasizing the changing ranks of layers. This is sometimes called a “ribbon” chart. (In fact, the default null **order** supports changing order of layers, too! But most often data comes already sorted by series.) - -:::plot defer https://observablehq.com/@observablehq/plot-ribbon-chart -```js -Plot.plot({ - y: { - grid: true, - label: "Annual revenue (billions, adj.)", - transform: (d) => d / 1000 // convert millions to billions - }, - marks: [ - Plot.areaY(riaa, {x: "year", y: "revenue", z: "format", fill: "group", order: "value"}), - Plot.ruleY([0]) - ] -}) -``` -::: - -The **offset** option controls the baseline of stacked layers. It defaults to null for a *y* = 0 baseline (for stackY, or *x* = 0 for stackX). The *center* **offset** centers each stack independently per [Havre *et al.*](https://innovis.cpsc.ucalgary.ca/innovis/uploads/Courses/InformationVisualizationDetails2009/Havre2000.pdf); the *wiggle* **offset** minimizes apparent movement per [Byron & Wattenberg](http://leebyron.com/streamgraph/stackedgraphs_byron_wattenberg.pdf); these two offsets produce “streamgraphs”, so called for their fluid appearance. The *wiggle* **offset** changes the default **order** to *inside-out* to further minimize movement. - -<p> - <label class="label-input"> - Offset: - <select v-model="offsets"> - <option>null</option> - <option>center</option> - <option>wiggle</option> - </select> - </label> -</p> - -:::plot defer https://observablehq.com/@observablehq/plot-stack-offset -```js -Plot.plot({ - y: { - grid: true, - label: "Annual revenue (billions, adj.)", - transform: (d) => d / 1000 - }, - marks: [ - Plot.areaY(riaa, {x: "year", y: "revenue", z: "format", fill: "group", offset}) - ] -}) -``` -::: - -:::warning CAUTION -When **offset** is not null, the *y* axis is harder to use because there is no longer a shared baseline at *y* = 0, though it is still useful for eyeballing length. -::: - -The *normalize* **offset** is again worth special mention: it scales stacks to fill the interval [0, 1], thereby showing the relative proportion of each layer. Sales of <span :style="{borderBottom: `solid 2px ${scheme[0]}`}">compact discs</span> accounted for over 90% of revenue in the early 2000’s, but now most revenue comes from <span :style="{borderBottom: `solid 2px ${scheme[3]}`}">streaming</span>. - -:::plot defer https://observablehq.com/@observablehq/plot-normalized-stack -```js -Plot.plot({ - y: { - label: "Annual revenue (%)", - percent: true - }, - marks: [ - Plot.areaY(riaa, Plot.stackY({offset: "normalize", order: "group", reverse: true}, {x: "year", y: "revenue", z: "format", fill: "group"})), - Plot.ruleY([0, 1]) - ] -}) -``` -::: - -When the provided length (typically **y**) is negative, in conjunction with the null **offset** the stack transform will produce diverging stacks on opposites sides of the zero baseline. The diverging stacked dot plot below shows the age and gender distribution of the U.S. Congress in 2023. This form is also often *popular* for [population pyramids](https://observablehq.com/@observablehq/plot-population-pyramid). - -:::plot defer https://observablehq.com/@observablehq/plot-stacked-dots -```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. -::: - -When visualizing [Likert scale](https://en.wikipedia.org/wiki/Likert_scale) survey results we may wish to place <span :style="{borderWidth: 2, borderBottomStyle: 'solid', borderImage: `linear-gradient(to right, ${d3.schemeRdBu[5][0]}, ${d3.schemeRdBu[5][1]}) 2`}">negative</span> (disagreeing) responses on the left and <span :style="{borderWidth: 2, borderBottomStyle: 'solid', borderImage: `linear-gradient(to right, ${d3.schemeRdBu[5][3]}, ${d3.schemeRdBu[5][4]}) 2`}">positive</span> (agreeing) responses on the right, leaving <span :style="{borderBottom: `solid 2px ${d3.schemeRdBu[5][2]}`}">neutral</span> responses in the middle. This is achieved below using a custom **offset** function. - -:::plot defer https://observablehq.com/@observablehq/plot-diverging-stacked-bar -```js -Plot.plot({ - x: {tickFormat: Math.abs}, - color: {domain: likert.order, scheme: "RdBu", legend: true}, - marks: [ - Plot.barX( - survey, - Plot.groupZ({x: "count"}, {fy: "Question", fill: "Response", ...likert}) - ), - Plot.ruleX([0]) - ] -}) -``` -::: - -Here `likert` declares which response values are negative (`-1`), which are positive (`1`), and which are neutral (`0`). - -```js -likert = Likert([ - ["Strongly Disagree", -1], - ["Disagree", -1], - ["Neutral", 0], - ["Agree", 1], - ["Strongly Agree", 1] -]) -``` - -And `Likert` implements the **order** (as an explicit array of ordinal values, such that the ordinal color scale lists in the correct order rather than sorting alphabetically) and **offset** (as a function that mutates the **x1** and **x2** channel values) stack options. - -```js -function Likert(responses) { - const map = new Map(responses); - return { - order: Array.from(map.keys()), - offset(I, X1, X2, Z) { - for (const stacks of I) { - for (const stack of stacks) { - const k = d3.sum(stack, (i) => (X2[i] - X1[i]) * (1 - map.get(Z[i]))) / 2; - for (const i of stack) { - X1[i] -= k; - X2[i] -= k; - } - } - } - } - }; -} -``` - -See the [Marimekko example](https://observablehq.com/@observablehq/plot-marimekko) for another interesting application of the stack transform. - -## Stack options - -The stackY transform groups on **x** and transforms **y** into **y1** and **y2**; the stackX transform groups on **y** and transforms **x** into **x1** and **x2**. If **y** is not specified for stackY, or if **x** is not specified for stackX, it defaults to the constant one, which is useful for constructing simple isotype charts (*e.g.*, stacked dots). - -The supported stack options are: - -- **offset** - the offset (or baseline) method -- **order** - the order in which stacks are layered -- **reverse** - true to reverse order - -The following **order** methods are supported: - -- null (default) - input order -- *value* - ascending value order (or descending with **reverse**) -- *x* - alias of *value*; for stackX only -- *y* - alias of *value*; for stackY only -- *sum* - order series by their total value -- *appearance* - order series by the position of their maximum value -- *inside-out* (default with *wiggle*) - order the earliest-appearing series on the inside -- a named field or function of data - order data by priority -- an array of *z* values - -The **reverse** option reverses the effective order. For the *value* order, stackY uses the *y* value while stackX uses the *x* value. For the *appearance* order, stackY uses the *x* position of the maximum *y* value while stackX uses the *y* position of the maximum *x* value. If an array of *z* values are specified, they should enumerate the *z* values for all series in the desired order; this array is typically hard-coded or computed with [d3.groupSort](https://d3js.org/d3-array/group#groupSort). Note that the input order (null) and *value* order can produce crossing paths: they do not guarantee a consistent series order across stacks. - -The stack transform supports diverging stacks: negative values are stacked below zero while positive values are stacked above zero. For stackY, the **y1** channel contains the value of lesser magnitude (closer to zero) while the **y2** channel contains the value of greater magnitude (farther from zero); the difference between the two corresponds to the input **y** channel value. For stackX, the same is true, except for **x1**, **x2**, and **x** respectively. - -After all values have been stacked from zero, an optional **offset** can be applied to translate or scale the stacks. The following **offset** methods are supported: - -- null (default) - a zero baseline -- *normalize* - rescale each stack to fill [0, 1] -- *center* - align the centers of all stacks -- *wiggle* - translate stacks to minimize apparent movement -- a function to be passed a nested index, and start, end, and *z* values - -If a given stack has zero total value, the *normalize* offset will not adjust the stack’s position. Both the *center* and *wiggle* offsets ensure that the lowest element across stacks starts at zero for better default axes. The *wiggle* offset is recommended for streamgraphs, and if used, changes the default order to *inside-out*; see [Byron & Wattenberg](http://leebyron.com/streamgraph/). - -If the offset is specified as a function, it will receive four arguments: an index of stacks nested by facet and then stack, an array of start values, an array of end values, and an array of *z* values. For stackX, the start and end values correspond to **x1** and **x2**, while for stackY, the start and end values correspond to **y1** and **y2**. The offset function is then responsible for mutating the arrays of start and end values, such as by subtracting a common offset for each of the indices that pertain to the same stack. - -In addition to the **y1** and **y2** output channels, stackY computes a **y** output channel that represents the midpoint of **y1** and **y2**; stackX does the same for **x**. This can be used to position a label or a dot in the center of a stacked layer. The **x** and **y** output channels are lazy: they are only computed if needed by a downstream mark or transform. - -If two arguments are passed to the stack transform functions below, the stack-specific options (**offset**, **order**, and **reverse**) are pulled exclusively from the first *options* argument, while any channels (*e.g.*, **x**, **y**, and **z**) are pulled from second *options* argument. Options from the second argument that are not consumed by the stack transform will be passed through. Using two arguments is sometimes necessary is disambiguate the option recipient when chaining transforms. - -## stackY(*stack*, *options*) {#stackY} - -```js -Plot.stackY({x: "year", y: "revenue", z: "format", fill: "group"}) -``` - -Creates new channels **y1** and **y2**, obtained by stacking the original **y** channel for data points that share a common **x** (and possibly **z**) value. A new **y** channel is also returned, which lazily computes the middle value of **y1** and **y2**. The input **y** channel defaults to a constant 1, resulting in a count of the data points. The stack options (**offset**, **order**, and **reverse**) may be specified as part of the *options* object, if the only argument, or as a separate *stack* options argument. - -## stackY1(*stack*, *options*) {#stackY1} - -```js -Plot.stackY1({x: "year", y: "revenue", z: "format", fill: "group"}) -``` - -Like [stackY](#stackY), except that the **y1** channel is returned as the **y** channel. This can be used, for example, to draw a line at the bottom of each stacked area. - -## stackY2(*stack*, *options*) {#stackY2} - -```js -Plot.stackY2({x: "year", y: "revenue", z: "format", fill: "group"}) -``` - -Like [stackY](#stackY), except that the **y2** channel is returned as the **y** channel. This can be used, for example, to draw a line at the top of each stacked area. - -## stackX(*stack*, *options*) {#stackX} - -```js -Plot.stackX({y: "year", x: "revenue", z: "format", fill: "group"}) -``` - -Like [stackY](#stackY), but with *x* as the input value channel, *y* as the stack index, *x1*, *x2* and *x* as the output channels. - -## stackX1(*stack*, *options*) {#stackX1} - -```js -Plot.stackX1({y: "year", x: "revenue", z: "format", fill: "group"}) -``` - -Like [stackX](#stackX), except that the **x1** channel is returned as the **x** channel. This can be used, for example, to draw a line at the left edge of each stacked area. - -## stackX2(*stack*, *options*) {#stackX2} - -```js -Plot.stackX2({y: "year", x: "revenue", z: "format", fill: "group"}) -``` - -Like [stackX](#stackX), except that the **x2** channel is returned as the **x** channel. This can be used, for example, to draw a line at the right edge of each stacked area. diff --git a/docs/transforms/tree.md b/docs/transforms/tree.md deleted file mode 100644 index 78dc446f..00000000 --- a/docs/transforms/tree.md +++ /dev/null @@ -1,114 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; - -const gods = [ - "Chaos/Gaia/Mountains", - "Chaos/Gaia/Pontus", - "Chaos/Gaia/Uranus", - "Chaos/Eros", - "Chaos/Erebus", - "Chaos/Tartarus" -]; - -function indent() { - return (root) => { - root.eachBefore((node, i) => { - node.y = node.depth; - node.x = i; - }); - }; -} - -</script> - -# Tree transform <VersionBadge version="0.4.3" /> - -The **tree transform** is rarely used directly; the two variants, [treeNode](#treeNode) and [treeLink](#treeLink), are typically used internally by the composite [tree mark](../marks/tree.md). The tree transform arranges a tabular dataset into a hierarchy according to the given **path** channel, which is typically a slash-separated string; it then executes a tree layout algorithm to compute **x** and **y**; these channels can then be used to construct a node-link diagram. - -As a contrived example, we can construct the equivalent of the tree mark using a [link](../marks/link.md), [dot](../marks/dot.md), and [text](../marks/text.md), and the corresponding tree transforms. - -:::plot https://observablehq.com/@observablehq/plot-tree-and-link -```js -Plot.plot({ - axis: null, - height: 100, - margin: 20, - marginRight: 120, - marks: [ - Plot.link(gods, Plot.treeLink()), - Plot.dot(gods, Plot.treeNode()), - Plot.text(gods, Plot.treeNode({text: "node:name", dx: 6})) - ] -}) -``` -::: - -Here `gods` is an array of slash-separated paths, similar to paths in a file system. Each path represents the hierarchical position of a node in the tree. - -```js-vue -gods = {{JSON.stringify(gods, null, 2)}} -``` - -:::tip -Given a text file, you can use `text.split("\n")` to split the contents into multiple lines. -::: - -## Tree options - -The following options control how the tabular data is organized into a hierarchy: - -* **path** - a column specifying each node’s hierarchy location; defaults to identity -* **delimiter** - the path separator, a single character; defaults to forward slash (/) - -The **path** column is typically slash-separated, as with UNIX-based file systems or URLs. - -The following options control how the node-link diagram is laid out: - -* **treeLayout** - a tree layout algorithm; defaults to [d3.tree](https://d3js.org/d3-hierarchy/tree) -* **treeAnchor** - a tree layout orientation, either *left* or *right*; defaults to *left* -* **treeSort** - a node comparator, or null to preserve input order -* **treeSeparation** - a node separation function, or null for uniform separation - -The default **treeLayout** implements the Reingold–Tilford “tidy” algorithm based on Buchheim _et al._’s linear time approach. Use [d3.cluster](https://d3js.org/d3-hierarchy/cluster) instead to align leaf nodes; see also the [cluster mark](../marks/tree.md#cluster). - -If **treeAnchor** is *left*, the root of the tree will be aligned with the left side of the frame; if **treeAnchor** is *right*, the root of the tree will be aligned with the right side of the frame; use the **insetLeft** and **insetRight** [scale options](../features/scales.md) if horizontal padding is desired, say to make room for labels. - -If the **treeSort** option is not null, it is typically a function that is passed two nodes in the hierarchy and compares them, similar to [_array_.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort); see [d3-hierarchy’s _node_.sort](https://d3js.org/d3-hierarchy/hierarchy#node_sort) for more. The **treeSort** option can also be specified as a string, in which case it refers either to a named column in data, or if it starts with “node:”, a node value. - -If the **treeSeparation** is not null, it is a function that is passed two nodes in the hierarchy and returns the desired (relative) amount of separation; see [d3-hierarchy’s _tree_.separation](https://d3js.org/d3-hierarchy/tree#tree_separation) for more. By default, non-siblings are at least twice as far apart as siblings. - -## treeNode(*options*) {#treeNode} - -Populates **x** and **y** with the positions for each node in the tree. The default **frameAnchor** inherits the **treeAnchor**. This transform is often used with the [dot](../marks/dot.md) or [text](../marks/text.md) mark. - -The treeNode transform will derive output columns for any *options* that have one of the following named node values: - -* *node:name* - the node’s name (the last part of its path) -* *node:path* - the node’s full, normalized, slash-separated path -* *node:internal* - true if the node is internal, or false for leaves -* *node:external* - true if the node is a leaf, or false for internal nodes -* *node:depth* - the distance from the node to the root -* *node:height* - the distance from the node to its deepest descendant - -In addition, if any option value is specified as an object with a **node** method, a derived output column will be generated by invoking the **node** method for each node in the tree. - -## treeLink(*options*) {#treeLink} - -Populates **x1**, **y1**, **x2**, and **y2** with the positions for each link in the tree, where **x1** & **y1** represents the position of the parent node and **x2** & **y2** the position of the child node. The default **curve** is *bump-x*, the default **stroke** is #555, the default **strokeWidth** is 1.5, and the default **strokeOpacity** is 0.5. This transform is often used with the [link](../marks/link.md) or [arrow](../marks/arrow.md) mark. - -The treeLink transform will likewise derive output columns for any *options* that have one of the following named link values: - -* *node:name* - the child node’s name (the last part of its path) -* *node:path* - the child node’s full, normalized, slash-separated path -* *node:internal* - true if the child node is internal, or false for leaves -* *node:external* - true if the child node is a leaf, or false for internal nodes -* *node:depth* - the distance from the child node to the root -* *node:height* - the distance from the child node to its deepest descendant -* *parent:name* - the parent node’s name (the last part of its path) -* *parent:path* - the parent node’s full, normalized, slash-separated path -* *parent:depth* - the distance from the parent node to the root -* *parent:height* - the distance from the parent node to its deepest descendant - -In addition, if any option value is specified as an object with a **node** method, a derived output column will be generated by invoking the **node** method for each child node in the tree; likewise if any option value is specified as an object with a **link** method, a derived output column will be generated by invoking the **link** method for each link in the tree, being passed two node arguments, the child and the parent. diff --git a/docs/transforms/window.md b/docs/transforms/window.md deleted file mode 100644 index 6de6910a..00000000 --- a/docs/transforms/window.md +++ /dev/null @@ -1,206 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {ref, shallowRef, onMounted} from "vue"; -import sftemp from "../data/sf-temperatures.ts"; - -const k = ref(7); -const loss = ref(0.01); -const anchor = ref("end"); -const strict = ref(true); -const reduce = ref("mean"); -const bls = shallowRef([]); - -onMounted(() => { - d3.csv("../data/bls-metro-unemployment.csv", d3.autoType).then((data) => (bls.value = data)); -}); - -</script> - -# Window transform - -The **window transform** is a specialized [map transform](./map.md) that computes a moving window and then derives summary statistics from the current window, say to compute rolling averages, rolling minimums, or rolling maximums. - -For example, the band chart below shows the daily high and low temperature in San Francisco. The <span style="border-bottom: solid 2px var(--vp-c-red)">red</span> line represents the {{k}}-day average high, and the <span style="border-bottom: solid 2px var(--vp-c-blue)">blue</span> line the {{k}}-day average low. - -<p> - <label class="label-input"> - <span>Window size (k):</span> - <input type="range" v-model.number="k" min="1" max="100" step="1" /> - <span style="font-variant-numeric: tabular-nums;">{{k.toLocaleString("en-US")}}</span> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-window-line-area -```js -Plot.plot({ - y: { - grid: true, - label: "Temperature (°F)" - }, - marks: [ - Plot.areaY(sftemp, {x: "date", y1: "low", y2: "high", fillOpacity: 0.3}), - Plot.lineY(sftemp, Plot.windowY(k, {x: "date", y: "low", stroke: "blue"})), - Plot.lineY(sftemp, Plot.windowY(k, {x: "date", y: "high", stroke: "red"})) - ] -}) -``` -::: - -The **k** option specifies the window size: the number of consecutive elements in the rolling window. A larger window for the rolling mean above produces a smoother curve. - -The **anchor** specifies how to align the rolling window with the data. If *middle* (the default), the window is centered around the current data point; for time-series data, this means the window will incorporate values from the future as well as the past. Setting **anchor** to *end* will compute a trailing moving average. - -<p> - <span class="label-input"> - Anchor: - <label style="margin-left: 0.5em;"> - <input type="radio" name="anchor" value="start" v-model="anchor" /> start - </label> - <label style="margin-left: 0.5em;"> - <input type="radio" name="anchor" value="middle" v-model="anchor" /> middle - </label> - <label style="margin-left: 0.5em;"> - <input type="radio" name="anchor" value="end" v-model="anchor" /> end - </label> - </span> -</p> - -:::plot https://observablehq.com/@observablehq/plot-window-anchor -```js -Plot.plot({ - y: { - grid: true, - label: "Temperature (°F)" - }, - marks: [ - Plot.lineY(sftemp, {x: "date", y: "high", strokeOpacity: 0.3}), - Plot.lineY(sftemp, Plot.windowY({k: 28, anchor}, {x: "date", y: "high"})) - ] -}) -``` -::: - -The window transform uses input order, not natural order by value, to determine the meaning of *start* and *end*. When the data is in reverse chronological order, the meaning of *start* and *end* is effectively reversed because the first data point is the most recent. Use a [sort transform](./sort.md) to change the order as needed. - -If **strict** is false (the default), the window size is effectively reduced at the start or end of each series or both, depending on the **anchor**. Values computed with a truncated window may be noisy; if you would prefer to not show this data instead, set the **strict** option to true. <VersionBadge version="0.6.0" /> The **strict** option can also have a dramatic effect if some data is missing: when strict, the reducer will be skipped if any of the values in the current window are null, undefined, or NaN. - -<p> - <label class="label-input"> - Strict: - <input type="checkbox" v-model="strict" /> - </label> -</p> - -:::plot https://observablehq.com/@observablehq/plot-window-anchor -```js -Plot.plot({ - y: { - grid: true, - label: "Temperature (°F)" - }, - marks: [ - Plot.lineY(sftemp, {x: "date", y: "low", strokeOpacity: 0.3}), - Plot.lineY(sftemp, Plot.windowY({k: 28, anchor: "end", strict}, {x: "date", y: "low"})) - ] -}) -``` -::: - -The **reduce** option specifies how to compute the output value for the current window. It defaults to *mean* for a rolling average. Below, the rolling <span style="border-bottom: solid 2px var(--vp-c-blue)">minimum</span>, <span style="border-bottom: solid 2px var(--vp-c-red)">maximum</span>, and <span style="border-bottom: solid 2px;">median</span> are shown. The window transform supports most of the same reducers as [bin](./bin.md) and [group](./group.md), and you can implement a custom reducer as a function if needed. - -:::plot https://observablehq.com/@observablehq/plot-window-reduce -```js -Plot.plot({ - y: { - grid: true, - label: "Temperature (°F)" - }, -marks: [ - Plot.lineY(sftemp, {x: "date", y: "low", strokeOpacity: 0.3}), - Plot.lineY(sftemp, Plot.windowY({k: 28, reduce: "min"}, {x: "date", y: "low", stroke: "blue"})), - Plot.lineY(sftemp, Plot.windowY({k: 28, reduce: "max"}, {x: "date", y: "low", stroke: "red"})), - Plot.lineY(sftemp, Plot.windowY({k: 28, reduce: "median"}, {x: "date", y: "low"})) - ] -}) -``` -::: - -While the windowY transform derives **y** (and **y1** and **y2**), and the windowX transform likewise derives **x**, **x1**, and **x2**, you can use the [map transform](./map.md) directly for other channels. For example, the chart below uses a variable **stroke** to encode slope: the monthly change in unemployment rate for each metropolitan division. The slope is computed with a window of size 2 and the *difference* reducer. - -:::plot defer https://observablehq.com/@observablehq/plot-window-and-map -```js -Plot.plot({ - y: {grid: true}, - color: {scheme: "BuYlRd", domain: [-0.5, 0.5]}, - marks: [ - Plot.ruleY([0]), - Plot.lineY( - bls, - Plot.map( - {stroke: Plot.window({k: 2, reduce: "difference"})}, - {x: "date", y: "unemployment", z: "division", stroke: "unemployment"} - ) - ), - ] -}) -``` -::: - -As shown above, the window transform also understands the **z** channel: each metropolitan division is treated as a separate series. - -## Window options - -The window transform supports the following options: - -* **k** - the window size (the number of elements in the window) -* **anchor** - how to align the window: *start*, *middle* (default), or *end* -* **reduce** - the window reducer; defaults to *mean* -* **strict** - if true, output undefined if any window value is undefined; defaults to false - -If the **strict** option is false (the default), the window will be automatically truncated as needed, and undefined input values are ignored. For example, if **k** is 24 and **anchor** is *middle*, then the initial 11 values have effective window sizes of 13, 14, 15, … 23, and likewise the last 12 values have effective window sizes of 23, 22, 21, … 12. Values computed with a truncated window may be noisy; if you would prefer to not show this data, set the **strict** option to true. - -If the **strict** option is true, the output start values or end values or both (depending on the **anchor**) of each series may be undefined since there are not enough elements to create a window of size **k**; output values may also be undefined if some of the input values in the corresponding window are undefined. - -The following named reducers are supported: - -* *min* - the minimum -* *max* - the maximum -* *mean* - the mean (average) -* *median* - the median -* *mode* - the mode (most common occurrence) -* *pXX* - the percentile value, where XX is a number in [00,99] -* *sum* - the sum of values -* *deviation* - the standard deviation -* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) -* *difference* - the difference between the last and first window value -* *ratio* - the ratio of the last and first window value -* *first* - the first value -* *last* - the last value - -A reducer may also be specified as a function to be passed an index of size **k** and the corresponding input channel array; or if the function only takes one argument, an array of **k** values. - -## window(*k*) <VersionBadge version="0.2.3" /> {#window} - -```js -Plot.map({y: Plot.window(24)}, {x: "Date", y: "Close", stroke: "Symbol"}) -``` - -Returns a window map method for the given window size *k*, suitable for use with Plot.map. For additional options to the window transform, replace the number *k* with an object with properties **k**, **anchor**, **reduce**, or **strict**. - -## windowX(*k*, *options*) {#windowX} - -```js -Plot.windowX(24, {y: "Date", x: "Anomaly"}) -``` - -Like [mapX](./map.md#mapX), but applies the window map method with the given window size *k*. For additional options to the window transform, replace the number *k* with an object with properties **k**, **anchor**, or **reduce**. - -## windowY(*k*, *options*) {#windowY} - -```js -Plot.windowY(24, {x: "Date", y: "Anomaly"}) -``` - -Like [mapY](./map.md#mapY), but applies the window map method with the given window size *k*. For additional options to the window transform, replace the number *k* with an object with properties **k**, **anchor**, or **reduce**. diff --git a/docs/vite.docs.config.ts b/docs/vite.docs.config.ts index 94091759..c1096440 100644 --- a/docs/vite.docs.config.ts +++ b/docs/vite.docs.config.ts @@ -48,7 +48,7 @@ export default defineConfig({ ], resolve: { alias: { - "@dave-hillier/replot/react": path.resolve(__dirname, "../src/react/index.tsx"), + "@dave-hillier/replot/react": path.resolve(__dirname, "../src/react/api.tsx"), "@dave-hillier/replot": path.resolve(__dirname, "../src/index.js") }, dedupe: ["react", "react-dom"] diff --git a/docs/what-is-plot.md b/docs/what-is-plot.md deleted file mode 100644 index b57b2c2f..00000000 --- a/docs/what-is-plot.md +++ /dev/null @@ -1,112 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import {computed, onMounted, shallowRef} from "vue"; -import {useData} from "vitepress"; -import PlotRender from "./components/PlotRender.js"; - -const olympians = shallowRef([ - {weight: 31, height: 1.21, sex: "female"}, - {weight: 170, height: 2.21, sex: "male"} -]); - -onMounted(() => { - d3.csv("./data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); -}); - -const {site: {value: {themeConfig: {sidebar}}}} = useData(); - -const paths = computed(() => { - const paths = []; - (function visit(node, path) { - paths.push({path, link: node.link && `.${node.link}`}); - if (node.items) { - for (const item of node.items) { - visit(item, (path === "/" ? path : path + "/") + item.text); - } - } - })({items: sidebar}, "/Plot"); - return paths; -}); - -// https://github.com/observablehq/plot/issues/1703 -function computeTreeWidth(paths) { - const root = d3.tree().nodeSize([1, 1])(d3.stratify().path((d) => d.path)(paths)); - const [x1, x2] = d3.extent(root, (d) => d.x); - return x2 - x1; -} - -</script> - -# What is Plot? - -**Replot** is a free, open-source, JavaScript library for visualizing tabular data, focused on accelerating exploratory data analysis. It has a concise, memorable, yet expressive interface, featuring [scales](./features/scales.md) and [layered marks](./features/marks.md) in the *grammar of graphics* style popularized by [Leland Wilkinson](https://en.wikipedia.org/wiki/Leland_Wilkinson) and [Hadley Wickham](https://en.wikipedia.org/wiki/Hadley_Wickham) and inspired by the earlier ideas of [Jacques Bertin](https://en.wikipedia.org/wiki/Jacques_Bertin). And there are [plenty of examples](https://observablehq.com/@observablehq/plot-gallery) to learn from and copy-paste. - -Plot offers two APIs: a concise **imperative API** for vanilla JavaScript environments and a **declarative React component API** for building charts with JSX. Both APIs share the same powerful core — D3 scales, transforms, projections, and mark types — so you get the same expressive visualizations regardless of which API you choose. See the [getting started guide](./getting-started.md) for details on both approaches. - -In the spirit of *show don’t tell*, here’s a scatterplot of body measurements of athletes from the [2016 Summer Olympics](https://flother.is/2017/olympic-games-data/). - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-scatterplot -```js -Plot - .dot(olympians, {x: "weight", y: "height", stroke: "sex"}) - .plot({color: {legend: true}}) -``` -::: - -A plot specification assigns columns of data (*weight*, *height*, and *sex*) to visual properties of marks (**x**, **y**, and **stroke**). Plot does the rest! You can configure much more, if needed, but Plot’s goal is to help you get a meaningful visualization quickly to accelerate analysis. - -This scatterplot suffers from overplotting: many dots are drawn in the same spot, so it’s hard to perceive density. We can fix this by applying a [bin transform](./transforms/bin.md) to group athletes of similar height and weight (and sex), and then use opacity to encode the number of athletes in the bin. - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-bins -```js -Plot.rect(olympians, Plot.bin({fillOpacity: "count"}, {x: "weight", y: "height", fill: "sex", inset: 0})).plot() -``` -::: - -Or we could try the [density mark](./marks/density.md). - -:::plot defer https://observablehq.com/@observablehq/plot-olympians-density -```js -Plot.density(olympians, {x: "weight", y: "height", stroke: "sex"}).plot() -``` -::: - -A simpler take on this data is to focus on one dimension: weight. We can use the bin transform again to make a histogram with weight on the *x*-axis and frequency on the *y*-axis. This plot uses a [rect mark](./marks/rect.md) and an implicit [stack transform](./transforms/stack.md). - -:::plot defer https://observablehq.com/@observablehq/plot-vertical-histogram -```js -Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex"})).plot() -``` -::: - -Or if we’d prefer to show the two distributions separately as small multiples, we can [facet](./features/facets.md) the data along *y* (keeping the *fill* encoding for consistency, and adding grid lines and a rule at *y* = 0 to improve readability). - -:::plot defer https://observablehq.com/@observablehq/plot-faceted-histogram -```js -Plot.plot({ - grid: true, - marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", fy: "sex"})), - Plot.ruleY([0]) - ] -}) -``` -::: - -## What can Plot do? - -Because marks are composable, and because you can extend Plot with custom marks, you can make almost anything with it — much more than the charts above! The following [tree diagram](./marks/tree.md) of the documentation gives a sense of what’s ”in the box” with Plot. Peruse our [gallery of examples](https://observablehq.com/@observablehq/plot-gallery) for more inspiration. - -<PlotRender :options='{ - axis: null, - height: computeTreeWidth(paths) * 12, - marginTop: 4, - marginRight: 120, - marginBottom: 4, - marginLeft: 24, - marks: [ - Plot.tree(paths, {path: "path", textStroke: "var(--vp-c-bg)", channels: {href: {value: "link", filter: null}}, treeSort: null}) - ] -}' /> diff --git a/docs/what-is-plot.mdx b/docs/what-is-plot.mdx index 213bcc06..c1e4af2c 100644 --- a/docs/what-is-plot.mdx +++ b/docs/what-is-plot.mdx @@ -97,7 +97,7 @@ Because marks are composable, and because you can extend Replot with custom mark Replot is a fork of [Observable Plot](https://observablehq.com/plot/) that keeps its grammar — marks, scales, transforms, projections — but restructures how plots are expressed and rendered for React. The differences run deeper than a wrapper: -**The renderer is React itself.** Observable Plot builds SVG imperatively with D3 selections and returns a detached element you insert into the page. In Replot, every mark renders to JSX, and the `<Replot>` component renders a plot as an ordinary React subtree. There are no refs, effects, or manual DOM insertion; plots participate in reconciliation, Suspense, and server-side rendering like any other component. +**The renderer is React itself.** Observable Plot builds SVG imperatively with D3 selections and returns a detached element you insert into the page. In Replot, every mark renders to JSX, and the `<Replot>` component renders a plot as an ordinary React subtree. There is no manual DOM insertion, and plots participate in reconciliation and Suspense like any other component. On the server, `renderToString` returns the whole plot; a hydrating client redraws it, because marks register with the plot from an effect. **Marks are components; a plot is composition.** Where Plot takes a `marks: [...]` array of mark objects, Replot layers sibling components inside `<Replot>`. Under the hood this uses a two-phase render: mark components register their configuration with the enclosing `<Replot>` through context, the plot computes scales and layout from everything registered — the same whole-plot inference Plot performs — and then each mark renders its SVG. Adding, removing, or reconfiguring a mark through React state recomputes the plot automatically. diff --git a/docs/why-plot.md b/docs/why-plot.md deleted file mode 100644 index 1eb30055..00000000 --- a/docs/why-plot.md +++ /dev/null @@ -1,183 +0,0 @@ -<script setup> - -import * as Plot from "@dave-hillier/replot"; -import * as d3 from "d3"; -import aapl from "./data/aapl.ts"; -import penguins from "./data/penguins.ts"; - -function arealineY(data, {color, fillOpacity = 0.1, ...options} = {}) { - return Plot.marks( - Plot.ruleY([0]), - Plot.areaY(data, {fill: color, fillOpacity, ...options}), - Plot.lineY(data, {stroke: color, ...options}) - ); -} - -</script> - -# Why Plot? - -**Replot** is for exploratory data visualization. It’s for finding insights quickly. Its API, while expressive and configurable, optimizes for conciseness and memorability. We want the time to first chart to be as fast as possible. - -And the speed doesn’t stop there: Plot helps you quickly pivot and refine your views of data. Our hope with Plot is that you’ll spend less time reading the docs, searching for code to copy-paste, and debugging — and more time asking questions of data. - -Compared to other visualization tools, including low-level tools such as D3 and less expressive high-level tools such as chart templates, we think you’ll be more productive exploring data with Plot. You’ll spend more time “using vision to think” and less time wrangling the machinery of programming. - -Or put more simply: **with Plot, you’ll see more charts.** - -## Plot is concise - -You can make a meaningful chart in Plot with as little as one line of code. - -:::plot https://observablehq.com/@observablehq/color-scatterplot -```js -Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}).plot() -``` -::: - -What makes Plot concise? In a word: *defaults*. If you specify the semantics — your data and the desired encodings — Plot will figure out the rest. - -The beauty of defaults is that you can override them as needed. This is ideal for exploring: you invest minimally in the initial chart, and as you start to see something interesting, you progressively customize to improve the display. Perhaps the plot above would be easier to read with an aspect ratio proportional to the data, a grid, and a legend? - -:::plot https://observablehq.com/@observablehq/plot-refined-color-scatterplot -```js -Plot.plot({ - grid: true, - aspectRatio: 1, - inset: 10, - x: {tickSpacing: 80, label: "Culmen length (mm)"}, - y: {tickSpacing: 80, label: "Culmen depth (mm)"}, - color: {legend: true}, - marks: [ - Plot.frame(), - Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}) - ] -}) -``` -::: - -## Plot transforms data - -Munging data, not visualizing it, is often most of the work of data analysis. Plot’s [transforms](./features/transforms.md) let you aggregate and derive data *within* your plot specification, reducing the time spent preparing data. For example, if you have a dataset of penguins, you can quickly count their frequency by *species* with the [group transform](./transforms/group.md). - -:::plot https://observablehq.com/@observablehq/plot-groupy-transform -```js -Plot.plot({ - marginLeft: 80, - marginRight: 80, - marks: [ - Plot.barX(penguins, Plot.groupY({x: "count"}, {y: "species"})), - Plot.ruleX([0]) - ] -}) -``` -::: - -Because transforms are integrated into Plot, they work automatically with other Plot features such as [faceting](./features/facets.md). For example, to breakdown the chart above by *island*, we just add the **fy** (vertical facet) option. - -:::plot https://observablehq.com/@observablehq/plot-groupy-transform/2 -```js -Plot.plot({ - marginLeft: 80, - marginRight: 80, - marks: [ - Plot.barX(penguins, Plot.groupY({x: "count"}, {fy: "island", y: "species"})), - Plot.ruleX([0]) - ] -}) -``` -::: - -And to color by *sex*, too? Add **fill**; the [bar mark](./marks/bar.md) then applies an implicit [stack transform](./transforms/stack.md). - -:::plot https://observablehq.com/@observablehq/plot-groupy-transform/3 -```js -Plot.plot({ - marginLeft: 80, - marginRight: 80, - color: {legend: true}, - marks: [ - Plot.barX(penguins, Plot.groupY({x: "count"}, {fy: "island", y: "species", fill: "sex"})), - Plot.ruleX([0]) - ] -}) -``` -::: - -Plot’s transforms can do powerful things, including [normalizing series](./transforms/normalize.md), computing [moving averages](./transforms/window.md), laying out [trees](./marks/tree.md), [dodging](./transforms/dodge.md), and [hexagonal binning](./transforms/hexbin.md). - -## Plot is composable - -Simple components gain power through composition, such as layering multiple [marks](./features/marks.md) into a single plot. In React, this composability is especially natural — each mark is a JSX component that you nest inside `<Plot>`: - -```jsx -import {Plot, RuleY, AreaY, LineY} from "@dave-hillier/replot/react"; - -<Plot> - <RuleY data={[0]} /> - <AreaY data={aapl} x="Date" y="Close" fill="blue" fillOpacity={0.1} /> - <LineY data={aapl} x="Date" y="Close" stroke="blue" /> -</Plot> -``` - -With the imperative API, you can similarly define custom composite marks: - -```js -function arealineY(data, {color, fillOpacity = 0.1, ...options} = {}) { - return Plot.marks( - Plot.ruleY([0]), - Plot.areaY(data, {fill: color, fillOpacity, ...options}), - Plot.lineY(data, {stroke: color, ...options}) - ); -} -``` - -You can use this composite mark like any built-in mark: - -:::plot https://observablehq.com/@observablehq/plot-arealiney-custom-mark -```js -arealineY(aapl, {x: "Date", y: "Close", color: "blue"}).plot() -``` -::: - -Plot uses this technique internally: the [axis mark](./marks/axis.md) and [box mark](./marks/box.md) are both composite marks. - -:::plot https://observablehq.com/@observablehq/plot-penguins-horizontal-box-plot -```js -Plot.boxX(penguins, {x: "body_mass_g", y: "species"}).plot({marginLeft: 60, y: {label: null}}) -``` -::: - -Plot’s [transforms](./features/transforms.md) are composable, too: to apply multiple transforms, you simply pass the *options* from one transform to the next. Some marks even apply implicit transforms, say for [stacking](./transforms/stack.md) or [binning](./transforms/bin.md) as shown [above](#plot-transforms-data). Mark options are plain JavaScript objects, so you can also share options across marks and inspect them to debug. - -## Plot is extensible - -Plot isn’t a new language; it’s “just” vanilla JavaScript. Plot embraces JavaScript, letting you plug in your own functions for accessors, reducers, transforms… even custom marks! And Plot generates SVG, so you can style it with CSS and manipulate it just like you do with D3. (See [Mike Freeman’s tooltip plugin](https://observablehq.com/@mkfreeman/plot-tooltip) for a great example of extending Plot this way.) - -## Plot builds on D3 - -Plot is informed by our more than [ten years’ experience](https://observablehq.com/@mbostock/10-years-of-open-source-visualization) developing [D3](https://d3js.org), the web’s most popular library for data visualization. - -Plot uses D3 to implement a wide variety of features: - -- scales (ticks, color schemes, number formatting) -- shapes (areas, lines, curves, symbols, stacks) -- planar geometry (Delaunay, Voronoi, contours, density estimation) -- spherical geometry (geographic projections) -- data manipulation (group, rollup, bin, statistics) -- tree diagrams -- … and more! - -If you already know some D3, you’ll find many parts of Plot familiar. - -We’ve long said that *D3 makes things possible, not necessarily easy.* And that’s true regardless of the task at hand. D3 makes hard and amazing things *possible*, yes, but even simple things that should be easy are often not. To paraphrase Amanda Cox: “Use D3 if you think it’s perfectly normal to write a hundred lines of code for a bar chart.” - -**Plot’s goal is to make the easy things easy, and fast, and then some.** - -:::tip -Whether or not Plot succeeds at this goal is up to you — so we’d love [your feedback](https://github.com/observablehq/plot/discussions/new/choose) on what you find easy or hard to do with Plot. And we encourage you to [ask for help](https://github.com/observablehq/plot/discussions/categories/q-a) when you get stuck. We learn a lot from helping! -::: - -Since Plot and D3 have different goals, they make different trade-offs. Plot is more efficient: you can make charts quickly. But it is also necessarily less expressive: bespoke visualizations with extensive animation and interaction, advanced techniques like force-directed graph layout, or even developing your own charting library, are better done with D3’s low-level API. - -We recommend D3 for *bespoke* data visualizations, if you decide the extra expressiveness of D3 is worth the time and effort. D3 makes sense for media organizations such as *The New York Times* or *The Pudding*, where a single graphic may be seen by a million readers, and where a team of editors can work together to advance the state of the art in visual communication; but is it the best tool for building your team’s private dashboard, or a one-off analysis? You may be surprised how far you can get with Plot. diff --git a/eslint.config.js b/eslint.config.js index ebf15fd5..c0e88861 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,4 @@ +import reactHooks from "eslint-plugin-react-hooks"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; import globals from "globals"; import tsParser from "@typescript-eslint/parser"; @@ -63,6 +64,22 @@ export default [ ] } }, + { + ...reactHooks.configs.flat.recommended, + files: ["src/react/**/*.tsx", "src/react/**/*.ts"], + rules: { + ...reactHooks.configs.flat.recommended.rules, + // The mark registry registers marks during render and records resolved + // options on the mark instance, by design: <Plot> must see every mark + // before its compute effect runs. The React Compiler rules read those + // patterns as violations, so they are reported rather than failed. + // react-hooks/rules-of-hooks stays an error: hook order is not something + // this layer is allowed to get wrong. + "react-hooks/immutability": "warn", + "react-hooks/refs": "warn", + "react-hooks/set-state-in-effect": "warn" + } + }, { files: ["test/**/*.js", "test/**/*.ts", "test/**/*.tsx"], languageOptions: { diff --git a/img/accent.png b/img/accent.png deleted file mode 100644 index b9849d7a..00000000 Binary files a/img/accent.png and /dev/null differ diff --git a/img/airports-clip-land.png b/img/airports-clip-land.png deleted file mode 100644 index 1dc88fc7..00000000 Binary files a/img/airports-clip-land.png and /dev/null differ diff --git a/img/anscombe.png b/img/anscombe.png deleted file mode 100644 index efc5d8ef..00000000 Binary files a/img/anscombe.png and /dev/null differ diff --git a/img/arc-diagram.png b/img/arc-diagram.png deleted file mode 100644 index 08db805d..00000000 Binary files a/img/arc-diagram.png and /dev/null differ diff --git a/img/area.png b/img/area.png deleted file mode 100644 index 9d574fb0..00000000 Binary files a/img/area.png and /dev/null differ diff --git a/img/arrow.png b/img/arrow.png deleted file mode 100644 index a0485740..00000000 Binary files a/img/arrow.png and /dev/null differ diff --git a/img/aspect-ratio.webp b/img/aspect-ratio.webp deleted file mode 100644 index f62907b1..00000000 Binary files a/img/aspect-ratio.webp and /dev/null differ diff --git a/img/auto-bar.png b/img/auto-bar.png deleted file mode 100644 index 3d883172..00000000 Binary files a/img/auto-bar.png and /dev/null differ diff --git a/img/auto-bin-color.webp b/img/auto-bin-color.webp deleted file mode 100644 index 9f2a6711..00000000 Binary files a/img/auto-bin-color.webp and /dev/null differ diff --git a/img/auto-bin.webp b/img/auto-bin.webp deleted file mode 100644 index 5956091b..00000000 Binary files a/img/auto-bin.webp and /dev/null differ diff --git a/img/auto-dot.webp b/img/auto-dot.webp deleted file mode 100644 index a7ef79e4..00000000 Binary files a/img/auto-dot.webp and /dev/null differ diff --git a/img/auto-group.webp b/img/auto-group.webp deleted file mode 100644 index d1ec048c..00000000 Binary files a/img/auto-group.webp and /dev/null differ diff --git a/img/auto-line.webp b/img/auto-line.webp deleted file mode 100644 index c3a8ceab..00000000 Binary files a/img/auto-line.webp and /dev/null differ diff --git a/img/axes.png b/img/axes.png deleted file mode 100644 index 7cb1c90e..00000000 Binary files a/img/axes.png and /dev/null differ diff --git a/img/axis-ggplot.webp b/img/axis-ggplot.webp deleted file mode 100644 index fec2157d..00000000 Binary files a/img/axis-ggplot.webp and /dev/null differ diff --git a/img/axis-multiline.webp b/img/axis-multiline.webp deleted file mode 100644 index 3c01ffdc..00000000 Binary files a/img/axis-multiline.webp and /dev/null differ diff --git a/img/bar-shorthand.png b/img/bar-shorthand.png deleted file mode 100644 index 075718da..00000000 Binary files a/img/bar-shorthand.png and /dev/null differ diff --git a/img/bar-sort.png b/img/bar-sort.png deleted file mode 100644 index 7ca7b256..00000000 Binary files a/img/bar-sort.png and /dev/null differ diff --git a/img/bar.png b/img/bar.png deleted file mode 100644 index 10f367e8..00000000 Binary files a/img/bar.png and /dev/null differ diff --git a/img/barycentric-before-after.png b/img/barycentric-before-after.png deleted file mode 100644 index 974568a8..00000000 Binary files a/img/barycentric-before-after.png and /dev/null differ diff --git a/img/beagle.png b/img/beagle.png deleted file mode 100644 index d99bb3cf..00000000 Binary files a/img/beagle.png and /dev/null differ diff --git a/img/bin-interval.png b/img/bin-interval.png deleted file mode 100644 index 63eac9cb..00000000 Binary files a/img/bin-interval.png and /dev/null differ diff --git a/img/bin-title.png b/img/bin-title.png deleted file mode 100644 index 78cb660d..00000000 Binary files a/img/bin-title.png and /dev/null differ diff --git a/img/bin.png b/img/bin.png deleted file mode 100644 index c4bcb078..00000000 Binary files a/img/bin.png and /dev/null differ diff --git a/img/blues.png b/img/blues.png deleted file mode 100644 index 7b4ed430..00000000 Binary files a/img/blues.png and /dev/null differ diff --git a/img/bollinger.png b/img/bollinger.png deleted file mode 100644 index 75c449aa..00000000 Binary files a/img/bollinger.png and /dev/null differ diff --git a/img/boolean-color.png b/img/boolean-color.png deleted file mode 100644 index a1513838..00000000 Binary files a/img/boolean-color.png and /dev/null differ diff --git a/img/box.png b/img/box.png deleted file mode 100644 index 9832e94b..00000000 Binary files a/img/box.png and /dev/null differ diff --git a/img/brbg.png b/img/brbg.png deleted file mode 100644 index 5ca384f2..00000000 Binary files a/img/brbg.png and /dev/null differ diff --git a/img/bugn.png b/img/bugn.png deleted file mode 100644 index 8c9acc6a..00000000 Binary files a/img/bugn.png and /dev/null differ diff --git a/img/bupu.png b/img/bupu.png deleted file mode 100644 index a5ca36c1..00000000 Binary files a/img/bupu.png and /dev/null differ diff --git a/img/burd.png b/img/burd.png deleted file mode 100644 index 6c0f9f39..00000000 Binary files a/img/burd.png and /dev/null differ diff --git a/img/buylrd.png b/img/buylrd.png deleted file mode 100644 index 963e5f34..00000000 Binary files a/img/buylrd.png and /dev/null differ diff --git a/img/ca55-contours.webp b/img/ca55-contours.webp deleted file mode 100644 index c8465a3b..00000000 Binary files a/img/ca55-contours.webp and /dev/null differ diff --git a/img/ca55.webp b/img/ca55.webp deleted file mode 100644 index f8b24e43..00000000 Binary files a/img/ca55.webp and /dev/null differ diff --git a/img/category10.png b/img/category10.png deleted file mode 100644 index 6812c8a7..00000000 Binary files a/img/category10.png and /dev/null differ diff --git a/img/cell-categorical.png b/img/cell-categorical.png deleted file mode 100644 index bb249338..00000000 Binary files a/img/cell-categorical.png and /dev/null differ diff --git a/img/cell.png b/img/cell.png deleted file mode 100644 index 6d4ad273..00000000 Binary files a/img/cell.png and /dev/null differ diff --git a/img/choropleth.png b/img/choropleth.png deleted file mode 100644 index d7712cdd..00000000 Binary files a/img/choropleth.png and /dev/null differ diff --git a/img/cividis.png b/img/cividis.png deleted file mode 100644 index ad9dbb15..00000000 Binary files a/img/cividis.png and /dev/null differ diff --git a/img/clip.png b/img/clip.png deleted file mode 100644 index d8c1930e..00000000 Binary files a/img/clip.png and /dev/null differ diff --git a/img/cool.png b/img/cool.png deleted file mode 100644 index 8bf1ad2d..00000000 Binary files a/img/cool.png and /dev/null differ diff --git a/img/crosshair-dot.webp b/img/crosshair-dot.webp deleted file mode 100644 index f773fbea..00000000 Binary files a/img/crosshair-dot.webp and /dev/null differ diff --git a/img/crosshair.png b/img/crosshair.png deleted file mode 100644 index 44537237..00000000 Binary files a/img/crosshair.png and /dev/null differ diff --git a/img/cubehelix.png b/img/cubehelix.png deleted file mode 100644 index 8aa831df..00000000 Binary files a/img/cubehelix.png and /dev/null differ diff --git a/img/dark2.png b/img/dark2.png deleted file mode 100644 index dd657769..00000000 Binary files a/img/dark2.png and /dev/null differ diff --git a/img/dense-series.png b/img/dense-series.png deleted file mode 100644 index 4369f67d..00000000 Binary files a/img/dense-series.png and /dev/null differ diff --git a/img/density-contours.png b/img/density-contours.png deleted file mode 100644 index 9c14a1a2..00000000 Binary files a/img/density-contours.png and /dev/null differ diff --git a/img/density-fill.png b/img/density-fill.png deleted file mode 100644 index 9f358099..00000000 Binary files a/img/density-fill.png and /dev/null differ diff --git a/img/difference-find.png b/img/difference-find.png deleted file mode 100644 index 06ef8163..00000000 Binary files a/img/difference-find.png and /dev/null differ diff --git a/img/difference-shift.png b/img/difference-shift.png deleted file mode 100644 index fa01ff0b..00000000 Binary files a/img/difference-shift.png and /dev/null differ diff --git a/img/difference-zero.png b/img/difference-zero.png deleted file mode 100644 index 7cb3a5c9..00000000 Binary files a/img/difference-zero.png and /dev/null differ diff --git a/img/dodge-random.png b/img/dodge-random.png deleted file mode 100644 index 1763d640..00000000 Binary files a/img/dodge-random.png and /dev/null differ diff --git a/img/dodge.png b/img/dodge.png deleted file mode 100644 index 52d8cb94..00000000 Binary files a/img/dodge.png and /dev/null differ diff --git a/img/dot.png b/img/dot.png deleted file mode 100644 index 11feb222..00000000 Binary files a/img/dot.png and /dev/null differ diff --git a/img/facet-axes.webp b/img/facet-axes.webp deleted file mode 100644 index 83d32c88..00000000 Binary files a/img/facet-axes.webp and /dev/null differ diff --git a/img/faceted-map.png b/img/faceted-map.png deleted file mode 100644 index adf4ecad..00000000 Binary files a/img/faceted-map.png and /dev/null differ diff --git a/img/frame.png b/img/frame.png deleted file mode 100644 index 48bc2c0d..00000000 Binary files a/img/frame.png and /dev/null differ diff --git a/img/geo-nc.png b/img/geo-nc.png deleted file mode 100644 index c8f59eba..00000000 Binary files a/img/geo-nc.png and /dev/null differ diff --git a/img/geo-tip.png b/img/geo-tip.png deleted file mode 100644 index a826a45e..00000000 Binary files a/img/geo-tip.png and /dev/null differ diff --git a/img/gnbu.png b/img/gnbu.png deleted file mode 100644 index d5d4ed1c..00000000 Binary files a/img/gnbu.png and /dev/null differ diff --git a/img/goode.png b/img/goode.png deleted file mode 100644 index d001816d..00000000 Binary files a/img/goode.png and /dev/null differ diff --git a/img/gradient.png b/img/gradient.png deleted file mode 100644 index 6da68fac..00000000 Binary files a/img/gradient.png and /dev/null differ diff --git a/img/greens.png b/img/greens.png deleted file mode 100644 index 66cda9bc..00000000 Binary files a/img/greens.png and /dev/null differ diff --git a/img/greys.png b/img/greys.png deleted file mode 100644 index f6730ff6..00000000 Binary files a/img/greys.png and /dev/null differ diff --git a/img/group-interval.png b/img/group-interval.png deleted file mode 100644 index b0fe7b16..00000000 Binary files a/img/group-interval.png and /dev/null differ diff --git a/img/group-marker.png b/img/group-marker.png deleted file mode 100644 index 439a83db..00000000 Binary files a/img/group-marker.png and /dev/null differ diff --git a/img/group.png b/img/group.png deleted file mode 100644 index c2985454..00000000 Binary files a/img/group.png and /dev/null differ diff --git a/img/heatmap.png b/img/heatmap.png deleted file mode 100644 index 0a734784..00000000 Binary files a/img/heatmap.png and /dev/null differ diff --git a/img/hexbin-map.png b/img/hexbin-map.png deleted file mode 100644 index 6fd2f2fe..00000000 Binary files a/img/hexbin-map.png and /dev/null differ diff --git a/img/hexbin.png b/img/hexbin.png deleted file mode 100644 index 370b6b7b..00000000 Binary files a/img/hexbin.png and /dev/null differ diff --git a/img/histogram-aapl-volume.png b/img/histogram-aapl-volume.png deleted file mode 100644 index 9ea009a6..00000000 Binary files a/img/histogram-aapl-volume.png and /dev/null differ diff --git a/img/horizon.png b/img/horizon.png deleted file mode 100644 index 18ba1c03..00000000 Binary files a/img/horizon.png and /dev/null differ diff --git a/img/identity-color.png b/img/identity-color.png deleted file mode 100644 index 627f8398..00000000 Binary files a/img/identity-color.png and /dev/null differ diff --git a/img/image.png b/img/image.png deleted file mode 100644 index 829305ea..00000000 Binary files a/img/image.png and /dev/null differ diff --git a/img/inferno.png b/img/inferno.png deleted file mode 100644 index 3d891793..00000000 Binary files a/img/inferno.png and /dev/null differ diff --git a/img/legend.png b/img/legend.png deleted file mode 100644 index c7e2e079..00000000 Binary files a/img/legend.png and /dev/null differ diff --git a/img/likert.png b/img/likert.png deleted file mode 100644 index ebb50d01..00000000 Binary files a/img/likert.png and /dev/null differ diff --git a/img/line-aapl-date-close.png b/img/line-aapl-date-close.png deleted file mode 100644 index 49188008..00000000 Binary files a/img/line-aapl-date-close.png and /dev/null differ diff --git a/img/line-bls-date-unemployment-division-custom.png b/img/line-bls-date-unemployment-division-custom.png deleted file mode 100644 index 0fd00e59..00000000 Binary files a/img/line-bls-date-unemployment-division-custom.png and /dev/null differ diff --git a/img/line-bls-date-unemployment-division.png b/img/line-bls-date-unemployment-division.png deleted file mode 100644 index b8b53a2a..00000000 Binary files a/img/line-bls-date-unemployment-division.png and /dev/null differ diff --git a/img/line-random-walk.png b/img/line-random-walk.png deleted file mode 100644 index 28e4fcc1..00000000 Binary files a/img/line-random-walk.png and /dev/null differ diff --git a/img/line-random.png b/img/line-random.png deleted file mode 100644 index 96c29515..00000000 Binary files a/img/line-random.png and /dev/null differ diff --git a/img/line-slope.png b/img/line-slope.png deleted file mode 100644 index e0c6628e..00000000 Binary files a/img/line-slope.png and /dev/null differ diff --git a/img/line.png b/img/line.png deleted file mode 100644 index 2d4c802a..00000000 Binary files a/img/line.png and /dev/null differ diff --git a/img/linear-regression.png b/img/linear-regression.png deleted file mode 100644 index fe87da05..00000000 Binary files a/img/linear-regression.png and /dev/null differ diff --git a/img/link.png b/img/link.png deleted file mode 100644 index 6a6084d1..00000000 Binary files a/img/link.png and /dev/null differ diff --git a/img/magma.png b/img/magma.png deleted file mode 100644 index 4d6796f9..00000000 Binary files a/img/magma.png and /dev/null differ diff --git a/img/mandelbrot.webp b/img/mandelbrot.webp deleted file mode 100644 index 7765759b..00000000 Binary files a/img/mandelbrot.webp and /dev/null differ diff --git a/img/marker.png b/img/marker.png deleted file mode 100644 index d9f03ea7..00000000 Binary files a/img/marker.png and /dev/null differ diff --git a/img/observable10.png b/img/observable10.png deleted file mode 100644 index 284a5aac..00000000 Binary files a/img/observable10.png and /dev/null differ diff --git a/img/oranges.png b/img/oranges.png deleted file mode 100644 index ed3132ef..00000000 Binary files a/img/oranges.png and /dev/null differ diff --git a/img/orrd.png b/img/orrd.png deleted file mode 100644 index d5883e02..00000000 Binary files a/img/orrd.png and /dev/null differ diff --git a/img/orthographic.png b/img/orthographic.png deleted file mode 100644 index 5c0be74e..00000000 Binary files a/img/orthographic.png and /dev/null differ diff --git a/img/paired.png b/img/paired.png deleted file mode 100644 index 3d3d9f5b..00000000 Binary files a/img/paired.png and /dev/null differ diff --git a/img/pastel1.png b/img/pastel1.png deleted file mode 100644 index 19be49ff..00000000 Binary files a/img/pastel1.png and /dev/null differ diff --git a/img/pastel2.png b/img/pastel2.png deleted file mode 100644 index ac70afbe..00000000 Binary files a/img/pastel2.png and /dev/null differ diff --git a/img/piecewise-rainbow.png b/img/piecewise-rainbow.png deleted file mode 100644 index 9bb72f33..00000000 Binary files a/img/piecewise-rainbow.png and /dev/null differ diff --git a/img/piyg.png b/img/piyg.png deleted file mode 100644 index bfcd09e9..00000000 Binary files a/img/piyg.png and /dev/null differ diff --git a/img/plasma.png b/img/plasma.png deleted file mode 100644 index 466bf243..00000000 Binary files a/img/plasma.png and /dev/null differ diff --git a/img/plot.svg b/img/plot.svg deleted file mode 100644 index b50dd459..00000000 --- a/img/plot.svg +++ /dev/null @@ -1,44 +0,0 @@ -<svg width="493" height="222" viewBox="0 0 493 222" fill="none" xmlns="http://www.w3.org/2000/svg"> -<rect width="493" height="222" fill="white"/> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<rect x="143.872" y="17" width="55.296" height="144" fill="#7EDF87"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<rect x="16" y="17" width="55.296" height="144" fill="#FFB6F8"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<rect x="399.616" y="75.7521" width="55.296" height="85.248" fill="#A9B6FC"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<rect x="382.336" y="17" width="89.856" height="55.296" fill="#D8A6FF"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<rect x="202.624" y="105.704" width="16.128" height="55.296" fill="#FFD850"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<circle cx="297.664" cy="89" r="72" fill="#B6E5FF"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<path d="M297.664 17C281.006 17 264.864 22.7758 251.988 33.3432C239.111 43.9107 230.297 58.616 227.047 74.9535C223.798 91.291 226.313 108.25 234.165 122.941C242.018 137.631 254.721 149.145 270.111 155.519C285.5 161.894 302.624 162.735 318.564 157.9C334.505 153.064 348.275 142.851 357.53 129.001C366.784 115.151 370.95 98.5201 369.317 81.9428C367.684 65.3654 360.354 49.867 348.576 38.0883L297.664 89V17Z" fill="#B6E5FF"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<path d="M297.664 17C286.731 17 275.942 19.4897 266.114 24.2804C256.287 29.071 247.68 36.0367 240.945 44.6491C234.211 53.2616 229.526 63.2945 227.246 73.9871C224.967 84.6796 225.152 95.7509 227.788 106.361C230.424 116.971 235.442 126.842 242.461 135.224C249.48 143.606 258.316 150.28 268.298 154.739C278.28 159.198 289.147 161.326 300.073 160.96C311 160.594 321.7 157.744 331.362 152.628L297.664 89V17Z" fill="#B6E5FF"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<path d="M71.296 127.592C85.9614 127.592 100.026 121.766 110.396 111.396C120.766 101.026 126.592 86.9614 126.592 72.296C126.592 57.6306 120.766 43.5658 110.396 33.1958C100.026 22.8258 85.9614 17 71.296 17L71.296 72.296L71.296 127.592Z" fill="#E975C8"/> -</g> -<g style="mix-blend-mode:multiply" opacity="0.5"> -<path d="M16 72.296C16 83.6982 19.5248 94.8216 26.0917 104.143C32.6586 113.464 41.9464 120.527 52.6832 124.365C63.4201 128.203 75.0808 128.628 86.0686 125.582C97.0563 122.536 106.834 116.168 114.062 107.349L71.296 72.296L16 72.296Z" fill="#E975C8"/> -</g> -<path d="M300.972 202.785C299.495 202.785 298.076 202.539 296.717 202.047C295.357 201.554 294.138 200.804 293.06 199.796C292.005 198.788 291.172 197.522 290.563 195.998C289.953 194.475 289.649 192.681 289.649 190.618C289.649 188.555 289.953 186.762 290.563 185.238C291.196 183.714 292.04 182.448 293.095 181.44C294.15 180.432 295.357 179.682 296.717 179.19C298.076 178.697 299.495 178.451 300.972 178.451C302.449 178.451 303.867 178.697 305.227 179.19C306.586 179.682 307.794 180.432 308.848 181.44C309.903 182.448 310.736 183.714 311.345 185.238C311.978 186.762 312.295 188.555 312.295 190.618C312.295 192.681 311.99 194.475 311.38 195.998C310.771 197.522 309.927 198.788 308.848 199.796C307.794 200.804 306.586 201.554 305.227 202.047C303.867 202.539 302.449 202.785 300.972 202.785ZM300.972 200.64C301.98 200.64 302.777 200.23 303.363 199.409C303.972 198.565 304.406 197.393 304.664 195.893C304.945 194.393 305.086 192.634 305.086 190.618C305.086 188.602 304.945 186.844 304.664 185.344C304.406 183.843 303.972 182.671 303.363 181.827C302.777 180.983 301.98 180.561 300.972 180.561C299.987 180.561 299.19 180.983 298.58 181.827C297.971 182.671 297.526 183.843 297.244 185.344C296.986 186.844 296.857 188.602 296.857 190.618C296.857 192.634 296.986 194.393 297.244 195.893C297.526 197.393 297.971 198.565 298.58 199.409C299.19 200.23 299.987 200.64 300.972 200.64Z" fill="#1C1C1C"/> -<path d="M313.927 202.117V200.324L315.51 199.972C315.533 199.128 315.545 198.225 315.545 197.264C315.545 196.303 315.545 195.459 315.545 194.732V180.139L313.646 179.893V178.311L321.663 176.728L322.261 177.115L322.121 182.003V186.434C323.504 185.074 325.168 184.394 327.114 184.394C328.403 184.394 329.575 184.734 330.63 185.414C331.685 186.07 332.518 187.067 333.127 188.403C333.76 189.716 334.077 191.38 334.077 193.396C334.077 195.319 333.713 196.971 332.986 198.354C332.26 199.738 331.31 200.804 330.138 201.554C328.989 202.281 327.759 202.644 326.446 202.644C325.438 202.644 324.559 202.469 323.808 202.117C323.058 201.789 322.402 201.308 321.839 200.675L321.136 202.644L313.927 202.117ZM323.879 187.875C323.574 187.875 323.281 187.922 323 188.016C322.742 188.11 322.484 188.239 322.226 188.403V198.776C322.695 199.105 323.246 199.269 323.879 199.269C324.817 199.269 325.567 198.788 326.129 197.827C326.692 196.866 326.973 195.389 326.973 193.396C326.973 191.38 326.692 189.962 326.129 189.141C325.567 188.297 324.817 187.875 323.879 187.875Z" fill="#1C1C1C"/> -<path d="M342.096 202.644C339.658 202.644 337.501 202.07 335.625 200.921L335.801 196.42H339.001L339.564 200.077C339.962 200.242 340.373 200.37 340.795 200.464C341.217 200.535 341.65 200.57 342.096 200.57C342.986 200.57 343.678 200.429 344.17 200.148C344.663 199.866 344.909 199.398 344.909 198.741C344.909 198.272 344.721 197.85 344.346 197.475C343.995 197.1 343.233 196.76 342.06 196.456L340.056 195.928C338.673 195.553 337.618 194.897 336.891 193.959C336.165 192.998 335.801 191.837 335.801 190.478C335.801 188.719 336.469 187.266 337.806 186.117C339.165 184.968 341.111 184.394 343.643 184.394C344.721 184.394 345.718 184.511 346.632 184.746C347.57 184.98 348.519 185.332 349.48 185.801L349.199 189.774H345.964L345.19 186.645C344.956 186.598 344.698 186.563 344.416 186.539C344.159 186.492 343.842 186.469 343.467 186.469C342.811 186.469 342.248 186.621 341.779 186.926C341.334 187.207 341.111 187.653 341.111 188.262C341.111 188.637 341.275 189.001 341.603 189.352C341.932 189.704 342.705 190.044 343.924 190.372L345.893 190.9C347.534 191.345 348.707 192.037 349.41 192.974C350.137 193.912 350.5 195.072 350.5 196.456C350.5 198.518 349.738 200.066 348.214 201.097C346.714 202.129 344.674 202.644 342.096 202.644Z" fill="#1C1C1C"/> -<path d="M360.94 186.293C360.401 186.293 359.944 186.692 359.569 187.489C359.193 188.286 358.982 189.774 358.936 191.954H360.588C361.432 191.954 361.995 191.79 362.276 191.462C362.581 191.111 362.733 190.489 362.733 189.598C362.733 188.379 362.546 187.524 362.171 187.031C361.819 186.539 361.409 186.293 360.94 186.293ZM361.045 202.644C359.287 202.644 357.717 202.281 356.333 201.554C354.974 200.828 353.895 199.784 353.098 198.425C352.301 197.042 351.903 195.389 351.903 193.467C351.903 191.943 352.172 190.618 352.711 189.493C353.251 188.368 353.977 187.43 354.892 186.68C355.806 185.906 356.826 185.332 357.951 184.957C359.076 184.582 360.213 184.394 361.362 184.394C363.05 184.394 364.445 184.746 365.546 185.449C366.672 186.129 367.516 187.055 368.078 188.227C368.641 189.376 368.922 190.665 368.922 192.095C368.922 192.47 368.911 192.798 368.887 193.08C368.864 193.338 368.817 193.631 368.746 193.959H358.971C359.158 195.67 359.662 196.913 360.483 197.686C361.327 198.46 362.264 198.847 363.296 198.847C364.187 198.847 364.949 198.694 365.582 198.39C366.238 198.061 366.812 197.663 367.305 197.194L368.676 198.53C367.926 199.96 366.894 201.003 365.582 201.66C364.292 202.316 362.78 202.644 361.045 202.644Z" fill="#1C1C1C"/> -<path d="M370.377 202.082V200.288L372.065 199.902C372.088 199.058 372.1 198.167 372.1 197.229C372.1 196.268 372.1 195.424 372.1 194.697V192.482C372.1 191.826 372.088 191.298 372.065 190.9C372.065 190.501 372.053 190.138 372.03 189.809C372.03 189.458 372.018 189.048 371.994 188.579L370.096 188.262V186.82L377.867 184.394L378.5 184.781L378.781 188.86C379.25 187.336 379.918 186.211 380.786 185.484C381.676 184.757 382.544 184.394 383.388 184.394C384.255 184.394 384.982 184.652 385.568 185.168C386.177 185.66 386.541 186.445 386.658 187.524C386.611 188.461 386.33 189.2 385.814 189.739C385.298 190.255 384.689 190.513 383.986 190.513C382.907 190.513 381.923 189.868 381.032 188.579L380.856 188.333C380.434 188.801 380.012 189.411 379.59 190.161C379.191 190.911 378.922 191.685 378.781 192.482V194.697C378.781 195.377 378.781 196.174 378.781 197.088C378.781 198.003 378.793 198.87 378.816 199.691L381.559 200.288V202.082H370.377Z" fill="#1C1C1C"/> -<path d="M399.385 186.609V184.957H405.996V186.609L403.886 186.961L398.084 202.082H394.919L388.66 186.961L386.972 186.609V184.957H397.557V186.609L395.693 187.031L398.612 195.119L401.319 186.996L399.385 186.609Z" fill="#1C1C1C"/> -<path d="M419.67 202.644C418.568 202.644 417.677 202.433 416.998 202.011C416.341 201.566 415.861 200.945 415.556 200.148C414.876 200.898 414.184 201.507 413.481 201.976C412.801 202.422 411.828 202.644 410.562 202.644C409.179 202.644 408.066 202.246 407.222 201.449C406.401 200.652 405.991 199.538 405.991 198.108C405.991 197.194 406.19 196.385 406.589 195.682C407.011 194.955 407.738 194.299 408.769 193.713C409.824 193.103 411.301 192.552 413.2 192.06C413.481 191.99 413.809 191.908 414.184 191.814C414.56 191.697 414.946 191.591 415.345 191.497V190.231C415.345 188.731 415.169 187.7 414.817 187.137C414.489 186.574 413.774 186.293 412.672 186.293C412.579 186.293 412.485 186.293 412.391 186.293C412.321 186.293 412.239 186.293 412.145 186.293V187.489C412.145 188.919 411.852 189.938 411.266 190.548C410.68 191.134 410.012 191.427 409.261 191.427C407.831 191.427 406.941 190.794 406.589 189.528C406.589 188.004 407.304 186.774 408.734 185.836C410.187 184.875 412.332 184.394 415.169 184.394C417.607 184.394 419.318 184.933 420.303 186.012C421.311 187.067 421.815 188.801 421.815 191.216V199.269C421.815 199.667 422.014 199.866 422.413 199.866C422.554 199.866 422.694 199.82 422.835 199.726C422.976 199.609 423.151 199.398 423.362 199.093L424.382 199.655C423.96 200.734 423.374 201.507 422.624 201.976C421.897 202.422 420.913 202.644 419.67 202.644ZM412.039 197.018C412.039 197.886 412.215 198.53 412.567 198.952C412.919 199.374 413.352 199.585 413.868 199.585C414.032 199.585 414.208 199.55 414.395 199.48C414.606 199.386 414.923 199.198 415.345 198.917V193.15C414.993 193.244 414.653 193.373 414.325 193.537C413.833 193.771 413.329 194.17 412.813 194.732C412.297 195.295 412.039 196.057 412.039 197.018Z" fill="#1C1C1C"/> -<path d="M425.087 202.117V200.324L426.669 199.972C426.693 199.128 426.704 198.225 426.704 197.264C426.704 196.303 426.704 195.459 426.704 194.732V180.139L424.805 179.893V178.311L432.823 176.728L433.421 177.115L433.28 182.003V186.434C434.663 185.074 436.328 184.394 438.273 184.394C439.563 184.394 440.735 184.734 441.79 185.414C442.845 186.07 443.677 187.067 444.287 188.403C444.92 189.716 445.236 191.38 445.236 193.396C445.236 195.319 444.873 196.971 444.146 198.354C443.419 199.738 442.47 200.804 441.298 201.554C440.149 202.281 438.918 202.644 437.605 202.644C436.597 202.644 435.718 202.469 434.968 202.117C434.218 201.789 433.561 201.308 432.999 200.675L432.296 202.644L425.087 202.117ZM435.038 187.875C434.734 187.875 434.441 187.922 434.159 188.016C433.901 188.11 433.643 188.239 433.386 188.403V198.776C433.854 199.105 434.405 199.269 435.038 199.269C435.976 199.269 436.726 198.788 437.289 197.827C437.851 196.866 438.133 195.389 438.133 193.396C438.133 191.38 437.851 189.962 437.289 189.141C436.726 188.297 435.976 187.875 435.038 187.875Z" fill="#1C1C1C"/> -<path d="M446.961 202.082V200.288L448.543 199.937C448.567 199.046 448.578 198.167 448.578 197.299C448.602 196.432 448.613 195.565 448.613 194.697V180.245L446.715 179.893V178.311L454.838 176.728L455.435 177.115L455.295 182.003V194.697C455.295 195.565 455.295 196.444 455.295 197.335C455.318 198.202 455.342 199.081 455.365 199.972L456.947 200.288V202.082H446.961Z" fill="#1C1C1C"/> -<path d="M467.567 186.293C467.027 186.293 466.57 186.692 466.195 187.489C465.82 188.286 465.609 189.774 465.562 191.954H467.215C468.059 191.954 468.621 191.79 468.903 191.462C469.208 191.111 469.36 190.489 469.36 189.598C469.36 188.379 469.172 187.524 468.797 187.031C468.446 186.539 468.035 186.293 467.567 186.293ZM467.672 202.644C465.914 202.644 464.343 202.281 462.96 201.554C461.6 200.828 460.522 199.784 459.725 198.425C458.928 197.042 458.529 195.389 458.529 193.467C458.529 191.943 458.799 190.618 459.338 189.493C459.877 188.368 460.604 187.43 461.518 186.68C462.433 185.906 463.452 185.332 464.578 184.957C465.703 184.582 466.84 184.394 467.989 184.394C469.676 184.394 471.071 184.746 472.173 185.449C473.298 186.129 474.142 187.055 474.705 188.227C475.268 189.376 475.549 190.665 475.549 192.095C475.549 192.47 475.537 192.798 475.514 193.08C475.49 193.338 475.443 193.631 475.373 193.959H465.597C465.785 195.67 466.289 196.913 467.109 197.686C467.953 198.46 468.891 198.847 469.923 198.847C470.813 198.847 471.575 198.694 472.208 198.39C472.865 198.061 473.439 197.663 473.931 197.194L475.303 198.53C474.553 199.96 473.521 201.003 472.208 201.66C470.919 202.316 469.407 202.644 467.672 202.644Z" fill="#1C1C1C"/> -<path d="M261.805 201.611C260.398 201.611 259.179 201.306 258.148 200.697C257.12 200.089 256.299 199.229 255.777 198.214C255.222 197.158 254.816 196.041 254.568 194.89C254.305 193.642 254.177 192.373 254.186 191.102C254.186 190.095 254.252 189.133 254.385 188.213C254.517 187.294 254.762 186.36 255.119 185.412C255.476 184.464 255.936 183.642 256.496 182.945C257.079 182.23 257.833 181.649 258.699 181.246C259.608 180.81 260.642 180.593 261.805 180.593C263.212 180.593 264.431 180.898 265.462 181.507C266.489 182.114 267.311 182.974 267.833 183.99C268.384 185.035 268.786 186.143 269.042 187.314C269.296 188.484 269.424 189.747 269.424 191.102C269.424 192.108 269.358 193.071 269.225 193.991C269.082 194.946 268.831 195.885 268.475 196.792C268.107 197.74 267.65 198.562 267.099 199.259C266.548 199.956 265.819 200.523 264.911 200.958C264.003 201.394 262.968 201.611 261.805 201.611ZM264.494 193.884C265.223 193.157 265.628 192.15 265.614 191.102C265.614 190.019 265.25 189.092 264.522 188.32C263.794 187.547 262.888 187.161 261.805 187.161C260.722 187.161 259.816 187.547 259.088 188.32C258.374 189.055 257.98 190.06 257.995 191.102C257.995 192.184 258.36 193.112 259.088 193.884C259.816 194.657 260.722 195.043 261.805 195.043C262.888 195.043 263.785 194.657 264.494 193.884ZM261.805 206.865C270.22 206.865 277.043 199.807 277.043 191.102C277.043 182.397 270.22 175.339 261.805 175.339C253.39 175.339 246.567 182.397 246.567 191.102C246.567 199.807 253.39 206.865 261.805 206.865Z" fill="#1C1C1C"/> -</svg> diff --git a/img/poem.png b/img/poem.png deleted file mode 100644 index 40cc17f1..00000000 Binary files a/img/poem.png and /dev/null differ diff --git a/img/point-band.png b/img/point-band.png deleted file mode 100644 index c2bc7e6c..00000000 Binary files a/img/point-band.png and /dev/null differ diff --git a/img/pointer-text.webp b/img/pointer-text.webp deleted file mode 100644 index 518de19d..00000000 Binary files a/img/pointer-text.webp and /dev/null differ diff --git a/img/prgn.png b/img/prgn.png deleted file mode 100644 index 2a551021..00000000 Binary files a/img/prgn.png and /dev/null differ diff --git a/img/pubu.png b/img/pubu.png deleted file mode 100644 index 6463a7e4..00000000 Binary files a/img/pubu.png and /dev/null differ diff --git a/img/pubugn.png b/img/pubugn.png deleted file mode 100644 index efb69fe7..00000000 Binary files a/img/pubugn.png and /dev/null differ diff --git a/img/puor.png b/img/puor.png deleted file mode 100644 index 8770ba53..00000000 Binary files a/img/puor.png and /dev/null differ diff --git a/img/purd.png b/img/purd.png deleted file mode 100644 index c5330da1..00000000 Binary files a/img/purd.png and /dev/null differ diff --git a/img/purples.png b/img/purples.png deleted file mode 100644 index 184000cb..00000000 Binary files a/img/purples.png and /dev/null differ diff --git a/img/quantize.png b/img/quantize.png deleted file mode 100644 index 1f9b6a98..00000000 Binary files a/img/quantize.png and /dev/null differ diff --git a/img/rainbow.png b/img/rainbow.png deleted file mode 100644 index 3abf103b..00000000 Binary files a/img/rainbow.png and /dev/null differ diff --git a/img/rdbu.png b/img/rdbu.png deleted file mode 100644 index ef096b06..00000000 Binary files a/img/rdbu.png and /dev/null differ diff --git a/img/rdgy.png b/img/rdgy.png deleted file mode 100644 index 4e0aece2..00000000 Binary files a/img/rdgy.png and /dev/null differ diff --git a/img/rdpu.png b/img/rdpu.png deleted file mode 100644 index ac420a57..00000000 Binary files a/img/rdpu.png and /dev/null differ diff --git a/img/rdylbu.png b/img/rdylbu.png deleted file mode 100644 index 23ec9a68..00000000 Binary files a/img/rdylbu.png and /dev/null differ diff --git a/img/rdylgn.png b/img/rdylgn.png deleted file mode 100644 index 883eb9a5..00000000 Binary files a/img/rdylgn.png and /dev/null differ diff --git a/img/rect-band.png b/img/rect-band.png deleted file mode 100644 index dd82d59d..00000000 Binary files a/img/rect-band.png and /dev/null differ diff --git a/img/rect-rounded.png b/img/rect-rounded.png deleted file mode 100644 index 0b2bf58b..00000000 Binary files a/img/rect-rounded.png and /dev/null differ diff --git a/img/rect.png b/img/rect.png deleted file mode 100644 index be8c0c31..00000000 Binary files a/img/rect.png and /dev/null differ diff --git a/img/reds.png b/img/reds.png deleted file mode 100644 index 172f2394..00000000 Binary files a/img/reds.png and /dev/null differ diff --git a/img/rule.png b/img/rule.png deleted file mode 100644 index 64b55e64..00000000 Binary files a/img/rule.png and /dev/null differ diff --git a/img/select.png b/img/select.png deleted file mode 100644 index 8b9bd285..00000000 Binary files a/img/select.png and /dev/null differ diff --git a/img/set1.png b/img/set1.png deleted file mode 100644 index 776d8d1a..00000000 Binary files a/img/set1.png and /dev/null differ diff --git a/img/set2.png b/img/set2.png deleted file mode 100644 index 06e8af22..00000000 Binary files a/img/set2.png and /dev/null differ diff --git a/img/set3.png b/img/set3.png deleted file mode 100644 index cb756773..00000000 Binary files a/img/set3.png and /dev/null differ diff --git a/img/sinebow.png b/img/sinebow.png deleted file mode 100644 index f405b82f..00000000 Binary files a/img/sinebow.png and /dev/null differ diff --git a/img/sort-length.png b/img/sort-length.png deleted file mode 100644 index e4767a6d..00000000 Binary files a/img/sort-length.png and /dev/null differ diff --git a/img/sparse-series.png b/img/sparse-series.png deleted file mode 100644 index d582a839..00000000 Binary files a/img/sparse-series.png and /dev/null differ diff --git a/img/spectral.png b/img/spectral.png deleted file mode 100644 index 8d76759c..00000000 Binary files a/img/spectral.png and /dev/null differ diff --git a/img/spike-map.webp b/img/spike-map.webp deleted file mode 100644 index 558bd35c..00000000 Binary files a/img/spike-map.webp and /dev/null differ diff --git a/img/stack.png b/img/stack.png deleted file mode 100644 index ec36cea0..00000000 Binary files a/img/stack.png and /dev/null differ diff --git a/img/symbol.png b/img/symbol.png deleted file mode 100644 index 4f0be6c7..00000000 Binary files a/img/symbol.png and /dev/null differ diff --git a/img/tableau10.png b/img/tableau10.png deleted file mode 100644 index 2736e2af..00000000 Binary files a/img/tableau10.png and /dev/null differ diff --git a/img/temporal-ordinal.png b/img/temporal-ordinal.png deleted file mode 100644 index 09ffddf8..00000000 Binary files a/img/temporal-ordinal.png and /dev/null differ diff --git a/img/text-overflow.webp b/img/text-overflow.webp deleted file mode 100644 index 84acd1a8..00000000 Binary files a/img/text-overflow.webp and /dev/null differ diff --git a/img/text.png b/img/text.png deleted file mode 100644 index 6392ab30..00000000 Binary files a/img/text.png and /dev/null differ diff --git a/img/tick.png b/img/tick.png deleted file mode 100644 index c793eeb1..00000000 Binary files a/img/tick.png and /dev/null differ diff --git a/img/time-axis-after.png b/img/time-axis-after.png deleted file mode 100644 index 25ab0898..00000000 Binary files a/img/time-axis-after.png and /dev/null differ diff --git a/img/time-axis-before.png b/img/time-axis-before.png deleted file mode 100644 index 0369e4ab..00000000 Binary files a/img/time-axis-before.png and /dev/null differ diff --git a/img/tip-custom.png b/img/tip-custom.png deleted file mode 100644 index 798f9977..00000000 Binary files a/img/tip-custom.png and /dev/null differ diff --git a/img/tip-line.webp b/img/tip-line.webp deleted file mode 100644 index 5a614176..00000000 Binary files a/img/tip-line.webp and /dev/null differ diff --git a/img/tip-title.png b/img/tip-title.png deleted file mode 100644 index 5429987d..00000000 Binary files a/img/tip-title.png and /dev/null differ diff --git a/img/tip.png b/img/tip.png deleted file mode 100644 index b23d27ac..00000000 Binary files a/img/tip.png and /dev/null differ diff --git a/img/title-subtitle.png b/img/title-subtitle.png deleted file mode 100644 index 544fe4f2..00000000 Binary files a/img/title-subtitle.png and /dev/null differ diff --git a/img/tree-gods.png b/img/tree-gods.png deleted file mode 100644 index 8940d7ef..00000000 Binary files a/img/tree-gods.png and /dev/null differ diff --git a/img/tree.png b/img/tree.png deleted file mode 100644 index c42872a4..00000000 Binary files a/img/tree.png and /dev/null differ diff --git a/img/ts-enum.png b/img/ts-enum.png deleted file mode 100644 index c8f4bc01..00000000 Binary files a/img/ts-enum.png and /dev/null differ diff --git a/img/ts-error.png b/img/ts-error.png deleted file mode 100644 index a6471999..00000000 Binary files a/img/ts-error.png and /dev/null differ diff --git a/img/ts-function.png b/img/ts-function.png deleted file mode 100644 index c36bc726..00000000 Binary files a/img/ts-function.png and /dev/null differ diff --git a/img/ts-hover.png b/img/ts-hover.png deleted file mode 100644 index 159d6f49..00000000 Binary files a/img/ts-hover.png and /dev/null differ diff --git a/img/ts-property.png b/img/ts-property.png deleted file mode 100644 index 9b38b86e..00000000 Binary files a/img/ts-property.png and /dev/null differ diff --git a/img/turbo.png b/img/turbo.png deleted file mode 100644 index 019e7094..00000000 Binary files a/img/turbo.png and /dev/null differ diff --git a/img/unit-circle-atan2.png b/img/unit-circle-atan2.png deleted file mode 100644 index 907e970d..00000000 Binary files a/img/unit-circle-atan2.png and /dev/null differ diff --git a/img/vapor-clip-us.png b/img/vapor-clip-us.png deleted file mode 100644 index 0014347c..00000000 Binary files a/img/vapor-clip-us.png and /dev/null differ diff --git a/img/vector-map.png b/img/vector-map.png deleted file mode 100644 index 2f68f19d..00000000 Binary files a/img/vector-map.png and /dev/null differ diff --git a/img/vector.png b/img/vector.png deleted file mode 100644 index 648f85a5..00000000 Binary files a/img/vector.png and /dev/null differ diff --git a/img/viridis.png b/img/viridis.png deleted file mode 100644 index 6dae33e6..00000000 Binary files a/img/viridis.png and /dev/null differ diff --git a/img/volcano.webp b/img/volcano.webp deleted file mode 100644 index 64e3627d..00000000 Binary files a/img/volcano.webp and /dev/null differ diff --git a/img/voronoi.png b/img/voronoi.png deleted file mode 100644 index 90a8431a..00000000 Binary files a/img/voronoi.png and /dev/null differ diff --git a/img/waffle-pointer-fractional.png b/img/waffle-pointer-fractional.png deleted file mode 100644 index 4a6c237b..00000000 Binary files a/img/waffle-pointer-fractional.png and /dev/null differ diff --git a/img/waffle.png b/img/waffle.png deleted file mode 100644 index ad2d2ee8..00000000 Binary files a/img/waffle.png and /dev/null differ diff --git a/img/warm.png b/img/warm.png deleted file mode 100644 index 03112ff9..00000000 Binary files a/img/warm.png and /dev/null differ diff --git a/img/warning.png b/img/warning.png deleted file mode 100644 index 8310a8d2..00000000 Binary files a/img/warning.png and /dev/null differ diff --git a/img/water-vapor.png b/img/water-vapor.png deleted file mode 100644 index f42fb688..00000000 Binary files a/img/water-vapor.png and /dev/null differ diff --git a/img/window-strict.png b/img/window-strict.png deleted file mode 100644 index edd90798..00000000 Binary files a/img/window-strict.png and /dev/null differ diff --git a/img/window.png b/img/window.png deleted file mode 100644 index e84c6b04..00000000 Binary files a/img/window.png and /dev/null differ diff --git a/img/wrap.png b/img/wrap.png deleted file mode 100644 index 0abaad97..00000000 Binary files a/img/wrap.png and /dev/null differ diff --git a/img/ylgn.png b/img/ylgn.png deleted file mode 100644 index 0f1c11aa..00000000 Binary files a/img/ylgn.png and /dev/null differ diff --git a/img/ylgnbu.png b/img/ylgnbu.png deleted file mode 100644 index 8918e847..00000000 Binary files a/img/ylgnbu.png and /dev/null differ diff --git a/img/ylorbr.png b/img/ylorbr.png deleted file mode 100644 index 042ec38c..00000000 Binary files a/img/ylorbr.png and /dev/null differ diff --git a/img/ylorrd.png b/img/ylorrd.png deleted file mode 100644 index 0e4b225c..00000000 Binary files a/img/ylorrd.png and /dev/null differ diff --git a/package.json b/package.json index 55f61490..519e37c8 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "default": "./dist/esm/index.js" }, "./react": { - "types": "./dist/types/react/index.d.ts", - "default": "./dist/esm/react/index.js" + "types": "./dist/types/react/api.d.ts", + "default": "./dist/esm/react/api.js" }, "./package.json": "./package.json" }, @@ -49,12 +49,14 @@ "dist/types/**/*.d.ts" ], "scripts": { - "test": "yarn test:mocha && yarn test:tsc && yarn test:lint && yarn test:prettier && yarn test:package", + "test": "yarn test:mocha && yarn test:tsc && yarn test:lint && yarn test:prettier && yarn test:smoke", "test:package": "yarn bundle && yarn test:publint && yarn test:attw", "test:publint": "npx --yes publint@^0.3.14 --strict --pack npm", "test:attw": "npx --yes @arethetypeswrong/cli@^0.18.2 --pack --profile esm-only", "test:coverage": "c8 yarn test:mocha", + "test:smoke": "node scripts/smoke-test.mjs", "test:mocha": "mkdir -p test/output && TZ=America/Los_Angeles tsx node_modules/.bin/mocha 'test/**/*-test.*' 'test/plot.*'", + "test:units": "mkdir -p test/output && TZ=America/Los_Angeles tsx node_modules/.bin/mocha 'test/**/*-test.*'", "test:lint": "eslint src test", "test:prettier": "prettier --check src test", "test:tsc": "tsc", @@ -65,7 +67,8 @@ "dev": "vite", "docs:dev": "tsx docs/scripts/ensure-bundle.ts && tsx docs/scripts/build-api-data.ts && vite --config docs/vite.docs.config.ts", "docs:build": "yarn bundle && tsx docs/scripts/build-api-data.ts && vite build --config docs/vite.docs.config.ts", - "docs:preview": "vite preview --config docs/vite.docs.config.ts" + "docs:preview": "vite preview --config docs/vite.docs.config.ts", + "parity": "tsx scripts/parity.ts" }, "sideEffects": [ "./dist/esm/index.js" @@ -78,10 +81,6 @@ "@mdx-js/rollup": "^3.1.1", "@observablehq/runtime": "^6.0.0", "@observablehq/stdlib": "^5.8.8", - "@rollup/plugin-commonjs": "^28.0.2", - "@rollup/plugin-json": "^6.0.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-terser": "^0.4.0", "@shikijs/rehype": "^4.3.1", "@types/d3": "^7.4.0", "@types/mdx": "^2.0.13", @@ -98,6 +97,7 @@ "d3-geo-projection": "^4.0.0", "eslint": "^9.20.1", "eslint-config-prettier": "^10.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^15.15.0", "htl": "^0.3.0", "js-beautify": "1", @@ -110,7 +110,6 @@ "react-dom": "^19.2.4", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", - "rollup": "^4.9.1", "shiki": "^4.3.1", "topojson-client": "^3.1.0", "ts-morph": "^25.0.1", @@ -122,7 +121,7 @@ "c8": { "all": true, "include": [ - "src/**/*.js" + "src/**/*.{js,ts,tsx}" ], "reporter": [ "text", @@ -138,14 +137,6 @@ "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - }, "engines": { "node": ">=18" }, diff --git a/parity-allow.json b/parity-allow.json new file mode 100644 index 00000000..a2a319fc --- /dev/null +++ b/parity-allow.json @@ -0,0 +1,221 @@ +{ + "arcCollatzUp": {"differs": "stop[stop-color] \u22124"}, + "arrowTestDifferenceY": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "arrowTestPointer": {"differs": "div +1"}, + "athletesSortNationality": {"differs": "div +2, figure[class] \u22121"}, + "athletesSortNullLimit": {"differs": "div +2, figure[class] \u22121"}, + "bigint2": {"differs": "defs +1"}, + "bigintOrdinal": {"differs": "div +2, figure[class] \u22121"}, + "binFillFirstEmpty": {"differs": "div +2, figure[class] \u22121"}, + "caltrain": {"differs": "div +2, figure[class] \u22121"}, + "carsHexbin": {"differs": "div +2, figure[class] \u22121"}, + "carsJitter": {"differs": "div +2, figure[class] \u22121"}, + "colorLegendOrdinalRampInline": {"differs": "div +2, figure[class] \u22121"}, + "colorLegendOrdinalTickFormat": {"differs": "\"1.0\" \u22121, \"2.0\" \u22121, \"3.0\" \u22121, \"4.0\" \u22121, \"5.0\" \u22121, \"1\" +1, \"2\" +1, \"3\" +1, \"4\" +1, \"5\" +1"}, + "colorLegendOrdinalTickFormatFunction": {"differs": "\"1.0\" \u22121, \"2.0\" \u22121, \"3.0\" \u22121, \"4.0\" \u22121, \"5.0\" \u22121, \"1\" +1, \"2\" +1, \"3\" +1, \"4\" +1, \"5\" +1"}, + "colorPiecewiseLinearDomain": {"differs": "div +2, figure[class] \u22121"}, + "colorPiecewiseLinearDomainReverse": {"differs": "div +2, figure[class] \u22121"}, + "colorPiecewiseLinearRange": {"differs": "div +2, figure[class] \u22121"}, + "colorPiecewiseLinearRangeHcl": {"differs": "div +2, figure[class] \u22121"}, + "colorPiecewiseLinearRangeReverse": {"differs": "div +2, figure[class] \u22121"}, + "contourVaporClip": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "crimeanWarArrow": {"differs": "defs +1"}, + "crimeanWarLine": {"differs": "defs +1"}, + "decathlon": {"differs": "div +2, figure[class] \u22121"}, + "differenceFilterX": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceFilterY1": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceFilterY2": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceX": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceY": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceY1": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYConstant": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYCurve": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYNegative": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYOrdinal": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYOrdinalFlip": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYRandom": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYReverse": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYVariable": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "differenceYZero": {"differs": "path[clip-path] 2 values, g[clip-path] 2 values"}, + "driving": {"differs": "defs +1"}, + "empty": {"differs": "circle \u22121"}, + "energyProduction": {"differs": "div +2, figure[class] \u22121"}, + "errorBarX": {"differs": "path \u22122, marker \u22122, defs +1, line[marker-end] 2 values, line[marker-mid] 2 values"}, + "errorBarY": {"differs": "path \u22122, marker \u22122, defs +1, line[marker-end] 2 values, line[marker-mid] 2 values"}, + "federalFunds": {"differs": "defs +1"}, + "figcaption": {"differs": "div +2, figure[class] \u22121"}, + "figcaptionHtml": {"differs": "div +2, figure[class] \u22121"}, + "findArrow": {"differs": "div +2, figure[class] \u22121"}, + "flareTree": {"differs": "defs +1"}, + "frameFillCategorical": {"differs": "div +2, figure[class] \u22121"}, + "frameFillQuantitative": {"differs": "div +2, figure[class] \u22121"}, + "geoLink": {"differs": "defs +1"}, + "greekGodsExplicit": {"differs": "g[stroke] 2 values"}, + "groupMarker": {"differs": "defs +1"}, + "groupMarkerEnd": {"differs": "defs +1"}, + "groupMarkerMid": {"differs": "defs +1"}, + "groupMarkerStart": {"differs": "defs +1"}, + "heatmap": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "heatmapArray": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "heatmapConstantOpacity": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "heatmapFaceted": {"differs": "image[href] \u22124, image[xlink:href] +4"}, + "heatmapFillOpacity": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "heatmapLog": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "heatmapOpacity": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "heatmapPartial": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "hexbinR": {"differs": "div +2, figure[class] \u22121"}, + "hexbinSymbol": {"differs": "div +2, figure[class] \u22121"}, + "hexbinZ": {"differs": "div +2, figure[class] \u22121"}, + "integerIntervalArea": {"differs": "div +2, figure[class] \u22121"}, + "integerIntervalAreaZ": {"differs": "div +2, figure[class] \u22121"}, + "interpolateBarycentric4": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "interpolateBarycentric4k": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "liborProjectionsFacet": {"differs": "defs +7, div +2, figure[class] \u22121, marker[id] 25 values, path[marker-start] 25 values"}, + "likertSurvey": {"differs": "div +2, figure[class] \u22121"}, + "longLabels": {"differs": "\"to \u201clike\u201d things\" \u22121, \"to \"like\" things\" +1"}, + "mandelbrot": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "mandelbrotClip": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "markerDasharray": {"differs": "defs +1"}, + "markerRuleX": {"differs": "path \u22122, marker \u22122, defs +1, line[marker-end] 2 values, line[marker-mid] 2 values"}, + "markerRuleY": {"differs": "path \u22122, marker \u22122, defs +1, line[marker-end] 2 values, line[marker-mid] 2 values"}, + "markerTickX": {"differs": "path \u22122, marker \u22122, defs +1, line[marker-end] 2 values, line[marker-mid] 2 values"}, + "markerTickY": {"differs": "path \u22122, marker \u22122, defs +1, line[marker-end] 2 values, line[marker-mid] 2 values"}, + "markovChain": {"differs": "path \u22123"}, + "nestedFacets": {"differs": "div +2, figure[class] \u22121"}, + "offsets": {"differs": "span \u22121, div +1"}, + "opacityLegend": {"differs": "filter \u22121, feflood \u22121, fecomposite \u22121, image[filter] \u22121", "note": "upstream overlays a feFlood/feComposite filter on the ramp image; replot composites the opacity into the image itself"}, + "opacityLegendCSS4": {"onlyUpstream": "no Replot fixture: the legend's canvas <image> cannot carry a display-p3 fill (node-canvas drops it, #164)"}, + "opacityLegendColor": {"differs": "filter \u22121, feflood \u22121, fecomposite \u22121, image[filter] \u22121", "note": "upstream overlays a feFlood/feComposite filter on the ramp image; replot composites the opacity into the image itself"}, + "opacityLegendLinear": {"differs": "filter \u22121, feflood \u22121, fecomposite \u22121, image[filter] \u22121", "note": "upstream overlays a feFlood/feComposite filter on the ramp image; replot composites the opacity into the image itself"}, + "opacityLegendLog": {"differs": "filter \u22121, feflood \u22121, fecomposite \u22121, image[filter] \u22121", "note": "upstream overlays a feFlood/feComposite filter on the ramp image; replot composites the opacity into the image itself"}, + "opacityLegendRange": {"differs": "filter \u22121, feflood \u22121, fecomposite \u22121, image[filter] \u22121", "note": "upstream overlays a feFlood/feComposite filter on the ramp image; replot composites the opacity into the image itself"}, + "opacityLegendSqrt": {"differs": "filter \u22121, feflood \u22121, fecomposite \u22121, image[filter] \u22121", "note": "upstream overlays a feFlood/feComposite filter on the ramp image; replot composites the opacity into the image itself"}, + "opacityLegendSwatches": {"differs": "svg[fill] 2 values"}, + "ordinalOpacityRamp": {"differs": "div +2, g +1, rect[fill] +10, figure[class] \u22121, feflood[flood-color] 2 values"}, + "ordinalOpacityThreshold": {"differs": "div +2, g +1, rect[fill] +4, figure[class] \u22121, feflood[flood-color] 2 values"}, + "penguinDensityFill": {"differs": "div +2, figure[class] \u22121"}, + "penguinDensityZ": {"differs": "div +2, figure[class] \u22121"}, + "penguinFacetDodgeIsland": {"differs": "div +2, figure[class] \u22121"}, + "penguinFacetDodgeSymbol": {"differs": "div +2, figure[class] \u22121"}, + "penguinQuantileUnknown": {"differs": "div +2, figure[class] \u22121"}, + "penguinSizeSymbols": {"differs": "div +2, figure[class] \u22121"}, + "penguinSpeciesCheysson": {"differs": "div +2, figure[class] \u22121"}, + "pointerLinkedRectInterval": {"differs": "defs +1"}, + "pointerViewof": {"differs": "div +1"}, + "pointerViewofTitle": {"differs": "div +3, figure[class] \u22121"}, + "projectionDomainRatioNC": {"differs": "path[d] 2 values"}, + "projectionHeightDegenerate": {"differs": "svg[style] 2 values"}, + "rasterCa55Barycentric": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterCa55Color": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterCa55Nearest": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterCa55None": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterCa55RandomWalk": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterFacet": {"differs": "image[href] \u22122, image[xlink:href] +2"}, + "rasterMixedOpacity": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterPenguinsBarycentric": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterPenguinsBarycentricBlur": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterPenguinsBlur": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterPenguinsCSS4": {"onlyUpstream": "no Replot fixture: node-canvas ignores colorSpace and drops oklch fills, so the raster is blank on both sides (#164)"}, + "rasterPenguinsRandomWalk": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterPrecision": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterVapor": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterVapor2": {"differs": "div +2, image[href] \u22122, image[xlink:href] +2, figure[class] \u22121"}, + "rasterVaporEqualEarth": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterVaporEqualEarthBarycentric": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterVaporP3": {"onlyUpstream": "no Replot fixture: node-canvas drops display-p3 fills, leaving only the transparent background (#164)"}, + "rasterVaporPeters": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterWalmartBarycentric": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterWalmartBarycentricOpacity": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterWalmartRandomWalk": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "rasterWalmartWalkOpacity": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "roundedRectNegativeX": {"differs": "div +2, figure[class] \u22121"}, + "roundedRectNegativeY": {"differs": "div +2, figure[class] \u22121"}, + "roundedRectNegativeY1": {"differs": "div +2, figure[class] \u22121"}, + "seattleTemperatureAmplitude": {"differs": "div +2, figure[class] \u22121"}, + "simpsonsViews": {"differs": "div +2, figure[class] \u22121"}, + "styleOverrideLegendCategorical": {"differs": "path +11, text +11, rect \u221210, span \u221210, circle +10, svg \u22129, g +5, div +1, style \u22121, figure +1, \"0\" +2, \".style-override-swatches { font-family: cursive; font-size: 25px; margin-bottom: 1em; }\" \u22121, \"A\" \u22121, \"B\" \u22121, \"C\" \u22121, \"D\" \u22121, \"E\" \u22121, \"F\" \u22121, \"G\" \u22121, \"H\" \u22121, \"I\" \u22121, \"J\" \u22121, \":where(.style-override-swatches) { font-family: system-ui, sans-serif; font-size: 10px; margin-bottom: 0.5em; } :where(.style-override-swatch > svg) { margin-right: 0.5em; overflow: visible; } :where(.style-override-swatches-wrap) { display: flex; align-items: center; min-height: 33px; flex-wrap: wrap; } :where(.style-override-swatches-wrap .style-override-swatch) { display: inline-flex; align-items: center; margin-right: 1em; }\" \u22121, \":where(.plot) { --plot-background: white; display: block; height: auto; height: intrinsic; max-width: 100%; } :where(.plot text), :where(.plot tspan) { white-space: pre; }\" +1, \"1\" +1, \"2\" +1, \"3\" +1, \"4\" +1, \"5\" +1, \"6\" +1, \"7\" +1, \"8\" +1, \"9\" +1"}, + "symbolSetFillColor": {"differs": "div +2, figure[class] \u22121"}, + "symbolSetStrokeColor": {"differs": "div +2, figure[class] \u22121"}, + "timeAxisBottom": {"differs": "svg[xmlns] +25, svg[xmlns:xlink] +25", "note": "the shared svg shell declares the namespace on every plot svg, including the plots nested inside another plot's output in this snapshot; upstream's imperative render leaves the namespace to serialisation (its own snapshot harness adds it to the root element instead)"}, + "timeAxisLeft": {"differs": "svg[xmlns] +9, svg[xmlns:xlink] +9", "note": "the shared svg shell declares the namespace on every plot svg, including the plots nested inside another plot's output in this snapshot; upstream's imperative render leaves the namespace to serialisation (its own snapshot harness adds it to the root element instead)"}, + "timeAxisRight": {"differs": "svg[xmlns] +9, svg[xmlns:xlink] +9", "note": "the shared svg shell declares the namespace on every plot svg, including the plots nested inside another plot's output in this snapshot; upstream's imperative render leaves the namespace to serialisation (its own snapshot harness adds it to the root element instead)"}, + "timeAxisTop": {"differs": "svg[xmlns] +25, svg[xmlns:xlink] +25", "note": "the shared svg shell declares the namespace on every plot svg, including the plots nested inside another plot's output in this snapshot; upstream's imperative render leaves the namespace to serialisation (its own snapshot harness adds it to the root element instead)"}, + "tipAnchors": {"differs": "g[visibility] +9, path[d] 18 values, text[transform] 14 values"}, + "tipDispatch": {"differs": "g \u22121, path \u22121, text \u22121, tspan \u22121, (text) \u22121", "note": "upstream's snapshot is hovered at (200, 200); the snapshot harness has no pointer facilities, so this records the unhovered shape (test/plots/tip.tsx)"}, + "tipFormatChannels": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatFacet": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFacetFalse": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFacetFormat": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFacetFormatDefaultDay": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFacetFormatDefaultHour": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFacetFormatDefaultYear": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFacetLabel": {"differs": "path[d] +2, text[transform] +2, text[y] +2"}, + "tipFormatFunction": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatNull": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPaired": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPairedFormat": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPairedLabel": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPairedLabelChannel": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPairedLabelScale": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPairedPartial": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPriority1": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPriority2": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPriorityDefault": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPriorityPaired": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatPriorityPaired2": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatStringDate": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatStringNumber": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatTitleExplicit": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatTitleFormat": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatTitleFormatFunction": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatTitleFormatShorthand": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipFormatTitlePrimitive": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipLongText": {"differs": "path[d] +1, text[transform] +1, text[y] +1"}, + "tipNewLines": {"differs": "path[d] +4, text[transform] +4, text[y] +4"}, + "tipRaster": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "tipTransform": {"differs": "div +2, figure[class] \u22121"}, + "title": {"differs": "div +2, figure[class] \u22121"}, + "titleHtml": {"differs": "div +2, h2 +1, h3 +1, figure[class] \u22121"}, + "trafficHorizon": {"differs": "div +2, figure[class] \u22121"}, + "usCountyChoropleth": {"differs": "div +2, figure[class] \u22121"}, + "usPresidentFavorabilityDots": {"differs": "image[href] 90 values"}, + "usPresidentGallery": {"differs": "image[href] 90 values"}, + "usPresidentGalleryAlt": {"differs": "image[href] 90 values"}, + "usStateCapitalsVoronoi": {"differs": "path \u22121, clippath \u22121, g[clip-path] 2 values"}, + "usStateCentroidVoronoi": {"differs": "path \u22121, clippath \u22121, g[clip-path] 2 values"}, + "usStateClipVoronoi": {"differs": "path \u22121, clippath \u22121, g[clip-path] 2 values"}, + "usStateGeoCentroidVoronoi": {"differs": "path \u22121, clippath \u22121, g[clip-path] 2 values"}, + "volcano": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "volcanoTerrain": {"differs": "image[href] \u22121, image[xlink:href] +1"}, + "waffleHref": {"differs": "defs +1"}, + "waffleMultiple": {"differs": "defs +2"}, + "wafflePointer": {"differs": "defs +2"}, + "wafflePointerFractional": {"differs": "defs +1"}, + "waffleRound": {"differs": "defs +2"}, + "waffleShapes": {"differs": "defs +20"}, + "waffleShorthand": {"differs": "defs +2"}, + "waffleSquished": {"differs": "defs +1"}, + "waffleStroke": {"differs": "defs +2"}, + "waffleStrokeMixed": {"differs": "defs +2"}, + "waffleStrokeNegative": {"differs": "defs +4"}, + "waffleStrokePositive": {"differs": "defs +4"}, + "waffleStrokeWidth": {"differs": "defs +1"}, + "waffleStrokeWidthConst": {"differs": "defs +1"}, + "waffleTip": {"differs": "defs +1"}, + "waffleTipFacet": {"differs": "defs +2"}, + "waffleTipFacetX": {"differs": "defs +2"}, + "waffleTipFacetXY": {"differs": "defs +6"}, + "waffleTipUnit": {"differs": "defs +1"}, + "waffleTipUnitX": {"differs": "defs +1"}, + "waffleTipX": {"differs": "defs +1"}, + "waffleX": {"differs": "defs +1"}, + "waffleXStacked": {"differs": "defs +1"}, + "waffleY": {"differs": "defs +1"}, + "waffleYGrouped": {"differs": "defs +1"}, + "waffleYStacked": {"differs": "div +2, defs +1, figure[class] \u22121"}, + "walmarts": {"differs": "div +2, figure[class] \u22121"}, + "walmartsAdditions": {"differs": "image[href] \u221210, image[xlink:href] +10"}, + "warnMisalignedDivergingDomain": {"differs": "div +2, figure[class] \u22121"}, + "yearFormat": {"differs": "defs +1"}, + "youngAdults": {"differs": "div +2, figure[class] \u22121, path[clip-path] 2 values, g[clip-path] 2 values"} +} diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index 8d5bfb7a..00000000 --- a/rollup.config.js +++ /dev/null @@ -1,65 +0,0 @@ -import fs from "fs"; -import commonjs from "@rollup/plugin-commonjs"; -import json from "@rollup/plugin-json"; -import node from "@rollup/plugin-node-resolve"; -import terser from "@rollup/plugin-terser"; -import meta from "./package.json" with {type: "json"}; - -const filename = meta.name.split("/").pop(); - -// Resolve D3 dependency. -const d3 = JSON.parse(fs.readFileSync("./node_modules/d3/package.json", "utf-8")); -if (typeof d3.jsdelivr === "undefined") throw new Error("unable to resolve d3"); -const d3Path = `d3@${d3.version}/${d3.jsdelivr}`; - -// Extract copyrights from the LICENSE. -const copyrights = fs - .readFileSync("./LICENSE", "utf-8") - .split(/\n/g) - .filter((line) => /^copyright\s+/i.test(line)) - .map((line) => line.replace(/^copyright\s+/i, "")); - -const config = { - input: "bundle.js", - external: ["d3"], - output: { - indent: false, - banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}` - }, - plugins: [commonjs(), json(), node()] -}; - -export default [ - { - ...config, - output: { - ...config.output, - name: "Plot", - format: "umd", - extend: true, - file: `dist/${filename}.umd.js`, - globals: {d3: "d3"}, - paths: {d3: d3Path} - } - }, - { - ...config, - output: { - ...config.output, - name: "Plot", - format: "umd", - extend: true, - file: `dist/${filename}.umd.min.js`, - globals: {d3: "d3"}, - paths: {d3: d3Path} - }, - plugins: [ - ...config.plugins, - terser({ - output: { - preamble: config.output.banner - } - }) - ] - } -]; diff --git a/scripts/check-mdx.mjs b/scripts/check-mdx.mjs deleted file mode 100644 index 62e19525..00000000 --- a/scripts/check-mdx.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import {compile} from "@mdx-js/mdx"; -import fs from "fs"; -import {globSync} from "glob"; - -const files = globSync("docs/**/*.mdx"); -const categories = {}; - -for (const file of files) { - try { - const content = fs.readFileSync(file, "utf-8"); - await compile(content, {jsx: true}); - } catch (e) { - const content = fs.readFileSync(file, "utf-8"); - const lines = content.split("\n"); - - let lineNum = e.line || -1; - let lineContent = lineNum > 0 ? (lines[lineNum - 1] || "") : ""; - - const msgShort = e.message.split("\n")[0].slice(0, 80); - const key = msgShort.slice(0, 60); - if (!categories[key]) categories[key] = []; - categories[key].push({file, line: lineNum, lineContent: lineContent.slice(0, 140)}); - } -} - -for (const [cat, items] of Object.entries(categories)) { - console.log(`\n=== ${cat} (${items.length} files) ===`); - for (const item of items.slice(0, 4)) { - console.log(` ${item.file}:${item.line}`); - if (item.lineContent) console.log(` ${item.lineContent}`); - } - if (items.length > 4) console.log(` ... and ${items.length - 4} more`); -} - -console.log(`\n${Object.values(categories).flat().length} files with errors out of ${files.length}`); diff --git a/scripts/convert-docs.ts b/scripts/convert-docs.ts deleted file mode 100644 index 3653ffe7..00000000 --- a/scripts/convert-docs.ts +++ /dev/null @@ -1,807 +0,0 @@ -/** - * Convert VitePress .md docs to React MDX docs. - * - * Usage: tsx scripts/convert-docs.ts [file.md ...] - * - * If no files are given, converts all docs/*.md and docs/**\/*.md files - * (excluding data files, .vitepress, and already-converted .mdx). - */ - -import fs from "node:fs"; -import path from "node:path"; -import {glob} from "glob"; - -// ─── Imperative → React component name mapping ─────────────────────────── -// Maps `Plot.<name>(...)` to the React component name. -// Lowercase imperative names → PascalCase React components. -const MARK_MAP: Record<string, string> = { - dot: "Dot", - dotX: "DotX", - dotY: "DotY", - circle: "Circle", - hexagon: "Hexagon", - line: "Line", - lineX: "LineX", - lineY: "LineY", - area: "Area", - areaX: "AreaX", - areaY: "AreaY", - barX: "BarX", - barY: "BarY", - rect: "Rect", - rectX: "RectX", - rectY: "RectY", - cell: "Cell", - cellX: "CellX", - cellY: "CellY", - ruleX: "RuleX", - ruleY: "RuleY", - text: "Text", - textX: "TextX", - textY: "TextY", - tickX: "TickX", - tickY: "TickY", - frame: "Frame", - link: "Link", - arrow: "Arrow", - vector: "Vector", - vectorX: "VectorX", - vectorY: "VectorY", - spike: "Spike", - image: "Image", - geo: "Geo", - sphere: "Sphere", - graticule: "Graticule", - delaunayLink: "DelaunayLink", - delaunayMesh: "DelaunayMesh", - hull: "Hull", - voronoi: "Voronoi", - voronoiMesh: "VoronoiMesh", - density: "Density", - contour: "Contour", - raster: "Raster", - hexgrid: "Hexgrid", - boxX: "BoxX", - boxY: "BoxY", - tree: "TreeMark", - cluster: "ClusterMark", - bollingerX: "BollingerX", - bollingerY: "BollingerY", - differenceX: "DifferenceX", - differenceY: "DifferenceY", - linearRegressionX: "LinearRegressionX", - linearRegressionY: "LinearRegressionY", - waffleX: "WaffleX", - waffleY: "WaffleY", - axisX: "AxisX", - axisY: "AxisY", - gridX: "GridX", - gridY: "GridY", - axisFx: "AxisFx", - axisFy: "AxisFy", - gridFx: "GridFx", - gridFy: "GridFy", - tip: "Tip", - crosshair: "Crosshair", - crosshairX: "CrosshairX", - crosshairY: "CrosshairY", - auto: "Auto", -}; - -// Transforms that remain as function calls (spread onto mark components) -const TRANSFORMS = new Set([ - "bin", "binX", "binY", - "group", "groupX", "groupY", "groupZ", - "stackX", "stackX1", "stackX2", "stackY", "stackY1", "stackY2", - "dodgeX", "dodgeY", - "normalizeX", "normalizeY", "normalize", - "windowX", "windowY", "window", - "mapX", "mapY", "map", - "selectFirst", "selectLast", "selectMaxX", "selectMaxY", "selectMinX", "selectMinY", "select", - "hexbin", - "shiftX", "shiftY", - "centroid", "geoCentroid", - "filter", "sort", "reverse", "shuffle", - "treeNode", "treeLink", - "find", - "bollinger", -]); - -// ─── VitePress data → import mapping ───────────────────────────────────── -// Maps `import foo from "../data/foo.ts"` to correct MDX import path -function fixDataImportPath(importPath: string, fileDir: string): string { - // Already a relative path to data — keep it, just adjust depth if needed - return importPath; -} - -// ─── Vue ref/shallowRef → React useState ───────────────────────────────── -interface StateDecl { - name: string; - initialValue: string; - setterName: string; -} - -interface AsyncLoad { - varName: string; - loadExpr: string; -} - -interface ComputedDecl { - name: string; - expr: string; -} - -interface ScriptSetupParsed { - imports: string[]; - states: StateDecl[]; - asyncLoads: AsyncLoad[]; - computed: ComputedDecl[]; - rawLines: string[]; -} - -function parseScriptSetup(block: string): ScriptSetupParsed { - const result: ScriptSetupParsed = {imports: [], states: [], asyncLoads: [], computed: [], rawLines: []}; - const lines = block.split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed === "<script setup>" || trimmed === "</script>") continue; - - // Skip Vue imports - if (/^import\s+.*from\s+["']vue["']/.test(trimmed)) continue; - if (/^import\s+\*\s+as\s+Plot\s+from\s+["']@dave-hillier\/replot["']/.test(trimmed)) continue; - - // Keep d3 and other imports - if (/^import\s/.test(trimmed)) { - // Rewrite data imports to remove .data suffix if present - const rewritten = trimmed.replace(/from\s+["'](.+?)["']/, (_, p) => { - return `from "${p}"`; - }); - result.imports.push(rewritten); - continue; - } - - // const foo = shallowRef([]) or ref(value) - const refMatch = trimmed.match(/^const\s+(\w+)\s*=\s*(?:shallowRef|ref)\((.+)\)\s*;?\s*$/); - if (refMatch) { - const [, name, init] = refMatch; - result.states.push({ - name, - initialValue: init, - setterName: `set${name[0].toUpperCase()}${name.slice(1)}` - }); - continue; - } - - // computed - const computedMatch = trimmed.match(/^const\s+(\w+)\s*=\s*computed\(\(\)\s*=>\s*(.+)\)\s*;?\s*$/); - if (computedMatch) { - result.computed.push({name: computedMatch[1], expr: computedMatch[2]}); - continue; - } - - // Multi-line computed – just flag for manual review - if (/computed\(/.test(trimmed)) { - result.rawLines.push(`// TODO: convert computed: ${trimmed}`); - continue; - } - - // onMounted block – extract async loads - if (/^onMounted\(/.test(trimmed)) { - // Parse the whole onMounted block from lines - continue; // handled below - } - - // d3.csv/json loading patterns (inside onMounted) - const loadMatch = trimmed.match(/d3\.\w+\(["'](.+?)["'](?:,\s*\w+(?:\.\w+)*)?\)\.then\(\(?(?:data|\w+)\)?\s*=>\s*\(?(\w+)\.value\s*=\s*(?:data|\w+)\)?\)/); - if (loadMatch) { - result.asyncLoads.push({ - varName: loadMatch[2], - loadExpr: trimmed.replace(/\.then\(.*/, "") - }); - continue; - } - - // Skip onMounted boilerplate lines - if (/^onMounted|^\}\);?\s*$|^\(\)\s*=>\s*\{/.test(trimmed)) continue; - - // Anything else - if (trimmed !== "{" && trimmed !== "}") { - result.rawLines.push(`// TODO: manually convert: ${trimmed}`); - } - } - - return result; -} - -// ─── Convert a :::plot code block to React JSX ────────────────────────── -interface ConvertedPlot { - jsx: string; - reactImports: Set<string>; - warnings: string[]; -} - -function convertPlotBlock(code: string): ConvertedPlot { - const reactImports = new Set<string>(); - const warnings: string[] = []; - - // Apply the same transformations as markdown-it-plot.ts - code = code.replace(/\bMath\.random\b/g, "d3.randomLcg(42)"); - code = code.replace(/\bd3\.(random(?!Lcg)\w+)\b/g, "d3.$1.source(d3.randomLcg(42))"); - code = code.replace(/\bd3\.shuffle\b/g, "d3.shuffler(d3.randomLcg(42))"); - - const trimmed = code.trim(); - - // Case 1: Plot.plot({...}) - if (/^Plot\.plot\(/.test(trimmed)) { - const result = convertPlotPlotCall(trimmed, reactImports, warnings); - if (result) return {jsx: result, reactImports, warnings}; - } - - // Case 2: mark.plot({...}) — e.g., Plot.dot(data, opts).plot({...}) - // Exclude Plot.plot() which is handled above - const plotMethodMatch = !(/^Plot\.plot\(/.test(trimmed)) ? trimmed.match(/^(.+?)\.plot\(([\s\S]*)\)$/) : null; - if (plotMethodMatch) { - const [, markExpr, plotOptsStr] = plotMethodMatch; - const markJsx = convertMarkExpr(markExpr, reactImports, warnings); - const plotProps = plotOptsStr.trim() ? convertObjectToProps(plotOptsStr.trim(), reactImports) : ""; - reactImports.add("Plot"); - return { - jsx: `<Plot${plotProps}>\n ${markJsx}\n</Plot>`, - reactImports, - warnings - }; - } - - // Fallback: can't convert automatically - warnings.push(`Could not auto-convert: ${trimmed.slice(0, 80)}...`); - return {jsx: `{/* TODO: manually convert */}\n{/* ${escapeJsxComment(trimmed)} */}`, reactImports, warnings}; -} - -function convertPlotPlotCall(code: string, imports: Set<string>, warnings: string[]): string | null { - // Extract the options object from Plot.plot({...}) - // This is tricky because of nested braces. Use a simple brace counter. - const start = code.indexOf("("); - if (start < 0) return null; - - const inner = extractBalancedParens(code, start); - if (!inner) return null; - - imports.add("Plot"); - - // Extract the marks array using balanced bracket matching - const marksIdx = inner.indexOf("marks:"); - if (marksIdx < 0) { - warnings.push("Plot.plot() without marks array — manual review needed"); - return `{/* TODO: Plot.plot(${inner.slice(0, 60)}...) */}`; - } - - // Find the opening [ after "marks:" - const bracketStart = inner.indexOf("[", marksIdx); - if (bracketStart < 0) { - warnings.push("Plot.plot() marks is not an array — manual review needed"); - return `{/* TODO: Plot.plot(${inner.slice(0, 60)}...) */}`; - } - - const marksContent = extractBalancedBrackets(inner, bracketStart); - if (!marksContent) { - warnings.push("Could not extract marks array — manual review needed"); - return `{/* TODO: Plot.plot(${inner.slice(0, 60)}...) */}`; - } - - // Find the full marks: [...] range to remove it from options - const marksEnd = bracketStart + marksContent.length + 2; // +2 for [ and ] - const beforeMarks = inner.slice(0, marksIdx).replace(/,\s*$/, ""); - const afterMarks = inner.slice(marksEnd).replace(/^\s*,?/, ""); - const optionsWithoutMarks = (beforeMarks + afterMarks).trim(); - - // Convert each mark in the marks array - const markExprs = splitTopLevelComma(marksContent); - const markJsxParts: string[] = []; - - for (const expr of markExprs) { - const trimExpr = expr.trim(); - if (!trimExpr) continue; - markJsxParts.push(convertMarkExpr(trimExpr, imports, warnings)); - } - - // Convert remaining options to props on <Plot> - let plotProps = ""; - if (optionsWithoutMarks) { - // Remove surrounding braces if present - let opts = optionsWithoutMarks; - if (opts.startsWith("{")) opts = opts.slice(1); - if (opts.endsWith("}")) opts = opts.slice(0, -1); - plotProps = convertObjectLiteralToProps(opts.trim(), imports); - } - - const marksJsx = markJsxParts.map(m => ` ${m}`).join("\n"); - return `<Plot${plotProps}>\n${marksJsx}\n</Plot>`; -} - -function convertMarkExpr(expr: string, imports: Set<string>, warnings: string[]): string { - const trimExpr = expr.trim(); - - // Handle Plot.markName(data, options) or Plot.markName(options) - const markMatch = trimExpr.match(/^Plot\.(\w+)\(([\s\S]*)$/); - if (!markMatch) { - // Could be a conditional or variable reference - warnings.push(`Non-Plot mark expression: ${trimExpr.slice(0, 60)}`); - return `{/* TODO: ${escapeJsxComment(trimExpr)} */}`; - } - - const [, fnName, rest] = markMatch; - // Remove trailing paren - const argsStr = rest.replace(/\)\s*$/, ""); - - // Check if this is a mark or a transform - const reactName = MARK_MAP[fnName]; - if (!reactName) { - // Might be a transform wrapping - like Plot.binX(...) - if (TRANSFORMS.has(fnName)) { - warnings.push(`Transform used as top-level mark: Plot.${fnName}(...) — needs manual review`); - return `{/* TODO: Plot.${fnName}(...) */}`; - } - warnings.push(`Unknown Plot function: Plot.${fnName}`); - return `{/* TODO: Plot.${fnName}(...) */}`; - } - - imports.add(reactName); - - // Parse arguments: first arg is data, second is options - // But some marks have transform wrappers: Plot.dot(data, Plot.stackY({...})) - const args = splitTopLevelComma(argsStr); - - if (args.length === 0) { - // No args — e.g., Plot.frame() - return `<${reactName} />`; - } - - if (args.length === 1) { - const arg = args[0].trim(); - // Could be just data or just options - if (arg.startsWith("{")) { - // Options object - const props = convertObjectLiteralToProps(stripBraces(arg), imports); - return `<${reactName}${props} />`; - } - // Data only - return `<${reactName} data={${arg}} />`; - } - - // Two args: data and options - const dataArg = args[0].trim(); - const optsArg = args[1].trim(); - - // Check if options is a transform: Plot.binX({...}, {...}) - const transformMatch = optsArg.match(/^Plot\.(\w+)\(([\s\S]*)\)$/); - if (transformMatch && TRANSFORMS.has(transformMatch[1])) { - const transformName = transformMatch[1]; - imports.add(transformName); - return `<${reactName} data={${dataArg}} {...${transformName}(${transformMatch[2]})} />`; - } - - // Regular options object - if (optsArg.startsWith("{")) { - const props = convertObjectLiteralToProps(stripBraces(optsArg), imports); - return `<${reactName} data={${dataArg}}${props} />`; - } - - // Variable reference or other expression - return `<${reactName} data={${dataArg}} {...${optsArg}} />`; -} - -// ─── Object literal → JSX props conversion ────────────────────────────── - -function convertObjectLiteralToProps(objContent: string, imports: Set<string>): string { - if (!objContent.trim()) return ""; - - // Split by top-level commas in the object - const entries = splitTopLevelComma(objContent); - const props: string[] = []; - - for (const entry of entries) { - const trimEntry = entry.trim(); - if (!trimEntry) continue; - - // key: value - const colonIdx = findTopLevelColon(trimEntry); - if (colonIdx < 0) { - // Shorthand property or spread - if (trimEntry.startsWith("...")) { - props.push(` {...${trimEntry.slice(3)}}`); - } else { - props.push(` ${trimEntry}={${trimEntry}}`); - } - continue; - } - - const key = trimEntry.slice(0, colonIdx).trim(); - const value = trimEntry.slice(colonIdx + 1).trim(); - - // String values - if (/^"[^"]*"$/.test(value) || /^'[^']*'$/.test(value)) { - props.push(` ${key}=${value.replace(/'/g, '"')}`); - continue; - } - - // Boolean true - if (value === "true") { - props.push(` ${key}`); - continue; - } - - // Number - if (/^-?\d+(\.\d+)?$/.test(value)) { - props.push(` ${key}={${value}}`); - continue; - } - - // Object values need double braces in JSX - if (value.startsWith("{")) { - props.push(` ${key}={${value}}`); - } else { - props.push(` ${key}={${value}}`); - } - } - - return props.join(""); -} - -function convertObjectToProps(objStr: string, imports: Set<string>): string { - let inner = objStr.trim(); - if (inner.startsWith("(")) inner = inner.slice(1); - if (inner.endsWith(")")) inner = inner.slice(0, -1); - if (inner.startsWith("{")) inner = inner.slice(1); - if (inner.endsWith("}")) inner = inner.slice(0, -1); - return convertObjectLiteralToProps(inner.trim(), imports); -} - -// ─── Utility functions ─────────────────────────────────────────────────── - -function extractBalancedBrackets(str: string, openIdx: number): string | null { - let depth = 0; - let inString: string | null = null; - let escaped = false; - for (let i = openIdx; i < str.length; i++) { - const ch = str[i]; - if (escaped) { escaped = false; continue; } - if (ch === "\\") { escaped = true; continue; } - if (inString) { if (ch === inString) inString = null; continue; } - if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; } - if (ch === "[") depth++; - else if (ch === "]") { - depth--; - if (depth === 0) return str.slice(openIdx + 1, i); - } - } - return null; -} - -function extractBalancedParens(str: string, openIdx: number): string | null { - let depth = 0; - let inString: string | null = null; - let escaped = false; - for (let i = openIdx; i < str.length; i++) { - const ch = str[i]; - if (escaped) { escaped = false; continue; } - if (ch === "\\") { escaped = true; continue; } - if (inString) { if (ch === inString) inString = null; continue; } - if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; } - if (ch === "(") depth++; - else if (ch === ")") { - depth--; - if (depth === 0) return str.slice(openIdx + 1, i); - } - } - return null; -} - -function splitTopLevelComma(str: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ""; - let inString: string | null = null; - let escaped = false; - - for (let i = 0; i < str.length; i++) { - const ch = str[i]; - - if (escaped) { - current += ch; - escaped = false; - continue; - } - if (ch === "\\") { - current += ch; - escaped = true; - continue; - } - if (inString) { - current += ch; - if (ch === inString) inString = null; - continue; - } - if (ch === '"' || ch === "'" || ch === "`") { - inString = ch; - current += ch; - continue; - } - if (ch === "(" || ch === "[" || ch === "{") { - depth++; - current += ch; - continue; - } - if (ch === ")" || ch === "]" || ch === "}") { - depth--; - current += ch; - continue; - } - if (ch === "," && depth === 0) { - parts.push(current); - current = ""; - continue; - } - current += ch; - } - - if (current.trim()) parts.push(current); - return parts; -} - -function findTopLevelColon(str: string): number { - let depth = 0; - let inString: string | null = null; - let escaped = false; - - for (let i = 0; i < str.length; i++) { - const ch = str[i]; - if (escaped) { escaped = false; continue; } - if (ch === "\\") { escaped = true; continue; } - if (inString) { if (ch === inString) inString = null; continue; } - if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; } - if (ch === "(" || ch === "[" || ch === "{") { depth++; continue; } - if (ch === ")" || ch === "]" || ch === "}") { depth--; continue; } - if (ch === ":" && depth === 0) return i; - } - return -1; -} - -function stripBraces(s: string): string { - const t = s.trim(); - if (t.startsWith("{") && t.endsWith("}")) return t.slice(1, -1).trim(); - return t; -} - -function escapeJsxComment(s: string): string { - return s.replace(/\*\//g, "*\\/").replace(/\/\*/g, "\\/*"); -} - -// ─── Convert interactive HTML controls ────────────────────────────────── - -function convertVueControls(html: string): string { - // v-model checkbox - html = html.replace( - /<input\s+type="checkbox"\s+v-model="(\w+)"\s*\/?>/g, - '<input type="checkbox" checked={$1} onChange={(e) => set$1(e.target.checked)} />' - ); - // v-model select - html = html.replace( - /<select\s+v-model="(\w+)">/g, - '<select value={$1} onChange={(e) => set$1(e.target.value)}>' - ); - // v-model.number for range inputs - html = html.replace( - /<input\s+type="range"\s+([^>]*)v-model(?:\.number)?="(\w+)"([^>]*)\/?>/g, - '<input type="range" $1value={$2} onChange={(e) => set$2(Number(e.target.value))}$3 />' - ); - // v-model radio - html = html.replace( - /<input\s+type="radio"\s+([^>]*)v-model="(\w+)"\s+value="([^"]*)"([^>]*)\/?>/g, - '<input type="radio" $1checked={$2 === "$3"} onChange={() => set$2("$3")}$4 />' - ); - // Generic v-model on input - html = html.replace( - /<input\s+([^>]*)v-model(?:\.number)?="(\w+)"([^>]*)\/?>/g, - '<input $1value={$2} onChange={(e) => set$2(e.target.value)}$3 />' - ); - // Fix setter names to proper camelCase - html = html.replace(/set(\w)/g, (_, c) => `set${c.toUpperCase()}`); - // class= → className= - html = html.replace(/\bclass="/g, 'className="'); - return html; -} - -// ─── Main conversion function ──────────────────────────────────────────── - -interface ConversionResult { - mdx: string; - warnings: string[]; -} - -function convertFile(mdContent: string, filePath: string): ConversionResult { - const warnings: string[] = []; - const allReactImports = new Set<string>(); - const needsUseState = new Set<string>(); - const needsUseEffect = false; - let hasAsyncLoads = false; - - // 1. Extract <script setup> block - const scriptMatch = mdContent.match(/<script setup>([\s\S]*?)<\/script>/); - let scriptParsed: ScriptSetupParsed | null = null; - let bodyContent = mdContent; - - if (scriptMatch) { - scriptParsed = parseScriptSetup(scriptMatch[1]); - bodyContent = mdContent.replace(/<script setup>[\s\S]*?<\/script>\s*/, ""); - - for (const s of scriptParsed.states) { - needsUseState.add(s.name); - } - if (scriptParsed.asyncLoads.length > 0) { - hasAsyncLoads = true; - } - } - - // 2. Remove frontmatter - bodyContent = bodyContent.replace(/^---[\s\S]*?---\s*/, ""); - - // 3. Convert :::plot blocks - const plotBlockRegex = /:::plot(?:\s+[^\n]*)?\n```(?:js|js-vue)\n([\s\S]*?)```\n:::/g; - bodyContent = bodyContent.replace(plotBlockRegex, (match, code) => { - const converted = convertPlotBlock(code.trim()); - for (const imp of converted.reactImports) allReactImports.add(imp); - warnings.push(...converted.warnings); - return converted.jsx; - }); - - // 4. Convert :::code-group blocks to plain code blocks - bodyContent = bodyContent.replace(/:::code-group\n/g, ""); - bodyContent = bodyContent.replace(/\n:::\s*$/gm, ""); - // Remove remaining ::: directives - bodyContent = bodyContent.replace(/^:::.*$/gm, ""); - - // 5. Convert Vue-style HTML controls - bodyContent = convertVueControls(bodyContent); - - // 6. Fix Vue-style template expressions (only in markdown text, not in JSX) - // Vue uses {{expr}} for interpolation, but JSX uses {expr}. - // Only convert if preceded by non-= character (JSX props use ={{...}}) - bodyContent = bodyContent.replace(/(?<!=)\{\{([^}]+)\}\}(?!\})/g, "{$1}"); - - // 7. Fix HTML style attributes for JSX - bodyContent = bodyContent.replace( - /style="([^"]*)"/g, - (_, styleStr) => { - // Convert CSS string to JSX style object - const props = styleStr.split(";").filter(Boolean).map((prop: string) => { - const [key, ...vals] = prop.split(":"); - const jsKey = key.trim().replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase()); - return `${jsKey}: "${vals.join(":").trim()}"`; - }); - return `style={{${props.join(", ")}}}`; - } - ); - - // 8. Build import section - const importLines: string[] = []; - - // React components from @dave-hillier/replot/react - if (allReactImports.size > 0) { - const sorted = [...allReactImports].sort(); - importLines.push(`import {${sorted.join(", ")}} from "@dave-hillier/replot/react";`); - } - - // d3 if used - if (bodyContent.includes("d3.") || scriptParsed?.imports.some(i => i.includes("d3"))) { - importLines.push('import * as d3 from "d3";'); - } - - // useState/useEffect if needed - const reactHooks: string[] = []; - if (needsUseState.size > 0) reactHooks.push("useState"); - if (hasAsyncLoads) reactHooks.push("useEffect"); - if (reactHooks.length > 0) { - importLines.push(`import {${reactHooks.join(", ")}} from "react";`); - } - - // Data imports from script setup - if (scriptParsed) { - for (const imp of scriptParsed.imports) { - if (!imp.includes("d3")) { - importLines.push(imp); - } - } - } - - // 9. Assemble MDX - let mdx = ""; - if (importLines.length > 0) { - mdx += importLines.join("\n") + "\n\n"; - } - - // Add state declarations and computed values as exported component if needed - if (scriptParsed && (scriptParsed.states.length > 0 || scriptParsed.asyncLoads.length > 0)) { - // Flag for manual wrapping — interactive pages need a wrapper component - warnings.push("Page has state/async loads — needs manual wrapper component"); - } - - // Add TODO comments for unconverted lines - if (scriptParsed?.rawLines.length) { - for (const line of scriptParsed.rawLines) { - mdx += line + "\n"; - } - mdx += "\n"; - } - - mdx += bodyContent.trim() + "\n"; - - return {mdx, warnings}; -} - -// ─── CLI entry point ───────────────────────────────────────────────────── - -async function main() { - const args = process.argv.slice(2); - let files: string[]; - - if (args.length > 0) { - files = args; - } else { - // Find all .md files in docs/, excluding .vitepress and data - const allMd = await glob("docs/**/*.md", { - ignore: ["docs/.vitepress/**", "docs/data/**", "docs/public/**"], - cwd: process.cwd() - }); - files = allMd.filter(f => !f.endsWith(".data.ts")); - } - - let totalWarnings = 0; - let totalConverted = 0; - - for (const file of files) { - const absPath = path.resolve(file); - if (!fs.existsSync(absPath)) { - console.error(`File not found: ${absPath}`); - continue; - } - - const content = fs.readFileSync(absPath, "utf-8"); - - // Skip files that don't have plot blocks or script setup - if (!content.includes(":::plot") && !content.includes("<script setup>")) { - // Still convert to .mdx for consistency but just rename - const mdxPath = absPath.replace(/\.md$/, ".mdx"); - // Remove frontmatter and :::code-group directives - let cleaned = content.replace(/^---[\s\S]*?---\s*/, ""); - cleaned = cleaned.replace(/:::code-group\n/g, ""); - cleaned = cleaned.replace(/^:::(?!plot).*$/gm, ""); - fs.writeFileSync(mdxPath, cleaned); - console.log(` Copied: ${file} → ${file.replace(/\.md$/, ".mdx")} (no plots)`); - totalConverted++; - continue; - } - - const result = convertFile(content, absPath); - const mdxPath = absPath.replace(/\.md$/, ".mdx"); - fs.writeFileSync(mdxPath, result.mdx); - - totalConverted++; - if (result.warnings.length > 0) { - totalWarnings += result.warnings.length; - console.log(` Converted: ${file} → ${file.replace(/\.md$/, ".mdx")} (${result.warnings.length} warnings)`); - for (const w of result.warnings) { - console.log(` ⚠ ${w}`); - } - } else { - console.log(` Converted: ${file} → ${file.replace(/\.md$/, ".mdx")}`); - } - } - - console.log(`\nDone. ${totalConverted} files converted, ${totalWarnings} warnings.`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/fix-mdx-final.mjs b/scripts/fix-mdx-final.mjs deleted file mode 100644 index 0dd1b3c7..00000000 --- a/scripts/fix-mdx-final.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import fs from "fs"; -import {globSync} from "glob"; - -const files = globSync("docs/**/*.mdx"); -let totalChanges = 0; - -for (const file of files) { - let content = fs.readFileSync(file, "utf-8"); - const original = content; - - // Fix duplicated nested {/* comments - content = content.replace(/\{\/\*\s*\n\/\/ TODO:.*?\n\{\/\*/g, (match) => { - return match.replace(/\n\{\/\*/g, ""); - }); - content = content.replace(/\*\/\}\s*\n\*\/\}/g, "*/}"); - - // Fix /, MI / back to the original (was mangled by previous regex replacement) - content = content.replace(/\/ MI \//g, "/, MI /"); - - // Fix broken JSX attributes from // comment removal - // Pattern: sort={(d) => / MI /.test(d.division)={MI /.test(d.division)} - content = content.replace(/(\([^)]+\) => \/ [^/]+ \/\.test\([^)]+\))=\{\1\}/g, "$1"); - - // Fix: remove standalone // comments that are between JSX attributes on their own line - // These appear after a closing ]} or }} and before the next attribute - const lines = content.split("\n"); - const fixedLines = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Check if this line is ONLY a // comment (not in a code block) - if (trimmed.startsWith("//") && !trimmed.startsWith("// TODO:")) { - // Check if previous line ends with JSX-like syntax - const prevLine = i > 0 ? lines[i - 1].trim() : ""; - const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : ""; - - // If previous line ends with }] or ]} or }, and next line looks like an attribute - // then this is likely a comment between JSX attributes - skip it - if ((prevLine.endsWith("}]") || prevLine.endsWith("]}") || prevLine.endsWith("},")) && - (nextLine.match(/^\w+[={]/) || nextLine.includes("="))) { - // Skip this comment line - continue; - } - } - - // Fix: // comments at end of JSX continuation lines (after ]} or }) - // Pattern: ]} // comment => ]} - if (line.includes("//") && (line.includes("}]") || line.includes("},"))) { - const commentPos = line.indexOf("//"); - const beforeComment = line.substring(0, commentPos); - - // Check if we're in JSX context (unclosed tag) - if (!beforeComment.trim().endsWith(">")) { - fixedLines.push(beforeComment.trimEnd()); - continue; - } - } - - // Fix: dangling // comments inside JSX props that create malformed attributes - // Pattern: x={{...}} // comment={// comment}> - if (line.includes("// ") && line.includes("={//")) { - const match = line.match(/(.*?)\s*\/\/ [^=]+=\{\/\/ .*?\}>/); - if (match) { - fixedLines.push(match[1] + ">"); - continue; - } - } - - fixedLines.push(line); - } - content = fixedLines.join("\n"); - - // Fix multi-line JSX with // comments between attributes - // This handles cases where } // comment appears in the middle of a JSX tag - content = content.replace(/(\}\s*)\/\/ [^\n]+\n(\s+<)/g, "$1\n$2"); - content = content.replace(/(\}\s*)\/\/ [^\n]+$/gm, "$1"); - - // Fix {*name*: *value*} pattern in markdown that causes parse errors - // This should be \{*name*: *value*\} or just escaped - content = content.replace(/([^\\])\{(\*\w+\*:\s*\*\w+\*)\}/g, "$1\\{$2\\}"); - - // Fix missing closing > on Plot tags - // Pattern: <Plot ... }}" with newline after, should be <Plot ... }}> with newline - content = content.replace(/(<Plot[^>]*\}\})"?\s*\n/g, "$1>\n"); - - // Fix SVG attributes in inline SVG that weren't caught - // Look for stroke-width in the middle of content - content = content.replace(/stroke-width=/g, "strokeWidth="); - - if (content !== original) { - fs.writeFileSync(file, content, "utf-8"); - totalChanges++; - console.log(`Fixed: ${file}`); - } -} - -console.log(`\n✅ Fixed ${totalChanges} files out of ${files.length} total`); diff --git a/scripts/fix-mdx-manual.mjs b/scripts/fix-mdx-manual.mjs deleted file mode 100644 index bd620217..00000000 --- a/scripts/fix-mdx-manual.mjs +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "fs"; -import {globSync} from "glob"; - -const files = globSync("docs/**/*.mdx"); -let totalChanges = 0; - -for (const file of files) { - let content = fs.readFileSync(file, "utf-8"); - const original = content; - - // Fix 1: Remove remaining // comments between JSX attributes (on their own lines) - const lines = content.split("\n"); - const filtered = []; - let inJSXTag = false; - let tagDepth = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Track if we're inside a JSX tag - const openTags = (line.match(/<[A-Z]\w*/g) || []).length; - const closeTags = (line.match(/<\/[A-Z]\w*>/g) || []).length; - const selfClose = (line.match(/\/>/g) || []).length; - - tagDepth += openTags - closeTags - selfClose; - inJSXTag = tagDepth > 0; - - // Skip // comment lines that appear between JSX attributes - if (trimmed.startsWith("//") && !trimmed.startsWith("// TODO:")) { - const prevLine = i > 0 ? lines[i - 1] : ""; - const nextLine = i < lines.length - 1 ? lines[i + 1] : ""; - - // If we're clearly between JSX attributes, skip the comment - if (inJSXTag || - (prevLine.includes("=") && nextLine.trim().match(/^\w+=/)) || - (prevLine.trim().match(/^(x|y|data|fill|stroke|r)\d?="/) && nextLine.trim().match(/^(x|y|data|fill|stroke|r)\d?="/))) { - continue; - } - } - - filtered.push(line); - } - content = filtered.join("\n"); - - // Fix 2: Fix numeric attributes in SVG tags (should be {12} not =12) - // Pattern: <svg width=12 height=12 => <svg width={12} height={12} - content = content.replace(/<svg([^>]*)\s+width=(\d+)/g, "<svg$1 width={$2}"); - content = content.replace(/<svg([^>]*)\s+height=(\d+)/g, "<svg$1 height={$2}"); - // Also fix viewBox with numeric values - content = content.replace(/viewBox="(-?\d+\s+-?\d+\s+\d+\s+\d+)"/g, 'viewBox="$1"'); // keep as string, it's correct - - // Fix 3: Add missing closing > on Plot tags - // Pattern: <Plot ... }}\n followed by <Geo or other component - content = content.replace(/(<Plot[^>]*\}\})\s*\n(\s*<[A-Z])/g, "$1>\n$2"); - - // Fix 4: Fix escaped markdown braces that cause parse errors - // Unescape {*name*: *value*} patterns that appear in markdown text - // But we need to be careful - only fix them if they're causing errors - // Look for patterns in bullet points or regular text - content = content.replace(/- a \\\{(\*[^*]+\*[^}]+)\\\}/g, "- a {$1}"); - - // Actually, the error says "Could not parse expression" which means - // the {*channel*, *order*} is being treated as a JSX expression - // We need to escape it better or avoid the issue - // Let's try replacing with backticks - content = content.replace(/\{(\*\w+\*(?:,\s*\*\w+\*)*)\}/g, "`{$1}`"); - - // Fix 5: Clean up double TODO comments - content = content.replace(/\{\/\* TODO: \{\/\* TODO:/g, "{/* TODO:"); - - if (content !== original) { - fs.writeFileSync(file, content, "utf-8"); - totalChanges++; - console.log(`Fixed: ${file}`); - } -} - -console.log(`\n✅ Fixed ${totalChanges} files out of ${files.length} total`); diff --git a/scripts/fix-mdx-remaining.mjs b/scripts/fix-mdx-remaining.mjs deleted file mode 100644 index 041d1b4d..00000000 --- a/scripts/fix-mdx-remaining.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import fs from "fs"; -import {globSync} from "glob"; - -const files = globSync("docs/**/*.mdx"); -let totalChanges = 0; - -for (const file of files) { - let content = fs.readFileSync(file, "utf-8"); - const original = content; - - // Fix 1: Triple braces in style attributes - // {{{borderBottom: ...}}} => {{borderBottom: ...}} - content = content.replace(/style=\{\{\{([^}]+)\}\}\}/g, "style={{$1}}"); - - // Fix 2: Template literal quotes inside style attributes - // {{borderBottom: "`solid...`"}} => {{borderBottom: `solid...`}} - content = content.replace(/\{\{([^:]+):\s*"`([^`]+)`"\}\}/g, "{{$1: `$2`}}"); - - // Fix 3: Fix PlotRender TODO comments with unclosed strings - // {/* TODO: <PlotRender options='{ => {/* TODO: <PlotRender (commented out) - content = content.replace(/\{\/\* TODO: <PlotRender options='\{/g, "{/* TODO: <PlotRender - manually convert */"); - - // Fix 4: Find other {*...*} patterns in markdown text and escape them - // But only if they're not already escaped and not in code blocks - const lines = content.split("\n"); - const fixedLines = []; - let inCodeBlock = false; - - for (const line of lines) { - if (line.trim().startsWith("```")) { - inCodeBlock = !inCodeBlock; - fixedLines.push(line); - continue; - } - - if (inCodeBlock || line.trim().startsWith("import ") || line.trim().startsWith("//")) { - fixedLines.push(line); - continue; - } - - // Fix unescaped {*...*} patterns in regular markdown text - // But don't touch code, imports, or already-escaped patterns - let fixed = line; - if (fixed.includes("{*") && !fixed.includes("\\{*") && !fixed.includes("<") && !fixed.includes("```")) { - // Simple heuristic: if line has {*something* ... } pattern and it's markdown text (no JSX) - fixed = fixed.replace(/(?<!\\)\{(\*[^}]+\*[^}]*)\}/g, "\\{$1\\}"); - } - - fixedLines.push(fixed); - } - content = fixedLines.join("\n"); - - // Fix 5: Check for any remaining unclosed Plot tags - // <Plot ... }}\n<Component should be <Plot ... }}>\n<Component - content = content.replace(/(<Plot[^>]*\}\})\s*\n(\s*<[A-Z])/g, "$1>\n$2"); - - // Fix 6: Check for projections.mdx similar issue - content = content.replace(/(<Plot[^>]*projection="[^"]*"[^>]*)\s*\n(\s*<Geo)/g, "$1>\n$2"); - - if (content !== original) { - fs.writeFileSync(file, content, "utf-8"); - totalChanges++; - console.log(`Fixed: ${file}`); - } -} - -console.log(`\n✅ Fixed ${totalChanges} files out of ${files.length} total`); diff --git a/scripts/fix-mdx.mjs b/scripts/fix-mdx.mjs deleted file mode 100644 index aab70bb3..00000000 --- a/scripts/fix-mdx.mjs +++ /dev/null @@ -1,143 +0,0 @@ -import fs from "fs"; -import {globSync} from "glob"; - -const files = globSync("docs/**/*.mdx"); -let totalChanges = 0; - -for (const file of files) { - let content = fs.readFileSync(file, "utf-8"); - const original = content; - - // Fix 1: Remove VitePress heading anchors {#...} - // Pattern: On lines starting with #, remove ` {#...}` at end of line - content = content.replace(/^(#+\s+.*?)\s+\{#[^}]+\}\s*$/gm, "$1"); - - // Fix 2: Fix `// TODO: manually convert:` blocks at top of files - // Wrap consecutive `// TODO:` lines in {/* ... */} - const lines = content.split("\n"); - const newLines = []; - let i = 0; - - while (i < lines.length) { - // Check if this is a TODO comment line (but not already wrapped) - if (lines[i].trim().startsWith("// TODO:") && !lines[i - 1]?.includes("{/*")) { - // Find consecutive TODO lines - const todoLines = []; - while (i < lines.length && lines[i].trim().startsWith("// TODO:")) { - todoLines.push(lines[i]); - i++; - } - // Wrap them in JSX comment - newLines.push("{/*"); - newLines.push(...todoLines); - newLines.push("*/}"); - } else { - newLines.push(lines[i]); - i++; - } - } - content = newLines.join("\n"); - - // Fix 3: Fix inline `//` comments in JSX tags - IMPROVED VERSION - // We need to be more careful here. Only remove // comments from inside JSX tags - // Look for lines that have unclosed JSX tags (< without matching >) and remove // comments - const fixedLines = content.split("\n").map((line, idx) => { - // Skip lines that are already comments, imports, or code blocks - if (line.trim().startsWith("//") || - line.trim().startsWith("import") || - line.trim().startsWith("```") || - line.trim().startsWith("{/*")) { - return line; - } - - // Only process lines that look like JSX with attributes - // Check for pattern like: <Tag ... // comment - if (line.includes("<") && line.includes("//")) { - // Find the position of // and check if it's in a JSX context - const commentPos = line.indexOf("//"); - const beforeComment = line.substring(0, commentPos); - - // Check if we're inside a JSX tag (has < but no closing >) - const lastOpenTag = beforeComment.lastIndexOf("<"); - const lastCloseTag = beforeComment.lastIndexOf(">"); - - if (lastOpenTag > lastCloseTag) { - // We're inside a JSX tag, check if // is outside strings and expressions - - // Count quotes to see if we're in a string - const doubleQuotes = (beforeComment.match(/"/g) || []).length; - const singleQuotes = (beforeComment.match(/'/g) || []).length; - const backticks = (beforeComment.match(/`/g) || []).length; - - // Count braces to see if we're in an expression - const openBraces = (beforeComment.match(/\{/g) || []).length; - const closeBraces = (beforeComment.match(/\}/g) || []).length; - - // If quotes and braces are balanced, we're outside strings/expressions - if (doubleQuotes % 2 === 0 && singleQuotes % 2 === 0 && - backticks % 2 === 0 && openBraces === closeBraces) { - // Safe to remove the // comment - return beforeComment.trimEnd(); - } - } - } - - return line; - }); - content = fixedLines.join("\n"); - - // Fix 4: Fix `:attr=` Vue binding leftovers - IMPROVED - // Only replace : prefix when it's clearly a Vue binding inside JSX - // Pattern: space or < followed by :attributeName= - content = content.replace(/([<\s]):(\w+)=/g, "$1$2="); - - // Fix 5: Fix SVG attributes - convert kebab-case to camelCase in JSX - content = content.replace(/stroke-width=/g, "strokeWidth="); - content = content.replace(/stroke-dasharray=/g, "strokeDasharray="); - content = content.replace(/fill-opacity=/g, "fillOpacity="); - content = content.replace(/stroke-opacity=/g, "strokeOpacity="); - - // Fix 6: Fix `<Plot (` pattern - these are broken IIFE conversions - content = content.replace(/<Plot\s+\(/g, "{/* TODO: manually convert this plot */}\n{/* <Plot ( */}"); - - // Fix 7: Remove <style> blocks (including <style module>) - content = content.replace(/<style[^>]*>[\s\S]*?<\/style>/g, ""); - - // Fix 8: Comment out <PlotRender references - content = content.replace(/<PlotRender\b/g, "{/* TODO: <PlotRender"); - content = content.replace(/<\/PlotRender>/g, "</PlotRender> */}"); - - // Fix 9: Remove <template> blocks (VitePress specific) - content = content.replace(/<template>[\s\S]*?<\/template>/g, ""); - - // Fix 10: Remove v-for and v-if attributes (Vue directives) - content = content.replace(/\s+v-for="[^"]*"/g, ""); - content = content.replace(/\s+v-if="[^"]*"/g, ""); - - // Fix 11: Fix broken regex patterns in JSX - // Pattern: /} something /.test becomes / something /.test - content = content.replace(/\/\}\s+([^/]+)\s+\/\.test/g, "/ $1 /.test"); - - // Fix 12: Fix className with $ interpolation - // className="$style.oneline" should just be a string - content = content.replace(/className="\$style\.(\w+)"/g, 'className="$1"'); - - // Fix 13: Fix template literal interpolations in attributes - // href="`${href}#${name}`" should be href={`${href}#${name}`} - content = content.replace(/(\w+)="`([^`]*\$\{[^`]*)`"/g, "$1={`$2`}"); - - // Fix 14: Fix href="href" to href={href} - content = content.replace(/href="href"/g, "href={href}"); - - // Fix 15: Fix unclosed Plot tags (missing >) - // Look for <Plot ... r={{...}}" with no closing > - content = content.replace(/(<Plot[^>]*\}\})(\s+<)/g, "$1>$2"); - - if (content !== original) { - fs.writeFileSync(file, content, "utf-8"); - totalChanges++; - console.log(`Fixed: ${file}`); - } -} - -console.log(`\n✅ Fixed ${totalChanges} files out of ${files.length} total`); diff --git a/scripts/parity.ts b/scripts/parity.ts new file mode 100644 index 00000000..443119dd --- /dev/null +++ b/scripts/parity.ts @@ -0,0 +1,588 @@ +// Compares Replot's rendered test output against Observable Plot's for the +// same test plots, and reports what differs. +// +// Both libraries assert their own snapshot suites against committed outputs, +// so those outputs are what each library produces at its current commit: +// Replot's in test/output (refresh with `yarn test:mocha`; adopt any +// *-changed files it writes), upstream's in the sibling checkout's +// test/output. This script canonicalises both sides — attribute order, +// generated ids and class names, numeric precision, whitespace — so that +// only real structural and attribute differences remain, then summarises +// them per plot by element tag and attribute name. +// +// yarn parity summary of every plot +// yarn parity --filter tip plots whose name contains "tip" +// yarn parity --show tipDotFacets unified diff of one plot's canonical form +// yarn parity --upstream ../plot upstream checkout (default ../observablehq-plot) +// yarn parity --allow parity-allow.json --fail +// exit 1 if any plot differs in a way the +// allow file doesn't already record +// yarn parity --allow parity-allow.json --update +// rewrite the allow file from this run +// +// The allow file records, per plot, the divergence that plot is allowed to +// have, so that a *new* difference inside an already-allowed plot changes the +// record and fails: +// +// { +// "tipDispatch": { +// "differs": "g −1, path −1, text −1, tspan −1, (text) −1", +// "note": "upstream's snapshot is hovered; the snapshot harness cannot hover" +// }, +// "rasterVaporP3": {"onlyUpstream": "node-canvas drops oklch fills (#164)"} +// } +// +// {"differs": "…"} is the same summary the report prints for that plot, so +// anything that changes it has to be reviewed. Its optional "note" is for a +// human: the cause where it is known, in a sentence. {"onlyUpstream": "…"} and +// {"onlyReplot": "…"} record a plot present on only one side, together with +// the reason it is absent from the other. An entry for a plot that no longer +// diverges is stale and fails too, so fixed divergences leave the list. +// +// When the list is legitimately out of date, run --update and review the diff +// it produces: it rewrites signatures but keeps existing notes, and never +// invents reasons, so a plot that newly goes missing on one side (and any note +// you want on a newly recorded difference) has to be written by hand first. + +import {execFileSync} from "node:child_process"; +import {promises as fs} from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import {JSDOM} from "jsdom"; + +interface Options { + upstream: string; + filter?: string; + show?: string; + allow?: string; + fail: boolean; + update: boolean; +} + +// What a plot is allowed to do, keyed by what the two sides have for it. +// A plot that renders on both sides but differs records its signature, which +// the script can generate and check; a plot missing from one side records the +// reason, which is why the reason is a string a person writes rather than a +// count the script can generate. A signature may carry a note, for a cause the +// script cannot know: prose, written by hand, that --update preserves. +type AllowEntry = {differs: string; note?: string} | {onlyUpstream: string} | {onlyReplot: string}; + +interface Comparison { + name: string; + status: "identical" | "differs" | "missing-upstream" | "missing-replot"; + added: number; + removed: number; + // Every difference the pair of files has, at the level that distinguishes it: + // an element count by tag, an attribute count by tag and attribute name, a + // text node by its words, or the number of different values an attribute + // takes where the elements and attributes themselves both cancel out. + tagDeltas: Map<string, number>; + attrDeltas: Map<string, number>; + textDeltas: Map<string, number>; + attrValueDeltas: Map<string, number>; +} + +function parseArgs(argv: string[]): Options { + const options: Options = {upstream: path.resolve("../observablehq-plot"), fail: false, update: false}; + for (let i = 0; i < argv.length; ++i) { + const a = argv[i]; + if (a === "--upstream") options.upstream = path.resolve(argv[++i]); + else if (a === "--filter") options.filter = argv[++i]; + else if (a === "--show") options.show = argv[++i]; + else if (a === "--allow") options.allow = argv[++i]; + else if (a === "--fail") options.fail = true; + else if (a === "--update") options.update = true; + else throw new Error(`unknown argument: ${a}`); + } + return options; +} + +// --- canonical form --------------------------------------------------------- + +// A canonical line per element: indentation by depth, tag, then sorted +// attributes. Text content becomes its own line. The result is stable under +// attribute reordering, id renumbering, and serialisation whitespace. +function canonicalize(markup: string): string[] { + const dom = new JSDOM(markup); + const root = dom.window.document.body; + const idMap = canonicalIds(root); + const lines: string[] = []; + for (const child of root.childNodes) walk(child, 0, lines, idMap); + return lines; +} + +function walk(node: Node, depth: number, lines: string[], idMap: Map<string, string>): void { + const indent = " ".repeat(depth); + if (node.nodeType === 3) { + const text = normalizeText(node.textContent ?? ""); + if (text) lines.push(`${indent}"${text}"`); + return; + } + if (node.nodeType !== 1) return; + const el = node as Element; + const attrs = [...el.attributes] + .map((a) => [a.name.toLowerCase(), normalizeAttr(a.name, a.value, idMap)] as const) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${k}="${v}"`); + lines.push(`${indent}<${el.tagName.toLowerCase()}${attrs.length ? " " + attrs.join(" ") : ""}>`); + for (const child of el.childNodes) walk(child, depth + 1, lines, idMap); +} + +// Both libraries generate ids for markers, clip paths, patterns, and (Replot +// only) React's useId. Map each id to a name derived from the element it +// labels, so a reference resolves the same way on both sides regardless of +// numbering, and identical definitions collapse to the same name. +function canonicalIds(root: Element): Map<string, string> { + const map = new Map<string, string>(); + const seen = new Map<string, number>(); + for (const el of root.querySelectorAll("[id]")) { + const id = el.getAttribute("id")!; + const signature = `${el.tagName.toLowerCase()}|${[...el.attributes] + .filter((a) => a.name !== "id") + .map((a) => `${a.name}=${normalizeAttr(a.name, a.value, new Map())}`) + .sort() + .join("|")}|${normalizeText(el.textContent ?? "")}`; + const n = (seen.get(signature) ?? 0) + 1; + seen.set(signature, n); + map.set(id, `${el.tagName.toLowerCase()}-${hash(signature)}${n > 1 ? `-${n}` : ""}`); + } + return map; +} + +function normalizeAttr(name: string, value: string, idMap: Map<string, string>): string { + let v = value; + if (name === "id") return idMap.get(v) ?? v; + // url(#id) and href="#id" references + v = v.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${idMap.get(id) ?? id})`); + if ((name === "href" || name === "xlink:href") && v.startsWith("#")) v = `#${idMap.get(v.slice(1)) ?? v.slice(1)}`; + // generated class names: upstream plot-xxxxxx, Replot replot-xxxxxx + v = v.replace(/\b(re)?plot-[a-z0-9]{6}\b/g, "PLOT-CLASS"); + // React's useId leaks into ids in a few places + v = v.replace(/[:_]r[:_][0-9a-z]+[:_]/g, "REACT-ID"); + // canvas output differs by architecture; the harness compares pixels instead + v = v.replace(/data:image\/png;base64,[A-Za-z0-9+/=]+/g, "data:image/png;base64,…"); + // numeric precision, matching test/plot.js + v = v.replace(/-?\d+\.\d{4,}/g, (d) => String(+(+d).toFixed(3))); + // -0 and 0 are the same coordinate + v = v.replace(/(^|[^\d.])-0(?![.\d])/g, "$10"); + if (name === "style") v = normalizeStyle(v); + return v; +} + +function normalizeStyle(value: string): string { + return value + .split(";") + .map((d) => d.trim()) + .filter(Boolean) + .map((d) => d.replace(/\s*:\s*/, ":")) + .sort() + .join(";"); +} + +function normalizeText(text: string): string { + return text + .replace(/\b(re)?plot-[a-z0-9]{6}\b/g, "PLOT-CLASS") + .replace(/-?\d+\.\d{4,}/g, (d) => String(+(+d).toFixed(3))) + .replace(/\s+/g, " ") + .trim(); +} + +function hash(s: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; ++i) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + +// --- comparison ------------------------------------------------------------- + +const elementRe = /^<([a-z0-9:-]+)((?: [a-z0-9:-]+="[^"]*")*)>$/; +const attributeRe = / ([a-z0-9:-]+)="([^"]*)"/g; + +function bump(map: Map<string, number>, key: string, n: number): void { + map.set(key, (map.get(key) ?? 0) + n); +} + +// Multiset difference of canonical lines. Ordering differences (the same +// elements in a different order) therefore don't count; only elements or +// attributes present on one side and not the other do. That is the question +// the report answers; `--show` gives the ordered unified diff for one plot. +// +// Lines are counted twice: once whole, which is what tells an added element +// from a removed one, and once per attribute value and per text node, which is +// what tells a moved path from a reworded label. The second reading only +// surfaces where the first cancels to nothing, so it never repeats a +// difference already named above it. +function compare(name: string, replot: string[] | null, upstream: string[] | null): Comparison { + const tagDeltas = new Map<string, number>(); + const attrDeltas = new Map<string, number>(); + const textDeltas = new Map<string, number>(); + const attrValueDeltas = new Map<string, number>(); + if (!upstream) + return {name, status: "missing-upstream", added: 0, removed: 0, tagDeltas, attrDeltas, textDeltas, attrValueDeltas}; + if (!replot) + return {name, status: "missing-replot", added: 0, removed: 0, tagDeltas, attrDeltas, textDeltas, attrValueDeltas}; + // Indentation is dropped here so that one extra wrapper element counts as + // one difference, not as every descendant it re-indents. + const counts = new Map<string, number>(); + const values = new Map<string, Map<string, number>>(); + for (const [lines, sign] of [ + [upstream, -1], + [replot, 1] + ] as const) { + for (const line of lines) { + const text = line.trimStart(); + bump(counts, text, sign); + const m = elementRe.exec(text); + if (!m) { + bump(collect(values, "(text)"), text, sign); + continue; + } + for (const attr of m[2].matchAll(attributeRe)) bump(collect(values, `${m[1]}[${attr[1]}]`), attr[2], sign); + } + } + let added = 0; + let removed = 0; + for (const [line, n] of counts) { + if (n === 0) continue; + if (n > 0) added += n; + else removed -= n; + const m = elementRe.exec(line); + if (!m) { + bump(tagDeltas, "(text)", n); + continue; + } + bump(tagDeltas, m[1], n); + for (const attr of m[2].matchAll(attributeRe)) bump(attrDeltas, `${m[1]}[${attr[1]}]`, n); + } + // What the counts above cancel out. A tag that appears the same number of + // times on both sides but carries different attributes is that tag's + // difference, so nothing below it is reported either. + const cancelled = new Set([...tagDeltas].filter(([, n]) => n === 0).map(([tag]) => tag)); + for (const [key, multiset] of values) { + const bracket = key.indexOf("["); + if (!cancelled.has(bracket < 0 ? key : key.slice(0, bracket))) continue; + if (bracket < 0) { + // Text: the words are the difference, and they are short enough to quote. + for (const [content, n] of multiset) if (n !== 0) bump(textDeltas, content, n); + } else { + // Attributes: path data and embedded media run to thousands of + // characters, so the count of values that differ is recorded and + // `--show` is where the values themselves are read. + if (attrDeltas.get(key)) continue; + let count = 0; + for (const n of multiset.values()) if (n !== 0) ++count; + if (count) attrValueDeltas.set(key, count); + } + } + return { + name, + status: added || removed ? "differs" : "identical", + added, + removed, + tagDeltas, + attrDeltas, + textDeltas, + attrValueDeltas + }; +} + +function collect(map: Map<string, Map<string, number>>, key: string): Map<string, number> { + let inner = map.get(key); + if (inner === undefined) map.set(key, (inner = new Map())); + return inner; +} + +// The element-tag deltas that survive after cancelling: a tag that is added +// N times and removed N times differs only in attributes, so what remains +// is the attribute-name delta for that tag. Reported as e.g. "title +3107", +// "path[href] +94", "g −7". +// +// This is the whole summary, never truncated, because it doubles as the +// record the allow file keeps and has to distinguish any two states of a +// plot's output. The order is by magnitude and then by insertion, which is +// fixed by the (unordered) multiset difference above, so the same pair of +// files always yields the same string. +function signature(c: Comparison): string { + const parts: string[] = []; + const cancelled = new Set<string>(); + for (const [tag, n] of byMagnitude(c.tagDeltas)) { + if (n !== 0) parts.push(`${tag} ${signed(n)}`); + else cancelled.add(tag); + } + for (const [key, n] of byMagnitude(c.attrDeltas)) { + if (n === 0 || !cancelled.has(key.slice(0, key.indexOf("[")))) continue; + parts.push(`${key} ${signed(n)}`); + } + for (const [text, n] of byMagnitude(c.textDeltas)) parts.push(`${text} ${signed(n)}`); + for (const [key, n] of byMagnitude(c.attrValueDeltas)) parts.push(`${key} ${n} ${n === 1 ? "value" : "values"}`); + return parts.join(", "); +} + +function byMagnitude(deltas: Map<string, number>): [string, number][] { + return [...deltas].sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])); +} + +// --- allow list ------------------------------------------------------------- + +// The allow file's shape is checked rather than trusted: a hand-edited file +// that loses a key would otherwise silently allow everything. +function parseAllow(text: string, file: string): Map<string, AllowEntry> { + const parsed: unknown = JSON.parse(text); + if (Array.isArray(parsed)) { + throw new Error( + `${file} is the old format (an array of plot names); it now maps each plot to the divergence it is allowed, e.g. {"tipDispatch": {"differs": "g −1, path −1"}}. Run with --update to rewrite it.` + ); + } + if (parsed === null || typeof parsed !== "object") throw new Error(`${file} must be a JSON object`); + const entries = new Map<string, AllowEntry>(); + for (const [name, entry] of Object.entries(parsed as Record<string, unknown>)) { + if (entry === null || typeof entry !== "object") throw new Error(`${file}: ${name} must be an object`); + const record = entry as Record<string, unknown>; + const kinds = ["differs", "onlyUpstream", "onlyReplot"].filter((k) => k in record); + if (kinds.length !== 1) + throw new Error(`${file}: ${name} must have exactly one of "differs", "onlyUpstream" or "onlyReplot"`); + const kind = kinds[0] as keyof AllowEntry; + const value = record[kind]; + if (typeof value !== "string") throw new Error(`${file}: ${name}.${kind} must be a string`); + const extra = Object.keys(record).filter((k) => k !== kind && k !== "note"); + if (extra.length) throw new Error(`${file}: ${name} has unknown key(s): ${extra.join(", ")}`); + const note = record.note; + // A note explains a signature; the other two kinds state their reason in + // the value itself, so a note beside one of them would have nowhere to go. + if (note !== undefined && (kind !== "differs" || typeof note !== "string")) + throw new Error(`${file}: ${name}.note must be a string, and only alongside "differs"`); + entries.set(name, (note === undefined ? {[kind]: value} : {[kind]: value, note}) as AllowEntry); + } + return entries; +} + +// One line per plot, sorted, so a diff of the file reads as a list of the +// plots that changed and how. JSON.stringify can't do that on its own, and it +// writes a record as {"differs":"…"} with a bare minus sign rather than the +// escaped, spaced form the file holds, so an update would rewrite every line +// and the diff would stop showing which plot actually moved. +function formatAllow(entries: Map<string, AllowEntry>): string { + const lines = [...entries.keys()] + .sort() + .map((name) => ` ${escaped(name)}: ${formatRecord(entries.get(name)!)}`); + return `{\n${lines.join(",\n")}\n}\n`; +} + +function formatRecord(entry: AllowEntry): string { + const fields = Object.entries(entry).map(([key, value]) => `${escaped(key)}: ${escaped(value as string)}`); + return `{${fields.join(", ")}}`; +} + +// The summary prints U+2212 for a minus, which the file escapes. +function escaped(value: string): string { + return JSON.stringify(value).replace( + /[\u007f-￿]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}` + ); +} + +// What the allow file says about a comparison, and what is wrong with it. +// An entry that is present but for the wrong status is as much a failure as a +// missing one: the list has to describe what is actually the case. +function allowance(result: Comparison, allow: Map<string, AllowEntry>): {flag: string; problem?: string} { + const entry = allow.get(result.name); + if (result.status === "differs") { + const expected = (entry as {differs?: string} | undefined)?.differs; + if (expected === undefined) return {flag: "", problem: "differs and is not in the allow list"}; + if (expected !== signature(result)) + return {flag: "changed", problem: "differs in a way the allow list does not record"}; + return {flag: "yes"}; + } + if (result.status === "identical") { + if (entry === undefined) return {flag: ""}; + return {flag: "stale", problem: "is in the allow list but no longer differs"}; + } + const kind = result.status === "missing-replot" ? "onlyUpstream" : "onlyReplot"; + if (entry === undefined || (entry as Record<string, string>)[kind] === undefined) + return {flag: "", problem: `is ${result.status} and is not recorded in the allow list as ${kind}`}; + return {flag: "yes"}; +} + +function signed(n: number): string { + return n > 0 ? `+${n}` : `−${-n}`; +} + +// --- main ------------------------------------------------------------------- + +async function readOutputs(dir: string): Promise<Map<string, string>> { + const files = new Map<string, string>(); + let names: string[]; + try { + names = await fs.readdir(dir); + } catch { + return files; + } + for (const f of names) { + if (!/\.(svg|html)$/.test(f) || f.includes("-changed")) continue; + files.set(path.parse(f).name, path.join(dir, f)); + } + return files; +} + +async function main(): Promise<void> { + const options = parseArgs(process.argv.slice(2)); + const replotDir = path.resolve("test/output"); + const upstreamDir = path.join(options.upstream, "test/output"); + const replot = await readOutputs(replotDir); + const upstream = await readOutputs(upstreamDir); + if (upstream.size === 0) { + console.error(`No upstream outputs found in ${upstreamDir}; pass --upstream <observablehq-plot checkout>.`); + process.exit(2); + } + + if (options.show) { + const name = options.show; + if (!replot.has(name)) throw new Error(`no Replot output named ${name}`); + if (!upstream.has(name)) throw new Error(`no upstream output named ${name}`); + const a = canonicalize(await fs.readFile(upstream.get(name)!, "utf8")); + const b = canonicalize(await fs.readFile(replot.get(name)!, "utf8")); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "parity-")); + await fs.writeFile(path.join(tmp, "upstream"), a.join("\n") + "\n"); + await fs.writeFile(path.join(tmp, "replot"), b.join("\n") + "\n"); + try { + execFileSync("diff", ["-u", path.join(tmp, "upstream"), path.join(tmp, "replot")], {stdio: "inherit"}); + } catch { + // diff exits 1 when the files differ; that's the point + } + return; + } + + if (options.update && !options.allow) throw new Error("--update needs --allow <file> to know what to rewrite"); + if (options.update && options.filter) + throw new Error("--update cannot be combined with --filter; it rewrites the whole list"); + const allow = new Map<string, AllowEntry>( + options.allow ? parseAllow(await fs.readFile(options.allow, "utf8"), options.allow) : [] + ); + const names = new Set([...replot.keys(), ...upstream.keys()]); + const results: Comparison[] = []; + for (const name of [...names].sort()) { + if (options.filter && !name.includes(options.filter)) continue; + const a = upstream.has(name) ? canonicalize(await fs.readFile(upstream.get(name)!, "utf8")) : null; + const b = replot.has(name) ? canonicalize(await fs.readFile(replot.get(name)!, "utf8")) : null; + const result = compare(name, b, a); + // The two suites may write the same plot with different extensions (a + // bare <svg> versus a <figure> wrapper); that's a root-element difference + // worth naming rather than hiding in the tag deltas. + if (a && b && path.extname(upstream.get(name)!) !== path.extname(replot.get(name)!)) { + result.status = "differs"; + result.tagDeltas.set(`(root ${path.extname(upstream.get(name)!)} → ${path.extname(replot.get(name)!)})`, 1); + } + results.push(result); + } + + const differing = results + .filter((r) => r.status === "differs") + .sort((a, b) => b.added + b.removed - (a.added + a.removed)); + const identical = results.filter((r) => r.status === "identical"); + const missingUpstream = results.filter((r) => r.status === "missing-upstream"); + const missingReplot = results.filter((r) => r.status === "missing-replot"); + + // The allow list is checked here rather than only under --fail so that a + // plain run also reports a drifting or stale record. + const allowances = new Map(results.map((r) => [r.name, allowance(r, allow)])); + const problems = results + .map((r) => ({name: r.name, problem: allowances.get(r.name)!.problem})) + .filter((p): p is {name: string; problem: string} => p.problem !== undefined); + for (const name of allow.keys()) { + if (!names.has(name)) problems.push({name, problem: "is in the allow list but is not a test plot"}); + } + + const nameWidth = Math.max(4, ...differing.map((r) => r.name.length)); + console.log( + `${"plot".padEnd(nameWidth)} ${"+".padStart(6)} ${"−".padStart( + 6 + )} allowed differences (Replot relative to upstream)` + ); + for (const r of differing) { + const flag = allowances.get(r.name)!.flag; + console.log( + `${r.name.padEnd(nameWidth)} ${String(r.added).padStart(6)} ${String(r.removed).padStart(6)} ${flag.padEnd( + 7 + )} ${signature(r)}` + ); + } + console.log(); + console.log( + `compared ${identical.length + differing.length}: ${identical.length} identical, ${differing.length} differ` + ); + if (missingUpstream.length) + console.log(`only in Replot (${missingUpstream.length}): ${missingUpstream.map((r) => r.name).join(", ")}`); + if (missingReplot.length) + console.log(`only upstream (${missingReplot.length}): ${missingReplot.map((r) => r.name).join(", ")}`); + + const causes = new Map<string, number>(); + for (const r of differing) { + for (const [tag, n] of r.tagDeltas) if (n !== 0) causes.set(tag, (causes.get(tag) ?? 0) + 1); + for (const [key, n] of r.attrDeltas) if (n !== 0) causes.set(key, (causes.get(key) ?? 0) + 1); + for (const [text, n] of r.textDeltas) if (n !== 0) causes.set("(text)", (causes.get("(text)") ?? 0) + 1); + for (const key of r.attrValueDeltas.keys()) causes.set(key, (causes.get(key) ?? 0) + 1); + } + const top = [...causes].sort((a, b) => b[1] - a[1]).slice(0, 12); + if (top.length) { + console.log(); + console.log("most common differences (number of plots affected):"); + for (const [key, n] of top) console.log(` ${String(n).padStart(4)} ${key}`); + } + + if (options.allow) { + console.log(); + console.log( + `allow list: ${[...allowances.values()].filter((a) => a.flag === "yes").length} of ${allow.size} entries match, ${ + problems.length + } problem(s)` + ); + for (const p of problems) console.log(` ${p.name} ${p.problem}`); + } + + if (options.update && options.allow) { + const updated = new Map<string, AllowEntry>(); + const noReason: string[] = []; + for (const r of results) { + if (r.status === "differs") { + // The note is a person's explanation for the divergence; refreshing the + // signature it sits beside must not throw it away. + const note = (allow.get(r.name) as {note?: string} | undefined)?.note; + updated.set(r.name, note === undefined ? {differs: signature(r)} : {differs: signature(r), note}); + } else if (r.status !== "identical") { + const kind = r.status === "missing-replot" ? "onlyUpstream" : "onlyReplot"; + const reason = (allow.get(r.name) as Record<string, string> | undefined)?.[kind]; + if (reason === undefined) noReason.push(`${r.name} (${kind})`); + else updated.set(r.name, {[kind]: reason} as AllowEntry); + } + } + if (noReason.length) { + throw new Error(`--update will not invent a reason for ${noReason.join(", ")}; add those entries by hand first.`); + } + const before = formatAllow(allow); + const after = formatAllow(updated); + await fs.writeFile(options.allow, after); + console.log( + before === after + ? `\n${options.allow} rewritten (no change).` + : `\n${options.allow} rewritten: review the diff, then commit it with the change that caused it.` + ); + return; + } + + if (options.fail && problems.length) { + console.error(`\n${problems.length} plot(s) are not as ${options.allow} records them:`); + for (const p of problems) console.error(` ${p.name} ${p.problem}`); + console.error( + `\nIf the change is intended, run \`yarn parity --allow ${options.allow} --update\`, review the diff it writes, and commit that with the change.` + ); + process.exit(1); + } +} + +main().catch((error) => { + console.error(error); + process.exit(2); +}); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100644 index 00000000..02cd62ef --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,186 @@ +// Pack-and-install smoke test. +// +// Packs the package the way `npm publish` does, installs the tarball into a +// throwaway project, and checks that a consumer can reach both entry points: +// through a bundler, through TypeScript's resolver, and through the UMD bundle +// in plain Node. Nothing in the repo's own test suite exercises the package as +// a consumer would, so an entry point that no longer resolves only shows up +// here. Run it with `yarn test:smoke`. + +import {execFileSync} from "node:child_process"; +import {existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const meta = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); +const name = meta.name; +const windows = process.platform === "win32"; +const cli = (command) => (windows ? `${command}.cmd` : command); +const tool = (command) => path.join(root, "node_modules", ".bin", windows ? `${command}.cmd` : command); +const work = mkdtempSync(path.join(os.tmpdir(), "replot-smoke-")); +const consumer = path.join(work, "consumer"); + +// Exports a consumer is expected to reach through each entry point. The names +// are the documented ones: `plot` for the imperative API, `Plot` and the mark +// components for the React one. +const expected = { + plot: "function", + Density: "function", + bin: "function", + rectY: "function", + Plot: "function", + Replot: "function", + Dot: "function", + Line: "function" +}; + +function run(file, args, {cwd = consumer} = {}) { + try { + return execFileSync(file, args, {cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]}); + } catch (error) { + throw new Error([`${file} ${args.join(" ")} failed`, error.stdout, error.stderr].filter(Boolean).join("\n")); + } +} + +function write(file, contents) { + writeFileSync(path.join(consumer, file), contents); +} + +function check(description, body) { + body(); + console.log(`ok ${description}`); +} + +// Every path the package points a resolver at, so a stale or renamed file is +// caught before a consumer finds it. +function declaredPaths(value) { + if (typeof value === "string") return [value]; + if (value && typeof value === "object") return Object.values(value).flatMap(declaredPaths); + return []; +} + +function main() { + console.log("Building the published artifacts (yarn run prepublishOnly)"); + run(cli("yarn"), ["run", "prepublishOnly"], {cwd: root}); + + console.log(`Packing ${name} (npm pack)`); + run(cli("npm"), ["pack", "--pack-destination", work], {cwd: root}); + const tarball = path.join( + work, + readdirSync(work).find((file) => file.endsWith(".tgz")) + ); + + mkdirSync(consumer); + write( + "package.json", + JSON.stringify({name: "replot-smoke-consumer", version: "0.0.0", private: true, type: "module"}, null, 2) + ); + + console.log(`Installing ${path.basename(tarball)} into a throwaway project`); + run(cli("npm"), ["install", "--no-audit", "--no-fund", tarball, "react", "react-dom"]); + run(cli("npm"), ["install", "--no-audit", "--no-fund", "--save-dev", "@types/react", "@types/react-dom"]); + + const installed = path.join(consumer, "node_modules", name); + const installedMeta = JSON.parse(readFileSync(path.join(installed, "package.json"), "utf8")); + + write( + "app.tsx", + `import {plot, Density, bin, rectY} from "${name}"; +import {Plot, Replot, Dot, Line} from "${name}/react"; + +const api: Record<string, unknown> = {plot, Density, bin, rectY, Plot, Replot, Dot, Line}; + +console.log("SMOKE_EXPORTS " + JSON.stringify(Object.fromEntries(Object.entries(api).map(([k, v]) => [k, typeof v])))); +` + ); + + // Reflects the package's own tsconfig: the published declaration entries + // re-export the TypeScript sources, so a strict consumer type-checks the + // package's internals and reports their diagnostics. This leg is about the + // entries resolving and being usable, which is what a rename breaks. + write( + "tsconfig.json", + JSON.stringify( + { + compilerOptions: { + strict: false, + noEmit: true, + module: "node16", + moduleResolution: "node16", + target: "es2022", + jsx: "react-jsx", + skipLibCheck: true, + types: [] + }, + files: ["app.tsx"] + }, + null, + 2 + ) + ); + + write( + "umd.mjs", + `import {pathToFileURL} from "node:url"; + +// The UMD bundle is built for a page that loads d3 first, so give it the same +// global before importing it, as a <script src> consumer would. +globalThis.d3 = await import("d3"); +await import(pathToFileURL(${JSON.stringify(path.join(installed, installedMeta.unpkg))}).href); +console.log("SMOKE_UMD " + typeof globalThis.Replot?.plot); + +// Importing the bundle leaves React's scheduler pending, so the process would +// otherwise never exit. +process.exit(0); +` + ); + + check("every declared entry point exists in the tarball", () => { + const declared = [ + installedMeta.main, + installedMeta.module, + installedMeta.types, + installedMeta.unpkg, + installedMeta.jsdelivr, + ...declaredPaths(installedMeta.exports) + ].filter(Boolean); + const missing = [...new Set(declared)].filter((target) => !existsSync(path.join(installed, target))); + if (missing.length) throw new Error(`declared but not in the tarball: ${missing.join(", ")}`); + }); + + check("a bundler imports both entry points and the bundle runs", () => { + run(tool("esbuild"), ["app.tsx", "--bundle", "--outfile=app.cjs", "--platform=node", "--format=cjs"]); + const output = run(process.execPath, ["app.cjs"]); + const line = output.split("\n").find((text) => text.startsWith("SMOKE_EXPORTS ")); + if (!line) throw new Error(`the bundle reported no exports:\n${output}`); + const actual = JSON.parse(line.slice("SMOKE_EXPORTS ".length)); + for (const [key, type] of Object.entries(expected)) { + if (actual[key] !== type) throw new Error(`expected ${key} to be a ${type}, got ${actual[key]}`); + } + }); + + check("a TypeScript consumer type-checks both entry points", () => { + run(tool("tsc"), ["-p", "tsconfig.json"]); + }); + + check("the UMD bundle loads in plain Node", () => { + const output = run(process.execPath, ["umd.mjs"]); + if (!output.includes("SMOKE_UMD function")) throw new Error(`the UMD bundle did not expose plot:\n${output}`); + }); +} + +let failed = false; +try { + main(); + console.log(`\n${name} packs, installs and imports cleanly.`); +} catch (error) { + failed = true; + console.error(`\nFAILED: ${error.message}`); +} finally { + if (process.env.REPLOT_SMOKE_KEEP) console.error(`kept ${work}`); + else rmSync(work, {recursive: true, force: true}); +} + +process.exit(failed ? 1 : 0); diff --git a/src/core/index.ts b/src/core/index.ts deleted file mode 100644 index dfdf1ce3..00000000 --- a/src/core/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// src/core/index.ts — Pure (non-DOM) functions shared between the imperative -// and React APIs. These modules have no dependency on D3 selections, the DOM, -// or any browser globals. They operate entirely on data structures. -// -// The React layer imports exclusively through this barrel so that the -// boundary between pure computation and DOM rendering is explicit. - -// Channel creation and scale inference -export {createChannel, inferChannelScale} from "../channel.js"; - -// Data formatting -export {formatDefault} from "../format.js"; - -// Layout dimension computation -export {createDimensions} from "../dimensions.js"; - -// Faceting (pure subset — facetTranslator is DOM-dependent, kept in facet.js) -export {createFacets, recreateFacets, facetExclude, facetGroups, facetFilter} from "../facet.js"; - -// Data and scale option utilities -export { - isScaleOptions, - isColor, - isNoneish, - maybeColorChannel, - maybeNumberChannel, - dataify, - map, - maybeIntervalTransform, - range, - valueof, - column, - identity, - indexOf -} from "../options.js"; - -// Projection creation -export {createProjection, getGeometryChannels, hasProjection, project, xyProjection} from "../projection.js"; - -// Scale creation and management -export {createScales, createScaleFunctions, autoScaleRange, innerDimensions} from "../scales.js"; - -// Scale registry (symbolic constants) -export { - registry as scaleRegistry, - position, - color, - radius, - length, - opacity, - symbol, - projection -} from "../scales/index.js"; - -// Style utilities (pure subset) -export {maybeClassName} from "../style.js"; - -// Curve resolution -export {maybeCurveAuto} from "../curve.js"; - -// Value predicates -export {defined, ascendingDefined, descendingDefined, nonempty, finite, positive, negative} from "../defined.js"; - -// Interval transforms for Rect/Bar marks -export {maybeTrivialIntervalX, maybeTrivialIntervalY, maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; diff --git a/src/dimensions.js b/src/dimensions.js index 1551463e..c7cee0e3 100644 --- a/src/dimensions.js +++ b/src/dimensions.js @@ -2,6 +2,7 @@ import {extent} from "d3"; import {projectionAspectRatio} from "./projection.js"; import {isOrdinalScale} from "./scales.js"; import {offset} from "./style.js"; +import {warn} from "./warnings.js"; export function createDimensions(scales, marks, options = {}) { // Compute the default margins: the maximum of the marks’ margins. While not @@ -114,9 +115,18 @@ function autoHeight( const fyb = fy ? fy.scale.bandwidth() : 1; const w = fxb * (width - marginLeftDefault - marginRightDefault) - x.insetLeft - x.insetRight; const height = (ratio * w + y.insetTop + y.insetBottom) / fyb + marginTopDefault + marginBottomDefault; - // Empty scale domains make the ratio NaN; an SVG must never get a - // non-finite height, so fall through to the default height instead. + // Upstream returns this height unconditionally (src/dimensions.js has no + // guard here), and an empty scale domain — `aspectRatio` with no data, or + // with every row filtered out — makes it NaN: a height of "NaN" on the + // <svg>, a viewBox of "0 0 640 NaN", and every mark laid out against a NaN + // height, which is a broken plot rather than a smaller one. The default + // height below is the better answer, but not a silent one: this is a + // deliberate divergence from upstream, so it says so, through the same + // channel every other plot warning uses (the ⚠️ indicator, and the console). if (isFinite(height)) return height; + warn( + `Warning: the aspect ratio is undefined because a scale domain is empty, so the default height is used instead. Set a non-empty domain, or an explicit height, to size the plot.` + ); } return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60; diff --git a/src/index.d.ts b/src/index.d.ts index 3710a9e3..00f3c967 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -41,7 +41,6 @@ export * from "./marks/vector.js"; export * from "./marks/waffle.js"; export {valueof, column, identity, indexOf} from "./options.js"; export * from "./plot.js"; -export * from "./plotDom.js"; export * from "./projection.js"; export * from "./reducer.js"; export * from "./scales.js"; diff --git a/src/index.ts b/src/index.ts index e14feabe..ee138c0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import {Mark} from "./mark.js"; -import {plot} from "./plotDom.js"; +import {plot} from "./plot.js"; // Note: this side effect avoids a circular dependency. Mark.prototype.plot = function ({marks = [], ...options} = {}) { return plot({...options, marks: [...marks, this]}); }; -export {plot} from "./plotDom.js"; +export {plot} from "./plot.js"; export {Mark, marks} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {Arrow, arrow} from "./marks/arrow.js"; diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 1e3a5a3f..bedad7af 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -3,7 +3,69 @@ import {composeRender} from "../mark.js"; import {isArray} from "../options.js"; import {applyFrameAnchor} from "../style.js"; -const states = new WeakMap(); +const states = new WeakMap(); // ownerSVGElement → per-plot pointer state +const handledEvents = new WeakSet(); + +// Three pieces of upstream’s closure are lifted out as pure functions below so +// that the React entry point (src/react/interactions/pointerHitTest.ts) and the +// imperative closure in this file share one implementation and cannot drift. +// Upstream’s own closure calls them, so re-syncing this file against a future +// Observable Plot release stays a small diff. + +// For faceting, we want to compute the local coordinates of each point, which +// means subtracting out the facet translation, if any. (It’s tempting to do this +// using the local coordinates in SVG, but that’s complicated by mark-specific +// transforms such as dx and dy.) Also, since band scales return the upper bound +// of the band, we have to offset by half the bandwidth. +export function pointerOffsets(index, scales, dimensions) { + const {x, y, fx, fy} = scales; + let tx = fx ? fx(index.fx) - dimensions.marginLeft : 0; + let ty = fy ? fy(index.fy) - dimensions.marginTop : 0; + if (x?.bandwidth) tx += x.bandwidth() / 2; + if (y?.bandwidth) ty += y.bandwidth() / 2; + return [tx, ty]; +} + +// The order of precedence for the pointer position is: px & py; the middle of x1 +// & y1 and x2 & y2; or x1 & y1 (e.g., area); or lastly x & y. If a dimension is +// unspecified, the frame anchor is used. +export function pointerAnchors(mark, values, dimensions) { + const [cx, cy] = applyFrameAnchor(mark, dimensions); + const {px: PX, py: PY} = values; + return [PX ? (i) => PX[i] : anchorX(values, cx), PY ? (i) => PY[i] : anchorY(values, cy)]; +} + +// Select the closest point to the mouse in the current facet; for pointerX or +// pointerY, the orthogonal component of the distance is squashed, selecting +// primarily on the dominant dimension. Across facets, use unsquashed distance to +// determine the winner. +// +// xp and yp must already be corrected for facets and band scales (see +// pointerOffsets), because the kpx/kpy margin test compares them against the +// dimensions’ margins in that same corrected space. +// +// A datum missing a coordinate yields undefined from px(j) or py(j), hence NaN +// for rj, and `NaN <= ri` is false — that is precisely how upstream excludes it. +// Never coerce with `?? 0`: that would place the datum at the origin and let it +// win the search. +export function pointerSearch(index, px, py, xp, yp, kx, ky, maxRadius, dimensions) { + const kpx = xp < dimensions.marginLeft || xp > dimensions.width - dimensions.marginRight ? 1 : kx; + const kpy = yp < dimensions.marginTop || yp > dimensions.height - dimensions.marginBottom ? 1 : ky; + let ii = null; + let ri = maxRadius * maxRadius; + for (const j of index) { + const dx = kpx * (px(j) - xp); + const dy = kpy * (py(j) - yp); + const rj = dx * dx + dy * dy; + if (rj <= ri) (ii = j), (ri = rj); + } + if (ii != null && (kx !== 1 || ky !== 1)) { + const dx = px(ii) - xp; + const dy = py(ii) - yp; + ri = dx * dx + dy * dy; + } + return {ii, ri}; +} function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -28,75 +90,55 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Isolate state per-pointer, per-plot; if the pointer is reused by // multiple marks, they will share the same state (e.g., sticky modality). + // The pool maps renderIndex → {ii, ri, render} for marks competing for + // the pointer (e.g., tips); only the closest point is shown. let state = states.get(svg); - if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: []})); + if (!state) { + state = {sticky: false, roots: [], renders: [], pool: this.pool ? {map: new Map()} : null}; + states.set(svg, state); + } // This serves as a unique identifier of the rendered mark per-plot; it is // used to record the currently-rendered elements (state.roots) so that we // can tell when a rendered element is clicked on. let renderIndex = state.renders.push(render) - 1; - // For faceting, we want to compute the local coordinates of each point, - // which means subtracting out the facet translation, if any. (It’s - // tempting to do this using the local coordinates in SVG, but that’s - // complicated by mark-specific transforms such as dx and dy.) Also, since - // band scales return the upper bound of the band, we have to offset by - // half the bandwidth. - const {x, y, fx, fy} = scales; - let tx = fx ? fx(index.fx) - dimensions.marginLeft : 0; - let ty = fy ? fy(index.fy) - dimensions.marginTop : 0; - if (x?.bandwidth) tx += x.bandwidth() / 2; - if (y?.bandwidth) ty += y.bandwidth() / 2; + const [tx, ty] = pointerOffsets(index, scales, dimensions); // For faceting, we also need to record the closest point per facet per // mark (!), since each facet has its own pointer event listeners; we only // want the closest point across facets to be visible. const faceted = index.fi != null; - let facetState; + let facetPool; if (faceted) { - let facetStates = state.facetStates; - if (!facetStates) state.facetStates = facetStates = new Map(); - facetState = facetStates.get(this); - if (!facetState) facetStates.set(this, (facetState = new Map())); + let facetPools = state.facetPools; + if (!facetPools) state.facetPools = facetPools = new Map(); + facetPool = facetPools.get(this); + if (!facetPool) facetPools.set(this, (facetPool = {map: new Map()})); } - // The order of precedence for the pointer position is: px & py; the - // middle of x1 & y1 and x2 & y2; or x1 & y1 (e.g., area); or lastly x & - // y. If a dimension is unspecified, the frame anchor is used. - const [cx, cy] = applyFrameAnchor(this, dimensions); - const {px: PX, py: PY} = values; - const px = PX ? (i) => PX[i] : anchorX(values, cx); - const py = PY ? (i) => PY[i] : anchorY(values, cy); + const [px, py] = pointerAnchors(this, values, dimensions); let i; // currently focused index let g; // currently rendered mark let s; // currently rendered stickiness - let f; // current animation frame - // When faceting, if more than one pointer would be visible, only show - // this one if it is the closest. We defer rendering using an animation - // frame to allow all pointer events to be received before deciding which - // mark to render; although when hiding, we render immediately. + // When pooling or faceting, if more than one pointer would be visible, + // only show the closest. We defer rendering using an animation frame to + // allow all pointer events to be received before deciding which mark to + // render; although when hiding, we render immediately. + const pool = state.pool ?? facetPool; function update(ii, ri) { - if (faceted) { - if (f) f = cancelAnimationFrame(f); - if (ii == null) facetState.delete(index.fi); - else { - facetState.set(index.fi, ri); - f = requestAnimationFrame(() => { - f = null; - for (const [fi, r] of facetState) { - if (r < ri || (r === ri && fi < index.fi)) { - ii = null; - break; - } - } - render(ii); - }); - return; - } - } - render(ii); + if (!pool) return void render(ii); + if (ii == null) render(ii); + pool.map.set(renderIndex, {ii, ri, render}); + if (pool.frame !== undefined) cancelAnimationFrame(pool.frame); + pool.frame = requestAnimationFrame(() => { + pool.frame = undefined; + let best = null; + for (const c of pool.map.values()) if (!best || c.ri < best.ri) best = c; + for (const c of pool.map.values()) c.render(c === best ? c.ii : null); + }); } function render(ii) { @@ -127,7 +169,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Dispatch the value. When simultaneously exiting this facet and // entering a new one, prioritize the entering facet. - if (!(i == null && facetState?.size > 1)) { + if (!(i == null && facetPool?.map.size > 1)) { const value = i == null ? null : isArray(data) ? data[i] : data.get(i); context.dispatchValue(value); } @@ -135,39 +177,22 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op return r; } - // Select the closest point to the mouse in the current facet; for - // pointerX or pointerY, the orthogonal component of the distance is - // squashed, selecting primarily on the dominant dimension. Across facets, - // use unsquashed distance to determine the winner. function pointermove(event) { if (state.sticky || (event.pointerType === "mouse" && event.buttons === 1)) return; // dragging let [xp, yp] = pointof(event); (xp -= tx), (yp -= ty); // correct for facets and band scales - const kpx = xp < dimensions.marginLeft || xp > dimensions.width - dimensions.marginRight ? 1 : kx; - const kpy = yp < dimensions.marginTop || yp > dimensions.height - dimensions.marginBottom ? 1 : ky; - let ii = null; - let ri = maxRadius * maxRadius; - for (const j of index) { - const dx = kpx * (px(j) - xp); - const dy = kpy * (py(j) - yp); - const rj = dx * dx + dy * dy; - if (rj <= ri) (ii = j), (ri = rj); - } - if (ii != null && (kx !== 1 || ky !== 1)) { - const dx = px(ii) - xp; - const dy = py(ii) - yp; - ri = dx * dx + dy * dy; - } + const {ii, ri} = pointerSearch(index, px, py, xp, yp, kx, ky, maxRadius, dimensions); update(ii, ri); } function pointerdown(event) { + if (handledEvents.has(event)) return; // ignore same event on a shared pointer + handledEvents.add(event); if (event.pointerType !== "mouse") return; if (i == null) return; // not pointing if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers else (state.sticky = true), render(i); - event.stopImmediatePropagation(); // suppress other pointers } function pointerleave(event) { @@ -187,9 +212,19 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op return render(null); }, render) }; - // Tag the pointer render so the React MarkSlot can recognize pointer-driven - // marks (which render only the selected datum) vs custom render-prop marks. - if (typeof pointerResult.render === "function") pointerResult.render.pointer = true; + // Replot-only tags. The React entry point never executes the closure above — + // it reimplements the interaction on top of the pure helpers — so these tags + // are how it recovers the state that composeRender otherwise buries. + if (typeof pointerResult.render === "function") { + pointerResult.render.pointer = true; + // kx/ky are pointerK parameters and maxRadius is destructured off the + // options, so this is the only place the React hit test can reach them. + pointerResult.render.pointerK = {kx, ky, maxRadius}; + // composeRender wraps the user’s own render option inside the (unreachable) + // pointer closure; the React path supplies its own pointer behaviour and + // then runs this one itself. + pointerResult.render.userRender = render ?? null; + } return pointerResult; } diff --git a/src/mark.d.ts b/src/mark.d.ts index 73a516b7..43cad54c 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -292,6 +292,13 @@ export interface MarkOptions { /** Whether to generate a tooltip for this mark, and any tip options. */ tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer}); + /** + * Whether this mark participates in the pointer pool, which ensures that + * only the closest point is shown when multiple pointer marks are present; + * defaults to true for the tip mark. + */ + pool?: boolean; + /** * How to clip the mark; one of: * diff --git a/src/mark.js b/src/mark.js index 225e41e5..35ffa232 100644 --- a/src/mark.js +++ b/src/mark.js @@ -26,6 +26,7 @@ export class Mark { clip = defaults?.clip, channels: extraChannels, tip, + pool = defaults?.pool, render, renderJSX } = options; @@ -73,6 +74,7 @@ export class Mark { this.marginLeft = +marginLeft; this.clip = maybeClip(clip); this.tip = maybeTip(tip); + this.pool = !!pool; this.className = string(className); // Super-faceting currently disallow position channels; in the future, we // could allow position to be specified in fx and fy in addition to (or diff --git a/src/marker.js b/src/marker.js index e22d51aa..7679e1c9 100644 --- a/src/marker.js +++ b/src/marker.js @@ -1,24 +1,31 @@ -// Markers are resolved to canonical names here; the actual <marker> SVG defs -// are produced as JSX by markerToJSX (src/react/Markers.tsx) inline alongside -// the referencing path. Custom marker functions (user-supplied) are passed -// through unchanged but are not yet rendered by the JSX path. +// Markers are resolved here to a canonical name — or passed through as a custom +// marker function — together with a scope that keeps their ids apart; the +// actual <marker> SVG defs are produced as JSX by markerToJSX +// (src/react/Markers.tsx) inline alongside the referencing path. The resolved +// shape is ResolvedMarker, declared there. export function markers(mark, {marker, markerStart = marker, markerMid = marker, markerEnd = marker} = {}) { - mark.markerStart = maybeMarker(markerStart); - mark.markerMid = maybeMarker(markerMid); - mark.markerEnd = maybeMarker(markerEnd); + // One scope per mark. A def resolves currentColor — and any CSS variable — + // against its own ancestors, not against the path that references it, so two + // marks sharing a def id would draw whichever of them comes first in the + // document. markerToJSX mints its ids from this scope, which is why the scope + // has to travel with the resolved marker rather than with the mark. + const scope = {}; + mark.markerStart = maybeMarker(markerStart, scope); + mark.markerMid = maybeMarker(markerMid, scope); + mark.markerEnd = maybeMarker(markerEnd, scope); } -function maybeMarker(marker) { +function maybeMarker(marker, scope) { if (marker == null || marker === false) return null; - if (marker === true) return "circle-fill"; - if (typeof marker === "function") return marker; // custom marker function + if (marker === true) return {marker: "circle-fill", scope}; + if (typeof marker === "function") return {marker, scope}; // custom marker function const name = `${marker}`.toLowerCase(); switch (name) { case "none": return null; case "circle": - return "circle-fill"; + return {marker: "circle-fill", scope}; case "arrow": case "arrow-reverse": case "dot": @@ -27,7 +34,7 @@ function maybeMarker(marker) { case "tick": case "tick-x": case "tick-y": - return name; + return {marker: name, scope}; } throw new Error(`invalid marker: ${marker}`); } diff --git a/src/marks/area.ts b/src/marks/area.ts index 898bbf8a..4e423e82 100644 --- a/src/marks/area.ts +++ b/src/marks/area.ts @@ -1,15 +1,18 @@ -import {area as shapeArea} from "d3"; -import {createElement as h, type ReactNode} from "react"; +import {area as shapeArea, line as shapeLine} from "d3"; +import {createElement as h, Fragment, type ReactNode} from "react"; import type {ChannelValue, ChannelValueDenseBinSpec, ChannelValueSpec} from "../channel.js"; import {maybeCurve} from "../curve.js"; import type {CurveOptions} from "../curve.js"; import {Mark} from "../mark.js"; import type {Data, MarkOptions} from "../mark.js"; +import {markers} from "../marker.js"; +import type {MarkerOptions} from "../marker.js"; // @ts-expect-error — runtime helpers not exposed in companion .d.ts -import {first, indexOf, maybeZ, second} from "../options.js"; +import {first, indexOf, keyof, maybeZ, second} from "../options.js"; // @ts-expect-error — runtime helpers not exposed in companion .d.ts import {groupIndex} from "../style.js"; -import {groupChannelStyleProps, indirectStyleProps, directStyleProps, transformProp} from "../react/styles.js"; +import {markerToJSX} from "../react/Markers.js"; +import {groupChannelStyleProps, indirectStyleProps, directStyleProps, offset, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; // @ts-expect-error — runtime helpers not exposed in companion .d.ts import {maybeDenseIntervalX, maybeDenseIntervalY} from "../transforms/bin.js"; @@ -59,7 +62,15 @@ export interface AreaOptions extends MarkOptions, StackOptions, CurveOptions { } /** Options for the areaX mark. */ -export interface AreaXOptions extends Omit<AreaOptions, "y1" | "y2">, BinOptions { +export interface AreaXOptions extends Omit<AreaOptions, "y1" | "y2">, BinOptions, MarkerOptions { + /** + * Whether to draw the area’s topline again as a stroke-only line, as in the + * area-line mark (area.js:96 upstream); the line also takes the marker + * options. The area itself is unchanged, so a translucent fill and a crisp + * edge can be combined without a second mark. + */ + line?: boolean; + /** * The horizontal position (or length) channel, typically bound to the *x* * scale. @@ -99,7 +110,15 @@ export interface AreaXOptions extends Omit<AreaOptions, "y1" | "y2">, BinOptions } /** Options for the areaY mark. */ -export interface AreaYOptions extends Omit<AreaOptions, "x1" | "x2">, BinOptions { +export interface AreaYOptions extends Omit<AreaOptions, "x1" | "x2">, BinOptions, MarkerOptions { + /** + * Whether to draw the area’s topline again as a stroke-only line, as in the + * area-line mark (area.js:96 upstream); the line also takes the marker + * options. The area itself is unchanged, so a translucent fill and a crisp + * edge can be combined without a second mark. + */ + line?: boolean; + /** * The horizontal position channel, typically bound to the *x* scale; defaults * to the zero-based index of the data [0, 1, 2, …]. @@ -138,7 +157,7 @@ export interface AreaYOptions extends Omit<AreaOptions, "x1" | "x2">, BinOptions reduce?: BinReducer; } -const defaults = { +const areaDefaults = { ariaLabel: "area", strokeWidth: 1, strokeLinecap: "round", @@ -146,11 +165,26 @@ const defaults = { strokeMiterlimit: 1 }; +// The area-line defaults follow the line mark (area.js:20-29 upstream): the +// topline is stroked over a translucent fill, so the fill opacity drops to 0.3 +// and the stroke takes the line’s 1.5 weight. They are passed to the Area +// constructor rather than merged into the options, so that the user’s options +// still win. +const areaLineDefaults = { + ariaLabel: "area-line", + fillOpacity: 0.3, + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round", + strokeLinejoin: "round", + strokeMiterlimit: 1 +}; + /** The area mark. */ export class Area extends Mark { z: any; curve: any; - constructor(data: Data | null | undefined, options: any = {}) { + constructor(data: Data | null | undefined, options: any = {}, defaults: any = areaDefaults) { const {x1, y1, x2, y2, z, curve, tension} = options; const channels = { x1: {value: x1, scale: "x"}, @@ -183,7 +217,7 @@ export class Area extends Mark { const groups = Array.from(groupIndex(index, [X1, Y1, X2, Y2], this, channels) as Iterable<number[]>); const paths = groups.map((G, k) => { const channel = groupChannelStyleProps(G, channels); - const titled = withTitleChild(channels, G[0], null); + const titled = withTitleChild(this, channels, G[0], null); const d = generator(G as any) ?? undefined; const pathEl = h("path", {key: k, ...direct, ...channel, d}, titled); return withHrefWrap(channels, this.target, G[0], pathEl); @@ -192,6 +226,114 @@ export class Area extends Mark { } } +/** + * The area-line mark: the area mark (area.js:96-152 upstream) with its topline + * drawn again as a stroke-only path, so that the area fill can be translucent + * while the edge stays crisp. The two paths share a group, which carries the + * direct and channel styles for the pair; the area path drops the stroke and + * the line path drops the fill. + * + * It is not exported: areaX and areaY select it from the **line** option, as + * upstream does. + */ +class AreaLine extends Area { + constructor(data: Data | null | undefined, options: any = {}) { + super(data, options, areaLineDefaults); + markers(this, options); + } + renderJSX(this: any, index: any, scales: any, channels: any, _dimensions: any, _context: any): ReactNode { + // A mark whose data is null has no index; render nothing rather than crash. + if (index == null) index = []; + const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, stroke: S} = channels; + const indirect = indirectStyleProps(this); + const direct = directStyleProps(this); + const transform = transformProp(this, scales, 0, 0); + const areaGenerator = shapeArea() + .curve(this.curve) + .defined((i: any) => i >= 0) + .x0((i: any) => X1[i]) + .y0((i: any) => Y1[i]) + .x1((i: any) => X2[i]) + .y1((i: any) => Y2[i]); + // The line follows the topline only — the baseline is the area’s business. + const lineGenerator = shapeLine() + .curve(this.curve) + .defined((i: any) => i >= 0) + .x((i: any) => X2[i]) + .y((i: any) => Y2[i]); + // Marker defs are collected by id and hoisted into a single <defs> at the + // mark root, as in Line.renderJSX: upstream inserts each <marker> inline + // before the first path that references it (marker.js:164), which the JSX + // path cannot do without a post-render pass. + const markerDefs = new Map<string, ReactNode>(); + const markerUrl = (marker: any, color: any): string | null => { + if (!marker) return null; + const m = markerToJSX(marker, color); + if (!m) return null; + if (!markerDefs.has(m.id)) markerDefs.set(m.id, m.defJSX); + return m.urlRef; + }; + const groups = Array.from(groupIndex(index, [X1, Y1, X2, Y2], this, channels) as Iterable<number[]>); + // Grouped-marker orientation, mirroring getGroupedOrientation in marker.js + // (and, in this port, Line.renderJSX): when a series (z) is split into + // several path segments only the first segment takes marker-start, and only + // the last takes marker-end; the interior joints take marker-mid. + const Z = channels.z; + const START = new Array(groups.length).fill(false); + const END = new Array(groups.length).fill(false); + if (Z) { + const multi = groups.map((_, k) => k).filter((k) => groups[k].length > 1); + const UNSET = {}; + let z: any = UNSET; + for (const k of multi) if (z !== (z = keyof(Z[groups[k][0]]))) START[k] = true; + z = UNSET; + for (let j = multi.length - 1; j >= 0; --j) { + const k = multi[j]; + if (z !== (z = keyof(Z[groups[k][0]]))) END[k] = true; + } + } + const paths = groups.map((G, k) => { + const channel = groupChannelStyleProps(G, channels); + const i = G[0]; + const color = S ? S[i] : this.stroke; + const markerAttrs: Record<string, string> = {}; + const startUrl = !Z || START[k] ? markerUrl(this.markerStart, color) : markerUrl(this.markerMid, color); + if (startUrl) markerAttrs.markerStart = startUrl; + const midUrl = markerUrl(this.markerMid, color); + if (midUrl) markerAttrs.markerMid = midUrl; + if (!Z || END[k]) { + const endUrl = markerUrl(this.markerEnd, color); + if (endUrl) markerAttrs.markerEnd = endUrl; + } + const titled = withTitleChild(this, channels, i, null); + const d = areaGenerator(G as any) ?? undefined; + const dl = lineGenerator(G as any) ?? undefined; + const areaPath = h("path", {key: "area", stroke: "none", d}); + const linePath = h( + "path", + { + key: "line", + ...markerAttrs, + fill: "none", + transform: offset ? `translate(${offset},${offset})` : undefined, + d: dl + }, + titled + ); + return withHrefWrap(channels, this.target, i, h("g", {key: k, ...direct, ...channel}, areaPath, linePath)); + }); + const defs = + markerDefs.size > 0 + ? h( + "defs", + {key: "__defs"}, + ...Array.from(markerDefs.entries()).map(([id, def]) => h(Fragment, {key: id}, def)) + ) + : null; + return h("g", {...indirect, ...transform}, defs, ...paths); + } +} + /** * Returns a new area mark with the given *data* and *options*. The area mark is * rarely used directly; it is only needed when the baseline and topline have @@ -217,13 +359,14 @@ export function areaX(data?: Data, options?: AreaXOptions): Area { const { x, y: yOut, + line, color, stroke = color, fill = color, z = x === fill || x === stroke ? null : undefined, ...rest } = maybeDenseIntervalY({y, ...maybeIdentityX(denseRest)}) as any; - return new Area(data, maybeStackX({...rest, x, y1: yOut, y2: undefined, z, stroke, fill})); + return new (line ? AreaLine : Area)(data, maybeStackX({...rest, x, y1: yOut, y2: undefined, z, stroke, fill})); } /** @@ -239,11 +382,12 @@ export function areaY(data?: Data, options?: AreaYOptions): Area { const { x: xOut, y, + line, color, stroke = color, fill = color, z = y === fill || y === stroke ? null : undefined, ...rest } = maybeDenseIntervalX({x, ...maybeIdentityY(denseRest)}) as any; - return new Area(data, maybeStackY({...rest, x1: xOut, x2: undefined, y, z, stroke, fill})); + return new (line ? AreaLine : Area)(data, maybeStackY({...rest, x1: xOut, x2: undefined, y, z, stroke, fill})); } diff --git a/src/marks/arrow.ts b/src/marks/arrow.ts index a9fd7a3c..c45204ef 100644 --- a/src/marks/arrow.ts +++ b/src/marks/arrow.ts @@ -7,6 +7,7 @@ import {radians} from "../math.js"; import {constant, keyword} from "../options.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {maybeSameValue} from "./link.js"; /** Options for the arrow mark. */ @@ -214,9 +215,9 @@ export class Arrow extends Mark { const d = buildPath(i); if (d == null) return null; const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key: k, ...direct, ...channel, d}, titled); - return withHrefWrap(channels, this.target, i, pathEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, pathEl), i); }); return h("g", {...indirect, ...transform}, paths); } diff --git a/src/marks/bar.ts b/src/marks/bar.ts index 0f185d30..c62a8867 100644 --- a/src/marks/bar.ts +++ b/src/marks/bar.ts @@ -5,7 +5,6 @@ import type {Data, MarkOptions} from "../mark.js"; import {Mark} from "../mark.js"; import {hasXY, identity, indexOf} from "../options.js"; import {isCollapsed} from "../scales.js"; -import {applyTransform} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import type {StackOptions} from "../transforms/stack.js"; @@ -15,6 +14,7 @@ import {rectInsets, rectRadii, roundedRectPath} from "./rect.js"; import {createElement as h, type ReactNode} from "react"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; /** Options for the barX and barY marks. */ interface BarOptions extends MarkOptions, InsetOptions, RectCornerOptions, StackOptions { @@ -166,7 +166,7 @@ export class AbstractBar extends Mark { const at = (v: any, i: number) => (typeof v === "function" ? v(i) : v); const rects = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const xi = at(x, i); const yi = at(y, i); const wi = at(w, i); @@ -188,7 +188,7 @@ export class AbstractBar extends Mark { }, titled ); - return withHrefWrap(channels, this.target, i, rectEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, rectEl), i); }); return h("g", {...indirect, ...transform}, rects); } @@ -210,9 +210,6 @@ export class AbstractBar extends Mark { const bandwidth = Y && y ? y.bandwidth() : height - marginTop - marginBottom; return Math.max(0, bandwidth - insetTop - insetBottom); } - _transform(_selection: any, _mark: any, _scales: any): void { - // overridden in subclasses - } _transformScales(_scales: any): any { return {}; } @@ -233,9 +230,6 @@ export class BarX extends AbstractBar { defaults ); } - _transform(selection: any, mark: any, {x}: any) { - selection.call(applyTransform, mark, {x}, 0, 0); - } _transformScales({x}: any): any { return {x}; } @@ -266,9 +260,6 @@ export class BarY extends AbstractBar { defaults ); } - _transform(selection: any, mark: any, {y}: any) { - selection.call(applyTransform, mark, {y}, 0, 0); - } _transformScales({y}: any): any { return {y}; } diff --git a/src/marks/box.ts b/src/marks/box.ts index 9aed5103..1920f63a 100644 --- a/src/marks/box.ts +++ b/src/marks/box.ts @@ -53,7 +53,7 @@ export function boxX( x = identity, y = null, r, - fill = "#ccc", + fill = "light-dark(#ccc, #555)", fillOpacity, stroke = "currentColor", strokeOpacity, @@ -98,7 +98,7 @@ export function boxY( y = identity, x = null, r, - fill = "#ccc", + fill = "light-dark(#ccc, #555)", fillOpacity, stroke = "currentColor", strokeOpacity, diff --git a/src/marks/cell.ts b/src/marks/cell.ts index a2eaf6db..b69ce37c 100644 --- a/src/marks/cell.ts +++ b/src/marks/cell.ts @@ -3,9 +3,9 @@ import type {ChannelValueSpec} from "../channel.js"; import type {InsetOptions} from "../inset.js"; import type {Data, MarkOptions, RenderableMark} from "../mark.js"; import {identity, indexOf, maybeColorChannel, maybeTuple} from "../options.js"; -import {applyTransform} from "../style.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {AbstractBar} from "./bar.js"; import type {RectCornerOptions} from "./rect.js"; import {roundedRectPath} from "./rect.js"; @@ -50,10 +50,6 @@ export class Cell extends (AbstractBar as {new (...args: any[]): RenderableMark} defaults ); } - _transform(selection: any, mark: any) { - // apply dx, dy - selection.call(applyTransform, mark, {}, 0, 0); - } renderJSX(this: any, index: any, scales: any, channels: any, dimensions: any, _context: any): ReactNode { // A mark whose data is null has no index; render nothing rather than crash. if (index == null) index = []; @@ -69,7 +65,7 @@ export class Cell extends (AbstractBar as {new (...args: any[]): RenderableMark} const transform = transformProp(this, {}, 0, 0); const rects = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const x = at(xOf, i); const y = at(yOf, i); const w = at(wOf, i); @@ -91,7 +87,7 @@ export class Cell extends (AbstractBar as {new (...args: any[]): RenderableMark} }, titled ); - return withHrefWrap(channels, this.target, i, rect); + return withDatumIndex(withHrefWrap(channels, this.target, i, rect), i); }); return h("g", {...indirect, ...transform}, rects); } diff --git a/src/marks/contour.ts b/src/marks/contour.ts index 6516abd7..5e8e57af 100644 --- a/src/marks/contour.ts +++ b/src/marks/contour.ts @@ -1,8 +1,9 @@ import {blur2, contours, geoPath, max, min, nice, range, ticks, thresholdSturges} from "d3"; -import {Fragment, createElement as h, type ReactNode} from "react"; +import {createElement as h, type ReactNode} from "react"; import type {ChannelValue} from "../channel.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; // @ts-expect-error — runtime export missing from channel.d.ts import {createChannels} from "../channel.js"; import type {RangeInterval} from "../interval.js"; @@ -142,11 +143,12 @@ export class Contour extends AbstractRaster { const children: ReactNode[] = []; for (const i of index) { const channel = channelStyleProps(i, channels); - let element: ReactNode = h("path", {key: i, ...direct, ...channel, d: path(G[i]) ?? undefined}); - const titled = withTitleChild(channels, i, null); - if (titled) element = h(Fragment, {key: i}, element, titled); - element = withHrefWrap(channels, this.target, i, element); - children.push(element); + // The title channel is applied to each band’s own path rather than to the + // enclosing group (as upstream’s applyChannelStyles does), so that + // hovering a band shows that band’s value. + const titled = withTitleChild(this, channels, i, null); + const element: ReactNode = h("path", {key: i, ...direct, ...channel, d: path(G[i]) ?? undefined}, titled); + children.push(withDatumIndex(withHrefWrap(channels, this.target, i, element), i)); } return h("g", {...indirect, ...transform}, ...children); } diff --git a/src/marks/delaunay.ts b/src/marks/delaunay.ts index 2cae1e97..e3e4e266 100644 --- a/src/marks/delaunay.ts +++ b/src/marks/delaunay.ts @@ -17,6 +17,7 @@ import {createElement as h, Fragment, type ReactNode} from "react"; import {markerToJSX} from "../react/Markers.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; /** Options for the Delaunay marks. */ export interface DelaunayOptions extends MarkOptions, MarkerOptions, CurveOptions { @@ -151,11 +152,11 @@ class DelaunayLink extends MarkBase { c.point(X2[ni], Y2[ni]); c.lineEnd(); const channel = channelStyleProps(ni, newChannels); - const titled = withTitleChild(newChannels, ni, null); + const titled = withTitleChild(this, newChannels, ni, null); const color = newChannels.stroke ? newChannels.stroke[ni] : this.stroke; const markerAttrs = markerAttrsFor(color); const pathEl = h("path", {key: k, ...direct, ...channel, ...markerAttrs, d: `${p}`}, titled); - return withHrefWrap(newChannels, this.target, ni, pathEl); + return withDatumIndex(withHrefWrap(newChannels, this.target, ni, pathEl), ni); }); }; @@ -210,7 +211,7 @@ class AbstractDelaunayMark extends MarkBase { const i = subIndex[0]; const d = (this as any)._render(delaunay, dimensions); const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key, ...direct, ...channel, d}, titled); return withHrefWrap(channels, this.target, i, pathEl); }; @@ -292,9 +293,9 @@ class Voronoi extends MarkBase { const direct = directStyleProps(this); const paths = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key: k, ...direct, ...channel, d: C[i]}, titled); - return withHrefWrap(channels, this.target, i, pathEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, pathEl), i); }); return h("g", {...indirect, ...transform}, paths); } diff --git a/src/marks/density.ts b/src/marks/density.ts index 8bde0a1f..c1e63327 100644 --- a/src/marks/density.ts +++ b/src/marks/density.ts @@ -12,6 +12,7 @@ import {applyFrameAnchor, groupZ} from "../style.js"; import {initializer} from "../transforms/basic.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; // Mark's runtime constructor takes (data, channels, transform, defaults), but the // public .d.ts declares Mark with a zero-arg constructor; cast for super(...) typing. @@ -99,9 +100,9 @@ export class Density extends Mark { const direct = directStyleProps(this); const paths = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key: k, ...direct, ...channel, d: path(contours[i]) ?? undefined}, titled); - return withHrefWrap(channels, this.target, i, pathEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, pathEl), i); }); return h("g", {...indirect, ...transform}, paths); } diff --git a/src/marks/difference.ts b/src/marks/difference.ts index 44ccdcb7..8bd92ccd 100644 --- a/src/marks/difference.ts +++ b/src/marks/difference.ts @@ -10,7 +10,6 @@ import {identity, indexOf} from "../options.js"; // @ts-expect-error - internal helpers not in .d.ts import {isNoneish, labelof, maybeColorChannel, maybeValue, valueof} from "../options.js"; import {inferScaleOrder} from "../scales.js"; -import {getClipId} from "../style.js"; import {area} from "./area.js"; import {line} from "./line.js"; @@ -133,6 +132,7 @@ function differenceK( z = maybeColorChannel(stroke)[0], clip, // optional additional clip for area tip, + render, // composed onto the two areas only, as upstream does ...options }: any = {} ): CompoundMark { @@ -143,39 +143,24 @@ function differenceK( else x1 = memo(0); } ({tip} = withTip({tip}, k === "y" ? "x" : "y")); + const shared = {x1, x2, y1, y2, z, clip, ...options}; return marks( !isNoneish(positiveFill) - ? Object.assign( - area(data, { - x1, - x2, - y1, - y2, - z, - fill: positiveFill, - fillOpacity: positiveFillOpacity, - renderJSX: clipDifferenceJSX(k, true), - clip, - ...options - }), - {ariaLabel: "positive difference"} + ? clippedArea( + data, + {...shared, fill: positiveFill, fillOpacity: positiveFillOpacity}, + clipDifferenceJSX(k, true), + "positive difference", + render ) : null, !isNoneish(negativeFill) - ? Object.assign( - area(data, { - x1, - x2, - y1, - y2, - z, - fill: negativeFill, - fillOpacity: negativeFillOpacity, - renderJSX: clipDifferenceJSX(k, false), - clip, - ...options - }), - {ariaLabel: "negative difference"} + ? clippedArea( + data, + {...shared, fill: negativeFill, fillOpacity: negativeFillOpacity}, + clipDifferenceJSX(k, false), + "negative difference", + render ) : null, line(data, { @@ -191,6 +176,26 @@ function differenceK( ); } +// One of the two clipped areas. A user *render* transform is composed outside +// the clip, as upstream's composeRender(render, clipDifference(k, …)) +// (difference.js:38,60) does: the transform receives the clipped area as its +// `next`, and whatever it returns is rendered in its place. The line is not +// given the transform at all, which is why differenceK destructures it. +function clippedArea(data: Data | undefined, options: any, clipJSX: any, ariaLabel: string, render: any): any { + const areaMark = Object.assign(area(data, {...options, renderJSX: clipJSX}), {ariaLabel}); + if (render == null) return areaMark; + // The clip renderJSX is an own property of the area, and an own renderJSX + // tells the JSX paths that the mark renders itself, so the imperative bridge + // would be skipped (see hasRenderTransform). Inheriting the finished mark + // instead — renderJSX then resolves through the prototype — keeps the clip + // and still brings the own `render` into the JSX path. `this` stays the area, + // so the transform sees the mark's styles and aria-label, as upstream's + // composeRender (which preserves `this`) does. + const mark = Object.create(areaMark); + mark.render = render; + return mark; +} + function memoTuple(x: any, x1: any, x2: any) { if (x1 === undefined && x2 === undefined) { // {x} → [x, x] @@ -250,7 +255,7 @@ function clipDifferenceJSX(k: "x" | "y", positive: boolean) { const out: ReactNode[] = []; const n = Math.min(clipChildren.length, gChildren.length); for (let i = 0; i < n; i++) { - const id = getClipId(); + const id = clipIdOf(context); const clipChild = clipChildren[i] as ReactElement; const gChild = gChildren[i] as ReactElement; out.push(h("clipPath", {key: `cp-${i}`, id}, clipChild)); @@ -259,3 +264,19 @@ function clipDifferenceJSX(k: "x" | "y", positive: boolean) { return cloneElement(gNode, gNode.props as any, ...out); }; } + +// The id for one of this mark's own <clipPath> defs, taken from the clip +// registry of the render that is building the <svg>. Each facet and each of +// the two areas takes its own id, in render order, which is deterministic for +// a given computed plot — so the ids neither collide with the frame clip's (a +// difference plot always has one: the differenced line is clip: true) nor +// change when React re-renders the marks without recomputing them. Both render +// paths create the registry before rendering any mark and expose it on the +// plot's context, which is what a renderJSX is given; assert rather than fall +// back to style.js's module-global counter, which is the collision this +// replaced. +function clipIdOf(context: any): string { + const registry = context?.clipRegistry; + if (registry == null) throw new Error("difference: no clip registry on the plot context"); + return registry.clipId(); +} diff --git a/src/marks/dot.ts b/src/marks/dot.ts index 4989cfa4..25238020 100644 --- a/src/marks/dot.ts +++ b/src/marks/dot.ts @@ -8,6 +8,7 @@ import { computeFrameAnchor } from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import type {ChannelValue, ChannelValueIntervalSpec, ChannelValueSpec} from "../channel.js"; import {negative, positive} from "../defined.js"; import type {Interval} from "../interval.js"; @@ -183,18 +184,26 @@ export class Dot extends (Mark as unknown as new (...args: any[]) => RenderableM const indirect = indirectStyleProps(this); const direct = directStyleProps(this); const transform = transformProp(this, {x: X && scales.x, y: Y && scales.y}); - const items = indices.map((i, k) => { + const items = indices.map((i) => { const channel = channelStyleProps(i, channels); + // The title channel is a CHILD of the dot, not a sibling: upstream + // appends it to the selected dot (style.js's applyTitle), so a titled + // dot is <circle><title/></circle> and never a <g> wrapper. + const title = withTitleChild(this, channels, i, null); let element: ReactNode; if (isCircle) { - element = h("circle", { - key: k, - ...direct, - ...channel, - cx: X ? X[i] : cx, - cy: Y ? Y[i] : cy, - r: R ? R[i] : r - }); + element = h( + "circle", + { + key: i, + ...direct, + ...channel, + cx: X ? X[i] : cx, + cy: Y ? Y[i] : cy, + r: R ? R[i] : r + }, + title + ); } else { const tx = X ? X[i] : cx; const ty = Y ? Y[i] : cy; @@ -204,17 +213,19 @@ export class Dot extends (Mark as unknown as new (...args: any[]) => RenderableM const sym = S ? S[i] : symbol; const sz = R ? R[i] * R[i] * Math.PI : size; sym.draw(p, sz); - element = h("path", { - key: k, - ...direct, - ...channel, - transform: t, - d: `${p}` - }); + element = h( + "path", + { + key: i, + ...direct, + ...channel, + transform: t, + d: `${p}` + }, + title + ); } - const titled = withTitleChild(channels, i, null); - if (titled) element = h("g", {key: k}, element, titled); - return withHrefWrap(channels, this.target, i, element); + return withDatumIndex(withHrefWrap(channels, this.target, i, element), i); }); return h("g", {...indirect, ...transform}, items); } diff --git a/src/marks/frame.ts b/src/marks/frame.ts index bf180d09..0995c551 100644 --- a/src/marks/frame.ts +++ b/src/marks/frame.ts @@ -1,4 +1,4 @@ -import {Fragment, createElement as h, type ReactNode} from "react"; +import {createElement as h, type ReactNode} from "react"; import type {InsetOptions} from "../inset.js"; import type {MarkOptions} from "../mark.js"; import type {RectCornerOptions} from "./rect.js"; @@ -55,27 +55,33 @@ export class Frame extends Mark { const direct = directStyleProps(this); const channel = channelStyleProps(0, channels); const transform = transformProp(this, {}); + // The title is the frame element’s own child, as upstream’s + // applyChannelStyles appends it; a sibling title would be parented by the + // <svg> (the frame is not wrapped in a group) and describe the whole plot. + const titled = withTitleChild(this, channels, 0, null); const baseProps = {...indirect, ...direct, ...channel, ...transform}; let element: ReactNode; - if (anchor === "left") element = h("line", {...baseProps, x1, x2: x1, y1, y2}); - else if (anchor === "right") element = h("line", {...baseProps, x1: x2, x2, y1, y2}); - else if (anchor === "top") element = h("line", {...baseProps, x1, x2, y1, y2: y1}); - else if (anchor === "bottom") element = h("line", {...baseProps, x1, x2, y1: y2, y2}); + if (anchor === "left") element = h("line", {...baseProps, x1, x2: x1, y1, y2}, titled); + else if (anchor === "right") element = h("line", {...baseProps, x1: x2, x2, y1, y2}, titled); + else if (anchor === "top") element = h("line", {...baseProps, x1, x2, y1, y2: y1}, titled); + else if (anchor === "bottom") element = h("line", {...baseProps, x1, x2, y1: y2, y2}, titled); else if (rx1y1 || rx1y2 || rx2y1 || rx2y2) { - element = h("path", {...baseProps, d: roundedRectPath(x1, y1, x2, y2, this)}); + element = h("path", {...baseProps, d: roundedRectPath(x1, y1, x2, y2, this)}, titled); } else { - element = h("rect", { - ...baseProps, - x: x1, - y: y1, - width: x2 - x1, - height: y2 - y1, - rx: rx ?? undefined, - ry: ry ?? undefined - }); + element = h( + "rect", + { + ...baseProps, + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + rx: rx ?? undefined, + ry: ry ?? undefined + }, + titled + ); } - const titled = withTitleChild(channels, 0, null); - if (titled) element = h(Fragment, null, element, titled); return withHrefWrap(channels, this.target, 0, element); } } diff --git a/src/marks/geo.ts b/src/marks/geo.ts index bc9b24b6..2e4c5127 100644 --- a/src/marks/geo.ts +++ b/src/marks/geo.ts @@ -8,6 +8,7 @@ import {identity, maybeNumberChannel} from "../options.js"; import {createElement as h, type ReactNode} from "react"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {centroid} from "../transforms/centroid.js"; import {withDefaultSort} from "./dot.js"; @@ -88,10 +89,10 @@ export class Geo extends Mark { const transform = transformProp(this, scales); const paths = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const d = (R ? path.pointRadius(R[i])(G[i]) : path(G[i])) ?? undefined; const pathEl = h("path", {key: k, ...direct, ...channel, d}, titled); - return withHrefWrap(channels, this.target, i, pathEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, pathEl), i); }); return h("g", {...indirect, ...transform}, paths); } @@ -113,7 +114,7 @@ export class Geo extends Mark { * data is *data*.geometries; if *data* is some other GeoJSON object, then the * mark’s data is the single-element array [*data*]. */ -export function geo(data?: Data | GeoPermissibleObjects, options: any = {}): Geo { +export function geo(data?: Data | GeoPermissibleObjects, options: GeoOptions = {}): Geo { if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options); else if (options.geometry === undefined) options = {...options, geometry: identity}; return new Geo(data, options); diff --git a/src/marks/hexgrid.ts b/src/marks/hexgrid.ts index da993eb9..05b39b21 100644 --- a/src/marks/hexgrid.ts +++ b/src/marks/hexgrid.ts @@ -5,7 +5,8 @@ import {number, singleton} from "../options.js"; import {offset} from "../style.js"; import {sqrt4_3} from "../symbol.js"; import {ox, oy} from "../transforms/hexbin.js"; -import {indirectStyleProps, directStyleProps, transformProp} from "../react/styles.js"; +import {channelStyleProps, indirectStyleProps, directStyleProps, transformProp} from "../react/styles.js"; +import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; /** Options for the hexgrid mark. */ export interface HexgridOptions extends MarkOptions { @@ -53,7 +54,7 @@ export class Hexgrid extends Mark { super(singleton, undefined, {clip, ...options}, defaults); this.binWidth = number(binWidth); } - renderJSX(this: any, _index: any, _scales: any, _channels: any, dimensions: any, _context: any): ReactNode { + renderJSX(this: any, _index: any, _scales: any, channels: any, dimensions: any, _context: any): ReactNode { const {binWidth} = this; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; const x0 = marginLeft - ox, @@ -79,7 +80,12 @@ export class Hexgrid extends Mark { const indirect = indirectStyleProps(this); const direct = directStyleProps(this); const transform = transformProp(this, {}, offset + ox, offset + oy); - return h("g", {...indirect, ...transform}, h("path", {...direct, d})); + // The grid is one path over a singleton datum, so its channel values — like + // upstream, which renders it with .datum(0) — are read at index 0. + const channel = channelStyleProps(0, channels); + const titled = withTitleChild(this, channels, 0, null); + const pathEl = h("path", {...direct, ...channel, d}, titled); + return h("g", {...indirect, ...transform}, withHrefWrap(channels, this.target, 0, pathEl)); } } diff --git a/src/marks/image.ts b/src/marks/image.ts index 127ea668..3ba4e1f6 100644 --- a/src/marks/image.ts +++ b/src/marks/image.ts @@ -6,6 +6,7 @@ import {Mark} from "../mark.js"; import {maybeFrameAnchor, maybeNumberChannel, maybeTuple, string} from "../options.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {applyFrameAnchor, impliedString} from "../style.js"; import {withDefaultSort} from "./dot.js"; @@ -199,7 +200,10 @@ export class Image extends Mark { imageRendering: this.imageRendering }; const rotation = A ? A[i] : rotate; - if (rotation) { + // Both attributes key off the presence of the rotate channel rather than + // on its value (image.js:99-100 upstream): a zero angle still rotates the + // image about its center. + if (A || rotation) { const ox = typeof xCenter === "function" ? xCenter(i) : xCenter; const oy = typeof yCenter === "function" ? yCenter(i) : yCenter; props.transform = `rotate(${rotation})`; @@ -207,9 +211,9 @@ export class Image extends Mark { } const clip = R ? `circle(${R[i]}px)` : r !== undefined ? `circle(${r}px)` : null; if (clip != null) props.clipPath = clip; - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const imgEl = h("image", props, titled); - return withHrefWrap(channels, this.target, i, imgEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, imgEl), i); }); return h("g", {...indirect, ...transform}, images); } diff --git a/src/marks/line.ts b/src/marks/line.ts index 34f594ba..a8f7ab74 100644 --- a/src/marks/line.ts +++ b/src/marks/line.ts @@ -197,7 +197,7 @@ export class Line extends Mark { const endUrl = markerUrl(this.markerEnd, color); if (endUrl) markerAttrs.markerEnd = endUrl; } - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key: k, ...direct, ...channel, ...markerAttrs, d: dOf(G)}, titled); return withHrefWrap(channels, this.target, i, pathEl); }); diff --git a/src/marks/linearRegression.ts b/src/marks/linearRegression.ts index b162f85d..050e1322 100644 --- a/src/marks/linearRegression.ts +++ b/src/marks/linearRegression.ts @@ -136,7 +136,7 @@ class LinearRegression extends Mark { const elements: ReactNode[] = []; groups.forEach((G, k) => { const lineChannel = groupChannelStyleProps(G, {...channels, fill: null, fillOpacity: null}); - const lineTitled = withTitleChild(channels, G[0], null); + const lineTitled = withTitleChild(this, channels, G[0], null); const dLine = (this as any)._renderLine(G, X, Y); if (showBand) { const bandChannel = groupChannelStyleProps(G, { @@ -146,7 +146,10 @@ class LinearRegression extends Mark { strokeWidth: null }); const dBand = (this as any)._renderBand(G, X, Y); - const bandEl = h("path", {key: `b${k}`, stroke: "none", ...direct, ...bandChannel, d: dBand}); + // The band carries the same title as its line — upstream applies the + // grouped channel styles, title included, to both paths. + const bandTitled = withTitleChild(this, channels, G[0], null); + const bandEl = h("path", {key: `b${k}`, stroke: "none", ...direct, ...bandChannel, d: dBand}, bandTitled); elements.push(withHrefWrap(channels, this.target, G[0], bandEl)); } const lineEl = h("path", {key: `l${k}`, fill: "none", ...direct, ...lineChannel, d: dLine}, lineTitled); diff --git a/src/marks/link.ts b/src/marks/link.ts index 53dee0ea..35b2c95c 100644 --- a/src/marks/link.ts +++ b/src/marks/link.ts @@ -14,6 +14,7 @@ import {markers} from "../marker.js"; import {coerceNumbers} from "../options.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; /** Options for the link mark. */ export interface LinkOptions extends MarkOptions, MarkerOptions, CurveAutoOptions { @@ -142,9 +143,9 @@ export class Link extends Mark { const paths = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); const markers = markerAttrs(i); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key: k, ...direct, ...channel, ...markers, d: dOf(i)}, titled); - return withHrefWrap(channels, this.target, i, pathEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, pathEl), i); }); const defs = markerDefs.size > 0 diff --git a/src/marks/raster.ts b/src/marks/raster.ts index db675973..d008b761 100644 --- a/src/marks/raster.ts +++ b/src/marks/raster.ts @@ -1,4 +1,4 @@ -import {blurImage, Delaunay, randomLcg, rgb} from "d3"; +import {blurImage, Delaunay, randomLcg} from "d3"; import type {ChannelValueSpec} from "../channel.js"; import {valueObject} from "../channel.js"; import type {Data, MarkOptions} from "../mark.js"; @@ -10,7 +10,7 @@ import {impliedString} from "../style.js"; import {directStyleProps, indirectStyleProps, offset as styleOffset, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; import {initializer} from "../transforms/basic.js"; -import {createElement as h, Fragment, type ReactNode} from "react"; +import {createElement as h, type ReactNode} from "react"; /** * The built-in spatial interpolation methods; one of: @@ -156,6 +156,14 @@ export interface RasterOptions extends Omit<MarkOptions, "fill" | "fillOpacity"> */ imageRendering?: string; + /** + * The [color space][1] of the backing canvas. Defaults to *srgb*; set to + * *display-p3* for the Display P3 color space. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData/colorSpace + */ + colorSpace?: ImageData["colorSpace"]; + /** * The fill, typically bound to the *color* scale. Can be specified as a * constant, a channel based on the sample *data*, or as a function *f*(*x*, @@ -195,6 +203,8 @@ export class AbstractRaster extends Mark { pixelSize: any; blur: any; interpolate: any; + colorSpace: string; + colorConverter: ((color: any) => Uint8ClampedArray) | undefined; constructor(data: any, channels: any, options: any = {}, defaults: any) { let { width, @@ -206,7 +216,7 @@ export class AbstractRaster extends Mark { x2 = x == null ? width : undefined, y2 = y == null ? height : undefined } = options; - const {pixelSize = defaults.pixelSize, blur = 0, interpolate} = options; + const {pixelSize = defaults.pixelSize, blur = 0, interpolate, colorSpace = "srgb"} = options; if (width != null) width = integer(width, "width"); if (height != null) height = integer(height, "height"); // These represent the (minimum) bounds of the raster; they are not @@ -246,6 +256,7 @@ export class AbstractRaster extends Mark { this.pixelSize = number(pixelSize, "pixelSize"); this.blur = number(blur, "blur"); this.interpolate = x == null || y == null ? null : maybeInterpolate(interpolate); // interpolation requires x & y + this.colorSpace = String(colorSpace).toLowerCase(); } } @@ -290,10 +301,17 @@ export class Raster extends AbstractRaster { if (FO) FO = this.interpolate(index, w, rh, IX, IY, FO); } else if ((this as any).data == null && index) offset = index.fi * n; + // The colour converter is cached on the mark: parsing a colour is done by + // the canvas (so that it respects the mark’s colorSpace, and so that + // colour syntax the CSS parser knows but d3-format does not, such as + // oklch, survives), which makes it far too expensive to do per pixel. It + // caches each colour it has seen, so a raster of one colour is parsed once + // per render. + const colorConverter = ((this as any).colorConverter ??= getColorConverter(this.colorSpace, context)); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = rh; - const context2d = canvas.getContext("2d"); + const context2d = canvas.getContext("2d", {colorSpace: this.colorSpace}); const image = context2d.createImageData(w, rh); const imageData = image.data; // rgba is the parsed fill color, including its own alpha (e.g. an @@ -301,23 +319,16 @@ export class Raster extends AbstractRaster { // fillOpacity baseline (or, if present, the per-pixel fillOpacity // channel). The final pixel alpha composes the color's own opacity with // `a`, matching observablehq-plot's `rgba[3] * a`. - let rgba = rgb((this as any).fill ?? "black"); + let rgba = colorConverter((this as any).fill ?? "black"); let a = (this as any).fillOpacity ?? 1; for (let i = 0; i < n; ++i) { const j = i << 2; - if (F) { - const fi = color(F[i + offset]); - if (fi == null) { - imageData[j + 3] = 0; - continue; - } - rgba = rgb(fi); - } + if (F) rgba = colorConverter(color(F[i + offset])); if (FO) a = FO[i + offset]; - imageData[j + 0] = rgba.r; - imageData[j + 1] = rgba.g; - imageData[j + 2] = rgba.b; - imageData[j + 3] = (rgba.opacity ?? 1) * a * 255; + imageData[j + 0] = rgba[0]; + imageData[j + 1] = rgba[1]; + imageData[j + 2] = rgba[2]; + imageData[j + 3] = rgba[3] * a; } if (this.blur > 0) blurImage(image, this.blur); context2d.putImageData(image, 0, 0); @@ -328,20 +339,24 @@ export class Raster extends AbstractRaster { // the <image> takes only direct (mark-level) styles, matching the // imperative applyDirectStyles (no applyChannelStyles). const transform = transformProp(this, scales, styleOffset, styleOffset); - const imageRendering = this.imageRendering != null ? {"image-rendering": this.imageRendering} : {}; - let imageEl: ReactNode = h("image", { - transform: `translate(${x1},${y1}) scale(${Math.sign(x2 - x1)},${Math.sign(y2 - y1)})`, - width: Math.abs(dx), - height: Math.abs(dy), - preserveAspectRatio: "none", - ...imageRendering, - ...direct, - xlinkHref: canvas.toDataURL() - }); - const titled = withTitleChild(values, 0, null); - if (titled) imageEl = h(Fragment, null, imageEl, titled); - imageEl = withHrefWrap(values, this.target, 0, imageEl); - return h("g", {...indirect, ...transform}, imageEl); + // React’s SVG attribute names are camelCase, so image-rendering is spelled + // imageRendering here; upstream reaches the same attribute through + // applyAttr. The title likewise belongs to the image itself, as upstream’s + // applyChannelStyles appends it, rather than to the enclosing group. + const imageEl: ReactNode = h( + "image", + { + transform: `translate(${x1},${y1}) scale(${Math.sign(x2 - x1)},${Math.sign(y2 - y1)})`, + width: Math.abs(dx), + height: Math.abs(dy), + preserveAspectRatio: "none", + imageRendering: this.imageRendering, + ...direct, + xlinkHref: canvas.toDataURL() + }, + withTitleChild(this, values, 0, null) + ); + return h("g", {...indirect, ...transform}, withHrefWrap(values, this.target, 0, imageEl)); } } @@ -723,3 +738,29 @@ function denseY(y1: number, y2: number, width: number, height: number) { } }; } + +const transparent = new Uint8ClampedArray(4); + +// Returns a converter from a CSS color string to its RGBA components, parsed +// by a canvas in the given color space. Parsing through the canvas (rather +// than d3-color) is what lets a wide-gamut color space keep its values, and +// what makes colors like oklch work. Each parsed color is cached, since a +// raster typically has few distinct colors but very many pixels of each. +function getColorConverter(colorSpace: string, {document}: any): (color: any) => Uint8ClampedArray { + const cache = new Map<any, Uint8ClampedArray>(); + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", {colorSpace, willReadFrequently: true}); + return (color: any) => { + if (color == null) return transparent; + let data = cache.get(color); + if (data !== undefined) return data; + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + data = context.getImageData(0, 0, 1, 1).data; + cache.set(color, data); + return data; + }; +} diff --git a/src/marks/rect.ts b/src/marks/rect.ts index 607f32e1..8404bc5f 100644 --- a/src/marks/rect.ts +++ b/src/marks/rect.ts @@ -9,6 +9,7 @@ import {isCollapsed} from "../scales.js"; import {impliedString} from "../style.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeTrivialIntervalX, maybeTrivialIntervalY} from "../transforms/interval.js"; import type {StackOptions} from "../transforms/stack.js"; @@ -346,9 +347,9 @@ export class Rect extends Mark { })(); const items = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const el = elementOf(i, k, {...direct, ...channel}, titled); - return withHrefWrap(channels, this.target, i, el); + return withDatumIndex(withHrefWrap(channels, this.target, i, el), i); }); return h("g", {...indirect, ...transform}, items); } diff --git a/src/marks/rule.ts b/src/marks/rule.ts index a9444093..de87e6ab 100644 --- a/src/marks/rule.ts +++ b/src/marks/rule.ts @@ -10,6 +10,7 @@ import {isCollapsed} from "../scales.js"; import {offset} from "../style.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {createElement as h, Fragment, type ReactNode} from "react"; import {markerToJSX} from "../react/Markers.js"; @@ -168,7 +169,7 @@ export class RuleX extends Mark { }; const lines = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const color = S ? S[i] : this.stroke; const markerAttrs = markerAttrsFor(color); const lineEl = h( @@ -176,7 +177,7 @@ export class RuleX extends Mark { {key: k, ...direct, ...channel, ...markerAttrs, x1: x1Of(i), x2: x2Of(i), y1: y1Of(i), y2: y2Of(i)}, titled ); - return withHrefWrap(channels, this.target, i, lineEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, lineEl), i); }); const defs = markerDefs.size > 0 @@ -250,7 +251,7 @@ export class RuleY extends Mark { }; const lines = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const color = S ? S[i] : this.stroke; const markerAttrs = markerAttrsFor(color); const lineEl = h( @@ -258,7 +259,7 @@ export class RuleY extends Mark { {key: k, ...direct, ...channel, ...markerAttrs, x1: x1Of(i), x2: x2Of(i), y1: yOf(i), y2: yOf(i)}, titled ); - return withHrefWrap(channels, this.target, i, lineEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, lineEl), i); }); const defs = markerDefs.size > 0 diff --git a/src/marks/text.ts b/src/marks/text.ts index d26fba4d..e69f5817 100644 --- a/src/marks/text.ts +++ b/src/marks/text.ts @@ -4,6 +4,7 @@ import {nonempty} from "../defined.js"; import {formatDefault} from "../format.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import type {Interval} from "../interval.js"; import type {Data, FrameAnchor, MarkOptions} from "../mark.js"; import {Mark} from "../mark.js"; @@ -312,7 +313,10 @@ export class Text extends Mark { const tx = X ? X[i] : cx; const ty = Y ? Y[i] : cy; const r = R ? R[i] : rotate; - const transformAttr = `translate(${tx},${ty})${r ? ` rotate(${r})` : ""}`; + // The rotate clause keys off the presence of the rotate channel rather + // than on its value (text.js:110-114 upstream): a zero angle is written, + // not skipped. + const transformAttr = `translate(${tx},${ty})${R || r ? ` rotate(${r})` : ""}`; const fontSizeAttr = FS ? FS[i] : undefined; const raw = T ? formatDefault(T[i]) ?? "" : ""; const lines: string[] = T ? splitLines(raw).map(clipLine) : []; @@ -346,10 +350,10 @@ export class Text extends Mark { children.push(h("title", {key: "overflow-title"}, T[i])); } } - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); if (titled) children.push(titled); const textEl = h("text", textProps, ...children); - return withHrefWrap(channels, this.target, i, textEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, textEl), i); }); return h("g", {...indirect, ...indirectText, ...transform}, texts); } diff --git a/src/marks/tick.ts b/src/marks/tick.ts index e03d1fdc..b095f4c9 100644 --- a/src/marks/tick.ts +++ b/src/marks/tick.ts @@ -5,9 +5,10 @@ import type {Data, MarkOptions} from "../mark.js"; import {Mark} from "../mark.js"; import type {MarkerOptions} from "../marker.js"; import {markers} from "../marker.js"; -import {applyTransform, offset} from "../style.js"; +import {offset} from "../style.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; import {createElement as h, Fragment, type ReactNode} from "react"; import {markerToJSX} from "../react/Markers.js"; @@ -60,7 +61,7 @@ class AbstractTick extends Mark { super(data, channels, options, defaults); markers(this, options); } - renderJSX(this: any, index: any, scales: any, channels: any, dimensions: any, _context: any): ReactNode { + renderJSX(this: any, index: any, scales: any, channels: any, dimensions: any, context: any): ReactNode { // A mark whose data is null has no index; render nothing rather than crash. if (index == null) index = []; const {stroke: S} = channels; @@ -75,6 +76,10 @@ class AbstractTick extends Mark { const x2Fn = typeof x2Of === "function" ? x2Of : () => x2Of; const y1Fn = typeof y1Of === "function" ? y1Of : () => y1Of; const y2Fn = typeof y2Of === "function" ? y2Of : () => y2Of; + // Every line names the same marker and color, so one def serves them all; + // the second reference to a def finds it here rather than asking for a + // duplicate. custom markers get the render context so they can build their + // element in the plot's document. const markerDefs = new Map<string, ReactNode>(); const markerAttrsFor = (color: any) => { const out: Record<string, string> = {}; @@ -84,7 +89,7 @@ class AbstractTick extends Mark { [this.markerEnd, "markerEnd"] ] as const) { if (!opt) continue; - const m = markerToJSX(opt, color); + const m = markerToJSX(opt, color, context); if (!m) continue; if (!markerDefs.has(m.id)) markerDefs.set(m.id, m.defJSX); out[attr] = m.urlRef; @@ -93,7 +98,7 @@ class AbstractTick extends Mark { }; const lines = (index as number[]).map((i, k) => { const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const color = S ? S[i] : this.stroke; const markerAttrs = markerAttrsFor(color); const lineEl = h( @@ -101,7 +106,7 @@ class AbstractTick extends Mark { {key: k, ...direct, ...channel, ...markerAttrs, x1: x1Fn(i), x2: x2Fn(i), y1: y1Fn(i), y2: y2Fn(i)}, titled ); - return withHrefWrap(channels, this.target, i, lineEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, lineEl), i); }); const defs = markerDefs.size > 0 @@ -131,9 +136,6 @@ export class TickX extends AbstractTick { this.insetTop = number(insetTop); this.insetBottom = number(insetBottom); } - _transform(selection: any, mark: any, {x}: any) { - selection.call(applyTransform, mark, {x}, offset, 0); - } _transformProp({x}: any) { return transformProp(this as any, {x}, offset, 0); } @@ -169,9 +171,6 @@ export class TickY extends AbstractTick { this.insetRight = number(insetRight); this.insetLeft = number(insetLeft); } - _transform(selection: any, mark: any, {y}: any) { - selection.call(applyTransform, mark, {y}, 0, offset); - } _transformProp({y}: any) { return transformProp(this as any, {y}, 0, offset); } diff --git a/src/marks/tip.ts b/src/marks/tip.ts index e9a53bce..7f1e3d64 100644 --- a/src/marks/tip.ts +++ b/src/marks/tip.ts @@ -1,5 +1,5 @@ import {format as numberFormat, utcFormat} from "d3"; -import {createElement as h, type ReactNode} from "react"; +import {createElement as h, type ReactElement, type ReactNode} from "react"; import type {ChannelName, ChannelValueSpec} from "../channel.js"; // @ts-expect-error -- getSource is declared only in channel.js, not channel.d.ts import {getSource} from "../channel.js"; @@ -18,8 +18,9 @@ import {inferTickFormat} from "./axis.js"; import {defaultWidth, ellipsis, monospaceWidth} from "./text.js"; import type {TextStyles} from "./text.js"; import {cut, clipper, splitter, maybeTextOverflow} from "./text.js"; -import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; -import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; +import {TipItem} from "../react/interactions/TipItem.js"; +import type {TipItemSize} from "../react/interactions/TipItem.js"; /** * How to format channel values; one of: @@ -120,14 +121,23 @@ export interface TipOptions extends MarkOptions, TextStyles { const defaults = { ariaLabel: "tip", fill: "var(--plot-background)", - stroke: "currentColor" + stroke: "currentColor", + // upstream tip.js:15-20. Pooling is a tip default, not an opt-in: when a tip + // is the FIRST pointer mark rendered in a plot it drags every other pointer + // mark of that plot into one pool, so a boxX (four marks, four tips) or two + // tipped dot marks show exactly one tip — the nearest — rather than one each. + pool: true }; // These channels are not displayed in the default tip; see formatChannels. -const ignoreChannels = new Set(["geometry", "href", "src", "ariaLabel", "scales"]); +// `contours` is the contour mark's own channel set (upstream bcff1480). +const ignoreChannels = new Set(["geometry", "href", "src", "ariaLabel", "scales", "contours"]); /** The tip mark. */ export class Tip extends (Mark as {new (...args: any[]): Mark}) { + // Set by plot.ts's inferTips on a tip it derives from a mark's `tip` option, + // naming that mark. Only the React path reads it, to key the tip's slot. + tipFor: any; anchor: any; preferredAnchor: any; frameAnchor: any; @@ -216,12 +226,19 @@ export class Tip extends (Mark as {new (...args: any[]): Mark}) { // A mark whose data is null has no index; render nothing rather than crash. if (index == null) index = []; const mark = this; - const {x, y} = scales; + const {x, y, fx, fy} = scales; const {anchor, monospace, lineHeight, lineWidth} = this; const {textPadding: r, pointerSize: m, pathFilter} = this; + const {marginTop, marginLeft} = dimensions; const {x1: X1, y1: Y1, x2: X2, y2: Y2, x: X = X1 ?? X2, y: Y = Y1 ?? Y2} = values; + // The facet's offset within the frame. A faceted tip renders inside its + // cell's coordinates, but postrender's fit compares against the frame + // (upstream tip.js:105-106). + const ox = fx ? fx(index.fx) - marginLeft : 0; + const oy = fy ? fy(index.fy) - marginTop : 0; + const [cx, cy] = applyFrameAnchor(this, dimensions); const px = anchorX(values, cx); const py = anchorY(values, cy); @@ -295,74 +312,95 @@ export class Tip extends (Mark as {new (...args: any[]): Mark}) { return {tspans: children, width}; } - const items = (index as number[]).map((i, k) => { - // Generate name-value lines for this datum. - const raw = format.call(mark, i, index, sources, scales, values); - let lineSpecs: any[]; - if (typeof raw === "string") { - lineSpecs = mark.splitLines(raw).map((line: string) => ({value: mark.clipLine(line)})); + // Format every datum's text first and drop the nulls afterwards (upstream + // tip.js:132-134): a format that returns null for one datum skips that + // datum's tip rather than crashing the whole render. T is keyed by datum + // index, not by position, because a faceted index is not dense. + const T: any[] = new Array(index.length); + for (const i of index as number[]) T[i] = format.call(mark, i, index, sources, scales, values); + const shown: number[] = (index as number[]).filter((i) => T[i] != null); + + // The frame the box is fitted into: a faceted tip is fitted to its cell + // (upstream tip.js:213). + const fitDimensions = dimensions.facet ?? dimensions; + + const items = shown.map((i) => { + // Render this datum's lines, dropping repeated labels. + let lines: {tspans: ReactNode[]; width: number}[]; + if (typeof T[i] === "string") { + lines = mark.splitLines(T[i]).map((line: string) => buildLine({value: mark.clipLine(line)})); } else { - lineSpecs = []; + lines = []; const labels = new Set(); - for (const line of raw) { + for (const line of T[i]) { const {label = ""} = line; if (label && labels.has(label)) continue; else labels.add(label); - lineSpecs.push(line); + lines.push(buildLine(line)); } } - const lines = lineSpecs.map(buildLine); const n = lines.length; - // Approximate text dimensions — we cannot getBBox in JSX rendering. - // Width is the widest line; height is n lines at lineHeight em. - // widthof returns units of ~100 per em, so scale matches getBBox roughly. + // The size to use until a layout engine can measure the text: the widest + // line at the font size, and n lines at lineHeight em. widthof counts + // ~100 units per em, so the estimate is in getBBox's units. const fontSize = +this.fontSize || 10; - const w = Math.round(Math.max(0, ...lines.map((l) => (l.width / 100) * fontSize))); - const heightPx = Math.round(n * lineHeight * fontSize); - - // Choose anchor: if explicit, use it; otherwise use preferredAnchor (we - // cannot do fit detection without DOM measurement in the JSX path). - const a = anchor ?? mark.preferredAnchor ?? "bottom"; - - const d = getPath(a, m, r, w, heightPx); - const [tx, ty] = getTextTranslate(a, m, r, w, heightPx); - const yOffset = +getLineOffset(a, n, lineHeight).toFixed(6); + const estimated: TipItemSize = { + x: 0, + width: Math.round(Math.max(0, ...lines.map((l) => (l.width / 100) * fontSize))), + height: Math.round(n * lineHeight * fontSize) + }; - const channel = channelStyleProps(i, values); + // The anchor position within the frame, for the fit below: a faceted tip + // draws inside its cell's coordinates (upstream postrender, tip.js:224). + const fx0 = px(i) + ox; + const fy0 = py(i) + oy; + + // The item is rendered twice: once from the estimate (which is all the + // server, jsdom and a static snapshot ever see) and again from the + // measured size, which TipItem supplies in a layout effect and which + // re-runs upstream's fit. + const render = (measured: TipItemSize | null): ReactElement => { + const {width, height} = measured ?? estimated; + // An explicit anchor always wins. Otherwise the measured box is + // oriented to fit the frame (upstream tip.js:222-243); the estimate is + // not, because a guess about the size cannot justify moving the box, + // and a box that moves as the guess changes would jitter. + const a = + anchor ?? + (measured + ? fitAnchor(fx0, fy0, width, height, m, r, fitDimensions, mark.preferredAnchor) + : mark.preferredAnchor ?? "bottom"); + const d = getPath(a, m, r, width, height); + const [tx, ty] = getTextTranslate(a, m, r, width, height); + const yOffset = +getLineOffset(a, n, lineHeight).toFixed(6); + // After measuring, each line is shifted so the box's text starts at + // its own origin whatever the text anchor is (upstream postrender + // writes -x onto every line, tip.js:238). + const lineX = measured?.x ? -measured.x : 0; + const tspans: ReactNode[] = []; + for (let li = 0; li < n; li++) { + tspans.push(h("tspan", {key: li, x: lineX, dy: `${lineHeight}em`}, ...lines[li].tspans)); + } + const pathEl = h("path", {filter: pathFilter, d}); + const textEl = h( + "text", + { + fill: "currentColor", + fillOpacity: 1, + stroke: "none", + y: `${yOffset}em`, + transform: `translate(${tx},${ty})` + }, + ...tspans + ); + // Note: no channel styles here. The tip's channels choose the content + // of the box, not its appearance (upstream tip.js:136-137), so a + // per-datum fill must not leak onto the box. + return h("g", {transform: `translate(${Math.round(px(i))},${Math.round(py(i))})`, ...direct}, pathEl, textEl); + }; - const pathEl = h("path", {filter: pathFilter, d}); - const tspans: ReactNode[] = []; - for (let li = 0; li < n; li++) { - tspans.push(h("tspan", {key: li, x: 0, dy: `${lineHeight}em`}, ...lines[li].tspans)); - } - const textEl = h( - "text", - { - fill: "currentColor", - fillOpacity: 1, - stroke: "none", - y: `${yOffset}em`, - transform: `translate(${tx},${ty})` - }, - ...tspans - ); - - const inner = h( - "g", - { - key: k, - transform: `translate(${Math.round(px(i))},${Math.round(py(i))})`, - ...direct, - ...channel - }, - pathEl, - textEl - ); - - const titled = withTitleChild(values, i, null); - const node = titled ? h("g", {key: k}, inner, titled) : inner; - return withHrefWrap(values, this.target, i, node); + return h(TipItem, {key: i, render}); }); // Upstream hides a non-empty tip until postrender measures the text and @@ -370,8 +408,10 @@ export class Tip extends (Mark as {new (...args: any[]): Mark}) { // microtask when already connected). Without an animation frame (SSR, // JSDOM) the tip therefore stays hidden. Our layout is computed // synchronously, so when a frame is available we render the revealed - // state directly; otherwise we match upstream's hidden output. - const visibility = index.length > 0 && typeof requestAnimationFrame === "undefined" ? "hidden" : undefined; + // state directly; otherwise we match upstream's hidden output. The count + // is the showing items, as upstream's is (it filters the index before + // deciding), so a tip whose data all format to null is empty, not hidden. + const visibility = shown.length > 0 && typeof requestAnimationFrame === "undefined" ? "hidden" : undefined; return h("g", {...indirect, ...indirectText, ...transform, visibility}, items); } } @@ -390,6 +430,39 @@ export function tip(data?: Data, {x, y, ...options}: TipOptions = {}): Tip { return new Tip(data, {...options, x, y}); } +// Upstream's automatic anchor (tip.js:222-243): prefer the orientation that +// keeps the whole box inside the frame, and fall back to the preferred anchor +// when none does. `x` and `y` are the anchor position in the frame's +// coordinates, which is why a faceted tip adds its cell's offset first. +function fitAnchor( + x: number, + y: number, + w: number, + h: number, + m: number, + r: number, + {width, height}: {width: number; height: number}, + preferredAnchor: any +): any { + const fitLeft = x + w + m + r * 2 < width; + const fitRight = x - w - m - r * 2 > 0; + const fitTop = y + h + m + r * 2 < height; + const fitBottom = y - h - m - r * 2 > 0; + return fitLeft && fitRight + ? fitTop && fitBottom + ? preferredAnchor + : fitBottom + ? "bottom" + : "top" + : fitTop && fitBottom + ? fitLeft + ? "left" + : "right" + : (fitLeft || fitRight) && (fitTop || fitBottom) + ? `${fitBottom ? "bottom" : "top"}-${fitLeft ? "left" : "right"}` + : preferredAnchor; +} + function getLineOffset(anchor: any, length: number, lineHeight: number): number { return /^top(?:-|$)/.test(anchor) ? 0.94 - lineHeight @@ -404,19 +477,19 @@ function getTextTranslate(anchor: any, m: number, r: number, width: number, heig case "middle": return [-width / 2, height / 2]; case "top-left": - return [r, m + r]; + return [r, m / 2 + r]; case "top": return [-width / 2, m / 2 + r]; case "top-right": - return [-width - r, m + r]; + return [-width - r, m / 2 + r]; case "right": return [-m / 2 - width - r, height / 2]; case "bottom-left": - return [r, -m - r]; + return [r, -m / 2 - r]; case "bottom": return [-width / 2, -m / 2 - r]; case "bottom-right": - return [-width - r, -m - r]; + return [-width - r, -m / 2 - r]; case "left": return [r + m / 2, height / 2]; } @@ -429,19 +502,19 @@ function getPath(anchor: any, m: number, r: number, width: number, height: numbe case "middle": return `M${-w / 2},${-h / 2}h${w}v${h}h${-w}z`; case "top-left": - return `M0,0l${m},${m}h${w - m}v${h}h${-w}z`; + return `M0,0l${m / 2},${m / 2}h${w - m / 2}v${h}h${-w}z`; case "top": return `M0,0l${m / 2},${m / 2}h${(w - m) / 2}v${h}h${-w}v${-h}h${(w - m) / 2}z`; case "top-right": - return `M0,0l${-m},${m}h${m - w}v${h}h${w}z`; + return `M0,0l${-m / 2},${m / 2}h${m / 2 - w}v${h}h${w}z`; case "right": return `M0,0l${-m / 2},${-m / 2}v${m / 2 - h / 2}h${-w}v${h}h${w}v${m / 2 - h / 2}z`; case "bottom-left": - return `M0,0l${m},${-m}h${w - m}v${-h}h${-w}z`; + return `M0,0l${m / 2},${-m / 2}h${w - m / 2}v${-h}h${-w}z`; case "bottom": return `M0,0l${m / 2},${-m / 2}h${(w - m) / 2}v${-h}h${-w}v${h}h${(w - m) / 2}z`; case "bottom-right": - return `M0,0l${-m},${-m}h${m - w}v${-h}h${w}z`; + return `M0,0l${-m / 2},${-m / 2}h${m / 2 - w}v${-h}h${w}z`; case "left": return `M0,0l${m / 2},${-m / 2}v${m / 2 - h / 2}h${w}v${h}h${-w}v${m / 2 - h / 2}z`; } @@ -476,8 +549,8 @@ function getSourceChannels(this: any, channels: any, scales: any) { if ((key === "x" || key === "y") && channels.geometry) continue; // ignore x & y on geo const source = getSource(channels, key); if (source) { - // Ignore color channels if the values are all literal colors. - if (source.scale == null && source.defaultScale === "color") continue; + // Ignore (e.g., color) channels if the values are all literal. + if (source.scale == null && source.defaultScale) continue; sources[key] = source; } } diff --git a/src/marks/vector.ts b/src/marks/vector.ts index 60cb07a9..b4dd02a8 100644 --- a/src/marks/vector.ts +++ b/src/marks/vector.ts @@ -8,6 +8,7 @@ import {maybeFrameAnchor, maybeNumberChannel, maybeTuple, keyword} from "../opti import {applyFrameAnchor} from "../style.js"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; /** * The built-in vector shape implementations; one of: @@ -182,14 +183,19 @@ export class Vector extends Mark { const ty = Y ? Y[i] : cy; const ang = A ? A[i] : rotate; const len = L ? L[i] : length; - const shift = anchor === "start" ? 0 : anchor === "end" ? len : len / 2; - const t = `translate(${tx},${ty})${ang ? ` rotate(${ang})` : ""}${shift ? ` translate(0,${shift})` : ""}`; + // The rotate clause turns on the presence of the rotate channel rather + // than on its value, so a zero angle is written (vector.js:106-114 + // upstream); likewise the anchor translate is written for every anchor + // but start, even when the length is zero. + const rotationAttr = A || ang ? ` rotate(${ang})` : ""; + const anchorAttr = anchor === "start" ? "" : ` translate(0,${anchor === "end" ? len : len / 2})`; + const t = `translate(${tx},${ty})${rotationAttr}${anchorAttr}`; const p = path(); shape.draw(p as unknown as CanvasPath, len, r); const channel = channelStyleProps(i, channels); - const titled = withTitleChild(channels, i, null); + const titled = withTitleChild(this, channels, i, null); const pathEl = h("path", {key: k, ...direct, ...channel, transform: t, d: `${p}`}, titled); - return withHrefWrap(channels, this.target, i, pathEl); + return withDatumIndex(withHrefWrap(channels, this.target, i, pathEl), i); }); return h("g", {...indirect, ...transform}, paths); } diff --git a/src/marks/waffle.ts b/src/marks/waffle.ts index c556da95..b1a43ca4 100644 --- a/src/marks/waffle.ts +++ b/src/marks/waffle.ts @@ -12,6 +12,7 @@ import {BarX, BarY} from "./bar.js"; import {createElement as h, type ReactNode} from "react"; import {channelStyleProps, directStyleProps, indirectStyleProps, transformProp} from "../react/styles.js"; import {withHrefWrap, withTitleChild} from "../react/styles-jsx.js"; +import {withDatumIndex} from "../react/perDatum.js"; /** Options for the waffleX and waffleY mark. */ interface WaffleOptions { @@ -95,7 +96,7 @@ function waffleRenderJSX(mark: any, index: any, scales: any, values: any, dimens "defs", null, indexes.map((i) => { - const {"aria-label": _al, href: _hr, ...rectStyles} = channelStyleProps(i, values); + const {"aria-label": _al, ...rectStyles} = channelStyleProps(i, values); return h( "pattern", {key: i, id: `${patternId}-${i}`, width: cx, height: cy, patternUnits: "userSpaceOnUse"}, @@ -124,9 +125,9 @@ function waffleRenderJSX(mark: any, index: any, scales: any, values: any, dimens stroke: mark.stroke == null ? undefined : "none", "aria-label": ariaLabel }, - withTitleChild(values, i, null) + withTitleChild(mark, values, i, null) ); - return withHrefWrap(values, mark.target, i, pathEl); + return withDatumIndex(withHrefWrap(values, mark.target, i, pathEl), i); }); return h("g", {...indirect, ...transform}, defs, paths); } diff --git a/src/options.js b/src/options.js index ec002b3a..75505308 100644 --- a/src/options.js +++ b/src/options.js @@ -213,19 +213,20 @@ export function slice(values, type = Array) { return values instanceof type ? values.slice() : type.from(values); } -// Returns true if any of x, x1, or x2 is not (strictly) undefined. -export function hasX({x, x1, x2}) { - return x !== undefined || x1 !== undefined || x2 !== undefined; +// Returns true if any of x, x1, or x2 is not (strictly) undefined. A missing +// options object is a shorthand for "none of them", as upstream's is. +export function hasX(options) { + return options?.x !== undefined || options?.x1 !== undefined || options?.x2 !== undefined; } // Returns true if any of y, y1, or y2 is not (strictly) undefined. -export function hasY({y, y1, y2}) { - return y !== undefined || y1 !== undefined || y2 !== undefined; +export function hasY(options) { + return options?.y !== undefined || options?.y1 !== undefined || options?.y2 !== undefined; } // Returns true if has x or y, or if interval is not (strictly) undefined. export function hasXY(options) { - return hasX(options) || hasY(options) || options.interval !== undefined; + return hasX(options) || hasY(options) || options?.interval !== undefined; } // Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. diff --git a/src/plot.d.ts b/src/plot.d.ts index 8cec69b0..7ce327e5 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -415,7 +415,11 @@ export interface Plot { */ legend(name: ScaleName, options?: LegendOptions): SVGSVGElement | HTMLElement | undefined; - /** For interactive plots, the current value. */ + /** + * The current value of an interactive plot, set on selection by the React + * entry point (`replot/react`), which also dispatches a bubbling `input` + * event. The static `plot` entry point is synchronous and never assigns it. + */ value?: any; } diff --git a/src/plot.ts b/src/plot.ts index 8e007929..b8e08e9b 100644 --- a/src/plot.ts +++ b/src/plot.ts @@ -4,6 +4,7 @@ import {createContext} from "./context.js"; import {createDimensions} from "./dimensions.js"; import {createFacets, recreateFacets, facetExclude, facetGroups, facetTranslator, facetFilter} from "./facet.js"; import {pointer, pointerX, pointerY} from "./interactions/pointer.js"; +import {buildAutoLegends, renderLegendElement} from "./react/legends/Legend.js"; import {Mark} from "./mark.js"; import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js"; import {frame} from "./marks/frame.js"; @@ -11,12 +12,15 @@ import {tip} from "./marks/tip.js"; import {isColor, isIterable, isNone, isScaleOptions} from "./options.js"; import {dataify, lengthof, map, yes, maybeIntervalTransform} from "./options.js"; import {createProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js"; -import {createScales, createScaleFunctions, autoScaleRange} from "./scales.js"; +import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; import {isPosition, registry as scaleRegistry} from "./scales/index.js"; import {maybeClassName} from "./style.js"; import {initializer} from "./transforms/basic.js"; -import {warn} from "./warnings.js"; +import {consumeWarnings, warn} from "./warnings.js"; +import {renderToStaticMarkup} from "react-dom/server"; +import {buildStaticPlotSvg} from "./react/renderStatic.js"; +import {withWarningIndicator} from "./react/warningIndicator.js"; // Returns the pre-render state needed by both the imperative DOM build path // (used by `plot()` below) and the React JSX path (used by `<Plot>` in @@ -156,10 +160,14 @@ export function computePlot(options: any = {}): any { // Initialize the context. const context = createContext(options); - // The figure/svg is produced by the imperative plot() entry (which renders - // the marks via React). computePlot only provides a holder that plot() - // mutates; the dispatchValue closure below reads through it so it tracks the - // latest figure. + // The element the pointer interaction reports its selection through: the + // <figure> when there is one, the <svg> otherwise, matching upstream. This is + // live plumbing for the React path, not a leftover from the imperative one: + // the React entry point writes its root here on every recompute and + // re-points it when the root changes (src/react/Replot.tsx), which is what + // gives the dispatchValue closure below a target to write to. plot() fills + // the same holder while it builds the figure, but renders statically, so + // nothing ever dispatches through it there; see the plot() JSDoc. const figureHolder: {current: any} = {current: null}; context.figureHolder = figureHolder; context.className = className; @@ -182,7 +190,11 @@ export function computePlot(options: any = {}): any { return {...state, channels: {...state.channels, ...facetState?.channels}}; }; - // Allows e.g. the pointer transform to support viewof. + // Allows e.g. the pointer transform to support viewof. Upstream’s pointer + // closure calls this (src/interactions/pointer.js); on a Replot plot the + // React entry point reaches it as well, through pointerStore’s value sink. + // plot() itself never does: a static render attaches no listener to report + // through in the first place. context.dispatchValue = (value) => { const figure = figureHolder.current; if (figure == null || figure.value === value) return; @@ -273,6 +285,130 @@ export function computePlot(options: any = {}): any { }; } +/** + * Renders a complete plot as a detached SVG element, wrapping it in an HTML + * figure element when the plot has a title, subtitle, caption or legend. The + * returned element is decorated with `scale` and `legend` methods, for sharing + * scales and legends across plots. + * + * The render is synchronous and static, and that is by design rather than a + * gap waiting to be filled. `plot()` has no React root, so no reconciler can + * re-render a mark in response to a pointer event and no commit owns the + * lifetime a pointer listener would need. Upstream Observable Plot attaches + * live pointer listeners here; Replot deliberately does not, and the + * differences a user will notice are: + * + * - nothing listens for pointerenter, pointermove, pointerdown or + * pointerleave on the returned element, so hovering the plot does nothing; + * - the pointer transform renders with no focused point, so the tip and + * crosshair marks that use it are drawn empty: a mark with an inferred tip, + * such as `Plot.lineY(data, {tip: true})`, renders an empty tip group rather + * than a tooltip; + * - `.value` is never assigned and no bubbling `input` event is ever + * dispatched, so the returned element does not work as a viewof, and reading + * it to observe a selection yields undefined. + * + * For interaction, use the React entry point, `replot/react`: <Replot> mounts a + * real React root and drives the pointer interaction from it, reporting the + * selection through its `onValue` prop and, following upstream, through + * `.value` and a bubbling `input` event on the plot’s root element. + */ +export function plot(options: any = {}) { + const computed: any = computePlot(options); + const {className, scales, scaleDescriptors, context} = computed; + const {style, title, subtitle, caption} = options; + const document = context.document; + const figureHolder: {current: any} = context.figureHolder; + + // Render the marks to a detached <svg> via React's renderJSX — no + // d3-selection. The same renderMarksWith/renderJSX code powers <Plot>, so + // the imperative and JSX outputs stay in lockstep. + // + // No ⚠️ indicator on it yet: this call is what RENDERS every mark, so a + // warning raised while a mark renders only reaches the global counter now, + // and the drain below has to come after it. Upstream drains at the very end + // of plot() for the same reason (plot.js:346). + const svgElement = buildStaticPlotSvg(computed, options.className); + + // Auto-legends render via the React legend components (no d3-selection); + // serialize each to a DOM node in the target document, matching the former + // createLegends output. Built before the svg is serialized because building + // them can warn too, and the drain below counts everything this plot raised. + const legends = buildAutoLegends(scaleDescriptors, context, options).map((el) => { + const h = document.createElement("div"); + h.innerHTML = renderToStaticMarkup(el); + return h.firstElementChild; + }); + + // Drain the global warning counter once, after the marks and the legends have + // rendered, so the indicator counts warnings raised while RENDERING and not + // only while computing — and so the warn() dedupe state (lastMessage) is + // reset before the next plot runs. + const warnings = consumeWarnings(); + + // Serialize to markup and reparse into the target document (which may be a + // custom jsdom doc). The indicator is appended last, as the svg's final + // child, where upstream puts it (plot.js:346). + const markup = renderToStaticMarkup(withWarningIndicator(svgElement, computed, warnings)); + const holder = document.createElement("div"); + holder.innerHTML = markup; + const svg: any = holder.firstElementChild; + + // Apply the plot-level style option (string or object), mirroring + // applyInlineStyles on the former imperative path. + if (typeof style === "string") svg.setAttribute("style", style); + else if (style != null) Object.assign(svg.style, style); + + figureHolder.current = svg; + + const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; + if (figured) { + const fig: any = document.createElement("figure"); + fig.className = `${className}-figure`; + fig.style.maxWidth = "initial"; // avoid Observable default style + if (title != null) fig.append(createTitleElement(document, title, "h2")); + if (subtitle != null) fig.append(createTitleElement(document, subtitle, "h3")); + fig.append(...legends, svg); + if (caption != null) fig.append(createFigcaption(document, caption)); + // Upstream carries the pointer’s current selection from the svg onto the + // figure here, so that wrapping a plot does not lose a value it has already + // reported. Nothing on this path ever assigns one, so there is nothing to + // carry; see the plot() JSDoc. + figureHolder.current = fig; + } + + figureHolder.current.scale = exposeScales(scales.scales, context); + // The .legend(key, options) method renders via the React legend components + // (no d3-selection); serialize to a DOM node in the target document. + figureHolder.current.legend = (key: string, legendOptions: any = {}) => { + if (key !== "color" && key !== "opacity" && key !== "symbol") throw new Error(`unknown legend type: ${key}`); + if (!(key in scaleDescriptors)) return; + const el = renderLegendElement(key, legendOptions, scaleDescriptors, context, options); + if (el == null) return; + // Render into the per-call document option if given (e.g. a separate jsdom + // window), else the plot's document. + const targetDoc = legendOptions?.document ?? document; + const h = targetDoc.createElement("div"); + h.innerHTML = renderToStaticMarkup(el); + return h.firstElementChild; + }; + + return figureHolder.current; +} + +function createTitleElement(document, contents, tag) { + if (contents.ownerDocument) return contents; + const e = document.createElement(tag); + e.append(contents); + return e; +} + +function createFigcaption(document, caption) { + const e = document.createElement("figcaption"); + e.append(caption); + return e; +} + function flatMarks(marks) { return marks .flat(Infinity) @@ -293,7 +429,6 @@ class Render extends Mark { super(); this.renderJSX = renderJSX; } - renderJSX() {} } // Note: mutates channel.value to apply the scale transform, if any. @@ -438,6 +573,12 @@ function inferTips(marks) { const t = tip(mark.data, tipOptions); t.facet = mark.facet; // inherit facet settings t.facetAnchor = mark.facetAnchor; // inherit facet settings + // An inferred tip is created here, inside computePlot, so it has no + // registration of its own in the React tree. Recording the mark it was + // inferred from is what lets <Plot> give its slot a key that survives + // marks being added and removed around it; see markKeysOf in + // src/react/Replot.tsx. Inert on the imperative path. + t.tipFor = mark; tips.push(t); } } diff --git a/src/plotDom.ts b/src/plotDom.ts deleted file mode 100644 index a1fe2051..00000000 --- a/src/plotDom.ts +++ /dev/null @@ -1,96 +0,0 @@ -import {buildAutoLegends, renderLegendElement} from "./react/legends/Legend.js"; -import {exposeScales} from "./scales.js"; -import {consumeWarnings} from "./warnings.js"; -import {computePlot} from "./plot.js"; -import {renderToStaticMarkup} from "react-dom/server"; -import {buildStaticPlotSvg} from "./react/renderStatic.js"; - -// The imperative entry point: plot() builds real DOM, so it is the only part -// of the core that needs a renderer capable of serializing React elements. -// -// It lives apart from computePlot (src/plot.ts) deliberately. <Plot> imports -// computePlot, and react-dom declares no `sideEffects: false`, so a bundler -// keeps the react-dom/server import of any module it pulls in — even when the -// binding is unused. Sharing a module with computePlot therefore put the whole -// server renderer in the client bundle of every app that renders a plot -// (issue #140). - -export function plot(options: any = {}) { - const computed: any = computePlot(options); - const {className, scales, scaleDescriptors, context} = computed; - const {style, title, subtitle, caption} = options; - const document = context.document; - const figureHolder: {current: any} = context.figureHolder; - - // Drain warnings emitted during computePlot so the ⚠️ indicator renders and - // the warn() dedupe state is reset (matching the React <Plot> path). - const warnings = consumeWarnings(); - - // Render the marks to a detached <svg> via React's renderJSX — no - // d3-selection. The same renderMarksWith/renderJSX code powers <Plot>, so - // the imperative and JSX outputs stay in lockstep. We serialize to markup - // and reparse into the target document (which may be a custom jsdom doc). - const markup = renderToStaticMarkup(buildStaticPlotSvg(computed, warnings, options.className)); - const holder = document.createElement("div"); - holder.innerHTML = markup; - const svg: any = holder.firstElementChild; - - // Apply the plot-level style option (string or object), mirroring - // applyInlineStyles on the former imperative path. - if (typeof style === "string") svg.setAttribute("style", style); - else if (style != null) Object.assign(svg.style, style); - - figureHolder.current = svg; - - // Wrap the plot in a figure, if needed. Auto-legends render via the React - // legend components (no d3-selection); serialize each to a DOM node in the - // target document, matching the former createLegends output. - const legends = buildAutoLegends(scaleDescriptors, context, options).map((el) => { - const h = document.createElement("div"); - h.innerHTML = renderToStaticMarkup(el); - return h.firstElementChild; - }); - const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; - if (figured) { - const fig: any = document.createElement("figure"); - fig.className = `${className}-figure`; - fig.style.maxWidth = "initial"; // avoid Observable default style - if (title != null) fig.append(createTitleElement(document, title, "h2")); - if (subtitle != null) fig.append(createTitleElement(document, subtitle, "h3")); - fig.append(...legends, svg); - if (caption != null) fig.append(createFigcaption(document, caption)); - if ("value" in svg) (fig.value = svg.value), delete svg.value; - figureHolder.current = fig; - } - - figureHolder.current.scale = exposeScales(scales.scales); - // The .legend(key, options) method renders via the React legend components - // (no d3-selection); serialize to a DOM node in the target document. - figureHolder.current.legend = (key: string, legendOptions: any = {}) => { - if (key !== "color" && key !== "opacity" && key !== "symbol") throw new Error(`unknown legend type: ${key}`); - if (!(key in scaleDescriptors)) return; - const el = renderLegendElement(key, legendOptions, scaleDescriptors, context, options); - if (el == null) return; - // Render into the per-call document option if given (e.g. a separate jsdom - // window), else the plot's document. - const targetDoc = legendOptions?.document ?? document; - const h = targetDoc.createElement("div"); - h.innerHTML = renderToStaticMarkup(el); - return h.firstElementChild; - }; - - return figureHolder.current; -} - -function createTitleElement(document, contents, tag) { - if (contents.ownerDocument) return contents; - const e = document.createElement(tag); - e.append(contents); - return e; -} - -function createFigcaption(document, caption) { - const e = document.createElement("figcaption"); - e.append(caption); - return e; -} diff --git a/src/projection.js b/src/projection.js index 20e01110..9c401947 100644 --- a/src/projection.js +++ b/src/projection.js @@ -38,7 +38,7 @@ export function createProjection( dimensions ) { if (projection == null) return; - if (typeof projection.stream === "function") return projection; // d3 projection + if (typeof projection.stream === "function") return exposeProjection(projection); // projection implementation let options; let domain; let clip = "frame"; @@ -80,34 +80,55 @@ export function createProjection( let tx = marginLeft + insetLeft; let ty = marginTop + insetTop; let transform; + let k = 1; // If a domain is specified, fit the projection to the frame. if (domain != null) { const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain); - const k = Math.min(dx / (x1 - x0), dy / (y1 - y0)); + k = Math.min(dx / (x1 - x0), dy / (y1 - y0)); if (k > 0) { tx -= (k * (x0 + x1) - dx) / 2; ty -= (k * (y0 + y1) - dy) / 2; - transform = geoTransform({ - point(x, y) { - this.stream.point(x * k + tx, y * k + ty); - } - }); + transform = scaleAndTranslate(k, tx, ty); } else { warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`); } } - transform ??= - tx === 0 && ty === 0 - ? identity() - : geoTransform({ - point(x, y) { - this.stream.point(x + tx, y + ty); - } - }); + transform ??= translate(tx, ty); - return {stream: (s) => projection.stream(transform.stream(clip(s)))}; + // The exposed projection answers in pixel coordinates, both to stream and + // point-wise: apply and invert are what plot.scale("projection") hands out, + // and what lets a projection be round-tripped into a second plot. + return { + stream(s) { + return projection.stream(transform.stream(clip(s))); + }, + apply(p) { + let result = null; + this.stream({point: (...p) => (result = p)}).point(...p); + return result; + }, + ...(projection.invert && { + invert(p) { + return projection.invert(transform.invert(p)); + } + }) + }; +} + +// A projection implementation is a d3 projection: callable, and streaming in +// raw coordinates. Wrapping it in the same {stream, apply, invert} shape that +// createProjection produces keeps one interface for plot.scale("projection"), +// as upstream does (projection.js:117-125). +function exposeProjection(projection) { + return typeof projection === "function" + ? { + stream: (s) => projection.stream(s), + apply: (p) => projection(p), + ...(projection.invert && {invert: (p) => projection.invert(p)}) + } + : projection; } function namedProjection(projection) { @@ -195,15 +216,49 @@ function conicProjection(createProjection, kx, ky) { }; } -const identity = constant({stream: (stream) => stream}); +const identity = constant({ + stream: (stream) => stream, + invert: (point) => point +}); -const reflectY = constant( - geoTransform({ +const reflectY = constant({ + ...geoTransform({ point(x, y) { this.stream.point(x, -y); } - }) -); + }), + invert: ([x, y]) => [x, -y] +}); + +// The two transforms that move the projection into the frame: a pure +// translation, and a scale followed by one (when a domain was fitted). Both +// carry the inverse of what they do to a point, because the exposed projection +// has to be able to go back from pixels to coordinates (projection.js:215-245). +function translate(tx, ty) { + return tx === 0 && ty === 0 + ? identity() + : { + ...geoTransform({ + point(x, y) { + this.stream.point(x + tx, y + ty); + } + }), + invert: ([x, y]) => [x - tx, y - ty] + }; +} + +function scaleAndTranslate(k, tx, ty) { + return k === 1 + ? translate(tx, ty) + : { + ...geoTransform({ + point(x, y) { + this.stream.point(x * k + tx, y * k + ty); + } + }), + invert: ([x, y]) => [(x - tx) / k, (y - ty) / k] + }; +} // Applies a point-wise projection to the given paired x and y channels. // Note: mutates values! diff --git a/src/react/FigureLayout.tsx b/src/react/FigureLayout.tsx index 8c2132e6..88cef3e9 100644 --- a/src/react/FigureLayout.tsx +++ b/src/react/FigureLayout.tsx @@ -1,4 +1,5 @@ -import React, {useLayoutEffect, useRef, type ReactNode} from "react"; +import React, {type ReactNode, type RefObject} from "react"; +import {domToJsx, isDomNode} from "./domToJsx.js"; // Document-layout half of <Plot>: wraps the rendered plot in a <figure> with // optional <h2> title, <h3> subtitle, legends, and a <figcaption>, mirroring @@ -7,15 +8,22 @@ import React, {useLayoutEffect, useRef, type ReactNode} from "react"; // (see its `figure` prop); this component owns the structure once that's // decided, keeping document layout separate from scale computation. export interface FigureLayoutProps { - title?: string; - subtitle?: string; - caption?: string; + // A slot's content is usually a string, but may also be a DOM node to be + // rendered from its markup (an HTML title built imperatively); see SlotHeader. + title?: string | Node | null; + subtitle?: string | Node | null; + caption?: string | Node | null; autoLegends: ReactNode; explicitLegends: ReactNode; plotElement: ReactNode; // When the plot rendered as a JSX <svg>, wrap it in div.plot-host to match // the imperative structure; the imperatively-mounted host is already a div. isJsx: boolean; + // <Plot> reports the pointer selection on the plot's root element (upstream's + // context.dispatchValue), which is this <figure> whenever there is one, so it + // needs a handle on it. Its own layout effect reads this ref, which React has + // attached by then: a child's host refs attach before a parent's effects run. + figureRef?: RefObject<HTMLElement | null>; } export function FigureLayout({ @@ -25,10 +33,11 @@ export function FigureLayout({ autoLegends, explicitLegends, plotElement, - isJsx + isJsx, + figureRef }: FigureLayoutProps) { return ( - <figure style={{maxWidth: "initial"}}> + <figure ref={figureRef} style={{maxWidth: "initial"}}> {title != null && <SlotHeader as="h2" content={title} />} {subtitle != null && <SlotHeader as="h3" content={subtitle} />} {autoLegends} @@ -46,24 +55,13 @@ export function FigureLayout({ } // Renders a title/subtitle/caption slot. Content is usually a string but may be -// a DOM Node (e.g. an HTML title built imperatively); in that case it's mounted -// via replaceChildren rather than as a React child. +// a DOM Node (e.g. an HTML title built imperatively) or a React element; either +// way it is rendered as an ordinary React child. A DOM node is converted to +// elements from its markup rather than mounted by hand: React cannot render a +// node as a child, and mounting it in a layout effect (replaceChildren) would +// take the slot's contents out of React's hands — it detaches the text node +// React itself put there, so React keeps updating a node nobody can see, and it +// moves a caller's node out of its own parent. function SlotHeader({as: Tag, content, style: styleProp}: {as: any; content: any; style?: any}) { - const ref = useRef<HTMLElement | null>(null); - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - if (content && typeof (content as any).nodeType === "number") { - el.replaceChildren(content as Node); - } else { - el.replaceChildren(); - if (content != null) el.appendChild(document.createTextNode(String(content))); - } - }, [content]); - const isNode = content && typeof (content as any).nodeType === "number"; - return ( - <Tag ref={ref} style={styleProp}> - {isNode ? null : content} - </Tag> - ); + return <Tag style={styleProp}>{isDomNode(content) ? domToJsx(content) : content}</Tag>; } diff --git a/src/react/Markers.tsx b/src/react/Markers.tsx index 3bbb0416..ae5f2669 100644 --- a/src/react/Markers.tsx +++ b/src/react/Markers.tsx @@ -5,21 +5,23 @@ // anywhere a graphics element is, and resolves by id). // // We deliberately skip a Plot-level <defs> registry here. A registry would let -// identical (markerFn, color) pairs collapse to a single shared <marker>, but -// it requires Plot.tsx to provide a context and a post-render pass. Inlining +// identical (marker, color) pairs collapse to a single shared <marker>, but it +// requires Plot.tsx to provide a context and a post-render pass. Inlining // per-mark is simpler, keeps each mark's renderJSX self-contained, and matches // the imperative renderer closely enough for Phase 1. The registry is a // follow-up optimisation in a Plot.tsx-level switch. // -// Custom marker functions (the ones users pass via options.marker) still -// produce DOM nodes — those callers stay on the imperative path until a later -// unit replaces the MarkerFunction contract. +// Custom marker functions (the ones users pass via options.marker) are drawn +// too: the function receives the stroke color and the render context (as +// upstream's MarkerFunction contract promises) and returns a <marker> element, +// which we convert back into React elements. import type {ReactElement} from "react"; -import type {Marker, MarkerName} from "../marker.js"; +import type {Marker, MarkerFunction, MarkerName} from "../marker.js"; +import {domToJsx, isDomNode} from "./domToJsx.js"; export interface MarkerJSX { - /** Deterministic id derived from the marker shape + color. */ + /** A document-unique id for this def. */ id: string; /** The <marker> element to include as a sibling of the referencing path. */ defJSX: ReactElement; @@ -27,13 +29,45 @@ export interface MarkerJSX { urlRef: string; } -// Resolve the same shorthand as src/marker.js#maybeMarker, but return a -// canonical name we can render as JSX. Custom marker functions and the -// `none`/false/null cases return null so callers can fall back. -function resolveMarkerName(marker: Marker | "none" | boolean | null | undefined): MarkerName | null { +/** + * A marker option as src/marker.js#markers stores it: the marker itself (a + * canonical name, or the user's function) together with the scope that keeps + * this mark's defs distinct from every other mark's. + */ +export interface ResolvedMarker { + marker: MarkerName | MarkerFunction; + scope: object; +} + +// The document a custom marker function builds its element in. Upstream passes +// the render context through (context.document); callers that don't have one to +// hand fall back to the global document, as src/context.js does. The element is +// only ever read (domToJsx) and re-created by React in the plot's own document. +export interface MarkerContext { + document?: Document; + [key: string]: any; +} + +// A marker option reaches us in either shape: marks store the resolved form +// (markers() in src/marker.js), while a raw value — a name, a boolean, or a +// user function — can still be handed in directly. A raw value has no scope of +// its own, so all raw values share one; that keeps their ids stable and deduped +// the way a caller that never went through markers() expects. +const unscoped: object = {}; + +function isResolvedMarker(value: any): value is ResolvedMarker { + return typeof value === "object" && value !== null && "marker" in value && "scope" in value; +} + +// Resolve the same shorthand as src/marker.js#maybeMarker — already-resolved +// markers pass straight through — but keep custom marker functions as functions +// so we can call them at render. The `none`/false/null cases return null so +// callers can fall back. +function resolveMarker(marker: any): MarkerName | MarkerFunction | null { + if (isResolvedMarker(marker)) return marker.marker; if (marker == null || marker === false) return null; if (marker === true) return "circle-fill"; - if (typeof marker === "function") return null; + if (typeof marker === "function") return marker; const k = `${marker}`.toLowerCase(); if (k === "none") return null; if (k === "circle") return "circle-fill"; @@ -52,20 +86,44 @@ function resolveMarkerName(marker: Marker | "none" | boolean | null | undefined) throw new Error(`invalid marker: ${marker}`); } -// FNV-1a hash, base36. The id only needs to be stable per shape + color -// within a single document; the `plot-m-` prefix avoids collisions with -// unrelated DOM ids. -function shortHash(s: string): string { - let h = 0x811c9dc5; - for (let i = 0; i < s.length; ++i) { - h ^= s.charCodeAt(i); - h = Math.imul(h, 0x01000193); - } - return (h >>> 0).toString(36); +// Custom marker functions are identified by function identity: two different +// functions drawing different shapes must not share a def even when they are +// given the same color. Named markers are identified by their canonical name. +const markerFunctionIds = new WeakMap<MarkerFunction, number>(); +let nextMarkerFunctionId = 0; + +function markerIdentity(marker: MarkerName | MarkerFunction): string { + if (typeof marker !== "function") return marker; + let id = markerFunctionIds.get(marker); + if (id === undefined) markerFunctionIds.set(marker, (id = ++nextMarkerFunctionId)); + return `fn${id}`; } -function markerId(name: MarkerName, color: string): string { - return `plot-m-${name}-${shortHash(color)}`; +// The identity of a (marker, color) pair, ignoring the scope: within one mark a +// pair draws one def, and every mark draws its own copy of it. +function markerKey(marker: MarkerName | MarkerFunction, color: string): string { + return `${markerIdentity(marker)}|${color}`; +} + +// Upstream mints a fresh id per <marker> node it inserts (marker.js). The ids +// are document-global and must not repeat: a def resolves currentColor — and +// any CSS variable — against its own ancestors, not against the path that +// references it, so a second mark resolving a colliding id would draw the first +// mark's colors. The scope therefore separates the marks, while the id itself +// is remembered per (scope, marker, color): an interactive mark re-renders on +// every pointer move, and an id that changed with each render would remount its +// defs. The plot-marker- prefix matches upstream, and is what the snapshot +// harness reindexes into a stable sequence. +let nextMarkerId = 0; +const markerIds = new WeakMap<object, Map<string, string>>(); + +function markerIdFor(scope: object, marker: MarkerName | MarkerFunction, color: string): string { + let ids = markerIds.get(scope); + if (ids === undefined) markerIds.set(scope, (ids = new Map())); + const key = markerKey(marker, color); + let id = ids.get(key); + if (id === undefined) ids.set(key, (id = `plot-marker-${++nextMarkerId}`)); + return id; } const tickOrient = {tick: "auto", "tick-x": 90, "tick-y": 0} as const; @@ -81,6 +139,7 @@ function renderMarker(name: MarkerName, color: string, id: string): ReactElement orient={name === "arrow" ? "auto" : "auto-start-reverse"} fill="none" stroke={color} + strokeDasharray="none" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" @@ -106,6 +165,7 @@ function renderMarker(name: MarkerName, color: string, id: string): ReactElement markerHeight={6.67} fill={filled ? color : "var(--plot-background)"} stroke={filled ? "var(--plot-background)" : color} + strokeDasharray="none" strokeWidth={1.5} > <circle r={3} /> @@ -121,21 +181,62 @@ function renderMarker(name: MarkerName, color: string, id: string): ReactElement markerHeight={6} orient={tickOrient[name] as any} stroke={color} + strokeDasharray="none" > <path d="M0,-3v6" /> </marker> ); } +// The context handed to a user-supplied marker function: the caller's render +// context when it has one, else the global document (as src/context.js +// defaults), else null — a document-less render cannot build the element, so +// the marker is dropped rather than drawn wrong. A function needs a document to +// build anything at all, which is why the context is narrowed here. +function markerRenderContext(context?: MarkerContext): (MarkerContext & {document: Document}) | null { + if (context?.document) return context as MarkerContext & {document: Document}; + const document = typeof window !== "undefined" ? window.document : undefined; + return document ? {document} : null; +} + +// Calls a user-supplied marker function and converts the element it builds back +// into React elements. Upstream sets the id on the node the function returns +// (marker.js), so we do the same before reading it. A function that returns +// something other than an element is dropped, like any other invalid channel +// value. +function renderMarkerFunction( + markerFn: MarkerFunction, + color: string, + id: string, + context: MarkerContext & {document: Document} +): ReactElement | null { + const node = markerFn(color, context); + if (!isDomNode(node)) return null; + node.setAttribute("id", id); + return domToJsx(node) as ReactElement; +} + /** * Resolve a marker option + stroke color to JSX for inline use. Returns null - * for `none`/false/null/undefined so callers can short-circuit. Custom marker - * functions (user-supplied) are not yet supported by the JSX path and also - * return null — those plots stay on the imperative renderer. + * for `none`/false/null/undefined so callers can short-circuit. The same + * (marker, color) pair on the same mark always yields the same id, so a caller + * that draws many references to one marker can dedupe on it; two different + * marks never share an id. */ -export function markerToJSX(marker: Marker | "none" | boolean | null | undefined, color: string): MarkerJSX | null { - const name = resolveMarkerName(marker); - if (!name) return null; - const id = markerId(name, color); - return {id, defJSX: renderMarker(name, color, id), urlRef: `url(#${id})`}; +export function markerToJSX( + marker: Marker | ResolvedMarker | "none" | boolean | null | undefined, + color: string, + context?: MarkerContext +): MarkerJSX | null { + const resolved = resolveMarker(marker); + if (!resolved) return null; + const id = markerIdFor(isResolvedMarker(marker) ? marker.scope : unscoped, resolved, color); + if (typeof resolved === "function") { + const renderContext = markerRenderContext(context); + if (!renderContext) return null; + const defJSX = renderMarkerFunction(resolved, color, id, renderContext); + if (!defJSX) return null; + return {id, defJSX, urlRef: `url(#${id})`}; + } + return {id, defJSX: renderMarker(resolved, color, id), urlRef: `url(#${id})`}; } diff --git a/src/react/PlotContext.ts b/src/react/PlotContext.ts index ba822126..6bc81064 100644 --- a/src/react/PlotContext.ts +++ b/src/react/PlotContext.ts @@ -19,7 +19,33 @@ export interface Dimensions { // Slim Plot context: just a registry. Marks are registered as opaque // factories that build imperative Mark instances; <Plot> calls the imperative // `plot()` to do all rendering. +// +// The value has two halves with different lifetimes: +// +// - the registration API, whose identities are fixed for the life of the +// <Plot>. A mark or scale calls it from its own layout effect, so a +// registration is taken once the plot it belongs to has committed, and a +// registry change is a state change on <Plot> rather than a write during +// a render React may throw away (#148). +// - the resolved values from the last computePlot() pass, which change +// whenever the plot recomputes. <Plot> keeps its context value IDENTICAL +// across renders and publishes these through accessors over a ref, so a +// consumer re-renders when the plot shows it something new rather than +// every time the plot re-renders for any reason at all — which is also +// what stops StrictMode's simulated unmount from being survivable only by +// accident (#148). They are accessors rather than their own context +// because <Legend> reads them off this same value; a second context is +// the shape to move to if legends ever need to subscribe to them. export interface PlotContextValue { + // True when this plot is being rendered where no effect will ever run — a + // server render (renderToString/renderToStaticMarkup). Every registration + // below is normally taken from a layout effect, which a server render does + // not run, so on a server the components taking them hand them over while + // they render instead, and <Replot> computes the plot in a render of its + // own, after theirs (see useMark and ServerPlot for why that ordering is + // sound rather than lucky). Set by <Replot> from the environment; every + // browser render, including a hydrating one, leaves it false. + readonly serverRender?: boolean; // Per-mark event handlers ride alongside the factory; their identities are // excluded from the stamp (like all functions), so a handler-identity // change refreshes the registration without a rebuild. @@ -45,12 +71,13 @@ export interface PlotContextValue { unregisterLegend?: (id: string) => void; // Resolved scale descriptors and render context from the parent Plot's // computePlot() pass. Populated after the first render; descendants like - // <Legend scale="color"> read named scales out of this map. - scaleDescriptors?: Record<string, any>; - context?: any; + // <Legend scale="color"> read named scales out of this map. Read them during + // render; they are not a subscription. + readonly scaleDescriptors?: Record<string, any>; + readonly context?: any; // Options passed to the parent <Plot> (used as defaults when resolving // scale-name legends, mirroring `exposeLegends(..., defaults)`). - plotOptions?: Record<string, any>; + readonly plotOptions?: Record<string, any>; } export const PlotContext = createContext<PlotContextValue | null>(null); diff --git a/src/react/Replot.tsx b/src/react/Replot.tsx index e32f5c14..67002fa2 100644 --- a/src/react/Replot.tsx +++ b/src/react/Replot.tsx @@ -1,85 +1,145 @@ import { - Children, cloneElement, Fragment, isValidElement, useContext, - useEffect, useLayoutEffect, useRef, useState, + useSyncExternalStore, type ReactNode, type ReactElement } from "react"; import {computePlot} from "../plot.js"; -import type {MarkOptions} from "../mark.js"; +import type {ChannelValue} from "../channel.js"; +import type {ColorLegendOptions, OpacityLegendOptions, SymbolLegendOptions} from "../legends.js"; +import type {Data, MarkOptions} from "../mark.js"; +import type {ProjectionFactory, ProjectionImplementation, ProjectionName, ProjectionOptions} from "../projection.js"; +import type {ScaleDefaults, ScaleOptions} from "../scales.js"; +import {exposeScales} from "../scales.js"; import {consumeWarnings} from "../warnings.js"; -import {PlotContext} from "./PlotContext.js"; +import {PlotContext, type PlotContextValue} from "./PlotContext.js"; import {markEventNames, useMark, type MarkEventHandlers, type MarkFactory} from "./useMark.js"; import {PointerRoot, PointerContext} from "./interactions/PointerContext.js"; +import {computeAnchors, pointerKOf} from "./interactions/pointerHitTest.js"; +import {createPointerStore, type PointerStore} from "./interactions/pointerStore.js"; import {buildAutoLegends, LegendDisplay, resolveLegendElement} from "./legends/Legend.js"; import {createClipRegistry, registerClips, type ClipRegistry} from "./clip.js"; -import {domToJsx, isDomNode} from "./domToJsx.js"; +import {domToJsx, isDomNode, parseStyleString} from "./domToJsx.js"; +import {datumIndexOf} from "./perDatum.js"; import {hasRenderTransform, renderTransformJSX} from "./renderTransform.js"; import {FigureLayout} from "./FigureLayout.js"; +import {warningIndicatorElement} from "./warningIndicator.js"; // <Plot> renders a JSX <svg> populated entirely by each mark's renderJSX(); // there is no imperative (d3-selection) render fallback. // -// Children's <Mark> components register their factories into marksRef during -// render; a useLayoutEffect then runs computePlot and stores the result in -// state. The component re-renders with <PlotSvg> as a normal React child so -// the JSX tree participates in the parent root's act() scope under JSDOM. -export interface ReplotProps { +// Children's <Mark> components register their factories into marksRef from +// their own layout effects; a useLayoutEffect here then runs computePlot and +// stores the result in state. The component re-renders with <PlotSvg> as a +// normal React child so the JSX tree participates in the parent root's act() +// scope under JSDOM. +// +// Effects rather than render because a render can be discarded: StrictMode +// renders (and mounts) everything twice, and a concurrent render can be thrown +// away before it commits, so a registration written during render can be +// recorded for a tree that never appears — and, since the cleanup that follows +// a simulated unmount has no re-render to restore it, lost entirely (#148). +// The cost of doing it in effects is that a registry's Map insertion order +// freezes at MOUNT order, while the plot must draw in CHILDREN order, so each +// effect also records this commit's order and the layout effect below +// reconciles the two (#145). Three registries share that machinery: marks, +// scales, legends. +// +// The plot's options are the imperative plot()'s options, so a plot can move +// between the two entry points without its options changing type: a scale prop +// is a ScaleOptions, a projection prop is what plot() takes for projection, and +// the scale defaults (round, nice, clamp, zero, align, padding, axis, grid, +// legend, label, inset…) come from ScaleDefaults, which PlotOptions itself +// extends. Two things differ: +// +// - `marks` is absent rather than optional. This entry point takes its marks +// as CHILDREN, and computePlot is handed the constructed children LAST (see +// the compute effect), so a marks prop would be overwritten and ignored. +// Leaving it out makes a silently ignored prop a compile error instead. +// - `figure` widens. Upstream's boolean asks only whether to wrap; this entry +// point also decides WHEN, because a plot with auto-legends or a title has a +// figure to show only once those have resolved. +// +// There is deliberately no [key: string]: any index signature. There used to +// be, and it left every prop above untyped: a misspelled prop, a prop given the +// wrong type, and a prop belonging to the imperative API only all type-checked +// and were then ignored at runtime. +// +// The types below are declared here rather than by importing PlotOptions, which +// checked code cannot reach: PlotOptions lives in src/plot.d.ts, and the +// implementation file src/plot.ts has the same basename, so TypeScript resolves +// "./plot.js" to the implementation and drops the declaration — its resolver +// says so out loud ("plot.d.ts has a '.d.ts' extension - stripping it" → "plot.ts +// exists - use it as a name resolution result"). Importing PlotOptions from +// checked code therefore fails; src/transforms/basic.d.ts has been importing it +// — and failing — since the rename, invisibly, because errors inside declaration +// files are hidden by skipLibCheck. Until that is sorted out this interface +// mirrors PlotOptions property by property, and PlotFacetOptions is assembled +// from the parts of it that ARE reachable. +// +// The projection options, with the `type` name also accepted as a plain string. +// ProjectionOptions declares `type?: ProjectionName | ProjectionFactory`, but a +// projection name computed at runtime is a string, which no member of that +// union accepts: test/plots/projection-domain-ratio.tsx passes one through its +// own `type: string | (() => any)` helper. `string & Record<never, never>` is a +// string that leaves the known names suggested by editors; a name that is not +// known fails where it is resolved, as it does today ("unknown projection: …"). +type ReplotProjectionOptions = Omit<ProjectionOptions, "type"> & { + type?: ProjectionName | ProjectionFactory | (string & Record<never, never>); +}; + +export interface ReplotProps extends ScaleDefaults { children?: ReactNode; + // Dimensions and margins, as in PlotOptions. width?: number; height?: number; + aspectRatio?: number | boolean | null; margin?: number; marginTop?: number; marginRight?: number; marginBottom?: number; marginLeft?: number; - aspectRatio?: number | boolean; - x?: any; - y?: any; - color?: any; - opacity?: any; - r?: any; - symbol?: any; - length?: any; - fx?: any; - fy?: any; - inset?: number; - insetTop?: number; - insetRight?: number; - insetBottom?: number; - insetLeft?: number; - round?: boolean; - nice?: boolean | number; - clamp?: boolean; - zero?: boolean; - align?: number; - padding?: number; - label?: string; - projection?: any; - facet?: any; + // The scale props, typed as PlotOptions types them: ScaleOptions, with the + // legend options of the scales that have a legend. + x?: ScaleOptions; + y?: ScaleOptions; + r?: ScaleOptions; + color?: ScaleOptions & ColorLegendOptions; + opacity?: ScaleOptions & OpacityLegendOptions; + symbol?: ScaleOptions & SymbolLegendOptions; + length?: ScaleOptions; + fx?: ScaleOptions; + fy?: ScaleOptions; + projection?: ReplotProjectionOptions | ProjectionName | ProjectionFactory | ProjectionImplementation | null; + // PlotFacetOptions: its margins are the mark margins, its grid and label are + // the scale defaults, and its data/x/y are the top-level facet channels. + facet?: Pick<MarkOptions, "margin" | "marginTop" | "marginRight" | "marginBottom" | "marginLeft"> & + Pick<ScaleDefaults, "grid" | "label"> & {data?: Data; x?: ChannelValue; y?: ChannelValue}; + // The remaining top-level options, as in PlotOptions. The title, subtitle and + // caption may be a DOM node as well as a string — the figure layout renders + // one from its markup rather than mounting it. className?: string; - style?: any; - ariaLabel?: string; - ariaDescription?: string; - axis?: any; - grid?: any; + style?: string | Partial<CSSStyleDeclaration> | null; + ariaLabel?: string | null; + ariaDescription?: string | null; clip?: MarkOptions["clip"]; - title?: string; - subtitle?: string; - caption?: string; - // Controls the <figure> wrapper. "auto" (default) wraps only when there's a - // title/subtitle/caption/legend to show; "always" forces it; "never" - // suppresses it even when those are present. Booleans are accepted as - // legacy aliases for "always"/"never". + title?: string | Node | null; + subtitle?: string | Node | null; + caption?: string | Node | null; + document?: Document; + // "auto" (default) wraps only when there's a title/subtitle/caption/legend to + // show; "always" forces it; "never" suppresses it even when those are + // present. Booleans are accepted as legacy aliases for "always"/"never". figure?: boolean | "auto" | "always" | "never"; + // Reports the pointer selection. Not a plot option: it is how this entry + // point exposes the dispatchValue upstream reports on the context. onValue?: (value: any) => void; - [key: string]: any; } interface Registration { @@ -118,11 +178,58 @@ type Mode = kind: "jsx"; computed: any; onSvgRef: (svg: SVGSVGElement | null) => void; - onInput: ((e: Event) => void) | null; pointerEnabled: boolean; - warnings: number; }; +// One computePlot pass: the plot it produced, the map from that plot's mark +// instances back to the registrations they came from, and the options it +// actually received. Kept together because the three are produced together, by +// runComputePass below, and are consumed together by its two callers — the +// compute effect, for a browser commit, and the server render of a plot. +interface ComputePass { + computed: any; + registrationByMark: Map<any, Registration>; + effectiveOptions: Record<string, any>; +} + +// The legends a pass shows, and whether a <figure> wraps it — everything the +// plot's element tree needs beyond the pass itself. Built once per pass by +// plotExtras, and read by both entries: the browser render, which also depends +// on the figure flag, and the server render. +interface PlotExtras { + autoLegends: ReactElement[]; + explicitLegends: ReactElement[]; + wantsFigure: boolean; +} + +// Whether a DOM exists in this process at all. Asked PER RENDER, never once at +// module scope: the test suite installs a jsdom document per test, after every +// module is already loaded, so a flag captured at import time would tell a +// jsdom test it is on a server (and tell a real server it has a document). +function hasDocument(): boolean { + return typeof document !== "undefined"; +} + +// The compute half of a server render. A parent's render body runs before its +// children's, so <Replot> cannot compute a server-rendered plot in its own +// render: the registrations it computes from are taken by descendants that have +// not rendered yet (useMark registers while rendering there). This component is +// rendered after those descendants, so by the time its body runs every mark, +// scale and legend this render declares has registered, and it calls back into +// <Replot> to compute the pass and build the plot elements from it. +// +// That ordering is sound for a reason of its own rather than inherited from the +// client path: a server pass renders each component exactly once, +// depth-first, in the order the tree declares them — there is no bailout that +// could skip a descendant, no second render that could deliver a registration +// late, and none of StrictMode's double rendering (that is a client-side, +// commit-phase behaviour) — so the registry holds exactly the components this +// tree declared, in the order tree order puts them, which is the contract +// renderMarksWith relies on. +function ServerPlot({render}: {render: () => ReactNode}) { + return <>{render()}</>; +} + export function Replot({ children, title, @@ -137,67 +244,159 @@ export function Replot({ const marksRef = useRef<Map<string, Registration>>(new Map()); const scalesRef = useRef<Map<string, ScaleRegistration>>(new Map()); const registrationByMarkRef = useRef<Map<any, Registration>>(new Map()); - const dirtyRef = useRef(false); + // Set once the first compute has run. After that, a registration change wakes + // <Plot> with a version bump; before it, the mount commit computes anyway, so + // a bump there would compute (and emit warnings) twice. const computedRef = useRef(false); const legendsRef = useRef<Map<string, LegendRegistration>>(new Map()); + // Registration order for this commit, one list per registry, reconciled in + // the layout effect below. See recordOrder and adoptOrder for why the order + // has to be recorded per commit rather than read off the Map. const pendingLegendOrderRef = useRef<string[]>([]); - const [version, setVersion] = useState(0); + const pendingMarkOrderRef = useRef<string[]>([]); + const pendingScaleOrderRef = useRef<string[]>([]); + // Registrations whose component unmounted this commit. They are held until + // the reconciliation below, rather than deleted on the spot, so that a + // StrictMode simulated unmount — which destroys and recreates every effect + // (create, destroy, create) — is told apart from a real unmount. See + // sweepRetired. + const retiredLegendRef = useRef<Set<string>>(new Set()); + const retiredMarkRef = useRef<Set<string>>(new Set()); + const retiredScaleRef = useRef<Set<string>>(new Set()); + // Re-render fodder, deliberately unread: a registration that changes a stamp + // bumps one of these so <Plot> renders again at all. The inputs key in the + // compute effect, not the counter, decides whether that render recomputes. + const [, setVersion] = useState(0); const [, setLegendsVersion] = useState(0); const [resolved, setResolved] = useState<ResolvedScales | null>(null); + // The same resolved values, readable from a closure that cannot see state. + // Written in the compute effect immediately before setResolved, so the two + // always name the same pass. + const resolvedRef = useRef<ResolvedScales | null>(null); const [mode, setMode] = useState<Mode>({kind: "empty"}); + // computePlot's last failure. Setting it re-renders, and the render throws; + // see the throw below. + const [error, setError] = useState<unknown>(null); + // A render with no DOM behind it: no effect will run, no ref will be called, + // and no state will ever be readable. It is asked per render (see + // hasDocument) and published on the context, where the components that + // register read it and hand their registrations over during the render + // instead of from a layout effect (useMark, Scale, Legend). + const serverRender = !hasDocument(); - // Updates the published scale descriptors only when the set of scale keys - // changes; identity-only changes mutate in place to avoid re-rendering - // every <Legend scale="…"> on each plot update. - const publishResolved = (next: ResolvedScales) => { - if (resolved && sameScaleKeys(resolved.scaleDescriptors, next.scaleDescriptors)) { - resolved.scaleDescriptors = next.scaleDescriptors; - resolved.context = next.context; - resolved.plotOptions = next.plotOptions; - } else { - setResolved(next); - } - }; + // The pointer selection store, created here rather than in <PointerRoot> + // because two of the three things it needs are only available at this level. + // It has to outlive the <svg> — a plot whose last pointer consumer is removed + // unmounts <PointerRoot> with the slot that was showing a datum, and the + // clearing value still has to be reported — and its settle() has to run after + // the plot's ROOT ELEMENT has been attached, which is this component's own + // layout effect and nothing lower. <PointerRoot> keeps the rest: the store is + // handed to it, and it wires the <svg>'s listeners into it and disposes it. + const [pointerStore] = useState(createPointerStore); - const registerMark = (id: string, stamp: string, factory: MarkFactory, handlers?: MarkEventHandlers) => { - const prev = marksRef.current.get(id); - if (!prev || prev.stamp !== stamp) { - marksRef.current.set(id, {stamp, factory, handlers}); - dirtyRef.current = true; - } else { - prev.factory = factory; - prev.handlers = handlers; - } - }; + // The options this render was given, for the plotOptions accessor below, which + // has no other way to see them. Assigned during render, like onValueRef: it is + // a read-only mirror of a prop, not a side effect. + const optionsRef = useRef(options); + optionsRef.current = options; - // Removal is unmount-driven (useMark's cleanup), NOT inferred from who - // re-registered this render: when <Plot> re-renders from its own state, - // unchanged children bail out of rendering and never call registerMark, so - // any presence-based sweep would wrongly drop live marks. Stable identity - // (it closes over refs and setVersion only) so useMark's unmount cleanup - // doesn't re-fire on every render. - const unregisterMark = useRef((id: string) => { - if (marksRef.current.delete(id)) setVersion((v) => v + 1); - }).current; - - // Scale components register like marks: during render, stamped by prop - // values so a prop change dirties the plot, with same-stamp re-registration - // refreshing the stored config in place (function identities are excluded - // from the stamp). - const registerScale = (id: string, stamp: string, scaleName: string, config: Record<string, any>) => { - const prev = scalesRef.current.get(id); - if (!prev || prev.stamp !== stamp) { - scalesRef.current.set(id, {stamp, scaleName, config}); - dirtyRef.current = true; - } else { - prev.config = config; + // The context value, built ONCE and never rebuilt. Everything in it closes + // over refs and state setters and nothing else, so the first render's copies + // stay correct for the life of the plot — which is what lets the value keep + // its identity across renders. That identity is load-bearing twice over: a + // consumer re-renders when the plot resolves something new for it rather than + // on every render of the plot (#148), and StrictMode's simulated unmount is + // survivable because marks and scales re-register from their own effects, + // instead of depending on a changed context re-rendering them into doing it. + const [ctx] = useState<PlotContextValue>(() => ({ + // Fixed for the life of this plot, like the rest of the value: the + // environment a render happens in does not change under a mounted plot. + serverRender, + registerMark: (id, stamp, factory, handlers) => { + recordOrder(pendingMarkOrderRef, id); + const prev = marksRef.current.get(id); + // A changed stamp is the plot's cue to rebuild: the registration is + // replaced and <Plot> is woken. Same-stamp re-registration happens on + // every commit, so it only refreshes the factory and handlers in place + // (a handler identity change must not rebuild the plot). The wake is + // skipped before the first compute, which this same commit performs + // anyway; bumping would compute — and emit warnings — twice on mount. + if (!prev || prev.stamp !== stamp) { + marksRef.current.set(id, {stamp, factory, handlers}); + if (computedRef.current) setVersion((v) => v + 1); + } else { + prev.factory = factory; + prev.handlers = handlers; + } + }, + // Removal is unmount-driven (useMark's cleanup), NOT inferred from who + // re-registered this commit: when <Plot> re-renders from its own state, + // unchanged children bail out of rendering and never call registerMark, so + // any presence-based sweep would wrongly drop live marks. Retirement is + // deferred to the reconciliation below, which can tell a real unmount from + // the destroy half of StrictMode's simulated one; see sweepRetired. Waking + // <Plot> is what makes that reconciliation happen at all when nothing else + // re-rendered this component. + unregisterMark: (id) => { + if (retireRegistration(retiredMarkRef, marksRef, id)) setVersion((v) => v + 1); + }, + // Scale components register like marks: from their own layout effect, + // stamped by prop values so a prop change wakes the plot, with same-stamp + // re-registration refreshing the stored config in place (function + // identities are excluded from the stamp). + registerScale: (id, stamp, scaleName, config) => { + recordOrder(pendingScaleOrderRef, id); + const prev = scalesRef.current.get(id); + if (!prev || prev.stamp !== stamp) { + scalesRef.current.set(id, {stamp, scaleName, config}); + if (computedRef.current) setVersion((v) => v + 1); + } else { + prev.config = config; + } + }, + // Unmount-driven removal, mirroring unregisterMark. + unregisterScale: (id) => { + if (retireRegistration(retiredScaleRef, scalesRef, id)) setVersion((v) => v + 1); + }, + // Legends register from their own layout effect, like marks and scales do + // now. Legend registrations do NOT feed computePlot, so they bump their own + // version rather than the plot's. A changed stamp stores a fresh + // registration; same-stamp re-registration only refreshes the stored props + // (function identities are excluded from the stamp). Each call also records + // this commit's registration order, which the layout effect below + // reconciles against the registry: Map insertion order alone would freeze + // legends at mount order, because React moves keyed instances without + // remounting them. Last-wins dedupe keeps the order correct under + // StrictMode double-invocation and heals stale entries from partial commits + // that <Plot>'s effect never observed. + registerLegend: (id, stamp, props) => { + recordOrder(pendingLegendOrderRef, id); + const prev = legendsRef.current.get(id); + if (!prev || prev.stamp !== stamp) { + legendsRef.current.set(id, {stamp, props}); + setLegendsVersion((v) => v + 1); + } else { + prev.props = props; + } + }, + // Unmount-driven removal, mirroring unregisterMark. + unregisterLegend: (id) => { + if (retireRegistration(retiredLegendRef, legendsRef, id)) setLegendsVersion((v) => v + 1); + }, + // Accessors rather than fields: the values are replaced by the compute + // effect and read on demand, so the value object around them can stay + // identical. Read during render — they are a snapshot of the last pass, not + // a subscription. + get scaleDescriptors() { + return resolvedRef.current?.scaleDescriptors; + }, + get context() { + return resolvedRef.current?.context; + }, + get plotOptions() { + return resolvedRef.current?.plotOptions ?? optionsRef.current; } - }; - - // Unmount-driven removal with stable identity, mirroring unregisterMark. - const unregisterScale = useRef((id: string) => { - if (scalesRef.current.delete(id)) setVersion((v) => v + 1); - }).current; + })); // Reads the CURRENT handlers for a built mark instance. Stable identity so // passing it down doesn't churn props; event closures call it at dispatch @@ -206,106 +405,76 @@ export function Replot({ (mark: any): MarkEventHandlers | undefined => registrationByMarkRef.current.get(mark)?.handlers ).current; - // Legends register from a layout effect (unlike marks, which register - // during render): StrictMode's simulated unmount/remount re-registers after - // its cleanup unregistered, and effect-phase registration may set state - // directly — so a legend mounted later by a wrapper component (without - // <Plot> itself re-rendering) still becomes visible. Legend registrations - // don't feed computePlot, so they bump their own version. A changed stamp - // stores a fresh registration; same-stamp re-registration only refreshes - // the stored props (function identities are excluded from the stamp). Each - // call also records this commit's registration order, which the layout - // effect below reconciles against the registry: Map insertion order alone - // would freeze legends at mount order, because React moves keyed instances - // without remounting them. Last-wins dedupe keeps the order correct under - // StrictMode double-invocation and heals stale entries from partial - // commits that <Plot>'s effect never observed. - const registerLegend = (id: string, stamp: string, props: Record<string, any>) => { - const pending = pendingLegendOrderRef.current; - const at = pending.indexOf(id); - if (at !== -1) pending.splice(at, 1); - pending.push(id); - const prev = legendsRef.current.get(id); - if (!prev || prev.stamp !== stamp) { - legendsRef.current.set(id, {stamp, props}); - setLegendsVersion((v) => v + 1); - } else { - prev.props = props; - } - }; - - // Unmount-driven removal with stable identity, mirroring unregisterMark. - const unregisterLegend = useRef((id: string) => { - if (legendsRef.current.delete(id)) setLegendsVersion((v) => v + 1); - }).current; - + // THE REGISTRATION RECONCILIATION, and the first of this component's two + // layout effects. No dependency array: a registry can change in any commit of + // this component, and the adoption is a no-op in the ones where nothing did. useLayoutEffect(() => { - // Child effects run before this one, so the recorded legend registration - // order is complete for this commit. When every registered legend - // re-registered (a full re-render of the children), adopt that order — - // this is what makes reordering keyed <Legend> children reorder the - // output. Partial commits (a lone legend re-rendered or mounted by its - // wrapper) can't reveal sibling order, so they keep the existing order, - // appending new registrations. - const pendingOrder = pendingLegendOrderRef.current; - if (pendingOrder.length > 0) { - pendingLegendOrderRef.current = []; - const registry = legendsRef.current; - if (pendingOrder.length === registry.size && pendingOrder.every((id) => registry.has(id))) { - const ordered = [...registry.keys()]; - if (pendingOrder.some((id, i) => id !== ordered[i])) { - legendsRef.current = new Map(pendingOrder.map((id) => [id, registry.get(id)!])); - setLegendsVersion((v) => v + 1); - } - } - } - if (dirtyRef.current) { - dirtyRef.current = false; - // The compute effect below re-runs when version changes, picking up the - // new registrations. Skip the bump before the first compute: it runs in - // this same commit anyway, and bumping would compute (and emit - // warnings) twice on mount. - if (computedRef.current) setVersion((v) => v + 1); - } + // Child effects run before this one, so both the recorded registration + // order and the retirements are complete for this commit. Sweep first: + // deleting can remove the entry an adoption would have ordered, and the + // sweep is also what reports the change. A legend registry change is not + // otherwise visible to this component, hence its own version; marks and + // scales need none — the inputs key below is taken over the registries as + // they are once this effect has run, so a deletion or a reorder differs + // from the last key and the compute runs in this same commit. + if (sweepRetired(retiredLegendRef, pendingLegendOrderRef, legendsRef)) setLegendsVersion((v) => v + 1); + sweepRetired(retiredMarkRef, pendingMarkOrderRef, marksRef); + sweepRetired(retiredScaleRef, pendingScaleOrderRef, scalesRef); + // When every registered entry re-registered (a full re-render of the + // children), adopt that order: this is what makes reordering keyed + // <Legend> children reorder the output, and what makes the mark list + // follow the children rather than the order they happened to mount in + // (#145 — a rule written before the dots but mounted a commit later used to + // draw on top of them). Partial commits (a lone mark re-rendered or + // mounted by its wrapper) can't reveal sibling order, so they keep the + // existing order, appending new registrations. + if (adoptOrder(pendingLegendOrderRef, legendsRef)) setLegendsVersion((v) => v + 1); + adoptOrder(pendingMarkOrderRef, marksRef); + adoptOrder(pendingScaleOrderRef, scalesRef); }); - const optionsKey = stableKey(options); - const lastInputsRef = useRef<string | null>(null); - const onValueRef = useRef(onValue); - onValueRef.current = onValue; - - useLayoutEffect(() => { - computedRef.current = true; - // Registration ids change when children remount without any real change — - // e.g. when the tree gains a <figure> once auto-legends resolve — bumping - // version while every stamp stays the same. Recomputing then would be - // wasted work and would re-emit computePlot warnings, so skip when the - // effective inputs are unchanged. (onValue identity is excluded, like - // functions in mark stamps; the onInput closure reads it from a ref.) - const inputsKey = [ - ...[...marksRef.current.values()].map((r) => r.stamp), - ...[...scalesRef.current.values()].map((r) => r.stamp), - optionsKey, - String(classNameProp), - stableKey({style}), - String(!!onValue) - ].join("\u0000"); - if (inputsKey === lastInputsRef.current) return; - lastInputsRef.current = inputsKey; + // ONE COMPUTE PASS, in a function rather than in the effect below, because + // two renders need one and only one of them commits. The effect runs it for + // a browser commit, after this commit's children have registered; <ServerPlot> + // runs it during a server render, after the registration subtree has rendered + // — the only moment a server render has every registration, since there they + // arrive while the marks render (see useMark). It reads the registries as + // they stand when it is called and publishes nothing: the caller decides + // where the result goes, which is what keeps the state writes on the effect + // side, where a server render cannot reach them. + const runComputePass = (): ComputePass => { const flat: any[] = []; // Maps each built mark instance to its registration so <MarkSlot> can read // the registration's CURRENT handlers at render and event time (handler // identity changes refresh the registration without a recompute, so the // instances — and this map — stay valid). const registrationByMark = new Map<any, Registration>(); - for (const registration of marksRef.current.values()) { + const markKeys = new Map<any, string>(); + for (const [id, registration] of marksRef.current) { const m = registration.factory(); - for (const one of Array.isArray(m) ? m : [m]) { + const built = Array.isArray(m) ? m : [m]; + for (const one of built) { flat.push(one); if (registration.handlers) registrationByMark.set(one, registration); } + // useMark's id comes from useId, which survives every rebuild of the mark + // objects a registration produces, so the key does not move when a mark's + // data or options change. The suffix is the leaf's position within this + // one registration's own output — a crosshair yields a rule and a text — + // which is fixed by the factory, not by what the rest of the plot + // contains. What useId does NOT give is component identity: React assigns + // it at MOUNT from a global counter and keeps it in the fiber's hook + // state, so it names a POSITION that a reused fiber inherits. Removing + // the first of two unkeyed sibling marks hands the survivor the first + // one's id, and with it this key. No key scheme can defend against that, + // because React genuinely considers the reused fiber to be the same + // component instance; what defends the pointer selection is that the + // store re-resolves it against the mark the record now holds (see settle + // in pointerStore.ts), which is a search the pointer is nowhere near. + (built as any[]).flat(Infinity).forEach((one: any, k: number) => { + if (one != null) markKeys.set(one, `m${id}#${k}`); + }); } - registrationByMarkRef.current = registrationByMark; // Merge scale-component registrations (<ScaleY>, <ScaleColor>, …) into // the plot-level options. Multiple components for the same scale merge in // registration order (later wins per key). Precedence on conflict: an @@ -325,17 +494,135 @@ export function Replot({ explicit === undefined ? config : isPlainOptionsObject(explicit) ? {...config, ...explicit} : explicit; } } - let computed: any; + // Always run computePlot, even with zero marks: declared position scales + // (e.g. x={{type: "log", …}}) infer implicit axis marks, so a markless + // <Plot> can still render axes — matching the imperative plot(). + const computed = computePlot({...effectiveOptions, marks: flat, style}); + // Slot keys are React fiber identity, and a pointer slot's fiber carries the + // registration record holding its selection. Keying by position in + // computed.marks would hand that record to whatever pointer consumer + // shifted into the slot whenever a mark was added or removed anywhere in + // the plot, so the key names the registration the mark came from instead — + // and the mark object cannot supply it, because useMark rebuilds its + // instances on every stamp change. markKeysOf completes the map with the + // marks computePlot creates for itself (the tip a tip-requesting mark + // produces). This narrows the problem; it does not close it (see the useId + // note above), which is why the store re-resolves every record at the + // pointer once a commit has settled. + computed.markKeys = markKeysOf(computed.marks, markKeys); + return {computed, registrationByMark, effectiveOptions}; + }; + + // A pass in the form the render needs it: the mode naming the subtree to + // draw, and the resolved values the context accessors hand back to + // descendants. Both are per-pass objects — computePlot builds a fresh context + // every time — and both callers publish them the same way: each writes them + // into the ref the accessors read, and the browser caller also into the state + // its render reads (a server render has no state to carry them in, which is + // why it renders from this call's return value instead). + const resolveComputePass = (pass: ComputePass): {mode: Mode; resolved: ResolvedScales} => { + const {computed, effectiveOptions} = pass; + // What the context accessors answer from, and what <Plot> itself renders + // the figure and legends from. The two are always the same object, so a + // descendant and the plot that owns it can never name different passes. + const resolved: ResolvedScales = { + scaleDescriptors: computed.scaleDescriptors, + context: computed.context, + plotOptions: effectiveOptions + }; + // The exposed scales are built here, once per computed plot, and carried on + // the mode: the root element GETS them through a ref callback, which React + // calls on every commit (the callback is a fresh closure each render), so + // building the lookup function there would hand out a new one every time. + // Upstream's plot() sets figure.scale = exposeScales(…) once (plot.js:343). + const scale = exposeScales(computed.scales.scales, computed.context); + const onSvgRef = (svg: SVGSVGElement | null) => { + if (!svg) return; + // The only property the plot writes on its root element, and not a style + // or a class: it IS the API. plot.scale("x") resolves a scale and throws + // on an unknown name, exactly as the imperative plot()'s root does. + (svg as any).scale = scale; + }; + const pointerEnabled = computed.marks.some(isPointerConsumer); + return {mode: {kind: "jsx", computed, onSvgRef, pointerEnabled}, resolved}; + }; + + // The plot elements for a server render: one pass, computed and rendered + // inside <ServerPlot>'s render rather than after a commit. Called from there, + // which is what makes the registries complete by the time it runs (see + // ServerPlot), and called at most once per render. + const serverPlotElement = (): ReactNode => { + // A failure is thrown, not stored: the client stores it so the throw happens + // in a render an error boundary can catch, and there is no state here to + // survive in nor a boundary to reach — renderToString reports it to its + // caller, exactly as upstream's plot() throws these at its caller. + const pass = runComputePass(); + // Before the tree renders, not after: the context accessors and the mark + // handler lookup read these refs, and on a server render this is the only + // moment they can be written — no effect follows to write them. It is safe + // because the refs belong to this render alone: a server render has no + // commits, no suspension point inside this call, and no second render that + // could read a value from a pass other than its own. + registrationByMarkRef.current = pass.registrationByMark; + // Nothing to render (no marks and no inferred axes): the same empty host the + // client keeps. It matches what the browser path produces for the same + // input, which is what the two paths must agree on. + if (!pass.computed.marks.length) return renderPlotTree({kind: "empty"}, plotExtras(null)); + const {mode: nextMode, resolved: nextResolved} = resolveComputePass(pass); + resolvedRef.current = nextResolved; + return renderPlotTree(nextMode, plotExtras(nextResolved)); + }; + + const lastInputsRef = useRef<string | null>(null); + const onValueRef = useRef(onValue); + onValueRef.current = onValue; + const figureRef = useRef<HTMLElement | null>(null); + const svgElementRef = useRef<SVGSVGElement | null>(null); + + useLayoutEffect(() => { + computedRef.current = true; + // Recompute directly when the effective inputs changed, rather than bumping + // a version and waiting for the next commit's effect to notice: the + // registrations this pass must see were taken by children in THIS commit, + // and a registration change wakes <Plot> with a version bump of its own, so + // there is nothing left for a dirty flag to add. + // + // Registration ids change when children remount without any real change — + // e.g. when the tree gains a <figure> once auto-legends resolve — waking + // <Plot> while every stamp stays the same. Recomputing then would be wasted + // work and would re-emit computePlot warnings, so skip when the effective + // inputs are unchanged; that guard is also what stops a commit that only + // setMode or setResolved caused from computing again. (onValue is excluded + // entirely, like functions in mark stamps: the pointer store reads it + // through a ref at dispatch time, so neither its identity nor its presence + // changes anything computePlot produces.) + const inputsKey = [ + ...[...marksRef.current.values()].map((r) => r.stamp), + ...[...scalesRef.current.values()].map((r) => r.stamp), + stableKey(options), + String(classNameProp), + stableKey({style}) + ].join("\u0000"); + if (inputsKey === lastInputsRef.current) return; + lastInputsRef.current = inputsKey; + let pass: ComputePass; try { - // Always run computePlot, even with zero marks: declared position scales - // (e.g. x={{type: "log", …}}) infer implicit axis marks, so a markless - // <Plot> can still render axes — matching the imperative plot(). - computed = computePlot({...effectiveOptions, marks: flat, style}); + pass = runComputePass(); } catch (e) { - console.error("Plot: computePlot failed.", e); - setMode((prev) => (prev.kind === "empty" ? prev : {kind: "empty"})); + // The failure is kept and rethrown from the render below, so an enclosing + // error boundary sees it. It is NOT downgraded to a console message and + // an empty plot-host: upstream's imperative plot() throws these straight + // out of plot() (src/plot.js:143 → src/scales.js:379, "unknown scale type: + // nope"), and nothing in upstream's src/ catches an error at all. Storing + // it rather than throwing here is what keeps the throw in the render + // phase, where an error boundary can catch it. The warning counter is + // deliberately left undrained, exactly as upstream leaves it when plot() + // throws partway through. + setError(e); return; } + const {computed, registrationByMark} = pass; + registrationByMarkRef.current = registrationByMark; // Nothing to render (no marks and no inferred axes); keep the empty host. if (!computed.marks.length) { @@ -343,130 +630,223 @@ export function Replot({ return; } - publishResolved({ - scaleDescriptors: computed.scaleDescriptors, - context: computed.context, - plotOptions: effectiveOptions - }); + // Published to the stable context value's accessors and, for the parts + // <Plot> itself renders from, to state. Both are written together, so they + // always name the same pass. + const {mode: nextMode, resolved: nextResolved} = resolveComputePass(pass); + resolvedRef.current = nextResolved; + setResolved(nextResolved); - // Drain the global warning counter just as the imperative plot() does, so - // the warn() dedupe state (lastMessage) doesn't leak across plots and we - // can render the ⚠️ indicator. computePlot emits warnings during mark - // initialization above. - const warnings = consumeWarnings(); + // NOTE the warning counter is deliberately NOT drained here. computePlot is + // only the first of the two phases that can warn: its marks warn while they + // RENDER, which has not happened yet at this point in the effect. Draining + // here would count the compute-phase warnings and leave the rest in the + // counter for the next plot to claim. <WarningIndicator> drains instead, + // after the whole render phase. - const onSvgRef = (svg: SVGSVGElement | null) => { - if (!svg) return; - // Expose scale on the svg, matching imperative API. - (svg as any).scale = computed.scales?.scales ?? null; - if (classNameProp) svg.classList.add(classNameProp); - // Apply the plot-level style option to the <svg>, mirroring - // applyInlineStyles on the imperative path (string or object). - if (typeof style === "string") svg.setAttribute("style", style); - else if (style != null) Object.assign(svg.style, style as any); + // Carry the plot's root element onto the NEW context's figureHolder here, + // rather than leaving it to the holder effect below. computePlot builds a + // fresh context per recompute, and so a fresh holder whose `current` is + // null, which a dispatch cannot reach `.value` through. The pointer's own + // reports are settled from a layout effect below, after the holder effect + // has filled it; this earlier assignment covers a plain pointer move that + // arrives in the window between this compute pass and that commit. The root + // element does not change across a recompute, so it is not a guess; the + // effect below still owns the case where the root itself changes, which is + // figure mode appearing or going away. + if (computed.context?.figureHolder != null) { + computed.context.figureHolder.current = figureRef.current ?? svgElementRef.current; + } + + setMode(nextMode); + // No dependency array: the inputs key above is the guard, and it is taken + // over the registries as the reconciliation above just ordered them. + }); + + // THE PLOT AS ELEMENTS, built from a pass rather than from this component's + // state. A browser commit renders it from the state the compute effect has + // just written; a server render serializes it from the pass <ServerPlot> + // computed during that render (nothing there commits, so no state can carry + // it). One builder for both is what keeps the two paths from drifting: the + // tree a browser draws and the tree a server serializes are the same + // expression of the same values. + const renderPlotTree = (mode: Mode, extras: PlotExtras): ReactNode => { + const {autoLegends, explicitLegends, wantsFigure} = extras; + // The plot's root ELEMENTS, for the viewof contract below. The <svg> arrives + // through this ref callback (which exposes the scales on it), so this + // wrapper records it on the way past. + const setSvgElement = (svg: SVGSVGElement | null) => { + svgElementRef.current = svg; + if (mode.kind === "jsx") mode.onSvgRef(svg); }; - const onInput = onValue - ? (e: Event) => { - const t = e.target as any; - const cb = onValueRef.current; - if (cb && t && "value" in t) cb(t.value); - } - : null; + // The plot-level style option, as a prop on the <svg>. React needs an object, + // so a string — upstream sets the whole style attribute from one, + // applyInlineStyles in style.js — is parsed into one. A prop, rather than a + // merge onto the node from a ref callback, is what lets React clear a key + // that a later render drops from the option. + const svgStyle = typeof style === "string" ? parseStyleString(style) : style; - const pointerEnabled = computed.marks.some(isPointerConsumer); - setMode({kind: "jsx", computed, onSvgRef, onInput, pointerEnabled, warnings}); - // version counts mark registration changes (stamp changes, additions, - // removals), so prop updates on marks after mount re-run computePlot. - }, [optionsKey, onValue, classNameProp, style, version]); - - const ctx = { - registerMark, - unregisterMark, - registerScale, - unregisterScale, - registerLegend, - unregisterLegend, - scaleDescriptors: resolved?.scaleDescriptors, - context: resolved?.context, - // Prefer the computed effective options (props + scale registrations) so - // legend defaults see scale options declared via components. - plotOptions: resolved?.plotOptions ?? options - }; + // In figure mode, wrap the plot in a div.plot-host inside the figure to + // match the imperative API's structure (figure > h2/h3 > div.plot-host > svg + // > figcaption). In non-figure mode, return the SVG directly (matching the + // existing .svg-snapshot test expectations) or the imperatively-mounted + // host div. + const plotElement = + mode.kind === "jsx" ? ( + <PlotSvg + computed={mode.computed} + svgRef={setSvgElement} + className={classNameProp} + style={svgStyle} + pointerEnabled={mode.pointerEnabled} + pointerStore={pointerStore} + onValueRef={onValueRef} + getHandlers={getMarkHandlers} + serverRender={serverRender} + /> + ) : ( + <div className="plot-host" /> + ); - // Auto-legends (color/opacity/symbol scales with legend requested) render - // via the React legend components and force figure mode, matching the - // imperative plot()'s createLegends behavior. - const autoLegends = resolved?.scaleDescriptors - ? buildAutoLegends(resolved.scaleDescriptors, resolved.context, resolved.plotOptions ?? options) - : []; - - // Explicit <Legend> descendants register via PlotContext (like marks via - // useMark) and render visibly here as <LegendDisplay>, matching the - // imperative plot()'s createLegends/exposeLegends placement above the - // <svg>. The <Legend> instances themselves render null inside the hidden - // children div, so any composition (memo, wrapper components, fragments) - // still surfaces the legend. Any registered legend forces figure mode. - // Registry order tracks the children's render order via the layout-effect - // reconciliation above, so keyed reorders update the visible order. - // A registered legend that resolves to nothing (e.g. scale="opacity" on a - // plot with no opacity scale) is dropped here rather than rendered as an - // empty wrapper, so it also doesn't force figure mode — matching the - // imperative plot.legend(), which returns undefined for such scales. - const explicitLegends: ReactElement[] = [...legendsRef.current.entries()] - .filter(([, r]) => resolveLegendElement(r.props as any, ctx) !== null) - .map(([id, r]) => <LegendDisplay key={id} {...r.props} />); - - // "always"/true forces a figure; "never"/false suppresses it; "auto" (or - // undefined) infers it from whether there's anything to wrap. - const autoFigure = Boolean(title || subtitle || caption || autoLegends.length > 0 || explicitLegends.length > 0); - const wantsFigure = - figure === "always" || figure === true ? true : figure === "never" || figure === false ? false : autoFigure; - - // In figure mode, wrap the plot in a div.plot-host inside the figure to - // match the imperative API's structure (figure > h2/h3 > div.plot-host > svg - // > figcaption). In non-figure mode, return the SVG directly (matching the - // existing .svg-snapshot test expectations) or the imperatively-mounted - // host div. - const plotElement = - mode.kind === "jsx" ? ( - <PlotSvg - computed={mode.computed} - svgRef={mode.onSvgRef} - className={classNameProp} - onInput={mode.onInput} - pointerEnabled={mode.pointerEnabled} - warnings={mode.warnings} - getHandlers={getMarkHandlers} + return wantsFigure ? ( + <FigureLayout + title={title} + subtitle={subtitle} + caption={caption} + autoLegends={autoLegends} + explicitLegends={explicitLegends} + plotElement={plotElement} + isJsx={mode.kind === "jsx"} + figureRef={figureRef} /> ) : ( - <div className="plot-host" /> + <> + {explicitLegends} + {plotElement} + </> ); + }; + + // The legends a pass shows and the figure decision around them. Both entries + // build these the same way — the client from its resolved state, the server + // from the pass it just computed — and the figure flag is also what the + // root-element effect below depends on, so it is read here rather than buried + // in renderPlotTree. + const plotExtras = (resolved: ResolvedScales | null): PlotExtras => { + // Auto-legends (color/opacity/symbol scales with legend requested) render + // via the React legend components and force figure mode, matching the + // imperative plot()'s createLegends behavior. + const autoLegends = resolved?.scaleDescriptors + ? buildAutoLegends(resolved.scaleDescriptors, resolved.context, resolved.plotOptions ?? options) + : []; + + // Explicit <Legend> descendants register via PlotContext (like marks via + // useMark) and render visibly here as <LegendDisplay>, matching the + // imperative plot()'s createLegends/exposeLegends placement above the + // <svg>. The <Legend> instances themselves render null inside the hidden + // children div, so any composition (memo, wrapper components, fragments) + // still surfaces the legend. Any registered legend forces figure mode. + // Registry order tracks the children's render order via the layout-effect + // reconciliation above, so keyed reorders update the visible order. + // A registered legend that resolves to nothing (e.g. scale="opacity" on a + // plot with no opacity scale) is dropped here rather than rendered as an + // empty wrapper, so it also does not force figure mode, matching the + // imperative plot.legend(), which returns undefined for such scales. + const explicitLegends: ReactElement[] = [...legendsRef.current.entries()] + .filter(([, r]) => resolveLegendElement(r.props as any, ctx) !== null) + .map(([id, r]) => <LegendDisplay key={id} {...r.props} />); + + // "always"/true forces a figure; "never"/false suppresses it; "auto" (or + // undefined) infers it from whether there's anything to wrap. + const autoFigure = Boolean(title || subtitle || caption || autoLegends.length > 0 || explicitLegends.length > 0); + const wantsFigure = + figure === "always" || figure === true ? true : figure === "never" || figure === false ? false : autoFigure; + return {autoLegends, explicitLegends, wantsFigure}; + }; + + // For a browser render, from the state the compute effect has written. + const extras = plotExtras(resolved); + + // Upstream reports the pointer selection through context.dispatchValue + // (src/plot.ts:189-194, verbatim from plot.js): it assigns `.value` on the + // plot's root element and dispatches a bubbling `input` event there. That + // root is the <figure> when the plot has one and the <svg> otherwise — + // upstream's plot() sets figureHolder.current in exactly those two places + // (plot.js:157/333/340) — and until this effect existed nothing on the React + // path ever filled the holder, so dispatchValue returned at its first line + // and viewof was dead. A layout effect is the right seam: React attaches a + // child's host refs before running a parent's layout effects, so both refs + // are populated here, and the assignment lands before any pointer event can + // be delivered. + useLayoutEffect(() => { + const holder = mode.kind === "jsx" ? mode.computed.context?.figureHolder : null; + if (holder == null) return; + holder.current = figureRef.current ?? svgElementRef.current; + }, [mode, extras.wantsFigure]); + + // THE POINTER'S SETTLING POINT, and the last thing to run in any commit of + // this plot: React runs layout effects child first, so every pointer slot has + // registered by now, and the effect above has just pointed the context's + // figure holder at the root element this commit produced. + // + // Both orderings are why it is here rather than beside the slots or inside + // <PointerRoot>. Re-resolving as each slot registers would arbitrate against + // the half-populated registry a commit passes through — React runs every + // layout-effect cleanup before any of the creates — and report values for + // winners that lose again in the same commit. Reporting from any earlier + // effect would dispatch through a figure holder that this commit has just + // replaced, which is why a plot that gained or lost its <figure> in the same + // commit used to drop its clearing value in silence. + // + // No dependency array: a registration can change in any commit of this + // component, and settle() is a no-op in the ones where nothing did. + useLayoutEffect(() => { + pointerStore.setValueSink(mode.kind === "jsx" ? mode.computed.context?.dispatchValue ?? null : null); + pointerStore.settle(); + }); + + // A computePlot failure is rethrown HERE, in the render phase, which is where + // an enclosing error boundary can see it. Throwing it in the effect instead + // would not: React catches an effect's throw and unmounts the whole root + // rather than handing it to the nearest boundary. The error is state, so the + // throw survives the commit boundary and is repeated on any later render of + // the same broken inputs; a boundary that resets remounts this component + // with fresh state, which is how a plot recovers from a fixed prop. + // A server render has no state to store one in and no boundary to hand it to; + // its failure is thrown straight out of the compute below. + if (error !== null) throw error; // The hidden registration div keeps a stable position in the tree across // figure-mode changes: if it moved inside <FigureLayout> when a figure // appears, React would remount the children subtree, wiping descendant // state — a legend mounted by a stateful wrapper would flip figure mode, // remount (and so reset) that wrapper, and immediately unregister itself. + const registrations = <div style={{display: "none"}}>{wrapFunctionChildren(children)}</div>; + + // A SERVER RENDER HAS TO RENDER THE REGISTRATIONS FIRST, and it is the one + // place the two entries' trees differ in order. A parent's render body runs + // before its children's, so this component cannot compute the plot in its own + // render — the marks it would compute from have not registered yet — and + // therefore cannot render the plot before the subtree that registers them. + // The plot is computed and rendered by <ServerPlot>, which sits after that + // subtree and computes during its own render, when the registry is complete + // (see its comment for why that ordering is sound rather than lucky). The + // serialized markup is consequently hidden-div-first, which is invisible + // (display: none) and is the only order that can produce a plot at all. + if (serverRender) { + return ( + <PlotContext.Provider value={ctx}> + {registrations} + <ServerPlot render={serverPlotElement} /> + </PlotContext.Provider> + ); + } + return ( <PlotContext.Provider value={ctx}> - {wantsFigure ? ( - <FigureLayout - title={title} - subtitle={subtitle} - caption={caption} - autoLegends={autoLegends} - explicitLegends={explicitLegends} - plotElement={plotElement} - isJsx={mode.kind === "jsx"} - /> - ) : ( - <> - {explicitLegends} - {plotElement} - </> - )} - <div style={{display: "none"}}>{wrapFunctionChildren(children)}</div> + {renderPlotTree(mode, extras)} + {registrations} </PlotContext.Provider> ); } @@ -503,16 +883,66 @@ function containsFunctionChild(node: unknown): boolean { return typeof node === "function" || (Array.isArray(node) && node.some(containsFunctionChild)); } -// Renders the whole plot as a JSX <svg> tree. -function PlotSvg({computed, svgRef, className: classNameProp, onInput, pointerEnabled, warnings, getHandlers}: any) { - const {className, ariaLabel, ariaDescription, dimensions} = computed; - const {width, height} = dimensions; - const internalSvgRef = useRef<SVGSVGElement | null>(null); - const setSvgRef = (el: SVGSVGElement | null) => { - internalSvgRef.current = el; - if (typeof svgRef === "function") svgRef(el); - }; - const styleText = `:where(.${className}) { +// The ⚠️ warning indicator, and the only thing on the React path that drains +// the global warning counter. It has to be a component of its own, rendered +// last inside the <svg> after the marks, because the warnings it counts are +// raised while the marks RENDER: their renderJSX runs in the render phase this +// component is part of, and layout effects run only once that phase is over, so +// the drain belongs in an effect of a child that comes after them. Draining in +// the compute effect instead — which is what this replaces — missed every +// warning raised while rendering and left it for the next plot to claim. +// +// It drains ONCE PER COMPUTED PLOT, guarded by a ref holding that computed +// object, because its own state update re-renders it in a second pass: without +// the guard, that pass would drain an already-empty counter, set the count back +// to zero and take the indicator out of the DOM again. The update is local to +// this component, so the marks are not re-rendered by it and cannot warn again +// — which is what makes showing a render-time warning safe rather than a loop. +// +// The effect therefore has NO dependency array on purpose. The ref, not a deps +// list, is what makes the redundant passes (this component's own re-render, and +// StrictMode's simulated remount, which re-runs every effect it created) cheap +// no-ops; a deps list would only add a way to get this wrong. +// +// A SERVER RENDER DRAINS HERE, IN THE RENDER, for the reason above read +// backwards: there is no effect and no second pass there, so the drain has to +// happen while rendering or not at all — and not at all would leave the +// counter, which is module-global and shared with every plot this process +// renders, holding this plot's warnings for the next plot to claim, reporting +// warnings that plot never raised. It cannot drain too early: this component +// renders last inside the <svg>, after every mark has rendered and warned, and +// a server pass renders nothing twice. +function WarningIndicator({computed, serverRender}: {computed: any; serverRender?: boolean}) { + const [warnings, setWarnings] = useState(0); + const drainedRef = useRef<any>(null); + useLayoutEffect(() => { + // Never runs on a server render, where the drain above has already + // happened; see this component's comment. + if (serverRender) return; + if (drainedRef.current === computed) return; + drainedRef.current = computed; + setWarnings(consumeWarnings()); + }); + return warningIndicatorElement(computed, serverRender ? consumeWarnings() : warnings); +} + +// THE PLOT'S <svg> SHELL, DEFINED ONCE. Both entry points build an <svg> +// around the marks they render — this file's <PlotSvg>, which renders it as +// React children, and renderStatic.tsx's buildStaticPlotSvg, which builds the +// element for the imperative plot() — and the two had drifted: the attributes, +// the inline stylesheet and the xmlns declarations were written twice, so a +// change to one (the xmlns pair, added to the JSX path only) never reached the +// other. The shell is what the two renderers have in common; what differs is +// how the marks inside it are rendered, which is what each path still owns. +// +// The stylesheet, which is the plot's only forced CSS: it makes the svg scale +// with its container and stops text being collapsed, all through :where() so +// the selectors carry no specificity a user's own rules have to fight. The +// class name is the plot's (meant to be unique per plot); upstream writes the +// same two rules in style.js, with a comment that changing them means changing +// defaultClassName there too. +export function plotStyleSheet(className: string): string { + return `:where(.${className}) { --plot-background: white; display: block; height: auto; @@ -523,67 +953,137 @@ function PlotSvg({computed, svgRef, className: classNameProp, onInput, pointerEn :where(.${className} tspan) { white-space: pre; }`; - // Render the ⚠️ warning indicator after the marks, matching the imperative - // plot() (font-family="initial" fixes emoji rendering in Chrome). - const warningIndicator = - warnings > 0 ? ( - <text x={width} y={20} dy="-1em" textAnchor="end" fontFamily="initial"> - {"⚠️"} - <title>{`${warnings.toLocaleString("en-US")} warning${ - warnings === 1 ? "" : "s" - }. Please check the console.`} - - ) : null; +} + +// The shell's attributes, in the order they are written to the element — the +// order React writes them in for the JSX path, and the order the markup +// serializes in on both. `style` is deliberately NOT here: the JSX path passes +// it as a prop (so React can clear a key a later render drops, and so it lands +// last, as upstream applies the style option after the svg's attributes are +// set, plot.js:278), while the imperative path applies it to the element it has +// just built. +// +// The xmlns declarations are not needed to render: React creates svg elements +// in the SVG namespace and the HTML parser puts an it meets into it. They +// are here so the SERIALIZED plot — a server-rendered page, an , a saved +// .svg file — carries its namespace, which is what every one of upstream's +// committed test outputs does. +export function plotSvgAttributes(computed: any, classNameProp?: string): Record { + const {className, ariaLabel, ariaDescription, dimensions} = computed; + const {width, height} = dimensions; + return { + className: [className, classNameProp].filter(Boolean).join(" ") || undefined, + fill: "currentColor", + fontFamily: "system-ui, sans-serif", + fontSize: 10, + textAnchor: "middle", + width, + height, + viewBox: `0 0 ${width} ${height}`, + "aria-label": ariaLabel ?? undefined, + "aria-description": ariaDescription ?? undefined, + xmlns: "http://www.w3.org/2000/svg", + xmlnsXlink: "http://www.w3.org/1999/xlink" + }; +} + +// Renders the whole plot as a JSX tree. +function PlotSvg({ + computed, + svgRef, + className: classNameProp, + style, + pointerEnabled, + pointerStore, + onValueRef, + getHandlers, + serverRender +}: any) { + const internalSvgRef = useRef(null); + const setSvgRef = (el: SVGSVGElement | null) => { + internalSvgRef.current = el; + if (typeof svgRef === "function") svgRef(el); + }; // Allocate clip-path defs up front (pre-pass) so they're known before the - // marks that reference them are rendered, then render them in the . - const clipReg = createClipRegistry(); + // marks that reference them are rendered, then render them in the . The + // plot's context is handed over so a mark that emits its own defs + // (the difference mark) allocates its ids from this render's counter rather + // than from style.js's module-global one — see ClipRegistry.clipId. + const clipReg = createClipRegistry(computed.context); registerClips(computed, clipReg); const inner = ( <> - + {clipReg.defs} {renderMarks(computed, clipReg, getHandlers)} - {warningIndicator} + ); return ( - {pointerEnabled ? {inner} : inner} + {pointerEnabled ? ( + + {inner} + + ) : ( + inner + )} ); } -// Computes the per-facet transform string by invoking the imperative -// facetTranslator against a minimal element shim (it sets a "transform" -// attribute for hosts). Returns undefined when there is no offset. -function facetTransform(facetTranslate: any, f: any): string | undefined { +// The attributes one facet's cell translation asks for, keyed by attribute +// name, out of facetTranslator. +export type FacetCell = Record; + +// Asks the imperative facetTranslator for this facet's cell offset, by +// invoking it against minimal element shims. Which attributes it writes +// depends on the node it is handed (facet.js:65): a nested — what a mark +// whose render option returns a whole plot produces, so the facet's node IS an +// svg — takes x/y, and any other node takes a transform. The node does not +// exist yet at the point the walker needs the offset, so both forms are +// collected here, through a shim standing in for each kind of host, and the +// applier takes the pair its own node needs. Returns undefined when the plot +// has no facetTranslator to ask. +export function facetCell(facetTranslate: any, f: any): FacetCell | undefined { if (typeof facetTranslate !== "function") return undefined; - let transform: string | undefined; - facetTranslate.call( - { - tagName: "g", - setAttribute: (k: string, v: string) => { - if (k === "transform") transform = v; - } - }, - f - ); - return transform; + const cell: FacetCell = {}; + for (const tagName of ["svg", "g"]) { + facetTranslate.call( + { + tagName, + setAttribute: (k: string, v: string) => { + cell[k] = String(v); + } + }, + f + ); + } + return cell; +} + +// Completes the registered marks' keys with the marks computePlot creates for +// itself. Two kinds reach here: tips inferred from a `tip` option, which carry +// the mark they were inferred from (plot.ts's inferTips) and are keyed under +// it — these are pointer consumers, so they are exactly the ones a positional +// key would shuffle on every mark added or removed elsewhere in the plot; and +// implicit axis and grid marks, which are not pointer consumers and fall back +// to their position, in a namespace that cannot collide with a registration +// key. +function markKeysOf(marks: readonly any[], registered: Map): Map { + const keys = new Map(registered); + marks.forEach((mark, i) => { + if (keys.has(mark)) return; + const source = mark.tipFor === undefined ? undefined : keys.get(mark.tipFor); + keys.set(mark, source === undefined ? `x${i}` : `${source}#tip`); + }); + return keys; } // A callback that renders one mark instance (a mark at a given facet/index) @@ -596,14 +1096,28 @@ export type RenderOne = ( dims: any, scales: any, context: any, - key: string + key: string, + // Render order of this (mark, facet) slot: markIndex * facetCount + + // facetOrdinal, which cannot collide however many facets the plot has. + // It is the pointer store's sort key, standing in for upstream's + // renderIndex, so that the two order-sensitive pointer behaviours (whose + // pooling infects the plot, and which slot claims a pointerdown) are decided + // by render order rather than by the order React fires effects in. + order: number, + // Set only for one facet of a PROMOTED group (see renderMarksWith): the cell + // offset this facet's node must carry in place of the mark transform, which + // the walker has hoisted onto the shared parent along with the mark's ARIA + // attributes. Undefined for an unfaceted mark, or when the plot has no + // facetTranslator to ask for a cell offset, in which case the walker wraps + // each facet in its own instead. + facetCell?: FacetCell ) => ReactNode; // Walks the computed marks, resolving each mark's per-facet index and (for // faceted marks) wrapping each facet in a at its cell. The // per-mark rendering is delegated to `renderOne` so the interactive and // static paths share identical structure. -export function renderMarksWith(computed: any, renderOne: RenderOne): ReactNode[] { +export function renderMarksWith(computed: any, renderOne: RenderOne, clipReg?: ClipRegistry): ReactNode[] { const { marks, stateByMark, @@ -617,6 +1131,13 @@ export function renderMarksWith(computed: any, renderOne: RenderOne): ReactNode[ facetTranslate } = computed; const out: ReactNode[] = []; + // The slot order packs (markIndex, facetOrdinal) into one sortable number. + // The stride is the plot's own facet count rather than a fixed 1000, because + // f.i indexes `facets`: with a fixed stride a 32-by-32 grid already overflows + // it and two different slots collide, which silently drops the pool + // contagion and the pointerdown claimant back to effect-firing order. + const stride = Math.max(1, facets?.length ?? 1); + const keyOf = (mark: any, i: number): string => computed.markKeys?.get(mark) ?? `x${i}`; marks.forEach((mark: any, i: number) => { const {channels, values, facets: indexes} = stateByMark.get(mark); if (facets === undefined || mark.facet === "super") { @@ -626,10 +1147,24 @@ export function renderMarksWith(computed: any, renderOne: RenderOne): ReactNode[ index = mark.filter(index, channels, values); if (index.length === 0) return; } - const node = renderOne(mark, index, values, superdimensions, scales, context, `${i}`); + const node = renderOne(mark, index, values, superdimensions, scales, context, keyOf(mark, i), i * stride); if (node != null) out.push(node); } else { const facetMarks: ReactNode[] = []; + // Upstream announces a faceted mark ONCE: it hoists aria-label, + // aria-description, aria-hidden and the mark transform off the per-facet + // nodes onto a single shared per mark, then writes each facet's cell + // transform onto the children in the vacated transform's place + // (plot.js:313-325, "Promote ARIA attributes and mark transform to avoid + // repetition on each facet"). Every faceted mark goes through it, so the + // shape for a mark with no aria attributes at all is still ONE per facet carrying the mark's own + // attributes, not the two nested s (one at the cell, one holding the + // mark transform) that an unpromoted facet would need. + // The facetTranslate guard is what makes `cell` a string for every facet + // below, and the promotion is all-or-nothing per mark: a facet left with + // its own ARIA attributes would defeat the whole point. + const promote = typeof facetTranslate === "function"; for (const f of facets) { if (!(mark.facetAnchor?.(facets, facetDomains, f) ?? !f.empty)) continue; let index: any = null; @@ -641,93 +1176,161 @@ export function renderMarksWith(computed: any, renderOne: RenderOne): ReactNode[ if (!faceted && index === indexes[0]) index = subarray(index); (index.fx = f.x), (index.fy = f.y), (index.fi = f.i); } - const inner = renderOne(mark, index, values, subdimensions, scales, context, `${i}-${f.i}`); + const cell = facetCell(facetTranslate, f); + const inner = renderOne( + mark, + index, + values, + subdimensions, + scales, + context, + `${keyOf(mark, i)}-${f.i}`, + i * stride + f.i, + promote ? cell : undefined + ); if (inner == null) continue; // Translate each facet's marks to its cell, mirroring the imperative // pipeline's per-facet (facetTranslator). Without this // wrapper every facet would render at the same origin (overlapping). + // A promoted mark needs no wrapper: renderOne has written the cell + // transform onto the mark's own node instead. facetMarks.push( - - {inner} - + promote ? ( + inner + ) : ( + + {inner} + + ) ); } if (facetMarks.length > 0) { - out.push({facetMarks}); + out.push( + + {facetMarks} + + ); } } }); return out; } +// The four attributes upstream hoists from the per-facet nodes onto the shared +// group (plot.js:318-321), read off the mark as it renders AT REST. +// +// Upstream reads them back off a real DOM node it has just appended. Here the +// interactive path's facets are / COMPONENT +// elements, whose output the walker cannot inspect at all, so the mark is +// rendered once more with an empty index purely to read its root. That is +// sound because none of the four depends on the index — the transform is the +// crispness offset plus dx/dy plus any band offset (computeTransform, which +// reads the mark and the scales, never the datum) and the ARIA attributes come +// from the mark's options — and cheap because an empty index renders no data. +// The clip wrap is reproduced because it is the OUTERMOST node upstream reads: +// a frame-clipped mark's wrapper carries the ARIA attributes and no transform, +// so the mark transform correctly stays inside. +function promotedProps( + mark: any, + values: any, + dims: any, + scales: any, + context: any, + clipReg?: ClipRegistry +): Record { + if (typeof mark.renderJSX !== "function") return {}; + let jsx: ReactNode; + if (hasRenderTransform(mark)) { + jsx = renderTransformJSX(mark, [], scales, values, dims, context) as ReactNode; + } else { + jsx = mark.renderJSX([], scales, values, dims, context) as ReactNode; + if (isDomNode(jsx)) jsx = domToJsx(jsx); + } + if (!isValidElement(jsx)) return {}; + const node = clipReg ? clipReg.wrap(jsx as ReactElement, mark, dims, context) : jsx; + if (!isValidElement(node)) return {}; + const props = (node as ReactElement).props as Record; + const promoted: Record = {}; + for (const name of promotedNames) if (props[name] != null) promoted[name] = props[name]; + return promoted; +} + +const promotedNames = ["aria-label", "aria-description", "aria-hidden", "transform"] as const; + +// One facet of a promoted group: the attributes the walker hoisted onto the +// shared parent are removed here (upstream removeAttribute, plot.js:321) and +// the facet's cell offset is written in the vacated transform's place +// (upstream's facetTranslate pass, plot.js:325). +export function promoteFacetChild(node: ReactNode, cell: FacetCell): ReactNode { + if (!isValidElement(node)) return node; + // Which form the offset takes is a property of the facet's own node, exactly + // as it is for upstream's facetTranslate (facet.js:65, `this.tagName === + // "svg"`): a nested — a mark whose render option returns a whole plot, + // so the facet's node IS that svg — is positioned with x/y, and everything + // else with a transform. The transform is cleared for the nested svg because + // the promotion has taken the mark transform off it: leaving both would + // apply the offset twice. + const nested = (node as ReactElement).type === "svg"; + const props: Record = { + "aria-label": undefined, + "aria-description": undefined, + "aria-hidden": undefined + }; + if (nested) { + props.x = cell.x; + props.y = cell.y; + props.transform = undefined; + } else { + props.transform = cell.transform; + } + return cloneElement(node as ReactElement, props as any); +} + function renderMarks( computed: any, clipReg: ClipRegistry, getHandlers?: (mark: any) => MarkEventHandlers | undefined ): ReactNode[] { - return renderMarksWith(computed, (mark, index, values, dims, scales, context, key) => ( - - )); -} - -// Renders one mark via its renderJSX into pure React SVG. Pointer-consumer -// marks (Tip, crosshair sub-marks) render with an empty index by default; -// PointerRoot will override this on hover to render only the selected datum. -function MarkSlot({mark, index, scales, values, dims, context, clipReg, getHandlers, markData}: any) { - const pointerCtx = useContext(PointerContext); - const pointerConsumer = isPointerConsumer(mark); - - // For pointer-consumer marks, replace the data index with the currently- - // selected index (or empty when nothing is hovered). The selection key - // identifies this mark's registration in PointerRoot. - const fi = (index as any)?.fi ?? null; - const regId = pointerConsumer ? pointerRegistrationId(mark, fi) : null; - - useEffect(() => { - if (!pointerCtx || !pointerConsumer || regId == null || index == null || index.length === 0) return; - return pointerCtx.register({id: regId, index, values, fi, kx: 1, ky: 1, maxRadius: 40}); - }, [pointerCtx, pointerConsumer, regId, index, values, fi]); - - const sel = pointerConsumer && pointerCtx && regId ? pointerCtx.selectionFor(regId) : null; - let renderIndex = index; - if (pointerConsumer && index != null) { - const empty: any = []; - if ((index as any).fx !== undefined) - (empty.fx = (index as any).fx), (empty.fy = (index as any).fy), (empty.fi = (index as any).fi); - if (sel?.i != null) { - const filled: any = [sel.i]; - filled.fx = (index as any).fx; - filled.fy = (index as any).fy; - filled.fi = (index as any).fi; - renderIndex = filled; - } else { - renderIndex = empty; - } - } + return renderMarksWith( + computed, + (mark, index, values, dims, scales, context, key, order, facetCell) => { + // The split is on the component TYPE, not on a prop or a branch inside + // one component: holds no useContext(PointerContext) and no + // pointer state at all, so an ordinary mark is structurally incapable of + // re-rendering because the pointer moved. + const Slot = isPointerConsumer(mark) ? PointerMarkSlot : MarkSlot; + return ( + + ); + }, + clipReg + ); +} +// Renders one ordinary (non-pointer) mark via its renderJSX into pure React +// SVG. It holds no hooks and reads no context, so nothing about the pointer +// interaction can reach it: a plot of ten thousand dots is not rebuilt because +// a tip moved. Pointer consumers go through instead, chosen +// by component type in renderMarks. +function MarkSlot({mark, index, scales, values, dims, context, clipReg, getHandlers, markData, facetCell}: any) { if (typeof mark.renderJSX !== "function") return null; - // Coerce index to a plain Array. Marks call (index as number[]).map(...) - // but `index` is often a TypedArray (e.g. Uint32Array); its .map() coerces - // returned React elements back to numbers, corrupting output. - const arrayIndex = - renderIndex == null || !ArrayBuffer.isView(renderIndex) - ? renderIndex - : Object.assign(Array.from(renderIndex as any), { - fx: (renderIndex as any).fx, - fy: (renderIndex as any).fy, - fi: (renderIndex as any).fi - }); + const arrayIndex = plainIndex(index); // renderJSX usually returns its own wrapper; we don't add another, to // keep the DOM structure identical to the imperative output. Clip wrapping // (frame/geo) is applied via the clip registry. A user render option (a @@ -743,47 +1346,199 @@ function MarkSlot({mark, index, scales, values, dims, context, clipReg, getHandl if (isDomNode(jsx)) jsx = domToJsx(jsx) as ReactElement; } if (jsx == null) return null; - // While the pointer is not sticky, pointer-driven marks must not intercept - // the pointer events that drive them (upstream defaults pointer-events to - // "none" when context.pointerSticky === false). - if (pointerConsumer && sel?.sticky !== true) jsx = defaultPointerEventsNone(jsx) as ReactElement; // Per-mark event handlers attach as React event props (no DOM-structure - // change): per element when the mark renders one element per datum, - // mark-level otherwise. Presence changes rebuild the plot (stamped), so - // this render-time decision stays in sync with the registration. + // change): per element where the mark tags its per-datum elements, mark-level + // otherwise. Presence changes rebuild the plot (stamped), so this render-time + // decision stays in sync with the registration. const handlers = getHandlers?.(mark); - if (handlers) jsx = attachMarkHandlers(jsx, arrayIndex, markData, handlers, () => getHandlers(mark)); - return <>{clipReg ? clipReg.wrap(jsx, mark, dims, context) : jsx}; -} - -// Attaches the registered handlers to a mark's rendered JSX. Marks that -// render one element per datum (dot, bar, rect, cell, text, tick, …) emit -// their per-datum elements as the direct children of the mark's root , in -// filtered-index order — so when the child count matches the index length, -// each child gets handlers with its datum index closed over. Otherwise -// (grouped marks like line/area render one path per series, and some marks -// nest further) the handlers attach at the mark level with datum/index -// undefined. Handler identity is read through `live` at dispatch time, so -// identity-only updates (which don't rebuild the plot) still take effect. + if (handlers) jsx = attachMarkHandlers(jsx, markData, handlers, () => getHandlers(mark)); + let out: ReactElement = clipReg ? clipReg.wrap(jsx, mark, dims, context) : jsx; + // One facet of a promoted ARIA group (renderMarksWith's faceted branch): + // drop the attributes now carried by the shared parent and take the facet's + // cell transform instead of the mark transform. Applied to the OUTERMOST + // node, which is the clip wrapper when there is one — the same node upstream + // reads its attributes off after render. + if (facetCell !== undefined) out = promoteFacetChild(out, facetCell) as ReactElement; + return <>{out}; +} + +// Renders one pointer-consumer mark (a tip, a crosshair sub-mark, or any +// pointer()-wrapped mark) for one facet — exactly upstream's renderIndex slot. +// The slot owns a registration record in the plot's pointer store and renders +// only the datum that record has been awarded, which is an empty index until +// something is hovered. +function PointerMarkSlot({ + mark, + index, + scales, + values, + dims, + context, + clipReg, + getHandlers, + markData, + order, + facetCell +}: any) { + const store = pointerStoreOf(useContext(PointerContext)); + + // Created once and kept for the life of the slot. The record OBJECT is the + // registration's identity — there is no string key anywhere — so two tips, + // or a crosshair's two identically-labelled rules, cannot collide. What it + // does NOT carry across a recompute is any claim about the data: the store + // re-resolves every registered record at the last pointer position once the + // commit is over (see settle in interactions/pointerStore.ts), so a record + // handed to a different mark by a reused fiber simply resolves against that + // mark, at a position it is nowhere near, and shows nothing. + const [reg] = useState(() => store.createRegistration()); + + const sel = useSyncExternalStore(reg.subscribe, reg.getSnapshot, reg.getServerSnapshot); + + // `index` is reallocated by mark.filter on most plot re-renders, so this + // effect DOES re-run whenever the plot recomputes — which is how the anchors, + // the data and the facet correction stay in step with the scales. It is + // deliberately not run on a pointer move: nothing it computes depends on the + // pointer. It writes the record only from an effect, so the store (which + // reads it from event handlers) can never see a half-filled record. + // + // WHATEVER THE INDEX IS, IT REGISTERS. An earlier revision also returned + // early on an EMPTY index, on the reasoning that a slot which can draw no + // datum has nothing to register — and that is exactly how a record goes + // missing: it never comes back to the store, so nothing re-resolves it and + // the plot goes on reporting a datum it has stopped drawing. An empty index + // is simply a search that always misses, so it registers like any other, and + // the store decides. (In practice one never arrives: renderMarksWith drops a + // mark whose filtered index is empty, exactly as upstream's plot.js does at + // 290/307, so the slot unmounts instead — which the store's departed-record + // path reports. The guard here is for the channel-less mark, whose index is + // null and which has nothing to hit-test at all.) + useLayoutEffect(() => { + if (index == null) return; + Object.assign(reg, { + order, + mark, + fi: index.fi ?? null, + index: Array.from(index as ArrayLike), + dimensions: dims, + context, + data: context.getMarkState(mark).data, + ...pointerKOf(mark), + ...computeAnchors(mark, scales, values, dims, index) + }); + return store.add(reg); + }, [store, reg, order, mark, index, values, scales, dims, context]); + + if (typeof mark.renderJSX !== "function") return null; + + // The substituted index: the datum the pointer has awarded this slot, or + // nothing at all until it has awarded one. + const renderIndex = pointerIndex(index, sel.i); + + // No plainIndex() here: a pointer consumer's index is one this slot built, + // so it is already a plain Array (or the null a channel-less mark was given). + // + // A user `render` option composed under pointer() runs HERE, over the + // substituted index, so it sees exactly the datum the pointer has selected — + // which is what upstream's composed closure does when it calls next(). Its + // own render is recovered from the pointer tag (see userRenderOf). + let jsx: ReactElement; + if (hasRenderTransform(mark)) { + jsx = renderTransformJSX(mark, renderIndex, scales, values, dims, context) as ReactElement; + } else { + jsx = mark.renderJSX(renderIndex, scales, values, dims, context) as ReactElement; + if (isDomNode(jsx)) jsx = domToJsx(jsx) as ReactElement; + } + if (jsx == null) return null; + // While the pointer is not sticky, a pointer-driven mark must not intercept + // the very events that drive it (upstream defaults pointer-events to "none" + // when context.pointerSticky === false). AFTER the transform branch on + // purpose: upstream applies the default to everything rendered under a + // pointer render, so a transform that wraps next()'s output must carry it on + // that wrapper, which is the root this slot renders. + if (!sel.sticky) jsx = defaultPointerEventsNone(jsx) as ReactElement; + const handlers = getHandlers?.(mark); + if (handlers) jsx = attachMarkHandlers(jsx, markData, handlers, () => getHandlers(mark)); + let out = (clipReg ? clipReg.wrap(jsx, mark, dims, context) : jsx) as ReactElement; + // One facet of a promoted ARIA group (renderMarksWith's faceted branch): + // drop the attributes now carried by the shared parent and take the facet's + // cell transform instead of the mark transform. + if (facetCell !== undefined) out = promoteFacetChild(out, facetCell) as ReactElement; + // The store needs the rendered root to answer "was this pointerdown inside an + // already-pinned mark?". rootRef is bound once per record, so attaching it + // costs no detach/attach churn on a re-render. + return cloneElement(out, {ref: reg.rootRef} as any); +} + +// PlotSvg mounts a whenever the plot contains any pointer +// consumer, and renderMarks picks by that very same +// predicate, so the context is always populated. Assert rather than degrade +// into a mark that renders correctly and can never be selected. +function pointerStoreOf(store: PointerStore | null): PointerStore { + if (store == null) throw new Error("PointerMarkSlot: a pointer-consumer mark rendered outside a "); + return store; +} + +// The index a pointer consumer renders: the selected datum alone, or — until +// the pointer has awarded one — none at all (upstream's `const I = i == null ? +// [] : [i]`, pointer.js). The facet markers travel with it either way, so a +// faceted mark still knows its cell: a tip reads index.fx/index.fy to report +// the facet channels. `fi` is the marker, exactly as upstream tests it (`const +// faceted = index.fi != null`) — a plot faceted only by fy has no fx at all. A +// channel-less mark's index is null and stays null: it has nothing to +// hit-test, and `[sel.i]` would invent a datum for it. Shared with the static +// renderer, which renders every pointer consumer at rest. +export function pointerIndex(index: any, selected?: number | null): any { + if (index == null) return null; + const out: any = selected == null ? [] : [selected]; + if (index.fi != null) (out.fx = index.fx), (out.fy = index.fy), (out.fi = index.fi); + return out; +} + +// Coerces an index to a plain Array, preserving the facet markers. Marks call +// (index as number[]).map(...), but `index` is often a TypedArray (e.g. +// Uint32Array), whose .map() coerces the returned React elements back to +// numbers and corrupts the output. Shared with the static renderer, which +// renders the same indexes through the same marks. +export function plainIndex(index: any): any { + if (index == null || !ArrayBuffer.isView(index)) return index; + const {fx, fy, fi} = index as any; + return Object.assign(Array.from(index as any), {fx, fy, fi}); +} + +// Attaches the registered handlers to a mark's rendered JSX. A mark that +// renders one element per datum tags each element with its datum's index +// (perDatum.ts); those children get handlers with their datum and index closed +// over, and any other child — a marker , a grouped mark's per-series +// path, a text child — is left untagged and so is covered by the mark-level +// handler on the root instead, with datum/index undefined. The child count is +// deliberately not consulted: a line with markers on three points in two +// series has as many children as data, which is how a came to be handed +// datum 0 and every series path shifted by one. +// +// The children are read raw rather than through Children.toArray, which +// rewrites each element's key and so hands back objects that are not the ones +// the mark tagged. Handler identity is read through `live` at dispatch time, +// so identity-only updates (which don't rebuild the plot) still take effect. function attachMarkHandlers( jsx: ReactElement, - index: number[] | null, data: any, attached: MarkEventHandlers, live: () => MarkEventHandlers | undefined ): ReactElement { if (!isValidElement(jsx)) return jsx; - const children = Children.toArray((jsx.props as any).children); - if (index != null && children.length === index.length && children.every((c) => isValidElement(c))) { - return cloneElement( - jsx, - undefined, - children.map((child, k) => - cloneElement(child as ReactElement, handlerProps(attached, live, data?.[index[k]], index[k])) - ) - ); + const children = (jsx.props as any).children; + const list = Array.isArray(children) ? children : [children]; + if (!list.some((child) => datumIndexOf(child) !== undefined)) { + return cloneElement(jsx, handlerProps(attached, live, undefined, undefined)); } - return cloneElement(jsx, handlerProps(attached, live, undefined, undefined)); + return cloneElement( + jsx, + undefined, + list.map((child) => { + const i = datumIndexOf(child); + return i === undefined ? child : cloneElement(child as ReactElement, handlerProps(attached, live, data?.[i], i)); + }) + ); } function handlerProps( @@ -803,19 +1558,6 @@ function handlerProps( return props; } -// Marks driven by the pointer interaction. Their render is wrapped by -// pointer.js's composeRender (which manages an internal "selected index" -// via D3 event subscriptions on the imperative path). On the React path, -// PointerRoot tracks the selection in React state; pointer-consumer marks -// render with an empty index by default and are overridden to render the -// selected datum when PointerRoot reports a hit. -// Stable id for a pointer registration. Combines aria-label and facet so -// each (mark, facet) pair gets its own slot; the ariaLabel is sufficient -// to disambiguate Tip from crosshair sub-marks within a single Plot. -function pointerRegistrationId(mark: any, fi: number | null): string { - return `${mark.ariaLabel ?? mark.constructor?.name ?? "?"}#${fi ?? "-"}`; -} - // Mirrors applyIndirectStyles' pointer-events default (upstream style.js): // when the pointer context is not sticky, a pointer-driven mark's root group // gets pointer-events="none" so it never intercepts the pointer events that @@ -839,13 +1581,79 @@ export function isPointerConsumer(mark: any): boolean { return false; } -function sameScaleKeys(a: Record | undefined, b: Record | undefined): boolean { - if (a === b) return true; - if (!a || !b) return false; - const ka = Object.keys(a); - const kb = Object.keys(b); - if (ka.length !== kb.length) return false; - for (const k of ka) if (!(k in b)) return false; +// Records that `id` registered in this commit, last-wins. React runs layout +// effects child first and siblings in tree order, so the recorded list is the +// registration order the children's own render produced; last-wins (rather +// than a plain push) keeps one entry per id when StrictMode double-invokes an +// effect and when a component's effect runs more than once in a commit. +function recordOrder(pendingRef: {current: string[]}, id: string): void { + const pending = pendingRef.current; + const at = pending.indexOf(id); + if (at !== -1) pending.splice(at, 1); + pending.push(id); +} + +// Retires a registration whose component unmounted. It stays in the registry — +// and so keeps its place in the order — until the reconciliation below, which +// is the only stage that can tell the two ways a registration can be destroyed +// apart. StrictMode's simulated unmount destroys and recreates every effect it +// finds (create, destroy, create), so a mark that mounted in this commit is +// unregistered and re-registered within it; a real unmount is unregistered and +// left alone. Dropping the entry on the spot loses the first case's position: +// the entry leaves the middle of the registry and the re-registration appends +// it at the END, which is the children-order inversion #145 is about, arriving +// by the back door. Returns whether there was anything to retire. +function retireRegistration( + retiredRef: {current: Set}, + registryRef: {current: Map}, + id: string +): boolean { + if (!registryRef.current.has(id)) return false; + retiredRef.current.add(id); + return true; +} + +// Deletes the registrations that were retired in this commit and did not come +// back. A retirement counts as "came back" when the same commit also recorded a +// registration for the id — which child effects do before this effect runs, so +// the pending list is the register/destroy ledger for the commit. Returns +// whether the registry lost anything. +function sweepRetired( + retiredRef: {current: Set}, + pendingRef: {current: string[]}, + registryRef: {current: Map} +): boolean { + const retired = retiredRef.current; + if (retired.size === 0) return false; + retiredRef.current = new Set(); + const pending = pendingRef.current; + let changed = false; + for (const id of retired) { + if (pending.includes(id)) continue; + if (registryRef.current.delete(id)) changed = true; + } + return changed; +} + +// Adopts the order a commit recorded into a registry. A registry's Map key +// order freezes at MOUNT order — React moves keyed instances without +// remounting them, and every effect here is depless, so an instance's +// registration is only re-set when the instance itself re-renders — while the +// plot must draw in CHILDREN order (#145). The recorded order reveals that +// order only when the commit re-registered the WHOLE registry (i.e. every +// child re-rendered); a partial commit — one mark re-rendered, or a mark newly +// mounted by its wrapper — cannot see its siblings, so it keeps the existing +// order and the new registrations append. Returns whether the registry +// changed. +function adoptOrder(pendingRef: {current: string[]}, registryRef: {current: Map}): boolean { + const pending = pendingRef.current; + if (pending.length === 0) return false; + pendingRef.current = []; + const registry = registryRef.current; + if (pending.length !== registry.size || !pending.every((id) => registry.has(id))) return false; + const ordered = [...registry.keys()]; + if (!pending.some((id, i) => id !== ordered[i])) return false; + registryRef.current = new Map(pending.map((id) => [id, registry.get(id)!])); return true; } @@ -862,10 +1670,98 @@ function subarray(index: any): any { return index.slice ? index.slice() : Array.from(index); } +// Whatever this returns is what decides whether the plot recomputes, which +// makes two failure modes equally fatal: a key that misses a real change leaves +// the plot stale, and a key that differs when nothing changed drives the +// compute effect's setMode in a loop until React gives up. JSON.stringify is +// the fast path, and its replacer records a function-valued option as present +// (JSON would drop it) without recording its identity. A value JSON REFUSES — +// a BigInt, a reference back into a container it is already inside — used to +// fall back to Math.random(), i.e. a key that differed on every render, which +// is the loop (#150). The fallback below serializes those by value instead. function stableKey(options: Record): string { try { return JSON.stringify(options, (_k, v) => (typeof v === "function" ? "[fn]" : v)); } catch { - return String(Math.random()); + return stableKeyUnserializable(options); } } + +// A JSON-shaped serializer for the values JSON.stringify refuses, so the key it +// produces still changes when the REST of the options change (a frozen key +// would silence the loop by breaking every recompute). Structure is walked +// exactly as JSON would walk it — objects by sorted key, arrays by index, the +// JSON representation of every primitive — with the three things JSON has no +// syntax for spelled out: functions as "[fn]" (matching the replacer above), +// bigints by their digits, and a reference back to an object already on this +// path as "[ref:n]", which is both how a cycle terminates and how two +// cyclically-equal structures produce the same key. +// +// `depth` guards one hole the back-reference test cannot: a toJSON that returns +// a FRESH object every call would nest arbitrarily deep, each level invisible +// to the other. Past the cap the value degrades to "[deep]", which is a key +// that ignores deeper changes — the safer of the two failure modes, since a +// stale key only delays a recompute that the next shallow change will trigger. +const stableKeyDepthCap = 16; + +function stableKeyUnserializable(value: unknown, seen: any[] = [], depth = 0): string { + if (value === null) return "null"; + switch (typeof value) { + case "string": + case "number": + case "boolean": + return JSON.stringify(value); + case "bigint": + return `[bigint:${value.toString()}]`; + case "undefined": + return "[undefined]"; + case "function": + return "[fn]"; + case "symbol": + return `[symbol:${String(value.description)}]`; + } + const object = value as any; + // toJSON is what JSON.stringify consults first, so it is consulted here too + // rather than reporting a Date (say) and a plain object alike. + if (typeof object.toJSON === "function") { + try { + return stableKeyUnserializable(object.toJSON(), seen, depth + 1); + } catch { + // A toJSON that throws is just another unreadable value; fall through. + } + } + const at = seen.indexOf(object); + if (at !== -1) return `[ref:${at}]`; + if (depth >= stableKeyDepthCap) return "[deep]"; + try { + seen.push(object); + if (Array.isArray(object)) { + return `[${object.map((d) => stableKeyUnserializable(d, seen, depth + 1)).join(",")}]`; + } + return `{${Object.keys(object) + .sort() + .map((k) => `${JSON.stringify(k)}:${stableKeyUnserializable(object[k], seen, depth + 1)}`) + .join(",")}}`; + } catch { + // Nothing about the value could be read at all — a throwing getter, a Proxy + // that refuses enumeration. Identity is all that is left to key on, and it + // is enough: the same reference keeps the same number, so the key is stable + // for props that did not change. + return `[unreadable:${identityKey(object)}]`; + } finally { + seen.pop(); + } +} + +// The identity sequence behind that last resort. A WeakMap so a value that is +// dropped is not held alive by having been keyed once. +const identityKeys = new WeakMap(); +let identityKeySeq = 0; + +function identityKey(value: object): number { + const found = identityKeys.get(value); + if (found !== undefined) return found; + const next = ++identityKeySeq; + identityKeys.set(value, next); + return next; +} diff --git a/src/react/index.tsx b/src/react/api.tsx similarity index 93% rename from src/react/index.tsx rename to src/react/api.tsx index 8558677d..8b95b8cd 100644 --- a/src/react/index.tsx +++ b/src/react/api.tsx @@ -1,5 +1,11 @@ // replot/react — React component API for Replot // +// This module is the package's "./react" entry point (see the `exports` map in +// package.json, the "replot/react" path in tsconfig.json and the docs alias in +// docs/vite.docs.config.ts). It is named api rather than index because the +// project does not use index.* modules: a re-export surface should say what it +// is. Its own re-exports are grouped below by the layer they come from. +// // Usage: // import { Replot, Dot, Line, BarY, AxisX, AxisY } from "@dave-hillier/replot/react"; // @@ -42,6 +48,10 @@ export { resolveStyles } from "./styles.js"; +// Per-datum element identity (for building custom marks): tag the element a +// custom mark renders for a datum so that the datum reaches its handlers. +export {withDatumIndex} from "./perDatum.js"; + // Mark components export {Dot, DotX, DotY, Circle, Hexagon} from "./marks/Dot.js"; export type {DotProps, DotXProps, DotYProps, CircleProps, HexagonProps} from "./marks/Dot.js"; @@ -164,16 +174,12 @@ export {Legend} from "./legends/Legend.js"; export type {LegendProps} from "./legends/Legend.js"; // Interaction components -export {Tip, formatTip} from "./interactions/Tip.js"; +export {Tip} from "./interactions/Tip.js"; export type {TipProps} from "./interactions/Tip.js"; export {Crosshair, CrosshairX, CrosshairY} from "./interactions/Crosshair.js"; export type {CrosshairProps} from "./interactions/Crosshair.js"; -// Interaction hooks -export {usePointer, findNearest} from "./interactions/usePointer.js"; -export type {PointerState, UsePointerOptions} from "./interactions/usePointer.js"; - // Transform components (wrappers composing via TransformContext) export {TransformContext, useTransformContext} from "./TransformContext.js"; export type {TransformContextValue} from "./TransformContext.js"; diff --git a/src/react/clip.tsx b/src/react/clip.tsx index 3b4cd94e..62de62fa 100644 --- a/src/react/clip.tsx +++ b/src/react/clip.tsx @@ -19,6 +19,17 @@ export interface ClipRegistry { register(mark: any, dims: any, context: any): void; // Wraps/annotates a rendered mark node with its clip-path, if any. wrap(node: ReactElement | null, mark: any, dims: any, context: any): ReactNode; + // The next clip id for this render. A mark that emits its OWN + // defs — the difference mark, whose clip geometries are the areas it is + // differencing — must take its ids from here rather than from style.js's + // module-global counter: that counter is not reset per render, so a fresh + // process hands the mark the id this registry already gave the frame clip, + // and two elements sharing an id make url(#…) resolve to whichever one the + // document reaches first (the frame clip, for a difference plot) instead of + // the mark's own. Allocating here is also what makes the ids stable across + // re-renders: the registry is created per render and allocates in render + // order, so a re-render of the same computed plot reproduces the same ids. + clipId(): string; } // Pre-registers every mark's clip so the defs are populated before @@ -34,16 +45,25 @@ export function registerClips(computed: any, clipReg: ClipRegistry): void { const SPHERE = {type: "Sphere"}; -export function createClipRegistry(): ClipRegistry { +// Creates the clip registry for ONE render of a plot. `context` is the plot's +// context (src/plot.ts's computePlot), which the registry exposes itself on: +// a mark's renderJSX is handed the context and nothing else, so it is the only +// channel a mark has back to the render building the , and a mark that +// emits its own defs needs the render's id counter (see clipId). +export function createClipRegistry(context?: any): ClipRegistry { const defs: ReactNode[] = []; let nextId = 0; const frameCache = new Map(); const geoCache = new Map(); + function clipId(): string { + return `plot-clip-${++nextId}`; + } + function frameClipUrl(dims: any): string { let url = frameCache.get(dims); if (url === undefined) { - const id = `plot-clip-${++nextId}`; + const id = clipId(); const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dims; defs.push( h( @@ -67,7 +87,7 @@ export function createClipRegistry(): ClipRegistry { if (geo.type === "Sphere") geo = SPHERE; // coalesce all spheres let url = geoCache.get(geo); if (url === undefined) { - const id = `plot-clip-${++nextId}`; + const id = clipId(); defs.push(h("clipPath", {key: id, id}, h("path", {d: context.path()(geo)}))); url = `url(#${id})`; geoCache.set(geo, url); @@ -81,8 +101,9 @@ export function createClipRegistry(): ClipRegistry { return mark.clip === undefined ? context?.clip : mark.clip; } - return { + const registry: ClipRegistry = { defs, + clipId, register(mark, dims, context) { const clip = clipOf(mark, context); if (!clip) return; @@ -110,4 +131,6 @@ export function createClipRegistry(): ClipRegistry { return cloneElement(node, {clipPath: geoClipUrl(clip, context)} as any); } }; + if (context != null) context.clipRegistry = registry; + return registry; } diff --git a/src/react/domToJsx.tsx b/src/react/domToJsx.tsx index de9b306e..96402e5f 100644 --- a/src/react/domToJsx.tsx +++ b/src/react/domToJsx.tsx @@ -45,16 +45,23 @@ function reactAttributeName(name: string): string { // React requires the style prop to be an object; other attributes pass // through verbatim (React renders unrecognized attributes as-is). function attributeValue(name: string, value: string): unknown { - if (name !== "style") return value; - const style: Record = {}; - for (const declaration of value.split(";")) { + return name === "style" ? parseStyleString(value) : value; +} + +// Parses a CSS declaration list — the value of a style attribute, or the plot's +// style option when it is given as a string — into the object React wants for +// the style prop. Custom properties are kept verbatim, everything else is +// camelCased, which is what React expects and what it serializes back. +export function parseStyleString(style: string): Record { + const parsed: Record = {}; + for (const declaration of style.split(";")) { const colon = declaration.indexOf(":"); if (colon === -1) continue; const property = declaration.slice(0, colon).trim(); if (!property) continue; - style[property.startsWith("--") ? property : camelCase(property)] = declaration.slice(colon + 1).trim(); + parsed[property.startsWith("--") ? property : camelCase(property)] = declaration.slice(colon + 1).trim(); } - return style; + return parsed; } function camelCase(property: string): string { diff --git a/src/react/index.d.ts b/src/react/index.d.ts deleted file mode 100644 index 91fd0c5a..00000000 --- a/src/react/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Type declarations for replot/react -export * from "./index.js"; diff --git a/src/react/interactions/PointerContext.tsx b/src/react/interactions/PointerContext.tsx index ce7e2f0d..4453a585 100644 --- a/src/react/interactions/PointerContext.tsx +++ b/src/react/interactions/PointerContext.tsx @@ -1,161 +1,59 @@ -import React, {createContext, useContext, useEffect, useRef, useState, type ReactNode, type RefObject} from "react"; -import {findNearest} from "./usePointer.js"; - -// PointerRoot tracks which datum (if any) is currently selected by the -// pointer in a Plot, and exposes that selection to descendant components -// (e.g., Tip) via PointerContext. This replaces the imperative D3 event -// subscription in src/interactions/pointer.js for the React entrypoint. +import {createContext, useEffect, type ReactNode, type RefObject} from "react"; +import {type PointerStore} from "./pointerStore.js"; + +// The React seam of the pointer interaction. Everything that decides WHICH +// datum is selected lives in the React-free store (./pointerStore.ts) over the +// pure hit test (./pointerHitTest.ts), which in turn calls the helpers +// extracted from upstream's own closure in src/interactions/pointer.js. This +// file does one thing and no more: it wires the 's pointer events into the +// store owns, and publishes that store to the marks below it. // -// Pointer-driven marks (Tip and the sub-marks of Crosshair) register a -// channel set with the provider; on each pointermove, the provider runs -// findNearest() per registered consumer, picks the closest across all -// registrations, and updates the selected index. Sticky toggle, RAF -// coalescing across facets, and pointer-leave clearing mirror pointer.js's -// behavior. - -export interface PointerRegistration { - // Stable id for this registration (a per-mark useId or constructor key). - id: string; - // Index of data rows this consumer can select from. - index: ArrayLike; - // Channel values (typically the mark's resolved values, with x/y arrays). - values: Record; - // X/Y weighting (kx/ky from pointerK) — defaults to 1/1 (xy mode). - kx?: number; - ky?: number; - // Maximum selection radius in pixels. - maxRadius?: number; - // Facet id (from index.fi); null when unfaceted. - fi?: number | null; -} - -export interface PointerSelection { - // The selected datum index (within `index`) or null if no hover. - i: number | null; - sticky: boolean; - // The facet id of the current selection (null when unfaceted). - fi: number | null; -} - -interface PointerContextValue { - register: (reg: PointerRegistration) => () => void; - // Returns the current selection for a given registration id, or null. - selectionFor: (id: string) => PointerSelection | null; -} - -export const PointerContext = createContext(null); - -export function usePointerContext(): PointerContextValue | null { - return useContext(PointerContext); -} - -interface PointerRootProps { +// The context value is the STORE ITSELF, created once and never replaced, so +// its identity never changes. Consuming the context therefore cannot re-render +// a mark: the only thing that reaches React on a pointer move is the +// useSyncExternalStore subscription in , and only for the +// slots whose selection actually changed. +export const PointerContext = createContext(null); + +export interface PointerRootProps { + /** + * The plot's store. Owned by rather than created here, because it + * has to outlive this component — the last pointer consumer can be removed + * from a plot that stays — and because the commit-settling call that + * re-resolves it (settle) belongs in the layout effect that runs after the + * plot's root element is attached, which is 's, not this one's. + */ + store: PointerStore; svgRef: RefObject; + /** The current callback, read through a ref at dispatch time. */ + onValueRef?: RefObject<((value: any) => void) | undefined>; children?: ReactNode; } -interface InternalState { - // The winning registration id and index, plus its facet. - winnerId: string | null; - winnerI: number | null; - winnerFi: number | null; - sticky: boolean; -} - -// Wraps the plot's with a context provider; subscribes to pointer -// events on the imperatively-attached svg ref and exposes the current -// selection via context. Renders no DOM of its own. -export function PointerRoot({svgRef, children}: PointerRootProps) { - const [state, setState] = useState({winnerId: null, winnerI: null, winnerFi: null, sticky: false}); - const registrations = useRef>(new Map()); - const rafRef = useRef(0); - const stickyRef = useRef(false); - stickyRef.current = state.sticky; - - const register = (reg: PointerRegistration) => { - registrations.current.set(reg.id, reg); - return () => { - registrations.current.delete(reg.id); - }; - }; - - const selectionFor = (id: string): PointerSelection | null => { - if (state.winnerId !== id) return null; - return {i: state.winnerI, sticky: state.sticky, fi: state.winnerFi}; - }; - +/** + * Provides the pointer store to the marks rendered inside the plot's `` + * and subscribes that `` to the pointer events that drive it. Renders no + * DOM of its own. + */ +export function PointerRoot({store, svgRef, onValueRef, children}: PointerRootProps) { + // A PASSIVE effect, deliberately. mounts INSIDE the , and + // a child's LAYOUT effect runs before the parent host element's ref is + // attached, so svgRef.current is still null at that point. Passive effects + // run after the whole tree has committed, by which time it is populated. useEffect(() => { const svg = svgRef.current; - if (!svg) return; - - const computeWinner = (px: number, py: number) => { - let bestId: string | null = null; - let bestI: number | null = null; - let bestFi: number | null = null; - let bestDist = Infinity; - for (const reg of registrations.current.values()) { - const idx = Array.isArray(reg.index) ? (reg.index as number[]) : Array.from(reg.index); - if (!idx.length) continue; - const mode = (reg.kx ?? 1) === 1 && (reg.ky ?? 1) === 1 ? "xy" : (reg.ky ?? 1) < 1 ? "x" : "y"; - const i = findNearest(idx, reg.values, px, py, mode as "xy" | "x" | "y"); - if (i == null) continue; - const X = reg.values?.x; - const Y = reg.values?.y; - const dx = X?.[i] != null ? (X[i] as number) - px : 0; - const dy = Y?.[i] != null ? (Y[i] as number) - py : 0; - const d = dx * dx + dy * dy; - const max = (reg.maxRadius ?? 40) ** 2; - if (d > max) continue; - if (d < bestDist) { - bestDist = d; - bestId = reg.id; - bestI = i; - bestFi = reg.fi ?? null; - } - } - return bestId == null ? null : {id: bestId, i: bestI as number, fi: bestFi}; - }; - - const onMove = (event: PointerEvent) => { - if (stickyRef.current) return; - if (event.pointerType === "mouse" && event.buttons === 1) return; - const rect = svg.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(() => { - rafRef.current = 0; - const w = computeWinner(px, py); - setState((prev) => { - if (w == null) { - if (prev.winnerId == null) return prev; - return {winnerId: null, winnerI: null, winnerFi: null, sticky: prev.sticky}; - } - if (prev.winnerId === w.id && prev.winnerI === w.i && prev.winnerFi === w.fi) return prev; - return {winnerId: w.id, winnerI: w.i, winnerFi: w.fi, sticky: prev.sticky}; - }); - }); - }; - - const onLeave = (event: PointerEvent) => { - if (event.pointerType !== "mouse") return; - if (stickyRef.current) return; - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = 0; - setState((prev) => - prev.winnerId == null ? prev : {winnerId: null, winnerI: null, winnerFi: null, sticky: false} - ); - }; - - const onDown = (event: PointerEvent) => { - if (event.pointerType !== "mouse") return; - setState((prev) => { - if (prev.sticky) return {winnerId: null, winnerI: null, winnerFi: null, sticky: false}; - if (prev.winnerId == null) return prev; - return {...prev, sticky: true}; - }); - }; - + // Assert rather than returning early: a silent bail-out here leaves a plot + // that renders correctly and never responds to the pointer, which is the + // hardest possible failure to notice. + if (svg == null) throw new Error("PointerRoot: the plot's was not attached before the effect ran"); + store.setOnValue((value) => onValueRef?.current?.(value)); + const onMove = (event: Event) => store.move(svg, event); + const onDown = (event: Event) => store.down(event); + const onLeave = (event: Event) => store.leave(event); + // One set of listeners per plot, where upstream attaches one set per + // rendered (mark, facet). A single listener sees every registration, so + // pool and facet exclusivity resolve inside the move-coalescing frame + // instead of needing upstream's second animation frame. svg.addEventListener("pointerenter", onMove); svg.addEventListener("pointermove", onMove); svg.addEventListener("pointerdown", onDown); @@ -165,9 +63,9 @@ export function PointerRoot({svgRef, children}: PointerRootProps) { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerdown", onDown); svg.removeEventListener("pointerleave", onLeave); - if (rafRef.current) cancelAnimationFrame(rafRef.current); + store.dispose(); }; - }, [svgRef]); + }, [store, svgRef, onValueRef]); - return {children}; + return {children}; } diff --git a/src/react/interactions/Tip.tsx b/src/react/interactions/Tip.tsx index 56089078..a657a88f 100644 --- a/src/react/interactions/Tip.tsx +++ b/src/react/interactions/Tip.tsx @@ -2,38 +2,19 @@ import {useMark} from "../useMark.js"; import type {MarkProps} from "../markProps.js"; import {tip} from "../../marks/tip.js"; import type {TipOptions} from "../../marks/tip.js"; -import {formatDefault} from "../../core/index.js"; // Mark-specific options come from the imperative options interface; the // shared MarkProps base contributes data and keeps the surface open (see // markProps.ts for the openness rationale). export interface TipProps extends MarkProps, TipOptions {} -// Registers the imperative Tip mark with the enclosing ; Plot's -// MarkSlot detects pointer-consumer marks and routes their rendering -// through PointerContext (see ./PointerContext.tsx). With no pointer hover, -// Tip renders an empty `` group; on hover, MarkSlot -// substitutes the selected datum's index and tip.renderJSX produces the -// tooltip content. +// Registers the imperative Tip mark with the enclosing . A tip is a +// pointer consumer, so 's renderMarks routes it through +// (see ./PointerContext.tsx and ./pointerStore.ts): with no +// pointer hover it renders an empty `` group, and on hover +// the slot substitutes the selected datum's index and tip.renderJSX produces +// the tooltip content. export function Tip({data, ...options}: TipProps) { useMark({name: "tip", data, options, create: tip}); return null; } - -// Utility: format a tooltip's content from a datum -export function formatTip(datum: any, channels?: string[]): string[] { - if (datum == null) return []; - const lines: string[] = []; - if (typeof datum === "object") { - const keys = channels ?? Object.keys(datum); - for (const key of keys) { - const value = datum[key]; - if (value != null) { - lines.push(`${key}: ${formatDefault(value)}`); - } - } - } else { - lines.push(`${formatDefault(datum)}`); - } - return lines; -} diff --git a/src/react/interactions/TipItem.tsx b/src/react/interactions/TipItem.tsx new file mode 100644 index 00000000..fc0cc310 --- /dev/null +++ b/src/react/interactions/TipItem.tsx @@ -0,0 +1,80 @@ +// The measurement seam for one tip item. +// +// Upstream's tip renders every item once with placeholder text metrics and +// then, in postrender, reads the exact size off the committed element with +// getBBox, re-lays the box out and — when no anchor was given — re-orients it +// to fit the frame (tip.js:212-245). A mark's renderJSX has no postrender +// hook, so the equivalent runs here: a layout effect measures the committed +// text, and the item re-renders with the measurement, which renderJSX turns +// into exactly the geometry postrender would have written. +// +// The measurement travels as a render prop rather than through a wrapper +// element: the item element IS the tip box's (it carries the transform +// and the direct styles), and an extra wrapper would appear in the output and +// in every snapshot. TipItem therefore attaches its ref to the element the +// caller returns, and measures through that. +// +// What is measured is the item's , not the item: the item also holds the +// box's , which is laid out FROM the measurement and so has no d +// attribute to measure in the first place. The text's box is the same +// {x, width, height} upstream reads off the group, because the un-laid-out +// path contributes nothing to the group's box. + +import { + cloneElement, + isValidElement, + useLayoutEffect, + useRef, + useState, + type ReactElement, + type ReactNode +} from "react"; + +/** The measured size of one tip item's text, in the item's own coordinates. */ +export interface TipItemSize { + /** The left edge of the text, relative to the anchor point. */ + x: number; + /** The width of the widest line, rounded (upstream rounds for crisp edges). */ + width: number; + /** The height of all the lines, rounded. */ + height: number; +} + +/** + * Renders one tip item, passing the measured text size to `render` as soon as + * a layout engine can produce one. `render` is called with null until then, + * which is every render on the server, in jsdom, and in a static snapshot. + */ +export function TipItem({render}: {render: (measured: TipItemSize | null) => ReactNode}): ReactNode { + const ref = useRef(null); + const [measured, setMeasured] = useState(null); + // No dependency array: the item re-renders when a different datum's text + // arrives, and the measurement has to follow the committed text each time. + useLayoutEffect(() => { + const text = ref.current?.querySelector("text") as SVGTextElement | null; + if (typeof text?.getBBox !== "function") return undefined; + let box: {x: number; y: number; width: number; height: number}; + try { + box = text.getBBox(); + } catch { + return undefined; // there is no layout engine after all; keep the estimate + } + if (!Number.isFinite(box.width) || !Number.isFinite(box.height)) return undefined; + const width = Math.round(box.width); + const height = Math.round(box.height); + // Only the first measurement sees the text where the caller put it: the + // geometry the first render writes shifts every line by -x, so a later + // measurement reads that shift back off (box.x + x). An unchanged size + // returns the very same object, because a fresh one would re-render + // forever. + setMeasured((previous) => + previous != null && previous.width === width && previous.height === height + ? previous + : {x: box.x + (measured?.x ?? 0), width, height} + ); + }); + const node = render(measured); + // A host element is required — the caller's item is what gets measured — and + // a non-element (a string, say) has nothing to attach to. + return isValidElement(node) ? cloneElement(node as ReactElement, {ref} as any) : node; +} diff --git a/src/react/interactions/pointerHitTest.ts b/src/react/interactions/pointerHitTest.ts new file mode 100644 index 00000000..d9e883e7 --- /dev/null +++ b/src/react/interactions/pointerHitTest.ts @@ -0,0 +1,86 @@ +import {pointer as pointof} from "d3"; +import {pointerAnchors, pointerOffsets, pointerSearch} from "./pointerUpstream.js"; + +// The pure hit-test half of the React pointer interaction: no React, no store, +// and no DOM beyond the single coordinate conversion in svgPoint. Every piece of +// arithmetic here comes from src/interactions/pointer.js, so the imperative +// pointer closure and the React path cannot drift apart. + +/** The per-registration state that {@link nearest} needs to run a search. */ +export interface HitTestTarget { + index: ArrayLike & Iterable; + px: (i: number) => number; + py: (i: number) => number; + tx: number; + ty: number; + kx: number; + ky: number; + maxRadius: number; + dimensions: any; +} + +/** + * Precomputes everything about one rendered (mark, facet) that does not depend + * on the pointer position: the facet and band-scale correction to subtract from + * an event, and the two target-position accessors. Called once per registration + * rather than once per pointer move. + */ +export function computeAnchors( + mark: any, + scales: any, + values: any, + dimensions: any, + index: any +): {tx: number; ty: number; px: (i: number) => number; py: (i: number) => number} { + const [tx, ty] = pointerOffsets(index, scales, dimensions); + const [px, py] = pointerAnchors(mark, values, dimensions); + return {tx, ty, px, py}; +} + +/** + * Reads the *kx*, *ky* and *maxRadius* that the pointer transform stamped onto + * the composed render function. They are otherwise trapped in the pointer + * closure, which the React path never executes. The defaults match pointer()'s + * own: isotropic distance within 40 pixels. + */ +export function pointerKOf(mark: any): {kx: number; ky: number; maxRadius: number} { + const k = mark?.render?.pointerK; + return {kx: k?.kx ?? 1, ky: k?.ky ?? 1, maxRadius: +(k?.maxRadius ?? 40)}; +} + +/** + * Converts a pointer event into the SVG's own user coordinates. Upstream uses + * d3.pointer, which is createSVGPoint + getScreenCTM().inverse() in a browser — + * so the CSS scaling from Plot's injected max-width:100% cannot skew the hit + * test — and falls back to getBoundingClientRect where no CTM is available, as + * in jsdom. + */ +export const svgPoint = (svg: SVGSVGElement, event: any): [number, number] => pointof(event, svg) as [number, number]; + +/** + * Runs upstream's search for one registration at the SVG-space point + * (*x0*, *y0*), correcting the POINTER by the registration's facet and + * band-scale offsets — the channel values are facet-local, so the event moves, + * not the data. + * + * A miss is reported exactly as upstream reports it: `ii` null with `ri` equal + * to *maxRadius* squared, the value pointerSearch's accumulator still holds + * when nothing came inside the radius. That number is not a sentinel; upstream + * enters it into the pool alongside the hits (pointer.js:134-140) and lets it + * compete, so a miss can win its group and blank every mark in it. Reporting a + * miss as Infinity instead — as an earlier revision did — silently drops it out + * of the arbitration and shows a datum wherever upstream shows nothing. + */ +export function nearest(reg: HitTestTarget, x0: number, y0: number): {ii: number | null; ri: number} { + return pointerSearch( + reg.index, + reg.px, + reg.py, + x0 - reg.tx, + y0 - reg.ty, + reg.kx, + reg.ky, + reg.maxRadius, + reg.dimensions + ); +} diff --git a/src/react/interactions/pointerStore.ts b/src/react/interactions/pointerStore.ts new file mode 100644 index 00000000..699d0c38 --- /dev/null +++ b/src/react/interactions/pointerStore.ts @@ -0,0 +1,621 @@ +import {nearest, svgPoint} from "./pointerHitTest.js"; + +// The registration and selection layer of the React pointer interaction. A +// plain object graph: no React import, no DOM beyond the event objects handed +// to it, and unit-testable on its own (test/pointer-store-test.ts). +// +// A registration is one rendered (mark, facet) pair — exactly upstream's +// renderIndex slot in src/interactions/pointer.js. Identity is the RECORD +// OBJECT, created once per slot instance, so there is no string key anywhere: +// two tips, or the two "crosshair rule" and two "crosshair text" sub-marks of a +// pair of crosshairs, cannot collide however they are labelled. + +/** One slot's current selection: the focused index, and whether it is pinned. */ +export interface PointerSelection { + readonly i: number | null; + readonly sticky: boolean; +} + +/** The shared empty selection. Frozen and shared so identity is stable. */ +export const NONE: PointerSelection = Object.freeze({i: null, sticky: false}); + +export interface Registration { + /** + * The slot's render order: `markIndex * facetCount + facetOrdinal`, so that + * it is strictly increasing in document order and — unlike a fixed stride — + * cannot collide however many facets a plot has. The registry is kept sorted + * by it so that the two order-sensitive upstream behaviours — which mark's + * pooling infects the plot, and which handler claims a pointerdown — are + * decided by render order rather than by the order React fires effects in. + */ + order: number; + mark: any; + /** The facet index, or null when the mark is not faceted. */ + fi: number | null; + index: number[]; + data: any; + dimensions: any; + context: any; + kx: number; + ky: number; + maxRadius: number; + tx: number; + ty: number; + px: (i: number) => number; + py: (i: number) => number; + /** Frozen; its identity changes only when the selection actually changes. */ + sel: PointerSelection; + /** The rendered root element, for the click-inside-a-sticky-mark test. */ + root: Element | null; + listeners: Set<() => void>; + /** Stable and bound at creation, so useSyncExternalStore never resubscribes. */ + subscribe: (fn: () => void) => () => void; + /** Stable. */ + getSnapshot: () => PointerSelection; + /** Stable. Server and imperative plot() renders have no pointer. */ + getServerSnapshot: () => PointerSelection; + /** Stable, so attaching it as a ref causes no detach/attach churn per commit. */ + rootRef: (el: Element | null) => void; +} + +interface Hit { + reg: Registration; + ii: number | null; + ri: number; +} + +/** The single group every registration falls into when the plot pools. */ +const GLOBAL = Symbol("pool"); + +/** A pointer position in the svg's own user space. */ +interface Point { + readonly x: number; + readonly y: number; +} + +export function createPointerStore() { + let regs: Registration[] = []; + let onValue: (v: unknown) => void = () => {}; + let frame = 0; + + // WHERE THE POINTER IS. This is the whole of what survives a plot recompute, + // and it is the reason nothing else has to. + // + // A selection is an INDEX into channel arrays that the next recompute + // reallocates, so a record that keeps one across re-registration is holding a + // number whose meaning has expired. Five rounds of this port tried to + // VALIDATE that number on the way back in — index membership, then the data + // array's reference, then the datum's own identity — and each proxy for + // "still the same datum" broke a legitimate case: membership silently + // re-points the tip at a neighbour when an earlier datum is deleted; the + // array reference dies on every controlled plot, whose parent hands the mark + // a fresh array on the very render the selection caused; and datum identity + // dies under groupX, binX and stackY, which synthesise new datum objects on + // every recompute, and under any mark whose rows are derived in render. + // + // The premise was wrong. Upstream never validates anything because its + // focused index lives in the same closure as its listeners: rebuild the mark + // and the index goes with it. Replot separated them, and re-linking them by + // identity cannot work, because after a transform there IS no identity to + // link. + // + // So the store remembers the pointer instead of the selection. When the + // registry changes, settle() re-runs the ordinary hit test at this point + // against the NEW index, values and anchors — exactly what a pointermove + // would do — and whatever is under the pointer now becomes the selection. + // Transforms, derived rows, reorders, insertions, deletions and filters all + // need no special case, because none of them move the pointer. + // + // null before the pointer has ever entered the plot, and again after it has + // left: in both states there is nothing under the pointer to re-resolve, and + // a stale point would let a recompute conjure a selection the user is not + // pointing at. It is deliberately NOT cleared while sticky (leave() returns + // early there), because a pin is a pin on the place the pointer was when it + // was taken. + let last: Point | null = null; + + // Set by add() and by unregistration, cleared by settle(). The registry + // changes many times within one commit — React runs every layout-effect + // cleanup before any of the creates — so re-resolving inside add() would + // arbitrate against a half-populated registry and dispatch values for + // winners that lose again microseconds later. settle() is called once, by + // , after the whole commit has registered. + let dirty = false; + + // Records whose registration was torn down since the last settle(). Most of + // them are re-registrations of the very same record and are back in `regs` + // by the time settle() looks; the ones that are not are slots that have + // UNMOUNTED, and a record that unmounts still holding a selection is the + // plot ceasing to show a datum, which has to be reported. + const departed: Registration[] = []; + + // The plot's sticky modality is DERIVED from the registered records rather + // than kept as a flag of its own, and that is what makes it impossible to + // wedge. Only the record that CLAIMED the pointerdown carries sticky in its + // selection — down() republishes the claimant alone (pointer.js:195) — so + // asking the registry is asking the owner. A flag would have exactly one way + // to be released, a gesture on the owner, and a record that unmounts and + // never comes back makes no gesture: the flag would stay raised with nobody + // holding it, and move(), leave() and down() would then all return early + // forever (move and leave on sticky, down because the claimant is gone from + // `regs` and whoever is now first is not pointing). Derived, the pin simply + // ceases to exist when its owner leaves the registry or loses its selection, + // and it comes back with the record when a slot re-registers mid-commit. + function isSticky(): boolean { + for (const reg of regs) if (reg.sel.sticky) return true; + return false; + } + + // Created during render (a useState initializer) and deliberately NOT added + // to the registry here: `add` happens in a layout effect, once the record has + // been filled in, so the store can never see a half-built record. + function createRegistration(): Registration { + const reg = { + order: 0, + mark: null, + fi: null, + index: [], + data: null, + dimensions: null, + context: null, + kx: 1, + ky: 1, + maxRadius: 40, + tx: 0, + ty: 0, + px: () => NaN, + py: () => NaN, + sel: NONE, + root: null, + listeners: new Set<() => void>(), + subscribe(fn: () => void) { + reg.listeners.add(fn); + return () => void reg.listeners.delete(fn); + }, + getSnapshot: () => reg.sel, + getServerSnapshot: () => NONE, + rootRef: (el: Element | null) => void (reg.root = el) + } as Registration; + return reg; + } + + /** Registers a filled-in record; returns its unregistration. */ + function add(reg: Registration): () => void { + regs.push(reg); + regs.sort((a, b) => a.order - b.order); + dirty = true; + return () => { + regs = regs.filter((r) => r !== reg); + departed.push(reg); + dirty = true; + // The record's `sel` is deliberately left ALONE here. Unregistration is + // not only a teardown: the registration effect re-runs whenever + // mark.filter reallocates the index, which is every plot recompute, so + // this cleanup is the ordinary path for a slot that is about to + // re-register with the very same record. Clearing here would blank the + // tip on every recompute, and would do it BEFORE the new index exists to + // re-resolve against. settle() decides, once the commit is over: a + // record that came back is re-resolved at the pointer like every other, + // and one that did not is cleared and reported. + }; + } + + /** + * Re-resolves the whole registry after a commit that changed it. + * + * Called once per commit by , from a layout effect that runs AFTER + * every slot's own — React runs layout effects child first — and after the + * plot's root element has been attached to the context's figure holder. Both + * orderings are load-bearing: re-resolving inside add() would arbitrate + * against the half-populated registry a commit passes through, and + * dispatching before the holder is filled would report a value to nothing. + * + * Three things happen, in this order: + * + * 1. every record that unmounted is cleared. Its slot is gone, so the plot + * has stopped SHOWING that datum; it must stop REPORTING it too, or the + * duplicate-value guard below stays poisoned with a datum nothing draws + * and swallows the next genuine selection of it. Only the clearing + * happens here: the report is step 3's, because the reported value is + * the plot's and only the plot can say whether anything still shows. + * 2. the survivors are re-resolved at the last pointer position, which is + * an ordinary hit test — the same arbitration, the same pooling, the + * same publish and dispatch rules as a pointermove. An index that went + * empty, a mark that lost its data, a datum that moved: all of them are + * just "what is under the pointer now", and the answer may be nothing. + * 3. and if the plot is now showing nothing at all while still reporting + * something, it reports the clearing value. This is the invariant the + * whole exercise is about — show and report cannot disagree — and it is + * stated here rather than left to the per-record dispatches above, + * because those reproduce upstream's faceted suppression quirk (see + * suppressesClearingDispatch) and a suppressed clearing would otherwise + * leave the guard holding a vanished datum for good. + * + * Nothing is dispatched for a record whose selection did not change, which is + * what stops a controlled plot from oscillating: re-resolving at an unmoved + * pointer picks the same index it picked before, publish() returns false, and + * the render that the last onValue caused reports nothing new. The cost of + * that rule is stated where it bites, in the note on publish(). + */ + function settle(): void { + if (!dirty) return; + dirty = false; + const gone = departed.filter((reg) => !regs.includes(reg)); + departed.length = 0; + const cleared = gone.filter((reg) => publish(reg, null)); + if (last !== null) resolve(last.x, last.y); + // 3. Nothing showing while something is reported means the report is stale. + // After the re-resolution above, so that a survivor which picked a datum up + // in this commit has already reported that instead, and AT THE PLOT LEVEL + // rather than per departed record, because the per-record dispatches + // reproduce upstream's faceted suppression quirk (see + // suppressesClearingDispatch): a suppressed clearing would leave the guard + // holding a vanished datum for good. The departed record is passed for the + // record-level dispatchValue fallback the store's own unit tests use; with + // no record left at all, the plot-level sink is what reports. + if (lastValue != null && !regs.some((reg) => reg.sel.i != null)) dispatch(null, gone[0]); + // The departed records' own subscribers, for the one path that unregisters + // without unmounting: a slot whose index went null re-runs its effect and + // takes the early return, so it is still listening. + notify(cleared); + } + + // The bail-out that keeps a pointer move from re-rendering the plot: when the + // selection is unchanged the record keeps the identical snapshot object, so + // useSyncExternalStore short-circuits — and we do not even notify. + // `sticky` is passed rather than read off the store: select() and clear() + // only ever run when the plot is NOT sticky (both guard on it), and the one + // place that sets a pin is down(), which sets it on the claimant alone. + // + // The selection is the INDEX and the pin, and nothing else — no datum, no + // provenance. That is what makes the re-resolution in settle() total: there + // is no stored fact about the old data that a transform could invalidate. + // Its one cost is that a record which re-resolves to the same index after a + // recompute does not change, so nothing is dispatched, and a mark whose rows + // are rebuilt per recompute (groupX, binX, `data={rows.map(d => ({...d}))}`) + // goes on reporting the row object it reported before while drawing an equal + // but freshly minted one. Dispatching on the datum's identity instead would + // close that and open something far worse: in a controlled plot each report + // re-renders the parent, which rebuilds the rows, which changes the identity + // again — an unbounded render loop, on precisely the shape this design exists + // to serve. + function publish(reg: Registration, i: number | null, sticky = false): boolean { + if (reg.sel.i === i && reg.sel.sticky === sticky) return false; + reg.sel = i == null && !sticky ? NONE : (Object.freeze({i, sticky}) as PointerSelection); + return true; + } + + function notify(changed: readonly Registration[]): void { + for (const reg of changed) for (const l of reg.listeners) l(); + } + + function datumOf(reg: Registration, i: number): unknown { + const d = reg.data; + return d == null ? null : Array.isArray(d) ? d[i] : typeof d.get === "function" ? d.get(i) : d[i]; + } + + // upstream pointer.js:130 — `const pool = state.pool ?? facetPool`, where + // state.pool is built from `this.pool` of whichever pointer mark renders + // FIRST and then applies to every later mark. That contagion is the + // behaviour, not an accident: a pooled tip rendered first drags every other + // pointer mark into its pool, while a crosshair rendered first leaves + // everything independent. + function globallyPooled(): boolean { + return regs.length > 0 && regs[0].mark?.pool === true; + } + + // upstream pointer.js:172 skips a mark's CLEARING dispatch when + // facetPool.map.size > 1 — and facetPool.map gains an entry on every + // pointermove, misses included (pointer.js:134), so that size counts the + // facets of the mark that have ever been SEARCHED, not the ones currently + // hitting. Every facet of a mark listens on the same , so for a + // two-facet mark the size is 2 from the very first move onward: upstream + // therefore essentially NEVER dispatches a clearing value for a faceted + // mark, and svg.value keeps the stale datum until a different datum replaces + // it. That looks like a bug and it is reproduced here on purpose — do not + // "fix" it back to counting the facets that are currently hitting. + // + // Set members are facet indices (`fi`), which are unique within a mark and + // are plain numbers, so the set retains neither records nor elements. + const searchedFacets = new Map>(); + + // useMark rebuilds its mark instances on every stamp change, so a pointer + // plot bound to changing data walks through a fresh mark object per + // recompute; keying a plain Map on them would accumulate one dead Set per + // mark for the life of the plot. Pruning at EVENT time rather than at + // unregistration is what keeps the persistence semantics intact: within one + // commit React runs every layout-effect cleanup before any of the creates, so + // a mark is briefly absent from `regs` while its own slots re-register, and + // dropping its set there would reset the quirk below on every re-render. No + // event can arrive mid-commit, so at this point `regs` is complete and a mark + // missing from it is genuinely gone. + function pruneSearchedFacets(): void { + if (searchedFacets.size === 0) return; + const live = new Set(regs.map((reg) => reg.mark)); + for (const mark of [...searchedFacets.keys()]) if (!live.has(mark)) searchedFacets.delete(mark); + } + + // upstream pointer.js:130 again: a facetPool is only ever WRITTEN when the + // plot is not globally pooled, because `state.pool ?? facetPool` resolves to + // state.pool in that case and facetPool.map stays empty — size 0, so the + // skip never applies. + function markSearchedFacets(hits: readonly Hit[]): void { + pruneSearchedFacets(); + if (globallyPooled()) return; + for (const h of hits) { + if (h.reg.fi == null) continue; + let searched = searchedFacets.get(h.reg.mark); + if (searched === undefined) searchedFacets.set(h.reg.mark, (searched = new Set())); + searched.add(h.reg.fi); + } + } + + /** Whether upstream would suppress this record's clearing dispatch. */ + function suppressesClearingDispatch(reg: Registration): boolean { + if (reg.fi == null || globallyPooled()) return false; + return (searchedFacets.get(reg.mark)?.size ?? 0) > 1; + } + + // The value the plot is currently reporting. Upstream keeps this state on the + // element itself — context.dispatchValue (plot.ts:189-194) returns early when + // `figure.value === value` — so the guard is per PLOT, not per registration, + // and that is the whole of the viewof contract: a plot whose tip, crosshair + // rule and crosshair text all move onto the same datum in one pointermove + // reports ONE change, not one per consumer. Mirroring it here rather than + // relying on dispatchValue's own check is what lets the React prop obey the + // identical rule; the two cannot drift, because dispatchValue is the only + // thing that ever writes figure.value. + // + // `undefined` initially, exactly as an untouched element's `.value` is, so + // the first selection — a datum or a null — always reports. + let lastValue: unknown = undefined; + + // The DOM half of the contract, installed by once per commit and + // resolved through the plot's CURRENT context. Per-plot rather than + // per-record because the value is per-plot: a record's own context is the one + // it registered under, and the two places a clearing value has to be reported + // are exactly the two where that context is the wrong one — a slot that has + // unmounted holds the previous recompute's context, and a commit that swaps + // the root between a bare and a
replaces the figure holder + // that context dispatches through, so the report would land on an element the + // plot no longer uses, or on none at all. + // + // null in the store's own unit tests, which drive records with no plot around + // them; the record's context is the fallback there. + let valueSink: ((value: unknown) => void) | null = null; + + function dispatch(value: unknown, reg?: Registration): void { + if (value === lastValue) return; + lastValue = value; + // Assigns `figure.value` and dispatches a bubbling `input` event, for + // viewof and for plain addEventListener users. + (valueSink ?? reg?.context?.dispatchValue)?.(value); + // …and the React half. has no onInput wiring on the , so the + // prop is called here and only here, and cannot fire twice. + onValue(value); + } + + // The move path: a search, then upstream's exclusivity rules. + function select(x: number, y: number): void { + // Defensive, and the invariant's real home: a selection must never be + // written by a MOVE while sticky. move() already guards, but a pointerdown + // can land in the same task as a pointermove whose frame is still pending, + // and the selection is written HERE. (down() cancels that frame as well.) + if (isSticky()) return; + resolve(x, y); + } + + // One pass over every registration, then upstream's exclusivity rules. This + // is the single-pass replacement for upstream's second animation frame: one + // listener sees every slot, so exclusivity resolves within the move-coalescing + // frame instead of a frame later. Identical steady state, one fewer frame. + // + // Both entry points run it: a pointer move, and settle()'s re-resolution of a + // recomputed plot at the unmoved pointer. There is deliberately no difference + // between them — that is the design. The one thing settle() needs that a move + // does not is the pin below, because a move cannot happen while pinned. + function resolve(x: number, y: number): void { + // A PIN IS A PIN ON A PLACE. The user pinned the datum that was under the + // pointer, so re-resolving at that same point and handing the pin back to + // the record that held it is what keeps the pin meaning what it meant: an + // unrelated re-render leaves it exactly where it was, and a recompute that + // moves the data under it moves the pinned tip with them rather than + // leaving it stranded on an index that now names something else. + // + // The pin is released — `win == null` below — when nothing is under the + // pointer any more. It has to be: a sticky selection with no index would + // make isSticky() true with nothing to show, and move(), leave() and down() + // all return early on sticky, so the plot could never be recovered. + const pinned = regs.find((reg) => reg.sel.sticky); + + const hits: Hit[] = regs.map((reg) => ({reg, ...nearest(reg, x, y)})); + markSearchedFacets(hits); + + const globalPool = globallyPooled(); + const groupOf = (reg: Registration): unknown => (globalPool ? GLOBAL : reg.fi != null ? reg.mark : reg); + + // MISSES COMPETE. upstream's update() writes every result into the pool, + // hit or miss (pointer.js:134), scores a miss at maxRadius squared, and + // picks the winner with `if (!best || c.ri < best.ri)` over the whole map + // (pointer.js:139) — no skip. So a miss can be the winner, and because the + // final loop renders `c === best ? c.ii : null` with best.ii null, a + // winning miss blanks every member of its group. Excluding misses here (as + // an earlier revision did) makes a group show its nearest datum wherever + // upstream shows nothing: over a faceted anisotropic mark a differential + // oracle put that at a third of the frame. + const best = new Map(); + for (const h of hits) { + const g = groupOf(h.reg); + const cur = best.get(g); + // Strictly less-than, scanning in `order`: the first entry wins a tie. + if (cur === undefined || h.ri < cur.ri) best.set(g, h); + } + + const changed: Registration[] = []; + for (const h of hits) { + const winner = best.get(groupOf(h.reg)); + // `winner.ii` rather than `h.ii`: when the winner is a miss, every member + // of the group renders nothing, the winner included. + const win = winner === h ? winner.ii : null; + if (publish(h.reg, win, h.reg === pinned && win != null)) changed.push(h.reg); + } + + // pointer.js:172 — upstream's stated intent is that when simultaneously + // leaving one facet and entering another, the entering facet's dispatch + // wins; slot order then gives its last writer. See + // suppressesClearingDispatch for how much wider the rule it actually wrote + // is, and why that width is reproduced. + for (const reg of changed) { + const i = reg.sel.i; + if (i == null && suppressesClearingDispatch(reg)) continue; + dispatch(i == null ? null : datumOf(reg, i), reg); + } + + notify(changed); + } + + function clear(): void { + const changed = regs.filter((r) => publish(r, null)); + for (const reg of changed) { + // The same upstream skip as in select(): pointerleave runs + // update(null) → render(null) (pointer.js:200/134), which reaches the + // very same facetPool.map.size test at pointer.js:172. + if (suppressesClearingDispatch(reg)) continue; + dispatch(null, reg); + } + // …and then records the facets as searched, exactly as update() does: the + // pool.map write at pointer.js:134 runs AFTER the render(null) above, so + // the leaving facets do not suppress their own clearing dispatch but do + // count towards every later one. Marking here rather than at the top of + // clear() is therefore load-bearing, and every registration is marked — + // update() writes the map for a facet whose render() bailed out early too. + markSearchedFacets(regs.map((reg) => ({reg, ii: null, ri: Infinity}))); + notify(changed); + } + + return { + createRegistration, + add, + settle, + + /** + * How many marks currently have a retained searched-facet set. Exposed for + * the leak test in test/pointer-store-test.ts: the count is bounded by the + * marks that are actually registered, never by how many times the plot has + * recomputed. + */ + retainedSearchedMarks(): number { + return searchedFacets.size; + }, + + /** Sets the callback backing the prop. */ + setOnValue(fn: (v: unknown) => void): void { + onValue = fn; + }, + + /** + * Sets the plot-level sink for the DOM half of the value contract — the + * current context's dispatchValue, which assigns `.value` on the plot's + * root and fires the bubbling `input` event there. installs it + * once per commit, from the same layout effect that fills the figure holder + * and calls settle(), so that a report made during that settle lands on the + * element the plot is using NOW. + */ + setValueSink(fn: ((value: unknown) => void) | null): void { + valueSink = fn; + }, + + get sticky(): boolean { + return isSticky(); + }, + + move(svg: SVGSVGElement, event: any): void { + if (isSticky()) return; + if (event.pointerType === "mouse" && event.buttons === 1) return; // dragging + const [x, y] = svgPoint(svg, event); + // Recorded at EVENT time, not in the frame below: the frame is coalesced + // and may be discarded, but the pointer really is here, and a recompute + // arriving before the frame runs must re-resolve against the newest + // position rather than the last one that happened to be searched. + last = {x, y}; + // Coalesce: only the last move of a frame is searched. + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = 0; + select(x, y); + }); + }, + + leave(event: any): void { + if (event.pointerType !== "mouse") return; + if (isSticky()) return; + if (frame) cancelAnimationFrame(frame), (frame = 0); + // FORGET WHERE THE POINTER WAS. The point outlives the selection + // everywhere else, but not here: the pointer is outside the plot, so a + // recompute must not re-resolve at the place it left through and hand a + // datum to a user who is no longer pointing at anything. Cleared before + // clear() only for symmetry with move(); nothing between them can read it. + last = null; + clear(); + }, + + // upstream pointer.js:188-196. The `handledEvents` WeakSet means the + // FIRST-registered handler claims the pointerdown and its own `i` decides — + // including the case where it is not pointing, in which case nothing + // toggles even though another mark is. `regs[0]` models that. + // + // But "first-registered" is only half of upstream's rule, because upstream + // has an invariant it never has to state: WHENEVER THERE IS A PIN, THE + // FIRST LISTENER IS THE RECORD HOLDING IT. Its listeners and its focused + // index live together in one closure per mark, and its set of listeners is + // fixed for the life of a render — a pin can only be taken while the first + // listener was pointing (this very guard), pointermove returns early while + // sticky so that `i` cannot move underneath it, and nothing can register in + // front of it meanwhile. So upstream never has to choose between the two. + // + // Here the registry is React's, and it gains members under a live pin: + // toggle a on, or give an earlier mark a tip, and regs[0] is + // suddenly a record that has never pointed. Reading first-registered + // literally then returns at the guard below while isSticky() stays true — + // the pin holder is still registered — and move() and leave() both return + // early on sticky. No gesture could ever recover the plot: it is pinned for + // good on a datum nobody can unpin. + // + // So the claimant is the pin holder while there is a pin, and regs[0] + // otherwise. That is not a divergence: it is upstream's own rule restated + // over a registry that can change membership, and the two agree in every + // state upstream can reach. A pointerdown while pinned must always be able + // to unpin. + down(event: any): void { + if (event.pointerType !== "mouse") return; + const claimant = regs.find((r) => r.sel.sticky) ?? regs[0]; + if (!claimant || claimant.sel.i == null) return; // not pointing + const sticky = isSticky(); + // A click inside an already-rendered sticky root keeps it sticky; this is + // what makes a sticky tip's text selectable. + if (sticky && regs.some((r) => r.root?.contains(event.target as Node))) return; + // A pointermove and a pointerdown arrive in the same task on an ordinary + // click, so the move's coalescing frame is still pending here. Cancel it + // exactly as leave() does: were it allowed to run afterwards it would + // re-publish every non-claimant with the new sticky flag and re-dispatch + // its value, when upstream re-renders the claiming slot only. + if (frame) cancelAnimationFrame(frame), (frame = 0); + if (sticky) { + // clear() publishes every record unsticky, the claimant included, so + // the modality goes with the pin that was holding it. + clear(); + } else { + // Upstream re-renders only the claiming slot (pointer.js:195); the + // other slots keep their stale DOM, so do not republish them. + if (publish(claimant, claimant.sel.i, true)) notify([claimant]); + } + }, + + dispose(): void { + if (frame) cancelAnimationFrame(frame); + frame = 0; + } + }; +} + +export type PointerStore = ReturnType; diff --git a/src/react/interactions/pointerUpstream.ts b/src/react/interactions/pointerUpstream.ts new file mode 100644 index 00000000..be0d17a3 --- /dev/null +++ b/src/react/interactions/pointerUpstream.ts @@ -0,0 +1,53 @@ +import * as pointer from "../../interactions/pointer.js"; + +// Replot-owned type sidecar for the three pure helpers extracted out of +// upstream's pointer closure in src/interactions/pointer.js, so that the +// imperative path and the React path share one implementation of the +// arithmetic and cannot drift. +// +// They are deliberately NOT declared in src/interactions/pointer.d.ts. That +// file is upstream's, so a future re-sync of it should be a zero-hunk diff; +// and src/index.d.ts does `export * from "./interactions/pointer.js"`, so a +// declaration there would advertise these internals as part of the package's +// public type surface even though src/index.ts exports only pointer, pointerX +// and pointerY at runtime. Upstream keeps such internals undeclared — which is +// why src/marks/tip.ts reaches anchorX/anchorY through an @ts-expect-error. +interface PointerInternals { + /** + * Returns the [*tx*, *ty*] correction to subtract from a pointer position to + * convert it into the facet-local coordinates the mark's channel values use: + * the facet translate, plus half a bandwidth for each band scale. + */ + pointerOffsets(index: any, scales: any, dimensions: any): [number, number]; + + /** + * Returns the [*px*, *py*] accessors giving the target position of each + * datum: the **px** and **py** channels, else the *x1*–*x2* and *y1*–*y2* + * midpoints, else *x* and *y* (where *x* defaults to *x1*), else the mark's + * frame anchor. An accessor returns undefined for a datum that lacks the + * coordinate. + */ + pointerAnchors(mark: any, values: any, dimensions: any): [(i: number) => number, (i: number) => number]; + + /** + * Returns the index *ii* of the datum closest to the already facet-corrected + * pointer position (*xp*, *yp*) within *maxRadius*, or null, together with + * its squared distance *ri* — unsquashed when **kx** or **ky** is not 1. A + * miss reports *ri* as *maxRadius* squared. + */ + pointerSearch( + index: Iterable, + px: (i: number) => number, + py: (i: number) => number, + xp: number, + yp: number, + kx: number, + ky: number, + maxRadius: number, + dimensions: any + ): {ii: number | null; ri: number}; +} + +const {pointerAnchors, pointerOffsets, pointerSearch} = pointer as unknown as PointerInternals; + +export {pointerAnchors, pointerOffsets, pointerSearch}; diff --git a/src/react/interactions/usePointer.ts b/src/react/interactions/usePointer.ts deleted file mode 100644 index ab7fc33e..00000000 --- a/src/react/interactions/usePointer.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {useState, useCallback, useRef, type RefObject} from "react"; - -export interface PointerState { - x: number | null; - y: number | null; - active: boolean; -} - -export interface UsePointerOptions { - mode?: "xy" | "x" | "y"; -} - -// Hook for tracking pointer position relative to an SVG element. -export function usePointer( - svgRef: RefObject, - _options: UsePointerOptions = {} -): PointerState & { - onPointerMove: (event: React.PointerEvent) => void; - onPointerLeave: () => void; -} { - const [state, setState] = useState({x: null, y: null, active: false}); - const rafRef = useRef(0); - - const onPointerMove = useCallback( - (event: React.PointerEvent) => { - const svg = svgRef.current; - if (!svg) return; - - // Cancel any pending animation frame - if (rafRef.current) cancelAnimationFrame(rafRef.current); - - rafRef.current = requestAnimationFrame(() => { - const rect = svg.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - setState({x, y, active: true}); - }); - }, - [svgRef] - ); - - const onPointerLeave = useCallback(() => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - setState({x: null, y: null, active: false}); - }, []); - - return {...state, onPointerMove, onPointerLeave}; -} - -// Find the nearest data point to a given position. -export function findNearest( - index: number[], - values: Record, - px: number, - py: number, - mode: "xy" | "x" | "y" = "xy" -): number | null { - const {x: X, y: Y} = values; - if (!index.length) return null; - - let nearestI: number | null = null; - let nearestDist = Infinity; - - for (const i of index) { - const xi = X?.[i]; - const yi = Y?.[i]; - if (xi == null && yi == null) continue; - - let dist: number; - if (mode === "x") { - dist = xi != null ? Math.abs(xi - px) : Infinity; - } else if (mode === "y") { - dist = yi != null ? Math.abs(yi - py) : Infinity; - } else { - const dx = xi != null ? xi - px : 0; - const dy = yi != null ? yi - py : 0; - dist = dx * dx + dy * dy; - } - - if (dist < nearestDist) { - nearestDist = dist; - nearestI = i; - } - } - - return nearestI; -} diff --git a/src/react/legends/Legend.tsx b/src/react/legends/Legend.tsx index 6dc6b9f2..e7de5e84 100644 --- a/src/react/legends/Legend.tsx +++ b/src/react/legends/Legend.tsx @@ -46,6 +46,10 @@ export function Legend(props: LegendProps) { const id = useId(); const register = ctx?.registerLegend; const unregister = ctx?.unregisterLegend; + const serverRender = ctx?.serverRender; + const registration = () => { + if (register) register(id, stampOptions("legend", null, props as Record), props); + }; // Inside a , register instead of rendering: the Plot renders the // visible in its figure slot, so a Legend is promoted no // matter how it's composed (memo, wrapper components, fragments). @@ -53,8 +57,14 @@ export function Legend(props: LegendProps) { // unmount runs the cleanup below without re-rendering, so a render-phase // registration would be lost; this depless effect re-registers every // commit, idempotently under the stable useId. + // A server render registers while it renders instead, as marks and scales do + // (useMark): no effect runs there, and an explicit legend that never + // registers is a legend the figure slot never shows. + if (serverRender) registration(); useLayoutEffect(() => { - if (register) register(id, stampOptions("legend", null, props as Record), props); + // Unconditional, and never run on a server render; see useMark. + if (serverRender) return; + registration(); }); useLayoutEffect(() => { if (!unregister) return; diff --git a/src/react/legends/Ramp.tsx b/src/react/legends/Ramp.tsx index 0634d06b..63aca17d 100644 --- a/src/react/legends/Ramp.tsx +++ b/src/react/legends/Ramp.tsx @@ -12,6 +12,7 @@ import {createContext} from "../../context.js"; import {map, maybeNumberChannel} from "../../options.js"; import {interpolatePiecewise} from "../../scales/quantitative.js"; import {impliedString, maybeClassName} from "../../style.js"; +import {legendStyleSheet, rampStyle} from "./legendStyles.js"; // Mirrors the option bag accepted by the imperative `legendRamp(color, options)`. // The first positional argument (`color`) is exposed here as the `scale` prop; @@ -65,6 +66,7 @@ export function Ramp(props: RampProps) { const reactId = useId(); const filterColor = props.filterColor; const filterId = filterColor ? `plot-filter-${reactId.replace(/[^A-Za-z0-9_-]/g, "")}` : null; + const rampId = `plot-ramp-${reactId.replace(/[^A-Za-z0-9_-]/g, "")}`; const context = createContext(props as any); const applyRange = round @@ -75,6 +77,11 @@ export function Ramp(props: RampProps) { let x: any; let body: React.ReactNode = null; + // The ramp's paint server, when it needs one: a document-less render paints + // the interpolate branch with a gradient instead of a canvas data URL, and + // the gradient's definition is rendered next to the filter's, outside the + // body it fills. + let gradient: React.ReactNode = null; if (interpolate) { const interpolator = @@ -88,16 +95,36 @@ export function Ramp(props: RampProps) { Math.min(domain.length + (pivot !== undefined ? 1 : 0), range === undefined ? Infinity : range.length) ) ); - const href = canvasDataURL(interpolator, context); - body = ( + // The ramp body is a 256x1 canvas interpolated into a data URL, which is + // what the imperative legend produces and needs a document to build. A + // server render has none, so it paints the same interpolator as an SVG + // gradient instead: the legend is still complete and correct-looking in the + // markup, and the browser path is untouched (whether the two agree is + // rampGradient's subject). The two bodies are equivalent, not identical — a + // client render of a server-rendered ramp legend replaces the gradient with + // the image, which is the same hydration re-render every other part of a + // server-rendered plot takes. + const rampWidth = width - marginLeft - marginRight; + const rampHeight = height - marginTop - marginBottom; + gradient = context.document == null ? rampGradient(interpolator, rampId) : null; + body = gradient ? ( + + ) : ( ); } else if (type === "threshold") { @@ -169,7 +196,8 @@ export function Ramp(props: RampProps) { viewBox={`0 0 ${width} ${height}`} style={styleAttr} > - + {legendStyleSheet(rampStyle(className))} + {gradient} {filterId ? ( @@ -194,19 +222,6 @@ export function Ramp(props: RampProps) { ); } -function rampStyle(className: string): string { - return `:where(.${className}-ramp) { - display: block; - height: auto; - height: intrinsic; - max-width: 100%; - overflow: visible; -} -:where(.${className}-ramp text) { - white-space: pre; -}`; -} - // Constructs the data URL for a 256x1 canvas filled by `interpolator(t)`. function canvasDataURL(interpolator: (t: number) => string, context: any): string { const n = 256; @@ -221,6 +236,27 @@ function canvasDataURL(interpolator: (t: number) => string, context: any): strin return canvas.toDataURL(); } +// The ramp body without a DOM: the same interpolator sampled into a horizontal +// , which a server render can emit as markup. 256 stops would +// be the canvas's own resolution and a needlessly large legend, so the ramp is +// sampled at 64 — the interpolators the legends use are piecewise-linear +// between a handful of stops, so sampling at 64 is within one and a half +// parts of 255 in any channel of what the canvas would paint (measured over +// sequential rgb, basis and turbo interpolators). The gradient is in +// objectBoundingBox units, so it stretches to whatever rect references it, +// exactly as preserveAspectRatio="none" stretches the image. +function rampGradient(interpolator: (t: number) => string, id: string): React.ReactNode { + const n = 64; + const offset = (i: number): string => `${+((100 * i) / (n - 1)).toFixed(4)}%`; + return ( + + {Array.from({length: n}, (_, i) => ( + + ))} + + ); +} + // Replicates the tick markup that d3-axis (axisBottom) emits, minus the // .domain path (which the imperative ramp removes). Each tick is a // with a and . diff --git a/src/react/legends/Swatches.tsx b/src/react/legends/Swatches.tsx index b12ec1bc..abb8631f 100644 --- a/src/react/legends/Swatches.tsx +++ b/src/react/legends/Swatches.tsx @@ -6,6 +6,7 @@ import {isNoneish, isScaleOptions, maybeColorChannel, maybeNumberChannel} from " import {isOrdinalScale, isThresholdScale, normalizeScale} from "../../scales.js"; import {maybeClassName} from "../../style.js"; import type {LegendScales} from "../../legends.js"; +import {legendStyleSheet, swatchesStyle} from "./legendStyles.js"; // Pure-JSX port of `src/legends/swatches.js`. Emits the same HTML+SVG tree the // imperative renderer builds (a wrapping
with a +
+ {legendStyleSheet(swatchesStyle(cls, layout))} {scale.domain.map((d: any, i: number) => { const label = tf.call(null, d, i); return columns != null ? ( diff --git a/src/react/legends/legendStyles.ts b/src/react/legends/legendStyles.ts new file mode 100644 index 00000000..2b548623 --- /dev/null +++ b/src/react/legends/legendStyles.ts @@ -0,0 +1,79 @@ +// Legend stylesheets. The CSS lives here rather than inline in the legend +// components: a - + - - 1 - + 1 - - 2 - + 2 - - 3 - + 3 - - 4 - + 4 series - - - - - - - - - - - - + - - - + - - - - 4 - 6 - 8 - 10 - 12 - + + + 4 + 6 + 8 + 10 + 12 ↑ y - - - - - - - - - - - - + - - - - - - - - - - - - + - - - - 5 - 10 - 15 - - - - - 5 - 10 - 15 - - - - - 5 - 10 - 15 - - - - - 5 - 10 - 15 - + + + 5 + 10 + 15 + + + 5 + 10 + 15 + + + 5 + 10 + 15 + + + 5 + 10 + 15 x → - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestDifferenceY.svg b/test/output/arrowTestDifferenceY.svg index 0d19f8c8..480645b7 100644 --- a/test/output/arrowTestDifferenceY.svg +++ b/test/output/arrowTestDifferenceY.svg @@ -58,16 +58,16 @@ 2018 - + - + - + - + diff --git a/test/output/arrowTestPointer.html b/test/output/arrowTestPointer.html new file mode 100644 index 00000000..82b9da91 --- /dev/null +++ b/test/output/arrowTestPointer.html @@ -0,0 +1,404 @@ +
+ + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
\ No newline at end of file diff --git a/test/output/arrowTestTree.svg b/test/output/arrowTestTree.svg index b360a31c..e63ac371 100644 --- a/test/output/arrowTestTree.svg +++ b/test/output/arrowTestTree.svg @@ -23,30 +23,14 @@
- - /Chaos - - - /Chaos/Eros - - - /Chaos/Erebus - - - /Chaos/Tartarus - - - /Chaos/Gaia - - - /Chaos/Gaia/Mountains - - - /Chaos/Gaia/Pontus - - - /Chaos/Gaia/Uranus - + /Chaos + /Chaos/Eros + /Chaos/Erebus + /Chaos/Tartarus + /Chaos/Gaia + /Chaos/Gaia/Mountains + /Chaos/Gaia/Pontus + /Chaos/Gaia/Uranus Eros/Chaos/Eros diff --git a/test/output/athletesBoxingHeight.svg b/test/output/athletesBoxingHeight.svg index 5f530812..f530d716 100644 --- a/test/output/athletesBoxingHeight.svg +++ b/test/output/athletesBoxingHeight.svg @@ -13,927 +13,343 @@ white-space: pre; } - + - - Africa - + Africa - - Americas - + Americas - - Asia - + Asia - - Europe - + Europe - - Oceania - + Oceania continent - - - + - - - - 1.5 - 1.6 - 1.7 - 1.8 - 1.9 - 2.0 - 2.1 - + + + 1.5 + 1.6 + 1.7 + 1.8 + 1.9 + 2.0 + 2.1 ↑ height - - - - - - - - - - - - - - - - + + + + + + - + - - - ALG - - - ALG - - - EGY - - - MAR - - - SEY - - - KEN - - - TUN - - - ALG - - - CPV - - - CMR - - - NGR - - - ALG - - - MAR - - - CMR - - - MAR - - - TUN - - - EGY - - - ALG - - - NAM - - - CAF - - - UGA - - - MRI - - - MAR - - - CMR - - - EGY - - - NAM - - - MRI - - - ALG - - - MAR - - - MAR - - - MAR - - - CGO - - - KEN - - - ALG - - - CMR - - - EGY - - - ALG - - - MAR - - + ALG + ALG + EGY + MAR + SEY + KEN + TUN + ALG + CPV + CMR + NGR + ALG + MAR + CMR + MAR + TUN + EGY + ALG + NAM + CAF + UGA + MRI + MAR + CMR + EGY + NAM + MRI + ALG + MAR + MAR + MAR + CGO + KEN + ALG + CMR + EGY + ALG + MAR - - - BRA - - - VEN - - - ARG - - - ARG - - - BRA - - - USA - - - CAN - - - CUB - - - CAN - - - PAN - - - ECU - - - ECU - - - USA - - - COL - - - USA - - - USA - - - VEN - - - MEX - - - VEN - - - CUB - - - ARG - - - VEN - - - USA - - - DOM - - - ARG - - - COL - - - PUR - - - CUB - - - BRA - - - COL - - - MEX - - - COL - - - BRA - - - MEX - - - BRA - - - ECU - - - CUB - - - CUB - - - ARG - - - CUB - - - DOM - - - MEX - - - VEN - - - VEN - - - CAN - - - ECU - - - BRA - - - USA - - - MEX - - - USA - - - TTO - - - BRA - - - MEX - - - CUB - - - BRA - - - BRA - - - CUB - - - USA - - - VEN - - - ARG - - - CUB - - - VEN - - - CUB - - - COL - - + BRA + VEN + ARG + ARG + BRA + USA + CAN + CUB + CAN + PAN + ECU + ECU + USA + COL + USA + USA + VEN + MEX + VEN + CUB + ARG + VEN + USA + DOM + ARG + COL + PUR + CUB + BRA + COL + MEX + COL + BRA + MEX + BRA + ECU + CUB + CUB + ARG + CUB + DOM + MEX + VEN + VEN + CAN + ECU + BRA + USA + MEX + USA + TTO + BRA + MEX + CUB + BRA + BRA + CUB + USA + VEN + ARG + CUB + VEN + CUB + COL - - - AZE - - - KAZ - - - KAZ - - - RUS - - - AZE - - - TUR - - - THA - - - RUS - - - RUS - - - TJK - - - ARM - - - JPN - - - TKM - - - RUS - - - ARM - - - UZB - - - TUR - - - UZB - - - KAZ - - - CHN - - - KAZ - - - CHN - - - PHI - - - THA - - - MGL - - - TPE - - - JPN - - - KAZ - - - KAZ - - - IRI - - - UZB - - - AZE - - - MGL - - - KGZ - - - RUS - - - UZB - - - CHN - - - MGL - - - QAT - - - UZB - - - ARM - - - UZB - - - JOR - - - RUS - - - KAZ - - - AZE - - - CHN - - - CHN - - - CHN - - - CHN - - - KAZ - - - AZE - - - IND - - - AZE - - - AZE - - - IND - - - TUR - - - CHN - - - RUS - - - UZB - - - ARM - - - TPE - - - JOR - - - KAZ - - - TUR - - - TUR - - - MGL - - - AZE - - - THA - - - RUS - - - CHN - - - CHN - - - PHI - - - AZE - - - UZB - - - KOR - - - THA - - - TUR - - - UZB - - - UZB - - - IND - - - AZE - - - QAT - - - MGL - - - MGL - - - RUS - - - KAZ - - - RUS - - - ARM - - - RUS - - - IRQ - - - CHN - - - THA - - - AZE - - - UZB - - - KAZ - - - KAZ - - + AZE + KAZ + KAZ + RUS + AZE + TUR + THA + RUS + RUS + TJK + ARM + JPN + TKM + RUS + ARM + UZB + TUR + UZB + KAZ + CHN + KAZ + CHN + PHI + THA + MGL + TPE + JPN + KAZ + KAZ + IRI + UZB + AZE + MGL + KGZ + RUS + UZB + CHN + MGL + QAT + UZB + ARM + UZB + JOR + RUS + KAZ + AZE + CHN + CHN + CHN + CHN + KAZ + AZE + IND + AZE + AZE + IND + TUR + CHN + RUS + UZB + ARM + TPE + JOR + KAZ + TUR + TUR + MGL + AZE + THA + RUS + CHN + CHN + PHI + AZE + UZB + KOR + THA + TUR + UZB + UZB + IND + AZE + QAT + MGL + MGL + RUS + KAZ + RUS + ARM + RUS + IRQ + CHN + THA + AZE + UZB + KAZ + KAZ - - - SWE - - - GBR - - - GER - - - GER - - - IRL - - - ITA - - - FRA - - - ITA - - - BUL - - - GER - - - IRL - - - UKR - - - UKR - - - BLR - - - LTU - - - FRA - - - NED - - - GER - - - FRA - - - LTU - - - CRO - - - GBR - - - ITA - - - GER - - - FRA - - - CRO - - - POL - - - HUN - - - ITA - - - GBR - - - GBR - - - IRL - - - GBR - - - GBR - - - IRL - - - GBR - - - ITA - - - FRA - - - IRL - - - IRL - - - ROU - - - BLR - - - FIN - - - GBR - - - UKR - - - GBR - - - NED - - - GBR - - - IRL - - - FRA - - - BLR - - - NED - - - GBR - - - ESP - - - FRA - - - GBR - - - GER - - - BUL - - - FRA - - - FRA - - - BUL - - - IRL - - - HON - - - UKR - - - POL - - - FRA - - - ITA - - - ITA - - - UKR - - - ESP - - - HUN - - + SWE + GBR + GER + GER + IRL + ITA + FRA + ITA + BUL + GER + IRL + UKR + UKR + BLR + LTU + FRA + NED + GER + FRA + LTU + CRO + GBR + ITA + GER + FRA + CRO + POL + HUN + ITA + GBR + GBR + IRL + GBR + GBR + IRL + GBR + ITA + FRA + IRL + IRL + ROU + BLR + FIN + GBR + UKR + GBR + NED + GBR + IRL + FRA + BLR + NED + GBR + ESP + FRA + GBR + GER + BUL + FRA + FRA + BUL + IRL + HON + UKR + POL + FRA + ITA + ITA + UKR + ESP + HUN - - - AUS - - - AUS - - - FSM - - - AUS - - - PNG - - + AUS + AUS + FSM + AUS + PNG \ No newline at end of file diff --git a/test/output/athletesSample.svg b/test/output/athletesSample.svg index 94326453..84425d85 100644 --- a/test/output/athletesSample.svg +++ b/test/output/athletesSample.svg @@ -107,338 +107,116 @@ weight → - - A Jesus Garcia - - - Aichen Wang - - - Alejandro Valverde Belmonte - - - Alexander Sigurbjornsson - - - Alin George Moldoveanu - - - Amas Daniel - - - Bachir Mahamat - - - Ben Kanute - - - Changrui Xue - - - Chun Hing Chan - - - Craig Miller - - - Danell Leyva - - - Davie Selke - - - Dong Keun Lee - - - Eduard Popp - - - Emanuel Silva - - - Erick Barrondo - - - Evan Van Moerkerke - - - Filip Dvorak - - - Frank Chamizo Marquez - - - Gauthier Grumier - - - Gregory Bauge - - - Hersony Canelon - - - Jack Green - - - Javier Garcia Gadea - - - Jeroen Dubbeldam - - - Joe Kovacs - - - Jordan Coelho - - - Kevin Staut - - - Kleber da Silva Ramos - - - Kurt Couto - - - Lomano Lemeki - - - Luguelin Santos - - - Marc Zwiebler - - - Markus Thormeyer - - - Matthew Glaetzer - - - Mehdi Marzouki - - - Michael Perez - - - Moises Duque - - - Myong Hyok Kim - - - Nic Woods - - - Pablo Herrera Allepuz - - - Paul Kipngetich Tanui - - - Pieter-Jan Postma - - - Raul Hernandez Hidalgo - - - Ricardas Nekriosius - - - Robert van der Horst - - - Ryan Bailie - - - Sam Webster - - - Seabelo Senatla - - - Serguey Torres - - - Simone Giannelli - - - Soslan Daurov - - - Stephen Bird - - - Taras Mishchuk - - - Theo Piniau - - - Tim Nedow - - - Tontowi Ahmad - - - Uvis Kalnins - - - Vicenc Ruiz - - - Will Brown - - - Yosuke Nakayama - - - Yuri Floriani - - - Ziv Kalontarov - - - Adela Hanzlickova - - - Anastasiia Lysenko - - - Angelique Kerber - - - Annika Beck - - - Aria Fischer - - - Ashlyn Harris - - - Birgit Michels - - - Brenda Flores - - - Caitlin van Sickle - - - Carolina Rodriguez - - - Chloe Leurquin - - - Daria Gavrilova - - - Diana Martin - - - Elena-Lavinia Tarlea - - - Farida Osman - - - Gil Cohen - - - Hye-Song Kim - - - Ilse Paulis - - - Isabel Kerschowski - - - Jamile Samuel - - - Jieni Shao - - - Julie Johnston - - - Kamia Yousufi - - - Katarzyna Krawczyk - - - Keesja Gofers - - - Leidy Asprilla - - - Liliyana Natsir - - - Maaike Head - - - Makenzie Fischer - - - Maria Casado - - - Mariia Shurochkina - - - Maryia Papova - - - Miho Yoshioka - - - Mirela Demireva - - - Natalia Vorobeva - - - Nikkita Holder - - - Nur Tatar - - - Olivia Schough - - - Persis William-Mensah - - - Rabia Guelec - - - Rosa Godoy - - - Sapsiree Taerattanachai - - - Shijie Qieyang - - - Svetlana Romashina - - - Vitoria Cristina Rosa - - - Xue Li - - - Yekaterina Nemich - + A Jesus Garcia + Aichen Wang + Alejandro Valverde Belmonte + Alexander Sigurbjornsson + Alin George Moldoveanu + Amas Daniel + Bachir Mahamat + Ben Kanute + Changrui Xue + Chun Hing Chan + Craig Miller + Danell Leyva + Davie Selke + Dong Keun Lee + Eduard Popp + Emanuel Silva + Erick Barrondo + Evan Van Moerkerke + Filip Dvorak + Frank Chamizo Marquez + Gauthier Grumier + Gregory Bauge + Hersony Canelon + Jack Green + Javier Garcia Gadea + Jeroen Dubbeldam + Joe Kovacs + Jordan Coelho + Kevin Staut + Kleber da Silva Ramos + Kurt Couto + Lomano Lemeki + Luguelin Santos + Marc Zwiebler + Markus Thormeyer + Matthew Glaetzer + Mehdi Marzouki + Michael Perez + Moises Duque + Myong Hyok Kim + Nic Woods + Pablo Herrera Allepuz + Paul Kipngetich Tanui + Pieter-Jan Postma + Raul Hernandez Hidalgo + Ricardas Nekriosius + Robert van der Horst + Ryan Bailie + Sam Webster + Seabelo Senatla + Serguey Torres + Simone Giannelli + Soslan Daurov + Stephen Bird + Taras Mishchuk + Theo Piniau + Tim Nedow + Tontowi Ahmad + Uvis Kalnins + Vicenc Ruiz + Will Brown + Yosuke Nakayama + Yuri Floriani + Ziv Kalontarov + Adela Hanzlickova + Anastasiia Lysenko + Angelique Kerber + Annika Beck + Aria Fischer + Ashlyn Harris + Birgit Michels + Brenda Flores + Caitlin van Sickle + Carolina Rodriguez + Chloe Leurquin + Daria Gavrilova + Diana Martin + Elena-Lavinia Tarlea + Farida Osman + Gil Cohen + Hye-Song Kim + Ilse Paulis + Isabel Kerschowski + Jamile Samuel + Jieni Shao + Julie Johnston + Kamia Yousufi + Katarzyna Krawczyk + Keesja Gofers + Leidy Asprilla + Liliyana Natsir + Maaike Head + Makenzie Fischer + Maria Casado + Mariia Shurochkina + Maryia Papova + Miho Yoshioka + Mirela Demireva + Natalia Vorobeva + Nikkita Holder + Nur Tatar + Olivia Schough + Persis William-Mensah + Rabia Guelec + Rosa Godoy + Sapsiree Taerattanachai + Shijie Qieyang + Svetlana Romashina + Vitoria Cristina Rosa + Xue Li + Yekaterina Nemich \ No newline at end of file diff --git a/test/output/athletesSampleFacet.svg b/test/output/athletesSampleFacet.svg index 78eb1cfa..aafb3145 100644 --- a/test/output/athletesSampleFacet.svg +++ b/test/output/athletesSampleFacet.svg @@ -13,915 +13,531 @@ white-space: pre; } - - - - aquatics - + + + aquatics - - - archery - + + archery - - - athletics - + + athletics - - - badminton - + + badminton - - - basketball - + + basketball - - - boxing - + + boxing - - - canoe - + + canoe - - - cycling - + + cycling - - - equestrian - + + equestrian - - - fencing - + + fencing - - - football - + + football - - - golf - + + golf - - - gymnastics - + + gymnastics - - - handball - + + handball - - - hockey - + + hockey - - - judo - + + judo - - - modern pentathlon - + + modern pentathlon - - - rowing - + + rowing - - - rugby sevens - + + rugby sevens - - - sailing - + + sailing - - - shooting - + + shooting - - - table tennis - + + table tennis - - - taekwondo - + + taekwondo - - - tennis - + + tennis - - - triathlon - + + triathlon - - - volleyball - + + volleyball - - - weightlifting - + + weightlifting - - - wrestling - + + wrestling sport - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + - - - - 40 - 60 - 80 - 100 - 120 - 140 - 160 - + + + 40 + 60 + 80 + 100 + 120 + 140 + 160 weight → - + - - - Evan Van Moerkerke - - - Javier Garcia Gadea - - - Jordan Coelho - - - Markus Thormeyer - - - Mehdi Marzouki - - - Uvis Kalnins - - - Ziv Kalontarov - - - Aria Fischer - - - Farida Osman - - - Keesja Gofers - - - Makenzie Fischer - - - Mariia Shurochkina - - - Svetlana Romashina - - - Yekaterina Nemich - - + Evan Van Moerkerke + Javier Garcia Gadea + Jordan Coelho + Markus Thormeyer + Mehdi Marzouki + Uvis Kalnins + Ziv Kalontarov + Aria Fischer + Farida Osman + Keesja Gofers + Makenzie Fischer + Mariia Shurochkina + Svetlana Romashina + Yekaterina Nemich - - - A Jesus Garcia - - - Bachir Mahamat - - - Changrui Xue - - - Erick Barrondo - - - Jack Green - - - Joe Kovacs - - - Kurt Couto - - - Luguelin Santos - - - Paul Kipngetich Tanui - - - Theo Piniau - - - Tim Nedow - - - Yuri Floriani - - - Brenda Flores - - - Diana Martin - - - Hye-Song Kim - - - Jamile Samuel - - - Kamia Yousufi - - - Mirela Demireva - - - Nikkita Holder - - - Persis William-Mensah - - - Rosa Godoy - - - Shijie Qieyang - - - Vitoria Cristina Rosa - - + A Jesus Garcia + Bachir Mahamat + Changrui Xue + Erick Barrondo + Jack Green + Joe Kovacs + Kurt Couto + Luguelin Santos + Paul Kipngetich Tanui + Theo Piniau + Tim Nedow + Yuri Floriani + Brenda Flores + Diana Martin + Hye-Song Kim + Jamile Samuel + Kamia Yousufi + Mirela Demireva + Nikkita Holder + Persis William-Mensah + Rosa Godoy + Shijie Qieyang + Vitoria Cristina Rosa - - - Dong Keun Lee - - - Marc Zwiebler - - - Tontowi Ahmad - - - Birgit Michels - - - Liliyana Natsir - - - Sapsiree Taerattanachai - - + Dong Keun Lee + Marc Zwiebler + Tontowi Ahmad + Birgit Michels + Liliyana Natsir + Sapsiree Taerattanachai - - - Maryia Papova - - + Maryia Papova - - - Emanuel Silva - - - Filip Dvorak - - - Ricardas Nekriosius - - - Serguey Torres - - - Stephen Bird - - - Taras Mishchuk - - + Emanuel Silva + Filip Dvorak + Ricardas Nekriosius + Serguey Torres + Stephen Bird + Taras Mishchuk - - - Alejandro Valverde Belmonte - - - Chun Hing Chan - - - Gregory Bauge - - - Hersony Canelon - - - Kleber da Silva Ramos - - - Matthew Glaetzer - - - Sam Webster - - + Alejandro Valverde Belmonte + Chun Hing Chan + Gregory Bauge + Hersony Canelon + Kleber da Silva Ramos + Matthew Glaetzer + Sam Webster - - - Jeroen Dubbeldam - - - Kevin Staut - - + Jeroen Dubbeldam + Kevin Staut - - - Gauthier Grumier - - + Gauthier Grumier - - - Davie Selke - - - Michael Perez - - - Ashlyn Harris - - - Isabel Kerschowski - - - Julie Johnston - - - Leidy Asprilla - - - Olivia Schough - - + Davie Selke + Michael Perez + Ashlyn Harris + Isabel Kerschowski + Julie Johnston + Leidy Asprilla + Olivia Schough - - - Chloe Leurquin - - + Chloe Leurquin - - - Carolina Rodriguez - - - Danell Leyva - - + Carolina Rodriguez + Danell Leyva - - - Nic Woods - - - Robert van der Horst - - - Vicenc Ruiz - - - Caitlin van Sickle - - + Nic Woods + Robert van der Horst + Vicenc Ruiz + Caitlin van Sickle - - - Alexander Sigurbjornsson - - - Raul Hernandez Hidalgo - - - Elena-Lavinia Tarlea - - - Ilse Paulis - - - Maaike Head - - + Alexander Sigurbjornsson + Raul Hernandez Hidalgo + Elena-Lavinia Tarlea + Ilse Paulis + Maaike Head - - - Maria Casado - - - Lomano Lemeki - - - Moises Duque - - - Seabelo Senatla - - + Maria Casado + Lomano Lemeki + Moises Duque + Seabelo Senatla - - - Gil Cohen - - - Miho Yoshioka - - - Aichen Wang - - - Pieter-Jan Postma - - + Gil Cohen + Miho Yoshioka + Aichen Wang + Pieter-Jan Postma - - - Alin George Moldoveanu - - - Will Brown - - + Alin George Moldoveanu + Will Brown - - - Jieni Shao - - - Xue Li - - + Jieni Shao + Xue Li - - - Nur Tatar - - - Rabia Guelec - - + Nur Tatar + Rabia Guelec - - - Angelique Kerber - - - Annika Beck - - - Daria Gavrilova - - + Angelique Kerber + Annika Beck + Daria Gavrilova - - - Ben Kanute - - - Ryan Bailie - - + Ben Kanute + Ryan Bailie - - - Pablo Herrera Allepuz - - - Simone Giannelli - - + Pablo Herrera Allepuz + Simone Giannelli - - - Myong Hyok Kim - - - Yosuke Nakayama - - - Anastasiia Lysenko - - + Myong Hyok Kim + Yosuke Nakayama + Anastasiia Lysenko - - - Amas Daniel - - - Craig Miller - - - Eduard Popp - - - Frank Chamizo Marquez - - - Soslan Daurov - - - Adela Hanzlickova - - - Katarzyna Krawczyk - - - Natalia Vorobeva - - + Amas Daniel + Craig Miller + Eduard Popp + Frank Chamizo Marquez + Soslan Daurov + Adela Hanzlickova + Katarzyna Krawczyk + Natalia Vorobeva \ No newline at end of file diff --git a/test/output/athletesSortFacet.svg b/test/output/athletesSortFacet.svg index a942eb6e..50ac92b2 100644 --- a/test/output/athletesSortFacet.svg +++ b/test/output/athletesSortFacet.svg @@ -13,317 +13,201 @@ white-space: pre; } - - - - boxing - + + + boxing - - - wrestling - + + wrestling - - - canoe - + + canoe - - - cycling - + + cycling - - - equestrian - + + equestrian - - - shooting - + + shooting - - - judo - + + judo - - - rowing - + + rowing - - - weightlifting - + + weightlifting - - - sailing - + + sailing - - - football - + + football - - - tennis - + + tennis - - - athletics - + + athletics - - - golf - + + golf - - - rugby sevens - + + rugby sevens - - - aquatics - + + aquatics - - - handball - + + handball - - - archery - + + archery - - - badminton - + + badminton - - - basketball - + + basketball - - - hockey - + + hockey - - - modern pentathlon - + + modern pentathlon - - - table tennis - + + table tennis - - - taekwondo - + + taekwondo - - - triathlon - + + triathlon - - - volleyball - + + volleyball - - - fencing - + + fencing - - - gymnastics - + + gymnastics sport - - - + - - - - 0.0 - 0.1 - 0.2 - 0.3 - 0.4 - 0.5 - 0.6 - + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - + diff --git a/test/output/athletesSportWeight.svg b/test/output/athletesSportWeight.svg index fa9bcc3a..fffa7b0c 100644 --- a/test/output/athletesSportWeight.svg +++ b/test/output/athletesSportWeight.svg @@ -13,1436 +13,1264 @@ white-space: pre; } - - - - aquatics - + + + aquatics - - - archery - + + archery - - - athletics - + + athletics - - - badminton - + + badminton - - - basketball - + + basketball - - - boxing - + + boxing - - - canoe - + + canoe - - - cycling - + + cycling - - - equestrian - + + equestrian - - - fencing - + + fencing - - - football - + + football - - - golf - + + golf - - - gymnastics - + + gymnastics - - - handball - + + handball - - - hockey - + + hockey - - - judo - + + judo - - - modern pentathlon - + + modern pentathlon - - - rowing - + + rowing - - - rugby sevens - + + rugby sevens - - - sailing - + + sailing - - - shooting - + + shooting - - - table tennis - + + table tennis - - - taekwondo - + + taekwondo - - - tennis - + + tennis - - - triathlon - + + triathlon - - - volleyball - + + volleyball - - - weightlifting - + + weightlifting - - - wrestling - + + wrestling sport - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + - - - - 40 - 60 - 80 - 100 - 120 - 140 - 160 - + + + 40 + 60 + 80 + 100 + 120 + 140 + 160 weight → - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + \ No newline at end of file diff --git a/test/output/autoDotFacet.svg b/test/output/autoDotFacet.svg index eb932c9b..60b41326 100644 --- a/test/output/autoDotFacet.svg +++ b/test/output/autoDotFacet.svg @@ -13,486 +13,446 @@ white-space: pre; } - + - - Biscoe - + Biscoe - - Dream - + Dream - - Torgersen - + Torgersen island - - - + - - - - 34 - 36 - 38 - 40 - 42 - 44 - 46 - 48 - 50 - 52 - 54 - 56 - 58 - + + + 34 + 36 + 38 + 40 + 42 + 44 + 46 + 48 + 50 + 52 + 54 + 56 + 58 ↑ culmen_length_mm - - - +
diff --git a/test/output/hexbinText.svg b/test/output/hexbinText.svg index 3fd84138..d2e5e5a7 100644 --- a/test/output/hexbinText.svg +++ b/test/output/hexbinText.svg @@ -16,415 +16,371 @@ - + - - FEMALE - + FEMALE - - MALE - + MALE sex - - - + - - - - 35 - 40 - 45 - 50 - 55 - + + + 35 + 40 + 45 + 50 + 55 ↑ culmen_length_mm - - - +
diff --git a/test/output/markerDasharray.svg b/test/output/markerDasharray.svg index cd34b6b0..57a23909 100644 --- a/test/output/markerDasharray.svg +++ b/test/output/markerDasharray.svg @@ -15,16 +15,16 @@ - + - + - + - + \ No newline at end of file diff --git a/test/output/markerRuleX.svg b/test/output/markerRuleX.svg index 7b177ff6..c2d8dace 100644 --- a/test/output/markerRuleX.svg +++ b/test/output/markerRuleX.svg @@ -41,12 +41,12 @@ - + - - - + + + \ No newline at end of file diff --git a/test/output/markerRuleY.svg b/test/output/markerRuleY.svg index 3b888381..b7980e5d 100644 --- a/test/output/markerRuleY.svg +++ b/test/output/markerRuleY.svg @@ -41,12 +41,12 @@ - + - - - + + + \ No newline at end of file diff --git a/test/output/markerTickX.svg b/test/output/markerTickX.svg index 4f792363..7444cb33 100644 --- a/test/output/markerTickX.svg +++ b/test/output/markerTickX.svg @@ -41,12 +41,12 @@ - + - - - + + + \ No newline at end of file diff --git a/test/output/markerTickY.svg b/test/output/markerTickY.svg index 9bcfb741..63a6d9a5 100644 --- a/test/output/markerTickY.svg +++ b/test/output/markerTickY.svg @@ -41,12 +41,12 @@ - + - - - + + + \ No newline at end of file diff --git a/test/output/metroUnemploymentRidgeline.svg b/test/output/metroUnemploymentRidgeline.svg index 96d15fac..1a7576ae 100644 --- a/test/output/metroUnemploymentRidgeline.svg +++ b/test/output/metroUnemploymentRidgeline.svg @@ -13,938 +13,574 @@ white-space: pre; } - - - - Detroit-Livonia-Dearborn, MI Met Div - - - - - Detroit-Warren-Livonia, MI MSA - - - - - Warren-Troy-Farmington Hills, MI Met Div - - - - - Lawrence-Methuen-Salem, MA-NH NECTA Div - - - - - Los Angeles-Long Beach-Glendale, CA Met Div - - - - - Miami-Miami Beach-Kendall, FL Met Div - - - - - Los Angeles-Long Beach-Santa Ana, CA MSA - - - - - Lake County-Kenosha County, IL-WI Met Div - - - - - West Palm Beach-Boca Raton-Boynton Beach, FL Met Div - - - - - Chicago-Joliet-Naperville, IL Met Div - - - - - Chicago-Joliet-Naperville, IL-IN-WI MSA - - - - - Miami-Fort Lauderdale-Pompano Beach, FL MSA - - - - - Oakland-Fremont-Hayward, CA Met Div - - - - - Gary, IN Met Div - - - - - Tacoma, WA Met Div - - - - - San Francisco-Oakland-Fremont, CA MSA - - - - - Camden, NJ Met Div - - - - - Brockton-Bridgewater-Easton, MA NECTA Div - - - - - Seattle-Tacoma-Bellevue, WA MSA - - - - - Fort Lauderdale-Pompano Beach-Deerfield Beach, FL Met Div - - - - - New York-White Plains-Wayne, NY-NJ Met Div - - - - - Santa Ana-Anaheim-Irvine, CA Met Div - - - - - Seattle-Bellevue-Everett, WA Met Div - - - - - Newark-Union, NJ-PA Met Div - - - - - Taunton-Norton-Raynham, MA NECTA Div - - - - - Lowell-Billerica-Chelmsford, MA-NH NECTA Div - - - - - New York-Northern New Jersey-Long Island, NY-NJ-PA MSA - - - - - San Francisco-San Mateo-Redwood City, CA Met Div - - - - - Edison-New Brunswick, NJ Met Div - - - - - Philadelphia-Camden-Wilmington, PA-NJ-DE-MD MSA - - - - - Wilmington, DE-MD-NJ Met Div - - - - - Peabody, MA NECTA Div - - - - - Philadelphia, PA Met Div - - - - - Fort Worth-Arlington, TX Met Div - - - - - Dallas-Fort Worth-Arlington, TX MSA - - - - - Dallas-Plano-Irving, TX Met Div - - - - - Haverhill-North Andover-Amesbury, MA-NH NECTA Div - - - - - Boston-Cambridge-Quincy, MA-NH Met NECTA - - - - - Boston-Cambridge-Quincy, MA NECTA Div - - - - - Nassau-Suffolk, NY Met Div - - - - - Framingham, MA NECTA Div - - - - - Nashua, NH-MA NECTA Div - - - - - Washington-Arlington-Alexandria, DC-VA-MD-WV Met Div - - - - - Washington-Arlington-Alexandria, DC-VA-MD-WV MSA - - - - - Bethesda-Rockville-Frederick, MD Met Div - + + + Detroit-Livonia-Dearborn, MI Met Div + + + Detroit-Warren-Livonia, MI MSA + + + Warren-Troy-Farmington Hills, MI Met Div + + + Lawrence-Methuen-Salem, MA-NH NECTA Div + + + Los Angeles-Long Beach-Glendale, CA Met Div + + + Miami-Miami Beach-Kendall, FL Met Div + + + Los Angeles-Long Beach-Santa Ana, CA MSA + + + Lake County-Kenosha County, IL-WI Met Div + + + West Palm Beach-Boca Raton-Boynton Beach, FL Met Div + + + Chicago-Joliet-Naperville, IL Met Div + + + Chicago-Joliet-Naperville, IL-IN-WI MSA + + + Miami-Fort Lauderdale-Pompano Beach, FL MSA + + + Oakland-Fremont-Hayward, CA Met Div + + + Gary, IN Met Div + + + Tacoma, WA Met Div + + + San Francisco-Oakland-Fremont, CA MSA + + + Camden, NJ Met Div + + + Brockton-Bridgewater-Easton, MA NECTA Div + + + Seattle-Tacoma-Bellevue, WA MSA + + + Fort Lauderdale-Pompano Beach-Deerfield Beach, FL Met Div + + + New York-White Plains-Wayne, NY-NJ Met Div + + + Santa Ana-Anaheim-Irvine, CA Met Div + + + Seattle-Bellevue-Everett, WA Met Div + + + Newark-Union, NJ-PA Met Div + + + Taunton-Norton-Raynham, MA NECTA Div + + + Lowell-Billerica-Chelmsford, MA-NH NECTA Div + + + New York-Northern New Jersey-Long Island, NY-NJ-PA MSA + + + San Francisco-San Mateo-Redwood City, CA Met Div + + + Edison-New Brunswick, NJ Met Div + + + Philadelphia-Camden-Wilmington, PA-NJ-DE-MD MSA + + + Wilmington, DE-MD-NJ Met Div + + + Peabody, MA NECTA Div + + + Philadelphia, PA Met Div + + + Fort Worth-Arlington, TX Met Div + + + Dallas-Fort Worth-Arlington, TX MSA + + + Dallas-Plano-Irving, TX Met Div + + + Haverhill-North Andover-Amesbury, MA-NH NECTA Div + + + Boston-Cambridge-Quincy, MA-NH Met NECTA + + + Boston-Cambridge-Quincy, MA NECTA Div + + + Nassau-Suffolk, NY Met Div + + + Framingham, MA NECTA Div + + + Nashua, NH-MA NECTA Div + + + Washington-Arlington-Alexandria, DC-VA-MD-WV Met Div + + + Washington-Arlington-Alexandria, DC-VA-MD-WV MSA + + + Bethesda-Rockville-Frederick, MD Met Div - - - + - - - - 2000 - 2002 - 2004 - 2006 - 2008 - 2010 - 2012 - + + + 2000 + 2002 + 2004 + 2006 + 2008 + 2010 + 2012 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/mixedFacets.svg b/test/output/mixedFacets.svg index 5d5b8bd4..85189f03 100644 --- a/test/output/mixedFacets.svg +++ b/test/output/mixedFacets.svg @@ -13,184 +13,136 @@ white-space: pre; } - - - - a - + + + a - - - b - + + b name - + - - 2024Jan - + 2024Jan - - Feb - + Feb - - - - - - + - - - - 0 - 2 - 4 - 6 - 8 - - - - - 0 - 2 - 4 - 6 - 8 - + + + 0 + 2 + 4 + 6 + 8 + + + 0 + 2 + 4 + 6 + 8 ↑ value - - - +
\ No newline at end of file diff --git a/test/output/offsets.html b/test/output/offsets.html index c86dabb6..f56013f9 100644 --- a/test/output/offsets.html +++ b/test/output/offsets.html @@ -1,4 +1,4 @@ -
+
+ - - - - Adelie - + + + Adelie - - - Chinstrap - + + Chinstrap - - - Gentoo - + + Gentoo species - + - - FEMALE - + FEMALE - - MALE - + MALE sex - - - + \ No newline at end of file diff --git a/test/output/penguinCulmenArray.svg b/test/output/penguinCulmenArray.svg index 44f5b32a..f6330e3c 100644 --- a/test/output/penguinCulmenArray.svg +++ b/test/output/penguinCulmenArray.svg @@ -13,3419 +13,3317 @@ white-space: pre; } - - - - Adelie - + + + Adelie - - - Chinstrap - + + Chinstrap - - - Gentoo - + + Gentoo species - + - - FEMALE - + FEMALE - - MALE - + MALE sex - - - +
diff --git a/test/output/penguinDensityZ.html b/test/output/penguinDensityZ.html index 8f6b605d..80b67b02 100644 --- a/test/output/penguinDensityZ.html +++ b/test/output/penguinDensityZ.html @@ -50,175 +50,141 @@ - + - - Biscoe - + Biscoe - - Dream - + Dream - - Torgersen - + Torgersen island - - - + - - - - 35 - 40 - 45 - 50 - 55 - + + + 35 + 40 + 45 + 50 + 55 ↑ culmen_length_mm - - - - - - - - - + - - - - 180 - 200 - 220 - - - - - 180 - 200 - 220 - - - - - 180 - 200 - 220 - + + + 180 + 200 + 220 + + + 180 + 200 + 220 + + + 180 + 200 + 220 flipper_length_mm → - - - - - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Gentoo - Gentoo - Gentoo - Gentoo - Gentoo - Gentoo - Gentoo - Gentoo - Gentoo - + + + + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Gentoo + Gentoo + Gentoo + Gentoo + Gentoo + Gentoo + Gentoo + Gentoo + Gentoo - - - - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Chinstrap - Chinstrap - Chinstrap - Chinstrap - Chinstrap - Chinstrap - Chinstrap - Chinstrap - Chinstrap - + + + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Chinstrap + Chinstrap + Chinstrap + Chinstrap + Chinstrap + Chinstrap + Chinstrap + Chinstrap + Chinstrap - - - - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - Adelie - + + + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie + Adelie - - - - - - - - - - + + + +
diff --git a/test/output/penguinDodgeHexbin.svg b/test/output/penguinDodgeHexbin.svg index 9838bba8..b1f5c56a 100644 --- a/test/output/penguinDodgeHexbin.svg +++ b/test/output/penguinDodgeHexbin.svg @@ -13,807 +13,773 @@ white-space: pre; } - - - - Adelie - + + + Adelie - - - Chinstrap - + + Chinstrap - - - Gentoo - + + Gentoo - - - +