diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 19dead2a01..0000000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
- "plugins": ["@typescript-eslint"],
- "parser": "@typescript-eslint/parser",
- "env": {
- "es2020": true,
- "node": true,
- "browser": true
- },
- "rules": {
- "no-cond-assign": 0,
- "no-constant-condition": 0,
- "no-sparse-arrays": 0,
- "no-unexpected-multiline": 0,
- "@typescript-eslint/no-empty-function": 0,
- "@typescript-eslint/no-explicit-any": 0,
- "@typescript-eslint/no-this-alias": 0,
- "@typescript-eslint/no-unused-vars": ["error", {"ignoreRestSiblings": true}]
- }
-}
diff --git a/.github/eslint.json b/.github/eslint.json
deleted file mode 100644
index c280fdbd53..0000000000
--- a/.github/eslint.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "problemMatcher": [
- {
- "owner": "eslint-compact",
- "pattern": [
- {
- "regexp": "^(.+):\\sline\\s(\\d+),\\scol\\s(\\d+),\\s(Error|Warning|Info)\\s-\\s(.+)\\s\\((.+)\\)$",
- "file": 1,
- "line": 2,
- "column": 3,
- "severity": 4,
- "message": 5,
- "code": 6
- }
- ]
- }
- ]
-}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 608cfe8622..70d1136306 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
cache: yarn
- run: yarn --frozen-lockfile
- run: yarn prepublishOnly
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a8ca29c08b..002d85321f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -15,9 +15,9 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
+ cache: yarn
registry-url: 'https://registry.npmjs.org'
- cache: 'yarn'
- run: yarn --frozen-lockfile
- run: yarn test
- run: npm publish
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4eaaa5b855..d6ab52f70d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,14 +13,12 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
cache: yarn
- run: yarn --frozen-lockfile
- run: yarn test:mocha
- run: yarn test:tsc
- - run: |
- echo ::add-matcher::.github/eslint.json
- yarn run eslint src test --format=compact
+ - run: yarn test:lint
- run: yarn test:prettier
- run: yarn prepublishOnly
- run: yarn docs:build
diff --git a/CHANGELOG-2021.md b/CHANGELOG-2021.md
index 3b57dc9101..f7c69507fe 100644
--- a/CHANGELOG-2021.md
+++ b/CHANGELOG-2021.md
@@ -1,6 +1,6 @@
# Observable Plot - Changelog [2021]
-Year: [Current (2024)](./CHANGELOG.md) · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · **2021**
+Year: [Current (2025)](./CHANGELOG.md) · [2024](./CHANGELOG-2024.md) · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · **2021**
## 0.3.2
diff --git a/CHANGELOG-2022.md b/CHANGELOG-2022.md
index fcc2fc6e4f..c3094cd61c 100644
--- a/CHANGELOG-2022.md
+++ b/CHANGELOG-2022.md
@@ -1,6 +1,6 @@
# Observable Plot - Changelog [2022]
-Year: [Current (2024)](./CHANGELOG.md) · [2023](./CHANGELOG-2023.md) · **2022** · [2021](./CHANGELOG-2021.md)
+Year: [Current (2025)](./CHANGELOG.md) · [2024](./CHANGELOG-2024.md) · [2023](./CHANGELOG-2023.md) · **2022** · [2021](./CHANGELOG-2021.md)
## 0.6.1
diff --git a/CHANGELOG-2023.md b/CHANGELOG-2023.md
index 7fc18fb69d..5ac25bfd45 100644
--- a/CHANGELOG-2023.md
+++ b/CHANGELOG-2023.md
@@ -1,6 +1,6 @@
# Observable Plot - Changelog [2023]
-Year: [Current (2024)](./CHANGELOG.md) · **2023** · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md)
+Year: [Current (2025)](./CHANGELOG.md) · [2024](./CHANGELOG-2024.md) · **2023** · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md)
## 0.6.13
@@ -233,7 +233,7 @@ 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.
-[](https://observablehq.com/@observablehq/plot-arc-diagram?intent=fork)
+[](https://observablehq.com/@observablehq/plot-arc-diagram)
```js
Plot.plot({
diff --git a/CHANGELOG-2024.md b/CHANGELOG-2024.md
new file mode 100644
index 0000000000..bb966eab27
--- /dev/null
+++ b/CHANGELOG-2024.md
@@ -0,0 +1,129 @@
+# Observable Plot - 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!
+
+[](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).
+
+[](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.
+
+[](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.)
+
+
+
+```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.
+
+
+
+```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 441795e696..a84f489039 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,130 +1,66 @@
# Observable Plot - Changelog
-Year: **Current (2024)** · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md)
+Year: **Current (2025)** · [2024](./CHANGELOG-2024.md) · [2023](./CHANGELOG-2023.md) · [2022](./CHANGELOG-2022.md) · [2021](./CHANGELOG-2021.md)
-## 0.6.16
+## 0.6.17
-[Released August 6, 2024.](https://github.com/observablehq/plot/releases/tag/v0.6.16)
+[Released TDB, 2025.](https://github.com/observablehq/plot/releases/tag/v0.6.17)
-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!
+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:
-[](https://observablehq.com/plot/marks/waffle)
+[](https://observablehq.com/@observablehq/plot-world-airports)
```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).
-
-[](https://observablehq.com/plot/marks/geo)
-
-```js
-Plot.plot({
- projection: "albers-usa",
- color: {
- type: "quantile",
- n: 9,
- scheme: "blues",
- label: "Unemployment (%)",
- legend: true
- },
+ projection: {type: "orthographic", rotate: [110, -50]},
marks: [
- Plot.geo(counties, {
- fill: "unemployment",
- title: (d) => `${d.properties.name} ${d.properties.unemployment}%`,
- tip: true
- })
+ 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)
]
})
```
-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.)
+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.
-```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.
-
-[](https://observablehq.com/plot/marks/rect)
+[](https://observablehq.com/@observablehq/plot-us-water-vapor)
```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.raster(vapor, {
+ fill: Plot.identity,
+ width: 360,
+ height: 180,
+ x1: -180, y1: 90, x2: 180, y2: -90,
+ interpolate: "barycentric",
+ blur: 10,
+ clip: nation
+}).plot()
```
-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.)
+[The code for the map above is too long to reproduce here in its entirety; click the image above for the complete code.]
-
+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:
-```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.
-
-
+[](https://observablehq.com/@observablehq/plot-color-angle)
```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"
- })
- ]
-})
+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})
```
-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.
+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 out unit tests showing these anchoring points for various amounts of waffling. Baffling!
-Upgrades D3 to 7.9.0.
+
---
-For earlier changes, continue to the [2023 CHANGELOG](./CHANGELOG-2023.md).
+For earlier changes, continue to the [2024 CHANGELOG](./CHANGELOG-2024.md).
diff --git a/LICENSE b/LICENSE
index 7ad9b9653e..21be248f93 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2020-2023 Observable, Inc.
+Copyright 2020-2025 Observable, Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
diff --git a/README.md b/README.md
index c9e7e78577..c23c18d037 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,18 @@
# Observable Plot
-[](https://observablehq.com/plot/)
+[](https://observablehq.com/plot/)
[**Observable Plot**](https://observablehq.com/plot/) is a free, [open-source](./LICENSE), JavaScript library for visualizing tabular data, focused on accelerating exploratory data analysis. It has a concise, memorable, yet expressive API, featuring [scales](https://observablehq.com/plot/features/scales) and [layered marks](https://observablehq.com/plot/features/marks) in the *grammar of graphics* style.
+
+
+
+
+
+
+
+Daily downloads of Observable Plot · [oss-analytics](https://observablehq.observablehq.cloud/oss-analytics/)
+
## Documentation 📚
https://observablehq.com/plot/
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 2271044fca..f1a0381ac8 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -6,7 +6,7 @@ import plot from "./markdown-it-plot.js";
// https://vitepress.dev/reference/site-config
// prettier-ignore
export default defineConfig({
- title: "Observable Plot",
+ title: "Plot",
description: "The JavaScript library for exploratory data visualization",
appearance: "force-auto",
base: "/plot/",
@@ -17,6 +17,16 @@ export default defineConfig({
{find: "@observablehq/plot", 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: {
@@ -25,13 +35,17 @@ export default defineConfig({
}
},
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", {}, "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/'
+ hostname: "https://observablehq.com/plot/"
},
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
@@ -40,19 +54,14 @@ export default defineConfig({
light: "/observable-light.svg",
dark: "/observable-dark.svg"
},
- nav: [
- {text: "Home", link: "/"},
- {text: "Examples", link: "https://observablehq.com/@observablehq/plot-gallery"},
- {text: "Community", link: "/community"},
- {text: "D3", link: "https://d3js.org"}
- ],
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: "Getting started", link: "/getting-started"},
+ {text: "Examples", link: "https://observablehq.com/@observablehq/plot-gallery"}
]
},
{
@@ -145,13 +154,6 @@ export default defineConfig({
search: {
provider: "local"
},
- socialLinks: [
- {icon: "github", link: "https://github.com/observablehq/plot"},
- {icon: "x", link: "https://twitter.com/observablehq"},
- {icon: "slack", link: "https://observablehq.com/slack/join"},
- {icon: "linkedin", link: "https://www.linkedin.com/company/observable"},
- {icon: "youtube", link: "https://www.youtube.com/c/Observablehq"}
- ],
footer: {
message: "Library released under ISC License.",
copyright: `Copyright 2020–${new Date().getUTCFullYear()} Observable, Inc.`
diff --git a/docs/.vitepress/markdown-it-plot.ts b/docs/.vitepress/markdown-it-plot.ts
index eb6253f421..c4f101fe89 100644
--- a/docs/.vitepress/markdown-it-plot.ts
+++ b/docs/.vitepress/markdown-it-plot.ts
@@ -26,7 +26,7 @@ export default function plot(md) {
directives.includes("hidden")
? `
diff --git a/docs/.vitepress/theme/ObservablePromo.vue b/docs/.vitepress/theme/ObservablePromo.vue
index a877b5c931..1ca79f42f8 100644
--- a/docs/.vitepress/theme/ObservablePromo.vue
+++ b/docs/.vitepress/theme/ObservablePromo.vue
@@ -62,7 +62,7 @@ h1 {
opacity: 0.7;
}
-a.button {
+.promo a.button {
display: inline-block;
border: 1px solid transparent;
text-align: center;
diff --git a/docs/.vitepress/theme/VersionAndStars.vue b/docs/.vitepress/theme/VersionAndStars.vue
new file mode 100644
index 0000000000..0522b556cc
--- /dev/null
+++ b/docs/.vitepress/theme/VersionAndStars.vue
@@ -0,0 +1,82 @@
+
+
+
+
+ {{ version }}
+
+
+ GitHub️ {{ formattedStarCount }}
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css
index 2b5f70e8e9..d17de04eec 100644
--- a/docs/.vitepress/theme/custom.css
+++ b/docs/.vitepress/theme/custom.css
@@ -1,27 +1,45 @@
:root {
- --vp-c-purple-1: #7135be;
- --vp-c-purple-2: #7f42cd;
- --vp-c-purple-3: #9555e2;
- --vp-c-purple-soft: rgba(155, 91, 233, 0.14);
+ --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 {
- --vp-c-purple-1: #db96ff;
- --vp-c-purple-2: #9a5ae8;
- --vp-c-purple-3: #884ad6;
- --vp-c-purple-soft: rgba(155, 91, 233, 0.16);
+ --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;
}
-:root {
- --vp-c-red: #f43f5e;
- --vp-c-green: #10b981;
- --vp-c-blue: #0092ff;
- --vp-c-purple: #a463f2;
- --vp-c-brand-1: var(--vp-c-purple-1);
- --vp-c-brand-2: var(--vp-c-purple-2);
- --vp-c-brand-3: var(--vp-c-purple-3);
- --vp-c-brand-soft: var(--vp-c-purple-soft);
- --vp-font-family-base: -apple-system, BlinkMacSystemFont, "avenir next", avenir, helvetica, "helvetica neue", ubuntu, roboto, noto, "segoe ui", arial, sans-serif;
+.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 {
diff --git a/docs/.vitepress/theme/gallery.data.js b/docs/.vitepress/theme/gallery.data.js
index 72dabbca88..7f0b62d03d 100644
--- a/docs/.vitepress/theme/gallery.data.js
+++ b/docs/.vitepress/theme/gallery.data.js
@@ -1,9 +1,18 @@
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 runtime = new Runtime();
- const module = runtime.module((await import("https://api.observablehq.com/@observablehq/plot-gallery.js?v=4")).default);
+ 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));
diff --git a/docs/.vitepress/theme/stargazers.data.ts b/docs/.vitepress/theme/stargazers.data.ts
new file mode 100644
index 0000000000..fabf38df8a
--- /dev/null
+++ b/docs/.vitepress/theme/stargazers.data.ts
@@ -0,0 +1,28 @@
+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/community.md b/docs/community.md
index c320406760..6070f28651 100644
--- a/docs/community.md
+++ b/docs/community.md
@@ -14,7 +14,7 @@ And of course, follow us on [Observable](https://observablehq.com/@observablehq?
## Getting help
-We recommend asking for help on the [Observable forum](https://talk.observablehq.com/c/help/6). Or if you prefer chat, join the [Observable community Slack](https://observablehq.com/slack/join).
+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.
@@ -35,7 +35,9 @@ We’d love for you to join the community! Here are some ways to participate:
* 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 the [Observable forum](https://talk.observablehq.com/) and the [Observable community Slack](https://observablehq.com/slack/join). You’ll help others, and might learn something yourself, too.
+* 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).
diff --git a/docs/components/PlotRender.js b/docs/components/PlotRender.js
index 52cfa420f2..bb058759e1 100644
--- a/docs/components/PlotRender.js
+++ b/docs/components/PlotRender.js
@@ -1,9 +1,12 @@
import * as Plot from "@observablehq/plot";
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);
diff --git a/docs/features/interactions.md b/docs/features/interactions.md
index bf1dae5741..4cd5a27b3a 100644
--- a/docs/features/interactions.md
+++ b/docs/features/interactions.md
@@ -70,6 +70,6 @@ With the exception of render transforms (see the [pointer transform](https://git
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?intent=fork). In React, use [useEffect](https://react.dev/reference/react/useEffect) and [useRef](https://react.dev/reference/react/useRef) to re-render the plot when data changes. In Vue, use [ref](https://vuejs.org/api/reactivity-core.html#ref). For more, see our [getting started guide](../getting-started.md).
+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, use [useEffect](https://react.dev/reference/react/useEffect) and [useRef](https://react.dev/reference/react/useRef) to re-render the plot when data changes. 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
index f631ecdea0..a672c9e71a 100644
--- a/docs/features/intervals.md
+++ b/docs/features/intervals.md
@@ -21,7 +21,7 @@ The *interval*.**offset** method takes a *value* and returns the corresponding v
```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-03-22T12: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.
diff --git a/docs/features/markers.md b/docs/features/markers.md
index 82f306c8c6..66ced34e7b 100644
--- a/docs/features/markers.md
+++ b/docs/features/markers.md
@@ -30,7 +30,7 @@ A **marker** defines a graphic drawn on vertices of a [line](../marks/line.md) o
-:::plot https://observablehq.com/@observablehq/plot-line-chart-with-markers?intent=fork
+:::plot https://observablehq.com/@observablehq/plot-line-chart-with-markers
```js-vue
Plot.plot({
marks: [
diff --git a/docs/features/marks.md b/docs/features/marks.md
index 4d9cb90cd4..230ebc51ee 100644
--- a/docs/features/marks.md
+++ b/docs/features/marks.md
@@ -114,7 +114,7 @@ Plot.plot({
```
:::
-Marks may also be a function which returns an SVG element, if you wish to insert arbitrary content. (Here we use [Hypertext Literal](https://github.com/observablehq/htl) to generate an SVG gradient.)
+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
@@ -493,7 +493,7 @@ All marks support the following style options:
* **clip** - whether and how to clip the mark
* **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md)
-If the **clip** option 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*, then a [geographic projection](./projections.md) is required and the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection).
+If the **clip** option 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 , 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.)
diff --git a/docs/features/plots.md b/docs/features/plots.md
index 34548522e7..0902dadda9 100644
--- a/docs/features/plots.md
+++ b/docs/features/plots.md
@@ -1,3 +1,9 @@
+---
+prev:
+ text: Getting started
+ link: /getting-started
+---
+
-
+
```
:::
-See our [Plot + Svelte REPL](https://svelte.dev/repl/ebf78a6a6c1145ecb84cf9345a7f82ae?version=4.2.0) for details.
+See our [Plot + Svelte REPL](https://svelte.dev/playground/e65b5c87ae7e44239cef41ec3df28f52?version=5.2.7) for details.
## Plot in Node.js
diff --git a/docs/index.md b/docs/index.md
index db54fd1db4..503da97341 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -90,7 +90,7 @@ features:
:root {
--vp-home-hero-name-color: transparent;
- --vp-home-hero-name-background: linear-gradient(-30deg, hsl(200deg 100% 65%), var(--vp-c-purple));
+ --vp-home-hero-name-background: linear-gradient(-30deg, var(--hero-brand-contrast), var(--vp-c-brand-1));
}
:root.dark .VPHero .VPImage {
diff --git a/docs/marks/rect.md b/docs/marks/rect.md
index 676e8a781c..adabe46f7d 100644
--- a/docs/marks/rect.md
+++ b/docs/marks/rect.md
@@ -196,7 +196,7 @@ To round corners, use the **r** option. If the combined corner radii exceed the
-:::plot hidden defer
+:::plot hidden defer https://observablehq.com/@observablehq/plot-rounded-rects
```js
Plot.plot({
marks: [
@@ -218,7 +218,7 @@ Plot.plot({
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
+:::plot defer https://observablehq.com/@observablehq/plot-rounded-rects
```js
Plot.plot({
color: {legend: true},
@@ -232,7 +232,7 @@ Plot.plot({
You can even round specific corners using the **rx1y1**, **rx2y1**, **rx2y2**, and **rx1y2** options.
-:::plot defer
+:::plot defer https://observablehq.com/@observablehq/plot-rounded-rects
```js
Plot.plot({
color: {legend: true},
diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md
index 84be6e66a0..ddb9591d62 100644
--- a/docs/marks/waffle.md
+++ b/docs/marks/waffle.md
@@ -29,7 +29,7 @@ onMounted(() => {
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
+:::plot https://observablehq.com/@observablehq/plot-simple-waffle
```js
Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]}).plot({height: 420})
```
@@ -37,7 +37,7 @@ Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]}
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
+:::plot https://observablehq.com/@observablehq/plot-waffle-group
```js
Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sex"})).plot({x: {label: null}})
```
@@ -62,7 +62,7 @@ The **unit** option determines the quantity each waffle cell represents; it defa
-:::plot
+:::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}})
```
@@ -76,7 +76,7 @@ While waffles typically represent integer quantities, say to count people or day
Like bars, waffles can be [stacked](../transforms/stack.md), and implicitly apply the stack transform when only a single quantitative channel is supplied.
-:::plot
+:::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}})
```
@@ -84,7 +84,7 @@ Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx:
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
+:::plot https://observablehq.com/@observablehq/plot-survey-waffle
```js
Plot.plot({
axis: null,
diff --git a/docs/transforms/interval.md b/docs/transforms/interval.md
index 7b19052a83..93ce9aafd5 100644
--- a/docs/transforms/interval.md
+++ b/docs/transforms/interval.md
@@ -14,18 +14,13 @@ There’s also an [**interval** scale option](../features/scales.md#scale-transf
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! 👓
+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
- tickRotate: -90,
- fontVariant: "tabular-nums",
- label: null
- },
+ x: {type: "band"}, // ⚠️ not utc
y: {
transform: (d) => d / 1e6,
label: "Daily trade volume (millions)"
@@ -66,12 +61,7 @@ The meaning of the **interval** mark option depends on the associated mark, such
```js
Plot.plot({
marginBottom: 80,
- x: {
- type: "band", // ⚠️ not utc
- tickRotate: -90,
- label: null,
- fontVariant: "tabular-nums"
- },
+ x: {type: "band"}, // ⚠️ not utc
y: {
grid: true,
transform: (d) => d / 1e6,
diff --git a/docs/why-plot.md b/docs/why-plot.md
index 236e62a935..e8be2aad6b 100644
--- a/docs/why-plot.md
+++ b/docs/why-plot.md
@@ -163,7 +163,7 @@ We’ve long said that *D3 makes things possible, not necessarily easy.* And tha
**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://talk.observablehq.com/c/site-feedback/3) on what you find easy or hard to do with Plot. And we encourage you to [ask for help](https://talk.observablehq.com/c/help/6) when you get stuck. We learn a lot from helping!
+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.
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000000..788a70d4dc
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,55 @@
+import typescriptEslint from "@typescript-eslint/eslint-plugin";
+import globals from "globals";
+import tsParser from "@typescript-eslint/parser";
+import path from "node:path";
+import {fileURLToPath} from "node:url";
+import js from "@eslint/js";
+import {FlatCompat} from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+ recommendedConfig: js.configs.recommended,
+ allConfig: js.configs.all
+});
+
+export default [
+ ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"),
+ {
+ plugins: {
+ "@typescript-eslint": typescriptEslint
+ },
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ ...globals.browser
+ },
+ parser: tsParser
+ },
+ rules: {
+ "no-cond-assign": 0,
+ "no-constant-condition": 0,
+ "no-sparse-arrays": 0,
+ "no-unexpected-multiline": 0,
+ "@typescript-eslint/no-empty-function": 0,
+ "@typescript-eslint/no-explicit-any": 0,
+ "@typescript-eslint/no-this-alias": 0,
+ "@typescript-eslint/no-unused-expressions": 0,
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ ignoreRestSiblings: true
+ }
+ ]
+ }
+ },
+ {
+ files: ["test/**/*.js"],
+ languageOptions: {
+ globals: {
+ ...globals.mocha
+ }
+ }
+ }
+];
diff --git a/img/airports-clip-land.png b/img/airports-clip-land.png
new file mode 100644
index 0000000000..3ac4826faf
Binary files /dev/null and b/img/airports-clip-land.png differ
diff --git a/img/unit-circle-atan2.png b/img/unit-circle-atan2.png
new file mode 100644
index 0000000000..99c58cf6c6
Binary files /dev/null and b/img/unit-circle-atan2.png differ
diff --git a/img/vapor-clip-us.png b/img/vapor-clip-us.png
new file mode 100644
index 0000000000..61593ff8b5
Binary files /dev/null and b/img/vapor-clip-us.png differ
diff --git a/img/waffle-pointer-fractional.png b/img/waffle-pointer-fractional.png
new file mode 100644
index 0000000000..b2e0f0593c
Binary files /dev/null and b/img/waffle-pointer-fractional.png differ
diff --git a/package.json b/package.json
index b0126d24f4..a74d0e6ab0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@observablehq/plot",
"description": "A JavaScript library for exploratory data visualization.",
- "version": "0.6.16",
+ "version": "0.6.17",
"author": {
"name": "Observable, Inc.",
"url": "https://observablehq.com"
@@ -35,46 +35,46 @@
"test:tsc": "tsc",
"prepublishOnly": "rm -rf dist && rollup -c",
"dev": "vite",
- "docs:dev": "node --experimental-network-imports node_modules/vitepress/dist/node/cli.js dev docs",
- "docs:build": "node --experimental-network-imports node_modules/vitepress/dist/node/cli.js build docs",
+ "docs:dev": "vitepress dev docs",
+ "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
- "_moduleAliases": {
- "@observablehq/plot": "./src/index.js"
- },
"sideEffects": [
"./src/index.js"
],
"devDependencies": {
- "@observablehq/runtime": "^5.7.3",
- "@rollup/plugin-commonjs": "^25.0.2",
+ "@eslint/eslintrc": "^3.2.0",
+ "@eslint/js": "^9.20.0",
+ "@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": "^15.0.1",
+ "@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-terser": "^0.4.0",
"@types/d3": "^7.4.0",
"@types/mocha": "^10.0.1",
- "@types/node": "^20.5.0",
- "@typescript-eslint/eslint-plugin": "^7.2.0",
- "@typescript-eslint/parser": "^7.2.0",
- "apache-arrow": "^16.0.2",
- "c8": "^9.1.0",
- "canvas": "^2.0.0",
+ "@types/node": "^22.13.2",
+ "@typescript-eslint/eslint-plugin": "^8.24.0",
+ "@typescript-eslint/parser": "^8.24.0",
+ "apache-arrow": "^19.0.0",
+ "c8": "^10.1.3",
+ "canvas": "^3.1.0",
"d3-geo-projection": "^4.0.0",
- "eslint": "^8.16.0",
- "eslint-config-prettier": "^9.1.0",
+ "eslint": "^9.20.1",
+ "eslint-config-prettier": "^10.0.1",
+ "globals": "^15.15.0",
"htl": "^0.3.0",
"js-beautify": "1",
- "jsdom": "^24.0.0",
+ "jsdom": "^26.0.0",
"markdown-it-container": "^4.0.0",
- "mocha": "^10.0.0",
- "module-alias": "^2.0.0",
+ "mocha": "^11.1.0",
"prettier": "~3.0.0",
"rollup": "^4.9.1",
"topojson-client": "^3.1.0",
- "ts-morph": "^22.0.0",
+ "ts-morph": "^25.0.1",
"tsx": "^4.7.0",
"typescript": "^5.0.2",
- "vite": "^5.0.10",
+ "vite": "^6.1.0",
"vitepress": "^1.3.1"
},
"c8": {
diff --git a/rollup.config.js b/rollup.config.js
index 63394705cd..8d5bfb7ac9 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -3,7 +3,7 @@ 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" assert {type: "json"};
+import meta from "./package.json" with {type: "json"};
const filename = meta.name.split("/").pop();
diff --git a/src/context.d.ts b/src/context.d.ts
index ce2c3568d8..53a1c01fee 100644
--- a/src/context.d.ts
+++ b/src/context.d.ts
@@ -1,4 +1,4 @@
-import type {GeoStreamWrapper} from "d3";
+import type {GeoPath, GeoStreamWrapper} from "d3";
import type {MarkOptions} from "./mark.js";
/** Additional rendering context provided to marks and initializers. */
@@ -18,6 +18,9 @@ export interface Context {
/** The current projection, if any. */
projection?: GeoStreamWrapper;
+ /** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */
+ path: () => GeoPath;
+
/** The default clip for all marks. */
clip?: MarkOptions["clip"];
}
diff --git a/src/facet.js b/src/facet.js
index 5398b344e7..cc3af59ac4 100644
--- a/src/facet.js
+++ b/src/facet.js
@@ -63,11 +63,16 @@ export function facetGroups(data, {fx, fy}) {
}
export function facetTranslator(fx, fy, {marginTop, marginLeft}) {
- return fx && fy
- ? ({x, y}) => `translate(${fx(x) - marginLeft},${fy(y) - marginTop})`
- : fx
- ? ({x}) => `translate(${fx(x) - marginLeft},0)`
- : ({y}) => `translate(0,${fy(y) - marginTop})`;
+ const x = fx ? ({x}) => fx(x) - marginLeft : () => 0;
+ const y = fy ? ({y}) => fy(y) - marginTop : () => 0;
+ return function (d) {
+ if (this.tagName === "svg") {
+ this.setAttribute("x", x(d));
+ this.setAttribute("y", y(d));
+ } else {
+ this.setAttribute("transform", `translate(${x(d)},${y(d)})`);
+ }
+ };
}
// Returns an index that for each facet lists all the elements present in other
diff --git a/src/mark.d.ts b/src/mark.d.ts
index 4e5a60cbed..43e61da15f 100644
--- a/src/mark.d.ts
+++ b/src/mark.d.ts
@@ -1,6 +1,8 @@
+import type {GeoPermissibleObjects} from "d3";
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
import type {Context} from "./context.js";
import type {Dimensions} from "./dimensions.js";
+import type {PointerOptions} from "./interactions/pointer.js";
import type {TipOptions} from "./marks/tip.js";
import type {plot} from "./plot.js";
import type {ScaleFunctions} from "./scales.js";
@@ -288,18 +290,19 @@ export interface MarkOptions {
title?: ChannelValue;
/** Whether to generate a tooltip for this mark, and any tip options. */
- tip?: boolean | TipPointer | (TipOptions & {pointer?: TipPointer});
+ tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer});
/**
* How to clip the mark; one of:
*
* - *frame* or true - clip to the plot’s frame (inner area)
* - *sphere* - clip to the projected sphere (*e.g.*, front hemisphere)
+ * - geojson - a GeoJSON object, typically with polygonal geometry
* - null or false - do not clip
*
* The *sphere* clip option requires a geographic projection.
*/
- clip?: "frame" | "sphere" | boolean | null;
+ clip?: "frame" | "sphere" | GeoPermissibleObjects | boolean | null;
/**
* The horizontal offset in pixels; a constant option. On low-density screens,
diff --git a/src/mark.js b/src/mark.js
index 21f86d805c..080e9a3f03 100644
--- a/src/mark.js
+++ b/src/mark.js
@@ -2,9 +2,9 @@ import {channelDomain, createChannels, valueObject} from "./channel.js";
import {defined} from "./defined.js";
import {maybeFacetAnchor} from "./facet.js";
import {maybeClip, maybeNamed, maybeValue} from "./options.js";
-import {dataify, isDomainSort, isObject, isOptions, keyword, range, singleton} from "./options.js";
+import {dataify, isDomainSort, isObject, isOptions, keyword, range, singleton, string} from "./options.js";
import {project} from "./projection.js";
-import {maybeClassName, styles} from "./style.js";
+import {styles} from "./style.js";
import {basic, initializer} from "./transforms/basic.js";
export class Mark {
@@ -72,7 +72,7 @@ export class Mark {
this.marginLeft = +marginLeft;
this.clip = maybeClip(clip);
this.tip = maybeTip(tip);
- this.className = className ? maybeClassName(className) : null;
+ 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
// instead of) x and y.
diff --git a/src/marks/area.js b/src/marks/area.js
index bd8393926c..628088445a 100644
--- a/src/marks/area.js
+++ b/src/marks/area.js
@@ -78,10 +78,10 @@ export function area(data, options) {
export function areaX(data, options) {
const {y = indexOf, ...rest} = maybeDenseIntervalY(options);
- return new Area(data, maybeStackX(maybeIdentityX({...rest, y1: y, y2: undefined})));
+ return new Area(data, maybeStackX(maybeIdentityX({...rest, y1: y, y2: undefined}, y === indexOf ? "x2" : "x")));
}
export function areaY(data, options) {
const {x = indexOf, ...rest} = maybeDenseIntervalX(options);
- return new Area(data, maybeStackY(maybeIdentityY({...rest, x1: x, x2: undefined})));
+ return new Area(data, maybeStackY(maybeIdentityY({...rest, x1: x, x2: undefined}, x === indexOf ? "y2" : "y")));
}
diff --git a/src/marks/axis.js b/src/marks/axis.js
index 406ff70dd5..3a0e644909 100644
--- a/src/marks/axis.js
+++ b/src/marks/axis.js
@@ -89,6 +89,7 @@ function axisKy(
labelAnchor,
labelArrow,
labelOffset,
+ ariaLabel = `${k}-axis`,
...options
}
) {
@@ -107,6 +108,7 @@ function axisKy(
tickPadding,
tickRotate,
x,
+ ariaLabel,
...options
})
: null,
@@ -126,6 +128,7 @@ function axisKy(
marginRight,
marginBottom,
marginLeft,
+ ariaLabel,
...options
})
: null,
@@ -150,7 +153,7 @@ function axisKy(
}
this.dy = cla === "top" ? 3 - marginTop : cla === "bottom" ? marginBottom - 3 : 0;
this.dx = anchor === "right" ? clo : -clo;
- this.ariaLabel = `${k}-axis label`;
+ this.ariaLabel = `${ariaLabel} label`;
return {
facets: [[0]],
channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}}
@@ -190,6 +193,7 @@ function axisKx(
labelAnchor,
labelArrow,
labelOffset,
+ ariaLabel = `${k}-axis`,
...options
}
) {
@@ -208,6 +212,7 @@ function axisKx(
tickPadding,
tickRotate,
y,
+ ariaLabel,
...options
})
: null,
@@ -227,6 +232,7 @@ function axisKx(
marginRight,
marginBottom,
marginLeft,
+ ariaLabel,
...options
})
: null,
@@ -248,7 +254,7 @@ function axisKx(
this.lineAnchor = anchor;
this.dy = anchor === "top" ? -clo : clo;
this.dx = cla === "right" ? marginRight - 3 : cla === "left" ? 3 - marginLeft : 0;
- this.ariaLabel = `${k}-axis label`;
+ this.ariaLabel = `${ariaLabel} label`;
return {
facets: [[0]],
channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}}
@@ -275,6 +281,7 @@ function axisTickKy(
insetRight = inset,
dx = 0,
y = k === "y" ? undefined : null,
+ ariaLabel,
...options
}
) {
@@ -283,7 +290,7 @@ function axisTickKy(
k,
data,
{
- ariaLabel: `${k}-axis tick`,
+ ariaLabel: `${ariaLabel} tick`,
ariaHidden: true
},
{
@@ -318,6 +325,7 @@ function axisTickKx(
insetBottom = inset,
dy = 0,
x = k === "x" ? undefined : null,
+ ariaLabel,
...options
}
) {
@@ -326,7 +334,7 @@ function axisTickKx(
k,
data,
{
- ariaLabel: `${k}-axis tick`,
+ ariaLabel: `${ariaLabel} tick`,
ariaHidden: true
},
{
@@ -363,6 +371,7 @@ function axisTextKy(
insetLeft = inset,
insetRight = inset,
dx = 0,
+ ariaLabel,
y = k === "y" ? undefined : null,
...options
}
@@ -371,7 +380,7 @@ function axisTextKy(
textY,
k,
data,
- {ariaLabel: `${k}-axis tick label`},
+ {ariaLabel: `${ariaLabel} tick label`},
{
facetAnchor,
frameAnchor,
@@ -410,6 +419,7 @@ function axisTextKx(
insetBottom = inset,
dy = 0,
x = k === "x" ? undefined : null,
+ ariaLabel,
...options
}
) {
@@ -417,7 +427,7 @@ function axisTextKx(
textX,
k,
data,
- {ariaLabel: `${k}-axis tick label`},
+ {ariaLabel: `${ariaLabel} tick label`},
{
facetAnchor,
frameAnchor,
@@ -466,10 +476,12 @@ function gridKy(
x = null,
x1 = anchor === "left" ? x : null,
x2 = anchor === "right" ? x : null,
+ ariaLabel = `${k}-grid`,
+ ariaHidden = true,
...options
}
) {
- return axisMark(ruleY, k, data, {ariaLabel: `${k}-grid`, ariaHidden: true}, {y, x1, x2, ...gridDefaults(options)});
+ return axisMark(ruleY, k, data, {ariaLabel, ariaHidden}, {y, x1, x2, ...gridDefaults(options)});
}
function gridKx(
@@ -481,10 +493,12 @@ function gridKx(
y = null,
y1 = anchor === "top" ? y : null,
y2 = anchor === "bottom" ? y : null,
+ ariaLabel = `${k}-grid`,
+ ariaHidden = true,
...options
}
) {
- return axisMark(ruleX, k, data, {ariaLabel: `${k}-grid`, ariaHidden: true}, {x, y1, y2, ...gridDefaults(options)});
+ return axisMark(ruleX, k, data, {ariaLabel, ariaHidden}, {x, y1, y2, ...gridDefaults(options)});
}
function gridDefaults({
@@ -658,10 +672,10 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) {
? inferTimeFormat(scale.type, data, anchor) ?? formatDefault
: scale.tickFormat
? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat)
+ : typeof tickFormat === "string" && scale.domain().length > 0
+ ? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat)
: tickFormat === undefined
? formatDefault
- : typeof tickFormat === "string"
- ? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat)
: constant(tickFormat);
}
diff --git a/src/marks/dot.d.ts b/src/marks/dot.d.ts
index c078bb96b1..61dc131b6b 100644
--- a/src/marks/dot.d.ts
+++ b/src/marks/dot.d.ts
@@ -138,10 +138,10 @@ export function dotX(data?: Data, options?: DotXOptions): Dot;
export function dotY(data?: Data, options?: DotYOptions): Dot;
/** Like dot, except that the **symbol** option is set to *circle*. */
-export function circle(data?: Data, options?: Exclude): Dot;
+export function circle(data?: Data, options?: Omit): Dot;
/** Like dot, except that the **symbol** option is set to *hexagon*. */
-export function hexagon(data?: Data, options?: Exclude): Dot;
+export function hexagon(data?: Data, options?: Omit): Dot;
/** The dot mark. */
export class Dot extends RenderableMark {}
diff --git a/src/marks/geo.js b/src/marks/geo.js
index 854e3dcef5..60252dd447 100644
--- a/src/marks/geo.js
+++ b/src/marks/geo.js
@@ -1,4 +1,4 @@
-import {geoGraticule10, geoPath, geoTransform} from "d3";
+import {geoGraticule10} from "d3";
import {create} from "../context.js";
import {negative, positive} from "../defined.js";
import {Mark} from "../mark.js";
@@ -35,7 +35,7 @@ export class Geo extends Mark {
}
render(index, scales, channels, dimensions, context) {
const {geometry: G, r: R} = channels;
- const path = geoPath(context.projection ?? scaleProjection(scales));
+ const path = context.path();
const {r} = this;
if (negative(r)) index = [];
else if (r !== undefined) path.pointRadius(r);
@@ -55,20 +55,6 @@ export class Geo extends Mark {
}
}
-// If no projection is specified, default to a projection that passes points
-// through the x and y scales, if any.
-function scaleProjection({x: X, y: Y}) {
- if (X || Y) {
- X ??= (x) => x;
- Y ??= (y) => y;
- return geoTransform({
- point(x, y) {
- this.stream.point(X(x), Y(y));
- }
- });
- }
-}
-
export function geo(data, options = {}) {
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
else if (options.geometry === undefined) options = {...options, geometry: identity};
diff --git a/src/marks/line.js b/src/marks/line.js
index 35038ab7ca..74e4b19fe5 100644
--- a/src/marks/line.js
+++ b/src/marks/line.js
@@ -1,4 +1,4 @@
-import {geoPath, line as shapeLine} from "d3";
+import {line as shapeLine} from "d3";
import {create} from "../context.js";
import {curveAuto, maybeCurveAuto} from "../curve.js";
import {Mark} from "../mark.js";
@@ -67,7 +67,7 @@ export class Line extends Mark {
.attr(
"d",
curve === curveAuto && context.projection
- ? sphereLine(context.projection, X, Y)
+ ? sphereLine(context.path(), X, Y)
: shapeLine()
.curve(curve)
.defined((i) => i >= 0)
@@ -79,8 +79,7 @@ export class Line extends Mark {
}
}
-function sphereLine(projection, X, Y) {
- const path = geoPath(projection);
+function sphereLine(path, X, Y) {
X = coerceNumbers(X);
Y = coerceNumbers(Y);
return (I) => {
diff --git a/src/marks/link.js b/src/marks/link.js
index 9bac4f0a4c..602727df3f 100644
--- a/src/marks/link.js
+++ b/src/marks/link.js
@@ -1,4 +1,4 @@
-import {geoPath, pathRound as path} from "d3";
+import {pathRound as path} from "d3";
import {create} from "../context.js";
import {curveAuto, maybeCurveAuto} from "../curve.js";
import {Mark} from "../mark.js";
@@ -52,7 +52,7 @@ export class Link extends Mark {
.attr(
"d",
curve === curveAuto && context.projection
- ? sphereLink(context.projection, X1, Y1, X2, Y2)
+ ? sphereLink(context.path(), X1, Y1, X2, Y2)
: (i) => {
const p = path();
const c = curve(p);
@@ -70,8 +70,7 @@ export class Link extends Mark {
}
}
-function sphereLink(projection, X1, Y1, X2, Y2) {
- const path = geoPath(projection);
+function sphereLink(path, X1, Y1, X2, Y2) {
X1 = coerceNumbers(X1);
Y1 = coerceNumbers(Y1);
X2 = coerceNumbers(X2);
diff --git a/src/marks/waffle.js b/src/marks/waffle.js
index c9d8771d21..1e848bcc7e 100644
--- a/src/marks/waffle.js
+++ b/src/marks/waffle.js
@@ -1,9 +1,11 @@
import {extent, namespaces} from "d3";
+import {valueObject} from "../channel.js";
import {create} from "../context.js";
import {composeRender} from "../mark.js";
-import {hasXY, identity, indexOf} from "../options.js";
+import {hasXY, identity, indexOf, isObject} from "../options.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, getPatternId} from "../style.js";
import {template} from "../template.js";
+import {initializer} from "../transforms/basic.js";
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
@@ -14,8 +16,8 @@ const waffleDefaults = {
};
export class WaffleX extends BarX {
- constructor(data, {unit = 1, gap = 1, round, render, multiple, ...options} = {}) {
- super(data, {...options, render: composeRender(render, waffleRender("x"))}, waffleDefaults);
+ constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
+ super(data, wafflePolygon("x", options), waffleDefaults);
this.unit = Math.max(0, unit);
this.gap = +gap;
this.round = maybeRound(round);
@@ -24,8 +26,8 @@ export class WaffleX extends BarX {
}
export class WaffleY extends BarY {
- constructor(data, {unit = 1, gap = 1, round, render, multiple, ...options} = {}) {
- super(data, {...options, render: composeRender(render, waffleRender("y"))}, waffleDefaults);
+ constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
+ super(data, wafflePolygon("y", options), waffleDefaults);
this.unit = Math.max(0, unit);
this.gap = +gap;
this.round = maybeRound(round);
@@ -33,16 +35,19 @@ export class WaffleY extends BarY {
}
}
-function waffleRender(y) {
- return function (index, scales, values, dimensions, context) {
- const {unit, gap, rx, ry, round} = this;
- const {document} = context;
- const Y1 = values.channels[`${y}1`].value;
- const Y2 = values.channels[`${y}2`].value;
+function wafflePolygon(y, options) {
+ const x = y === "y" ? "x" : "y";
+ const y1 = `${y}1`;
+ const y2 = `${y}2`;
+ return initializer(waffleRender(options), function (data, facets, channels, scales, dimensions) {
+ const {round, unit} = this;
+ const Y1 = channels[y1].value;
+ const Y2 = channels[y2].value;
// We might not use all the available bandwidth if the cells don’t fit evenly.
- const barwidth = this[y === "y" ? "_width" : "_height"](scales, values, dimensions);
- const barx = this[y === "y" ? "_x" : "_y"](scales, values, dimensions);
+ const xy = valueObject({...(x in channels && {[x]: channels[x]}), [y1]: channels[y1], [y2]: channels[y2]}, scales);
+ const barwidth = this[y === "y" ? "_width" : "_height"](scales, xy, dimensions);
+ const barx = this[y === "y" ? "_x" : "_y"](scales, xy, dimensions);
// The length of a unit along y in pixels.
const scale = unit * scaleof(scales.scales[y]);
@@ -54,62 +59,98 @@ function waffleRender(y) {
const cx = Math.min(barwidth / multiple, scale * multiple);
const cy = scale * multiple;
- // TODO insets?
- const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
+ // The reference position.
const tx = (barwidth - multiple * cx) / 2;
const x0 = typeof barx === "function" ? (i) => barx(i) + tx : barx + tx;
const y0 = scales[y](0);
- // Create a base pattern with shared attributes for cloning.
- const patternId = getPatternId();
- const basePattern = document.createElementNS(namespaces.svg, "pattern");
- basePattern.setAttribute("width", y === "y" ? cx : cy);
- basePattern.setAttribute("height", y === "y" ? cy : cx);
- basePattern.setAttribute("patternUnits", "userSpaceOnUse");
- const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
- basePatternRect.setAttribute("x", gap / 2);
- basePatternRect.setAttribute("y", gap / 2);
- basePatternRect.setAttribute("width", (y === "y" ? cx : cy) - gap);
- basePatternRect.setAttribute("height", (y === "y" ? cy : cx) - gap);
- if (rx != null) basePatternRect.setAttribute("rx", rx);
- if (ry != null) basePatternRect.setAttribute("ry", ry);
-
- return create("svg:g", context)
- .call(applyIndirectStyles, this, dimensions, context)
- .call(this._transform, this, scales)
- .call((g) =>
- g
- .selectAll()
- .data(index)
- .enter()
- .append(() => basePattern.cloneNode(true))
- .attr("id", (i) => `${patternId}-${i}`)
- .select("rect")
- .call(applyDirectStyles, this)
- .call(applyChannelStyles, this, values)
- )
- .call((g) =>
- g
- .selectAll()
- .data(index)
- .enter()
- .append("path")
- .attr("transform", y === "y" ? template`translate(${x0},${y0})` : template`translate(${y0},${x0})`)
- .attr(
- "d",
- (i) =>
- `M${wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), multiple)
- .map(transform)
- .join("L")}Z`
- )
- .attr("fill", (i) => `url(#${patternId}-${i})`)
- .attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`)
- )
- .node();
+ // TODO insets?
+ const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
+ const mx = typeof x0 === "function" ? (i) => x0(i) - barwidth / 2 : () => x0;
+ const [ix, iy] = y === "y" ? [0, 1] : [1, 0];
+
+ const n = Y2.length;
+ const P = new Array(n);
+ const X = new Float64Array(n);
+ const Y = new Float64Array(n);
+
+ for (let i = 0; i < n; ++i) {
+ P[i] = wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), multiple).map(transform);
+ const c = P[i].pop(); // extract the transformed centroid
+ X[i] = c[ix] + mx(i);
+ Y[i] = c[iy] + y0;
+ }
+
+ return {
+ channels: {
+ polygon: {value: P, source: null, filter: null},
+ [`c${x}`]: {value: [cx, x0], source: null, filter: null},
+ [`c${y}`]: {value: [cy, y0], source: null, filter: null},
+ [x]: {value: X, scale: null, source: null},
+ [y1]: {value: Y, scale: null, source: channels[y1]},
+ [y2]: {value: Y, scale: null, source: channels[y2]}
+ }
+ };
+ });
+}
+
+function waffleRender({render, ...options}) {
+ return {
+ ...options,
+ render: composeRender(render, function (index, scales, values, dimensions, context) {
+ const {gap, rx, ry} = this;
+ const {channels, ariaLabel, href, title, ...visualValues} = values;
+ const {document} = context;
+ const polygon = channels.polygon.value;
+ const [cx, x0] = channels.cx.value;
+ const [cy, y0] = channels.cy.value;
+
+ // Create a base pattern with shared attributes for cloning.
+ const patternId = getPatternId();
+ const basePattern = document.createElementNS(namespaces.svg, "pattern");
+ basePattern.setAttribute("width", cx);
+ basePattern.setAttribute("height", cy);
+ basePattern.setAttribute("patternUnits", "userSpaceOnUse");
+ const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
+ basePatternRect.setAttribute("x", gap / 2);
+ basePatternRect.setAttribute("y", gap / 2);
+ basePatternRect.setAttribute("width", cx - gap);
+ basePatternRect.setAttribute("height", cy - gap);
+ if (rx != null) basePatternRect.setAttribute("rx", rx);
+ if (ry != null) basePatternRect.setAttribute("ry", ry);
+
+ return create("svg:g", context)
+ .call(applyIndirectStyles, this, dimensions, context)
+ .call(this._transform, this, scales)
+ .call((g) =>
+ g
+ .selectAll()
+ .data(index)
+ .enter()
+ .append(() => basePattern.cloneNode(true))
+ .attr("id", (i) => `${patternId}-${i}`)
+ .select("rect")
+ .call(applyDirectStyles, this)
+ .call(applyChannelStyles, this, visualValues)
+ )
+ .call((g) =>
+ g
+ .selectAll()
+ .data(index)
+ .enter()
+ .append("path")
+ .attr("transform", template`translate(${x0},${y0})`)
+ .attr("d", (i) => `M${polygon[i].join("L")}Z`)
+ .attr("fill", (i) => `url(#${patternId}-${i})`)
+ .attr("stroke", this.stroke == null ? null : "none")
+ .call(applyChannelStyles, this, {ariaLabel, href, title})
+ )
+ .node();
+ })
};
}
-// A waffle is a approximately rectangular shape, but may have one or two corner
+// A waffle is approximately a rectangular shape, but may have one or two corner
// cuts if the starting or ending value is not an even multiple of the number of
// columns (the width of the waffle in cells). We can represent any waffle by
// 8 points; below is a waffle of five columns representing the interval 2–11:
@@ -146,36 +187,73 @@ function waffleRender(y) {
// Waffles can also represent fractional intervals (e.g., 2.4–10.1). These
// require additional corner cuts, so the implementation below generates a few
// more points.
+//
+// The last point describes the centroid (used for pointing)
function wafflePoints(i1, i2, columns) {
- if (i1 < 0 || i2 < 0) {
- const k = Math.ceil(-Math.min(i1, i2) / columns); // shift negative to positive
- return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
+ if (i2 < i1) return wafflePoints(i2, i1, columns); // ensure i1 <= i2
+ if (i1 < 0) return wafflePointsOffset(i1, i2, columns, Math.ceil(-Math.min(i1, i2) / columns)); // ensure i1 >= 0
+ const x1f = Math.floor(i1 % columns);
+ const x1c = Math.ceil(i1 % columns);
+ const x2f = Math.floor(i2 % columns);
+ const x2c = Math.ceil(i2 % columns);
+ const y1f = Math.floor(i1 / columns);
+ const y1c = Math.ceil(i1 / columns);
+ const y2f = Math.floor(i2 / columns);
+ const y2c = Math.ceil(i2 / columns);
+ const points = [];
+ if (y2c > y1c) points.push([0, y1c]);
+ points.push([x1f, y1c], [x1f, y1f + (i1 % 1)], [x1c, y1f + (i1 % 1)]);
+ if (!(i1 % columns > columns - 1)) {
+ points.push([x1c, y1f]);
+ if (y2f > y1f) points.push([columns, y1f]);
}
- if (i2 < i1) {
- return wafflePoints(i2, i1, columns);
+ if (y2f > y1f) points.push([columns, y2f]);
+ points.push([x2c, y2f], [x2c, y2f + (i2 % 1)], [x2f, y2f + (i2 % 1)]);
+ if (!(i2 % columns < 1)) {
+ points.push([x2f, y2c]);
+ if (y2c > y1c) points.push([0, y2c]);
}
- return [
- [0, Math.ceil(i1 / columns)],
- [Math.floor(i1 % columns), Math.ceil(i1 / columns)],
- [Math.floor(i1 % columns), Math.floor(i1 / columns) + (i1 % 1)],
- [Math.ceil(i1 % columns), Math.floor(i1 / columns) + (i1 % 1)],
- ...(i1 % columns > columns - 1
- ? []
- : [
- [Math.ceil(i1 % columns), Math.floor(i1 / columns)],
- [columns, Math.floor(i1 / columns)]
- ]),
- [columns, Math.floor(i2 / columns)],
- [Math.ceil(i2 % columns), Math.floor(i2 / columns)],
- [Math.ceil(i2 % columns), Math.floor(i2 / columns) + (i2 % 1)],
- [Math.floor(i2 % columns), Math.floor(i2 / columns) + (i2 % 1)],
- ...(i2 % columns < 1
- ? []
- : [
- [Math.floor(i2 % columns), Math.ceil(i2 / columns)],
- [0, Math.ceil(i2 / columns)]
- ])
- ];
+ points.push(waffleCentroid(i1, i2, columns));
+ return points;
+}
+
+function wafflePointsOffset(i1, i2, columns, k) {
+ return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
+}
+
+function waffleCentroid(i1, i2, columns) {
+ const r = Math.floor(i2 / columns) - Math.floor(i1 / columns);
+ return r === 0
+ ? // Single row
+ waffleRowCentroid(i1, i2, columns)
+ : r === 1
+ ? // Two incomplete rows; use the midpoint of their overlap if any, otherwise the larger row
+ Math.floor(i2 % columns) > Math.ceil(i1 % columns)
+ ? [(Math.floor(i2 % columns) + Math.ceil(i1 % columns)) / 2, Math.floor(i2 / columns)]
+ : i2 % columns > columns - (i1 % columns)
+ ? waffleRowCentroid(i2 - (i2 % columns), i2, columns)
+ : waffleRowCentroid(i1, columns * Math.ceil(i1 / columns), columns)
+ : // At least one full row; take the midpoint of all the rows that include the middle
+ [columns / 2, (Math.round(i1 / columns) + Math.round(i2 / columns)) / 2];
+}
+
+function waffleRowCentroid(i1, i2, columns) {
+ const c = Math.floor(i2) - Math.floor(i1);
+ return c === 0
+ ? // Single cell
+ [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (((i1 + i2) / 2) % 1)]
+ : c === 1
+ ? // Two incomplete cells; use the overlap if large enough, otherwise use the largest
+ (i2 % 1) - (i1 % 1) > 0.5
+ ? [Math.ceil(i1 % columns), Math.floor(i2 / columns) + ((i1 % 1) + (i2 % 1)) / 2]
+ : i2 % 1 > 1 - (i1 % 1)
+ ? [Math.floor(i2 % columns) + 0.5, Math.floor(i2 / columns) + (i2 % 1) / 2]
+ : [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (1 + (i1 % 1)) / 2]
+ : // At least one full cell; take the midpoint
+ [
+ Math.ceil(i1 % columns) + Math.ceil(Math.floor(i2) - Math.ceil(i1)) / 2,
+ Math.floor(i1 / columns) + (i2 >= 1 + i1 ? 0.5 : ((i1 + i2) / 2) % 1)
+ ];
}
function maybeRound(round) {
@@ -198,12 +276,28 @@ function spread(domain) {
return max - min;
}
-export function waffleX(data, options = {}) {
+export function waffleX(data, {tip, ...options} = {}) {
if (!hasXY(options)) options = {...options, y: indexOf, x2: identity};
- return new WaffleX(data, maybeStackX(maybeIntervalX(maybeIdentityX(options))));
+ return new WaffleX(data, {tip: waffleTip(tip), ...maybeStackX(maybeIntervalX(maybeIdentityX(options)))});
}
-export function waffleY(data, options = {}) {
+export function waffleY(data, {tip, ...options} = {}) {
if (!hasXY(options)) options = {...options, x: indexOf, y2: identity};
- return new WaffleY(data, maybeStackY(maybeIntervalY(maybeIdentityY(options))));
+ return new WaffleY(data, {tip: waffleTip(tip), ...maybeStackY(maybeIntervalY(maybeIdentityY(options)))});
+}
+
+/**
+ * Waffle tips behave a bit unpredictably because we they are driven by the
+ * waffle centroid; you could be hovering over a waffle segment, but more than
+ * 40px away from its centroid, or closer to the centroid of another segment.
+ * We’d rather show a tip, even if it’s the “wrong” one, so we increase the
+ * default maxRadius to Infinity. The “right” way to fix this would be to use
+ * signed distance to the waffle geometry rather than the centroid.
+ */
+function waffleTip(tip) {
+ return tip === true
+ ? {maxRadius: Infinity}
+ : isObject(tip) && tip.maxRadius === undefined
+ ? {...tip, maxRadius: Infinity}
+ : undefined;
}
diff --git a/src/options.js b/src/options.js
index ac9caca472..d11bee1bb4 100644
--- a/src/options.js
+++ b/src/options.js
@@ -68,8 +68,12 @@ function maybeTypedArrowify(vector, type) {
return vector == null
? vector
: (type === undefined || type === Array) && isArrowDateType(vector.type)
- ? coerceDates(vector.toArray())
- : maybeTypedArrayify(vector.toArray(), type);
+ ? coerceDates(vectorToArray(vector))
+ : maybeTypedArrayify(vectorToArray(vector), type);
+}
+
+function vectorToArray(vector) {
+ return vector.nullCount ? vector.toJSON() : vector.toArray();
}
export const singleton = [null]; // for data-less decoration marks, e.g. frame
@@ -165,11 +169,24 @@ export function dataify(data) {
export function arrayify(values) {
if (values == null || isArray(values)) return values;
if (isArrowVector(values)) return maybeTypedArrowify(values);
- switch (values.type) {
+ if (isGeoJSON(values)) {
+ switch (values.type) {
+ case "FeatureCollection":
+ return values.features;
+ case "GeometryCollection":
+ return values.geometries;
+ default:
+ return [values];
+ }
+ }
+ return Array.from(values);
+}
+
+// Duck typing test for GeoJSON
+function isGeoJSON(x) {
+ switch (x?.type) {
case "FeatureCollection":
- return values.features;
case "GeometryCollection":
- return values.geometries;
case "Feature":
case "LineString":
case "MultiLineString":
@@ -178,9 +195,10 @@ export function arrayify(values) {
case "Point":
case "Polygon":
case "Sphere":
- return [values];
+ return true;
+ default:
+ return false;
}
- return Array.from(values);
}
// An optimization of type.from(values, f): if the given values are already an
@@ -598,12 +616,13 @@ export function maybeNamed(things) {
return isIterable(things) ? named(things) : things;
}
-// TODO Accept other types of clips (paths, urls, x, y, other marks…)?
-// https://github.com/observablehq/plot/issues/181
export function maybeClip(clip) {
if (clip === true) clip = "frame";
else if (clip === false) clip = null;
- else if (clip != null) clip = keyword(clip, "clip", ["frame", "sphere"]);
+ else if (!isGeoJSON(clip) && clip != null) {
+ clip = keyword(clip, "clip", ["frame", "sphere"]);
+ if (clip === "sphere") clip = {type: "Sphere"};
+ }
return clip;
}
diff --git a/src/plot.d.ts b/src/plot.d.ts
index 05fc238dc5..5024d49694 100644
--- a/src/plot.d.ts
+++ b/src/plot.d.ts
@@ -30,8 +30,10 @@ export interface PlotOptions extends ScaleDefaults {
* height. Given an aspect ratio of *dx* / *dy*, and assuming that the *x* and
* *y* scales represent equivalent units (say, degrees Celsius or meters),
* computes a default height such that *dx* pixels along *x* represents the
- * same variation as *dy* pixels along *y*. Note: when faceting, set the *fx*
- * and *fy* scales’ **round** option to false for an exact aspect ratio.
+ * same variation as *dy* pixels along *y*. When *x* or *y* is ordinal,
+ * consecutive domain values are treated as one unit length apart. Note: when
+ * faceting, set the *fx* and *fy* scales’ **round** option to false for an
+ * exact aspect ratio.
*/
aspectRatio?: number | boolean | null;
diff --git a/src/plot.js b/src/plot.js
index 9fc6a3e138..16976c2585 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -1,4 +1,4 @@
-import {creator, select} from "d3";
+import {creator, geoPath, select} from "d3";
import {createChannel, inferChannelScale} from "./channel.js";
import {createContext} from "./context.js";
import {createDimensions} from "./dimensions.js";
@@ -11,7 +11,7 @@ import {frame} from "./marks/frame.js";
import {tip} from "./marks/tip.js";
import {isColor, isIterable, isNone, isScaleOptions} from "./options.js";
import {dataify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js";
-import {createProjection, getGeometryChannels, hasProjection} from "./projection.js";
+import {createProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js";
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {innerDimensions, outerDimensions} from "./scales.js";
import {isPosition, registry as scaleRegistry} from "./scales/index.js";
@@ -159,6 +159,11 @@ export function plot(options = {}) {
context.className = className;
context.projection = createProjection(options, subdimensions);
+ // A path generator for marks that want to draw GeoJSON.
+ context.path = function () {
+ return geoPath(this.projection ?? xyProjection(scales));
+ };
+
// Allows e.g. the axis mark to determine faceting lazily.
context.filterFacets = (data, channels) => {
return facetFilter(facets, {channels, groups: facetGroups(data, channels)});
@@ -175,7 +180,7 @@ export function plot(options = {}) {
context.dispatchValue = (value) => {
if (figure.value === value) return;
figure.value = value;
- figure.dispatchEvent(new Event("input", {bubbles: true}));
+ figure.dispatchEvent(new context.document.defaultView.Event("input", {bubbles: true}));
};
// Reinitialize; for deriving channels dependent on other channels.
@@ -317,7 +322,7 @@ export function plot(options = {}) {
}
}
}
- g?.selectChildren().attr("transform", facetTranslate);
+ g?.selectChildren().each(facetTranslate);
}
}
diff --git a/src/projection.js b/src/projection.js
index 30df2ae88e..20e011101a 100644
--- a/src/projection.js
+++ b/src/projection.js
@@ -296,3 +296,17 @@ export function getGeometryChannels(channel) {
for (const object of channel.value) geoStream(object, sink);
return [x, y];
}
+
+// If no projection is specified, default to a projection that passes points
+// through the x and y scales, if any.
+export function xyProjection({x: X, y: Y}) {
+ if (X || Y) {
+ X ??= (x) => x;
+ Y ??= (y) => y;
+ return geoTransform({
+ point(x, y) {
+ this.stream.point(X(x), Y(y));
+ }
+ });
+ }
+}
diff --git a/src/scales.js b/src/scales.js
index 46a1f45c3c..7d01163ad0 100644
--- a/src/scales.js
+++ b/src/scales.js
@@ -425,10 +425,11 @@ function inferScaleType(key, channels, {type, domain, range, scheme, pivot, proj
if (kind === opacity || kind === length) return "linear";
if (kind === symbol) return "ordinal";
- // If the domain or range has more than two values, assume it’s ordinal. You
- // can still use a “piecewise” (or “polylinear”) scale, but you must set the
- // type explicitly.
- if ((domain || range || []).length > 2) return asOrdinalType(kind);
+ // If a domain or range is explicitly specified and doesn’t have two values,
+ // assume it’s ordinal. You can still use a “piecewise” (or “polylinear”)
+ // scale, but you must set the type explicitly.
+ const n = (domain ?? range)?.length;
+ if (n < 2 || n > 2) return asOrdinalType(kind);
// Otherwise, infer the scale type from the data! Prefer the domain, if
// present, over channels. (The domain and channels should be consistently
diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js
index 88feec4d07..8cf9cadbbb 100644
--- a/src/scales/quantitative.js
+++ b/src/scales/quantitative.js
@@ -80,6 +80,7 @@ export function createScaleQ(
reverse
}
) {
+ domain = maybeRepeat(domain);
interval = maybeRangeInterval(interval, type);
if (type === "cyclical" || type === "sequential") type = "linear"; // shorthand for color schemes
if (typeof interpolate !== "function") interpolate = maybeInterpolator(interpolate); // named interpolator
@@ -88,8 +89,8 @@ export function createScaleQ(
// If an explicit range is specified, and it has a different length than the
// domain, then redistribute the range using a piecewise interpolator.
if (range !== undefined) {
- const n = (domain = arrayify(domain)).length;
- const m = (range = arrayify(range)).length;
+ const n = domain.length;
+ const m = (range = maybeRepeat(range)).length;
if (n !== m) {
if (interpolate.length === 1) throw new Error("invalid piecewise interpolator"); // e.g., turbo
interpolate = piecewise(interpolate, range);
@@ -137,6 +138,11 @@ export function createScaleQ(
return {type, domain, range, scale, interpolate, interval};
}
+function maybeRepeat(values) {
+ values = arrayify(values);
+ return values.length >= 2 ? values : [values[0], values[0]];
+}
+
function maybeNice(nice, type) {
return nice === true ? undefined : typeof nice === "number" ? nice : maybeNiceInterval(nice, type);
}
diff --git a/src/style.js b/src/style.js
index 94e4742ee0..4fc1c9828a 100644
--- a/src/style.js
+++ b/src/style.js
@@ -1,4 +1,4 @@
-import {geoPath, group, namespaces, select} from "d3";
+import {group, namespaces, select} from "d3";
import {create} from "./context.js";
import {defined, nonempty} from "./defined.js";
import {formatDefault} from "./format.js";
@@ -306,24 +306,20 @@ export function* groupIndex(I, position, mark, channels) {
function applyClip(selection, mark, dimensions, context) {
let clipUrl;
const {clip = context.clip} = mark;
- switch (clip) {
- case "frame": {
- // Wrap the G element with another (untransformed) G element, applying the
- // clip to the parent G element so that the clip path is not affected by
- // the mark’s transform. To simplify the adoption of this fix, mutate the
- // passed-in selection.node to return the parent G element.
- selection = create("svg:g", context).each(function () {
- this.appendChild(selection.node());
- selection.node = () => this; // Note: mutation!
- });
- clipUrl = getFrameClip(context, dimensions);
- break;
- }
- case "sphere": {
- clipUrl = getProjectionClip(context);
- break;
- }
+ if (clip === "frame") {
+ // Wrap the G element with another (untransformed) G element, applying the
+ // clip to the parent G element so that the clip path is not affected by
+ // the mark’s transform. To simplify the adoption of this fix, mutate the
+ // passed-in selection.node to return the parent G element.
+ selection = create("svg:g", context).each(function () {
+ this.appendChild(selection.node());
+ selection.node = () => this; // Note: mutation!
+ });
+ clipUrl = getFrameClip(context, dimensions);
+ } else if (clip) {
+ clipUrl = getGeoClip(clip, context);
}
+
// Here we’re careful to apply the ARIA attributes to the outer G element when
// clipping is applied, and to apply the ARIA attributes before any other
// attributes (for readability).
@@ -356,11 +352,20 @@ const getFrameClip = memoizeClip((clipPath, context, dimensions) => {
.attr("height", height - marginTop - marginBottom);
});
-const getProjectionClip = memoizeClip((clipPath, context) => {
- const {projection} = context;
- if (!projection) throw new Error(`the "sphere" clip option requires a projection`);
- clipPath.append("path").attr("d", geoPath(projection)({type: "Sphere"}));
-});
+const geoClipCache = new WeakMap();
+const sphere = {type: "Sphere"};
+
+function getGeoClip(geo, context) {
+ let cache, url;
+ if (!(cache = geoClipCache.get(context))) geoClipCache.set(context, (cache = new WeakMap()));
+ if (geo.type === "Sphere") geo = sphere; // coalesce all spheres
+ if (!(url = cache.get(geo))) {
+ const id = getClipId();
+ select(context.ownerSVGElement).append("clipPath").attr("id", id).append("path").attr("d", context.path()(geo));
+ cache.set(geo, (url = `url(#${id})`));
+ }
+ return url;
+}
// Note: may mutate selection.node!
export function applyIndirectStyles(selection, mark, dimensions, context) {
diff --git a/src/transforms/centroid.js b/src/transforms/centroid.js
index a7d745e64f..6ad36c8c65 100644
--- a/src/transforms/centroid.js
+++ b/src/transforms/centroid.js
@@ -1,4 +1,4 @@
-import {geoCentroid as GeoCentroid, geoPath} from "d3";
+import {geoCentroid as GeoCentroid} from "d3";
import {memoize1} from "../memoize.js";
import {identity, valueof} from "../options.js";
import {initializer} from "./basic.js";
@@ -9,20 +9,17 @@ export function centroid({geometry = identity, ...options} = {}) {
// Suppress defaults for x and y since they will be computed by the initializer.
// Propagate the (memoized) geometry channel in case it’s still needed.
{...options, x: null, y: null, geometry: {transform: getG}},
- (data, facets, channels, scales, dimensions, {projection}) => {
+ (data, facets, channels, scales, dimensions, context) => {
const G = getG(data);
const n = G.length;
const X = new Float64Array(n);
const Y = new Float64Array(n);
- const path = geoPath(projection);
- for (let i = 0; i < n; ++i) [X[i], Y[i]] = path.centroid(G[i]);
+ const {centroid} = context.path();
+ for (let i = 0; i < n; ++i) [X[i], Y[i]] = centroid(G[i]);
return {
data,
facets,
- channels: {
- x: {value: X, scale: projection == null ? "x" : null, source: null},
- y: {value: Y, scale: projection == null ? "y" : null, source: null}
- }
+ channels: {x: {value: X, scale: null, source: null}, y: {value: Y, scale: null, source: null}}
};
}
);
diff --git a/src/transforms/identity.js b/src/transforms/identity.js
index 909511cc68..3b2ddf54cc 100644
--- a/src/transforms/identity.js
+++ b/src/transforms/identity.js
@@ -1,9 +1,9 @@
import {hasX, hasY, identity} from "../options.js";
-export function maybeIdentityX(options = {}) {
- return hasX(options) ? options : {...options, x: identity};
+export function maybeIdentityX(options = {}, k = "x") {
+ return hasX(options) ? options : {...options, [k]: identity};
}
-export function maybeIdentityY(options = {}) {
- return hasY(options) ? options : {...options, y: identity};
+export function maybeIdentityY(options = {}, k = "y") {
+ return hasY(options) ? options : {...options, [k]: identity};
}
diff --git a/test/.eslintrc.json b/test/.eslintrc.json
deleted file mode 100644
index 7eeefc33b6..0000000000
--- a/test/.eslintrc.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "env": {
- "mocha": true
- }
-}
diff --git a/test/event-test.js b/test/event-test.js
new file mode 100644
index 0000000000..2b0c1d0e2f
--- /dev/null
+++ b/test/event-test.js
@@ -0,0 +1,8 @@
+import * as Plot from "@observablehq/plot";
+import * as assert from "assert";
+import {JSDOM} from "jsdom";
+
+it("Plot uses the context’s event", () => {
+ Plot.lineY([1, 2, 3], {tip: true}).plot({document: new JSDOM("").window.document});
+ assert.ok(true);
+});
diff --git a/test/output/classNameOnMarks.svg b/test/output/classNameOnMarks.svg
index d06c154f19..3225c3a636 100644
--- a/test/output/classNameOnMarks.svg
+++ b/test/output/classNameOnMarks.svg
@@ -54,7 +54,7 @@
units →
-
+
diff --git a/test/output/colorLegendDomainEmpty.html b/test/output/colorLegendDomainEmpty.html
new file mode 100644
index 0000000000..5e9bfac54d
--- /dev/null
+++ b/test/output/colorLegendDomainEmpty.html
@@ -0,0 +1,27 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/colorLegendDomainUnary.html b/test/output/colorLegendDomainUnary.html
new file mode 100644
index 0000000000..1401790db6
--- /dev/null
+++ b/test/output/colorLegendDomainUnary.html
@@ -0,0 +1,29 @@
+