@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/dist/index.js +1066 -110
- package/dist-tsc/BrushOverlay.d.ts +31 -0
- package/dist-tsc/BrushOverlay.d.ts.map +1 -0
- package/dist-tsc/BrushOverlay.js +95 -0
- package/dist-tsc/Pluot.d.ts +2 -1
- package/dist-tsc/Pluot.d.ts.map +1 -1
- package/dist-tsc/Pluot.js +118 -22
- package/dist-tsc/Tooltip.d.ts +2 -1
- package/dist-tsc/Tooltip.d.ts.map +1 -1
- package/dist-tsc/Tooltip.js +15 -1
- package/dist-tsc/brush.d.ts +155 -0
- package/dist-tsc/brush.d.ts.map +1 -0
- package/dist-tsc/brush.js +312 -0
- package/dist-tsc/brush.test.d.ts +2 -0
- package/dist-tsc/brush.test.d.ts.map +1 -0
- package/dist-tsc/brush.test.js +487 -0
- package/dist-tsc/index.d.ts +3 -1
- package/dist-tsc/index.d.ts.map +1 -1
- package/dist-tsc/index.js +1 -0
- package/dist-tsc/types.d.ts +283 -0
- package/dist-tsc/types.d.ts.map +1 -0
- package/dist-tsc/types.js +11 -0
- package/dist-tsc/use-brush.d.ts +55 -0
- package/dist-tsc/use-brush.d.ts.map +1 -0
- package/dist-tsc/use-brush.js +361 -0
- package/package.json +5 -3
- package/src/BrushOverlay.tsx +258 -0
- package/src/{Pluot.jsx → Pluot.tsx} +200 -47
- package/src/{Tooltip.jsx → Tooltip.tsx} +19 -3
- package/src/brush.test.ts +590 -0
- package/src/brush.ts +435 -0
- package/src/index.ts +26 -0
- package/src/types.ts +412 -0
- package/src/use-brush.ts +505 -0
- package/src/index.js +0 -2
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { getBounds } from "@pluot/core";
|
|
2
|
+
// Avoid dividing by zero for degenerate (zero-width or zero-height) regions.
|
|
3
|
+
function safeDivide(numerator, denominator) {
|
|
4
|
+
return denominator === 0 ? 0 : numerator / denominator;
|
|
5
|
+
}
|
|
6
|
+
export function getBrushGeometry(params) {
|
|
7
|
+
const { width, height, marginTop, marginRight, marginBottom, marginLeft, brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft, brushUnitsModeX, brushUnitsModeY, aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix, } = params;
|
|
8
|
+
const layerLeft = marginLeft;
|
|
9
|
+
const layerTop = marginTop;
|
|
10
|
+
const layerWidth = width - marginLeft - marginRight;
|
|
11
|
+
const layerHeight = height - marginTop - marginBottom;
|
|
12
|
+
// When an axis is in `Data` units mode, the brush margins for that axis are
|
|
13
|
+
// ignored and the layer (i.e. camera) bounds take precedence, so that the
|
|
14
|
+
// brushable region always coincides with the region the camera maps onto.
|
|
15
|
+
const isDataX = brushUnitsModeX === "Data";
|
|
16
|
+
const isDataY = brushUnitsModeY === "Data";
|
|
17
|
+
const brushLeft = isDataX ? layerLeft : (brushMarginLeft ?? marginLeft);
|
|
18
|
+
const brushRight = width - (isDataX ? marginRight : (brushMarginRight ?? marginRight));
|
|
19
|
+
const brushTop = isDataY ? layerTop : (brushMarginTop ?? marginTop);
|
|
20
|
+
const brushBottom = height - (isDataY ? marginBottom : (brushMarginBottom ?? marginBottom));
|
|
21
|
+
const dataBounds = getBounds(cameraMatrix, {
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
aspectRatioMode,
|
|
25
|
+
aspectRatioAlignmentMode,
|
|
26
|
+
margins: { marginTop, marginRight, marginBottom, marginLeft },
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
layerLeft, layerTop, layerWidth, layerHeight,
|
|
30
|
+
brushLeft, brushTop, brushRight, brushBottom,
|
|
31
|
+
dataBounds,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a full {@link BrushVertex} (all three units modes) from a position in
|
|
36
|
+
* container pixels.
|
|
37
|
+
*/
|
|
38
|
+
export function vertexFromPixels(xPixels, yPixels, geom) {
|
|
39
|
+
const { xMin, xMax, yMin, yMax } = geom.dataBounds;
|
|
40
|
+
return {
|
|
41
|
+
x_pixels: xPixels,
|
|
42
|
+
y_pixels: yPixels,
|
|
43
|
+
x_data: xMin + safeDivide(xPixels - geom.layerLeft, geom.layerWidth) * (xMax - xMin),
|
|
44
|
+
// Y is flipped: data Y increases upwards, pixel Y increases downwards.
|
|
45
|
+
y_data: yMin + safeDivide(geom.layerTop + geom.layerHeight - yPixels, geom.layerHeight) * (yMax - yMin),
|
|
46
|
+
x_normalized: safeDivide(xPixels - geom.brushLeft, geom.brushRight - geom.brushLeft),
|
|
47
|
+
y_normalized: safeDivide(geom.brushBottom - yPixels, geom.brushBottom - geom.brushTop),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Recover the container-pixel position of a vertex from whichever of its
|
|
52
|
+
* representations is authoritative for each axis.
|
|
53
|
+
*
|
|
54
|
+
* Only the representation matching the units mode survives a camera or resize
|
|
55
|
+
* change; the other two are derived, so they must be recomputed rather than
|
|
56
|
+
* read back (see {@link reprojectVertex}).
|
|
57
|
+
*/
|
|
58
|
+
export function pixelsFromVertex(vertex, geom, brushUnitsModeX, brushUnitsModeY) {
|
|
59
|
+
const { xMin, xMax, yMin, yMax } = geom.dataBounds;
|
|
60
|
+
let xPixels;
|
|
61
|
+
if (brushUnitsModeX === "Data") {
|
|
62
|
+
xPixels = geom.layerLeft + safeDivide(vertex.x_data - xMin, xMax - xMin) * geom.layerWidth;
|
|
63
|
+
}
|
|
64
|
+
else if (brushUnitsModeX === "Normalized") {
|
|
65
|
+
xPixels = geom.brushLeft + vertex.x_normalized * (geom.brushRight - geom.brushLeft);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
xPixels = vertex.x_pixels;
|
|
69
|
+
}
|
|
70
|
+
let yPixels;
|
|
71
|
+
if (brushUnitsModeY === "Data") {
|
|
72
|
+
yPixels = geom.layerTop + geom.layerHeight - safeDivide(vertex.y_data - yMin, yMax - yMin) * geom.layerHeight;
|
|
73
|
+
}
|
|
74
|
+
else if (brushUnitsModeY === "Normalized") {
|
|
75
|
+
yPixels = geom.brushBottom - vertex.y_normalized * (geom.brushBottom - geom.brushTop);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
yPixels = vertex.y_pixels;
|
|
79
|
+
}
|
|
80
|
+
return [xPixels, yPixels];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Re-derive the non-authoritative representations of a vertex under the current
|
|
84
|
+
* geometry. This is what makes a `Data`-units brush track the camera as the user
|
|
85
|
+
* zooms/pans: `x_data`/`y_data` stay fixed while the pixel positions move.
|
|
86
|
+
*/
|
|
87
|
+
export function reprojectVertex(vertex, geom, brushUnitsModeX, brushUnitsModeY) {
|
|
88
|
+
const [xPixels, yPixels] = pixelsFromVertex(vertex, geom, brushUnitsModeX, brushUnitsModeY);
|
|
89
|
+
return vertexFromPixels(xPixels, yPixels, geom);
|
|
90
|
+
}
|
|
91
|
+
export function reprojectBrushState(state, geom, brushUnitsModeX, brushUnitsModeY) {
|
|
92
|
+
const vertices = state.vertices.map(v => reprojectVertex(v, geom, brushUnitsModeX, brushUnitsModeY));
|
|
93
|
+
const boundingBox = state.shape === "Polygon" ? null : getVerticesBoundingBox(vertices);
|
|
94
|
+
if (state.shape === "Polygon" || boundingBox === null) {
|
|
95
|
+
return { ...state, vertices };
|
|
96
|
+
}
|
|
97
|
+
// Rebuild the corners from the reprojected extent, so that the unselected axis
|
|
98
|
+
// of a RangeX/RangeY brush keeps spanning the whole brushable region even as
|
|
99
|
+
// the camera, the container size, or the margins change. For a plain Rect this
|
|
100
|
+
// is a no-op, since reprojection is axis-aligned and monotonic.
|
|
101
|
+
return {
|
|
102
|
+
...state,
|
|
103
|
+
vertices: rectVerticesFromCorners(boundingBox.left, boundingBox.top, boundingBox.right, boundingBox.bottom, geom, state.shape),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Restrict a container-pixel position to the brushable region. */
|
|
107
|
+
export function clampToBrushRegion(xPixels, yPixels, geom) {
|
|
108
|
+
return [
|
|
109
|
+
Math.min(Math.max(xPixels, geom.brushLeft), geom.brushRight),
|
|
110
|
+
Math.min(Math.max(yPixels, geom.brushTop), geom.brushBottom),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The four corners of the rect spanned by two opposite corners, ordered
|
|
115
|
+
* clockwise in pixel space starting from the top-left, so that corner `i` is
|
|
116
|
+
* always diagonally opposite corner `(i + 2) % 4`.
|
|
117
|
+
*
|
|
118
|
+
* `RangeX` and `RangeY` select along a single axis, so the other axis is
|
|
119
|
+
* discarded and pinned to the full extent of the brushable region.
|
|
120
|
+
*/
|
|
121
|
+
export function rectVerticesFromCorners(x0, y0, x1, y1, geom, shape = "Rect") {
|
|
122
|
+
const left = shape === "RangeY" ? geom.brushLeft : Math.min(x0, x1);
|
|
123
|
+
const right = shape === "RangeY" ? geom.brushRight : Math.max(x0, x1);
|
|
124
|
+
const top = shape === "RangeX" ? geom.brushTop : Math.min(y0, y1);
|
|
125
|
+
const bottom = shape === "RangeX" ? geom.brushBottom : Math.max(y0, y1);
|
|
126
|
+
return [
|
|
127
|
+
vertexFromPixels(left, top, geom),
|
|
128
|
+
vertexFromPixels(right, top, geom),
|
|
129
|
+
vertexFromPixels(right, bottom, geom),
|
|
130
|
+
vertexFromPixels(left, bottom, geom),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
/** The bounding box, in container pixels, of a list of already-reprojected vertices. */
|
|
134
|
+
export function getVerticesBoundingBox(vertices) {
|
|
135
|
+
if (vertices.length === 0) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const xs = vertices.map(v => v.x_pixels);
|
|
139
|
+
const ys = vertices.map(v => v.y_pixels);
|
|
140
|
+
return {
|
|
141
|
+
left: Math.min(...xs),
|
|
142
|
+
top: Math.min(...ys),
|
|
143
|
+
right: Math.max(...xs),
|
|
144
|
+
bottom: Math.max(...ys),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** The smallest extent, in pixels, that a brush must span along a selected axis. */
|
|
148
|
+
const MIN_BRUSH_EXTENT_PX = 2;
|
|
149
|
+
/**
|
|
150
|
+
* Whether a brush is too small to be a selection.
|
|
151
|
+
*
|
|
152
|
+
* A long-click that never turns into a drag produces a rect whose four corners
|
|
153
|
+
* coincide, which draws as a stray dot rather than as nothing, so these states
|
|
154
|
+
* are held back instead of being committed.
|
|
155
|
+
*/
|
|
156
|
+
export function isDegenerateBrush(state) {
|
|
157
|
+
if (state.shape === "Polygon") {
|
|
158
|
+
return state.vertices.length < 3;
|
|
159
|
+
}
|
|
160
|
+
const boundingBox = getVerticesBoundingBox(state.vertices);
|
|
161
|
+
if (boundingBox === null) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const brushWidth = boundingBox.right - boundingBox.left;
|
|
165
|
+
const brushHeight = boundingBox.bottom - boundingBox.top;
|
|
166
|
+
// A range brush only selects along one axis; the other always spans the whole
|
|
167
|
+
// brushable region, so it is not evidence that the user drew anything.
|
|
168
|
+
if (state.shape === "RangeX") {
|
|
169
|
+
return brushWidth < MIN_BRUSH_EXTENT_PX;
|
|
170
|
+
}
|
|
171
|
+
if (state.shape === "RangeY") {
|
|
172
|
+
return brushHeight < MIN_BRUSH_EXTENT_PX;
|
|
173
|
+
}
|
|
174
|
+
return brushWidth < MIN_BRUSH_EXTENT_PX || brushHeight < MIN_BRUSH_EXTENT_PX;
|
|
175
|
+
}
|
|
176
|
+
/** How much clear air to leave between the brush's last vertex and the clear button. */
|
|
177
|
+
const CLEAR_BUTTON_GAP_PX = 3;
|
|
178
|
+
/**
|
|
179
|
+
* Where the clear button sits: adjacent to the brush's first vertex — the
|
|
180
|
+
* top-left corner of a rect, or the point a lasso was started from.
|
|
181
|
+
*
|
|
182
|
+
* Anchoring to the first vertex keeps the button in one place while a lasso is
|
|
183
|
+
* being drawn, rather than trailing the cursor around the shape. It is pushed
|
|
184
|
+
* outwards along the ray from the centroid through that vertex, so it lands
|
|
185
|
+
* outside the brush and does not obscure the brushed content. Returns `null` for
|
|
186
|
+
* an empty brush.
|
|
187
|
+
*
|
|
188
|
+
* The result is kept within the brushable region, since the overlay is clipped to
|
|
189
|
+
* that region and a button pushed outside it would be invisible and unclickable.
|
|
190
|
+
*/
|
|
191
|
+
export function getClearButtonCenter(vertices, radius, geom) {
|
|
192
|
+
const firstVertex = vertices[0];
|
|
193
|
+
if (firstVertex === undefined) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const centroidX = vertices.reduce((sum, v) => sum + v.x_pixels, 0) / vertices.length;
|
|
197
|
+
const centroidY = vertices.reduce((sum, v) => sum + v.y_pixels, 0) / vertices.length;
|
|
198
|
+
let directionX = firstVertex.x_pixels - centroidX;
|
|
199
|
+
let directionY = firstVertex.y_pixels - centroidY;
|
|
200
|
+
const length = Math.hypot(directionX, directionY);
|
|
201
|
+
if (length === 0) {
|
|
202
|
+
// No interior to move away from, so fall back to a fixed up-and-right diagonal.
|
|
203
|
+
directionX = Math.SQRT1_2;
|
|
204
|
+
directionY = -Math.SQRT1_2;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
directionX /= length;
|
|
208
|
+
directionY /= length;
|
|
209
|
+
}
|
|
210
|
+
const offset = radius + CLEAR_BUTTON_GAP_PX;
|
|
211
|
+
return [
|
|
212
|
+
Math.min(Math.max(firstVertex.x_pixels + directionX * offset, geom.brushLeft + radius), geom.brushRight - radius),
|
|
213
|
+
Math.min(Math.max(firstVertex.y_pixels + directionY * offset, geom.brushTop + radius), geom.brushBottom - radius),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Which sides of a brush the user may drag to extend it.
|
|
218
|
+
*
|
|
219
|
+
* A range brush pins its unselected axis to the whole brushable region, so
|
|
220
|
+
* dragging those two sides could not change anything and they are left out.
|
|
221
|
+
*/
|
|
222
|
+
export function getEditableEdges(shape) {
|
|
223
|
+
switch (shape) {
|
|
224
|
+
case "Rect":
|
|
225
|
+
return ["Top", "Right", "Bottom", "Left"];
|
|
226
|
+
case "RangeX":
|
|
227
|
+
return ["Left", "Right"];
|
|
228
|
+
case "RangeY":
|
|
229
|
+
return ["Top", "Bottom"];
|
|
230
|
+
default:
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** The endpoints `[x1, y1, x2, y2]` of an edge, in container pixels. */
|
|
235
|
+
export function getEdgeLine(edge, boundingBox) {
|
|
236
|
+
const { left, top, right, bottom } = boundingBox;
|
|
237
|
+
switch (edge) {
|
|
238
|
+
case "Top":
|
|
239
|
+
return [left, top, right, top];
|
|
240
|
+
case "Bottom":
|
|
241
|
+
return [left, bottom, right, bottom];
|
|
242
|
+
case "Left":
|
|
243
|
+
return [left, top, left, bottom];
|
|
244
|
+
case "Right":
|
|
245
|
+
return [right, top, right, bottom];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The two opposite corners that dragging `edge` spans: the corner that stays
|
|
250
|
+
* put, and the corner that follows the cursor along `axis` only.
|
|
251
|
+
*
|
|
252
|
+
* Expressing an edge drag as a pair of corners lets it reuse
|
|
253
|
+
* {@link rectVerticesFromCorners}, which also means dragging a side past its
|
|
254
|
+
* opposite side flips the brush rather than inverting it.
|
|
255
|
+
*/
|
|
256
|
+
export function getEdgeDragCorners(edge, boundingBox) {
|
|
257
|
+
const { left, top, right, bottom } = boundingBox;
|
|
258
|
+
switch (edge) {
|
|
259
|
+
case "Left":
|
|
260
|
+
return { axis: "X", fixedX: right, fixedY: top, movingX: left, movingY: bottom };
|
|
261
|
+
case "Right":
|
|
262
|
+
return { axis: "X", fixedX: left, fixedY: top, movingX: right, movingY: bottom };
|
|
263
|
+
case "Top":
|
|
264
|
+
return { axis: "Y", fixedX: left, fixedY: bottom, movingX: right, movingY: top };
|
|
265
|
+
case "Bottom":
|
|
266
|
+
return { axis: "Y", fixedX: left, fixedY: top, movingX: right, movingY: bottom };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Whether a container-pixel position lies inside a brush, used to decide when to
|
|
271
|
+
* reveal the clear button. Hit-testing is done here rather than with SVG pointer
|
|
272
|
+
* events so that the overlay never swallows camera pan/zoom interactions.
|
|
273
|
+
* TODO: replace this by using the regular onHover events in the brush overlay SVG.
|
|
274
|
+
* The challenge with using the regular onHover events in the overlay is that
|
|
275
|
+
* it makes it tricky to avoid absorbing the hover/mouse events which the camera pan/zoom need.
|
|
276
|
+
* An alternative/intermediate optimization would be to compute the polygon bounding box
|
|
277
|
+
* upon the polygon creation/modification, and do hit-testing against that cached bounding box instead.
|
|
278
|
+
*/
|
|
279
|
+
export function isPointInBrush(xPixels, yPixels, vertices) {
|
|
280
|
+
if (vertices.length < 3) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
// Ray casting: count the polygon edges crossed by a ray heading in +X.
|
|
284
|
+
let isInside = false;
|
|
285
|
+
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
|
|
286
|
+
const xi = vertices[i].x_pixels;
|
|
287
|
+
const yi = vertices[i].y_pixels;
|
|
288
|
+
const xj = vertices[j].x_pixels;
|
|
289
|
+
const yj = vertices[j].y_pixels;
|
|
290
|
+
const doesEdgeStraddleRay = (yi > yPixels) !== (yj > yPixels);
|
|
291
|
+
if (doesEdgeStraddleRay && xPixels < xi + ((yPixels - yi) / (yj - yi)) * (xj - xi)) {
|
|
292
|
+
isInside = !isInside;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return isInside;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* An SVG path for a pie wedge filled clockwise from 12 o'clock, used to
|
|
299
|
+
* visualize progress towards the long-click that starts a brush.
|
|
300
|
+
*/
|
|
301
|
+
export function describeWedgePath(cx, cy, radius, fraction) {
|
|
302
|
+
const clamped = Math.min(Math.max(fraction, 0), 1);
|
|
303
|
+
if (clamped >= 1) {
|
|
304
|
+
// A single arc cannot express a full circle, so use two half-circle arcs.
|
|
305
|
+
return `M ${cx} ${cy - radius} A ${radius} ${radius} 0 1 1 ${cx} ${cy + radius} A ${radius} ${radius} 0 1 1 ${cx} ${cy - radius} Z`;
|
|
306
|
+
}
|
|
307
|
+
const angle = clamped * 2 * Math.PI;
|
|
308
|
+
const endX = cx + radius * Math.sin(angle);
|
|
309
|
+
const endY = cy - radius * Math.cos(angle);
|
|
310
|
+
const largeArcFlag = clamped > 0.5 ? 1 : 0;
|
|
311
|
+
return `M ${cx} ${cy} L ${cx} ${cy - radius} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY} Z`;
|
|
312
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"brush.test.d.ts","sourceRoot":"","sources":["../src/brush.test.ts"],"names":[],"mappings":""}
|