hyper-scatter 0.1.0
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/LICENSE +21 -0
- package/README.md +142 -0
- package/dist-lib/controller/interaction_controller.d.ts +29 -0
- package/dist-lib/controller/interaction_controller.d.ts.map +1 -0
- package/dist-lib/controller/interaction_controller.js +286 -0
- package/dist-lib/controller/interaction_controller.js.map +1 -0
- package/dist-lib/core/dataset.d.ts +62 -0
- package/dist-lib/core/dataset.d.ts.map +1 -0
- package/dist-lib/core/dataset.js +152 -0
- package/dist-lib/core/dataset.js.map +1 -0
- package/dist-lib/core/lasso_simplify.d.ts +16 -0
- package/dist-lib/core/lasso_simplify.d.ts.map +1 -0
- package/dist-lib/core/lasso_simplify.js +173 -0
- package/dist-lib/core/lasso_simplify.js.map +1 -0
- package/dist-lib/core/math/euclidean.d.ts +31 -0
- package/dist-lib/core/math/euclidean.d.ts.map +1 -0
- package/dist-lib/core/math/euclidean.js +64 -0
- package/dist-lib/core/math/euclidean.js.map +1 -0
- package/dist-lib/core/math/poincare.d.ts +117 -0
- package/dist-lib/core/math/poincare.d.ts.map +1 -0
- package/dist-lib/core/math/poincare.js +321 -0
- package/dist-lib/core/math/poincare.js.map +1 -0
- package/dist-lib/core/rng.d.ts +18 -0
- package/dist-lib/core/rng.d.ts.map +1 -0
- package/dist-lib/core/rng.js +52 -0
- package/dist-lib/core/rng.js.map +1 -0
- package/dist-lib/core/selection/point_in_polygon.d.ts +30 -0
- package/dist-lib/core/selection/point_in_polygon.d.ts.map +1 -0
- package/dist-lib/core/selection/point_in_polygon.js +112 -0
- package/dist-lib/core/selection/point_in_polygon.js.map +1 -0
- package/dist-lib/core/types.d.ts +185 -0
- package/dist-lib/core/types.d.ts.map +1 -0
- package/dist-lib/core/types.js +53 -0
- package/dist-lib/core/types.js.map +1 -0
- package/dist-lib/impl_candidate/spatial_index.d.ts +45 -0
- package/dist-lib/impl_candidate/spatial_index.d.ts.map +1 -0
- package/dist-lib/impl_candidate/spatial_index.js +186 -0
- package/dist-lib/impl_candidate/spatial_index.js.map +1 -0
- package/dist-lib/impl_candidate/webgl_candidate.d.ts +283 -0
- package/dist-lib/impl_candidate/webgl_candidate.d.ts.map +1 -0
- package/dist-lib/impl_candidate/webgl_candidate.js +2276 -0
- package/dist-lib/impl_candidate/webgl_candidate.js.map +1 -0
- package/dist-lib/index.d.ts +11 -0
- package/dist-lib/index.d.ts.map +1 -0
- package/dist-lib/index.js +52 -0
- package/dist-lib/index.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,2276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebGL2 candidate renderers.
|
|
3
|
+
*
|
|
4
|
+
* Key idea:
|
|
5
|
+
* - Keep all math (view state, project/unproject, pan/zoom) identical to reference
|
|
6
|
+
* by delegating to src/core/math/*.
|
|
7
|
+
* - Move rendering to GPU (WebGL2 point sprites) for high throughput.
|
|
8
|
+
* - Speed up hit-testing and lasso selection via spatial indexes.
|
|
9
|
+
*
|
|
10
|
+
* ---------------------------------------------------------------------------
|
|
11
|
+
* Adaptive Quality / Performance Policy (budget-based)
|
|
12
|
+
* ---------------------------------------------------------------------------
|
|
13
|
+
* This renderer intentionally adapts quality to keep interaction smooth on
|
|
14
|
+
* typical developer hardware.
|
|
15
|
+
*
|
|
16
|
+
* Instead of relying on point-count-only thresholds, we choose settings from
|
|
17
|
+
* simple *work budgets*:
|
|
18
|
+
*
|
|
19
|
+
* 1) Fragment budget (fill-rate proxy)
|
|
20
|
+
* estFragments ≈ drawCount * π * r^2 * dpr^2
|
|
21
|
+
* where r is point radius in CSS pixels and dpr is the offscreen points-FBO
|
|
22
|
+
* pixel ratio (NOT necessarily window.devicePixelRatio).
|
|
23
|
+
*
|
|
24
|
+
* 2) Points FBO pixel budget (memory/bandwidth proxy)
|
|
25
|
+
* pointsPixels = (width * height) * dpr^2
|
|
26
|
+
*
|
|
27
|
+
* The policy chooses an offscreen points DPR that respects BOTH budgets. When
|
|
28
|
+
* fragment pressure is high, we may switch from anti-aliased circles to faster
|
|
29
|
+
* squares (with hysteresis) rather than doing it at a fixed N threshold.
|
|
30
|
+
*
|
|
31
|
+
* NOTE: These adaptations affect *rendering only*; hit-testing and lasso
|
|
32
|
+
* selection remain exact (CPU-side) and must match the reference semantics.
|
|
33
|
+
*
|
|
34
|
+
* IMPORTANT:
|
|
35
|
+
* - The browser benchmark/accuracy harness uses a *separate* hidden canvas for
|
|
36
|
+
* the WebGL candidate (a single <canvas> cannot hold both a 2D and WebGL
|
|
37
|
+
* context at the same time).
|
|
38
|
+
* - This renderer still lazily creates its WebGL2 context only when `render()`
|
|
39
|
+
* is called, because the demo/harness may re-initialize renderers and we
|
|
40
|
+
* want `init()` to remain side-effect-light.
|
|
41
|
+
*/
|
|
42
|
+
import { DEFAULT_COLORS, SELECTION_COLOR, HOVER_COLOR, createIndicesSelectionResult, createGeometrySelectionResult, } from '../core/types.js';
|
|
43
|
+
import { createEuclideanView, projectEuclidean, unprojectEuclidean, panEuclidean, zoomEuclidean, } from '../core/math/euclidean.js';
|
|
44
|
+
import { createHyperbolicView, projectPoincare, unprojectPoincare, panPoincare, zoomPoincare, } from '../core/math/poincare.js';
|
|
45
|
+
import { UniformGridIndex, } from './spatial_index.js';
|
|
46
|
+
import { pointInPolygon } from '../core/selection/point_in_polygon.js';
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Small helpers
|
|
49
|
+
// ============================================================================
|
|
50
|
+
function parseHexColor(color) {
|
|
51
|
+
// Accept: #rgb, #rrggbb, #rrggbbaa
|
|
52
|
+
const s = color.trim();
|
|
53
|
+
if (!s.startsWith('#'))
|
|
54
|
+
return [1, 1, 1, 1];
|
|
55
|
+
const hex = s.slice(1);
|
|
56
|
+
if (hex.length === 3) {
|
|
57
|
+
const r = parseInt(hex[0] + hex[0], 16) / 255;
|
|
58
|
+
const g = parseInt(hex[1] + hex[1], 16) / 255;
|
|
59
|
+
const b = parseInt(hex[2] + hex[2], 16) / 255;
|
|
60
|
+
return [r, g, b, 1];
|
|
61
|
+
}
|
|
62
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
63
|
+
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
|
64
|
+
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
|
65
|
+
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
|
66
|
+
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
|
67
|
+
return [r, g, b, a];
|
|
68
|
+
}
|
|
69
|
+
return [1, 1, 1, 1];
|
|
70
|
+
}
|
|
71
|
+
function parseHexColorBytes(color) {
|
|
72
|
+
// Accept: #rgb, #rrggbb, #rrggbbaa
|
|
73
|
+
const s = color.trim();
|
|
74
|
+
if (!s.startsWith('#'))
|
|
75
|
+
return [255, 255, 255, 255];
|
|
76
|
+
const hex = s.slice(1);
|
|
77
|
+
if (hex.length === 3) {
|
|
78
|
+
const r = parseInt(hex[0] + hex[0], 16);
|
|
79
|
+
const g = parseInt(hex[1] + hex[1], 16);
|
|
80
|
+
const b = parseInt(hex[2] + hex[2], 16);
|
|
81
|
+
return [r, g, b, 255];
|
|
82
|
+
}
|
|
83
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
84
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
85
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
86
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
87
|
+
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255;
|
|
88
|
+
return [r, g, b, a];
|
|
89
|
+
}
|
|
90
|
+
return [255, 255, 255, 255];
|
|
91
|
+
}
|
|
92
|
+
function compileShader(gl, type, source) {
|
|
93
|
+
const shader = gl.createShader(type);
|
|
94
|
+
if (!shader)
|
|
95
|
+
throw new Error('Failed to create shader');
|
|
96
|
+
gl.shaderSource(shader, source);
|
|
97
|
+
gl.compileShader(shader);
|
|
98
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
99
|
+
const info = gl.getShaderInfoLog(shader) ?? 'unknown';
|
|
100
|
+
gl.deleteShader(shader);
|
|
101
|
+
throw new Error(`Shader compile failed: ${info}`);
|
|
102
|
+
}
|
|
103
|
+
return shader;
|
|
104
|
+
}
|
|
105
|
+
function linkProgram(gl, vsSource, fsSource) {
|
|
106
|
+
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSource);
|
|
107
|
+
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
|
108
|
+
const program = gl.createProgram();
|
|
109
|
+
if (!program)
|
|
110
|
+
throw new Error('Failed to create program');
|
|
111
|
+
gl.attachShader(program, vs);
|
|
112
|
+
gl.attachShader(program, fs);
|
|
113
|
+
gl.linkProgram(program);
|
|
114
|
+
gl.deleteShader(vs);
|
|
115
|
+
gl.deleteShader(fs);
|
|
116
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
117
|
+
const info = gl.getProgramInfoLog(program) ?? 'unknown';
|
|
118
|
+
gl.deleteProgram(program);
|
|
119
|
+
throw new Error(`Program link failed: ${info}`);
|
|
120
|
+
}
|
|
121
|
+
return program;
|
|
122
|
+
}
|
|
123
|
+
function setCanvasSize(canvas, width, height, dpr) {
|
|
124
|
+
canvas.width = Math.max(1, Math.floor(width * dpr));
|
|
125
|
+
canvas.height = Math.max(1, Math.floor(height * dpr));
|
|
126
|
+
canvas.style.width = `${width}px`;
|
|
127
|
+
canvas.style.height = `${height}px`;
|
|
128
|
+
}
|
|
129
|
+
// Note: Palette upload is handled by WebGLRendererBase via a small palette
|
|
130
|
+
// texture (supports arbitrary label counts) and cached upload buffers.
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// Shader sources
|
|
133
|
+
// ============================================================================
|
|
134
|
+
const FS_POINTS = `#version 300 es
|
|
135
|
+
precision highp float;
|
|
136
|
+
precision highp int;
|
|
137
|
+
|
|
138
|
+
flat in uint v_label;
|
|
139
|
+
|
|
140
|
+
// Present in the vertex stage too; redeclare here so we can compute AA width.
|
|
141
|
+
uniform float u_dpr;
|
|
142
|
+
uniform float u_pointRadiusCss;
|
|
143
|
+
|
|
144
|
+
uniform sampler2D u_paletteTex;
|
|
145
|
+
uniform int u_paletteSize;
|
|
146
|
+
uniform int u_paletteWidth;
|
|
147
|
+
|
|
148
|
+
out vec4 outColor;
|
|
149
|
+
|
|
150
|
+
void main() {
|
|
151
|
+
vec2 p = gl_PointCoord * 2.0 - 1.0;
|
|
152
|
+
// Anti-aliased circle: avoid harsh discard edges that can look like
|
|
153
|
+
// "weird polygons" at small sizes or without MSAA.
|
|
154
|
+
float r = length(p);
|
|
155
|
+
// Ensure at least ~1px transition (in point-local coordinates) so small
|
|
156
|
+
// points remain visually circular.
|
|
157
|
+
float radiusPx = max(u_pointRadiusCss * u_dpr, 1.0);
|
|
158
|
+
// Slightly wider than 1px helps circles stay round-looking when zoomed out
|
|
159
|
+
// (where points are perceptually tiny and aliasing is more obvious).
|
|
160
|
+
float aa = max(fwidth(r), 1.5 / radiusPx);
|
|
161
|
+
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, r);
|
|
162
|
+
if (alpha <= 0.0) discard;
|
|
163
|
+
|
|
164
|
+
int size = max(u_paletteSize, 1);
|
|
165
|
+
int w = max(u_paletteWidth, 1);
|
|
166
|
+
int idx = int(v_label) % size;
|
|
167
|
+
int x = idx % w;
|
|
168
|
+
int y = idx / w;
|
|
169
|
+
vec4 c = texelFetch(u_paletteTex, ivec2(x, y), 0);
|
|
170
|
+
outColor = vec4(c.rgb, c.a * alpha);
|
|
171
|
+
}
|
|
172
|
+
`;
|
|
173
|
+
// Performance mode: square points (no discard).
|
|
174
|
+
// This is often faster on some GPUs for very large point counts.
|
|
175
|
+
const FS_POINTS_SQUARE = `#version 300 es
|
|
176
|
+
precision highp float;
|
|
177
|
+
precision highp int;
|
|
178
|
+
|
|
179
|
+
flat in uint v_label;
|
|
180
|
+
|
|
181
|
+
uniform sampler2D u_paletteTex;
|
|
182
|
+
uniform int u_paletteSize;
|
|
183
|
+
uniform int u_paletteWidth;
|
|
184
|
+
|
|
185
|
+
out vec4 outColor;
|
|
186
|
+
|
|
187
|
+
void main() {
|
|
188
|
+
int size = max(u_paletteSize, 1);
|
|
189
|
+
int w = max(u_paletteWidth, 1);
|
|
190
|
+
int idx = int(v_label) % size;
|
|
191
|
+
int x = idx % w;
|
|
192
|
+
int y = idx / w;
|
|
193
|
+
outColor = texelFetch(u_paletteTex, ivec2(x, y), 0);
|
|
194
|
+
}
|
|
195
|
+
`;
|
|
196
|
+
const FS_SOLID = `#version 300 es
|
|
197
|
+
precision highp float;
|
|
198
|
+
precision highp int;
|
|
199
|
+
|
|
200
|
+
uniform float u_dpr;
|
|
201
|
+
uniform float u_pointRadiusCss;
|
|
202
|
+
|
|
203
|
+
uniform vec4 u_color;
|
|
204
|
+
uniform float u_pointSizePx;
|
|
205
|
+
uniform float u_ringThicknessPx;
|
|
206
|
+
uniform int u_ringMode; // 0 = solid, 1 = ring
|
|
207
|
+
|
|
208
|
+
out vec4 outColor;
|
|
209
|
+
|
|
210
|
+
void main() {
|
|
211
|
+
vec2 p = gl_PointCoord * 2.0 - 1.0;
|
|
212
|
+
float r = length(p);
|
|
213
|
+
float radiusPx = max(u_pointRadiusCss * u_dpr, 1.0);
|
|
214
|
+
float aa = max(fwidth(r), 1.5 / radiusPx);
|
|
215
|
+
float outer = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, r);
|
|
216
|
+
if (outer <= 0.0) discard;
|
|
217
|
+
|
|
218
|
+
float alpha = outer;
|
|
219
|
+
|
|
220
|
+
if (u_ringMode == 1) {
|
|
221
|
+
float radiusPx = u_pointSizePx * 0.5;
|
|
222
|
+
float t = clamp(u_ringThicknessPx / max(radiusPx, 1e-6), 0.0, 1.0);
|
|
223
|
+
float inner = 1.0 - t;
|
|
224
|
+
// Keep only the outer ring with an anti-aliased inner boundary.
|
|
225
|
+
float innerMask = smoothstep(inner - aa, inner + aa, r);
|
|
226
|
+
alpha *= innerMask;
|
|
227
|
+
if (alpha <= 0.0) discard;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
outColor = vec4(u_color.rgb, u_color.a * alpha);
|
|
231
|
+
}
|
|
232
|
+
`;
|
|
233
|
+
// Fullscreen triangle (no vertex attributes)
|
|
234
|
+
const VS_FULLSCREEN = `#version 300 es
|
|
235
|
+
precision highp float;
|
|
236
|
+
|
|
237
|
+
out vec2 v_uv;
|
|
238
|
+
|
|
239
|
+
void main() {
|
|
240
|
+
// Fullscreen triangle
|
|
241
|
+
// (-1,-1), (3,-1), (-1,3)
|
|
242
|
+
if (gl_VertexID == 0) {
|
|
243
|
+
gl_Position = vec4(-1.0, -1.0, 0.0, 1.0);
|
|
244
|
+
v_uv = vec2(0.0, 0.0);
|
|
245
|
+
} else if (gl_VertexID == 1) {
|
|
246
|
+
gl_Position = vec4(3.0, -1.0, 0.0, 1.0);
|
|
247
|
+
v_uv = vec2(2.0, 0.0);
|
|
248
|
+
} else {
|
|
249
|
+
gl_Position = vec4(-1.0, 3.0, 0.0, 1.0);
|
|
250
|
+
v_uv = vec2(0.0, 2.0);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
`;
|
|
254
|
+
// Composite pass: draw points texture over the background using alpha.
|
|
255
|
+
const FS_COMPOSITE = `#version 300 es
|
|
256
|
+
precision highp float;
|
|
257
|
+
|
|
258
|
+
in vec2 v_uv;
|
|
259
|
+
|
|
260
|
+
uniform sampler2D u_tex;
|
|
261
|
+
|
|
262
|
+
out vec4 outColor;
|
|
263
|
+
|
|
264
|
+
void main() {
|
|
265
|
+
vec2 uv = clamp(v_uv, 0.0, 1.0);
|
|
266
|
+
outColor = texture(u_tex, uv);
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
// Poincaré disk background + border (matches reference styling closely)
|
|
270
|
+
// Drawn as a single fullscreen pass. Outside the disk it discards, leaving the
|
|
271
|
+
// cleared background color.
|
|
272
|
+
const FS_POINCARE_DISK = `#version 300 es
|
|
273
|
+
precision highp float;
|
|
274
|
+
precision highp int;
|
|
275
|
+
|
|
276
|
+
uniform vec2 u_cssSize;
|
|
277
|
+
uniform float u_dpr;
|
|
278
|
+
uniform float u_displayZoom;
|
|
279
|
+
|
|
280
|
+
uniform vec4 u_diskFillColor;
|
|
281
|
+
uniform vec4 u_diskBorderColor;
|
|
282
|
+
uniform vec4 u_gridColor;
|
|
283
|
+
uniform float u_diskBorderWidthPx;
|
|
284
|
+
uniform float u_gridWidthPx;
|
|
285
|
+
|
|
286
|
+
out vec4 outColor;
|
|
287
|
+
|
|
288
|
+
void main() {
|
|
289
|
+
// Convert framebuffer pixels to CSS pixels
|
|
290
|
+
vec2 fragCss = gl_FragCoord.xy / max(u_dpr, 1.0);
|
|
291
|
+
vec2 center = u_cssSize * 0.5;
|
|
292
|
+
|
|
293
|
+
float diskRadius = min(u_cssSize.x, u_cssSize.y) * 0.45 * u_displayZoom;
|
|
294
|
+
vec2 p = fragCss - center;
|
|
295
|
+
float dist = length(p);
|
|
296
|
+
|
|
297
|
+
// Reference-like styling
|
|
298
|
+
vec3 diskFill = u_diskFillColor.rgb;
|
|
299
|
+
vec3 diskBorder = u_diskBorderColor.rgb;
|
|
300
|
+
|
|
301
|
+
float borderWidth = max(u_diskBorderWidthPx, 0.0);
|
|
302
|
+
float halfW = 0.5 * borderWidth;
|
|
303
|
+
|
|
304
|
+
// Anti-aliasing width (CSS px). Keep at least 1px for crisp edges.
|
|
305
|
+
float aa = max(1.0, fwidth(dist));
|
|
306
|
+
|
|
307
|
+
// Discard outside disk+border region so the clear color remains intact.
|
|
308
|
+
if (dist > diskRadius + halfW + aa) discard;
|
|
309
|
+
|
|
310
|
+
// Outer fade for anti-aliased boundary
|
|
311
|
+
float outerAlpha = 1.0 - smoothstep(diskRadius + halfW - aa, diskRadius + halfW + aa, dist);
|
|
312
|
+
|
|
313
|
+
// Border mask
|
|
314
|
+
float borderInner = smoothstep(diskRadius - halfW - aa, diskRadius - halfW + aa, dist);
|
|
315
|
+
float borderOuter = 1.0 - smoothstep(diskRadius + halfW - aa, diskRadius + halfW + aa, dist);
|
|
316
|
+
float borderMask = clamp(borderInner * borderOuter, 0.0, 1.0);
|
|
317
|
+
|
|
318
|
+
vec3 col = mix(diskFill, diskBorder, borderMask);
|
|
319
|
+
|
|
320
|
+
// ------------------------------------------------------------------------
|
|
321
|
+
// Reference-like hyperbolic grid overlay
|
|
322
|
+
// ------------------------------------------------------------------------
|
|
323
|
+
// Matches HyperbolicReference.drawHyperbolicGrid():
|
|
324
|
+
// - 8 radial lines (geodesics through origin)
|
|
325
|
+
// - 5 concentric circles
|
|
326
|
+
vec3 gridCol = u_gridColor.rgb;
|
|
327
|
+
float gridWidth = max(u_gridWidthPx, 0.0);
|
|
328
|
+
float halfGrid = 0.5 * gridWidth;
|
|
329
|
+
|
|
330
|
+
// AA width for thin lines in CSS pixel space.
|
|
331
|
+
float aaLine = max(1.0, fwidth(dist));
|
|
332
|
+
|
|
333
|
+
float gridMask = 0.0;
|
|
334
|
+
|
|
335
|
+
// Concentric circles (5)
|
|
336
|
+
for (int i = 1; i <= 5; i++) {
|
|
337
|
+
float r = (float(i) / 6.0) * diskRadius;
|
|
338
|
+
float d = abs(dist - r);
|
|
339
|
+
float m = 1.0 - smoothstep(halfGrid - aaLine, halfGrid + aaLine, d);
|
|
340
|
+
gridMask = max(gridMask, m);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Radial lines (8), angle = (i/8)*pi
|
|
344
|
+
// Distance to line through origin with direction (cos a, sin a): |cross(p, dir)|
|
|
345
|
+
for (int i = 0; i < 8; i++) {
|
|
346
|
+
float a = (float(i) / 8.0) * 3.141592653589793;
|
|
347
|
+
vec2 dir = vec2(cos(a), sin(a));
|
|
348
|
+
float d = abs(p.x * dir.y - p.y * dir.x);
|
|
349
|
+
float m = 1.0 - smoothstep(halfGrid - aaLine, halfGrid + aaLine, d);
|
|
350
|
+
gridMask = max(gridMask, m);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Apply grid on top of disk fill/border. Use u_gridColor alpha as intensity.
|
|
354
|
+
col = mix(col, gridCol, clamp(gridMask, 0.0, 1.0) * clamp(u_gridColor.a, 0.0, 1.0));
|
|
355
|
+
outColor = vec4(col, outerAlpha);
|
|
356
|
+
}
|
|
357
|
+
`;
|
|
358
|
+
const VS_EUCLIDEAN = `#version 300 es
|
|
359
|
+
precision highp float;
|
|
360
|
+
precision highp int;
|
|
361
|
+
|
|
362
|
+
layout(location = 0) in vec2 a_pos;
|
|
363
|
+
layout(location = 1) in uint a_label;
|
|
364
|
+
|
|
365
|
+
uniform vec2 u_center;
|
|
366
|
+
uniform vec2 u_cssSize;
|
|
367
|
+
uniform float u_zoom;
|
|
368
|
+
uniform float u_dpr;
|
|
369
|
+
uniform float u_pointRadiusCss;
|
|
370
|
+
|
|
371
|
+
flat out uint v_label;
|
|
372
|
+
|
|
373
|
+
void main() {
|
|
374
|
+
float baseScale = min(u_cssSize.x, u_cssSize.y) * 0.4 * u_zoom;
|
|
375
|
+
float sx = u_cssSize.x * 0.5 + (a_pos.x - u_center.x) * baseScale;
|
|
376
|
+
float sy = u_cssSize.y * 0.5 - (a_pos.y - u_center.y) * baseScale;
|
|
377
|
+
|
|
378
|
+
vec2 dbufSize = u_cssSize * u_dpr;
|
|
379
|
+
vec2 dbuf = vec2(sx, sy) * u_dpr;
|
|
380
|
+
|
|
381
|
+
float cx = (dbuf.x / dbufSize.x) * 2.0 - 1.0;
|
|
382
|
+
float cy = 1.0 - (dbuf.y / dbufSize.y) * 2.0;
|
|
383
|
+
|
|
384
|
+
gl_Position = vec4(cx, cy, 0.0, 1.0);
|
|
385
|
+
gl_PointSize = (u_pointRadiusCss * 2.0) * u_dpr;
|
|
386
|
+
v_label = a_label;
|
|
387
|
+
}
|
|
388
|
+
`;
|
|
389
|
+
const VS_POINCARE = `#version 300 es
|
|
390
|
+
precision highp float;
|
|
391
|
+
precision highp int;
|
|
392
|
+
|
|
393
|
+
layout(location = 0) in vec2 a_pos;
|
|
394
|
+
layout(location = 1) in uint a_label;
|
|
395
|
+
|
|
396
|
+
uniform vec2 u_cssSize;
|
|
397
|
+
uniform float u_dpr;
|
|
398
|
+
uniform float u_pointRadiusCss;
|
|
399
|
+
|
|
400
|
+
uniform vec2 u_a; // camera translation (ax, ay)
|
|
401
|
+
uniform float u_displayZoom; // visual zoom
|
|
402
|
+
|
|
403
|
+
flat out uint v_label;
|
|
404
|
+
|
|
405
|
+
vec2 mobiusTransform(vec2 z, vec2 a) {
|
|
406
|
+
// (z - a) / (1 - conj(a) * z)
|
|
407
|
+
vec2 num = z - a;
|
|
408
|
+
|
|
409
|
+
// denom = 1 - (ax*zx + ay*zy) + i * (-(ax*zy - ay*zx))
|
|
410
|
+
float denomX = 1.0 - (a.x * z.x + a.y * z.y);
|
|
411
|
+
float denomY = -(a.x * z.y - a.y * z.x);
|
|
412
|
+
float denomNormSq = denomX * denomX + denomY * denomY;
|
|
413
|
+
if (denomNormSq < 1e-12) {
|
|
414
|
+
// Push outside clip
|
|
415
|
+
return vec2(2.0, 2.0);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// complex division
|
|
419
|
+
float rx = (num.x * denomX + num.y * denomY) / denomNormSq;
|
|
420
|
+
float ry = (num.y * denomX - num.x * denomY) / denomNormSq;
|
|
421
|
+
return vec2(rx, ry);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
void main() {
|
|
425
|
+
vec2 w = mobiusTransform(a_pos, u_a);
|
|
426
|
+
float r2 = dot(w, w);
|
|
427
|
+
if (r2 >= 1.0) {
|
|
428
|
+
gl_Position = vec4(2.0, 2.0, 0.0, 1.0);
|
|
429
|
+
gl_PointSize = 0.0;
|
|
430
|
+
v_label = a_label;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
float diskRadius = min(u_cssSize.x, u_cssSize.y) * 0.45 * u_displayZoom;
|
|
435
|
+
float sx = u_cssSize.x * 0.5 + w.x * diskRadius;
|
|
436
|
+
float sy = u_cssSize.y * 0.5 - w.y * diskRadius;
|
|
437
|
+
|
|
438
|
+
vec2 dbufSize = u_cssSize * u_dpr;
|
|
439
|
+
vec2 dbuf = vec2(sx, sy) * u_dpr;
|
|
440
|
+
|
|
441
|
+
float cx = (dbuf.x / dbufSize.x) * 2.0 - 1.0;
|
|
442
|
+
float cy = 1.0 - (dbuf.y / dbufSize.y) * 2.0;
|
|
443
|
+
|
|
444
|
+
gl_Position = vec4(cx, cy, 0.0, 1.0);
|
|
445
|
+
gl_PointSize = (u_pointRadiusCss * 2.0) * u_dpr;
|
|
446
|
+
v_label = a_label;
|
|
447
|
+
}
|
|
448
|
+
`;
|
|
449
|
+
class WebGLRendererBase {
|
|
450
|
+
canvas = null;
|
|
451
|
+
width = 0;
|
|
452
|
+
height = 0;
|
|
453
|
+
deviceDpr = 1;
|
|
454
|
+
// Canvas DPR (drawing buffer for the final composite). Keep at device DPR so
|
|
455
|
+
// thin background/grid lines match the reference.
|
|
456
|
+
canvasDpr = 1;
|
|
457
|
+
// Points DPR (adaptive). We render points into a low-res offscreen buffer and
|
|
458
|
+
// composite it onto the full-res canvas.
|
|
459
|
+
dpr = 1;
|
|
460
|
+
dataset = null;
|
|
461
|
+
selection = new Set();
|
|
462
|
+
hoveredIndex = -1;
|
|
463
|
+
pointRadiusCss = 3;
|
|
464
|
+
colors = DEFAULT_COLORS;
|
|
465
|
+
backgroundColor = '#0a0a0a';
|
|
466
|
+
// Hyperbolic backdrop styling (Poincaré disk). Neutral grayscale defaults.
|
|
467
|
+
// Override per app via InitOptions as needed.
|
|
468
|
+
poincareDiskFillColor = '#141414';
|
|
469
|
+
poincareDiskBorderColor = '#666666';
|
|
470
|
+
poincareGridColor = '#66666633';
|
|
471
|
+
poincareDiskBorderWidthPx = 2;
|
|
472
|
+
poincareGridWidthPx = 0.5;
|
|
473
|
+
// Palette (label -> RGBA). Implemented as a small 2D texture so we can
|
|
474
|
+
// support arbitrary label counts (not limited to 16 uniforms).
|
|
475
|
+
paletteSize = 0;
|
|
476
|
+
paletteDirty = true;
|
|
477
|
+
paletteTex = null;
|
|
478
|
+
paletteTexW = 0;
|
|
479
|
+
paletteTexH = 0;
|
|
480
|
+
paletteBytes = new Uint8Array(0);
|
|
481
|
+
paletteTexUnit = 1;
|
|
482
|
+
// Scratch arrays (avoid per-call allocations in hit testing / selection)
|
|
483
|
+
scratchIds = [];
|
|
484
|
+
// Scratch typed arrays for hover uploads (avoid per-frame allocations)
|
|
485
|
+
hoverPosScratch = new Float32Array(2);
|
|
486
|
+
hoverLabScratch = new Uint16Array(1);
|
|
487
|
+
hoverIndexScratch = new Uint32Array(1);
|
|
488
|
+
// Interaction-adaptive rendering (used to keep panning smooth at very large N)
|
|
489
|
+
lastViewChangeTs = 0;
|
|
490
|
+
markViewChanged() {
|
|
491
|
+
// performance.now() is available in browsers; in non-DOM contexts this class
|
|
492
|
+
// isn't used.
|
|
493
|
+
this.lastViewChangeTs = performance.now();
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Optional UI hook: call when the user ends an interaction (mouse up / gesture end).
|
|
497
|
+
*
|
|
498
|
+
* The renderer uses `lastViewChangeTs` to decide whether to enable interaction
|
|
499
|
+
* LOD (subsampling) for smooth panning/zooming. In the demo app we only render
|
|
500
|
+
* on demand; if the final frame after mouseup is still considered "interacting",
|
|
501
|
+
* we can end up showing a subsample until the next hover-triggered render,
|
|
502
|
+
* which looks like a visual snap/pop.
|
|
503
|
+
*
|
|
504
|
+
* By resetting the interaction timer, the next render will use the stable
|
|
505
|
+
* (non-interaction) policy immediately.
|
|
506
|
+
*/
|
|
507
|
+
endInteraction() {
|
|
508
|
+
this.lastViewChangeTs = 0;
|
|
509
|
+
}
|
|
510
|
+
markBackdropDirty() {
|
|
511
|
+
this.backdropDirty = true;
|
|
512
|
+
}
|
|
513
|
+
uploadPoincareDiskStyleUniforms() {
|
|
514
|
+
const gl = this.gl;
|
|
515
|
+
const disk = this.poincareDisk;
|
|
516
|
+
if (!gl || !disk)
|
|
517
|
+
return;
|
|
518
|
+
const fill = parseHexColor(this.poincareDiskFillColor);
|
|
519
|
+
const border = parseHexColor(this.poincareDiskBorderColor);
|
|
520
|
+
const grid = parseHexColor(this.poincareGridColor);
|
|
521
|
+
if (disk.uDiskFillColor)
|
|
522
|
+
gl.uniform4f(disk.uDiskFillColor, fill[0], fill[1], fill[2], fill[3]);
|
|
523
|
+
if (disk.uDiskBorderColor)
|
|
524
|
+
gl.uniform4f(disk.uDiskBorderColor, border[0], border[1], border[2], border[3]);
|
|
525
|
+
if (disk.uGridColor)
|
|
526
|
+
gl.uniform4f(disk.uGridColor, grid[0], grid[1], grid[2], grid[3]);
|
|
527
|
+
if (disk.uDiskBorderWidthPx)
|
|
528
|
+
gl.uniform1f(disk.uDiskBorderWidthPx, this.poincareDiskBorderWidthPx);
|
|
529
|
+
if (disk.uGridWidthPx)
|
|
530
|
+
gl.uniform1f(disk.uGridWidthPx, this.poincareGridWidthPx);
|
|
531
|
+
}
|
|
532
|
+
// Overridden by the hyperbolic renderer.
|
|
533
|
+
getBackdropZoom() {
|
|
534
|
+
return 1;
|
|
535
|
+
}
|
|
536
|
+
// CPU spatial index (data space)
|
|
537
|
+
dataIndex = null;
|
|
538
|
+
// WebGL state (created lazily in render())
|
|
539
|
+
gl = null;
|
|
540
|
+
vao = null;
|
|
541
|
+
posBuffer = null;
|
|
542
|
+
labelBuffer = null;
|
|
543
|
+
// Overlay buffers (used when main GPU buffers are a subsample).
|
|
544
|
+
hoverVao = null;
|
|
545
|
+
hoverPosBuffer = null;
|
|
546
|
+
hoverLabelBuffer = null;
|
|
547
|
+
selectionVao = null;
|
|
548
|
+
selectionPosBuffer = null;
|
|
549
|
+
selectionLabelBuffer = null;
|
|
550
|
+
selectionOverlayCount = 0;
|
|
551
|
+
selectionEbo = null;
|
|
552
|
+
hoverEbo = null;
|
|
553
|
+
interactionEbo = null;
|
|
554
|
+
interactionCount = 0;
|
|
555
|
+
// Practical cap for vertex work in the base pass at very large N.
|
|
556
|
+
// For 20M datasets, drawing all points every frame is often vertex-bound;
|
|
557
|
+
// we instead draw a deterministic subsample and keep interaction semantics
|
|
558
|
+
// exact via CPU hit-testing + exact lasso.
|
|
559
|
+
//
|
|
560
|
+
// This does *not* affect the accuracy harness, which compares math + hit/lasso
|
|
561
|
+
// results rather than pixel output.
|
|
562
|
+
maxBaseDrawPoints = 4_000_000;
|
|
563
|
+
// Above this, upload only a deterministic subsample to GPU to avoid huge
|
|
564
|
+
// GPU allocations / upload stalls at 10M-20M points.
|
|
565
|
+
maxGpuUploadPoints = 10_000_000;
|
|
566
|
+
gpuUsesFullDataset = true;
|
|
567
|
+
gpuPointCount = 0;
|
|
568
|
+
// -------------------------------------------------------------------------
|
|
569
|
+
// Policy knobs (tuned via benchmarks; intended to generalize across hardware)
|
|
570
|
+
// -------------------------------------------------------------------------
|
|
571
|
+
policy = {
|
|
572
|
+
// Rough target for 60 FPS. This is a proxy budget (fragment invocations).
|
|
573
|
+
// If you change point radius defaults, re-evaluate this budget.
|
|
574
|
+
fragmentBudget: 100_000_000,
|
|
575
|
+
// Circles (AA + discard) are noticeably more expensive per-fragment than
|
|
576
|
+
// squares. Use a separate (lower) threshold for when we allow circles.
|
|
577
|
+
// Above this, prefer squares even if we're still under fragmentBudget.
|
|
578
|
+
circleBudget: 60_000_000,
|
|
579
|
+
// Hysteresis for circle<->square switching.
|
|
580
|
+
// Switch ON squares when estimated fragment load is high;
|
|
581
|
+
// switch back OFF when comfortably below the threshold.
|
|
582
|
+
squareOnRatio: 1.0,
|
|
583
|
+
squareOffRatio: 0.75,
|
|
584
|
+
// Minimum acceptable offscreen DPR for points (quality floor).
|
|
585
|
+
// Keeping this too high can cause perf cliffs at huge N; too low can make
|
|
586
|
+
// points overly blurry.
|
|
587
|
+
minPointsDpr: 0.35,
|
|
588
|
+
};
|
|
589
|
+
// Sticky render mode (to avoid per-frame flip-flops)
|
|
590
|
+
renderAsSquares = false;
|
|
591
|
+
// Exposed for benchmarks (read via reflection)
|
|
592
|
+
__debugPolicy = null;
|
|
593
|
+
// Cached hyperbolic backdrop (disk + grid) rendered to an offscreen texture.
|
|
594
|
+
// Rendering the backdrop shader every frame is expensive; we render it only
|
|
595
|
+
// when size/DPR or displayZoom changes, then blit the cached image.
|
|
596
|
+
backdropTex = null;
|
|
597
|
+
backdropFbo = null;
|
|
598
|
+
backdropW = 0;
|
|
599
|
+
backdropH = 0;
|
|
600
|
+
backdropDpr = 1;
|
|
601
|
+
backdropZoom = NaN;
|
|
602
|
+
backdropDirty = true;
|
|
603
|
+
// Low-res points render target (adaptive DPR)
|
|
604
|
+
pointsTex = null;
|
|
605
|
+
pointsFbo = null;
|
|
606
|
+
pointsW = 0;
|
|
607
|
+
pointsH = 0;
|
|
608
|
+
// Program for compositing the points texture to the main framebuffer
|
|
609
|
+
programComposite = null;
|
|
610
|
+
uCompositeTex = null;
|
|
611
|
+
poincareDisk = null;
|
|
612
|
+
pointsCircle = null;
|
|
613
|
+
pointsSquare = null;
|
|
614
|
+
programSolid = null;
|
|
615
|
+
// Uniform locations (solid)
|
|
616
|
+
uSolidColor = null;
|
|
617
|
+
uSolidPointSizePx = null;
|
|
618
|
+
uSolidRingThicknessPx = null;
|
|
619
|
+
uSolidRingMode = null;
|
|
620
|
+
uCssSizeSolid = null;
|
|
621
|
+
uDprSolid = null;
|
|
622
|
+
uPointRadiusSolid = null;
|
|
623
|
+
selectionDirty = true;
|
|
624
|
+
hoverDirty = true;
|
|
625
|
+
init(canvas, opts) {
|
|
626
|
+
this.canvas = canvas;
|
|
627
|
+
this.width = opts.width;
|
|
628
|
+
this.height = opts.height;
|
|
629
|
+
this.deviceDpr = opts.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
630
|
+
this.canvasDpr = this.deviceDpr;
|
|
631
|
+
this.dpr = this.deviceDpr;
|
|
632
|
+
const hasDiskFillOverride = typeof opts.poincareDiskFillColor === 'string';
|
|
633
|
+
if (opts.backgroundColor)
|
|
634
|
+
this.backgroundColor = opts.backgroundColor;
|
|
635
|
+
if (opts.pointRadius)
|
|
636
|
+
this.pointRadiusCss = opts.pointRadius;
|
|
637
|
+
if (opts.colors)
|
|
638
|
+
this.colors = opts.colors;
|
|
639
|
+
// Optional per-app styling for hyperbolic disk/grid.
|
|
640
|
+
// If the app did not specify a disk fill, keep the neutral default.
|
|
641
|
+
this.poincareDiskFillColor = hasDiskFillOverride
|
|
642
|
+
? opts.poincareDiskFillColor
|
|
643
|
+
: this.poincareDiskFillColor;
|
|
644
|
+
if (opts.poincareDiskBorderColor)
|
|
645
|
+
this.poincareDiskBorderColor = opts.poincareDiskBorderColor;
|
|
646
|
+
if (opts.poincareGridColor)
|
|
647
|
+
this.poincareGridColor = opts.poincareGridColor;
|
|
648
|
+
if (typeof opts.poincareDiskBorderWidthPx === 'number' && Number.isFinite(opts.poincareDiskBorderWidthPx)) {
|
|
649
|
+
this.poincareDiskBorderWidthPx = Math.max(0, opts.poincareDiskBorderWidthPx);
|
|
650
|
+
}
|
|
651
|
+
if (typeof opts.poincareGridWidthPx === 'number' && Number.isFinite(opts.poincareGridWidthPx)) {
|
|
652
|
+
this.poincareGridWidthPx = Math.max(0, opts.poincareGridWidthPx);
|
|
653
|
+
}
|
|
654
|
+
this.paletteDirty = true;
|
|
655
|
+
// IMPORTANT:
|
|
656
|
+
// Do NOT touch `canvas.width/height` here.
|
|
657
|
+
// The accuracy harness initializes reference and candidate on the same
|
|
658
|
+
// canvas; resizing would reset the reference's 2D context.
|
|
659
|
+
// We size the canvas only when we actually acquire a WebGL context.
|
|
660
|
+
}
|
|
661
|
+
chooseRenderDpr(pointCount) {
|
|
662
|
+
const d = this.deviceDpr;
|
|
663
|
+
const cssPixels = Math.max(1, this.width) * Math.max(1, this.height);
|
|
664
|
+
// Expected draw count for rendering. For very large datasets we expect to
|
|
665
|
+
// draw a deterministic subsample (LOD) rather than all points.
|
|
666
|
+
const expectedDrawCount = pointCount > this.maxBaseDrawPoints
|
|
667
|
+
? this.estimateSubsampleCount(pointCount)
|
|
668
|
+
: pointCount;
|
|
669
|
+
// Budget 1: points-FBO pixel budget (memory / bandwidth proxy).
|
|
670
|
+
// These numbers were originally tuned empirically; we keep them but treat
|
|
671
|
+
// them explicitly as a *budget* rather than a point-count threshold.
|
|
672
|
+
const pointsFboPixelBudget = pointCount >= 1_000_000
|
|
673
|
+
? (cssPixels > 1_000_000 ? 200_000 : 500_000)
|
|
674
|
+
: pointCount >= 500_000
|
|
675
|
+
? 1_400_000
|
|
676
|
+
: pointCount >= 250_000
|
|
677
|
+
? 2_100_000
|
|
678
|
+
: 8_000_000; // cap allocations for very large canvases even at small N
|
|
679
|
+
const dprFromPointsPixels = Math.sqrt(pointsFboPixelBudget / cssPixels);
|
|
680
|
+
// Budget 2: fragment budget (fill-rate proxy).
|
|
681
|
+
// estFragments ≈ N * π * r^2 * dpr^2 => dpr <= sqrt(budget / (N * π * r^2))
|
|
682
|
+
const r = Math.max(0.5, this.pointRadiusCss);
|
|
683
|
+
const denom = Math.max(1, expectedDrawCount) * Math.PI * r * r;
|
|
684
|
+
const dprFromFragments = Math.sqrt(this.policy.fragmentBudget / denom);
|
|
685
|
+
// Cap for stability (avoid huge offscreen buffers) and quality.
|
|
686
|
+
const cap = pointCount >= 1_000_000 ? 1.0 : pointCount >= 500_000 ? 1.25 : 1.5;
|
|
687
|
+
const floor = pointCount >= 1_000_000 ? this.policy.minPointsDpr : pointCount >= 500_000 ? 0.75 : 1.0;
|
|
688
|
+
const chosen = Math.min(d, cap, dprFromPointsPixels, dprFromFragments);
|
|
689
|
+
return Math.max(floor, chosen);
|
|
690
|
+
}
|
|
691
|
+
estimateSubsampleCount(n) {
|
|
692
|
+
// Mirrors uploadDatasetToGPU() subsample logic, but used for budgeting.
|
|
693
|
+
if (n < 500_000)
|
|
694
|
+
return n;
|
|
695
|
+
const target = Math.min(n, Math.max(250_000, Math.min(this.maxBaseDrawPoints, Math.floor(n * 0.25))));
|
|
696
|
+
const step = Math.max(1, Math.floor(n / target));
|
|
697
|
+
const count = Math.min(target, Math.ceil(n / step));
|
|
698
|
+
return count;
|
|
699
|
+
}
|
|
700
|
+
estimatePointFragments(drawCount, pointsDpr) {
|
|
701
|
+
// Proxy for total fragment shader invocations spent on point sprites.
|
|
702
|
+
// This is intentionally simple and stable.
|
|
703
|
+
const r = Math.max(0.5, this.pointRadiusCss);
|
|
704
|
+
const n = Math.max(0, drawCount);
|
|
705
|
+
const dpr = Math.max(0, pointsDpr);
|
|
706
|
+
return n * Math.PI * r * r * dpr * dpr;
|
|
707
|
+
}
|
|
708
|
+
updateSquarePointPolicy(estimatedFragments) {
|
|
709
|
+
// Switch based on the circle budget, not the overall fragment budget.
|
|
710
|
+
const on = this.policy.circleBudget * this.policy.squareOnRatio;
|
|
711
|
+
const off = this.policy.circleBudget * this.policy.squareOffRatio;
|
|
712
|
+
// If we're rendering points at a reduced offscreen DPR, the AA circle shader
|
|
713
|
+
// tends to be a poor trade (extra ALU + discard) while visual fidelity is
|
|
714
|
+
// already limited by resolution. Prefer squares in that regime.
|
|
715
|
+
const forceSquaresForLowDpr = this.dpr <= 0.75;
|
|
716
|
+
if (forceSquaresForLowDpr) {
|
|
717
|
+
this.renderAsSquares = true;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (!this.renderAsSquares) {
|
|
721
|
+
if (estimatedFragments >= on)
|
|
722
|
+
this.renderAsSquares = true;
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
if (estimatedFragments <= off)
|
|
726
|
+
this.renderAsSquares = false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
setDataset(dataset) {
|
|
730
|
+
this.dataset = dataset;
|
|
731
|
+
// Reset selection without mutating any external object passed via setSelection().
|
|
732
|
+
this.selection = new Set();
|
|
733
|
+
this.hoveredIndex = -1;
|
|
734
|
+
this.selectionDirty = true;
|
|
735
|
+
this.hoverDirty = true;
|
|
736
|
+
// Potentially clamp points-FBO DPR for large datasets (performance priority).
|
|
737
|
+
const nextDpr = this.chooseRenderDpr(dataset.n);
|
|
738
|
+
if (nextDpr !== this.dpr) {
|
|
739
|
+
this.dpr = nextDpr;
|
|
740
|
+
}
|
|
741
|
+
// Bounds are computed internally by UniformGridIndex if omitted.
|
|
742
|
+
this.dataIndex = new UniformGridIndex(dataset.positions, undefined, 64);
|
|
743
|
+
// If WebGL is already active (performance benchmarks), upload immediately.
|
|
744
|
+
if (this.gl) {
|
|
745
|
+
this.uploadDatasetToGPU();
|
|
746
|
+
}
|
|
747
|
+
// Dataset changes don't affect the backdrop, but point DPR might have changed.
|
|
748
|
+
this.markBackdropDirty();
|
|
749
|
+
}
|
|
750
|
+
resize(width, height) {
|
|
751
|
+
this.width = width;
|
|
752
|
+
this.height = height;
|
|
753
|
+
// Only resize the drawing buffer when we own a WebGL context.
|
|
754
|
+
if (this.gl && this.canvas) {
|
|
755
|
+
setCanvasSize(this.canvas, width, height, this.canvasDpr);
|
|
756
|
+
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
757
|
+
this.markBackdropDirty();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
setSelection(indices) {
|
|
761
|
+
// IMPORTANT: Do not eagerly clone huge selections into a JS Set.
|
|
762
|
+
// For large-N lasso this can OOM. We keep the provided Set-like object.
|
|
763
|
+
// For small sets, clone to keep reference semantics similar to reference impls.
|
|
764
|
+
const n = indices.size;
|
|
765
|
+
this.selection = n <= 200_000 ? new Set(indices) : indices;
|
|
766
|
+
this.selectionDirty = true;
|
|
767
|
+
if (this.gl) {
|
|
768
|
+
this.uploadSelectionToGPU();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
getSelection() {
|
|
772
|
+
// Returning a cloned Set is nice for encapsulation, but can OOM for huge
|
|
773
|
+
// selections. For large selections, return the internal Set-like object.
|
|
774
|
+
return this.selection.size <= 200_000 ? new Set(this.selection) : this.selection;
|
|
775
|
+
}
|
|
776
|
+
setHovered(index) {
|
|
777
|
+
this.hoveredIndex = index;
|
|
778
|
+
this.hoverDirty = true;
|
|
779
|
+
if (this.gl) {
|
|
780
|
+
this.uploadHoverToGPU();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
destroy() {
|
|
784
|
+
const gl = this.gl;
|
|
785
|
+
if (gl) {
|
|
786
|
+
if (this.pointsCircle)
|
|
787
|
+
gl.deleteProgram(this.pointsCircle.program);
|
|
788
|
+
if (this.pointsSquare)
|
|
789
|
+
gl.deleteProgram(this.pointsSquare.program);
|
|
790
|
+
if (this.programSolid)
|
|
791
|
+
gl.deleteProgram(this.programSolid);
|
|
792
|
+
if (this.poincareDisk)
|
|
793
|
+
gl.deleteProgram(this.poincareDisk.program);
|
|
794
|
+
if (this.vao)
|
|
795
|
+
gl.deleteVertexArray(this.vao);
|
|
796
|
+
if (this.hoverVao)
|
|
797
|
+
gl.deleteVertexArray(this.hoverVao);
|
|
798
|
+
if (this.selectionVao)
|
|
799
|
+
gl.deleteVertexArray(this.selectionVao);
|
|
800
|
+
if (this.posBuffer)
|
|
801
|
+
gl.deleteBuffer(this.posBuffer);
|
|
802
|
+
if (this.labelBuffer)
|
|
803
|
+
gl.deleteBuffer(this.labelBuffer);
|
|
804
|
+
if (this.hoverPosBuffer)
|
|
805
|
+
gl.deleteBuffer(this.hoverPosBuffer);
|
|
806
|
+
if (this.hoverLabelBuffer)
|
|
807
|
+
gl.deleteBuffer(this.hoverLabelBuffer);
|
|
808
|
+
if (this.selectionPosBuffer)
|
|
809
|
+
gl.deleteBuffer(this.selectionPosBuffer);
|
|
810
|
+
if (this.selectionLabelBuffer)
|
|
811
|
+
gl.deleteBuffer(this.selectionLabelBuffer);
|
|
812
|
+
if (this.selectionEbo)
|
|
813
|
+
gl.deleteBuffer(this.selectionEbo);
|
|
814
|
+
if (this.hoverEbo)
|
|
815
|
+
gl.deleteBuffer(this.hoverEbo);
|
|
816
|
+
if (this.interactionEbo)
|
|
817
|
+
gl.deleteBuffer(this.interactionEbo);
|
|
818
|
+
if (this.backdropFbo)
|
|
819
|
+
gl.deleteFramebuffer(this.backdropFbo);
|
|
820
|
+
if (this.backdropTex)
|
|
821
|
+
gl.deleteTexture(this.backdropTex);
|
|
822
|
+
if (this.pointsFbo)
|
|
823
|
+
gl.deleteFramebuffer(this.pointsFbo);
|
|
824
|
+
if (this.pointsTex)
|
|
825
|
+
gl.deleteTexture(this.pointsTex);
|
|
826
|
+
if (this.paletteTex)
|
|
827
|
+
gl.deleteTexture(this.paletteTex);
|
|
828
|
+
if (this.programComposite)
|
|
829
|
+
gl.deleteProgram(this.programComposite);
|
|
830
|
+
}
|
|
831
|
+
this.gl = null;
|
|
832
|
+
this.vao = null;
|
|
833
|
+
this.hoverVao = null;
|
|
834
|
+
this.selectionVao = null;
|
|
835
|
+
this.posBuffer = null;
|
|
836
|
+
this.labelBuffer = null;
|
|
837
|
+
this.hoverPosBuffer = null;
|
|
838
|
+
this.hoverLabelBuffer = null;
|
|
839
|
+
this.selectionPosBuffer = null;
|
|
840
|
+
this.selectionLabelBuffer = null;
|
|
841
|
+
this.selectionOverlayCount = 0;
|
|
842
|
+
this.selectionEbo = null;
|
|
843
|
+
this.hoverEbo = null;
|
|
844
|
+
this.interactionEbo = null;
|
|
845
|
+
this.interactionCount = 0;
|
|
846
|
+
this.gpuUsesFullDataset = true;
|
|
847
|
+
this.gpuPointCount = 0;
|
|
848
|
+
this.backdropFbo = null;
|
|
849
|
+
this.backdropTex = null;
|
|
850
|
+
this.backdropW = 0;
|
|
851
|
+
this.backdropH = 0;
|
|
852
|
+
this.backdropDpr = 1;
|
|
853
|
+
this.backdropZoom = NaN;
|
|
854
|
+
this.backdropDirty = true;
|
|
855
|
+
this.pointsFbo = null;
|
|
856
|
+
this.pointsTex = null;
|
|
857
|
+
this.pointsW = 0;
|
|
858
|
+
this.pointsH = 0;
|
|
859
|
+
this.programComposite = null;
|
|
860
|
+
this.uCompositeTex = null;
|
|
861
|
+
this.pointsCircle = null;
|
|
862
|
+
this.pointsSquare = null;
|
|
863
|
+
this.programSolid = null;
|
|
864
|
+
this.poincareDisk = null;
|
|
865
|
+
this.paletteTex = null;
|
|
866
|
+
this.paletteTexW = 0;
|
|
867
|
+
this.paletteTexH = 0;
|
|
868
|
+
this.paletteSize = 0;
|
|
869
|
+
this.paletteDirty = true;
|
|
870
|
+
}
|
|
871
|
+
uploadPaletteUniforms() {
|
|
872
|
+
const gl = this.gl;
|
|
873
|
+
if (!gl)
|
|
874
|
+
return;
|
|
875
|
+
const rawSize = this.colors.length;
|
|
876
|
+
// Labels are Uint16 in the dataset, so the max addressable palette size is 65536.
|
|
877
|
+
const size = Math.max(1, Math.min(rawSize, 0xffff + 1));
|
|
878
|
+
const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
879
|
+
const texW = Math.min(maxTex, size);
|
|
880
|
+
const texH = Math.ceil(size / texW);
|
|
881
|
+
if (texH > maxTex) {
|
|
882
|
+
throw new Error(`Palette too large for WebGL texture: size=${size}, maxTex=${maxTex}`);
|
|
883
|
+
}
|
|
884
|
+
// Allocate upload buffer (RGBA8)
|
|
885
|
+
const capacity = texW * texH;
|
|
886
|
+
if (this.paletteBytes.length !== capacity * 4) {
|
|
887
|
+
this.paletteBytes = new Uint8Array(capacity * 4);
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
this.paletteBytes.fill(0);
|
|
891
|
+
}
|
|
892
|
+
if (rawSize === 0) {
|
|
893
|
+
// Fallback: opaque white
|
|
894
|
+
this.paletteBytes[0] = 255;
|
|
895
|
+
this.paletteBytes[1] = 255;
|
|
896
|
+
this.paletteBytes[2] = 255;
|
|
897
|
+
this.paletteBytes[3] = 255;
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
for (let i = 0; i < size; i++) {
|
|
901
|
+
const [r, g, b, a] = parseHexColorBytes(this.colors[i]);
|
|
902
|
+
const o = i * 4;
|
|
903
|
+
this.paletteBytes[o + 0] = r;
|
|
904
|
+
this.paletteBytes[o + 1] = g;
|
|
905
|
+
this.paletteBytes[o + 2] = b;
|
|
906
|
+
this.paletteBytes[o + 3] = a;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (!this.paletteTex) {
|
|
910
|
+
this.paletteTex = gl.createTexture();
|
|
911
|
+
if (!this.paletteTex)
|
|
912
|
+
throw new Error('Failed to create palette texture');
|
|
913
|
+
gl.activeTexture(gl.TEXTURE0 + this.paletteTexUnit);
|
|
914
|
+
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
|
|
915
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
916
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
917
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
918
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
919
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
920
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
921
|
+
}
|
|
922
|
+
this.paletteSize = size;
|
|
923
|
+
this.paletteTexW = texW;
|
|
924
|
+
this.paletteTexH = texH;
|
|
925
|
+
// Upload texture
|
|
926
|
+
gl.activeTexture(gl.TEXTURE0 + this.paletteTexUnit);
|
|
927
|
+
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
|
|
928
|
+
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
929
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, texW, texH, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.paletteBytes);
|
|
930
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
931
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
932
|
+
// Upload uniforms to both palette programs; uniforms persist per-program.
|
|
933
|
+
const upload = (p) => {
|
|
934
|
+
if (!p)
|
|
935
|
+
return;
|
|
936
|
+
gl.useProgram(p.program);
|
|
937
|
+
if (p.uPaletteTex)
|
|
938
|
+
gl.uniform1i(p.uPaletteTex, this.paletteTexUnit);
|
|
939
|
+
if (p.uPaletteSize)
|
|
940
|
+
gl.uniform1i(p.uPaletteSize, this.paletteSize);
|
|
941
|
+
if (p.uPaletteWidth)
|
|
942
|
+
gl.uniform1i(p.uPaletteWidth, this.paletteTexW);
|
|
943
|
+
};
|
|
944
|
+
upload(this.pointsCircle);
|
|
945
|
+
upload(this.pointsSquare);
|
|
946
|
+
this.paletteDirty = false;
|
|
947
|
+
}
|
|
948
|
+
bindPaletteTexture() {
|
|
949
|
+
const gl = this.gl;
|
|
950
|
+
if (!gl || !this.paletteTex)
|
|
951
|
+
return;
|
|
952
|
+
gl.activeTexture(gl.TEXTURE0 + this.paletteTexUnit);
|
|
953
|
+
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
|
|
954
|
+
// Restore predictable state for resource alloc helpers.
|
|
955
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
956
|
+
}
|
|
957
|
+
async countSelection(result, opts = {}) {
|
|
958
|
+
const ds = this.dataset;
|
|
959
|
+
const idx = this.dataIndex;
|
|
960
|
+
if (!ds || !idx)
|
|
961
|
+
return 0;
|
|
962
|
+
if (result.indices)
|
|
963
|
+
return result.indices.size;
|
|
964
|
+
if (result.kind !== 'geometry' || !result.geometry)
|
|
965
|
+
return 0;
|
|
966
|
+
const polygon = result.geometry.coords;
|
|
967
|
+
const nPoly = polygon.length / 2;
|
|
968
|
+
if (nPoly < 3)
|
|
969
|
+
return 0;
|
|
970
|
+
// Bounds are usually attached by the candidate lassoSelect(). Compute as
|
|
971
|
+
// a fallback to keep this method robust.
|
|
972
|
+
let bounds = result.geometry.bounds;
|
|
973
|
+
if (!bounds) {
|
|
974
|
+
let xMin = Infinity;
|
|
975
|
+
let yMin = Infinity;
|
|
976
|
+
let xMax = -Infinity;
|
|
977
|
+
let yMax = -Infinity;
|
|
978
|
+
for (let i = 0; i < polygon.length; i += 2) {
|
|
979
|
+
const x = polygon[i];
|
|
980
|
+
const y = polygon[i + 1];
|
|
981
|
+
if (x < xMin)
|
|
982
|
+
xMin = x;
|
|
983
|
+
if (x > xMax)
|
|
984
|
+
xMax = x;
|
|
985
|
+
if (y < yMin)
|
|
986
|
+
yMin = y;
|
|
987
|
+
if (y > yMax)
|
|
988
|
+
yMax = y;
|
|
989
|
+
}
|
|
990
|
+
bounds = { xMin, yMin, xMax, yMax };
|
|
991
|
+
}
|
|
992
|
+
const shouldCancel = opts.shouldCancel;
|
|
993
|
+
const onProgress = opts.onProgress;
|
|
994
|
+
const yieldEveryMs = (typeof opts.yieldEveryMs === 'number' && Number.isFinite(opts.yieldEveryMs))
|
|
995
|
+
? Math.max(0, opts.yieldEveryMs)
|
|
996
|
+
: 8;
|
|
997
|
+
const eps = 1e-12;
|
|
998
|
+
const minX = bounds.xMin - eps;
|
|
999
|
+
const minY = bounds.yMin - eps;
|
|
1000
|
+
const maxX = bounds.xMax + eps;
|
|
1001
|
+
const maxY = bounds.yMax + eps;
|
|
1002
|
+
const clampInt = (v, lo, hi) => {
|
|
1003
|
+
if (v < lo)
|
|
1004
|
+
return lo;
|
|
1005
|
+
if (v > hi)
|
|
1006
|
+
return hi;
|
|
1007
|
+
return v | 0;
|
|
1008
|
+
};
|
|
1009
|
+
const cx0 = clampInt(Math.floor((minX - idx.bounds.minX) / idx.cellSizeX), 0, idx.cellsX - 1);
|
|
1010
|
+
const cy0 = clampInt(Math.floor((minY - idx.bounds.minY) / idx.cellSizeY), 0, idx.cellsY - 1);
|
|
1011
|
+
const cx1 = clampInt(Math.floor((maxX - idx.bounds.minX) / idx.cellSizeX), 0, idx.cellsX - 1);
|
|
1012
|
+
const cy1 = clampInt(Math.floor((maxY - idx.bounds.minY) / idx.cellSizeY), 0, idx.cellsY - 1);
|
|
1013
|
+
const positions = ds.positions;
|
|
1014
|
+
const ids = idx.ids;
|
|
1015
|
+
const offsets = idx.offsets;
|
|
1016
|
+
let selected = 0;
|
|
1017
|
+
let processed = 0;
|
|
1018
|
+
const CHECK_STRIDE = 16_384;
|
|
1019
|
+
let nextCheck = CHECK_STRIDE;
|
|
1020
|
+
let lastYieldTs = yieldEveryMs > 0 ? performance.now() : 0;
|
|
1021
|
+
for (let cy = cy0; cy <= cy1; cy++) {
|
|
1022
|
+
const rowBase = cy * idx.cellsX;
|
|
1023
|
+
for (let cx = cx0; cx <= cx1; cx++) {
|
|
1024
|
+
const cell = rowBase + cx;
|
|
1025
|
+
const start = offsets[cell];
|
|
1026
|
+
const end = offsets[cell + 1];
|
|
1027
|
+
for (let k = start; k < end; k++) {
|
|
1028
|
+
const i = ids[k];
|
|
1029
|
+
const x = positions[i * 2];
|
|
1030
|
+
const y = positions[i * 2 + 1];
|
|
1031
|
+
// Tight AABB prefilter (cells overlap bounds).
|
|
1032
|
+
if (x < bounds.xMin || x > bounds.xMax || y < bounds.yMin || y > bounds.yMax)
|
|
1033
|
+
continue;
|
|
1034
|
+
if (pointInPolygon(x, y, polygon))
|
|
1035
|
+
selected++;
|
|
1036
|
+
processed++;
|
|
1037
|
+
if (yieldEveryMs > 0 && processed >= nextCheck) {
|
|
1038
|
+
nextCheck = processed + CHECK_STRIDE;
|
|
1039
|
+
if (shouldCancel?.())
|
|
1040
|
+
return selected;
|
|
1041
|
+
const now = performance.now();
|
|
1042
|
+
if (now - lastYieldTs >= yieldEveryMs) {
|
|
1043
|
+
onProgress?.(selected, processed);
|
|
1044
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
1045
|
+
lastYieldTs = performance.now();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
onProgress?.(selected, processed);
|
|
1052
|
+
return selected;
|
|
1053
|
+
}
|
|
1054
|
+
ensureGL() {
|
|
1055
|
+
if (this.gl)
|
|
1056
|
+
return;
|
|
1057
|
+
if (!this.canvas)
|
|
1058
|
+
throw new Error('Renderer not initialized');
|
|
1059
|
+
// Now that we actually intend to render with WebGL, we can safely size the
|
|
1060
|
+
// canvas drawing buffer.
|
|
1061
|
+
// Keep final canvas at device DPR for reference-like background quality.
|
|
1062
|
+
setCanvasSize(this.canvas, this.width, this.height, this.canvasDpr);
|
|
1063
|
+
const gl = this.canvas.getContext('webgl2', {
|
|
1064
|
+
// MSAA improves point sprite edge quality (less "squarish" at small sizes).
|
|
1065
|
+
// This is especially noticeable for Euclidean where points are drawn all over
|
|
1066
|
+
// the canvas and your eye picks up aliasing more easily.
|
|
1067
|
+
// But it can cost noticeable fill-rate at very large N, so keep it off
|
|
1068
|
+
// and rely on shader-based AA instead.
|
|
1069
|
+
antialias: false,
|
|
1070
|
+
// Keep opaque for performance; we render the hyperbolic backdrop in WebGL.
|
|
1071
|
+
alpha: false,
|
|
1072
|
+
depth: false,
|
|
1073
|
+
stencil: false,
|
|
1074
|
+
preserveDrawingBuffer: false,
|
|
1075
|
+
premultipliedAlpha: false,
|
|
1076
|
+
desynchronized: true,
|
|
1077
|
+
});
|
|
1078
|
+
if (!gl) {
|
|
1079
|
+
throw new Error('Failed to get WebGL2 context (is the canvas already using 2D context?)');
|
|
1080
|
+
}
|
|
1081
|
+
this.gl = gl;
|
|
1082
|
+
gl.disable(gl.DEPTH_TEST);
|
|
1083
|
+
gl.disable(gl.CULL_FACE);
|
|
1084
|
+
// Enable blending so anti-aliased point edges can blend with the background.
|
|
1085
|
+
gl.enable(gl.BLEND);
|
|
1086
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
1087
|
+
const [br, bg, bb, ba] = parseHexColor(this.backgroundColor);
|
|
1088
|
+
gl.clearColor(br, bg, bb, ba);
|
|
1089
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
1090
|
+
this.createProgramsAndBuffers();
|
|
1091
|
+
// Upload palette uniforms once after program creation.
|
|
1092
|
+
this.uploadPaletteUniforms();
|
|
1093
|
+
if (this.dataset) {
|
|
1094
|
+
this.uploadDatasetToGPU();
|
|
1095
|
+
}
|
|
1096
|
+
this.uploadSelectionToGPU();
|
|
1097
|
+
this.uploadHoverToGPU();
|
|
1098
|
+
// Backdrop depends on size/zoom.
|
|
1099
|
+
this.markBackdropDirty();
|
|
1100
|
+
}
|
|
1101
|
+
ensurePointsResources() {
|
|
1102
|
+
if (!this.gl || !this.canvas)
|
|
1103
|
+
return;
|
|
1104
|
+
const gl = this.gl;
|
|
1105
|
+
// Points buffer size at adaptive DPR.
|
|
1106
|
+
let w = Math.max(1, Math.floor(this.width * this.dpr));
|
|
1107
|
+
let h = Math.max(1, Math.floor(this.height * this.dpr));
|
|
1108
|
+
const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
1109
|
+
if (w > maxTex || h > maxTex) {
|
|
1110
|
+
const s = Math.min(1, maxTex / w, maxTex / h);
|
|
1111
|
+
w = Math.max(1, Math.floor(w * s));
|
|
1112
|
+
h = Math.max(1, Math.floor(h * s));
|
|
1113
|
+
}
|
|
1114
|
+
if (!this.pointsTex) {
|
|
1115
|
+
this.pointsTex = gl.createTexture();
|
|
1116
|
+
if (!this.pointsTex)
|
|
1117
|
+
throw new Error('Failed to create points texture');
|
|
1118
|
+
gl.bindTexture(gl.TEXTURE_2D, this.pointsTex);
|
|
1119
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
1120
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
1121
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
1122
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
1123
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1124
|
+
}
|
|
1125
|
+
if (!this.pointsFbo) {
|
|
1126
|
+
this.pointsFbo = gl.createFramebuffer();
|
|
1127
|
+
if (!this.pointsFbo)
|
|
1128
|
+
throw new Error('Failed to create points framebuffer');
|
|
1129
|
+
}
|
|
1130
|
+
if (w !== this.pointsW || h !== this.pointsH) {
|
|
1131
|
+
this.pointsW = w;
|
|
1132
|
+
this.pointsH = h;
|
|
1133
|
+
gl.bindTexture(gl.TEXTURE_2D, this.pointsTex);
|
|
1134
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
1135
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1136
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, this.pointsFbo);
|
|
1137
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.pointsTex, 0);
|
|
1138
|
+
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
|
1139
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1140
|
+
if (status !== gl.FRAMEBUFFER_COMPLETE) {
|
|
1141
|
+
throw new Error(`Points framebuffer incomplete: ${status}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
ensureBackdropResources() {
|
|
1146
|
+
if (!this.gl || !this.canvas)
|
|
1147
|
+
return;
|
|
1148
|
+
if (this.geometryKind() !== 'poincare')
|
|
1149
|
+
return;
|
|
1150
|
+
if (!this.poincareDisk || !this.vao)
|
|
1151
|
+
return;
|
|
1152
|
+
const gl = this.gl;
|
|
1153
|
+
// Render backdrop at full canvas DPR so thin grid lines survive.
|
|
1154
|
+
const desiredDpr = Math.max(1, this.canvasDpr);
|
|
1155
|
+
let w = Math.max(1, Math.floor(this.width * desiredDpr));
|
|
1156
|
+
let h = Math.max(1, Math.floor(this.height * desiredDpr));
|
|
1157
|
+
const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
1158
|
+
if (w > maxTex || h > maxTex) {
|
|
1159
|
+
const s = Math.min(1, maxTex / w, maxTex / h);
|
|
1160
|
+
w = Math.max(1, Math.floor(w * s));
|
|
1161
|
+
h = Math.max(1, Math.floor(h * s));
|
|
1162
|
+
this.backdropDpr = desiredDpr * s;
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
this.backdropDpr = desiredDpr;
|
|
1166
|
+
}
|
|
1167
|
+
if (!this.backdropTex) {
|
|
1168
|
+
this.backdropTex = gl.createTexture();
|
|
1169
|
+
if (!this.backdropTex)
|
|
1170
|
+
throw new Error('Failed to create backdrop texture');
|
|
1171
|
+
gl.bindTexture(gl.TEXTURE_2D, this.backdropTex);
|
|
1172
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
1173
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
1174
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
1175
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
1176
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1177
|
+
}
|
|
1178
|
+
if (!this.backdropFbo) {
|
|
1179
|
+
this.backdropFbo = gl.createFramebuffer();
|
|
1180
|
+
if (!this.backdropFbo)
|
|
1181
|
+
throw new Error('Failed to create backdrop framebuffer');
|
|
1182
|
+
}
|
|
1183
|
+
if (w !== this.backdropW || h !== this.backdropH) {
|
|
1184
|
+
this.backdropW = w;
|
|
1185
|
+
this.backdropH = h;
|
|
1186
|
+
gl.bindTexture(gl.TEXTURE_2D, this.backdropTex);
|
|
1187
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
1188
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1189
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, this.backdropFbo);
|
|
1190
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.backdropTex, 0);
|
|
1191
|
+
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
|
1192
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1193
|
+
if (status !== gl.FRAMEBUFFER_COMPLETE) {
|
|
1194
|
+
throw new Error(`Backdrop framebuffer incomplete: ${status}`);
|
|
1195
|
+
}
|
|
1196
|
+
this.markBackdropDirty();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
renderBackdropIfNeeded() {
|
|
1200
|
+
if (!this.gl || !this.canvas)
|
|
1201
|
+
return;
|
|
1202
|
+
if (this.geometryKind() !== 'poincare')
|
|
1203
|
+
return;
|
|
1204
|
+
if (!this.poincareDisk || !this.vao)
|
|
1205
|
+
return;
|
|
1206
|
+
this.ensureBackdropResources();
|
|
1207
|
+
if (!this.backdropFbo)
|
|
1208
|
+
return;
|
|
1209
|
+
const zoom = this.getBackdropZoom();
|
|
1210
|
+
const zoomSame = Number.isFinite(this.backdropZoom) && Math.abs(this.backdropZoom - zoom) <= 1e-12;
|
|
1211
|
+
if (!this.backdropDirty && zoomSame)
|
|
1212
|
+
return;
|
|
1213
|
+
const gl = this.gl;
|
|
1214
|
+
// Render disk+grid into texture.
|
|
1215
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, this.backdropFbo);
|
|
1216
|
+
gl.viewport(0, 0, this.backdropW, this.backdropH);
|
|
1217
|
+
const [br, bg, bb, ba] = parseHexColor(this.backgroundColor);
|
|
1218
|
+
gl.clearColor(br, bg, bb, ba);
|
|
1219
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1220
|
+
gl.useProgram(this.poincareDisk.program);
|
|
1221
|
+
this.bindViewUniformsForProgram(this.poincareDisk.program);
|
|
1222
|
+
this.uploadPoincareDiskStyleUniforms();
|
|
1223
|
+
if (this.poincareDisk.uCssSize)
|
|
1224
|
+
gl.uniform2f(this.poincareDisk.uCssSize, this.width, this.height);
|
|
1225
|
+
if (this.poincareDisk.uDpr)
|
|
1226
|
+
gl.uniform1f(this.poincareDisk.uDpr, this.backdropDpr);
|
|
1227
|
+
gl.bindVertexArray(this.vao);
|
|
1228
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
1229
|
+
// Restore default framebuffer + viewport.
|
|
1230
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1231
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
1232
|
+
this.backdropZoom = zoom;
|
|
1233
|
+
this.backdropDirty = false;
|
|
1234
|
+
}
|
|
1235
|
+
createProgramsAndBuffers() {
|
|
1236
|
+
const gl = this.gl;
|
|
1237
|
+
const vs = this.geometryKind() === 'euclidean' ? VS_EUCLIDEAN : VS_POINCARE;
|
|
1238
|
+
// Programs
|
|
1239
|
+
const circleProgram = linkProgram(gl, vs, FS_POINTS);
|
|
1240
|
+
const squareProgram = linkProgram(gl, vs, FS_POINTS_SQUARE);
|
|
1241
|
+
this.programSolid = linkProgram(gl, vs, FS_SOLID);
|
|
1242
|
+
// Composite program (fullscreen textured triangle)
|
|
1243
|
+
this.programComposite = linkProgram(gl, VS_FULLSCREEN, FS_COMPOSITE);
|
|
1244
|
+
gl.useProgram(this.programComposite);
|
|
1245
|
+
this.uCompositeTex = gl.getUniformLocation(this.programComposite, 'u_tex');
|
|
1246
|
+
// Poincaré disk background (only for hyperbolic)
|
|
1247
|
+
if (this.geometryKind() === 'poincare') {
|
|
1248
|
+
const diskProgram = linkProgram(gl, VS_FULLSCREEN, FS_POINCARE_DISK);
|
|
1249
|
+
this.poincareDisk = {
|
|
1250
|
+
program: diskProgram,
|
|
1251
|
+
uCssSize: gl.getUniformLocation(diskProgram, 'u_cssSize'),
|
|
1252
|
+
uDpr: gl.getUniformLocation(diskProgram, 'u_dpr'),
|
|
1253
|
+
uDiskFillColor: gl.getUniformLocation(diskProgram, 'u_diskFillColor'),
|
|
1254
|
+
uDiskBorderColor: gl.getUniformLocation(diskProgram, 'u_diskBorderColor'),
|
|
1255
|
+
uGridColor: gl.getUniformLocation(diskProgram, 'u_gridColor'),
|
|
1256
|
+
uDiskBorderWidthPx: gl.getUniformLocation(diskProgram, 'u_diskBorderWidthPx'),
|
|
1257
|
+
uGridWidthPx: gl.getUniformLocation(diskProgram, 'u_gridWidthPx'),
|
|
1258
|
+
};
|
|
1259
|
+
// Upload style uniforms once; they persist for this program.
|
|
1260
|
+
gl.useProgram(diskProgram);
|
|
1261
|
+
this.uploadPoincareDiskStyleUniforms();
|
|
1262
|
+
}
|
|
1263
|
+
// Points pipeline (circle)
|
|
1264
|
+
gl.useProgram(circleProgram);
|
|
1265
|
+
this.pointsCircle = {
|
|
1266
|
+
program: circleProgram,
|
|
1267
|
+
uPaletteTex: gl.getUniformLocation(circleProgram, 'u_paletteTex'),
|
|
1268
|
+
uPaletteSize: gl.getUniformLocation(circleProgram, 'u_paletteSize'),
|
|
1269
|
+
uPaletteWidth: gl.getUniformLocation(circleProgram, 'u_paletteWidth'),
|
|
1270
|
+
uCssSize: gl.getUniformLocation(circleProgram, 'u_cssSize'),
|
|
1271
|
+
uDpr: gl.getUniformLocation(circleProgram, 'u_dpr'),
|
|
1272
|
+
uPointRadius: gl.getUniformLocation(circleProgram, 'u_pointRadiusCss'),
|
|
1273
|
+
};
|
|
1274
|
+
// Points pipeline (square)
|
|
1275
|
+
gl.useProgram(squareProgram);
|
|
1276
|
+
this.pointsSquare = {
|
|
1277
|
+
program: squareProgram,
|
|
1278
|
+
uPaletteTex: gl.getUniformLocation(squareProgram, 'u_paletteTex'),
|
|
1279
|
+
uPaletteSize: gl.getUniformLocation(squareProgram, 'u_paletteSize'),
|
|
1280
|
+
uPaletteWidth: gl.getUniformLocation(squareProgram, 'u_paletteWidth'),
|
|
1281
|
+
uCssSize: gl.getUniformLocation(squareProgram, 'u_cssSize'),
|
|
1282
|
+
uDpr: gl.getUniformLocation(squareProgram, 'u_dpr'),
|
|
1283
|
+
uPointRadius: gl.getUniformLocation(squareProgram, 'u_pointRadiusCss'),
|
|
1284
|
+
};
|
|
1285
|
+
// Uniform locations (solid)
|
|
1286
|
+
gl.useProgram(this.programSolid);
|
|
1287
|
+
this.uSolidColor = gl.getUniformLocation(this.programSolid, 'u_color');
|
|
1288
|
+
this.uSolidPointSizePx = gl.getUniformLocation(this.programSolid, 'u_pointSizePx');
|
|
1289
|
+
this.uSolidRingThicknessPx = gl.getUniformLocation(this.programSolid, 'u_ringThicknessPx');
|
|
1290
|
+
this.uSolidRingMode = gl.getUniformLocation(this.programSolid, 'u_ringMode');
|
|
1291
|
+
this.uCssSizeSolid = gl.getUniformLocation(this.programSolid, 'u_cssSize');
|
|
1292
|
+
this.uDprSolid = gl.getUniformLocation(this.programSolid, 'u_dpr');
|
|
1293
|
+
this.uPointRadiusSolid = gl.getUniformLocation(this.programSolid, 'u_pointRadiusCss');
|
|
1294
|
+
// Buffers + VAO
|
|
1295
|
+
this.vao = gl.createVertexArray();
|
|
1296
|
+
this.posBuffer = gl.createBuffer();
|
|
1297
|
+
this.labelBuffer = gl.createBuffer();
|
|
1298
|
+
// Overlay VAOs/buffers
|
|
1299
|
+
this.hoverVao = gl.createVertexArray();
|
|
1300
|
+
this.hoverPosBuffer = gl.createBuffer();
|
|
1301
|
+
this.hoverLabelBuffer = gl.createBuffer();
|
|
1302
|
+
this.selectionVao = gl.createVertexArray();
|
|
1303
|
+
this.selectionPosBuffer = gl.createBuffer();
|
|
1304
|
+
this.selectionLabelBuffer = gl.createBuffer();
|
|
1305
|
+
this.selectionEbo = gl.createBuffer();
|
|
1306
|
+
this.hoverEbo = gl.createBuffer();
|
|
1307
|
+
this.interactionEbo = gl.createBuffer();
|
|
1308
|
+
if (!this.vao || !this.posBuffer || !this.labelBuffer ||
|
|
1309
|
+
!this.hoverVao || !this.hoverPosBuffer || !this.hoverLabelBuffer ||
|
|
1310
|
+
!this.selectionVao || !this.selectionPosBuffer || !this.selectionLabelBuffer ||
|
|
1311
|
+
!this.selectionEbo || !this.hoverEbo || !this.interactionEbo) {
|
|
1312
|
+
throw new Error('Failed to allocate WebGL resources');
|
|
1313
|
+
}
|
|
1314
|
+
gl.bindVertexArray(this.vao);
|
|
1315
|
+
// Positions (vec2)
|
|
1316
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.posBuffer);
|
|
1317
|
+
gl.enableVertexAttribArray(0);
|
|
1318
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
1319
|
+
// Labels (uint)
|
|
1320
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.labelBuffer);
|
|
1321
|
+
gl.enableVertexAttribArray(1);
|
|
1322
|
+
gl.vertexAttribIPointer(1, 1, gl.UNSIGNED_SHORT, 0, 0);
|
|
1323
|
+
gl.bindVertexArray(null);
|
|
1324
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
1325
|
+
// Hover VAO (single point)
|
|
1326
|
+
gl.bindVertexArray(this.hoverVao);
|
|
1327
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.hoverPosBuffer);
|
|
1328
|
+
gl.enableVertexAttribArray(0);
|
|
1329
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
1330
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.hoverLabelBuffer);
|
|
1331
|
+
gl.enableVertexAttribArray(1);
|
|
1332
|
+
gl.vertexAttribIPointer(1, 1, gl.UNSIGNED_SHORT, 0, 0);
|
|
1333
|
+
gl.bindVertexArray(null);
|
|
1334
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
1335
|
+
// Selection VAO (N points)
|
|
1336
|
+
gl.bindVertexArray(this.selectionVao);
|
|
1337
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.selectionPosBuffer);
|
|
1338
|
+
gl.enableVertexAttribArray(0);
|
|
1339
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
1340
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.selectionLabelBuffer);
|
|
1341
|
+
gl.enableVertexAttribArray(1);
|
|
1342
|
+
gl.vertexAttribIPointer(1, 1, gl.UNSIGNED_SHORT, 0, 0);
|
|
1343
|
+
gl.bindVertexArray(null);
|
|
1344
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
1345
|
+
}
|
|
1346
|
+
uploadDatasetToGPU() {
|
|
1347
|
+
const gl = this.gl;
|
|
1348
|
+
const ds = this.dataset;
|
|
1349
|
+
if (!ds)
|
|
1350
|
+
return;
|
|
1351
|
+
gl.bindVertexArray(this.vao);
|
|
1352
|
+
// Decide whether to upload the full dataset or only a deterministic subsample.
|
|
1353
|
+
// NOTE: CPU-side interaction (hitTest/lasso) always uses the full dataset.
|
|
1354
|
+
const useFullUpload = ds.n <= this.maxGpuUploadPoints;
|
|
1355
|
+
this.gpuUsesFullDataset = useFullUpload;
|
|
1356
|
+
if (useFullUpload) {
|
|
1357
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.posBuffer);
|
|
1358
|
+
gl.bufferData(gl.ARRAY_BUFFER, ds.positions, gl.STATIC_DRAW);
|
|
1359
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.labelBuffer);
|
|
1360
|
+
gl.bufferData(gl.ARRAY_BUFFER, ds.labels, gl.STATIC_DRAW);
|
|
1361
|
+
this.gpuPointCount = ds.n;
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
const n = ds.n;
|
|
1365
|
+
const target = Math.min(n, Math.max(250_000, Math.min(this.maxBaseDrawPoints, Math.floor(n * 0.25))));
|
|
1366
|
+
const step = Math.max(1, Math.floor(n / target));
|
|
1367
|
+
const count = Math.min(target, Math.ceil(n / step));
|
|
1368
|
+
const subPos = new Float32Array(count * 2);
|
|
1369
|
+
const subLab = new Uint16Array(count);
|
|
1370
|
+
let k = 0;
|
|
1371
|
+
for (let i = 0; i < n && k < count; i += step) {
|
|
1372
|
+
subPos[k * 2] = ds.positions[i * 2];
|
|
1373
|
+
subPos[k * 2 + 1] = ds.positions[i * 2 + 1];
|
|
1374
|
+
subLab[k] = ds.labels[i];
|
|
1375
|
+
k++;
|
|
1376
|
+
}
|
|
1377
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.posBuffer);
|
|
1378
|
+
gl.bufferData(gl.ARRAY_BUFFER, subPos, gl.STATIC_DRAW);
|
|
1379
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.labelBuffer);
|
|
1380
|
+
gl.bufferData(gl.ARRAY_BUFFER, subLab, gl.STATIC_DRAW);
|
|
1381
|
+
this.gpuPointCount = k;
|
|
1382
|
+
}
|
|
1383
|
+
// Precompute a deterministic, globally distributed subsample for LOD.
|
|
1384
|
+
// This is used during interaction (to keep panning smooth), and also as an
|
|
1385
|
+
// always-on cap for very large N where drawing every point every frame is
|
|
1386
|
+
// not realistic.
|
|
1387
|
+
this.interactionCount = 0;
|
|
1388
|
+
if (this.interactionEbo && this.gpuUsesFullDataset) {
|
|
1389
|
+
const n = ds.n;
|
|
1390
|
+
if (n >= 500_000) {
|
|
1391
|
+
// Keep enough points for a faithful density impression, but avoid
|
|
1392
|
+
// unbounded vertex cost.
|
|
1393
|
+
const target = Math.min(n, Math.max(250_000, Math.min(this.maxBaseDrawPoints, Math.floor(n * 0.25))));
|
|
1394
|
+
const step = Math.max(1, Math.floor(n / target));
|
|
1395
|
+
const count = Math.min(target, Math.ceil(n / step));
|
|
1396
|
+
const indices = new Uint32Array(count);
|
|
1397
|
+
let k = 0;
|
|
1398
|
+
for (let i = 0; i < n && k < count; i += step) {
|
|
1399
|
+
indices[k++] = i;
|
|
1400
|
+
}
|
|
1401
|
+
this.interactionCount = k;
|
|
1402
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.interactionEbo);
|
|
1403
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
|
|
1404
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
gl.bindVertexArray(null);
|
|
1408
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
1409
|
+
}
|
|
1410
|
+
uploadSelectionToGPU() {
|
|
1411
|
+
if (!this.gl || !this.selectionEbo)
|
|
1412
|
+
return;
|
|
1413
|
+
const gl = this.gl;
|
|
1414
|
+
// Rendering selection overlays for millions of points is both expensive and
|
|
1415
|
+
// memory-heavy (index buffers / position buffers). We keep the selection
|
|
1416
|
+
// semantics exact, but cap the *rendered* overlay for practicality.
|
|
1417
|
+
const MAX_RENDER_SELECTION = 250_000;
|
|
1418
|
+
if (!this.gpuUsesFullDataset) {
|
|
1419
|
+
const ds = this.dataset;
|
|
1420
|
+
if (!ds || !this.selectionVao || !this.selectionPosBuffer || !this.selectionLabelBuffer)
|
|
1421
|
+
return;
|
|
1422
|
+
const count = this.selection.size;
|
|
1423
|
+
this.selectionOverlayCount = Math.min(count, MAX_RENDER_SELECTION);
|
|
1424
|
+
if (count === 0) {
|
|
1425
|
+
this.selectionDirty = false;
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
// For huge selections, render only a deterministic prefix (iteration
|
|
1429
|
+
// order of Set is deterministic for a fixed construction).
|
|
1430
|
+
const renderCount = Math.min(count, MAX_RENDER_SELECTION);
|
|
1431
|
+
const pos = new Float32Array(renderCount * 2);
|
|
1432
|
+
const lab = new Uint16Array(renderCount);
|
|
1433
|
+
let k = 0;
|
|
1434
|
+
for (const i of this.selection) {
|
|
1435
|
+
pos[k * 2] = ds.positions[i * 2];
|
|
1436
|
+
pos[k * 2 + 1] = ds.positions[i * 2 + 1];
|
|
1437
|
+
lab[k] = ds.labels[i];
|
|
1438
|
+
k++;
|
|
1439
|
+
if (k >= renderCount)
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
this.selectionOverlayCount = k;
|
|
1443
|
+
gl.bindVertexArray(this.selectionVao);
|
|
1444
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.selectionPosBuffer);
|
|
1445
|
+
gl.bufferData(gl.ARRAY_BUFFER, pos, gl.DYNAMIC_DRAW);
|
|
1446
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.selectionLabelBuffer);
|
|
1447
|
+
gl.bufferData(gl.ARRAY_BUFFER, lab, gl.DYNAMIC_DRAW);
|
|
1448
|
+
gl.bindVertexArray(null);
|
|
1449
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
1450
|
+
this.selectionDirty = false;
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
// Pack selection indices to Uint32 element buffer.
|
|
1454
|
+
const count = this.selection.size;
|
|
1455
|
+
const renderCount = Math.min(count, MAX_RENDER_SELECTION);
|
|
1456
|
+
this.selectionOverlayCount = renderCount;
|
|
1457
|
+
if (renderCount === 0) {
|
|
1458
|
+
this.selectionDirty = false;
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
const indices = new Uint32Array(renderCount);
|
|
1462
|
+
let k = 0;
|
|
1463
|
+
for (const i of this.selection) {
|
|
1464
|
+
indices[k++] = i;
|
|
1465
|
+
if (k >= renderCount)
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
this.selectionOverlayCount = k;
|
|
1469
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionEbo);
|
|
1470
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
|
|
1471
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
1472
|
+
this.selectionDirty = false;
|
|
1473
|
+
}
|
|
1474
|
+
uploadHoverToGPU() {
|
|
1475
|
+
if (!this.gl || !this.hoverEbo)
|
|
1476
|
+
return;
|
|
1477
|
+
const gl = this.gl;
|
|
1478
|
+
if (!this.gpuUsesFullDataset) {
|
|
1479
|
+
const ds = this.dataset;
|
|
1480
|
+
if (!ds || !this.hoverVao || !this.hoverPosBuffer || !this.hoverLabelBuffer)
|
|
1481
|
+
return;
|
|
1482
|
+
const i = (this.hoveredIndex >= 0 && this.hoveredIndex < ds.n) ? this.hoveredIndex : -1;
|
|
1483
|
+
const pos = this.hoverPosScratch;
|
|
1484
|
+
const lab = this.hoverLabScratch;
|
|
1485
|
+
if (i >= 0) {
|
|
1486
|
+
pos[0] = ds.positions[i * 2];
|
|
1487
|
+
pos[1] = ds.positions[i * 2 + 1];
|
|
1488
|
+
lab[0] = ds.labels[i];
|
|
1489
|
+
}
|
|
1490
|
+
else {
|
|
1491
|
+
pos[0] = 2;
|
|
1492
|
+
pos[1] = 2;
|
|
1493
|
+
lab[0] = 0;
|
|
1494
|
+
}
|
|
1495
|
+
gl.bindVertexArray(this.hoverVao);
|
|
1496
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.hoverPosBuffer);
|
|
1497
|
+
gl.bufferData(gl.ARRAY_BUFFER, pos, gl.DYNAMIC_DRAW);
|
|
1498
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.hoverLabelBuffer);
|
|
1499
|
+
gl.bufferData(gl.ARRAY_BUFFER, lab, gl.DYNAMIC_DRAW);
|
|
1500
|
+
gl.bindVertexArray(null);
|
|
1501
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
1502
|
+
this.hoverDirty = false;
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
const idx = this.hoveredIndex >= 0 ? this.hoveredIndex : 0;
|
|
1506
|
+
const indices = this.hoverIndexScratch;
|
|
1507
|
+
indices[0] = idx;
|
|
1508
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.hoverEbo);
|
|
1509
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
|
|
1510
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
1511
|
+
this.hoverDirty = false;
|
|
1512
|
+
}
|
|
1513
|
+
render() {
|
|
1514
|
+
this.ensureGL();
|
|
1515
|
+
const gl = this.gl;
|
|
1516
|
+
const ds = this.dataset;
|
|
1517
|
+
if (!ds)
|
|
1518
|
+
return;
|
|
1519
|
+
// During active interaction (pan/zoom), hyperbolic rendering can become
|
|
1520
|
+
// vertex-transform bound at 1M points. Temporarily draw fewer points to
|
|
1521
|
+
// keep the interaction at (or near) 60 FPS, then restore full detail when
|
|
1522
|
+
// the view stabilizes.
|
|
1523
|
+
const now = performance.now();
|
|
1524
|
+
const isInteracting = (now - this.lastViewChangeTs) < 80;
|
|
1525
|
+
const hasLod = !!this.interactionEbo && this.interactionCount > 0;
|
|
1526
|
+
// 1) Interaction LOD (primarily hyperbolic; vertex math is heavier)
|
|
1527
|
+
// NOTE: For ~1M points the subsample->full switch can be perceptually jarring
|
|
1528
|
+
// (a density "pop") even if the view state is correct. We therefore only
|
|
1529
|
+
// enable interaction LOD above a higher threshold.
|
|
1530
|
+
const interactionLodMinPoints = 2_000_000;
|
|
1531
|
+
const useInteractionLod = isInteracting &&
|
|
1532
|
+
this.geometryKind() === 'poincare' &&
|
|
1533
|
+
ds.n >= interactionLodMinPoints &&
|
|
1534
|
+
hasLod;
|
|
1535
|
+
// 2) Always-on LOD cap for very large datasets (both geometries)
|
|
1536
|
+
// Apply regardless of interaction to avoid catastrophic pan/hover stalls
|
|
1537
|
+
// on huge datasets (especially Euclidean).
|
|
1538
|
+
const useLargeNLod = ds.n > this.maxBaseDrawPoints &&
|
|
1539
|
+
hasLod;
|
|
1540
|
+
// If the main GPU buffers already contain a subsample, do not additionally
|
|
1541
|
+
// apply EBO LOD.
|
|
1542
|
+
const useLod = this.gpuUsesFullDataset && (useInteractionLod || useLargeNLod);
|
|
1543
|
+
const baseDrawCount = this.gpuUsesFullDataset
|
|
1544
|
+
? (useLod ? this.interactionCount : ds.n)
|
|
1545
|
+
: this.gpuPointCount;
|
|
1546
|
+
const estimatedFragments = this.estimatePointFragments(baseDrawCount, this.dpr);
|
|
1547
|
+
this.updateSquarePointPolicy(estimatedFragments);
|
|
1548
|
+
if (this.selectionDirty)
|
|
1549
|
+
this.uploadSelectionToGPU();
|
|
1550
|
+
if (this.hoverDirty)
|
|
1551
|
+
this.uploadHoverToGPU();
|
|
1552
|
+
// Background (full-res)
|
|
1553
|
+
// NOTE: We intentionally avoid gl.blitFramebuffer() here because it can
|
|
1554
|
+
// fail (INVALID_OPERATION) depending on driver/default framebuffer
|
|
1555
|
+
// constraints, leading to a missing disk/grid. Sampling the cached
|
|
1556
|
+
// backdrop texture via a fullscreen draw is robust.
|
|
1557
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1558
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
1559
|
+
gl.disable(gl.BLEND);
|
|
1560
|
+
if (this.geometryKind() === 'poincare') {
|
|
1561
|
+
this.renderBackdropIfNeeded();
|
|
1562
|
+
if (this.backdropTex && this.programComposite) {
|
|
1563
|
+
gl.useProgram(this.programComposite);
|
|
1564
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1565
|
+
gl.bindTexture(gl.TEXTURE_2D, this.backdropTex);
|
|
1566
|
+
if (this.uCompositeTex)
|
|
1567
|
+
gl.uniform1i(this.uCompositeTex, 0);
|
|
1568
|
+
gl.bindVertexArray(this.vao);
|
|
1569
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
1570
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1571
|
+
}
|
|
1572
|
+
else {
|
|
1573
|
+
const [br, bg, bb, ba] = parseHexColor(this.backgroundColor);
|
|
1574
|
+
gl.clearColor(br, bg, bb, ba);
|
|
1575
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
else {
|
|
1579
|
+
// Restore clearColor (may have been changed to transparent for offscreen FBO)
|
|
1580
|
+
const [br, bg, bb, ba] = parseHexColor(this.backgroundColor);
|
|
1581
|
+
gl.clearColor(br, bg, bb, ba);
|
|
1582
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1583
|
+
}
|
|
1584
|
+
// Render points into low-res offscreen buffer.
|
|
1585
|
+
this.ensurePointsResources();
|
|
1586
|
+
if (!this.pointsFbo || !this.pointsTex || !this.programComposite)
|
|
1587
|
+
return;
|
|
1588
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, this.pointsFbo);
|
|
1589
|
+
gl.viewport(0, 0, this.pointsW, this.pointsH);
|
|
1590
|
+
gl.clearColor(0, 0, 0, 0);
|
|
1591
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1592
|
+
// Base points
|
|
1593
|
+
// NOTE: WebGL points are square by default. We implement circles by discarding
|
|
1594
|
+
// fragments outside the unit disk in the fragment shader (FS_POINTS).
|
|
1595
|
+
// For extremely large point counts, using square points can be faster on
|
|
1596
|
+
// some GPUs (no discard). However, it noticeably changes appearance.
|
|
1597
|
+
//
|
|
1598
|
+
// Heuristic: prefer circles up to a few million points.
|
|
1599
|
+
const basePoints = this.renderAsSquares ? this.pointsSquare : this.pointsCircle;
|
|
1600
|
+
if (!basePoints)
|
|
1601
|
+
return;
|
|
1602
|
+
gl.useProgram(basePoints.program);
|
|
1603
|
+
this.bindViewUniformsForProgram(basePoints.program);
|
|
1604
|
+
// Palette uniforms are uploaded once per program.
|
|
1605
|
+
if (this.paletteDirty)
|
|
1606
|
+
this.uploadPaletteUniforms();
|
|
1607
|
+
this.bindPaletteTexture();
|
|
1608
|
+
if (basePoints.uCssSize)
|
|
1609
|
+
gl.uniform2f(basePoints.uCssSize, this.width, this.height);
|
|
1610
|
+
if (basePoints.uDpr)
|
|
1611
|
+
gl.uniform1f(basePoints.uDpr, this.dpr);
|
|
1612
|
+
if (basePoints.uPointRadius)
|
|
1613
|
+
gl.uniform1f(basePoints.uPointRadius, this.pointRadiusCss);
|
|
1614
|
+
// Optimization: Disable blending when rendering squares (performance mode).
|
|
1615
|
+
// This avoids expensive read-modify-write operations for every pixel.
|
|
1616
|
+
if (this.renderAsSquares) {
|
|
1617
|
+
gl.disable(gl.BLEND);
|
|
1618
|
+
}
|
|
1619
|
+
else {
|
|
1620
|
+
gl.enable(gl.BLEND);
|
|
1621
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
1622
|
+
}
|
|
1623
|
+
gl.bindVertexArray(this.vao);
|
|
1624
|
+
if (useLod) {
|
|
1625
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.interactionEbo);
|
|
1626
|
+
gl.drawElements(gl.POINTS, this.interactionCount, gl.UNSIGNED_INT, 0);
|
|
1627
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
const count = this.gpuUsesFullDataset ? ds.n : this.gpuPointCount;
|
|
1631
|
+
gl.drawArrays(gl.POINTS, 0, count);
|
|
1632
|
+
}
|
|
1633
|
+
// Expose policy snapshot (for benchmarks / diagnostics).
|
|
1634
|
+
this.__debugPolicy = {
|
|
1635
|
+
pointsDpr: this.dpr,
|
|
1636
|
+
deviceDpr: this.deviceDpr,
|
|
1637
|
+
canvasDpr: this.canvasDpr,
|
|
1638
|
+
renderAsSquares: this.renderAsSquares,
|
|
1639
|
+
useLod,
|
|
1640
|
+
baseDrawCount,
|
|
1641
|
+
interactionCount: this.interactionCount,
|
|
1642
|
+
gpuUsesFullDataset: this.gpuUsesFullDataset,
|
|
1643
|
+
gpuPointCount: this.gpuPointCount,
|
|
1644
|
+
estimatedPointFragments: estimatedFragments,
|
|
1645
|
+
fragmentBudget: this.policy.fragmentBudget,
|
|
1646
|
+
isInteracting,
|
|
1647
|
+
};
|
|
1648
|
+
// Selection overlay (still into points buffer)
|
|
1649
|
+
if (!isInteracting && this.selection.size > 0) {
|
|
1650
|
+
gl.useProgram(this.programSolid);
|
|
1651
|
+
this.bindViewUniformsForProgram(this.programSolid);
|
|
1652
|
+
if (this.uCssSizeSolid)
|
|
1653
|
+
gl.uniform2f(this.uCssSizeSolid, this.width, this.height);
|
|
1654
|
+
if (this.uDprSolid)
|
|
1655
|
+
gl.uniform1f(this.uDprSolid, this.dpr);
|
|
1656
|
+
if (this.uPointRadiusSolid)
|
|
1657
|
+
gl.uniform1f(this.uPointRadiusSolid, this.pointRadiusCss + 1);
|
|
1658
|
+
if (this.uSolidColor) {
|
|
1659
|
+
const [r, g, b, a] = parseHexColor(SELECTION_COLOR);
|
|
1660
|
+
gl.uniform4f(this.uSolidColor, r, g, b, a);
|
|
1661
|
+
}
|
|
1662
|
+
if (this.uSolidRingMode)
|
|
1663
|
+
gl.uniform1i(this.uSolidRingMode, 0);
|
|
1664
|
+
if (this.uSolidRingThicknessPx)
|
|
1665
|
+
gl.uniform1f(this.uSolidRingThicknessPx, 0);
|
|
1666
|
+
if (this.uSolidPointSizePx)
|
|
1667
|
+
gl.uniform1f(this.uSolidPointSizePx, (this.pointRadiusCss + 1) * 2 * this.dpr);
|
|
1668
|
+
if (this.gpuUsesFullDataset) {
|
|
1669
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionEbo);
|
|
1670
|
+
gl.drawElements(gl.POINTS, this.selectionOverlayCount, gl.UNSIGNED_INT, 0);
|
|
1671
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
1672
|
+
}
|
|
1673
|
+
else if (this.selectionVao && this.selectionOverlayCount > 0) {
|
|
1674
|
+
gl.bindVertexArray(this.selectionVao);
|
|
1675
|
+
gl.drawArrays(gl.POINTS, 0, this.selectionOverlayCount);
|
|
1676
|
+
gl.bindVertexArray(this.vao);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
// Hover overlay (still into points buffer)
|
|
1680
|
+
if (!isInteracting && this.hoveredIndex >= 0 && this.hoveredIndex < ds.n) {
|
|
1681
|
+
// Ring
|
|
1682
|
+
gl.useProgram(this.programSolid);
|
|
1683
|
+
this.bindViewUniformsForProgram(this.programSolid);
|
|
1684
|
+
if (this.uCssSizeSolid)
|
|
1685
|
+
gl.uniform2f(this.uCssSizeSolid, this.width, this.height);
|
|
1686
|
+
if (this.uDprSolid)
|
|
1687
|
+
gl.uniform1f(this.uDprSolid, this.dpr);
|
|
1688
|
+
// Ring pass
|
|
1689
|
+
const ringRadius = this.pointRadiusCss + 3;
|
|
1690
|
+
if (this.uPointRadiusSolid)
|
|
1691
|
+
gl.uniform1f(this.uPointRadiusSolid, ringRadius);
|
|
1692
|
+
if (this.uSolidColor) {
|
|
1693
|
+
const [r, g, b, a] = parseHexColor(HOVER_COLOR);
|
|
1694
|
+
gl.uniform4f(this.uSolidColor, r, g, b, a);
|
|
1695
|
+
}
|
|
1696
|
+
if (this.uSolidRingMode)
|
|
1697
|
+
gl.uniform1i(this.uSolidRingMode, 1);
|
|
1698
|
+
if (this.uSolidRingThicknessPx)
|
|
1699
|
+
gl.uniform1f(this.uSolidRingThicknessPx, 2);
|
|
1700
|
+
if (this.uSolidPointSizePx)
|
|
1701
|
+
gl.uniform1f(this.uSolidPointSizePx, ringRadius * 2 * this.dpr);
|
|
1702
|
+
if (this.gpuUsesFullDataset) {
|
|
1703
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.hoverEbo);
|
|
1704
|
+
gl.drawElements(gl.POINTS, 1, gl.UNSIGNED_INT, 0);
|
|
1705
|
+
}
|
|
1706
|
+
else if (this.hoverVao) {
|
|
1707
|
+
gl.bindVertexArray(this.hoverVao);
|
|
1708
|
+
gl.drawArrays(gl.POINTS, 0, 1);
|
|
1709
|
+
gl.bindVertexArray(this.vao);
|
|
1710
|
+
}
|
|
1711
|
+
// Fill pass (selection color if selected else palette)
|
|
1712
|
+
const fillRadius = this.pointRadiusCss + 1;
|
|
1713
|
+
if (this.selection.has(this.hoveredIndex)) {
|
|
1714
|
+
// Solid red
|
|
1715
|
+
if (this.uPointRadiusSolid)
|
|
1716
|
+
gl.uniform1f(this.uPointRadiusSolid, fillRadius);
|
|
1717
|
+
if (this.uSolidColor) {
|
|
1718
|
+
const [r, g, b, a] = parseHexColor(SELECTION_COLOR);
|
|
1719
|
+
gl.uniform4f(this.uSolidColor, r, g, b, a);
|
|
1720
|
+
}
|
|
1721
|
+
if (this.uSolidRingMode)
|
|
1722
|
+
gl.uniform1i(this.uSolidRingMode, 0);
|
|
1723
|
+
if (this.uSolidRingThicknessPx)
|
|
1724
|
+
gl.uniform1f(this.uSolidRingThicknessPx, 0);
|
|
1725
|
+
if (this.uSolidPointSizePx)
|
|
1726
|
+
gl.uniform1f(this.uSolidPointSizePx, fillRadius * 2 * this.dpr);
|
|
1727
|
+
if (this.gpuUsesFullDataset) {
|
|
1728
|
+
gl.drawElements(gl.POINTS, 1, gl.UNSIGNED_INT, 0);
|
|
1729
|
+
}
|
|
1730
|
+
else if (this.hoverVao) {
|
|
1731
|
+
gl.bindVertexArray(this.hoverVao);
|
|
1732
|
+
gl.drawArrays(gl.POINTS, 0, 1);
|
|
1733
|
+
gl.bindVertexArray(this.vao);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
// Use palette program for correct label color
|
|
1738
|
+
const circlePoints = this.pointsCircle;
|
|
1739
|
+
if (!circlePoints)
|
|
1740
|
+
return;
|
|
1741
|
+
gl.useProgram(circlePoints.program);
|
|
1742
|
+
this.bindViewUniformsForProgram(circlePoints.program);
|
|
1743
|
+
if (this.paletteDirty)
|
|
1744
|
+
this.uploadPaletteUniforms();
|
|
1745
|
+
this.bindPaletteTexture();
|
|
1746
|
+
if (circlePoints.uCssSize)
|
|
1747
|
+
gl.uniform2f(circlePoints.uCssSize, this.width, this.height);
|
|
1748
|
+
if (circlePoints.uDpr)
|
|
1749
|
+
gl.uniform1f(circlePoints.uDpr, this.dpr);
|
|
1750
|
+
if (circlePoints.uPointRadius)
|
|
1751
|
+
gl.uniform1f(circlePoints.uPointRadius, fillRadius);
|
|
1752
|
+
if (this.gpuUsesFullDataset) {
|
|
1753
|
+
gl.drawElements(gl.POINTS, 1, gl.UNSIGNED_INT, 0);
|
|
1754
|
+
}
|
|
1755
|
+
else if (this.hoverVao) {
|
|
1756
|
+
gl.bindVertexArray(this.hoverVao);
|
|
1757
|
+
gl.drawArrays(gl.POINTS, 0, 1);
|
|
1758
|
+
gl.bindVertexArray(this.vao);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (this.gpuUsesFullDataset) {
|
|
1762
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
// Composite points buffer onto full-res default framebuffer.
|
|
1766
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1767
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
1768
|
+
gl.useProgram(this.programComposite);
|
|
1769
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1770
|
+
gl.bindTexture(gl.TEXTURE_2D, this.pointsTex);
|
|
1771
|
+
if (this.uCompositeTex)
|
|
1772
|
+
gl.uniform1i(this.uCompositeTex, 0);
|
|
1773
|
+
// Blend points over background.
|
|
1774
|
+
gl.enable(gl.BLEND);
|
|
1775
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
1776
|
+
gl.bindVertexArray(this.vao);
|
|
1777
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
1778
|
+
// Restore point VAO for next frame.
|
|
1779
|
+
gl.bindVertexArray(this.vao);
|
|
1780
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1781
|
+
gl.bindVertexArray(null);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
// ============================================================================
|
|
1785
|
+
// Euclidean candidate
|
|
1786
|
+
// ============================================================================
|
|
1787
|
+
export class EuclideanWebGLCandidate extends WebGLRendererBase {
|
|
1788
|
+
view = createEuclideanView();
|
|
1789
|
+
uniformCache = new Map();
|
|
1790
|
+
geometryKind() {
|
|
1791
|
+
return 'euclidean';
|
|
1792
|
+
}
|
|
1793
|
+
setDataset(dataset) {
|
|
1794
|
+
if (dataset.geometry !== 'euclidean') {
|
|
1795
|
+
throw new Error('EuclideanWebGLCandidate only supports euclidean geometry');
|
|
1796
|
+
}
|
|
1797
|
+
super.setDataset(dataset);
|
|
1798
|
+
this.fitToData();
|
|
1799
|
+
}
|
|
1800
|
+
fitToData() {
|
|
1801
|
+
const ds = this.dataset;
|
|
1802
|
+
if (!ds || ds.n === 0)
|
|
1803
|
+
return;
|
|
1804
|
+
let minX = Infinity, maxX = -Infinity;
|
|
1805
|
+
let minY = Infinity, maxY = -Infinity;
|
|
1806
|
+
for (let i = 0; i < ds.n; i++) {
|
|
1807
|
+
const x = ds.positions[i * 2];
|
|
1808
|
+
const y = ds.positions[i * 2 + 1];
|
|
1809
|
+
minX = Math.min(minX, x);
|
|
1810
|
+
maxX = Math.max(maxX, x);
|
|
1811
|
+
minY = Math.min(minY, y);
|
|
1812
|
+
maxY = Math.max(maxY, y);
|
|
1813
|
+
}
|
|
1814
|
+
const dataWidth = maxX - minX || 1;
|
|
1815
|
+
const dataHeight = maxY - minY || 1;
|
|
1816
|
+
const dataSize = Math.max(dataWidth, dataHeight);
|
|
1817
|
+
const fitZoom = 2 / dataSize;
|
|
1818
|
+
this.view = {
|
|
1819
|
+
type: 'euclidean',
|
|
1820
|
+
centerX: (minX + maxX) / 2,
|
|
1821
|
+
centerY: (minY + maxY) / 2,
|
|
1822
|
+
zoom: Math.max(0.1, Math.min(100, fitZoom)),
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
setView(view) {
|
|
1826
|
+
if (view.type !== 'euclidean') {
|
|
1827
|
+
throw new Error('EuclideanWebGLCandidate only supports euclidean view state');
|
|
1828
|
+
}
|
|
1829
|
+
this.view = view;
|
|
1830
|
+
}
|
|
1831
|
+
getView() {
|
|
1832
|
+
return { ...this.view };
|
|
1833
|
+
}
|
|
1834
|
+
bindViewUniformsForProgram(program) {
|
|
1835
|
+
if (!this.gl)
|
|
1836
|
+
return;
|
|
1837
|
+
const gl = this.gl;
|
|
1838
|
+
let cached = this.uniformCache.get(program);
|
|
1839
|
+
if (!cached) {
|
|
1840
|
+
cached = {
|
|
1841
|
+
uCenter: gl.getUniformLocation(program, 'u_center'),
|
|
1842
|
+
uZoom: gl.getUniformLocation(program, 'u_zoom'),
|
|
1843
|
+
};
|
|
1844
|
+
this.uniformCache.set(program, cached);
|
|
1845
|
+
}
|
|
1846
|
+
if (cached.uCenter)
|
|
1847
|
+
gl.uniform2f(cached.uCenter, this.view.centerX, this.view.centerY);
|
|
1848
|
+
if (cached.uZoom)
|
|
1849
|
+
gl.uniform1f(cached.uZoom, this.view.zoom);
|
|
1850
|
+
}
|
|
1851
|
+
pan(deltaX, deltaY, _modifiers) {
|
|
1852
|
+
this.view = panEuclidean(this.view, deltaX, deltaY, this.width, this.height);
|
|
1853
|
+
this.markViewChanged();
|
|
1854
|
+
}
|
|
1855
|
+
zoom(anchorX, anchorY, delta, _modifiers) {
|
|
1856
|
+
this.view = zoomEuclidean(this.view, anchorX, anchorY, delta, this.width, this.height);
|
|
1857
|
+
this.markViewChanged();
|
|
1858
|
+
}
|
|
1859
|
+
hitTest(screenX, screenY) {
|
|
1860
|
+
const ds = this.dataset;
|
|
1861
|
+
const idx = this.dataIndex;
|
|
1862
|
+
if (!ds || !idx)
|
|
1863
|
+
return null;
|
|
1864
|
+
// Reference hit radius rule
|
|
1865
|
+
const maxDistPx = this.pointRadiusCss + 5;
|
|
1866
|
+
const maxDistSq = maxDistPx * maxDistPx;
|
|
1867
|
+
const scale = Math.min(this.width, this.height) * 0.4 * this.view.zoom;
|
|
1868
|
+
if (!(scale > 0))
|
|
1869
|
+
return null;
|
|
1870
|
+
// Convert hit radius to data space.
|
|
1871
|
+
// Add a tiny epsilon to avoid rare edge-case misses due to floating-point rounding.
|
|
1872
|
+
const dataRadius = (maxDistPx / scale) * (1 + 1e-12);
|
|
1873
|
+
const maxDataDistSq = dataRadius * dataRadius;
|
|
1874
|
+
const dataPt = unprojectEuclidean(screenX, screenY, this.view, this.width, this.height);
|
|
1875
|
+
// Inline projection math in the loop to avoid allocating {x,y} objects.
|
|
1876
|
+
// We still compute exact screen-space distance for correctness.
|
|
1877
|
+
const cx = this.width * 0.5;
|
|
1878
|
+
const cy = this.height * 0.5;
|
|
1879
|
+
const cX = this.view.centerX;
|
|
1880
|
+
const cY = this.view.centerY;
|
|
1881
|
+
let bestIndex = -1;
|
|
1882
|
+
let bestDistSq = Infinity;
|
|
1883
|
+
// Avoid building a potentially large candidates array (push-heavy).
|
|
1884
|
+
// Instead iterate overlapping grid cells directly.
|
|
1885
|
+
idx.forEachInAABB(dataPt.x - dataRadius, dataPt.y - dataRadius, dataPt.x + dataRadius, dataPt.y + dataRadius, (i) => {
|
|
1886
|
+
const dataX = ds.positions[i * 2];
|
|
1887
|
+
const dataY = ds.positions[i * 2 + 1];
|
|
1888
|
+
// Fast reject in data space (equivalent up to scale).
|
|
1889
|
+
const dxData = dataX - dataPt.x;
|
|
1890
|
+
const dyData = dataY - dataPt.y;
|
|
1891
|
+
const dataDistSq = dxData * dxData + dyData * dyData;
|
|
1892
|
+
if (dataDistSq > maxDataDistSq)
|
|
1893
|
+
return;
|
|
1894
|
+
const sx = cx + (dataX - cX) * scale;
|
|
1895
|
+
const sy = cy - (dataY - cY) * scale;
|
|
1896
|
+
const dx = sx - screenX;
|
|
1897
|
+
const dy = sy - screenY;
|
|
1898
|
+
const distSq = dx * dx + dy * dy;
|
|
1899
|
+
if (distSq <= maxDistSq) {
|
|
1900
|
+
if (distSq < bestDistSq || (distSq === bestDistSq && i < bestIndex)) {
|
|
1901
|
+
bestDistSq = distSq;
|
|
1902
|
+
bestIndex = i;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1906
|
+
if (bestIndex < 0)
|
|
1907
|
+
return null;
|
|
1908
|
+
const bx = ds.positions[bestIndex * 2];
|
|
1909
|
+
const by = ds.positions[bestIndex * 2 + 1];
|
|
1910
|
+
const screen = projectEuclidean(bx, by, this.view, this.width, this.height);
|
|
1911
|
+
return {
|
|
1912
|
+
index: bestIndex,
|
|
1913
|
+
screenX: screen.x,
|
|
1914
|
+
screenY: screen.y,
|
|
1915
|
+
distance: Math.sqrt(bestDistSq),
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
lassoSelect(polyline) {
|
|
1919
|
+
const ds = this.dataset;
|
|
1920
|
+
const idx = this.dataIndex;
|
|
1921
|
+
if (!ds || !idx)
|
|
1922
|
+
return createIndicesSelectionResult(new Set(), 0);
|
|
1923
|
+
const startTime = performance.now();
|
|
1924
|
+
// Always return geometry (Embedding Atlas style). The UI/benchmarks should
|
|
1925
|
+
// use Renderer.countSelection(...) to obtain counts efficiently.
|
|
1926
|
+
const dataPolyline = new Float32Array(polyline.length);
|
|
1927
|
+
for (let i = 0; i < polyline.length / 2; i++) {
|
|
1928
|
+
const sx = polyline[i * 2];
|
|
1929
|
+
const sy = polyline[i * 2 + 1];
|
|
1930
|
+
const data = unprojectEuclidean(sx, sy, this.view, this.width, this.height);
|
|
1931
|
+
dataPolyline[i * 2] = data.x;
|
|
1932
|
+
dataPolyline[i * 2 + 1] = data.y;
|
|
1933
|
+
}
|
|
1934
|
+
// Tight AABB for fast reject in has() and efficient indexed counting.
|
|
1935
|
+
let minX = Infinity;
|
|
1936
|
+
let minY = Infinity;
|
|
1937
|
+
let maxX = -Infinity;
|
|
1938
|
+
let maxY = -Infinity;
|
|
1939
|
+
for (let i = 0; i < dataPolyline.length; i += 2) {
|
|
1940
|
+
const x = dataPolyline[i];
|
|
1941
|
+
const y = dataPolyline[i + 1];
|
|
1942
|
+
if (x < minX)
|
|
1943
|
+
minX = x;
|
|
1944
|
+
if (x > maxX)
|
|
1945
|
+
maxX = x;
|
|
1946
|
+
if (y < minY)
|
|
1947
|
+
minY = y;
|
|
1948
|
+
if (y > maxY)
|
|
1949
|
+
maxY = y;
|
|
1950
|
+
}
|
|
1951
|
+
const bounds = { xMin: minX, yMin: minY, xMax: maxX, yMax: maxY };
|
|
1952
|
+
const geometry = { type: 'polygon', coords: dataPolyline, bounds };
|
|
1953
|
+
const computeTimeMs = performance.now() - startTime;
|
|
1954
|
+
return createGeometrySelectionResult(geometry, ds.positions, computeTimeMs, (px, py, polygon) => {
|
|
1955
|
+
if (px < bounds.xMin || px > bounds.xMax || py < bounds.yMin || py > bounds.yMax)
|
|
1956
|
+
return false;
|
|
1957
|
+
return pointInPolygon(px, py, polygon);
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
projectToScreen(dataX, dataY) {
|
|
1961
|
+
return projectEuclidean(dataX, dataY, this.view, this.width, this.height);
|
|
1962
|
+
}
|
|
1963
|
+
unprojectFromScreen(screenX, screenY) {
|
|
1964
|
+
return unprojectEuclidean(screenX, screenY, this.view, this.width, this.height);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
// ============================================================================
|
|
1968
|
+
// Hyperbolic candidate
|
|
1969
|
+
// ============================================================================
|
|
1970
|
+
export class HyperbolicWebGLCandidate extends WebGLRendererBase {
|
|
1971
|
+
view = createHyperbolicView();
|
|
1972
|
+
uniformCache = new Map();
|
|
1973
|
+
// Pan tracking (same as reference)
|
|
1974
|
+
lastPanScreenX = 0;
|
|
1975
|
+
lastPanScreenY = 0;
|
|
1976
|
+
hasPanAnchor = false;
|
|
1977
|
+
geometryKind() {
|
|
1978
|
+
return 'poincare';
|
|
1979
|
+
}
|
|
1980
|
+
getBackdropZoom() {
|
|
1981
|
+
return this.view.displayZoom;
|
|
1982
|
+
}
|
|
1983
|
+
setDataset(dataset) {
|
|
1984
|
+
if (dataset.geometry !== 'poincare') {
|
|
1985
|
+
throw new Error('HyperbolicWebGLCandidate only supports poincare geometry');
|
|
1986
|
+
}
|
|
1987
|
+
super.setDataset(dataset);
|
|
1988
|
+
this.view = createHyperbolicView();
|
|
1989
|
+
this.hasPanAnchor = false;
|
|
1990
|
+
}
|
|
1991
|
+
setView(view) {
|
|
1992
|
+
if (view.type !== 'poincare') {
|
|
1993
|
+
throw new Error('HyperbolicWebGLCandidate only supports poincare view state');
|
|
1994
|
+
}
|
|
1995
|
+
this.view = view;
|
|
1996
|
+
this.markBackdropDirty();
|
|
1997
|
+
}
|
|
1998
|
+
getView() {
|
|
1999
|
+
return { ...this.view };
|
|
2000
|
+
}
|
|
2001
|
+
bindViewUniformsForProgram(program) {
|
|
2002
|
+
if (!this.gl)
|
|
2003
|
+
return;
|
|
2004
|
+
const gl = this.gl;
|
|
2005
|
+
let cached = this.uniformCache.get(program);
|
|
2006
|
+
if (!cached) {
|
|
2007
|
+
cached = {
|
|
2008
|
+
uA: gl.getUniformLocation(program, 'u_a'),
|
|
2009
|
+
uDisplayZoom: gl.getUniformLocation(program, 'u_displayZoom'),
|
|
2010
|
+
};
|
|
2011
|
+
this.uniformCache.set(program, cached);
|
|
2012
|
+
}
|
|
2013
|
+
if (cached.uA)
|
|
2014
|
+
gl.uniform2f(cached.uA, this.view.ax, this.view.ay);
|
|
2015
|
+
if (cached.uDisplayZoom)
|
|
2016
|
+
gl.uniform1f(cached.uDisplayZoom, this.view.displayZoom);
|
|
2017
|
+
}
|
|
2018
|
+
// For accuracy harness: called via reflection if present.
|
|
2019
|
+
startPan(screenX, screenY) {
|
|
2020
|
+
this.lastPanScreenX = screenX;
|
|
2021
|
+
this.lastPanScreenY = screenY;
|
|
2022
|
+
this.hasPanAnchor = true;
|
|
2023
|
+
}
|
|
2024
|
+
pan(deltaX, deltaY, _modifiers) {
|
|
2025
|
+
if (!this.hasPanAnchor) {
|
|
2026
|
+
this.lastPanScreenX = this.width / 2;
|
|
2027
|
+
this.lastPanScreenY = this.height / 2;
|
|
2028
|
+
this.hasPanAnchor = true;
|
|
2029
|
+
}
|
|
2030
|
+
const startX = this.lastPanScreenX;
|
|
2031
|
+
const startY = this.lastPanScreenY;
|
|
2032
|
+
const endX = startX + deltaX;
|
|
2033
|
+
const endY = startY + deltaY;
|
|
2034
|
+
this.view = panPoincare(this.view, startX, startY, endX, endY, this.width, this.height);
|
|
2035
|
+
this.markViewChanged();
|
|
2036
|
+
this.lastPanScreenX = endX;
|
|
2037
|
+
this.lastPanScreenY = endY;
|
|
2038
|
+
}
|
|
2039
|
+
zoom(anchorX, anchorY, delta, _modifiers) {
|
|
2040
|
+
this.view = zoomPoincare(this.view, anchorX, anchorY, delta, this.width, this.height);
|
|
2041
|
+
this.markViewChanged();
|
|
2042
|
+
this.markBackdropDirty();
|
|
2043
|
+
}
|
|
2044
|
+
mobiusDerivativeScaleAt(zx, zy) {
|
|
2045
|
+
// For T_a(z) = (z - a) / (1 - conj(a) z), the conformal scale factor is:
|
|
2046
|
+
// |T'_a(z)| = (1 - |a|^2) / |1 - conj(a) z|^2
|
|
2047
|
+
const ax = this.view.ax;
|
|
2048
|
+
const ay = this.view.ay;
|
|
2049
|
+
const a2 = ax * ax + ay * ay;
|
|
2050
|
+
const denomX = 1.0 - (ax * zx + ay * zy);
|
|
2051
|
+
const denomY = -(ax * zy - ay * zx);
|
|
2052
|
+
const denomNormSq = denomX * denomX + denomY * denomY;
|
|
2053
|
+
if (denomNormSq < 1e-12)
|
|
2054
|
+
return 0;
|
|
2055
|
+
const num = Math.max(0, 1.0 - a2);
|
|
2056
|
+
return num / denomNormSq;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Compute a conservative Euclidean data-space radius that guarantees we won't
|
|
2060
|
+
* miss any point within `screenRadiusPx` of the cursor.
|
|
2061
|
+
*
|
|
2062
|
+
* Derivation:
|
|
2063
|
+
* - Screen-space displacement is (locally) scaled by:
|
|
2064
|
+
* localScale(z) = diskRadius * |T'_a(z)|
|
|
2065
|
+
* where T_a is the Möbius transform used by the camera.
|
|
2066
|
+
* - |T'_a(z)| = (1 - |a|^2) / |1 - conj(a) z|^2.
|
|
2067
|
+
* - Over a Euclidean ball |z - z0| <= r, the denominator norm is Lipschitz:
|
|
2068
|
+
* | |1 - conj(a)z| - |1 - conj(a)z0| | <= |a| * r
|
|
2069
|
+
* hence for any z in the ball:
|
|
2070
|
+
* |1 - conj(a)z| <= D0 + |a| r
|
|
2071
|
+
* which yields a lower bound on |T'_a(z)| (worst-case smallest scale).
|
|
2072
|
+
*
|
|
2073
|
+
* We solve the fixed point:
|
|
2074
|
+
* r = screenRadiusPx / (diskRadius * min|T'_a|)
|
|
2075
|
+
* = screenRadiusPx * (D0 + |a| r)^2 / (diskRadius * (1 - |a|^2))
|
|
2076
|
+
* by a few iterations (converges quickly for |a|<1).
|
|
2077
|
+
*/
|
|
2078
|
+
conservativeDataRadiusForScreenRadius(zx, zy, screenRadiusPx, diskRadius) {
|
|
2079
|
+
const ax = this.view.ax;
|
|
2080
|
+
const ay = this.view.ay;
|
|
2081
|
+
const a2 = ax * ax + ay * ay;
|
|
2082
|
+
const aMag = Math.sqrt(a2);
|
|
2083
|
+
const C = Math.max(1e-12, 1.0 - a2);
|
|
2084
|
+
if (!(diskRadius > 1e-9) || !(screenRadiusPx > 0))
|
|
2085
|
+
return 0;
|
|
2086
|
+
// D0 = |1 - conj(a) z0|
|
|
2087
|
+
const denomX0 = 1.0 - (ax * zx + ay * zy);
|
|
2088
|
+
const denomY0 = -(ax * zy - ay * zx);
|
|
2089
|
+
const D0 = Math.sqrt(denomX0 * denomX0 + denomY0 * denomY0);
|
|
2090
|
+
if (!Number.isFinite(D0) || D0 < 1e-12)
|
|
2091
|
+
return 2.0;
|
|
2092
|
+
const K = screenRadiusPx / (diskRadius * C);
|
|
2093
|
+
let r = K * D0 * D0;
|
|
2094
|
+
// Fixed-point iterations. 4-5 is plenty.
|
|
2095
|
+
for (let it = 0; it < 5; it++) {
|
|
2096
|
+
const D = D0 + aMag * r;
|
|
2097
|
+
r = K * D * D;
|
|
2098
|
+
}
|
|
2099
|
+
if (!Number.isFinite(r))
|
|
2100
|
+
return 2.0;
|
|
2101
|
+
// Tiny slack for floating point noise.
|
|
2102
|
+
r *= 1.001;
|
|
2103
|
+
return Math.min(1.999, Math.max(0, r));
|
|
2104
|
+
}
|
|
2105
|
+
hitTest(screenX, screenY) {
|
|
2106
|
+
const ds = this.dataset;
|
|
2107
|
+
const idx = this.dataIndex;
|
|
2108
|
+
if (!ds || !idx)
|
|
2109
|
+
return null;
|
|
2110
|
+
const { width, height, view } = this;
|
|
2111
|
+
const centerX = width / 2;
|
|
2112
|
+
const centerY = height / 2;
|
|
2113
|
+
const diskRadius = Math.min(width, height) * 0.45 * view.displayZoom;
|
|
2114
|
+
const diskR2 = diskRadius * diskRadius;
|
|
2115
|
+
const maxDistPx = this.pointRadiusCss + 5;
|
|
2116
|
+
const maxDistSq = maxDistPx * maxDistPx;
|
|
2117
|
+
// Reference semantics: cursor may be outside the disk. We only cull points
|
|
2118
|
+
// based on their *projected* position being outside the disk.
|
|
2119
|
+
//
|
|
2120
|
+
// However, if the cursor is far enough outside the disk that no point
|
|
2121
|
+
// inside the disk could be within the hit radius, we can return null.
|
|
2122
|
+
const dxCur = screenX - centerX;
|
|
2123
|
+
const dyCur = screenY - centerY;
|
|
2124
|
+
const maxCursorR = diskRadius + maxDistPx;
|
|
2125
|
+
if (dxCur * dxCur + dyCur * dyCur > maxCursorR * maxCursorR)
|
|
2126
|
+
return null;
|
|
2127
|
+
// Convert cursor to data space.
|
|
2128
|
+
const dataPt = unprojectPoincare(screenX, screenY, view, width, height);
|
|
2129
|
+
const queryRadius = this.conservativeDataRadiusForScreenRadius(dataPt.x, dataPt.y, maxDistPx, diskRadius);
|
|
2130
|
+
let bestIndex = -1;
|
|
2131
|
+
let bestDistSq = Infinity;
|
|
2132
|
+
// Inline the Poincaré projection math to avoid per-candidate object allocations.
|
|
2133
|
+
// This mirrors `projectPoincare()` + `mobiusTransform()` clamping behavior.
|
|
2134
|
+
const ax = view.ax;
|
|
2135
|
+
const ay = view.ay;
|
|
2136
|
+
// Avoid building a candidates array; iterate overlapping cells directly.
|
|
2137
|
+
idx.forEachInAABB(dataPt.x - queryRadius, dataPt.y - queryRadius, dataPt.x + queryRadius, dataPt.y + queryRadius, (i) => {
|
|
2138
|
+
const dataX = ds.positions[i * 2];
|
|
2139
|
+
const dataY = ds.positions[i * 2 + 1];
|
|
2140
|
+
// mobiusTransform(z) = (z - a) / (1 - conj(a) * z)
|
|
2141
|
+
const numX = dataX - ax;
|
|
2142
|
+
const numY = dataY - ay;
|
|
2143
|
+
const denomX = 1.0 - (ax * dataX + ay * dataY);
|
|
2144
|
+
const denomY = -(ax * dataY - ay * dataX);
|
|
2145
|
+
const denomNormSq = denomX * denomX + denomY * denomY;
|
|
2146
|
+
let wx = 0.0;
|
|
2147
|
+
let wy = 0.0;
|
|
2148
|
+
if (denomNormSq < 1e-12) {
|
|
2149
|
+
const norm = Math.sqrt(numX * numX + numY * numY);
|
|
2150
|
+
if (norm < 1e-12) {
|
|
2151
|
+
wx = 0.0;
|
|
2152
|
+
wy = 0.0;
|
|
2153
|
+
}
|
|
2154
|
+
else {
|
|
2155
|
+
wx = (numX / norm) * 0.999;
|
|
2156
|
+
wy = (numY / norm) * 0.999;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
else {
|
|
2160
|
+
wx = (numX * denomX + numY * denomY) / denomNormSq;
|
|
2161
|
+
wy = (numY * denomX - numX * denomY) / denomNormSq;
|
|
2162
|
+
const rSq = wx * wx + wy * wy;
|
|
2163
|
+
if (rSq >= 1.0) {
|
|
2164
|
+
const r = Math.sqrt(rSq);
|
|
2165
|
+
wx = (wx / r) * 0.999;
|
|
2166
|
+
wy = (wy / r) * 0.999;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
const sx = centerX + wx * diskRadius;
|
|
2170
|
+
const sy = centerY - wy * diskRadius;
|
|
2171
|
+
const dxDisk = sx - centerX;
|
|
2172
|
+
const dyDisk = sy - centerY;
|
|
2173
|
+
if (dxDisk * dxDisk + dyDisk * dyDisk > diskR2)
|
|
2174
|
+
return;
|
|
2175
|
+
const dx = sx - screenX;
|
|
2176
|
+
const dy = sy - screenY;
|
|
2177
|
+
const distSq = dx * dx + dy * dy;
|
|
2178
|
+
if (distSq <= maxDistSq) {
|
|
2179
|
+
if (distSq < bestDistSq || (distSq === bestDistSq && i < bestIndex)) {
|
|
2180
|
+
bestDistSq = distSq;
|
|
2181
|
+
bestIndex = i;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
if (bestIndex < 0)
|
|
2186
|
+
return null;
|
|
2187
|
+
const bx = ds.positions[bestIndex * 2];
|
|
2188
|
+
const by = ds.positions[bestIndex * 2 + 1];
|
|
2189
|
+
// Recompute best screen position (single point) without allocations.
|
|
2190
|
+
const bNumX = bx - ax;
|
|
2191
|
+
const bNumY = by - ay;
|
|
2192
|
+
const bDenomX = 1.0 - (ax * bx + ay * by);
|
|
2193
|
+
const bDenomY = -(ax * by - ay * bx);
|
|
2194
|
+
const bDenomNormSq = bDenomX * bDenomX + bDenomY * bDenomY;
|
|
2195
|
+
let bwx = 0.0;
|
|
2196
|
+
let bwy = 0.0;
|
|
2197
|
+
if (bDenomNormSq < 1e-12) {
|
|
2198
|
+
const norm = Math.sqrt(bNumX * bNumX + bNumY * bNumY);
|
|
2199
|
+
if (norm < 1e-12) {
|
|
2200
|
+
bwx = 0.0;
|
|
2201
|
+
bwy = 0.0;
|
|
2202
|
+
}
|
|
2203
|
+
else {
|
|
2204
|
+
bwx = (bNumX / norm) * 0.999;
|
|
2205
|
+
bwy = (bNumY / norm) * 0.999;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
else {
|
|
2209
|
+
bwx = (bNumX * bDenomX + bNumY * bDenomY) / bDenomNormSq;
|
|
2210
|
+
bwy = (bNumY * bDenomX - bNumX * bDenomY) / bDenomNormSq;
|
|
2211
|
+
const rSq = bwx * bwx + bwy * bwy;
|
|
2212
|
+
if (rSq >= 1.0) {
|
|
2213
|
+
const r = Math.sqrt(rSq);
|
|
2214
|
+
bwx = (bwx / r) * 0.999;
|
|
2215
|
+
bwy = (bwy / r) * 0.999;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
const bestScreenX = centerX + bwx * diskRadius;
|
|
2219
|
+
const bestScreenY = centerY - bwy * diskRadius;
|
|
2220
|
+
return {
|
|
2221
|
+
index: bestIndex,
|
|
2222
|
+
screenX: bestScreenX,
|
|
2223
|
+
screenY: bestScreenY,
|
|
2224
|
+
distance: Math.sqrt(bestDistSq),
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
lassoSelect(polyline) {
|
|
2228
|
+
const ds = this.dataset;
|
|
2229
|
+
const idx = this.dataIndex;
|
|
2230
|
+
if (!ds || !idx)
|
|
2231
|
+
return createIndicesSelectionResult(new Set(), 0);
|
|
2232
|
+
const startTime = performance.now();
|
|
2233
|
+
// Always return geometry (Embedding Atlas style). The UI/benchmarks should
|
|
2234
|
+
// use Renderer.countSelection(...) to obtain counts efficiently.
|
|
2235
|
+
const dataPolyline = new Float32Array(polyline.length);
|
|
2236
|
+
for (let i = 0; i < polyline.length / 2; i++) {
|
|
2237
|
+
const sx = polyline[i * 2];
|
|
2238
|
+
const sy = polyline[i * 2 + 1];
|
|
2239
|
+
const data = unprojectPoincare(sx, sy, this.view, this.width, this.height);
|
|
2240
|
+
dataPolyline[i * 2] = data.x;
|
|
2241
|
+
dataPolyline[i * 2 + 1] = data.y;
|
|
2242
|
+
}
|
|
2243
|
+
// Tight AABB for fast reject in has() and efficient indexed counting.
|
|
2244
|
+
let minX = Infinity;
|
|
2245
|
+
let minY = Infinity;
|
|
2246
|
+
let maxX = -Infinity;
|
|
2247
|
+
let maxY = -Infinity;
|
|
2248
|
+
for (let i = 0; i < dataPolyline.length; i += 2) {
|
|
2249
|
+
const x = dataPolyline[i];
|
|
2250
|
+
const y = dataPolyline[i + 1];
|
|
2251
|
+
if (x < minX)
|
|
2252
|
+
minX = x;
|
|
2253
|
+
if (x > maxX)
|
|
2254
|
+
maxX = x;
|
|
2255
|
+
if (y < minY)
|
|
2256
|
+
minY = y;
|
|
2257
|
+
if (y > maxY)
|
|
2258
|
+
maxY = y;
|
|
2259
|
+
}
|
|
2260
|
+
const bounds = { xMin: minX, yMin: minY, xMax: maxX, yMax: maxY };
|
|
2261
|
+
const geometry = { type: 'polygon', coords: dataPolyline, bounds };
|
|
2262
|
+
const computeTimeMs = performance.now() - startTime;
|
|
2263
|
+
return createGeometrySelectionResult(geometry, ds.positions, computeTimeMs, (px, py, polygon) => {
|
|
2264
|
+
if (px < bounds.xMin || px > bounds.xMax || py < bounds.yMin || py > bounds.yMax)
|
|
2265
|
+
return false;
|
|
2266
|
+
return pointInPolygon(px, py, polygon);
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
projectToScreen(dataX, dataY) {
|
|
2270
|
+
return projectPoincare(dataX, dataY, this.view, this.width, this.height);
|
|
2271
|
+
}
|
|
2272
|
+
unprojectFromScreen(screenX, screenY) {
|
|
2273
|
+
return unprojectPoincare(screenX, screenY, this.view, this.width, this.height);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
//# sourceMappingURL=webgl_candidate.js.map
|