@rokkit/graph 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +67 -1
  2. package/dist/Graph.svelte +234 -259
  3. package/dist/Graph.svelte.d.ts +1 -1
  4. package/dist/GraphLegend.svelte +81 -10
  5. package/dist/GraphLegend.svelte.d.ts +15 -0
  6. package/dist/GraphState.svelte.d.ts +168 -51
  7. package/dist/GraphState.svelte.js +406 -305
  8. package/dist/actions/canvas.d.ts +18 -0
  9. package/dist/actions/canvas.js +59 -0
  10. package/dist/actions/choices.d.ts +12 -0
  11. package/dist/actions/choices.js +23 -0
  12. package/dist/actions/interactions.d.ts +20 -0
  13. package/dist/actions/interactions.js +57 -0
  14. package/dist/actions/listen.d.ts +2 -0
  15. package/dist/actions/listen.js +10 -0
  16. package/dist/canvas/frame.d.ts +41 -0
  17. package/dist/canvas/frame.js +34 -0
  18. package/dist/controls/BundleControl.svelte +11 -10
  19. package/dist/controls/BundleControl.svelte.d.ts +0 -7
  20. package/dist/controls/DensityControl.svelte +17 -9
  21. package/dist/controls/DensityControl.svelte.d.ts +0 -8
  22. package/dist/controls/DepthControl.svelte +11 -4
  23. package/dist/controls/DepthControl.svelte.d.ts +0 -9
  24. package/dist/controls/DrillBar.svelte +63 -0
  25. package/dist/controls/DrillBar.svelte.d.ts +9 -0
  26. package/dist/controls/EdgeStyleControl.svelte +10 -10
  27. package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
  28. package/dist/controls/HiddenControl.svelte +27 -0
  29. package/dist/controls/HiddenControl.svelte.d.ts +7 -0
  30. package/dist/controls/MeasureControl.svelte +39 -0
  31. package/dist/controls/MeasureControl.svelte.d.ts +12 -0
  32. package/dist/controls/ToggleControl.svelte +33 -0
  33. package/dist/controls/ToggleControl.svelte.d.ts +13 -0
  34. package/dist/controls/ViolationsControl.svelte +27 -0
  35. package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
  36. package/dist/controls/ZoomControl.svelte +21 -18
  37. package/dist/controls/zoom.d.ts +3 -0
  38. package/dist/controls/zoom.js +6 -0
  39. package/dist/diagrams/ArcDiagram.svelte +116 -0
  40. package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
  41. package/dist/diagrams/CallTree.svelte +17 -5
  42. package/dist/diagrams/CallTree.svelte.d.ts +5 -1
  43. package/dist/diagrams/DependencyDiagram.svelte +18 -6
  44. package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
  45. package/dist/diagrams/DependencyMatrix.svelte +156 -0
  46. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  47. package/dist/diagrams/ErDiagram.svelte +18 -6
  48. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  49. package/dist/diagrams/LayersDiagram.svelte +124 -0
  50. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  51. package/dist/diagrams/Neighborhood.svelte +26 -6
  52. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  53. package/dist/diagrams/PolymetricTree.svelte +116 -0
  54. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  55. package/dist/diagrams/StructureDiagram.svelte +38 -8
  56. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  57. package/dist/diagrams/Sunburst.svelte +44 -8
  58. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  59. package/dist/diagrams/Treemap.svelte +44 -8
  60. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  61. package/dist/index.d.ts +13 -3
  62. package/dist/index.js +11 -2
  63. package/dist/layout/arcs.d.ts +6 -0
  64. package/dist/layout/arcs.js +141 -0
  65. package/dist/layout/cards.js +9 -2
  66. package/dist/layout/edges.d.ts +13 -1
  67. package/dist/layout/edges.js +35 -0
  68. package/dist/layout/extent.d.ts +17 -0
  69. package/dist/layout/extent.js +27 -0
  70. package/dist/layout/flow.js +3 -1
  71. package/dist/layout/index.d.ts +4 -1
  72. package/dist/layout/index.js +17 -2
  73. package/dist/layout/layers.d.ts +6 -0
  74. package/dist/layout/layers.js +180 -0
  75. package/dist/layout/matrix.d.ts +50 -0
  76. package/dist/layout/matrix.js +110 -0
  77. package/dist/layout/neighborhood.js +16 -7
  78. package/dist/layout/options.d.ts +1 -1
  79. package/dist/layout/options.js +16 -3
  80. package/dist/layout/percentile.d.ts +8 -0
  81. package/dist/layout/percentile.js +13 -0
  82. package/dist/layout/polymetric.d.ts +3 -0
  83. package/dist/layout/polymetric.js +221 -0
  84. package/dist/layout/radial.js +3 -1
  85. package/dist/layout/structure.js +7 -1
  86. package/dist/layout/sunburst.js +11 -4
  87. package/dist/layout/types.d.ts +83 -0
  88. package/dist/layout/world.js +11 -4
  89. package/dist/messages.d.ts +14 -0
  90. package/dist/messages.js +17 -0
  91. package/dist/model/condense.d.ts +8 -0
  92. package/dist/model/condense.js +114 -0
  93. package/dist/model/entities.d.ts +12 -0
  94. package/dist/model/entities.js +12 -0
  95. package/dist/model/normalize.d.ts +2 -6
  96. package/dist/model/normalize.js +83 -5
  97. package/dist/model/relationships.d.ts +30 -0
  98. package/dist/model/relationships.js +51 -0
  99. package/dist/model/share.d.ts +9 -0
  100. package/dist/model/share.js +43 -0
  101. package/dist/model/tree.d.ts +7 -1
  102. package/dist/model/tree.js +11 -4
  103. package/dist/preset.d.ts +18 -0
  104. package/dist/preset.js +34 -3
  105. package/dist/schema/EntitiesView.svelte +8 -6
  106. package/dist/schema/EntityView.svelte +13 -10
  107. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  108. package/dist/state/GraphConfig.svelte.js +253 -0
  109. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  110. package/dist/state/GraphDrill.svelte.js +115 -0
  111. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  112. package/dist/state/GraphGroups.svelte.js +73 -0
  113. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  114. package/dist/state/GraphSelection.svelte.js +94 -0
  115. package/dist/state/intents.d.ts +33 -0
  116. package/dist/state/intents.js +30 -0
  117. package/dist/state/text.d.ts +28 -0
  118. package/dist/state/text.js +56 -0
  119. package/dist/types.d.ts +92 -1
  120. package/package.json +5 -4
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Moving around the canvas: drag the background to pan, ctrl/⌘ + wheel to zoom.
3
+ *
4
+ * Ctrl/⌘ + wheel is what a trackpad pinch reports as; without preventDefault the browser
5
+ * zooms the whole PAGE instead of the diagram. A plain wheel is left alone — the canvas
6
+ * scrolls, which is how panning works here, and the drag is the direct-manipulation path on
7
+ * top of it. The paper carries `data-graph-panning` while a drag lasts.
8
+ */
9
+ import type { ActionReturn } from 'svelte/action';
10
+ export type CanvasParams = {
11
+ zoomable?: boolean;
12
+ /** A pinch: which way, and where — relative to the paper — so the zoom can keep that point still. */
13
+ onzoom?: (direction: 'in' | 'out', at: {
14
+ x: number;
15
+ y: number;
16
+ }) => void;
17
+ };
18
+ export declare function canvasNavigation(paper: HTMLElement, initial: CanvasParams): ActionReturn<CanvasParams>;
@@ -0,0 +1,59 @@
1
+ import { listen } from './listen.js';
2
+ /**
3
+ * What a press can start on without being a pan: a node card, and anything interactive. A pan
4
+ * captures the pointer, and the browser then delivers the CLICK to the canvas instead of the
5
+ * box — so a treemap box could be neither opened nor selected by mouse.
6
+ */
7
+ const OWNS_ITS_PRESS = '[data-graph-node], button, [role="button"], a[href], input, select, textarea';
8
+ /** Drag-to-pan on the background: scroll the paper against the pointer while it is held. */
9
+ function panHandlers(paper) {
10
+ let from = null;
11
+ const end = () => {
12
+ from = null;
13
+ paper.removeAttribute('data-graph-panning');
14
+ };
15
+ return {
16
+ pointerdown(event) {
17
+ if (event.target.closest(OWNS_ITS_PRESS))
18
+ return;
19
+ from = { x: event.clientX, y: event.clientY, left: paper.scrollLeft, top: paper.scrollTop };
20
+ paper.setAttribute('data-graph-panning', '');
21
+ paper.setPointerCapture(event.pointerId);
22
+ },
23
+ pointermove(event) {
24
+ if (!from)
25
+ return;
26
+ paper.scrollLeft = from.left - (event.clientX - from.x);
27
+ paper.scrollTop = from.top - (event.clientY - from.y);
28
+ },
29
+ pointerup: end,
30
+ pointercancel: end
31
+ };
32
+ }
33
+ /** A pinch (ctrl/⌘ + wheel) zooms; a plain wheel is left to scroll. */
34
+ function zoomHandler(params) {
35
+ return (event) => {
36
+ if (!params().zoomable || !(event.ctrlKey || event.metaKey))
37
+ return;
38
+ event.preventDefault();
39
+ const box = event.currentTarget.getBoundingClientRect();
40
+ params().onzoom?.(event.deltaY < 0 ? 'in' : 'out', { x: event.clientX - box.left, y: event.clientY - box.top });
41
+ };
42
+ }
43
+ export function canvasNavigation(paper, initial) {
44
+ let params = initial;
45
+ const pan = panHandlers(paper);
46
+ const stopPan = listen(paper, pan);
47
+ // Not passive: preventing the page zoom is the point of the wheel listener.
48
+ const stopZoom = listen(paper, { wheel: zoomHandler(() => params) }, { passive: false });
49
+ return {
50
+ update(next) {
51
+ params = next;
52
+ },
53
+ destroy() {
54
+ stopPan();
55
+ stopZoom();
56
+ pan.pointercancel();
57
+ }
58
+ };
59
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What a control reports: the value of the choice the reader made, and nothing else.
3
+ *
4
+ * A button names its choice in `data-graph-choice`; a `<select>` reports its value on change.
5
+ * The control maps the string back to its own type and calls its `onchange` — so no button
6
+ * carries a handler, and a disabled choice never fires.
7
+ */
8
+ import type { ActionReturn } from 'svelte/action';
9
+ export type ChoicesParams = {
10
+ onchoose: (value: string) => void;
11
+ };
12
+ export declare function choices(root: HTMLElement, initial: ChoicesParams): ActionReturn<ChoicesParams>;
@@ -0,0 +1,23 @@
1
+ import { listen } from './listen.js';
2
+ export function choices(root, initial) {
3
+ let params = initial;
4
+ const stop = listen(root, {
5
+ click(event) {
6
+ const chosen = event.target.closest('[data-graph-choice]');
7
+ if (!chosen || !root.contains(chosen) || chosen.disabled)
8
+ return;
9
+ params.onchoose(chosen.getAttribute('data-graph-choice') ?? '');
10
+ },
11
+ change(event) {
12
+ const target = event.target;
13
+ if (target.tagName === 'SELECT')
14
+ params.onchoose(target.value);
15
+ }
16
+ });
17
+ return {
18
+ update(next) {
19
+ params = next;
20
+ },
21
+ destroy: stop
22
+ };
23
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Every press on the graph, through one delegated listener.
3
+ *
4
+ * The state writes what a press MEANS onto the element — `data-graph-press` for a click (and
5
+ * Enter / Space), `data-graph-open` for a double-click, `data-graph-key` for what it is about —
6
+ * and this reads it back and calls `state.act`. No element carries a handler of its own, so a
7
+ * new interactive element is an attribute, and the whole mapping is testable on bare DOM.
8
+ *
9
+ * The innermost intent wins: an "Expand" control inside a card is its own press, not the card's.
10
+ */
11
+ import type { ActionReturn } from 'svelte/action';
12
+ import type { Intent } from '../state/intents.js';
13
+ export type InteractionsParams = {
14
+ state: {
15
+ act(intent: Intent, key: string | null): void;
16
+ };
17
+ /** A click on nothing interactive, or Escape, clears the selection — the canvas does this. */
18
+ clearOnBackground?: boolean;
19
+ };
20
+ export declare function interactions(root: HTMLElement, initial: InteractionsParams): ActionReturn<InteractionsParams>;
@@ -0,0 +1,57 @@
1
+ import { isIntent } from '../state/intents.js';
2
+ import { listen } from './listen.js';
3
+ const PRESS = '[data-graph-press]';
4
+ const OPEN = '[data-graph-open]';
5
+ /** The innermost element inside `root` that carries `selector`, from the event's target. */
6
+ function carrier(root, event, selector) {
7
+ const found = event.target?.closest(selector) ?? null;
8
+ return found && root.contains(found) ? found : null;
9
+ }
10
+ /** Act on the intent `element` declares under `attribute`, if it is one the state knows. */
11
+ function perform(params, element, attribute) {
12
+ const intent = element.getAttribute(attribute);
13
+ if (isIntent(intent))
14
+ params.state.act(intent, element.getAttribute('data-graph-key'));
15
+ }
16
+ const isActivation = (event) => event.key === 'Enter' || event.key === ' ';
17
+ /** The three delegated listeners, reading the latest params on every event. */
18
+ function handlers(root, params) {
19
+ return {
20
+ click(event) {
21
+ const pressed = carrier(root, event, PRESS);
22
+ if (pressed)
23
+ perform(params(), pressed, 'data-graph-press');
24
+ else if (params().clearOnBackground)
25
+ params().state.act('clear', null);
26
+ },
27
+ dblclick(event) {
28
+ const opened = carrier(root, event, OPEN);
29
+ if (opened)
30
+ perform(params(), opened, 'data-graph-open');
31
+ },
32
+ keydown(event) {
33
+ if (event.key === 'Escape') {
34
+ if (params().clearOnBackground)
35
+ params().state.act('clear', null);
36
+ return;
37
+ }
38
+ const pressed = carrier(root, event, PRESS);
39
+ // A real <button> turns Enter and Space into a click by itself; acting here too would
40
+ // press it twice.
41
+ if (!pressed || pressed.tagName === 'BUTTON' || !isActivation(event))
42
+ return;
43
+ event.preventDefault();
44
+ perform(params(), pressed, 'data-graph-press');
45
+ }
46
+ };
47
+ }
48
+ export function interactions(root, initial) {
49
+ let params = initial;
50
+ const stop = listen(root, handlers(root, () => params));
51
+ return {
52
+ update(next) {
53
+ params = next;
54
+ },
55
+ destroy: stop
56
+ };
57
+ }
@@ -0,0 +1,2 @@
1
+ /** Attach every listener in `listeners` to `target`; the returned function detaches them all. */
2
+ export declare function listen(target: EventTarget, listeners: Record<string, (event: never) => void>, options?: AddEventListenerOptions): () => void;
@@ -0,0 +1,10 @@
1
+ /** Attach every listener in `listeners` to `target`; the returned function detaches them all. */
2
+ export function listen(target, listeners, options) {
3
+ const entries = Object.entries(listeners);
4
+ for (const [type, listener] of entries)
5
+ target.addEventListener(type, listener, options);
6
+ return () => {
7
+ for (const [type, listener] of entries)
8
+ target.removeEventListener(type, listener, options);
9
+ };
10
+ }
@@ -0,0 +1,41 @@
1
+ import type { Size } from '../layout/types.js';
2
+ export type Frame = {
3
+ /** The scale that fits the content in the viewport, before the reader's zoom. */
4
+ fit: number;
5
+ /** fit × zoom — what the world is drawn at. */
6
+ scale: number;
7
+ /** Where the scaled world's top-left sits inside the scrollable extent. */
8
+ tx: number;
9
+ ty: number;
10
+ /** The scrollable extent: the scaled drawing plus `pad` all round, never less than the viewport. */
11
+ extent: Size;
12
+ };
13
+ type FrameInput = {
14
+ vw: number;
15
+ vh: number;
16
+ content: Size;
17
+ zoom: number;
18
+ pad: number;
19
+ };
20
+ export declare function frameOf({ vw, vh, content, zoom, pad }: FrameInput): Frame;
21
+ type Scroll = {
22
+ left: number;
23
+ top: number;
24
+ };
25
+ /**
26
+ * The scroll that keeps the content point under `anchor` (viewport coordinates) where it was,
27
+ * across a change of frame. A pinch anchors at the pointer; a zoom button at the viewport centre.
28
+ *
29
+ * It holds on an axis that scrolls. On an axis where the drawing still fits, the drawing stays
30
+ * centred instead (`offset`), and no scroll can move it — fitting wins over anchoring there.
31
+ */
32
+ export declare function anchoredScroll(input: {
33
+ before: Frame;
34
+ after: Frame;
35
+ scroll: Scroll;
36
+ anchor: {
37
+ x: number;
38
+ y: number;
39
+ };
40
+ }): Scroll;
41
+ export {};
@@ -0,0 +1,34 @@
1
+ /** The smallest scale fit will choose: below it a drawing is noise, however large it is. */
2
+ const MIN_SCALE = 0.08;
3
+ /** Centred while the drawing is smaller than the viewport; `pad` in from the edge once it is not. */
4
+ const offset = (viewport, drawn, pad) => Math.max(pad, (viewport - drawn) / 2);
5
+ export function frameOf({ vw, vh, content, zoom, pad }) {
6
+ const fit = Math.max(MIN_SCALE, Math.min((vw - pad * 2) / content.w, (vh - pad * 2) / content.h, 1)) || 0.5;
7
+ const scale = fit * zoom;
8
+ const drawn = { w: content.w * scale, h: content.h * scale };
9
+ return {
10
+ fit,
11
+ scale,
12
+ tx: offset(vw, drawn.w, pad),
13
+ ty: offset(vh, drawn.h, pad),
14
+ // The same pad on the trailing edge as the leading one, so the far side is reachable
15
+ // with room to spare — and exactly the viewport when the drawing fits, so nothing scrolls.
16
+ extent: { w: Math.max(vw, drawn.w + pad * 2), h: Math.max(vh, drawn.h + pad * 2) }
17
+ };
18
+ }
19
+ /**
20
+ * The scroll that keeps the content point under `anchor` (viewport coordinates) where it was,
21
+ * across a change of frame. A pinch anchors at the pointer; a zoom button at the viewport centre.
22
+ *
23
+ * It holds on an axis that scrolls. On an axis where the drawing still fits, the drawing stays
24
+ * centred instead (`offset`), and no scroll can move it — fitting wins over anchoring there.
25
+ */
26
+ export function anchoredScroll(input) {
27
+ const { before, after, scroll, anchor } = input;
28
+ const x = (scroll.left + anchor.x - before.tx) / before.scale;
29
+ const y = (scroll.top + anchor.y - before.ty) / before.scale;
30
+ return {
31
+ left: Math.max(0, x * after.scale + after.tx - anchor.x),
32
+ top: Math.max(0, y * after.scale + after.ty - anchor.y)
33
+ };
34
+ }
@@ -6,6 +6,9 @@
6
6
  * the view is readable at all — a few hundred straight chords across a rim are a solid disc
7
7
  * of ink — and straight is what you switch to when you want to see one edge exactly.
8
8
  */
9
+ import ToggleControl from './ToggleControl.svelte'
10
+ import { say } from '../messages.js'
11
+
9
12
  type Props = {
10
13
  bundleTension?: number
11
14
  /** Tension used for the bundled state. Holten's 0.85 by default. */
@@ -18,13 +21,11 @@
18
21
  const on = $derived(bundleTension > 0)
19
22
  </script>
20
23
 
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>
24
+ <ToggleControl
25
+ pressed={on}
26
+ hooks={{ 'data-graph-bundle': on ? 'bundled' : 'straight' }}
27
+ glyph={on ? '❨' : '/'}
28
+ label={say(on ? 'bundled' : 'straight')}
29
+ title={say(on ? 'bundledTitle' : 'straightTitle')}
30
+ onchange={(next) => onchange?.(next ? bundled : 0)}
31
+ />
@@ -1,10 +1,3 @@
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
1
  type Props = {
9
2
  bundleTension?: number;
10
3
  /** Tension used for the bundled state. Holten's 0.85 by default. */
@@ -6,34 +6,42 @@
6
6
  * control that moves while the picture does not reads as a broken view. The diagram that
7
7
  * wants it places it; the rest never render it.
8
8
  */
9
+ import { choices } from '../actions/choices.js'
10
+ import { say } from '../messages.js'
11
+ import type { GraphMessageKey } from '../messages.js'
9
12
  import type { Density } from '../layout/types.js'
10
13
 
11
14
  type Props = {
12
15
  density?: Density
13
- /** Fires with the chosen value. The caller owns the value — this control stores none. */
14
16
  onchange?: (value: Density) => void
15
17
  }
16
18
 
17
19
  let { density = 'keys', onchange }: Props = $props()
18
20
 
19
- const options: [Density, string, string][] = [
20
- ['names', 'Names', 'Titles only'],
21
- ['keys', 'Keys', 'Key rows only'],
22
- ['full', 'All', 'All rows']
21
+ /** Each level, and the words for it. */
22
+ const LEVELS: [Density, GraphMessageKey, GraphMessageKey][] = [
23
+ ['names', 'densityNames', 'densityNamesTitle'],
24
+ ['keys', 'densityKeys', 'densityKeysTitle'],
25
+ ['full', 'densityFull', 'densityFullTitle']
23
26
  ]
24
27
  </script>
25
28
 
26
29
  <!-- Named, because three unlabelled buttons announce as "Names Keys All" and say nothing
27
30
  about what they control. -->
28
- <div data-graph-density-controls role="group" aria-label="Detail level">
29
- {#each options as [value, short, title] (value)}
31
+ <div
32
+ data-graph-density-controls
33
+ role="group"
34
+ aria-label={say('detailLevel')}
35
+ use:choices={{ onchoose: (value) => onchange?.(value as Density) }}
36
+ >
37
+ {#each LEVELS as [value, short, title] (value)}
30
38
  <button
31
39
  type="button"
32
40
  data-graph-density={value}
41
+ data-graph-choice={value}
33
42
  data-selected={density === value ? '' : undefined}
34
43
  aria-pressed={density === value}
35
- {title}
36
- onclick={() => onchange?.(value)}>{short}</button
44
+ title={say(title)}>{say(short)}</button
37
45
  >
38
46
  {/each}
39
47
  </div>
@@ -1,14 +1,6 @@
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
1
  import type { Density } from '../layout/types.js';
9
2
  type Props = {
10
3
  density?: Density;
11
- /** Fires with the chosen value. The caller owns the value — this control stores none. */
12
4
  onchange?: (value: Density) => void;
13
5
  };
14
6
  declare const DensityControl: import("svelte").Component<Props, {}, "">;
@@ -7,9 +7,11 @@
7
7
  * nothing is legible — show the crates first, then drill. Depth and drilling are the same
8
8
  * idea from two directions: this caps how far DOWN, `focusPath` moves where you start.
9
9
  */
10
+ import { choices } from '../actions/choices.js'
11
+ import { counted, say } from '../messages.js'
12
+
10
13
  type Props = {
11
14
  levels?: number
12
- /** Deepest level offered. Beyond the tree's own depth the extra buttons do nothing. */
13
15
  max?: number
14
16
  onchange?: (value: number) => void
15
17
  }
@@ -20,15 +22,20 @@
20
22
  const steps = $derived(Array.from({ length: Math.max(1, max) }, (_, i) => i + 1))
21
23
  </script>
22
24
 
23
- <div data-graph-depth-controls role="group" aria-label="Levels shown">
25
+ <div
26
+ data-graph-depth-controls
27
+ role="group"
28
+ aria-label={say('levelsShown')}
29
+ use:choices={{ onchoose: (value) => onchange?.(Number(value)) }}
30
+ >
24
31
  {#each steps as level (level)}
25
32
  <button
26
33
  type="button"
27
34
  data-graph-depth={level}
35
+ data-graph-choice={level}
28
36
  data-selected={levels === level ? '' : undefined}
29
37
  aria-pressed={levels === level}
30
- title="{level} level{level === 1 ? '' : 's'}"
31
- onclick={() => onchange?.(level)}>{level}</button
38
+ title={counted(level, 'levelOne', 'levelMany')}>{level}</button
32
39
  >
33
40
  {/each}
34
41
  </div>
@@ -1,14 +1,5 @@
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
1
  type Props = {
10
2
  levels?: number;
11
- /** Deepest level offered. Beyond the tree's own depth the extra buttons do nothing. */
12
3
  max?: number;
13
4
  onchange?: (value: number) => void;
14
5
  };
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Where the reader has drilled to, and the way back (#165).
4
+ *
5
+ * Breadcrumbs from the root to the current level (each an ancestor to climb to), a status
6
+ * while the host loads a level, the error when a load fails, "Open" for the selected box
7
+ * when it can be drilled — the keyboard route into a leaf, since Enter on a leaf selects it —
8
+ * and Expand / Collapse for the selected group or group member (#166).
9
+ * Renders nothing when there is nothing to show. Each control declares its intent; the
10
+ * `interactions` action performs it, so this reads the state and computes nothing.
11
+ */
12
+ import { interactions } from '../actions/interactions.js'
13
+ import { say } from '../messages.js'
14
+ import type { GraphState } from '../GraphState.svelte.js'
15
+
16
+ type Props = {
17
+ state: GraphState
18
+ /** What the root crumb says. Defaults to the locale's (`All`). */
19
+ rootLabel?: string
20
+ }
21
+
22
+ let { state: graph, rootLabel = undefined }: Props = $props()
23
+ </script>
24
+
25
+ {#if graph.showsDrillBar}
26
+ <nav data-graph-drill aria-label={say('drillPath')} use:interactions={{ state: graph }}>
27
+ <ol data-graph-drill-trail>
28
+ {#each graph.breadcrumbs as crumb, i (crumb.path.join('/'))}
29
+ <li>
30
+ {#if crumb.current}
31
+ <span data-graph-drill-crumb aria-current="location"
32
+ >{crumb.label ?? rootLabel ?? say('drillRoot')}</span
33
+ >
34
+ {:else}
35
+ <button type="button" data-graph-drill-crumb data-graph-press="crumb" data-graph-key={String(i)}
36
+ >{crumb.label ?? rootLabel ?? say('drillRoot')}</button
37
+ >
38
+ {/if}
39
+ </li>
40
+ {/each}
41
+ </ol>
42
+ {#if graph.groupAction}
43
+ <!-- The keyboard route through a group: Enter selects a node, then this opens or folds it. -->
44
+ <button
45
+ type="button"
46
+ data-graph-group-action={graph.groupAction.kind}
47
+ data-graph-press="group-selected"
48
+ >{say(graph.groupAction.kind, { name: graph.groupAction.label })}</button
49
+ >
50
+ {/if}
51
+ {#if graph.drillTarget}
52
+ <button type="button" data-graph-drill-open data-graph-press="open-selected"
53
+ >{say('open', { name: graph.drillTarget.name })}</button
54
+ >
55
+ {/if}
56
+ {#if graph.pending}
57
+ <span data-graph-drill-status role="status">{say('drillLoading')}</span>
58
+ {/if}
59
+ {#if graph.drillErrorText}
60
+ <span data-graph-drill-error role="alert">{graph.drillErrorText}</span>
61
+ {/if}
62
+ </nav>
63
+ {/if}
@@ -0,0 +1,9 @@
1
+ import type { GraphState } from '../GraphState.svelte.js';
2
+ type Props = {
3
+ state: GraphState;
4
+ /** What the root crumb says. Defaults to the locale's (`All`). */
5
+ rootLabel?: string;
6
+ };
7
+ declare const DrillBar: import("svelte").Component<Props, {}, "">;
8
+ type DrillBar = ReturnType<typeof DrillBar>;
9
+ export default DrillBar;
@@ -5,6 +5,8 @@
5
5
  * A toggle rather than a picker: there are exactly two, and a two-option `<select>` costs a
6
6
  * reader more interaction than the thing it is choosing between is worth.
7
7
  */
8
+ import ToggleControl from './ToggleControl.svelte'
9
+ import { say } from '../messages.js'
8
10
  import type { EdgeStyle } from '../layout/types.js'
9
11
 
10
12
  type Props = {
@@ -19,13 +21,11 @@
19
21
  const orthogonal = $derived(edgeStyle === 'orthogonal')
20
22
  </script>
21
23
 
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>
24
+ <ToggleControl
25
+ pressed={orthogonal}
26
+ hooks={{ 'data-graph-edge-style': edgeStyle }}
27
+ glyph={orthogonal ? '⌐' : '︵'}
28
+ label={say(orthogonal ? 'angled' : 'curved')}
29
+ title={say(orthogonal ? 'angledTitle' : 'curvedTitle')}
30
+ onchange={(next) => onchange?.(next ? 'orthogonal' : 'curved')}
31
+ />
@@ -1,9 +1,3 @@
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
1
  import type { EdgeStyle } from '../layout/types.js';
8
2
  type Props = {
9
3
  edgeStyle?: EdgeStyle;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Every pair, or only the hidden coupling (#169).
4
+ *
5
+ * A toggle: on a real repo the ordinary pairs vastly outnumber the ones nothing in the code
6
+ * explains, and those are the only rows anyone acts on.
7
+ */
8
+ import ToggleControl from './ToggleControl.svelte'
9
+ import { say } from '../messages.js'
10
+
11
+ type Props = {
12
+ showEdges?: 'all' | 'hidden'
13
+ onchange?: (value: 'all' | 'hidden') => void
14
+ }
15
+
16
+ let { showEdges = 'all', onchange }: Props = $props()
17
+
18
+ const only = $derived(showEdges === 'hidden')
19
+ </script>
20
+
21
+ <ToggleControl
22
+ pressed={only}
23
+ hooks={{ 'data-graph-hidden-only': showEdges }}
24
+ label={say(only ? 'hiddenOnly' : 'allPairs')}
25
+ title={say(only ? 'hiddenOnlyTitle' : 'allPairsTitle')}
26
+ onchange={(on) => onchange?.(on ? 'hidden' : 'all')}
27
+ />
@@ -0,0 +1,7 @@
1
+ type Props = {
2
+ showEdges?: 'all' | 'hidden';
3
+ onchange?: (value: 'all' | 'hidden') => void;
4
+ };
5
+ declare const HiddenControl: import("svelte").Component<Props, {}, "">;
6
+ type HiddenControl = ReturnType<typeof HiddenControl>;
7
+ export default HiddenControl;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Which measure a channel encodes (#168) — one picker per channel of a polymetric view.
4
+ *
5
+ * The measures offered are the ones the data has (`GraphState.measureKeys`): a picker that
6
+ * lists a metric no node carries offers a box of minimum size everywhere.
7
+ */
8
+ import { choices } from '../actions/choices.js'
9
+ import { say } from '../messages.js'
10
+
11
+ type Props = {
12
+ channel: 'width' | 'height' | 'color'
13
+ value?: string
14
+ /** The measures to offer — `GraphState.measureKeys`, what the data has. */
15
+ options: string[]
16
+ /** Offer "none" — a host with two metrics yet can leave shade off. */
17
+ allowNone?: boolean
18
+ onchange?: (value: string | undefined) => void
19
+ }
20
+
21
+ let { channel, value = undefined, options, allowNone = false, onchange }: Props = $props()
22
+
23
+ const LABEL = { width: 'width', height: 'height', color: 'shade' } as const
24
+ </script>
25
+
26
+ <label
27
+ data-graph-measure-control
28
+ use:choices={{ onchoose: (picked) => onchange?.(picked === '' ? undefined : picked) }}
29
+ >
30
+ <span data-graph-control-label>{say(LABEL[channel])}</span>
31
+ <select data-graph-measure={channel} value={value ?? ''}>
32
+ {#if allowNone}
33
+ <option value="">{say('measureNone')}</option>
34
+ {/if}
35
+ {#each options as option (option)}
36
+ <option value={option}>{option}</option>
37
+ {/each}
38
+ </select>
39
+ </label>