@rokkit/graph 1.6.0 → 1.7.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 (77) hide show
  1. package/README.md +59 -2
  2. package/dist/Graph.svelte +121 -72
  3. package/dist/GraphLegend.svelte +112 -0
  4. package/dist/GraphLegend.svelte.d.ts +33 -0
  5. package/dist/GraphState.svelte.d.ts +107 -15
  6. package/dist/GraphState.svelte.js +186 -3
  7. package/dist/controls/BundleControl.svelte +30 -0
  8. package/dist/controls/BundleControl.svelte.d.ts +16 -0
  9. package/dist/controls/DensityControl.svelte +39 -0
  10. package/dist/controls/DensityControl.svelte.d.ts +16 -0
  11. package/dist/controls/DepthControl.svelte +34 -0
  12. package/dist/controls/DepthControl.svelte.d.ts +17 -0
  13. package/dist/controls/EdgeStyleControl.svelte +31 -0
  14. package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
  15. package/dist/controls/ZoomControl.svelte +47 -0
  16. package/dist/controls/ZoomControl.svelte.d.ts +7 -0
  17. package/dist/controls/zoom.d.ts +6 -0
  18. package/dist/controls/zoom.js +12 -0
  19. package/dist/diagrams/CallTree.svelte +135 -0
  20. package/dist/diagrams/CallTree.svelte.d.ts +37 -0
  21. package/dist/diagrams/DependencyDiagram.svelte +124 -0
  22. package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
  23. package/dist/diagrams/DiagramFrame.svelte +36 -0
  24. package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
  25. package/dist/diagrams/ErDiagram.svelte +128 -0
  26. package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
  27. package/dist/diagrams/Neighborhood.svelte +127 -0
  28. package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
  29. package/dist/diagrams/StructureDiagram.svelte +131 -0
  30. package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
  31. package/dist/diagrams/Sunburst.svelte +124 -0
  32. package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
  33. package/dist/diagrams/Treemap.svelte +124 -0
  34. package/dist/diagrams/Treemap.svelte.d.ts +32 -0
  35. package/dist/icons.d.ts +5 -0
  36. package/dist/icons.js +5 -0
  37. package/dist/index.d.ts +18 -1
  38. package/dist/index.js +23 -1
  39. package/dist/layout/arc.d.ts +13 -0
  40. package/dist/layout/arc.js +58 -0
  41. package/dist/layout/bundle.d.ts +30 -0
  42. package/dist/layout/bundle.js +80 -0
  43. package/dist/layout/cluster.js +2 -0
  44. package/dist/layout/flow.d.ts +8 -0
  45. package/dist/layout/flow.js +141 -0
  46. package/dist/layout/hierarchy.d.ts +42 -0
  47. package/dist/layout/hierarchy.js +175 -0
  48. package/dist/layout/index.d.ts +7 -1
  49. package/dist/layout/index.js +8 -2
  50. package/dist/layout/neighborhood.js +164 -28
  51. package/dist/layout/options.d.ts +44 -0
  52. package/dist/layout/options.js +106 -0
  53. package/dist/layout/order.d.ts +11 -0
  54. package/dist/layout/order.js +153 -0
  55. package/dist/layout/points.js +27 -35
  56. package/dist/layout/radial.d.ts +16 -0
  57. package/dist/layout/radial.js +214 -0
  58. package/dist/layout/rank.d.ts +15 -0
  59. package/dist/layout/rank.js +105 -0
  60. package/dist/layout/rings.d.ts +20 -0
  61. package/dist/layout/rings.js +92 -0
  62. package/dist/layout/sizing.d.ts +37 -0
  63. package/dist/layout/sizing.js +79 -0
  64. package/dist/layout/squarify.d.ts +14 -0
  65. package/dist/layout/squarify.js +114 -0
  66. package/dist/layout/structure.d.ts +9 -0
  67. package/dist/layout/structure.js +367 -0
  68. package/dist/layout/sunburst.d.ts +8 -0
  69. package/dist/layout/sunburst.js +126 -0
  70. package/dist/layout/types.d.ts +141 -0
  71. package/dist/layout/world.d.ts +9 -0
  72. package/dist/layout/world.js +144 -0
  73. package/dist/model/normalize.js +47 -1
  74. package/dist/model/tree.d.ts +27 -0
  75. package/dist/model/tree.js +182 -0
  76. package/dist/types.d.ts +55 -3
  77. package/package.json +9 -4
@@ -2,6 +2,8 @@ import { SvelteSet } from 'svelte/reactivity';
2
2
  import { normalizeGraph } from './model/normalize.js';
3
3
  import { layouts } from './layout/index.js';
4
4
  import { edgePath } from './layout/edges.js';
5
+ import { nodeShapeOf } from './layout/options.js';
6
+ import { arcPath } from './layout/arc.js';
5
7
  import { defaultGraphPreset, resolveGroupStyles } from './preset.js';
6
8
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
7
9
  /** How `id` sits on an edge: not on it, both ends of it, or one end with a far side. */
@@ -60,15 +62,49 @@ function extentOf(boxes) {
60
62
  * Deliberately NOT `PlotState.update`, which guards each field with
61
63
  * `if (config.X !== undefined)` and therefore merges.
62
64
  */
65
+ /**
66
+ * Sorted unique strings, dropping empties.
67
+ *
68
+ * Sort-then-dedupe rather than a `Set`: these vocabularies are tiny and the result has to be
69
+ * sorted anyway, so the Set would be an extra structure for nothing — and a plain Set inside a
70
+ * reactive class is exactly what `svelte/prefer-svelte-reactivity` is right to flag.
71
+ */
72
+ /**
73
+ * A level cap, or nothing.
74
+ *
75
+ * Left UNDEFINED when the caller said nothing: a state-level default of 2 is indistinguishable
76
+ * from an explicit 2, and `radial` needs "no cap" to mean the whole tree — inventing a number
77
+ * here silently pruned a codebase dendrogram to two rings.
78
+ */
79
+ function floorLevels(levels) {
80
+ return levels === undefined ? undefined : Math.max(1, Math.floor(levels));
81
+ }
82
+ function unique(values) {
83
+ return values
84
+ .filter((v) => Boolean(v))
85
+ .sort()
86
+ .filter((v, i, all) => i === 0 || v !== all[i - 1]);
87
+ }
63
88
  export class GraphState {
64
89
  #nodes = $state([]);
65
90
  #edges = $state([]);
66
91
  #fields = $state({});
67
- #layout = $state('cluster');
92
+ #layout = $state('flow');
93
+ #groupTint = $state(false);
94
+ #radialMode = $state('tree');
95
+ #root = $state(null);
96
+ #bundleTension = $state(undefined);
97
+ /** The last FULL config, so `apply` can merge over it rather than reset around it. */
98
+ #last = {};
68
99
  #density = $state('keys');
69
100
  #arrange = $state('untangle');
70
101
  #groupBy = $state('group');
71
102
  #nestBy = $state(null);
103
+ #sizeBy = $state('degree');
104
+ #sizeScale = $state('linear');
105
+ #depth = $state(1);
106
+ #focusPath = $state([]);
107
+ #levels = $state(undefined);
72
108
  #edgeStyle = $state('curved');
73
109
  #focus = $state(null);
74
110
  #value = $state(null);
@@ -84,6 +120,14 @@ export class GraphState {
84
120
  arrange: this.#arrange,
85
121
  groupBy: this.#groupBy,
86
122
  nestBy: this.#nestBy ?? undefined,
123
+ sizeBy: this.#sizeBy,
124
+ sizeScale: this.#sizeScale,
125
+ depth: this.#depth,
126
+ focusPath: this.#focusPath,
127
+ levels: this.#levels,
128
+ radialMode: this.#radialMode,
129
+ root: this.#root,
130
+ bundleTension: this.#bundleTension,
87
131
  edgeStyle: this.#edgeStyle,
88
132
  focus: this.#focus ?? this.#value,
89
133
  expanded: this.#expanded
@@ -156,6 +200,7 @@ export class GraphState {
156
200
  * field by field.
157
201
  */
158
202
  update(config = {}) {
203
+ this.#last = config;
159
204
  this.#applyData(config);
160
205
  this.#applyView(config);
161
206
  // `value` is input AND output, so it is only adopted when the caller supplies
@@ -163,11 +208,22 @@ export class GraphState {
163
208
  if (config.value !== undefined)
164
209
  this.#value = config.value;
165
210
  }
211
+ /**
212
+ * Merge a partial config, leaving every key it does not name alone.
213
+ *
214
+ * `update()` fully RE-APPLIES — an omitted key reverts to its default, which is what makes
215
+ * it safe to call from a render. That is the wrong verb for a component writing the few
216
+ * options its own controls drive into a state someone else owns: it would reset the
217
+ * owner's data on every keystroke. This merges over the last full config instead.
218
+ */
219
+ apply(config) {
220
+ this.update({ ...this.#last, ...config });
221
+ }
166
222
  #applyData(config) {
167
223
  this.#nodes = config.nodes ?? [];
168
224
  this.#edges = config.edges ?? [];
169
225
  this.#fields = config.fields ?? {};
170
- this.#layout = config.layout ?? 'cluster';
226
+ this.#layout = config.layout ?? 'flow';
171
227
  this.#focus = config.focus ?? null;
172
228
  }
173
229
  /** How the boxes are organised — one axis, or one subdivided by a second. */
@@ -177,11 +233,26 @@ export class GraphState {
177
233
  // Rejected rather than rendered: a box subdivided by its OWN axis yields exactly one
178
234
  // child containing everything, which reads as a rendering fault, not a no-op.
179
235
  this.#nestBy = config.nestBy === outer ? null : (config.nestBy ?? null);
236
+ this.#groupTint = config.groupTint ?? false;
237
+ }
238
+ /** What a node's size encodes, and how far a neighbourhood reaches. */
239
+ #applyMeasure(config) {
240
+ this.#sizeBy = config.sizeBy ?? 'degree';
241
+ this.#sizeScale = config.sizeScale ?? 'linear';
242
+ // Floored at 1: a depth of 0 or -1 is a request for nothing, and returning an empty
243
+ // canvas for it looks identical to a broken focus.
244
+ this.#depth = Math.max(1, Math.floor(config.depth ?? 1));
245
+ this.#focusPath = config.focusPath ?? [];
246
+ this.#levels = floorLevels(config.levels);
247
+ this.#root = config.root ?? null;
248
+ this.#bundleTension = config.bundleTension;
249
+ this.#radialMode = config.radialMode ?? 'tree';
180
250
  }
181
251
  #applyView(config) {
182
252
  this.#density = config.density ?? 'keys';
183
253
  this.#arrange = config.arrange ?? 'untangle';
184
254
  this.#applyGrouping(config);
255
+ this.#applyMeasure(config);
185
256
  this.#edgeStyle = config.edgeStyle ?? 'curved';
186
257
  this.#preset = config.preset ?? defaultGraphPreset;
187
258
  this.#mode = config.mode ?? 'light';
@@ -193,8 +264,22 @@ export class GraphState {
193
264
  this.#value = id;
194
265
  this.#onselect?.(id);
195
266
  }
267
+ /**
268
+ * Drop the selection, and SAY SO.
269
+ *
270
+ * Found by dbd consuming the package: it owns `selected` in its route and branches on it
271
+ * to show the entity panel, so a silent clear left that panel open over nothing. A
272
+ * controlled consumer cannot observe an internal `#value` — the callback is the only
273
+ * channel, which is why it carries `null` rather than being skipped.
274
+ *
275
+ * Guarded on an actual change, or a background click on an already-empty canvas
276
+ * round-trips through the consumer's setter on every stray click.
277
+ */
196
278
  clear() {
279
+ if (this.#value === null)
280
+ return;
197
281
  this.#value = null;
282
+ this.#onselect?.(null);
198
283
  }
199
284
  /**
200
285
  * Show one node's rows in full, whatever the density says.
@@ -258,7 +343,9 @@ export class GraphState {
258
343
  return edge.fromKey === this.#value || edge.toKey === this.#value ? 'highlight' : 'dim';
259
344
  }
260
345
  edgePath(edge) {
261
- return edgePath(edge, this.#edgeStyle);
346
+ // A layout that knows more than the endpoints builds its own — a bundled edge follows
347
+ // the whole chain of ancestors between them, which `edgePath` never sees.
348
+ return edge.path ?? edgePath(edge, this.#edgeStyle);
262
349
  }
263
350
  groupStyle(group) {
264
351
  return (group && this.#groupStyles.get(group)) || {};
@@ -279,6 +366,19 @@ export class GraphState {
279
366
  get model() {
280
367
  return this.#model;
281
368
  }
369
+ /**
370
+ * Clusters drawn as rectangles — everything the box renderer handles.
371
+ *
372
+ * A wedge has no box, so it is drawn as an SVG arc instead. Split here rather than in the
373
+ * template so the component keeps computing nothing.
374
+ */
375
+ get boxes() {
376
+ return this.clusters.filter((c) => c.wedge === undefined);
377
+ }
378
+ /** Whether any cluster is a wedge, which is what puts the arc layer on the canvas. */
379
+ get hasWedges() {
380
+ return this.clusters.some((c) => c.wedge !== undefined);
381
+ }
282
382
  get clusters() {
283
383
  return this.#result.clusters;
284
384
  }
@@ -354,15 +454,98 @@ export class GraphState {
354
454
  * render aborts, and no inner box appears at all, which reads as nesting silently not
355
455
  * working rather than as an error.
356
456
  */
457
+ /**
458
+ * The SVG path for a wedge-shaped box, centred on the canvas.
459
+ *
460
+ * Here rather than in the component for the same reason `edgePath` is: it is arithmetic,
461
+ * and the view's job is to render what state derives, not to compute geometry.
462
+ */
463
+ wedgePath(cluster) {
464
+ if (!cluster.wedge)
465
+ return '';
466
+ return arcPath(this.size.w / 2, this.size.h / 2, cluster.wedge);
467
+ }
468
+ /**
469
+ * The data attributes every containment box carries, wedge or rectangle.
470
+ *
471
+ * Built here so the two render branches — interactive and scenery — cannot drift: they
472
+ * differ only in the element and its role, which is exactly the difference that matters.
473
+ */
474
+ boxAttrs(cluster) {
475
+ return {
476
+ 'data-cluster-depth': String(cluster.depth ?? 0),
477
+ 'data-node-group': cluster.name,
478
+ 'data-graph-node-id': cluster.nodeId,
479
+ 'data-node-kind': cluster.kind,
480
+ 'data-node-state': cluster.nodeId ? this.nodeState(cluster.nodeId) : undefined
481
+ };
482
+ }
357
483
  clusterKey(cluster) {
358
484
  return `${cluster.depth ?? 0}:${cluster.parent ?? ''}:${cluster.name}`;
359
485
  }
360
486
  get groupBy() {
361
487
  return this.#groupBy;
362
488
  }
489
+ /**
490
+ * The node kinds actually present, sorted.
491
+ *
492
+ * What a LEGEND needs: naming the whole vocabulary over a diagram that uses three of it is
493
+ * a key to a different picture, and the reader hunts for a `trigger` that is not there.
494
+ */
495
+ get kindsPresent() {
496
+ return unique(this.#model.nodes.map((n) => n.kind));
497
+ }
498
+ /**
499
+ * The edge relations present, sorted — the producer's verb where it gave one, else the
500
+ * coarse kind.
501
+ *
502
+ * A plain foreign key carries no verb, and listing nothing for it would leave the one
503
+ * stroke actually on the canvas unexplained.
504
+ */
505
+ get relationsPresent() {
506
+ return unique(this.#model.edges.map((e) => e.relation ?? e.kind));
507
+ }
508
+ /** The group names present, sorted. Same reasoning as `kindsPresent`. */
509
+ get groupsPresent() {
510
+ return unique(this.#model.nodes.map((n) => n.group));
511
+ }
512
+ /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
513
+ /** `dot` or `card` — what the active layout draws a node as. */
514
+ get nodeShape() {
515
+ return nodeShapeOf(this.layoutName);
516
+ }
517
+ get groupTint() {
518
+ return this.#groupTint;
519
+ }
363
520
  get nestBy() {
364
521
  return this.#nestBy;
365
522
  }
523
+ get sizeBy() {
524
+ return this.#sizeBy;
525
+ }
526
+ get sizeScale() {
527
+ return this.#sizeScale;
528
+ }
529
+ get depth() {
530
+ return this.#depth;
531
+ }
532
+ get focusPath() {
533
+ return this.#focusPath;
534
+ }
535
+ /** The configured cap, or the package default of 2 when none was set. */
536
+ get levels() {
537
+ return this.#levels ?? 2;
538
+ }
539
+ /**
540
+ * Column headings for a multi-column layout, empty for the grid ones.
541
+ *
542
+ * Exposed so the view can render headings without knowing which layout is active or what
543
+ * it means — the layout decides both the position and the wording, and the component
544
+ * prints them.
545
+ */
546
+ get columns() {
547
+ return this.#result.columns ?? [];
548
+ }
366
549
  /**
367
550
  * Change the grouping axes from inside the component.
368
551
  *
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Straight chords or bundled ones.
4
+ *
5
+ * A toggle rather than a slider: the useful values are the two ends. Bundled is the reason
6
+ * the view is readable at all — a few hundred straight chords across a rim are a solid disc
7
+ * of ink — and straight is what you switch to when you want to see one edge exactly.
8
+ */
9
+ type Props = {
10
+ bundleTension?: number
11
+ /** Tension used for the bundled state. Holten's 0.85 by default. */
12
+ bundled?: number
13
+ onchange?: (value: number) => void
14
+ }
15
+
16
+ let { bundleTension = 0.85, bundled = 0.85, onchange }: Props = $props()
17
+
18
+ const on = $derived(bundleTension > 0)
19
+ </script>
20
+
21
+ <button
22
+ type="button"
23
+ data-graph-bundle={on ? 'bundled' : 'straight'}
24
+ aria-pressed={on}
25
+ title={on ? 'Edges follow the hierarchy' : 'Edges run straight across'}
26
+ onclick={() => onchange?.(on ? 0 : bundled)}
27
+ >
28
+ <span data-graph-bundle-glyph aria-hidden="true">{on ? '❨' : '/'}</span>
29
+ <span data-graph-control-label>{on ? 'Bundled' : 'Straight'}</span>
30
+ </button>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Straight chords or bundled ones.
3
+ *
4
+ * A toggle rather than a slider: the useful values are the two ends. Bundled is the reason
5
+ * the view is readable at all — a few hundred straight chords across a rim are a solid disc
6
+ * of ink — and straight is what you switch to when you want to see one edge exactly.
7
+ */
8
+ type Props = {
9
+ bundleTension?: number;
10
+ /** Tension used for the bundled state. Holten's 0.85 by default. */
11
+ bundled?: number;
12
+ onchange?: (value: number) => void;
13
+ };
14
+ declare const BundleControl: import("svelte").Component<Props, {}, "">;
15
+ type BundleControl = ReturnType<typeof BundleControl>;
16
+ export default BundleControl;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ /**
3
+ * How much of each node's row list a card shows.
4
+ *
5
+ * A component rather than canvas chrome: only some diagrams have rows to thin, and a
6
+ * control that moves while the picture does not reads as a broken view. The diagram that
7
+ * wants it places it; the rest never render it.
8
+ */
9
+ import type { Density } from '../layout/types.js'
10
+
11
+ type Props = {
12
+ density?: Density
13
+ /** Fires with the chosen value. The caller owns the value — this control stores none. */
14
+ onchange?: (value: Density) => void
15
+ }
16
+
17
+ let { density = 'keys', onchange }: Props = $props()
18
+
19
+ const options: [Density, string, string][] = [
20
+ ['names', 'Names', 'Titles only'],
21
+ ['keys', 'Keys', 'Key rows only'],
22
+ ['full', 'All', 'All rows']
23
+ ]
24
+ </script>
25
+
26
+ <!-- Named, because three unlabelled buttons announce as "Names Keys All" and say nothing
27
+ about what they control. -->
28
+ <div data-graph-density-controls role="group" aria-label="Detail level">
29
+ {#each options as [value, short, title] (value)}
30
+ <button
31
+ type="button"
32
+ data-graph-density={value}
33
+ data-selected={density === value ? '' : undefined}
34
+ aria-pressed={density === value}
35
+ {title}
36
+ onclick={() => onchange?.(value)}>{short}</button
37
+ >
38
+ {/each}
39
+ </div>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How much of each node's row list a card shows.
3
+ *
4
+ * A component rather than canvas chrome: only some diagrams have rows to thin, and a
5
+ * control that moves while the picture does not reads as a broken view. The diagram that
6
+ * wants it places it; the rest never render it.
7
+ */
8
+ import type { Density } from '../layout/types.js';
9
+ type Props = {
10
+ density?: Density;
11
+ /** Fires with the chosen value. The caller owns the value — this control stores none. */
12
+ onchange?: (value: Density) => void;
13
+ };
14
+ declare const DensityControl: import("svelte").Component<Props, {}, "">;
15
+ type DensityControl = ReturnType<typeof DensityControl>;
16
+ export default DensityControl;
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ /**
3
+ * How many levels of a containment tree to materialise.
4
+ *
5
+ * The control that makes a large tree readable at all. A radial dendrogram over a whole
6
+ * codebase renders every leaf on one rim, where each is a fraction of a degree wide and
7
+ * nothing is legible — show the crates first, then drill. Depth and drilling are the same
8
+ * idea from two directions: this caps how far DOWN, `focusPath` moves where you start.
9
+ */
10
+ type Props = {
11
+ levels?: number
12
+ /** Deepest level offered. Beyond the tree's own depth the extra buttons do nothing. */
13
+ max?: number
14
+ onchange?: (value: number) => void
15
+ }
16
+
17
+ let { levels = 2, max = 4, onchange }: Props = $props()
18
+
19
+ // From 1: depth 0 materialises nothing at all, so offering it is offering a blank canvas.
20
+ const steps = $derived(Array.from({ length: Math.max(1, max) }, (_, i) => i + 1))
21
+ </script>
22
+
23
+ <div data-graph-depth-controls role="group" aria-label="Levels shown">
24
+ {#each steps as level (level)}
25
+ <button
26
+ type="button"
27
+ data-graph-depth={level}
28
+ data-selected={levels === level ? '' : undefined}
29
+ aria-pressed={levels === level}
30
+ title="{level} level{level === 1 ? '' : 's'}"
31
+ onclick={() => onchange?.(level)}>{level}</button
32
+ >
33
+ {/each}
34
+ </div>
@@ -0,0 +1,17 @@
1
+ /**
2
+ * How many levels of a containment tree to materialise.
3
+ *
4
+ * The control that makes a large tree readable at all. A radial dendrogram over a whole
5
+ * codebase renders every leaf on one rim, where each is a fraction of a degree wide and
6
+ * nothing is legible — show the crates first, then drill. Depth and drilling are the same
7
+ * idea from two directions: this caps how far DOWN, `focusPath` moves where you start.
8
+ */
9
+ type Props = {
10
+ levels?: number;
11
+ /** Deepest level offered. Beyond the tree's own depth the extra buttons do nothing. */
12
+ max?: number;
13
+ onchange?: (value: number) => void;
14
+ };
15
+ declare const DepthControl: import("svelte").Component<Props, {}, "">;
16
+ type DepthControl = ReturnType<typeof DepthControl>;
17
+ export default DepthControl;
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Curved or orthogonal connectors.
4
+ *
5
+ * A toggle rather than a picker: there are exactly two, and a two-option `<select>` costs a
6
+ * reader more interaction than the thing it is choosing between is worth.
7
+ */
8
+ import type { EdgeStyle } from '../layout/types.js'
9
+
10
+ type Props = {
11
+ edgeStyle?: EdgeStyle
12
+ onchange?: (value: EdgeStyle) => void
13
+ }
14
+
15
+ let { edgeStyle = 'curved', onchange }: Props = $props()
16
+
17
+ // `curved` is the default, so `orthogonal` is the pressed state — a toggle reads as "the
18
+ // non-default is on", not as "one of two things is selected".
19
+ const orthogonal = $derived(edgeStyle === 'orthogonal')
20
+ </script>
21
+
22
+ <button
23
+ type="button"
24
+ data-graph-edge-style={edgeStyle}
25
+ aria-pressed={orthogonal}
26
+ title={orthogonal ? 'Right-angle connectors' : 'Curved connectors'}
27
+ onclick={() => onchange?.(orthogonal ? 'curved' : 'orthogonal')}
28
+ >
29
+ <span data-graph-edge-style-glyph aria-hidden="true">{orthogonal ? '⌐' : '︵'}</span>
30
+ <span data-graph-control-label>{orthogonal ? 'Angled' : 'Curved'}</span>
31
+ </button>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Curved or orthogonal connectors.
3
+ *
4
+ * A toggle rather than a picker: there are exactly two, and a two-option `<select>` costs a
5
+ * reader more interaction than the thing it is choosing between is worth.
6
+ */
7
+ import type { EdgeStyle } from '../layout/types.js';
8
+ type Props = {
9
+ edgeStyle?: EdgeStyle;
10
+ onchange?: (value: EdgeStyle) => void;
11
+ };
12
+ declare const EdgeStyleControl: import("svelte").Component<Props, {}, "">;
13
+ type EdgeStyleControl = ReturnType<typeof EdgeStyleControl>;
14
+ export default EdgeStyleControl;
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Zoom in, out, and back to fit.
4
+ *
5
+ * Buttons rather than a slider: each is one tab stop and one keypress, so the diagram is
6
+ * navigable without a trackpad. The canvas handles ctrl+wheel and pinch itself — this is
7
+ * the discoverable path to the same value, not the only one.
8
+ */
9
+ import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, clampZoom } from './zoom.js'
10
+
11
+ type Props = {
12
+ zoom?: number
13
+ onchange?: (value: number) => void
14
+ }
15
+
16
+ let { zoom = 1, onchange }: Props = $props()
17
+
18
+ const percent = $derived(Math.round(zoom * 100))
19
+
20
+ // Clamped here as well as in the canvas: the buttons disable at the ends, but a caller can
21
+ // hand this control a value from anywhere, and reporting one outside the range would push
22
+ // the canvas past a limit it has already agreed to.
23
+ const step = (factor: number) => onchange?.(clampZoom(zoom * factor))
24
+ </script>
25
+
26
+ <div data-graph-zoom-controls role="group" aria-label="Zoom">
27
+ <button
28
+ type="button"
29
+ data-graph-zoom="out"
30
+ aria-label="Zoom out"
31
+ disabled={zoom <= ZOOM_MIN}
32
+ onclick={() => step(1 / ZOOM_STEP)}>−</button
33
+ >
34
+ <button
35
+ type="button"
36
+ data-graph-zoom="reset"
37
+ aria-label="Reset zoom to fit"
38
+ onclick={() => onchange?.(1)}>{percent}%</button
39
+ >
40
+ <button
41
+ type="button"
42
+ data-graph-zoom="in"
43
+ aria-label="Zoom in"
44
+ disabled={zoom >= ZOOM_MAX}
45
+ onclick={() => step(ZOOM_STEP)}>+</button
46
+ >
47
+ </div>
@@ -0,0 +1,7 @@
1
+ type Props = {
2
+ zoom?: number;
3
+ onchange?: (value: number) => void;
4
+ };
5
+ declare const ZoomControl: import("svelte").Component<Props, {}, "">;
6
+ type ZoomControl = ReturnType<typeof ZoomControl>;
7
+ export default ZoomControl;
@@ -0,0 +1,6 @@
1
+ export declare const ZOOM_MIN = 0.25;
2
+ export declare const ZOOM_MAX = 4;
3
+ /** One step of the +/- buttons, and of one wheel notch. */
4
+ export declare const ZOOM_STEP = 1.25;
5
+ /** `value` brought inside the range. */
6
+ export declare const clampZoom: (value: number) => number;
@@ -0,0 +1,12 @@
1
+ /* Zoom bounds, shared by the canvas and the zoom control.
2
+ *
3
+ * Both have to agree: the canvas clamps a ctrl+wheel gesture and the control disables its
4
+ * buttons at the ends, and if those two numbers drift the control goes dead a step before the
5
+ * gesture does — which reads as a broken button.
6
+ */
7
+ export const ZOOM_MIN = 0.25;
8
+ export const ZOOM_MAX = 4;
9
+ /** One step of the +/- buttons, and of one wheel notch. */
10
+ export const ZOOM_STEP = 1.25;
11
+ /** `value` brought inside the range. */
12
+ export const clampZoom = (value) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value));