forked from observablehq/plot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
dodge.js
125 lines (113 loc) · 4.81 KB
/
dodge.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import IntervalTree from "interval-tree-1d";
import {finite, positive} from "../defined.js";
import {identity, number, valueof} from "../options.js";
import {coerceNumbers} from "../scales.js";
import {initializer} from "./basic.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) ([dodgeOptions, options] = mergeOptions(dodgeOptions));
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, number(padding), options);
}
export function dodgeY(dodgeOptions = {}, options = {}) {
if (arguments.length === 1) ([dodgeOptions, options] = mergeOptions(dodgeOptions));
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, number(padding), options);
}
function mergeOptions(options) {
const {anchor, padding, ...rest} = options;
return [{anchor, padding}, rest];
}
function dodge(y, x, anchor, padding, options) {
const {r} = options;
if (r != null && typeof r !== "number") {
const {channels, sort, reverse} = options;
options = {...options, channels: [...channels ?? [], {name: "r", value: r, scale: "r"}]};
if (sort === undefined && reverse === undefined) options.sort = {channel: "r", order: "descending"};
}
return initializer(options, function(data, facets, {[x]: X, r: R}, scales, dimensions) {
if (!X) throw new Error(`missing channel: ${x}`);
X = coerceNumbers(valueof(X.value, scales[X.scale] || identity));
const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? number(options.r) : 3;
if (R) R = coerceNumbers(valueof(R.value, scales[R.scale] || identity));
let [ky, ty] = anchor(dimensions);
const compare = ky ? compareAscending : compareSymmetric;
const Y = new Float64Array(X.length);
const radius = R ? i => R[i] : () => r;
for (let I of facets) {
const tree = IntervalTree();
I = I.filter(R ? i => finite(X[i]) && positive(R[i]) : i => finite(X[i]));
const intervals = new Float64Array(2 * I.length + 2);
for (const i of I) {
const ri = radius(i);
const y0 = ky ? ri + padding : 0; // offset baseline for varying radius
const l = X[i] - ri;
const h = X[i] + ri;
// The first two positions are 0 to test placing the dot on the baseline.
let k = 2;
// 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, h + padding, ([,, j]) => {
const yj = Y[j] - y0;
const dx = X[i] - X[j];
const dr = padding + (R ? R[i] + R[j] : 2 * r);
const dy = Math.sqrt(dr * dr - dx * dx);
intervals[k++] = yj - dy;
intervals[k++] = yj + dy;
});
// Find the best y-value where this circle can fit.
let candidates = intervals.slice(0, k);
if (ky) candidates = candidates.filter(y => y >= 0);
out: for (const y of candidates.sort(compare)) {
for (let j = 0; j < k; j += 2) {
if (intervals[j] + 1e-6 < y && y < intervals[j + 1] - 1e-6) {
continue out;
}
}
Y[i] = y + y0;
break;
}
// Insert the placed circle into the interval tree.
tree.insert([l, h, i]);
}
}
if (!ky) ky = 1;
for (const I of facets) {
for (const i of I) {
Y[i] = Y[i] * ky + ty;
}
}
return {data, facets, channels: {
[x]: {value: X},
[y]: {value: Y},
...R && {r: {value: R}}
}};
});
}
function compareSymmetric(a, b) {
return Math.abs(a) - Math.abs(b);
}
function compareAscending(a, b) {
return a - b;
}