// fc-components.jsx — shared primitives, NodeCard, FcCanvas, ChartSidebar

const { useState, useRef, useEffect, useMemo, useCallback } = React;

const NODE_W = 230;
const NODE_H_EST = 100;

// ── Shared primitives ──────────────────────────────────────────────────────
const fcInputStyle = {
  background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8,
  padding: '7px 10px', color: 'var(--text)', fontSize: 12, width: '100%',
  outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
};
const fcSelectStyle = { ...fcInputStyle, cursor: 'pointer' };
const fcBtnStyle = {
  background: 'transparent', border: '1px solid var(--border)', borderRadius: 8,
  padding: '6px 12px', fontSize: 11, cursor: 'pointer', color: 'var(--text)',
  fontFamily: 'inherit', fontWeight: 500, transition: 'all 0.12s',
};

function FcLabel({ children }) {
  return <div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text2)', marginBottom: 5 }}>{children}</div>;
}
function FcInput(props) { return <input style={fcInputStyle} {...props} />; }
function FcTextarea(props) { return <textarea style={{ ...fcInputStyle, resize: 'vertical', minHeight: 64, lineHeight: 1.5 }} {...props} />; }
function FcSelect({ children, ...props }) { return <select style={fcSelectStyle} {...props}>{children}</select>; }
function FcBtn({ children, onClick, danger, primary, subtle, disabled, title, style: extra }) {
  return (
    <button onClick={onClick} disabled={disabled} title={title} style={{
      ...fcBtnStyle,
      background: primary ? 'var(--accent)' : subtle ? 'var(--bg2)' : 'transparent',
      color: primary ? '#fff' : danger ? '#f87171' : 'var(--text)',
      borderColor: primary ? 'var(--accent)' : danger ? '#f8717140' : subtle ? 'transparent' : 'var(--border)',
      opacity: disabled ? 0.4 : 1, cursor: disabled ? 'not-allowed' : 'pointer',
      ...extra,
    }}>{children}</button>
  );
}

function ISection({ label, children }) {
  return (
    <section style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      <div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--text2)', paddingBottom: 6, borderBottom: '1px solid var(--border)' }}>{label}</div>
      {children}
    </section>
  );
}
function IField({ label, children }) {
  return <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}><FcLabel>{label}</FcLabel>{children}</div>;
}

function HudBtn({ children, onClick, title, active, disabled }) {
  return (
    <button onClick={onClick} title={title} disabled={disabled} style={{
      background: active ? 'var(--accent)' : 'transparent',
      border: `1px solid ${active ? 'var(--accent)' : 'transparent'}`,
      borderRadius: 99, padding: '4px 10px', fontSize: 11, fontWeight: 500,
      cursor: disabled ? 'not-allowed' : 'pointer',
      color: active ? '#fff' : disabled ? 'var(--text2)' : 'var(--text)',
      fontFamily: 'inherit', transition: 'all 0.1s', whiteSpace: 'nowrap',
      opacity: disabled ? 0.45 : 1,
    }}>{children}</button>
  );
}
function HudDiv() { return <span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 3px' }} />; }

// ── NodeCard ───────────────────────────────────────────────────────────────
function NodeCard({ node, nodeTypes, statuses, isSelected, isMultiSelected, isConnectSource, connectMode, roundedNodes,
  isEditing, editingField, collapsedCount, hidingAncestorCount, searchMatch, remotePresence,
  levels, linkedCount,
  onBodyPointerDown, onBodyClick, onBodyContextMenu, onLabelClick, onNotesClick, onOpenExpanded,
  onEditChange, onEditEnd, onImageDrop, onChainClick }) {

  const type   = nodeTypes.find(t => t.id === node.typeId) || nodeTypes[0] || { name: 'Generic', color: '#94a3b8' };
  const sm     = getStatus(statuses, node.status);
  const radius = roundedNodes !== false ? 14 : 6;
  const editingLabel = isEditing && editingField === 'label';
  const editingNotes = isEditing && editingField === 'notes';
  const rawThumb = (node.images || []).length > 0 ? node.images[0] : null;
  // This value is interpolated into a CSS `url("…")`. Project data can come
  // from an untrusted #share= link or imported JSON, so only allow well-formed
  // image sources with no characters that could break out of the url() and
  // inject extra CSS (or smuggle in a beacon URL).
  const thumb = (typeof rawThumb === 'string'
    && /^(data:image\/|https?:\/\/|blob:)/i.test(rawThumb)
    && !/["'()\\\s]/.test(rawThumb)) ? rawThumb : null;
  const [dragOver, setDragOver] = useState(false);
  // When selected, show the full summary instead of the 2-line clamp
  const showFullNotes = isSelected;
  const hasDetails = (node.details || '').trim().length > 0;
  const imageCount = (node.images || []).length;

  function handleDragOver(e) {
    if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDragOver(true); }
  }
  function handleDragLeave() { setDragOver(false); }
  function handleDrop(e) {
    e.preventDefault(); e.stopPropagation(); setDragOver(false);
    const files = [...(e.dataTransfer.files || [])].filter(f => f.type.startsWith('image/'));
    if (files.length && onImageDrop) onImageDrop(node.id, files);
  }

  // Multi-select highlight uses the accent color with a dashed outline so it
  // visually distinguishes from the primary selection (which is the node's
  // own type color). When BOTH apply (this is the primary node within a
  // multi-select), the primary style wins on the inner border + glow.
  const baseBorderColor = isSelected ? type.color
    : isConnectSource ? '#22c55e'
    : isMultiSelected ? 'var(--accent)'
    : searchMatch ? type.color + '88'
    : 'var(--border)';
  const accentBorder = dragOver ? `1.5px solid #22c55e` : `1.5px solid ${baseBorderColor}`;

  return (
    <article
      data-node-id={node.id}
      onPointerDown={onBodyPointerDown}
      onClick={onBodyClick}
      onContextMenu={onBodyContextMenu}
      onDoubleClick={e => { e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      style={{
        position: 'absolute', left: node.x, top: node.y, width: NODE_W,
        background: dragOver ? 'var(--accent-muted)' : 'var(--surface)',
        border: accentBorder,
        borderTop: `3px solid ${type.color}`,
        borderRadius: radius,
        padding: '10px 12px 11px',
        cursor: connectMode ? 'crosshair' : editingLabel || editingNotes ? 'text' : 'grab',
        userSelect: editingLabel || editingNotes ? 'text' : 'none',
        boxShadow: isSelected
          ? `0 0 0 3px ${type.color}28, 0 12px 32px rgba(0,0,0,0.28)`
          : isMultiSelected ? '0 0 0 3px var(--accent-muted), 0 6px 20px rgba(0,0,0,0.22)'
          : isConnectSource ? '0 0 0 3px #22c55e40, 0 8px 24px rgba(0,0,0,0.2)'
          : searchMatch ? `0 0 0 2px ${type.color}44, 0 4px 16px rgba(0,0,0,0.2)`
          : '0 2px 10px rgba(0,0,0,0.16)',
        transition: 'box-shadow 0.13s, border-color 0.13s, background 0.1s',
        overflow: 'visible',
        opacity: searchMatch === false ? 0.3 : 1,
      }}
    >
      {/* Linked-nodes chain icon (top-right). Shows count of siblings in the
          same link group. Click to open a popover that lists them and lets
          the user jump to each. */}
      {linkedCount > 0 && (
        <button
          onClick={e => { e.stopPropagation(); onChainClick && onChainClick(); }}
          onPointerDown={e => e.stopPropagation()}
          title={`Linked to ${linkedCount} other node${linkedCount !== 1 ? 's' : ''} — click to view`}
          style={{
            position: 'absolute', top: -8,
            right: (remotePresence && remotePresence.length > 0) ? 56 : -8,
            background: 'var(--accent)',
            color: '#fff', border: '2px solid var(--surface)',
            borderRadius: 99, padding: '2px 7px', fontSize: 9, fontWeight: 800,
            cursor: 'pointer', lineHeight: 1.2, letterSpacing: '0.02em',
            boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
            display: 'inline-flex', alignItems: 'center', gap: 3,
            fontFamily: 'inherit', zIndex: 5,
          }}>
          🔗 {linkedCount}
        </button>
      )}
      {/* Remote presence stack (top-right corner) */}
      {remotePresence && remotePresence.length > 0 && (
        <div style={{ position: 'absolute', top: 6, right: 6, display: 'flex', gap: 2, zIndex: 20, pointerEvents: 'none' }}>
          {remotePresence.slice(0, 3).map((u, i) => (
            <div key={u.id} title={u.name}
              style={{
                width: 18, height: 18, borderRadius: '50%', background: u.color, fontSize: 8, fontWeight: 800,
                color: '#fff', display: 'grid', placeItems: 'center', border: '1px solid var(--surface)',
                boxShadow: '0 0 0 0.5px ' + u.color, pointerEvents: 'auto',
              }}>{(u.name || '?')[0]}</div>
          ))}
        </div>
      )}

      {/* Type + status row. Level pill (depth from root) lives inline with
          the type label so it's INSIDE the card. Numbers only — no "L"
          prefix — per the user's spec. Multi-path nodes show all reachable
          depths comma-separated. */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 7, gap: 4 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0, flex: 1 }}>
          {Array.isArray(levels) && levels.length > 0 && (
            <span
              title={levels.length > 1
                ? `Reachable at depth ${levels.join(', ')} from root`
                : `Depth ${levels[0]} from root`}
              style={{
                background: levelColor(levels[0]),
                color: '#0b1220',
                fontSize: 9, fontWeight: 800,
                padding: '1px 6px',
                borderRadius: 99, lineHeight: 1.35, letterSpacing: '0.02em',
                flexShrink: 0,
              }}>
              {levels.join(', ')}
            </span>
          )}
          <span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: type.color, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{type.name}</span>
        </div>
        <span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, fontWeight: 600, color: sm.color, flexShrink: 0 }}>
          <span style={{ width: 5, height: 5, borderRadius: '50%', background: sm.color, display: 'inline-block', flexShrink: 0 }} />
          {sm.label}
        </span>
      </div>

      {/* Thumbnail */}
      {thumb && !editingLabel && !editingNotes && (
        <div
          onClick={e => { e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
          style={{
            width: '100%', height: 78, borderRadius: 6, overflow: 'hidden',
            background: `center/cover no-repeat url("${thumb}"), var(--bg2)`,
            marginBottom: 8, border: '1px solid var(--border)', position: 'relative', cursor: 'zoom-in',
          }}
        >
          {node.images.length > 1 && (
            <span style={{ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.65)', color: '#fff', fontSize: 9, fontWeight: 700, padding: '2px 6px', borderRadius: 99 }}>
              +{node.images.length - 1}
            </span>
          )}
        </div>
      )}

      {/* Label */}
      {editingLabel ? (
        <input autoFocus defaultValue={node.label}
          onChange={e => onEditChange('label', e.target.value)}
          onBlur={onEditEnd}
          onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onEditEnd(); } if (e.key === 'Escape') onEditEnd(); }}
          onPointerDown={e => e.stopPropagation()}
          onClick={e => e.stopPropagation()}
          style={{ ...fcInputStyle, fontSize: 13, fontWeight: 700, padding: '3px 6px', marginBottom: 4, background: 'var(--bg2)' }}
        />
      ) : (
        <div
          onClick={e => { e.stopPropagation(); onLabelClick && onLabelClick(); }}
          onDoubleClick={e => { e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
          title="Click to edit title · Double-click to expand"
          style={{
            display: 'block', fontSize: 13.5, fontWeight: 700, lineHeight: 1.3,
            color: 'var(--text)', marginBottom: 4,
            cursor: 'text', padding: '3px 5px', margin: '0 -5px 4px',
            borderRadius: 5, transition: 'background 0.1s',
          }}
          onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
          onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
        >
          <strong style={{ display: 'block' }}>{node.label || 'Untitled'}</strong>
        </div>
      )}

      {/* Notes */}
      {editingNotes ? (
        <textarea autoFocus defaultValue={node.notes}
          onChange={e => onEditChange('notes', e.target.value)}
          onBlur={onEditEnd}
          onKeyDown={e => { if (e.key === 'Escape') onEditEnd(); }}
          onPointerDown={e => e.stopPropagation()}
          onClick={e => e.stopPropagation()}
          rows={3}
          style={{ ...fcInputStyle, fontSize: 11, padding: '4px 6px', resize: 'none', background: 'var(--bg2)', lineHeight: 1.4 }}
        />
      ) : (
        <div
          onClick={e => { e.stopPropagation(); onNotesClick && onNotesClick(); }}
          onDoubleClick={e => { e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
          title={node.notes ? (showFullNotes ? 'Click to edit · Double-click to expand' : 'Click once to expand summary · Click again to edit') : 'Click to add notes'}
          style={{
            margin: '0 -5px', fontSize: 11, color: node.notes ? 'var(--text2)' : 'var(--text2)',
            lineHeight: 1.45,
            overflow: 'hidden',
            display: showFullNotes ? 'block' : '-webkit-box',
            WebkitLineClamp: showFullNotes ? 'unset' : 2,
            WebkitBoxOrient: 'vertical',
            whiteSpace: 'pre-wrap', wordBreak: 'break-word',
            cursor: 'text', fontStyle: node.notes ? 'normal' : 'italic', opacity: node.notes ? 1 : 0.45,
            minHeight: 24, padding: '3px 5px', borderRadius: 5, transition: 'background 0.1s, max-height 0.2s',
          }}
          onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
          onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
        >{node.notes || 'Add notes…'}</div>
      )}

      {/* Tags */}
      {(node.tags || []).length > 0 && !editingLabel && !editingNotes && (
        <div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 3 }}>
          {node.tags.map((tag, i) => (
            <span key={i} style={{ fontSize: 9, fontWeight: 600, color: type.color, background: type.color + '18', padding: '1px 6px', borderRadius: 99 }}>#{tag}</span>
          ))}
        </div>
      )}

      {/* Footer badges */}
      {((node.assignees || []).length > 0 || node.subChartId || node.poll || hasDetails || imageCount > 0 || collapsedCount > 0 || hidingAncestorCount > 0) && (
        <div style={{ marginTop: 8, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
          {hasDetails && (
            <span title="Has extra details" style={{ fontSize: 9, fontWeight: 700, color: '#38bdf8', background: '#38bdf818', padding: '2px 7px', borderRadius: 99, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
              <span style={{ fontSize: 11, lineHeight: 1 }}>≡</span> Details
            </span>
          )}
          {imageCount > 0 && (
            <span title={`${imageCount} image${imageCount !== 1 ? 's' : ''}`} style={{ fontSize: 9, fontWeight: 700, color: '#fb923c', background: '#fb923c18', padding: '2px 7px', borderRadius: 99, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
              <span style={{ fontSize: 10, lineHeight: 1 }}>▣</span> {imageCount}
            </span>
          )}
          {(node.assignees || []).map((a, i) => (
            <span key={i} style={{ fontSize: 9, background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text2)', padding: '1px 6px', borderRadius: 99 }}>{a}</span>
          ))}
          {node.poll && (
            <span style={{ fontSize: 9, fontWeight: 700, color: '#a78bfa', background: '#a78bfa18', padding: '2px 7px', borderRadius: 99 }}>◉ Poll</span>
          )}
          {node.subChartId && (
            <span style={{ fontSize: 9, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-muted)', padding: '2px 7px', borderRadius: 99 }}>↳ sub-chart</span>
          )}
          {collapsedCount > 0 && (
            <span style={{ fontSize: 9, fontWeight: 700, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>▶ {collapsedCount} hidden</span>
          )}
          {hidingAncestorCount > 0 && (
            <span style={{ fontSize: 9, fontWeight: 700, color: '#c084fc', background: '#c084fc18', padding: '2px 7px', borderRadius: 99 }}>△ {hidingAncestorCount} above</span>
          )}
        </div>
      )}
    </article>
  );
}

// ── Menu primitives (hoisted so React doesn't unmount/remount them on
// every parent state change like mouseenter→setHov, which was eating clicks)
function MenuItem({ icon, label, onClick, onClose, red, accent, disabled, shortcut }) {
  const [hov, setHov] = useState(false);
  return (
    <button
      type="button"
      onMouseDown={e => { e.stopPropagation(); }}
      onPointerDown={e => e.stopPropagation()}
      onClick={e => {
        e.stopPropagation();
        if (disabled) return;
        onClick && onClick();
        onClose && onClose();
      }}
      onMouseEnter={() => setHov(true)}
      onMouseLeave={() => setHov(false)}
      style={{
        display: 'flex', alignItems: 'center', gap: 8, width: '100%',
        background: hov && !disabled ? 'var(--bg2)' : 'transparent',
        border: 'none', borderRadius: 7, padding: '7px 10px', cursor: disabled ? 'not-allowed' : 'pointer',
        color: disabled ? 'var(--text2)' : red ? '#f87171' : accent ? 'var(--accent)' : 'var(--text)',
        fontSize: 12, fontFamily: 'inherit', textAlign: 'left', opacity: disabled ? 0.45 : 1,
        transition: 'background 0.08s',
      }}>
      <span style={{ width: 16, textAlign: 'center', opacity: 0.75, fontSize: 13, flexShrink: 0 }}>{icon}</span>
      <span style={{ flex: 1 }}>{label}</span>
      {shortcut && <span style={{ fontSize: 9, color: 'var(--text2)', opacity: 0.7 }}>{shortcut}</span>}
    </button>
  );
}
function MenuDivider() { return <div style={{ height: 1, background: 'var(--border)', margin: '3px 0' }} />; }

// ── Node Context Menu ──────────────────────────────────────────────────────
function NodeContextMenu({ node, x, y, chart, isRoot, collapsedCount, hidingAncestorCount,
  onClose, onAddChild, onStartConnect, onToggleCollapse, onToggleHideAncestors,
  onOpenSubChart, onCreateSubChart, onDelete, onOpenExpanded, onDuplicate,
  onSplitToSubChart, onPromoteToRoot,
  onAttachChartAsSubChart, onDetachSubChart, allCharts, currentChartId, readOnly,
  onLinkNodes, onUnlinkNode, project }) {

  const isCollapsed   = collapsedCount > 0;
  const isHidingAnc   = hidingAncestorCount > 0;
  const descendantsCnt = getDescendants(chart, node.id).size;
  const ancestorsCnt   = getAncestors(chart, node.id).size;
  const [showLinkMenu, setShowLinkMenu]         = useState(false);
  const [showLinkNodeMenu, setShowLinkNodeMenu] = useState(false);
  const [nodeQuery, setNodeQuery]               = useState('');
  const linkableCharts = (allCharts || []).filter(c => c.id !== currentChartId && c.id !== node.subChartId);
  const linkedChart    = node.subChartId && (allCharts || []).find(c => c.id === node.subChartId);
  const linkedSiblings = (typeof findLinkedNodes === 'function' && node.linkGroupId && project)
    ? findLinkedNodes(project, node.linkGroupId, node.id) : [];

  // Build a flat list of pickable nodes for "Link existing node" — every
  // node in the project EXCEPT this one and any already in the same link
  // group. Filtered by query (label/notes/chart-title).
  const pickableNodes = (allCharts || []).flatMap(c =>
    (c.nodes || []).filter(n => {
      if (c.id === currentChartId && n.id === node.id) return false;
      if (node.linkGroupId && n.linkGroupId === node.linkGroupId) return false;
      if (!nodeQuery.trim()) return true;
      const q = nodeQuery.toLowerCase();
      return (n.label || '').toLowerCase().includes(q)
          || (n.notes || '').toLowerCase().includes(q)
          || (c.title || '').toLowerCase().includes(q);
    }).map(n => ({ chart: c, node: n }))
  ).slice(0, 30);

  const menuRef = useRef(null);

  // Close on click outside / Escape — avoids the fragility of a fullscreen
  // overlay (which can eat clicks under some stacking-context / transform
  // scenarios even when z-index "should" win).
  useEffect(() => {
    function onDocDown(e) {
      if (menuRef.current && !menuRef.current.contains(e.target)) onClose();
    }
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    // Use capture so we see the event before any other handler can swallow it.
    document.addEventListener('mousedown', onDocDown, true);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocDown, true);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose]);

  return (
    <div ref={menuRef} style={{
      position: 'absolute', left: x, top: y, zIndex: 60, pointerEvents: 'auto',
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 12, padding: 5,
      boxShadow: '0 8px 32px rgba(0,0,0,0.32)', minWidth: 224,
    }}
    onMouseDown={e => e.stopPropagation()}
    onPointerDown={e => e.stopPropagation()}
    onContextMenu={e => e.preventDefault()}>
        <div style={{ padding: '5px 10px 7px', borderBottom: '1px solid var(--border)', marginBottom: 4 }}>
          <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.label}</div>
        </div>
        <MenuItem onClose={onClose} icon="⛶" label="Open expanded view" onClick={onOpenExpanded} shortcut="↵" />
        {!readOnly && <MenuItem onClose={onClose} icon="+" label="Add child node" onClick={onAddChild} accent shortcut="N" />}
        {!readOnly && <MenuItem onClose={onClose} icon="⧉" label="Duplicate node" onClick={onDuplicate} shortcut="⌘D" />}
        {!readOnly && <MenuItem onClose={onClose} icon="⟶" label="Connect from here" onClick={onStartConnect} shortcut="C" />}
        {node.subChartId
          ? <MenuItem onClose={onClose} icon="↳" label={`Open sub-chart${linkedChart ? ` · ${linkedChart.title}` : ''}`} onClick={() => onOpenSubChart(node.subChartId, node.label)} accent />
          : (!readOnly && <MenuItem onClose={onClose} icon="↳" label="Create new sub-chart" onClick={() => onCreateSubChart(node.id, node.label)} />)}
        {!readOnly && !node.subChartId && linkableCharts.length > 0 && !showLinkMenu && (
          <button
            onClick={e => { e.stopPropagation(); setShowLinkMenu(true); }}
            style={{
              display: 'flex', alignItems: 'center', gap: 8, width: '100%',
              background: 'transparent', border: 'none', borderRadius: 7, padding: '7px 10px',
              cursor: 'pointer', color: 'var(--text)', fontSize: 12, fontFamily: 'inherit', textAlign: 'left',
            }}
            onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
            onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
          >
            <span style={{ width: 16, textAlign: 'center', opacity: 0.75, fontSize: 13, flexShrink: 0 }}>⇢</span>
            <span style={{ flex: 1 }}>Link existing chart as sub-chart…</span>
            <span style={{ fontSize: 10, color: 'var(--text2)' }}>▸</span>
          </button>
        )}
        {!readOnly && !node.subChartId && showLinkMenu && (
          <div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 7, margin: '3px 0', padding: 4, maxHeight: 180, overflowY: 'auto' }}>
            <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 700, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
              Pick a chart to link
            </div>
            {linkableCharts.map(c => (
              <MenuItem onClose={onClose} key={c.id} icon="↳" label={c.title || 'Untitled'} onClick={() => onAttachChartAsSubChart(c.id)} />
            ))}
            <MenuItem onClose={onClose} icon="×" label="Cancel" onClick={() => setShowLinkMenu(false)} />
          </div>
        )}
        {!readOnly && node.subChartId && (
          <MenuItem onClose={onClose} icon="⊗" label="Unlink sub-chart" onClick={onDetachSubChart} />
        )}
        {!readOnly && !node.subChartId && descendantsCnt > 0 && (
          <MenuItem onClose={onClose} icon="⎘" label={`Split ${descendantsCnt} descendants into sub-chart`} onClick={onSplitToSubChart} />
        )}
        {!readOnly && !isRoot && (
          <MenuItem onClose={onClose} icon="★" label="Make this the chart root" onClick={onPromoteToRoot} />
        )}

        {/* ── Linked-node section ─────────────────────────────────────────
            Lets the user join this node to another existing node anywhere
            in the project so their content stays in sync. Bidirectional —
            editing either side propagates to all members of the group. */}
        {!readOnly && <MenuDivider />}
        {!readOnly && linkedSiblings.length > 0 && (
          <div style={{ padding: '4px 10px 2px', fontSize: 9, fontWeight: 700, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
            🔗 Linked to {linkedSiblings.length} other node{linkedSiblings.length !== 1 ? 's' : ''}
          </div>
        )}
        {!readOnly && linkedSiblings.length > 0 && (
          <MenuItem onClose={onClose} icon="⊘" label="Unlink this node" onClick={() => onUnlinkNode && onUnlinkNode(node.id)} />
        )}
        {!readOnly && !showLinkNodeMenu && (
          <button
            onClick={e => { e.stopPropagation(); setShowLinkNodeMenu(true); setShowLinkMenu(false); }}
            onMouseDown={e => e.stopPropagation()}
            onPointerDown={e => e.stopPropagation()}
            style={{
              display: 'flex', alignItems: 'center', gap: 8, width: '100%',
              background: 'transparent', border: 'none', borderRadius: 7, padding: '7px 10px',
              cursor: 'pointer', color: 'var(--text)', fontSize: 12, fontFamily: 'inherit', textAlign: 'left',
            }}
            onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
            onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
          >
            <span style={{ width: 16, textAlign: 'center', opacity: 0.75, fontSize: 13, flexShrink: 0 }}>🔗</span>
            <span style={{ flex: 1 }}>Link to existing node…</span>
            <span style={{ fontSize: 10, color: 'var(--text2)' }}>▸</span>
          </button>
        )}
        {!readOnly && showLinkNodeMenu && (
          <div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 7, margin: '3px 0', padding: 5, display: 'flex', flexDirection: 'column', gap: 5 }}
               onMouseDown={e => e.stopPropagation()}
               onPointerDown={e => e.stopPropagation()}>
            <div style={{ padding: '2px 4px 0', fontSize: 10, color: 'var(--text2)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
              Pick a node to link
            </div>
            <input
              autoFocus value={nodeQuery}
              onChange={e => setNodeQuery(e.target.value)}
              onKeyDown={e => { if (e.key === 'Escape') { setShowLinkNodeMenu(false); setNodeQuery(''); } }}
              placeholder="Search by title, notes, chart…"
              style={fcInputStyle}
            />
            <p style={{ margin: '0 4px', fontSize: 10, color: 'var(--text2)', lineHeight: 1.4 }}>
              Editing any linked node updates them all. Picking a target will overwrite its content with this node's.
            </p>
            <div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
              {pickableNodes.length === 0 ? (
                <div style={{ padding: '10px 6px', fontSize: 11, color: 'var(--text2)', fontStyle: 'italic', textAlign: 'center' }}>
                  No matching nodes.
                </div>
              ) : pickableNodes.map(({ chart: c, node: cand }) => (
                <button key={c.id + ':' + cand.id}
                  onClick={() => {
                    if (onLinkNodes) onLinkNodes(node.id, c.id, cand.id);
                    setShowLinkNodeMenu(false); setNodeQuery(''); onClose && onClose();
                  }}
                  onMouseDown={e => e.stopPropagation()}
                  onPointerDown={e => e.stopPropagation()}
                  style={{
                    textAlign: 'left', background: 'transparent', border: '1px solid transparent',
                    borderRadius: 5, padding: '5px 7px', cursor: 'pointer',
                    color: 'var(--text)', fontFamily: 'inherit', fontSize: 11,
                  }}
                  onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg)'; e.currentTarget.style.borderColor = 'var(--accent)'; }}
                  onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; }}
                >
                  <strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                    {cand.label || 'Untitled'}
                  </strong>
                  <span style={{ color: 'var(--text2)', fontSize: 10 }}>{c.title}</span>
                </button>
              ))}
            </div>
            <button onClick={() => { setShowLinkNodeMenu(false); setNodeQuery(''); }}
              onMouseDown={e => e.stopPropagation()}
              onPointerDown={e => e.stopPropagation()}
              style={{ ...fcBtnStyle, alignSelf: 'flex-end', fontSize: 10, padding: '3px 8px' }}>
              Cancel
            </button>
          </div>
        )}
        <MenuDivider />
        {!readOnly && (
          <MenuItem onClose={onClose}
            icon={isCollapsed ? '▶' : '▼'}
            label={isCollapsed ? `Show ${descendantsCnt} descendants` : `Hide ${descendantsCnt} descendants`}
            onClick={onToggleCollapse} disabled={descendantsCnt === 0}
          />
        )}
        {!readOnly && (
          <MenuItem onClose={onClose}
            icon={isHidingAnc ? '▽' : '△'}
            label={isHidingAnc ? `Show ${ancestorsCnt} ancestors` : `Hide ${ancestorsCnt} ancestors`}
            onClick={onToggleHideAncestors} disabled={ancestorsCnt === 0}
          />
        )}
        {!readOnly && <MenuDivider />}
        {!readOnly && <MenuItem onClose={onClose} icon="✕" label="Delete node" onClick={onDelete} red shortcut="⌫" />}
    </div>
  );
}

// ── Edge Route Menu (right-click on a connection line) ────────────────────
// SidePicker / StylePicker are defined OUTSIDE the menu component on purpose:
// nested components get unmounted/remounted on every parent render (new
// function identity), which can swallow real user clicks. Hoisting them and
// stopPropagation'ing pointerdown on each button (mirroring MenuItem) keeps
// clicks reliable.
function FcRoutePill({ active, onPick, children, title }) {
  return (
    <button
      type="button"
      title={title}
      onPointerDown={e => e.stopPropagation()}
      onMouseDown={e => e.stopPropagation()}
      onClick={e => { e.stopPropagation(); onPick && onPick(); }}
      style={{
        flex: 1, fontSize: 10, fontWeight: 600,
        background: active ? 'var(--accent)' : 'var(--bg)',
        color: active ? '#fff' : 'var(--text)',
        border: `1px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
        borderRadius: 6, padding: '3px 6px', cursor: 'pointer',
        fontFamily: 'inherit', textTransform: 'capitalize',
      }}>{children}</button>
  );
}
function FcSidePicker({ label, valueKey, route, onChange }) {
  const val = (route && route[valueKey]) || 'auto';
  const sides = ['auto','top','right','bottom','left'];
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
      <span style={{ width: 56, color: 'var(--text2)' }}>{label}</span>
      <div style={{ display: 'flex', gap: 2, flex: 1 }}>
        {sides.map(s => (
          <FcRoutePill key={s} active={val === s} onPick={() => onChange({ [valueKey]: s })}>{s}</FcRoutePill>
        ))}
      </div>
    </div>
  );
}

function EdgeRouteMenu({ edge, x, y, onClose, onChange, onClearBend }) {
  const menuRef = useRef(null);
  useEffect(() => {
    function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    document.addEventListener('mousedown', onDocDown, true);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocDown, true);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose]);
  if (!edge) return null;
  const route = edge.route || {};
  const styles = [
    { id: 'curved',     label: 'Curved' },
    { id: 'straight',   label: 'Straight' },
    { id: 'orthogonal', label: 'Right-angle' },
  ];
  const left = Math.min(x, (typeof window !== 'undefined' ? window.innerWidth  : 1200) - 270);
  const top  = Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 800) - 220);
  return (
    <div ref={menuRef} data-fc-edge-menu="true" style={{
      position: 'absolute', left, top, zIndex: 70,
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 12, padding: 10, width: 260,
      boxShadow: '0 8px 32px rgba(0,0,0,0.32)',
      display: 'flex', flexDirection: 'column', gap: 8,
    }}
    onContextMenu={e => e.preventDefault()}
    onMouseDown={e => e.stopPropagation()}
    onPointerDown={e => e.stopPropagation()}>
      <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Connection routing</div>
      <FcSidePicker label="Source" valueKey="fromSide" route={route} onChange={onChange} />
      <FcSidePicker label="Target" valueKey="toSide"   route={route} onChange={onChange} />
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
        <span style={{ width: 56, color: 'var(--text2)' }}>Style</span>
        <div style={{ display: 'flex', gap: 2, flex: 1 }}>
          {styles.map(s => {
            const active = (route.style || 'curved') === s.id;
            return (
              <FcRoutePill key={s.id} active={active} onPick={() => onChange({ style: s.id })}>{s.label}</FcRoutePill>
            );
          })}
        </div>
      </div>
      <p style={{ margin: 0, fontSize: 10, color: 'var(--text2)', lineHeight: 1.5 }}>
        Right-drag the line's mid-handle to bend it freely.
      </p>
      {route.bend && (
        <FcBtn onClick={onClearBend} style={{ alignSelf: 'flex-start', fontSize: 10 }}>↺ Clear bend</FcBtn>
      )}
    </div>
  );
}

// ── Linked-nodes popover (chain icon click) ───────────────────────────────
function LinkedNodesPopover({ x, y, node, siblings, onClose, onJump, onUnlink, readOnly }) {
  const menuRef = useRef(null);
  useEffect(() => {
    function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    document.addEventListener('mousedown', onDocDown, true);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocDown, true);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose]);
  return (
    <div ref={menuRef} style={{
      position: 'absolute', left: x, top: y, zIndex: 70,
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 12, padding: 8, width: 240,
      boxShadow: '0 8px 32px rgba(0,0,0,0.32)',
      display: 'flex', flexDirection: 'column', gap: 6,
    }}
    onContextMenu={e => e.preventDefault()}
    onMouseDown={e => e.stopPropagation()}
    onPointerDown={e => e.stopPropagation()}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
          🔗 Linked nodes
        </span>
        <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text2)', fontSize: 14, lineHeight: 1 }}>×</button>
      </div>
      {siblings.length === 0 ? (
        <div style={{ padding: '8px 4px', fontSize: 11, color: 'var(--text2)', fontStyle: 'italic' }}>
          No other linked nodes.
        </div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 240, overflowY: 'auto' }}>
          {siblings.map(({ chartId, chartTitle, node: ln }) => (
            <button key={ln.id}
              onClick={() => onJump(chartId, ln.id)}
              style={{
                textAlign: 'left', background: 'var(--bg)', border: '1px solid var(--border)',
                borderRadius: 7, padding: '6px 8px', cursor: 'pointer',
                color: 'var(--text)', fontFamily: 'inherit', fontSize: 11,
              }}
              onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
              onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
            >
              <strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {ln.label || 'Untitled'}
              </strong>
              <span style={{ color: 'var(--text2)', fontSize: 10 }}>{chartTitle}</span>
            </button>
          ))}
        </div>
      )}
      {!readOnly && siblings.length > 0 && (
        <FcBtn danger onClick={onUnlink} style={{ fontSize: 10 }}>Unlink this node</FcBtn>
      )}
    </div>
  );
}

// ── Bulk action toolbar (visible when ≥2 nodes are multi-selected) ────────
function BulkActionBar({ count, nodeTypes, statuses, onClear, onDelete, onCopy, onPaste, onUpdate, onAddTag, onGroup }) {
  const [openMenu, setOpenMenu] = useState(null);
  const [tagDraft, setTagDraft] = useState('');
  const wrapRef = useRef(null);
  useEffect(() => {
    function onDocDown(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpenMenu(null); }
    document.addEventListener('mousedown', onDocDown, true);
    return () => document.removeEventListener('mousedown', onDocDown, true);
  }, []);
  function btnStyle(extra) {
    return Object.assign({
      background: 'transparent', border: '1px solid var(--border)',
      borderRadius: 8, padding: '5px 10px', fontSize: 11, fontWeight: 600,
      cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit',
    }, extra || {});
  }
  function popupBox() {
    return {
      position: 'absolute', top: 'calc(100% + 4px)', left: 0, minWidth: 160,
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 8, padding: 4, boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
      display: 'flex', flexDirection: 'column', gap: 2, zIndex: 5,
    };
  }
  function rowStyle() {
    return {
      display: 'flex', alignItems: 'center', textAlign: 'left',
      background: 'transparent', border: 'none', borderRadius: 5,
      padding: '5px 8px', fontSize: 11, color: 'var(--text)',
      cursor: 'pointer', fontFamily: 'inherit',
    };
  }
  return (
    <div ref={wrapRef} data-fc-no-pan="true" style={{
      position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)',
      zIndex: 30, pointerEvents: 'auto',
      background: 'var(--surface)', border: '1px solid var(--accent)',
      borderRadius: 12, padding: '6px 10px',
      boxShadow: '0 8px 32px rgba(0,0,0,0.28)',
      display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap',
    }}>
      <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--accent)', padding: '0 6px' }}>
        {count} selected
      </span>
      <span style={{ width: 1, height: 16, background: 'var(--border)' }} />
      <button onClick={onCopy}   style={btnStyle()}>⧉ Copy</button>
      <button onClick={onPaste}  style={btnStyle()}>⎘ Paste</button>
      <button onClick={onGroup}  style={btnStyle()}>↳ Group sub-chart</button>
      <span style={{ width: 1, height: 16, background: 'var(--border)' }} />
      <div style={{ position: 'relative' }}>
        <button onClick={() => setOpenMenu(m => m === 'type' ? null : 'type')} style={btnStyle()}>● Type ▾</button>
        {openMenu === 'type' && (
          <div style={popupBox()}>
            {nodeTypes.map(t => (
              <button key={t.id} onClick={() => { onUpdate({ typeId: t.id }); setOpenMenu(null); }} style={rowStyle()}>
                <span style={{ width: 9, height: 9, borderRadius: 99, background: t.color, display: 'inline-block', marginRight: 7 }} />
                {t.name}
              </button>
            ))}
          </div>
        )}
      </div>
      <div style={{ position: 'relative' }}>
        <button onClick={() => setOpenMenu(m => m === 'status' ? null : 'status')} style={btnStyle()}>◐ Status ▾</button>
        {openMenu === 'status' && (
          <div style={popupBox()}>
            {statuses.map(s => (
              <button key={s.id} onClick={() => { onUpdate({ status: s.id }); setOpenMenu(null); }} style={rowStyle()}>
                <span style={{ width: 9, height: 9, borderRadius: 99, background: s.color, display: 'inline-block', marginRight: 7 }} />
                {s.label}
              </button>
            ))}
          </div>
        )}
      </div>
      <div style={{ position: 'relative' }}>
        <button onClick={() => setOpenMenu(m => m === 'tag' ? null : 'tag')} style={btnStyle()}># Tag ▾</button>
        {openMenu === 'tag' && (
          <div style={popupBox()}>
            <input
              autoFocus value={tagDraft}
              onChange={e => setTagDraft(e.target.value)}
              onKeyDown={e => {
                if (e.key === 'Enter') {
                  if (tagDraft.trim()) onAddTag(tagDraft.trim());
                  setTagDraft(''); setOpenMenu(null);
                }
                if (e.key === 'Escape') setOpenMenu(null);
              }}
              placeholder="tag, Enter to add"
              style={{ ...fcInputStyle, padding: '4px 8px', fontSize: 11 }}
            />
          </div>
        )}
      </div>
      <span style={{ width: 1, height: 16, background: 'var(--border)' }} />
      <button onClick={onDelete} style={btnStyle({ color: '#f87171', borderColor: '#f8717140' })}>✕ Delete</button>
      <button onClick={onClear}  style={btnStyle({ color: 'var(--text2)' })} title="Clear selection (Esc)">↺</button>
    </div>
  );
}

// ── Canvas Context Menu (right-click empty space) ──────────────────────────
function CanvasContextMenu({ x, y, onClose, onAddNode, onFit, onArrange, readOnly }) {
  const menuRef = useRef(null);
  useEffect(() => {
    function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    document.addEventListener('mousedown', onDocDown, true);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocDown, true);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose]);

  return (
    <div ref={menuRef} style={{
      position: 'absolute', left: x, top: y, zIndex: 60, pointerEvents: 'auto',
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 12, padding: 5,
      boxShadow: '0 8px 32px rgba(0,0,0,0.32)', minWidth: 200,
    }}
    onMouseDown={e => e.stopPropagation()}
    onPointerDown={e => e.stopPropagation()}
    onContextMenu={e => e.preventDefault()}>
      {!readOnly && <MenuItem onClose={onClose} icon="+" label="Add node here" onClick={onAddNode} accent shortcut="N" />}
      <MenuItem onClose={onClose} icon="⊡" label="Fit to screen" onClick={onFit} shortcut="F" />
      {onArrange && <MenuItem onClose={onClose} icon="⟳" label="Auto-arrange" onClick={onArrange} shortcut="A" />}
    </div>
  );
}

// ── Breadcrumb ─────────────────────────────────────────────────────────────
function Breadcrumb({ navStack, currentChart, onNavigate }) {
  if (!navStack.length) return null;
  const crumbs = [...navStack, { chartId: currentChart?.id, label: currentChart?.title || '…' }];
  return (
    <div style={{
      position: 'absolute', top: 14, left: 14, zIndex: 20, pointerEvents: 'auto',
      display: 'flex', alignItems: 'center',
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 99, padding: '5px 14px', fontSize: 11, fontWeight: 500,
      boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
    }}>
      {crumbs.map((crumb, i) => (
        <span key={i} style={{ display: 'flex', alignItems: 'center' }}>
          {i < crumbs.length - 1
            ? <button onClick={() => onNavigate(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--accent)', fontSize: 11, fontWeight: 600, padding: '0 2px' }}>{crumb.label}</button>
            : <span style={{ color: 'var(--text)', padding: '0 2px' }}>{crumb.label}</span>}
          {i < crumbs.length - 1 && <span style={{ color: 'var(--text2)', margin: '0 4px', fontSize: 10 }}>›</span>}
        </span>
      ))}
    </div>
  );
}

// ── Minimap ────────────────────────────────────────────────────────────────
function Minimap({ nodes, edges, pan, scale, containerW, containerH, onNavigate }) {
  const W = 152, H = 96;
  if (!nodes.length) return null;

  const xs = nodes.map(n => n.x), ys = nodes.map(n => n.y);
  const minX = Math.min(...xs) - 60, maxX = Math.max(...xs) + NODE_W + 60;
  const minY = Math.min(...ys) - 60, maxY = Math.max(...ys) + NODE_H_EST + 60;
  const bw = Math.max(maxX - minX, 1), bh = Math.max(maxY - minY, 1);
  const mmScale = Math.min(W / bw, H / bh, 0.35);

  function toMM(wx, wy) {
    return [(wx - minX) * mmScale + (W - bw * mmScale) / 2, (wy - minY) * mmScale + (H - bh * mmScale) / 2];
  }

  const vpX = -pan.x / scale, vpY = -pan.y / scale;
  const vpW = containerW / scale, vpH = containerH / scale;
  const [vpMX, vpMY] = toMM(vpX, vpY);

  function handleClick(e) {
    const rect = e.currentTarget.getBoundingClientRect();
    const mx = e.clientX - rect.left, my = e.clientY - rect.top;
    // mx = (wx - minX)*mmScale + offset → wx = (mx - offset)/mmScale + minX
    const offX = (W - bw * mmScale) / 2, offY = (H - bh * mmScale) / 2;
    const wx = (mx - offX) / mmScale + minX;
    const wy = (my - offY) / mmScale + minY;
    onNavigate && onNavigate(wx, wy);
  }

  return (
    <div style={{
      position: 'absolute', bottom: 66, right: 14, zIndex: 15, pointerEvents: 'auto',
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 10, overflow: 'hidden',
      boxShadow: '0 4px 16px rgba(0,0,0,0.2)', cursor: 'crosshair',
    }} onClick={handleClick} title="Click minimap to navigate">
      <svg width={W} height={H} style={{ display: 'block' }}>
        {edges.map(edge => {
          const from = nodes.find(n => n.id === edge.from);
          const to   = nodes.find(n => n.id === edge.to);
          if (!from || !to) return null;
          const [x1, y1] = toMM(from.x + NODE_W / 2, from.y + NODE_H_EST / 2);
          const [x2, y2] = toMM(to.x   + NODE_W / 2, to.y   + NODE_H_EST / 2);
          return <line key={edge.id} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--edge-color)" strokeWidth={1} />;
        })}
        {nodes.map(n => {
          const [mx, my] = toMM(n.x, n.y);
          const nw = Math.max(NODE_W * mmScale, 5), nh = Math.max(NODE_H_EST * mmScale, 4);
          return <rect key={n.id} x={mx} y={my} width={nw} height={nh} rx={2} fill="var(--text2)" opacity={0.55} />;
        })}
        <rect
          x={vpMX} y={vpMY}
          width={Math.max(vpW * mmScale, 8)} height={Math.max(vpH * mmScale, 6)}
          fill="var(--accent)" opacity={0.12}
          stroke="var(--accent)" strokeWidth={1.5} rx={2}
        />
      </svg>
    </div>
  );
}

// ── FcCanvas ───────────────────────────────────────────────────────────────
function FcCanvas({ chart, project, allCharts, nodeTypes, statuses, selectedNodeId,
  selectedNodeIds, onSelectedNodeIdsChange,
  connectSource, connectMode, navStack,
  scale, pan, roundedNodes, collapsedSet, hidingAncestorSet, editingNodeId, editingField,
  fitTrigger, onSelectNode, onConnectNode, onUpdateNodePos, onBulkMove,
  onBulkDelete, onBulkCopy, onBulkPaste, onBulkUpdate, onBulkAddTag, onBulkGroupIntoSubChart,
  onOpenSubChart, onScaleChange, onPanChange,
  onNavigate, onAddChild, onDeleteNode, onDeleteEdge, onUpdateEdge,
  onToggleCollapse, onToggleHideAncestors,
  onCreateSubChart, onAddNodeAt, onEditStart, onEditChange, onEditEnd, onArrange, onToggleConnect,
  onOpenExpanded, onDuplicateNode, onImageDrop, onSplitToSubChart, onPromoteToRoot,
  onAttachChartAsSubChart, onDetachSubChart,
  onLinkNodes, onUnlinkNode, onJumpToLinkedNode,
  readOnly, remoteSelections,
  layoutAlgorithm, onLayoutAlgorithmChange }) {

  const containerRef  = useRef(null);
  // ── Drag/marquee state ────────────────────────────────────────────────
  // mode values:
  //   'pan'     — left/middle drag on empty canvas (camera pan)
  //   'node'    — left drag on a node (move single or multi-selection)
  //   'marquee' — right drag on empty canvas (rubber-band node selection)
  //   'bend'    — right drag on an edge midpoint (warp the curve)
  const dragStateRef  = useRef({ mode: null, nodeId: null, startX: 0, startY: 0, moved: false, pointerId: null });
  const [, forceRender] = useState(0);
  const [popup,          setPopup]          = useState(null); // { nodeId, x, y } for node context menu
  const [canvasMenu,     setCanvasMenu]     = useState(null); // { x, y, worldX, worldY }
  const [edgeMenu,       setEdgeMenu]       = useState(null); // { edgeId, x, y } for edge routing popover
  const [linkedPopup,    setLinkedPopup]    = useState(null); // { nodeId, x, y } for chain-icon click
  const [selectedEdgeId, setSelectedEdgeId] = useState(null);
  const [showSearch,     setShowSearch]     = useState(false);
  const [searchQ,        setSearchQ]        = useState('');
  const [showMinimap,    setShowMinimap]    = useState(true);
  // Marquee rect in WORLD coords. null when not active.
  const [marquee,        setMarquee]        = useState(null); // { x1, y1, x2, y2 }
  const searchRef = useRef(null);
  const handleFitRef = useRef(null);

  // Normalise selectedNodeIds as a Set even if parent passes null.
  const selectedSet = useMemo(() => {
    if (selectedNodeIds instanceof Set) return selectedNodeIds;
    if (Array.isArray(selectedNodeIds)) return new Set(selectedNodeIds);
    return new Set();
  }, [selectedNodeIds]);
  const setSelected = useCallback((next) => {
    if (onSelectedNodeIdsChange) onSelectedNodeIdsChange(next instanceof Set ? next : new Set(next || []));
  }, [onSelectedNodeIdsChange]);

  useEffect(() => { if (fitTrigger > 0 && handleFitRef.current) handleFitRef.current(); }, [fitTrigger]);

  // Open search with Ctrl+F
  useEffect(() => {
    function onKey(e) {
      if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
        e.preventDefault();
        setShowSearch(v => { if (!v) setTimeout(() => searchRef.current?.focus(), 50); return !v; });
      }
      if (e.key === 'Escape') { setShowSearch(false); setSearchQ(''); setSelectedEdgeId(null); }
      // Delete selected edge
      if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdgeId && !editingNodeId) {
        const tag = e.target?.tagName?.toLowerCase();
        if (tag !== 'input' && tag !== 'textarea') {
          onDeleteEdge && onDeleteEdge(selectedEdgeId);
          setSelectedEdgeId(null);
        }
      }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedEdgeId, editingNodeId, onDeleteEdge]);

  const { visibleNodes, visibleEdges, collapsedInfo, hidingAncInfo } = useMemo(() => {
    if (!chart) return { visibleNodes: [], visibleEdges: [], collapsedInfo: new Map(), hidingAncInfo: new Map() };
    const hiddenByCollapse = new Set();
    const cInfo = new Map();
    for (const nodeId of (collapsedSet || [])) {
      const desc = getDescendants(chart, nodeId);
      cInfo.set(nodeId, desc.size);
      desc.forEach(id => hiddenByCollapse.add(id));
    }
    const hiddenByAnc = new Set();
    const hInfo = new Map();
    for (const nodeId of (hidingAncestorSet || [])) {
      const anc = getAncestors(chart, nodeId);
      hInfo.set(nodeId, anc.size);
      anc.forEach(id => hiddenByAnc.add(id));
    }
    const allHidden = new Set([...hiddenByCollapse, ...hiddenByAnc]);
    return {
      visibleNodes: chart.nodes.filter(n => !allHidden.has(n.id)),
      visibleEdges: chart.edges.filter(e => !allHidden.has(e.from) && !allHidden.has(e.to)),
      collapsedInfo: cInfo, hidingAncInfo: hInfo,
    };
  }, [chart, collapsedSet, hidingAncestorSet]);

  // Per-node depth from the chart root (1-indexed). Multiple paths → array
  // of distinct levels per node. Recomputed whenever the chart structure
  // changes. Passed into NodeCard as `levels` for the badge rendering.
  const nodeLevels = useMemo(() => {
    if (!chart) return new Map();
    return (typeof computeNodeLevels === 'function') ? computeNodeLevels(chart) : new Map();
  }, [chart]);

  // Number of OTHER nodes that share this node's linkGroupId, anywhere in
  // the project. Used by NodeCard to draw the chain icon + count.
  const linkedCounts = useMemo(() => {
    const counts = new Map();
    if (!chart || !project) return counts;
    const groupCounts = new Map();
    (project.charts || []).forEach(c => (c.nodes || []).forEach(n => {
      if (n.linkGroupId) groupCounts.set(n.linkGroupId, (groupCounts.get(n.linkGroupId) || 0) + 1);
    }));
    chart.nodes.forEach(n => {
      if (n.linkGroupId) counts.set(n.id, Math.max(0, (groupCounts.get(n.linkGroupId) || 0) - 1));
    });
    return counts;
  }, [chart, project]);

  // Search matches: null = no search, true/false per node id
  const searchMatchMap = useMemo(() => {
    if (!searchQ.trim()) return null;
    const q = searchQ.toLowerCase();
    const map = new Map();
    visibleNodes.forEach(n => {
      const hit = n.label?.toLowerCase().includes(q) || n.notes?.toLowerCase().includes(q) || (n.tags||[]).some(t => t.toLowerCase().includes(q));
      map.set(n.id, hit);
    });
    return map;
  }, [searchQ, visibleNodes]);

  function handleFit() {
    if (!visibleNodes.length || !containerRef.current) return;
    const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
    const pad = 100;
    const xs = visibleNodes.map(n => n.x), ys = visibleNodes.map(n => n.y);
    const minX = Math.min(...xs), maxX = Math.max(...xs) + NODE_W;
    const minY = Math.min(...ys), maxY = Math.max(...ys) + NODE_H_EST;
    const bw = Math.max(maxX - minX, 1), bh = Math.max(maxY - minY, 1);
    const newScale = Math.min((cw - pad * 2) / bw, (ch - pad * 2) / bh, 1.5);
    onScaleChange(newScale);
    onPanChange({ x: cw / 2 - (minX + bw / 2) * newScale, y: ch / 2 - (minY + bh / 2) * newScale });
  }
  handleFitRef.current = handleFit;

  function handleWheel(e) {
    e.preventDefault();
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    const mx = e.clientX - rect.left, my = e.clientY - rect.top;
    const factor = e.deltaY > 0 ? 0.9 : 1.1;
    const prevScale = scale;
    const nextScale = Math.max(0.05, Math.min(3, prevScale * factor));
    onScaleChange(nextScale);
    onPanChange(p => ({ x: mx - (mx - p.x) * (nextScale / prevScale), y: my - (my - p.y) * (nextScale / prevScale) }));
  }

  // Convert a clientX/Y pair to world coords (cancels pan + zoom).
  function clientToWorld(clientX, clientY) {
    if (!containerRef.current) return { x: 0, y: 0 };
    const rect = containerRef.current.getBoundingClientRect();
    return {
      x: (clientX - rect.left - pan.x) / scale,
      y: (clientY - rect.top  - pan.y) / scale,
    };
  }

  // ── Pointer capture approach — no window listeners needed ──────────────
  function handleCanvasPointerDown(e) {
    const t = e.target;
    // Bail on interactive UI / nodes — they handle their own events.
    // Note: `svg` is intentionally NOT in this list. Empty SVG areas should
    // pass clicks through to the canvas so right-click marquee / canvas
    // context menu work near nodes. Edge groups stopPropagation in their
    // own onPointerDown so canvas doesn't double-react to edge clicks.
    if (t && t.closest('[data-node-id], button, input, textarea, select, a, [data-fc-no-pan], [data-fc-edge], [data-fc-edge-menu]')) return;
    // Right button on empty canvas → start a candidate marquee. Distinguish
    // from a quick right-click (which should open the canvas context menu) by
    // waiting for movement past a 4px threshold. The browser's contextmenu
    // event fires on pointerup, so we use a ref to swallow it when the user
    // actually dragged.
    if (e.button === 2) {
      const world = clientToWorld(e.clientX, e.clientY);
      dragStateRef.current = {
        mode: 'marquee-pending', nodeId: null,
        startX: e.clientX, startY: e.clientY,
        originX: e.clientX, originY: e.clientY,
        worldStart: world,
        moved: false, pointerId: e.pointerId, captured: false,
      };
      setPopup(null); setEdgeMenu(null); setLinkedPopup(null);
      forceRender(n => n + 1);
      return;
    }
    if (e.button !== 0 && e.button !== 1) return;
    setSelectedEdgeId(null);
    dragStateRef.current = {
      mode: 'pan', nodeId: null,
      startX: e.clientX, startY: e.clientY,
      originX: e.clientX, originY: e.clientY,
      moved: false, pointerId: e.pointerId, captured: false,
    };
    setPopup(null); setEdgeMenu(null); setLinkedPopup(null);
    // Plain (non-shift/ctrl/cmd) click on empty canvas clears all selection.
    if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
      if (selectedSet.size > 0) setSelected(new Set());
      if (selectedNodeId)       onSelectNode('');
    }
    forceRender(n => n + 1);
  }

  function handleCanvasPointerMove(e) {
    const st = dragStateRef.current;
    if (!st.mode) return;

    // Marquee-pending → marquee once we cross the threshold.
    if (st.mode === 'marquee-pending') {
      const totalDx = e.clientX - st.originX, totalDy = e.clientY - st.originY;
      if (Math.hypot(totalDx, totalDy) < 4) return;
      // Promote to active marquee. Capture so we keep getting move events
      // even if the cursor leaves the container.
      try { containerRef.current?.setPointerCapture(st.pointerId); } catch {}
      st.captured = true;
      st.mode = 'marquee';
      forceRender(n => n + 1);
    }

    if (st.mode === 'marquee') {
      const world = clientToWorld(e.clientX, e.clientY);
      setMarquee({ x1: st.worldStart.x, y1: st.worldStart.y, x2: world.x, y2: world.y });
      return;
    }

    if (st.mode === 'edge-bend') {
      // Drag-bend an edge: store an offset from the natural midpoint so the
      // bezier follows the cursor. We push every mousemove through onUpdateEdge
      // for live feedback; skipHistory keeps undo clean.
      const world = clientToWorld(e.clientX, e.clientY);
      const dx = world.x - st.midNatX;
      const dy = world.y - st.midNatY;
      if (onUpdateEdge) {
        onUpdateEdge(st.edgeId, { route: { ...(st.baseRoute || {}), bend: { dx, dy } } });
      }
      st.moved = true;
      return;
    }

    if (st.mode === 'pan') {
      const dx = e.clientX - st.startX, dy = e.clientY - st.startY;
      st.startX = e.clientX; st.startY = e.clientY;
      if (Math.sqrt(dx * dx + dy * dy) > 0.5) st.moved = true;
      if (st.moved && !st.captured) {
        try { containerRef.current?.setPointerCapture(st.pointerId); } catch {}
        st.captured = true;
        try { window.getSelection()?.removeAllRanges(); } catch {}
      }
      onPanChange(prev => ({ x: prev.x + dx, y: prev.y + dy }));
    } else if (st.mode === 'node') {
      if (st.originX === undefined) { st.originX = st.startX; st.originY = st.startY; }
      const totalDx = e.clientX - st.originX;
      const totalDy = e.clientY - st.originY;
      if (!st.moved && Math.hypot(totalDx, totalDy) < 4) return; // threshold
      if (!st.captured) {
        try { containerRef.current?.setPointerCapture(st.pointerId); } catch {}
        st.captured = true;
        try { window.getSelection()?.removeAllRanges(); } catch {}
      }
      const dx = (e.clientX - st.startX) / scale, dy = (e.clientY - st.startY) / scale;
      st.startX = e.clientX; st.startY = e.clientY;
      st.moved = true;
      // Move the entire multi-selection if the dragged node is part of it.
      if (st.groupDrag && onBulkMove) {
        onBulkMove(dx, dy);
      } else {
        onUpdateNodePos(st.nodeId, dx, dy);
      }
    }
  }

  function handleCanvasPointerUp(e) {
    const st = dragStateRef.current;
    try { containerRef.current?.releasePointerCapture(e.pointerId); } catch {}

    if (st.mode === 'marquee') {
      // Finalise the marquee selection. Any visible node whose centre lies
      // inside the world-space rect joins selectedNodeIds. Shift held →
      // append to existing selection; otherwise replace.
      if (marquee && setSelected) {
        const x1 = Math.min(marquee.x1, marquee.x2);
        const y1 = Math.min(marquee.y1, marquee.y2);
        const x2 = Math.max(marquee.x1, marquee.x2);
        const y2 = Math.max(marquee.y1, marquee.y2);
        const next = e.shiftKey ? new Set(selectedSet) : new Set();
        visibleNodes.forEach(n => {
          const cx = n.x + NODE_W / 2, cy = n.y + NODE_H_EST / 2;
          if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) next.add(n.id);
        });
        setSelected(next);
        if (next.size === 1) onSelectNode([...next][0]);
      }
      setMarquee(null);
    }

    if (st.mode === 'marquee-pending') {
      // No movement → treat as a normal right-click → open canvas context menu.
      if (containerRef.current && !readOnly && !connectMode) {
        const rect = containerRef.current.getBoundingClientRect();
        const worldX = (st.originX - rect.left - pan.x) / scale;
        const worldY = (st.originY - rect.top  - pan.y) / scale;
        setCanvasMenu({
          x: st.originX - rect.left,
          y: st.originY - rect.top,
          worldX: worldX - NODE_W / 2,
          worldY: worldY - NODE_H_EST / 2,
        });
      }
    }

    dragStateRef.current = { mode: null, nodeId: null, startX: 0, startY: 0, moved: st.moved, pointerId: null };
    forceRender(n => n + 1);
  }

  // Suppress the native contextmenu event everywhere — we open menus
  // ourselves on pointerup so we can disambiguate quick-click from drag.
  function handleContextMenu(e) {
    e.preventDefault();
  }

  function handleCanvasDragOver(e) {
    if (e.dataTransfer.types.includes('Files')) e.preventDefault();
  }
  function handleCanvasDrop(e) {
    e.preventDefault();
    const files = [...(e.dataTransfer.files || [])].filter(f => f.type.startsWith('image/'));
    if (!files.length) return;
    // Find nearest node to drop point
    const rect = containerRef.current.getBoundingClientRect();
    const wx = (e.clientX - rect.left - pan.x) / scale;
    const wy = (e.clientY - rect.top  - pan.y) / scale;
    let nearest = null, nearDist = 9999;
    visibleNodes.forEach(n => {
      const cx = n.x + NODE_W / 2, cy = n.y + NODE_H_EST / 2;
      const d = Math.hypot(wx - cx, wy - cy);
      if (d < nearDist) { nearDist = d; nearest = n; }
    });
    if (nearest && nearDist < NODE_W && onImageDrop) {
      onImageDrop(nearest.id, files);
    } else {
      // No close node — create one then add image
      onAddNodeAt && onAddNodeAt(wx - NODE_W / 2, wy - NODE_H_EST / 2);
    }
  }

  function openPopup(nodeId, nodeX, nodeY) {
    if (!containerRef.current) return;
    const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
    const estW = 232;
    let popX = pan.x + (nodeX + NODE_W + 10) * scale;
    let popY = pan.y + nodeY * scale;
    if (popX + estW > cw) popX = pan.x + nodeX * scale - estW - 10;
    setPopup({ nodeId, x: Math.max(4, Math.min(popX, cw - estW - 4)), y: Math.max(4, Math.min(popY, ch - 300)) });
  }

  function handleMinimapNavigate(wx, wy) {
    if (!containerRef.current) return;
    const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
    onPanChange({ x: cw / 2 - wx * scale, y: ch / 2 - wy * scale });
  }

  // Compute bezier endpoints/handles/midpoint for an edge. Honors:
  //  - edge.route.fromSide / toSide ('top'|'right'|'bottom'|'left'|'auto')
  //    to override the auto-picked anchor side.
  //  - edge.route.bend = { dx, dy } in world units, dragged offset applied to
  //    the bezier's t=0.5 midpoint so the user can warp the curve.
  //  - edge.route.style 'straight' | 'orthogonal' | 'curved' (default curved)
  function edgeMidpoint(from, to, vert, layout, route) {
    const useHierarchical = layout === 'improved' || layout === 'hierarchical' || layout === 'compact' || !layout;

    // Resolve sides — explicit route values win, otherwise we pick the side
    // that points at the other node.
    function anchor(node, side) {
      const cx = node.x + NODE_W / 2, cy = node.y + NODE_H_EST / 2;
      if (side === 'right')  return { x: node.x + NODE_W,    y: cy, nx:  1, ny:  0 };
      if (side === 'left')   return { x: node.x,             y: cy, nx: -1, ny:  0 };
      if (side === 'bottom') return { x: cx, y: node.y + NODE_H_EST, nx:  0, ny:  1 };
      return                        { x: cx, y: node.y,              nx:  0, ny: -1 };
    }
    function autoSides() {
      if (useHierarchical) {
        return vert ? { fromSide: 'bottom', toSide: 'top' } : { fromSide: 'right', toSide: 'left' };
      }
      const fcx = from.x + NODE_W / 2, fcy = from.y + NODE_H_EST / 2;
      const tcx = to.x   + NODE_W / 2, tcy = to.y   + NODE_H_EST / 2;
      const dx = tcx - fcx, dy = tcy - fcy;
      const ax = Math.abs(dx) / (NODE_W / 2), ay = Math.abs(dy) / (NODE_H_EST / 2);
      if (ax >= ay) return { fromSide: dx >= 0 ? 'right' : 'left', toSide: dx >= 0 ? 'left' : 'right' };
      return { fromSide: dy >= 0 ? 'bottom' : 'top', toSide: dy >= 0 ? 'top' : 'bottom' };
    }
    const auto = autoSides();
    const fromSide = (route && route.fromSide && route.fromSide !== 'auto') ? route.fromSide : auto.fromSide;
    const toSide   = (route && route.toSide   && route.toSide   !== 'auto') ? route.toSide   : auto.toSide;

    const a = anchor(from, fromSide);
    const b = anchor(to,   toSide);
    const sx = a.x, sy = a.y, ex = b.x, ey = b.y;

    // Handle length scales with distance, so short edges still curve nicely
    // and long ones get a soft swoop.
    const dist   = Math.hypot(ex - sx, ey - sy);
    const handle = Math.min(140, Math.max(40, dist * 0.4));
    let c1x = sx + a.nx * handle, c1y = sy + a.ny * handle;
    let c2x = ex + b.nx * handle, c2y = ey + b.ny * handle;

    // Bend offset — apply equally to both control points so the curve's
    // mid-region tracks the dragged offset. This is intentionally not a
    // proper bezier-from-mid solve (which is fiddly) — a uniform offset gives
    // an intuitive "the line follows where I dragged" feel.
    if (route && route.bend && (route.bend.dx || route.bend.dy)) {
      c1x += route.bend.dx; c1y += route.bend.dy;
      c2x += route.bend.dx; c2y += route.bend.dy;
    }

    // t=0.5 on cubic bezier
    const t = 0.5, mt = 1 - t;
    const mx = mt*mt*mt*sx + 3*mt*mt*t*c1x + 3*mt*t*t*c2x + t*t*t*ex;
    const my = mt*mt*mt*sy + 3*mt*mt*t*c1y + 3*mt*t*t*c2y + t*t*t*ey;

    const style = (route && route.style) || 'curved';
    let d;
    if (style === 'straight') {
      d = `M${sx},${sy} L${ex},${ey}`;
    } else if (style === 'orthogonal') {
      // Right-angle routing: step out from each endpoint along its outward
      // normal, then connect with two axis-aligned segments. Bend offset
      // shifts the corner.
      const step = Math.max(30, dist * 0.25);
      const mx2 = (route && route.bend) ? (sx + ex) / 2 + (route.bend.dx || 0) : (sx + ex) / 2;
      const my2 = (route && route.bend) ? (sy + ey) / 2 + (route.bend.dy || 0) : (sy + ey) / 2;
      if (a.nx !== 0) {
        // Horizontal first
        d = `M${sx},${sy} L${mx2},${sy} L${mx2},${ey} L${ex},${ey}`;
      } else {
        // Vertical first
        d = `M${sx},${sy} L${sx},${my2} L${ex},${my2} L${ex},${ey}`;
      }
    } else {
      d = `M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${ex},${ey}`;
    }
    return { sx, sy, ex, ey, c1x, c1y, c2x, c2y, mx, my, d, fromSide, toSide, style };
  }

  if (!chart) {
    return (
      <div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--text2)', background: 'var(--canvas-bg)' }}>
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 36, marginBottom: 14, opacity: 0.2 }}>◈</div>
          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, color: 'var(--text)' }}>No chart selected</div>
          <div style={{ fontSize: 12 }}>Create a chart from the sidebar.</div>
        </div>
      </div>
    );
  }

  const popupNode = popup ? visibleNodes.find(n => n.id === popup.nodeId) : null;
  const vert = chart.orientation === 'vertical';
  const cw = containerRef.current?.offsetWidth || 800;
  const ch = containerRef.current?.offsetHeight || 600;

  return (
    <div
      ref={containerRef}
      data-fc-canvas="true"
      style={{
        flex: 1, position: 'relative', overflow: 'hidden',
        background: 'var(--canvas-bg)',
        backgroundImage: 'radial-gradient(circle, var(--dot-color) 1.5px, transparent 1.5px)',
        backgroundSize: '24px 24px',
        cursor: dragStateRef.current.mode === 'pan' ? 'grabbing'
              : dragStateRef.current.mode === 'node' ? 'grabbing'
              : connectMode ? 'crosshair' : 'default',
        touchAction: 'none',
        userSelect: 'none',
        WebkitUserSelect: 'none',
      }}
      onWheel={handleWheel}
      onPointerDown={handleCanvasPointerDown}
      onPointerMove={handleCanvasPointerMove}
      onPointerUp={handleCanvasPointerUp}
      onPointerCancel={handleCanvasPointerUp}
      onContextMenu={handleContextMenu}
      onDragOver={handleCanvasDragOver}
      onDrop={handleCanvasDrop}
    >
      <Breadcrumb navStack={navStack} currentChart={chart} onNavigate={onNavigate} />

      {/* Connect mode banner */}
      {connectMode && (
        <div style={{
          position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)',
          background: connectSource ? '#22c55e18' : 'var(--accent-muted)',
          border: `1px solid ${connectSource ? '#22c55e' : 'var(--accent)'}`,
          color: connectSource ? '#22c55e' : 'var(--accent)',
          borderRadius: 99, padding: '6px 20px', fontSize: 11, fontWeight: 600,
          zIndex: 20, pointerEvents: 'none',
        }}>
          {connectSource ? 'Click the target node  ·  Esc to cancel' : 'Click the source node  ·  Esc to cancel'}
        </div>
      )}

      {/* Search bar */}
      {showSearch && (
        <div style={{
          position: 'absolute', top: 14, right: 14, zIndex: 25, pointerEvents: 'auto',
          background: 'var(--surface)', border: '1px solid var(--border)',
          borderRadius: 10, padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 8,
          boxShadow: '0 4px 16px rgba(0,0,0,0.2)', minWidth: 220,
        }}>
          <span style={{ color: 'var(--text2)', fontSize: 12 }}>🔍</span>
          <input
            ref={searchRef}
            value={searchQ}
            onChange={e => setSearchQ(e.target.value)}
            placeholder="Search nodes…"
            onKeyDown={e => { if (e.key === 'Escape') { setShowSearch(false); setSearchQ(''); } }}
            style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12, color: 'var(--text)', fontFamily: 'inherit', width: 160 }}
          />
          {searchQ && (
            <button onClick={() => setSearchQ('')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text2)', fontSize: 14, lineHeight: 1, padding: 0 }}>×</button>
          )}
          {searchMatchMap && (
            <span style={{ fontSize: 10, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
              {[...searchMatchMap.values()].filter(Boolean).length} found
            </span>
          )}
        </div>
      )}

      {/* Hint */}
      <div style={{
        position: 'absolute', bottom: 64, left: 14,
        fontSize: 10, color: 'var(--text2)', opacity: 0.45, pointerEvents: 'none',
        lineHeight: 1.85, textAlign: 'left',
      }}>
        Click label/notes to edit · Double-click to expand · Right-click node for menu<br />
        Right-click canvas to add · Drag node to move · Scroll to zoom<br />
        N: child · C: connect · F: fit · ⌘D: dup · ⌘Z: undo · ⌘F: search
      </div>

      {/* Stage */}
      <div style={{
        position: 'absolute', inset: 0,
        transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
        transformOrigin: '0 0',
      }}>
        {/* Edges SVG */}
        <svg style={{ position: 'absolute', inset: 0, width: '9999px', height: '9999px', overflow: 'visible' }} color="var(--edge-color)">
          <defs>
            <marker id="fc-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
              <polygon points="0,1 8,4 0,7" fill="currentColor" opacity="0.85" />
            </marker>
            <marker id="fc-arrow-sel" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
              <polygon points="0,1 8,4 0,7" fill="#f87171" />
            </marker>
          </defs>

          {visibleEdges.map(edge => {
            const from = visibleNodes.find(n => n.id === edge.from);
            const to   = visibleNodes.find(n => n.id === edge.to);
            if (!from || !to) return null;
            const route = edge.route || null;
            const m = edgeMidpoint(from, to, vert, layoutAlgorithm, route);
            const { sx, sy, ex, ey, mx, my, d } = m;
            const isSelEdge = selectedEdgeId === edge.id;

            // Right-button on midpoint area: hold + drag → bend the line so
            // the section under the pointer follows the cursor. Quick
            // right-click → open the routing menu. ONLY attached to the
            // visible mid-handle circle (shown when the edge is selected) —
            // not the wide invisible hit path — otherwise right-clicks near
            // nodes get eaten by the edge band.
            function onMidPointerDown(ev) {
              if (readOnly) return;
              if (ev.button !== 2) return;
              ev.stopPropagation(); ev.preventDefault();
              // Natural midpoint excluding any current bend, so the dragged
              // offset is computed relative to the unbent curve.
              const baseRoute = { ...(edge.route || {}) };
              const natural = edgeMidpoint(from, to, vert, layoutAlgorithm,
                { ...baseRoute, bend: null });
              dragStateRef.current = {
                mode: 'edge-bend',
                edgeId: edge.id,
                pointerId: ev.pointerId,
                midNatX: natural.mx, midNatY: natural.my,
                baseRoute,
                startX: ev.clientX, startY: ev.clientY,
                originX: ev.clientX, originY: ev.clientY,
                moved: false, captured: false,
              };
              try { containerRef.current?.setPointerCapture(ev.pointerId); } catch {}
              forceRender(n => n + 1);
            }
            function onMidPointerUp(ev) {
              const st = dragStateRef.current;
              if (st.mode === 'edge-bend' && st.edgeId === edge.id) {
                // If the user didn't actually move, treat it as a quick
                // right-click → open the routing menu instead.
                if (!st.moved && containerRef.current) {
                  const rect = containerRef.current.getBoundingClientRect();
                  setEdgeMenu({
                    edgeId: edge.id,
                    x: ev.clientX - rect.left,
                    y: ev.clientY - rect.top,
                  });
                }
                dragStateRef.current = { mode: null, edgeId: null, pointerId: null, moved: false };
                try { containerRef.current?.releasePointerCapture(ev.pointerId); } catch {}
              }
            }
            // Right-click on the wide hit path opens the edge routing menu
            // directly (no drag detection). Bending requires the mid-handle.
            // This stops short right-clicks near nodes from being eaten as
            // failed-bend attempts.
            function onWideContextMenu(ev) {
              if (readOnly) return;
              ev.preventDefault();
              ev.stopPropagation();
              if (!containerRef.current) return;
              const rect = containerRef.current.getBoundingClientRect();
              setEdgeMenu({
                edgeId: edge.id,
                x: ev.clientX - rect.left,
                y: ev.clientY - rect.top,
              });
            }

            return (
              <g key={edge.id} data-fc-edge="true" style={{ cursor: 'pointer' }}
                onPointerDown={e => {
                  // Only swallow when the pointer is actually on a line/handle
                  // (path or circle). Empty SVG areas fall through to the
                  // canvas div so right-click marquee / context menu work.
                  const tag = (e.target && e.target.tagName || '').toLowerCase();
                  if (tag === 'path' || tag === 'circle') e.stopPropagation();
                }}
                onClick={e => {
                  e.stopPropagation();
                  if (isSelEdge) {
                    onDeleteEdge && onDeleteEdge(edge.id);
                    setSelectedEdgeId(null);
                  } else {
                    setSelectedEdgeId(edge.id);
                    setPopup(null);
                  }
                }}
                onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }}
              >
                {/* Narrow invisible hit area — only catches clicks directly
                    on the line. Right-click opens the routing menu; no bend
                    starts from here (use the mid-handle circle below). */}
                <path d={d} fill="none" stroke="transparent" strokeWidth={9}
                  onContextMenu={onWideContextMenu} />
                {/* Visible edge */}
                <path
                  d={d}
                  fill="none"
                  stroke={isSelEdge ? '#f87171' : 'currentColor'}
                  strokeWidth={isSelEdge ? 2.5 : 2}
                  strokeDasharray={route && route.style === 'orthogonal' ? '' : (route && route.dashed ? '6 5' : '')}
                  strokeOpacity={isSelEdge ? 1 : 0.5}
                  strokeLinecap="round"
                  markerEnd={isSelEdge ? 'url(#fc-arrow-sel)' : 'url(#fc-arrow)'}
                />
                {/* Mid-handle: visible bend grip when the edge is selected.
                    Right-drag on it warps the line so the user can route it
                    by hand. Right-click without drag opens the routing menu. */}
                {(isSelEdge || (route && route.bend)) && !readOnly && (
                  <circle cx={mx} cy={my} r={6}
                    fill={route && route.bend ? '#3b82f6' : 'rgba(59,130,246,0.55)'}
                    stroke="#fff" strokeWidth={1.5}
                    onPointerDown={onMidPointerDown}
                    onPointerUp={onMidPointerUp}
                    style={{ cursor: 'grab' }}
                  />
                )}
                {/* Delete button on selected edge */}
                {isSelEdge && !readOnly && (
                  <g transform={`translate(${mx + 14},${my - 14})`}
                    onClick={e => { e.stopPropagation(); onDeleteEdge && onDeleteEdge(edge.id); setSelectedEdgeId(null); }}
                    style={{ cursor: 'pointer' }}>
                    <circle r={10} fill="#f87171" />
                    <text textAnchor="middle" dominantBaseline="central" fill="#fff" fontSize={13} fontWeight="bold">×</text>
                  </g>
                )}
              </g>
            );
          })}

          {/* Marquee rectangle — in world coords inside the same transformed
              stage. Rendered as a translucent accent-coloured box. */}
          {marquee && (() => {
            const x = Math.min(marquee.x1, marquee.x2);
            const y = Math.min(marquee.y1, marquee.y2);
            const w = Math.abs(marquee.x2 - marquee.x1);
            const h = Math.abs(marquee.y2 - marquee.y1);
            return (
              <rect x={x} y={y} width={w} height={h}
                fill="var(--accent)" fillOpacity={0.10}
                stroke="var(--accent)" strokeWidth={1.5 / scale}
                strokeDasharray={`${6 / scale} ${4 / scale}`}
                rx={4 / scale}
              />
            );
          })()}
        </svg>

        {/* Nodes */}
        {visibleNodes.map(node => (
          <NodeCard
            key={node.id} node={node} nodeTypes={nodeTypes} statuses={statuses}
            isSelected={node.id === selectedNodeId}
            isMultiSelected={selectedSet.has(node.id) && selectedSet.size > 1}
            isConnectSource={node.id === connectSource}
            connectMode={connectMode} roundedNodes={roundedNodes}
            isEditing={node.id === editingNodeId} editingField={editingField}
            collapsedCount={collapsedInfo.get(node.id) || 0}
            hidingAncestorCount={hidingAncInfo.get(node.id) || 0}
            searchMatch={searchMatchMap ? (searchMatchMap.get(node.id) ?? false) : null}
            remotePresence={remoteSelections ? (remoteSelections[node.id] || null) : null}
            levels={nodeLevels.get(node.id) || null}
            linkedCount={linkedCounts.get(node.id) || 0}
            onEditChange={(field, val) => onEditChange(node.id, field, val)}
            onEditEnd={onEditEnd}
            onImageDrop={onImageDrop}
            onChainClick={() => {
              // Open the linked-nodes popover anchored near the chain icon.
              if (!containerRef.current) return;
              const rect = containerRef.current.getBoundingClientRect();
              const px = pan.x + (node.x + NODE_W - 4) * scale - rect.left;
              const py = pan.y + (node.y - 4) * scale - rect.top;
              setLinkedPopup({ nodeId: node.id, x: Math.min(Math.max(8, px), rect.width - 260), y: Math.max(8, py) });
            }}
            onLabelClick={() => {
              if (!connectMode && !readOnly) { onSelectNode(node.id); onEditStart(node.id, 'label'); setPopup(null); setSelectedEdgeId(null); }
              else if (connectMode) { onConnectNode(node.id); }
              else { onSelectNode(node.id); }
            }}
            onNotesClick={() => {
              if (!connectMode && !readOnly) { onSelectNode(node.id); onEditStart(node.id, 'notes'); setPopup(null); setSelectedEdgeId(null); }
              else if (connectMode) { onConnectNode(node.id); }
              else { onSelectNode(node.id); }
            }}
            onOpenExpanded={() => { if (!connectMode) { onSelectNode(node.id); onOpenExpanded(node.id); setPopup(null); } }}
            onBodyPointerDown={e => {
              if (connectMode || node.id === editingNodeId) return;
              if (e.button !== 0) return; // only left button starts drag
              const t = e.target;
              if (t && (t.closest('input, textarea, select, button, a, [contenteditable="true"]'))) return;
              // Shift / Ctrl / Cmd click adds/removes from multi-selection
              // without starting a drag. Ctrl/Cmd matches OS-native multi-
              // pick behaviour, Shift mirrors marquee-extend semantics.
              if (e.shiftKey || e.ctrlKey || e.metaKey) {
                e.preventDefault();
                const next = new Set(selectedSet);
                if (next.has(node.id)) next.delete(node.id); else next.add(node.id);
                // Seed with the current primary selection so the first
                // additive click promotes it into a group of two, not one.
                if (next.size && selectedNodeId && !next.has(selectedNodeId)) {
                  next.add(selectedNodeId);
                }
                setSelected(next);
                // Promote the just-touched node to primary so the Inspector
                // shows it.
                if (next.has(node.id)) onSelectNode(node.id);
                return;
              }
              dragStateRef.current = {
                mode: 'node', nodeId: node.id,
                startX: e.clientX, startY: e.clientY,
                originX: e.clientX, originY: e.clientY,
                moved: false, pointerId: e.pointerId, captured: false,
                // Drag the whole multi-selection when the user grabs a member
                // of it. Otherwise just this node.
                groupDrag: selectedSet.has(node.id) && selectedSet.size > 1,
              };
              forceRender(n => n + 1);
            }}
            onBodyClick={e => {
              if (dragStateRef.current.moved) { dragStateRef.current.moved = false; return; }
              setSelectedEdgeId(null);
              setPopup(null);
              if (connectMode) { onConnectNode(node.id); return; }
              if (node.id === editingNodeId) return;
              onSelectNode(node.id);
              // Clicking outside the multi-selection collapses it to just this node.
              if (!selectedSet.has(node.id) && selectedSet.size > 0) setSelected(new Set([node.id]));
            }}
            onBodyContextMenu={e => {
              e.preventDefault();
              e.stopPropagation();
              if (connectMode) return;
              onSelectNode(node.id);
              setSelectedEdgeId(null);
              if (!containerRef.current) return;
              const rect = containerRef.current.getBoundingClientRect();
              const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
              const estW = 240, estH = 360;
              let px = e.clientX - rect.left;
              let py = e.clientY - rect.top;
              if (px + estW > cw) px = cw - estW - 6;
              if (py + estH > ch) py = ch - estH - 6;
              setPopup({ nodeId: node.id, x: Math.max(4, px), y: Math.max(4, py) });
            }}
          />
        ))}
      </div>

      {/* Canvas context menu (right-click empty space) */}
      {canvasMenu && (
        <CanvasContextMenu
          x={canvasMenu.x} y={canvasMenu.y}
          onClose={() => setCanvasMenu(null)}
          onAddNode={() => { onAddNodeAt && onAddNodeAt(canvasMenu.worldX, canvasMenu.worldY); }}
          onFit={handleFit}
          onArrange={!readOnly ? onArrange : null}
          readOnly={readOnly}
        />
      )}

      {edgeMenu && (
        <EdgeRouteMenu
          edge={chart.edges.find(e => e.id === edgeMenu.edgeId)}
          x={edgeMenu.x} y={edgeMenu.y}
          onClose={() => setEdgeMenu(null)}
          onChange={changes => {
            const e = chart.edges.find(x => x.id === edgeMenu.edgeId);
            if (!e || !onUpdateEdge) return;
            onUpdateEdge(e.id, { route: { ...(e.route || {}), ...changes } });
          }}
          onClearBend={() => {
            const e = chart.edges.find(x => x.id === edgeMenu.edgeId);
            if (!e || !onUpdateEdge) return;
            onUpdateEdge(e.id, { route: { ...(e.route || {}), bend: null } });
          }}
        />
      )}

      {linkedPopup && (() => {
        const n = chart.nodes.find(nn => nn.id === linkedPopup.nodeId);
        if (!n || !n.linkGroupId) return null;
        const sibs = (typeof findLinkedNodes === 'function')
          ? findLinkedNodes(project, n.linkGroupId, n.id) : [];
        return (
          <LinkedNodesPopover
            x={linkedPopup.x} y={linkedPopup.y}
            node={n} siblings={sibs}
            onClose={() => setLinkedPopup(null)}
            onJump={(cid, nid) => { setLinkedPopup(null); onJumpToLinkedNode && onJumpToLinkedNode(cid, nid); }}
            onUnlink={() => { setLinkedPopup(null); onUnlinkNode && onUnlinkNode(n.id); }}
            readOnly={readOnly}
          />
        );
      })()}

      {selectedSet.size > 1 && !readOnly && (
        <BulkActionBar
          count={selectedSet.size}
          nodeTypes={nodeTypes}
          statuses={statuses}
          onClear={() => setSelected(new Set())}
          onDelete={onBulkDelete}
          onCopy={onBulkCopy}
          onPaste={onBulkPaste}
          onUpdate={onBulkUpdate}
          onAddTag={onBulkAddTag}
          onGroup={onBulkGroupIntoSubChart}
        />
      )}

      {/* Context popup */}
      {popup && popupNode && (
        <NodeContextMenu
          node={popupNode} x={popup.x} y={popup.y} chart={chart}
          isRoot={popupNode.id === chart.rootNodeId}
          collapsedCount={collapsedInfo.get(popupNode.id) || 0}
          hidingAncestorCount={hidingAncInfo.get(popupNode.id) || 0}
          onClose={() => setPopup(null)}
          onAddChild={() => onAddChild(popupNode.id)}
          onStartConnect={() => { onSelectNode(popupNode.id); onToggleConnect(popupNode.id); }}
          onToggleCollapse={() => onToggleCollapse(popupNode.id)}
          onToggleHideAncestors={() => onToggleHideAncestors(popupNode.id)}
          onOpenSubChart={onOpenSubChart}
          onCreateSubChart={onCreateSubChart}
          onDelete={() => onDeleteNode(popupNode.id)}
          onOpenExpanded={() => onOpenExpanded(popupNode.id)}
          onDuplicate={() => onDuplicateNode(popupNode.id)}
          onSplitToSubChart={() => onSplitToSubChart && onSplitToSubChart(popupNode.id)}
          onPromoteToRoot={() => onPromoteToRoot && onPromoteToRoot(popupNode.id)}
          onAttachChartAsSubChart={(chartId) => onAttachChartAsSubChart && onAttachChartAsSubChart(popupNode.id, chartId)}
          onDetachSubChart={() => onDetachSubChart && onDetachSubChart(popupNode.id)}
          onLinkNodes={onLinkNodes}
          onUnlinkNode={onUnlinkNode}
          project={project}
          allCharts={allCharts}
          currentChartId={chart.id}
          readOnly={readOnly}
        />
      )}

      {/* Minimap */}
      {showMinimap && visibleNodes.length > 1 && (
        <Minimap
          nodes={visibleNodes} edges={visibleEdges}
          pan={pan} scale={scale}
          containerW={cw} containerH={ch}
          onNavigate={handleMinimapNavigate}
        />
      )}

      {/* HUD */}
      <div style={{
        position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)',
        display: 'flex', alignItems: 'center', gap: 2,
        background: 'var(--surface)', border: '1px solid var(--border)',
        borderRadius: 99, padding: '5px 8px',
        boxShadow: '0 4px 20px rgba(0,0,0,0.22)', zIndex: 20, userSelect: 'none', pointerEvents: 'auto',
      }}>
        <HudBtn onClick={() => onScaleChange(s => Math.max(0.05, s - 0.15))} title="Zoom out (−)">−</HudBtn>
        <button onClick={handleFit} style={{
          background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text2)',
          fontSize: 11, fontWeight: 600, minWidth: 44, textAlign: 'center', padding: '3px 2px',
        }} title="Fit to screen (F)">{Math.round(scale * 100)}%</button>
        <HudBtn onClick={() => onScaleChange(s => Math.min(3, s + 0.15))} title="Zoom in (+)">+</HudBtn>
        <HudDiv />
        <HudBtn onClick={handleFit} title="Fit all nodes (F)">⊡ Fit</HudBtn>
        {!readOnly && (
          <>
            <HudBtn onClick={onArrange} title="Auto-arrange (A)">⟳ Arrange</HudBtn>
            <select
              value={layoutAlgorithm || 'hierarchical'}
              onChange={e => onLayoutAlgorithmChange && onLayoutAlgorithmChange(e.target.value)}
              title="Layout algorithm"
              style={{
                background: 'transparent', border: '1px solid var(--border)', borderRadius: 6,
                color: 'var(--text)', fontSize: 10, fontFamily: 'inherit', fontWeight: 500,
                cursor: 'pointer', padding: '2px 5px', outline: 'none', maxWidth: 100,
              }}
            >
              <option value="improved">Tree (Reingold-Tilford)</option>
              <option value="hierarchical">Hierarchical (Original)</option>
              <option value="compact">Compact Hierarchical</option>
              <option value="radial">Radial</option>
              <option value="force">Force-Directed</option>
            </select>
          </>
        )}
        {!readOnly && (
          <HudBtn active={connectMode} title="Connect nodes (C)" onClick={() => onToggleConnect()}>
            {connectMode ? '✕ Cancel' : '⟶ Connect'}
          </HudBtn>
        )}
        <HudDiv />
        <HudBtn
          active={showSearch}
          onClick={() => { setShowSearch(v => { if (!v) setTimeout(() => searchRef.current?.focus(), 50); return !v; }); }}
          title="Search nodes (Ctrl+F)"
        >⌕</HudBtn>
        <HudBtn
          active={showMinimap}
          onClick={() => setShowMinimap(v => !v)}
          title="Toggle minimap"
        >⊞</HudBtn>
      </div>
    </div>
  );
}

// ── ChartSidebar ───────────────────────────────────────────────────────────
function ChartSidebar({ charts, selectedChartId, onSelect, onCreate, onDelete, onClone, onReorder, readOnly,
  remoteUsers, myUser, mySelectedChartId }) {
  const [hoverId,    setHoverId]    = useState(null);
  const [dragId,     setDragId]     = useState(null);
  const [dragOverId, setDragOverId] = useState(null);
  const [chartMenu,  setChartMenu]  = useState(null); // { chartId, x, y }
  const canDelete = charts.length > 1;

  // Group remote users by the chart they're currently viewing. We include
  // the local user in their currently-selected chart so the user can see
  // themselves too. Each entry: chartId → [{ id, name, color }]
  const usersByChart = useMemo(() => {
    const map = {};
    Object.entries(remoteUsers || {}).forEach(([id, u]) => {
      if (!u || !u.chartId) return;
      (map[u.chartId] = map[u.chartId] || []).push({ id, name: u.name, color: u.color });
    });
    if (myUser && mySelectedChartId) {
      (map[mySelectedChartId] = map[mySelectedChartId] || []).push({
        id: myUser.id + '_self', name: (myUser.name || 'You') + ' (you)', color: myUser.color, self: true,
      });
    }
    return map;
  }, [remoteUsers, myUser, mySelectedChartId]);

  return (
    <div style={{ width: 226, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0, background: 'var(--surface)' }}>
      <div style={{ padding: '13px 12px 9px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <span style={{ fontWeight: 700, fontSize: 10, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Charts</span>
        {!readOnly && (
          <button onClick={onCreate} style={{ background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: 6, padding: '3px 9px', fontSize: 11, fontWeight: 700, cursor: 'pointer' }} title="New chart">+</button>
        )}
      </div>
      <div style={{ flex: 1, overflowY: 'auto', padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
        {charts.map(chart => {
          const total = chart.nodes.length, done = chart.nodes.filter(n => n.status === 'done').length;
          const active  = chart.id === selectedChartId;
          const isHov   = hoverId === chart.id;
          const isDrag  = dragId === chart.id;
          const isOver  = dragOverId === chart.id && dragOverId !== dragId;
          const users   = usersByChart[chart.id] || [];
          return (
            <div
              key={chart.id}
              draggable={!readOnly && charts.length > 1}
              onDragStart={e => { e.dataTransfer.effectAllowed = 'move'; setDragId(chart.id); }}
              onDragEnd={() => { setDragId(null); setDragOverId(null); }}
              onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverId(chart.id); }}
              onDragLeave={() => setDragOverId(null)}
              onDrop={e => {
                e.preventDefault();
                if (dragId && dragId !== chart.id && onReorder) onReorder(dragId, chart.id);
                setDragId(null); setDragOverId(null);
              }}
              onContextMenu={e => {
                if (readOnly) return;
                e.preventDefault();
                const rect = e.currentTarget.getBoundingClientRect();
                setChartMenu({ chartId: chart.id, x: e.clientX, y: e.clientY });
              }}
              onMouseEnter={() => setHoverId(chart.id)}
              onMouseLeave={() => setHoverId(null)}
              style={{
                position: 'relative',
                opacity: isDrag ? 0.4 : 1,
                transition: 'opacity 0.15s',
                borderTop: isOver ? '2px solid var(--accent)' : '2px solid transparent',
              }}
            >
              {/* drag handle — visible on hover */}
              {!readOnly && charts.length > 1 && (isHov || isDrag) && (
                <span style={{
                  position: 'absolute', left: 2, top: '50%', transform: 'translateY(-50%)',
                  fontSize: 10, color: 'var(--text2)', cursor: 'grab', userSelect: 'none',
                  lineHeight: 1, zIndex: 3, pointerEvents: 'none',
                }}>⠿</span>
              )}
              <button onClick={() => onSelect(chart.id)} style={{
                textAlign: 'left', background: active ? 'var(--accent-muted)' : isHov ? 'var(--bg2)' : 'transparent',
                border: `1px solid ${active ? 'var(--accent)' : 'transparent'}`,
                borderRadius: 10, padding: '9px 10px', cursor: 'pointer', color: 'var(--text)',
                display: 'flex', flexDirection: 'column', gap: 6, transition: 'all 0.1s', width: '100%',
                paddingLeft: (!readOnly && charts.length > 1) ? 18 : 10,
                paddingRight: isHov && !readOnly && canDelete ? 30 : 10,
              }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
                  <strong style={{ flex: 1, fontSize: 12, lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{chart.title}</strong>
                  {/* Per-chart presence stack — shows which teammates (and you,
                      if active) are currently viewing this chart. */}
                  {users.length > 0 && (
                    <SidebarPresence users={users} />
                  )}
                </div>
                <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
                  {total > 0 ? (
                    <>
                      <div style={{ flex: 1, height: 3, background: 'var(--border)', borderRadius: 99, overflow: 'hidden' }}>
                        <div style={{ height: '100%', width: `${(done / total) * 100}%`, background: '#22c55e', borderRadius: 99, transition: 'width 0.3s' }} />
                      </div>
                      <span style={{ fontSize: 9, color: '#22c55e', fontWeight: 700, flexShrink: 0 }}>{done}/{total}</span>
                    </>
                  ) : <span style={{ fontSize: 9, color: 'var(--text2)' }}>empty</span>}
                </div>
              </button>
              {isHov && !readOnly && canDelete && (
                <button
                  onClick={e => { e.stopPropagation(); onDelete(chart.id); }}
                  title="Delete chart"
                  style={{
                    position: 'absolute', top: '50%', right: 6, transform: 'translateY(-50%)',
                    background: '#f87171', border: 'none', borderRadius: 6, width: 20, height: 20,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    cursor: 'pointer', color: '#fff', fontSize: 11, fontWeight: 700, zIndex: 2,
                  }}
                >×</button>
              )}
            </div>
          );
        })}
        {charts.length === 0 && (
          <div style={{ fontSize: 12, color: 'var(--text2)', textAlign: 'center', padding: '28px 8px', lineHeight: 1.7 }}>
            No charts yet.<br />Click + to create one.
          </div>
        )}
      </div>

      {/* Right-click context menu for charts — fixed-position relative to
          the viewport so it doesn't get clipped by the sidebar's overflow. */}
      {chartMenu && (
        <ChartSidebarMenu
          x={chartMenu.x} y={chartMenu.y}
          onClose={() => setChartMenu(null)}
          onClone={() => onClone && onClone(chartMenu.chartId)}
          onDelete={canDelete ? () => onDelete(chartMenu.chartId) : null}
        />
      )}
    </div>
  );
}

// Small presence-pill stack tuned for the sidebar (16px circles, capped at 3).
function SidebarPresence({ users }) {
  const list = (users || []).slice(0, 3);
  if (!list.length) return null;
  return (
    <div style={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
      {list.map((u, i) => {
        const initials = (u.name || '??').split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase();
        return (
          <div key={u.id} title={u.name}
            style={{
              width: 16, height: 16, borderRadius: '50%',
              background: u.color || '#888', color: '#fff',
              fontSize: 7, fontWeight: 800, letterSpacing: '0.01em',
              display: 'grid', placeItems: 'center',
              border: '1.5px solid var(--surface)',
              marginLeft: i === 0 ? 0 : -5,
              boxShadow: '0 0 0 0.5px ' + (u.color || '#888'),
              position: 'relative', zIndex: 5 - i,
            }}>{initials}</div>
        );
      })}
      {users.length > list.length && (
        <span style={{ marginLeft: 4, fontSize: 9, fontWeight: 700, color: 'var(--text2)' }}>
          +{users.length - list.length}
        </span>
      )}
    </div>
  );
}

// Right-click menu for chart rows: clone, delete, etc.
function ChartSidebarMenu({ x, y, onClose, onClone, onDelete }) {
  const menuRef = useRef(null);
  useEffect(() => {
    function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    document.addEventListener('mousedown', onDocDown, true);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocDown, true);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose]);
  // Clamp to viewport so the menu doesn't overflow the right edge.
  const left = Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 1200) - 200);
  const top  = Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 800) - 120);
  return (
    <div ref={menuRef} style={{
      position: 'fixed', left, top, zIndex: 200,
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 10, padding: 5,
      boxShadow: '0 8px 32px rgba(0,0,0,0.32)', minWidth: 180,
    }} onContextMenu={e => e.preventDefault()}>
      <MenuItem onClose={onClose} icon="⎘" label="Clone chart" onClick={onClone} accent />
      {onDelete && <MenuDivider />}
      {onDelete && <MenuItem onClose={onClose} icon="✕" label="Delete chart" onClick={onDelete} red />}
    </div>
  );
}

// ── Export ─────────────────────────────────────────────────────────────────
Object.assign(window, {
  NodeCard, NodeContextMenu, Breadcrumb, Minimap, FcCanvas, ChartSidebar,
  SidebarPresence, ChartSidebarMenu, EdgeRouteMenu, LinkedNodesPopover, BulkActionBar,
  MenuItem, MenuDivider,
  FcBtn, FcInput, FcTextarea, FcSelect, FcLabel, HudBtn, HudDiv,
  ISection, IField,
  fcBtnStyle, fcInputStyle, fcSelectStyle,
  NODE_W, NODE_H_EST,
});
