@opendata-ai/openchart-vanilla 2.0.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/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas 2D renderer for force-directed graph visualization.
|
|
3
|
+
*
|
|
4
|
+
* Stateless renderer: receives a GraphRenderState each frame and draws it.
|
|
5
|
+
* Handles DPR scaling, viewport culling, LOD labels, dark mode glow effects,
|
|
6
|
+
* and batched drawing for performance at 10k+ nodes.
|
|
7
|
+
*
|
|
8
|
+
* Performance strategy:
|
|
9
|
+
* - Edges batched by (stroke, strokeWidth, dash) key → one stroke() per group
|
|
10
|
+
* - Nodes batched by fill color → one fill() per color group
|
|
11
|
+
* - Node strokes batched by stroke color
|
|
12
|
+
* - Labels and glow skipped during active pan/zoom gestures
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GraphRenderState, PositionedEdge, PositionedNode } from './types';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Constants
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const LABEL_FONT_MIN = 10;
|
|
22
|
+
const LABEL_FONT_MAX = 14;
|
|
23
|
+
const EDGE_ALPHA_DEFAULT = 0.35;
|
|
24
|
+
const EDGE_ALPHA_CONNECTED = 1.0;
|
|
25
|
+
const EDGE_ALPHA_DIMMED = 0.05;
|
|
26
|
+
const SEARCH_NON_MATCH_ALPHA = 0.15;
|
|
27
|
+
const GLOW_NODE_THRESHOLD = 2000;
|
|
28
|
+
const GLOW_RADIUS_MULTIPLIER = 1.5;
|
|
29
|
+
const GLOW_ALPHA = 0.2;
|
|
30
|
+
const CULL_MARGIN = 50;
|
|
31
|
+
const TWO_PI = Math.PI * 2;
|
|
32
|
+
|
|
33
|
+
/** Minimum node radius in screen pixels. Keeps nodes visible when zoomed out. */
|
|
34
|
+
const MIN_SCREEN_RADIUS = 2.5;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers (exported for testing)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute label visibility threshold from zoom level.
|
|
42
|
+
* At zoom 0.2 (zoomed out): threshold ~1.0 (only top ~5% visible).
|
|
43
|
+
* At zoom 2.0+: threshold ~0.0 (all visible).
|
|
44
|
+
*/
|
|
45
|
+
export function labelThreshold(zoom: number): number {
|
|
46
|
+
const t = Math.max(0, Math.min(1, (zoom - 0.2) / 1.8));
|
|
47
|
+
return 1 - t;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Compute visible rect in graph coordinates from canvas size + transform. */
|
|
51
|
+
export function visibleRect(
|
|
52
|
+
canvasWidth: number,
|
|
53
|
+
canvasHeight: number,
|
|
54
|
+
transform: { x: number; y: number; k: number },
|
|
55
|
+
margin: number = CULL_MARGIN,
|
|
56
|
+
): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
57
|
+
const { x, y, k } = transform;
|
|
58
|
+
return {
|
|
59
|
+
minX: (-x - margin) / k,
|
|
60
|
+
minY: (-y - margin) / k,
|
|
61
|
+
maxX: (canvasWidth - x + margin) / k,
|
|
62
|
+
maxY: (canvasHeight - y + margin) / k,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check if a node falls within the visible rect. */
|
|
67
|
+
function nodeInView(
|
|
68
|
+
node: PositionedNode,
|
|
69
|
+
rect: { minX: number; minY: number; maxX: number; maxY: number },
|
|
70
|
+
): boolean {
|
|
71
|
+
return (
|
|
72
|
+
node.x + node.radius >= rect.minX &&
|
|
73
|
+
node.x - node.radius <= rect.maxX &&
|
|
74
|
+
node.y + node.radius >= rect.minY &&
|
|
75
|
+
node.y - node.radius <= rect.maxY
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Check if an edge has at least one endpoint in view. */
|
|
80
|
+
function edgeInView(
|
|
81
|
+
edge: PositionedEdge,
|
|
82
|
+
rect: { minX: number; minY: number; maxX: number; maxY: number },
|
|
83
|
+
): boolean {
|
|
84
|
+
return (
|
|
85
|
+
(edge.sourceX >= rect.minX &&
|
|
86
|
+
edge.sourceX <= rect.maxX &&
|
|
87
|
+
edge.sourceY >= rect.minY &&
|
|
88
|
+
edge.sourceY <= rect.maxY) ||
|
|
89
|
+
(edge.targetX >= rect.minX &&
|
|
90
|
+
edge.targetX <= rect.maxX &&
|
|
91
|
+
edge.targetY >= rect.minY &&
|
|
92
|
+
edge.targetY <= rect.maxY)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Dash patterns for edge styles
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const DASH_PATTERNS: Record<string, number[]> = {
|
|
101
|
+
solid: [],
|
|
102
|
+
dashed: [6, 4],
|
|
103
|
+
dotted: [2, 3],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// GraphCanvasRenderer
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export class GraphCanvasRenderer {
|
|
111
|
+
private canvas: HTMLCanvasElement;
|
|
112
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
|
|
113
|
+
private ctx: CanvasRenderingContext2D;
|
|
114
|
+
private dpr: number;
|
|
115
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
|
|
116
|
+
private cssWidth = 0;
|
|
117
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
|
|
118
|
+
private cssHeight = 0;
|
|
119
|
+
|
|
120
|
+
constructor(canvas: HTMLCanvasElement) {
|
|
121
|
+
this.canvas = canvas;
|
|
122
|
+
this.ctx = canvas.getContext('2d')!;
|
|
123
|
+
this.dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Update canvas dimensions with DPR scaling. CSS size stays at css values. */
|
|
127
|
+
resize(width: number, height: number): void {
|
|
128
|
+
this.cssWidth = width;
|
|
129
|
+
this.cssHeight = height;
|
|
130
|
+
this.canvas.width = width * this.dpr;
|
|
131
|
+
this.canvas.height = height * this.dpr;
|
|
132
|
+
this.canvas.style.width = `${width}px`;
|
|
133
|
+
this.canvas.style.height = `${height}px`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Clear canvas and render the full graph state. */
|
|
137
|
+
render(state: GraphRenderState): void {
|
|
138
|
+
const { ctx, dpr, cssWidth, cssHeight } = this;
|
|
139
|
+
const {
|
|
140
|
+
nodes,
|
|
141
|
+
edges,
|
|
142
|
+
transform,
|
|
143
|
+
hoveredNodeId,
|
|
144
|
+
selectedNodeIds,
|
|
145
|
+
adjacencyMap,
|
|
146
|
+
theme,
|
|
147
|
+
searchMatches,
|
|
148
|
+
isGesturing,
|
|
149
|
+
} = state;
|
|
150
|
+
|
|
151
|
+
const hasActiveNode = hoveredNodeId !== null || selectedNodeIds.size > 0;
|
|
152
|
+
const activeNodeIds = new Set<string>();
|
|
153
|
+
if (hoveredNodeId) activeNodeIds.add(hoveredNodeId);
|
|
154
|
+
for (const id of selectedNodeIds) activeNodeIds.add(id);
|
|
155
|
+
|
|
156
|
+
// Collect all nodes connected to any active node
|
|
157
|
+
const connectedNodeIds = new Set<string>();
|
|
158
|
+
for (const id of activeNodeIds) {
|
|
159
|
+
connectedNodeIds.add(id);
|
|
160
|
+
const neighbors = adjacencyMap.get(id);
|
|
161
|
+
if (neighbors) {
|
|
162
|
+
for (const nid of neighbors) connectedNodeIds.add(nid);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Viewport culling
|
|
167
|
+
const rect = visibleRect(cssWidth, cssHeight, transform);
|
|
168
|
+
const visibleNodes = nodes.filter((n) => nodeInView(n, rect));
|
|
169
|
+
const visibleEdges = edges.filter((e) => edgeInView(e, rect));
|
|
170
|
+
|
|
171
|
+
const isDark = theme.isDark;
|
|
172
|
+
const showGlow = isDark && !isGesturing && visibleNodes.length < GLOW_NODE_THRESHOLD;
|
|
173
|
+
const threshold = labelThreshold(transform.k);
|
|
174
|
+
// Minimum radius in graph coordinates so nodes stay visible when zoomed out
|
|
175
|
+
const minRadius = MIN_SCREEN_RADIUS / transform.k;
|
|
176
|
+
|
|
177
|
+
// -- Clear and apply transform --
|
|
178
|
+
ctx.save();
|
|
179
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
180
|
+
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
181
|
+
|
|
182
|
+
// Fill background
|
|
183
|
+
ctx.fillStyle = theme.colors.background;
|
|
184
|
+
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
185
|
+
|
|
186
|
+
ctx.translate(transform.x, transform.y);
|
|
187
|
+
ctx.scale(transform.k, transform.k);
|
|
188
|
+
|
|
189
|
+
// -- Draw edges (batched by style key) --
|
|
190
|
+
this.drawEdgesBatched(
|
|
191
|
+
ctx,
|
|
192
|
+
visibleEdges,
|
|
193
|
+
hasActiveNode,
|
|
194
|
+
connectedNodeIds,
|
|
195
|
+
isGesturing ? null : searchMatches,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// -- Draw nodes (batched by fill color) --
|
|
199
|
+
this.drawNodesBatched(
|
|
200
|
+
ctx,
|
|
201
|
+
visibleNodes,
|
|
202
|
+
hoveredNodeId,
|
|
203
|
+
selectedNodeIds,
|
|
204
|
+
isGesturing ? null : searchMatches,
|
|
205
|
+
showGlow,
|
|
206
|
+
theme,
|
|
207
|
+
minRadius,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// -- Draw labels (skipped during gestures) --
|
|
211
|
+
if (!isGesturing) {
|
|
212
|
+
this.drawLabels(
|
|
213
|
+
ctx,
|
|
214
|
+
visibleNodes,
|
|
215
|
+
threshold,
|
|
216
|
+
hoveredNodeId,
|
|
217
|
+
selectedNodeIds,
|
|
218
|
+
searchMatches,
|
|
219
|
+
transform.k,
|
|
220
|
+
theme,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
ctx.restore();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// -------------------------------------------------------------------------
|
|
228
|
+
// Batched edge drawing
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
private drawEdgesBatched(
|
|
232
|
+
ctx: CanvasRenderingContext2D,
|
|
233
|
+
edges: PositionedEdge[],
|
|
234
|
+
hasActiveNode: boolean,
|
|
235
|
+
connectedNodeIds: Set<string>,
|
|
236
|
+
searchMatches: Set<string> | null,
|
|
237
|
+
): void {
|
|
238
|
+
// Classify edges by alpha level, then batch by visual style within each level
|
|
239
|
+
const dimmedEdges: PositionedEdge[] = [];
|
|
240
|
+
const defaultEdges: PositionedEdge[] = [];
|
|
241
|
+
const connectedEdges: PositionedEdge[] = [];
|
|
242
|
+
|
|
243
|
+
for (const edge of edges) {
|
|
244
|
+
const isConnected =
|
|
245
|
+
hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
|
|
246
|
+
const isDimmed = hasActiveNode && !isConnected;
|
|
247
|
+
|
|
248
|
+
if (isConnected) {
|
|
249
|
+
connectedEdges.push(edge);
|
|
250
|
+
} else if (isDimmed) {
|
|
251
|
+
dimmedEdges.push(edge);
|
|
252
|
+
} else {
|
|
253
|
+
defaultEdges.push(edge);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Draw dimmed first, then default, then connected (on top)
|
|
258
|
+
this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
|
|
259
|
+
this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
|
|
260
|
+
this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
|
|
265
|
+
* When search is inactive, all edges of the same style are drawn in a single path.
|
|
266
|
+
* When search is active, edges split by search-match status for alpha dimming.
|
|
267
|
+
*/
|
|
268
|
+
private drawEdgeGroupBatched(
|
|
269
|
+
ctx: CanvasRenderingContext2D,
|
|
270
|
+
edges: PositionedEdge[],
|
|
271
|
+
alpha: number,
|
|
272
|
+
searchMatches: Set<string> | null,
|
|
273
|
+
): void {
|
|
274
|
+
if (edges.length === 0) return;
|
|
275
|
+
|
|
276
|
+
// Group by visual key: stroke + strokeWidth + style
|
|
277
|
+
const groups = new Map<string, PositionedEdge[]>();
|
|
278
|
+
for (const edge of edges) {
|
|
279
|
+
const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
|
|
280
|
+
let group = groups.get(key);
|
|
281
|
+
if (!group) {
|
|
282
|
+
group = [];
|
|
283
|
+
groups.set(key, group);
|
|
284
|
+
}
|
|
285
|
+
group.push(edge);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const [, group] of groups) {
|
|
289
|
+
const sample = group[0];
|
|
290
|
+
const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
|
|
291
|
+
ctx.setLineDash(dash);
|
|
292
|
+
ctx.strokeStyle = sample.stroke;
|
|
293
|
+
ctx.lineWidth = sample.strokeWidth;
|
|
294
|
+
|
|
295
|
+
if (!searchMatches) {
|
|
296
|
+
// Fast path: single batched path for all edges in this group
|
|
297
|
+
ctx.globalAlpha = alpha;
|
|
298
|
+
ctx.beginPath();
|
|
299
|
+
for (const edge of group) {
|
|
300
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
301
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
302
|
+
}
|
|
303
|
+
ctx.stroke();
|
|
304
|
+
} else {
|
|
305
|
+
// Search active: split into matched and non-matched batches
|
|
306
|
+
ctx.globalAlpha = alpha;
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
let hasMatched = false;
|
|
309
|
+
|
|
310
|
+
const nonMatchPath: PositionedEdge[] = [];
|
|
311
|
+
|
|
312
|
+
for (const edge of group) {
|
|
313
|
+
const srcMatch = searchMatches.has(edge.source);
|
|
314
|
+
const tgtMatch = searchMatches.has(edge.target);
|
|
315
|
+
if (srcMatch || tgtMatch) {
|
|
316
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
317
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
318
|
+
hasMatched = true;
|
|
319
|
+
} else {
|
|
320
|
+
nonMatchPath.push(edge);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (hasMatched) ctx.stroke();
|
|
324
|
+
|
|
325
|
+
// Draw non-matching edges dimmed
|
|
326
|
+
if (nonMatchPath.length > 0) {
|
|
327
|
+
ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * alpha;
|
|
328
|
+
ctx.beginPath();
|
|
329
|
+
for (const edge of nonMatchPath) {
|
|
330
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
331
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
332
|
+
}
|
|
333
|
+
ctx.stroke();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
ctx.setLineDash([]);
|
|
339
|
+
ctx.globalAlpha = 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
// Batched node drawing
|
|
344
|
+
// -------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
private drawNodesBatched(
|
|
347
|
+
ctx: CanvasRenderingContext2D,
|
|
348
|
+
nodes: PositionedNode[],
|
|
349
|
+
hoveredNodeId: string | null,
|
|
350
|
+
selectedNodeIds: Set<string>,
|
|
351
|
+
searchMatches: Set<string> | null,
|
|
352
|
+
showGlow: boolean,
|
|
353
|
+
theme: GraphRenderState['theme'],
|
|
354
|
+
minRadius: number,
|
|
355
|
+
): void {
|
|
356
|
+
// Separate special nodes (hovered/selected) from bulk nodes.
|
|
357
|
+
// Special nodes need individual treatment; bulk nodes get batched by color.
|
|
358
|
+
const bulkNodes: PositionedNode[] = [];
|
|
359
|
+
const specialNodes: PositionedNode[] = [];
|
|
360
|
+
|
|
361
|
+
for (const node of nodes) {
|
|
362
|
+
if (node.id === hoveredNodeId || selectedNodeIds.has(node.id)) {
|
|
363
|
+
specialNodes.push(node);
|
|
364
|
+
} else {
|
|
365
|
+
bulkNodes.push(node);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Helper: effective radius clamped to minimum screen size
|
|
370
|
+
const r = (node: PositionedNode) => Math.max(node.radius, minRadius);
|
|
371
|
+
|
|
372
|
+
// --- Glow pass (dark mode only, before fills) ---
|
|
373
|
+
if (showGlow) {
|
|
374
|
+
this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- Bulk fill pass: batch by fill color ---
|
|
378
|
+
const fillGroups = new Map<string, PositionedNode[]>();
|
|
379
|
+
for (const node of bulkNodes) {
|
|
380
|
+
let group = fillGroups.get(node.fill);
|
|
381
|
+
if (!group) {
|
|
382
|
+
group = [];
|
|
383
|
+
fillGroups.set(node.fill, group);
|
|
384
|
+
}
|
|
385
|
+
group.push(node);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!searchMatches) {
|
|
389
|
+
// Fast path: no search dimming
|
|
390
|
+
ctx.globalAlpha = 1;
|
|
391
|
+
for (const [fill, group] of fillGroups) {
|
|
392
|
+
ctx.fillStyle = fill;
|
|
393
|
+
ctx.beginPath();
|
|
394
|
+
for (const node of group) {
|
|
395
|
+
const nr = r(node);
|
|
396
|
+
ctx.moveTo(node.x + nr, node.y);
|
|
397
|
+
ctx.arc(node.x, node.y, nr, 0, TWO_PI);
|
|
398
|
+
}
|
|
399
|
+
ctx.fill();
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
// Search active: split each color group into matched/dimmed batches
|
|
403
|
+
for (const [fill, group] of fillGroups) {
|
|
404
|
+
ctx.fillStyle = fill;
|
|
405
|
+
|
|
406
|
+
// Matched nodes
|
|
407
|
+
ctx.globalAlpha = 1;
|
|
408
|
+
ctx.beginPath();
|
|
409
|
+
let hasMatched = false;
|
|
410
|
+
const dimmedNodes: PositionedNode[] = [];
|
|
411
|
+
|
|
412
|
+
for (const node of group) {
|
|
413
|
+
if (searchMatches.has(node.id)) {
|
|
414
|
+
const nr = r(node);
|
|
415
|
+
ctx.moveTo(node.x + nr, node.y);
|
|
416
|
+
ctx.arc(node.x, node.y, nr, 0, TWO_PI);
|
|
417
|
+
hasMatched = true;
|
|
418
|
+
} else {
|
|
419
|
+
dimmedNodes.push(node);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (hasMatched) ctx.fill();
|
|
423
|
+
|
|
424
|
+
// Dimmed nodes
|
|
425
|
+
if (dimmedNodes.length > 0) {
|
|
426
|
+
ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
|
|
427
|
+
ctx.beginPath();
|
|
428
|
+
for (const node of dimmedNodes) {
|
|
429
|
+
const nr = r(node);
|
|
430
|
+
ctx.moveTo(node.x + nr, node.y);
|
|
431
|
+
ctx.arc(node.x, node.y, nr, 0, TWO_PI);
|
|
432
|
+
}
|
|
433
|
+
ctx.fill();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- Bulk stroke pass: batch by stroke color ---
|
|
439
|
+
const strokeGroups = new Map<string, PositionedNode[]>();
|
|
440
|
+
for (const node of bulkNodes) {
|
|
441
|
+
const key = `${node.stroke}|${node.strokeWidth}`;
|
|
442
|
+
let group = strokeGroups.get(key);
|
|
443
|
+
if (!group) {
|
|
444
|
+
group = [];
|
|
445
|
+
strokeGroups.set(key, group);
|
|
446
|
+
}
|
|
447
|
+
group.push(node);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const [key, group] of strokeGroups) {
|
|
451
|
+
const [stroke, widthStr] = key.split('|');
|
|
452
|
+
ctx.strokeStyle = stroke;
|
|
453
|
+
ctx.lineWidth = parseFloat(widthStr);
|
|
454
|
+
|
|
455
|
+
if (!searchMatches) {
|
|
456
|
+
ctx.globalAlpha = 1;
|
|
457
|
+
ctx.beginPath();
|
|
458
|
+
for (const node of group) {
|
|
459
|
+
const nr = r(node);
|
|
460
|
+
ctx.moveTo(node.x + nr, node.y);
|
|
461
|
+
ctx.arc(node.x, node.y, nr, 0, TWO_PI);
|
|
462
|
+
}
|
|
463
|
+
ctx.stroke();
|
|
464
|
+
} else {
|
|
465
|
+
// Split matched/dimmed for strokes too
|
|
466
|
+
ctx.globalAlpha = 1;
|
|
467
|
+
ctx.beginPath();
|
|
468
|
+
let hasMatched = false;
|
|
469
|
+
const dimmedNodes: PositionedNode[] = [];
|
|
470
|
+
|
|
471
|
+
for (const node of group) {
|
|
472
|
+
if (searchMatches.has(node.id)) {
|
|
473
|
+
const nr = r(node);
|
|
474
|
+
ctx.moveTo(node.x + nr, node.y);
|
|
475
|
+
ctx.arc(node.x, node.y, nr, 0, TWO_PI);
|
|
476
|
+
hasMatched = true;
|
|
477
|
+
} else {
|
|
478
|
+
dimmedNodes.push(node);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (hasMatched) ctx.stroke();
|
|
482
|
+
|
|
483
|
+
if (dimmedNodes.length > 0) {
|
|
484
|
+
ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
|
|
485
|
+
ctx.beginPath();
|
|
486
|
+
for (const node of dimmedNodes) {
|
|
487
|
+
const nr = r(node);
|
|
488
|
+
ctx.moveTo(node.x + nr, node.y);
|
|
489
|
+
ctx.arc(node.x, node.y, nr, 0, TWO_PI);
|
|
490
|
+
}
|
|
491
|
+
ctx.stroke();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- Special nodes (hovered/selected) drawn individually ---
|
|
497
|
+
for (const node of specialNodes) {
|
|
498
|
+
const isHovered = node.id === hoveredNodeId;
|
|
499
|
+
const isSelected = selectedNodeIds.has(node.id);
|
|
500
|
+
const dimmed = searchMatches !== null && !searchMatches.has(node.id);
|
|
501
|
+
const baseRadius = Math.max(node.radius, minRadius);
|
|
502
|
+
const radius = isHovered ? baseRadius * 1.15 : baseRadius;
|
|
503
|
+
|
|
504
|
+
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
505
|
+
|
|
506
|
+
// Glow for special nodes
|
|
507
|
+
if (showGlow && !dimmed) {
|
|
508
|
+
ctx.beginPath();
|
|
509
|
+
ctx.arc(node.x, node.y, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
|
|
510
|
+
ctx.fillStyle = node.fill;
|
|
511
|
+
ctx.globalAlpha = GLOW_ALPHA;
|
|
512
|
+
ctx.fill();
|
|
513
|
+
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Fill
|
|
517
|
+
ctx.beginPath();
|
|
518
|
+
ctx.arc(node.x, node.y, radius, 0, TWO_PI);
|
|
519
|
+
ctx.fillStyle = isHovered ? brighten(node.fill) : node.fill;
|
|
520
|
+
ctx.fill();
|
|
521
|
+
|
|
522
|
+
// Stroke
|
|
523
|
+
ctx.strokeStyle = node.stroke;
|
|
524
|
+
ctx.lineWidth = node.strokeWidth;
|
|
525
|
+
ctx.stroke();
|
|
526
|
+
|
|
527
|
+
// Selection ring
|
|
528
|
+
if (isSelected) {
|
|
529
|
+
ctx.beginPath();
|
|
530
|
+
ctx.arc(node.x, node.y, radius + 3, 0, TWO_PI);
|
|
531
|
+
ctx.strokeStyle = theme.colors.categorical[0] ?? '#3b82f6';
|
|
532
|
+
ctx.lineWidth = 2;
|
|
533
|
+
ctx.stroke();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
ctx.globalAlpha = 1;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Batch glow circles by fill color. */
|
|
541
|
+
private drawGlowBatched(
|
|
542
|
+
ctx: CanvasRenderingContext2D,
|
|
543
|
+
nodes: PositionedNode[],
|
|
544
|
+
searchMatches: Set<string> | null,
|
|
545
|
+
minRadius: number,
|
|
546
|
+
): void {
|
|
547
|
+
const glowGroups = new Map<string, PositionedNode[]>();
|
|
548
|
+
for (const node of nodes) {
|
|
549
|
+
if (searchMatches && !searchMatches.has(node.id)) continue;
|
|
550
|
+
let group = glowGroups.get(node.fill);
|
|
551
|
+
if (!group) {
|
|
552
|
+
group = [];
|
|
553
|
+
glowGroups.set(node.fill, group);
|
|
554
|
+
}
|
|
555
|
+
group.push(node);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
ctx.globalAlpha = GLOW_ALPHA;
|
|
559
|
+
for (const [fill, group] of glowGroups) {
|
|
560
|
+
ctx.fillStyle = fill;
|
|
561
|
+
ctx.beginPath();
|
|
562
|
+
for (const node of group) {
|
|
563
|
+
const gr = Math.max(node.radius, minRadius) * GLOW_RADIUS_MULTIPLIER;
|
|
564
|
+
ctx.moveTo(node.x + gr, node.y);
|
|
565
|
+
ctx.arc(node.x, node.y, gr, 0, TWO_PI);
|
|
566
|
+
}
|
|
567
|
+
ctx.fill();
|
|
568
|
+
}
|
|
569
|
+
ctx.globalAlpha = 1;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// -------------------------------------------------------------------------
|
|
573
|
+
// Labels (drawn individually, skipped during gestures)
|
|
574
|
+
// -------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
private drawLabels(
|
|
577
|
+
ctx: CanvasRenderingContext2D,
|
|
578
|
+
nodes: PositionedNode[],
|
|
579
|
+
threshold: number,
|
|
580
|
+
hoveredNodeId: string | null,
|
|
581
|
+
selectedNodeIds: Set<string>,
|
|
582
|
+
searchMatches: Set<string> | null,
|
|
583
|
+
zoom: number,
|
|
584
|
+
theme: GraphRenderState['theme'],
|
|
585
|
+
): void {
|
|
586
|
+
// Font size inversely scaled by zoom, clamped to readable range
|
|
587
|
+
const rawSize = 12 / zoom;
|
|
588
|
+
const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
|
|
589
|
+
|
|
590
|
+
ctx.font = `${fontSize}px ${theme.fonts.family}`;
|
|
591
|
+
ctx.textAlign = 'center';
|
|
592
|
+
ctx.textBaseline = 'top';
|
|
593
|
+
|
|
594
|
+
for (const node of nodes) {
|
|
595
|
+
if (!node.label) continue;
|
|
596
|
+
|
|
597
|
+
const isHovered = node.id === hoveredNodeId;
|
|
598
|
+
const isSelected = selectedNodeIds.has(node.id);
|
|
599
|
+
const forced = isHovered || isSelected;
|
|
600
|
+
const dimmed = searchMatches !== null && !searchMatches.has(node.id);
|
|
601
|
+
|
|
602
|
+
// LOD: skip labels below threshold unless forced
|
|
603
|
+
if (!forced && node.labelPriority < threshold) continue;
|
|
604
|
+
|
|
605
|
+
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
606
|
+
|
|
607
|
+
const labelY = node.y + node.radius + 3;
|
|
608
|
+
|
|
609
|
+
// Dark halo for readability
|
|
610
|
+
ctx.strokeStyle = theme.colors.background;
|
|
611
|
+
ctx.lineWidth = 3;
|
|
612
|
+
ctx.lineJoin = 'round';
|
|
613
|
+
ctx.strokeText(node.label, node.x, labelY);
|
|
614
|
+
|
|
615
|
+
// White/light text
|
|
616
|
+
ctx.fillStyle = theme.colors.text;
|
|
617
|
+
ctx.fillText(node.label, node.x, labelY);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
ctx.globalAlpha = 1;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// Color helpers
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Brighten a hex/rgb color by ~20% for hover effect.
|
|
630
|
+
* Quick and dirty approach: parse hex, lighten each channel.
|
|
631
|
+
*/
|
|
632
|
+
function brighten(color: string): string {
|
|
633
|
+
// Handle rgb(r,g,b) or rgb(r, g, b)
|
|
634
|
+
const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
635
|
+
if (rgbMatch) {
|
|
636
|
+
const r = Math.min(255, parseInt(rgbMatch[1], 10) + 40);
|
|
637
|
+
const g = Math.min(255, parseInt(rgbMatch[2], 10) + 40);
|
|
638
|
+
const b = Math.min(255, parseInt(rgbMatch[3], 10) + 40);
|
|
639
|
+
return `rgb(${r},${g},${b})`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Handle hex colors (#rgb and #rrggbb)
|
|
643
|
+
const hex = color.replace('#', '');
|
|
644
|
+
const full =
|
|
645
|
+
hex.length === 3
|
|
646
|
+
? hex
|
|
647
|
+
.split('')
|
|
648
|
+
.map((c) => c + c)
|
|
649
|
+
.join('')
|
|
650
|
+
: hex;
|
|
651
|
+
|
|
652
|
+
if (full.length === 6) {
|
|
653
|
+
const r = Math.min(255, parseInt(full.slice(0, 2), 16) + 40);
|
|
654
|
+
const g = Math.min(255, parseInt(full.slice(2, 4), 16) + 40);
|
|
655
|
+
const b = Math.min(255, parseInt(full.slice(4, 6), 16) + 40);
|
|
656
|
+
return `rgb(${r},${g},${b})`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return color;
|
|
660
|
+
}
|