@lensmap/starlight 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,2104 @@
1
+ // src/ViewerHost.tsx
2
+ import { useEffect as useEffect5, useState as useState4 } from "react";
3
+ import { createPortal } from "react-dom";
4
+
5
+ // ../../apps/web/src/document/layout.ts
6
+ import {
7
+ forceCollide,
8
+ forceSimulation,
9
+ forceX,
10
+ forceY
11
+ } from "d3-force";
12
+
13
+ // ../../apps/web/src/document/subtree.ts
14
+ import { polygonHull } from "d3-polygon";
15
+
16
+ // ../../apps/web/src/Viewer.tsx
17
+ import {
18
+ connectionWording as connectionWording4,
19
+ resolveThoughtAnchor,
20
+ thoughtAnchors as thoughtAnchors2
21
+ } from "@lensmap/document";
22
+ import {
23
+ useCallback as useCallback2,
24
+ useEffect as useEffect4,
25
+ useEffectEvent as useEffectEvent2,
26
+ useId as useId3,
27
+ useLayoutEffect,
28
+ useMemo,
29
+ useRef as useRef4,
30
+ useState as useState2
31
+ } from "react";
32
+ import Markdown2 from "react-markdown";
33
+
34
+ // ../../apps/web/src/ConnectionChooser.tsx
35
+ import { connectionWording } from "@lensmap/document";
36
+ import { useEffect, useRef } from "react";
37
+
38
+ // ../../apps/web/src/graph.ts
39
+ var colours = {
40
+ blue: "#83c8ff",
41
+ amber: "#f7c46c",
42
+ mint: "#82dfc1",
43
+ rose: "#e9a3ca"
44
+ };
45
+ function label(node) {
46
+ return node.unresolved ? "Unresolved thought" : node.title.replace(/^#{1,6}\s+/, "").replace(/[*_`]/g, "");
47
+ }
48
+
49
+ // ../../apps/web/src/ConnectionChooser.tsx
50
+ import { jsx, jsxs } from "react/jsx-runtime";
51
+ function ConnectionChooser({
52
+ choice,
53
+ origin,
54
+ visibleKeys,
55
+ onPick,
56
+ onAccept,
57
+ onCancel,
58
+ onActivatePortal
59
+ }) {
60
+ const current = useRef(null);
61
+ useEffect(() => {
62
+ if (choice.candidates[choice.index])
63
+ current.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
64
+ }, [choice]);
65
+ return /* @__PURE__ */ jsxs(
66
+ "section",
67
+ {
68
+ className: "connection-chooser",
69
+ "data-connection-chooser": "true",
70
+ "aria-label": "Connection choices",
71
+ children: [
72
+ /* @__PURE__ */ jsxs("h2", { children: [
73
+ "Connections from ",
74
+ label(origin)
75
+ ] }),
76
+ /* @__PURE__ */ jsxs("p", { children: [
77
+ choice.direction.replace("Arrow", ""),
78
+ " \xB7 Shift+arrow chooses \xB7 Space travels \xB7 Enter switches view \xB7 Esc cancels"
79
+ ] }),
80
+ choice.candidates.length ? /* @__PURE__ */ jsx("ol", { children: choice.candidates.map((candidate, index) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
81
+ "button",
82
+ {
83
+ className: "connection-option",
84
+ ref: index === choice.index ? current : void 0,
85
+ type: "button",
86
+ id: `connection-option-${candidate.node.key}`,
87
+ "aria-label": `Preview ${label(candidate.node)}`,
88
+ "aria-describedby": `connection-details-${candidate.node.key}`,
89
+ "aria-pressed": index === choice.index,
90
+ onClick: () => onPick(index),
91
+ children: [
92
+ /* @__PURE__ */ jsx("strong", { children: label(candidate.node) }),
93
+ /* @__PURE__ */ jsxs("span", { id: `connection-details-${candidate.node.key}`, children: [
94
+ candidate.routes.map((route, routeIndex) => /* @__PURE__ */ jsxs("span", { children: [
95
+ routeIndex > 0 ? " \xB7 " : "",
96
+ connectionWording(route.edge.type, route.direction),
97
+ ` (${route.direction} ${route.edge.kind === "branch" ? "branch" : "cross-link"})`
98
+ ] }, `${route.edge.key}:${route.direction}`)),
99
+ !visibleKeys.has(candidate.node.key) && /* @__PURE__ */ jsx("span", { className: "outside-view", children: "outside this view" })
100
+ ] })
101
+ ]
102
+ }
103
+ ) }, candidate.node.key)) }) : /* @__PURE__ */ jsx("p", { children: "No connections in this direction" }),
104
+ /* @__PURE__ */ jsxs("div", { className: "connection-actions", children: [
105
+ /* @__PURE__ */ jsx(
106
+ "button",
107
+ {
108
+ type: "button",
109
+ disabled: !choice.candidates.length,
110
+ onClick: onAccept,
111
+ children: "Go to thought"
112
+ }
113
+ ),
114
+ choice.candidates[choice.index]?.node.destination && /* @__PURE__ */ jsxs(
115
+ "button",
116
+ {
117
+ type: "button",
118
+ disabled: !onActivatePortal,
119
+ onClick: () => onActivatePortal?.(choice.candidates[choice.index].node),
120
+ children: [
121
+ "Open",
122
+ " ",
123
+ choice.candidates[choice.index].node.destination?.kind === "map_link" ? "destination map" : "external link"
124
+ ]
125
+ }
126
+ ),
127
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onCancel, children: "Cancel choice" })
128
+ ] })
129
+ ]
130
+ }
131
+ );
132
+ }
133
+
134
+ // ../../apps/web/src/ConnectionGap.tsx
135
+ import { useId } from "react";
136
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
137
+ function BlockingMarker({
138
+ id,
139
+ colour,
140
+ gap = false
141
+ }) {
142
+ return /* @__PURE__ */ jsx2(
143
+ "marker",
144
+ {
145
+ id,
146
+ viewBox: "0 0 10 10",
147
+ refX: "7",
148
+ refY: "5",
149
+ markerWidth: "8",
150
+ markerHeight: "8",
151
+ orient: "auto",
152
+ overflow: gap ? "visible" : void 0,
153
+ children: gap ? /* @__PURE__ */ jsx2("rect", { x: "3", y: "-2", width: "3", height: "14", fill: "black" }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
154
+ /* @__PURE__ */ jsx2("line", { x1: "2", x2: "2", y1: "0", y2: "10", stroke: colour, strokeWidth: "2" }),
155
+ /* @__PURE__ */ jsx2("line", { x1: "7", x2: "7", y1: "0", y2: "10", stroke: colour, strokeWidth: "2" })
156
+ ] })
157
+ }
158
+ );
159
+ }
160
+ function ConnectionGap({
161
+ path,
162
+ points,
163
+ className,
164
+ markerId,
165
+ children
166
+ }) {
167
+ const id = useId();
168
+ const padding = 64;
169
+ const x = Math.min(...points.map((point) => point.x)) - padding;
170
+ const y = Math.min(...points.map((point) => point.y)) - padding;
171
+ const width = Math.max(...points.map((point) => point.x)) + padding - x;
172
+ const height = Math.max(...points.map((point) => point.y)) + padding - y;
173
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
174
+ /* @__PURE__ */ jsx2("defs", { children: /* @__PURE__ */ jsxs2(
175
+ "mask",
176
+ {
177
+ id,
178
+ maskUnits: "userSpaceOnUse",
179
+ x,
180
+ y,
181
+ width,
182
+ height,
183
+ style: { maskType: "luminance" },
184
+ children: [
185
+ /* @__PURE__ */ jsx2("rect", { x, y, width, height, fill: "white" }),
186
+ /* @__PURE__ */ jsx2(
187
+ "path",
188
+ {
189
+ d: path,
190
+ className,
191
+ style: { stroke: "none", opacity: 1, filter: "none" },
192
+ markerEnd: `url(#${markerId})`
193
+ }
194
+ )
195
+ ]
196
+ }
197
+ ) }),
198
+ /* @__PURE__ */ jsx2("g", { mask: `url(#${id})`, children })
199
+ ] });
200
+ }
201
+
202
+ // ../../apps/web/src/clearance.ts
203
+ function limitMagnification(points, nodeRadius2, strength) {
204
+ const amount = Math.max(0, Math.min(1, strength));
205
+ if (amount === 0 || points.length < 2)
206
+ return points.map((point) => ({ ...point }));
207
+ const radii = points.map((point) => point.scale * nodeRadius2);
208
+ const minimum = radii.map((radius2) => Math.min(radius2, 2));
209
+ const limited = [...radii];
210
+ for (let i = 0; i < points.length; i++) {
211
+ for (let j = i + 1; j < points.length; j++) {
212
+ const distance = Math.hypot(
213
+ points[i].x - points[j].x,
214
+ points[i].y - points[j].y
215
+ );
216
+ const capacity = Math.max(minimum[i] + minimum[j], distance - 6);
217
+ if (radii[i] + radii[j] <= capacity) continue;
218
+ const available = Math.max(0, capacity - minimum[i] - minimum[j]);
219
+ const extraI = radii[i] - minimum[i];
220
+ const extraJ = radii[j] - minimum[j];
221
+ const weight = radii[i] ** 2 / (radii[i] ** 2 + radii[j] ** 2);
222
+ const shareI = Math.max(
223
+ 0,
224
+ available - extraJ,
225
+ Math.min(extraI, available, available * weight)
226
+ );
227
+ limited[i] = Math.min(limited[i], minimum[i] + shareI);
228
+ limited[j] = Math.min(limited[j], minimum[j] + available - shareI);
229
+ }
230
+ }
231
+ return points.map((point, index) => ({
232
+ ...point,
233
+ scale: point.scale + (limited[index] / nodeRadius2 - point.scale) * amount
234
+ }));
235
+ }
236
+
237
+ // ../../apps/web/src/connection-appearance.ts
238
+ function connectionAppearance(type, continuation) {
239
+ return {
240
+ dashed: type === "optional",
241
+ ...type === "follows" && continuation !== "incoming" ? { start: "arrow" } : {},
242
+ ...continuation !== "outgoing" && (type === "precedes" || type === "prevents") ? { end: type === "precedes" ? "arrow" : "bar" } : {}
243
+ };
244
+ }
245
+
246
+ // ../../apps/web/src/edge-routing.ts
247
+ function edgeLanes(edges) {
248
+ const groups = /* @__PURE__ */ new Map();
249
+ for (const edge of edges) {
250
+ const pair = JSON.stringify([edge.from, edge.to].sort());
251
+ const group = groups.get(pair) ?? [];
252
+ group.push(edge);
253
+ groups.set(pair, group);
254
+ }
255
+ const lanes = /* @__PURE__ */ new Map();
256
+ for (const group of groups.values()) {
257
+ group.forEach((edge, index) => {
258
+ const offset = (index - (group.length - 1) / 2) * 60;
259
+ lanes.set(edge.key, {
260
+ index,
261
+ count: group.length,
262
+ bend: edge.from <= edge.to ? offset : -offset
263
+ });
264
+ });
265
+ }
266
+ return lanes;
267
+ }
268
+
269
+ // ../../apps/web/src/lens.ts
270
+ function projectPoint(point, camera, viewport, strength) {
271
+ const blend = Math.min(1, strength);
272
+ const spacing = 1 - blend * 0.4;
273
+ const dx = (point.x - camera.x) * spacing;
274
+ const dy = (point.y - camera.y) * spacing;
275
+ const halfWidth = Math.max(1, viewport.width / 2 - 24);
276
+ const halfHeight = Math.max(1, viewport.height / 2 - 24);
277
+ const distance = Math.hypot(dx / halfWidth, dy / halfHeight);
278
+ const focusScale = 1 + strength * 0.75;
279
+ const gain = 1 - blend + blend * focusScale / (1 + focusScale * distance);
280
+ const circleSize = 1 - blend * 0.3;
281
+ return {
282
+ x: viewport.width / 2 + dx * gain * camera.zoom,
283
+ y: viewport.height / 2 + dy * gain * camera.zoom,
284
+ scale: camera.zoom * gain * circleSize
285
+ };
286
+ }
287
+ function panCamera(camera, delta) {
288
+ return {
289
+ ...camera,
290
+ x: camera.x - delta.x / camera.zoom,
291
+ y: camera.y - delta.y / camera.zoom
292
+ };
293
+ }
294
+ function zoomCamera(camera, factor) {
295
+ return { ...camera, zoom: Math.min(2, Math.max(0.3, camera.zoom * factor)) };
296
+ }
297
+ function fitCamera(points, size, radius2) {
298
+ if (points.length === 0) return { x: 0, y: 0, zoom: 1 };
299
+ const minX = Math.min(...points.map((p) => p.x)) - radius2;
300
+ const maxX = Math.max(...points.map((p) => p.x)) + radius2;
301
+ const minY = Math.min(...points.map((p) => p.y)) - radius2;
302
+ const maxY = Math.max(...points.map((p) => p.y)) + radius2;
303
+ return {
304
+ x: (minX + maxX) / 2,
305
+ y: (minY + maxY) / 2,
306
+ zoom: Math.max(
307
+ 1e-3,
308
+ Math.min(
309
+ (size.width - 48) / (maxX - minX),
310
+ (size.height - 48) / (maxY - minY),
311
+ 1
312
+ )
313
+ )
314
+ };
315
+ }
316
+
317
+ // ../../apps/web/src/NeighbourhoodContinuations.tsx
318
+ import { connectionWording as connectionWording2 } from "@lensmap/document";
319
+ import { useId as useId2 } from "react";
320
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
321
+ function NeighbourhoodContinuations({
322
+ continuations,
323
+ positions,
324
+ centreKey,
325
+ nodes,
326
+ nodeRadius: nodeRadius2,
327
+ previewEdges,
328
+ markerPrefix
329
+ }) {
330
+ const prefix = useId2();
331
+ const origin = positions.get(centreKey);
332
+ const byKey2 = new Map(nodes.map((node) => [node.key, node]));
333
+ const segments = continuations.flatMap((continuation, index) => {
334
+ const point = positions.get(continuation.at);
335
+ const source = byKey2.get(continuation.edge.from);
336
+ const target = byKey2.get(continuation.edge.to);
337
+ if (!point || !origin || !source || !target) return [];
338
+ const incoming = continuation.edge.to === continuation.at;
339
+ const appearance = connectionAppearance(
340
+ continuation.edge.type,
341
+ incoming ? "incoming" : "outgoing"
342
+ );
343
+ const angle = Math.atan2(point.y - origin.y, point.x - origin.x) + continuation.offset;
344
+ const ux = Math.cos(angle), uy = Math.sin(angle);
345
+ const marked = incoming ? appearance.end : appearance.start;
346
+ const radius2 = nodeRadius2 * point.scale + (marked ? incoming ? 7 : 3 : 0);
347
+ const near = { x: point.x + ux * radius2, y: point.y + uy * radius2 };
348
+ const far = {
349
+ x: near.x + ux * 70 * point.scale,
350
+ y: near.y + uy * 70 * point.scale
351
+ };
352
+ const [from, to] = incoming ? [far, near] : [near, far];
353
+ return [
354
+ {
355
+ ...continuation,
356
+ appearance,
357
+ incoming,
358
+ near,
359
+ far,
360
+ target,
361
+ description: `${label(source)} ${connectionWording2(continuation.edge.type, "outgoing")} ${label(target)}`,
362
+ id: `${prefix}-${index}`,
363
+ path: `M ${from.x} ${from.y} L ${to.x} ${to.y}`
364
+ }
365
+ ];
366
+ });
367
+ return /* @__PURE__ */ jsxs3("g", { className: "continuations", children: [
368
+ /* @__PURE__ */ jsx3("defs", { children: segments.map((segment) => /* @__PURE__ */ jsxs3(
369
+ "linearGradient",
370
+ {
371
+ id: segment.id,
372
+ gradientUnits: "userSpaceOnUse",
373
+ x1: segment.near.x,
374
+ y1: segment.near.y,
375
+ x2: segment.far.x,
376
+ y2: segment.far.y,
377
+ children: [
378
+ /* @__PURE__ */ jsx3(
379
+ "stop",
380
+ {
381
+ offset: "0%",
382
+ stopColor: colours[segment.target.branch],
383
+ stopOpacity: 0.65
384
+ }
385
+ ),
386
+ /* @__PURE__ */ jsx3(
387
+ "stop",
388
+ {
389
+ offset: "100%",
390
+ stopColor: colours[segment.target.branch],
391
+ stopOpacity: 0
392
+ }
393
+ )
394
+ ]
395
+ },
396
+ segment.edge.key
397
+ )) }),
398
+ segments.map((segment) => {
399
+ const appearance = segment.appearance;
400
+ const edgeClass = `edge continuation ${segment.edge.kind} ${previewEdges.has(segment.edge.key) ? "connection-preview" : ""}`;
401
+ const painted = /* @__PURE__ */ jsx3(
402
+ "path",
403
+ {
404
+ "data-continuation": segment.edge.key,
405
+ "data-connection-preview": previewEdges.has(segment.edge.key) ? "true" : void 0,
406
+ "data-kind": segment.edge.kind,
407
+ "data-type": segment.edge.type ?? "connected",
408
+ strokeDasharray: appearance.dashed ? "7 6" : void 0,
409
+ "data-direction": segment.incoming ? "incoming" : "outgoing",
410
+ d: segment.path,
411
+ stroke: `url(#${segment.id})`,
412
+ style: { color: colours[segment.target.branch] },
413
+ className: edgeClass,
414
+ markerStart: appearance.start ? `url(#${markerPrefix}-arrow-${segment.target.branch})` : void 0,
415
+ markerEnd: appearance.end ? `url(#${markerPrefix}-${appearance.end}-${segment.target.branch})` : void 0
416
+ }
417
+ );
418
+ return /* @__PURE__ */ jsxs3("g", { children: [
419
+ appearance.end === "bar" ? /* @__PURE__ */ jsx3(
420
+ ConnectionGap,
421
+ {
422
+ path: segment.path,
423
+ points: [segment.near, segment.far],
424
+ className: edgeClass,
425
+ markerId: `${markerPrefix}-bar-gap`,
426
+ children: painted
427
+ }
428
+ ) : painted,
429
+ /* @__PURE__ */ jsx3(
430
+ "path",
431
+ {
432
+ className: "edge-hit",
433
+ "data-continuation-hit": segment.edge.key,
434
+ d: segment.path,
435
+ children: /* @__PURE__ */ jsx3("title", { children: segment.description })
436
+ }
437
+ )
438
+ ] }, segment.edge.key);
439
+ })
440
+ ] });
441
+ }
442
+
443
+ // ../../apps/web/src/navigation.ts
444
+ var directions = {
445
+ ArrowLeft: { x: -1, y: 0 },
446
+ ArrowRight: { x: 1, y: 0 },
447
+ ArrowUp: { x: 0, y: -1 },
448
+ ArrowDown: { x: 0, y: 1 }
449
+ };
450
+ function isDirection(key) {
451
+ return key in directions;
452
+ }
453
+ var byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
454
+ var radius = (p) => Math.hypot(p.x, p.y);
455
+ function centralTarget(points, region = 130) {
456
+ return points.filter((p) => radius(p) <= region).sort((a, b) => radius(a) - radius(b) || byKey(a, b))[0];
457
+ }
458
+ function spatialTarget(points, key, direction) {
459
+ const origin = points.find((p) => p.key === key) ?? { x: 0, y: 0 };
460
+ const axis = directions[direction], r = radius(origin);
461
+ const radial = r ? (origin.x * axis.x + origin.y * axis.y) / r : 0;
462
+ return points.filter((p) => p.key !== key).map((point) => {
463
+ const dx = point.x - origin.x, dy = point.y - origin.y;
464
+ const distance = Math.hypot(dx, dy);
465
+ const cosine = distance ? (dx * axis.x + dy * axis.y) / distance : 0;
466
+ const change = radius(point) - r;
467
+ const orbitalPenalty = (1 - Math.abs(radial)) * Math.abs(change) + Math.abs(radial) * Math.max(0, -Math.sign(radial) * change);
468
+ return {
469
+ point,
470
+ cosine,
471
+ score: distance * (1 + 3 * (1 - cosine)) + 0.6 * orbitalPenalty
472
+ };
473
+ }).filter((p) => p.cosine >= 0.5).sort((a, b) => a.score - b.score || byKey(a.point, b.point))[0]?.point;
474
+ }
475
+ function connectionCandidates(graph, key, direction, visible = graph.nodes) {
476
+ const origin = graph.nodes.find((p) => p.key === key);
477
+ if (!origin) return [];
478
+ const at = visible.find((p) => p.key === key) ?? origin;
479
+ const neighbours = new Set(
480
+ graph.edges.flatMap(
481
+ (edge) => edge.from === key ? [edge.to] : edge.to === key ? [edge.from] : []
482
+ )
483
+ );
484
+ const axis = directions[direction];
485
+ return graph.nodes.filter((p) => p.key !== key && neighbours.has(p.key)).map((node) => {
486
+ const point = visible.find((p) => p.key === node.key) ?? {
487
+ x: at.x + node.x - origin.x,
488
+ y: at.y + node.y - origin.y
489
+ };
490
+ const dx = point.x - at.x, dy = point.y - at.y, distance = Math.hypot(dx, dy);
491
+ return {
492
+ node,
493
+ distance,
494
+ cosine: distance ? (dx * axis.x + dy * axis.y) / distance : 0
495
+ };
496
+ }).filter((p) => p.cosine >= 0.5).sort(
497
+ (a, b) => b.cosine - a.cosine || a.distance - b.distance || byKey(a.node, b.node)
498
+ ).map(
499
+ ({ node }) => ({
500
+ node,
501
+ routes: graph.edges.filter(
502
+ (edge) => edge.from === key && edge.to === node.key || edge.to === key && edge.from === node.key
503
+ ).map((edge) => ({
504
+ edge,
505
+ direction: edge.from === key ? "outgoing" : "incoming"
506
+ }))
507
+ })
508
+ );
509
+ }
510
+ function stepConnectionChoice(graph, key, direction, visible, previous) {
511
+ if (previous?.direction === direction) {
512
+ return {
513
+ ...previous,
514
+ index: previous.candidates.length ? (previous.index + 1) % previous.candidates.length : 0
515
+ };
516
+ }
517
+ const origin = previous?.origin ?? key;
518
+ const positions = previous?.positions ?? visible.map((point) => ({ ...point }));
519
+ return {
520
+ origin,
521
+ direction,
522
+ positions,
523
+ candidates: connectionCandidates(graph, origin, direction, positions),
524
+ index: 0
525
+ };
526
+ }
527
+ function heldDirection(keys) {
528
+ const x = Number(keys.has("ArrowRight")) - Number(keys.has("ArrowLeft"));
529
+ const y = Number(keys.has("ArrowDown")) - Number(keys.has("ArrowUp"));
530
+ const length = Math.hypot(x, y) || 1;
531
+ return { x: x / length, y: y / length };
532
+ }
533
+ function panDistance(elapsed, seconds, maximum) {
534
+ const start = 120, acceleration = 900, ramp = (maximum - start) / acceleration;
535
+ const integral = (t) => {
536
+ const accelerated = Math.min(t, ramp);
537
+ return start * accelerated + acceleration * accelerated ** 2 / 2 + maximum * Math.max(0, t - ramp);
538
+ };
539
+ return integral(elapsed + seconds) - integral(elapsed);
540
+ }
541
+
542
+ // ../../apps/web/src/neighbourhood.ts
543
+ function neighbourhood(graph, key, nodeRadius2 = 43) {
544
+ const centre = graph.nodes.find((node) => node.key === key);
545
+ if (!centre) return { nodes: [], edges: [], continuations: [] };
546
+ const visible = /* @__PURE__ */ new Set([key]);
547
+ for (const edge of graph.edges) {
548
+ if (edge.from === key) visible.add(edge.to);
549
+ if (edge.to === key) visible.add(edge.from);
550
+ }
551
+ const neighbours = graph.nodes.filter((node) => node.key !== key && visible.has(node.key)).map((node) => ({
552
+ node,
553
+ bearing: Math.atan2(node.y - centre.y, node.x - centre.x)
554
+ })).sort((a, b) => a.bearing - b.bearing);
555
+ const step = 2 * Math.PI / Math.max(1, neighbours.length);
556
+ const rotation = Math.atan2(
557
+ neighbours.reduce((sum, n, i) => sum + Math.sin(n.bearing - i * step), 0),
558
+ neighbours.reduce((sum, n, i) => sum + Math.cos(n.bearing - i * step), 0)
559
+ );
560
+ const radius2 = Math.max(
561
+ 260,
562
+ (2 * nodeRadius2 + 24) / (2 * Math.sin(Math.PI / Math.max(2, neighbours.length)))
563
+ );
564
+ const nodes = [
565
+ { ...centre, x: 0, y: 0 },
566
+ ...neighbours.map(({ node }, i) => ({
567
+ ...node,
568
+ x: radius2 * Math.cos(rotation + i * step),
569
+ y: radius2 * Math.sin(rotation + i * step)
570
+ }))
571
+ ];
572
+ const edges = graph.edges.filter(
573
+ (edge) => visible.has(edge.from) && visible.has(edge.to)
574
+ );
575
+ const boundary = graph.edges.filter(
576
+ (edge) => visible.has(edge.from) !== visible.has(edge.to)
577
+ );
578
+ const groups = /* @__PURE__ */ new Map();
579
+ for (const edge of boundary) {
580
+ const at = visible.has(edge.from) ? edge.from : edge.to;
581
+ const group = groups.get(at) ?? [];
582
+ group.push(edge);
583
+ groups.set(at, group);
584
+ }
585
+ const continuations = boundary.map((edge) => {
586
+ const at = visible.has(edge.from) ? edge.from : edge.to;
587
+ const group = groups.get(at) ?? [];
588
+ return {
589
+ edge,
590
+ at,
591
+ offset: group.length > 1 ? (group.indexOf(edge) / (group.length - 1) - 0.5) * 1.25 : 0
592
+ };
593
+ });
594
+ return { nodes, edges, continuations };
595
+ }
596
+
597
+ // ../../apps/web/src/SidebarResizeHandle.tsx
598
+ import { useEffect as useEffect2, useRef as useRef2, useState } from "react";
599
+ import { jsx as jsx4 } from "react/jsx-runtime";
600
+ function SidebarResizeHandle({ width, onResize }) {
601
+ const handle = useRef2(null);
602
+ const currentWidth = useRef2(width);
603
+ currentWidth.current = width;
604
+ const drag = useRef2(null);
605
+ const [maximum, setMaximum] = useState(720);
606
+ const minimum = 240;
607
+ useEffect2(() => {
608
+ const parent = handle.current?.parentElement;
609
+ if (!parent) return;
610
+ const observer = new ResizeObserver(([entry]) => {
611
+ if (window.matchMedia("(max-width: 760px)").matches) return;
612
+ const max = Math.max(
613
+ minimum,
614
+ Math.min(720, entry.contentRect.width - 320)
615
+ );
616
+ setMaximum(max);
617
+ if (currentWidth.current > max) onResize(max);
618
+ });
619
+ observer.observe(parent);
620
+ return () => observer.disconnect();
621
+ }, [onResize]);
622
+ function resize(next) {
623
+ onResize(Math.max(minimum, Math.min(maximum, next)));
624
+ }
625
+ return /* @__PURE__ */ jsx4(
626
+ "hr",
627
+ {
628
+ ref: handle,
629
+ className: "sidebar-resizer",
630
+ "aria-label": "Resize sidebar",
631
+ "aria-orientation": "vertical",
632
+ "aria-controls": "lensmap-sidebar",
633
+ "aria-valuemin": minimum,
634
+ "aria-valuemax": maximum,
635
+ "aria-valuenow": Math.round(width),
636
+ tabIndex: 0,
637
+ onPointerDown: (event) => {
638
+ if (event.button !== 0 || !event.isPrimary) return;
639
+ event.preventDefault();
640
+ event.currentTarget.focus();
641
+ event.currentTarget.setPointerCapture(event.pointerId);
642
+ drag.current = { id: event.pointerId, x: event.clientX, width };
643
+ },
644
+ onPointerMove: (event) => {
645
+ const start = drag.current;
646
+ if (start?.id === event.pointerId)
647
+ resize(start.width + start.x - event.clientX);
648
+ },
649
+ onPointerUp: (event) => {
650
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
651
+ event.currentTarget.releasePointerCapture(event.pointerId);
652
+ drag.current = null;
653
+ },
654
+ onPointerCancel: () => {
655
+ drag.current = null;
656
+ },
657
+ onLostPointerCapture: () => {
658
+ drag.current = null;
659
+ },
660
+ onKeyDown: (event) => {
661
+ const next = {
662
+ ArrowLeft: width + 24,
663
+ ArrowRight: width - 24,
664
+ Home: minimum,
665
+ End: maximum
666
+ }[event.key];
667
+ if (next !== void 0) {
668
+ event.preventDefault();
669
+ resize(next);
670
+ }
671
+ }
672
+ }
673
+ );
674
+ }
675
+
676
+ // ../../apps/web/src/useMapKeyboard.ts
677
+ import {
678
+ useCallback,
679
+ useEffect as useEffect3,
680
+ useEffectEvent,
681
+ useRef as useRef3
682
+ } from "react";
683
+ function useMapKeyboard(ref, controls) {
684
+ const keys = useRef3(/* @__PURE__ */ new Set());
685
+ const frame = useRef3(null);
686
+ const last = useRef3(0);
687
+ const elapsed = useRef3(0);
688
+ const stop = useCallback(() => {
689
+ keys.current.clear();
690
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
691
+ frame.current = null;
692
+ elapsed.current = 0;
693
+ }, []);
694
+ const eligible = (target) => target instanceof Element && !!ref.current?.contains(target) && (target === ref.current || !!target.closest("[data-node]"));
695
+ const tick = useEffectEvent((now) => {
696
+ if (!controls.canPan || !eligible(document.activeElement)) {
697
+ stop();
698
+ return;
699
+ }
700
+ const direction = heldDirection(keys.current);
701
+ const seconds = Math.min(0.05, Math.max(0, (now - last.current) / 1e3));
702
+ last.current = now;
703
+ if (direction.x || direction.y) {
704
+ const distance = panDistance(elapsed.current, seconds, controls.maximum);
705
+ elapsed.current += seconds;
706
+ controls.onPan({
707
+ x: -direction.x * distance,
708
+ y: -direction.y * distance
709
+ });
710
+ }
711
+ frame.current = requestAnimationFrame(tick);
712
+ });
713
+ const down = useEffectEvent((event) => {
714
+ if (!eligible(event.target)) return;
715
+ if (event.isComposing || event.metaKey || event.altKey) {
716
+ stop();
717
+ return;
718
+ }
719
+ if (event.ctrlKey && ["=", "+", "-"].includes(event.key)) {
720
+ event.preventDefault();
721
+ controls.onIntent();
722
+ stop();
723
+ controls.onZoom(event.key === "-" ? 1 / 1.2 : 1.2);
724
+ return;
725
+ }
726
+ if (["Control", "Shift", "Alt", "Meta", "Tab"].includes(event.key)) stop();
727
+ if (isDirection(event.key)) {
728
+ event.preventDefault();
729
+ controls.onIntent();
730
+ if (event.ctrlKey || event.shiftKey) {
731
+ stop();
732
+ if (event.ctrlKey && !event.shiftKey) controls.onSpatial(event.key);
733
+ else if (event.shiftKey && !event.ctrlKey && !event.repeat)
734
+ controls.onConnection(event.key);
735
+ return;
736
+ }
737
+ if (!controls.canPan || event.repeat || keys.current.has(event.key))
738
+ return;
739
+ const previous = heldDirection(keys.current), wasEmpty = keys.current.size === 0;
740
+ keys.current.add(event.key);
741
+ const direction = heldDirection(keys.current);
742
+ if (previous.x !== direction.x || previous.y !== direction.y)
743
+ elapsed.current = 0;
744
+ if (wasEmpty)
745
+ controls.onPan({ x: -direction.x * 8, y: -direction.y * 8 });
746
+ if (frame.current === null) {
747
+ last.current = performance.now();
748
+ frame.current = requestAnimationFrame(tick);
749
+ }
750
+ } else if (event.key === " " || event.key === "Enter" || event.key === "Escape") {
751
+ event.preventDefault();
752
+ controls.onIntent();
753
+ stop();
754
+ if (event.repeat || event.ctrlKey && event.shiftKey) return;
755
+ if (event.key === " ") controls.onAccept();
756
+ else if (event.key === "Enter") controls.onToggle();
757
+ else controls.onEscape();
758
+ }
759
+ });
760
+ const up = useEffectEvent((event) => {
761
+ if (event.key === " " && eligible(event.target) && !event.isComposing && !event.metaKey && !event.altKey)
762
+ event.preventDefault();
763
+ if (!keys.current.has(event.key)) return;
764
+ const previous = heldDirection(keys.current);
765
+ keys.current.delete(event.key);
766
+ const direction = heldDirection(keys.current);
767
+ if (direction.x !== previous.x || direction.y !== previous.y)
768
+ elapsed.current = 0;
769
+ if (!keys.current.size) stop();
770
+ });
771
+ const blur = useEffectEvent((event) => {
772
+ if (!eligible(event.relatedTarget)) stop();
773
+ });
774
+ useEffect3(() => {
775
+ const element = ref.current;
776
+ if (!element) return;
777
+ const hidden = () => {
778
+ if (document.hidden) stop();
779
+ };
780
+ element.addEventListener("keydown", down);
781
+ element.addEventListener("keyup", up);
782
+ element.addEventListener("focusout", blur);
783
+ window.addEventListener("blur", stop);
784
+ document.addEventListener("visibilitychange", hidden);
785
+ return () => {
786
+ stop();
787
+ element.removeEventListener("keydown", down);
788
+ element.removeEventListener("keyup", up);
789
+ element.removeEventListener("focusout", blur);
790
+ window.removeEventListener("blur", stop);
791
+ document.removeEventListener("visibilitychange", hidden);
792
+ };
793
+ }, [ref, stop]);
794
+ return { stop };
795
+ }
796
+
797
+ // ../../apps/web/src/ViewerInspector.tsx
798
+ import { thoughtAnchors } from "@lensmap/document";
799
+ import Markdown from "react-markdown";
800
+
801
+ // ../../apps/web/src/ViewerConnections.tsx
802
+ import { connectionWording as connectionWording3 } from "@lensmap/document";
803
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
804
+ function ViewerConnections({
805
+ graph,
806
+ active,
807
+ onFocus,
808
+ getLabel = label
809
+ }) {
810
+ return /* @__PURE__ */ jsx5("nav", { className: "viewer-connections", "aria-label": "Connected thoughts", children: ["outgoing", "incoming"].map((direction) => {
811
+ const connections = graph.edges.filter(
812
+ (edge) => direction === "outgoing" ? edge.from === active?.key : edge.to === active?.key
813
+ );
814
+ return /* @__PURE__ */ jsxs4("details", { className: "sidebar-section outgoing", open: true, children: [
815
+ /* @__PURE__ */ jsx5("summary", { children: direction === "outgoing" ? "Outgoing connections" : "Incoming connections" }),
816
+ connections.map((edge) => {
817
+ const key = direction === "outgoing" ? edge.to : edge.from;
818
+ const node = graph.nodes.find(
819
+ (candidate) => candidate.key === key
820
+ );
821
+ return node && /* @__PURE__ */ jsxs4(
822
+ "button",
823
+ {
824
+ type: "button",
825
+ "data-focus-key": key,
826
+ onClick: () => onFocus(key),
827
+ children: [
828
+ connectionWording3(edge.type, direction),
829
+ " \xB7 ",
830
+ getLabel(node)
831
+ ]
832
+ },
833
+ edge.key
834
+ );
835
+ }),
836
+ !connections.length && /* @__PURE__ */ jsxs4("p", { children: [
837
+ "No ",
838
+ direction,
839
+ " connections."
840
+ ] })
841
+ ] }, direction);
842
+ }) });
843
+ }
844
+
845
+ // ../../apps/web/src/ViewerInspector.tsx
846
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
847
+ function ViewerInspector({
848
+ graph,
849
+ thoughtHref,
850
+ active,
851
+ onFocus,
852
+ onFragment,
853
+ onActivatePortal,
854
+ onRevealSource
855
+ }) {
856
+ return /* @__PURE__ */ jsxs5("aside", { className: "notes", id: "lensmap-sidebar", "aria-label": "Sidebar", children: [
857
+ /* @__PURE__ */ jsxs5("details", { className: "sidebar-section", open: true, children: [
858
+ /* @__PURE__ */ jsx6("summary", { children: "Selected thought" }),
859
+ /* @__PURE__ */ jsxs5("div", { className: "note-content", children: [
860
+ active ? /* @__PURE__ */ jsx6(
861
+ Markdown,
862
+ {
863
+ components: {
864
+ h1: ({ children }) => /* @__PURE__ */ jsx6("h2", { children }),
865
+ a: ({ href, children }) => /* @__PURE__ */ jsx6(
866
+ "a",
867
+ {
868
+ href,
869
+ onClick: (event) => {
870
+ if (href?.startsWith("#")) {
871
+ event.preventDefault();
872
+ onFragment(href.slice(1));
873
+ }
874
+ },
875
+ children
876
+ }
877
+ )
878
+ },
879
+ children: active.markdown || (active.unresolved ? "# Unresolved thought" : active.title)
880
+ }
881
+ ) : /* @__PURE__ */ jsx6("p", { children: "No thought selected." }),
882
+ active && onRevealSource && /* @__PURE__ */ jsx6("button", { type: "button", onClick: () => onRevealSource(active.key), children: "Reveal source" }),
883
+ active && /* @__PURE__ */ jsx6(
884
+ "a",
885
+ {
886
+ href: thoughtHref ?? `#${encodeURIComponent(thoughtAnchors(graph.nodes).get(active.key) ?? active.key)}`,
887
+ onClick: (event) => {
888
+ event.preventDefault();
889
+ onFragment(
890
+ encodeURIComponent(
891
+ thoughtAnchors(graph.nodes).get(active.key) ?? active.key
892
+ )
893
+ );
894
+ },
895
+ children: "Link to this thought"
896
+ }
897
+ ),
898
+ active?.destination && /* @__PURE__ */ jsxs5("div", { className: "portal-actions", children: [
899
+ /* @__PURE__ */ jsx6("p", { children: active.destination.url }),
900
+ /* @__PURE__ */ jsxs5(
901
+ "button",
902
+ {
903
+ type: "button",
904
+ onClick: () => onActivatePortal?.(active),
905
+ disabled: !onActivatePortal,
906
+ children: [
907
+ "Open",
908
+ " ",
909
+ active.destination.kind === "map_link" ? "destination map" : "external link"
910
+ ]
911
+ }
912
+ )
913
+ ] })
914
+ ] })
915
+ ] }),
916
+ /* @__PURE__ */ jsx6(ViewerConnections, { graph, active, onFocus })
917
+ ] });
918
+ }
919
+
920
+ // ../../apps/web/src/viewer-state.ts
921
+ function reconcileViewer(previous, next, selected, camera, mode) {
922
+ if (mode === "live")
923
+ return {
924
+ selected: next.nodes.find((node) => node.key === selected)?.key ?? next.nodes[0]?.key ?? "",
925
+ camera,
926
+ reset: false
927
+ };
928
+ const id = previous.nodes.find((node) => node.key === selected)?.id;
929
+ const active = (id ? next.nodes.find((node) => node.id === id) : void 0) ?? next.nodes[0];
930
+ return {
931
+ selected: active?.key ?? "",
932
+ camera: { x: active?.x ?? 0, y: active?.y ?? 0, zoom: 0.85 },
933
+ reset: true
934
+ };
935
+ }
936
+
937
+ // ../../apps/web/src/Viewer.tsx
938
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
939
+ var initialCamera = { x: 0, y: 0, zoom: 0.85 };
940
+ var nodeRadius = 43;
941
+ function Viewer({
942
+ graph,
943
+ embedded = false,
944
+ navigationControls,
945
+ thoughtLink,
946
+ onActivatePortal,
947
+ initialState,
948
+ initialTarget,
949
+ initialOverview,
950
+ onStateChange,
951
+ updateMode = "live",
952
+ focusRequest,
953
+ onRevealSource,
954
+ showInspector = true,
955
+ renderSidebar,
956
+ renderExternalInspector
957
+ }) {
958
+ const scope = useId3();
959
+ const anchors = useMemo(() => thoughtAnchors2(graph.nodes), [graph.nodes]);
960
+ const entryNode = graph.nodes.find((n) => n.key === initialTarget);
961
+ const restoredNode = graph.nodes.find(
962
+ (n) => n.key === initialState?.selected
963
+ );
964
+ const canvas = useRef4(null);
965
+ const scene = useRef4(null);
966
+ const pendingActivation = useRef4(null);
967
+ const doubleCandidate = useRef4(null);
968
+ const cancelActivation = useCallback2(() => {
969
+ if (pendingActivation.current !== null)
970
+ clearTimeout(pendingActivation.current);
971
+ pendingActivation.current = null;
972
+ doubleCandidate.current = null;
973
+ }, []);
974
+ const drag = useRef4(null);
975
+ const suppressPointerClick = useRef4(false);
976
+ const [size, setSize] = useState2({ width: 900, height: 640 });
977
+ const [camera, setCamera] = useState2(
978
+ initialState?.camera ?? (entryNode ? { ...initialCamera, x: entryNode.x, y: entryNode.y } : initialCamera)
979
+ );
980
+ const { nodes, edges } = graph;
981
+ const lanes = useMemo(() => edgeLanes(edges), [edges]);
982
+ const [selected, setSelected] = useState2(
983
+ restoredNode?.key ?? entryNode?.key ?? nodes[0]?.key ?? ""
984
+ );
985
+ const [sidebarWidth, setSidebarWidth] = useState2(340);
986
+ const [strength, setStrength] = useState2(initialState?.strength ?? 1.1);
987
+ const [neighbourhoodStrength, setNeighbourhoodStrength] = useState2(
988
+ initialState?.neighbourhoodStrength ?? 1.1
989
+ );
990
+ const [overview, setOverview] = useState2(
991
+ initialState?.overview ?? initialOverview ?? false
992
+ );
993
+ const [inNeighbourhood, setInNeighbourhood] = useState2(
994
+ !!restoredNode && (initialState?.inNeighbourhood ?? false)
995
+ );
996
+ const [neighbourhoodCamera, setNeighbourhoodCamera] = useState2(
997
+ initialState?.neighbourhoodCamera ?? {
998
+ x: 0,
999
+ y: 0,
1000
+ zoom: 1
1001
+ }
1002
+ );
1003
+ const [dragging, setDragging] = useState2(false);
1004
+ const [message, setMessage] = useState2("");
1005
+ const [keyboardTarget, setKeyboardTarget] = useState2();
1006
+ const [keyboardActive, setKeyboardActive] = useState2(false);
1007
+ const [maximumPanSpeed, setMaximumPanSpeed] = useState2(900);
1008
+ const [connectionChoice, setConnectionChoice] = useState2();
1009
+ const dismissConnectionChoice = useCallback2(() => {
1010
+ const focused = document.activeElement;
1011
+ if (focused instanceof Element && canvas.current?.contains(focused) && focused.closest("[data-connection-chooser]")) {
1012
+ canvas.current.focus({ preventScroll: true });
1013
+ }
1014
+ setConnectionChoice(void 0);
1015
+ }, []);
1016
+ const returnOverview = useRef4(initialState?.returnOverview ?? false);
1017
+ const reportState = useEffectEvent2(
1018
+ () => onStateChange?.({
1019
+ selected,
1020
+ camera,
1021
+ strength,
1022
+ overview,
1023
+ inNeighbourhood,
1024
+ neighbourhoodCamera,
1025
+ neighbourhoodStrength,
1026
+ returnOverview: returnOverview.current
1027
+ })
1028
+ );
1029
+ useEffect4(() => {
1030
+ reportState();
1031
+ }, [
1032
+ selected,
1033
+ camera,
1034
+ strength,
1035
+ overview,
1036
+ inNeighbourhood,
1037
+ neighbourhoodCamera,
1038
+ neighbourhoodStrength
1039
+ ]);
1040
+ const keyboard = useMapKeyboard(canvas, {
1041
+ canPan: !overview,
1042
+ maximum: maximumPanSpeed,
1043
+ onIntent: cancelActivation,
1044
+ onPan: panView,
1045
+ onZoom: zoomView,
1046
+ onSpatial: moveTarget,
1047
+ onConnection: previewConnection,
1048
+ onAccept: () => {
1049
+ if (target) focusNode(target.key);
1050
+ },
1051
+ onToggle: () => {
1052
+ if (targetNode?.destination) {
1053
+ dismissConnectionChoice();
1054
+ onActivatePortal?.(targetNode);
1055
+ } else if (target) toggleNeighbourhood(target.key);
1056
+ },
1057
+ onEscape: escapeNavigation
1058
+ });
1059
+ const setViewCamera = inNeighbourhood ? setNeighbourhoodCamera : setCamera;
1060
+ const shownStrength = inNeighbourhood ? neighbourhoodStrength : strength;
1061
+ const setViewStrength = inNeighbourhood ? setNeighbourhoodStrength : setStrength;
1062
+ const local = useMemo(
1063
+ () => inNeighbourhood ? neighbourhood(graph, selected, nodeRadius) : null,
1064
+ [graph, inNeighbourhood, selected]
1065
+ );
1066
+ useEffect4(() => cancelActivation, [cancelActivation]);
1067
+ useEffect4(() => {
1068
+ const element = canvas.current;
1069
+ if (!element) return;
1070
+ const observer = new ResizeObserver(([entry]) => {
1071
+ dismissConnectionChoice();
1072
+ setSize({
1073
+ width: entry.contentRect.width,
1074
+ height: entry.contentRect.height
1075
+ });
1076
+ });
1077
+ observer.observe(element);
1078
+ return () => observer.disconnect();
1079
+ }, [dismissConnectionChoice]);
1080
+ useEffect4(() => {
1081
+ const element = canvas.current;
1082
+ if (!element) return;
1083
+ const wheel = (event) => {
1084
+ if (event.target instanceof Element && event.target.closest("[data-connection-chooser]"))
1085
+ return;
1086
+ cancelActivation();
1087
+ keyboard.stop();
1088
+ dismissConnectionChoice();
1089
+ if (overview) return;
1090
+ event.preventDefault();
1091
+ const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? size.height : 1);
1092
+ setViewCamera(
1093
+ (current) => zoomCamera(
1094
+ current,
1095
+ Math.exp(-Math.max(-400, Math.min(400, pixels)) * 15e-4)
1096
+ )
1097
+ );
1098
+ };
1099
+ element.addEventListener("wheel", wheel, { passive: false });
1100
+ return () => element.removeEventListener("wheel", wheel);
1101
+ }, [
1102
+ overview,
1103
+ size.height,
1104
+ setViewCamera,
1105
+ cancelActivation,
1106
+ keyboard.stop,
1107
+ dismissConnectionChoice
1108
+ ]);
1109
+ const shownCamera = inNeighbourhood ? neighbourhoodCamera : overview ? fitCamera(nodes, size, nodeRadius) : camera;
1110
+ const projected = (local?.nodes ?? nodes).map((node) => ({
1111
+ ...projectPoint(
1112
+ node,
1113
+ { ...shownCamera, zoom: 1 },
1114
+ size,
1115
+ overview ? 0 : shownStrength
1116
+ ),
1117
+ node
1118
+ }));
1119
+ const circles = overview ? projected : limitMagnification(projected, nodeRadius, shownStrength);
1120
+ const positions = new Map(circles.map((point) => [point.node.key, point]));
1121
+ const navigationPoints = circles.map((point) => ({
1122
+ key: point.node.key,
1123
+ x: (point.x - size.width / 2) * shownCamera.zoom,
1124
+ y: (point.y - size.height / 2) * shownCamera.zoom
1125
+ }));
1126
+ const visiblePoints = navigationPoints.filter(
1127
+ (point) => Math.abs(point.x) <= size.width / 2 && Math.abs(point.y) <= size.height / 2
1128
+ );
1129
+ const ordinaryTarget = keyboardTarget === void 0 ? centralTarget(visiblePoints) : visiblePoints.find((point) => point.key === keyboardTarget);
1130
+ const pendingConnection = connectionChoice?.candidates[connectionChoice.index];
1131
+ const target = connectionChoice ? pendingConnection?.node : ordinaryTarget;
1132
+ const targetNode = nodes.find((node) => node.key === target?.key);
1133
+ const choiceOrigin = nodes.find(
1134
+ (node) => node.key === connectionChoice?.origin
1135
+ );
1136
+ const previewEdges = new Set(
1137
+ pendingConnection?.routes.map((route) => route.edge.key)
1138
+ );
1139
+ const visibleKeys = new Set(visiblePoints.map((point) => point.key));
1140
+ const active = nodes.find((node) => node.key === selected) ?? nodes[0];
1141
+ const incident = edges.filter(
1142
+ (edge) => edge.from === active?.key || edge.to === active?.key
1143
+ );
1144
+ const connectedEdges = new Set(incident.map((edge) => edge.key));
1145
+ const connectedNodes = new Set(
1146
+ incident.flatMap((edge) => [edge.from, edge.to])
1147
+ );
1148
+ const sceneEdges = local?.edges ?? edges;
1149
+ const drawnEdges = [
1150
+ ...sceneEdges.filter(
1151
+ (edge) => !connectedEdges.has(edge.key) && !previewEdges.has(edge.key)
1152
+ ),
1153
+ ...sceneEdges.filter(
1154
+ (edge) => connectedEdges.has(edge.key) && !previewEdges.has(edge.key)
1155
+ ),
1156
+ ...sceneEdges.filter((edge) => previewEdges.has(edge.key))
1157
+ ];
1158
+ function panView(delta) {
1159
+ dismissConnectionChoice();
1160
+ setKeyboardTarget(void 0);
1161
+ setViewCamera((current) => panCamera(current, delta));
1162
+ }
1163
+ function zoomView(factor) {
1164
+ cancelActivation();
1165
+ keyboard.stop();
1166
+ dismissConnectionChoice();
1167
+ if (!overview) setViewCamera((current) => zoomCamera(current, factor));
1168
+ }
1169
+ function moveTarget(direction) {
1170
+ const from = visibleKeys.has(target?.key ?? "") ? target?.key : connectionChoice?.origin ?? ordinaryTarget?.key;
1171
+ const next = spatialTarget(visiblePoints, from, direction);
1172
+ dismissConnectionChoice();
1173
+ setKeyboardTarget(next?.key ?? from);
1174
+ }
1175
+ function previewConnection(direction) {
1176
+ const origin = connectionChoice?.origin ?? target?.key;
1177
+ if (origin)
1178
+ setConnectionChoice(
1179
+ (previous) => stepConnectionChoice(
1180
+ graph,
1181
+ origin,
1182
+ direction,
1183
+ navigationPoints,
1184
+ previous
1185
+ )
1186
+ );
1187
+ }
1188
+ function escapeNavigation() {
1189
+ if (connectionChoice) dismissConnectionChoice();
1190
+ else restoreMap();
1191
+ }
1192
+ function restoreMap() {
1193
+ if (!inNeighbourhood) return;
1194
+ cancelActivation();
1195
+ keyboard.stop();
1196
+ stopDrag();
1197
+ setInNeighbourhood(false);
1198
+ setOverview(returnOverview.current);
1199
+ setKeyboardTarget(void 0);
1200
+ revealScene();
1201
+ }
1202
+ function retainViewerFocus(key, nextNeighbourhood) {
1203
+ if (!nextNeighbourhood) return;
1204
+ const viewer = canvas.current;
1205
+ const focused = document.activeElement;
1206
+ if (!viewer?.contains(focused)) return;
1207
+ const focusedKey = focused instanceof Element ? focused.closest("[data-node]")?.dataset.node : void 0;
1208
+ if (focusedKey && focusedKey !== key && !edges.some(
1209
+ (edge) => edge.from === key && edge.to === focusedKey || edge.to === key && edge.from === focusedKey
1210
+ ))
1211
+ viewer.focus({ preventScroll: true });
1212
+ }
1213
+ function focusNode(key) {
1214
+ cancelActivation();
1215
+ keyboard.stop();
1216
+ const node = nodes.find((candidate) => candidate.key === key);
1217
+ if (!node) {
1218
+ setMessage(`The selected node is no longer in the graph.`);
1219
+ return;
1220
+ }
1221
+ dismissConnectionChoice();
1222
+ retainViewerFocus(key, inNeighbourhood);
1223
+ setSelected(key);
1224
+ setKeyboardTarget(key);
1225
+ setOverview(false);
1226
+ if (inNeighbourhood) {
1227
+ setNeighbourhoodCamera((current) => ({ ...current, x: 0, y: 0 }));
1228
+ revealScene();
1229
+ } else {
1230
+ setCamera((current) => ({ ...current, x: node.x, y: node.y }));
1231
+ }
1232
+ setMessage("");
1233
+ }
1234
+ function revealScene() {
1235
+ if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches)
1236
+ scene.current?.animate([{ opacity: 0.4 }, { opacity: 1 }], {
1237
+ duration: 160,
1238
+ easing: "ease-out"
1239
+ });
1240
+ }
1241
+ function toggleNeighbourhood(key) {
1242
+ const node = nodes.find((candidate) => candidate.key === key);
1243
+ if (!node) return;
1244
+ dismissConnectionChoice();
1245
+ cancelActivation();
1246
+ keyboard.stop();
1247
+ stopDrag();
1248
+ retainViewerFocus(key, !inNeighbourhood);
1249
+ setSelected(key);
1250
+ setKeyboardTarget(key);
1251
+ if (!inNeighbourhood) returnOverview.current = overview;
1252
+ setOverview(false);
1253
+ setInNeighbourhood(!inNeighbourhood);
1254
+ if (inNeighbourhood)
1255
+ setCamera((current) => ({ ...current, x: node.x, y: node.y }));
1256
+ else {
1257
+ setNeighbourhoodCamera({ x: 0, y: 0, zoom: 1 });
1258
+ setNeighbourhoodStrength(strength);
1259
+ }
1260
+ setMessage("");
1261
+ revealScene();
1262
+ }
1263
+ function showMap(nextOverview) {
1264
+ dismissConnectionChoice();
1265
+ cancelActivation();
1266
+ keyboard.stop();
1267
+ stopDrag();
1268
+ if (inNeighbourhood && active) {
1269
+ setCamera((current) => ({ ...current, x: active.x, y: active.y }));
1270
+ revealScene();
1271
+ }
1272
+ setInNeighbourhood(false);
1273
+ setOverview(nextOverview);
1274
+ setKeyboardTarget(void 0);
1275
+ }
1276
+ function activateNode(event, key) {
1277
+ if (event.detail === 0) {
1278
+ focusNode(key);
1279
+ return;
1280
+ }
1281
+ if (event.detail !== 1) return;
1282
+ cancelActivation();
1283
+ doubleCandidate.current = key;
1284
+ pendingActivation.current = setTimeout(() => focusNode(key), 350);
1285
+ }
1286
+ function focusFragment(fragment) {
1287
+ let id;
1288
+ try {
1289
+ id = decodeURIComponent(fragment);
1290
+ } catch {
1291
+ setMessage(`The fragment #${fragment} is not a valid URL fragment.`);
1292
+ return;
1293
+ }
1294
+ const key = resolveThoughtAnchor(nodes, id);
1295
+ const node = nodes.find((candidate) => candidate.key === key);
1296
+ if (node) focusNode(node.key);
1297
+ else setMessage(`No thought has anchor "${id}".`);
1298
+ }
1299
+ const previousGraph = useRef4(graph);
1300
+ const lastRequest = useRef4(
1301
+ void 0
1302
+ );
1303
+ const focusedBeforeUpdate = typeof document === "undefined" ? null : document.activeElement;
1304
+ const hadViewerFocus = !!canvas.current?.contains(focusedBeforeUpdate);
1305
+ const reconcileGraph = useEffectEvent2(() => {
1306
+ if (previousGraph.current === graph) return;
1307
+ const next = reconcileViewer(
1308
+ previousGraph.current,
1309
+ graph,
1310
+ selected,
1311
+ camera,
1312
+ updateMode
1313
+ );
1314
+ previousGraph.current = graph;
1315
+ cancelActivation();
1316
+ keyboard.stop();
1317
+ stopDrag();
1318
+ setConnectionChoice(void 0);
1319
+ setKeyboardTarget(void 0);
1320
+ setSelected(next.selected);
1321
+ setCamera(next.camera);
1322
+ if (next.reset || !graph.nodes.some((node) => node.key === selected))
1323
+ setInNeighbourhood(false);
1324
+ setMessage("");
1325
+ });
1326
+ useLayoutEffect(() => {
1327
+ reconcileGraph();
1328
+ if (hadViewerFocus && focusedBeforeUpdate && !focusedBeforeUpdate.isConnected)
1329
+ canvas.current?.focus({ preventScroll: true });
1330
+ });
1331
+ const receiveFocus = useEffectEvent2(() => {
1332
+ if (!focusRequest || lastRequest.current?.key === focusRequest.key && lastRequest.current?.sequence === focusRequest.sequence)
1333
+ return;
1334
+ lastRequest.current = focusRequest;
1335
+ const node = graph.nodes.find(
1336
+ (candidate) => candidate.key === focusRequest.key
1337
+ );
1338
+ if (!node) return;
1339
+ cancelActivation();
1340
+ keyboard.stop();
1341
+ stopDrag();
1342
+ setConnectionChoice(void 0);
1343
+ setSelected(node.key);
1344
+ setKeyboardTarget(node.key);
1345
+ setInNeighbourhood(false);
1346
+ setOverview(false);
1347
+ setCamera((current) => ({ ...current, x: node.x, y: node.y }));
1348
+ setMessage("");
1349
+ });
1350
+ useLayoutEffect(() => {
1351
+ receiveFocus();
1352
+ });
1353
+ function stopDrag() {
1354
+ const previous = drag.current;
1355
+ if (previous?.capture.hasPointerCapture(previous.id)) {
1356
+ previous.capture.releasePointerCapture(previous.id);
1357
+ }
1358
+ drag.current = null;
1359
+ setDragging(false);
1360
+ }
1361
+ const Container = embedded ? "div" : "main";
1362
+ const sidebarContext = {
1363
+ graph,
1364
+ active,
1365
+ thoughtHref: active ? thoughtLink?.(anchors.get(active.key) ?? active.key) : void 0,
1366
+ onActivatePortal,
1367
+ onFocus: focusNode,
1368
+ onFragment: focusFragment
1369
+ };
1370
+ return /* @__PURE__ */ jsxs6(
1371
+ Container,
1372
+ {
1373
+ className: `lensmap-viewer${embedded ? " lensmap-embedded" : ""}`,
1374
+ children: [
1375
+ !embedded && /* @__PURE__ */ jsxs6("header", { className: "masthead", children: [
1376
+ /* @__PURE__ */ jsxs6("div", { className: "brand", children: [
1377
+ /* @__PURE__ */ jsx7("span", { className: "brand-mark", "aria-hidden": "true", children: "\u25CE" }),
1378
+ /* @__PURE__ */ jsx7("h1", { children: "Lensmap" }),
1379
+ /* @__PURE__ */ jsx7("span", { className: "tag", children: "spatial lens lab" })
1380
+ ] }),
1381
+ /* @__PURE__ */ jsx7("p", { children: "Follow a thought. Keep the world around it." }),
1382
+ /* @__PURE__ */ jsx7("span", { className: "phase", children: "01 / web experiment" })
1383
+ ] }),
1384
+ /* @__PURE__ */ jsxs6(
1385
+ "section",
1386
+ {
1387
+ className: `workspace${showInspector ? "" : " canvas-only"}`,
1388
+ "aria-label": "Lens exploration",
1389
+ style: { "--sidebar-width": `${sidebarWidth}px` },
1390
+ onPointerDownCapture: (event) => {
1391
+ keyboard.stop();
1392
+ if (!event.target.closest("[data-node]"))
1393
+ cancelActivation();
1394
+ },
1395
+ onKeyDown: cancelActivation,
1396
+ children: [
1397
+ /* @__PURE__ */ jsxs6("div", { className: "map-column", children: [
1398
+ /* @__PURE__ */ jsxs6("div", { className: "toolbar", children: [
1399
+ navigationControls,
1400
+ /* @__PURE__ */ jsxs6("fieldset", { className: "segmented", children: [
1401
+ /* @__PURE__ */ jsx7("legend", { className: "sr-only", children: "Map view" }),
1402
+ /* @__PURE__ */ jsx7(
1403
+ "button",
1404
+ {
1405
+ type: "button",
1406
+ "aria-pressed": !overview && !inNeighbourhood,
1407
+ onClick: () => showMap(false),
1408
+ children: "Lens"
1409
+ }
1410
+ ),
1411
+ /* @__PURE__ */ jsx7(
1412
+ "button",
1413
+ {
1414
+ type: "button",
1415
+ "aria-pressed": overview,
1416
+ onClick: () => showMap(true),
1417
+ children: "Overview"
1418
+ }
1419
+ )
1420
+ ] }),
1421
+ /* @__PURE__ */ jsxs6("label", { className: "strength", children: [
1422
+ "Lens strength",
1423
+ " ",
1424
+ /* @__PURE__ */ jsx7(
1425
+ "input",
1426
+ {
1427
+ "aria-label": "Lens strength",
1428
+ type: "range",
1429
+ min: "0",
1430
+ max: "1.4",
1431
+ step: "0.05",
1432
+ value: shownStrength,
1433
+ disabled: overview,
1434
+ onChange: (event) => {
1435
+ cancelActivation();
1436
+ dismissConnectionChoice();
1437
+ setViewStrength(Number(event.target.value));
1438
+ }
1439
+ }
1440
+ ),
1441
+ /* @__PURE__ */ jsx7("output", { children: shownStrength.toFixed(2) })
1442
+ ] }),
1443
+ /* @__PURE__ */ jsxs6("label", { className: "strength", children: [
1444
+ "Pan max",
1445
+ /* @__PURE__ */ jsx7(
1446
+ "input",
1447
+ {
1448
+ "aria-label": "Maximum keyboard pan speed",
1449
+ type: "range",
1450
+ min: "120",
1451
+ max: "1800",
1452
+ step: "60",
1453
+ value: maximumPanSpeed,
1454
+ onChange: (event) => {
1455
+ keyboard.stop();
1456
+ setMaximumPanSpeed(Number(event.target.value));
1457
+ }
1458
+ }
1459
+ ),
1460
+ /* @__PURE__ */ jsxs6("output", { children: [
1461
+ maximumPanSpeed,
1462
+ " px/s"
1463
+ ] })
1464
+ ] }),
1465
+ /* @__PURE__ */ jsx7(
1466
+ "button",
1467
+ {
1468
+ type: "button",
1469
+ className: "neighbourhood-action",
1470
+ disabled: !active,
1471
+ onClick: () => active && toggleNeighbourhood(active.key),
1472
+ children: inNeighbourhood ? "Return to map" : "Explore neighbourhood"
1473
+ }
1474
+ ),
1475
+ inNeighbourhood && active && /* @__PURE__ */ jsxs6("span", { className: "neighbourhood-label", children: [
1476
+ "Neighbourhood \xB7 ",
1477
+ label(active)
1478
+ ] }),
1479
+ /* @__PURE__ */ jsx7(
1480
+ "button",
1481
+ {
1482
+ type: "button",
1483
+ className: "quiet",
1484
+ onClick: () => {
1485
+ dismissConnectionChoice();
1486
+ cancelActivation();
1487
+ keyboard.stop();
1488
+ stopDrag();
1489
+ setInNeighbourhood(false);
1490
+ setCamera({
1491
+ ...initialCamera,
1492
+ x: nodes[0]?.x ?? 0,
1493
+ y: nodes[0]?.y ?? 0
1494
+ });
1495
+ setSelected(nodes[0]?.key ?? "");
1496
+ setKeyboardTarget(void 0);
1497
+ setStrength(1.1);
1498
+ setOverview(false);
1499
+ setMessage("");
1500
+ },
1501
+ children: "Reset view"
1502
+ }
1503
+ )
1504
+ ] }),
1505
+ /* @__PURE__ */ jsxs6(
1506
+ "div",
1507
+ {
1508
+ ref: canvas,
1509
+ className: `map ${dragging ? "dragging" : ""} ${overview ? "overview" : ""} ${inNeighbourhood ? "neighbourhood" : ""}`,
1510
+ "data-view": inNeighbourhood ? "neighbourhood" : overview ? "overview" : "lens",
1511
+ role: "application",
1512
+ "aria-label": "Spatial map",
1513
+ "aria-describedby": "map-help",
1514
+ "aria-activedescendant": keyboardActive && target ? connectionChoice ? `connection-option-${target.key}` : `${scope}-thought-${target.key}` : void 0,
1515
+ tabIndex: 0,
1516
+ onFocusCapture: (event) => {
1517
+ const node = event.target.closest(
1518
+ "[data-node]"
1519
+ );
1520
+ setKeyboardActive(event.target === event.currentTarget || !!node);
1521
+ if (node) {
1522
+ dismissConnectionChoice();
1523
+ setKeyboardTarget(node.dataset.node);
1524
+ }
1525
+ },
1526
+ onBlurCapture: (event) => {
1527
+ if (!event.currentTarget.contains(
1528
+ event.relatedTarget
1529
+ ))
1530
+ setKeyboardActive(false);
1531
+ },
1532
+ onPointerDown: (event) => {
1533
+ if (event.target.closest("[data-connection-chooser]"))
1534
+ return;
1535
+ if (event.button !== 0 || !event.isPrimary) return;
1536
+ suppressPointerClick.current = false;
1537
+ const button = event.target.closest("button");
1538
+ if (button && !button.hasAttribute("data-node"))
1539
+ cancelActivation();
1540
+ if (overview || button && !button.hasAttribute("data-node"))
1541
+ return;
1542
+ if (!button) event.currentTarget.focus({ preventScroll: true });
1543
+ const capture = button ?? event.currentTarget;
1544
+ capture.setPointerCapture(event.pointerId);
1545
+ drag.current = {
1546
+ id: event.pointerId,
1547
+ x: event.clientX,
1548
+ y: event.clientY,
1549
+ startX: event.clientX,
1550
+ startY: event.clientY,
1551
+ capture,
1552
+ active: false
1553
+ };
1554
+ },
1555
+ onPointerMove: (event) => {
1556
+ const previous = drag.current;
1557
+ if (!previous || event.pointerId !== previous.id) return;
1558
+ if (!previous.active && Math.hypot(
1559
+ event.clientX - previous.startX,
1560
+ event.clientY - previous.startY
1561
+ ) < 4)
1562
+ return;
1563
+ if (!previous.active) {
1564
+ cancelActivation();
1565
+ setDragging(true);
1566
+ suppressPointerClick.current = true;
1567
+ event.currentTarget.focus({ preventScroll: true });
1568
+ }
1569
+ const delta = {
1570
+ x: event.clientX - previous.x,
1571
+ y: event.clientY - previous.y
1572
+ };
1573
+ panView(delta);
1574
+ drag.current = {
1575
+ ...previous,
1576
+ x: event.clientX,
1577
+ y: event.clientY,
1578
+ active: true
1579
+ };
1580
+ },
1581
+ onPointerUp: (event) => {
1582
+ if (drag.current?.id === event.pointerId) stopDrag();
1583
+ },
1584
+ onPointerCancel: (event) => {
1585
+ cancelActivation();
1586
+ if (drag.current?.id === event.pointerId) stopDrag();
1587
+ },
1588
+ onLostPointerCapture: (event) => {
1589
+ if (drag.current?.id !== event.pointerId) return;
1590
+ drag.current = null;
1591
+ setDragging(false);
1592
+ },
1593
+ onClickCapture: (event) => {
1594
+ if (!suppressPointerClick.current || event.detail === 0) return;
1595
+ suppressPointerClick.current = false;
1596
+ event.preventDefault();
1597
+ event.stopPropagation();
1598
+ },
1599
+ children: [
1600
+ !overview && /* @__PURE__ */ jsx7("div", { className: "focus-ring", "aria-hidden": "true" }),
1601
+ !nodes.length && /* @__PURE__ */ jsxs6("div", { className: "empty-map", children: [
1602
+ /* @__PURE__ */ jsx7("h2", { children: "Start with a thought" }),
1603
+ /* @__PURE__ */ jsx7("p", { children: "Supply a document with thoughts to explore." })
1604
+ ] }),
1605
+ /* @__PURE__ */ jsxs6(
1606
+ "div",
1607
+ {
1608
+ ref: scene,
1609
+ className: "graph-scene",
1610
+ style: { transform: `scale(${shownCamera.zoom})` },
1611
+ children: [
1612
+ /* @__PURE__ */ jsxs6(
1613
+ "svg",
1614
+ {
1615
+ className: "connections",
1616
+ width: size.width,
1617
+ height: size.height,
1618
+ children: [
1619
+ /* @__PURE__ */ jsx7("title", { children: "Explicit thought connections" }),
1620
+ /* @__PURE__ */ jsxs6("defs", { children: [
1621
+ Object.entries(colours).map(([branch, colour]) => /* @__PURE__ */ jsx7(
1622
+ "marker",
1623
+ {
1624
+ id: `${scope}-arrow-${branch}`,
1625
+ viewBox: "0 0 10 10",
1626
+ refX: "8",
1627
+ refY: "5",
1628
+ markerWidth: "7",
1629
+ markerHeight: "7",
1630
+ orient: "auto-start-reverse",
1631
+ children: /* @__PURE__ */ jsx7(
1632
+ "path",
1633
+ {
1634
+ d: "M 1 1 L 9 5 L 1 9",
1635
+ fill: "none",
1636
+ stroke: colour,
1637
+ strokeWidth: "1.5"
1638
+ }
1639
+ )
1640
+ },
1641
+ branch
1642
+ )),
1643
+ Object.entries(colours).map(([branch, colour]) => /* @__PURE__ */ jsx7(
1644
+ BlockingMarker,
1645
+ {
1646
+ id: `${scope}-bar-${branch}`,
1647
+ colour
1648
+ },
1649
+ `bar-${branch}`
1650
+ )),
1651
+ /* @__PURE__ */ jsx7(BlockingMarker, { id: `${scope}-bar-gap`, colour: "black", gap: true })
1652
+ ] }),
1653
+ local && /* @__PURE__ */ jsx7(
1654
+ NeighbourhoodContinuations,
1655
+ {
1656
+ continuations: local.continuations,
1657
+ positions,
1658
+ centreKey: selected,
1659
+ nodes,
1660
+ nodeRadius,
1661
+ previewEdges,
1662
+ markerPrefix: scope
1663
+ }
1664
+ ),
1665
+ drawnEdges.map((edge) => {
1666
+ const appearance = connectionAppearance(edge.type);
1667
+ const from = positions.get(edge.from);
1668
+ const to = positions.get(edge.to);
1669
+ if (!from || !to) return null;
1670
+ const description = `${label(from.node)} ${connectionWording4(edge.type, "outgoing")} ${label(to.node)}`;
1671
+ const dx = to.x - from.x;
1672
+ const dy = to.y - from.y;
1673
+ const distance = Math.max(1, Math.hypot(dx, dy));
1674
+ const start = {
1675
+ x: from.x + dx / distance * nodeRadius * from.scale,
1676
+ y: from.y + dy / distance * nodeRadius * from.scale
1677
+ };
1678
+ const targetRadius = nodeRadius * to.scale + (appearance.end ? 7 : 0);
1679
+ const end = {
1680
+ x: to.x - dx / distance * targetRadius,
1681
+ y: to.y - dy / distance * targetRadius
1682
+ };
1683
+ const lane = lanes.get(edge.key);
1684
+ let bend = (lane?.count ?? 0) > 1 ? lane?.bend ?? 0 : edge.kind === "cross" ? 42 : 0;
1685
+ const centre = positions.get(selected);
1686
+ if (local && !connectedEdges.has(edge.key) && centre) {
1687
+ const side = (((start.x + end.x) / 2 - centre.x) * -dy + ((start.y + end.y) / 2 - centre.y) * dx) / distance;
1688
+ bend = (side < 0 ? -1 : 1) * (190 + (lane?.index ?? 0) * 60);
1689
+ }
1690
+ const radius2 = nodeRadius * from.scale;
1691
+ const loop = radius2 * (3 + (lane?.index ?? 0));
1692
+ const points = edge.from === edge.to ? [
1693
+ {
1694
+ x: from.x + radius2 * 0.7,
1695
+ y: from.y - radius2 * 0.7
1696
+ },
1697
+ { x: from.x + loop, y: from.y - loop },
1698
+ { x: from.x - loop, y: from.y - loop },
1699
+ {
1700
+ x: from.x - radius2 * 0.7,
1701
+ y: from.y - radius2 * 0.7
1702
+ }
1703
+ ] : [
1704
+ start,
1705
+ {
1706
+ x: (start.x + end.x) / 2 - dy / distance * bend,
1707
+ y: (start.y + end.y) / 2 + dx / distance * bend
1708
+ },
1709
+ end
1710
+ ];
1711
+ const path = points.length === 4 ? `M ${points[0].x} ${points[0].y} C ${points[1].x} ${points[1].y} ${points[2].x} ${points[2].y} ${points[3].x} ${points[3].y}` : `M ${points[0].x} ${points[0].y} Q ${points[1].x} ${points[1].y} ${points[2].x} ${points[2].y}`;
1712
+ const edgeClass = `edge ${edge.kind} ${connectedEdges.has(edge.key) ? "connected" : local ? "context" : ""} ${previewEdges.has(edge.key) ? "connection-preview" : ""}`;
1713
+ const painted = /* @__PURE__ */ jsxs6(Fragment2, { children: [
1714
+ /* @__PURE__ */ jsx7("path", { d: path, className: "edge-halo" }),
1715
+ /* @__PURE__ */ jsx7(
1716
+ "path",
1717
+ {
1718
+ "data-edge": `${edge.from}-${edge.to}`,
1719
+ "data-edge-key": edge.key,
1720
+ "data-connection-preview": previewEdges.has(edge.key) ? "true" : void 0,
1721
+ "data-kind": edge.kind,
1722
+ "data-type": edge.type ?? "connected",
1723
+ strokeDasharray: appearance.dashed ? "7 6" : void 0,
1724
+ d: path,
1725
+ stroke: colours[to.node.branch],
1726
+ style: { color: colours[to.node.branch] },
1727
+ className: edgeClass,
1728
+ markerStart: appearance.start ? `url(#${scope}-${appearance.start}-${to.node.branch})` : void 0,
1729
+ markerEnd: appearance.end ? `url(#${scope}-${appearance.end}-${to.node.branch})` : void 0
1730
+ }
1731
+ )
1732
+ ] });
1733
+ return /* @__PURE__ */ jsxs6("g", { children: [
1734
+ appearance.end === "bar" ? /* @__PURE__ */ jsx7(
1735
+ ConnectionGap,
1736
+ {
1737
+ path,
1738
+ points,
1739
+ className: edgeClass,
1740
+ markerId: `${scope}-bar-gap`,
1741
+ children: painted
1742
+ }
1743
+ ) : painted,
1744
+ /* @__PURE__ */ jsx7(
1745
+ "path",
1746
+ {
1747
+ className: "edge-hit",
1748
+ d: path,
1749
+ "data-edge-hit": edge.key,
1750
+ children: /* @__PURE__ */ jsx7("title", { children: description })
1751
+ }
1752
+ )
1753
+ ] }, edge.key);
1754
+ })
1755
+ ]
1756
+ }
1757
+ ),
1758
+ (local?.nodes ?? nodes).map((node) => {
1759
+ const point = positions.get(node.key);
1760
+ if (!point) return null;
1761
+ return /* @__PURE__ */ jsxs6(
1762
+ "button",
1763
+ {
1764
+ type: "button",
1765
+ id: `${scope}-thought-${node.key}`,
1766
+ "data-anchor": anchors.get(node.key),
1767
+ className: `node ${selected === node.key ? "selected" : ""} ${connectedNodes.has(node.key) ? "connected" : ""} ${node.unresolved ? "unresolved" : ""}`,
1768
+ "data-node": node.key,
1769
+ "data-keyboard-target": keyboardActive && target?.key === node.key ? "true" : void 0,
1770
+ "data-scale": point.scale * shownCamera.zoom,
1771
+ "aria-label": label(node),
1772
+ "aria-pressed": selected === node.key,
1773
+ title: label(node),
1774
+ style: {
1775
+ left: point.x,
1776
+ top: point.y,
1777
+ transform: `translate(-50%, -50%) scale(${point.scale})`,
1778
+ borderColor: colours[node.branch],
1779
+ "--branch-colour": colours[node.branch]
1780
+ },
1781
+ onClick: (event) => activateNode(event, node.key),
1782
+ onDoubleClick: () => {
1783
+ const eligible = doubleCandidate.current === node.key;
1784
+ cancelActivation();
1785
+ if (node.destination) onActivatePortal?.(node);
1786
+ else if (eligible) toggleNeighbourhood(node.key);
1787
+ else focusNode(node.key);
1788
+ },
1789
+ children: [
1790
+ node.destination && /* @__PURE__ */ jsx7(
1791
+ "span",
1792
+ {
1793
+ role: "img",
1794
+ className: "destination-icon",
1795
+ "data-destination": node.destination.kind,
1796
+ "aria-label": node.destination.kind === "map_link" ? "Map portal" : "External link",
1797
+ children: node.destination.kind === "map_link" ? "\u{1F300}" : /* @__PURE__ */ jsx7(
1798
+ "svg",
1799
+ {
1800
+ width: "20",
1801
+ height: "20",
1802
+ viewBox: "0 0 24 24",
1803
+ "aria-hidden": "true",
1804
+ children: /* @__PURE__ */ jsx7(
1805
+ "path",
1806
+ {
1807
+ d: "M 14 3 H 21 V 10 M 21 3 L 10 14 M 10 5 H 4 V 21 H 20 V 14",
1808
+ stroke: "currentColor",
1809
+ strokeWidth: "2",
1810
+ fill: "none"
1811
+ }
1812
+ )
1813
+ }
1814
+ )
1815
+ }
1816
+ ),
1817
+ /* @__PURE__ */ jsx7(
1818
+ "span",
1819
+ {
1820
+ className: "thought-anchor",
1821
+ id: `${scope}-anchor-${encodeURIComponent(anchors.get(node.key) ?? node.key)}`,
1822
+ "aria-hidden": "true"
1823
+ }
1824
+ ),
1825
+ /* @__PURE__ */ jsx7("span", { children: node.unresolved ? "?" : /* @__PURE__ */ jsx7(
1826
+ Markdown2,
1827
+ {
1828
+ allowedElements: [
1829
+ "em",
1830
+ "strong",
1831
+ "code",
1832
+ "del",
1833
+ "br"
1834
+ ],
1835
+ unwrapDisallowed: true,
1836
+ children: node.title
1837
+ }
1838
+ ) })
1839
+ ]
1840
+ },
1841
+ node.key
1842
+ );
1843
+ })
1844
+ ]
1845
+ }
1846
+ ),
1847
+ connectionChoice && choiceOrigin && /* @__PURE__ */ jsx7(
1848
+ ConnectionChooser,
1849
+ {
1850
+ choice: connectionChoice,
1851
+ origin: choiceOrigin,
1852
+ visibleKeys,
1853
+ onPick: (index) => {
1854
+ setConnectionChoice(
1855
+ (current) => current ? { ...current, index } : current
1856
+ );
1857
+ canvas.current?.focus({ preventScroll: true });
1858
+ },
1859
+ onAccept: () => {
1860
+ if (pendingConnection) focusNode(pendingConnection.node.key);
1861
+ },
1862
+ onActivatePortal,
1863
+ onCancel: dismissConnectionChoice
1864
+ }
1865
+ ),
1866
+ /* @__PURE__ */ jsxs6("div", { className: "zoom-controls", children: [
1867
+ /* @__PURE__ */ jsx7(
1868
+ "button",
1869
+ {
1870
+ type: "button",
1871
+ "aria-label": "Zoom out",
1872
+ disabled: overview,
1873
+ onClick: () => zoomView(1 / 1.2),
1874
+ children: "\u2212"
1875
+ }
1876
+ ),
1877
+ /* @__PURE__ */ jsxs6("output", { "aria-label": "Zoom level", children: [
1878
+ Math.round(shownCamera.zoom * 100),
1879
+ "%"
1880
+ ] }),
1881
+ /* @__PURE__ */ jsx7(
1882
+ "button",
1883
+ {
1884
+ type: "button",
1885
+ "aria-label": "Zoom in",
1886
+ disabled: overview,
1887
+ onClick: () => zoomView(1.2),
1888
+ children: "+"
1889
+ }
1890
+ )
1891
+ ] })
1892
+ ]
1893
+ }
1894
+ ),
1895
+ /* @__PURE__ */ jsxs6("footer", { className: "map-footer", children: [
1896
+ /* @__PURE__ */ jsx7("p", { className: "map-caption", children: inNeighbourhood ? `NEIGHBOURHOOD / ${active ? label(active) : ""}` : overview ? "UNDISTORTED / select a thought to return" : "SPATIAL FOCUS / pan through the centre" }),
1897
+ /* @__PURE__ */ jsx7("p", { id: "map-help", children: "Drag / arrows pan \xB7 hold to accelerate \xB7 Ctrl+= / Ctrl+- zoom \xB7 Ctrl+arrows browse \xB7 Shift+arrows choose exits \xB7 Space travels \xB7 Enter / double-click switches views \xB7 Esc cancels choice / restores map" }),
1898
+ keyboardActive && /* @__PURE__ */ jsxs6("p", { className: "keyboard-target-label", children: [
1899
+ "Target:",
1900
+ " ",
1901
+ targetNode ? `${label(targetNode)}${visibleKeys.has(targetNode.key) ? "" : " (outside this view)"}` : "none in the central region"
1902
+ ] }),
1903
+ /* @__PURE__ */ jsxs6("div", { className: "legend", children: [
1904
+ /* @__PURE__ */ jsx7("span", { children: "\u2501 connected" }),
1905
+ /* @__PURE__ */ jsx7("span", { children: "\u2504 optional \xB7 \u2192 sequence \xB7 \u2500| | prevents" })
1906
+ ] })
1907
+ ] })
1908
+ ] }),
1909
+ showInspector && /* @__PURE__ */ jsx7(
1910
+ SidebarResizeHandle,
1911
+ {
1912
+ width: sidebarWidth,
1913
+ onResize: setSidebarWidth
1914
+ }
1915
+ ),
1916
+ showInspector && (renderSidebar ? renderSidebar(sidebarContext) : /* @__PURE__ */ jsx7(
1917
+ ViewerInspector,
1918
+ {
1919
+ ...sidebarContext,
1920
+ onRevealSource
1921
+ }
1922
+ ))
1923
+ ]
1924
+ }
1925
+ ),
1926
+ renderExternalInspector?.(sidebarContext),
1927
+ /* @__PURE__ */ jsxs6("p", { className: "sr-only", role: "status", children: [
1928
+ "Selected thought: ",
1929
+ active ? label(active) : "none",
1930
+ ".",
1931
+ " ",
1932
+ inNeighbourhood ? "Neighbourhood view." : overview ? "Overview." : "Lens map.",
1933
+ " ",
1934
+ message,
1935
+ keyboardActive && ` Keyboard target: ${targetNode ? label(targetNode) : "none"}.`,
1936
+ connectionChoice && choiceOrigin && ` Connection choice from ${label(choiceOrigin)}. ${pendingConnection ? `${label(pendingConnection.node)}${visibleKeys.has(pendingConnection.node.key) ? "" : ", outside this view"}. Space travels; Enter switches view.` : "No connections in this direction."} Escape cancels choice.`
1937
+ ] }),
1938
+ /* @__PURE__ */ jsxs6("footer", { className: "page-footer", children: [
1939
+ /* @__PURE__ */ jsxs6("span", { children: [
1940
+ nodes.length,
1941
+ " thoughts / ",
1942
+ edges.length,
1943
+ " explicit connections"
1944
+ ] }),
1945
+ /* @__PURE__ */ jsx7("span", { children: "Source layout is stable. Magnification is a view." })
1946
+ ] })
1947
+ ]
1948
+ }
1949
+ );
1950
+ }
1951
+
1952
+ // src/SelectedThought.tsx
1953
+ import { useId as useId4, useMemo as useMemo2, useState as useState3 } from "react";
1954
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1955
+ function readingExcerpt(html) {
1956
+ const template = document.createElement("template");
1957
+ template.innerHTML = html;
1958
+ const text = template.content.textContent?.replace(/\s+/g, " ").trim() ?? "";
1959
+ if (text.length <= 800) return null;
1960
+ const first = template.content.querySelector("p, li")?.textContent ?? text;
1961
+ const paragraph = first.replace(/\s+/g, " ").trim();
1962
+ const end = paragraph.length > 360 ? paragraph.lastIndexOf(" ", 360) : paragraph.length;
1963
+ return {
1964
+ heading: template.content.querySelector("h1, h2, h3, h4, h5, h6")?.textContent,
1965
+ text: `${paragraph.slice(0, end > 0 ? end : 360)}\u2026`
1966
+ };
1967
+ }
1968
+ function SelectedThought({ html }) {
1969
+ const excerpt = useMemo2(() => readingExcerpt(html), [html]);
1970
+ const [expanded, setExpanded] = useState3(false);
1971
+ const fullId = useId4();
1972
+ if (!excerpt) {
1973
+ return /* @__PURE__ */ jsx8(
1974
+ "div",
1975
+ {
1976
+ className: "sl-markdown-content",
1977
+ "data-selected-content": true,
1978
+ dangerouslySetInnerHTML: { __html: html }
1979
+ }
1980
+ );
1981
+ }
1982
+ return /* @__PURE__ */ jsxs7("div", { className: "sl-markdown-content", "data-selected-content": true, children: [
1983
+ /* @__PURE__ */ jsxs7("div", { "data-thought-excerpt": true, hidden: expanded, children: [
1984
+ excerpt.heading && /* @__PURE__ */ jsx8("h3", { children: excerpt.heading }),
1985
+ /* @__PURE__ */ jsx8("p", { children: excerpt.text })
1986
+ ] }),
1987
+ /* @__PURE__ */ jsx8(
1988
+ "button",
1989
+ {
1990
+ type: "button",
1991
+ "aria-expanded": expanded,
1992
+ "aria-controls": fullId,
1993
+ onClick: () => setExpanded(!expanded),
1994
+ children: expanded ? "Show less" : "Show more"
1995
+ }
1996
+ ),
1997
+ /* @__PURE__ */ jsx8(
1998
+ "div",
1999
+ {
2000
+ id: fullId,
2001
+ "data-thought-full": true,
2002
+ hidden: !expanded,
2003
+ dangerouslySetInnerHTML: { __html: html }
2004
+ }
2005
+ )
2006
+ ] });
2007
+ }
2008
+
2009
+ // src/ViewerHost.tsx
2010
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2011
+ function readableLabel(node, rendered) {
2012
+ const template = document.createElement("template");
2013
+ template.innerHTML = rendered[node.key] ?? "";
2014
+ return template.content.querySelector("h1, h2, h3, h4, h5, h6")?.textContent ?? node.title;
2015
+ }
2016
+ function Inspector({
2017
+ context,
2018
+ rendered,
2019
+ getLabel
2020
+ }) {
2021
+ const { active, graph, onFocus, onFragment } = context;
2022
+ return /* @__PURE__ */ jsxs8(
2023
+ "section",
2024
+ {
2025
+ "data-live-inspector": true,
2026
+ "data-selected-key": active?.key,
2027
+ "aria-label": "Live thought inspector",
2028
+ children: [
2029
+ /* @__PURE__ */ jsx9("h2", { children: "Selected thought" }),
2030
+ active && // biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: Delegate native anchor activation, including keyboard-generated clicks; this container is not a control.
2031
+ /* @__PURE__ */ jsx9(
2032
+ "div",
2033
+ {
2034
+ onClick: (event) => {
2035
+ const link = event.target.closest("a");
2036
+ const href = link?.getAttribute("href");
2037
+ if (href?.startsWith("#")) {
2038
+ event.preventDefault();
2039
+ try {
2040
+ onFragment(decodeURIComponent(href.slice(1)));
2041
+ } catch {
2042
+ }
2043
+ }
2044
+ },
2045
+ children: /* @__PURE__ */ jsx9(SelectedThought, { html: rendered[active.key] ?? "" }, active.key)
2046
+ }
2047
+ ),
2048
+ /* @__PURE__ */ jsx9(
2049
+ ViewerConnections,
2050
+ {
2051
+ graph,
2052
+ active,
2053
+ onFocus,
2054
+ getLabel: getLabel ?? ((node) => readableLabel(node, rendered))
2055
+ }
2056
+ )
2057
+ ]
2058
+ }
2059
+ );
2060
+ }
2061
+ function ViewerHost({
2062
+ graph,
2063
+ rendered,
2064
+ inspectorId = "default",
2065
+ getLabel
2066
+ }) {
2067
+ const [targets, setTargets] = useState4([]);
2068
+ useEffect5(() => {
2069
+ setTargets(
2070
+ [
2071
+ ...document.querySelectorAll(
2072
+ "[data-lensmap-inspector-target]"
2073
+ )
2074
+ ].filter(
2075
+ (target) => target.dataset.lensmapInspectorTarget === inspectorId
2076
+ )
2077
+ );
2078
+ }, [inspectorId]);
2079
+ return /* @__PURE__ */ jsx9(
2080
+ Viewer,
2081
+ {
2082
+ graph,
2083
+ embedded: true,
2084
+ showInspector: false,
2085
+ renderExternalInspector: (context) => targets.map(
2086
+ (target, index) => createPortal(
2087
+ /* @__PURE__ */ jsx9(
2088
+ Inspector,
2089
+ {
2090
+ context,
2091
+ rendered,
2092
+ getLabel
2093
+ }
2094
+ ),
2095
+ target,
2096
+ String(index)
2097
+ )
2098
+ )
2099
+ }
2100
+ );
2101
+ }
2102
+ export {
2103
+ ViewerHost as default
2104
+ };