@pharos-ds/charts 0.0.0-stage → 1.0.10
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/CHANGELOG.md +73 -0
- package/LICENSE +24 -0
- package/README.md +117 -2
- package/dist/Chart.d.ts +62 -0
- package/dist/Chart.d.ts.map +1 -0
- package/dist/Chart.js +401 -0
- package/dist/ChartAxis.d.ts +37 -0
- package/dist/ChartAxis.d.ts.map +1 -0
- package/dist/ChartAxis.js +233 -0
- package/dist/ChartContext.d.ts +4 -0
- package/dist/ChartContext.d.ts.map +1 -0
- package/dist/ChartContext.js +15 -0
- package/dist/ChartGrid.d.ts +26 -0
- package/dist/ChartGrid.d.ts.map +1 -0
- package/dist/ChartGrid.js +118 -0
- package/dist/ChartLegend.d.ts +14 -0
- package/dist/ChartLegend.d.ts.map +1 -0
- package/dist/ChartLegend.js +62 -0
- package/dist/ChartSwatch.d.ts +36 -0
- package/dist/ChartSwatch.d.ts.map +1 -0
- package/dist/ChartSwatch.js +85 -0
- package/dist/ChartTooltip.d.ts +46 -0
- package/dist/ChartTooltip.d.ts.map +1 -0
- package/dist/ChartTooltip.js +330 -0
- package/dist/charts.css +33 -0
- package/dist/dataTable.d.ts +56 -0
- package/dist/dataTable.d.ts.map +1 -0
- package/dist/dataTable.js +88 -0
- package/dist/formatters.d.ts +37 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +156 -0
- package/dist/getChartColors.d.ts +67 -0
- package/dist/getChartColors.d.ts.map +1 -0
- package/dist/getChartColors.js +191 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +40 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +322 -0
- package/dist/legend.d.ts +17 -0
- package/dist/legend.d.ts.map +1 -0
- package/dist/legend.js +40 -0
- package/dist/markColor.d.ts +20 -0
- package/dist/markColor.d.ts.map +1 -0
- package/dist/markColor.js +56 -0
- package/dist/marks/area.d.ts +19 -0
- package/dist/marks/area.d.ts.map +1 -0
- package/dist/marks/area.js +121 -0
- package/dist/marks/band.d.ts +9 -0
- package/dist/marks/band.d.ts.map +1 -0
- package/dist/marks/band.js +64 -0
- package/dist/marks/bar.d.ts +21 -0
- package/dist/marks/bar.d.ts.map +1 -0
- package/dist/marks/bar.js +161 -0
- package/dist/marks/candlestick.d.ts +16 -0
- package/dist/marks/candlestick.d.ts.map +1 -0
- package/dist/marks/candlestick.js +110 -0
- package/dist/marks/dot.d.ts +19 -0
- package/dist/marks/dot.d.ts.map +1 -0
- package/dist/marks/dot.js +98 -0
- package/dist/marks/dotGL.d.ts +16 -0
- package/dist/marks/dotGL.d.ts.map +1 -0
- package/dist/marks/dotGL.js +231 -0
- package/dist/marks/dotGLInteractive.d.ts +35 -0
- package/dist/marks/dotGLInteractive.d.ts.map +1 -0
- package/dist/marks/dotGLInteractive.js +584 -0
- package/dist/marks/errorBar.d.ts +29 -0
- package/dist/marks/errorBar.d.ts.map +1 -0
- package/dist/marks/errorBar.js +95 -0
- package/dist/marks/heatmapGL.d.ts +27 -0
- package/dist/marks/heatmapGL.d.ts.map +1 -0
- package/dist/marks/heatmapGL.js +335 -0
- package/dist/marks/index.d.ts +13 -0
- package/dist/marks/index.d.ts.map +1 -0
- package/dist/marks/index.js +14 -0
- package/dist/marks/line.d.ts +18 -0
- package/dist/marks/line.d.ts.map +1 -0
- package/dist/marks/line.js +83 -0
- package/dist/marks/referenceLine.d.ts +37 -0
- package/dist/marks/referenceLine.d.ts.map +1 -0
- package/dist/marks/referenceLine.js +204 -0
- package/dist/marks/streamGL.d.ts +41 -0
- package/dist/marks/streamGL.d.ts.map +1 -0
- package/dist/marks/streamGL.js +317 -0
- package/dist/tooltip.d.ts +38 -0
- package/dist/tooltip.d.ts.map +1 -0
- package/dist/tooltip.js +57 -0
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +70 -0
- package/dist/useChartColors.d.ts +16 -0
- package/dist/useChartColors.d.ts.map +1 -0
- package/dist/useChartColors.js +37 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +31 -0
- package/dist/webgl.d.ts +77 -0
- package/dist/webgl.d.ts.map +1 -0
- package/dist/webgl.js +235 -0
- package/package.json +71 -3
- package/src/Chart.doc.mjs +174 -0
- package/src/Chart.test.tsx +273 -0
- package/src/Chart.tsx +535 -0
- package/src/ChartAxis.tsx +289 -0
- package/src/ChartContext.ts +18 -0
- package/src/ChartGrid.tsx +136 -0
- package/src/ChartLegend.tsx +77 -0
- package/src/ChartSwatch.doc.mjs +17 -0
- package/src/ChartSwatch.tsx +89 -0
- package/src/ChartTooltip.tsx +452 -0
- package/src/charts.css.d.ts +3 -0
- package/src/dataTable.test.ts +86 -0
- package/src/dataTable.ts +124 -0
- package/src/formatters.ts +167 -0
- package/src/getChartColors.test.ts +114 -0
- package/src/getChartColors.ts +282 -0
- package/src/index.ts +88 -0
- package/src/layout.ts +372 -0
- package/src/legend.ts +44 -0
- package/src/markColor.ts +70 -0
- package/src/marks/area.tsx +142 -0
- package/src/marks/band.tsx +86 -0
- package/src/marks/bar.tsx +210 -0
- package/src/marks/candlestick.tsx +128 -0
- package/src/marks/dot.tsx +119 -0
- package/src/marks/dotGL.tsx +274 -0
- package/src/marks/dotGLInteractive.tsx +660 -0
- package/src/marks/errorBar.tsx +119 -0
- package/src/marks/heatmapGL.tsx +392 -0
- package/src/marks/index.ts +17 -0
- package/src/marks/line.tsx +118 -0
- package/src/marks/referenceLine.tsx +252 -0
- package/src/marks/streamGL.tsx +381 -0
- package/src/tooltip.ts +73 -0
- package/src/types.ts +212 -0
- package/src/useChartColors.ts +41 -0
- package/src/utils.ts +38 -0
- package/src/webgl.ts +262 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file marks/dotGLInteractive.tsx
|
|
5
|
+
* @output WebGL scatter with GPU color-picking for O(1) nearest-point hover
|
|
6
|
+
*
|
|
7
|
+
* Each point gets a unique color encoding its index. On hover, readPixels
|
|
8
|
+
* from an offscreen framebuffer decodes the hovered index — no JS iteration.
|
|
9
|
+
*
|
|
10
|
+
* Two GL contexts (visible + offscreen pick) plus their programs, buffers,
|
|
11
|
+
* framebuffer, and pick texture are created once and reused. The pick texture
|
|
12
|
+
* is re-allocated only when the drawing-buffer size changes, and everything is
|
|
13
|
+
* released (both contexts eagerly lost) on unmount. Recovers from context loss.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
|
17
|
+
import { hexToGL, createProgram, registerContextLossHandlers, loseContext } from "../webgl.js";
|
|
18
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
19
|
+
/**
|
|
20
|
+
* Extra radius (CSS px) added to each point in the pick pass only, so hover
|
|
21
|
+
* feels "magnetic" — you don't have to land pixel-perfectly on a small dot.
|
|
22
|
+
* The visible pass is unaffected, so dots still render at their real size.
|
|
23
|
+
*/
|
|
24
|
+
const HIT_PADDING = 16;
|
|
25
|
+
|
|
26
|
+
/** Encode a point index as an RGB color (supports up to 16.7M points) */
|
|
27
|
+
function indexToColor(i) {
|
|
28
|
+
const id = i + 1;
|
|
29
|
+
return [(id >> 16 & 0xff) / 255, (id >> 8 & 0xff) / 255, (id & 0xff) / 255];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Decode an RGB pixel back to a point index. Returns -1 for background. */
|
|
33
|
+
function colorToIndex(r, g, b) {
|
|
34
|
+
if (r === 0 && g === 0 && b === 0) {
|
|
35
|
+
return -1;
|
|
36
|
+
}
|
|
37
|
+
return (r << 16 | g << 8 | b) - 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Visible pass
|
|
41
|
+
const VERT_VIS = `
|
|
42
|
+
attribute vec2 a_position;
|
|
43
|
+
uniform vec2 u_resolution;
|
|
44
|
+
uniform float u_size;
|
|
45
|
+
void main() {
|
|
46
|
+
vec2 clip = (a_position / u_resolution) * 2.0 - 1.0;
|
|
47
|
+
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
|
48
|
+
gl_PointSize = u_size;
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
const FRAG_VIS = `
|
|
52
|
+
precision mediump float;
|
|
53
|
+
uniform vec3 u_color;
|
|
54
|
+
uniform float u_opacity;
|
|
55
|
+
void main() {
|
|
56
|
+
vec2 coord = gl_PointCoord - vec2(0.5);
|
|
57
|
+
if (dot(coord, coord) > 0.25) discard;
|
|
58
|
+
gl_FragColor = vec4(u_color, u_opacity);
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
// Pick pass — encode index as color
|
|
63
|
+
const VERT_PICK = `
|
|
64
|
+
attribute vec2 a_position;
|
|
65
|
+
attribute vec3 a_pickColor;
|
|
66
|
+
uniform vec2 u_resolution;
|
|
67
|
+
uniform float u_size;
|
|
68
|
+
varying vec3 v_pickColor;
|
|
69
|
+
void main() {
|
|
70
|
+
vec2 clip = (a_position / u_resolution) * 2.0 - 1.0;
|
|
71
|
+
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
|
72
|
+
gl_PointSize = u_size;
|
|
73
|
+
v_pickColor = a_pickColor;
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
const FRAG_PICK = `
|
|
77
|
+
precision mediump float;
|
|
78
|
+
varying vec3 v_pickColor;
|
|
79
|
+
void main() {
|
|
80
|
+
vec2 coord = gl_PointCoord - vec2(0.5);
|
|
81
|
+
if (dot(coord, coord) > 0.25) discard;
|
|
82
|
+
gl_FragColor = vec4(v_pickColor, 1.0);
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Inline component — manages visible + pick canvases and hover state.
|
|
88
|
+
*/
|
|
89
|
+
function DotGLInteractiveCanvas({
|
|
90
|
+
resolved,
|
|
91
|
+
color,
|
|
92
|
+
size,
|
|
93
|
+
opacity,
|
|
94
|
+
width,
|
|
95
|
+
height,
|
|
96
|
+
data,
|
|
97
|
+
renderTooltip
|
|
98
|
+
}) {
|
|
99
|
+
const visCanvasRef = useRef(null);
|
|
100
|
+
const pickCanvasRef = useRef(null);
|
|
101
|
+
const [hoverIndex, setHoverIndex] = useState(-1);
|
|
102
|
+
const [mousePos, setMousePos] = useState(null);
|
|
103
|
+
// Bumped on context restore to force the render effect to rebuild + repaint.
|
|
104
|
+
const [contextEpoch, setContextEpoch] = useState(0);
|
|
105
|
+
const visGLRef = useRef(null);
|
|
106
|
+
const visBufRef = useRef(null);
|
|
107
|
+
const pickGLRef = useRef(null);
|
|
108
|
+
const pickPosBufRef = useRef(null);
|
|
109
|
+
const pickColBufRef = useRef(null);
|
|
110
|
+
|
|
111
|
+
// Positions and pick colors are index-aligned with `resolved` (and thus with
|
|
112
|
+
// `data`); do NOT drop entries here or hover decode would map to wrong rows.
|
|
113
|
+
// Non-finite points get NaN clip positions and simply don't rasterize.
|
|
114
|
+
const positions = useMemo(() => {
|
|
115
|
+
const pos = new Float32Array(resolved.length * 2);
|
|
116
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
117
|
+
pos[i * 2] = resolved[i].px;
|
|
118
|
+
pos[i * 2 + 1] = resolved[i].py;
|
|
119
|
+
}
|
|
120
|
+
return pos;
|
|
121
|
+
}, [resolved]);
|
|
122
|
+
const pickColors = useMemo(() => {
|
|
123
|
+
const colors = new Float32Array(resolved.length * 3);
|
|
124
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
125
|
+
const [r, g, b] = indexToColor(i);
|
|
126
|
+
colors[i * 3] = r;
|
|
127
|
+
colors[i * 3 + 1] = g;
|
|
128
|
+
colors[i * 3 + 2] = b;
|
|
129
|
+
}
|
|
130
|
+
return colors;
|
|
131
|
+
}, [resolved.length]);
|
|
132
|
+
|
|
133
|
+
// Render both passes. Contexts, programs, buffers, framebuffer, and pick
|
|
134
|
+
// texture are created once and reused; only vertex data + uniforms upload per
|
|
135
|
+
// run, and the pick texture re-allocates only when the buffer size changes.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (width <= 0 || height <= 0) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const dpr = window.devicePixelRatio || 1;
|
|
141
|
+
const bufW = Math.max(1, Math.round(width * dpr));
|
|
142
|
+
const bufH = Math.max(1, Math.round(height * dpr));
|
|
143
|
+
|
|
144
|
+
// ── Visible pass ──
|
|
145
|
+
const visCanvas = visCanvasRef.current;
|
|
146
|
+
if (visCanvas) {
|
|
147
|
+
if (visCanvas.width !== bufW) {
|
|
148
|
+
visCanvas.width = bufW;
|
|
149
|
+
}
|
|
150
|
+
if (visCanvas.height !== bufH) {
|
|
151
|
+
visCanvas.height = bufH;
|
|
152
|
+
}
|
|
153
|
+
if (!visGLRef.current) {
|
|
154
|
+
const gl = visCanvas.getContext('webgl', {
|
|
155
|
+
alpha: true,
|
|
156
|
+
premultipliedAlpha: false,
|
|
157
|
+
antialias: true
|
|
158
|
+
});
|
|
159
|
+
const prog = gl ? createProgram(gl, VERT_VIS, FRAG_VIS) : null;
|
|
160
|
+
if (gl && prog) {
|
|
161
|
+
visGLRef.current = {
|
|
162
|
+
gl,
|
|
163
|
+
prog
|
|
164
|
+
};
|
|
165
|
+
visBufRef.current = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const vis = visGLRef.current;
|
|
169
|
+
if (vis && !vis.gl.isContextLost()) {
|
|
170
|
+
const {
|
|
171
|
+
gl,
|
|
172
|
+
prog
|
|
173
|
+
} = vis;
|
|
174
|
+
if (!visBufRef.current) {
|
|
175
|
+
visBufRef.current = gl.createBuffer();
|
|
176
|
+
}
|
|
177
|
+
const buf = visBufRef.current;
|
|
178
|
+
if (buf) {
|
|
179
|
+
gl.viewport(0, 0, visCanvas.width, visCanvas.height);
|
|
180
|
+
gl.clearColor(0, 0, 0, 0);
|
|
181
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
182
|
+
gl.enable(gl.BLEND);
|
|
183
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
184
|
+
gl.useProgram(prog);
|
|
185
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
186
|
+
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
|
|
187
|
+
const aPos = gl.getAttribLocation(prog, 'a_position');
|
|
188
|
+
gl.enableVertexAttribArray(aPos);
|
|
189
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
190
|
+
const [r, g, b] = hexToGL(color);
|
|
191
|
+
gl.uniform2f(gl.getUniformLocation(prog, 'u_resolution'), width, height);
|
|
192
|
+
gl.uniform3f(gl.getUniformLocation(prog, 'u_color'), r, g, b);
|
|
193
|
+
gl.uniform1f(gl.getUniformLocation(prog, 'u_size'), size * dpr);
|
|
194
|
+
gl.uniform1f(gl.getUniformLocation(prog, 'u_opacity'), opacity);
|
|
195
|
+
gl.drawArrays(gl.POINTS, 0, positions.length / 2);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Pick pass (offscreen framebuffer) ──
|
|
201
|
+
const pickCanvas = pickCanvasRef.current;
|
|
202
|
+
if (pickCanvas) {
|
|
203
|
+
if (pickCanvas.width !== bufW) {
|
|
204
|
+
pickCanvas.width = bufW;
|
|
205
|
+
}
|
|
206
|
+
if (pickCanvas.height !== bufH) {
|
|
207
|
+
pickCanvas.height = bufH;
|
|
208
|
+
}
|
|
209
|
+
if (!pickGLRef.current) {
|
|
210
|
+
const gl = pickCanvas.getContext('webgl', {
|
|
211
|
+
alpha: false,
|
|
212
|
+
premultipliedAlpha: false,
|
|
213
|
+
antialias: false,
|
|
214
|
+
preserveDrawingBuffer: true
|
|
215
|
+
});
|
|
216
|
+
if (gl) {
|
|
217
|
+
const prog = createProgram(gl, VERT_PICK, FRAG_PICK);
|
|
218
|
+
const fb = gl.createFramebuffer();
|
|
219
|
+
const tex = gl.createTexture();
|
|
220
|
+
if (prog && fb && tex) {
|
|
221
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
222
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
223
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
224
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
225
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
|
226
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
227
|
+
pickGLRef.current = {
|
|
228
|
+
gl,
|
|
229
|
+
prog,
|
|
230
|
+
fb,
|
|
231
|
+
tex,
|
|
232
|
+
texW: 0,
|
|
233
|
+
texH: 0,
|
|
234
|
+
dpr
|
|
235
|
+
};
|
|
236
|
+
pickPosBufRef.current = null;
|
|
237
|
+
pickColBufRef.current = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const pick = pickGLRef.current;
|
|
242
|
+
if (pick && !pick.gl.isContextLost()) {
|
|
243
|
+
const {
|
|
244
|
+
gl,
|
|
245
|
+
prog,
|
|
246
|
+
fb,
|
|
247
|
+
tex
|
|
248
|
+
} = pick;
|
|
249
|
+
pick.dpr = dpr;
|
|
250
|
+
|
|
251
|
+
// (Re)allocate the pick texture only when the buffer size changes.
|
|
252
|
+
if (pick.texW !== pickCanvas.width || pick.texH !== pickCanvas.height) {
|
|
253
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
254
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, pickCanvas.width, pickCanvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
255
|
+
pick.texW = pickCanvas.width;
|
|
256
|
+
pick.texH = pickCanvas.height;
|
|
257
|
+
}
|
|
258
|
+
if (!pickPosBufRef.current) {
|
|
259
|
+
pickPosBufRef.current = gl.createBuffer();
|
|
260
|
+
}
|
|
261
|
+
if (!pickColBufRef.current) {
|
|
262
|
+
pickColBufRef.current = gl.createBuffer();
|
|
263
|
+
}
|
|
264
|
+
const posBuf = pickPosBufRef.current;
|
|
265
|
+
const colBuf = pickColBufRef.current;
|
|
266
|
+
if (posBuf && colBuf) {
|
|
267
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
268
|
+
gl.viewport(0, 0, pickCanvas.width, pickCanvas.height);
|
|
269
|
+
gl.clearColor(0, 0, 0, 1);
|
|
270
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
271
|
+
gl.disable(gl.BLEND);
|
|
272
|
+
gl.useProgram(prog);
|
|
273
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
|
274
|
+
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
|
|
275
|
+
const aPos = gl.getAttribLocation(prog, 'a_position');
|
|
276
|
+
gl.enableVertexAttribArray(aPos);
|
|
277
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
278
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, colBuf);
|
|
279
|
+
gl.bufferData(gl.ARRAY_BUFFER, pickColors, gl.STATIC_DRAW);
|
|
280
|
+
const aCol = gl.getAttribLocation(prog, 'a_pickColor');
|
|
281
|
+
gl.enableVertexAttribArray(aCol);
|
|
282
|
+
gl.vertexAttribPointer(aCol, 3, gl.FLOAT, false, 0, 0);
|
|
283
|
+
gl.uniform2f(gl.getUniformLocation(prog, 'u_resolution'), width, height);
|
|
284
|
+
gl.uniform1f(gl.getUniformLocation(prog, 'u_size'), (size + HIT_PADDING) * dpr);
|
|
285
|
+
gl.drawArrays(gl.POINTS, 0, positions.length / 2);
|
|
286
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}, [width, height, positions, pickColors, color, size, opacity, contextEpoch]);
|
|
291
|
+
|
|
292
|
+
// Register context-loss recovery for both canvases (runs once post-mount).
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const cleanups = [];
|
|
295
|
+
const visCanvas = visCanvasRef.current;
|
|
296
|
+
if (visCanvas) {
|
|
297
|
+
cleanups.push(registerContextLossHandlers(visCanvas, () => {
|
|
298
|
+
visGLRef.current = null;
|
|
299
|
+
visBufRef.current = null;
|
|
300
|
+
}, () => setContextEpoch(e => e + 1)));
|
|
301
|
+
}
|
|
302
|
+
const pickCanvas = pickCanvasRef.current;
|
|
303
|
+
if (pickCanvas) {
|
|
304
|
+
cleanups.push(registerContextLossHandlers(pickCanvas, () => {
|
|
305
|
+
pickGLRef.current = null;
|
|
306
|
+
pickPosBufRef.current = null;
|
|
307
|
+
pickColBufRef.current = null;
|
|
308
|
+
}, () => setContextEpoch(e => e + 1)));
|
|
309
|
+
}
|
|
310
|
+
return () => {
|
|
311
|
+
for (const c of cleanups) {
|
|
312
|
+
c();
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
// Release both GL contexts and all their resources on unmount.
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
return () => {
|
|
320
|
+
const vis = visGLRef.current;
|
|
321
|
+
if (vis) {
|
|
322
|
+
const {
|
|
323
|
+
gl,
|
|
324
|
+
prog
|
|
325
|
+
} = vis;
|
|
326
|
+
if (visBufRef.current) {
|
|
327
|
+
gl.deleteBuffer(visBufRef.current);
|
|
328
|
+
}
|
|
329
|
+
gl.deleteProgram(prog);
|
|
330
|
+
loseContext(gl);
|
|
331
|
+
}
|
|
332
|
+
const pick = pickGLRef.current;
|
|
333
|
+
if (pick) {
|
|
334
|
+
const {
|
|
335
|
+
gl,
|
|
336
|
+
prog,
|
|
337
|
+
fb,
|
|
338
|
+
tex
|
|
339
|
+
} = pick;
|
|
340
|
+
if (pickPosBufRef.current) {
|
|
341
|
+
gl.deleteBuffer(pickPosBufRef.current);
|
|
342
|
+
}
|
|
343
|
+
if (pickColBufRef.current) {
|
|
344
|
+
gl.deleteBuffer(pickColBufRef.current);
|
|
345
|
+
}
|
|
346
|
+
gl.deleteFramebuffer(fb);
|
|
347
|
+
gl.deleteTexture(tex);
|
|
348
|
+
gl.deleteProgram(prog);
|
|
349
|
+
loseContext(gl);
|
|
350
|
+
}
|
|
351
|
+
visGLRef.current = null;
|
|
352
|
+
visBufRef.current = null;
|
|
353
|
+
pickGLRef.current = null;
|
|
354
|
+
pickPosBufRef.current = null;
|
|
355
|
+
pickColBufRef.current = null;
|
|
356
|
+
};
|
|
357
|
+
}, []);
|
|
358
|
+
const handleMouseMove = useCallback(e => {
|
|
359
|
+
const pick = pickGLRef.current;
|
|
360
|
+
if (!pick || pick.gl.isContextLost()) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const svg = e.currentTarget.ownerSVGElement;
|
|
364
|
+
if (!svg) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const ctm = e.currentTarget.getScreenCTM();
|
|
368
|
+
if (!ctm) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const pt = svg.createSVGPoint();
|
|
372
|
+
pt.x = e.clientX;
|
|
373
|
+
pt.y = e.clientY;
|
|
374
|
+
const local = pt.matrixTransform(ctm.inverse());
|
|
375
|
+
|
|
376
|
+
// Use the DPR the pick framebuffer was built with so pixel math stays in
|
|
377
|
+
// sync even if devicePixelRatio changed since the last render.
|
|
378
|
+
const {
|
|
379
|
+
gl,
|
|
380
|
+
fb,
|
|
381
|
+
dpr,
|
|
382
|
+
texW,
|
|
383
|
+
texH
|
|
384
|
+
} = pick;
|
|
385
|
+
const px = Math.floor(local.x * dpr);
|
|
386
|
+
const py = Math.floor(local.y * dpr);
|
|
387
|
+
if (px < 0 || py < 0 || px >= texW || py >= texH) {
|
|
388
|
+
setHoverIndex(-1);
|
|
389
|
+
setMousePos(null);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
393
|
+
const pixel = new Uint8Array(4);
|
|
394
|
+
// Flip Y from top-origin CSS space to the framebuffer's bottom-left,
|
|
395
|
+
// 0-indexed origin. `texH - 1 - py` (not `texH - py`) keeps the read in
|
|
396
|
+
// [0, texH-1] — the bare `texH - py` reads one row too high and samples
|
|
397
|
+
// out of bounds at the very top edge.
|
|
398
|
+
gl.readPixels(px, texH - 1 - py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
|
|
399
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
400
|
+
const idx = colorToIndex(pixel[0], pixel[1], pixel[2]);
|
|
401
|
+
setHoverIndex(idx);
|
|
402
|
+
setMousePos(idx >= 0 ? {
|
|
403
|
+
x: local.x,
|
|
404
|
+
y: local.y
|
|
405
|
+
} : null);
|
|
406
|
+
}, []);
|
|
407
|
+
const handleMouseLeave = useCallback(() => {
|
|
408
|
+
setHoverIndex(-1);
|
|
409
|
+
setMousePos(null);
|
|
410
|
+
}, []);
|
|
411
|
+
const datum = hoverIndex >= 0 && hoverIndex < data.length ? data[hoverIndex] : null;
|
|
412
|
+
const defaultTooltip = (d, i) => /*#__PURE__*/_jsxs("div", {
|
|
413
|
+
style: {
|
|
414
|
+
display: 'flex',
|
|
415
|
+
flexDirection: 'column',
|
|
416
|
+
gap: 2,
|
|
417
|
+
fontSize: 12
|
|
418
|
+
},
|
|
419
|
+
children: [/*#__PURE__*/_jsxs("div", {
|
|
420
|
+
style: {
|
|
421
|
+
fontWeight: 600,
|
|
422
|
+
color: 'var(--color-text-primary)'
|
|
423
|
+
},
|
|
424
|
+
children: ["Point ", i]
|
|
425
|
+
}), Object.entries(d).map(([k, v]) => /*#__PURE__*/_jsxs("div", {
|
|
426
|
+
children: [/*#__PURE__*/_jsxs("span", {
|
|
427
|
+
style: {
|
|
428
|
+
color: 'var(--color-text-secondary)'
|
|
429
|
+
},
|
|
430
|
+
children: [k, ":"]
|
|
431
|
+
}), ' ', /*#__PURE__*/_jsx("span", {
|
|
432
|
+
style: {
|
|
433
|
+
fontWeight: 500
|
|
434
|
+
},
|
|
435
|
+
children: String(v)
|
|
436
|
+
})]
|
|
437
|
+
}, k))]
|
|
438
|
+
});
|
|
439
|
+
return /*#__PURE__*/_jsxs("g", {
|
|
440
|
+
children: [/*#__PURE__*/_jsx("foreignObject", {
|
|
441
|
+
x: 0,
|
|
442
|
+
y: 0,
|
|
443
|
+
width: width,
|
|
444
|
+
height: height,
|
|
445
|
+
style: {
|
|
446
|
+
overflow: 'hidden'
|
|
447
|
+
},
|
|
448
|
+
children: /*#__PURE__*/_jsx("canvas", {
|
|
449
|
+
ref: visCanvasRef,
|
|
450
|
+
style: {
|
|
451
|
+
width,
|
|
452
|
+
height,
|
|
453
|
+
pointerEvents: 'none'
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
}), /*#__PURE__*/_jsx("foreignObject", {
|
|
457
|
+
x: 0,
|
|
458
|
+
y: 0,
|
|
459
|
+
width: 0,
|
|
460
|
+
height: 0,
|
|
461
|
+
style: {
|
|
462
|
+
overflow: 'hidden'
|
|
463
|
+
},
|
|
464
|
+
children: /*#__PURE__*/_jsx("canvas", {
|
|
465
|
+
ref: pickCanvasRef,
|
|
466
|
+
style: {
|
|
467
|
+
display: 'none'
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
}), /*#__PURE__*/_jsx("rect", {
|
|
471
|
+
x: 0,
|
|
472
|
+
y: 0,
|
|
473
|
+
width: width,
|
|
474
|
+
height: height,
|
|
475
|
+
fill: "transparent",
|
|
476
|
+
onMouseMove: handleMouseMove,
|
|
477
|
+
onMouseLeave: handleMouseLeave
|
|
478
|
+
}), datum && hoverIndex >= 0 && /*#__PURE__*/_jsxs("g", {
|
|
479
|
+
pointerEvents: "none",
|
|
480
|
+
children: [/*#__PURE__*/_jsx("circle", {
|
|
481
|
+
cx: positions[hoverIndex * 2],
|
|
482
|
+
cy: positions[hoverIndex * 2 + 1],
|
|
483
|
+
r: size / 2 + 7,
|
|
484
|
+
fill: "none",
|
|
485
|
+
stroke: color,
|
|
486
|
+
strokeWidth: 2,
|
|
487
|
+
strokeOpacity: 0.35
|
|
488
|
+
}), /*#__PURE__*/_jsx("circle", {
|
|
489
|
+
cx: positions[hoverIndex * 2],
|
|
490
|
+
cy: positions[hoverIndex * 2 + 1],
|
|
491
|
+
r: size / 2 + 2.5,
|
|
492
|
+
fill: color,
|
|
493
|
+
stroke: "var(--color-background-body, #fff)",
|
|
494
|
+
strokeWidth: 2
|
|
495
|
+
})]
|
|
496
|
+
}), datum && mousePos && /*#__PURE__*/_jsx("foreignObject", {
|
|
497
|
+
x: mousePos.x + 12,
|
|
498
|
+
y: Math.max(0, mousePos.y - 40),
|
|
499
|
+
width: 200,
|
|
500
|
+
height: 120,
|
|
501
|
+
pointerEvents: "none",
|
|
502
|
+
style: {
|
|
503
|
+
overflow: 'visible'
|
|
504
|
+
},
|
|
505
|
+
children: /*#__PURE__*/_jsx("div", {
|
|
506
|
+
style: {
|
|
507
|
+
background: 'var(--color-background-popover)',
|
|
508
|
+
border: '1px solid var(--color-border)',
|
|
509
|
+
borderRadius: 8,
|
|
510
|
+
padding: '8px 12px',
|
|
511
|
+
boxShadow: 'var(--shadow-med)',
|
|
512
|
+
whiteSpace: 'nowrap',
|
|
513
|
+
width: 'fit-content'
|
|
514
|
+
},
|
|
515
|
+
children: renderTooltip ? renderTooltip(datum, hoverIndex) : defaultTooltip(datum, hoverIndex)
|
|
516
|
+
})
|
|
517
|
+
})]
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* WebGL scatter with GPU color-picking hover. O(1) hit detection via offscreen
|
|
523
|
+
* framebuffer — no JS iteration regardless of point count.
|
|
524
|
+
*
|
|
525
|
+
* @example
|
|
526
|
+
* ```
|
|
527
|
+
* series={[dotGLInteractive('value', {
|
|
528
|
+
* color: '#4f46e5',
|
|
529
|
+
* renderTooltip: (d, i) => <span>{d.name}: {d.value}</span>,
|
|
530
|
+
* })]}
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
export function dotGLInteractive(dataKey, options) {
|
|
534
|
+
const {
|
|
535
|
+
color
|
|
536
|
+
} = options;
|
|
537
|
+
const size = options.size ?? 6;
|
|
538
|
+
const opacity = options.opacity ?? 0.8;
|
|
539
|
+
const renderTooltip = options.renderTooltip;
|
|
540
|
+
return {
|
|
541
|
+
type: 'dotGLInteractive',
|
|
542
|
+
key: dataKey,
|
|
543
|
+
dataKeys: [dataKey],
|
|
544
|
+
layout: {},
|
|
545
|
+
resolve(ctx) {
|
|
546
|
+
const {
|
|
547
|
+
data,
|
|
548
|
+
xKey,
|
|
549
|
+
xScale,
|
|
550
|
+
yScale
|
|
551
|
+
} = ctx;
|
|
552
|
+
const points = [];
|
|
553
|
+
for (let i = 0; i < data.length; i++) {
|
|
554
|
+
const d = data[i];
|
|
555
|
+
let px;
|
|
556
|
+
if ('bandwidth' in xScale) {
|
|
557
|
+
px = (xScale(String(d[xKey])) ?? 0) + xScale.bandwidth() / 2;
|
|
558
|
+
} else {
|
|
559
|
+
px = xScale(d[xKey]);
|
|
560
|
+
}
|
|
561
|
+
const v = typeof d[dataKey] === 'number' ? d[dataKey] : 0;
|
|
562
|
+
points.push({
|
|
563
|
+
px,
|
|
564
|
+
py: yScale(v),
|
|
565
|
+
py0: yScale(0),
|
|
566
|
+
dataIndex: i
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return points;
|
|
570
|
+
},
|
|
571
|
+
render(resolved, ctx) {
|
|
572
|
+
return /*#__PURE__*/_jsx(DotGLInteractiveCanvas, {
|
|
573
|
+
resolved: resolved,
|
|
574
|
+
color: color,
|
|
575
|
+
size: size,
|
|
576
|
+
opacity: opacity,
|
|
577
|
+
width: ctx.width,
|
|
578
|
+
height: ctx.height,
|
|
579
|
+
data: ctx.data,
|
|
580
|
+
renderTooltip: renderTooltip
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file marks/errorBar.tsx
|
|
3
|
+
* @output Error bar (whisker) series — vertical high/low bounds. Bounds are
|
|
4
|
+
* absolute (so asymmetric intervals work); a point missing either bound is
|
|
5
|
+
* skipped rather than drawn down to the axis.
|
|
6
|
+
*/
|
|
7
|
+
import type { SeriesDef } from '../types';
|
|
8
|
+
export interface ErrorBarOptions {
|
|
9
|
+
/** Data key for the upper bound */
|
|
10
|
+
high: string;
|
|
11
|
+
/** Data key for the lower bound */
|
|
12
|
+
low: string;
|
|
13
|
+
/** Stroke color */
|
|
14
|
+
color?: string;
|
|
15
|
+
/** Stroke width */
|
|
16
|
+
strokeWidth?: number;
|
|
17
|
+
/** Width of the whisker caps in pixels */
|
|
18
|
+
capWidth?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Error bars with whisker caps. Pair with bar() or dot().
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```
|
|
25
|
+
* series={[bar('mean'), errorBar({high: 'upper95', low: 'lower95'})]}
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function errorBar(options: ErrorBarOptions): SeriesDef;
|
|
29
|
+
//# sourceMappingURL=errorBar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errorBar.d.ts","sourceRoot":"","sources":["../../src/marks/errorBar.tsx"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAgB,MAAM,UAAU,CAAC;AAGvD,MAAM,WAAW,eAAe;IAC9B,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mBAAmB;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,SAAS,CAqF5D"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file marks/errorBar.tsx
|
|
5
|
+
* @output Error bar (whisker) series — vertical high/low bounds. Bounds are
|
|
6
|
+
* absolute (so asymmetric intervals work); a point missing either bound is
|
|
7
|
+
* skipped rather than drawn down to the axis.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { xPixel } from "../utils.js";
|
|
11
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
12
|
+
/**
|
|
13
|
+
* Error bars with whisker caps. Pair with bar() or dot().
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```
|
|
17
|
+
* series={[bar('mean'), errorBar({high: 'upper95', low: 'lower95'})]}
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function errorBar(options) {
|
|
21
|
+
const {
|
|
22
|
+
high,
|
|
23
|
+
low
|
|
24
|
+
} = options;
|
|
25
|
+
const color = options.color ?? 'var(--color-text-primary)';
|
|
26
|
+
const strokeWidth = options.strokeWidth ?? 1.5;
|
|
27
|
+
const capWidth = options.capWidth ?? 8;
|
|
28
|
+
return {
|
|
29
|
+
type: 'errorBar',
|
|
30
|
+
key: `errorBar-${high}-${low}`,
|
|
31
|
+
dataKeys: [high, low],
|
|
32
|
+
layout: {},
|
|
33
|
+
resolve(ctx) {
|
|
34
|
+
const {
|
|
35
|
+
data,
|
|
36
|
+
xKey,
|
|
37
|
+
xScale,
|
|
38
|
+
yScale
|
|
39
|
+
} = ctx;
|
|
40
|
+
const points = [];
|
|
41
|
+
for (let i = 0; i < data.length; i++) {
|
|
42
|
+
const d = data[i];
|
|
43
|
+
// NaN for a missing bound so render() skips the whole whisker instead
|
|
44
|
+
// of anchoring a cap at the axis (a misleading zero-height error bar).
|
|
45
|
+
const hiVal = d[high];
|
|
46
|
+
const loVal = d[low];
|
|
47
|
+
const upper = typeof hiVal === 'number' && Number.isFinite(hiVal) ? hiVal : NaN;
|
|
48
|
+
const lower = typeof loVal === 'number' && Number.isFinite(loVal) ? loVal : NaN;
|
|
49
|
+
points.push({
|
|
50
|
+
px: xPixel(d, xKey, xScale),
|
|
51
|
+
py: yScale(upper),
|
|
52
|
+
py0: yScale(lower),
|
|
53
|
+
dataIndex: i
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return points;
|
|
57
|
+
},
|
|
58
|
+
render(resolved) {
|
|
59
|
+
// Non-finite capWidth (NaN/±Infinity) would leak into the cap x-coords;
|
|
60
|
+
// treat it like a non-positive width — draw the stem, drop the caps.
|
|
61
|
+
const half = Number.isFinite(capWidth) ? Math.max(0, capWidth) / 2 : 0;
|
|
62
|
+
return /*#__PURE__*/_jsx("g", {
|
|
63
|
+
children: resolved.map(p => {
|
|
64
|
+
if (!Number.isFinite(p.px) || !Number.isFinite(p.py) || !Number.isFinite(p.py0)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return /*#__PURE__*/_jsxs("g", {
|
|
68
|
+
children: [/*#__PURE__*/_jsx("line", {
|
|
69
|
+
x1: p.px,
|
|
70
|
+
x2: p.px,
|
|
71
|
+
y1: p.py,
|
|
72
|
+
y2: p.py0,
|
|
73
|
+
stroke: color,
|
|
74
|
+
strokeWidth: strokeWidth
|
|
75
|
+
}), /*#__PURE__*/_jsx("line", {
|
|
76
|
+
x1: p.px - half,
|
|
77
|
+
x2: p.px + half,
|
|
78
|
+
y1: p.py,
|
|
79
|
+
y2: p.py,
|
|
80
|
+
stroke: color,
|
|
81
|
+
strokeWidth: strokeWidth
|
|
82
|
+
}), /*#__PURE__*/_jsx("line", {
|
|
83
|
+
x1: p.px - half,
|
|
84
|
+
x2: p.px + half,
|
|
85
|
+
y1: p.py0,
|
|
86
|
+
y2: p.py0,
|
|
87
|
+
stroke: color,
|
|
88
|
+
strokeWidth: strokeWidth
|
|
89
|
+
})]
|
|
90
|
+
}, p.dataIndex);
|
|
91
|
+
})
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|