diff --git a/package.json b/package.json
index cbdb4ab4de..f73dc8a1b8 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
},
"sideEffects": false,
"devDependencies": {
+ "@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "4",
"@rollup/plugin-node-resolve": "13",
"canvas": "2",
@@ -50,6 +51,7 @@
},
"dependencies": {
"d3": "^7.3.0",
+ "interval-tree-1d": "1",
"isoformat": "0.2"
},
"engines": {
diff --git a/rollup.config.js b/rollup.config.js
index f2cded1105..ae8f8778b5 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -1,5 +1,6 @@
import fs from "fs";
import {terser} from "rollup-plugin-terser";
+import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import node from "@rollup/plugin-node-resolve";
import * as meta from "./package.json";
@@ -25,6 +26,7 @@ const config = {
banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`
},
plugins: [
+ commonjs(),
json(),
node()
]
diff --git a/src/index.js b/src/index.js
index 7ac7a63778..2eadbeeab7 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,6 +14,7 @@ export {Text, text, textX, textY} from "./marks/text.js";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {Vector, vector} from "./marks/vector.js";
export {valueof} from "./options.js";
+export {dodgeX, dodgeY} from "./layouts/dodge.js";
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js
new file mode 100644
index 0000000000..5e7371519b
--- /dev/null
+++ b/src/layouts/dodge.js
@@ -0,0 +1,88 @@
+import {max} from "d3";
+import IntervalTree from "interval-tree-1d";
+import {maybeNumberChannel} from "../options.js";
+import {layout} from "./index.js";
+
+const anchorXLeft = ({marginLeft}) => [1, marginLeft];
+const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
+const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2];
+const anchorYTop = ({marginTop}) => [1, marginTop];
+const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom];
+const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2];
+
+function maybeAnchor(anchor) {
+ return typeof anchor === "string" ? {anchor} : anchor;
+}
+
+export function dodgeX(dodgeOptions = {}, options = {}) {
+ if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
+ let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions);
+ switch (`${anchor}`.toLowerCase()) {
+ case "left": anchor = anchorXLeft; break;
+ case "right": anchor = anchorXRight; break;
+ case "middle": anchor = anchorXMiddle; break;
+ default: throw new Error(`unknown dodge anchor: ${anchor}`);
+ }
+ return dodge("x", "y", anchor, +padding, options);
+}
+
+export function dodgeY(dodgeOptions = {}, options = {}) {
+ if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
+ let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions);
+ switch (`${anchor}`.toLowerCase()) {
+ case "top": anchor = anchorYTop; break;
+ case "bottom": anchor = anchorYBottom; break;
+ case "middle": anchor = anchorYMiddle; break;
+ default: throw new Error(`unknown dodge anchor: ${anchor}`);
+ }
+ return dodge("y", "x", anchor, +padding, options);
+}
+
+function dodge(y, x, anchor, padding, options) {
+ const [, r] = maybeNumberChannel(options.r, 3);
+ return layout(options, (I, scales, {[x]: X, r: R}, dimensions) => {
+ if (X == null) throw new Error(`missing channel: ${x}`);
+ let [ky, ty] = anchor(dimensions);
+ const compare = ky ? compareAscending : compareSymmetric;
+ if (ky) ty += ky * ((R ? max(I, i => R[i]) : r) + padding); else ky = 1;
+ if (!R) R = new Float64Array(X.length).fill(r);
+ const Y = new Float64Array(X.length);
+ const tree = IntervalTree();
+ for (const i of I) {
+ const intervals = [];
+ const l = X[i] - R[i];
+ const r = X[i] + R[i];
+
+ // For any previously placed circles that may overlap this circle, compute
+ // the y-positions that place this circle tangent to these other circles.
+ // https://observablehq.com/@mbostock/circle-offset-along-line
+ tree.queryInterval(l - padding, r + padding, ([,, j]) => {
+ const yj = Y[j];
+ const dx = X[i] - X[j];
+ const dr = R[i] + padding + R[j];
+ const dy = Math.sqrt(dr * dr - dx * dx);
+ intervals.push([yj - dy, yj + dy]);
+ });
+
+ // Find the best y-value where this circle can fit.
+ for (let y of intervals.flat().sort(compare)) {
+ if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) {
+ Y[i] = y;
+ break;
+ }
+ }
+
+ // Insert the placed circle into the interval tree.
+ tree.insert([l, r, i]);
+ }
+ return {[y]: Y.map(y => y * ky + ty)};
+ });
+}
+
+function compareSymmetric(a, b) {
+ return Math.abs(a) - Math.abs(b);
+}
+
+function compareAscending(a, b) {
+ return (a < 0) - (b < 0) || (a - b);
+}
diff --git a/src/layouts/index.js b/src/layouts/index.js
new file mode 100644
index 0000000000..cee6396209
--- /dev/null
+++ b/src/layouts/index.js
@@ -0,0 +1,19 @@
+export function layout({layout: layout1, ...options}, layout2) {
+ if (layout2 == null) throw new Error("invalid layout");
+ layout2 = partialLayout(layout2);
+ if (layout1 != null) layout2 = composeLayout(layout1, layout2);
+ return {...options, layout: layout2};
+}
+
+function composeLayout(l1, l2) {
+ return function(index, scales, values, dimensions) {
+ values = l1.call(this, index, scales, values, dimensions);
+ return l2.call(this, index, scales, values, dimensions);
+ };
+}
+
+function partialLayout(l) {
+ return function(index, scales, values, dimensions) {
+ return {...values, ...l.call(this, index, scales, values, dimensions)};
+ };
+}
diff --git a/src/plot.js b/src/plot.js
index bcbbc92233..857d6fff41 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -95,8 +95,9 @@ export function plot(options = {}) {
for (const mark of marks) {
const channels = markChannels.get(mark) ?? [];
- const values = applyScales(channels, scales);
+ let values = applyScales(channels, scales);
const index = filter(markIndex.get(mark), channels, values);
+ if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
const node = mark.render(index, scales, values, dimensions, axes);
if (node != null) svg.appendChild(node);
}
@@ -132,9 +133,10 @@ function filter(index, channels, values) {
export class Mark {
constructor(data, channels = [], options = {}, defaults) {
- const {facet = "auto", sort, dx, dy} = options;
+ const {layout, facet = "auto", sort, dx, dy} = options;
const names = new Set();
this.data = data;
+ this.layout = layout;
this.sort = isOptions(sort) ? sort : null;
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
const {transform} = basic(options);
@@ -311,9 +313,11 @@ class Facet extends Mark {
.each(function(key) {
const marksFacetIndex = marksIndexByFacet.get(key);
for (let i = 0; i < marks.length; ++i) {
- const values = marksValues[i];
+ const mark = marks[i];
+ let values = marksValues[i];
const index = filter(marksFacetIndex[i], marksChannels[i], values);
- const node = marks[i].render(index, scales, values, subdimensions);
+ if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions);
+ const node = mark.render(index, scales, values, subdimensions);
if (node != null) this.appendChild(node);
}
}))
diff --git a/test/output/penguinDodge.svg b/test/output/penguinDodge.svg
new file mode 100644
index 0000000000..5383c14225
--- /dev/null
+++ b/test/output/penguinDodge.svg
@@ -0,0 +1,383 @@
+
\ No newline at end of file
diff --git a/test/output/penguinFacetDodge.svg b/test/output/penguinFacetDodge.svg
new file mode 100644
index 0000000000..c3509dd94c
--- /dev/null
+++ b/test/output/penguinFacetDodge.svg
@@ -0,0 +1,413 @@
+
\ No newline at end of file
diff --git a/test/plots/index.js b/test/plots/index.js
index 88ef7e17ed..df7cc1044e 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -87,6 +87,8 @@ export {default as musicRevenue} from "./music-revenue.js";
export {default as ordinalBar} from "./ordinal-bar.js";
export {default as penguinCulmen} from "./penguin-culmen.js";
export {default as penguinCulmenArray} from "./penguin-culmen-array.js";
+export {default as penguinDodge} from "./penguin-dodge.js";
+export {default as penguinFacetDodge} from "./penguin-facet-dodge.js";
export {default as penguinIslandUnknown} from "./penguin-island-unknown.js";
export {default as penguinMass} from "./penguin-mass.js";
export {default as penguinMassSex} from "./penguin-mass-sex.js";
diff --git a/test/plots/penguin-dodge.js b/test/plots/penguin-dodge.js
new file mode 100644
index 0000000000..47fc6ce892
--- /dev/null
+++ b/test/plots/penguin-dodge.js
@@ -0,0 +1,12 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ height: 200,
+ marks: [
+ Plot.dot(penguins, Plot.dodgeY({x: "body_mass_g"}))
+ ]
+ });
+}
diff --git a/test/plots/penguin-facet-dodge.js b/test/plots/penguin-facet-dodge.js
new file mode 100644
index 0000000000..77ed20a433
--- /dev/null
+++ b/test/plots/penguin-facet-dodge.js
@@ -0,0 +1,21 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ height: 300,
+ x: {
+ grid: true
+ },
+ facet: {
+ data: penguins,
+ y: "species",
+ label: null,
+ marginLeft: 60
+ },
+ marks: [
+ Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g"}))
+ ]
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index 6eef348e39..ec7bdc5607 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -67,6 +67,19 @@
semver "^7.3.5"
tar "^6.1.11"
+"@rollup/plugin-commonjs@^21.0.1":
+ version "21.0.1"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz#1e57c81ae1518e4df0954d681c642e7d94588fee"
+ integrity sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg==
+ dependencies:
+ "@rollup/pluginutils" "^3.1.0"
+ commondir "^1.0.1"
+ estree-walker "^2.0.1"
+ glob "^7.1.6"
+ is-reference "^1.2.1"
+ magic-string "^0.25.7"
+ resolve "^1.17.0"
+
"@rollup/plugin-json@4":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3"
@@ -100,6 +113,11 @@
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
+"@types/estree@*":
+ version "0.0.50"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
+ integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
+
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@@ -242,6 +260,11 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+binary-search-bounds@^2.0.0:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz#125e5bd399882f71e6660d4bf1186384e989fba7"
+ integrity sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==
+
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -388,6 +411,11 @@ commander@^2.19.0, commander@^2.20.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+commondir@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
+ integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
+
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -1021,6 +1049,11 @@ estree-walker@^1.0.1:
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+estree-walker@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+ integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
@@ -1163,7 +1196,7 @@ glob@7.1.7:
once "^1.3.0"
path-is-absolute "^1.0.0"
-glob@^7.1.3:
+glob@^7.1.3, glob@^7.1.6:
version "7.2.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
@@ -1296,6 +1329,13 @@ ini@^1.3.4:
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+interval-tree-1d@1:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz#b44f657de7ddae69ea3f98e0a9ad4bb046b07d11"
+ integrity sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==
+ dependencies:
+ binary-search-bounds "^2.0.0"
+
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
@@ -1303,7 +1343,7 @@ is-binary-path@~2.1.0:
dependencies:
binary-extensions "^2.0.0"
-is-core-module@^2.8.0:
+is-core-module@^2.8.0, is-core-module@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
@@ -1347,6 +1387,13 @@ is-potential-custom-element-name@^1.0.1:
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
+is-reference@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
+ integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
+ dependencies:
+ "@types/estree" "*"
+
is-unicode-supported@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
@@ -1487,6 +1534,13 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
+magic-string@^0.25.7:
+ version "0.25.7"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
+ integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==
+ dependencies:
+ sourcemap-codec "^1.4.4"
+
make-dir@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@@ -1811,6 +1865,15 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
+resolve@^1.17.0:
+ version "1.22.0"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
+ integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
+ dependencies:
+ is-core-module "^2.8.1"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
resolve@^1.19.0, resolve@^1.20.0:
version "1.21.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f"
@@ -1966,6 +2029,11 @@ source-map@~0.7.2:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
+sourcemap-codec@^1.4.4:
+ version "1.4.8"
+ resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
+ integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
+
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"