@mvarble/mesearch-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/dist/ambient.d.ts +2 -0
  4. package/dist/components/Article.svelte +226 -0
  5. package/dist/components/Article.svelte.d.ts +16 -0
  6. package/dist/components/ArticleHeader.svelte +134 -0
  7. package/dist/components/ArticleHeader.svelte.d.ts +19 -0
  8. package/dist/components/Chips.svelte +22 -0
  9. package/dist/components/Chips.svelte.d.ts +8 -0
  10. package/dist/components/Graph.svelte +727 -0
  11. package/dist/components/Graph.svelte.d.ts +16 -0
  12. package/dist/components/GraphPanel.svelte +328 -0
  13. package/dist/components/GraphPanel.svelte.d.ts +17 -0
  14. package/dist/components/Icon.svelte +67 -0
  15. package/dist/components/Icon.svelte.d.ts +8 -0
  16. package/dist/components/Index.svelte +283 -0
  17. package/dist/components/Index.svelte.d.ts +9 -0
  18. package/dist/components/Pager.svelte +71 -0
  19. package/dist/components/Pager.svelte.d.ts +12 -0
  20. package/dist/components/Palette.svelte +333 -0
  21. package/dist/components/Palette.svelte.d.ts +9 -0
  22. package/dist/components/Parts.svelte +62 -0
  23. package/dist/components/Parts.svelte.d.ts +11 -0
  24. package/dist/components/Proof.svelte +39 -0
  25. package/dist/components/Proof.svelte.d.ts +8 -0
  26. package/dist/components/Rail.svelte +186 -0
  27. package/dist/components/Rail.svelte.d.ts +13 -0
  28. package/dist/components/References.svelte +94 -0
  29. package/dist/components/References.svelte.d.ts +8 -0
  30. package/dist/components/SequenceTrack.svelte +186 -0
  31. package/dist/components/SequenceTrack.svelte.d.ts +11 -0
  32. package/dist/components/Shell.svelte +82 -0
  33. package/dist/components/Shell.svelte.d.ts +14 -0
  34. package/dist/components/Statement.svelte +105 -0
  35. package/dist/components/Statement.svelte.d.ts +11 -0
  36. package/dist/components/ThemeToggle.svelte +19 -0
  37. package/dist/components/ThemeToggle.svelte.d.ts +3 -0
  38. package/dist/components/Toc.svelte +145 -0
  39. package/dist/components/Toc.svelte.d.ts +8 -0
  40. package/dist/fonts.d.ts +4 -0
  41. package/dist/fonts.js +6 -0
  42. package/dist/fuzzy.d.ts +12 -0
  43. package/dist/fuzzy.js +57 -0
  44. package/dist/graph-layout.d.ts +35 -0
  45. package/dist/graph-layout.js +98 -0
  46. package/dist/index.d.ts +24 -0
  47. package/dist/index.js +30 -0
  48. package/dist/reading.svelte.d.ts +3 -0
  49. package/dist/reading.svelte.js +4 -0
  50. package/dist/server.d.ts +26 -0
  51. package/dist/server.js +115 -0
  52. package/dist/settings.d.ts +10 -0
  53. package/dist/settings.js +28 -0
  54. package/dist/styles/base.css +136 -0
  55. package/dist/styles/index.css +6 -0
  56. package/dist/styles/prose.css +203 -0
  57. package/dist/styles/tokens.css +111 -0
  58. package/dist/theme.svelte.d.ts +12 -0
  59. package/dist/theme.svelte.js +66 -0
  60. package/dist/types.d.ts +84 -0
  61. package/dist/types.js +3 -0
  62. package/package.json +66 -3
@@ -0,0 +1,727 @@
1
+ <script lang="ts" module>
2
+ import type { GraphLegend as Legend } from '../types.js';
3
+
4
+ export const MESEARCH_LEGEND: Legend = {
5
+ kinds: [
6
+ { kind: 'concept', label: 'Concept', shape: 'pill' },
7
+ { kind: 'writeup', label: 'Writeup', shape: 'card' },
8
+ ],
9
+ solid: 'builds on',
10
+ dashed: 'related',
11
+ sequenced: 'in a sequence',
12
+ from: 'Builds on',
13
+ to: 'Leads to',
14
+ related: 'Related',
15
+ empty: 'Concepts and writeups will appear here as they are written.',
16
+ };
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import { onMount, type Component } from 'svelte';
21
+ import {
22
+ boxExit,
23
+ createSimulation,
24
+ DEFAULT_TUNING,
25
+ NODE_HEIGHT,
26
+ type GraphTuning,
27
+ type LayoutEdge,
28
+ type LayoutNode,
29
+ } from '../graph-layout.js';
30
+ import { kindLabel } from '../settings.js';
31
+ import type { GraphLegend, GraphLink, GraphNode, IndexEntry } from '../types.js';
32
+ import GraphPanel from './GraphPanel.svelte';
33
+ import Icon from './Icon.svelte';
34
+
35
+ // The library as a map. Solid arrows run from what a document builds on to
36
+ // the document, so following them is a reading order; dashed lines join
37
+ // documents that link to each other without either depending on the other.
38
+ //
39
+ // The page ships this already laid out, as plain links. With JavaScript it
40
+ // comes alive: drag a node and the rest make room, pan and zoom, and a
41
+ // click opens the document's description beside the graph rather than
42
+ // leaving the page.
43
+ //
44
+ // The nodes arrive with positions: lay them out at build time with
45
+ // `layout` from `@mvarble/mesearch-ui/server`. `entries` and
46
+ // `descriptions` feed the preview panel, by node key.
47
+ let {
48
+ nodes: given,
49
+ links,
50
+ entries,
51
+ descriptions = {},
52
+ tuning = DEFAULT_TUNING,
53
+ legend = MESEARCH_LEGEND,
54
+ }: {
55
+ nodes: GraphNode[];
56
+ links: GraphLink[];
57
+ entries: Record<string, IndexEntry>;
58
+ descriptions?: Record<string, Component>;
59
+ tuning?: GraphTuning;
60
+ // What the kinds and the lines mean; mesearch's own by default.
61
+ legend?: GraphLegend;
62
+ } = $props();
63
+
64
+ type SimNode = LayoutNode & GraphNode;
65
+ // d3 moves these objects itself, so they are kept out of Svelte's
66
+ // reactivity; `frame` is bumped whenever they have moved.
67
+ // svelte-ignore state_referenced_locally
68
+ const nodes: SimNode[] = given.map((node) => ({
69
+ ...node,
70
+ id: node.key,
71
+ height: NODE_HEIGHT,
72
+ }));
73
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
74
+ // svelte-ignore state_referenced_locally
75
+ const edges = links.filter((link) => byKey.has(link.from) && byKey.has(link.to));
76
+ let frame = $state(0);
77
+
78
+ // Everything is drawn in graph coordinates; the view is a pan and a zoom.
79
+ const PAD = 40;
80
+ function bounds() {
81
+ if (nodes.length == 0) return { x: -200, y: -120, width: 400, height: 240 };
82
+ const xs = nodes.flatMap((n) => [(n.x ?? 0) - n.width / 2, (n.x ?? 0) + n.width / 2]);
83
+ const ys = nodes.flatMap((n) => [(n.y ?? 0) - n.height / 2, (n.y ?? 0) + n.height / 2]);
84
+ const x = Math.min(...xs) - PAD;
85
+ const y = Math.min(...ys) - PAD;
86
+ return { x, y, width: Math.max(...xs) + PAD - x, height: Math.max(...ys) + PAD - y };
87
+ }
88
+ const box = bounds();
89
+ const viewBox = `${box.x} ${box.y} ${box.width} ${box.height}`;
90
+ let view = $state({ x: 0, y: 0, k: 1 });
91
+
92
+ let selected = $state<string | null>(null);
93
+ let hovered = $state<string | null>(null);
94
+ let svg: SVGSVGElement | undefined = $state();
95
+ let container: HTMLElement | undefined = $state();
96
+ let live = $state(false);
97
+
98
+ // What to emphasise: the focused node, its neighbours, and --- for the
99
+ // selected node --- everything it transitively builds on.
100
+ let focus = $derived(hovered ?? selected);
101
+ let lit = $derived.by(() => {
102
+ if (!focus) return null;
103
+ // Built afresh on every change and never mutated afterwards, so these
104
+ // need no reactivity of their own.
105
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
106
+ const set = new Set([focus]);
107
+ for (const edge of edges) {
108
+ if (edge.from == focus) set.add(edge.to);
109
+ if (edge.to == focus) set.add(edge.from);
110
+ }
111
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
112
+ const seen = new Set([focus]);
113
+ const stack = [focus];
114
+ while (stack.length) {
115
+ const key = stack.pop()!;
116
+ for (const edge of edges) {
117
+ if (edge.style != 'solid' || edge.to != key || seen.has(edge.from)) continue;
118
+ seen.add(edge.from);
119
+ set.add(edge.from);
120
+ stack.push(edge.from);
121
+ }
122
+ }
123
+ return set;
124
+ });
125
+
126
+ const label = (title: string) =>
127
+ title.length > 40 ? title.slice(0, 38).trimEnd() + '…' : title;
128
+
129
+ let simulation: ReturnType<typeof createSimulation> | undefined;
130
+
131
+ onMount(() => {
132
+ live = true;
133
+ // Labels are measured now that there is a font to measure them in,
134
+ // and the layout settles around their real sizes.
135
+ for (const text of svg?.querySelectorAll<SVGTextElement>('text[data-key]') ?? []) {
136
+ const node = byKey.get(text.dataset.key!);
137
+ if (node) node.width = Math.ceil(text.getComputedTextLength()) + 28;
138
+ }
139
+ const simEdges: LayoutEdge[] = edges.map((e) => ({
140
+ source: e.from,
141
+ target: e.to,
142
+ style: e.style,
143
+ }));
144
+ simulation = createSimulation(nodes, simEdges, tuning)
145
+ .alpha(0.12)
146
+ .alphaDecay(0.04)
147
+ .on('tick', () => frame++);
148
+ return () => simulation?.stop();
149
+ });
150
+
151
+ // Screen coordinates to graph coordinates.
152
+ function toGraph(clientX: number, clientY: number) {
153
+ const point = toViewBox(clientX, clientY);
154
+ return { x: (point.x - view.x) / view.k, y: (point.y - view.y) / view.k };
155
+ }
156
+
157
+ // How many view-box units one screen pixel is.
158
+ const unitsPerPixel = () => 1 / (svg?.getScreenCTM()?.a || 1);
159
+
160
+ // A point on the screen in view-box coordinates, before the pan and zoom.
161
+ function toViewBox(clientX: number, clientY: number) {
162
+ const matrix = svg?.getScreenCTM();
163
+ if (!matrix) return { x: 0, y: 0 };
164
+ const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
165
+ return { x: point.x, y: point.y };
166
+ }
167
+
168
+ function zoomBy(factor: number, about?: { x: number; y: number }) {
169
+ const k = Math.min(4, Math.max(0.3, view.k * factor));
170
+ const centre = about ?? { x: box.x + box.width / 2, y: box.y + box.height / 2 };
171
+ // Keep `centre` (in view-box coordinates) where it is.
172
+ view = {
173
+ k,
174
+ x: centre.x - ((centre.x - view.x) * k) / view.k,
175
+ y: centre.y - ((centre.y - view.y) * k) / view.k,
176
+ };
177
+ }
178
+
179
+ function reset() {
180
+ view = { x: 0, y: 0, k: 1 };
181
+ }
182
+
183
+ // Brings a node into the part of the graph the panel leaves visible.
184
+ function reveal(key: string) {
185
+ const node = byKey.get(key);
186
+ if (!node || !svg || !container) return;
187
+ const rect = container.getBoundingClientRect();
188
+ const wide = matchMedia('(min-width: 760px)').matches;
189
+ // The drawer covers the right of a wide graph, the sheet the bottom of
190
+ // a narrow one; the node goes in the middle of what is left.
191
+ const visibleWidth = wide ? rect.width - Math.min(368, rect.width * 0.45) : rect.width;
192
+ const visibleHeight = wide ? rect.height : rect.height * 0.4;
193
+ const target = toViewBox(rect.left + visibleWidth / 2, rect.top + visibleHeight / 2);
194
+ view = {
195
+ k: view.k,
196
+ x: target.x - (node.x ?? 0) * view.k,
197
+ y: target.y - (node.y ?? 0) * view.k,
198
+ };
199
+ }
200
+
201
+ function select(key: string | null) {
202
+ selected = key;
203
+ if (key) reveal(key);
204
+ }
205
+
206
+ // Dragging a node, panning the background, and pinching with two fingers
207
+ // all start from a pointer going down. None of this is drawn, so none of
208
+ // it is reactive.
209
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
210
+ const pointers = new Map<number, { x: number; y: number }>();
211
+ let drag: { key: string; startX: number; startY: number; moved: boolean } | null = null;
212
+ let pan = $state<{ x: number; y: number; viewX: number; viewY: number } | null>(null);
213
+ let pinch: { distance: number; k: number } | null = null;
214
+ let suppressClick = false;
215
+
216
+ function onNodePointerDown(event: PointerEvent, key: string) {
217
+ if (event.button != 0) return;
218
+ drag = { key, startX: event.clientX, startY: event.clientY, moved: false };
219
+ (event.currentTarget as Element).setPointerCapture(event.pointerId);
220
+ event.stopPropagation();
221
+ }
222
+
223
+ function onNodePointerMove(event: PointerEvent) {
224
+ if (!drag) return;
225
+ const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
226
+ if (!drag.moved && distance < 4) return;
227
+ drag.moved = true;
228
+ const node = byKey.get(drag.key)!;
229
+ const point = toGraph(event.clientX, event.clientY);
230
+ node.fx = point.x;
231
+ node.fy = point.y;
232
+ simulation?.alphaTarget(0.25).restart();
233
+ }
234
+
235
+ function onNodePointerUp() {
236
+ if (!drag) return;
237
+ if (drag.moved) {
238
+ const node = byKey.get(drag.key)!;
239
+ node.fx = null;
240
+ node.fy = null;
241
+ simulation?.alphaTarget(0);
242
+ suppressClick = true;
243
+ }
244
+ drag = null;
245
+ }
246
+
247
+ function onNodeClick(event: MouseEvent, key: string) {
248
+ // A modified click still opens the document, in a new tab or not.
249
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button != 0) return;
250
+ event.preventDefault();
251
+ if (suppressClick) {
252
+ suppressClick = false;
253
+ return;
254
+ }
255
+ select(selected == key ? null : key);
256
+ }
257
+
258
+ function onBackgroundPointerDown(event: PointerEvent) {
259
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
260
+ if (pointers.size == 2) {
261
+ const [a, b] = [...pointers.values()] as [
262
+ { x: number; y: number },
263
+ { x: number; y: number },
264
+ ];
265
+ pinch = { distance: Math.hypot(a.x - b.x, a.y - b.y), k: view.k };
266
+ pan = null;
267
+ } else if (event.pointerType == 'mouse' && event.button == 0) {
268
+ pan = { x: event.clientX, y: event.clientY, viewX: view.x, viewY: view.y };
269
+ }
270
+ (event.currentTarget as Element).setPointerCapture(event.pointerId);
271
+ }
272
+
273
+ function onBackgroundPointerMove(event: PointerEvent) {
274
+ if (pointers.has(event.pointerId))
275
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
276
+ if (pinch && pointers.size == 2) {
277
+ const [a, b] = [...pointers.values()] as [
278
+ { x: number; y: number },
279
+ { x: number; y: number },
280
+ ];
281
+ const distance = Math.hypot(a.x - b.x, a.y - b.y);
282
+ const mid = toViewBox((a.x + b.x) / 2, (a.y + b.y) / 2);
283
+ zoomBy((pinch.k * distance) / pinch.distance / view.k, mid);
284
+ } else if (pan) {
285
+ const scale = unitsPerPixel();
286
+ view = {
287
+ ...view,
288
+ x: pan.viewX + (event.clientX - pan.x) * scale,
289
+ y: pan.viewY + (event.clientY - pan.y) * scale,
290
+ };
291
+ }
292
+ }
293
+
294
+ function onBackgroundPointerUp(event: PointerEvent) {
295
+ pointers.delete(event.pointerId);
296
+ if (pointers.size < 2) pinch = null;
297
+ if (pan && Math.hypot(event.clientX - pan.x, event.clientY - pan.y) < 4) select(null);
298
+ pan = null;
299
+ }
300
+
301
+ // A plain wheel scrolls the page past the graph; with Ctrl or ⌘ held ---
302
+ // which is also what a trackpad pinch reports --- it zooms.
303
+ function onwheel(event: WheelEvent) {
304
+ if (!(event.ctrlKey || event.metaKey)) return;
305
+ event.preventDefault();
306
+ zoomBy(Math.exp(-event.deltaY * 0.0025), toViewBox(event.clientX, event.clientY));
307
+ }
308
+
309
+ function onkeydown(event: KeyboardEvent) {
310
+ if (event.key == 'Escape' && selected) {
311
+ select(null);
312
+ event.stopPropagation();
313
+ }
314
+ }
315
+
316
+ // Positions, reread on every frame of the simulation.
317
+ // Drawn round, as concepts are; anything else is a card.
318
+ const pill = (kind: string) => legend.kinds.find((k) => k.kind == kind)?.shape == 'pill';
319
+
320
+ const at = (key: string) => {
321
+ void frame;
322
+ const node = byKey.get(key)!;
323
+ return { x: node.x ?? 0, y: node.y ?? 0, width: node.width, height: node.height };
324
+ };
325
+ // A solid edge is straight. A dashed one bows gently to one side, so that
326
+ // it stays distinguishable where it runs alongside a chain of solid ones.
327
+ function path(link: GraphLink) {
328
+ const a = at(link.from);
329
+ const b = at(link.to);
330
+ const start = boxExit(a.x, a.y, a.width, a.height, b.x, b.y);
331
+ const end = boxExit(b.x, b.y, b.width, b.height, a.x, a.y, link.style == 'solid' ? 5 : 3);
332
+ if (link.style == 'solid') return `M${start.x} ${start.y}L${end.x} ${end.y}`;
333
+ const bend = 0.18;
334
+ const cx = (start.x + end.x) / 2 - (end.y - start.y) * bend;
335
+ const cy = (start.y + end.y) / 2 + (end.x - start.x) * bend;
336
+ return `M${start.x} ${start.y}Q${cx} ${cy} ${end.x} ${end.y}`;
337
+ }
338
+ </script>
339
+
340
+ <div
341
+ class="graph"
342
+ style:aspect-ratio="{box.width} / {box.height}"
343
+ class:live
344
+ class:has-selection={selected}
345
+ bind:this={container}
346
+ role="group"
347
+ aria-label="Graph of the library"
348
+ >
349
+ {#if nodes.length == 0}
350
+ <p class="empty">{legend.empty}</p>
351
+ {:else}
352
+ <svg
353
+ bind:this={svg}
354
+ {viewBox}
355
+ preserveAspectRatio="xMidYMid meet"
356
+ class:panning={pan}
357
+ onpointerdown={onBackgroundPointerDown}
358
+ onpointermove={onBackgroundPointerMove}
359
+ onpointerup={onBackgroundPointerUp}
360
+ onpointercancel={onBackgroundPointerUp}
361
+ {onwheel}
362
+ {onkeydown}
363
+ role="presentation"
364
+ >
365
+ <defs>
366
+ <marker
367
+ id="graph-arrow"
368
+ viewBox="0 0 10 10"
369
+ refX="8.5"
370
+ refY="5"
371
+ markerWidth="6"
372
+ markerHeight="6"
373
+ orient="auto-start-reverse"
374
+ >
375
+ <path d="M0 0.8 9 5 0 9.2Z" class="arrow" />
376
+ </marker>
377
+ <marker
378
+ id="graph-arrow-lit"
379
+ viewBox="0 0 10 10"
380
+ refX="8.5"
381
+ refY="5"
382
+ markerWidth="6"
383
+ markerHeight="6"
384
+ orient="auto-start-reverse"
385
+ >
386
+ <path d="M0 0.8 9 5 0 9.2Z" class="arrow lit" />
387
+ </marker>
388
+ <pattern id="graph-grid" width="24" height="24" patternUnits="userSpaceOnUse">
389
+ <circle cx="1" cy="1" r="1" class="grid-dot" />
390
+ </pattern>
391
+ </defs>
392
+ <rect
393
+ x={box.x - 4000}
394
+ y={box.y - 4000}
395
+ width={box.width + 8000}
396
+ height={box.height + 8000}
397
+ fill="url(#graph-grid)"
398
+ />
399
+ <g transform="translate({view.x} {view.y}) scale({view.k})">
400
+ <g class="edges">
401
+ {#each edges as link (link.from + '→' + link.to)}
402
+ {@const on = lit?.has(link.from) && lit?.has(link.to)}
403
+ <path
404
+ d={path(link)}
405
+ class="edge {link.style}"
406
+ class:lit={on}
407
+ class:dim={lit && !on}
408
+ marker-end={link.style == 'solid'
409
+ ? on
410
+ ? 'url(#graph-arrow-lit)'
411
+ : 'url(#graph-arrow)'
412
+ : undefined}
413
+ />
414
+ {/each}
415
+ </g>
416
+ <g class="nodes">
417
+ {#each nodes as node (node.key)}
418
+ {@const p = at(node.key)}
419
+ <a
420
+ href={node.url}
421
+ class="node kind-{node.kind}"
422
+ class:selected={selected == node.key}
423
+ class:dim={lit && !lit.has(node.key)}
424
+ onclick={(event) => onNodeClick(event, node.key)}
425
+ onpointerdown={(event) => onNodePointerDown(event, node.key)}
426
+ onpointermove={onNodePointerMove}
427
+ onpointerup={onNodePointerUp}
428
+ onpointerenter={() => (hovered = node.key)}
429
+ onpointerleave={() => (hovered = null)}
430
+ onfocus={() => (hovered = node.key)}
431
+ onblur={() => (hovered = null)}
432
+ aria-label="{kindLabel(node.kind)}: {node.title}"
433
+ >
434
+ <g transform="translate({p.x} {p.y})">
435
+ <title>{node.title}</title>
436
+ <rect
437
+ x={-p.width / 2}
438
+ y={-p.height / 2}
439
+ width={p.width}
440
+ height={p.height}
441
+ rx={pill(node.kind) ? p.height / 2 : 5}
442
+ />
443
+ {#if node.sequenced && legend.sequenced}
444
+ <circle
445
+ class="tick"
446
+ cx={-p.width / 2 + 2}
447
+ cy={-p.height / 2 + 2}
448
+ r="3.5"
449
+ />
450
+ {/if}
451
+ <text data-key={node.key} text-anchor="middle" dy="0.35em"
452
+ >{label(node.title)}</text
453
+ >
454
+ </g>
455
+ </a>
456
+ {/each}
457
+ </g>
458
+ </g>
459
+ </svg>
460
+
461
+ <div class="legend" aria-hidden="true">
462
+ {#each legend.kinds as { kind, label } (kind)}
463
+ <span><i class="swatch kind-{kind}" class:pill={pill(kind)}></i>{label}</span>
464
+ {/each}
465
+ <span><i class="line solid"></i>{legend.solid}</span>
466
+ <span><i class="line dashed"></i>{legend.dashed}</span>
467
+ {#if legend.sequenced}
468
+ <span><i class="mark"></i>{legend.sequenced}</span>
469
+ {/if}
470
+ </div>
471
+
472
+ <div class="controls needs-js">
473
+ <button type="button" onclick={() => zoomBy(1.25)} aria-label="Zoom in" title="Zoom in"
474
+ ><Icon name="plus" size={18} /></button
475
+ >
476
+ <button type="button" onclick={() => zoomBy(0.8)} aria-label="Zoom out" title="Zoom out"
477
+ ><Icon name="minus" size={18} /></button
478
+ >
479
+ <button
480
+ type="button"
481
+ onclick={reset}
482
+ aria-label="Fit the whole graph"
483
+ title="Fit the whole graph"><Icon name="reset" size={18} /></button
484
+ >
485
+ </div>
486
+ <p class="hint needs-js">
487
+ Click a node for a preview · drag to rearrange · Ctrl/⌘ + scroll to zoom
488
+ </p>
489
+
490
+ {#if live}
491
+ <GraphPanel
492
+ entry={selected ? entries[selected] : undefined}
493
+ Description={selected ? descriptions[selected] : undefined}
494
+ links={edges}
495
+ {entries}
496
+ onselect={select}
497
+ headings={legend}
498
+ />
499
+ {/if}
500
+ {/if}
501
+ </div>
502
+
503
+ <style>
504
+ /* As tall as the map's own proportions ask, within limits: a wide,
505
+ * shallow map gets a shorter box rather than empty space. */
506
+ .graph {
507
+ position: relative;
508
+ /* Set, so that only the height follows the proportions: with an
509
+ * automatic width, the minimum height would widen the box instead. */
510
+ width: 100%;
511
+ min-height: 26rem;
512
+ max-height: clamp(26rem, 72vh, 46rem);
513
+ border: 1px solid var(--rule);
514
+ border-radius: var(--radius-large);
515
+ background: var(--graph-background);
516
+ overflow: hidden;
517
+ font-family: var(--font-ui);
518
+ }
519
+
520
+ svg {
521
+ display: block;
522
+ width: 100%;
523
+ height: 100%;
524
+ touch-action: pan-y;
525
+ user-select: none;
526
+ -webkit-user-select: none;
527
+ }
528
+
529
+ .live svg {
530
+ cursor: grab;
531
+ }
532
+
533
+ svg.panning {
534
+ cursor: grabbing;
535
+ }
536
+
537
+ .grid-dot {
538
+ fill: var(--graph-grid);
539
+ }
540
+
541
+ .edge {
542
+ fill: none;
543
+ stroke: var(--graph-edge);
544
+ stroke-width: 1.4;
545
+ transition: opacity 150ms;
546
+ }
547
+
548
+ .edge.dashed {
549
+ stroke-dasharray: 5 5;
550
+ stroke-width: 1.2;
551
+ }
552
+
553
+ .edge.lit {
554
+ stroke: var(--graph-edge-strong);
555
+ stroke-width: 1.8;
556
+ }
557
+
558
+ .edge.dim {
559
+ opacity: 0.18;
560
+ }
561
+
562
+ .arrow {
563
+ fill: var(--graph-edge);
564
+ }
565
+
566
+ .arrow.lit {
567
+ fill: var(--graph-edge-strong);
568
+ }
569
+
570
+ .node {
571
+ cursor: pointer;
572
+ outline: none;
573
+ transition: opacity 150ms;
574
+ touch-action: none;
575
+ }
576
+
577
+ .node rect {
578
+ fill: var(--graph-node);
579
+ stroke: var(--kind);
580
+ stroke-width: 1.3;
581
+ transition:
582
+ fill 120ms,
583
+ stroke-width 120ms;
584
+ }
585
+
586
+ .node text {
587
+ fill: var(--graph-label);
588
+ font: 500 12.5px / 1 var(--font-ui);
589
+ pointer-events: none;
590
+ }
591
+
592
+ .node:hover rect,
593
+ .node:focus-visible rect {
594
+ fill: var(--kind-soft);
595
+ stroke-width: 2;
596
+ }
597
+
598
+ .node.selected rect {
599
+ fill: var(--kind);
600
+ stroke-width: 2;
601
+ }
602
+
603
+ .node.selected text {
604
+ fill: var(--paper-raised);
605
+ }
606
+
607
+ .node.dim {
608
+ opacity: 0.25;
609
+ }
610
+
611
+ .tick {
612
+ fill: var(--sequence);
613
+ stroke: var(--graph-background);
614
+ stroke-width: 1.5;
615
+ }
616
+
617
+ .legend {
618
+ position: absolute;
619
+ left: 0.9rem;
620
+ top: 0.75rem;
621
+ display: flex;
622
+ flex-wrap: wrap;
623
+ gap: 0.35rem 0.9rem;
624
+ max-width: calc(100% - 1.8rem);
625
+ font: 500 0.72rem / 1.3 var(--font-ui);
626
+ color: var(--muted);
627
+ pointer-events: none;
628
+ }
629
+
630
+ .legend span {
631
+ display: inline-flex;
632
+ align-items: center;
633
+ gap: 0.35rem;
634
+ }
635
+
636
+ .swatch {
637
+ width: 0.7rem;
638
+ height: 0.7rem;
639
+ border-radius: 2px;
640
+ border: 1.5px solid var(--kind);
641
+ }
642
+
643
+ .swatch.pill {
644
+ border-radius: 50%;
645
+ }
646
+
647
+ .mark {
648
+ width: 0.5rem;
649
+ height: 0.5rem;
650
+ border: none;
651
+ background: var(--sequence);
652
+ }
653
+
654
+ .line {
655
+ width: 1.3rem;
656
+ border-top: 1.5px solid var(--graph-edge-strong);
657
+ }
658
+
659
+ .line.dashed {
660
+ border-top-style: dashed;
661
+ }
662
+
663
+ .controls {
664
+ position: absolute;
665
+ right: 0.75rem;
666
+ bottom: 0.75rem;
667
+ display: flex;
668
+ flex-direction: column;
669
+ border: 1px solid var(--rule);
670
+ border-radius: 10px;
671
+ background: var(--paper-raised);
672
+ box-shadow: var(--shadow);
673
+ overflow: hidden;
674
+ transition: right 220ms ease;
675
+ }
676
+
677
+ .controls button {
678
+ display: grid;
679
+ place-items: center;
680
+ width: 2.1rem;
681
+ height: 2.1rem;
682
+ border: none;
683
+ background: transparent;
684
+ color: var(--muted);
685
+ cursor: pointer;
686
+ }
687
+
688
+ .controls button + button {
689
+ border-top: 1px solid var(--rule);
690
+ }
691
+
692
+ .controls button:hover {
693
+ color: var(--ink);
694
+ background: var(--accent-soft);
695
+ }
696
+
697
+ .hint {
698
+ position: absolute;
699
+ left: 0.9rem;
700
+ bottom: 0.6rem;
701
+ margin: 0;
702
+ font: 0.72rem / 1.3 var(--font-ui);
703
+ color: var(--faint);
704
+ pointer-events: none;
705
+ }
706
+
707
+ .empty {
708
+ display: grid;
709
+ place-items: center;
710
+ height: 100%;
711
+ margin: 0;
712
+ color: var(--muted);
713
+ font: var(--font-size-ui) var(--font-ui);
714
+ }
715
+
716
+ @media (min-width: 760px) {
717
+ .has-selection .controls {
718
+ right: calc(min(23rem, 45%) + 0.75rem);
719
+ }
720
+ }
721
+
722
+ @media (max-width: 560px) {
723
+ .hint {
724
+ display: none;
725
+ }
726
+ }
727
+ </style>