@pluot/react 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/brush.ts ADDED
@@ -0,0 +1,435 @@
1
+ import { getBounds, type AspectRatioMode, type AspectRatioAlignmentMode, type Bounds, type CameraMatrix } from "@pluot/core";
2
+ import type { BrushMode, BrushState, BrushUnitsMode, BrushVertex, RectLikeBrushMode } from "./types.js";
3
+
4
+ /** An axis-aligned brush extent, in container pixels. */
5
+ export type BrushBoundingBox = {
6
+ left: number;
7
+ top: number;
8
+ right: number;
9
+ bottom: number;
10
+ };
11
+
12
+ /** One side of an axis-aligned brush, which the user can drag to extend it. */
13
+ export type BrushEdge = "Top" | "Right" | "Bottom" | "Left";
14
+
15
+ /**
16
+ * Everything needed to convert a brush vertex between the three units modes.
17
+ *
18
+ * There are two rectangles involved, both expressed in *container* pixels
19
+ * (relative to the top-left of the outer `width` x `height` element, Y down),
20
+ * which is also the coordinate space of the brush overlay SVG:
21
+ *
22
+ * - The **layer** rect (the camera region, inside `margin*`), which anchors the
23
+ * `Data` units mode, since that is the region the camera matrix maps onto.
24
+ * - The **brushable** rect (inside `brushMargin*`), which bounds where the user
25
+ * may draw and which anchors the `Normalized` units mode.
26
+ *
27
+ * Note that `Data` and `Normalized` are Y-up (matching Pluot's data coordinate
28
+ * system, where `getBounds().yMin` is the bottom of the layer), whereas
29
+ * `Pixels` is Y-down (matching the DOM/SVG convention).
30
+ */
31
+ export type BrushGeometry = {
32
+ layerLeft: number;
33
+ layerTop: number;
34
+ layerWidth: number;
35
+ layerHeight: number;
36
+ brushLeft: number;
37
+ brushTop: number;
38
+ brushRight: number;
39
+ brushBottom: number;
40
+ /** The visible data range of the layer rect, under the current camera. */
41
+ dataBounds: Required<Bounds>;
42
+ };
43
+
44
+ export type BrushGeometryParams = {
45
+ width: number;
46
+ height: number;
47
+ marginTop: number;
48
+ marginRight: number;
49
+ marginBottom: number;
50
+ marginLeft: number;
51
+ /** Each defaults to the corresponding layer margin when undefined. */
52
+ brushMarginTop: number | undefined;
53
+ brushMarginRight: number | undefined;
54
+ brushMarginBottom: number | undefined;
55
+ brushMarginLeft: number | undefined;
56
+ brushUnitsModeX: BrushUnitsMode;
57
+ brushUnitsModeY: BrushUnitsMode;
58
+ aspectRatioMode: AspectRatioMode;
59
+ aspectRatioAlignmentMode: AspectRatioAlignmentMode;
60
+ cameraMatrix: CameraMatrix;
61
+ };
62
+
63
+ // Avoid dividing by zero for degenerate (zero-width or zero-height) regions.
64
+ function safeDivide(numerator: number, denominator: number): number {
65
+ return denominator === 0 ? 0 : numerator / denominator;
66
+ }
67
+
68
+ export function getBrushGeometry(params: BrushGeometryParams): BrushGeometry {
69
+ const {
70
+ width, height,
71
+ marginTop, marginRight, marginBottom, marginLeft,
72
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
73
+ brushUnitsModeX, brushUnitsModeY,
74
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
75
+ } = params;
76
+
77
+ const layerLeft = marginLeft;
78
+ const layerTop = marginTop;
79
+ const layerWidth = width - marginLeft - marginRight;
80
+ const layerHeight = height - marginTop - marginBottom;
81
+
82
+ // When an axis is in `Data` units mode, the brush margins for that axis are
83
+ // ignored and the layer (i.e. camera) bounds take precedence, so that the
84
+ // brushable region always coincides with the region the camera maps onto.
85
+ const isDataX = brushUnitsModeX === "Data";
86
+ const isDataY = brushUnitsModeY === "Data";
87
+
88
+ const brushLeft = isDataX ? layerLeft : (brushMarginLeft ?? marginLeft);
89
+ const brushRight = width - (isDataX ? marginRight : (brushMarginRight ?? marginRight));
90
+ const brushTop = isDataY ? layerTop : (brushMarginTop ?? marginTop);
91
+ const brushBottom = height - (isDataY ? marginBottom : (brushMarginBottom ?? marginBottom));
92
+
93
+ const dataBounds = getBounds(cameraMatrix, {
94
+ width,
95
+ height,
96
+ aspectRatioMode,
97
+ aspectRatioAlignmentMode,
98
+ margins: { marginTop, marginRight, marginBottom, marginLeft },
99
+ });
100
+
101
+ return {
102
+ layerLeft, layerTop, layerWidth, layerHeight,
103
+ brushLeft, brushTop, brushRight, brushBottom,
104
+ dataBounds,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Build a full {@link BrushVertex} (all three units modes) from a position in
110
+ * container pixels.
111
+ */
112
+ export function vertexFromPixels(xPixels: number, yPixels: number, geom: BrushGeometry): BrushVertex {
113
+ const { xMin, xMax, yMin, yMax } = geom.dataBounds;
114
+ return {
115
+ x_pixels: xPixels,
116
+ y_pixels: yPixels,
117
+ x_data: xMin + safeDivide(xPixels - geom.layerLeft, geom.layerWidth) * (xMax - xMin),
118
+ // Y is flipped: data Y increases upwards, pixel Y increases downwards.
119
+ y_data: yMin + safeDivide(geom.layerTop + geom.layerHeight - yPixels, geom.layerHeight) * (yMax - yMin),
120
+ x_normalized: safeDivide(xPixels - geom.brushLeft, geom.brushRight - geom.brushLeft),
121
+ y_normalized: safeDivide(geom.brushBottom - yPixels, geom.brushBottom - geom.brushTop),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Recover the container-pixel position of a vertex from whichever of its
127
+ * representations is authoritative for each axis.
128
+ *
129
+ * Only the representation matching the units mode survives a camera or resize
130
+ * change; the other two are derived, so they must be recomputed rather than
131
+ * read back (see {@link reprojectVertex}).
132
+ */
133
+ export function pixelsFromVertex(
134
+ vertex: BrushVertex,
135
+ geom: BrushGeometry,
136
+ brushUnitsModeX: BrushUnitsMode,
137
+ brushUnitsModeY: BrushUnitsMode,
138
+ ): [number, number] {
139
+ const { xMin, xMax, yMin, yMax } = geom.dataBounds;
140
+
141
+ let xPixels: number;
142
+ if (brushUnitsModeX === "Data") {
143
+ xPixels = geom.layerLeft + safeDivide(vertex.x_data - xMin, xMax - xMin) * geom.layerWidth;
144
+ } else if (brushUnitsModeX === "Normalized") {
145
+ xPixels = geom.brushLeft + vertex.x_normalized * (geom.brushRight - geom.brushLeft);
146
+ } else {
147
+ xPixels = vertex.x_pixels;
148
+ }
149
+
150
+ let yPixels: number;
151
+ if (brushUnitsModeY === "Data") {
152
+ yPixels = geom.layerTop + geom.layerHeight - safeDivide(vertex.y_data - yMin, yMax - yMin) * geom.layerHeight;
153
+ } else if (brushUnitsModeY === "Normalized") {
154
+ yPixels = geom.brushBottom - vertex.y_normalized * (geom.brushBottom - geom.brushTop);
155
+ } else {
156
+ yPixels = vertex.y_pixels;
157
+ }
158
+
159
+ return [xPixels, yPixels];
160
+ }
161
+
162
+ /**
163
+ * Re-derive the non-authoritative representations of a vertex under the current
164
+ * geometry. This is what makes a `Data`-units brush track the camera as the user
165
+ * zooms/pans: `x_data`/`y_data` stay fixed while the pixel positions move.
166
+ */
167
+ export function reprojectVertex(
168
+ vertex: BrushVertex,
169
+ geom: BrushGeometry,
170
+ brushUnitsModeX: BrushUnitsMode,
171
+ brushUnitsModeY: BrushUnitsMode,
172
+ ): BrushVertex {
173
+ const [xPixels, yPixels] = pixelsFromVertex(vertex, geom, brushUnitsModeX, brushUnitsModeY);
174
+ return vertexFromPixels(xPixels, yPixels, geom);
175
+ }
176
+
177
+ export function reprojectBrushState(
178
+ state: BrushState,
179
+ geom: BrushGeometry,
180
+ brushUnitsModeX: BrushUnitsMode,
181
+ brushUnitsModeY: BrushUnitsMode,
182
+ ): BrushState {
183
+ const vertices = state.vertices.map(v => reprojectVertex(v, geom, brushUnitsModeX, brushUnitsModeY));
184
+ const boundingBox = state.shape === "Polygon" ? null : getVerticesBoundingBox(vertices);
185
+ if (state.shape === "Polygon" || boundingBox === null) {
186
+ return { ...state, vertices };
187
+ }
188
+ // Rebuild the corners from the reprojected extent, so that the unselected axis
189
+ // of a RangeX/RangeY brush keeps spanning the whole brushable region even as
190
+ // the camera, the container size, or the margins change. For a plain Rect this
191
+ // is a no-op, since reprojection is axis-aligned and monotonic.
192
+ return {
193
+ ...state,
194
+ vertices: rectVerticesFromCorners(
195
+ boundingBox.left, boundingBox.top,
196
+ boundingBox.right, boundingBox.bottom,
197
+ geom, state.shape,
198
+ ),
199
+ };
200
+ }
201
+
202
+ /** Restrict a container-pixel position to the brushable region. */
203
+ export function clampToBrushRegion(xPixels: number, yPixels: number, geom: BrushGeometry): [number, number] {
204
+ return [
205
+ Math.min(Math.max(xPixels, geom.brushLeft), geom.brushRight),
206
+ Math.min(Math.max(yPixels, geom.brushTop), geom.brushBottom),
207
+ ];
208
+ }
209
+
210
+ /**
211
+ * The four corners of the rect spanned by two opposite corners, ordered
212
+ * clockwise in pixel space starting from the top-left, so that corner `i` is
213
+ * always diagonally opposite corner `(i + 2) % 4`.
214
+ *
215
+ * `RangeX` and `RangeY` select along a single axis, so the other axis is
216
+ * discarded and pinned to the full extent of the brushable region.
217
+ */
218
+ export function rectVerticesFromCorners(
219
+ x0: number, y0: number,
220
+ x1: number, y1: number,
221
+ geom: BrushGeometry,
222
+ shape: RectLikeBrushMode = "Rect",
223
+ ): BrushVertex[] {
224
+ const left = shape === "RangeY" ? geom.brushLeft : Math.min(x0, x1);
225
+ const right = shape === "RangeY" ? geom.brushRight : Math.max(x0, x1);
226
+ const top = shape === "RangeX" ? geom.brushTop : Math.min(y0, y1);
227
+ const bottom = shape === "RangeX" ? geom.brushBottom : Math.max(y0, y1);
228
+ return [
229
+ vertexFromPixels(left, top, geom),
230
+ vertexFromPixels(right, top, geom),
231
+ vertexFromPixels(right, bottom, geom),
232
+ vertexFromPixels(left, bottom, geom),
233
+ ];
234
+ }
235
+
236
+ /** The bounding box, in container pixels, of a list of already-reprojected vertices. */
237
+ export function getVerticesBoundingBox(vertices: BrushVertex[]): BrushBoundingBox | null {
238
+ if (vertices.length === 0) {
239
+ return null;
240
+ }
241
+ const xs = vertices.map(v => v.x_pixels);
242
+ const ys = vertices.map(v => v.y_pixels);
243
+ return {
244
+ left: Math.min(...xs),
245
+ top: Math.min(...ys),
246
+ right: Math.max(...xs),
247
+ bottom: Math.max(...ys),
248
+ };
249
+ }
250
+
251
+ /** The smallest extent, in pixels, that a brush must span along a selected axis. */
252
+ const MIN_BRUSH_EXTENT_PX = 2;
253
+
254
+ /**
255
+ * Whether a brush is too small to be a selection.
256
+ *
257
+ * A long-click that never turns into a drag produces a rect whose four corners
258
+ * coincide, which draws as a stray dot rather than as nothing, so these states
259
+ * are held back instead of being committed.
260
+ */
261
+ export function isDegenerateBrush(state: BrushState): boolean {
262
+ if (state.shape === "Polygon") {
263
+ return state.vertices.length < 3;
264
+ }
265
+ const boundingBox = getVerticesBoundingBox(state.vertices);
266
+ if (boundingBox === null) {
267
+ return true;
268
+ }
269
+ const brushWidth = boundingBox.right - boundingBox.left;
270
+ const brushHeight = boundingBox.bottom - boundingBox.top;
271
+ // A range brush only selects along one axis; the other always spans the whole
272
+ // brushable region, so it is not evidence that the user drew anything.
273
+ if (state.shape === "RangeX") {
274
+ return brushWidth < MIN_BRUSH_EXTENT_PX;
275
+ }
276
+ if (state.shape === "RangeY") {
277
+ return brushHeight < MIN_BRUSH_EXTENT_PX;
278
+ }
279
+ return brushWidth < MIN_BRUSH_EXTENT_PX || brushHeight < MIN_BRUSH_EXTENT_PX;
280
+ }
281
+
282
+ /** How much clear air to leave between the brush's last vertex and the clear button. */
283
+ const CLEAR_BUTTON_GAP_PX = 3;
284
+
285
+ /**
286
+ * Where the clear button sits: adjacent to the brush's first vertex — the
287
+ * top-left corner of a rect, or the point a lasso was started from.
288
+ *
289
+ * Anchoring to the first vertex keeps the button in one place while a lasso is
290
+ * being drawn, rather than trailing the cursor around the shape. It is pushed
291
+ * outwards along the ray from the centroid through that vertex, so it lands
292
+ * outside the brush and does not obscure the brushed content. Returns `null` for
293
+ * an empty brush.
294
+ *
295
+ * The result is kept within the brushable region, since the overlay is clipped to
296
+ * that region and a button pushed outside it would be invisible and unclickable.
297
+ */
298
+ export function getClearButtonCenter(
299
+ vertices: BrushVertex[],
300
+ radius: number,
301
+ geom: BrushGeometry,
302
+ ): [number, number] | null {
303
+ const firstVertex = vertices[0];
304
+ if (firstVertex === undefined) {
305
+ return null;
306
+ }
307
+
308
+ const centroidX = vertices.reduce((sum, v) => sum + v.x_pixels, 0) / vertices.length;
309
+ const centroidY = vertices.reduce((sum, v) => sum + v.y_pixels, 0) / vertices.length;
310
+ let directionX = firstVertex.x_pixels - centroidX;
311
+ let directionY = firstVertex.y_pixels - centroidY;
312
+ const length = Math.hypot(directionX, directionY);
313
+ if (length === 0) {
314
+ // No interior to move away from, so fall back to a fixed up-and-right diagonal.
315
+ directionX = Math.SQRT1_2;
316
+ directionY = -Math.SQRT1_2;
317
+ } else {
318
+ directionX /= length;
319
+ directionY /= length;
320
+ }
321
+
322
+ const offset = radius + CLEAR_BUTTON_GAP_PX;
323
+ return [
324
+ Math.min(Math.max(firstVertex.x_pixels + directionX * offset, geom.brushLeft + radius), geom.brushRight - radius),
325
+ Math.min(Math.max(firstVertex.y_pixels + directionY * offset, geom.brushTop + radius), geom.brushBottom - radius),
326
+ ];
327
+ }
328
+
329
+ /**
330
+ * Which sides of a brush the user may drag to extend it.
331
+ *
332
+ * A range brush pins its unselected axis to the whole brushable region, so
333
+ * dragging those two sides could not change anything and they are left out.
334
+ */
335
+ export function getEditableEdges(shape: BrushMode): BrushEdge[] {
336
+ switch (shape) {
337
+ case "Rect":
338
+ return ["Top", "Right", "Bottom", "Left"];
339
+ case "RangeX":
340
+ return ["Left", "Right"];
341
+ case "RangeY":
342
+ return ["Top", "Bottom"];
343
+ default:
344
+ return [];
345
+ }
346
+ }
347
+
348
+ /** The endpoints `[x1, y1, x2, y2]` of an edge, in container pixels. */
349
+ export function getEdgeLine(edge: BrushEdge, boundingBox: BrushBoundingBox): [number, number, number, number] {
350
+ const { left, top, right, bottom } = boundingBox;
351
+ switch (edge) {
352
+ case "Top":
353
+ return [left, top, right, top];
354
+ case "Bottom":
355
+ return [left, bottom, right, bottom];
356
+ case "Left":
357
+ return [left, top, left, bottom];
358
+ case "Right":
359
+ return [right, top, right, bottom];
360
+ }
361
+ }
362
+
363
+ /**
364
+ * The two opposite corners that dragging `edge` spans: the corner that stays
365
+ * put, and the corner that follows the cursor along `axis` only.
366
+ *
367
+ * Expressing an edge drag as a pair of corners lets it reuse
368
+ * {@link rectVerticesFromCorners}, which also means dragging a side past its
369
+ * opposite side flips the brush rather than inverting it.
370
+ */
371
+ export function getEdgeDragCorners(edge: BrushEdge, boundingBox: BrushBoundingBox): {
372
+ axis: "X" | "Y";
373
+ fixedX: number;
374
+ fixedY: number;
375
+ movingX: number;
376
+ movingY: number;
377
+ } {
378
+ const { left, top, right, bottom } = boundingBox;
379
+ switch (edge) {
380
+ case "Left":
381
+ return { axis: "X", fixedX: right, fixedY: top, movingX: left, movingY: bottom };
382
+ case "Right":
383
+ return { axis: "X", fixedX: left, fixedY: top, movingX: right, movingY: bottom };
384
+ case "Top":
385
+ return { axis: "Y", fixedX: left, fixedY: bottom, movingX: right, movingY: top };
386
+ case "Bottom":
387
+ return { axis: "Y", fixedX: left, fixedY: top, movingX: right, movingY: bottom };
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Whether a container-pixel position lies inside a brush, used to decide when to
393
+ * reveal the clear button. Hit-testing is done here rather than with SVG pointer
394
+ * events so that the overlay never swallows camera pan/zoom interactions.
395
+ * TODO: replace this by using the regular onHover events in the brush overlay SVG.
396
+ * The challenge with using the regular onHover events in the overlay is that
397
+ * it makes it tricky to avoid absorbing the hover/mouse events which the camera pan/zoom need.
398
+ * An alternative/intermediate optimization would be to compute the polygon bounding box
399
+ * upon the polygon creation/modification, and do hit-testing against that cached bounding box instead.
400
+ */
401
+ export function isPointInBrush(xPixels: number, yPixels: number, vertices: BrushVertex[]): boolean {
402
+ if (vertices.length < 3) {
403
+ return false;
404
+ }
405
+ // Ray casting: count the polygon edges crossed by a ray heading in +X.
406
+ let isInside = false;
407
+ for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
408
+ const xi = vertices[i]!.x_pixels;
409
+ const yi = vertices[i]!.y_pixels;
410
+ const xj = vertices[j]!.x_pixels;
411
+ const yj = vertices[j]!.y_pixels;
412
+ const doesEdgeStraddleRay = (yi > yPixels) !== (yj > yPixels);
413
+ if (doesEdgeStraddleRay && xPixels < xi + ((yPixels - yi) / (yj - yi)) * (xj - xi)) {
414
+ isInside = !isInside;
415
+ }
416
+ }
417
+ return isInside;
418
+ }
419
+
420
+ /**
421
+ * An SVG path for a pie wedge filled clockwise from 12 o'clock, used to
422
+ * visualize progress towards the long-click that starts a brush.
423
+ */
424
+ export function describeWedgePath(cx: number, cy: number, radius: number, fraction: number): string {
425
+ const clamped = Math.min(Math.max(fraction, 0), 1);
426
+ if (clamped >= 1) {
427
+ // A single arc cannot express a full circle, so use two half-circle arcs.
428
+ return `M ${cx} ${cy - radius} A ${radius} ${radius} 0 1 1 ${cx} ${cy + radius} A ${radius} ${radius} 0 1 1 ${cx} ${cy - radius} Z`;
429
+ }
430
+ const angle = clamped * 2 * Math.PI;
431
+ const endX = cx + radius * Math.sin(angle);
432
+ const endY = cy - radius * Math.cos(angle);
433
+ const largeArcFlag = clamped > 0.5 ? 1 : 0;
434
+ return `M ${cx} ${cy} L ${cx} ${cy - radius} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY} Z`;
435
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export * from '@pluot/core'; // Re-export everything from the vanilla JS package.
2
+ export { Pluot } from "./Pluot.js";
3
+ export { NO_BRUSH } from "./types.js";
4
+ export type {
5
+ PluotProps,
6
+ ViewMode,
7
+ GraphicsFormat,
8
+ PlotType,
9
+ LayerParams,
10
+ PlotParams,
11
+ RenderParams,
12
+ ScreenCoord,
13
+ DataCoord,
14
+ LayerPickingResult,
15
+ PickingResult,
16
+ TooltipContent,
17
+ BrushUnitsMode,
18
+ BrushMode,
19
+ RectLikeBrushMode,
20
+ BrushVertex,
21
+ BrushState,
22
+ BrushResult,
23
+ LayerBrushingResult,
24
+ BrushingResult,
25
+ NoBrush,
26
+ } from "./types.js";