@lensmap/starlight 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  // src/ViewerHost.tsx
2
- import { useEffect as useEffect5, useState as useState4 } from "react";
2
+ import { useEffect as useEffect5, useState as useState5 } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
 
5
5
  // ../../apps/web/src/document/layout.ts
@@ -10,8 +10,182 @@ import {
10
10
  forceY
11
11
  } from "d3-force";
12
12
 
13
+ // ../../apps/web/src/node-style.ts
14
+ function positionAxis(node, axis) {
15
+ const value = node.style?.[axis];
16
+ return value === "auto" ? void 0 : value ?? node.position?.[axis];
17
+ }
18
+ function baseRadius(style) {
19
+ return 43 * (typeof style?.scale === "number" ? style.scale : 1);
20
+ }
21
+ var properties = /^(color|background-color|font-family|font-size|font-weight|font-style|line-height|border(?:-(?:color|width|style|top|right|bottom|left)(?:-(?:color|width|style))?)?|padding(?:-(?:top|right|bottom|left))?)$/;
22
+ var applied = /* @__PURE__ */ new WeakMap();
23
+ function applyNodeStyle(element, style, borderColour) {
24
+ for (const property of applied.get(element) ?? [])
25
+ element.style.removeProperty(property);
26
+ if (borderColour) element.style.borderColor = borderColour;
27
+ applied.set(
28
+ element,
29
+ (style?.declarations ?? []).map((d) => d.property).filter((p) => properties.test(p))
30
+ );
31
+ for (const declaration of style?.declarations ?? []) {
32
+ if (properties.test(declaration.property))
33
+ element.style.setProperty(
34
+ declaration.property,
35
+ declaration.value,
36
+ declaration.important ? "important" : ""
37
+ );
38
+ }
39
+ }
40
+
13
41
  // ../../apps/web/src/document/subtree.ts
14
42
  import { polygonHull } from "d3-polygon";
43
+ function silhouette(contour, distance, padding) {
44
+ let low = Infinity;
45
+ let high = -Infinity;
46
+ for (const [x, y] of contour) {
47
+ const theta = Math.atan2(y, distance + x);
48
+ const half = Math.asin(padding / Math.hypot(distance + x, y));
49
+ low = Math.min(low, theta - half);
50
+ high = Math.max(high, theta + half);
51
+ }
52
+ return { low, high };
53
+ }
54
+ function planSubtree(key, children, fullCircle, step, padding) {
55
+ if (children.length === 0) return { key, contour: [[0, 0]], children: [] };
56
+ const atDistance = (distance2) => children.map((child) => silhouette(child.contour, distance2, padding));
57
+ const width = (bounds2) => bounds2.reduce((sum, bound) => sum + bound.high - bound.low, 0);
58
+ let distance = step;
59
+ let bounds = atDistance(distance);
60
+ const span = fullCircle ? Math.PI * 2 : Math.min(Math.PI, Math.max(Math.PI * 2 / 3, width(bounds)));
61
+ if (width(bounds) > span) {
62
+ let lower = distance;
63
+ while (width(atDistance(distance)) > span) distance *= 2;
64
+ while (distance - lower > 0.5) {
65
+ const middle = (lower + distance) / 2;
66
+ if (width(atDistance(middle)) > span) lower = middle;
67
+ else distance = middle;
68
+ }
69
+ bounds = atDistance(distance);
70
+ }
71
+ const required = width(bounds);
72
+ const gap = fullCircle ? (span - required) / children.length : children.length > 1 ? (span - required) / (children.length - 1) : 0;
73
+ let cursor = fullCircle ? -Math.PI / 2 + bounds[0].low : children.length === 1 ? -required / 2 : -span / 2;
74
+ const contour = [[0, 0]];
75
+ const placements = children.map((subtree, index) => {
76
+ const bound = bounds[index];
77
+ const angle = cursor - bound.low;
78
+ cursor += bound.high - bound.low + gap;
79
+ const cos = Math.cos(angle), sin = Math.sin(angle);
80
+ for (const [x, y] of subtree.contour) {
81
+ contour.push([
82
+ (distance + x) * cos - y * sin,
83
+ (distance + x) * sin + y * cos
84
+ ]);
85
+ }
86
+ return { subtree, angle, distance };
87
+ });
88
+ return {
89
+ key,
90
+ contour: polygonHull(contour) ?? contour,
91
+ children: placements
92
+ };
93
+ }
94
+
95
+ // ../../apps/web/src/document/layout.ts
96
+ function layoutGraph(nodes, edges) {
97
+ if (nodes.length === 0) return { nodes: [], edges };
98
+ const byKey2 = new Map(nodes.map((node) => [node.key, node]));
99
+ const children = new Map(nodes.map((node) => [node.key, []]));
100
+ const childKeys = /* @__PURE__ */ new Set();
101
+ for (const edge of edges) {
102
+ if (edge.kind !== "branch") continue;
103
+ const child = byKey2.get(edge.to);
104
+ if (child) children.get(edge.from)?.push(child);
105
+ childKeys.add(edge.to);
106
+ }
107
+ const roots = nodes.filter((node) => !childKeys.has(node.key));
108
+ const placed = /* @__PURE__ */ new Map();
109
+ const maximum = Math.max(43, ...nodes.map((n) => n.baseRadius ?? 43));
110
+ const step = Math.max(180, maximum * 2 + 24);
111
+ const padding = Math.max(56, maximum + 13);
112
+ function plan(node, isRoot) {
113
+ const descendants = (children.get(node.key) ?? []).map(
114
+ (child) => plan(child, false)
115
+ );
116
+ return planSubtree(node.key, descendants, isRoot, step, padding);
117
+ }
118
+ const plans = roots.map((root) => plan(root, true));
119
+ function seed(node, subtree, point, heading, isRoot) {
120
+ const pinX = positionAxis(node, "x");
121
+ const pinY = positionAxis(node, "y");
122
+ const actual = { x: pinX ?? point.x, y: pinY ?? point.y };
123
+ placed.set(node.key, {
124
+ key: node.key,
125
+ x: actual.x,
126
+ y: actual.y,
127
+ targetX: actual.x,
128
+ targetY: actual.y,
129
+ fx: pinX !== void 0 || isRoot ? actual.x : void 0,
130
+ fy: pinY !== void 0 || isRoot ? actual.y : void 0
131
+ });
132
+ subtree.children.forEach((placement) => {
133
+ const child = byKey2.get(placement.subtree.key);
134
+ if (!child)
135
+ throw new Error(
136
+ `Automatic layout could not find child "${placement.subtree.key}".`
137
+ );
138
+ const angle = heading + placement.angle;
139
+ const target = {
140
+ x: actual.x + Math.cos(angle) * placement.distance,
141
+ y: actual.y + Math.sin(angle) * placement.distance
142
+ };
143
+ const childPoint = {
144
+ x: positionAxis(child, "x") ?? target.x,
145
+ y: positionAxis(child, "y") ?? target.y
146
+ };
147
+ const direction = childPoint.x === actual.x && childPoint.y === actual.y ? angle : Math.atan2(childPoint.y - actual.y, childPoint.x - actual.x);
148
+ seed(child, placement.subtree, target, direction, false);
149
+ });
150
+ }
151
+ const anchor = {
152
+ x: positionAxis(nodes[0], "x") ?? 0,
153
+ y: positionAxis(nodes[0], "y") ?? 0
154
+ };
155
+ const rootRadius = roots.length > 1 ? step * 2 / Math.sin(Math.PI / roots.length) : 0;
156
+ roots.forEach((root, index) => {
157
+ const angle = -Math.PI / 2 + Math.PI * 2 * index / roots.length;
158
+ seed(
159
+ root,
160
+ plans[index],
161
+ {
162
+ x: anchor.x + Math.cos(angle) * rootRadius,
163
+ y: anchor.y + rootRadius + Math.sin(angle) * rootRadius
164
+ },
165
+ 0,
166
+ true
167
+ );
168
+ });
169
+ const simulation = forceSimulation([...placed.values()]).stop().force("x", forceX((node) => node.targetX).strength(0.2)).force("y", forceY((node) => node.targetY).strength(0.2)).force("collide", forceCollide(padding).iterations(2));
170
+ const ticks = Math.ceil(
171
+ Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())
172
+ );
173
+ simulation.tick(ticks);
174
+ return {
175
+ nodes: nodes.map(({ position, ...node }) => {
176
+ const point = placed.get(node.key);
177
+ if (!point)
178
+ throw new Error(`Automatic layout could not place node "${node.key}".`);
179
+ return {
180
+ ...node,
181
+ ...position ? { position } : {},
182
+ x: point.x,
183
+ y: point.y
184
+ };
185
+ }),
186
+ edges
187
+ };
188
+ }
15
189
 
16
190
  // ../../apps/web/src/Viewer.tsx
17
191
  import {
@@ -23,11 +197,11 @@ import {
23
197
  useCallback as useCallback2,
24
198
  useEffect as useEffect4,
25
199
  useEffectEvent as useEffectEvent2,
26
- useId as useId3,
27
- useLayoutEffect,
200
+ useId as useId2,
201
+ useLayoutEffect as useLayoutEffect2,
28
202
  useMemo,
29
- useRef as useRef4,
30
- useState as useState2
203
+ useRef as useRef5,
204
+ useState as useState3
31
205
  } from "react";
32
206
  import Markdown2 from "react-markdown";
33
207
 
@@ -93,7 +267,11 @@ function ConnectionChooser({
93
267
  /* @__PURE__ */ jsxs("span", { id: `connection-details-${candidate.node.key}`, children: [
94
268
  candidate.routes.map((route, routeIndex) => /* @__PURE__ */ jsxs("span", { children: [
95
269
  routeIndex > 0 ? " \xB7 " : "",
96
- connectionWording(route.edge.type, route.direction),
270
+ connectionWording(
271
+ route.edge.type,
272
+ route.direction,
273
+ route.edge.presentation
274
+ ),
97
275
  ` (${route.direction} ${route.edge.kind === "branch" ? "branch" : "cross-link"})`
98
276
  ] }, `${route.edge.key}:${route.direction}`)),
99
277
  !visibleKeys.has(candidate.node.key) && /* @__PURE__ */ jsx("span", { className: "outside-view", children: "outside this view" })
@@ -134,69 +312,197 @@ function ConnectionChooser({
134
312
  // ../../apps/web/src/ConnectionGap.tsx
135
313
  import { useId } from "react";
136
314
  import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
137
- function BlockingMarker({
138
- id,
315
+ function Shape({
316
+ kind,
139
317
  colour,
140
- gap = false
318
+ cut = false
141
319
  }) {
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
- );
320
+ if (kind === "circle")
321
+ return /* @__PURE__ */ jsx2(
322
+ "circle",
323
+ {
324
+ cx: "-3",
325
+ cy: "0",
326
+ r: cut ? 2.5 : 3,
327
+ fill: cut ? "black" : "none",
328
+ stroke: cut ? "black" : colour,
329
+ strokeWidth: cut ? 0 : 1
330
+ }
331
+ );
332
+ if (kind === "diamond")
333
+ return /* @__PURE__ */ jsx2(
334
+ "path",
335
+ {
336
+ d: cut ? "M -0.7 0 L -3 -2.3 L -5.3 0 L -3 2.3 Z" : "M 0 0 L -3 -3 L -6 0 L -3 3 Z",
337
+ fill: cut ? "black" : "none",
338
+ stroke: cut ? "black" : colour,
339
+ strokeWidth: cut ? 0 : 1
340
+ }
341
+ );
342
+ if (kind === "bar")
343
+ return cut ? /* @__PURE__ */ jsx2("rect", { x: "-4.5", y: "-5", width: "3", height: "10", fill: "black" }) : /* @__PURE__ */ jsxs2("g", { stroke: colour, strokeWidth: "1", children: [
344
+ /* @__PURE__ */ jsx2("line", { x1: "-5", x2: "-5", y1: "-3", y2: "3" }),
345
+ /* @__PURE__ */ jsx2("line", { x1: "-1", x2: "-1", y1: "-3", y2: "3" })
346
+ ] });
347
+ if (kind === "arrow" && !cut)
348
+ return /* @__PURE__ */ jsx2(
349
+ "path",
350
+ {
351
+ d: "M -5 -3 L 0 0 L -5 3",
352
+ fill: "none",
353
+ stroke: colour,
354
+ strokeWidth: "1"
355
+ }
356
+ );
357
+ return null;
159
358
  }
160
- function ConnectionGap({
359
+ function StyledConnection({
161
360
  path,
162
361
  points,
362
+ presentation,
363
+ width,
364
+ colour,
163
365
  className,
164
- markerId,
165
- children
366
+ attributes,
367
+ continuation
166
368
  }) {
167
369
  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",
370
+ const start = continuation === "incoming" ? "none" : presentation.startMarker;
371
+ const end = continuation === "outgoing" ? "none" : presentation.endMarker;
372
+ const paint = presentation.lineColor === "auto" ? colour : presentation.lineColor;
373
+ const marker = (kind, side, cut = false) => /* @__PURE__ */ jsx2(
374
+ "marker",
375
+ {
376
+ id: `${id}-${kind}-${side}`,
377
+ markerUnits: "strokeWidth",
378
+ markerWidth: 14,
379
+ markerHeight: 14,
380
+ viewBox: "-7 -7 14 14",
381
+ refX: "0",
382
+ refY: "0",
383
+ orient: "auto-start-reverse",
384
+ overflow: "visible",
385
+ children: /* @__PURE__ */ jsx2(Shape, { kind, colour: paint, cut })
386
+ },
387
+ side
388
+ );
389
+ const pad = Math.max(64, width * 16);
390
+ const x = Math.min(...points.map((p) => p.x)) - pad;
391
+ const y = Math.min(...points.map((p) => p.y)) - pad;
392
+ const w = Math.max(...points.map((p) => p.x)) - x + pad;
393
+ const h = Math.max(...points.map((p) => p.y)) - y + pad;
394
+ const style = { strokeWidth: width, color: paint };
395
+ return /* @__PURE__ */ jsxs2(
396
+ "g",
397
+ {
398
+ "data-presentation-width": width,
399
+ mask: continuation ? `url(#${id}-fade)` : void 0,
400
+ children: [
401
+ /* @__PURE__ */ jsxs2("defs", { children: [
402
+ continuation && /* @__PURE__ */ jsxs2(Fragment, { children: [
403
+ /* @__PURE__ */ jsxs2(
404
+ "linearGradient",
405
+ {
406
+ id: `${id}-gradient`,
407
+ gradientUnits: "userSpaceOnUse",
408
+ x1: points[0].x,
409
+ y1: points[0].y,
410
+ x2: points[1].x,
411
+ y2: points[1].y,
412
+ children: [
413
+ /* @__PURE__ */ jsx2("stop", { offset: "0%", stopColor: "white", stopOpacity: 0.65 }),
414
+ /* @__PURE__ */ jsx2("stop", { offset: "100%", stopColor: "white", stopOpacity: 0 })
415
+ ]
416
+ }
417
+ ),
418
+ /* @__PURE__ */ jsx2(
419
+ "mask",
420
+ {
421
+ id: `${id}-fade`,
422
+ maskUnits: "userSpaceOnUse",
423
+ x,
424
+ y,
425
+ width: w,
426
+ height: h,
427
+ style: { maskType: "luminance" },
428
+ children: /* @__PURE__ */ jsx2(
429
+ "rect",
430
+ {
431
+ x,
432
+ y,
433
+ width: w,
434
+ height: h,
435
+ fill: `url(#${id}-gradient)`
436
+ }
437
+ )
438
+ }
439
+ )
440
+ ] }),
441
+ marker(start, "start"),
442
+ marker(end, "end"),
443
+ marker(start, "start-cut", true),
444
+ marker(end, "end-cut", true),
445
+ /* @__PURE__ */ jsxs2(
446
+ "mask",
188
447
  {
189
- d: path,
190
- className,
191
- style: { stroke: "none", opacity: 1, filter: "none" },
192
- markerEnd: `url(#${markerId})`
448
+ id: `${id}-mask`,
449
+ maskUnits: "userSpaceOnUse",
450
+ x,
451
+ y,
452
+ width: w,
453
+ height: h,
454
+ style: { maskType: "luminance" },
455
+ children: [
456
+ /* @__PURE__ */ jsx2("rect", { x, y, width: w, height: h, fill: "white" }),
457
+ /* @__PURE__ */ jsx2(
458
+ "path",
459
+ {
460
+ d: path,
461
+ fill: "none",
462
+ stroke: "none",
463
+ strokeWidth: width,
464
+ markerStart: `url(#${id}-${start}-start-cut)`,
465
+ markerEnd: `url(#${id}-${end}-end-cut)`
466
+ }
467
+ )
468
+ ]
193
469
  }
194
470
  )
195
- ]
196
- }
197
- ) }),
198
- /* @__PURE__ */ jsx2("g", { mask: `url(#${id})`, children })
199
- ] });
471
+ ] }),
472
+ /* @__PURE__ */ jsxs2(
473
+ "g",
474
+ {
475
+ visibility: width === 0 ? "hidden" : void 0,
476
+ mask: `url(#${id}-mask)`,
477
+ children: [
478
+ /* @__PURE__ */ jsx2(
479
+ "path",
480
+ {
481
+ d: path,
482
+ className: "edge-halo",
483
+ style: { strokeWidth: width * 3.2 }
484
+ }
485
+ ),
486
+ /* @__PURE__ */ jsx2(
487
+ "path",
488
+ {
489
+ ...attributes,
490
+ d: path,
491
+ className,
492
+ stroke: paint,
493
+ style,
494
+ markerStart: start === "none" ? void 0 : `url(#${id}-${start}-start)`,
495
+ markerEnd: end === "none" ? void 0 : `url(#${id}-${end}-end)`,
496
+ strokeLinecap: presentation.lineStyle === "dotted" ? "round" : void 0,
497
+ strokeDasharray: presentation.lineStyle === "dotted" ? `0 ${width * 2.5}` : presentation.lineStyle === "dashed" ? `${width * 2.8} ${width * 2.4}` : void 0
498
+ }
499
+ )
500
+ ]
501
+ }
502
+ )
503
+ ]
504
+ }
505
+ );
200
506
  }
201
507
 
202
508
  // ../../apps/web/src/clearance.ts
@@ -204,7 +510,9 @@ function limitMagnification(points, nodeRadius2, strength) {
204
510
  const amount = Math.max(0, Math.min(1, strength));
205
511
  if (amount === 0 || points.length < 2)
206
512
  return points.map((point) => ({ ...point }));
207
- const radii = points.map((point) => point.scale * nodeRadius2);
513
+ const radii = points.map(
514
+ (point) => point.scale * (point.baseRadius ?? nodeRadius2)
515
+ );
208
516
  const minimum = radii.map((radius2) => Math.min(radius2, 2));
209
517
  const limited = [...radii];
210
518
  for (let i = 0; i < points.length; i++) {
@@ -230,7 +538,7 @@ function limitMagnification(points, nodeRadius2, strength) {
230
538
  }
231
539
  return points.map((point, index) => ({
232
540
  ...point,
233
- scale: point.scale + (limited[index] / nodeRadius2 - point.scale) * amount
541
+ scale: point.scale + (limited[index] / (point.baseRadius ?? nodeRadius2) - point.scale) * amount
234
542
  }));
235
543
  }
236
544
 
@@ -242,6 +550,39 @@ function connectionAppearance(type, continuation) {
242
550
  ...continuation !== "outgoing" && (type === "precedes" || type === "prevents") ? { end: type === "precedes" ? "arrow" : "bar" } : {}
243
551
  };
244
552
  }
553
+ function fallbackPresentation(type) {
554
+ const appearance = connectionAppearance(type);
555
+ return {
556
+ strokeWidth: 2.5,
557
+ lineStyle: appearance.dashed ? "dashed" : "solid",
558
+ lineColor: "auto",
559
+ startMarker: appearance.start ?? "none",
560
+ endMarker: appearance.end ?? "none",
561
+ text: "connected to",
562
+ reverseText: "connected to"
563
+ };
564
+ }
565
+
566
+ // ../../apps/web/src/connection-style.ts
567
+ function markerClearance(marker, width) {
568
+ if (marker === "arrow") return width * Math.hypot(5, 3) / 6;
569
+ if (marker === "diamond") return width / Math.SQRT2;
570
+ return marker === "circle" ? width / 2 : 0;
571
+ }
572
+ function resolveConnectionWidth(value, root, width, height) {
573
+ if (typeof value === "number") return value;
574
+ const container = document.createElement("div");
575
+ container.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;height:0;overflow:hidden";
576
+ container.style.width = `${Math.hypot(width, height) / Math.SQRT2}px`;
577
+ const probe = document.createElement("div");
578
+ probe.style.width = CSS.supports("width", value) ? value : `calc((${value}) * 1px)`;
579
+ probe.style.height = "0";
580
+ container.append(probe);
581
+ root.append(container);
582
+ const result = Number.parseFloat(getComputedStyle(probe).width);
583
+ container.remove();
584
+ return Number.isFinite(result) ? Math.max(0, result) : 0;
585
+ }
245
586
 
246
587
  // ../../apps/web/src/edge-routing.ts
247
588
  function edgeLanes(edges) {
@@ -296,10 +637,10 @@ function zoomCamera(camera, factor) {
296
637
  }
297
638
  function fitCamera(points, size, radius2) {
298
639
  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;
640
+ const minX = Math.min(...points.map((p) => p.x - (p.baseRadius ?? radius2)));
641
+ const maxX = Math.max(...points.map((p) => p.x + (p.baseRadius ?? radius2)));
642
+ const minY = Math.min(...points.map((p) => p.y - (p.baseRadius ?? radius2)));
643
+ const maxY = Math.max(...points.map((p) => p.y + (p.baseRadius ?? radius2)));
303
644
  return {
304
645
  x: (minX + maxX) / 2,
305
646
  y: (minY + maxY) / 2,
@@ -316,7 +657,6 @@ function fitCamera(points, size, radius2) {
316
657
 
317
658
  // ../../apps/web/src/NeighbourhoodContinuations.tsx
318
659
  import { connectionWording as connectionWording2 } from "@lensmap/document";
319
- import { useId as useId2 } from "react";
320
660
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
321
661
  function NeighbourhoodContinuations({
322
662
  continuations,
@@ -325,25 +665,25 @@ function NeighbourhoodContinuations({
325
665
  nodes,
326
666
  nodeRadius: nodeRadius2,
327
667
  previewEdges,
328
- markerPrefix
668
+ widths
329
669
  }) {
330
- const prefix = useId2();
331
670
  const origin = positions.get(centreKey);
332
671
  const byKey2 = new Map(nodes.map((node) => [node.key, node]));
333
- const segments = continuations.flatMap((continuation, index) => {
672
+ const segments = continuations.flatMap((continuation) => {
334
673
  const point = positions.get(continuation.at);
335
674
  const source = byKey2.get(continuation.edge.from);
336
675
  const target = byKey2.get(continuation.edge.to);
337
676
  if (!point || !origin || !source || !target) return [];
338
677
  const incoming = continuation.edge.to === continuation.at;
339
- const appearance = connectionAppearance(
340
- continuation.edge.type,
341
- incoming ? "incoming" : "outgoing"
342
- );
343
678
  const angle = Math.atan2(point.y - origin.y, point.x - origin.x) + continuation.offset;
344
679
  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);
680
+ const width = widths.get(continuation.edge.key) ?? 2.5;
681
+ const presentation = continuation.edge.presentation ?? fallbackPresentation(continuation.edge.type);
682
+ const clearance = markerClearance(
683
+ incoming ? presentation.endMarker : presentation.startMarker,
684
+ width
685
+ );
686
+ const radius2 = (byKey2.get(continuation.at)?.baseRadius ?? nodeRadius2) * point.scale + clearance;
347
687
  const near = { x: point.x + ux * radius2, y: point.y + uy * radius2 };
348
688
  const far = {
349
689
  x: near.x + ux * 70 * point.scale,
@@ -353,91 +693,49 @@ function NeighbourhoodContinuations({
353
693
  return [
354
694
  {
355
695
  ...continuation,
356
- appearance,
696
+ presentation,
357
697
  incoming,
358
698
  near,
359
699
  far,
360
700
  target,
361
- description: `${label(source)} ${connectionWording2(continuation.edge.type, "outgoing")} ${label(target)}`,
362
- id: `${prefix}-${index}`,
701
+ description: `${label(source)} ${connectionWording2(continuation.edge.type, "outgoing", continuation.edge.presentation)} ${label(target)}`,
363
702
  path: `M ${from.x} ${from.y} L ${to.x} ${to.y}`
364
703
  }
365
704
  ];
366
705
  });
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(
706
+ return /* @__PURE__ */ jsx3("g", { className: "continuations", children: segments.map((segment) => {
707
+ const edgeClass = `edge continuation ${segment.edge.kind} ${previewEdges.has(segment.edge.key) ? "connection-preview" : ""}`;
708
+ return /* @__PURE__ */ jsxs3("g", { children: [
709
+ /* @__PURE__ */ jsx3(
710
+ StyledConnection,
711
+ {
712
+ path: segment.path,
713
+ points: [segment.near, segment.far],
714
+ presentation: segment.presentation,
715
+ width: widths.get(segment.edge.key) ?? 2.5,
716
+ colour: colours[segment.target.branch],
717
+ className: edgeClass,
718
+ continuation: segment.incoming ? "incoming" : "outgoing",
719
+ attributes: {
720
+ "data-continuation": segment.edge.key,
721
+ "data-connection-preview": previewEdges.has(segment.edge.key) ? "true" : void 0,
722
+ "data-kind": segment.edge.kind,
723
+ "data-type": segment.edge.type ?? "connected",
724
+ "data-direction": segment.incoming ? "incoming" : "outgoing"
725
+ }
726
+ }
727
+ ),
728
+ /* @__PURE__ */ jsx3(
402
729
  "path",
403
730
  {
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",
731
+ className: "edge-hit",
732
+ "data-continuation-hit": segment.edge.key,
410
733
  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
734
+ children: /* @__PURE__ */ jsx3("title", { children: segment.description })
416
735
  }
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
- ] });
736
+ )
737
+ ] }, segment.edge.key);
738
+ }) });
441
739
  }
442
740
 
443
741
  // ../../apps/web/src/navigation.ts
@@ -557,9 +855,14 @@ function neighbourhood(graph, key, nodeRadius2 = 43) {
557
855
  neighbours.reduce((sum, n, i) => sum + Math.sin(n.bearing - i * step), 0),
558
856
  neighbours.reduce((sum, n, i) => sum + Math.cos(n.bearing - i * step), 0)
559
857
  );
858
+ const maximum = Math.max(
859
+ nodeRadius2,
860
+ ...graph.nodes.filter((n) => visible.has(n.key)).map((n) => n.baseRadius ?? nodeRadius2)
861
+ );
560
862
  const radius2 = Math.max(
863
+ (centre.baseRadius ?? nodeRadius2) + maximum + 24,
561
864
  260,
562
- (2 * nodeRadius2 + 24) / (2 * Math.sin(Math.PI / Math.max(2, neighbours.length)))
865
+ (2 * maximum + 24) / (2 * Math.sin(Math.PI / Math.max(2, neighbours.length)))
563
866
  );
564
867
  const nodes = [
565
868
  { ...centre, x: 0, y: 0 },
@@ -594,6 +897,133 @@ function neighbourhood(graph, key, nodeRadius2 = 43) {
594
897
  return { nodes, edges, continuations };
595
898
  }
596
899
 
900
+ // ../../apps/web/src/responsive-style.ts
901
+ import {
902
+ resolveNodePresentation
903
+ } from "@lensmap/document";
904
+ var conditionKey = (condition) => JSON.stringify([condition.kind, condition.query]);
905
+ function styleConditions(graph) {
906
+ const conditions = /* @__PURE__ */ new Map();
907
+ for (const node of graph.nodes)
908
+ for (const rule of node.style?.rules ?? [])
909
+ for (const condition of rule.conditions ?? [])
910
+ conditions.set(conditionKey(condition), condition);
911
+ return [...conditions].sort(([a], [b]) => a.localeCompare(b)).map(([, c]) => c);
912
+ }
913
+ var geometryKey = (graph) => JSON.stringify({
914
+ nodes: graph.nodes.map((node) => [
915
+ node.key,
916
+ positionAxis(node, "x"),
917
+ positionAxis(node, "y"),
918
+ node.baseRadius ?? 43
919
+ ]),
920
+ edges: graph.edges.map((edge) => [edge.key, edge.from, edge.to, edge.kind])
921
+ });
922
+ var placementKey = (graph) => JSON.stringify(graph.nodes.map((node) => [node.key, node.x, node.y]));
923
+ function resolveGraphPresentation(input, matches, measured = /* @__PURE__ */ new Map(), previous) {
924
+ const nodes = input.nodes.map((node) => {
925
+ const style = node.style?.rules ? resolveNodePresentation(node.style.rules, matches) : node.style;
926
+ const radius2 = (style?.scale === "auto" ? measured.get(node.key) : void 0) ?? baseRadius(style);
927
+ return style === node.style && radius2 === (node.baseRadius ?? 43) ? node : { ...node, style, baseRadius: radius2 };
928
+ });
929
+ const next = { nodes, edges: input.edges };
930
+ if (previous && placementKey(previous.input) === placementKey(input) && geometryKey(previous.graph) === geometryKey(next)) {
931
+ const points = new Map(
932
+ previous.graph.nodes.map((node) => [node.key, node])
933
+ );
934
+ return {
935
+ ...next,
936
+ nodes: nodes.map((node) => ({
937
+ ...node,
938
+ x: points.get(node.key)?.x ?? node.x,
939
+ y: points.get(node.key)?.y ?? node.y
940
+ }))
941
+ };
942
+ }
943
+ if (geometryKey(input) === geometryKey(next))
944
+ return nodes.every((node, i) => node === input.nodes[i]) ? input : next;
945
+ return layoutGraph(nodes, input.edges);
946
+ }
947
+ function createStyleEnvironment(conditions, changed) {
948
+ const media = /* @__PURE__ */ new Map();
949
+ const flags = /* @__PURE__ */ new Map();
950
+ const host = document.createElement("div");
951
+ host.setAttribute("aria-hidden", "true");
952
+ host.style.cssText = "position:fixed;left:-100000px;top:0;visibility:hidden;pointer-events:none;container-type:size;box-sizing:content-box;padding:0;border:0;min-width:0;max-width:none;min-height:0;max-height:none;width:0;height:0;";
953
+ const shadow = host.attachShadow({ mode: "open" });
954
+ const sheet = new CSSStyleSheet();
955
+ const sheetRules = [];
956
+ for (const [index, condition] of conditions.entries()) {
957
+ const key = conditionKey(condition);
958
+ if (condition.kind === "media") {
959
+ media.set(key, matchMedia(condition.query));
960
+ } else if (condition.kind === "container") {
961
+ const id = `query-${index}`;
962
+ const text = `@container ${condition.query}{#${id}{--lensmap-query-match:1}}`;
963
+ try {
964
+ const check = new CSSStyleSheet();
965
+ check.insertRule(text);
966
+ const outer = check.cssRules[0];
967
+ if (!(outer instanceof CSSContainerRule) || outer.containerName || outer.cssRules.length !== 1)
968
+ continue;
969
+ const rule = outer.cssRules[0];
970
+ if (!(rule instanceof CSSStyleRule) || rule.selectorText !== `#${id}` || rule.style.length !== 1 || rule.style.getPropertyValue("--lensmap-query-match") !== "1")
971
+ continue;
972
+ const flag = document.createElement("i");
973
+ flag.id = id;
974
+ shadow.append(flag);
975
+ flags.set(key, flag);
976
+ sheetRules.push(text);
977
+ } catch {
978
+ }
979
+ }
980
+ }
981
+ sheet.replaceSync(sheetRules.join("\n"));
982
+ shadow.adoptedStyleSheets = [sheet];
983
+ if (flags.size) document.body.append(host);
984
+ let previous = /* @__PURE__ */ new Map();
985
+ let context;
986
+ const refresh = () => {
987
+ if (context) {
988
+ const style = getComputedStyle(context);
989
+ host.style.font = style.font;
990
+ host.style.writingMode = style.writingMode;
991
+ }
992
+ const next = /* @__PURE__ */ new Map();
993
+ for (const condition of conditions) {
994
+ const key = conditionKey(condition);
995
+ const flag = flags.get(key);
996
+ next.set(
997
+ key,
998
+ media.get(key)?.matches ?? (flag ? getComputedStyle(flag).getPropertyValue("--lensmap-query-match").trim() === "1" : false)
999
+ );
1000
+ }
1001
+ if (JSON.stringify([...previous]) !== JSON.stringify([...next])) {
1002
+ previous = next;
1003
+ changed?.(next);
1004
+ }
1005
+ };
1006
+ for (const query of media.values()) query.addEventListener("change", refresh);
1007
+ window.addEventListener("resize", refresh);
1008
+ document.fonts.addEventListener("loadingdone", refresh);
1009
+ return {
1010
+ matches: (condition) => previous.get(conditionKey(condition)) ?? false,
1011
+ resize(width, height, element) {
1012
+ context = element;
1013
+ host.style.width = `${width}px`;
1014
+ host.style.height = `${height}px`;
1015
+ refresh();
1016
+ },
1017
+ dispose() {
1018
+ for (const query of media.values())
1019
+ query.removeEventListener("change", refresh);
1020
+ window.removeEventListener("resize", refresh);
1021
+ document.fonts.removeEventListener("loadingdone", refresh);
1022
+ host.remove();
1023
+ }
1024
+ };
1025
+ }
1026
+
597
1027
  // ../../apps/web/src/SidebarResizeHandle.tsx
598
1028
  import { useEffect as useEffect2, useRef as useRef2, useState } from "react";
599
1029
  import { jsx as jsx4 } from "react/jsx-runtime";
@@ -794,6 +1224,48 @@ function useMapKeyboard(ref, controls) {
794
1224
  return { stop };
795
1225
  }
796
1226
 
1227
+ // ../../apps/web/src/useNodeStyleMatches.ts
1228
+ import { useLayoutEffect, useRef as useRef4, useState as useState2 } from "react";
1229
+ function useNodeStyleMatches(graph, canvas, size) {
1230
+ const conditions = styleConditions(graph);
1231
+ const signature = JSON.stringify(conditions);
1232
+ const environment = useRef4(null);
1233
+ const [matches, setMatches] = useState2(
1234
+ /* @__PURE__ */ new Map()
1235
+ );
1236
+ const refreshSize = () => {
1237
+ const element = canvas.current;
1238
+ if (!element || !environment.current) return;
1239
+ const style = getComputedStyle(element);
1240
+ environment.current.resize(
1241
+ element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
1242
+ element.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom),
1243
+ element
1244
+ );
1245
+ };
1246
+ useLayoutEffect(() => {
1247
+ const conditions2 = JSON.parse(signature);
1248
+ if (!conditions2.length) {
1249
+ setMatches((previous) => previous.size ? /* @__PURE__ */ new Map() : previous);
1250
+ return;
1251
+ }
1252
+ const current = createStyleEnvironment(conditions2, setMatches);
1253
+ environment.current = current;
1254
+ refreshSize();
1255
+ return () => {
1256
+ current.dispose();
1257
+ if (environment.current === current) environment.current = null;
1258
+ };
1259
+ }, [signature]);
1260
+ useLayoutEffect(refreshSize, [size.width, size.height]);
1261
+ return {
1262
+ matches,
1263
+ ready: conditions.every(
1264
+ (condition) => matches.has(conditionKey(condition))
1265
+ )
1266
+ };
1267
+ }
1268
+
797
1269
  // ../../apps/web/src/ViewerInspector.tsx
798
1270
  import { thoughtAnchors } from "@lensmap/document";
799
1271
  import Markdown from "react-markdown";
@@ -825,8 +1297,9 @@ function ViewerConnections({
825
1297
  "data-focus-key": key,
826
1298
  onClick: () => onFocus(key),
827
1299
  children: [
828
- connectionWording3(edge.type, direction),
829
- " \xB7 ",
1300
+ connectionWording3(edge.type, direction, edge.presentation),
1301
+ " ",
1302
+ "\xB7 ",
830
1303
  getLabel(node)
831
1304
  ]
832
1305
  },
@@ -935,11 +1408,11 @@ function reconcileViewer(previous, next, selected, camera, mode) {
935
1408
  }
936
1409
 
937
1410
  // ../../apps/web/src/Viewer.tsx
938
- import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1411
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
939
1412
  var initialCamera = { x: 0, y: 0, zoom: 0.85 };
940
1413
  var nodeRadius = 43;
941
1414
  function Viewer({
942
- graph,
1415
+ graph: inputGraph,
943
1416
  embedded = false,
944
1417
  navigationControls,
945
1418
  thoughtLink,
@@ -955,57 +1428,94 @@ function Viewer({
955
1428
  renderSidebar,
956
1429
  renderExternalInspector
957
1430
  }) {
958
- const scope = useId3();
1431
+ const canvas = useRef5(null);
1432
+ const [size, setSize] = useState3({ width: 900, height: 640 });
1433
+ const { matches, ready: presentationReady } = useNodeStyleMatches(
1434
+ inputGraph,
1435
+ canvas,
1436
+ size
1437
+ );
1438
+ const [measured, setMeasured] = useState3(
1439
+ /* @__PURE__ */ new Map()
1440
+ );
1441
+ const previousPresentation = useRef5(null);
1442
+ const graph = useMemo(() => {
1443
+ const resolved = resolveGraphPresentation(
1444
+ inputGraph,
1445
+ (condition) => matches.get(conditionKey(condition)) ?? false,
1446
+ measured,
1447
+ previousPresentation.current ?? void 0
1448
+ );
1449
+ previousPresentation.current = { input: inputGraph, graph: resolved };
1450
+ return resolved;
1451
+ }, [inputGraph, measured, matches]);
1452
+ const scope = useId2();
959
1453
  const anchors = useMemo(() => thoughtAnchors2(graph.nodes), [graph.nodes]);
960
1454
  const entryNode = graph.nodes.find((n) => n.key === initialTarget);
961
1455
  const restoredNode = graph.nodes.find(
962
1456
  (n) => n.key === initialState?.selected
963
1457
  );
964
- const canvas = useRef4(null);
965
- const scene = useRef4(null);
966
- const pendingActivation = useRef4(null);
967
- const doubleCandidate = useRef4(null);
1458
+ const scene = useRef5(null);
1459
+ const pendingActivation = useRef5(null);
1460
+ const doubleCandidate = useRef5(null);
968
1461
  const cancelActivation = useCallback2(() => {
969
1462
  if (pendingActivation.current !== null)
970
1463
  clearTimeout(pendingActivation.current);
971
1464
  pendingActivation.current = null;
972
1465
  doubleCandidate.current = null;
973
1466
  }, []);
974
- const drag = useRef4(null);
975
- const suppressPointerClick = useRef4(false);
976
- const [size, setSize] = useState2({ width: 900, height: 640 });
977
- const [camera, setCamera] = useState2(
1467
+ const drag = useRef5(null);
1468
+ const suppressPointerClick = useRef5(false);
1469
+ const [widths, setWidths] = useState3(/* @__PURE__ */ new Map());
1470
+ useLayoutEffect2(() => {
1471
+ const root = canvas.current;
1472
+ if (!root) return;
1473
+ setWidths(
1474
+ new Map(
1475
+ graph.edges.map((edge) => [
1476
+ edge.key,
1477
+ resolveConnectionWidth(
1478
+ edge.presentation?.strokeWidth ?? 2.5,
1479
+ root,
1480
+ size.width,
1481
+ size.height
1482
+ )
1483
+ ])
1484
+ )
1485
+ );
1486
+ }, [graph, size]);
1487
+ const [camera, setCamera] = useState3(
978
1488
  initialState?.camera ?? (entryNode ? { ...initialCamera, x: entryNode.x, y: entryNode.y } : initialCamera)
979
1489
  );
980
1490
  const { nodes, edges } = graph;
981
1491
  const lanes = useMemo(() => edgeLanes(edges), [edges]);
982
- const [selected, setSelected] = useState2(
1492
+ const [selected, setSelected] = useState3(
983
1493
  restoredNode?.key ?? entryNode?.key ?? nodes[0]?.key ?? ""
984
1494
  );
985
- const [sidebarWidth, setSidebarWidth] = useState2(340);
986
- const [strength, setStrength] = useState2(initialState?.strength ?? 1.1);
987
- const [neighbourhoodStrength, setNeighbourhoodStrength] = useState2(
1495
+ const [sidebarWidth, setSidebarWidth] = useState3(340);
1496
+ const [strength, setStrength] = useState3(initialState?.strength ?? 1.1);
1497
+ const [neighbourhoodStrength, setNeighbourhoodStrength] = useState3(
988
1498
  initialState?.neighbourhoodStrength ?? 1.1
989
1499
  );
990
- const [overview, setOverview] = useState2(
1500
+ const [overview, setOverview] = useState3(
991
1501
  initialState?.overview ?? initialOverview ?? false
992
1502
  );
993
- const [inNeighbourhood, setInNeighbourhood] = useState2(
1503
+ const [inNeighbourhood, setInNeighbourhood] = useState3(
994
1504
  !!restoredNode && (initialState?.inNeighbourhood ?? false)
995
1505
  );
996
- const [neighbourhoodCamera, setNeighbourhoodCamera] = useState2(
1506
+ const [neighbourhoodCamera, setNeighbourhoodCamera] = useState3(
997
1507
  initialState?.neighbourhoodCamera ?? {
998
1508
  x: 0,
999
1509
  y: 0,
1000
1510
  zoom: 1
1001
1511
  }
1002
1512
  );
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();
1513
+ const [dragging, setDragging] = useState3(false);
1514
+ const [message, setMessage] = useState3("");
1515
+ const [keyboardTarget, setKeyboardTarget] = useState3();
1516
+ const [keyboardActive, setKeyboardActive] = useState3(false);
1517
+ const [maximumPanSpeed, setMaximumPanSpeed] = useState3(900);
1518
+ const [connectionChoice, setConnectionChoice] = useState3();
1009
1519
  const dismissConnectionChoice = useCallback2(() => {
1010
1520
  const focused = document.activeElement;
1011
1521
  if (focused instanceof Element && canvas.current?.contains(focused) && focused.closest("[data-connection-chooser]")) {
@@ -1013,7 +1523,7 @@ function Viewer({
1013
1523
  }
1014
1524
  setConnectionChoice(void 0);
1015
1525
  }, []);
1016
- const returnOverview = useRef4(initialState?.returnOverview ?? false);
1526
+ const returnOverview = useRef5(initialState?.returnOverview ?? false);
1017
1527
  const reportState = useEffectEvent2(
1018
1528
  () => onStateChange?.({
1019
1529
  selected,
@@ -1063,6 +1573,58 @@ function Viewer({
1063
1573
  () => inNeighbourhood ? neighbourhood(graph, selected, nodeRadius) : null,
1064
1574
  [graph, inNeighbourhood, selected]
1065
1575
  );
1576
+ const measurementView = inNeighbourhood ? selected : null;
1577
+ useLayoutEffect2(() => {
1578
+ let alive = true;
1579
+ const measure = () => {
1580
+ if (!alive || !canvas.current) return;
1581
+ const visible = /* @__PURE__ */ new Map();
1582
+ canvas.current.querySelectorAll(".node[data-auto-size='true']").forEach((element) => {
1583
+ const key = element.dataset.node;
1584
+ if (!key || !graph.nodes.some((node) => node.key === key)) return;
1585
+ const clone = element.cloneNode(true);
1586
+ clone.removeAttribute("id");
1587
+ clone.classList.remove("selected", "connected");
1588
+ clone.style.cssText = element.style.cssText;
1589
+ Object.assign(clone.style, {
1590
+ transform: "none",
1591
+ visibility: "hidden",
1592
+ position: "absolute",
1593
+ width: "max-content",
1594
+ maxWidth: "260px",
1595
+ height: "auto",
1596
+ minHeight: "0",
1597
+ left: "0",
1598
+ top: "0"
1599
+ });
1600
+ canvas.current?.append(clone);
1601
+ const box = clone.getBoundingClientRect();
1602
+ visible.set(
1603
+ key,
1604
+ Math.max(43, Math.ceil(Math.hypot(box.width, box.height) / 2))
1605
+ );
1606
+ clone.remove();
1607
+ });
1608
+ setMeasured((previous) => {
1609
+ const next = /* @__PURE__ */ new Map();
1610
+ for (const node of graph.nodes) {
1611
+ if (node.style?.scale !== "auto") continue;
1612
+ const radius2 = visible.get(node.key) ?? previous.get(node.key);
1613
+ if (radius2 !== void 0) next.set(node.key, radius2);
1614
+ }
1615
+ return JSON.stringify([...previous]) === JSON.stringify([...next]) ? previous : next;
1616
+ });
1617
+ };
1618
+ measure();
1619
+ void document.fonts.ready.then(measure);
1620
+ document.fonts.addEventListener("loadingdone", measure);
1621
+ window.addEventListener("resize", measure);
1622
+ return () => {
1623
+ alive = false;
1624
+ document.fonts.removeEventListener("loadingdone", measure);
1625
+ window.removeEventListener("resize", measure);
1626
+ };
1627
+ }, [graph, measurementView, size.width, size.height]);
1066
1628
  useEffect4(() => cancelActivation, [cancelActivation]);
1067
1629
  useEffect4(() => {
1068
1630
  const element = canvas.current;
@@ -1106,7 +1668,19 @@ function Viewer({
1106
1668
  keyboard.stop,
1107
1669
  dismissConnectionChoice
1108
1670
  ]);
1109
- const shownCamera = inNeighbourhood ? neighbourhoodCamera : overview ? fitCamera(nodes, size, nodeRadius) : camera;
1671
+ const overviewPoints = nodes.flatMap((node) => {
1672
+ const loops = edges.filter(
1673
+ (edge) => edge.from === node.key && edge.to === node.key
1674
+ );
1675
+ if (!node.style || loops.length === 0) return [node];
1676
+ const reach = (node.baseRadius ?? nodeRadius) * (3 + Math.max(...loops.map((edge) => lanes.get(edge.key)?.index ?? 0)));
1677
+ return [
1678
+ node,
1679
+ { x: node.x - reach, y: node.y - reach, baseRadius: 0 },
1680
+ { x: node.x + reach, y: node.y - reach, baseRadius: 0 }
1681
+ ];
1682
+ });
1683
+ const shownCamera = inNeighbourhood ? neighbourhoodCamera : overview ? fitCamera(overviewPoints, size, nodeRadius) : camera;
1110
1684
  const projected = (local?.nodes ?? nodes).map((node) => ({
1111
1685
  ...projectPoint(
1112
1686
  node,
@@ -1114,7 +1688,8 @@ function Viewer({
1114
1688
  size,
1115
1689
  overview ? 0 : shownStrength
1116
1690
  ),
1117
- node
1691
+ node,
1692
+ baseRadius: node.baseRadius
1118
1693
  }));
1119
1694
  const circles = overview ? projected : limitMagnification(projected, nodeRadius, shownStrength);
1120
1695
  const positions = new Map(circles.map((point) => [point.node.key, point]));
@@ -1296,8 +1871,9 @@ function Viewer({
1296
1871
  if (node) focusNode(node.key);
1297
1872
  else setMessage(`No thought has anchor "${id}".`);
1298
1873
  }
1299
- const previousGraph = useRef4(graph);
1300
- const lastRequest = useRef4(
1874
+ const previousGraph = useRef5(graph);
1875
+ const previousInput = useRef5(inputGraph);
1876
+ const lastRequest = useRef5(
1301
1877
  void 0
1302
1878
  );
1303
1879
  const focusedBeforeUpdate = typeof document === "undefined" ? null : document.activeElement;
@@ -1309,9 +1885,10 @@ function Viewer({
1309
1885
  graph,
1310
1886
  selected,
1311
1887
  camera,
1312
- updateMode
1888
+ previousInput.current === inputGraph ? "live" : updateMode
1313
1889
  );
1314
1890
  previousGraph.current = graph;
1891
+ previousInput.current = inputGraph;
1315
1892
  cancelActivation();
1316
1893
  keyboard.stop();
1317
1894
  stopDrag();
@@ -1323,11 +1900,20 @@ function Viewer({
1323
1900
  setInNeighbourhood(false);
1324
1901
  setMessage("");
1325
1902
  });
1326
- useLayoutEffect(() => {
1903
+ useLayoutEffect2(() => {
1327
1904
  reconcileGraph();
1328
1905
  if (hadViewerFocus && focusedBeforeUpdate && !focusedBeforeUpdate.isConnected)
1329
1906
  canvas.current?.focus({ preventScroll: true });
1330
1907
  });
1908
+ const initializedPresentation = useRef5(false);
1909
+ useLayoutEffect2(() => {
1910
+ if (!presentationReady || initializedPresentation.current) return;
1911
+ initializedPresentation.current = true;
1912
+ if (entryNode && !initialState)
1913
+ setCamera(
1914
+ (current) => current.x === entryNode.x && current.y === entryNode.y ? current : { ...current, x: entryNode.x, y: entryNode.y }
1915
+ );
1916
+ }, [presentationReady, entryNode, initialState]);
1331
1917
  const receiveFocus = useEffectEvent2(() => {
1332
1918
  if (!focusRequest || lastRequest.current?.key === focusRequest.key && lastRequest.current?.sequence === focusRequest.sequence)
1333
1919
  return;
@@ -1347,7 +1933,7 @@ function Viewer({
1347
1933
  setCamera((current) => ({ ...current, x: node.x, y: node.y }));
1348
1934
  setMessage("");
1349
1935
  });
1350
- useLayoutEffect(() => {
1936
+ useLayoutEffect2(() => {
1351
1937
  receiveFocus();
1352
1938
  });
1353
1939
  function stopDrag() {
@@ -1617,39 +2203,6 @@ function Viewer({
1617
2203
  height: size.height,
1618
2204
  children: [
1619
2205
  /* @__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
2206
  local && /* @__PURE__ */ jsx7(
1654
2207
  NeighbourhoodContinuations,
1655
2208
  {
@@ -1659,23 +2212,32 @@ function Viewer({
1659
2212
  nodes,
1660
2213
  nodeRadius,
1661
2214
  previewEdges,
1662
- markerPrefix: scope
2215
+ widths
1663
2216
  }
1664
2217
  ),
1665
2218
  drawnEdges.map((edge) => {
1666
- const appearance = connectionAppearance(edge.type);
2219
+ const presentation = edge.presentation ?? fallbackPresentation(edge.type);
2220
+ const width = widths.get(edge.key) ?? 2.5;
2221
+ const startClearance = markerClearance(
2222
+ presentation.startMarker,
2223
+ width
2224
+ );
2225
+ const endClearance = markerClearance(
2226
+ presentation.endMarker,
2227
+ width
2228
+ );
1667
2229
  const from = positions.get(edge.from);
1668
2230
  const to = positions.get(edge.to);
1669
2231
  if (!from || !to) return null;
1670
- const description = `${label(from.node)} ${connectionWording4(edge.type, "outgoing")} ${label(to.node)}`;
2232
+ const description = `${label(from.node)} ${connectionWording4(edge.type, "outgoing", edge.presentation)} ${label(to.node)}`;
1671
2233
  const dx = to.x - from.x;
1672
2234
  const dy = to.y - from.y;
1673
2235
  const distance = Math.max(1, Math.hypot(dx, dy));
1674
2236
  const start = {
1675
- x: from.x + dx / distance * nodeRadius * from.scale,
1676
- y: from.y + dy / distance * nodeRadius * from.scale
2237
+ x: from.x + dx / distance * ((from.node.baseRadius ?? nodeRadius) * from.scale + startClearance),
2238
+ y: from.y + dy / distance * ((from.node.baseRadius ?? nodeRadius) * from.scale + startClearance)
1677
2239
  };
1678
- const targetRadius = nodeRadius * to.scale + (appearance.end ? 7 : 0);
2240
+ const targetRadius = (to.node.baseRadius ?? nodeRadius) * to.scale + endClearance;
1679
2241
  const end = {
1680
2242
  x: to.x - dx / distance * targetRadius,
1681
2243
  y: to.y - dy / distance * targetRadius
@@ -1687,18 +2249,18 @@ function Viewer({
1687
2249
  const side = (((start.x + end.x) / 2 - centre.x) * -dy + ((start.y + end.y) / 2 - centre.y) * dx) / distance;
1688
2250
  bend = (side < 0 ? -1 : 1) * (190 + (lane?.index ?? 0) * 60);
1689
2251
  }
1690
- const radius2 = nodeRadius * from.scale;
2252
+ const radius2 = (from.node.baseRadius ?? nodeRadius) * from.scale;
1691
2253
  const loop = radius2 * (3 + (lane?.index ?? 0));
1692
2254
  const points = edge.from === edge.to ? [
1693
2255
  {
1694
- x: from.x + radius2 * 0.7,
1695
- y: from.y - radius2 * 0.7
2256
+ x: from.x + (radius2 + startClearance) * 0.7,
2257
+ y: from.y - (radius2 + startClearance) * 0.7
1696
2258
  },
1697
2259
  { x: from.x + loop, y: from.y - loop },
1698
2260
  { x: from.x - loop, y: from.y - loop },
1699
2261
  {
1700
- x: from.x - radius2 * 0.7,
1701
- y: from.y - radius2 * 0.7
2262
+ x: from.x - (radius2 + endClearance) * 0.7,
2263
+ y: from.y - (radius2 + endClearance) * 0.7
1702
2264
  }
1703
2265
  ] : [
1704
2266
  start,
@@ -1710,37 +2272,25 @@ function Viewer({
1710
2272
  ];
1711
2273
  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
2274
  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
2275
  return /* @__PURE__ */ jsxs6("g", { children: [
1734
- appearance.end === "bar" ? /* @__PURE__ */ jsx7(
1735
- ConnectionGap,
2276
+ /* @__PURE__ */ jsx7(
2277
+ StyledConnection,
1736
2278
  {
1737
2279
  path,
1738
2280
  points,
2281
+ presentation,
2282
+ width,
2283
+ colour: colours[to.node.branch],
1739
2284
  className: edgeClass,
1740
- markerId: `${scope}-bar-gap`,
1741
- children: painted
2285
+ attributes: {
2286
+ "data-edge": `${edge.from}-${edge.to}`,
2287
+ "data-edge-key": edge.key,
2288
+ "data-kind": edge.kind,
2289
+ "data-type": edge.type ?? "connected",
2290
+ "data-connection-preview": previewEdges.has(edge.key) ? "true" : void 0
2291
+ }
1742
2292
  }
1743
- ) : painted,
2293
+ ),
1744
2294
  /* @__PURE__ */ jsx7(
1745
2295
  "path",
1746
2296
  {
@@ -1762,6 +2312,15 @@ function Viewer({
1762
2312
  "button",
1763
2313
  {
1764
2314
  type: "button",
2315
+ ref: (element) => {
2316
+ if (element)
2317
+ applyNodeStyle(
2318
+ element,
2319
+ node.style,
2320
+ colours[node.branch]
2321
+ );
2322
+ },
2323
+ "data-auto-size": node.style?.scale === "auto" ? "true" : void 0,
1765
2324
  id: `${scope}-thought-${node.key}`,
1766
2325
  "data-anchor": anchors.get(node.key),
1767
2326
  className: `node ${selected === node.key ? "selected" : ""} ${connectedNodes.has(node.key) ? "connected" : ""} ${node.unresolved ? "unresolved" : ""}`,
@@ -1772,6 +2331,8 @@ function Viewer({
1772
2331
  "aria-pressed": selected === node.key,
1773
2332
  title: label(node),
1774
2333
  style: {
2334
+ width: 2 * (node.baseRadius ?? nodeRadius),
2335
+ height: 2 * (node.baseRadius ?? nodeRadius),
1775
2336
  left: point.x,
1776
2337
  top: point.y,
1777
2338
  transform: `translate(-50%, -50%) scale(${point.scale})`,
@@ -1787,6 +2348,7 @@ function Viewer({
1787
2348
  else focusNode(node.key);
1788
2349
  },
1789
2350
  children: [
2351
+ node.style?.icon && /* @__PURE__ */ jsx7("span", { className: "node-icon", "aria-hidden": "true", children: node.style.icon }),
1790
2352
  node.destination && /* @__PURE__ */ jsx7(
1791
2353
  "span",
1792
2354
  {
@@ -1822,7 +2384,7 @@ function Viewer({
1822
2384
  "aria-hidden": "true"
1823
2385
  }
1824
2386
  ),
1825
- /* @__PURE__ */ jsx7("span", { children: node.unresolved ? "?" : /* @__PURE__ */ jsx7(
2387
+ /* @__PURE__ */ jsx7("span", { className: "node-summary", children: node.unresolved ? "?" : /* @__PURE__ */ jsx7(
1826
2388
  Markdown2,
1827
2389
  {
1828
2390
  allowedElements: [
@@ -1950,7 +2512,7 @@ function Viewer({
1950
2512
  }
1951
2513
 
1952
2514
  // src/SelectedThought.tsx
1953
- import { useId as useId4, useMemo as useMemo2, useState as useState3 } from "react";
2515
+ import { useId as useId3, useMemo as useMemo2, useState as useState4 } from "react";
1954
2516
  import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1955
2517
  function readingExcerpt(html) {
1956
2518
  const template = document.createElement("template");
@@ -1967,8 +2529,8 @@ function readingExcerpt(html) {
1967
2529
  }
1968
2530
  function SelectedThought({ html }) {
1969
2531
  const excerpt = useMemo2(() => readingExcerpt(html), [html]);
1970
- const [expanded, setExpanded] = useState3(false);
1971
- const fullId = useId4();
2532
+ const [expanded, setExpanded] = useState4(false);
2533
+ const fullId = useId3();
1972
2534
  if (!excerpt) {
1973
2535
  return /* @__PURE__ */ jsx8(
1974
2536
  "div",
@@ -2062,9 +2624,33 @@ function ViewerHost({
2062
2624
  graph,
2063
2625
  rendered,
2064
2626
  inspectorId = "default",
2065
- getLabel
2627
+ getLabel,
2628
+ mapId
2066
2629
  }) {
2067
- const [targets, setTargets] = useState4([]);
2630
+ const [liveGraph, setLiveGraph] = useState5(graph);
2631
+ const [stale, setStale] = useState5(false);
2632
+ useEffect5(() => setLiveGraph(graph), [graph]);
2633
+ useEffect5(() => {
2634
+ const hot = import.meta.hot;
2635
+ if (!hot || !mapId) return;
2636
+ const update = (data) => {
2637
+ if (data.id !== mapId) return;
2638
+ setStale(data.stale);
2639
+ const edges = data.edges;
2640
+ if (!data.stale && edges)
2641
+ setLiveGraph((previous) => ({
2642
+ ...previous,
2643
+ edges,
2644
+ nodes: previous.nodes.map((node) => ({
2645
+ ...node,
2646
+ destination: data.nodes?.find((update2) => update2.key === node.key)?.destination
2647
+ }))
2648
+ }));
2649
+ };
2650
+ hot.on("lensmap:vocabulary", update);
2651
+ return () => hot.off("lensmap:vocabulary", update);
2652
+ }, [mapId]);
2653
+ const [targets, setTargets] = useState5([]);
2068
2654
  useEffect5(() => {
2069
2655
  setTargets(
2070
2656
  [
@@ -2076,28 +2662,31 @@ function ViewerHost({
2076
2662
  )
2077
2663
  );
2078
2664
  }, [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)
2665
+ return /* @__PURE__ */ jsxs8("div", { "data-lensmap-stale": String(stale), children: [
2666
+ stale && /* @__PURE__ */ jsx9("p", { role: "status", children: "Connection vocabulary is invalid. Showing the last valid map." }),
2667
+ /* @__PURE__ */ jsx9(
2668
+ Viewer,
2669
+ {
2670
+ graph: liveGraph,
2671
+ embedded: true,
2672
+ showInspector: false,
2673
+ renderExternalInspector: (context) => targets.map(
2674
+ (target, index) => createPortal(
2675
+ /* @__PURE__ */ jsx9(
2676
+ Inspector,
2677
+ {
2678
+ context,
2679
+ rendered,
2680
+ getLabel
2681
+ }
2682
+ ),
2683
+ target,
2684
+ String(index)
2685
+ )
2097
2686
  )
2098
- )
2099
- }
2100
- );
2687
+ }
2688
+ )
2689
+ ] });
2101
2690
  }
2102
2691
  export {
2103
2692
  ViewerHost as default