web/app/collab/components/diagram-viewer.tsxblame
View source
0b4b5821"use client";
0b4b5822
0b4b5823import { useEffect, useRef, useState, useCallback } from "react";
0b4b5824
0b4b5825interface DiagramViewerProps {
0b4b5826 code: string;
0b4b5827 diagramId: string;
0b4b5828 onNodeClick?: (nodeId: string | null) => void;
0b4b5829}
0b4b58210
0b4b58211// ── Node Focus: dim unconnected elements on click ──
0b4b58212
0b4b58213interface GraphInfo {
0b4b58214 adjacency: Record<string, Set<string>>;
0b4b58215 nodeMap: Map<string, SVGElement[]>;
0b4b58216}
0b4b58217
0b4b58218function buildGraphFromSvg(svgEl: SVGElement): GraphInfo {
0b4b58219 const adjacency: Record<string, Set<string>> = {};
0b4b58220 const nodeMap = new Map<string, SVGElement[]>();
0b4b58221
0b4b58222 const ensure = (id: string) => {
0b4b58223 if (!adjacency[id]) adjacency[id] = new Set();
0b4b58224 };
0b4b58225 const addNode = (id: string, el: SVGElement) => {
0b4b58226 if (!nodeMap.has(id)) nodeMap.set(id, []);
0b4b58227 nodeMap.get(id)!.push(el);
0b4b58228 el.classList.add("node-dimable", "node-clickable");
0b4b58229 };
0b4b58230
0b4b58231 // Collect nodes — extract the short ID that edges use in data-start/data-end.
0b4b58232 // Flowchart: g.id "flowchart-Customer-0" → edge uses "Customer"
0b4b58233 // ER: g.id "entity-policy-3" → edge uses "entity-policy-3"
0b4b58234 svgEl.querySelectorAll<SVGElement>("g.node").forEach((g) => {
0b4b58235 if (!g.id) return;
0b4b58236 const parts = g.id.split("-");
0b4b58237 const short = parts.length >= 3 ? parts.slice(1, -1).join("-") : g.id;
0b4b58238 addNode(short, g);
0b4b58239 });
0b4b58240 svgEl
0b4b58241 .querySelectorAll<SVGElement>(
0b4b58242 "g[id^='entity-'], g.classGroup, g.statediagram-state"
0b4b58243 )
0b4b58244 .forEach((g) => {
0b4b58245 if (g.id) addNode(g.id, g);
0b4b58246 });
0b4b58247
0b4b58248 // Build adjacency from data-start / data-end on edges
0b4b58249 svgEl
0b4b58250 .querySelectorAll<SVGElement>("[data-start][data-end]")
0b4b58251 .forEach((el) => {
0b4b58252 const s = el.getAttribute("data-start")!;
0b4b58253 const e = el.getAttribute("data-end")!;
0b4b58254 if (s && e) {
0b4b58255 ensure(s);
0b4b58256 ensure(e);
0b4b58257 adjacency[s].add(e);
0b4b58258 adjacency[e].add(s);
0b4b58259 }
0b4b58260 el.classList.add("node-dimable");
0b4b58261 });
0b4b58262
0b4b58263 // Copy data-start/data-end from edge paths onto their labels
0b4b58264 // so labels highlight together with their edges during node focus
0b4b58265 const pathDataById = new Map<string, { start: string; end: string }>();
0b4b58266 svgEl.querySelectorAll<SVGElement>("[data-start][data-end]").forEach((el) => {
0b4b58267 if (el.id) {
0b4b58268 pathDataById.set(el.id, {
0b4b58269 start: el.getAttribute("data-start")!,
0b4b58270 end: el.getAttribute("data-end")!,
0b4b58271 });
0b4b58272 }
0b4b58273 });
0b4b58274 svgEl.querySelectorAll<SVGElement>("g.edgeLabel").forEach((label) => {
0b4b58275 const inner = label.querySelector("[data-id]");
0b4b58276 const dataId = inner?.getAttribute("data-id");
0b4b58277 if (dataId && pathDataById.has(dataId)) {
0b4b58278 const { start, end } = pathDataById.get(dataId)!;
0b4b58279 label.setAttribute("data-start", start);
0b4b58280 label.setAttribute("data-end", end);
0b4b58281 }
0b4b58282 });
0b4b58283
0b4b58284 // Tag edge containers and clusters as dimable
0b4b58285 svgEl
0b4b58286 .querySelectorAll<SVGElement>(".edgePath, .edgeLabel, .cluster")
0b4b58287 .forEach((el) => {
0b4b58288 el.classList.add("node-dimable");
0b4b58289 });
0b4b58290
0b4b58291 // Tag loose path/line elements NOT inside a node, existing dimable, or marker defs
0b4b58292 svgEl.querySelectorAll<SVGElement>("path, line").forEach((el) => {
0b4b58293 if (el.closest("marker, defs")) return;
0b4b58294 if (!el.closest(".node-dimable") && !el.closest(".node-clickable")) {
0b4b58295 el.classList.add("node-dimable");
0b4b58296 }
0b4b58297 });
0b4b58298
0b4b58299 return { adjacency, nodeMap };
0b4b582100}
0b4b582101
0b4b582102function applyNodeFocus(
0b4b582103 container: HTMLElement,
0b4b582104 svgEl: SVGElement,
0b4b582105 clickedNodeId: string,
0b4b582106 adjacency: Record<string, Set<string>>,
0b4b582107 nodeMap: Map<string, SVGElement[]>
0b4b582108) {
0b4b582109 const connected = new Set([clickedNodeId]);
0b4b582110 if (adjacency[clickedNodeId])
0b4b582111 adjacency[clickedNodeId].forEach((id) => connected.add(id));
0b4b582112
0b4b582113 svgEl
0b4b582114 .querySelectorAll(".node-highlight")
0b4b582115 .forEach((el) => el.classList.remove("node-highlight"));
0b4b582116
0b4b582117 const highlight = (el: Element) => {
0b4b582118 el.classList.add("node-highlight");
0b4b582119 el.querySelectorAll(".node-dimable").forEach((child) =>
0b4b582120 child.classList.add("node-highlight")
0b4b582121 );
0b4b582122 };
0b4b582123
0b4b582124 for (const [id, elements] of nodeMap) {
0b4b582125 if (connected.has(id)) elements.forEach(highlight);
0b4b582126 }
0b4b582127
0b4b582128 svgEl
0b4b582129 .querySelectorAll<SVGElement>("[data-start][data-end]")
0b4b582130 .forEach((el) => {
0b4b582131 const s = el.getAttribute("data-start")!;
0b4b582132 const e = el.getAttribute("data-end")!;
0b4b582133 if (connected.has(s) && connected.has(e)) highlight(el);
0b4b582134 });
0b4b582135
0b4b582136 container.classList.add("node-focus");
0b4b582137}
0b4b582138
0b4b582139function clearNodeFocus(container: HTMLElement) {
0b4b582140 container.classList.remove("node-focus");
0b4b582141 const svg = container.querySelector("svg");
0b4b582142 if (svg) {
0b4b582143 svg
0b4b582144 .querySelectorAll(".node-highlight")
0b4b582145 .forEach((el) => el.classList.remove("node-highlight"));
0b4b582146 }
0b4b582147}
0b4b582148
0b4b582149function wireNodeFocus(container: HTMLElement): string | null {
0b4b582150 const svgEl = container.querySelector("svg") as SVGElement | null;
0b4b582151 if (!svgEl) return null;
0b4b582152
0b4b582153 const { adjacency, nodeMap } = buildGraphFromSvg(svgEl);
0b4b582154 if (Object.keys(adjacency).length === 0) return null;
0b4b582155
0b4b582156 let selectedNodeId: string | null = null;
0b4b582157
0b4b582158 for (const [nodeId, elements] of nodeMap) {
0b4b582159 elements.forEach((el) => {
0b4b582160 el.addEventListener("click", (e) => {
0b4b582161 e.stopPropagation();
0b4b582162 if (selectedNodeId === nodeId) {
0b4b582163 clearNodeFocus(container);
0b4b582164 selectedNodeId = null;
0b4b582165 } else {
0b4b582166 applyNodeFocus(container, svgEl, nodeId, adjacency, nodeMap);
0b4b582167 selectedNodeId = nodeId;
0b4b582168 }
0b4b582169 });
0b4b582170 });
0b4b582171 }
0b4b582172
0b4b582173 return selectedNodeId;
0b4b582174}
0b4b582175
0b4b582176// ── CSS for node focus (injected once) ──
0b4b582177const NODE_FOCUS_STYLES = `
0b4b582178.node-focus svg .node-dimable {
0b4b582179 opacity: 0.1;
0b4b582180 transition: opacity 0.2s ease;
0b4b582181}
0b4b582182.node-focus svg .node-dimable.node-highlight {
0b4b582183 opacity: 1 !important;
0b4b582184 transition: opacity 0.2s ease;
0b4b582185}
0b4b582186svg .node-clickable {
0b4b582187 cursor: pointer;
0b4b582188}
0b4b582189`;
0b4b582190
0b4b582191let stylesInjected = false;
0b4b582192function injectNodeFocusStyles() {
0b4b582193 if (stylesInjected) return;
0b4b582194 stylesInjected = true;
0b4b582195 const style = document.createElement("style");
0b4b582196 style.textContent = NODE_FOCUS_STYLES;
0b4b582197 document.head.appendChild(style);
0b4b582198}
0b4b582199
0b4b582200export function DiagramViewer({
0b4b582201 code,
0b4b582202 diagramId,
0b4b582203 onNodeClick,
0b4b582204}: DiagramViewerProps) {
0b4b582205 const wrapperRef = useRef<HTMLDivElement>(null);
0b4b582206 const containerRef = useRef<HTMLDivElement>(null);
0b4b582207 const [panZoom, setPanZoom] = useState({ x: 0, y: 0, scale: 1 });
0b4b582208 const [error, setError] = useState<string | null>(null);
0b4b582209 const dragging = useRef(false);
0b4b582210 const didDrag = useRef(false);
0b4b582211 const lastPos = useRef({ x: 0, y: 0 });
0b4b582212 const mermaidRef = useRef<any>(null);
0b4b582213
0b4b582214 // Load mermaid dynamically
0b4b582215 useEffect(() => {
0b4b582216 injectNodeFocusStyles();
0b4b582217 import("mermaid").then((mod) => {
0b4b582218 const m = mod.default;
0b4b582219 m.initialize({
0b4b582220 startOnLoad: false,
0b4b582221 theme: "base",
0b4b582222 themeVariables: {
0b4b582223 primaryColor: "#e8f2ee",
0b4b582224 primaryTextColor: "#2c2824",
0b4b582225 primaryBorderColor: "#b0d4c5",
0b4b582226 lineColor: "#7a746c",
0b4b582227 secondaryColor: "#f2efe9",
0b4b582228 tertiaryColor: "#faf8f5",
0b4b582229 fontFamily: "'Libre Caslon Text', Georgia, serif",
0b4b582230 fontSize: "13px",
0b4b582231 },
0b4b582232 securityLevel: "loose",
0b4b582233 });
0b4b582234 mermaidRef.current = m;
0b4b582235 });
0b4b582236 }, []);
0b4b582237
0b4b582238 // Render diagram when code changes
0b4b582239 const render = useCallback(async () => {
0b4b582240 const m = mermaidRef.current;
0b4b582241 const el = containerRef.current;
0b4b582242 if (!m || !el || !code.trim()) return;
0b4b582243
0b4b582244 try {
0b4b582245 const id = `mermaid-${diagramId}-${Date.now()}`;
0b4b582246 const { svg } = await m.render(id, code);
0b4b582247 el.innerHTML = svg;
0b4b582248 setError(null);
0b4b582249
0b4b582250 // Fit to container
0b4b582251 const svgEl = el.querySelector("svg");
0b4b582252 if (svgEl) {
0b4b582253 svgEl.style.width = "100%";
0b4b582254 svgEl.style.height = "100%";
0b4b582255 svgEl.style.maxWidth = "none";
0b4b582256 }
0b4b582257
0b4b582258 // Wire up node-focus click handlers
0b4b582259 wireNodeFocus(el);
0b4b582260 } catch (e: any) {
0b4b582261 setError(e.message || "Failed to render diagram");
0b4b582262 }
0b4b582263 }, [code, diagramId]);
0b4b582264
0b4b582265 useEffect(() => {
0b4b582266 if (mermaidRef.current) render();
0b4b582267 }, [render]);
0b4b582268
0b4b582269 // Also render once mermaid loads
0b4b582270 useEffect(() => {
0b4b582271 const check = setInterval(() => {
0b4b582272 if (mermaidRef.current) {
0b4b582273 clearInterval(check);
0b4b582274 render();
0b4b582275 }
0b4b582276 }, 100);
0b4b582277 return () => clearInterval(check);
0b4b582278 }, [render]);
0b4b582279
0b4b582280 // Reset pan/zoom on diagram switch
0b4b582281 useEffect(() => {
0b4b582282 setPanZoom({ x: 0, y: 0, scale: 1 });
0b4b582283 }, [diagramId]);
0b4b582284
0b4b582285 // Pan handling
0b4b582286 const handleMouseDown = useCallback((e: React.MouseEvent) => {
0b4b582287 if (e.button !== 0 && e.button !== 1) return;
0b4b582288 dragging.current = true;
0b4b582289 didDrag.current = false;
0b4b582290 lastPos.current = { x: e.clientX, y: e.clientY };
0b4b582291 }, []);
0b4b582292
0b4b582293 const handleMouseMove = useCallback((e: React.MouseEvent) => {
0b4b582294 if (!dragging.current) return;
0b4b582295 const dx = e.clientX - lastPos.current.x;
0b4b582296 const dy = e.clientY - lastPos.current.y;
0b4b582297 if (Math.abs(dx) > 2 || Math.abs(dy) > 2) didDrag.current = true;
0b4b582298 lastPos.current = { x: e.clientX, y: e.clientY };
0b4b582299 setPanZoom((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
0b4b582300 }, []);
0b4b582301
0b4b582302 const handleMouseUp = useCallback(() => {
0b4b582303 dragging.current = false;
0b4b582304 }, []);
0b4b582305
0b4b582306 // Click on background clears node focus
0b4b582307 const handleClick = useCallback(() => {
0b4b582308 if (didDrag.current) return;
0b4b582309 if (containerRef.current) clearNodeFocus(containerRef.current);
0b4b582310 }, []);
0b4b582311
0b4b582312 // Zoom handling — must use native listener with { passive: false } to preventDefault
0b4b582313 useEffect(() => {
0b4b582314 const el = wrapperRef.current;
0b4b582315 if (!el) return;
0b4b582316 const handleWheel = (e: WheelEvent) => {
0b4b582317 e.preventDefault();
0b4b582318 const delta = e.deltaY > 0 ? 0.9 : 1.1;
0b4b582319 const rect = el.getBoundingClientRect();
0b4b582320 const cx = e.clientX - rect.left;
0b4b582321 const cy = e.clientY - rect.top;
0b4b582322 setPanZoom((prev) => {
0b4b582323 const newScale = Math.max(0.1, Math.min(5, prev.scale * delta));
0b4b582324 const ratio = newScale / prev.scale;
0b4b582325 return {
0b4b582326 scale: newScale,
0b4b582327 x: cx - ratio * (cx - prev.x),
0b4b582328 y: cy - ratio * (cy - prev.y),
0b4b582329 };
0b4b582330 });
0b4b582331 };
0b4b582332 el.addEventListener("wheel", handleWheel, { passive: false });
0b4b582333 return () => el.removeEventListener("wheel", handleWheel);
0b4b582334 }, []);
0b4b582335
0b4b582336 const resetZoom = useCallback(() => {
0b4b582337 setPanZoom({ x: 0, y: 0, scale: 1 });
0b4b582338 }, []);
0b4b582339
0b4b582340 return (
0b4b582341 <div
0b4b582342 ref={wrapperRef}
0b4b582343 style={{
0b4b582344 flex: 1,
0b4b582345 position: "relative",
0b4b582346 overflow: "hidden",
0b4b582347 cursor: dragging.current ? "grabbing" : "grab",
0b4b582348 backgroundColor: "var(--bg-page)",
0b4b582349 }}
0b4b582350 onMouseDown={handleMouseDown}
0b4b582351 onMouseMove={handleMouseMove}
0b4b582352 onMouseUp={handleMouseUp}
0b4b582353 onMouseLeave={handleMouseUp}
0b4b582354 onClick={handleClick}
0b4b582355 >
0b4b582356 <div
0b4b582357 ref={containerRef}
0b4b582358 style={{
0b4b582359 transform: `translate(${panZoom.x}px, ${panZoom.y}px) scale(${panZoom.scale})`,
0b4b582360 transformOrigin: "0 0",
0b4b582361 padding: "2rem",
0b4b582362 display: "flex",
0b4b582363 justifyContent: "center",
0b4b582364 alignItems: "flex-start",
0b4b582365 minHeight: "100%",
0b4b582366 }}
0b4b582367 />
0b4b582368 {error && (
0b4b582369 <div
0b4b582370 className="text-xs px-3 py-2"
0b4b582371 style={{
0b4b582372 position: "absolute",
0b4b582373 bottom: "3rem",
0b4b582374 left: "1rem",
0b4b582375 right: "1rem",
0b4b582376 backgroundColor: "var(--bg-card)",
0b4b582377 border: "1px solid var(--border)",
0b4b582378 color: "var(--text-muted)",
0b4b582379 }}
0b4b582380 >
0b4b582381 {error}
0b4b582382 </div>
0b4b582383 )}
0b4b582384 <div
0b4b582385 className="flex items-center gap-1 text-xs"
0b4b582386 style={{
0b4b582387 position: "absolute",
0b4b582388 bottom: "0.75rem",
0b4b582389 left: "50%",
0b4b582390 transform: "translateX(-50%)",
0b4b582391 }}
0b4b582392 >
0b4b582393 <button
0b4b582394 onClick={() =>
0b4b582395 setPanZoom((p) => ({ ...p, scale: Math.min(5, p.scale * 1.2) }))
0b4b582396 }
0b4b582397 className="px-2 py-1"
0b4b582398 style={{
0b4b582399 background: "var(--bg-card)",
0b4b582400 border: "1px solid var(--border)",
0b4b582401 color: "var(--text-muted)",
0b4b582402 cursor: "pointer",
0b4b582403 font: "inherit",
0b4b582404 }}
0b4b582405 >
0b4b582406 +
0b4b582407 </button>
0b4b582408 <button
0b4b582409 onClick={resetZoom}
0b4b582410 className="px-2 py-1"
0b4b582411 style={{
0b4b582412 background: "var(--bg-card)",
0b4b582413 border: "1px solid var(--border)",
0b4b582414 color: "var(--text-muted)",
0b4b582415 cursor: "pointer",
0b4b582416 font: "inherit",
0b4b582417 }}
0b4b582418 >
0b4b582419 {Math.round(panZoom.scale * 100)}%
0b4b582420 </button>
0b4b582421 <button
0b4b582422 onClick={() =>
0b4b582423 setPanZoom((p) => ({ ...p, scale: Math.max(0.1, p.scale * 0.8) }))
0b4b582424 }
0b4b582425 className="px-2 py-1"
0b4b582426 style={{
0b4b582427 background: "var(--bg-card)",
0b4b582428 border: "1px solid var(--border)",
0b4b582429 color: "var(--text-muted)",
0b4b582430 cursor: "pointer",
0b4b582431 font: "inherit",
0b4b582432 }}
0b4b582433 >
0b4b582434 &minus;
0b4b582435 </button>
0b4b582436 </div>
0b4b582437 </div>
0b4b582438 );
0b4b582439}