Skip to content

Commit 6f8d9cb

Browse files
committed
dodge (beeswarm)
1 parent 5ba59d9 commit 6f8d9cb

File tree

9 files changed

+566
-4
lines changed

9 files changed

+566
-4
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"sideEffects": false,
3737
"devDependencies": {
38+
"@rollup/plugin-commonjs": "^21.0.1",
3839
"@rollup/plugin-json": "4",
3940
"@rollup/plugin-node-resolve": "13",
4041
"canvas": "2",
@@ -50,6 +51,7 @@
5051
},
5152
"dependencies": {
5253
"d3": "^7.3.0",
54+
"interval-tree-1d": "1",
5355
"isoformat": "0.2"
5456
},
5557
"engines": {

rollup.config.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs";
22
import {terser} from "rollup-plugin-terser";
3+
import commonjs from "@rollup/plugin-commonjs";
34
import json from "@rollup/plugin-json";
45
import node from "@rollup/plugin-node-resolve";
56
import * as meta from "./package.json";
@@ -25,6 +26,7 @@ const config = {
2526
banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`
2627
},
2728
plugins: [
29+
commonjs(),
2830
json(),
2931
node()
3032
]

src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {Text, text, textX, textY} from "./marks/text.js";
1414
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
1515
export {Vector, vector} from "./marks/vector.js";
1616
export {valueof} from "./options.js";
17+
export {dodgeX, dodgeY} from "./layouts/dodge.js";
1718
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
1819
export {bin, binX, binY} from "./transforms/bin.js";
1920
export {group, groupX, groupY, groupZ} from "./transforms/group.js";

src/layouts/dodge.js

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {max} from "d3";
2+
import IntervalTree from "interval-tree-1d";
3+
import {maybeNumberChannel} from "../options.js";
4+
5+
const anchorXLeft = ({marginLeft}) => [1, marginLeft];
6+
const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
7+
const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2];
8+
const anchorYTop = ({marginTop}) => [1, marginTop];
9+
const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom];
10+
const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2];
11+
12+
function maybeDodge(options) {
13+
return typeof options === "string" ? {anchor: options} : options;
14+
}
15+
16+
export function dodgeX(dodgeOptions = {}, options = {}) {
17+
if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
18+
let {anchor = "left", padding} = maybeDodge(dodgeOptions);
19+
switch (`${anchor}`.toLowerCase()) {
20+
case "left": anchor = anchorXLeft; break;
21+
case "right": anchor = anchorXRight; break;
22+
case "middle": anchor = anchorXMiddle; break;
23+
default: throw new Error(`unknown dodge anchor: ${anchor}`);
24+
}
25+
return dodge("x", "y", anchor, padding, options);
26+
}
27+
28+
export function dodgeY(dodgeOptions = {}, options = {}) {
29+
if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
30+
let {anchor = "bottom", padding} = maybeDodge(dodgeOptions);
31+
switch (`${anchor}`.toLowerCase()) {
32+
case "top": anchor = anchorYTop; break;
33+
case "bottom": anchor = anchorYBottom; break;
34+
case "middle": anchor = anchorYMiddle; break;
35+
default: throw new Error(`unknown dodge anchor: ${anchor}`);
36+
}
37+
return dodge("y", "x", anchor, padding, options);
38+
}
39+
40+
function dodge(y, x, anchor, padding = 1, options) {
41+
const [, r] = maybeNumberChannel(options.r, 3);
42+
if (options[x] == null) throw new Error(`missing channel: ${x}`);
43+
return {
44+
...options,
45+
layout(I, scales, values, dimensions) { // TODO wrap previous layout?
46+
let {[x]: X, r: R} = values;
47+
let [ky, ty] = anchor(dimensions);
48+
const compare = ky ? compareAscending : compareSymmetric;
49+
if (ky) ty += ky * ((R ? max(I, i => R[i]) : r) + padding); else ky = 1;
50+
if (!R) R = new Float64Array(X.length).fill(r);
51+
const Y = new Float64Array(X.length);
52+
const tree = IntervalTree();
53+
for (const i of I) {
54+
const intervals = [];
55+
const l = X[i] - R[i];
56+
const r = X[i] + R[i];
57+
58+
// For any previously placed circles that may overlap this circle, compute
59+
// the y-positions that place this circle tangent to these other circles.
60+
// https://observablehq.com/@mbostock/circle-offset-along-line
61+
tree.queryInterval(l - padding, r + padding, ([,, j]) => {
62+
const yj = Y[j];
63+
const dx = X[i] - X[j];
64+
const dr = R[i] + padding + R[j];
65+
const dy = Math.sqrt(dr * dr - dx * dx);
66+
intervals.push([yj - dy, yj + dy]);
67+
});
68+
69+
// Find the best y-value where this circle can fit.
70+
for (let y of intervals.flat().sort(compare)) {
71+
if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) {
72+
Y[i] = y;
73+
break;
74+
}
75+
}
76+
77+
// Insert the placed circle into the interval tree.
78+
tree.insert([l, r, i]);
79+
}
80+
return {...values, [y]: Y.map(y => y * ky + ty)};
81+
}
82+
};
83+
}
84+
85+
function compareSymmetric(a, b) {
86+
return Math.abs(a) - Math.abs(b);
87+
}
88+
89+
function compareAscending(a, b) {
90+
return (a < 0) - (b < 0) || (a - b);
91+
}

src/plot.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ export function plot(options = {}) {
9595

9696
for (const mark of marks) {
9797
const channels = markChannels.get(mark) ?? [];
98-
const values = applyScales(channels, scales);
98+
let values = applyScales(channels, scales);
9999
const index = filter(markIndex.get(mark), channels, values);
100+
if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
100101
const node = mark.render(index, scales, values, dimensions, axes);
101102
if (node != null) svg.appendChild(node);
102103
}
@@ -132,9 +133,10 @@ function filter(index, channels, values) {
132133

133134
export class Mark {
134135
constructor(data, channels = [], options = {}, defaults) {
135-
const {facet = "auto", sort, dx, dy} = options;
136+
const {layout, facet = "auto", sort, dx, dy} = options;
136137
const names = new Set();
137138
this.data = data;
139+
this.layout = layout;
138140
this.sort = isOptions(sort) ? sort : null;
139141
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
140142
const {transform} = basic(options);

0 commit comments

Comments
 (0)