@riebeckite/graph 0.0.0-stage → 0.0.37

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/ui.js ADDED
@@ -0,0 +1,2196 @@
1
+ // ui.tsx
2
+ import { useEffect, useMemo, useRef, useState } from "hono/jsx";
3
+
4
+ // src/animation.ts
5
+ function buildRevealSchedule(nodes) {
6
+ const dated = nodes.filter((node) => node.date !== null).sort(
7
+ (left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id)
8
+ );
9
+ const undated = nodes.filter((node) => node.date === null).sort((left, right) => left.id.localeCompare(right.id));
10
+ if (dated.length === 0) {
11
+ return [...undated].map((node, index) => ({
12
+ id: node.id,
13
+ at: (index + 1) / Math.max(undated.length, 1),
14
+ dated: false
15
+ }));
16
+ }
17
+ const datedShare = undated.length === 0 ? 1 : 0.85;
18
+ const entries = dated.map((node, index) => ({
19
+ id: node.id,
20
+ at: (index + 1) / dated.length * datedShare,
21
+ dated: true
22
+ }));
23
+ undated.forEach((node, index) => {
24
+ entries.push({
25
+ id: node.id,
26
+ at: datedShare + (index + 1) / undated.length * (1 - datedShare),
27
+ dated: false
28
+ });
29
+ });
30
+ return entries;
31
+ }
32
+ function hasDatedNodes(nodes) {
33
+ return nodes.some((node) => node.date !== null);
34
+ }
35
+
36
+ // src/query.ts
37
+ var SUPPORTED_OPERATORS = /* @__PURE__ */ new Set(["file", "path", "tag"]);
38
+ var TOKEN_PATTERN = /"[^"]*"|\S+/g;
39
+ function parseGraphQuery(input) {
40
+ const terms = [];
41
+ const unsupportedOperators = /* @__PURE__ */ new Set();
42
+ for (const rawToken of input.match(TOKEN_PATTERN) ?? []) {
43
+ let token = rawToken.trim();
44
+ if (token.length === 0) continue;
45
+ let negated = false;
46
+ if (token.startsWith("-") && token.length > 1) {
47
+ negated = true;
48
+ token = token.slice(1);
49
+ }
50
+ const separator = token.indexOf(":");
51
+ if (separator > 0) {
52
+ const operator = token.slice(0, separator).toLocaleLowerCase();
53
+ if (!/^[a-z]+$/.test(operator)) {
54
+ terms.push({ field: "text", value: unquote(token), negated });
55
+ continue;
56
+ }
57
+ if (!SUPPORTED_OPERATORS.has(operator)) {
58
+ unsupportedOperators.add(operator);
59
+ terms.push({
60
+ field: "unsupported",
61
+ value: unquote(token.slice(separator + 1)),
62
+ negated
63
+ });
64
+ continue;
65
+ }
66
+ terms.push({
67
+ field: operator,
68
+ value: normalizeTagValue(unquote(token.slice(separator + 1))),
69
+ negated
70
+ });
71
+ continue;
72
+ }
73
+ terms.push({ field: "text", value: unquote(token), negated });
74
+ }
75
+ return { terms, unsupportedOperators: [...unsupportedOperators] };
76
+ }
77
+ function matchesGraphQuery(node, query) {
78
+ for (const term of query.terms) {
79
+ const matched = matchesTerm(node, term);
80
+ if (term.negated ? matched : !matched) return false;
81
+ }
82
+ return true;
83
+ }
84
+ function normalizeGraphText(value) {
85
+ return value.normalize("NFKC").toLocaleLowerCase().replace(
86
+ /[ァ-ン]/g,
87
+ (character) => String.fromCharCode(character.charCodeAt(0) - 96)
88
+ );
89
+ }
90
+ function matchesTerm(node, term) {
91
+ const value = normalizeGraphText(term.value);
92
+ if (value.length === 0) return false;
93
+ switch (term.field) {
94
+ case "file":
95
+ return normalizeGraphText(node.title).includes(value);
96
+ case "path":
97
+ return normalizeGraphText(node.path).includes(value);
98
+ case "tag":
99
+ return node.tags.some((tag) => {
100
+ const normalized = normalizeGraphText(tag);
101
+ return normalized === value || normalized.startsWith(`${value}/`);
102
+ });
103
+ case "text":
104
+ return normalizeGraphText(node.title).includes(value) || normalizeGraphText(node.path).includes(value) || node.tags.some((tag) => normalizeGraphText(tag).includes(value));
105
+ case "unsupported":
106
+ return false;
107
+ }
108
+ }
109
+ function normalizeTagValue(value) {
110
+ return value.replace(/^#+/, "");
111
+ }
112
+ function unquote(value) {
113
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
114
+ return value.slice(1, -1);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ // src/settings.ts
120
+ var GRAPH_SETTINGS_RANGES = {
121
+ depth: { min: 0, max: 5, step: 1 },
122
+ textFade: { min: 0, max: 1, step: 0.05 },
123
+ nodeSize: { min: 0.5, max: 2.5, step: 0.1 },
124
+ linkThickness: { min: 0.25, max: 3, step: 0.05 },
125
+ centerForce: { min: 0, max: 1, step: 0.05 },
126
+ repelForce: { min: 0, max: 2e3, step: 10 },
127
+ linkForce: { min: 0, max: 1, step: 0.05 },
128
+ linkDistance: { min: 30, max: 500, step: 5 }
129
+ };
130
+ var DEFAULT_GRAPH_SETTINGS = Object.freeze({
131
+ search: "",
132
+ showTags: true,
133
+ showAttachments: true,
134
+ existingOnly: false,
135
+ showOrphans: true,
136
+ depth: 2,
137
+ arrows: false,
138
+ textFade: 0.35,
139
+ nodeSize: 1,
140
+ linkThickness: 1,
141
+ animate: false,
142
+ centerForce: 0.35,
143
+ repelForce: 600,
144
+ linkForce: 0.7,
145
+ linkDistance: 100,
146
+ groups: []
147
+ });
148
+ var GROUP_COLORS = [
149
+ "#e07a5f",
150
+ "#3d85c6",
151
+ "#6aa84f",
152
+ "#c27ba0",
153
+ "#e6b422",
154
+ "#7c6ee6"
155
+ ];
156
+ var MAX_GROUPS = 24;
157
+ function normalizeGraphSettings(value) {
158
+ const source = value !== null && typeof value === "object" ? value : {};
159
+ return {
160
+ search: text(source.search, DEFAULT_GRAPH_SETTINGS.search, 200),
161
+ showTags: flag(source.showTags, DEFAULT_GRAPH_SETTINGS.showTags),
162
+ showAttachments: flag(
163
+ source.showAttachments,
164
+ DEFAULT_GRAPH_SETTINGS.showAttachments
165
+ ),
166
+ existingOnly: flag(
167
+ source.existingOnly,
168
+ DEFAULT_GRAPH_SETTINGS.existingOnly
169
+ ),
170
+ showOrphans: flag(source.showOrphans, DEFAULT_GRAPH_SETTINGS.showOrphans),
171
+ depth: integer(source.depth, "depth", DEFAULT_GRAPH_SETTINGS.depth),
172
+ arrows: flag(source.arrows, DEFAULT_GRAPH_SETTINGS.arrows),
173
+ textFade: number(
174
+ source.textFade,
175
+ "textFade",
176
+ DEFAULT_GRAPH_SETTINGS.textFade
177
+ ),
178
+ nodeSize: number(
179
+ source.nodeSize,
180
+ "nodeSize",
181
+ DEFAULT_GRAPH_SETTINGS.nodeSize
182
+ ),
183
+ linkThickness: number(
184
+ source.linkThickness,
185
+ "linkThickness",
186
+ DEFAULT_GRAPH_SETTINGS.linkThickness
187
+ ),
188
+ animate: flag(source.animate, DEFAULT_GRAPH_SETTINGS.animate),
189
+ centerForce: number(
190
+ source.centerForce,
191
+ "centerForce",
192
+ DEFAULT_GRAPH_SETTINGS.centerForce
193
+ ),
194
+ repelForce: number(
195
+ source.repelForce,
196
+ "repelForce",
197
+ DEFAULT_GRAPH_SETTINGS.repelForce
198
+ ),
199
+ linkForce: number(
200
+ source.linkForce,
201
+ "linkForce",
202
+ DEFAULT_GRAPH_SETTINGS.linkForce
203
+ ),
204
+ linkDistance: number(
205
+ source.linkDistance,
206
+ "linkDistance",
207
+ DEFAULT_GRAPH_SETTINGS.linkDistance
208
+ ),
209
+ groups: groups(source.groups)
210
+ };
211
+ }
212
+ function readStoredGraphSettings(storage, key) {
213
+ try {
214
+ const raw = storage.getItem(key);
215
+ if (!raw) return null;
216
+ return normalizeGraphSettings(JSON.parse(raw));
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+ function writeStoredGraphSettings(storage, key, settings) {
222
+ try {
223
+ storage.setItem(key, JSON.stringify(settings));
224
+ } catch {
225
+ return;
226
+ }
227
+ }
228
+ function flag(value, fallback) {
229
+ return typeof value === "boolean" ? value : fallback;
230
+ }
231
+ function number(value, key, fallback) {
232
+ const range = GRAPH_SETTINGS_RANGES[key];
233
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
234
+ return clamp(value, range.min, range.max);
235
+ }
236
+ function integer(value, key, fallback) {
237
+ return Math.round(number(value, key, fallback));
238
+ }
239
+ function text(value, fallback, maxLength) {
240
+ return typeof value === "string" ? value.slice(0, maxLength) : fallback;
241
+ }
242
+ function groups(value) {
243
+ if (!Array.isArray(value)) return [];
244
+ const result = [];
245
+ const seen = /* @__PURE__ */ new Set();
246
+ for (const item of value) {
247
+ if (result.length >= MAX_GROUPS) break;
248
+ if (item === null || typeof item !== "object") continue;
249
+ const source = item;
250
+ if (typeof source.id !== "string" || source.id.length === 0) continue;
251
+ if (seen.has(source.id)) continue;
252
+ const query = text(source.query, "", 200);
253
+ if (query.trim().length === 0) continue;
254
+ seen.add(source.id);
255
+ result.push({
256
+ id: source.id.slice(0, 64),
257
+ query,
258
+ color: color(source.color, result.length)
259
+ });
260
+ }
261
+ return result;
262
+ }
263
+ function color(value, index) {
264
+ if (typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)) {
265
+ return value.toLocaleLowerCase();
266
+ }
267
+ return GROUP_COLORS[index % GROUP_COLORS.length] ?? "#6aa84f";
268
+ }
269
+ function clamp(value, min, max) {
270
+ return Math.min(Math.max(value, min), max);
271
+ }
272
+
273
+ // src/simulation.ts
274
+ var ALPHA_MIN = 1e-3;
275
+ var ALPHA_DECAY = 0.0228;
276
+ var VELOCITY_DECAY = 0.4;
277
+ var MAX_VELOCITY = 40;
278
+ var GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
279
+ function createGraphSimulation(input) {
280
+ const ids = [...new Set(input.nodeIds)].sort(
281
+ (left, right) => left.localeCompare(right)
282
+ );
283
+ const nodes = ids.map((id, index) => {
284
+ const previous = input.positions?.get(id);
285
+ if (previous && Number.isFinite(previous.x) && Number.isFinite(previous.y)) {
286
+ return {
287
+ id,
288
+ x: previous.x,
289
+ y: previous.y,
290
+ vx: 0,
291
+ vy: 0,
292
+ fx: null,
293
+ fy: null
294
+ };
295
+ }
296
+ if (id === input.centerId) {
297
+ return { id, x: 0, y: 0, vx: 0, vy: 0, fx: null, fy: null };
298
+ }
299
+ const radius = 18 + 13 * Math.sqrt(index);
300
+ const angle = index * GOLDEN_ANGLE + hash(id) % 360 * (Math.PI / 180);
301
+ return {
302
+ id,
303
+ x: Math.cos(angle) * radius,
304
+ y: Math.sin(angle) * radius,
305
+ vx: 0,
306
+ vy: 0,
307
+ fx: null,
308
+ fy: null
309
+ };
310
+ });
311
+ const indexById = new Map(nodes.map((node, index) => [node.id, index]));
312
+ const links = input.links.map((link) => ({
313
+ source: indexById.get(link.source),
314
+ target: indexById.get(link.target)
315
+ })).filter(
316
+ (link) => link.source !== void 0 && link.target !== void 0
317
+ );
318
+ let options = { ...input.options };
319
+ let alpha = 1;
320
+ let alphaTarget = 0;
321
+ let active = true;
322
+ function tick() {
323
+ if (!active && alpha < ALPHA_MIN) return false;
324
+ alpha += (alphaTarget - alpha) * ALPHA_DECAY;
325
+ applyCenterForce(nodes, options.centerForce, alpha);
326
+ applyLinkForce(nodes, links, options, alpha);
327
+ applyRepelForce(nodes, options.repelForce * alpha * 0.02);
328
+ for (const node of nodes) {
329
+ if (node.fx !== null && node.fy !== null) {
330
+ node.x = node.fx;
331
+ node.y = node.fy;
332
+ node.vx = 0;
333
+ node.vy = 0;
334
+ continue;
335
+ }
336
+ node.vx *= 1 - VELOCITY_DECAY;
337
+ node.vy *= 1 - VELOCITY_DECAY;
338
+ node.vx = clampVelocity(node.vx);
339
+ node.vy = clampVelocity(node.vy);
340
+ node.x += node.vx;
341
+ node.y += node.vy;
342
+ }
343
+ if (alpha < ALPHA_MIN) {
344
+ alpha = 0;
345
+ active = false;
346
+ }
347
+ return active;
348
+ }
349
+ return {
350
+ nodes,
351
+ tick,
352
+ reheat(value = 0.7) {
353
+ alpha = Math.max(alpha, value);
354
+ active = true;
355
+ },
356
+ setOptions(next) {
357
+ options = { ...next };
358
+ if (alpha < 0.05) {
359
+ alpha = 0.05;
360
+ active = true;
361
+ }
362
+ },
363
+ fix(id, x, y) {
364
+ const index = indexById.get(id);
365
+ const node = index === void 0 ? void 0 : nodes[index];
366
+ if (!node) return;
367
+ node.fx = x;
368
+ node.fy = y;
369
+ node.x = x;
370
+ node.y = y;
371
+ node.vx = 0;
372
+ node.vy = 0;
373
+ alpha = Math.max(alpha, 0.5);
374
+ active = true;
375
+ },
376
+ release(id) {
377
+ const index = indexById.get(id);
378
+ const node = index === void 0 ? void 0 : nodes[index];
379
+ if (!node) return;
380
+ node.fx = null;
381
+ node.fy = null;
382
+ alpha = Math.max(alpha, 0.45);
383
+ active = true;
384
+ },
385
+ stop() {
386
+ alpha = 0;
387
+ alphaTarget = 0;
388
+ active = false;
389
+ },
390
+ isActive() {
391
+ return active;
392
+ },
393
+ getAlpha() {
394
+ return alpha;
395
+ }
396
+ };
397
+ }
398
+ function applyCenterForce(nodes, centerForce, alpha) {
399
+ const strength = centerForce * 0.03 * alpha;
400
+ if (strength <= 0) return;
401
+ for (const node of nodes) {
402
+ node.vx -= node.x * strength;
403
+ node.vy -= node.y * strength;
404
+ }
405
+ }
406
+ function applyLinkForce(nodes, links, options, alpha) {
407
+ const strength = options.linkForce * 0.12 * alpha;
408
+ if (strength <= 0) return;
409
+ for (const link of links) {
410
+ const source = nodes[link.source];
411
+ const target = nodes[link.target];
412
+ if (!source || !target) continue;
413
+ const dx = target.x - source.x;
414
+ const dy = target.y - source.y;
415
+ const distance = Math.hypot(dx, dy);
416
+ if (distance < 1e-3) continue;
417
+ const force = (distance - options.linkDistance) / distance * strength;
418
+ source.vx += dx * force;
419
+ source.vy += dy * force;
420
+ target.vx -= dx * force;
421
+ target.vy -= dy * force;
422
+ }
423
+ }
424
+ function applyRepelForce(nodes, strength) {
425
+ if (strength <= 0 || nodes.length < 2) return;
426
+ if (nodes.length <= 250) {
427
+ applyExactRepulsion(nodes, strength);
428
+ return;
429
+ }
430
+ applyQuadtreeRepulsion(nodes, strength);
431
+ }
432
+ function applyExactRepulsion(nodes, strength) {
433
+ for (let left = 0; left < nodes.length; left += 1) {
434
+ const a = nodes[left];
435
+ if (!a) continue;
436
+ for (let right = left + 1; right < nodes.length; right += 1) {
437
+ const b = nodes[right];
438
+ if (!b) continue;
439
+ const dx = b.x - a.x;
440
+ const dy = b.y - a.y;
441
+ const distanceSquared = dx * dx + dy * dy + 1;
442
+ const force = strength / distanceSquared;
443
+ a.vx -= dx * force;
444
+ a.vy -= dy * force;
445
+ b.vx += dx * force;
446
+ b.vy += dy * force;
447
+ }
448
+ }
449
+ }
450
+ function applyQuadtreeRepulsion(nodes, strength) {
451
+ let minX = Number.POSITIVE_INFINITY;
452
+ let minY = Number.POSITIVE_INFINITY;
453
+ let maxX = Number.NEGATIVE_INFINITY;
454
+ let maxY = Number.NEGATIVE_INFINITY;
455
+ for (const node of nodes) {
456
+ minX = Math.min(minX, node.x);
457
+ minY = Math.min(minY, node.y);
458
+ maxX = Math.max(maxX, node.x);
459
+ maxY = Math.max(maxY, node.y);
460
+ }
461
+ const size = Math.max(maxX - minX, maxY - minY, 1) + 1;
462
+ const root = {
463
+ x: (minX + maxX - size) / 2,
464
+ y: (minY + maxY - size) / 2,
465
+ size,
466
+ mass: 0,
467
+ centerX: 0,
468
+ centerY: 0,
469
+ members: null,
470
+ children: null
471
+ };
472
+ for (let index = 0; index < nodes.length; index += 1) {
473
+ insertQuad(root, nodes, index, 0);
474
+ }
475
+ for (let index = 0; index < nodes.length; index += 1) {
476
+ const node = nodes[index];
477
+ if (node) applyQuadForce(root, nodes, node, index, strength);
478
+ }
479
+ }
480
+ function insertQuad(quad, nodes, index, depth) {
481
+ const node = nodes[index];
482
+ if (!node) return;
483
+ quad.centerX = (quad.centerX * quad.mass + node.x) / (quad.mass + 1);
484
+ quad.centerY = (quad.centerY * quad.mass + node.y) / (quad.mass + 1);
485
+ quad.mass += 1;
486
+ if (quad.children) {
487
+ insertIntoChild(quad, nodes, index, depth + 1);
488
+ return;
489
+ }
490
+ if (!quad.members) {
491
+ quad.members = [index];
492
+ return;
493
+ }
494
+ if (depth >= 16) {
495
+ quad.members.push(index);
496
+ return;
497
+ }
498
+ const members = quad.members;
499
+ quad.members = null;
500
+ const half = quad.size / 2;
501
+ quad.children = [
502
+ createQuad(quad.x, quad.y, half),
503
+ createQuad(quad.x + half, quad.y, half),
504
+ createQuad(quad.x, quad.y + half, half),
505
+ createQuad(quad.x + half, quad.y + half, half)
506
+ ];
507
+ for (const member of members) insertIntoChild(quad, nodes, member, depth + 1);
508
+ insertIntoChild(quad, nodes, index, depth + 1);
509
+ }
510
+ function insertIntoChild(quad, nodes, index, depth) {
511
+ const children = quad.children;
512
+ if (!children) return;
513
+ const node = nodes[index];
514
+ if (!node) return;
515
+ const half = quad.size / 2;
516
+ const right = node.x >= quad.x + half ? 1 : 0;
517
+ const bottom = node.y >= quad.y + half ? 2 : 0;
518
+ const child = children[right + bottom];
519
+ if (child) insertQuad(child, nodes, index, depth);
520
+ }
521
+ function createQuad(x, y, size) {
522
+ return {
523
+ x,
524
+ y,
525
+ size,
526
+ mass: 0,
527
+ centerX: 0,
528
+ centerY: 0,
529
+ members: null,
530
+ children: null
531
+ };
532
+ }
533
+ function applyQuadForce(quad, nodes, target, targetIndex, strength) {
534
+ if (quad.mass === 0) return;
535
+ const dx = quad.centerX - target.x;
536
+ const dy = quad.centerY - target.y;
537
+ const distanceSquared = dx * dx + dy * dy + 1;
538
+ if (!quad.children) {
539
+ for (const index of quad.members ?? []) {
540
+ if (index === targetIndex) continue;
541
+ const other = nodes[index];
542
+ if (other) applyPairRepulsion(target, other, strength);
543
+ }
544
+ return;
545
+ }
546
+ if (quad.size * quad.size < 0.81 * distanceSquared) {
547
+ const force = strength * quad.mass / distanceSquared;
548
+ target.vx -= dx * force;
549
+ target.vy -= dy * force;
550
+ return;
551
+ }
552
+ for (const child of quad.children) {
553
+ applyQuadForce(child, nodes, target, targetIndex, strength);
554
+ }
555
+ }
556
+ function applyPairRepulsion(a, b, strength) {
557
+ const dx = b.x - a.x;
558
+ const dy = b.y - a.y;
559
+ const distanceSquared = dx * dx + dy * dy + 1;
560
+ const force = strength / distanceSquared;
561
+ a.vx -= dx * force;
562
+ a.vy -= dy * force;
563
+ b.vx += dx * force;
564
+ b.vy += dy * force;
565
+ }
566
+ function clampVelocity(value) {
567
+ return Math.min(Math.max(value, -MAX_VELOCITY), MAX_VELOCITY);
568
+ }
569
+ function hash(value) {
570
+ let result = 2166136261;
571
+ for (let index = 0; index < value.length; index += 1) {
572
+ result ^= value.charCodeAt(index);
573
+ result = Math.imul(result, 16777619);
574
+ }
575
+ return result >>> 0;
576
+ }
577
+
578
+ // src/view.ts
579
+ function buildVisibleGraph(input) {
580
+ const { document: document2, settings, mode } = input;
581
+ const centerId = input.centerId ?? document2.centerId;
582
+ const nodeById = new Map(document2.nodes.map((node) => [node.id, node]));
583
+ const adjacency = buildAdjacency(document2);
584
+ const fullDegree = /* @__PURE__ */ new Map();
585
+ for (const link of document2.links) {
586
+ fullDegree.set(link.source, (fullDegree.get(link.source) ?? 0) + 1);
587
+ fullDegree.set(link.target, (fullDegree.get(link.target) ?? 0) + 1);
588
+ }
589
+ const search = parseGraphQuery(settings.search);
590
+ const groups2 = settings.groups.map((group) => ({
591
+ group,
592
+ query: parseGraphQuery(group.query)
593
+ }));
594
+ const unsupportedOperators = new Set(search.unsupportedOperators);
595
+ for (const entry of groups2) {
596
+ for (const operator of entry.query.unsupportedOperators) {
597
+ unsupportedOperators.add(operator);
598
+ }
599
+ }
600
+ const candidates = mode === "local" ? collectLocalNodeIds(adjacency, centerId, settings.depth) : new Set(document2.nodes.map((node) => node.id));
601
+ const visibleNodes = [];
602
+ for (const id of candidates) {
603
+ const node = nodeById.get(id);
604
+ if (!node) continue;
605
+ if (node.kind === "tag" && !settings.showTags) continue;
606
+ if (node.kind === "attachment" && !settings.showAttachments) continue;
607
+ if (node.kind === "unresolved" && settings.existingOnly) continue;
608
+ if (node.kind === "note" && !settings.showOrphans && (fullDegree.get(node.id) ?? 0) === 0) {
609
+ continue;
610
+ }
611
+ if (settings.search.trim().length > 0 && !matchesGraphQuery(node, search)) {
612
+ if (mode !== "local" || id !== centerId) continue;
613
+ }
614
+ visibleNodes.push({
615
+ ...node,
616
+ degree: 0,
617
+ radius: 0,
618
+ color: "",
619
+ groupId: null
620
+ });
621
+ }
622
+ const visibleIds = new Set(visibleNodes.map((node) => node.id));
623
+ const links = document2.links.filter(
624
+ (link) => visibleIds.has(link.source) && visibleIds.has(link.target)
625
+ );
626
+ const degree = /* @__PURE__ */ new Map();
627
+ for (const link of links) {
628
+ degree.set(link.source, (degree.get(link.source) ?? 0) + 1);
629
+ degree.set(link.target, (degree.get(link.target) ?? 0) + 1);
630
+ }
631
+ visibleNodes.sort((left, right) => left.id.localeCompare(right.id));
632
+ for (const node of visibleNodes) {
633
+ node.degree = degree.get(node.id) ?? 0;
634
+ node.radius = 3 + Math.sqrt(node.degree) * 1.7;
635
+ const group = findGroup(node, groups2);
636
+ node.groupId = group?.group.id ?? null;
637
+ node.color = group?.group.color ?? "";
638
+ }
639
+ return {
640
+ nodes: visibleNodes,
641
+ links,
642
+ centerId: centerId && visibleIds.has(centerId) ? centerId : null,
643
+ unsupportedOperators: [...unsupportedOperators]
644
+ };
645
+ }
646
+ function collectLocalNodeIds(adjacency, centerId, depth) {
647
+ const ids = /* @__PURE__ */ new Set();
648
+ if (!centerId) return ids;
649
+ ids.add(centerId);
650
+ let frontier = [centerId];
651
+ for (let level = 0; level < depth; level += 1) {
652
+ const next = [];
653
+ for (const id of frontier) {
654
+ for (const neighbor of adjacency.get(id) ?? []) {
655
+ if (ids.has(neighbor)) continue;
656
+ ids.add(neighbor);
657
+ next.push(neighbor);
658
+ }
659
+ }
660
+ if (next.length === 0) break;
661
+ frontier = next;
662
+ }
663
+ return ids;
664
+ }
665
+ function buildAdjacency(document2) {
666
+ const adjacency = /* @__PURE__ */ new Map();
667
+ for (const node of document2.nodes) adjacency.set(node.id, []);
668
+ for (const link of document2.links) {
669
+ adjacency.get(link.source)?.push(link.target);
670
+ adjacency.get(link.target)?.push(link.source);
671
+ }
672
+ return adjacency;
673
+ }
674
+ function findGroup(node, groups2) {
675
+ for (const entry of groups2) {
676
+ if (entry.group.query.trim().length === 0) continue;
677
+ if (entry.query.unsupportedOperators.length > 0) continue;
678
+ if (matchesGraphQuery(node, entry.query)) return entry;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ // client.ts
684
+ var MIN_SCALE = 0.12;
685
+ var MAX_SCALE = 4;
686
+ var DRAG_THRESHOLD_PX = 4;
687
+ var ANIMATION_DURATION_MS = 3200;
688
+ function createGraphRenderer(options) {
689
+ const canvas = options.canvas;
690
+ const context = canvas.getContext("2d");
691
+ if (!context) {
692
+ return createNoopRenderer();
693
+ }
694
+ const ctx = context;
695
+ const callbacks = options;
696
+ let input = null;
697
+ let visible = {
698
+ nodes: [],
699
+ links: [],
700
+ centerId: null,
701
+ unsupportedOperators: []
702
+ };
703
+ let simulation = null;
704
+ let signature = "";
705
+ let camera = { x: 0, y: 0, scale: 1 };
706
+ let size = { width: 0, height: 0 };
707
+ let hoveredId = null;
708
+ let selectedId = null;
709
+ let drag = null;
710
+ const pointers = /* @__PURE__ */ new Map();
711
+ let pinch = null;
712
+ let frameHandle = null;
713
+ let paused = false;
714
+ let destroyed = false;
715
+ let animating = false;
716
+ let animationStart = 0;
717
+ let animationCutoff = 1;
718
+ let pendingFit = false;
719
+ let reveal = /* @__PURE__ */ new Map();
720
+ let colors = readColors(canvas);
721
+ const reducedMotion = options.reducedMotion ?? false;
722
+ const resizeObserver = new ResizeObserver(() => resize());
723
+ const intersectionObserver = new IntersectionObserver((entries) => {
724
+ paused = entries.every((entry) => !entry.isIntersecting);
725
+ if (!paused) requestFrame();
726
+ });
727
+ const mutationObserver = new MutationObserver(() => {
728
+ colors = readColors(canvas);
729
+ requestFrame();
730
+ });
731
+ const onVisibilityChange = () => {
732
+ paused = document.visibilityState === "hidden";
733
+ if (!paused) requestFrame();
734
+ };
735
+ resizeObserver.observe(canvas);
736
+ intersectionObserver.observe(canvas);
737
+ mutationObserver.observe(document.documentElement, {
738
+ attributes: true,
739
+ attributeFilter: ["data-rb-mode", "data-rb-theme", "class", "style"]
740
+ });
741
+ document.addEventListener("visibilitychange", onVisibilityChange);
742
+ canvas.addEventListener("pointerdown", onPointerDown);
743
+ canvas.addEventListener("pointermove", onPointerMove);
744
+ canvas.addEventListener("pointerup", onPointerUp);
745
+ canvas.addEventListener("pointercancel", onPointerUp);
746
+ canvas.addEventListener("pointerleave", onPointerLeave);
747
+ canvas.addEventListener("wheel", onWheel, { passive: false });
748
+ canvas.addEventListener("dblclick", onDoubleClick);
749
+ canvas.addEventListener("contextmenu", onContextMenu);
750
+ canvas.addEventListener("keydown", onKeyDown);
751
+ resize();
752
+ return {
753
+ update,
754
+ setSelected,
755
+ zoomBy,
756
+ resetView,
757
+ focusNode,
758
+ playAnimation,
759
+ stopAnimation,
760
+ isAnimating: () => animating,
761
+ destroy
762
+ };
763
+ function update(next) {
764
+ if (destroyed) return;
765
+ const previousMode = input?.mode;
766
+ const previousCenter = input?.centerId;
767
+ const previousDocument = input?.document;
768
+ input = next;
769
+ visible = buildVisibleGraph({
770
+ document: next.document,
771
+ settings: next.settings,
772
+ mode: next.mode,
773
+ centerId: next.centerId
774
+ });
775
+ selectedId = next.selectedId ?? null;
776
+ reveal = new Map(
777
+ buildRevealSchedule(visible.nodes).map((entry) => [entry.id, entry.at])
778
+ );
779
+ const nextSignature = visibleSignature(visible);
780
+ const positions = simulation ? new Map(
781
+ simulation.nodes.map((node) => [node.id, { x: node.x, y: node.y }])
782
+ ) : void 0;
783
+ if (nextSignature !== signature || !simulation) {
784
+ signature = nextSignature;
785
+ simulation = createGraphSimulation({
786
+ nodeIds: visible.nodes.map((node) => node.id),
787
+ links: visible.links,
788
+ options: forceOptions(next.settings),
789
+ positions,
790
+ centerId: visible.centerId
791
+ });
792
+ if (!previousDocument || previousDocument !== next.document || previousMode !== next.mode || previousCenter !== next.centerId) {
793
+ pendingFit = true;
794
+ fitView();
795
+ }
796
+ } else {
797
+ simulation.setOptions(forceOptions(next.settings));
798
+ }
799
+ if (!next.settings.animate) {
800
+ animating = false;
801
+ animationCutoff = 1;
802
+ }
803
+ requestFrame();
804
+ }
805
+ function setSelected(id) {
806
+ if (selectedId === id) return;
807
+ selectedId = id;
808
+ requestFrame();
809
+ }
810
+ function resize() {
811
+ const rect = canvas.getBoundingClientRect();
812
+ const width = Math.max(1, Math.round(rect.width));
813
+ const height = Math.max(1, Math.round(rect.height));
814
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
815
+ if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
816
+ canvas.width = Math.round(width * dpr);
817
+ canvas.height = Math.round(height * dpr);
818
+ }
819
+ size = { width, height };
820
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
821
+ if (camera.x === 0 && camera.y === 0) {
822
+ camera = { x: width / 2, y: height / 2, scale: 1 };
823
+ }
824
+ requestFrame();
825
+ }
826
+ function fitView() {
827
+ if (visible.nodes.length === 0) {
828
+ camera = { x: size.width / 2, y: size.height / 2, scale: 1 };
829
+ return;
830
+ }
831
+ const positions = simulation?.nodes ?? [];
832
+ let minX = Number.POSITIVE_INFINITY;
833
+ let minY = Number.POSITIVE_INFINITY;
834
+ let maxX = Number.NEGATIVE_INFINITY;
835
+ let maxY = Number.NEGATIVE_INFINITY;
836
+ for (const node of positions) {
837
+ minX = Math.min(minX, node.x);
838
+ minY = Math.min(minY, node.y);
839
+ maxX = Math.max(maxX, node.x);
840
+ maxY = Math.max(maxY, node.y);
841
+ }
842
+ if (!Number.isFinite(minX)) {
843
+ camera = { x: size.width / 2, y: size.height / 2, scale: 1 };
844
+ return;
845
+ }
846
+ const boundsWidth = Math.max(maxX - minX, 80);
847
+ const boundsHeight = Math.max(maxY - minY, 80);
848
+ const scale = clamp2(
849
+ Math.min(
850
+ (size.width - 80) / boundsWidth,
851
+ (size.height - 80) / boundsHeight
852
+ ),
853
+ 0.2,
854
+ 2
855
+ );
856
+ const centerX = (minX + maxX) / 2;
857
+ const centerY = (minY + maxY) / 2;
858
+ camera = {
859
+ x: size.width / 2 - centerX * scale,
860
+ y: size.height / 2 - centerY * scale,
861
+ scale
862
+ };
863
+ }
864
+ function resetView() {
865
+ pendingFit = false;
866
+ fitView();
867
+ requestFrame();
868
+ }
869
+ function focusNode(id) {
870
+ const node = simulation?.nodes.find((entry) => entry.id === id);
871
+ if (!node) return;
872
+ pendingFit = false;
873
+ camera = {
874
+ ...camera,
875
+ x: size.width / 2 - node.x * camera.scale,
876
+ y: size.height / 2 - node.y * camera.scale
877
+ };
878
+ requestFrame();
879
+ }
880
+ function zoomBy(factor, origin) {
881
+ pendingFit = false;
882
+ const center = origin ?? { x: size.width / 2, y: size.height / 2 };
883
+ const scale = clamp2(camera.scale * factor, MIN_SCALE, MAX_SCALE);
884
+ if (scale === camera.scale) return;
885
+ const worldX = (center.x - camera.x) / camera.scale;
886
+ const worldY = (center.y - camera.y) / camera.scale;
887
+ camera = {
888
+ x: center.x - worldX * scale,
889
+ y: center.y - worldY * scale,
890
+ scale
891
+ };
892
+ requestFrame();
893
+ }
894
+ function playAnimation() {
895
+ if (reducedMotion || visible.nodes.length === 0) {
896
+ animationCutoff = 1;
897
+ animating = false;
898
+ callbacks.onAnimationChange?.(false);
899
+ requestFrame();
900
+ return;
901
+ }
902
+ animationStart = performance.now();
903
+ animationCutoff = 0;
904
+ animating = true;
905
+ callbacks.onAnimationChange?.(true);
906
+ requestFrame();
907
+ }
908
+ function stopAnimation() {
909
+ animating = false;
910
+ animationCutoff = 1;
911
+ callbacks.onAnimationChange?.(false);
912
+ requestFrame();
913
+ }
914
+ function requestFrame() {
915
+ if (destroyed || paused || frameHandle !== null) return;
916
+ frameHandle = requestAnimationFrame(frame);
917
+ }
918
+ function frame(time) {
919
+ frameHandle = null;
920
+ if (destroyed) return;
921
+ if (animating) {
922
+ const progress = clamp2(
923
+ (time - animationStart) / ANIMATION_DURATION_MS,
924
+ 0,
925
+ 1
926
+ );
927
+ animationCutoff = progress;
928
+ if (progress >= 1) {
929
+ animating = false;
930
+ callbacks.onAnimationChange?.(false);
931
+ }
932
+ }
933
+ const active = simulation?.tick() ?? false;
934
+ if (pendingFit && simulation && !active) {
935
+ pendingFit = false;
936
+ fitView();
937
+ }
938
+ draw();
939
+ if (active || animating) requestFrame();
940
+ }
941
+ function draw() {
942
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
943
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
944
+ ctx.clearRect(0, 0, size.width, size.height);
945
+ ctx.fillStyle = colors.background;
946
+ ctx.fillRect(0, 0, size.width, size.height);
947
+ const related = relatedIds();
948
+ const dimOthers = related.size > 0;
949
+ const selected = selectedId;
950
+ const labelThreshold = 0.35 + (input?.settings.textFade ?? 0.35) * 1.1;
951
+ const labelAlpha = clamp2((camera.scale - labelThreshold) / 0.4, 0, 1);
952
+ const nodes = simulation?.nodes ?? [];
953
+ const simulationById = new Map(nodes.map((node) => [node.id, node]));
954
+ const positions = /* @__PURE__ */ new Map();
955
+ for (const node of visible.nodes) {
956
+ const revealAt = reveal.get(node.id);
957
+ if (revealAt !== void 0 && revealAt > animationCutoff) {
958
+ continue;
959
+ }
960
+ const simulationNode = simulationById.get(node.id);
961
+ if (!simulationNode) continue;
962
+ const x = simulationNode.x;
963
+ const y = simulationNode.y;
964
+ positions.set(node.id, {
965
+ node,
966
+ x,
967
+ y,
968
+ radius: Math.max(2, node.radius * (input?.settings.nodeSize ?? 1))
969
+ });
970
+ }
971
+ const linkThickness = input?.settings.linkThickness ?? 1;
972
+ ctx.lineCap = "round";
973
+ for (const link of visible.links) {
974
+ const source = positions.get(link.source);
975
+ const target = positions.get(link.target);
976
+ if (!source || !target) continue;
977
+ const emphasized = related.has(link.source) && related.has(link.target);
978
+ const alpha = dimOthers ? emphasized ? 0.72 : 0.06 : 0.28;
979
+ const [sx, sy] = toScreen(source.x, source.y);
980
+ const [tx, ty] = toScreen(target.x, target.y);
981
+ if (!segmentVisible(sx, sy, tx, ty)) continue;
982
+ ctx.strokeStyle = withAlpha(colors.edge, alpha);
983
+ ctx.lineWidth = Math.max(
984
+ 0.5,
985
+ linkThickness * (emphasized ? 1.15 : 0.7) * Math.sqrt(camera.scale)
986
+ );
987
+ ctx.beginPath();
988
+ ctx.moveTo(sx, sy);
989
+ ctx.lineTo(tx, ty);
990
+ ctx.stroke();
991
+ if (input?.settings.arrows) {
992
+ drawArrow(sx, sy, tx, ty, alpha);
993
+ }
994
+ }
995
+ const labelBudget = visible.nodes.length > 900 ? 0 : visible.nodes.length > 500 ? 120 : 400;
996
+ let labelsDrawn = 0;
997
+ const labelBounds = [];
998
+ for (const entry of positions.values()) {
999
+ const { node, x, y, radius } = entry;
1000
+ const [sx, sy] = toScreen(x, y);
1001
+ const screenRadius = Math.max(1.5, radius * camera.scale);
1002
+ if (sx < -screenRadius || sy < -screenRadius || sx > size.width + screenRadius || sy > size.height + screenRadius) {
1003
+ continue;
1004
+ }
1005
+ const isRelated = related.has(node.id);
1006
+ const alpha = dimOthers && !isRelated ? 0.15 : 0.92;
1007
+ const isSelected = selected === node.id;
1008
+ const isHovered = hoveredId === node.id;
1009
+ if (isSelected || isHovered) {
1010
+ ctx.beginPath();
1011
+ ctx.arc(sx, sy, screenRadius + 3, 0, Math.PI * 2);
1012
+ ctx.strokeStyle = withAlpha(colors.focus, isSelected ? 0.9 : 0.5);
1013
+ ctx.lineWidth = 1.5;
1014
+ ctx.stroke();
1015
+ }
1016
+ ctx.beginPath();
1017
+ ctx.arc(sx, sy, screenRadius, 0, Math.PI * 2);
1018
+ ctx.fillStyle = withAlpha(nodeColor(node), alpha);
1019
+ ctx.fill();
1020
+ ctx.strokeStyle = withAlpha(colors.background, 0.8);
1021
+ ctx.lineWidth = 1;
1022
+ ctx.stroke();
1023
+ const canDrawLabel = labelAlpha > 0.02 && (labelBudget === 0 ? isSelected || isHovered || isRelated : labelsDrawn < labelBudget);
1024
+ if (!canDrawLabel) continue;
1025
+ labelsDrawn += 1;
1026
+ const title = truncateLabel(node.title);
1027
+ ctx.font = "11px ui-sans-serif, system-ui, sans-serif";
1028
+ ctx.textBaseline = "middle";
1029
+ ctx.textAlign = "left";
1030
+ const labelWidth = ctx.measureText(title).width;
1031
+ let labelX = sx + screenRadius + 6;
1032
+ let left = labelX;
1033
+ let right = labelX + labelWidth;
1034
+ if (right > size.width - 4) {
1035
+ labelX = sx - screenRadius - 6;
1036
+ ctx.textAlign = "right";
1037
+ left = labelX - labelWidth;
1038
+ right = labelX;
1039
+ }
1040
+ const labelY = sy;
1041
+ const top = labelY - 7;
1042
+ const bottom = labelY + 7;
1043
+ if (top < 4 || bottom > size.height - 4 || labelBounds.some(
1044
+ (bounds) => left < bounds.right && right > bounds.left && top < bounds.bottom && bottom > bounds.top
1045
+ )) {
1046
+ continue;
1047
+ }
1048
+ labelBounds.push({ left, top, right, bottom });
1049
+ ctx.strokeStyle = withAlpha(colors.background, 0.85 * labelAlpha * alpha);
1050
+ ctx.lineWidth = 3;
1051
+ ctx.strokeText(title, labelX, labelY);
1052
+ ctx.fillStyle = withAlpha(colors.label, labelAlpha * alpha);
1053
+ ctx.fillText(title, labelX, labelY);
1054
+ }
1055
+ }
1056
+ function drawArrow(sx, sy, tx, ty, alpha) {
1057
+ const angle = Math.atan2(ty - sy, tx - sx);
1058
+ const length = 5;
1059
+ const x = tx - Math.cos(angle) * 4;
1060
+ const y = ty - Math.sin(angle) * 4;
1061
+ ctx.fillStyle = withAlpha(colors.edge, Math.min(0.8, alpha + 0.1));
1062
+ ctx.beginPath();
1063
+ ctx.moveTo(x, y);
1064
+ ctx.lineTo(
1065
+ x - Math.cos(angle - 0.45) * length,
1066
+ y - Math.sin(angle - 0.45) * length
1067
+ );
1068
+ ctx.lineTo(
1069
+ x - Math.cos(angle + 0.45) * length,
1070
+ y - Math.sin(angle + 0.45) * length
1071
+ );
1072
+ ctx.closePath();
1073
+ ctx.fill();
1074
+ }
1075
+ function toScreen(x, y) {
1076
+ return [camera.x + x * camera.scale, camera.y + y * camera.scale];
1077
+ }
1078
+ function toWorld(clientX, clientY) {
1079
+ const rect = canvas.getBoundingClientRect();
1080
+ return {
1081
+ x: (clientX - rect.left - camera.x) / camera.scale,
1082
+ y: (clientY - rect.top - camera.y) / camera.scale
1083
+ };
1084
+ }
1085
+ function hitTest(clientX, clientY) {
1086
+ if (!input) return null;
1087
+ const rect = canvas.getBoundingClientRect();
1088
+ const x = clientX - rect.left;
1089
+ const y = clientY - rect.top;
1090
+ let best = null;
1091
+ const nodes = simulation?.nodes ?? [];
1092
+ const simulationById = new Map(nodes.map((node) => [node.id, node]));
1093
+ for (const node of visible.nodes) {
1094
+ if ((reveal.get(node.id) ?? 0) > animationCutoff) continue;
1095
+ const simulationNode = simulationById.get(node.id);
1096
+ if (!simulationNode) continue;
1097
+ const [sx, sy] = toScreen(simulationNode.x, simulationNode.y);
1098
+ const radius = Math.max(
1099
+ 6,
1100
+ node.radius * (input.settings.nodeSize ?? 1) * camera.scale + 4
1101
+ );
1102
+ const distance = Math.hypot(x - sx, y - sy);
1103
+ if (distance <= radius && (!best || distance < best.distance)) {
1104
+ best = { node, distance };
1105
+ }
1106
+ }
1107
+ return best?.node ?? null;
1108
+ }
1109
+ function onPointerDown(event) {
1110
+ if (event.button !== 0 && event.pointerType === "mouse") return;
1111
+ pendingFit = false;
1112
+ canvas.focus({ preventScroll: true });
1113
+ canvas.setPointerCapture(event.pointerId);
1114
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
1115
+ if (pointers.size === 2) {
1116
+ const [first, second] = [...pointers.values()];
1117
+ if (first && second) {
1118
+ pinch = {
1119
+ distance: Math.hypot(second.x - first.x, second.y - first.y),
1120
+ center: {
1121
+ x: (first.x + second.x) / 2,
1122
+ y: (first.y + second.y) / 2
1123
+ }
1124
+ };
1125
+ if (drag?.kind === "node" && simulation) {
1126
+ simulation.release(drag.id);
1127
+ }
1128
+ drag = null;
1129
+ }
1130
+ return;
1131
+ }
1132
+ const node = hitTest(event.clientX, event.clientY);
1133
+ if (node) {
1134
+ selectedId = node.id;
1135
+ callbacks.onSelect?.(node);
1136
+ drag = {
1137
+ kind: "node",
1138
+ id: node.id,
1139
+ startX: event.clientX,
1140
+ startY: event.clientY,
1141
+ moved: false
1142
+ };
1143
+ requestFrame();
1144
+ return;
1145
+ }
1146
+ drag = {
1147
+ kind: "pan",
1148
+ lastX: event.clientX,
1149
+ lastY: event.clientY,
1150
+ moved: false
1151
+ };
1152
+ }
1153
+ function onPointerMove(event) {
1154
+ if (pointers.has(event.pointerId)) {
1155
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
1156
+ }
1157
+ if (pinch && pointers.size >= 2) {
1158
+ const [first, second] = [...pointers.values()];
1159
+ if (!first || !second) return;
1160
+ const distance = Math.hypot(second.x - first.x, second.y - first.y);
1161
+ const center = {
1162
+ x: (first.x + second.x) / 2,
1163
+ y: (first.y + second.y) / 2
1164
+ };
1165
+ const rect = canvas.getBoundingClientRect();
1166
+ camera = {
1167
+ ...camera,
1168
+ x: camera.x + center.x - pinch.center.x,
1169
+ y: camera.y + center.y - pinch.center.y
1170
+ };
1171
+ if (pinch.distance > 0) {
1172
+ zoomBy(distance / pinch.distance, {
1173
+ x: center.x - rect.left,
1174
+ y: center.y - rect.top
1175
+ });
1176
+ }
1177
+ pinch = { distance, center };
1178
+ return;
1179
+ }
1180
+ if (drag?.kind === "node") {
1181
+ const moved = drag.moved || Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) >= DRAG_THRESHOLD_PX;
1182
+ if (!moved) return;
1183
+ drag.moved = true;
1184
+ const point = toWorld(event.clientX, event.clientY);
1185
+ simulation?.fix(drag.id, point.x, point.y);
1186
+ requestFrame();
1187
+ return;
1188
+ }
1189
+ if (drag?.kind === "pan") {
1190
+ const dx = event.clientX - drag.lastX;
1191
+ const dy = event.clientY - drag.lastY;
1192
+ if (Math.abs(dx) + Math.abs(dy) > 0) drag.moved = true;
1193
+ camera = { ...camera, x: camera.x + dx, y: camera.y + dy };
1194
+ drag.lastX = event.clientX;
1195
+ drag.lastY = event.clientY;
1196
+ requestFrame();
1197
+ return;
1198
+ }
1199
+ const node = hitTest(event.clientX, event.clientY);
1200
+ const nextHovered = node?.id ?? null;
1201
+ canvas.style.cursor = node ? "pointer" : "grab";
1202
+ if (hoveredId !== nextHovered) {
1203
+ hoveredId = nextHovered;
1204
+ callbacks.onHover?.(node);
1205
+ requestFrame();
1206
+ }
1207
+ }
1208
+ function onPointerUp(event) {
1209
+ pointers.delete(event.pointerId);
1210
+ if (canvas.hasPointerCapture(event.pointerId)) {
1211
+ canvas.releasePointerCapture(event.pointerId);
1212
+ }
1213
+ if (pointers.size < 2) pinch = null;
1214
+ const currentDrag = drag;
1215
+ drag = null;
1216
+ if (!currentDrag) return;
1217
+ if (currentDrag.kind === "node") {
1218
+ if (simulation) simulation.release(currentDrag.id);
1219
+ if (!currentDrag.moved) {
1220
+ const node = visible.nodes.find((entry) => entry.id === currentDrag.id);
1221
+ if (node) callbacks.onOpen?.(node);
1222
+ }
1223
+ } else if (!currentDrag.moved) {
1224
+ selectedId = null;
1225
+ callbacks.onSelect?.(null);
1226
+ }
1227
+ requestFrame();
1228
+ }
1229
+ function onPointerLeave() {
1230
+ if (drag) return;
1231
+ if (hoveredId !== null) {
1232
+ hoveredId = null;
1233
+ callbacks.onHover?.(null);
1234
+ requestFrame();
1235
+ }
1236
+ }
1237
+ function onWheel(event) {
1238
+ event.preventDefault();
1239
+ const rect = canvas.getBoundingClientRect();
1240
+ zoomBy(Math.exp(-event.deltaY * 15e-4), {
1241
+ x: event.clientX - rect.left,
1242
+ y: event.clientY - rect.top
1243
+ });
1244
+ }
1245
+ function onDoubleClick(event) {
1246
+ const node = hitTest(event.clientX, event.clientY);
1247
+ if (node) {
1248
+ callbacks.onOpen?.(node);
1249
+ return;
1250
+ }
1251
+ resetView();
1252
+ }
1253
+ function onContextMenu(event) {
1254
+ const node = hitTest(event.clientX, event.clientY);
1255
+ if (!node) return;
1256
+ event.preventDefault();
1257
+ callbacks.onContextMenu?.(node, event.clientX, event.clientY);
1258
+ }
1259
+ function onKeyDown(event) {
1260
+ const step = event.shiftKey ? 120 : 40;
1261
+ switch (event.key) {
1262
+ case "+":
1263
+ case "=":
1264
+ event.preventDefault();
1265
+ zoomBy(1.2);
1266
+ return;
1267
+ case "-":
1268
+ case "_":
1269
+ event.preventDefault();
1270
+ zoomBy(1 / 1.2);
1271
+ return;
1272
+ case "ArrowLeft":
1273
+ event.preventDefault();
1274
+ pendingFit = false;
1275
+ camera = { ...camera, x: camera.x + step };
1276
+ break;
1277
+ case "ArrowRight":
1278
+ event.preventDefault();
1279
+ pendingFit = false;
1280
+ camera = { ...camera, x: camera.x - step };
1281
+ break;
1282
+ case "ArrowUp":
1283
+ event.preventDefault();
1284
+ pendingFit = false;
1285
+ camera = { ...camera, y: camera.y + step };
1286
+ break;
1287
+ case "ArrowDown":
1288
+ event.preventDefault();
1289
+ pendingFit = false;
1290
+ camera = { ...camera, y: camera.y - step };
1291
+ break;
1292
+ case "Escape":
1293
+ selectedId = null;
1294
+ callbacks.onSelect?.(null);
1295
+ break;
1296
+ case "Enter": {
1297
+ const node = visible.nodes.find(
1298
+ (entry) => entry.id === (hoveredId ?? selectedId)
1299
+ ) ?? null;
1300
+ if (node) {
1301
+ event.preventDefault();
1302
+ callbacks.onOpen?.(node);
1303
+ }
1304
+ return;
1305
+ }
1306
+ case "n":
1307
+ case "N":
1308
+ case "p":
1309
+ case "P": {
1310
+ const index = visible.nodes.findIndex(
1311
+ (entry) => entry.id === selectedId
1312
+ );
1313
+ if (visible.nodes.length === 0) return;
1314
+ event.preventDefault();
1315
+ const direction = event.key.toLowerCase() === "n" ? 1 : -1;
1316
+ const next = visible.nodes[(index + direction + visible.nodes.length) % visible.nodes.length];
1317
+ if (next) {
1318
+ selectedId = next.id;
1319
+ callbacks.onSelect?.(next);
1320
+ focusNode(next.id);
1321
+ requestFrame();
1322
+ }
1323
+ return;
1324
+ }
1325
+ default:
1326
+ return;
1327
+ }
1328
+ requestFrame();
1329
+ }
1330
+ function relatedIds() {
1331
+ const origin = hoveredId;
1332
+ const related = /* @__PURE__ */ new Set();
1333
+ if (!origin) return related;
1334
+ related.add(origin);
1335
+ for (const link of visible.links) {
1336
+ if (link.source === origin) related.add(link.target);
1337
+ if (link.target === origin) related.add(link.source);
1338
+ }
1339
+ return related;
1340
+ }
1341
+ function nodeColor(node) {
1342
+ if (node.color) return node.color;
1343
+ switch (node.kind) {
1344
+ case "unresolved":
1345
+ case "attachment":
1346
+ return colors.muted;
1347
+ case "tag":
1348
+ return colors.link;
1349
+ case "note":
1350
+ return colors.accent;
1351
+ }
1352
+ }
1353
+ function segmentVisible(x1, y1, x2, y2) {
1354
+ return !(Math.max(x1, x2) < 0 || Math.max(y1, y2) < 0 || Math.min(x1, x2) > size.width || Math.min(y1, y2) > size.height);
1355
+ }
1356
+ function destroy() {
1357
+ if (destroyed) return;
1358
+ destroyed = true;
1359
+ if (frameHandle !== null) cancelAnimationFrame(frameHandle);
1360
+ frameHandle = null;
1361
+ simulation?.stop();
1362
+ resizeObserver.disconnect();
1363
+ intersectionObserver.disconnect();
1364
+ mutationObserver.disconnect();
1365
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1366
+ canvas.removeEventListener("pointerdown", onPointerDown);
1367
+ canvas.removeEventListener("pointermove", onPointerMove);
1368
+ canvas.removeEventListener("pointerup", onPointerUp);
1369
+ canvas.removeEventListener("pointercancel", onPointerUp);
1370
+ canvas.removeEventListener("pointerleave", onPointerLeave);
1371
+ canvas.removeEventListener("wheel", onWheel);
1372
+ canvas.removeEventListener("dblclick", onDoubleClick);
1373
+ canvas.removeEventListener("contextmenu", onContextMenu);
1374
+ canvas.removeEventListener("keydown", onKeyDown);
1375
+ }
1376
+ }
1377
+ function forceOptions(settings) {
1378
+ return {
1379
+ centerForce: settings.centerForce,
1380
+ repelForce: settings.repelForce,
1381
+ linkForce: settings.linkForce,
1382
+ linkDistance: settings.linkDistance
1383
+ };
1384
+ }
1385
+ function visibleSignature(visible) {
1386
+ return `${visible.nodes.map((node) => node.id).join("\0")}|${visible.links.map((link) => `${link.source}>${link.target}`).join("\0")}`;
1387
+ }
1388
+ function readColors(canvas) {
1389
+ const style = getComputedStyle(canvas);
1390
+ return {
1391
+ background: css(style, "--rb-color-paper", "#ffffff"),
1392
+ accent: css(style, "--rb-color-accent", "#2563eb"),
1393
+ link: css(style, "--rb-color-link", "#2563eb"),
1394
+ ink: css(style, "--rb-color-ink", "#1f2937"),
1395
+ muted: css(style, "--rb-color-muted", "#6b7280"),
1396
+ edge: css(style, "--rb-color-border-strong", "#94a3b8"),
1397
+ label: css(style, "--rb-color-ink", "#1f2937"),
1398
+ focus: css(style, "--rb-color-focus", "#2563eb")
1399
+ };
1400
+ }
1401
+ function css(style, name, fallback) {
1402
+ const value = style.getPropertyValue(name).trim();
1403
+ return value.length > 0 ? value : fallback;
1404
+ }
1405
+ function withAlpha(color2, alpha) {
1406
+ const clamped = clamp2(alpha, 0, 1);
1407
+ if (color2.startsWith("#")) {
1408
+ const hex = color2.length === 4 ? color2.slice(1).split("").map((character) => character + character).join("") : color2.slice(1);
1409
+ if (hex.length === 6 && /^[0-9a-f]{6}$/i.test(hex)) {
1410
+ const red = Number.parseInt(hex.slice(0, 2), 16);
1411
+ const green = Number.parseInt(hex.slice(2, 4), 16);
1412
+ const blue = Number.parseInt(hex.slice(4, 6), 16);
1413
+ return `rgba(${red}, ${green}, ${blue}, ${clamped})`;
1414
+ }
1415
+ }
1416
+ if (color2.startsWith("rgb(")) {
1417
+ return color2.replace("rgb(", "rgba(").replace(")", `, ${clamped})`);
1418
+ }
1419
+ return color2;
1420
+ }
1421
+ function truncateLabel(title) {
1422
+ return title.length > 34 ? `${title.slice(0, 33)}…` : title;
1423
+ }
1424
+ function clamp2(value, min, max) {
1425
+ return Math.min(Math.max(value, min), max);
1426
+ }
1427
+ function createNoopRenderer() {
1428
+ return {
1429
+ update() {
1430
+ },
1431
+ setSelected() {
1432
+ },
1433
+ zoomBy() {
1434
+ },
1435
+ resetView() {
1436
+ },
1437
+ focusNode() {
1438
+ },
1439
+ playAnimation() {
1440
+ },
1441
+ stopAnimation() {
1442
+ },
1443
+ isAnimating: () => false,
1444
+ destroy() {
1445
+ }
1446
+ };
1447
+ }
1448
+
1449
+ // ui.tsx
1450
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
1451
+ var GROUP_COLORS2 = [
1452
+ "#e07a5f",
1453
+ "#3d85c6",
1454
+ "#6aa84f",
1455
+ "#c27ba0",
1456
+ "#e6b422",
1457
+ "#7c6ee6"
1458
+ ];
1459
+ function GraphView(props) {
1460
+ const mode = props.mode ?? "global";
1461
+ const [settings, setSettings] = useState(
1462
+ () => normalizeGraphSettings({
1463
+ ...DEFAULT_GRAPH_SETTINGS,
1464
+ ...props.initialSettings,
1465
+ search: props.search ?? props.initialSettings?.search ?? "",
1466
+ depth: Math.min(
1467
+ props.initialSettings?.depth ?? DEFAULT_GRAPH_SETTINGS.depth,
1468
+ props.maxDepth ?? GRAPH_SETTINGS_RANGES.depth.max
1469
+ )
1470
+ })
1471
+ );
1472
+ const [storageReady, setStorageReady] = useState(false);
1473
+ const [panelOpen, setPanelOpen] = useState(false);
1474
+ const [expanded, setExpanded] = useState(false);
1475
+ const [selected, setSelected] = useState(
1476
+ props.selectedId ?? null
1477
+ );
1478
+ const [hovered, setHovered] = useState(null);
1479
+ const [animating, setAnimating] = useState(false);
1480
+ const [reducedMotion, setReducedMotion] = useState(false);
1481
+ const [contextMenu, setContextMenu] = useState(null);
1482
+ const canvasRef = useRef(null);
1483
+ const rendererRef = useRef(null);
1484
+ const groupCounter = useRef(0);
1485
+ const selectedId = props.selectedId !== void 0 ? props.selectedId : selected;
1486
+ const visible = useMemo(
1487
+ () => buildVisibleGraph({
1488
+ document: props.document,
1489
+ settings,
1490
+ mode,
1491
+ centerId: props.centerId
1492
+ }),
1493
+ [props.document, settings, mode, props.centerId]
1494
+ );
1495
+ const dated = useMemo(() => hasDatedNodes(visible.nodes), [visible.nodes]);
1496
+ const callbacksRef = useRef({});
1497
+ callbacksRef.current = {
1498
+ onSelect(node) {
1499
+ setSelected(node?.id ?? null);
1500
+ props.onSelect?.(node);
1501
+ },
1502
+ onOpen(node) {
1503
+ if (props.onOpen) {
1504
+ props.onOpen(node);
1505
+ return;
1506
+ }
1507
+ if (node.permalink) window.location.href = node.permalink;
1508
+ },
1509
+ onHover(node) {
1510
+ setHovered(node?.id ?? null);
1511
+ },
1512
+ onContextMenu(node, x, y) {
1513
+ setContextMenu({
1514
+ node,
1515
+ x: Math.min(x, window.innerWidth - 230),
1516
+ y: Math.min(y, window.innerHeight - 220)
1517
+ });
1518
+ },
1519
+ onAnimationChange(value) {
1520
+ setAnimating(value);
1521
+ }
1522
+ };
1523
+ useEffect(() => {
1524
+ const canvas = canvasRef.current;
1525
+ if (!canvas) return;
1526
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)");
1527
+ setReducedMotion(media.matches);
1528
+ const renderer = createGraphRenderer({
1529
+ canvas,
1530
+ reducedMotion: media.matches,
1531
+ onSelect: (node) => callbacksRef.current.onSelect?.(node),
1532
+ onOpen: (node) => callbacksRef.current.onOpen?.(node),
1533
+ onHover: (node) => callbacksRef.current.onHover?.(node),
1534
+ onContextMenu: (node, x, y) => callbacksRef.current.onContextMenu?.(node, x, y),
1535
+ onAnimationChange: (value) => callbacksRef.current.onAnimationChange?.(value)
1536
+ });
1537
+ rendererRef.current = renderer;
1538
+ return () => {
1539
+ renderer.destroy();
1540
+ rendererRef.current = null;
1541
+ };
1542
+ }, []);
1543
+ useEffect(() => {
1544
+ rendererRef.current?.update({
1545
+ document: props.document,
1546
+ settings,
1547
+ mode,
1548
+ centerId: props.centerId,
1549
+ selectedId
1550
+ });
1551
+ }, [props.document, props.centerId, settings, mode, selectedId]);
1552
+ useEffect(() => {
1553
+ if (!props.storageKey) {
1554
+ setStorageReady(true);
1555
+ return;
1556
+ }
1557
+ const stored = readStoredGraphSettings(
1558
+ window.localStorage,
1559
+ props.storageKey
1560
+ );
1561
+ if (stored) {
1562
+ setSettings(
1563
+ (current) => normalizeGraphSettings({
1564
+ ...current,
1565
+ ...stored,
1566
+ depth: Math.min(
1567
+ stored.depth,
1568
+ props.maxDepth ?? GRAPH_SETTINGS_RANGES.depth.max
1569
+ )
1570
+ })
1571
+ );
1572
+ }
1573
+ setStorageReady(true);
1574
+ }, [props.storageKey]);
1575
+ useEffect(() => {
1576
+ if (!storageReady || !props.storageKey) return;
1577
+ writeStoredGraphSettings(window.localStorage, props.storageKey, settings);
1578
+ }, [settings, storageReady, props.storageKey]);
1579
+ useEffect(() => {
1580
+ if (props.search === void 0) return;
1581
+ setSettings(
1582
+ (current) => current.search === props.search ? current : { ...current, search: props.search ?? "" }
1583
+ );
1584
+ }, [props.search]);
1585
+ useEffect(() => {
1586
+ const renderer = rendererRef.current;
1587
+ if (!renderer) return;
1588
+ if (settings.animate) renderer.playAnimation();
1589
+ else renderer.stopAnimation();
1590
+ }, [settings.animate]);
1591
+ useEffect(() => {
1592
+ if (!panelOpen && !expanded && !contextMenu) return;
1593
+ const onKeyDown = (event) => {
1594
+ if (event.key !== "Escape") return;
1595
+ if (contextMenu) {
1596
+ setContextMenu(null);
1597
+ return;
1598
+ }
1599
+ if (panelOpen) setPanelOpen(false);
1600
+ if (expanded) setExpanded(false);
1601
+ };
1602
+ window.addEventListener("keydown", onKeyDown);
1603
+ return () => window.removeEventListener("keydown", onKeyDown);
1604
+ }, [panelOpen, expanded, contextMenu]);
1605
+ useEffect(() => {
1606
+ if (!contextMenu) return;
1607
+ const onPointerDown = (event) => {
1608
+ const target = event.target;
1609
+ if (target instanceof Element && target.closest(".rr-graph__menu")) {
1610
+ return;
1611
+ }
1612
+ setContextMenu(null);
1613
+ };
1614
+ document.addEventListener("pointerdown", onPointerDown);
1615
+ return () => document.removeEventListener("pointerdown", onPointerDown);
1616
+ }, [contextMenu]);
1617
+ useEffect(() => {
1618
+ if (!expanded) return;
1619
+ const previous = document.body.style.overflow;
1620
+ document.body.style.overflow = "hidden";
1621
+ return () => {
1622
+ document.body.style.overflow = previous;
1623
+ };
1624
+ }, [expanded]);
1625
+ const patch = (next) => {
1626
+ setSettings((current) => ({ ...current, ...next }));
1627
+ };
1628
+ const updateSearch = (value) => {
1629
+ patch({ search: value });
1630
+ props.onSearchChange?.(value);
1631
+ };
1632
+ const addGroup = () => {
1633
+ groupCounter.current += 1;
1634
+ const group = {
1635
+ id: `group-${Date.now().toString(36)}-${groupCounter.current}`,
1636
+ query: "",
1637
+ color: GROUP_COLORS2[settings.groups.length % GROUP_COLORS2.length] ?? "#6aa84f"
1638
+ };
1639
+ patch({ groups: [...settings.groups, group] });
1640
+ };
1641
+ const updateGroup = (id, next) => {
1642
+ patch({
1643
+ groups: settings.groups.map(
1644
+ (group) => group.id === id ? { ...group, ...next } : group
1645
+ )
1646
+ });
1647
+ };
1648
+ const moveGroup = (id, direction) => {
1649
+ const index = settings.groups.findIndex((group2) => group2.id === id);
1650
+ const target = index + direction;
1651
+ if (index < 0 || target < 0 || target >= settings.groups.length) return;
1652
+ const groups2 = [...settings.groups];
1653
+ const [group] = groups2.splice(index, 1);
1654
+ if (!group) return;
1655
+ groups2.splice(target, 0, group);
1656
+ patch({ groups: groups2 });
1657
+ };
1658
+ const removeGroup = (id) => {
1659
+ patch({ groups: settings.groups.filter((group) => group.id !== id) });
1660
+ };
1661
+ const restoreDefaults = () => {
1662
+ setSettings(
1663
+ normalizeGraphSettings({
1664
+ ...DEFAULT_GRAPH_SETTINGS,
1665
+ ...props.initialSettings,
1666
+ search: props.search ?? "",
1667
+ depth: Math.min(
1668
+ props.initialSettings?.depth ?? DEFAULT_GRAPH_SETTINGS.depth,
1669
+ props.maxDepth ?? GRAPH_SETTINGS_RANGES.depth.max
1670
+ )
1671
+ })
1672
+ );
1673
+ };
1674
+ const openFromMenu = (node) => {
1675
+ setContextMenu(null);
1676
+ if (props.onOpen) props.onOpen(node);
1677
+ else if (node.permalink) window.location.href = node.permalink;
1678
+ };
1679
+ const copyLink = async (node) => {
1680
+ setContextMenu(null);
1681
+ if (!node.permalink) return;
1682
+ const url = new URL(node.permalink, window.location.origin).href;
1683
+ try {
1684
+ await navigator.clipboard.writeText(url);
1685
+ } catch {
1686
+ return;
1687
+ }
1688
+ };
1689
+ const focusLocal = (node) => {
1690
+ setContextMenu(null);
1691
+ if (props.onShowLocal) {
1692
+ props.onShowLocal(node);
1693
+ return;
1694
+ }
1695
+ if (node.slug)
1696
+ window.location.href = `/explore?note=${encodeURIComponent(node.slug)}`;
1697
+ };
1698
+ const rootClass = [
1699
+ "rr-graph",
1700
+ expanded ? "rr-graph--expanded" : "",
1701
+ props.class ?? ""
1702
+ ].filter(Boolean).join(" ");
1703
+ return /* @__PURE__ */ jsxs(
1704
+ "div",
1705
+ {
1706
+ class: rootClass,
1707
+ "data-graph-mode": mode,
1708
+ "data-node-count": visible.nodes.length,
1709
+ "data-link-count": visible.links.length,
1710
+ "data-reduced-motion": reducedMotion ? "true" : "false",
1711
+ children: [
1712
+ props.showHeader !== false ? /* @__PURE__ */ jsxs("div", { class: "rr-graph__header", children: [
1713
+ /* @__PURE__ */ jsx("h2", { class: "rr-graph__title", children: props.heading ?? "Graph" }),
1714
+ /* @__PURE__ */ jsxs("span", { class: "rr-graph__count", "aria-live": "polite", children: [
1715
+ visible.nodes.length,
1716
+ " nodes · ",
1717
+ visible.links.length,
1718
+ " links"
1719
+ ] })
1720
+ ] }) : null,
1721
+ /* @__PURE__ */ jsxs("div", { class: "rr-graph__toolbar", children: [
1722
+ props.showModeLabel !== false || visible.unsupportedOperators.length > 0 ? /* @__PURE__ */ jsxs("span", { class: "rr-graph__status", children: [
1723
+ props.showModeLabel !== false ? mode === "local" ? "Local graph" : "Global graph" : null,
1724
+ visible.unsupportedOperators.length > 0 ? /* @__PURE__ */ jsxs("span", { class: "rr-graph__warning", children: [
1725
+ "Unsupported operators: ",
1726
+ visible.unsupportedOperators.join(", ")
1727
+ ] }) : null
1728
+ ] }) : null,
1729
+ /* @__PURE__ */ jsxs("div", { class: "rr-graph__actions", children: [
1730
+ /* @__PURE__ */ jsx(
1731
+ "button",
1732
+ {
1733
+ type: "button",
1734
+ "aria-label": "Zoom out",
1735
+ onClick: () => rendererRef.current?.zoomBy(1 / 1.2),
1736
+ children: "−"
1737
+ }
1738
+ ),
1739
+ /* @__PURE__ */ jsx(
1740
+ "button",
1741
+ {
1742
+ type: "button",
1743
+ "aria-label": "Zoom in",
1744
+ onClick: () => rendererRef.current?.zoomBy(1.2),
1745
+ children: "+"
1746
+ }
1747
+ ),
1748
+ /* @__PURE__ */ jsx(
1749
+ "button",
1750
+ {
1751
+ type: "button",
1752
+ onClick: () => rendererRef.current?.resetView(),
1753
+ children: "Reset"
1754
+ }
1755
+ ),
1756
+ props.expandable ? /* @__PURE__ */ jsx(
1757
+ "button",
1758
+ {
1759
+ type: "button",
1760
+ "aria-pressed": expanded,
1761
+ "aria-label": expanded ? "Exit full screen graph" : "Expand graph",
1762
+ onClick: () => setExpanded((value) => !value),
1763
+ children: expanded ? "Close" : "Expand"
1764
+ }
1765
+ ) : null,
1766
+ /* @__PURE__ */ jsx(
1767
+ "button",
1768
+ {
1769
+ type: "button",
1770
+ class: "rr-graph__settings-toggle",
1771
+ "aria-label": "Graph settings",
1772
+ "aria-expanded": panelOpen,
1773
+ "aria-controls": "rr-graph-settings",
1774
+ onClick: () => setPanelOpen((value) => !value),
1775
+ children: /* @__PURE__ */ jsxs(
1776
+ "svg",
1777
+ {
1778
+ viewBox: "0 0 24 24",
1779
+ width: "18",
1780
+ height: "18",
1781
+ "aria-hidden": "true",
1782
+ fill: "none",
1783
+ stroke: "currentColor",
1784
+ "stroke-width": "1.8",
1785
+ children: [
1786
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" }),
1787
+ /* @__PURE__ */ jsx("path", { d: "M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" })
1788
+ ]
1789
+ }
1790
+ )
1791
+ }
1792
+ )
1793
+ ] })
1794
+ ] }),
1795
+ /* @__PURE__ */ jsxs("div", { class: "rr-graph__stage", children: [
1796
+ /* @__PURE__ */ jsx(
1797
+ "canvas",
1798
+ {
1799
+ ref: canvasRef,
1800
+ class: "rr-graph__canvas",
1801
+ tabindex: 0,
1802
+ "aria-label": `${mode === "local" ? "Local" : "Global"} note graph. Use plus and minus to zoom, arrow keys to pan, Enter to open the selected note.`
1803
+ }
1804
+ ),
1805
+ /* @__PURE__ */ jsx("noscript", { children: /* @__PURE__ */ jsx("ul", { class: "rr-graph__fallback", children: visible.nodes.filter((node) => node.kind === "note" && node.permalink).slice(0, 60).map((node) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", { href: node.permalink ?? "#", children: node.title }) }, node.id)) }) }),
1806
+ contextMenu ? /* @__PURE__ */ jsxs(
1807
+ "div",
1808
+ {
1809
+ class: "rr-graph__menu",
1810
+ role: "menu",
1811
+ style: `left:${contextMenu.x}px;top:${contextMenu.y}px`,
1812
+ children: [
1813
+ /* @__PURE__ */ jsx("p", { class: "rr-graph__menu-title", children: contextMenu.node.title }),
1814
+ contextMenu.node.permalink ? /* @__PURE__ */ jsx(
1815
+ "button",
1816
+ {
1817
+ type: "button",
1818
+ role: "menuitem",
1819
+ onClick: () => openFromMenu(contextMenu.node),
1820
+ children: "Open note"
1821
+ }
1822
+ ) : null,
1823
+ contextMenu.node.permalink ? /* @__PURE__ */ jsx(
1824
+ "button",
1825
+ {
1826
+ type: "button",
1827
+ role: "menuitem",
1828
+ onClick: () => window.open(
1829
+ contextMenu.node.permalink ?? "",
1830
+ "_blank",
1831
+ "noopener"
1832
+ ),
1833
+ children: "Open in new tab"
1834
+ }
1835
+ ) : null,
1836
+ contextMenu.node.permalink ? /* @__PURE__ */ jsx(
1837
+ "button",
1838
+ {
1839
+ type: "button",
1840
+ role: "menuitem",
1841
+ onClick: () => void copyLink(contextMenu.node),
1842
+ children: "Copy link"
1843
+ }
1844
+ ) : null,
1845
+ contextMenu.node.slug ? /* @__PURE__ */ jsx(
1846
+ "button",
1847
+ {
1848
+ type: "button",
1849
+ role: "menuitem",
1850
+ onClick: () => focusLocal(contextMenu.node),
1851
+ children: mode === "global" ? "Show local graph" : "Open in Explorer"
1852
+ }
1853
+ ) : null,
1854
+ /* @__PURE__ */ jsx(
1855
+ "button",
1856
+ {
1857
+ type: "button",
1858
+ role: "menuitem",
1859
+ onClick: () => {
1860
+ const node = contextMenu.node;
1861
+ setContextMenu(null);
1862
+ updateSearch(
1863
+ node.kind === "tag" ? `tag:${node.id.slice("tag:".length)}` : `"${node.title}"`
1864
+ );
1865
+ },
1866
+ children: "Filter to this node"
1867
+ }
1868
+ )
1869
+ ]
1870
+ }
1871
+ ) : null,
1872
+ panelOpen ? /* @__PURE__ */ jsxs(
1873
+ "aside",
1874
+ {
1875
+ id: "rr-graph-settings",
1876
+ class: "rr-graph__settings",
1877
+ "aria-label": "Graph settings",
1878
+ children: [
1879
+ /* @__PURE__ */ jsxs("div", { class: "rr-graph__settings-header", children: [
1880
+ /* @__PURE__ */ jsx("h3", { children: "Graph settings" }),
1881
+ /* @__PURE__ */ jsx(
1882
+ "button",
1883
+ {
1884
+ type: "button",
1885
+ "aria-label": "Close graph settings",
1886
+ onClick: () => setPanelOpen(false),
1887
+ children: "×"
1888
+ }
1889
+ )
1890
+ ] }),
1891
+ /* @__PURE__ */ jsxs("details", { open: true, children: [
1892
+ /* @__PURE__ */ jsx("summary", { children: "Filters" }),
1893
+ /* @__PURE__ */ jsxs("div", { class: "rr-graph__field", children: [
1894
+ /* @__PURE__ */ jsx("label", { for: "rr-graph-search", children: "Search files" }),
1895
+ /* @__PURE__ */ jsx(
1896
+ "input",
1897
+ {
1898
+ id: "rr-graph-search",
1899
+ type: "search",
1900
+ value: settings.search,
1901
+ placeholder: "tag:#topic path:notes file:title",
1902
+ onInput: (event) => updateSearch(
1903
+ event.currentTarget.value
1904
+ )
1905
+ }
1906
+ ),
1907
+ /* @__PURE__ */ jsx("p", { class: "rr-graph__help", children: "Supports file:, path:, tag:, quotes, and -negation." })
1908
+ ] }),
1909
+ /* @__PURE__ */ jsxs("label", { class: "rr-graph__check", children: [
1910
+ /* @__PURE__ */ jsx(
1911
+ "input",
1912
+ {
1913
+ type: "checkbox",
1914
+ checked: settings.showTags,
1915
+ onChange: (event) => patch({
1916
+ showTags: event.currentTarget.checked
1917
+ })
1918
+ }
1919
+ ),
1920
+ "Tags"
1921
+ ] }),
1922
+ /* @__PURE__ */ jsxs("label", { class: "rr-graph__check", children: [
1923
+ /* @__PURE__ */ jsx(
1924
+ "input",
1925
+ {
1926
+ type: "checkbox",
1927
+ checked: settings.showAttachments,
1928
+ onChange: (event) => patch({
1929
+ showAttachments: event.currentTarget.checked
1930
+ })
1931
+ }
1932
+ ),
1933
+ "Attachments"
1934
+ ] }),
1935
+ /* @__PURE__ */ jsxs("label", { class: "rr-graph__check", children: [
1936
+ /* @__PURE__ */ jsx(
1937
+ "input",
1938
+ {
1939
+ type: "checkbox",
1940
+ checked: settings.existingOnly,
1941
+ onChange: (event) => patch({
1942
+ existingOnly: event.currentTarget.checked
1943
+ })
1944
+ }
1945
+ ),
1946
+ "Existing files only"
1947
+ ] }),
1948
+ /* @__PURE__ */ jsxs("label", { class: "rr-graph__check", children: [
1949
+ /* @__PURE__ */ jsx(
1950
+ "input",
1951
+ {
1952
+ type: "checkbox",
1953
+ checked: settings.showOrphans,
1954
+ onChange: (event) => patch({
1955
+ showOrphans: event.currentTarget.checked
1956
+ })
1957
+ }
1958
+ ),
1959
+ "Orphans"
1960
+ ] }),
1961
+ mode === "local" ? /* @__PURE__ */ jsx(
1962
+ RangeField,
1963
+ {
1964
+ label: "Depth",
1965
+ value: settings.depth,
1966
+ range: {
1967
+ ...GRAPH_SETTINGS_RANGES.depth,
1968
+ max: Math.min(
1969
+ GRAPH_SETTINGS_RANGES.depth.max,
1970
+ props.maxDepth ?? GRAPH_SETTINGS_RANGES.depth.max
1971
+ )
1972
+ },
1973
+ onInput: (value) => patch({ depth: value })
1974
+ }
1975
+ ) : null
1976
+ ] }),
1977
+ /* @__PURE__ */ jsxs("details", { open: true, children: [
1978
+ /* @__PURE__ */ jsx("summary", { children: "Groups" }),
1979
+ /* @__PURE__ */ jsxs("div", { class: "rr-graph__groups", children: [
1980
+ settings.groups.length === 0 ? /* @__PURE__ */ jsx("p", { class: "rr-graph__help", children: "Groups color nodes that match a query. The first match wins." }) : null,
1981
+ settings.groups.map((group, index) => /* @__PURE__ */ jsxs("div", { class: "rr-graph__group", children: [
1982
+ /* @__PURE__ */ jsx(
1983
+ "input",
1984
+ {
1985
+ type: "color",
1986
+ value: group.color,
1987
+ "aria-label": `Group ${index + 1} color`,
1988
+ onInput: (event) => updateGroup(group.id, {
1989
+ color: event.currentTarget.value
1990
+ })
1991
+ }
1992
+ ),
1993
+ /* @__PURE__ */ jsx(
1994
+ "input",
1995
+ {
1996
+ type: "text",
1997
+ value: group.query,
1998
+ placeholder: "tag:#topic",
1999
+ "aria-label": `Group ${index + 1} query`,
2000
+ onInput: (event) => updateGroup(group.id, {
2001
+ query: event.currentTarget.value
2002
+ })
2003
+ }
2004
+ ),
2005
+ /* @__PURE__ */ jsx(
2006
+ "button",
2007
+ {
2008
+ type: "button",
2009
+ "aria-label": `Move group ${index + 1} up`,
2010
+ onClick: () => moveGroup(group.id, -1),
2011
+ children: "↑"
2012
+ }
2013
+ ),
2014
+ /* @__PURE__ */ jsx(
2015
+ "button",
2016
+ {
2017
+ type: "button",
2018
+ "aria-label": `Move group ${index + 1} down`,
2019
+ onClick: () => moveGroup(group.id, 1),
2020
+ children: "↓"
2021
+ }
2022
+ ),
2023
+ /* @__PURE__ */ jsx(
2024
+ "button",
2025
+ {
2026
+ type: "button",
2027
+ "aria-label": `Delete group ${index + 1}`,
2028
+ onClick: () => removeGroup(group.id),
2029
+ children: "×"
2030
+ }
2031
+ )
2032
+ ] }, group.id)),
2033
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: addGroup, children: "New group" })
2034
+ ] })
2035
+ ] }),
2036
+ /* @__PURE__ */ jsxs("details", { open: true, children: [
2037
+ /* @__PURE__ */ jsx("summary", { children: "Display" }),
2038
+ /* @__PURE__ */ jsxs("label", { class: "rr-graph__check", children: [
2039
+ /* @__PURE__ */ jsx(
2040
+ "input",
2041
+ {
2042
+ type: "checkbox",
2043
+ checked: settings.arrows,
2044
+ onChange: (event) => patch({
2045
+ arrows: event.currentTarget.checked
2046
+ })
2047
+ }
2048
+ ),
2049
+ "Arrows"
2050
+ ] }),
2051
+ /* @__PURE__ */ jsx(
2052
+ RangeField,
2053
+ {
2054
+ label: "Text fade threshold",
2055
+ value: settings.textFade,
2056
+ range: GRAPH_SETTINGS_RANGES.textFade,
2057
+ onInput: (value) => patch({ textFade: value })
2058
+ }
2059
+ ),
2060
+ /* @__PURE__ */ jsx(
2061
+ RangeField,
2062
+ {
2063
+ label: "Node size",
2064
+ value: settings.nodeSize,
2065
+ range: GRAPH_SETTINGS_RANGES.nodeSize,
2066
+ onInput: (value) => patch({ nodeSize: value })
2067
+ }
2068
+ ),
2069
+ /* @__PURE__ */ jsx(
2070
+ RangeField,
2071
+ {
2072
+ label: "Link thickness",
2073
+ value: settings.linkThickness,
2074
+ range: GRAPH_SETTINGS_RANGES.linkThickness,
2075
+ onInput: (value) => patch({ linkThickness: value })
2076
+ }
2077
+ ),
2078
+ /* @__PURE__ */ jsxs("label", { class: "rr-graph__check", children: [
2079
+ /* @__PURE__ */ jsx(
2080
+ "input",
2081
+ {
2082
+ type: "checkbox",
2083
+ checked: settings.animate,
2084
+ onChange: (event) => patch({
2085
+ animate: event.currentTarget.checked
2086
+ })
2087
+ }
2088
+ ),
2089
+ "Animate"
2090
+ ] }),
2091
+ settings.animate ? /* @__PURE__ */ jsxs("div", { class: "rr-graph__animate-actions", children: [
2092
+ /* @__PURE__ */ jsx(
2093
+ "button",
2094
+ {
2095
+ type: "button",
2096
+ onClick: () => rendererRef.current?.playAnimation(),
2097
+ disabled: animating || reducedMotion,
2098
+ children: "Play"
2099
+ }
2100
+ ),
2101
+ /* @__PURE__ */ jsx(
2102
+ "button",
2103
+ {
2104
+ type: "button",
2105
+ onClick: () => rendererRef.current?.stopAnimation(),
2106
+ disabled: !animating,
2107
+ children: "Stop"
2108
+ }
2109
+ ),
2110
+ /* @__PURE__ */ jsx(
2111
+ "button",
2112
+ {
2113
+ type: "button",
2114
+ onClick: () => rendererRef.current?.playAnimation(),
2115
+ disabled: reducedMotion,
2116
+ children: "Replay"
2117
+ }
2118
+ )
2119
+ ] }) : null,
2120
+ settings.animate && !dated ? /* @__PURE__ */ jsx("p", { class: "rr-graph__help", children: "No publication dates are available. Notes appear in a stable sequential order instead." }) : null,
2121
+ settings.animate && reducedMotion ? /* @__PURE__ */ jsx("p", { class: "rr-graph__help", children: "Reduced motion is enabled, so all nodes are shown immediately." }) : null
2122
+ ] }),
2123
+ /* @__PURE__ */ jsxs("details", { open: true, children: [
2124
+ /* @__PURE__ */ jsx("summary", { children: "Forces" }),
2125
+ /* @__PURE__ */ jsx(
2126
+ RangeField,
2127
+ {
2128
+ label: "Center force",
2129
+ value: settings.centerForce,
2130
+ range: GRAPH_SETTINGS_RANGES.centerForce,
2131
+ onInput: (value) => patch({ centerForce: value })
2132
+ }
2133
+ ),
2134
+ /* @__PURE__ */ jsx(
2135
+ RangeField,
2136
+ {
2137
+ label: "Repel force",
2138
+ value: settings.repelForce,
2139
+ range: GRAPH_SETTINGS_RANGES.repelForce,
2140
+ onInput: (value) => patch({ repelForce: value })
2141
+ }
2142
+ ),
2143
+ /* @__PURE__ */ jsx(
2144
+ RangeField,
2145
+ {
2146
+ label: "Link force",
2147
+ value: settings.linkForce,
2148
+ range: GRAPH_SETTINGS_RANGES.linkForce,
2149
+ onInput: (value) => patch({ linkForce: value })
2150
+ }
2151
+ ),
2152
+ /* @__PURE__ */ jsx(
2153
+ RangeField,
2154
+ {
2155
+ label: "Link distance",
2156
+ value: settings.linkDistance,
2157
+ range: GRAPH_SETTINGS_RANGES.linkDistance,
2158
+ onInput: (value) => patch({ linkDistance: value })
2159
+ }
2160
+ ),
2161
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: restoreDefaults, children: "Restore default settings" })
2162
+ ] }),
2163
+ /* @__PURE__ */ jsx("p", { class: "rr-graph__selection", "aria-live": "polite", children: hovered ? `Hover: ${visible.nodes.find((node) => node.id === hovered)?.title ?? ""}` : selectedId ? `Selected: ${visible.nodes.find((node) => node.id === selectedId)?.title ?? ""}` : "Hover or select a node to highlight its links." })
2164
+ ]
2165
+ }
2166
+ ) : null
2167
+ ] })
2168
+ ]
2169
+ }
2170
+ );
2171
+ }
2172
+ function RangeField(props) {
2173
+ return /* @__PURE__ */ jsxs("label", { class: "rr-graph__range", children: [
2174
+ /* @__PURE__ */ jsxs("span", { children: [
2175
+ props.label,
2176
+ /* @__PURE__ */ jsx("output", { children: formatValue(props.value, props.range.step) })
2177
+ ] }),
2178
+ /* @__PURE__ */ jsx(
2179
+ "input",
2180
+ {
2181
+ type: "range",
2182
+ min: props.range.min,
2183
+ max: props.range.max,
2184
+ step: props.range.step,
2185
+ value: props.value,
2186
+ onInput: (event) => props.onInput(Number(event.currentTarget.value))
2187
+ }
2188
+ )
2189
+ ] });
2190
+ }
2191
+ function formatValue(value, step) {
2192
+ return step >= 1 ? String(Math.round(value)) : value.toFixed(2);
2193
+ }
2194
+ export {
2195
+ GraphView as default
2196
+ };