/* Interactive graph: column view.
   Path entities (your navigation history) are pinned left-to-right.
   Neighbors of the currently focused entity fan out to the next column.
   Click a neighbor → it becomes focused and gets appended to the path;
   columns to the left stay stable.
*/
{
  const { useEffect, useRef, useState, useMemo } = React;
  const {
    HEADER_H, PAD_X, PAD_Y, ROW_H, MAX_ROWS, LEFT_MARGIN, EDIT_FOOTER_H,
    bodyLines, getNodeDims, classifyRole,
    buildColumns, computeColumnPositions, totalLayoutWidth, walkTree,
  } = GraphLayout;

  function CardPill({ x, y, text, focal }) {
    if (!text) return null;
    const fontSize = 11;
    const padX = 4;
    const padY = 2;
    const charW = 6.4;
    const w = Math.max(14, text.length * charW + padX * 2);
    const h = fontSize + padY * 2 + 2;
    return (
      <g transform={`translate(${x - w/2}, ${y - h/2})`} style={{ pointerEvents: "none" }}>
        <rect width={w} height={h} rx={3}
          fill="var(--panel)"
          stroke={focal ? "var(--focal-line)" : "var(--border-strong)"}
          strokeWidth={focal ? 1.25 : 1}
        />
        <text x={w/2} y={h/2 + fontSize/2 - 1.5} textAnchor="middle"
          fontFamily="var(--font-mono)"
          fontSize={fontSize}
          fontWeight={700}
          fill={focal ? "var(--focal-line)" : "var(--text)"}>
          {text}
        </text>
      </g>
    );
  }

  const MARKERS = (
    <defs>
      <marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0,0 L10,5 L0,10" fill="none" stroke="var(--text-2)" strokeWidth="1.2" />
      </marker>
      <marker id="arrow-open-focal" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0,0 L10,5 L0,10" fill="none" stroke="var(--focal-line)" strokeWidth="1.3" />
      </marker>
      <marker id="arrow-tri" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="11" markerHeight="11" orient="auto">
        <path d="M0,0 L11,6 L0,12 z" fill="var(--panel)" stroke="var(--text)" strokeWidth="1.2" />
      </marker>
      <marker id="arrow-tri-focal" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="11" markerHeight="11" orient="auto">
        <path d="M0,0 L11,6 L0,12 z" fill="var(--panel)" stroke="var(--focal-line)" strokeWidth="1.2" />
      </marker>
      <marker id="arrow-diamond" viewBox="0 0 14 10" refX="13" refY="5" markerWidth="11" markerHeight="9" orient="auto">
        <path d="M0,5 L6,0 L13,5 L6,10 z" fill="var(--text-2)" stroke="var(--text-2)" strokeWidth="1" />
      </marker>
      <marker id="arrow-diamond-focal" viewBox="0 0 14 10" refX="13" refY="5" markerWidth="11" markerHeight="9" orient="auto">
        <path d="M0,5 L6,0 L13,5 L6,10 z" fill="var(--focal-line)" stroke="var(--focal-line)" strokeWidth="1" />
      </marker>
      <marker id="arrow-diamond-open" viewBox="0 0 14 10" refX="13" refY="5" markerWidth="11" markerHeight="9" orient="auto">
        <path d="M0,5 L6,0 L13,5 L6,10 z" fill="var(--panel)" stroke="var(--text-2)" strokeWidth="1" />
      </marker>
      <marker id="arrow-diamond-open-focal" viewBox="0 0 14 10" refX="13" refY="5" markerWidth="11" markerHeight="9" orient="auto">
        <path d="M0,5 L6,0 L13,5 L6,10 z" fill="var(--panel)" stroke="var(--focal-line)" strokeWidth="1" />
      </marker>
    </defs>
  );

  function edgeMarker(kind, focal) {
    if (kind === "inheritance") return focal ? "url(#arrow-tri-focal)" : "url(#arrow-tri)";
    if (kind === "composition") return focal ? "url(#arrow-diamond-focal)" : "url(#arrow-diamond)";
    if (kind === "aggregation") return focal ? "url(#arrow-diamond-open-focal)" : "url(#arrow-diamond-open)";
    return focal ? "url(#arrow-open-focal)" : "url(#arrow-open)";
  }

  /* Where does a straight line from (nx,ny) towards (tx,ty) leave the rect? */
  function rectIntersect(nx, ny, w, h, tx, ty) {
    const dx = tx - nx, dy = ty - ny;
    const adx = Math.abs(dx) || 0.0001;
    const ady = Math.abs(dy) || 0.0001;
    const s = Math.min((w / 2) / adx, (h / 2) / ady);
    return { x: nx + dx * s, y: ny + dy * s };
  }

  function useContainerSize(containerRef) {
    const [size, setSize] = useState({ w: 800, h: 600 });
    useEffect(() => {
      if (!containerRef.current) return;
      const update = () => {
        if (!containerRef.current) return;
        const r = containerRef.current.getBoundingClientRect();
        if (r.width > 0 && r.height > 0) setSize({ w: r.width, h: r.height });
      };
      update();
      let ro;
      try {
        ro = new ResizeObserver(update);
        ro.observe(containerRef.current);
      } catch (e) { /* ignore */ }
      window.addEventListener("resize", update);
      return () => { ro && ro.disconnect(); window.removeEventListener("resize", update); };
    }, []);
    return size;
  }

  function GraphNode({
    n, focused, dim, isExpanded, effectiveShowAttrs, entityNames,
    onFocus, onSelect, onClose, onAttrClick, onToggleExpanded, onStartDrag,
    onPreviewEnter, onPreviewLeave, onPreviewCommit,
    onAttrPreviewEnter, onAttrPreviewLeave,
    editMode, onAddField, onAddSubclass, onEditField,
  }) {
    const ent = n.ent;
    const colors = stereoColors(ent.stereotype);
    const allLines = bodyLines(ent, entityNames);
    const cap = isExpanded ? 60 : MAX_ROWS;
    const lines = effectiveShowAttrs ? allLines : [];
    const shown = lines.slice(0, cap);
    const hasMore = lines.length > shown.length;
    const role = n.role;
    const ICON_W = 14;
    const FOCUS_W = 16;
    const CONTROL_GAP = 8;
    const FOCUS_ROLE_GAP = 16; // extra breathing room between focus-btn and role text
    const ROLE_CHAR_W = 7.4;
    const closeX = n.w - PAD_X - ICON_W;
    const previewX = closeX - CONTROL_GAP - ICON_W;
    const roleRightX = previewX - CONTROL_GAP;
    const focusX = roleRightX - (role.length * ROLE_CHAR_W) - FOCUS_ROLE_GAP - FOCUS_W;

    return (
      <g
        className={`graph-node role-${role.toLowerCase()} ${focused ? "focused" : ""} ${dim ? "dim" : ""} ${n.inPath ? "in-path" : ""} ${n.ghost ? "ghost" : ""}`}
        transform={`translate(${n.x - n.w/2},${n.y - n.h/2})`}
      >
        <rect className="body-rect" width={n.w} height={n.h} rx="6" ry="6"
          fill="var(--panel)"
          stroke={focused ? "var(--focal-line)" : "var(--border)"}
          strokeWidth={focused ? 1.25 : 1}
        />
        {/* header doubles as a drag handle. Click does NOT focus; use focus button. */}
        <g
          className="node-header"
          onMouseDown={(e) => onStartDrag && onStartDrag(e, n.name)}
          style={{ cursor: "grab" }}
        >
          <rect width={n.w} height={HEADER_H} rx="6" ry="6"
            fill={focused ? "var(--focal-bg)" : colors.bg}
          />
          <rect width={n.w} height={HEADER_H - 6} y={6}
            fill={focused ? "var(--focal-bg)" : colors.bg}
          />
          {(shown.length || hasMore) ? (
            <line x1={0} y1={HEADER_H} x2={n.w} y2={HEADER_H}
              stroke={focused ? "var(--focal-line)" : "var(--border)"}
              strokeWidth="1"
            />
          ) : null}
          <text
            x={PAD_X} y={HEADER_H/2 + 5}
            fontFamily="var(--font-serif)"
            fontSize="14"
            fontStyle={ent.kind === "abstract" ? "italic" : "normal"}
            fontWeight={focused ? 700 : 600}
            fill={focused ? "var(--focal-line)" : colors.fg}
          >{n.name}</text>
          <text
            x={roleRightX} y={HEADER_H/2 + 4}
            textAnchor="end"
            fontFamily="var(--font-mono)"
            fontSize="9.5"
            letterSpacing="0.12em"
            fontWeight="500"
            fill={focused ? "var(--focal-line)" : "var(--text-3)"}
          >{role}</text>
          {!focused && onFocus && (
            <g
              className="focus-btn"
              transform={`translate(${focusX}, ${HEADER_H/2 - 8})`}
              onClick={(e) => { e.stopPropagation(); onFocus(n.name); }}
              onMouseDown={(e) => e.stopPropagation()}
              style={{ cursor: "pointer" }}
            >
              <rect x={0} y={0} width={16} height={16} rx="3" fill="var(--panel)" stroke="var(--text-3)" strokeWidth="0.9" />
              <circle cx={8} cy={8} r="3.2" fill="none" stroke="var(--text-2)" strokeWidth="1.1" />
              <circle cx={8} cy={8} r="1" fill="var(--text-2)" />
            </g>
          )}
          {!n.ghost && onPreviewEnter && (
            <g
              className="preview-btn"
              transform={`translate(${previewX}, ${HEADER_H/2 - 7})`}
              onMouseEnter={() => onPreviewEnter(n.name)}
              onMouseLeave={() => onPreviewLeave && onPreviewLeave()}
              onClick={(e) => { e.stopPropagation(); onPreviewCommit && onPreviewCommit(n.name); }}
              onMouseDown={(e) => e.stopPropagation()}
              style={{ cursor: "pointer" }}
            >
              <rect x={0} y={0} width={14} height={14} rx="3" fill="transparent" />
              <path
                d="M1,7 Q7,1.5 13,7 Q7,12.5 1,7 Z"
                fill="none"
                stroke={focused ? "var(--focal-line)" : "var(--text-3)"}
                strokeWidth="1.2"
                strokeLinejoin="round"
              />
              <circle cx={7} cy={7} r="1.8" fill={focused ? "var(--focal-line)" : "var(--text-3)"} />
            </g>
          )}
          {onClose && (
            <g
              className="close-btn"
              transform={`translate(${closeX}, ${HEADER_H/2 - 7})`}
              onClick={(e) => { e.stopPropagation(); onClose(n.name); }}
              onMouseDown={(e) => e.stopPropagation()}
              style={{ cursor: "pointer" }}
            >
              <rect x={0} y={0} width={14} height={14} rx="3" fill="transparent" />
              <path d="M3,3 L11,11 M3,11 L11,3"
                stroke={focused ? "var(--focal-line)" : "var(--text-3)"}
                strokeWidth="1.4"
                strokeLinecap="round"
              />
            </g>
          )}
        </g>

        {effectiveShowAttrs && shown.map((row, ri) => {
          const rowY = HEADER_H + PAD_Y + ri * ROW_H;
          const nameX = PAD_X + 18 + (row.entityRef ? 8 : 0);
          return (
            <g key={ri} transform={`translate(0, ${rowY})`}>
              {ri > 0 && (
                <line x1={PAD_X} y1={-2} x2={n.w - PAD_X} y2={-2}
                  stroke="var(--border)" opacity="0.4" />
              )}
              <rect
                x={PAD_X + 18} y={-PAD_Y/2}
                width={Math.max(0, n.w - (PAD_X + 18) - 2)} height={ROW_H}
                fill="transparent"
                style={{ cursor: row.entityRef ? "pointer" : "default" }}
                onClick={(e) => {
                  e.stopPropagation();
                  if (row.entityRef && onSelect) onSelect(row.entityRef, n.name);
                }}
                onMouseEnter={() => {
                  if (row.entityRef && onAttrPreviewEnter) {
                    // anchor the ghost at the row's absolute centre in graph coords
                    const anchorY = (n.y - n.h/2) + rowY + ROW_H/2;
                    onAttrPreviewEnter(row.entityRef, anchorY);
                  }
                }}
                onMouseLeave={() => {
                  if (row.entityRef && onAttrPreviewLeave) onAttrPreviewLeave();
                }}
              />
              <g
                className="copy-glyph"
                onClick={(e) => {
                  e.stopPropagation();
                  if (onAttrClick) onAttrClick(n.name, row.name, row.isList);
                }}
              >
                <rect x={2} y={-PAD_Y/2 + 1} width={14} height={ROW_H - 2} fill="transparent" />
                <g transform={`translate(${4}, ${2})`}>
                  <rect x={0} y={3} width={7} height={8} rx="1" fill="var(--panel)" stroke="var(--text-3)" strokeWidth="0.9" />
                  <rect x={2.5} y={0.5} width={7} height={8} rx="1" fill="var(--panel)" stroke="var(--text-3)" strokeWidth="0.9" />
                </g>
              </g>
              {editMode && !n.ghost && onEditField && (
                <g
                  className="edit-glyph"
                  onClick={(e) => { e.stopPropagation(); onEditField(n.name, row.name, e); }}
                >
                  <rect x={16} y={-PAD_Y/2 + 1} width={14} height={ROW_H - 2} fill="transparent" />
                  <g transform={`translate(${18}, ${3})`}>
                    {/* simple pencil glyph */}
                    <path d="M 0 9 L 6 3 L 8 5 L 2 11 Z M 0 9 L -0.5 11.5 L 2 11"
                      fill="var(--panel)" stroke="var(--text-3)" strokeWidth="0.9" strokeLinejoin="round" />
                    <line x1={5.5} y1={2.5} x2={7.5} y2={4.5} stroke="var(--text-3)" strokeWidth="0.9" />
                  </g>
                </g>
              )}
              {row.entityRef && (
                <circle cx={PAD_X + 22} cy={9} r="2.4" fill="var(--focal-line)" />
              )}
              <text x={nameX} y={11}
                className={row.entityRef ? "attr-name-link" : ""}
                fontSize="11" fontFamily="var(--font-mono)"
                fill="var(--text)"
              >
                {row.left}
              </text>
              {row.right && (
                <text x={n.w - PAD_X} y={11}
                  textAnchor="end"
                  fontFamily="var(--font-mono)"
                  fontStyle="italic"
                  fontSize="11"
                  fill={row.entityRef ? (focused ? "var(--focal-line)" : "var(--text)") : "var(--text-2)"}
                >
                  {row.right}
                </text>
              )}
            </g>
          );
        })}
        {effectiveShowAttrs && hasMore && (
          <g
            style={{ cursor: "pointer" }}
            onClick={(e) => { e.stopPropagation(); onToggleExpanded(n.name); }}
          >
            <rect x={2} y={HEADER_H + PAD_Y + shown.length * ROW_H - 2} width={n.w - 4} height={ROW_H} fill="transparent" />
            <text x={n.w/2} y={HEADER_H + PAD_Y + shown.length * ROW_H + 12}
              fontSize="10" textAnchor="middle" fill="var(--text-3)"
              fontFamily="var(--font-mono)" fontStyle="italic">
              + {lines.length - shown.length} more — click to expand
            </text>
          </g>
        )}
        {effectiveShowAttrs && isExpanded && allLines.length > MAX_ROWS && (
          <g
            style={{ cursor: "pointer" }}
            onClick={(e) => { e.stopPropagation(); onToggleExpanded(n.name); }}
          >
            <rect x={2} y={n.h - 20} width={n.w - 4} height={18} fill="transparent" />
            <text x={n.w/2} y={n.h - 6}
              fontSize="10" textAnchor="middle" fill="var(--text-3)"
              fontFamily="var(--font-mono)" fontStyle="italic">
              collapse
            </text>
          </g>
        )}
        {n.inPath && !focused && (
          <circle cx={6} cy={6} r={3.5} fill="var(--green)" />
        )}
        {editMode && !n.ghost && (
          (() => {
            const footerY = n.h - EDIT_FOOTER_H;
            const isEnum = ent.kind === "enum";
            const isIface = ent.kind === "interface";
            const fieldLabel = isEnum ? "+ value" : "+ field";
            const subLabel = isIface ? "+ impl" : "+ sub";
            const slotW = (n.w - PAD_X * 2 - 6) / 2;
            const btnY = footerY + 4;
            const btnH = EDIT_FOOTER_H - 8;
            return (
              <g className="edit-footer">
                <line x1={0} y1={footerY} x2={n.w} y2={footerY} stroke="var(--border)" strokeWidth="1" />
                <g
                  className="edit-mini-btn"
                  transform={`translate(${PAD_X}, ${btnY})`}
                  onClick={(e) => { e.stopPropagation(); onAddField && onAddField(n.name, e); }}
                  onMouseDown={(e) => e.stopPropagation()}
                  style={{ cursor: "pointer" }}
                >
                  <rect width={slotW} height={btnH} rx="4" fill="var(--panel-2)" stroke="var(--border)" />
                  <text x={slotW / 2} y={btnH / 2 + 4}
                    textAnchor="middle"
                    fontFamily="var(--font-mono)"
                    fontSize="10.5"
                    fill="var(--text-2)"
                  >{fieldLabel}</text>
                </g>
                <g
                  className="edit-mini-btn"
                  transform={`translate(${PAD_X + slotW + 6}, ${btnY})`}
                  onClick={(e) => { e.stopPropagation(); onAddSubclass && onAddSubclass(n.name, e); }}
                  onMouseDown={(e) => e.stopPropagation()}
                  style={{ cursor: "pointer" }}
                >
                  <rect width={slotW} height={btnH} rx="4" fill="var(--panel-2)" stroke="var(--border)" />
                  <text x={slotW / 2} y={btnH / 2 + 4}
                    textAnchor="middle"
                    fontFamily="var(--font-mono)"
                    fontSize="10.5"
                    fill="var(--text-2)"
                  >{subLabel}</text>
                </g>
              </g>
            );
          })()
        )}
      </g>
    );
  }

  function GraphEdge({ e, fromN, toN, focused, dimmed, editable, onEditClick }) {
    const focal = e.from === focused || e.to === focused;
    const p1 = rectIntersect(fromN.x, fromN.y, fromN.w + 4, fromN.h + 4, toN.x, toN.y);
    const p2 = rectIntersect(toN.x, toN.y, toN.w + 4, toN.h + 4, fromN.x, fromN.y);
    const dx = p2.x - p1.x, dy = p2.y - p1.y;
    const lineLen = Math.hypot(dx, dy) || 1;
    const t1 = Math.max(0.14, Math.min(0.28, 26 / lineLen));
    const t2 = 1 - t1;
    const c1x = p1.x + dx * t1, c1y = p1.y + dy * t1;
    const c2x = p1.x + dx * t2, c2y = p1.y + dy * t2;
    const midX = p1.x + dx * 0.5, midY = p1.y + dy * 0.5;
    const labelX = midX + (dy / lineLen) * 10;
    const labelY = midY - (dx / lineLen) * 10;
    return (
      <g className={`graph-edge-g ${dimmed ? "dim" : ""} ${focal ? "focal" : ""} ${editable ? "editable" : ""}`}>
        <line
          className={`graph-edge ${e.kind} ${focal ? "focal" : ""}`}
          x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y}
          markerEnd={edgeMarker(e.kind, focal)}
          style={{ pointerEvents: "none" }}
        />
        {e.fromCard && <CardPill x={c1x} y={c1y} text={e.fromCard} focal={focal} />}
        {e.toCard   && <CardPill x={c2x} y={c2y} text={e.toCard}   focal={focal} />}
        {e.label && (
          <text
            x={labelX} y={labelY}
            textAnchor="middle"
            fontFamily="var(--font-mono)"
            fontSize="10"
            fontStyle="italic"
            fill={focal ? "var(--focal-line)" : "var(--text-3)"}
            style={{ pointerEvents: "none" }}
          >{e.label}</text>
        )}
        {editable && (
          <line
            className="graph-edge-hit"
            x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y}
            stroke="transparent"
            strokeWidth="16"
            style={{ cursor: "pointer", pointerEvents: "stroke" }}
            onMouseDown={(ev) => ev.stopPropagation()}
            onClick={(ev) => { ev.stopPropagation(); onEditClick && onEditClick(ev); }}
          />
        )}
      </g>
    );
  }

  function Graph({
    model, focusedName, onSelect, onFocus, onClose, showAttrs, onToggleAttrs,
    historyEntries, onAttrClick,
    editMode, onToggleEditMode, onEdit,
  }) {
    const svgRef = useRef(null);
    const containerRef = useRef(null);
    const size = useContainerSize(containerRef);
    const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 });
    const [hoverName, setHoverName] = useState(null);
    const [expanded, setExpanded] = useState(new Set());
    const [positionOverrides, setPositionOverrides] = useState(new Map());
    // preview = null | { from: string, only: string[] | null, anchorY: number | null }
    //   only = null → show every entity-typed attribute target (eye-icon behaviour)
    //   only = [name] → show just that one (attribute-hover behaviour)
    //   anchorY = absolute y to centre the single ghost on (null → centre on source)
    const [preview, setPreview] = useState(null);
    // active edit popover (null when no popover open)
    // shape: { kind: "field"|"subclass"|"entity"|"relation", x, y, ...ctx }
    const [popover, setPopover] = useState(null);
    const [isFullscreen, setIsFullscreen] = useState(false);

    useEffect(() => {
      function onChange() {
        setIsFullscreen(document.fullscreenElement === containerRef.current);
      }
      document.addEventListener("fullscreenchange", onChange);
      return () => document.removeEventListener("fullscreenchange", onChange);
    }, []);

    function toggleFullscreen() {
      const el = containerRef.current;
      if (!el) return;
      if (document.fullscreenElement) {
        document.exitFullscreen && document.exitFullscreen();
      } else if (el.requestFullscreen) {
        el.requestFullscreen();
      } else if (el.webkitRequestFullscreen) {
        el.webkitRequestFullscreen();
      }
    }

    const entityNames = useMemo(() => new Set(model.entities.keys()), [model]);
    const pathNames = useMemo(() => historyEntries.map(h => h.entity), [historyEntries]);

    // Drop preview on model swap or when its source entity is no longer in history.
    useEffect(() => { setPreview(null); }, [model]);
    useEffect(() => {
      if (preview && !pathNames.includes(preview.from)) setPreview(null);
    }, [pathNames, preview]);
    // Close popover when edit mode is toggled off or the model is swapped.
    useEffect(() => { setPopover(null); }, [model, editMode]);

    // Drop overrides when the model changes or an entity is no longer visible.
    useEffect(() => { setPositionOverrides(new Map()); }, [model]);
    useEffect(() => {
      const present = new Set(pathNames);
      setPositionOverrides(prev => {
        let stale = false;
        for (const name of prev.keys()) if (!present.has(name)) { stale = true; break; }
        if (!stale) return prev;
        const next = new Map();
        for (const [k, v] of prev) if (present.has(k)) next.set(k, v);
        return next;
      });
    }, [pathNames]);

    const layout = useMemo(() => {
      if (!model || !pathNames.length) return { nodes: [], edges: [], totalW: 0 };
      const { columns, entityCol } = buildColumns(model, historyEntries);

      const dimsByName = new Map();
      const visibleSet = new Set();
      for (const col of columns) {
        if (!col) continue;
        for (const tree of col.trees) {
          walkTree(tree, node => {
            visibleSet.add(node.entity);
            dimsByName.set(node.entity, getNodeDims(model.entities.get(node.entity), showAttrs, entityNames, expanded.has(node.entity), editMode));
          });
        }
      }

      const positions = computeColumnPositions(columns, dimsByName, size, showAttrs);
      for (const [name, pos] of positionOverrides) {
        if (positions.has(name)) positions.set(name, pos);
      }
      const pathSet = new Set(pathNames);
      const nodes = [];
      for (const [name, pos] of positions) {
        const ent = model.entities.get(name);
        const dims = dimsByName.get(name);
        nodes.push({
          name, ent,
          x: pos.x, y: pos.y, w: dims.w, h: dims.h,
          role: classifyRole(name, focusedName, model, ent),
          inPath: pathSet.has(name) && name !== focusedName,
          col: entityCol.get(name),
        });
      }
      const edges = model.relations.filter(r => visibleSet.has(r.from) && visibleSet.has(r.to));
      const totalW = totalLayoutWidth(columns, dimsByName, showAttrs);
      return { nodes, edges, totalW };
    }, [model, focusedName, pathNames, historyEntries, showAttrs, size.w, size.h, entityNames, expanded, positionOverrides, editMode]);

    // Ghost layer: while previewing, render entity-typed neighbours that aren't
    // already on the graph. They're purely visual peeks.
    const ghostLayer = useMemo(() => {
      if (!preview || !model || !model.entities.has(preview.from)) return { nodes: [], edges: [], targets: [] };
      const srcNode = layout.nodes.find(n => n.name === preview.from);
      if (!srcNode) return { nodes: [], edges: [], targets: [] };

      const inHistory = new Set(pathNames);
      const allRefs = [];
      const seen = new Set();
      for (const l of bodyLines(model.entities.get(preview.from), entityNames)) {
        if (l.entityRef && !inHistory.has(l.entityRef) && !seen.has(l.entityRef)) {
          seen.add(l.entityRef);
          allRefs.push(l.entityRef);
        }
      }
      const targets = preview.only ? preview.only.filter(t => allRefs.includes(t)) : allRefs;
      if (!targets.length) return { nodes: [], edges: [], targets: [] };

      const GAP_X = 80;
      const GAP_Y = 20;
      const dimsList = targets.map(name => getNodeDims(model.entities.get(name), showAttrs, entityNames, false, false));
      const totalH = dimsList.reduce((a, d) => a + d.h, 0) + GAP_Y * Math.max(0, targets.length - 1);
      const anchorY = (preview.anchorY != null) ? preview.anchorY : srcNode.y;
      let yCursor = anchorY - totalH / 2;
      const ghostNodes = targets.map((name, i) => {
        const ent = model.entities.get(name);
        const dims = dimsList[i];
        const node = {
          name, ent, ghost: true,
          x: srcNode.x + srcNode.w / 2 + GAP_X + dims.w / 2,
          y: yCursor + dims.h / 2,
          w: dims.w, h: dims.h,
          role: classifyRole(name, focusedName, model, ent),
          inPath: false,
        };
        yCursor += dims.h + GAP_Y;
        return node;
      });

      const targetSet = new Set(targets);
      const ghostEdges = model.relations.filter(r =>
        (r.from === preview.from && targetSet.has(r.to)) ||
        (r.to === preview.from && targetSet.has(r.from))
      );
      return { nodes: ghostNodes, edges: ghostEdges, targets };
    }, [preview, model, pathNames, entityNames, showAttrs, focusedName, layout.nodes]);

    function commitPreview(sourceName) {
      const targets = ghostLayer.targets;
      if (!targets.length) {
        setPreview(null);
        return;
      }
      for (const t of targets) onSelect(t, sourceName);
      setPreview(null);
    }

    // Auto-pan so the most recently opened card sits inside the viewport.
    useEffect(() => {
      if (!layout.nodes.length || size.w <= 0) return;
      const scrollTargetName = pathNames.length ? pathNames[pathNames.length - 1] : focusedName;
      const targetNode = layout.nodes.find(n => n.name === scrollTargetName);
      if (!targetNode) return;
      setTransform(t => {
        const k = t.k;
        const contentRight = layout.totalW * k;
        const leftAnchor = -LEFT_MARGIN * k + 16;
        let targetX;
        if (contentRight <= size.w) {
          targetX = leftAnchor;
        } else {
          const naive = size.w / 2 - targetNode.x * k;
          const rightAnchor = size.w - contentRight - 16;
          targetX = Math.min(leftAnchor, Math.max(rightAnchor, naive));
        }
        if (Math.abs(targetX - t.x) < 0.5) return t;
        return { ...t, x: targetX };
      });
    }, [pathNames, focusedName, layout.totalW, size.w, size.h]);

    // panning
    const panRef = useRef({ active: false });
    function onBgMouseDown(e) {
      if (e.target.closest(".graph-node")) return;
      panRef.current = { active: true, sx: e.clientX, sy: e.clientY, ox: transform.x, oy: transform.y };
      if (svgRef.current) svgRef.current.classList.add("panning");
    }

    // node dragging — keep the live zoom in a ref so we don't rebind listeners on every wheel event.
    const dragRef = useRef({ active: false });
    const transformRef = useRef(transform);
    transformRef.current = transform;

    function onNodeDragStart(e, name) {
      e.stopPropagation();
      e.preventDefault();
      const node = layout.nodes.find(n => n.name === name);
      if (!node) return;
      dragRef.current = {
        active: true,
        name,
        startMouseX: e.clientX,
        startMouseY: e.clientY,
        startNodeX: node.x,
        startNodeY: node.y,
      };
      if (svgRef.current) svgRef.current.classList.add("dragging-node");
    }

    useEffect(() => {
      function onMove(e) {
        if (panRef.current.active) {
          const { sx, sy, ox, oy } = panRef.current;
          setTransform(t => ({ ...t, x: ox + (e.clientX - sx), y: oy + (e.clientY - sy) }));
          return;
        }
        if (dragRef.current.active) {
          const { name, startMouseX, startMouseY, startNodeX, startNodeY } = dragRef.current;
          const k = transformRef.current.k || 1;
          const nx = startNodeX + (e.clientX - startMouseX) / k;
          const ny = startNodeY + (e.clientY - startMouseY) / k;
          setPositionOverrides(prev => {
            const next = new Map(prev);
            next.set(name, { x: nx, y: ny });
            return next;
          });
        }
      }
      function onUp() {
        panRef.current.active = false;
        dragRef.current.active = false;
        if (svgRef.current) {
          svgRef.current.classList.remove("panning");
          svgRef.current.classList.remove("dragging-node");
        }
      }
      window.addEventListener("mousemove", onMove);
      window.addEventListener("mouseup", onUp);
      return () => {
        window.removeEventListener("mousemove", onMove);
        window.removeEventListener("mouseup", onUp);
      };
    }, []);

    function onWheel(e) {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const delta = -e.deltaY * 0.001;
        const newK = Math.max(0.3, Math.min(2, transform.k * (1 + delta)));
        const rect = svgRef.current.getBoundingClientRect();
        const mx = e.clientX - rect.left, my = e.clientY - rect.top;
        const nx = mx - (mx - transform.x) * (newK / transform.k);
        const ny = my - (my - transform.y) * (newK / transform.k);
        setTransform({ x: nx, y: ny, k: newK });
      } else {
        setTransform(t => ({ ...t, x: t.x - e.deltaX - e.deltaY * 0.5 }));
      }
    }

    function fit() {
      if (!layout.nodes.length) return;
      const minX = Math.min(...layout.nodes.map(n => n.x - n.w/2)) - 24;
      const maxX = Math.max(...layout.nodes.map(n => n.x + n.w/2)) + 24;
      const minY = Math.min(...layout.nodes.map(n => n.y - n.h/2)) - 24;
      const maxY = Math.max(...layout.nodes.map(n => n.y + n.h/2)) + 24;
      const w = maxX - minX, h = maxY - minY;
      const k = Math.min(size.w / w, size.h / h, 1.2);
      const x = (size.w - w * k) / 2 - minX * k;
      const y = (size.h - h * k) / 2 - minY * k;
      setTransform({ x, y, k });
    }

    const adjOfHover = useMemo(() => {
      if (!hoverName) return null;
      const s = new Set([hoverName]);
      for (const e of layout.edges) {
        if (e.from === hoverName) s.add(e.to);
        if (e.to === hoverName) s.add(e.from);
      }
      return s;
    }, [hoverName, layout.edges]);

    function toggleExpanded(name) {
      setExpanded(prev => {
        const next = new Set(prev);
        if (next.has(name)) next.delete(name); else next.add(name);
        return next;
      });
    }

    return (
      <div className="graph-wrap" ref={containerRef}>
        <svg
          ref={svgRef}
          width="100%"
          height="100%"
          style={{ display: "block" }}
          onMouseDown={onBgMouseDown}
          onWheel={onWheel}
        >
          {MARKERS}
          <g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
            {layout.edges.map((e, i) => {
              const fromN = layout.nodes.find(n => n.name === e.from);
              const toN = layout.nodes.find(n => n.name === e.to);
              if (!fromN || !toN) return null;
              const dimmed = hoverName && !(adjOfHover && adjOfHover.has(e.from) && adjOfHover.has(e.to));
              const editable = editMode && !e.implicit && typeof e.lineNumber === "number";
              return (
                <GraphEdge key={`${e.from}-${e.to}-${i}`}
                  e={e} fromN={fromN} toN={toN}
                  focused={focusedName} dimmed={dimmed}
                  editable={editable}
                  onEditClick={(ev) =>
                    setPopover({ kind: "relation", x: ev.clientX, y: ev.clientY, relation: e })
                  }
                />
              );
            })}
            {layout.nodes.map(n => {
              const focused = n.name === focusedName;
              const dim = hoverName && adjOfHover && !adjOfHover.has(n.name);
              const isExpanded = expanded.has(n.name);
              return (
                <g key={n.name}
                  onMouseEnter={() => setHoverName(n.name)}
                  onMouseLeave={() => setHoverName(null)}
                >
                  <GraphNode
                    n={n}
                    focused={focused}
                    dim={dim}
                    isExpanded={isExpanded}
                    effectiveShowAttrs={showAttrs || isExpanded}
                    entityNames={entityNames}
                    onFocus={onFocus}
                    onSelect={onSelect}
                    onClose={onClose}
                    onAttrClick={onAttrClick}
                    onToggleExpanded={toggleExpanded}
                    onStartDrag={onNodeDragStart}
                    onPreviewEnter={(name) => setPreview({ from: name, only: null, anchorY: null })}
                    onPreviewLeave={() => setPreview(null)}
                    onPreviewCommit={commitPreview}
                    onAttrPreviewEnter={(target, anchorY) =>
                      setPreview({ from: n.name, only: [target], anchorY })
                    }
                    onAttrPreviewLeave={() => setPreview(null)}
                    editMode={editMode}
                    onAddField={(name, ev) =>
                      setPopover({ kind: "field", x: ev.clientX, y: ev.clientY, entity: name })
                    }
                    onAddSubclass={(name, ev) =>
                      setPopover({ kind: "subclass", x: ev.clientX, y: ev.clientY, parent: name })
                    }
                    onEditField={(entityName, attrName, ev) => {
                      const ent = model.entities.get(entityName);
                      if (!ent) return;
                      let existing = null;
                      if (ent.kind === "enum") {
                        existing = { value: attrName };
                      } else {
                        const attr = ent.attributes.find(a => a.name === attrName);
                        if (attr) existing = { name: attr.name, type: attr.type, visibility: attr.visibility };
                      }
                      if (!existing) return;
                      setPopover({ kind: "field", x: ev.clientX, y: ev.clientY, entity: entityName, existing });
                    }}
                  />
                </g>
              );
            })}
            {/* preview layer — ghost edges then ghost nodes */}
            {ghostLayer.edges.map((e, i) => {
              const src = preview?.from;
              const fromN = (e.from === src)
                ? layout.nodes.find(n => n.name === src)
                : ghostLayer.nodes.find(n => n.name === e.from);
              const toN = (e.to === src)
                ? layout.nodes.find(n => n.name === src)
                : ghostLayer.nodes.find(n => n.name === e.to);
              if (!fromN || !toN) return null;
              return (
                <g key={`ghost-${e.from}-${e.to}-${i}`} className="ghost-edge">
                  <GraphEdge e={e} fromN={fromN} toN={toN} focused={focusedName} dimmed={false} />
                </g>
              );
            })}
            {ghostLayer.nodes.map(n => (
              <GraphNode
                key={`ghost-${n.name}`}
                n={n}
                focused={false}
                dim={false}
                isExpanded={false}
                effectiveShowAttrs={showAttrs}
                entityNames={entityNames}
              />
            ))}
          </g>
        </svg>

        <div className="graph-toolbar">
          <button className={showAttrs ? "active" : ""} onClick={() => onToggleAttrs(!showAttrs)} title="Toggle field display">
            {showAttrs ? "▤" : "▢"} Fields
          </button>
          {onToggleEditMode && (
            <button
              className={editMode ? "active" : ""}
              onClick={onToggleEditMode}
              title={editMode ? "Exit edit mode" : "Edit mode: add classes, fields, subclasses directly on the graph"}
            >✎ Edit</button>
          )}
          {editMode && (
            <button
              onClick={(ev) => setPopover({ kind: "entity", x: ev.clientX, y: ev.clientY })}
              title="Add a new class / interface / enum"
            >+ Entity</button>
          )}
          <div className="sep"></div>
          <button onClick={fit} title="Fit to view">⤢ Fit</button>
          <button onClick={() => setTransform({x:0,y:0,k:1})} title="Reset zoom">↺</button>
          <button onClick={() => setTransform(t => ({...t, k: Math.min(2, t.k * 1.2)}))} title="Zoom in">+</button>
          <button onClick={() => setTransform(t => ({...t, k: Math.max(0.3, t.k / 1.2)}))} title="Zoom out">−</button>
          <span style={{padding: "0 8px", fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--text-3)", display: "inline-flex", alignItems: "center"}}>
            {Math.round(transform.k * 100)}%
          </span>
          <div className="sep"></div>
          <button
            onClick={toggleFullscreen}
            title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
            className={isFullscreen ? "active" : ""}
          >{isFullscreen ? "⤓ Exit" : "⛶ Fullscreen"}</button>
        </div>

        <div className="graph-legend">
          <div className="row"><span className="line thick"></span> is a</div>
          <div className="row"><span className="line" style={{borderTopColor: "var(--text-2)", borderTopWidth: 2}}></span> has ◆</div>
          <div className="row"><span className="line"></span> links to</div>
          <div className="row"><span className="line dashed"></span> uses</div>
          <div className="row"><span className="swatch" style={{background: "var(--focal-bg)", border: "1px solid var(--focal-line)"}}></span> start</div>
          <div className="row"><span className="swatch" style={{background: "var(--green)", borderRadius: "50%"}}></span> visited</div>
        </div>

        <div className="graph-hint">
          <span>{editMode
            ? "edit mode · click an arrow to edit · + field / + sub on each node"
            : "drag header to move · 👁 peek neighbors (click to commit) · × to close · drag bg to pan · ⌘/ctrl+scroll to zoom"}
          </span>
        </div>
        {popover && popover.kind === "entity" && Popovers.AddEntityPopover && (
          <Popovers.AddEntityPopover
            anchor={{ x: popover.x, y: popover.y }}
            existingNames={entityNames}
            parents={[...model.entities.keys()]}
            onClose={() => setPopover(null)}
            onSubmit={(payload) => onEdit && onEdit({
              op: "addEntity",
              name: payload.name,
              kind: payload.kind,
              stereotype: payload.stereotype,
              extendsFrom: payload.extendsFrom,
            })}
          />
        )}
        {popover && popover.kind === "subclass" && Popovers.AddEntityPopover && (
          <Popovers.AddEntityPopover
            anchor={{ x: popover.x, y: popover.y }}
            existingNames={entityNames}
            parents={[...model.entities.keys()]}
            defaultExtendsFrom={popover.parent}
            defaultKind={model.entities.get(popover.parent)?.kind === "interface" ? "class" : undefined}
            onClose={() => setPopover(null)}
            onSubmit={(payload) => onEdit && onEdit({
              op: "addEntity",
              name: payload.name,
              kind: payload.kind,
              stereotype: payload.stereotype,
              extendsFrom: payload.extendsFrom,
            })}
          />
        )}
        {popover && popover.kind === "field" && Popovers.AddFieldPopover && (() => {
          const ent = model.entities.get(popover.entity);
          const isEnum = ent?.kind === "enum";
          return (
            <Popovers.AddFieldPopover
              anchor={{ x: popover.x, y: popover.y }}
              entity={popover.entity}
              isEnum={isEnum}
              knownTypes={[...model.entities.keys()]}
              existing={popover.existing}
              onClose={() => setPopover(null)}
              onSubmit={(payload) => {
                if (payload.kind === "enumvalue") {
                  onEdit && onEdit({ op: "addEnumValue", entity: payload.entity, value: payload.value });
                } else if (payload.kind === "enumvalue-update") {
                  onEdit && onEdit({ op: "updateEnumValue", entity: payload.entity, oldValue: payload.oldValue, value: payload.value });
                } else if (payload.kind === "attribute") {
                  onEdit && onEdit({
                    op: "addAttribute",
                    entity: payload.entity,
                    name: payload.name,
                    type: payload.type,
                    visibility: payload.visibility,
                  });
                } else if (payload.kind === "attribute-update") {
                  onEdit && onEdit({
                    op: "updateAttribute",
                    entity: payload.entity,
                    oldName: payload.oldName,
                    name: payload.name,
                    type: payload.type,
                    visibility: payload.visibility,
                  });
                }
              }}
              onDelete={popover.existing ? () => {
                if (isEnum) {
                  onEdit && onEdit({ op: "removeEnumValue", entity: popover.entity, value: popover.existing.value });
                } else {
                  onEdit && onEdit({ op: "removeAttribute", entity: popover.entity, name: popover.existing.name });
                }
              } : undefined}
            />
          );
        })()}
        {popover && popover.kind === "relation" && Popovers.EditRelationPopover && (
          <Popovers.EditRelationPopover
            anchor={{ x: popover.x, y: popover.y }}
            relation={popover.relation}
            onClose={() => setPopover(null)}
            onUpdate={(patch) => onEdit && onEdit({
              op: "updateRelation",
              lineNumber: popover.relation.lineNumber,
              patch,
            })}
            onDelete={() => onEdit && onEdit({
              op: "deleteRelation",
              lineNumber: popover.relation.lineNumber,
              from: popover.relation.from,
              to: popover.relation.to,
            })}
          />
        )}
      </div>
    );
  }

  window.Graph = Graph;
}
