An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
1<script lang="ts">
2 /**
3 * CytoscapeGraph wrapper component.
4 * A reusable Svelte 5 wrapper around Cytoscape.js with dagre layout support.
5 * Handles initialization, reactive updates, cleanup, and exposes methods for external control.
6 */
7
8 import { onMount } from 'svelte';
9 import cytoscape from 'cytoscape';
10 import type { ElementDefinition, Stylesheet } from 'cytoscape';
11 import dagre from 'cytoscape-dagre';
12
13 // Register the dagre layout extension at module level, once
14 cytoscape.use(dagre);
15
16 type Props = {
17 elements: Array<ElementDefinition>;
18 stylesheet: Array<Stylesheet>;
19 layoutOptions?: Record<string, unknown>;
20 onNodeClick?: (nodeId: string) => void;
21 onBackgroundClick?: () => void;
22 };
23
24 let {
25 elements,
26 stylesheet,
27 layoutOptions = { name: 'dagre', rankDir: 'TB', spacingFactor: 1.5 },
28 onNodeClick,
29 onBackgroundClick,
30 }: Props = $props();
31
32 let containerDiv: HTMLDivElement | undefined = $state();
33 let cyInstance: cytoscape.Core | null = $state(null);
34 let initialized = $state(false);
35
36 onMount(() => {
37 if (!containerDiv) return;
38
39 // Initialize cytoscape instance
40 cyInstance = cytoscape({
41 container: containerDiv,
42 elements: elements,
43 style: stylesheet,
44 layout: layoutOptions as cytoscape.LayoutOptions,
45 pixelRatio: 1,
46 wheelSensitivity: 0.2,
47 });
48
49 // Register event handlers
50 cyInstance.on('tap', 'node', (event: cytoscape.EventObject) => {
51 const nodeId = event.target.id();
52 onNodeClick?.(nodeId);
53 });
54
55 cyInstance.on('tap', (event: cytoscape.EventObject) => {
56 // Only trigger background click if target is the background (not a node/edge)
57 if (event.target === cyInstance) {
58 onBackgroundClick?.();
59 }
60 });
61
62 initialized = true;
63
64 // Return cleanup function
65 return () => {
66 if (cyInstance) {
67 cyInstance.destroy();
68 cyInstance = null;
69 }
70 };
71 });
72
73 // Reactive updates when elements change
74 $effect(() => {
75 if (!cyInstance || !initialized) return;
76
77 cyInstance.batch(() => {
78 cyInstance!.elements().remove();
79 cyInstance!.add(elements);
80 });
81
82 // Re-run layout after adding elements
83 const layout = cyInstance.layout(layoutOptions as cytoscape.LayoutOptions);
84 layout.run();
85 });
86
87 // Reactive updates when stylesheet changes
88 $effect(() => {
89 if (!cyInstance) return;
90 cyInstance.style(stylesheet);
91 });
92
93 /**
94 * Fit the entire graph to the viewport with padding.
95 */
96 export function fitView(): void {
97 if (cyInstance) {
98 cyInstance.fit(undefined, 20);
99 }
100 }
101
102 /**
103 * Get the underlying Cytoscape instance for advanced use cases.
104 */
105 export function getInstance(): cytoscape.Core | null {
106 return cyInstance;
107 }
108</script>
109
110<div class="cytoscape-container" bind:this={containerDiv}></div>
111
112<style>
113 .cytoscape-container {
114 width: 100%;
115 height: 100%;
116 background-color: #f5f5f5;
117 position: relative;
118 }
119</style>