@ankhorage/zora 20.1.4 → 20.2.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 (74) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.d.ts +13 -0
  4. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.d.ts.map +1 -0
  5. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.js +71 -0
  6. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.js.map +1 -0
  7. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.d.ts +87 -0
  8. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.d.ts.map +1 -0
  9. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js +132 -0
  10. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js.map +1 -0
  11. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.d.ts +7 -0
  12. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.d.ts.map +1 -0
  13. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.js +59 -0
  14. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.js.map +1 -0
  15. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts +7 -0
  16. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts.map +1 -0
  17. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js +32 -0
  18. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js.map +1 -0
  19. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.d.ts +22 -0
  20. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.d.ts.map +1 -0
  21. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.js +41 -0
  22. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.js.map +1 -0
  23. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.d.ts +30 -0
  24. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.d.ts.map +1 -0
  25. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +222 -0
  26. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -0
  27. package/dist/features/graph-view/adapters/inbound/web-artifact/createRenderableGraphElements.d.ts +5 -0
  28. package/dist/features/graph-view/adapters/inbound/web-artifact/createRenderableGraphElements.d.ts.map +1 -0
  29. package/dist/features/graph-view/adapters/inbound/web-artifact/createRenderableGraphElements.js +51 -0
  30. package/dist/features/graph-view/adapters/inbound/web-artifact/createRenderableGraphElements.js.map +1 -0
  31. package/dist/features/graph-view/adapters/inbound/web-artifact/fitGraphViewport.d.ts +5 -0
  32. package/dist/features/graph-view/adapters/inbound/web-artifact/fitGraphViewport.d.ts.map +1 -0
  33. package/dist/features/graph-view/adapters/inbound/web-artifact/fitGraphViewport.js +18 -0
  34. package/dist/features/graph-view/adapters/inbound/web-artifact/fitGraphViewport.js.map +1 -0
  35. package/dist/features/graph-view/adapters/inbound/web-artifact/readGraphRenderedNodes.d.ts +5 -0
  36. package/dist/features/graph-view/adapters/inbound/web-artifact/readGraphRenderedNodes.d.ts.map +1 -0
  37. package/dist/features/graph-view/adapters/inbound/web-artifact/readGraphRenderedNodes.js +14 -0
  38. package/dist/features/graph-view/adapters/inbound/web-artifact/readGraphRenderedNodes.js.map +1 -0
  39. package/dist/features/graph-view/adapters/inbound/web-artifact/runGraphLayout.d.ts +14 -0
  40. package/dist/features/graph-view/adapters/inbound/web-artifact/runGraphLayout.d.ts.map +1 -0
  41. package/dist/features/graph-view/adapters/inbound/web-artifact/runGraphLayout.js +34 -0
  42. package/dist/features/graph-view/adapters/inbound/web-artifact/runGraphLayout.js.map +1 -0
  43. package/dist/features/graph-view/adapters/inbound/web-artifact/scheduleGraphFrame.d.ts +3 -0
  44. package/dist/features/graph-view/adapters/inbound/web-artifact/scheduleGraphFrame.d.ts.map +1 -0
  45. package/dist/features/graph-view/adapters/inbound/web-artifact/scheduleGraphFrame.js +10 -0
  46. package/dist/features/graph-view/adapters/inbound/web-artifact/scheduleGraphFrame.js.map +1 -0
  47. package/dist/features/graph-view/adapters/inbound/web-artifact/syncGraphElements.d.ts +5 -0
  48. package/dist/features/graph-view/adapters/inbound/web-artifact/syncGraphElements.d.ts.map +1 -0
  49. package/dist/features/graph-view/adapters/inbound/web-artifact/syncGraphElements.js +12 -0
  50. package/dist/features/graph-view/adapters/inbound/web-artifact/syncGraphElements.js.map +1 -0
  51. package/dist/features/web-component-artifact/application/use-cases/materializeWebComponentArtifactAsync.d.ts.map +1 -1
  52. package/dist/features/web-component-artifact/application/use-cases/materializeWebComponentArtifactAsync.js +25 -10
  53. package/dist/features/web-component-artifact/application/use-cases/materializeWebComponentArtifactAsync.js.map +1 -1
  54. package/package.json +6 -4
  55. package/src/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.tsx +142 -0
  56. package/src/features/graph-view/adapters/inbound/web-artifact/GraphView.tsx +293 -0
  57. package/src/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.ts +102 -0
  58. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.ts +38 -0
  59. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.ts +80 -0
  60. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +301 -0
  61. package/src/features/graph-view/adapters/inbound/web-artifact/createRenderableGraphElements.test.ts +49 -0
  62. package/src/features/graph-view/adapters/inbound/web-artifact/createRenderableGraphElements.ts +72 -0
  63. package/src/features/graph-view/adapters/inbound/web-artifact/fitGraphViewport.ts +20 -0
  64. package/src/features/graph-view/adapters/inbound/web-artifact/readGraphRenderedNodes.ts +20 -0
  65. package/src/features/graph-view/adapters/inbound/web-artifact/runGraphLayout.ts +53 -0
  66. package/src/features/graph-view/adapters/inbound/web-artifact/scheduleGraphFrame.ts +13 -0
  67. package/src/features/graph-view/adapters/inbound/web-artifact/syncGraphElements.ts +19 -0
  68. package/src/features/web-component-artifact/application/use-cases/materializeWebComponentArtifactAsync.test.ts +47 -31
  69. package/src/features/web-component-artifact/application/use-cases/materializeWebComponentArtifactAsync.ts +47 -11
  70. package/src/types/cytoscape-elk.d.ts +4 -0
  71. package/web-dist/graph-view/GraphView.d.ts +87 -0
  72. package/web-dist/graph-view/GraphView.js +123308 -0
  73. package/web-dist/graph-view/artifact.json +7 -0
  74. package/web-dist/tree-view/artifact.json +7 -0
@@ -0,0 +1,80 @@
1
+ import type { Core } from 'cytoscape';
2
+
3
+ import { fitGraphViewport } from './fitGraphViewport';
4
+ import { scheduleGraphFrame } from './scheduleGraphFrame';
5
+
6
+ interface CreateGraphResizeObserverInput {
7
+ readonly container: unknown;
8
+ readonly cy: Core;
9
+ readonly fitPaddingRef: { current: number };
10
+ readonly layoutRunningRef: { current: boolean };
11
+ readonly readyRef: { current: boolean };
12
+ readonly onViewportSettled: () => void;
13
+ }
14
+
15
+ export interface GraphResizeObserver {
16
+ disconnect(): void;
17
+ }
18
+
19
+ /*** Observe settled container resizing without racing an active layout generation. */
20
+ export function createGraphResizeObserver(
21
+ input: CreateGraphResizeObserverInput,
22
+ ): GraphResizeObserver | null {
23
+ const Observer = readResizeObserverConstructor();
24
+ if (Observer === null) return null;
25
+ const sizeRef = { height: -1, width: -1 };
26
+
27
+ const observer = new Observer((entries) => {
28
+ const entry = entries.at(0);
29
+ if (entry === undefined || !hasSizeChanged(sizeRef, entry.contentRect)) return;
30
+ sizeRef.height = entry.contentRect.height;
31
+ sizeRef.width = entry.contentRect.width;
32
+ if (!input.readyRef.current || input.layoutRunningRef.current) return;
33
+ scheduleResizeFit(input);
34
+ });
35
+ observer.observe(input.container);
36
+ return observer;
37
+ }
38
+
39
+ /*** Fit one genuine resize only after the active layout has settled. */
40
+ function scheduleResizeFit(input: CreateGraphResizeObserverInput) {
41
+ scheduleGraphFrame(() => {
42
+ if (input.cy.destroyed() || input.layoutRunningRef.current) return;
43
+ input.cy.resize();
44
+ fitGraphViewport(input.cy, { padding: input.fitPaddingRef.current });
45
+ input.onViewportSettled();
46
+ });
47
+ }
48
+
49
+ /*** Return whether ResizeObserver reported a genuinely different container size. */
50
+ function hasSizeChanged(
51
+ previous: { readonly height: number; readonly width: number },
52
+ next: { readonly height: number; readonly width: number },
53
+ ): boolean {
54
+ return previous.height !== next.height || previous.width !== next.width;
55
+ }
56
+
57
+ /*** Read ResizeObserver without imposing DOM library types on the package target. */
58
+ function readResizeObserverConstructor(): ResizeObserverConstructorLike | null {
59
+ const value = (
60
+ globalThis as unknown as {
61
+ ResizeObserver?: ResizeObserverConstructorLike;
62
+ }
63
+ ).ResizeObserver;
64
+ return value ?? null;
65
+ }
66
+
67
+ interface ResizeObserverEntryLike {
68
+ readonly contentRect: {
69
+ readonly height: number;
70
+ readonly width: number;
71
+ };
72
+ }
73
+
74
+ interface ResizeObserverLike extends GraphResizeObserver {
75
+ observe(target: unknown): void;
76
+ }
77
+
78
+ type ResizeObserverConstructorLike = new (
79
+ callback: (entries: readonly ResizeObserverEntryLike[]) => void,
80
+ ) => ResizeObserverLike;
@@ -0,0 +1,301 @@
1
+ import cytoscape, { type Core, type CytoscapeOptions } from 'cytoscape';
2
+ import elk from 'cytoscape-elk';
3
+
4
+ import { bindGraphEvents } from './bindGraphEvents';
5
+ import { createGraphController } from './createGraphController';
6
+ import { createGraphResizeObserver, type GraphResizeObserver } from './createGraphResizeObserver';
7
+ import { fitGraphViewport } from './fitGraphViewport';
8
+ import type {
9
+ GraphViewCallbacks,
10
+ GraphViewEdge,
11
+ GraphViewElementEventType,
12
+ GraphViewLayoutName,
13
+ GraphViewNode,
14
+ GraphViewRenderedNode,
15
+ GraphViewSize,
16
+ GraphViewStyleRule,
17
+ } from './GraphView';
18
+ import { readGraphRenderedNodes } from './readGraphRenderedNodes';
19
+ import { runGraphLayout } from './runGraphLayout';
20
+ import { scheduleGraphFrame } from './scheduleGraphFrame';
21
+ import { syncGraphElements } from './syncGraphElements';
22
+
23
+ cytoscape.use(elk as cytoscape.Ext);
24
+
25
+ type GraphContainer = NonNullable<CytoscapeOptions['container']>;
26
+ type RenderedNodeListener = (nodes: readonly GraphViewRenderedNode[]) => void;
27
+
28
+ interface GraphRuntimeUpdate {
29
+ readonly edges: readonly GraphViewEdge[];
30
+ readonly fitPadding?: number;
31
+ readonly layout?: GraphViewLayoutName;
32
+ readonly layoutOptions?: Readonly<Record<string, unknown>>;
33
+ readonly maxZoom?: number;
34
+ readonly minZoom?: number;
35
+ readonly nodes: readonly GraphViewNode[];
36
+ readonly richNodeRendering: boolean;
37
+ readonly spacingFactor?: number;
38
+ readonly styleRules?: readonly GraphViewStyleRule[];
39
+ }
40
+
41
+ export interface GraphRuntime {
42
+ destroy(): void;
43
+ handleOverlayNodeEvent(id: string, type: GraphViewElementEventType): void;
44
+ setNodeSize(id: string, size: GraphViewSize): void;
45
+ setSelectedNodeIds(nodeIds: readonly string[] | undefined): void;
46
+ subscribeRenderedNodes(listener: RenderedNodeListener): () => void;
47
+ update(input: GraphRuntimeUpdate): void;
48
+ }
49
+
50
+ interface GraphRuntimeState {
51
+ readonly callbacksRef: { current: GraphViewCallbacks };
52
+ readonly controller: ReturnType<typeof createGraphController>;
53
+ readonly cy: Core;
54
+ readonly fitPaddingRef: { current: number };
55
+ readonly generationRef: { current: number };
56
+ readonly hoveredNodeIds: Set<string>;
57
+ readonly latestUpdateRef: { current: GraphRuntimeUpdate | null };
58
+ readonly layoutRef: { current: ReturnType<typeof runGraphLayout> | null };
59
+ readonly layoutRunningRef: { current: boolean };
60
+ readonly nodeSizes: Map<string, GraphViewSize>;
61
+ readonly readyRef: { current: boolean };
62
+ readonly relayoutScheduledRef: { current: boolean };
63
+ readonly renderedNodeListeners: Set<RenderedNodeListener>;
64
+ readonly resizeObserver: GraphResizeObserver | null;
65
+ readonly unbindEvents: () => void;
66
+ }
67
+
68
+ /*** Create one Cytoscape runtime that exclusively owns layout, fit, resize, and disposal. */
69
+ export function createGraphRuntime(
70
+ container: GraphContainer,
71
+ callbacksRef: { current: GraphViewCallbacks },
72
+ ): GraphRuntime {
73
+ const cy = cytoscape({
74
+ container,
75
+ elements: [],
76
+ minZoom: 0.05,
77
+ maxZoom: 2,
78
+ selectionType: 'additive',
79
+ userPanningEnabled: true,
80
+ });
81
+ const state = createRuntimeState(cy, container, callbacksRef);
82
+
83
+ return {
84
+ destroy: () => destroyRuntime(state),
85
+ handleOverlayNodeEvent: (id, type) => handleOverlayNodeEvent(state, id, type),
86
+ setNodeSize: (id, size) => setNodeSize(state, id, size),
87
+ setSelectedNodeIds: (nodeIds) => setSelectedNodeIds(state, nodeIds),
88
+ subscribeRenderedNodes: (listener) => subscribeRenderedNodes(state, listener),
89
+ update: (input) => updateRuntime(state, input),
90
+ };
91
+ }
92
+
93
+ /*** Build the mutable state shared by the single graph-runtime owner. */
94
+ function createRuntimeState(
95
+ cy: Core,
96
+ container: GraphContainer,
97
+ callbacksRef: { current: GraphViewCallbacks },
98
+ ): GraphRuntimeState {
99
+ const fitPaddingRef = { current: 50 };
100
+ const controller = createGraphController(cy, fitPaddingRef);
101
+ const layoutRunningRef = { current: false };
102
+ const readyRef = { current: false };
103
+ const renderedNodeListeners = new Set<RenderedNodeListener>();
104
+ const stateBase = {
105
+ callbacksRef,
106
+ controller,
107
+ cy,
108
+ fitPaddingRef,
109
+ generationRef: { current: 0 },
110
+ hoveredNodeIds: new Set<string>(),
111
+ latestUpdateRef: { current: null as GraphRuntimeUpdate | null },
112
+ layoutRef: { current: null as ReturnType<typeof runGraphLayout> | null },
113
+ layoutRunningRef,
114
+ nodeSizes: new Map<string, GraphViewSize>(),
115
+ readyRef,
116
+ relayoutScheduledRef: { current: false },
117
+ renderedNodeListeners,
118
+ };
119
+ const unbindEvents = bindGraphEvents(cy, callbacksRef, controller, () =>
120
+ emitRenderedNodes(stateBase),
121
+ );
122
+ const resizeObserver = createGraphResizeObserver({
123
+ container,
124
+ cy,
125
+ fitPaddingRef,
126
+ layoutRunningRef,
127
+ onViewportSettled: () => emitRenderedNodes(stateBase),
128
+ readyRef,
129
+ });
130
+
131
+ return { ...stateBase, resizeObserver, unbindEvents };
132
+ }
133
+
134
+ /*** Apply one graph update and start exactly one new layout generation. */
135
+ function updateRuntime(state: GraphRuntimeState, input: GraphRuntimeUpdate) {
136
+ if (state.cy.destroyed()) return;
137
+ stopCurrentLayout(state);
138
+ state.latestUpdateRef.current = input;
139
+ state.fitPaddingRef.current = input.fitPadding ?? 50;
140
+ state.cy.minZoom(input.minZoom ?? 0.05);
141
+ state.cy.maxZoom(input.maxZoom ?? 2);
142
+ syncGraphElements(state.cy, input.nodes, input.edges);
143
+ applyGraphStyles(state.cy, input.styleRules, input.richNodeRendering);
144
+ applyKnownNodeSizes(state);
145
+ startCurrentLayout(state, input);
146
+ }
147
+
148
+ /*** Start a generation-safe layout whose completion is the only normal automatic fit trigger. */
149
+ function startCurrentLayout(state: GraphRuntimeState, input: GraphRuntimeUpdate) {
150
+ const generation = state.generationRef.current + 1;
151
+ state.generationRef.current = generation;
152
+ state.layoutRunningRef.current = true;
153
+ state.cy.resize();
154
+ state.layoutRef.current = runGraphLayout(
155
+ state.cy,
156
+ {
157
+ layout: input.layout ?? 'concentric',
158
+ layoutOptions: input.layoutOptions,
159
+ spacingFactor: input.spacingFactor ?? 1,
160
+ },
161
+ () => completeCurrentLayout(state, generation),
162
+ );
163
+ }
164
+
165
+ /*** Settle only the latest layout generation and fit once from node bounds. */
166
+ function completeCurrentLayout(state: GraphRuntimeState, generation: number) {
167
+ state.layoutRef.current = null;
168
+ scheduleGraphFrame(() => {
169
+ if (state.cy.destroyed() || generation !== state.generationRef.current) return;
170
+ state.cy.resize();
171
+ state.layoutRunningRef.current = false;
172
+ if (!hasUsableViewport(state.cy)) return;
173
+ fitGraphViewport(state.cy, { padding: state.fitPaddingRef.current });
174
+ emitRenderedNodes(state);
175
+
176
+ if (!state.readyRef.current) {
177
+ state.readyRef.current = true;
178
+ state.callbacksRef.current.onReady?.(state.controller);
179
+ }
180
+ state.callbacksRef.current.onLayoutComplete?.(state.controller);
181
+ });
182
+ }
183
+
184
+ /*** Stop the current layout and invalidate every stale completion callback. */
185
+ function stopCurrentLayout(state: GraphRuntimeState) {
186
+ state.generationRef.current += 1;
187
+ state.layoutRunningRef.current = false;
188
+ state.layoutRef.current?.stop();
189
+ state.layoutRef.current = null;
190
+ }
191
+
192
+ /*** Apply consumer styles and hide native node paint only when React rich nodes are active. */
193
+ function applyGraphStyles(
194
+ cy: Core,
195
+ rules: readonly GraphViewStyleRule[] | undefined,
196
+ richNodeRendering: boolean,
197
+ ) {
198
+ const styles = [...(rules ?? [])];
199
+ if (richNodeRendering) {
200
+ styles.push({
201
+ selector: 'node',
202
+ style: {
203
+ 'background-opacity': 0,
204
+ 'border-width': 0,
205
+ label: '',
206
+ 'overlay-opacity': 0,
207
+ },
208
+ });
209
+ }
210
+ if (styles.length > 0) cy.style(styles).update();
211
+ }
212
+
213
+ /*** Synchronize controlled selection without rerunning layout. */
214
+ function setSelectedNodeIds(state: GraphRuntimeState, nodeIds: readonly string[] | undefined) {
215
+ if (state.cy.destroyed() || nodeIds === undefined) return;
216
+ const selectedIds = new Set(nodeIds);
217
+ state.cy.nodes().forEach((node) => {
218
+ if (selectedIds.has(node.id())) node.select();
219
+ else node.unselect();
220
+ });
221
+ emitRenderedNodes(state);
222
+ }
223
+
224
+ /*** Persist one rich-node size and schedule at most one relayout for changed geometry. */
225
+ function setNodeSize(state: GraphRuntimeState, id: string, size: GraphViewSize) {
226
+ const previous = state.nodeSizes.get(id);
227
+ if (previous?.width === size.width && previous.height === size.height) return;
228
+ state.nodeSizes.set(id, size);
229
+ applyNodeSize(state.cy, id, size);
230
+ scheduleMeasuredNodeRelayout(state);
231
+ }
232
+
233
+ /*** Apply every known rich-node size after Cytoscape elements are replaced. */
234
+ function applyKnownNodeSizes(state: GraphRuntimeState) {
235
+ for (const [id, size] of state.nodeSizes) applyNodeSize(state.cy, id, size);
236
+ }
237
+
238
+ /*** Apply one measured model-space size as a Cytoscape per-node style bypass. */
239
+ function applyNodeSize(cy: Core, id: string, size: GraphViewSize) {
240
+ const node = cy.getElementById(id);
241
+ if (node.empty()) return;
242
+ node.style({ height: size.height, width: size.width });
243
+ }
244
+
245
+ /*** Coalesce DOM measurements into one follow-up layout generation. */
246
+ function scheduleMeasuredNodeRelayout(state: GraphRuntimeState) {
247
+ if (state.relayoutScheduledRef.current) return;
248
+ state.relayoutScheduledRef.current = true;
249
+ scheduleGraphFrame(() => {
250
+ state.relayoutScheduledRef.current = false;
251
+ const input = state.latestUpdateRef.current;
252
+ if (input === null || state.cy.destroyed()) return;
253
+ stopCurrentLayout(state);
254
+ startCurrentLayout(state, input);
255
+ });
256
+ }
257
+
258
+ /*** Subscribe one React overlay to rendered node state without exposing the Cytoscape core. */
259
+ function subscribeRenderedNodes(state: GraphRuntimeState, listener: RenderedNodeListener) {
260
+ state.renderedNodeListeners.add(listener);
261
+ listener(readGraphRenderedNodes(state.cy, state.hoveredNodeIds));
262
+ return () => {
263
+ state.renderedNodeListeners.delete(listener);
264
+ };
265
+ }
266
+
267
+ /*** Publish current rendered node positions only to active rich-node subscribers. */
268
+ function emitRenderedNodes(
269
+ state: Pick<GraphRuntimeState, 'cy' | 'hoveredNodeIds' | 'renderedNodeListeners'>,
270
+ ) {
271
+ if (state.renderedNodeListeners.size === 0) return;
272
+ const nodes = readGraphRenderedNodes(state.cy, state.hoveredNodeIds);
273
+ for (const listener of state.renderedNodeListeners) listener(nodes);
274
+ }
275
+
276
+ /*** Translate one rich-overlay event back into normal graph selection and callbacks. */
277
+ function handleOverlayNodeEvent(
278
+ state: GraphRuntimeState,
279
+ id: string,
280
+ type: GraphViewElementEventType,
281
+ ) {
282
+ if (type === 'pointer-enter') state.hoveredNodeIds.add(id);
283
+ if (type === 'pointer-leave') state.hoveredNodeIds.delete(id);
284
+ if (type === 'press') state.cy.getElementById(id).select();
285
+ state.callbacksRef.current.onNodeEvent?.({ id, type });
286
+ emitRenderedNodes(state);
287
+ }
288
+
289
+ /*** Return whether Cytoscape currently has dimensions suitable for fitting. */
290
+ function hasUsableViewport(cy: Core): boolean {
291
+ return cy.width() > 0 && cy.height() > 0;
292
+ }
293
+
294
+ /*** Dispose all runtime resources in the reverse order they were acquired. */
295
+ function destroyRuntime(state: GraphRuntimeState) {
296
+ state.resizeObserver?.disconnect();
297
+ stopCurrentLayout(state);
298
+ state.unbindEvents();
299
+ state.renderedNodeListeners.clear();
300
+ if (!state.cy.destroyed()) state.cy.destroy();
301
+ }
@@ -0,0 +1,49 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ import { createRenderableGraphElements } from './createRenderableGraphElements';
4
+ import type { GraphViewEdge, GraphViewNode } from './GraphView';
5
+
6
+ test('omits compound ancestor and descendant edges without mutating source graph data', () => {
7
+ const nodes: readonly GraphViewNode[] = [
8
+ { id: 'projectadministration' },
9
+ { id: 'projectadministration.domain', parentId: 'projectadministration' },
10
+ { id: 'other' },
11
+ ];
12
+ const edges: readonly GraphViewEdge[] = [
13
+ {
14
+ id: 'invalid',
15
+ source: 'projectadministration',
16
+ target: 'projectadministration.domain',
17
+ },
18
+ {
19
+ id: 'valid',
20
+ source: 'projectadministration.domain',
21
+ target: 'other',
22
+ },
23
+ ];
24
+
25
+ const elements = createRenderableGraphElements(nodes, edges);
26
+ const renderedEdgeIds = elements
27
+ .filter((element) => element.group === 'edges')
28
+ .map((element) => element.data.id);
29
+
30
+ expect(renderedEdgeIds).toEqual(['valid']);
31
+ expect(edges).toHaveLength(2);
32
+ });
33
+
34
+ test('keeps ordinary edges across unrelated compound branches', () => {
35
+ const nodes: readonly GraphViewNode[] = [
36
+ { id: 'a' },
37
+ { id: 'a.child', parentId: 'a' },
38
+ { id: 'b' },
39
+ { id: 'b.child', parentId: 'b' },
40
+ ];
41
+ const edges: readonly GraphViewEdge[] = [{ id: 'cross', source: 'a.child', target: 'b.child' }];
42
+
43
+ const elements = createRenderableGraphElements(nodes, edges);
44
+ const renderedEdgeIds = elements
45
+ .filter((element) => element.group === 'edges')
46
+ .map((element) => element.data.id);
47
+
48
+ expect(renderedEdgeIds).toEqual(['cross']);
49
+ });
@@ -0,0 +1,72 @@
1
+ import type { ElementDefinition } from 'cytoscape';
2
+
3
+ import type { GraphViewEdge, GraphViewNode } from './GraphView';
4
+
5
+ /*** Convert plain graph data into renderable Cytoscape elements without impossible compound edges. */
6
+ export function createRenderableGraphElements(
7
+ nodes: readonly GraphViewNode[],
8
+ edges: readonly GraphViewEdge[],
9
+ ): ElementDefinition[] {
10
+ const nodeIds = new Set(nodes.map((node) => node.id));
11
+ const parentById = new Map(
12
+ nodes.flatMap((node) => (node.parentId ? [[node.id, node.parentId] as const] : [])),
13
+ );
14
+ const nodeElements: ElementDefinition[] = nodes.map((node) => ({
15
+ group: 'nodes',
16
+ classes: node.classes,
17
+ data: {
18
+ ...(node.data ?? {}),
19
+ id: node.id,
20
+ label: node.label ?? node.id,
21
+ ...(node.parentId ? { parent: node.parentId } : {}),
22
+ },
23
+ }));
24
+ const edgeElements = edges.flatMap((edge, index) =>
25
+ createEdgeElement(edge, index, nodeIds, parentById),
26
+ );
27
+
28
+ return [...nodeElements, ...edgeElements];
29
+ }
30
+
31
+ /*** Build one edge unless one endpoint owns the other in the compound hierarchy. */
32
+ function createEdgeElement(
33
+ edge: GraphViewEdge,
34
+ index: number,
35
+ nodeIds: ReadonlySet<string>,
36
+ parentById: ReadonlyMap<string, string>,
37
+ ): ElementDefinition[] {
38
+ if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return [];
39
+ if (isAncestor(edge.source, edge.target, parentById)) return [];
40
+ if (isAncestor(edge.target, edge.source, parentById)) return [];
41
+
42
+ return [
43
+ {
44
+ group: 'edges',
45
+ classes: edge.classes,
46
+ data: {
47
+ ...(edge.data ?? {}),
48
+ id: edge.id ?? `${edge.source}->${edge.target}:${index}`,
49
+ source: edge.source,
50
+ target: edge.target,
51
+ },
52
+ },
53
+ ];
54
+ }
55
+
56
+ /*** Return whether a node appears in another node's compound parent chain. */
57
+ function isAncestor(
58
+ possibleAncestor: string,
59
+ nodeId: string,
60
+ parentById: ReadonlyMap<string, string>,
61
+ ): boolean {
62
+ const visited = new Set<string>();
63
+ let current = parentById.get(nodeId);
64
+
65
+ while (current !== undefined && !visited.has(current)) {
66
+ if (current === possibleAncestor) return true;
67
+ visited.add(current);
68
+ current = parentById.get(current);
69
+ }
70
+
71
+ return false;
72
+ }
@@ -0,0 +1,20 @@
1
+ import type { Core } from 'cytoscape';
2
+
3
+ import type { GraphViewFitOptions } from './GraphView';
4
+
5
+ /*** Fit the requested visible nodes and never include edge geometry in viewport bounds. */
6
+ export function fitGraphViewport(cy: Core, options: GraphViewFitOptions = {}): boolean {
7
+ if (cy.destroyed()) return false;
8
+ const nodes = getFitNodes(cy, options.nodeIds);
9
+ if (nodes.empty()) return false;
10
+
11
+ cy.fit(nodes, options.padding ?? 50);
12
+ return true;
13
+ }
14
+
15
+ /*** Resolve an optional node-id target without ever adding edges to the fit collection. */
16
+ function getFitNodes(cy: Core, nodeIds: readonly string[] | undefined) {
17
+ if (nodeIds === undefined || nodeIds.length === 0) return cy.nodes();
18
+ const requestedIds = new Set(nodeIds);
19
+ return cy.nodes().filter((node) => requestedIds.has(node.id()));
20
+ }
@@ -0,0 +1,20 @@
1
+ import type { Core } from 'cytoscape';
2
+
3
+ import type { GraphViewRenderedNode } from './GraphView';
4
+
5
+ /*** Read React-overlay node state from Cytoscape's authoritative rendered positions. */
6
+ export function readGraphRenderedNodes(
7
+ cy: Core,
8
+ hoveredNodeIds: ReadonlySet<string>,
9
+ ): readonly GraphViewRenderedNode[] {
10
+ if (cy.destroyed()) return [];
11
+ const zoom = cy.zoom();
12
+
13
+ return cy.nodes().map((node) => ({
14
+ hovered: hoveredNodeIds.has(node.id()),
15
+ id: node.id(),
16
+ position: node.renderedPosition(),
17
+ selected: node.selected(),
18
+ zoom,
19
+ }));
20
+ }
@@ -0,0 +1,53 @@
1
+ import type { Core, LayoutOptions, Layouts } from 'cytoscape';
2
+
3
+ import type { GraphViewLayoutName } from './GraphView';
4
+
5
+ interface RunGraphLayoutInput {
6
+ readonly layout: GraphViewLayoutName;
7
+ readonly layoutOptions?: Readonly<Record<string, unknown>>;
8
+ readonly spacingFactor: number;
9
+ }
10
+
11
+ interface GraphLayoutSession {
12
+ stop(): void;
13
+ }
14
+
15
+ /*** Run one Cytoscape layout with automatic fitting disabled and explicit stop ownership. */
16
+ export function runGraphLayout(
17
+ cy: Core,
18
+ input: RunGraphLayoutInput,
19
+ onComplete: () => void,
20
+ ): GraphLayoutSession {
21
+ const layout = cy.layout(createLayoutOptions(input));
22
+ const handleStop = () => onComplete();
23
+ cy.one('layoutstop', handleStop);
24
+ layout.run();
25
+
26
+ return {
27
+ stop() {
28
+ stopGraphLayout(cy, layout, handleStop);
29
+ },
30
+ };
31
+ }
32
+
33
+ /*** Build layout options while reserving fit ownership for the graph runtime. */
34
+ function createLayoutOptions(input: RunGraphLayoutInput): LayoutOptions {
35
+ return {
36
+ ...(input.layoutOptions ?? {}),
37
+ name: input.layout,
38
+ spacingFactor: input.spacingFactor,
39
+ nodeDimensionsIncludeLabels: true,
40
+ fit: false,
41
+ animate: false,
42
+ };
43
+ }
44
+
45
+ /*** Stop one layout without allowing its stale layout-stop callback to escape. */
46
+ function stopGraphLayout(cy: Core, layout: Layouts, handleStop: () => void) {
47
+ cy.off('layoutstop', handleStop);
48
+ try {
49
+ layout.stop();
50
+ } catch {
51
+ // Cytoscape may already have disposed a superseded layout.
52
+ }
53
+ }
@@ -0,0 +1,13 @@
1
+ /*** Schedule one graph-runtime callback after browser paint with a non-browser fallback. */
2
+ export function scheduleGraphFrame(callback: () => void) {
3
+ const frame = (
4
+ globalThis as unknown as {
5
+ requestAnimationFrame?: (scheduled: () => void) => number;
6
+ }
7
+ ).requestAnimationFrame;
8
+ if (frame) {
9
+ frame(callback);
10
+ return;
11
+ }
12
+ setTimeout(callback, 0);
13
+ }
@@ -0,0 +1,19 @@
1
+ import type { Core } from 'cytoscape';
2
+
3
+ import { createRenderableGraphElements } from './createRenderableGraphElements';
4
+ import type { GraphViewEdge, GraphViewNode } from './GraphView';
5
+
6
+ /*** Replace Cytoscape elements from the current plain graph projection in one batch. */
7
+ export function syncGraphElements(
8
+ cy: Core,
9
+ nodes: readonly GraphViewNode[],
10
+ edges: readonly GraphViewEdge[],
11
+ ) {
12
+ if (cy.destroyed()) return;
13
+
14
+ const elements = createRenderableGraphElements(nodes, edges);
15
+ cy.batch(() => {
16
+ cy.elements().remove();
17
+ cy.add(elements);
18
+ });
19
+ }