Skip to content

Commit 786bf3f

Browse files
committed
Merge branch 'fil/brush-data-options' into fil/brush-merge
2 parents 9b0b97e + 662ad4a commit 786bf3f

8 files changed

Lines changed: 382 additions & 81 deletions

File tree

src/interactions/brush.js

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "d3";
1111
import {composeRender, Mark} from "../mark.js";
1212
import {constant, dataify, identity, isIterable, keyword, maybeInterval, maybeTuple, take} from "../options.js";
13+
import {pixelRound} from "../precision.js";
1314
import {applyAttr} from "../style.js";
1415

1516
const defaults = {ariaLabel: "brush", fill: "#777", fillOpacity: 0.3, stroke: "#fff"};
@@ -49,8 +50,8 @@ export class Brush extends Mark {
4950
const dim = this._dimension;
5051
const interval = this._interval;
5152
if (context.projection && dim !== "xy") throw new Error(`brush${dim.toUpperCase()} does not support projections`);
52-
const invertX = (!context.projection && x?.invert) || ((d) => d);
53-
const invertY = (!context.projection && y?.invert) || ((d) => d);
53+
const invertX = precisionInvert(x, context.projection);
54+
const invertY = precisionInvert(y, context.projection);
5455
const applyX = (this._applyX = (!context.projection && x) || ((d) => d));
5556
const applyY = (this._applyY = (!context.projection && y) || ((d) => d));
5657
context.dispatchValue(null);
@@ -72,7 +73,10 @@ export class Brush extends Mark {
7273
_brush
7374
.extent([
7475
[dimensions.marginLeft - (dim !== "y"), dimensions.marginTop - (dim !== "x")],
75-
[dimensions.width - dimensions.marginRight + (dim !== "y"), dimensions.height - dimensions.marginBottom + (dim !== "x")]
76+
[
77+
dimensions.width - dimensions.marginRight + (dim !== "y"),
78+
dimensions.height - dimensions.marginBottom + (dim !== "x")
79+
]
7680
])
7781
.on("start brush end", function (event) {
7882
if (syncing) return;
@@ -131,40 +135,28 @@ export class Brush extends Mark {
131135
context.dispatchValue(value);
132136
}
133137
} else {
134-
const [[px1, py1], [px2, py2]] =
135-
dim === "xy"
136-
? selection
137-
: dim === "x"
138-
? [
139-
[selection[0], NaN],
140-
[selection[1], NaN]
141-
]
142-
: [
143-
[NaN, selection[0]],
144-
[NaN, selection[1]]
145-
];
146-
147-
const inX = isNaN(px1) ? () => true : (xi) => px1 <= xi && xi < px2;
148-
const inY = isNaN(py1) ? () => true : (yi) => py1 <= yi && yi < py2;
149-
138+
const [[px1, py1], [px2, py2]] = dim === "xy" ? selection
139+
: dim === "x" ? [[selection[0]], [selection[1]]]
140+
: [[, selection[0]], [, selection[1]]]; // prettier-ignore
141+
const inX = dim !== "y" && ((xi) => px1 <= xi && xi < px2);
142+
const inY = dim !== "x" && ((yi) => py1 <= yi && yi < py2);
150143
if (sync) {
151144
syncing = true;
152145
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, selection);
153146
syncing = false;
154147
}
155148
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
156149
inactive.update(false, i);
157-
ctx.update((xi, yi) => !(inX(xi) && inY(yi)), i);
158-
focus.update((xi, yi) => inX(xi) && inY(yi), i);
150+
ctx.update(!inX ? (_, yi) => !inY(yi) : !inY ? (xi) => !inX(xi) : (xi, yi) => !(inX(xi) && inY(yi)), i);
151+
focus.update(!inX ? (_, yi) => inY(yi) : !inY ? inX : (xi, yi) => inX(xi) && inY(yi), i);
159152
}
160153

161154
const [x1, x2] = invertX && [invertX(px1), invertX(px2)].sort(ascending);
162155
const [y1, y2] = invertY && [invertY(py1), invertY(py2)].sort(ascending);
163156

164157
// Snap to interval on end
165-
if (type === "end" && interval && !snapping) {
166-
const s1 = dim === "x" ? x1 : y1;
167-
const s2 = dim === "x" ? x2 : y2;
158+
if (!snapping && type === "end" && interval) {
159+
const [s1, s2] = dim === "x" ? [x1, x2] : [y1, y2];
168160
const r1 = intervalRound(interval, s1);
169161
let r2 = intervalRound(interval, s2);
170162
if (+r1 === +r2) r2 = interval.offset(r1);
@@ -207,10 +199,9 @@ export class Brush extends Mark {
207199
return;
208200
}
209201
const {x1, x2, y1, y2, fx, fy} = value;
210-
const node = this._brushNodes.find((n) => {
211-
const d = n.__data__;
212-
return (fx === undefined || d?.x === fx) && (fy === undefined || d?.y === fy);
213-
});
202+
const node = this._brushNodes.find(
203+
(n) => (fx === undefined || n.__data__?.x === fx) && (fy === undefined || n.__data__?.y === fy)
204+
);
214205
if (!node) return;
215206
const [px1, px2] = [x1, x2].map(this._applyX).sort(ascending);
216207
const [py1, py2] = [y1, y2].map(this._applyY).sort(ascending);
@@ -315,24 +306,22 @@ function renderFilter(initialTest, channelDefaults = {}) {
315306
pointerEvents: "none",
316307
...channelDefaults,
317308
...options,
318-
render: composeRender(function (index, scales, values, dimensions, context, next) {
319-
const {x: X, y: Y, x1: X1, x2: X2, y1: Y1, y2: Y2} = values;
309+
render: composeRender((index, scales, values, dimensions, context, next) => {
310+
const {x: X, y: Y, x1: X1, x2: X2, y1: Y1, y2: Y2, z: Z} = values;
320311
const MX = X ?? (X1 && X2 ? Float64Array.from(X1, (v, i) => (v + X2[i]) / 2) : undefined);
321312
const MY = Y ?? (Y1 && Y2 ? Float64Array.from(Y1, (v, i) => (v + Y2[i]) / 2) : undefined);
313+
const G = Array((MX ?? MY).length);
322314
const render = (test) => {
323315
if (typeof test !== "function") test = constant(test);
324-
let run = [];
325-
const runs = [run];
316+
const I = [];
317+
let run = 0;
326318
for (const i of index) {
327-
if (test(MX?.[i], MY?.[i])) run.push(i);
328-
else if (run.length) runs.push((run = []));
319+
if (test(MX?.[i], MY?.[i])) {
320+
I.push(i);
321+
G[i] = `${Z?.[i] ?? ""}/${run}`;
322+
} else ++run;
329323
}
330-
const g = next(runs[0], scales, values, dimensions, context);
331-
for (const run of runs.slice(1)) {
332-
const h = next(run, scales, values, dimensions, context);
333-
while (h.firstChild) g.appendChild(h.firstChild);
334-
}
335-
return g;
324+
return next(I, scales, {...values, z: G}, dimensions, context);
336325
};
337326
let g = render(initialTest);
338327
updatePerFacet.push((test) => {
@@ -351,3 +340,9 @@ function renderFilter(initialTest, channelDefaults = {}) {
351340
}
352341
);
353342
}
343+
344+
function precisionInvert(scale, projection) {
345+
if (projection || !scale?.invert) return (d) => d;
346+
const round = pixelRound(scale);
347+
return (p) => round(scale.invert(p));
348+
}

src/precision.d.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type {ScaleType} from "./scales.js";
2+
3+
/** Internal d3 scale with type, as produced by createScaleFunctions. */
4+
export interface MaterializedScale {
5+
(value: any): number;
6+
type: ScaleType;
7+
domain(): any[];
8+
range(): number[];
9+
invert(value: number): any;
10+
}
11+
12+
/**
13+
* Returns a function that rounds values in data space to the coarsest
14+
* precision that distinguishes neighboring pixels. For temporal scales, finds
15+
* the coarsest calendar interval that spans at most 1px; for linear scales,
16+
* uses a uniform step; for non-linear scales (where the data density varies),
17+
* computes the step locally.
18+
*/
19+
export function pixelRound(scale: MaterializedScale): (value: any) => any;

src/precision.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {tickStep, timeTickInterval, utcTickInterval} from "d3";
2+
import {numberInterval} from "./options.js";
3+
4+
export function pixelRound(scale) {
5+
if (scale.type === "identity") return Math.round;
6+
if (!scale.invert) throw new Error(`Unsupported scale ${scale.type}`);
7+
const [d0, d1] = scale.domain();
8+
const r = scale.range();
9+
const span = Math.abs(r[1] - r[0]);
10+
return !span
11+
? (v) => v
12+
: scale.type === "linear"
13+
? niceRound(tickStep(0, Math.abs(d1 - d0) / span, 2))
14+
: scale.type === "utc" || scale.type === "time"
15+
? temporalPrecision(scale, d0, d1, span)
16+
: (v) => niceRound(tickStep(0, Math.abs(scale.invert(scale(v) + 0.5) - v), 2))(v);
17+
}
18+
19+
// Find the coarsest calendar interval whose offset spans at most 1px;
20+
// fall back to identity for sub-millisecond domains. The multipliers
21+
// 1, 1.5, 2, 2.5 cover the possible ratios between adjacent intervals.
22+
function temporalPrecision(scale, d0, d1, span) {
23+
const tickInterval = scale.type === "utc" ? utcTickInterval : timeTickInterval;
24+
const p0 = scale(d0);
25+
for (let k = 1; k <= 2.5; k += 0.5) {
26+
const interval = tickInterval(d0, d1, k * span);
27+
if (!interval) break;
28+
if (Math.abs(scale(interval.offset(d0)) - p0) <= 1) return interval.round;
29+
}
30+
return (v) => v;
31+
}
32+
33+
function niceRound(step) {
34+
const {floor} = numberInterval(step);
35+
return (v) => floor(+v + step / 2);
36+
}

test/output/arcCollatzUp.svg

Lines changed: 37 additions & 37 deletions
Loading

0 commit comments

Comments
 (0)