12.4 KB440 lines
Blame
1"use client";
2
3import { useEffect, useRef, useState, useCallback } from "react";
4
5interface DiagramViewerProps {
6 code: string;
7 diagramId: string;
8 onNodeClick?: (nodeId: string | null) => void;
9}
10
11// ── Node Focus: dim unconnected elements on click ──
12
13interface GraphInfo {
14 adjacency: Record<string, Set<string>>;
15 nodeMap: Map<string, SVGElement[]>;
16}
17
18function buildGraphFromSvg(svgEl: SVGElement): GraphInfo {
19 const adjacency: Record<string, Set<string>> = {};
20 const nodeMap = new Map<string, SVGElement[]>();
21
22 const ensure = (id: string) => {
23 if (!adjacency[id]) adjacency[id] = new Set();
24 };
25 const addNode = (id: string, el: SVGElement) => {
26 if (!nodeMap.has(id)) nodeMap.set(id, []);
27 nodeMap.get(id)!.push(el);
28 el.classList.add("node-dimable", "node-clickable");
29 };
30
31 // Collect nodes — extract the short ID that edges use in data-start/data-end.
32 // Flowchart: g.id "flowchart-Customer-0" → edge uses "Customer"
33 // ER: g.id "entity-policy-3" → edge uses "entity-policy-3"
34 svgEl.querySelectorAll<SVGElement>("g.node").forEach((g) => {
35 if (!g.id) return;
36 const parts = g.id.split("-");
37 const short = parts.length >= 3 ? parts.slice(1, -1).join("-") : g.id;
38 addNode(short, g);
39 });
40 svgEl
41 .querySelectorAll<SVGElement>(
42 "g[id^='entity-'], g.classGroup, g.statediagram-state"
43 )
44 .forEach((g) => {
45 if (g.id) addNode(g.id, g);
46 });
47
48 // Build adjacency from data-start / data-end on edges
49 svgEl
50 .querySelectorAll<SVGElement>("[data-start][data-end]")
51 .forEach((el) => {
52 const s = el.getAttribute("data-start")!;
53 const e = el.getAttribute("data-end")!;
54 if (s && e) {
55 ensure(s);
56 ensure(e);
57 adjacency[s].add(e);
58 adjacency[e].add(s);
59 }
60 el.classList.add("node-dimable");
61 });
62
63 // Copy data-start/data-end from edge paths onto their labels
64 // so labels highlight together with their edges during node focus
65 const pathDataById = new Map<string, { start: string; end: string }>();
66 svgEl.querySelectorAll<SVGElement>("[data-start][data-end]").forEach((el) => {
67 if (el.id) {
68 pathDataById.set(el.id, {
69 start: el.getAttribute("data-start")!,
70 end: el.getAttribute("data-end")!,
71 });
72 }
73 });
74 svgEl.querySelectorAll<SVGElement>("g.edgeLabel").forEach((label) => {
75 const inner = label.querySelector("[data-id]");
76 const dataId = inner?.getAttribute("data-id");
77 if (dataId && pathDataById.has(dataId)) {
78 const { start, end } = pathDataById.get(dataId)!;
79 label.setAttribute("data-start", start);
80 label.setAttribute("data-end", end);
81 }
82 });
83
84 // Tag edge containers and clusters as dimable
85 svgEl
86 .querySelectorAll<SVGElement>(".edgePath, .edgeLabel, .cluster")
87 .forEach((el) => {
88 el.classList.add("node-dimable");
89 });
90
91 // Tag loose path/line elements NOT inside a node, existing dimable, or marker defs
92 svgEl.querySelectorAll<SVGElement>("path, line").forEach((el) => {
93 if (el.closest("marker, defs")) return;
94 if (!el.closest(".node-dimable") && !el.closest(".node-clickable")) {
95 el.classList.add("node-dimable");
96 }
97 });
98
99 return { adjacency, nodeMap };
100}
101
102function applyNodeFocus(
103 container: HTMLElement,
104 svgEl: SVGElement,
105 clickedNodeId: string,
106 adjacency: Record<string, Set<string>>,
107 nodeMap: Map<string, SVGElement[]>
108) {
109 const connected = new Set([clickedNodeId]);
110 if (adjacency[clickedNodeId])
111 adjacency[clickedNodeId].forEach((id) => connected.add(id));
112
113 svgEl
114 .querySelectorAll(".node-highlight")
115 .forEach((el) => el.classList.remove("node-highlight"));
116
117 const highlight = (el: Element) => {
118 el.classList.add("node-highlight");
119 el.querySelectorAll(".node-dimable").forEach((child) =>
120 child.classList.add("node-highlight")
121 );
122 };
123
124 for (const [id, elements] of nodeMap) {
125 if (connected.has(id)) elements.forEach(highlight);
126 }
127
128 svgEl
129 .querySelectorAll<SVGElement>("[data-start][data-end]")
130 .forEach((el) => {
131 const s = el.getAttribute("data-start")!;
132 const e = el.getAttribute("data-end")!;
133 if (connected.has(s) && connected.has(e)) highlight(el);
134 });
135
136 container.classList.add("node-focus");
137}
138
139function clearNodeFocus(container: HTMLElement) {
140 container.classList.remove("node-focus");
141 const svg = container.querySelector("svg");
142 if (svg) {
143 svg
144 .querySelectorAll(".node-highlight")
145 .forEach((el) => el.classList.remove("node-highlight"));
146 }
147}
148
149function wireNodeFocus(container: HTMLElement): string | null {
150 const svgEl = container.querySelector("svg") as SVGElement | null;
151 if (!svgEl) return null;
152
153 const { adjacency, nodeMap } = buildGraphFromSvg(svgEl);
154 if (Object.keys(adjacency).length === 0) return null;
155
156 let selectedNodeId: string | null = null;
157
158 for (const [nodeId, elements] of nodeMap) {
159 elements.forEach((el) => {
160 el.addEventListener("click", (e) => {
161 e.stopPropagation();
162 if (selectedNodeId === nodeId) {
163 clearNodeFocus(container);
164 selectedNodeId = null;
165 } else {
166 applyNodeFocus(container, svgEl, nodeId, adjacency, nodeMap);
167 selectedNodeId = nodeId;
168 }
169 });
170 });
171 }
172
173 return selectedNodeId;
174}
175
176// ── CSS for node focus (injected once) ──
177const NODE_FOCUS_STYLES = `
178.node-focus svg .node-dimable {
179 opacity: 0.1;
180 transition: opacity 0.2s ease;
181}
182.node-focus svg .node-dimable.node-highlight {
183 opacity: 1 !important;
184 transition: opacity 0.2s ease;
185}
186svg .node-clickable {
187 cursor: pointer;
188}
189`;
190
191let stylesInjected = false;
192function injectNodeFocusStyles() {
193 if (stylesInjected) return;
194 stylesInjected = true;
195 const style = document.createElement("style");
196 style.textContent = NODE_FOCUS_STYLES;
197 document.head.appendChild(style);
198}
199
200export function DiagramViewer({
201 code,
202 diagramId,
203 onNodeClick,
204}: DiagramViewerProps) {
205 const wrapperRef = useRef<HTMLDivElement>(null);
206 const containerRef = useRef<HTMLDivElement>(null);
207 const [panZoom, setPanZoom] = useState({ x: 0, y: 0, scale: 1 });
208 const [error, setError] = useState<string | null>(null);
209 const dragging = useRef(false);
210 const didDrag = useRef(false);
211 const lastPos = useRef({ x: 0, y: 0 });
212 const mermaidRef = useRef<any>(null);
213
214 // Load mermaid dynamically
215 useEffect(() => {
216 injectNodeFocusStyles();
217 import("mermaid").then((mod) => {
218 const m = mod.default;
219 m.initialize({
220 startOnLoad: false,
221 theme: "base",
222 themeVariables: {
223 primaryColor: "#e8f2ee",
224 primaryTextColor: "#2c2824",
225 primaryBorderColor: "#b0d4c5",
226 lineColor: "#7a746c",
227 secondaryColor: "#f2efe9",
228 tertiaryColor: "#faf8f5",
229 fontFamily: "'Libre Caslon Text', Georgia, serif",
230 fontSize: "13px",
231 },
232 securityLevel: "loose",
233 });
234 mermaidRef.current = m;
235 });
236 }, []);
237
238 // Render diagram when code changes
239 const render = useCallback(async () => {
240 const m = mermaidRef.current;
241 const el = containerRef.current;
242 if (!m || !el || !code.trim()) return;
243
244 try {
245 const id = `mermaid-${diagramId}-${Date.now()}`;
246 const { svg } = await m.render(id, code);
247 el.innerHTML = svg;
248 setError(null);
249
250 // Fit to container
251 const svgEl = el.querySelector("svg");
252 if (svgEl) {
253 svgEl.style.width = "100%";
254 svgEl.style.height = "100%";
255 svgEl.style.maxWidth = "none";
256 }
257
258 // Wire up node-focus click handlers
259 wireNodeFocus(el);
260 } catch (e: any) {
261 setError(e.message || "Failed to render diagram");
262 }
263 }, [code, diagramId]);
264
265 useEffect(() => {
266 if (mermaidRef.current) render();
267 }, [render]);
268
269 // Also render once mermaid loads
270 useEffect(() => {
271 const check = setInterval(() => {
272 if (mermaidRef.current) {
273 clearInterval(check);
274 render();
275 }
276 }, 100);
277 return () => clearInterval(check);
278 }, [render]);
279
280 // Reset pan/zoom on diagram switch
281 useEffect(() => {
282 setPanZoom({ x: 0, y: 0, scale: 1 });
283 }, [diagramId]);
284
285 // Pan handling
286 const handleMouseDown = useCallback((e: React.MouseEvent) => {
287 if (e.button !== 0 && e.button !== 1) return;
288 dragging.current = true;
289 didDrag.current = false;
290 lastPos.current = { x: e.clientX, y: e.clientY };
291 }, []);
292
293 const handleMouseMove = useCallback((e: React.MouseEvent) => {
294 if (!dragging.current) return;
295 const dx = e.clientX - lastPos.current.x;
296 const dy = e.clientY - lastPos.current.y;
297 if (Math.abs(dx) > 2 || Math.abs(dy) > 2) didDrag.current = true;
298 lastPos.current = { x: e.clientX, y: e.clientY };
299 setPanZoom((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
300 }, []);
301
302 const handleMouseUp = useCallback(() => {
303 dragging.current = false;
304 }, []);
305
306 // Click on background clears node focus
307 const handleClick = useCallback(() => {
308 if (didDrag.current) return;
309 if (containerRef.current) clearNodeFocus(containerRef.current);
310 }, []);
311
312 // Zoom handling — must use native listener with { passive: false } to preventDefault
313 useEffect(() => {
314 const el = wrapperRef.current;
315 if (!el) return;
316 const handleWheel = (e: WheelEvent) => {
317 e.preventDefault();
318 const delta = e.deltaY > 0 ? 0.9 : 1.1;
319 const rect = el.getBoundingClientRect();
320 const cx = e.clientX - rect.left;
321 const cy = e.clientY - rect.top;
322 setPanZoom((prev) => {
323 const newScale = Math.max(0.1, Math.min(5, prev.scale * delta));
324 const ratio = newScale / prev.scale;
325 return {
326 scale: newScale,
327 x: cx - ratio * (cx - prev.x),
328 y: cy - ratio * (cy - prev.y),
329 };
330 });
331 };
332 el.addEventListener("wheel", handleWheel, { passive: false });
333 return () => el.removeEventListener("wheel", handleWheel);
334 }, []);
335
336 const resetZoom = useCallback(() => {
337 setPanZoom({ x: 0, y: 0, scale: 1 });
338 }, []);
339
340 return (
341 <div
342 ref={wrapperRef}
343 style={{
344 flex: 1,
345 position: "relative",
346 overflow: "hidden",
347 cursor: dragging.current ? "grabbing" : "grab",
348 backgroundColor: "var(--bg-page)",
349 }}
350 onMouseDown={handleMouseDown}
351 onMouseMove={handleMouseMove}
352 onMouseUp={handleMouseUp}
353 onMouseLeave={handleMouseUp}
354 onClick={handleClick}
355 >
356 <div
357 ref={containerRef}
358 style={{
359 transform: `translate(${panZoom.x}px, ${panZoom.y}px) scale(${panZoom.scale})`,
360 transformOrigin: "0 0",
361 padding: "2rem",
362 display: "flex",
363 justifyContent: "center",
364 alignItems: "flex-start",
365 minHeight: "100%",
366 }}
367 />
368 {error && (
369 <div
370 className="text-xs px-3 py-2"
371 style={{
372 position: "absolute",
373 bottom: "3rem",
374 left: "1rem",
375 right: "1rem",
376 backgroundColor: "var(--bg-card)",
377 border: "1px solid var(--border)",
378 color: "var(--text-muted)",
379 }}
380 >
381 {error}
382 </div>
383 )}
384 <div
385 className="flex items-center gap-1 text-xs"
386 style={{
387 position: "absolute",
388 bottom: "0.75rem",
389 left: "50%",
390 transform: "translateX(-50%)",
391 }}
392 >
393 <button
394 onClick={() =>
395 setPanZoom((p) => ({ ...p, scale: Math.min(5, p.scale * 1.2) }))
396 }
397 className="px-2 py-1"
398 style={{
399 background: "var(--bg-card)",
400 border: "1px solid var(--border)",
401 color: "var(--text-muted)",
402 cursor: "pointer",
403 font: "inherit",
404 }}
405 >
406 +
407 </button>
408 <button
409 onClick={resetZoom}
410 className="px-2 py-1"
411 style={{
412 background: "var(--bg-card)",
413 border: "1px solid var(--border)",
414 color: "var(--text-muted)",
415 cursor: "pointer",
416 font: "inherit",
417 }}
418 >
419 {Math.round(panZoom.scale * 100)}%
420 </button>
421 <button
422 onClick={() =>
423 setPanZoom((p) => ({ ...p, scale: Math.max(0.1, p.scale * 0.8) }))
424 }
425 className="px-2 py-1"
426 style={{
427 background: "var(--bg-card)",
428 border: "1px solid var(--border)",
429 color: "var(--text-muted)",
430 cursor: "pointer",
431 font: "inherit",
432 }}
433 >
434 &minus;
435 </button>
436 </div>
437 </div>
438 );
439}
440