@iloveagents/foundry-web-graph 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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/adapters/index.d.ts +1 -0
  4. package/dist/adapters/index.js +1 -0
  5. package/dist/adapters/neo4j.d.ts +52 -0
  6. package/dist/adapters/neo4j.js +149 -0
  7. package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
  8. package/dist/assistant-ui/graph-client-tools.js +261 -0
  9. package/dist/assistant-ui/graph-context.d.ts +38 -0
  10. package/dist/assistant-ui/graph-context.js +113 -0
  11. package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
  12. package/dist/assistant-ui/graph-panel-content.js +28 -0
  13. package/dist/assistant-ui/graph-panel.d.ts +11 -0
  14. package/dist/assistant-ui/graph-panel.js +121 -0
  15. package/dist/assistant-ui/graph-provenance.d.ts +17 -0
  16. package/dist/assistant-ui/graph-provenance.js +31 -0
  17. package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
  18. package/dist/assistant-ui/graph-result-seeder.js +157 -0
  19. package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
  20. package/dist/assistant-ui/graph-tool-registry.js +15 -0
  21. package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
  22. package/dist/assistant-ui/graph-tool-ui.js +77 -0
  23. package/dist/assistant-ui/index.d.ts +17 -0
  24. package/dist/assistant-ui/index.js +17 -0
  25. package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
  26. package/dist/assistant-ui/merge-into-panel.js +72 -0
  27. package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
  28. package/dist/assistant-ui/register-graph-panel.js +18 -0
  29. package/dist/caption-placement.d.ts +119 -0
  30. package/dist/caption-placement.js +146 -0
  31. package/dist/graph-canvas-paint.d.ts +90 -0
  32. package/dist/graph-canvas-paint.js +186 -0
  33. package/dist/graph-canvas.d.ts +49 -0
  34. package/dist/graph-canvas.js +533 -0
  35. package/dist/graph-inspector.d.ts +42 -0
  36. package/dist/graph-inspector.js +106 -0
  37. package/dist/graph-legend.d.ts +19 -0
  38. package/dist/graph-legend.js +20 -0
  39. package/dist/graph-notice.d.ts +19 -0
  40. package/dist/graph-notice.js +30 -0
  41. package/dist/graph-table.d.ts +28 -0
  42. package/dist/graph-table.js +57 -0
  43. package/dist/graph-toolbar.d.ts +22 -0
  44. package/dist/graph-toolbar.js +8 -0
  45. package/dist/graph-tooltip.d.ts +4 -0
  46. package/dist/graph-tooltip.js +55 -0
  47. package/dist/graph-view.d.ts +94 -0
  48. package/dist/graph-view.js +338 -0
  49. package/dist/graph-workspace.d.ts +55 -0
  50. package/dist/graph-workspace.js +100 -0
  51. package/dist/index.d.ts +21 -0
  52. package/dist/index.js +21 -0
  53. package/dist/model.d.ts +206 -0
  54. package/dist/model.js +369 -0
  55. package/dist/styles.css +97 -0
  56. package/dist/theme.d.ts +36 -0
  57. package/dist/theme.js +83 -0
  58. package/dist/use-element-size.d.ts +13 -0
  59. package/dist/use-element-size.js +32 -0
  60. package/dist/use-graph-model.d.ts +101 -0
  61. package/dist/use-graph-model.js +164 -0
  62. package/dist/use-graph-styling.d.ts +55 -0
  63. package/dist/use-graph-styling.js +156 -0
  64. package/dist/use-graph-theme.d.ts +11 -0
  65. package/dist/use-graph-theme.js +54 -0
  66. package/dist/use-graph-view-state.d.ts +42 -0
  67. package/dist/use-graph-view-state.js +145 -0
  68. package/package.json +83 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Which labels can be drawn without overprinting anything.
3
+ *
4
+ * Choosing labels by rank alone — the biggest N nodes, or "everything above
5
+ * some zoom" — is position-blind, and in a force layout the important nodes are
6
+ * exactly the ones that end up near each other. Two adjacent hubs both qualify,
7
+ * their captions overlap, and no amount of force tuning fixes it: it is a label
8
+ * placement problem, not a layout problem.
9
+ *
10
+ * So: walk the candidates in priority order and keep the ones whose rectangle
11
+ * is still free. Density then takes care of itself — zoomed in, few things
12
+ * share the viewport and nearly everything is labelled; zoomed out, collisions
13
+ * thin the labels down to the structure.
14
+ *
15
+ * Node captions and relationship types go through the SAME pass, competing for
16
+ * the same space, because they overlap each other as readily as they overlap
17
+ * their own kind. Captions outrank relationship types: a node's name is what
18
+ * you navigate by, and a `DIRECTED` you cannot read is a smaller loss than a
19
+ * `Metropolis` you cannot read.
20
+ *
21
+ * Pure, and works entirely in SCREEN pixels, so it needs neither the canvas nor
22
+ * the engine and can be tested without either.
23
+ */
24
+ /** Priority order. Lower is placed first and therefore wins its space. */
25
+ export declare const LabelTier: {
26
+ /**
27
+ * Always drawn, and reserves its space before anything else. Only for what
28
+ * the user has selected or is pointing at — that label is the answer to a
29
+ * question they just asked, and it must not lose to a bigger neighbour.
30
+ */
31
+ readonly Pinned: 0;
32
+ /** A search hit. Ahead of ordinary captions, but still yields to a collision. */
33
+ readonly Match: 1;
34
+ /** An ordinary node caption, ranked among its peers by weight. */
35
+ readonly Caption: 2;
36
+ /** A relationship type. Drawn only with space left over. */
37
+ readonly Edge: 3;
38
+ };
39
+ export type LabelTierValue = (typeof LabelTier)[keyof typeof LabelTier];
40
+ export interface Rect {
41
+ left: number;
42
+ right: number;
43
+ top: number;
44
+ bottom: number;
45
+ }
46
+ export interface LabelCandidate {
47
+ id: string;
48
+ /** The space the label will occupy, in screen pixels. */
49
+ rect: Rect;
50
+ tier: LabelTierValue;
51
+ /** Ranking within a tier; larger wins. Node radius, which follows degree. */
52
+ weight: number;
53
+ /**
54
+ * Obstacles this label may overlap — its own node's disc, or the two ends of
55
+ * its own edge. Without it a caption would collide with the very node it
56
+ * names.
57
+ */
58
+ exempt?: readonly string[];
59
+ }
60
+ export interface PlacementOptions {
61
+ /**
62
+ * Obstacles nothing may print over: the node discs. Keyed so a label can
63
+ * exempt its own.
64
+ */
65
+ obstacles?: readonly {
66
+ id: string;
67
+ rect: Rect;
68
+ }[];
69
+ /**
70
+ * Where the labels have to fit. Candidates outside it are dropped — an
71
+ * off-screen label reserving space would suppress a visible one for no one's
72
+ * benefit. Omit to place without regard to the viewport.
73
+ */
74
+ viewport?: {
75
+ width: number;
76
+ height: number;
77
+ };
78
+ /**
79
+ * Stop after considering this many candidates.
80
+ *
81
+ * The scan is quadratic in what it places, which is fine for the tens of
82
+ * labels that fit on a screen and not fine for a 5,000-node graph where
83
+ * almost every candidate would be rejected anyway.
84
+ */
85
+ limit?: number;
86
+ /** How far outside the viewport a label may sit and still be considered. */
87
+ margin?: number;
88
+ }
89
+ export declare function placeLabels(candidates: readonly LabelCandidate[], options?: PlacementOptions): Set<string>;
90
+ /** The disc a node occupies — an obstacle no label may print across. */
91
+ export declare function nodeRect(x: number, y: number, radius: number): Rect;
92
+ /** Where a node's caption sits: centred under the node. Matches `paintNode`. */
93
+ export declare function captionRect(x: number, y: number, radius: number, width: number, { lineHeight, gap, padX }?: {
94
+ lineHeight?: number | undefined;
95
+ gap?: number | undefined;
96
+ padX?: number | undefined;
97
+ }): Rect;
98
+ /**
99
+ * Where a relationship type sits: at the midpoint of the edge, rotated to
100
+ * follow it.
101
+ *
102
+ * Approximated by the axis-aligned box the rotated text fits inside. That is
103
+ * generous for a diagonal edge, which is the right way to be wrong — it drops a
104
+ * label that would have just fitted rather than printing one that just does
105
+ * not.
106
+ */
107
+ export declare function edgeLabelRect(x1: number, y1: number, x2: number, y2: number, width: number, { lineHeight }?: {
108
+ lineHeight?: number | undefined;
109
+ }): Rect;
110
+ /**
111
+ * Label widths, measured once per string.
112
+ *
113
+ * `paintNode` sets the font in GRAPH units (`11 / globalScale`), which is a
114
+ * constant 11 pixels on screen at every zoom — so a label's rendered width
115
+ * never changes and measuring it per frame would be pure waste. Keyed by the
116
+ * string that will actually be drawn (already truncated), so two nodes with the
117
+ * same name measure once.
118
+ */
119
+ export declare function createCaptionMeasurer(font?: string): (text: string) => number;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Which labels can be drawn without overprinting anything.
3
+ *
4
+ * Choosing labels by rank alone — the biggest N nodes, or "everything above
5
+ * some zoom" — is position-blind, and in a force layout the important nodes are
6
+ * exactly the ones that end up near each other. Two adjacent hubs both qualify,
7
+ * their captions overlap, and no amount of force tuning fixes it: it is a label
8
+ * placement problem, not a layout problem.
9
+ *
10
+ * So: walk the candidates in priority order and keep the ones whose rectangle
11
+ * is still free. Density then takes care of itself — zoomed in, few things
12
+ * share the viewport and nearly everything is labelled; zoomed out, collisions
13
+ * thin the labels down to the structure.
14
+ *
15
+ * Node captions and relationship types go through the SAME pass, competing for
16
+ * the same space, because they overlap each other as readily as they overlap
17
+ * their own kind. Captions outrank relationship types: a node's name is what
18
+ * you navigate by, and a `DIRECTED` you cannot read is a smaller loss than a
19
+ * `Metropolis` you cannot read.
20
+ *
21
+ * Pure, and works entirely in SCREEN pixels, so it needs neither the canvas nor
22
+ * the engine and can be tested without either.
23
+ */
24
+ /** Priority order. Lower is placed first and therefore wins its space. */
25
+ export const LabelTier = {
26
+ /**
27
+ * Always drawn, and reserves its space before anything else. Only for what
28
+ * the user has selected or is pointing at — that label is the answer to a
29
+ * question they just asked, and it must not lose to a bigger neighbour.
30
+ */
31
+ Pinned: 0,
32
+ /** A search hit. Ahead of ordinary captions, but still yields to a collision. */
33
+ Match: 1,
34
+ /** An ordinary node caption, ranked among its peers by weight. */
35
+ Caption: 2,
36
+ /** A relationship type. Drawn only with space left over. */
37
+ Edge: 3,
38
+ };
39
+ const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
40
+ const centreX = (rect) => (rect.left + rect.right) / 2;
41
+ const centreY = (rect) => (rect.top + rect.bottom) / 2;
42
+ /**
43
+ * Order is the whole algorithm, so it must be total.
44
+ *
45
+ * Ties broken by id, deliberately: two nodes of equal degree competing for the
46
+ * same space would otherwise swap between frames, and a label that flickers
47
+ * between two names is worse than either name alone.
48
+ */
49
+ function byPriority(a, b) {
50
+ if (a.tier !== b.tier)
51
+ return a.tier - b.tier;
52
+ if (a.weight !== b.weight)
53
+ return b.weight - a.weight;
54
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
55
+ }
56
+ export function placeLabels(candidates, options = {}) {
57
+ const { obstacles = [], viewport, limit = 160, margin = 80 } = options;
58
+ const visible = viewport
59
+ ? candidates.filter((candidate) => {
60
+ const x = centreX(candidate.rect);
61
+ const y = centreY(candidate.rect);
62
+ return (x >= -margin &&
63
+ x <= viewport.width + margin &&
64
+ y >= -margin &&
65
+ y <= viewport.height + margin);
66
+ })
67
+ : candidates.slice();
68
+ const ordered = visible.sort(byPriority).slice(0, limit);
69
+ const placed = new Set();
70
+ const taken = obstacles.map((obstacle) => ({
71
+ id: obstacle.id,
72
+ rect: obstacle.rect,
73
+ }));
74
+ for (const candidate of ordered) {
75
+ const blocked = taken.some((other) => !(other.id !== undefined && candidate.exempt?.includes(other.id)) &&
76
+ overlaps(candidate.rect, other.rect));
77
+ // A pinned label is not negotiable: it is drawn, and it takes its space out
78
+ // of the running for everything after it.
79
+ if (candidate.tier !== LabelTier.Pinned && blocked)
80
+ continue;
81
+ taken.push({ rect: candidate.rect });
82
+ placed.add(candidate.id);
83
+ }
84
+ return placed;
85
+ }
86
+ /** The disc a node occupies — an obstacle no label may print across. */
87
+ export function nodeRect(x, y, radius) {
88
+ return { left: x - radius, right: x + radius, top: y - radius, bottom: y + radius };
89
+ }
90
+ /** Where a node's caption sits: centred under the node. Matches `paintNode`. */
91
+ export function captionRect(x, y, radius, width, { lineHeight = 14, gap = 3, padX = 3 } = {}) {
92
+ const half = width / 2 + padX;
93
+ const top = y + radius + gap;
94
+ return { left: x - half, right: x + half, top, bottom: top + lineHeight };
95
+ }
96
+ /**
97
+ * Where a relationship type sits: at the midpoint of the edge, rotated to
98
+ * follow it.
99
+ *
100
+ * Approximated by the axis-aligned box the rotated text fits inside. That is
101
+ * generous for a diagonal edge, which is the right way to be wrong — it drops a
102
+ * label that would have just fitted rather than printing one that just does
103
+ * not.
104
+ */
105
+ export function edgeLabelRect(x1, y1, x2, y2, width, { lineHeight = 12 } = {}) {
106
+ const midX = (x1 + x2) / 2;
107
+ const midY = (y1 + y2) / 2;
108
+ const angle = Math.atan2(y2 - y1, x2 - x1);
109
+ const cos = Math.abs(Math.cos(angle));
110
+ const sin = Math.abs(Math.sin(angle));
111
+ const halfW = (width * cos + lineHeight * sin) / 2;
112
+ const halfH = (width * sin + lineHeight * cos) / 2;
113
+ return { left: midX - halfW, right: midX + halfW, top: midY - halfH, bottom: midY + halfH };
114
+ }
115
+ /**
116
+ * Label widths, measured once per string.
117
+ *
118
+ * `paintNode` sets the font in GRAPH units (`11 / globalScale`), which is a
119
+ * constant 11 pixels on screen at every zoom — so a label's rendered width
120
+ * never changes and measuring it per frame would be pure waste. Keyed by the
121
+ * string that will actually be drawn (already truncated), so two nodes with the
122
+ * same name measure once.
123
+ */
124
+ export function createCaptionMeasurer(font = "11px ui-sans-serif, system-ui, sans-serif") {
125
+ const widths = new Map();
126
+ let ctx;
127
+ return (text) => {
128
+ const cached = widths.get(text);
129
+ if (cached !== undefined)
130
+ return cached;
131
+ if (ctx === undefined) {
132
+ // Detached: this measures text, it never draws. `getContext` returns null
133
+ // in a jsdom without a canvas implementation, which is why the estimate
134
+ // below exists rather than a crash.
135
+ ctx =
136
+ typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
137
+ if (ctx)
138
+ ctx.font = font;
139
+ }
140
+ // Only ever used where there is no canvas to ask, and a placement that is
141
+ // slightly wrong beats no labels at all.
142
+ const width = ctx ? ctx.measureText(text).width : text.length * 6;
143
+ widths.set(text, width);
144
+ return width;
145
+ };
146
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Canvas painting for {@link GraphView}.
3
+ *
4
+ * Pure functions over a 2D context, deliberately separate from the React
5
+ * component: painting is the part that is easy to get wrong at odd zoom levels
6
+ * and impossible to inspect from a test that only renders JSX.
7
+ *
8
+ * Everything here works in GRAPH coordinates — force-graph has already applied
9
+ * the pan/zoom transform. `globalScale` is the current zoom, needed to keep
10
+ * text and stroke widths a constant size on screen rather than growing with
11
+ * the graph.
12
+ */
13
+ import type { NodeStyle } from "./use-graph-styling.js";
14
+ import type { GraphTheme } from "./theme.js";
15
+ /** A node after force-graph has assigned it a position. */
16
+ export interface PositionedNode {
17
+ id?: string | number;
18
+ x?: number;
19
+ y?: number;
20
+ }
21
+ export interface NodePaintState {
22
+ style: NodeStyle;
23
+ /** Draw the selection ring. */
24
+ selected: boolean;
25
+ /** Draw the hover ring. */
26
+ hovered: boolean;
27
+ /**
28
+ * Dim the node: a search is active and this node is not a match. Applied via
29
+ * `globalAlpha` rather than by rewriting the colour string, so it works for
30
+ * any colour format the host's tokens happen to use (oklch, hsl, hex).
31
+ */
32
+ dimmed: boolean;
33
+ /** The user pinned this node — draw the pin marker. */
34
+ pinned: boolean;
35
+ /**
36
+ * Draw the caption.
37
+ *
38
+ * Decided per node rather than by one global zoom threshold. A single
39
+ * threshold forces a choice between two bad outcomes: label everything and
40
+ * watch dense neighbourhoods turn into overlapping scratch, or label nothing
41
+ * and show an anonymous constellation. Budgeting keeps the graph readable at
42
+ * every zoom — the nodes that carry the structure stay named, the rest
43
+ * appear as you zoom in.
44
+ */
45
+ showCaption: boolean;
46
+ }
47
+ /**
48
+ * Which nodes keep their caption when zoomed out.
49
+ *
50
+ * Ranked by radius, which is already a function of degree — so the budget
51
+ * spends itself on the hubs, the nodes someone is actually orienting by.
52
+ */
53
+ export declare function captionBudget(nodes: readonly {
54
+ id?: string | number;
55
+ }[], radiusOf: (node: {
56
+ id?: string | number;
57
+ }) => number, limit?: number): Set<string>;
58
+ /**
59
+ * At or above this zoom there is room for every caption, so draw them all.
60
+ * Below it, only the nodes in the caption budget get one (see
61
+ * `captionBudget`), plus whatever the user is pointing at or searching for.
62
+ */
63
+ export declare const FULL_CAPTION_SCALE = 0.75;
64
+ export declare function truncateCaption(text: string, max?: number): string;
65
+ /**
66
+ * Paint one node: filled disc, contrast ring, optional selection/hover ring,
67
+ * pin marker, and the caption underneath.
68
+ */
69
+ export declare function paintNode(node: PositionedNode, state: NodePaintState, theme: GraphTheme, ctx: CanvasRenderingContext2D, globalScale: number): void;
70
+ /**
71
+ * Paint the hit-test mask for a node.
72
+ *
73
+ * force-graph renders this to an off-screen colour-indexed buffer to decide
74
+ * what the pointer is over. It must cover the same area the visible node does,
75
+ * or the node's clickable region drifts away from the circle the user sees.
76
+ */
77
+ export declare function paintNodePointerArea(node: PositionedNode, color: string, style: NodeStyle, ctx: CanvasRenderingContext2D): void;
78
+ export interface PositionedLink {
79
+ source?: PositionedNode | string | number;
80
+ target?: PositionedNode | string | number;
81
+ }
82
+ /**
83
+ * Draw the relationship type at the midpoint of an edge.
84
+ *
85
+ * Runs in force-graph's `"after"` link mode, so the default line and arrow are
86
+ * already painted; this only adds the label. Text is rotated to follow the
87
+ * edge and flipped when the edge points leftwards, so labels never read
88
+ * upside-down.
89
+ */
90
+ export declare function paintEdgeLabel(link: PositionedLink, label: string, theme: GraphTheme, ctx: CanvasRenderingContext2D, globalScale: number): void;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Canvas painting for {@link GraphView}.
3
+ *
4
+ * Pure functions over a 2D context, deliberately separate from the React
5
+ * component: painting is the part that is easy to get wrong at odd zoom levels
6
+ * and impossible to inspect from a test that only renders JSX.
7
+ *
8
+ * Everything here works in GRAPH coordinates — force-graph has already applied
9
+ * the pan/zoom transform. `globalScale` is the current zoom, needed to keep
10
+ * text and stroke widths a constant size on screen rather than growing with
11
+ * the graph.
12
+ */
13
+ /**
14
+ * Which nodes keep their caption when zoomed out.
15
+ *
16
+ * Ranked by radius, which is already a function of degree — so the budget
17
+ * spends itself on the hubs, the nodes someone is actually orienting by.
18
+ */
19
+ export function captionBudget(nodes, radiusOf, limit = 14) {
20
+ if (nodes.length <= limit)
21
+ return new Set(nodes.map((node) => String(node.id)));
22
+ return new Set([...nodes]
23
+ .sort((a, b) => radiusOf(b) - radiusOf(a))
24
+ .slice(0, limit)
25
+ .map((node) => String(node.id)));
26
+ }
27
+ /**
28
+ * At or above this zoom there is room for every caption, so draw them all.
29
+ * Below it, only the nodes in the caption budget get one (see
30
+ * `captionBudget`), plus whatever the user is pointing at or searching for.
31
+ */
32
+ export const FULL_CAPTION_SCALE = 0.75;
33
+ const MAX_CAPTION_CHARS = 22;
34
+ export function truncateCaption(text, max = MAX_CAPTION_CHARS) {
35
+ if (text.length <= max)
36
+ return text;
37
+ return `${text.slice(0, max - 1)}…`;
38
+ }
39
+ /**
40
+ * Paint one node: filled disc, contrast ring, optional selection/hover ring,
41
+ * pin marker, and the caption underneath.
42
+ */
43
+ export function paintNode(node, state, theme, ctx, globalScale) {
44
+ const { x, y } = node;
45
+ if (typeof x !== "number" || typeof y !== "number")
46
+ return;
47
+ const { style, selected, hovered, dimmed, pinned, showCaption } = state;
48
+ const r = style.radius;
49
+ ctx.save();
50
+ if (dimmed)
51
+ ctx.globalAlpha = 0.15;
52
+ ctx.beginPath();
53
+ ctx.arc(x, y, r, 0, 2 * Math.PI);
54
+ ctx.fillStyle = style.color;
55
+ ctx.fill();
56
+ // A hairline in the background colour separates overlapping nodes of the
57
+ // same label, which otherwise merge into one blob at low zoom.
58
+ ctx.lineWidth = 0.6 / globalScale;
59
+ ctx.strokeStyle = theme.background;
60
+ ctx.stroke();
61
+ if (selected || hovered) {
62
+ ctx.beginPath();
63
+ ctx.arc(x, y, r + (selected ? 3 : 2), 0, 2 * Math.PI);
64
+ ctx.lineWidth = (selected ? 2 : 1.5) / globalScale;
65
+ ctx.strokeStyle = selected ? theme.ring : theme.mutedForeground;
66
+ ctx.stroke();
67
+ }
68
+ if (pinned) {
69
+ ctx.beginPath();
70
+ ctx.arc(x + r * 0.78, y - r * 0.78, Math.max(1.2, r * 0.28), 0, 2 * Math.PI);
71
+ ctx.fillStyle = theme.foreground;
72
+ ctx.fill();
73
+ }
74
+ if (showCaption && style.caption) {
75
+ // Constant size on screen: dividing by the zoom keeps the label legible
76
+ // when zoomed out and stops it ballooning when zoomed in.
77
+ const fontSize = 11 / globalScale;
78
+ const text = truncateCaption(style.caption);
79
+ ctx.font = `${fontSize}px ui-sans-serif, system-ui, sans-serif`;
80
+ ctx.textAlign = "center";
81
+ ctx.textBaseline = "top";
82
+ // Knock out a pill behind the text. Captions in a force layout overlap
83
+ // each other and the edges constantly; without a backing plate the
84
+ // overlaps turn into unreadable pencil-scratch, and no amount of layout
85
+ // tuning prevents it in a dense neighbourhood.
86
+ const padX = 3 / globalScale;
87
+ const padY = 1.5 / globalScale;
88
+ const width = ctx.measureText(text).width;
89
+ const top = y + r + 3 / globalScale;
90
+ ctx.globalAlpha *= 0.82;
91
+ ctx.fillStyle = theme.background;
92
+ ctx.fillRect(x - width / 2 - padX, top - padY, width + padX * 2, fontSize + padY * 2);
93
+ ctx.globalAlpha = dimmed ? 0.15 : 1;
94
+ ctx.fillStyle = selected || hovered ? theme.foreground : theme.mutedForeground;
95
+ ctx.fillText(text, x, top);
96
+ }
97
+ ctx.restore();
98
+ }
99
+ /**
100
+ * Paint the hit-test mask for a node.
101
+ *
102
+ * force-graph renders this to an off-screen colour-indexed buffer to decide
103
+ * what the pointer is over. It must cover the same area the visible node does,
104
+ * or the node's clickable region drifts away from the circle the user sees.
105
+ */
106
+ export function paintNodePointerArea(node, color, style, ctx) {
107
+ const { x, y } = node;
108
+ if (typeof x !== "number" || typeof y !== "number")
109
+ return;
110
+ ctx.fillStyle = color;
111
+ ctx.beginPath();
112
+ ctx.arc(x, y, style.radius + 3, 0, 2 * Math.PI);
113
+ ctx.fill();
114
+ }
115
+ const asPositioned = (end) => typeof end === "object" && end !== null ? end : null;
116
+ /**
117
+ * How much longer than its label an edge must be before the label is drawn.
118
+ *
119
+ * Room at both ends, so the text stops short of the two nodes instead of
120
+ * running under them.
121
+ */
122
+ const EDGE_LABEL_FIT = 1.6;
123
+ /**
124
+ * Draw the relationship type at the midpoint of an edge.
125
+ *
126
+ * Runs in force-graph's `"after"` link mode, so the default line and arrow are
127
+ * already painted; this only adds the label. Text is rotated to follow the
128
+ * edge and flipped when the edge points leftwards, so labels never read
129
+ * upside-down.
130
+ */
131
+ export function paintEdgeLabel(link, label, theme, ctx, globalScale) {
132
+ if (!label)
133
+ return;
134
+ const source = asPositioned(link.source);
135
+ const target = asPositioned(link.target);
136
+ if (!source || !target)
137
+ return;
138
+ const { x: x1, y: y1 } = source;
139
+ const { x: x2, y: y2 } = target;
140
+ if (typeof x1 !== "number" ||
141
+ typeof y1 !== "number" ||
142
+ typeof x2 !== "number" ||
143
+ typeof y2 !== "number") {
144
+ return;
145
+ }
146
+ const midX = (x1 + x2) / 2;
147
+ const midY = (y1 + y2) / 2;
148
+ let angle = Math.atan2(y2 - y1, x2 - x1);
149
+ if (angle > Math.PI / 2 || angle < -Math.PI / 2)
150
+ angle += Math.PI;
151
+ // Divide by the zoom and DO NOT floor it in graph units. A floor here is a
152
+ // minimum in GRAPH space, which the zoom then multiplies — so on a small
153
+ // graph, where zoom-to-fit lands at a high scale, the label renders enormous.
154
+ const fontSize = 9 / globalScale;
155
+ ctx.save();
156
+ ctx.font = `${fontSize}px ui-sans-serif, system-ui, sans-serif`;
157
+ // Does the label fit ON the edge, as opposed to merely not colliding with
158
+ // anything? Placement decides the second question; this is the first, and it
159
+ // is the one a rectangle cannot answer — a long word across a short edge
160
+ // clears its neighbours and still reads as belonging to neither end.
161
+ const edgeLength = Math.hypot(x2 - x1, y2 - y1);
162
+ if (ctx.measureText(label).width * EDGE_LABEL_FIT > edgeLength) {
163
+ ctx.restore();
164
+ return;
165
+ }
166
+ ctx.translate(midX, midY);
167
+ ctx.rotate(angle);
168
+ ctx.textAlign = "center";
169
+ ctx.textBaseline = "bottom";
170
+ // A halo, not a knockout box.
171
+ //
172
+ // Filling a rectangle behind the label reads as the relationship line being
173
+ // BROKEN: the gap is the same colour as the canvas and sits exactly where
174
+ // the eye is following the line. Stroking the glyphs in the background
175
+ // colour keeps the text readable over the line and over anything crossing
176
+ // it, and the line stays continuous.
177
+ const offset = 1.5 / globalScale;
178
+ ctx.lineWidth = 3 / globalScale;
179
+ ctx.strokeStyle = theme.background;
180
+ ctx.lineJoin = "round";
181
+ ctx.miterLimit = 2;
182
+ ctx.strokeText(label, 0, -offset);
183
+ ctx.fillStyle = theme.mutedForeground;
184
+ ctx.fillText(label, 0, -offset);
185
+ ctx.restore();
186
+ }
@@ -0,0 +1,49 @@
1
+ import { type Ref } from "react";
2
+ import { type GraphNode } from "./model.js";
3
+ import type { GraphModel } from "./use-graph-model.js";
4
+ import type { GraphStyleMap } from "./use-graph-styling.js";
5
+ import type { GraphTheme } from "./theme.js";
6
+ import type { GraphViewState } from "./use-graph-view-state.js";
7
+ /** Imperative controls the host can drive through a ref. */
8
+ export interface GraphCanvasHandle {
9
+ /** Frame the whole graph. Padding is not a parameter: the fit has to
10
+ * account for the details drawer, which only the canvas knows about. */
11
+ zoomToFit: (durationMs?: number) => void;
12
+ /** Bring a node to the centre of the VISIBLE area — see `insetRight`. */
13
+ centerOn: (nodeId: string, durationMs?: number) => void;
14
+ /**
15
+ * Bring a node into view with the SMALLEST pan that does it, and not at all
16
+ * if it is already comfortably visible.
17
+ */
18
+ reveal: (nodeId: string, durationMs?: number) => void;
19
+ /** Re-run the force simulation from a hot state — the "untangle it" action. */
20
+ reheat: () => void;
21
+ }
22
+ export interface GraphCanvasProps {
23
+ model: GraphModel;
24
+ styleMap: GraphStyleMap;
25
+ theme: GraphTheme;
26
+ state: GraphViewState;
27
+ width: number;
28
+ height: number;
29
+ onExpandNode?: (node: GraphNode) => void;
30
+ /**
31
+ * Pixels of the canvas's right edge covered by an overlay (the details
32
+ * drawer). Framing has to know: a node centred under the drawer is a node
33
+ * the user selected and then could not see.
34
+ */
35
+ insetRight?: number;
36
+ handleRef?: Ref<GraphCanvasHandle>;
37
+ /** Skip the settle animation for `prefers-reduced-motion`. */
38
+ reducedMotion?: boolean;
39
+ }
40
+ /**
41
+ * The force-directed canvas.
42
+ *
43
+ * The ONLY module in this package that imports `react-force-graph-2d`, so the
44
+ * engine can be code-split away from the data model and the surrounding chrome
45
+ * (a guard test enforces the boundary). Everything it needs is passed in
46
+ * already computed — it owns no state of its own beyond the engine handle.
47
+ */
48
+ export declare function GraphCanvas({ model, styleMap, theme, state, width, height, onExpandNode, insetRight, handleRef, reducedMotion, }: GraphCanvasProps): import("react/jsx-runtime").JSX.Element;
49
+ export default GraphCanvas;