<?php
// ── NAME / FILE RESOLUTION ──────────────────────────────────────────────────
// "name" can be passed as ?name=xxxx. If it has a .xml extension, that's
// stripped before building the actual input/output (.xml) filename.
$requestedName = $_GET['name'] ?? ($_POST['name'] ?? null);
$rawName = $requestedName ?? 'dataDiagram';
$rawName = basename($rawName);                       // strip any path components
$rawName = preg_replace('/\.xml$/i', '', $rawName);   // strip known ext
$rawName = preg_replace('/[^A-Za-z0-9_\-]/', '', $rawName); // sanitize
if ($rawName === '') {
    $rawName = 'dataDiagram';
}
$baseName = $rawName;
$xmlFile  = $baseName . '.xml';
$svgFile  = $baseName . '.svg';
$mapFile  = $baseName . '.map.html';

// ── DETECT PREVIOUSLY-EXPORTED WIDTH ────────────────────────────────────────
// The image map file (written by "Export SVG + Image Map") embeds an
// <img ... width="NNN" ...> tag carrying the width it was last exported at.
// Use that as the default selection for the "Export width" dropdown; fall
// back to 600 if the diagram has never been exported.
$detectedWidth = 600;
$mapFilePath = __DIR__ . '/' . $mapFile;
if (is_file($mapFilePath)) {
    $mapContents = @file_get_contents($mapFilePath);
    if ($mapContents !== false && preg_match('/<img\b[^>]*\bwidth="(\d+)"/i', $mapContents, $m)) {
        $detectedWidth = (int) $m[1];
    }
}

// ── LOGO DISCOVERY ───────────────────────────────────────────────────────────
// Any image file placed in a "logos" subfolder next to this script becomes
// selectable from the "Logo" dropdown and gets drawn at the top-right corner
// of the live preview and the exported SVG. Drop .png/.jpg/.jpeg/.gif/.svg/
// .webp files into ./logos/ to make them available (no code changes needed).
$logoDir = __DIR__ . '/logos';
$logoFiles = [];
if (is_dir($logoDir)) {
    $exts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
    foreach ($exts as $ext) {
        foreach ((glob($logoDir . '/*.' . $ext) ?: []) as $f) {
            $logoFiles[] = basename($f);
        }
        foreach ((glob($logoDir . '/*.' . strtoupper($ext)) ?: []) as $f) {
            $logoFiles[] = basename($f);
        }
    }
    $logoFiles = array_values(array_unique($logoFiles));
    sort($logoFiles, SORT_NATURAL | SORT_FLAG_CASE);
}
// Preferred default logo, matched case-insensitively (file systems/uploads
// don't always preserve exact case). If that specific file isn't found but
// exactly one logo exists in the folder, default to that one instead.
$defaultLogo = '';
foreach ($logoFiles as $lf) {
    if (strcasecmp($lf, 'ALA_logo_gray.png') === 0) { $defaultLogo = $lf; break; }
}
if ($defaultLogo === '' && count($logoFiles) === 1) {
    $defaultLogo = $logoFiles[0];
}

// ── SAVE HANDLER (XML) ──────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save') {
    header('Content-Type: application/json');
    $xml = $_POST['xml'] ?? '';
    if ($xml === '') {
        echo json_encode(['ok' => false, 'error' => 'No XML content received.']);
        exit;
    }
    $targetFile = __DIR__ . '/' . $xmlFile;
    $result = @file_put_contents($targetFile, $xml);
    if ($result === false) {
        echo json_encode(['ok' => false, 'error' => "Failed to write {$xmlFile}. Check permissions."]);
    } else {
        echo json_encode(['ok' => true, 'bytes' => $result, 'file' => $xmlFile]);
    }
    exit;
}

// ── SAVE HANDLER (SVG + HTML IMAGE MAP TOGETHER) ─────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save_both') {
    header('Content-Type: application/json');
    $svg = $_POST['svg'] ?? '';
    $map = $_POST['map'] ?? '';
    if ($svg === '' || $map === '') {
        echo json_encode(['ok' => false, 'error' => 'Missing SVG or map content.']);
        exit;
    }
    $svgResult = @file_put_contents(__DIR__ . '/' . $svgFile, $svg);
    $mapResult = @file_put_contents(__DIR__ . '/' . $mapFile, $map);
    if ($svgResult === false || $mapResult === false) {
        $err = [];
        if ($svgResult === false) $err[] = "Failed to write {$svgFile}.";
        if ($mapResult === false) $err[] = "Failed to write {$mapFile}.";
        echo json_encode(['ok' => false, 'error' => implode(' ', $err) . ' Check permissions.']);
    } else {
        echo json_encode([
            'ok' => true,
            'svg_file' => $svgFile,
            'svg_bytes' => $svgResult,
            'map_file' => $mapFile,
            'map_bytes' => $mapResult,
        ]);
    }
    exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MAGEC Data Relationship Diagram</title>
<style>
  /* ── THEME VARIABLES ────────────────────────────────────────────────────
     Defaults here are the "Bold" theme (this app's original look, and
     flowchart.php's default too). The theme <select> in the topbar
     overwrites these at runtime via JS (see THEMES / applyTheme() below) —
     this block just ensures the page looks correct before JS finishes
     initializing, and acts as the canonical fallback values. */
  :root {
    --bg-app: #0f1117;
    --bg-panel: #1a1d27;
    --bg-input: #20232f;
    --border: #2d3148;
    --border-strong: #4a5280;
    --text-primary: #e2e8f0;
    --text-muted: #94a3b8;
    --text-dim: #64748b;
    --accent: #a5b4fc;
    --accent-strong: #6366f1;
    --accent-hover-glow: rgba(99,102,241,.18);
    --success: #4ade80;
    --warn: #fbbf24;
    --danger-bg: #7f1d1d;
    --danger-text: #fecaca;
    --edge-color: #8b95c4;
    --mono-text: #c7d2fe;
    --domain-color: #ec4899;
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: 'Segoe UI', system-ui, sans-serif;
    background: var(--bg-app);
    color: var(--text-primary);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  #topbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    padding: 10px 16px;
    background: var(--bg-panel);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
  }
  #topbar h1 {
    font-size: 15px;
    font-weight: 600;
    color: var(--accent);
    letter-spacing: .04em;
  }
  select, input[type=text] {
    background: var(--bg-input);
    color: var(--text-primary);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 5px 8px;
    font-size: 13px;
  }
  .btn {
    padding: 6px 14px;
    border-radius: 6px;
    border: none;
    font-size: 13px;
    font-weight: 500;
    cursor: pointer;
    transition: opacity .15s, background .15s;
    background: var(--border);
    color: var(--text-primary);
  }
  .btn:hover { opacity: .85; }
  .btn-primary { background: var(--accent-strong); color: #fff; }
  .btn-danger { background: var(--danger-bg); color: var(--danger-text); }
  .btn.active { background: var(--accent-strong); color: #fff; }
  .btn:disabled { opacity: .4; cursor: not-allowed; }

  .topbar-spacer { flex: 1; }
  .topbar-sep { width: 1px; align-self: stretch; background: var(--border); }

  label.inline {
    font-size: 12px; color: var(--text-muted);
    display: flex; align-items: center; gap: 6px; cursor: pointer;
  }

  #main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
  #chart-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
  #chart-panel .panel-header {
    position: relative;
    padding: 8px 12px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--text-dim);
    border-bottom: 1px solid var(--border);
    background: var(--bg-panel);
    display: flex;
    align-items: center;
    gap: 8px;
  }
  #svg-container {
    position: relative;
    flex: 1;
    min-height: 0;
    overflow: auto;
    padding: 40px;
    background: var(--bg-app);
    background-image:
      linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
      linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
    background-size: 24px 24px;
  }
  #diagram-svg { display: block; }

  .entity-shape { cursor: pointer; }
  .entity-body { fill: var(--bg-panel); stroke: var(--border-strong); stroke-width: 1.5; }
  .entity-shape.selected .entity-body { stroke: var(--accent); stroke-width: 2.5; }
  .entity-shape:hover .entity-body { filter: brightness(1.15); }
  .entity-shape.link-armed .entity-body { stroke: var(--success); stroke-width: 2.5; stroke-dasharray: 5 3; }

  .rel-line { stroke: var(--edge-color); stroke-width: 1.75; fill: none; cursor: pointer; }
  .rel-hit { stroke: transparent; stroke-width: 14; fill: none; cursor: pointer; }
  .rel-group:hover .rel-line { stroke: var(--accent); stroke-width: 2.25; }
  .rel-symbol { stroke: var(--edge-color); stroke-width: 1.75; fill: none; }
  .rel-symbol-fill { stroke: var(--edge-color); stroke-width: 1.75; fill: var(--bg-app); }
  .rel-group:hover .rel-symbol, .rel-group:hover .rel-symbol-fill { stroke: var(--accent); }
  .rel-label-bg { fill: var(--bg-app); }
  .rel-label-text { fill: var(--text-muted); font-size: 11px; font-family: 'Segoe UI', system-ui, sans-serif; font-style: italic; }
  .rel-group:hover .rel-label-text { fill: var(--accent); }
  .rel-domain-text { fill: var(--domain-color); font-size: 9px; font-family: 'Segoe UI', system-ui, sans-serif; font-weight: 600; }

  .copyright-notice {
    font-family: 'Segoe UI', system-ui, sans-serif;
    font-size: 10px;
    fill: var(--text-muted);
    opacity: .85;
    pointer-events: none;
    user-select: none;
  }

  #legend {
    position: absolute;
    top: 100%; right: 12px; margin-top: 6px;
    background: var(--bg-panel);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 10px 14px;
    font-size: 11px;
    color: var(--text-muted);
    max-width: 240px;
    box-shadow: 0 8px 24px rgba(0,0,0,.4);
    z-index: 20;
  }
  #legend h3 {
    font-size: 10px; text-transform: uppercase; letter-spacing: .08em;
    color: var(--text-dim); margin-bottom: 8px; font-weight: 700;
  }
  #legend .legend-row { display: flex; align-items: center; gap: 8px; margin: 5px 0; }
  #legend svg { flex-shrink: 0; }
  #legend-toggle {
    margin-left: auto; flex-shrink: 0;
    background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px;
    color: var(--text-dim); font-size: 12px; padding: 4px 10px; cursor: pointer;
    text-transform: none; letter-spacing: 0; font-weight: 500;
  }
  #legend-toggle.active { color: var(--accent); border-color: var(--accent); }

  /* ── Modal overlay (shared by entity / relationship / view modals) ──── */
  .overlay {
    display: none;
    position: fixed; inset: 0;
    background: rgba(0,0,0,.55);
    align-items: center; justify-content: center;
    z-index: 50;
  }
  .overlay.open { display: flex; }
  .modal-box {
    background: var(--bg-panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    width: min(480px, 92vw);
    max-height: 86vh;
    overflow-y: auto;
    padding: 22px 24px;
    box-shadow: 0 20px 60px rgba(0,0,0,.5);
  }
  .modal-box h2 { font-size: 16px; margin-bottom: 14px; color: var(--text-primary); }
  .modal-close {
    float: right; background: none; border: none; color: var(--text-dim);
    font-size: 20px; cursor: pointer; line-height: 1;
  }
  .badge {
    display: inline-block; padding: 2px 10px; border-radius: 999px;
    font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
    color: #fff; margin-bottom: 10px;
  }
  .xf-row { display: flex; gap: 10px; margin-bottom: 12px; }
  .xf-field { flex: 1; display: flex; flex-direction: column; gap: 4px; }
  .xf-field label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
  .xf-field select, .xf-field input, .xf-field textarea {
    width: 100%; background: var(--bg-input); color: var(--text-primary);
    border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; font-size: 13px;
    font-family: 'Segoe UI', system-ui, sans-serif;
  }
  .xf-field textarea { font-family: 'SFMono-Regular',Consolas,Menlo,monospace; font-size: 12.5px; resize: vertical; }
  .xf-field.narrow { flex: 0 0 120px; }
  .xf-hint { font-size: 11px; color: var(--text-dim); margin: -4px 0 12px; line-height: 1.5; }
  #em-block-kyf, #em-block-elt, #em-block-dit, #em-block-dit-domain, #em-block-dit-domain-hint, #em-block-comment { display: none; }
  .xf-cardinality-pair {
    display: flex; align-items: center; justify-content: space-between;
    background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px;
    padding: 10px 12px; margin-bottom: 12px; gap: 10px;
  }
  .xf-cardinality-pair .side { flex: 1; display: flex; flex-direction: column; gap: 6px; }
  .xf-cardinality-pair .side-label { font-size: 11px; color: var(--text-dim); text-align: center; }
  .xf-arrow { color: var(--text-dim); font-size: 16px; }
  .modal-actions { display: flex; justify-content: space-between; margin-top: 16px; gap: 8px; }
  .view-rel-endpoint {
    background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px;
    padding: 10px 12px; margin: 8px 0; font-size: 13px;
  }
  .view-rel-endpoint b { color: var(--accent); }

  /* ── PRINT ─────────────────────────────────────────────────────────
     Only the diagram itself gets printed — topbar and any open modals
     are hidden. The page background is forced to white regardless of the
     active theme, so printing while on Bold/Technical/Monochrome doesn't
     waste ink on one giant dark rectangle behind the diagram; entity/
     relationship colors themselves still print as-is (pick the Boardroom
     theme first for genuinely ink-economical pure black-and-white output). */
  @media print {
    body * { visibility: hidden; }
    #svg-container, #svg-container * { visibility: visible; }
    #svg-container {
      position: absolute;
      top: 0; left: 0;
      width: 100%;
      height: auto;
      padding: 0;
      margin: 0;
      overflow: visible;
      background: #fff !important;
    }
    #diagram-svg { max-width: 100%; height: auto; }
    html, body { background: #fff !important; height: auto; overflow: visible; }
  }
</style>
</head>
<body>

<div id="topbar">
  <h1>⬡ MAGEC Data Relationship Diagram</h1>
  <label for="theme-select" class="inline" style="cursor:default;">
    🎨
    <select id="theme-select" onchange="onThemeSelectChange(this.value)">
      <option value="bold">Bold (default)</option>
      <option value="boardroom">Boardroom (B&amp;W print)</option>
      <option value="technical">Technical</option>
      <option value="monochrome">Monochrome</option>
    </select>
  </label>
  <label class="inline" for="edit-mode-toggle">
    <input type="checkbox" id="edit-mode-toggle" onchange="toggleEditMode()"/>
    ✏️ Edit mode
  </label>
  <div class="topbar-sep"></div>
  <button class="btn" id="add-entity-btn" onclick="addEntity()" disabled>➕ Add entity</button>
  <button class="btn" id="link-mode-btn" onclick="toggleLinkMode()" disabled>🔗 Link entities</button>
  <div class="topbar-spacer"></div>
  <span id="edit-mode-hint" style="font-size:12px; color:var(--text-dim);"></span>
  <label for="export-width-select" class="inline" style="cursor:default;">
    Export width:
    <select id="export-width-select" onchange="onExportWidthChange()">
<?php $standardWidths = [1200, 900, 600]; ?>
<?php foreach ($standardWidths as $w): ?>
      <option value="<?php echo $w; ?>"<?php echo ($w === $detectedWidth) ? ' selected' : ''; ?>><?php echo $w; ?>px</option>
<?php endforeach; ?>
<?php if (!in_array($detectedWidth, $standardWidths, true)): ?>
      <option value="<?php echo $detectedWidth; ?>" selected><?php echo $detectedWidth; ?>px</option>
<?php endif; ?>
    </select>
  </label>
  <label for="logo-select" class="inline" title="Images are read from a 'logos' folder next to this script">
    Logo:
    <select id="logo-select" onchange="onLogoChange()">
      <option value="">None</option>
<?php foreach ($logoFiles as $lf): ?>
      <option value="<?php echo htmlspecialchars($lf, ENT_QUOTES); ?>"<?php echo ($lf === $defaultLogo) ? ' selected' : ''; ?>><?php echo htmlspecialchars($lf, ENT_QUOTES); ?></option>
<?php endforeach; ?>
<?php if (empty($logoFiles)): ?>
      <option value="_none_found_" disabled>(add images to /logos)</option>
<?php endif; ?>
    </select>
  </label>
  <button class="btn" onclick="exportAll()">📤 Export SVG + Image Map</button>
  <button class="btn btn-primary" id="save-btn" onclick="saveDiagram()" disabled>💾 Save</button>
  <button class="btn" onclick="window.print()">🖨️ Print</button>
  <button class="btn" onclick="exitApp()">🚪 Exit</button>
</div>

<div id="main">
  <div id="chart-panel">
    <div class="panel-header">
      <span>📊</span> Live Preview
      <span id="chart-title"></span>
      <span id="counts" style="text-transform:none; letter-spacing:0; font-weight:400;"></span>
      <button id="legend-toggle" onclick="toggleLegend()">ℹ️ Notation</button>
      <div id="legend" style="display:none;"></div>
    </div>
    <div id="svg-container">
      <svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"></svg>
    </div>
  </div>
</div>

<!-- ENTITY EDIT MODAL -->
<div class="overlay" id="entity-modal-overlay" onclick="closeOverlay(event,'entity-modal-overlay')">
  <div class="modal-box">
    <button class="modal-close" onclick="closeOverlay(null,'entity-modal-overlay')">✕</button>
    <h2>Edit entity</h2>
    <div class="xf-row">
      <div class="xf-field narrow">
        <label for="em-type">Type</label>
        <select id="em-type" onchange="updateEntityModalFields()">
          <option value="DCL">DCL</option>
          <option value="KYF">KYF</option>
          <option value="ELT">ELT</option>
          <option value="DIT">DIT</option>
          <option value="COPYBOOK">COPYBOOK</option>
          <option value="DOMAIN">DOMAIN</option>
          <option value="TITLE">TITLE</option>
          <option value="COMMENT">COMMENT</option>
        </select>
      </div>
      <div class="xf-field" id="em-name-field">
        <label for="em-name">Name</label>
        <input id="em-name" type="text" placeholder="e.g. VAC01-DAYS-EARNED" style="text-transform:uppercase;"/>
      </div>
    </div>
    <div class="xf-row" id="em-block-comment">
      <div class="xf-field">
        <label for="em-comment-text">Text</label>
        <textarea id="em-comment-text" rows="4" placeholder="Comment text — wraps automatically on the diagram"></textarea>
      </div>
    </div>
    <div class="xf-row">
      <div class="xf-field narrow">
        <label for="em-id">ID (mirrors name)</label>
        <input id="em-id" type="text" disabled style="opacity:.6;"/>
      </div>
    </div>
    <div class="xf-row">
      <div class="xf-field">
        <label for="em-narrative">Narrative</label>
        <textarea id="em-narrative" rows="2" placeholder="Short description of this entity"></textarea>
      </div>
    </div>

    <!-- KYF-only -->
    <div class="xf-row" id="em-block-kyf">
      <div class="xf-field narrow">
        <label for="em-keytype">Key type</label>
        <select id="em-keytype">
          <option value="Primary">Primary</option>
          <option value="Alternate">Alternate</option>
          <option value="Foreign">Foreign</option>
          <option value="Unique">Unique</option>
        </select>
      </div>
    </div>

    <!-- ELT-only -->
    <div class="xf-row" id="em-block-elt">
      <div class="xf-field narrow">
        <label for="em-offset">Offset</label>
        <input id="em-offset" type="text" placeholder="0"/>
      </div>
      <div class="xf-field narrow">
        <label for="em-length">Length</label>
        <input id="em-length" type="text" placeholder="300"/>
      </div>
    </div>

    <!-- DIT-only -->
    <div class="xf-row" id="em-block-dit">
      <div class="xf-field">
        <label for="em-picture">Picture</label>
        <input id="em-picture" type="text" placeholder="e.g. 9(9), S9(7)V99" style="text-transform:uppercase;"/>
      </div>
      <div class="xf-field narrow">
        <label for="em-derived">Derived</label>
        <select id="em-derived"><option value="n">n</option><option value="y">y</option></select>
      </div>
    </div>
    <div class="xf-row" id="em-block-dit-domain">
      <div class="xf-field">
        <label for="em-domain">Domain (optional)</label>
        <input id="em-domain" type="text" list="em-domain-list" placeholder="e.g. CUSTOMER-ID" style="text-transform:uppercase;"/>
        <datalist id="em-domain-list"></datalist>
      </div>
    </div>
    <div class="xf-hint" id="em-block-dit-domain-hint">Only some DITs need a domain — leave blank if not applicable. If filled in and it doesn't already exist on the diagram, a matching DOMAIN node is added automatically when you save.</div>

    <div class="xf-row">
      <div class="xf-field">
        <label for="em-link">Link</label>
        <input id="em-link" type="text" placeholder="e.g. https://... or another diagram's URL"/>
      </div>
      <div class="xf-field narrow">
        <label for="em-target">Target</label>
        <input id="em-target" type="text" placeholder="MAIN"/>
      </div>
    </div>

    <div id="em-block-domain-usage" style="display:none; margin: -4px 0 12px;">
      <div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;">Where used in this diagram</div>
      <div id="em-domain-usage-list" style="font-size:13px;color:var(--text-muted);"></div>
      <div style="font-size:11px;color:var(--text-dim);margin-top:6px;">This domain may also be referenced by DITs on other diagrams — this list only covers the current one.</div>
    </div>

    <div class="modal-actions">
      <button class="btn btn-danger" onclick="deleteEntity()">🗑 Delete entity</button>
      <button class="btn btn-primary" onclick="commitEntity()">Save changes</button>
    </div>
  </div>
</div>

<!-- RELATIONSHIP EDIT MODAL -->
<div class="overlay" id="rel-modal-overlay" onclick="closeOverlay(event,'rel-modal-overlay')">
  <div class="modal-box">
    <button class="modal-close" onclick="closeOverlay(null,'rel-modal-overlay')">✕</button>
    <h2>Edit relationship</h2>
    <div class="xf-row">
      <div class="xf-field">
        <label>Label</label>
        <input id="rm-label" type="text" placeholder="e.g. owns, contains, maps to"/>
      </div>
      <div class="xf-field">
        <label>Domain (optional)</label>
        <select id="rm-domain"><option value="">— none —</option></select>
      </div>
    </div>
    <div class="xf-cardinality-pair">
      <div class="side">
        <div class="side-label" id="rm-from-name">FROM</div>
        <select id="rm-from-card"><option value="one">One</option><option value="many">Many</option></select>
        <label class="inline"><input type="checkbox" id="rm-from-optional"/> Optional</label>
      </div>
      <div class="xf-arrow">→</div>
      <div class="side">
        <div class="side-label" id="rm-to-name">TO</div>
        <select id="rm-to-card"><option value="one">One</option><option value="many">Many</option></select>
        <label class="inline"><input type="checkbox" id="rm-to-optional"/> Optional</label>
      </div>
    </div>
    <div class="xf-hint">Cardinality sits at the entity's own edge (one = single tick, many = crow's foot). Optionality sits further out (circle = optional/zero, tick = mandatory/one).</div>
    <div class="modal-actions">
      <button class="btn btn-danger" onclick="deleteRelationship()">🗑 Delete relationship</button>
      <button class="btn btn-primary" onclick="commitRelationship()">Save changes</button>
    </div>
  </div>
</div>

<!-- READ-ONLY VIEW MODAL (used when not in edit mode) -->
<div class="overlay" id="view-modal-overlay" onclick="closeOverlay(event,'view-modal-overlay')">
  <div class="modal-box">
    <button class="modal-close" onclick="closeOverlay(null,'view-modal-overlay')">✕</button>
    <div id="view-badge" class="badge"></div>
    <h2 id="view-title"></h2>
    <div id="view-body"></div>
    <div id="view-link-wrap" style="margin-top:14px;display:none;">
      <a id="view-link" href="#" target="MAIN"
         style="display:inline-block;font-size:13px;font-weight:600;color:var(--accent);text-decoration:none;border:1px solid var(--border-strong);border-radius:6px;padding:6px 12px;">
        🔗 Open link
      </a>
    </div>
  </div>
</div>

<script>
// ── CONFIG ───────────────────────────────────────────────────────────────
const XML_FILENAME = <?php echo json_encode($xmlFile); ?>;
const SVG_FILENAME = <?php echo json_encode($svgFile); ?>;
const MAP_FILENAME = <?php echo json_encode($mapFile); ?>;
let BASE_NAME       = <?php echo json_encode($baseName); ?>;

// Single source of truth for the trademark/copyright notice text, reused
// across the live-preview SVG, the exported SVG, the saved XML (as a
// comment), and the HTML image map (as a comment). A function rather than a
// plain constant so the year is always whatever's current at the moment
// something is rendered/saved.
function copyrightNoticeLines() {
  const year = new Date().getFullYear();
  return [
    `MAGEC is a trademark of Al Lee & Associates, Inc. \u00A9 ${year} Al Lee & Associates, Inc. All rights reserved.`,
    `All programs, scripts, diagrams, and documentation are copyright protected.`
  ];
}
function copyrightNoticeText() {
  return copyrightNoticeLines().join(' ');
}

const ENTITY_TYPES = {
  DCL:      { color: '#6366f1' },
  KYF:      { color: '#10b981' },
  ELT:      { color: '#a855f7' },
  DIT:      { color: '#f59e0b' },
  COPYBOOK: { color: '#14b8a6' },
  DOMAIN:   { color: '#ec4899' },
  TITLE:    { color: '#a5b4fc' },
  COMMENT:  { color: '#94a3b8' },
};

// ── COLOR THEMES ─────────────────────────────────────────────────────────
// Same four themes as flowchart.php, reusing its exact CSS custom-property
// values (bg/border/text/accent/edge colors) for consistency across the two
// tools — only the vars this stylesheet actually declares in :root are
// included here (dataDiagram has no warn-bg/warn-border/danger-soft-bg/
// node-label-color usage, so those are omitted). "entityColors" supplies
// each entity type's chip color per theme (this app's equivalent of
// flowchart's per-node-type fill/stroke) — applyTheme() mutates the live
// ENTITY_TYPES object's .color fields in place from these on every switch.
const THEMES = {
  bold: {
    label: 'Bold (default)',
    css: {
      '--bg-app': '#0f1117', '--bg-panel': '#1a1d27', '--bg-input': '#20232f',
      '--border': '#2d3148', '--border-strong': '#4a5280',
      '--text-primary': '#e2e8f0', '--text-muted': '#94a3b8', '--text-dim': '#64748b',
      '--accent': '#a5b4fc', '--accent-strong': '#6366f1', '--accent-hover-glow': 'rgba(99,102,241,.18)',
      '--success': '#4ade80', '--warn': '#fbbf24',
      '--danger-bg': '#7f1d1d', '--danger-text': '#fecaca',
      '--edge-color': '#8b95c4', '--mono-text': '#c7d2fe',
    },
    entityColors: {
      DCL: '#6366f1', KYF: '#10b981', ELT: '#a855f7', DIT: '#f59e0b',
      COPYBOOK: '#14b8a6', DOMAIN: '#ec4899', TITLE: '#a5b4fc', COMMENT: '#94a3b8',
    },
  },
  boardroom: {
    label: 'Boardroom (B&W print)',
    css: {
      '--bg-app': '#ffffff', '--bg-panel': '#f7f7f7', '--bg-input': '#eeeeee',
      '--border': '#cccccc', '--border-strong': '#999999',
      '--text-primary': '#1a1a1a', '--text-muted': '#4d4d4d', '--text-dim': '#737373',
      '--accent': '#1a1a1a', '--accent-strong': '#404040', '--accent-hover-glow': 'rgba(0,0,0,.15)',
      '--success': '#2b2b2b', '--warn': '#333333',
      '--danger-bg': '#1a1a1a', '--danger-text': '#ffffff',
      '--edge-color': '#4d4d4d', '--mono-text': '#262626',
    },
    // Pure white background, black/gray chips only — no hue anywhere, so
    // printing this theme costs only black toner/ink, not color cartridges.
    entityColors: {
      DCL: '#1a1a1a', KYF: '#333333', ELT: '#4d4d4d', DIT: '#666666',
      COPYBOOK: '#404040', DOMAIN: '#000000', TITLE: '#262626', COMMENT: '#595959',
    },
  },
  technical: {
    label: 'Technical (schematic)',
    css: {
      '--bg-app': '#0b1414', '--bg-panel': '#0f1c1c', '--bg-input': '#142626',
      '--border': '#1f3838', '--border-strong': '#2f5252',
      '--text-primary': '#d3f5ea', '--text-muted': '#7fa89e', '--text-dim': '#4f6f68',
      '--accent': '#4fd1c5', '--accent-strong': '#14b8a6', '--accent-hover-glow': 'rgba(20,184,166,.2)',
      '--success': '#4ade80', '--warn': '#eab308',
      '--danger-bg': '#7c2d2d', '--danger-text': '#fca5a5',
      '--edge-color': '#4fd1c5', '--mono-text': '#a7f3ea',
    },
    entityColors: {
      DCL: '#14b8a6', KYF: '#10b981', ELT: '#0ea5e9', DIT: '#d97706',
      COPYBOOK: '#0d9488', DOMAIN: '#e11d48', TITLE: '#2dd4bf', COMMENT: '#527a72',
    },
  },
  monochrome: {
    label: 'Monochrome (B&W)',
    css: {
      '--bg-app': '#0a0a0a', '--bg-panel': '#161616', '--bg-input': '#202020',
      '--border': '#333333', '--border-strong': '#4d4d4d',
      '--text-primary': '#f5f5f5', '--text-muted': '#a0a0a0', '--text-dim': '#707070',
      '--accent': '#ffffff', '--accent-strong': '#808080', '--accent-hover-glow': 'rgba(255,255,255,.18)',
      '--success': '#cccccc', '--warn': '#e0e0e0',
      '--danger-bg': '#000000', '--danger-text': '#ffffff',
      '--edge-color': '#999999', '--mono-text': '#d0d0d0',
    },
    entityColors: {
      DCL: '#595959', KYF: '#666666', ELT: '#404040', DIT: '#737373',
      COPYBOOK: '#333333', DOMAIN: '#8c8c8c', TITLE: '#808080', COMMENT: '#2b2b2b',
    },
  },
};

let currentThemeName = 'bold';

function hexToRgb(hex) {
  const c = String(hex).replace('#', '');
  return [parseInt(c.substr(0, 2), 16), parseInt(c.substr(2, 2), 16), parseInt(c.substr(4, 2), 16)];
}

// Applies a theme: pushes its CSS custom properties onto the document (so
// every var(--...) reference in the stylesheet picks it up immediately),
// mutates the shared ENTITY_TYPES/EXPORT_COLORS objects in place (so every
// piece of code that reads ENTITY_TYPES[type].color or EXPORT_COLORS.* —
// live preview, standalone SVG export, image-map export — reflects the new
// theme without needing its own theme-awareness), and remembers the choice
// in localStorage as a fallback for brand-new diagrams that don't have a
// theme saved in their own XML yet (see applyLoadedSettings()).
function applyTheme(themeName, skipRender) {
  const theme = THEMES[themeName] ? themeName : 'bold';
  const t = THEMES[theme];

  Object.entries(t.css).forEach(([varName, value]) => {
    document.documentElement.style.setProperty(varName, value);
  });
  document.documentElement.style.setProperty('--domain-color', t.entityColors.DOMAIN);
  currentThemeName = theme;

  Object.keys(ENTITY_TYPES).forEach(type => {
    if (t.entityColors[type]) ENTITY_TYPES[type].color = t.entityColors[type];
  });
  EXPORT_COLORS.bgApp = t.css['--bg-app'];
  EXPORT_COLORS.bgPanel = t.css['--bg-panel'];
  EXPORT_COLORS.borderStrong = t.css['--border-strong'];
  EXPORT_COLORS.textPrimary = t.css['--text-primary'];
  EXPORT_COLORS.textMuted = t.css['--text-muted'];
  EXPORT_COLORS.edgeColor = t.css['--edge-color'];

  const select = document.getElementById('theme-select');
  if (select) select.value = theme;

  try { localStorage.setItem('dataDiagram-theme', theme); } catch (e) { /* private browsing, etc. — non-fatal */ }

  if (!skipRender) renderDiagram(); // no-op before any data is loaded, or during settings restore
}

function getSavedTheme() {
  try { return localStorage.getItem('dataDiagram-theme') || 'bold'; } catch (e) { return 'bold'; }
}

function onThemeSelectChange(name) {
  applyTheme(name);
  markSettingsChanged();
}

const NS = 'http://www.w3.org/2000/svg';
const BOX_H = 22;
const ROW_STEP = BOX_H + 6; // vertical spacing between stacked rows in a DCL group
const INDENT_KYF = 10;      // KYF sits 10px right of its DCL
const INDENT_ELT = 20;      // ELT sits 20px right of its DCL
const INDENT_DIT = 10;      // DIT sits 10px right of its owning ELT
const NAME_FONT = '700 12.5px Segoe UI, system-ui, sans-serif';
const DIT_NAME_FONT = '400 10.5px Segoe UI, system-ui, sans-serif';
const CHIP_FONT = '700 9px Segoe UI, system-ui, sans-serif';
const DIT_BOX_H = BOX_H - 4;
// TITLE/COMMENT geometry, fonts, and colors mirror flowchart.php's
// BASE_TYPE_CFG.title/.comment exactly (w/h/fontSize/fontFamily) and its
// "Bold" theme's nodeColors.title/.comment (fill:none, stroke:none — no
// box at all, just text — and the same fontColor), since dataDiagram.php
// is intentionally locked to that one theme (see note above).
const TITLE_W = 460, TITLE_H = 60;
const TITLE_FONT = "700 28px Georgia, 'Times New Roman', Cambria, serif";
const COMMENT_W = 220, COMMENT_H = 40;
const COMMENT_FONT = "600 13px 'Segoe UI', system-ui, sans-serif";
const COMMENT_MAX_CHARS = 30;
// Logo box size, in the same content-unit space as entity positions/sizes
// below (mirrors flowchart.php's LOGO_W/LOGO_H/LOGO_MARGIN exactly, so a
// logo placed in ./logos/ looks consistent between the two tools).
const LOGO_W = 239, LOGO_H = 70, LOGO_MARGIN = 8;
function nameFontFor(type) { return (type === 'DIT' || type === 'COPYBOOK') ? DIT_NAME_FONT : NAME_FONT; }
function boxHeightFor(type) { return type === 'DIT' ? DIT_BOX_H : BOX_H; }
function fontPartsFor(type) { return (type === 'DIT' || type === 'COPYBOOK') ? { size: 10.5, weight: 400 } : { size: 12.5, weight: 700 }; }
// Character-count word-wrap, same algorithm as flowchart.php's wrapLabel()
// (minus its <br> support, which dataDiagram's plain-text fields don't need).
function wrapLabel(text, maxChars) {
  const words = String(text || '').trim().split(/\s+/).filter(Boolean);
  if (!words.length) return [''];
  const lines = [];
  let cur = '';
  words.forEach(w => {
    if ((cur + ' ' + w).trim().length > maxChars && cur) {
      lines.push(cur.trim());
      cur = w;
    } else {
      cur = (cur + ' ' + w).trim();
    }
  });
  if (cur) lines.push(cur.trim());
  return lines;
}
function titleLines(e) { return wrapLabel(e.name || 'Untitled', 9999); }       // effectively single-line, like flowchart's title
function commentLines(e) { return wrapLabel(e.name || 'Comment', COMMENT_MAX_CHARS); }

// A standalone exported SVG/image-map file can't see this page's CSS custom
// properties, so export colors are hardcoded here (mirrors flowchart.php's
// THEMES-based approach) rather than reusing var(--...) from the stylesheet.
// Initial values are the Bold theme's; applyTheme() overwrites these fields
// in place whenever the theme changes, so every reader of EXPORT_COLORS
// (the standalone SVG/image-map export builders) automatically follows the
// active theme without needing its own theme-awareness.
const EXPORT_COLORS = {
  bgApp: '#0f1117',
  bgPanel: '#1a1d27',
  borderStrong: '#4a5280',
  textPrimary: '#e2e8f0',
  textMuted: '#94a3b8',
  edgeColor: '#8b95c4',
};

// Blends a hex color toward the *active theme's* panel background — used to
// make a derived DIT's chip a bit less bright than a stored (non-derived)
// one. Reads THEMES[currentThemeName] rather than a fixed value so this
// still looks right on light themes (e.g. Boardroom) instead of always
// blending toward the dark Bold-theme panel color.
function mutedColor(hex, amount = 0.42) {
  const bgHex = (THEMES[currentThemeName] || THEMES.bold).css['--bg-panel'];
  const bg = hexToRgb(bgHex);
  const c = hex.replace('#', '');
  const r = parseInt(c.substr(0, 2), 16), g = parseInt(c.substr(2, 2), 16), b = parseInt(c.substr(4, 2), 16);
  const mix = (v, i) => Math.round(v * (1 - amount) + bg[i] * amount);
  return `rgb(${mix(r, 0)},${mix(g, 1)},${mix(b, 2)})`;
}
const _measureCtx = document.createElement('canvas').getContext('2d');
function textWidth(text, font) {
  _measureCtx.font = font;
  return _measureCtx.measureText(text).width;
}

// Known child-element tags this tool understands per entity type, in the
// order they're written back out. "narrative" applies to every type.
const TYPE_ATTR_KEYS = {
  DCL: [], KYF: ['keytype'], ELT: ['offset', 'length'],
  DIT: ['picture', 'derived', 'domain'], COPYBOOK: [], DOMAIN: [],
};

// ── STATE ────────────────────────────────────────────────────────────────
let entities = [];       // {id, type, name, x, y, attrs:{narrative, keytype, offset, length, picture, derived, ...}}
let relationships = [];  // {id, from, to, fromCard, toCard, fromOptional, toOptional, label, domain}
let editMode = false;
let hasUnsavedChanges = false;
let linkModeActive = false;
let linkFirstEntity = null;
let relCounter = 0;
let noteCounter = 0; // for TITLE/COMMENT ids ("note1", "note2"...), which are independent of their (free-text, non-unique) name
// Settings (export width, logo selection) read from a <settings> element in
// the last-loaded XML, applied to the topbar controls once on load. See
// applyLoadedSettings() / writeSettingsIntoXml() below.
let loadedSettings = null;
let intentionalExit = false;
// Whether the currently-loaded XML file actually existed on disk when
// this page loaded (set in loadDiagram()). A diagram that doesn't have a
// file yet (brand-new / default name) prompts for a file name the first
// time it's saved, rather than silently writing to "dataDiagram.xml" —
// see saveDiagram().
let fileExisted = false;

let dragState = null; // {entity, startMouseX, startMouseY, startX, startY, moved}

// Map of entity id -> parent entity id, populated by applyAutoLayout().
// Any entity present here has its x/y computed automatically each render
// (from its DCL/ELT ancestor) and is not draggable; only the anchors —
// DCLs and any ungrouped/orphan entities — can be dragged.
let entityParentMap = {};

// Naming convention drives the hierarchy, so no relationship line is drawn
// for a pair that's already implied by it: DCL→KYF, DCL→ELT, ELT→DIT.
// Applies auto-layout in place: computes x/y for every KYF/ELT/DIT that
// belongs to a DCL group (by name-prefix match) relative to that DCL's own
// (draggable) position, and stacks rows in a fixed vertical order:
// DCL, its KYFs, then each top-level ELT followed immediately by its DITs.
function applyAutoLayout() {
  entityParentMap = {};
  const dcls = entities.filter(en => en.type === 'DCL');

  // Most-specific (longest name) DCL whose name is a strict prefix of `name`.
  function ownerDcl(name) {
    let best = null;
    for (const d of dcls) {
      if (name.startsWith(d.name) && name !== d.name) {
        if (!best || d.name.length > best.name.length) best = d;
      }
    }
    return best;
  }

  for (const dcl of dcls) {
    const kyfs = entities.filter(en => en.type === 'KYF' && ownerDcl(en.name) === dcl);
    const topElts = entities.filter(en => en.type === 'ELT' && ownerDcl(en.name) === dcl)
      .sort((a, b) => a.name.localeCompare(b.name));

    let rowY = dcl.y + ROW_STEP;
    for (const k of kyfs) {
      k.x = dcl.x + INDENT_KYF;
      k.y = rowY;
      entityParentMap[k.id] = dcl.id;
      rowY += ROW_STEP;
    }
    for (const elt of topElts) {
      elt.x = dcl.x + INDENT_ELT;
      elt.y = rowY;
      entityParentMap[elt.id] = dcl.id;
      rowY += ROW_STEP;

      // An ELT's copybook (name "<ELT>-C" — see ensureCopybookEntities())
      // renders directly below it, at the same indent, before that ELT's
      // own DITs.
      const copybook = entities.find(en => en.type === 'COPYBOOK' && en.name === elt.name + '-C');
      if (copybook) {
        copybook.x = elt.x;
        copybook.y = rowY;
        entityParentMap[copybook.id] = elt.id;
        rowY += ROW_STEP;
      }

      // DIT names carry their owning ELT's full name as a prefix.
      const dits = entities.filter(en => en.type === 'DIT' && en.name.startsWith(elt.name))
        .sort((a, b) => a.name.localeCompare(b.name));
      for (const d of dits) {
        d.x = elt.x + INDENT_DIT;
        d.y = rowY;
        entityParentMap[d.id] = elt.id;
        rowY += ROW_STEP;
      }
    }
  }
}

// ── LOAD ─────────────────────────────────────────────────────────────────
async function loadDiagram() {
  document.getElementById('chart-title').textContent = BASE_NAME;
  try {
    const res = await fetch(XML_FILENAME, { cache: 'no-store' });
    if (!res.ok) throw new Error('not found');
    const text = await res.text();
    parseXml(text);
    fileExisted = true;
  } catch (e) {
    entities = [];
    relationships = [];
    loadedSettings = null;
    fileExisted = false;
  }
  applyLoadedSettings();
  normalizeIds();
  renderDiagram();
}

function parseXml(text) {
  const doc = new DOMParser().parseFromString(text, 'application/xml');
  if (doc.querySelector('parsererror')) { entities = []; relationships = []; loadedSettings = null; return; }

  const settingsEl = doc.querySelector('settings');
  loadedSettings = settingsEl ? {
    exportWidth: settingsEl.getAttribute('exportWidth'),
    logo: settingsEl.getAttribute('logo'),
    theme: settingsEl.getAttribute('theme'),
  } : null;

  entities = Array.from(doc.querySelectorAll('entities > entity')).map(el => {
    const attrs = {};
    Array.from(el.children).forEach(child => {
      attrs[child.tagName] = child.textContent;
    });
    return {
      id: el.getAttribute('id'),
      type: el.getAttribute('type') || 'DCL',
      name: el.getAttribute('name') || '',
      x: parseFloat(el.getAttribute('x')) || 0,
      y: parseFloat(el.getAttribute('y')) || 0,
      attrs,
    };
  });
  noteCounter = 0;
  for (const e of entities) {
    const m = /^note(\d+)$/.exec(e.id || '');
    if (m) noteCounter = Math.max(noteCounter, parseInt(m[1], 10));
  }

  relationships = Array.from(doc.querySelectorAll('relationships > relationship')).map(el => {
    const id = el.getAttribute('id');
    const num = parseInt((id || '').replace(/\D/g, ''), 10);
    if (!isNaN(num)) relCounter = Math.max(relCounter, num);
    return {
      id,
      from: el.getAttribute('from'),
      to: el.getAttribute('to'),
      fromCard: el.getAttribute('fromCard') || 'one',
      toCard: el.getAttribute('toCard') || 'many',
      fromOptional: el.getAttribute('fromOptional') === 'true',
      toOptional: el.getAttribute('toOptional') === 'true',
      label: el.getAttribute('label') || '',
      domain: el.getAttribute('domain') || '',
    };
  });
}

// Ensures a <select> has an <option> for the given value (e.g. a saved
// export width that isn't one of the standard 1200/900/600 choices),
// mirroring what the PHP side already does for the map.html-detected width.
function ensureSelectOption(selectEl, value, labelSuffix) {
  if (!Array.from(selectEl.options).some(o => o.value === String(value))) {
    const opt = document.createElement('option');
    opt.value = value;
    opt.textContent = value + (labelSuffix || '');
    selectEl.appendChild(opt);
  }
}

// Applies whatever was found in the just-loaded XML's <settings> element
// to the topbar controls. Called once from loadDiagram() right after
// parseXml(), before the first render — so it should never mark unsaved
// changes or render on its own, it's restoring already-saved state.
function applyLoadedSettings() {
  const widthSel = document.getElementById('export-width-select');
  const logoSel = document.getElementById('logo-select');

  applyTheme((loadedSettings && loadedSettings.theme) || getSavedTheme(), /* skipRender */ true);

  if (!loadedSettings) return;
  if (loadedSettings.exportWidth) {
    ensureSelectOption(widthSel, loadedSettings.exportWidth, 'px');
    widthSel.value = loadedSettings.exportWidth;
  }
  if (loadedSettings.logo !== null && loadedSettings.logo !== undefined) {
    if (Array.from(logoSel.options).some(o => o.value === loadedSettings.logo)) {
      logoSel.value = loadedSettings.logo;
    }
  }
}

// The entity id is always the entity name (uppercased) — this normalizes
// any file where they've drifted apart or use lowercase (e.g. hand-edited
// XML) and repoints relationships at the corrected ids so nothing dangles.
function normalizeIds() {
  const remap = {};
  for (const e of entities) {
    e.name = (e.name || '').toUpperCase();
    if (e.attrs && e.attrs.picture) e.attrs.picture = e.attrs.picture.toUpperCase();
    if (e.attrs && e.attrs.domain) e.attrs.domain = e.attrs.domain.toUpperCase();
    remap[e.id] = e.name;
    e.id = e.name;
  }
  for (const r of relationships) {
    if (remap[r.from] !== undefined) r.from = remap[r.from];
    if (remap[r.to] !== undefined) r.to = remap[r.to];
  }
}

// A DIT entity may optionally carry a domain (see the DIT edit-modal field
// and its validation in commitEntity()) — only some DITs need one. Whenever
// the set of domain names actually filled in contains one with no matching
// DOMAIN entity on the diagram yet, this creates one automatically — the
// person shouldn't have to separately remember to also add a DOMAIN node
// for every domain name they type into a DIT. Only ever adds; an existing
// DOMAIN entity that's no longer referenced by any DIT is left alone rather
// than silently deleted, since it may still be intentionally documented
// there (e.g. referenced by a relationship's own optional "domain"
// classifier). Called at the top of every renderDiagram(), so the diagram
// always reflects the current DIT domain references — idempotent and
// cheap (existence is checked before anything is added), so re-running it
// on every render (including mid-drag) is harmless.
function ensureDomainEntities() {
  const referenced = new Set();
  entities.forEach(e => {
    if (e.type === 'DIT' && e.attrs && e.attrs.domain) referenced.add(e.attrs.domain);
  });

  let added = false;
  referenced.forEach(domainName => {
    if (entities.some(e => e.type === 'DOMAIN' && e.name === domainName)) return;

    // Stack newly-created domain nodes down the right edge of whatever's
    // currently on the canvas, so they land clear of existing entities
    // rather than on top of them.
    let maxRight = 800;
    entities.forEach(en => { maxRight = Math.max(maxRight, en.x + entityWidth(en)); });
    const stackIndex = entities.filter(e => e.type === 'DOMAIN').length;

    entities.push({
      id: domainName,
      type: 'DOMAIN',
      name: domainName,
      x: maxRight + 60,
      y: 60 + stackIndex * 50,
      attrs: { narrative: '' },
    });
    added = true;
  });
  return added;
}

// An audit-stamp ELT — one ending in "00" (e.g. "ABC00") — always shares
// the same 36-byte layout, documented once in a common copybook rather
// than per-DCL. Whenever such an ELT exists with no matching COPYBOOK
// entity yet, one is created automatically, named after its ELT with a
// "-C" suffix (e.g. ELT "ABC00" → copybook "ABC00-C") and defaulted to
// link to "genmmp_main.htm#genmmp004030" with target "MAIN" — so a fresh audit stamp is
// immediately documented without the person having to remember to add its
// copybook by hand. Non-audit-stamp ELTs (anything not ending in "00")
// are NOT given an auto-created copybook, since they don't share this
// common definition. Only ever adds, the same as ensureDomainEntities():
// an existing COPYBOOK that no longer has a matching ELT is left alone
// rather than deleted, since it may still be intentionally documented
// there. Called at the top of every renderDiagram() alongside
// ensureDomainEntities() — idempotent and cheap, so re-running it on every
// render (including mid-drag) is harmless. Positioning (directly below its
// ELT) is handled separately, in applyAutoLayout().
function ensureCopybookEntities() {
  const auditStampElts = entities.filter(en => en.type === 'ELT' && /00$/.test(en.name));

  let added = false;
  auditStampElts.forEach(elt => {
    const copybookName = elt.name + '-C';
    if (entities.some(e => e.type === 'COPYBOOK' && e.name === copybookName)) return;

    entities.push({
      id: copybookName,
      type: 'COPYBOOK',
      name: copybookName,
      x: elt.x,
      y: elt.y, // provisional — applyAutoLayout() positions it just below the ELT on every render
      attrs: { narrative: '', link: 'genmmp_main.htm#genmmp004030', target: 'MAIN' },
    });
    added = true;
  });
  return added;
}

// Is `name` already used by some other entity (excluding `excludeId`)?
function isDuplicateName(name, excludeId) {
  return entities.some(e => e.id !== excludeId && e.name === name);
}

// ── ENTITY NAME VALIDATION ──────────────────────────────────────────────
// Naming rules are type-specific:
//   DCL — exactly 3 alphanumeric characters, first character alphabetic.
//   KYF — exactly 5 characters: the first 3 must be the name of an
//         existing DCL entity, the 4th must be "K", the 5th a digit.
//   ELT — exactly 5 characters: the first 3 must be the name of an
//         existing DCL entity, the last 2 must be digits (e.g. "ABC00").
//         (An ELT ending in "00" conventionally denotes a 36-byte audit
//         stamp, almost always at displacement 0 — that's a semantic
//         convention on top of the pattern, not a stricter name rule.)
//   DIT — must be a valid Cobol data name, and must start with the name
//         of an existing ELT entity (e.g. "ABC00-SOME-FIELD").
//   DOMAIN — a valid Cobol data name of up to 20 characters; unlike DIT,
//         it does NOT need to start with an ELT name.
// COPYBOOK has no pattern requirement beyond the existing uniqueness check
// (though every audit-stamp ELT — one ending in "00" — gets one
// auto-created for it, named "<ELT>-C" — see ensureCopybookEntities()
// above).
function definedDclNames(excludeId) {
  return new Set(entities.filter(e => e.type === 'DCL' && e.id !== excludeId).map(e => e.name));
}
function definedEltNames(excludeId) {
  return new Set(entities.filter(e => e.type === 'ELT' && e.id !== excludeId).map(e => e.name));
}
function validateEntityName(type, name, excludeId) {
  if (type === 'DCL') {
    if (!/^[A-Z][A-Z0-9]{2}$/.test(name)) {
      return 'DCL names must be exactly 3 alphanumeric characters, with the first character alphabetic (e.g. "ABC").';
    }
  } else if (type === 'KYF') {
    if (name.length !== 5) {
      return 'KYF names must be exactly 5 characters: a 3-character DCL name, then "K", then a digit (e.g. "ABCK1").';
    }
    const dclPart = name.slice(0, 3), fourth = name[3], fifth = name[4];
    if (!definedDclNames(excludeId).has(dclPart)) {
      return `The first 3 characters of a KYF name ("${dclPart}") must match an existing DCL entity's name.`;
    }
    if (fourth !== 'K') {
      return 'The 4th character of a KYF name must be "K".';
    }
    if (!/^[0-9]$/.test(fifth)) {
      return 'The 5th character of a KYF name must be a digit.';
    }
  } else if (type === 'ELT') {
    if (name.length !== 5) {
      return 'ELT names must be exactly 5 characters: a 3-character DCL name followed by 2 digits (e.g. "ABC00").';
    }
    const dclPart = name.slice(0, 3), digits = name.slice(3);
    if (!definedDclNames(excludeId).has(dclPart)) {
      return `The first 3 characters of an ELT name ("${dclPart}") must match an existing DCL entity's name.`;
    }
    if (!/^[0-9]{2}$/.test(digits)) {
      return 'The last 2 characters of an ELT name must be digits.';
    }
  } else if (type === 'DIT') {
    // Valid Cobol data name: letters, digits, and single hyphens only,
    // starts with a letter, no leading/trailing/doubled hyphen, max 30 chars.
    if (name.length > 30 || !/^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$/.test(name)) {
      return 'DIT names must be a valid Cobol data name: up to 30 characters, starting with a letter, containing only letters, digits, and single hyphens (e.g. "ABC00-SOME-FIELD").';
    }
    const eltPart = name.slice(0, 5);
    if (!definedEltNames(excludeId).has(eltPart)) {
      return `A DIT name must start with the name of an existing ELT entity (the first 5 characters, "${eltPart}", don't match any ELT).`;
    }
  } else if (type === 'DOMAIN') {
    // Same Cobol-data-name shape as DIT, but capped at 20 characters and
    // with no required prefix (a DOMAIN isn't anchored under an ELT).
    if (name.length > 20 || !/^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$/.test(name)) {
      return 'DOMAIN names must be a valid Cobol data name of up to 20 characters, starting with a letter, containing only letters, digits, and single hyphens (e.g. "CUSTOMER-ID").';
    }
  }
  return null; // valid (or a type with no pattern rule)
}

// A default name for a newly-added entity, guaranteed unique.
function uniqueDefaultName(type) {
  let n = 1, name = type + n;
  while (isDuplicateName(name, null)) { n++; name = type + n; }
  return name;
}

// ── SAVE ─────────────────────────────────────────────────────────────────
function buildXml() {
  const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  const widthVal = document.getElementById('export-width-select').value;
  const logoVal = document.getElementById('logo-select').value;
  const themeVal = document.getElementById('theme-select').value;
  let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
  // Trademark/copyright notice, written as an XML comment immediately
  // before the root element — same convention flowchart.php uses for its
  // saved XML — so it's carried along with the file itself, not just the
  // rendered/exported output.
  xml += `<!-- ${copyrightNoticeText()} -->\n`;
  xml += '<datadiagram>\n';
  // Settings (theme, export width, logo selection) persisted so the
  // diagram opens the same way for whoever opens the file next, on
  // whatever browser — same approach as flowchart.php's <settings> element.
  xml += `  <settings theme="${esc(themeVal)}" exportWidth="${esc(widthVal)}" logo="${esc(logoVal)}"/>\n`;
  xml += '  <entities>\n';
  for (const e of entities) {
    xml += `    <entity id="${esc(e.id)}" type="${esc(e.type)}" name="${esc(e.name)}" x="${Math.round(e.x)}" y="${Math.round(e.y)}">\n`;
    // Fixed, predictable write order: narrative, then this type's known
    // attrs, then link/target (mirrors flowchart.php's node schema), then
    // any other custom keys this tool doesn't have UI for (round-tripped).
    const fixedOrder = ['narrative', ...(TYPE_ATTR_KEYS[e.type] || []), 'link', 'target'];
    const leftover = Object.keys(e.attrs).filter(k => !fixedOrder.includes(k));
    const ordered = [...fixedOrder, ...leftover];
    for (const key of ordered) {
      const val = e.attrs[key];
      if (val === undefined || val === '') continue;
      xml += `      <${key}>${esc(val)}</${key}>\n`;
    }
    xml += `    </entity>\n`;
  }
  xml += '  </entities>\n  <relationships>\n';
  for (const r of relationships) {
    xml += `    <relationship id="${esc(r.id)}" from="${esc(r.from)}" to="${esc(r.to)}" fromCard="${esc(r.fromCard)}" toCard="${esc(r.toCard)}" fromOptional="${r.fromOptional}" toOptional="${r.toOptional}" label="${esc(r.label)}" domain="${esc(r.domain || '')}"/>\n`;
  }
  xml += '  </relationships>\n</datadiagram>\n';
  return xml;
}

// Called by the export-width / logo <select> onchange handlers — flags an
// unsaved change (same as editing an entity/relationship) and re-renders
// the preview so a logo pick / width change is visible immediately, not
// just at export/save time.
function markSettingsChanged() {
  hasUnsavedChanges = true;
  updateUnsavedUI();
}
function onExportWidthChange() {
  renderDiagram();
  markSettingsChanged();
}
function onLogoChange() {
  renderDiagram();
  markSettingsChanged();
}

// Strips a ".xml" extension and any characters outside the same
// letters/digits/underscore/hyphen set the PHP side enforces (see the
// "NAME / FILE RESOLUTION" block at the top of this file) — a client-side
// mirror so a bad name is caught immediately instead of round-tripping to
// the server first.
function sanitizeFileBaseName(raw) {
  return String(raw || '')
    .trim()
    .replace(/\.xml$/i, '')
    .replace(/[^A-Za-z0-9_\-]/g, '');
}

async function saveDiagram() {
  // A brand-new diagram (nothing loaded from disk on page load — see
  // fileExisted in loadDiagram()) doesn't have a real name yet; ask for
  // one before writing anything, rather than silently saving under
  // whatever default name the page happened to load with.
  let nameToSave = BASE_NAME;
  if (!fileExisted) {
    const proposed = prompt('Save this new diagram as (file name, no extension):', BASE_NAME === 'dataDiagram' ? '' : BASE_NAME);
    if (proposed === null) return; // cancelled
    const cleaned = sanitizeFileBaseName(proposed);
    if (!cleaned) {
      alert('Please enter a valid file name (letters, digits, "_" and "-" only).');
      return;
    }
    nameToSave = cleaned;
  }

  const xml = buildXml();
  try {
    const resp = await fetch(window.location.pathname + '?name=' + encodeURIComponent(nameToSave), {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: 'action=save&name=' + encodeURIComponent(nameToSave) + '&xml=' + encodeURIComponent(xml)
    });
    const data = await resp.json();
    if (data.ok) {
      hasUnsavedChanges = false;
      updateUnsavedUI();
      // Now that the file exists under this name, later saves/exports use
      // it without asking again — and a page refresh loads the same file,
      // since the URL now carries ?name=... too.
      BASE_NAME = nameToSave;
      fileExisted = true;
      document.getElementById('chart-title').textContent = BASE_NAME;
      const url = new URL(window.location.href);
      url.searchParams.set('name', BASE_NAME);
      history.replaceState(null, '', url);
      alert(`Saved ${data.file} (${data.bytes} bytes) to the server.`);
    } else {
      alert('Save failed: ' + data.error);
    }
  } catch (e) {
    console.error('Save error:', e);
    alert('Save failed: ' + e.message);
  }
}

function updateUnsavedUI() {
  // Save is available whenever there's something unsaved — a settings-only
  // change (export width / logo) is savable even outside Edit mode, same
  // as flowchart.php's Save Changes button.
  document.getElementById('save-btn').disabled = !hasUnsavedChanges;
  document.getElementById('save-btn').textContent = hasUnsavedChanges ? '💾 Save*' : '💾 Save';
}

// ── EXIT ─────────────────────────────────────────────────────────────────
// Confirms before navigating away if there are unsaved changes, mirroring
// flowchart.php's Exit button.
function exitApp() {
  if (hasUnsavedChanges && !confirm('You have unsaved changes that will be lost if you exit. Exit anyway?')) return;
  intentionalExit = true;
  history.go(-1);
  // Note: window.close() only works on tabs/windows that were opened via
  // script — most browsers silently ignore it on a normally-navigated tab.
  setTimeout(() => window.close(), 1000);
}

// ── EDIT MODE ────────────────────────────────────────────────────────────
function toggleEditMode() {
  editMode = document.getElementById('edit-mode-toggle').checked;
  document.getElementById('add-entity-btn').disabled = !editMode;
  document.getElementById('link-mode-btn').disabled = !editMode;
  if (!editMode) { linkModeActive = false; linkFirstEntity = null; updateLinkModeUI(); }
  renderDiagram();
}

function updateEditHint() {
  const hint = document.getElementById('edit-mode-hint');
  if (!editMode) {
    hint.textContent = 'Click an entity or relationship for details';
  } else if (linkModeActive && linkFirstEntity) {
    const from = entities.find(x => x.id === linkFirstEntity);
    hint.textContent = `🔗 "${from ? from.name : linkFirstEntity}" selected — click a different entity to connect it to (Esc to cancel)`;
  } else if (linkModeActive) {
    hint.textContent = '🔗 Click the first entity to start a relationship (Esc to cancel)';
  } else {
    hint.textContent = 'Drag a DCL to reposition · click any entity to edit · Link to connect';
  }
}

function toggleLinkMode() {
  linkModeActive = !linkModeActive;
  linkFirstEntity = null;
  updateLinkModeUI();
  renderDiagram();
}
function updateLinkModeUI() {
  document.getElementById('link-mode-btn').classList.toggle('active', linkModeActive);
}

function toggleLegend() {
  const el = document.getElementById('legend');
  const btn = document.getElementById('legend-toggle');
  const open = el.style.display !== 'none';
  el.style.display = open ? 'none' : 'block';
  btn.classList.toggle('active', !open);
}

// ── ENTITY / RELATIONSHIP CRUD ──────────────────────────────────────────
let newEntitySeq = 0;
function addEntity() {
  newEntitySeq++;
  const type = 'DCL'; // just a starting point — the modal that opens next is where type is actually chosen
  const container = document.getElementById('svg-container');
  const name = uniqueDefaultName(type);
  // Spread fresh entities across a wide grid that never exactly repeats
  // (row keeps increasing rather than wrapping), reducing the odds that a
  // just-added entity lands on top of — and gets hidden behind/in front
  // of — another one before it's been renamed/repositioned.
  const col = newEntitySeq % 5;
  const row = Math.floor(newEntitySeq / 5);
  const e = {
    id: name,
    type,
    name,
    x: 60 + col * 170 + (row % 3) * 25 + container.scrollLeft,
    y: 60 + row * 60 + container.scrollTop,
    attrs: { narrative: '' },
  };
  entities.push(e);
  hasUnsavedChanges = true;
  updateUnsavedUI();
  renderDiagram();
  openEntityModal(e.id);
}

let activeEntityId = null;
let activeRelId = null;

function updateEntityModalFields() {
  const type = document.getElementById('em-type').value;
  document.getElementById('em-block-kyf').style.display = type === 'KYF' ? 'flex' : 'none';
  document.getElementById('em-block-elt').style.display = type === 'ELT' ? 'flex' : 'none';
  document.getElementById('em-block-dit').style.display = type === 'DIT' ? 'flex' : 'none';
  document.getElementById('em-block-dit-domain').style.display = type === 'DIT' ? 'flex' : 'none';
  document.getElementById('em-block-dit-domain-hint').style.display = type === 'DIT' ? 'block' : 'none';
  document.getElementById('em-block-comment').style.display = type === 'COMMENT' ? 'flex' : 'none';
  if (type === 'DIT') populateDomainDatalist();
  document.getElementById('em-block-domain-usage').style.display = type === 'DOMAIN' ? 'block' : 'none';
  if (type === 'DOMAIN') populateDomainUsageList();
  // COMMENT's text lives in the textarea above instead of the Name field.
  document.getElementById('em-name-field').style.display = type === 'COMMENT' ? 'none' : 'flex';
  // TITLE/COMMENT hold free text, not a Cobol-style identifier — don't
  // visually force-uppercase what the person is typing.
  const isNote = type === 'TITLE' || type === 'COMMENT';
  document.getElementById('em-name').style.textTransform = isNote ? 'none' : 'uppercase';
  document.getElementById('em-name').placeholder = type === 'TITLE' ? 'e.g. Customer Subsystem' : 'e.g. VAC01-DAYS-EARNED';
  document.querySelector('label[for="em-id"]').textContent = isNote ? 'ID (auto-generated)' : 'ID (mirrors name)';
}

// Fills the DIT domain field's <datalist> with every DOMAIN entity name
// currently on the diagram, so typing a domain offers autocomplete against
// what already exists — while still allowing a brand-new name to be typed
// freely (it becomes a new DOMAIN node automatically; see
// ensureDomainEntities()).
function populateDomainDatalist() {
  const list = document.getElementById('em-domain-list');
  list.innerHTML = entities
    .filter(e => e.type === 'DOMAIN')
    .map(e => `<option value="${escHtml(e.name)}"></option>`)
    .join('');
}

// Read-only "where used" summary shown in the entity edit modal while
// editing a DOMAIN entity — based on the entity's currently-saved name
// (activeEntityId), not whatever's live in the Name field, since a rename
// isn't committed until "Save changes" is clicked.
function populateDomainUsageList() {
  const listEl = document.getElementById('em-domain-usage-list');
  const e = entities.find(x => x.id === activeEntityId);
  if (!e) { listEl.innerHTML = '<em>Save this entity first to see usage.</em>'; return; }
  const usedBy = entities.filter(x => x.type === 'DIT' && x.attrs && x.attrs.domain === e.name)
    .sort((a, b) => a.name.localeCompare(b.name));
  listEl.innerHTML = usedBy.length
    ? usedBy.map(d => escHtml(d.name)).join(', ')
    : '<em>No DIT entities on this diagram currently reference this domain.</em>';
}

function openEntityModal(id) {
  activeEntityId = id;
  const e = entities.find(x => x.id === id);
  if (!e) return;
  document.getElementById('em-type').value = e.type;
  document.getElementById('em-name').value = e.name;
  document.getElementById('em-id').value = e.id;
  document.getElementById('em-narrative').value = e.attrs.narrative || '';
  document.getElementById('em-keytype').value = e.attrs.keytype || 'Primary';
  document.getElementById('em-offset').value = e.attrs.offset || '';
  document.getElementById('em-length').value = e.attrs.length || '';
  document.getElementById('em-picture').value = e.attrs.picture || '';
  document.getElementById('em-derived').value = e.attrs.derived === 'y' ? 'y' : 'n';
  document.getElementById('em-domain').value = e.attrs.domain || '';
  document.getElementById('em-link').value = e.attrs.link || '';
  document.getElementById('em-target').value = e.attrs.target || '';
  document.getElementById('em-comment-text').value = e.type === 'COMMENT' ? e.name : '';
  updateEntityModalFields();
  document.getElementById('entity-modal-overlay').classList.add('open');
}
function commitEntity() {
  const e = entities.find(x => x.id === activeEntityId);
  if (!e) return;
  const newType = document.getElementById('em-type').value;
  const isNote = newType === 'TITLE' || newType === 'COMMENT';
  let newName;
  if (isNote) {
    newName = newType === 'COMMENT'
      ? (document.getElementById('em-comment-text').value.trim() || 'Comment')
      : (document.getElementById('em-name').value.trim() || 'Untitled');
    // Free-form text: no Cobol-name pattern, no forced case, no uniqueness
    // requirement (two comments can legitimately say the same thing).
  } else {
    newName = (document.getElementById('em-name').value.trim() || newType).toUpperCase();
    const nameError = validateEntityName(newType, newName, e.id);
    if (nameError) {
      alert(nameError);
      return;
    }
    if (isDuplicateName(newName, e.id)) {
      alert(`An entity named "${newName}" already exists. Choose a different name — the entity id always matches its name, so names must be unique.`);
      return;
    }
  }

  // A DIT's domain is optional (only some DITs need one) — but if a value
  // is entered, it's validated up front, like the name checks above, so a
  // bad value aborts the whole save cleanly instead of leaving the
  // entity's type/name already mutated but its domain not (see the
  // mutations below).
  let ditDomainName = null;
  if (newType === 'DIT') {
    const rawDomain = document.getElementById('em-domain').value.trim().toUpperCase();
    if (rawDomain) {
      const domainError = validateEntityName('DOMAIN', rawDomain, null);
      if (domainError) {
        alert(domainError);
        return;
      }
      const domainConflict = entities.find(x => x.name === rawDomain && x.type !== 'DOMAIN' && x.id !== e.id);
      if (domainConflict) {
        alert(`"${rawDomain}" is already used by a ${domainConflict.type} entity — choose a different domain name, or rename that entity.`);
        return;
      }
      ditDomainName = rawDomain;
    }
  }

  const oldId = e.id;
  const wasNameForDomainRepoint = (e.type === 'DOMAIN') ? e.name : null;
  e.type = newType;
  e.name = newName;
  if (isNote) {
    // Keep a note's id stable across edits; only mint a fresh one the
    // first time an entity becomes a TITLE/COMMENT.
    if (!/^note\d+$/.test(e.id)) e.id = 'note' + (++noteCounter);
  } else {
    e.id = newName; // id always mirrors name
  }
  if (oldId !== e.id) {
    for (const r of relationships) {
      if (r.from === oldId) r.from = e.id;
      if (r.to === oldId) r.to = e.id;
    }
    activeEntityId = e.id;
  }
  // A renamed DOMAIN entity: repoint every DIT that referenced its old
  // name, so they stay attached to it (rather than that domain silently
  // reappearing as a brand-new, empty DOMAIN node on the next render).
  if (wasNameForDomainRepoint !== null && wasNameForDomainRepoint !== e.name) {
    entities.forEach(x => {
      if (x.type === 'DIT' && x.attrs && x.attrs.domain === wasNameForDomainRepoint) {
        x.attrs.domain = e.name;
      }
    });
  }
  e.attrs.narrative = document.getElementById('em-narrative').value.trim();
  if (e.type === 'KYF') e.attrs.keytype = document.getElementById('em-keytype').value;
  if (e.type === 'ELT') {
    e.attrs.offset = document.getElementById('em-offset').value.trim();
    e.attrs.length = document.getElementById('em-length').value.trim();
  }
  if (e.type === 'DIT') {
    e.attrs.picture = document.getElementById('em-picture').value.trim().toUpperCase();
    e.attrs.derived = document.getElementById('em-derived').value;
    e.attrs.domain = ditDomainName || '';
  } else {
    // A DIT that's been retyped to something else no longer needs a
    // dangling domain attr — dropped so it doesn't linger as leftover
    // state (and so it can't wrongly keep an old DOMAIN "in use").
    delete e.attrs.domain;
  }
  e.attrs.link = document.getElementById('em-link').value.trim();
  e.attrs.target = document.getElementById('em-target').value.trim();
  hasUnsavedChanges = true;
  updateUnsavedUI();
  closeOverlay(null, 'entity-modal-overlay');
  renderDiagram();
}
function deleteEntity() {
  if (!activeEntityId) return;
  const e = entities.find(x => x.id === activeEntityId);
  if (e && e.type === 'DOMAIN') {
    const usedBy = entities.filter(x => x.type === 'DIT' && x.attrs && x.attrs.domain === e.name);
    if (usedBy.length) {
      alert(
        `Can't delete "${e.name}" — ${usedBy.length} DIT entity/entities still reference it as their domain:\n`
        + usedBy.map(d => `• ${d.name}`).join('\n')
        + `\n\nChange or remove those DITs' domain first (it would otherwise just reappear automatically on the next render).`
      );
      return;
    }
  }
  if (!confirm('Delete this entity and any relationships attached to it?')) return;
  entities = entities.filter(x => x.id !== activeEntityId);
  relationships = relationships.filter(r => r.from !== activeEntityId && r.to !== activeEntityId);
  hasUnsavedChanges = true;
  updateUnsavedUI();
  closeOverlay(null, 'entity-modal-overlay');
  renderDiagram();
}

function openRelModal(id) {
  activeRelId = id;
  const r = relationships.find(x => x.id === id);
  if (!r) return;
  const fromE = entities.find(x => x.id === r.from);
  const toE = entities.find(x => x.id === r.to);
  document.getElementById('rm-label').value = r.label;
  document.getElementById('rm-from-name').textContent = (fromE ? fromE.name : r.from);
  document.getElementById('rm-to-name').textContent = (toE ? toE.name : r.to);
  document.getElementById('rm-from-card').value = r.fromCard;
  document.getElementById('rm-to-card').value = r.toCard;
  document.getElementById('rm-from-optional').checked = r.fromOptional;
  document.getElementById('rm-to-optional').checked = r.toOptional;
  const domainSel = document.getElementById('rm-domain');
  domainSel.innerHTML = '<option value="">— none —</option>' +
    entities.filter(e => e.type === 'DOMAIN')
      .map(e => `<option value="${e.name}">${e.name}</option>`)
      .join('');
  domainSel.value = r.domain || '';
  document.getElementById('rel-modal-overlay').classList.add('open');
}
function commitRelationship() {
  const r = relationships.find(x => x.id === activeRelId);
  if (!r) return;
  r.label = document.getElementById('rm-label').value.trim();
  r.domain = document.getElementById('rm-domain').value || '';
  r.fromCard = document.getElementById('rm-from-card').value;
  r.toCard = document.getElementById('rm-to-card').value;
  r.fromOptional = document.getElementById('rm-from-optional').checked;
  r.toOptional = document.getElementById('rm-to-optional').checked;
  hasUnsavedChanges = true;
  updateUnsavedUI();
  closeOverlay(null, 'rel-modal-overlay');
  renderDiagram();
}
function deleteRelationship() {
  if (!activeRelId) return;
  relationships = relationships.filter(x => x.id !== activeRelId);
  hasUnsavedChanges = true;
  updateUnsavedUI();
  closeOverlay(null, 'rel-modal-overlay');
  renderDiagram();
}

function closeOverlay(evt, id) {
  if (evt && evt.target.id !== id) return;
  document.getElementById(id).classList.remove('open');
}

const ATTR_LABELS = { keytype: 'Key type', offset: 'Offset', length: 'Length', picture: 'Picture', derived: 'Derived', domain: 'Domain' };
function openViewModal(kind, id) {
  const linkWrap = document.getElementById('view-link-wrap');
  const linkEl = document.getElementById('view-link');
  if (kind === 'entity') {
    const e = entities.find(x => x.id === id);
    if (!e) return;
    document.getElementById('view-badge').textContent = e.type;
    document.getElementById('view-badge').style.background = ENTITY_TYPES[e.type].color;
    document.getElementById('view-title').textContent = e.name;
    let html = '';
    if (e.attrs.narrative) html += `<p style="color:var(--text-muted);font-size:13.5px;line-height:1.6;">${escHtml(e.attrs.narrative)}</p>`;
    const rows = (TYPE_ATTR_KEYS[e.type] || []).filter(k => e.attrs[k]);
    if (rows.length) {
      html += '<div style="margin-top:10px;">' + rows.map(k =>
        `<div class="view-rel-endpoint"><b>${ATTR_LABELS[k] || k}:</b> ${escHtml(e.attrs[k])}</div>`
      ).join('') + '</div>';
    }
    if (e.type === 'DOMAIN') {
      const usedBy = entities.filter(x => x.type === 'DIT' && x.attrs && x.attrs.domain === e.name)
        .sort((a, b) => a.name.localeCompare(b.name));
      html += `<div style="margin-top:14px;">
        <div style="font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:6px;">Where used in this diagram (${usedBy.length} DIT${usedBy.length === 1 ? '' : 's'})</div>`;
      if (usedBy.length) {
        html += usedBy.map(d =>
          `<div class="view-rel-endpoint" style="cursor:pointer;" onclick="openViewModal('entity', '${d.id}')" title="View this DIT">${escHtml(d.name)}</div>`
        ).join('');
      } else {
        html += '<p style="color:var(--text-dim);font-size:13px;">No DIT entities on this diagram currently reference this domain.</p>';
      }
      html += '<p style="color:var(--text-dim);font-size:11.5px;margin-top:8px;">This domain may also be used by DITs on other diagrams — this list only covers the current one.</p>';
      html += '</div>';
    }
    if (!html) html = '<p style="color:var(--text-dim);font-size:13px;">No details recorded.</p>';
    document.getElementById('view-body').innerHTML = html;

    if (e.attrs.link) {
      linkEl.href = e.attrs.link;
      // Same default as flowchart.php: target defaults to MAIN when a
      // link is present but no explicit target was given.
      linkEl.target = e.attrs.target || 'MAIN';
      linkEl.textContent = '🔗 See ' + getLinkDisplayName(e.attrs.link);
      linkWrap.style.display = 'block';
    } else {
      linkWrap.style.display = 'none';
    }
  } else {
    linkWrap.style.display = 'none';
    const r = relationships.find(x => x.id === id);
    if (!r) return;
    const fromE = entities.find(x => x.id === r.from);
    const toE = entities.find(x => x.id === r.to);
    document.getElementById('view-badge').textContent = 'RELATIONSHIP';
    document.getElementById('view-badge').style.background = 'var(--accent-strong)';
    document.getElementById('view-title').textContent = r.label || '(unlabeled)';
    document.getElementById('view-body').innerHTML = `
      <div class="view-rel-endpoint"><b>${escHtml(fromE ? fromE.name : r.from)}</b> — ${cardText(r.fromCard, r.fromOptional)}</div>
      <div class="view-rel-endpoint"><b>${escHtml(toE ? toE.name : r.to)}</b> — ${cardText(r.toCard, r.toOptional)}</div>
      ${r.domain ? `<div class="view-rel-endpoint">Domain: <b style="color:var(--domain-color)">${escHtml(r.domain)}</b></div>` : ''}
    `;
  }
  document.getElementById('view-modal-overlay').classList.add('open');
}
// Short, human-friendly label for a link — last path segment or fragment,
// falling back to the raw link if nothing nicer can be extracted.
function getLinkDisplayName(link) {
  if (!link) return '';
  try {
    let withoutFrag = link, frag = '';
    if (link.includes('#')) {
      const parts = link.split('#');
      withoutFrag = parts[0];
      frag = parts[1] || '';
    }
    if (!withoutFrag) return frag || link;
    const segs = withoutFrag.split('/').filter(Boolean);
    const last = segs[segs.length - 1];
    return last || frag || link;
  } catch (e) {
    return link;
  }
}
function cardText(card, optional) {
  const c = card === 'many' ? 'many' : 'one';
  const o = optional ? 'optional (zero or ' + c + ')' : 'mandatory (one or ' + (c === 'many' ? 'many' : 'only one') + ')';
  return c + ', ' + o;
}
function escHtml(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

// ── LAYOUT HELPERS ───────────────────────────────────────────────────────
// A DCL is the "root" of its group and, being a single small box, is the
// one most likely to end up overlapping a freshly-added or not-yet-renamed
// child entity. Rather than rely on array/insertion order for stacking
// (which is arbitrary), DCLs are always drawn/listed behind every other
// type — so a DCL can never cover up and steal clicks from a KYF/ELT/DIT/
// COPYBOOK/DOMAIN sitting on top of it.
function entityDrawOrder() {
  return [...entities].sort((a, b) => (a.type === 'DCL' ? 0 : 1) - (b.type === 'DCL' ? 0 : 1));
}

// Box is a single-line "chip + name" pill sized to fit its text — no field
// rows, since each attribute in this schema is its own entity/node.
// TITLE and COMMENT are annotations rather than data entities, so they
// skip the chip and use flowchart.php's fixed geometry instead (see
// entityRect below) — a fixed box, not one sized to fit the text, exactly
// like flowchart.php's title/comment nodes (they can visually overflow
// their box on long text there too).
function entityWidth(e) {
  if (e.type === 'TITLE') return TITLE_W;
  if (e.type === 'COMMENT') return COMMENT_W;
  const chipW = textWidth(e.type, CHIP_FONT) + 16;
  const nameW = textWidth(e.name, nameFontFor(e.type));
  return Math.max(90, chipW + nameW + 26);
}
function entityRect(e) {
  if (e.type === 'TITLE') return { x: e.x, y: e.y, w: TITLE_W, h: TITLE_H };
  if (e.type === 'COMMENT') return { x: e.x, y: e.y, w: COMMENT_W, h: COMMENT_H };
  return { x: e.x, y: e.y, w: entityWidth(e), h: boxHeightFor(e.type) };
}
function rectCenter(rect) {
  return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
}

// Orthogonal ("elbow") route between two rects, always entering/exiting
// through the vertical (left/right) sides — never top/bottom. Whichever
// rect is further left exits/enters on its right side and vice versa, so
// the path is a horizontal-vertical-horizontal "Z" with two right-angle
// bends (a single straight horizontal segment when the rows line up).
//
// The bend's x is snapped to a conceptual 5px grid, in one of two lanes
// depending on horizontal direction — this keeps left-to-right and
// right-to-left lines from stacking on the same vertical and gives them
// visual separation:
//   - rectA (from) is to the right of rectB (to), i.e. line runs right-to-left:
//     snap to grid lines NOT evenly divisible by 10 (…,5,15,25,35,…)
//   - everything else (left-to-right or level):
//     snap to grid lines evenly divisible by 10 (…,0,10,20,30,…)
function snapLaneX(x, useOddFive) {
  const GRID = 10;
  return useOddFive
    ? Math.round((x - 5) / GRID) * GRID + 5
    : Math.round(x / GRID) * GRID;
}
function elbowRoute(rectA, rectB, fromY, toY) {
  const cA = rectCenter(rectA), cB = rectCenter(rectB);
  const aExitsRight = cA.x <= cB.x;
  const ay = fromY != null ? fromY : cA.y;
  const by = toY != null ? toY : cB.y;
  const p1 = aExitsRight
    ? { x: rectA.x + rectA.w, y: ay }
    : { x: rectA.x, y: ay };
  const p2 = aExitsRight
    ? { x: rectB.x, y: by }
    : { x: rectB.x + rectB.w, y: by };
  const dir1 = { x: aExitsRight ? 1 : -1, y: 0 };
  const dir2 = { x: aExitsRight ? -1 : 1, y: 0 };
  const midX = snapLaneX((p1.x + p2.x) / 2, !aExitsRight);
  const pathD = `M${p1.x},${p1.y} H${midX} V${p2.y} H${p2.x}`;
  return { p1, p2, dir1, dir2, midX, pathD };
}

// ── PORT ASSIGNMENT ──────────────────────────────────────────────────────
// elbowRoute() used to always enter/exit at an entity's vertical center,
// which meant any two relationships sharing an entity on the same side —
// or, worse, two relationships between entities that happened to sit on
// the same row — produced perfectly coincident horizontal segments with
// no visual separation at all.
//
// Fix: for every entity, spread the relationships touching a given side
// (left/right) across evenly-spaced "ports" along that side's height,
// ordered by the y-position of the entity at the other end of each line
// (so ports roughly line up with what they connect to and minimize
// crossings). A side with only one relationship still uses dead-center,
// so the common case is visually unchanged.
function portSlotY(rect, index, count) {
  if (count <= 1) return rect.y + rect.h / 2;
  const margin = Math.min(10, rect.h * 0.28);
  const usable = rect.h - margin * 2;
  const step = count > 1 ? usable / (count - 1) : 0;
  return rect.y + margin + step * index;
}
function computeRelPorts() {
  const groups = new Map(); // "entityId|side" -> [{ relId, end, sortY, entity }]
  const touch = (entity, side, relId, end, sortY) => {
    const key = entity.id + '|' + side;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push({ relId, end, sortY, entity });
  };
  for (const r of relationships) {
    const fromE = entities.find(x => x.id === r.from);
    const toE = entities.find(x => x.id === r.to);
    if (!fromE || !toE || fromE.id === toE.id) continue;
    if (entityParentMap[r.to] === r.from || entityParentMap[r.from] === r.to) continue;
    const cA = rectCenter(entityRect(fromE)), cB = rectCenter(entityRect(toE));
    const aExitsRight = cA.x <= cB.x;
    touch(fromE, aExitsRight ? 'right' : 'left', r.id, 'from', cB.y);
    touch(toE, aExitsRight ? 'left' : 'right', r.id, 'to', cA.y);
  }
  const ports = new Map(); // relId -> { fromY, toY }
  for (const list of groups.values()) {
    list.sort((a, b) => a.sortY - b.sortY);
    const rect = entityRect(list[0].entity);
    list.forEach((item, i) => {
      const y = portSlotY(rect, i, list.length);
      if (!ports.has(item.relId)) ports.set(item.relId, {});
      ports.get(item.relId)[item.end === 'from' ? 'fromY' : 'toY'] = y;
    });
  }
  return ports;
}

// ── RENDER ───────────────────────────────────────────────────────────────
function svgEl(tag, attrs = {}) {
  const el = document.createElementNS(NS, tag);
  for (const k in attrs) el.setAttribute(k, attrs[k]);
  return el;
}

function renderDiagram() {
  let autoAdded = false;
  if (ensureDomainEntities()) autoAdded = true;
  if (ensureCopybookEntities()) autoAdded = true;
  if (autoAdded) {
    hasUnsavedChanges = true;
    updateUnsavedUI();
  }
  applyAutoLayout();
  updateEditHint();

  const svg = document.getElementById('diagram-svg');
  svg.innerHTML = '';

  // bounding box
  let maxX = 800, maxY = 500;
  for (const e of entities) {
    maxX = Math.max(maxX, e.x + entityWidth(e) + 60);
    maxY = Math.max(maxY, e.y + boxHeightFor(e.type) + 60);
  }
  svg.setAttribute('width', maxX);
  svg.setAttribute('height', maxY);
  svg.setAttribute('viewBox', `0 0 ${maxX} ${maxY}`);

  // defs (currently unused but kept for future markers)
  svg.appendChild(svgEl('defs'));

  // relationships first (under entities)
  const relPorts = computeRelPorts();
  for (const r of relationships) {
    const g = renderRelationship(r, relPorts.get(r.id));
    if (g) svg.appendChild(g);
  }
  // entities on top of relationships; DCLs always drawn first/behind so they
  // can never obscure an overlapping child (see entityDrawOrder())
  for (const e of entityDrawOrder()) {
    svg.appendChild(renderEntity(e));
  }

  // Logo (optional) — fixed size in content units, top-right corner, drawn
  // above the diagram content but below the copyright line (mirrors
  // flowchart.php's live-preview logo placement).
  const logoFile = document.getElementById('logo-select').value;
  if (logoFile) {
    const lx = maxX - LOGO_MARGIN - LOGO_W;
    const ly = LOGO_MARGIN;
    const logoSrc = 'logos/' + logoFile;
    const logoImg = svgEl('image', {
      x: lx, y: ly, width: LOGO_W, height: LOGO_H,
      href: logoSrc, 'preserveAspectRatio': 'xMaxYMin meet'
    });
    logoImg.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', logoSrc);
    svg.appendChild(logoImg);
  }

  // Small, unobtrusive trademark/copyright notice — always present, drawn
  // last so it's never covered by the diagram itself.
  const copyrightLines = copyrightNoticeLines();
  const copyrightLineHeight = 12;
  const copyrightStartY = maxY - 8 - (copyrightLines.length - 1) * copyrightLineHeight;
  const copyrightEl = svgEl('text', { class: 'copyright-notice', 'text-anchor': 'middle' });
  copyrightLines.forEach((line, i) => {
    const tspan = svgEl('tspan', { x: maxX / 2, y: copyrightStartY + i * copyrightLineHeight });
    tspan.textContent = line;
    copyrightEl.appendChild(tspan);
  });
  svg.appendChild(copyrightEl);

  document.getElementById('counts').textContent =
    `${entities.length} entit${entities.length === 1 ? 'y' : 'ies'} · ${relationships.length} relationship${relationships.length === 1 ? '' : 's'}`;

  renderLegend();
}

function renderEntity(e) {
  const rect = entityRect(e);
  const g = svgEl('g', { class: 'entity-shape' + (linkFirstEntity === e.id ? ' link-armed' : ''), transform: `translate(${rect.x},${rect.y})` });
  g.style.userSelect = 'none';
  g.addEventListener('mousedown', (evt) => onEntityMouseDown(evt, e));

  if (e.type === 'TITLE' || e.type === 'COMMENT') {
    // No box, no fill, no border — just centered (possibly multi-line)
    // text, matching flowchart.php's title/comment nodes exactly. A
    // transparent hit-area rect still covers the full box so the node
    // stays selectable/draggable across its whole footprint, not just
    // where the glyphs happen to be.
    g.appendChild(svgEl('rect', { class: 'note-hit-area', width: rect.w, height: rect.h, fill: 'transparent' }));
    const font = e.type === 'TITLE' ? TITLE_FONT : COMMENT_FONT;
    const fontSize = e.type === 'TITLE' ? 28 : 13;
    const lines = e.type === 'TITLE' ? titleLines(e) : commentLines(e);
    const lineH = fontSize + 3;
    const totalH = lines.length * lineH;
    const cx = rect.w / 2, cy = rect.h / 2;
    lines.forEach((line, i) => {
      const t = svgEl('text', {
        x: cx, y: cy - totalH / 2 + lineH / 2 + i * lineH,
        'text-anchor': 'middle', 'dominant-baseline': 'central',
        style: `font:${font};`, fill: 'var(--text-primary)',
      });
      t.textContent = line;
      g.appendChild(t);
    });
    return g;
  }

  const baseColor = (ENTITY_TYPES[e.type] || ENTITY_TYPES.DCL).color;
  const isDerivedDit = e.type === 'DIT' && e.attrs.derived === 'y';
  const color = isDerivedDit ? mutedColor(baseColor) : baseColor;
  const chipW = textWidth(e.type, CHIP_FONT) + 16;

  // body
  g.appendChild(svgEl('rect', { class: 'entity-body', width: rect.w, height: rect.h, rx: 7, ry: 7 }));
  // type chip
  g.appendChild(svgEl('rect', { x: 4, y: 4, width: chipW, height: rect.h - 8, rx: 5, ry: 5, fill: color }));
  const typeText = svgEl('text', { x: 4 + chipW / 2, y: rect.h / 2 + 3, 'text-anchor': 'middle', fill: '#fff', style: `font:${CHIP_FONT};letter-spacing:.03em;` });
  typeText.textContent = e.type;
  g.appendChild(typeText);
  // name
  const nameText = svgEl('text', { x: 4 + chipW + 10, y: rect.h / 2 + 4, style: `font:${nameFontFor(e.type)};`, fill: 'var(--text-primary)' });
  nameText.textContent = e.name;
  g.appendChild(nameText);

  return g;
}

function onEntityMouseDown(evt, e) {
  if (!editMode) return;
  evt.preventDefault();
  const svg = document.getElementById('diagram-svg');
  const ctm = svg.getScreenCTM();
  const startMouseX = (evt.clientX - ctm.e) / ctm.a;
  const startMouseY = (evt.clientY - ctm.f) / ctm.d;
  const autoPositioned = !!entityParentMap[e.id];
  dragState = { entity: e, startMouseX, startMouseY, startX: e.x, startY: e.y, moved: false, autoPositioned };
  window.addEventListener('mousemove', onDocMouseMove);
  window.addEventListener('mouseup', onDocMouseUp);
}
function onDocMouseMove(evt) {
  // While linking, every entity is click-only — a DCL's normal drag
  // behavior would otherwise let trackpad/mouse jitter get misread as a
  // drag and silently swallow the click needed to arm/complete the link.
  if (!dragState || dragState.autoPositioned || linkModeActive) return;
  const svg = document.getElementById('diagram-svg');
  const ctm = svg.getScreenCTM();
  const mx = (evt.clientX - ctm.e) / ctm.a;
  const my = (evt.clientY - ctm.f) / ctm.d;
  const dx = mx - dragState.startMouseX, dy = my - dragState.startMouseY;
  if (Math.abs(dx) > 6 || Math.abs(dy) > 6) dragState.moved = true;
  if (dragState.moved) {
    dragState.entity.x = Math.max(0, dragState.startX + dx);
    dragState.entity.y = Math.max(0, dragState.startY + dy);
    renderDiagram();
  }
}
function onDocMouseUp() {
  window.removeEventListener('mousemove', onDocMouseMove);
  window.removeEventListener('mouseup', onDocMouseUp);
  if (dragState && dragState.moved) {
    hasUnsavedChanges = true;
    updateUnsavedUI();
  } else if (dragState) {
    onEntityClick(dragState.entity);
  }
  dragState = null;
}

function onEntityClick(e) {
  if (editMode && linkModeActive) {
    if (e.type === 'TITLE' || e.type === 'COMMENT') {
      alert('Titles and comments are annotations, not data entities — they can\'t be connected with a relationship.');
      return;
    }
    if (!linkFirstEntity) {
      linkFirstEntity = e.id;
      renderDiagram();
    } else if (linkFirstEntity === e.id) {
      alert('A relationship needs two different entities — click a different one to connect to "' + e.name + '" (or Esc to cancel).');
    } else {
      const fromId = linkFirstEntity;
      linkFirstEntity = null;
      linkModeActive = false;
      updateLinkModeUI();
      createRelationship(fromId, e.id);
    }
    return;
  }
  if (editMode) {
    openEntityModal(e.id);
  } else {
    openViewModal('entity', e.id);
  }
}

function createRelationship(fromId, toId) {
  relCounter++;
  const r = {
    id: 'rel' + relCounter,
    from: fromId, to: toId,
    fromCard: 'one', toCard: 'many',
    fromOptional: false, toOptional: true,
    label: '',
    domain: '',
  };
  relationships.push(r);
  hasUnsavedChanges = true;
  updateUnsavedUI();
  renderDiagram(); // applyAutoLayout() inside this refreshes entityParentMap first
  if (entityParentMap[toId] === fromId || entityParentMap[fromId] === toId) {
    alert('Heads up: these two entities are already connected by the DCL naming hierarchy, so this relationship is saved but won\u2019t be drawn as a separate line on the diagram.');
  }
  openRelModal(r.id);
}

function renderRelationship(r, port) {
  const fromE = entities.find(x => x.id === r.from);
  const toE = entities.find(x => x.id === r.to);
  if (!fromE || !toE) return null;
  // Redundant with the auto-layout hierarchy (e.g. DCL→KYF, DCL→ELT, ELT→DIT):
  // the indentation already shows this, so no notation is needed.
  if (entityParentMap[r.to] === r.from || entityParentMap[r.from] === r.to) return null;
  const g = svgEl('g', { class: 'rel-group' });

  let p1, p2, pathD, dirAtP1, dirAtP2, bendX;
  if (fromE.id === toE.id) {
    // self-relationship: right-angle loop above the entity
    const rect = entityRect(fromE);
    p1 = { x: rect.x + rect.w * 0.3, y: rect.y };
    p2 = { x: rect.x + rect.w * 0.7, y: rect.y };
    const loopY = rect.y - 40;
    pathD = `M${p1.x},${p1.y} V${loopY} H${p2.x} V${p2.y}`;
    dirAtP1 = { x: 0, y: -1 };
    dirAtP2 = { x: 0, y: -1 };
    bendX = (p1.x + p2.x) / 2;
  } else {
    const route = elbowRoute(entityRect(fromE), entityRect(toE), port?.fromY, port?.toY);
    ({ p1, p2, pathD } = route);
    dirAtP1 = route.dir1;
    dirAtP2 = route.dir2;
    bendX = route.midX;
  }

  g.appendChild(svgEl('path', { class: 'rel-hit', d: pathD }));
  g.appendChild(svgEl('path', { class: 'rel-line', d: pathD }));

  // endpoint decorations
  appendCardinalitySymbol(g, p1, dirAtP1, r.fromCard, r.fromOptional);
  appendCardinalitySymbol(g, p2, dirAtP2, r.toCard, r.toOptional);

  // label centered on the bend (the vertical segment of the elbow)
  const bendMx = bendX, bendMy = fromE.id === toE.id ? (entityRect(fromE).y - 40) : (p1.y + p2.y) / 2;

  // The elbow is (up to) 3 segments: left-horizontal (p1 -> bend),
  // vertical (top of bend -> bottom of bend), right-horizontal (bend -> p2).
  // Self-relationships don't have this shape, so they're handled separately.
  const isSelf = fromE.id === toE.id;
  const leftLen  = isSelf ? 0 : Math.abs(bendMx - p1.x);
  const vertLen  = isSelf ? 0 : Math.abs(p2.y - p1.y);
  const rightLen = isSelf ? 0 : Math.abs(p2.x - bendMx);
  const longest = Math.max(leftLen, vertLen, rightLen);
  const longestSeg = isSelf ? 'self' : (longest === vertLen ? 'vertical' : longest === leftLen ? 'left' : 'right');

  if (editMode) {
    const dbg = svgEl('text', { class: 'rel-debug-text', x: bendMx + 8, y: bendMy + 24, 'text-anchor': 'start' });
    dbg.textContent = isSelf
      ? 'self-relationship'
      : `L=${Math.round(leftLen)} V=${Math.round(vertLen)} R=${Math.round(rightLen)} → ${longestSeg}`;
    g.appendChild(dbg);
  }

  if (r.label) {
    const w = Math.max(24, r.label.length * 6.2);
    g.appendChild(svgEl('rect', { class: 'rel-label-bg', x: bendMx - w / 2, y: bendMy - 9, width: w, height: 16, rx: 4 }));
    const t = svgEl('text', { class: 'rel-label-text', x: bendMx, y: bendMy + 3, 'text-anchor': 'middle' });
    t.textContent = r.label;
    g.appendChild(t);
  }
  // domain goes on whichever of the elbow's 3 segments is longest, rotated
  // 90° only when that longest segment is the vertical one. Recomputed on
  // every render, so it stays in sync as the connection is redrawn (e.g.
  // while dragging).
  if (r.domain) {
    if (isSelf) {
      const dy = bendMy - (r.label ? 12 : 5);
      const t = svgEl('text', { class: 'rel-domain-text', x: bendMx, y: dy, 'text-anchor': 'middle' });
      t.textContent = r.domain;
      g.appendChild(t);
    } else if (longestSeg === 'vertical') {
      const dx = bendMx - (r.label ? 8 : 4);
      const t = svgEl('text', {
        class: 'rel-domain-text', x: dx, y: bendMy, 'text-anchor': 'middle',
        transform: `rotate(-90 ${dx} ${bendMy})`,
      });
      t.textContent = r.domain;
      g.appendChild(t);
    } else if (longestSeg === 'left') {
      const mx = (p1.x + bendMx) / 2;
      const t = svgEl('text', { class: 'rel-domain-text', x: mx, y: p1.y - 6, 'text-anchor': 'middle' });
      t.textContent = r.domain;
      g.appendChild(t);
    } else {
      const mx = (bendMx + p2.x) / 2;
      const t = svgEl('text', { class: 'rel-domain-text', x: mx, y: p2.y - 6, 'text-anchor': 'middle' });
      t.textContent = r.domain;
      g.appendChild(t);
    }
  }

  g.style.cursor = 'pointer';
  g.addEventListener('click', () => {
    if (linkModeActive) return;
    editMode ? openRelModal(r.id) : openViewModal('rel', r.id);
  });
  return g;
}

// dir = unit vector pointing FROM the entity edge OUTWARD along the line
// (i.e. toward the other entity). Cardinality symbol sits touching the
// entity edge; optionality symbol sits further out along the same line.
function appendCardinalitySymbol(g, pt, dir, card, optional) {
  const px = -dir.y, py = dir.x; // perpendicular unit vector
  const nearD = 14, farD = 24, spread = 6;

  if (card === 'many') {
    const apex = { x: pt.x + dir.x * nearD, y: pt.y + dir.y * nearD };
    const a = { x: pt.x + px * spread, y: pt.y + py * spread };
    const b = { x: pt.x - px * spread, y: pt.y - py * spread };
    g.appendChild(svgEl('path', { class: 'rel-symbol', d: `M${apex.x},${apex.y} L${a.x},${a.y} M${apex.x},${apex.y} L${pt.x},${pt.y} M${apex.x},${apex.y} L${b.x},${b.y}` }));
  } else {
    const mid = { x: pt.x + dir.x * nearD, y: pt.y + dir.y * nearD };
    const a = { x: mid.x + px * spread, y: mid.y + py * spread };
    const b = { x: mid.x - px * spread, y: mid.y - py * spread };
    g.appendChild(svgEl('line', { class: 'rel-symbol', x1: a.x, y1: a.y, x2: b.x, y2: b.y }));
  }

  const farPt = { x: pt.x + dir.x * farD, y: pt.y + dir.y * farD };
  if (optional) {
    g.appendChild(svgEl('circle', { class: 'rel-symbol-fill', cx: farPt.x, cy: farPt.y, r: 5.5 }));
  } else {
    const a = { x: farPt.x + px * spread, y: farPt.y + py * spread };
    const b = { x: farPt.x - px * spread, y: farPt.y - py * spread };
    g.appendChild(svgEl('line', { class: 'rel-symbol', x1: a.x, y1: a.y, x2: b.x, y2: b.y }));
  }
}

function renderLegend() {
  const el = document.getElementById('legend');
  if (el.dataset.built) return; // static content, only needs building once
  const rows = [
    ['one-and-only-one', 'one', false, 'One (mandatory)'],
    ['zero-or-one', 'one', true, 'One (optional)'],
    ['one-or-many', 'many', false, 'Many (mandatory)'],
    ['zero-or-many', 'many', true, 'Many (optional)'],
  ];
  let html = '<h3>Crow\'s-foot notation</h3>';
  rows.forEach((row, i) => { html += `<div class="legend-row"><span data-slot="${i}"></span><span>${row[3]}</span></div>`; });
  el.innerHTML = html;
  rows.forEach(([key, card, optional], i) => {
    const slot = el.querySelector(`[data-slot="${i}"]`);
    if (!slot) return;
    const svgMini = svgEl('svg', { width: 60, height: 20, viewBox: '0 0 60 20' });
    svgMini.appendChild(svgEl('line', { x1: 0, y1: 10, x2: 60, y2: 10, class: 'rel-symbol' }));
    appendCardinalitySymbol(svgMini, { x: 0, y: 10 }, { x: 1, y: 0 }, card, optional);
    slot.appendChild(svgMini);
  });
  el.dataset.built = '1';
}

// ── EXPORT: STANDALONE SVG (string-based, no I/O) ───────────────────────────
// Computes a bounding box across all entities, scales it to the requested
// export width, and writes out entities + (non-redundant) relationships +
// the copyright notice as a self-contained SVG string with inlined colors.
function exportBoundingBox() {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  entities.forEach(e => {
    const r = entityRect(e);
    minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.w);
    minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.h);
  });
  const PAD = 40, COPYRIGHT_SPACE = 30;
  const OX = -minX + PAD, OY = -minY + PAD;
  const contentW = (maxX - minX) + PAD * 2;
  const contentH = (maxY - minY) + PAD * 2 + COPYRIGHT_SPACE;
  return { OX, OY, contentW, contentH };
}

// String-based re-implementation of appendCardinalitySymbol() for the
// standalone export (the live version builds real DOM nodes via svgEl()).
function svgSymbolMarkup(pt, dir, card, optional) {
  const px = -dir.y, py = dir.x;
  const nearD = 14, farD = 24, spread = 6;
  const ec = EXPORT_COLORS.edgeColor;
  let s = '';
  if (card === 'many') {
    const apex = { x: pt.x + dir.x * nearD, y: pt.y + dir.y * nearD };
    const a = { x: pt.x + px * spread, y: pt.y + py * spread };
    const b = { x: pt.x - px * spread, y: pt.y - py * spread };
    s += `  <path d="M${apex.x},${apex.y} L${a.x},${a.y} M${apex.x},${apex.y} L${pt.x},${pt.y} M${apex.x},${apex.y} L${b.x},${b.y}" stroke="${ec}" stroke-width="1.75" fill="none"/>\n`;
  } else {
    const mid = { x: pt.x + dir.x * nearD, y: pt.y + dir.y * nearD };
    const a = { x: mid.x + px * spread, y: mid.y + py * spread };
    const b = { x: mid.x - px * spread, y: mid.y - py * spread };
    s += `  <line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${ec}" stroke-width="1.75"/>\n`;
  }
  const farPt = { x: pt.x + dir.x * farD, y: pt.y + dir.y * farD };
  if (optional) {
    s += `  <circle cx="${farPt.x}" cy="${farPt.y}" r="5.5" fill="${EXPORT_COLORS.bgApp}" stroke="${ec}" stroke-width="1.75"/>\n`;
  } else {
    const a = { x: farPt.x + px * spread, y: farPt.y + py * spread };
    const b = { x: farPt.x - px * spread, y: farPt.y - py * spread };
    s += `  <line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${ec}" stroke-width="1.75"/>\n`;
  }
  return s;
}

function buildSvgMarkup(targetWidth) {
  if (!entities.length) return '';
  applyAutoLayout();
  targetWidth = Number(targetWidth) || 600;
  const { OX, OY, contentW, contentH } = exportBoundingBox();
  const exportW = targetWidth;
  const exportH = Math.round(contentH * (exportW / contentW));

  const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  const escAttr = s => String(s ?? '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

  let svg = `<svg width="${exportW}" height="${exportH}" viewBox="0 0 ${contentW} ${contentH}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n`;
  svg += `  <rect width="${contentW}" height="${contentH}" fill="${EXPORT_COLORS.bgApp}"/>\n`;

  // Relationships (skip lines already implied by the naming hierarchy, same as the live preview)
  const relPorts = computeRelPorts();
  relationships.forEach(r => {
    const fromE = entities.find(x => x.id === r.from);
    const toE = entities.find(x => x.id === r.to);
    if (!fromE || !toE) return;
    if (entityParentMap[r.to] === r.from || entityParentMap[r.from] === r.to) return;

    let p1, p2, pathD, dir1, dir2, bendX;
    if (fromE.id === toE.id) {
      const rect = entityRect(fromE);
      p1 = { x: rect.x + rect.w * 0.3 + OX, y: rect.y + OY };
      p2 = { x: rect.x + rect.w * 0.7 + OX, y: rect.y + OY };
      const loopY = rect.y - 40 + OY;
      pathD = `M${p1.x},${p1.y} V${loopY} H${p2.x} V${p2.y}`;
      dir1 = { x: 0, y: -1 }; dir2 = { x: 0, y: -1 };
      bendX = (p1.x + p2.x) / 2;
    } else {
      const port = relPorts.get(r.id);
      const route = elbowRoute(entityRect(fromE), entityRect(toE), port?.fromY, port?.toY);
      p1 = { x: route.p1.x + OX, y: route.p1.y + OY };
      p2 = { x: route.p2.x + OX, y: route.p2.y + OY };
      bendX = route.midX + OX;
      pathD = `M${p1.x},${p1.y} H${bendX} V${p2.y} H${p2.x}`;
      dir1 = route.dir1; dir2 = route.dir2;
    }
    svg += `  <path d="${pathD}" stroke="${EXPORT_COLORS.edgeColor}" stroke-width="1.75" fill="none"/>\n`;
    svg += svgSymbolMarkup(p1, dir1, r.fromCard, r.fromOptional);
    svg += svgSymbolMarkup(p2, dir2, r.toCard, r.toOptional);
    const bendMx = bendX;
    const bendMy = fromE.id === toE.id ? (entityRect(fromE).y - 40 + OY) : (p1.y + p2.y) / 2;
    if (r.label) {
      const w = Math.max(24, r.label.length * 6.2);
      svg += `  <rect x="${bendMx - w/2}" y="${bendMy - 9}" width="${w}" height="16" rx="4" fill="${EXPORT_COLORS.bgApp}"/>\n`;
      svg += `  <text x="${bendMx}" y="${bendMy + 3}" text-anchor="middle" font-family="Segoe UI, sans-serif" font-size="11" font-style="italic" fill="${EXPORT_COLORS.textMuted}">${esc(r.label)}</text>\n`;
    }
    if (r.domain) {
      const domainColor = ENTITY_TYPES.DOMAIN.color;
      const isSelf = fromE.id === toE.id;
      const leftLen  = isSelf ? 0 : Math.abs(bendMx - p1.x);
      const vertLen  = isSelf ? 0 : Math.abs(p2.y - p1.y);
      const rightLen = isSelf ? 0 : Math.abs(p2.x - bendMx);
      const longest = Math.max(leftLen, vertLen, rightLen);
      const longestSeg = isSelf ? 'self' : (longest === vertLen ? 'vertical' : longest === leftLen ? 'left' : 'right');
      if (isSelf) {
        const dy = bendMy - (r.label ? 12 : 5);
        svg += `  <text x="${bendMx}" y="${dy}" text-anchor="middle" font-family="Segoe UI, sans-serif" font-size="9" font-weight="600" fill="${domainColor}">${esc(r.domain)}</text>\n`;
      } else if (longestSeg === 'vertical') {
        const dx = bendMx - (r.label ? 8 : 4);
        svg += `  <text x="${dx}" y="${bendMy}" text-anchor="middle" transform="rotate(-90 ${dx} ${bendMy})" font-family="Segoe UI, sans-serif" font-size="9" font-weight="600" fill="${domainColor}">${esc(r.domain)}</text>\n`;
      } else if (longestSeg === 'left') {
        const mx = (p1.x + bendMx) / 2;
        svg += `  <text x="${mx}" y="${p1.y - 6}" text-anchor="middle" font-family="Segoe UI, sans-serif" font-size="9" font-weight="600" fill="${domainColor}">${esc(r.domain)}</text>\n`;
      } else {
        const mx = (bendMx + p2.x) / 2;
        svg += `  <text x="${mx}" y="${p2.y - 6}" text-anchor="middle" font-family="Segoe UI, sans-serif" font-size="9" font-weight="600" fill="${domainColor}">${esc(r.domain)}</text>\n`;
      }
    }
  });

  // Entities (wrapped in <a> when linked, same as flowchart.php's exported nodes)
  entityDrawOrder().forEach(e => {
    const rect = entityRect(e);
    const x = rect.x + OX, y = rect.y + OY;
    const hasLink = !!e.attrs.link;

    if (hasLink) svg += `  <a href="${escAttr(e.attrs.link)}" target="${escAttr(e.attrs.target || 'MAIN')}">\n`;

    if (e.type === 'TITLE' || e.type === 'COMMENT') {
      const font = e.type === 'TITLE' ? { family: "Georgia, 'Times New Roman', Cambria, serif", size: 28, weight: 700 } : { family: "'Segoe UI', system-ui, sans-serif", size: 13, weight: 600 };
      const lines = e.type === 'TITLE' ? titleLines(e) : commentLines(e);
      const lineH = font.size + 3;
      const totalH = lines.length * lineH;
      const cx = x + rect.w / 2, cy = y + rect.h / 2;
      lines.forEach((line, i) => {
        const ty = cy - totalH / 2 + lineH / 2 + i * lineH;
        svg += `    <text x="${cx}" y="${ty}" text-anchor="middle" dominant-baseline="central" font-family="${font.family}" font-size="${font.size}" font-weight="${font.weight}" fill="${EXPORT_COLORS.textPrimary}">${esc(line)}</text>\n`;
      });
    } else {
      const baseColor = (ENTITY_TYPES[e.type] || ENTITY_TYPES.DCL).color;
      const isDerivedDit = e.type === 'DIT' && e.attrs.derived === 'y';
      const color = isDerivedDit ? mutedColor(baseColor) : baseColor;
      const chipW = textWidth(e.type, CHIP_FONT) + 16;
      const fp = fontPartsFor(e.type);
      svg += `    <rect x="${x}" y="${y}" width="${rect.w}" height="${rect.h}" rx="7" ry="7" fill="${EXPORT_COLORS.bgPanel}" stroke="${EXPORT_COLORS.borderStrong}" stroke-width="1.5"/>\n`;
      svg += `    <rect x="${x+4}" y="${y+4}" width="${chipW}" height="${rect.h-8}" rx="5" ry="5" fill="${color}"/>\n`;
      svg += `    <text x="${x+4+chipW/2}" y="${y+rect.h/2+3}" text-anchor="middle" font-family="Segoe UI, sans-serif" font-size="9" font-weight="700" fill="#ffffff">${esc(e.type)}</text>\n`;
      svg += `    <text x="${x+4+chipW+10}" y="${y+rect.h/2+4}" font-family="Segoe UI, sans-serif" font-size="${fp.size}" font-weight="${fp.weight}" fill="${EXPORT_COLORS.textPrimary}">${esc(e.name)}</text>\n`;
    }

    if (hasLink) svg += `  </a>\n`;
  });

  // Logo (optional) — same fixed content-unit box as the live preview,
  // drawn before the copyright notice so it sits above the diagram content
  // but doesn't overlap the notice itself.
  const logoFile = document.getElementById('logo-select').value;
  if (logoFile) {
    const lx = contentW - LOGO_MARGIN - LOGO_W;
    const ly = LOGO_MARGIN;
    const logoSrc = 'logos/' + logoFile;
    svg += `  <image x="${lx}" y="${ly}" width="${LOGO_W}" height="${LOGO_H}" href="${logoSrc}" xlink:href="${logoSrc}" preserveAspectRatio="xMaxYMin meet"/>\n`;
  }

  // Copyright notice
  const copyrightLines = copyrightNoticeLines().map(esc);
  const lineH = 12;
  const startY = contentH - 8 - (copyrightLines.length - 1) * lineH;
  copyrightLines.forEach((line, i) => {
    svg += `  <text x="${contentW/2}" y="${startY + i*lineH}" text-anchor="middle" font-family="Segoe UI, sans-serif" font-size="10" fill="${EXPORT_COLORS.textMuted}" opacity="0.85">${line}</text>\n`;
  });

  svg += `</svg>\n`;
  return svg;
}

// ── EXPORT: HTML IMAGE MAP (rect <area> per entity, mirrors SVG click/tooltip) ──
// Meant to be used together with the exported SVG via:
//   <img src="name.svg" usemap="#namemap" width="{exportWidth}" height="{exportHeight}">
function buildImageMapHtml(targetWidth) {
  if (!entities.length) return '';
  applyAutoLayout();
  targetWidth = Number(targetWidth) || 600;
  const { OX, OY, contentW, contentH } = exportBoundingBox();
  const scale = targetWidth / contentW;
  const exportW = targetWidth;
  const exportH = Math.round(contentH * scale);
  const mapName = BASE_NAME + 'map';

  const esc = s => (s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

  let html = `<map name="${mapName}" id="${mapName}">\n`;
  // HTML image maps give click priority to the first *listed* area when
  // two overlap — the opposite convention from SVG z-order — so DCLs go
  // last here (mirrors entityDrawOrder(), reversed) to keep them from
  // winning a click over an overlapping child.
  const mapOrder = [...entities].sort((a, b) => (a.type === 'DCL' ? 1 : 0) - (b.type === 'DCL' ? 1 : 0));
  mapOrder.forEach(e => {
    const rect = entityRect(e);
    const x1 = (rect.x + OX) * scale, y1 = (rect.y + OY) * scale;
    const x2 = (rect.x + rect.w + OX) * scale, y2 = (rect.y + rect.h + OY) * scale;
    const href = e.attrs.link ? esc(e.attrs.link) : `#${BASE_NAME}`;
    const target = e.attrs.link ? ` target="${esc(e.attrs.target || 'MAIN')}"` : '';
    const extra = (TYPE_ATTR_KEYS[e.type] || []).filter(k => e.attrs[k]).map(k => `${ATTR_LABELS[k] || k}: ${e.attrs[k]}`).join(' | ');
    // Where-used list for DOMAIN entities — same data the live-preview view
    // modal shows, so the exported/standalone HTML behaves the same way
    // when a DOMAIN node is clicked, not just the in-app preview.
    const whereUsed = e.type === 'DOMAIN'
      ? entities.filter(x => x.type === 'DIT' && x.attrs && x.attrs.domain === e.name).map(x => x.name).sort().join(' | ')
      : '';
    const dataAttrs = ` data-entity-id="${esc(e.id)}" data-type="${esc(e.type)}" data-name="${esc(e.name)}"`
      + ` data-narrative="${esc(e.attrs.narrative || '')}" data-extra="${esc(extra)}"`
      + ` data-whereused="${esc(whereUsed)}"`
      + ` data-link="${esc(e.attrs.link || '')}" data-target="${esc(e.attrs.target || '')}"`;
    const coords = [Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)].join(',');
    html += `  <area shape="rect" coords="${coords}" href="${href}"${target} alt="${esc(e.name)}"${dataAttrs}>\n`;
  });
  html += `</map>\n`;

  // Logo overlay (optional) — rendered as a plain HTML <img>, absolutely
  // positioned on top of the diagram <img>, rather than relying on the
  // SVG's own embedded <image href="logos/..."> to paint (once an SVG is
  // consumed via <img src="...">, external raster references inside it
  // aren't reliably fetched/rendered by every browser or SVG-to-PNG
  // pipeline — see flowchart.php's identical note). Sized off exportW
  // alone (not contentW/scale), so the same export width always yields
  // the same on-screen logo size regardless of a given diagram's own
  // content-unit bounding box.
  const LOGO_REF_WIDTH = 1200;
  const logoFile = document.getElementById('logo-select').value;
  let logoHtml = '';
  if (logoFile) {
    const logoScale = exportW / LOGO_REF_WIDTH;
    const logoW = Math.round(LOGO_W * logoScale);
    const logoH = Math.round(LOGO_H * logoScale);
    const logoMargin = Math.round(LOGO_MARGIN * logoScale);
    const logoLeft = exportW - logoMargin - logoW;
    const logoTop = logoMargin;
    const logoSrc = 'logos/' + logoFile;
    logoHtml = `  <img src="${logoSrc}" alt="logo" style="position:absolute; left:${logoLeft}px; top:${logoTop}px; width:${logoW}px; height:${logoH}px; object-fit:contain; object-position:right top; pointer-events:none;">\n`;
  }

  const imgTag = `<div class="magec-diagram-wrap" style="position:relative; display:inline-block; line-height:0;">\n`
    + `  <img src="${BASE_NAME}.svg" usemap="#${mapName}" id="${BASE_NAME}" width="${exportW}" height="${exportH}" alt="${esc(BASE_NAME)}">\n`
    + logoHtml
    + `</div>\n`;

  // Self-contained tooltip styling + behavior — this file is meant to be
  // dropped into other pages, so it carries its own CSS/JS.
  const tooltipAssets = `
<style>
  .magec-tip {
    position: absolute; z-index: 9999; max-width: 300px;
    background: #1a1d27; color: #e2e8f0; border: 2px solid silver; border-radius: 10px;
    padding: 12px 14px; font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px;
    line-height: 1.6; box-shadow: 0 12px 32px rgba(0,0,0,.45); display: none;
  }
  .magec-tip.open { display: block; }
  .magec-tip .magec-tip-close { position: absolute; top: 4px; right: 8px; background: none; border: none; color: #64748b; font-size: 16px; cursor: pointer; line-height: 1; }
  .magec-tip .magec-tip-close:hover { color: #e2e8f0; }
  .magec-tip-badge { display:inline-block; font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:#fff; background:#6366f1; border-radius:999px; padding:1px 8px; margin-bottom:6px; }
  .magec-tip-title { font-weight: 700; font-size: 13.5px; margin: 0 18px 6px 0; color: #f1f5f9; }
  .magec-tip-body { color: #94a3b8; }
  .magec-tip-extra { margin-top: 6px; font-size: 12px; color: #c7d2fe; }
  .magec-tip-whereused { margin-top: 8px; font-size: 12px; color: #94a3b8; }
  .magec-tip-whereused b { display: block; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #64748b; margin-bottom: 4px; }
  .magec-tip-whereused-note { margin-top: 6px; font-size: 10.5px; color: #64748b; font-style: italic; }
  .magec-tip-link { display: inline-block; margin-top: 10px; font-size: 12.5px; font-weight: 600; color: #a5b4fc; text-decoration: none; border: 1px solid #4a5280; border-radius: 6px; padding: 4px 10px; }
  .magec-tip-link:hover { opacity: .85; }
</style>
<div class="magec-tip" id="${BASE_NAME}-tip">
  <button class="magec-tip-close" type="button" aria-label="Close">&times;</button>
  <div class="magec-tip-badge"></div>
  <div class="magec-tip-title"></div>
  <div class="magec-tip-body"></div>
  <div class="magec-tip-extra"></div>
  <div class="magec-tip-whereused"></div>
</div>
<script>
(function() {
  var tip = document.getElementById(${JSON.stringify(BASE_NAME + '-tip')});
  if (!tip) return;
  var badgeEl = tip.querySelector('.magec-tip-badge');
  var titleEl = tip.querySelector('.magec-tip-title');
  var bodyEl = tip.querySelector('.magec-tip-body');
  var extraEl = tip.querySelector('.magec-tip-extra');
  var whereUsedEl = tip.querySelector('.magec-tip-whereused');
  var closeBtn = tip.querySelector('.magec-tip-close');

  function hideTip() { tip.classList.remove('open'); }
  closeBtn.addEventListener('click', hideTip);
  document.addEventListener('click', function(e) {
    if (tip.classList.contains('open') && !tip.contains(e.target) && e.target.tagName !== 'AREA') hideTip();
  });

  function getLinkDisplayName(link) {
    if (!link) return '';
    var frag = '', withoutFrag = link;
    if (link.indexOf('#') !== -1) {
      var parts = link.split('#');
      withoutFrag = parts[0];
      frag = parts[1] || '';
    }
    if (!withoutFrag) return frag || link;
    var segments = withoutFrag.split('/');
    var last = segments[segments.length - 1] || '';
    return last || frag || link;
  }

  document.querySelectorAll('map[name=${JSON.stringify(mapName)}] area').forEach(function(area) {
    area.addEventListener('click', function(e) {
      var type = area.getAttribute('data-type') || '';
      var name = area.getAttribute('data-name') || '';
      var narrative = area.getAttribute('data-narrative') || '';
      var extra = area.getAttribute('data-extra') || '';
      var whereUsed = area.getAttribute('data-whereused') || '';
      var link = area.getAttribute('data-link') || '';
      var target = area.getAttribute('data-target') || 'MAIN';

      // A DOMAIN node always opens the tooltip (even with nothing else to
      // show) so its where-used list — including "used by nothing" — is
      // reachable by clicking it, same as in the live app.
      if (!narrative && !link && !extra && type !== 'DOMAIN') return;
      e.preventDefault();

      badgeEl.textContent = type;
      titleEl.textContent = name;
      bodyEl.innerHTML = narrative ? narrative : '<em>No narrative provided.</em>';
      extraEl.innerHTML = extra ? extra.split(' | ').join('<br>') : '';
      if (type === 'DOMAIN') {
        var items = whereUsed ? whereUsed.split(' | ') : [];
        whereUsedEl.innerHTML = '<b>Where used in this diagram (' + items.length + ')</b>'
          + (items.length ? items.join('<br>') : '<em>No DIT entities on this diagram currently reference this domain.</em>')
          + '<div class="magec-tip-whereused-note">This domain may also be used by DITs on other diagrams — this list only covers the current one.</div>';
      } else {
        whereUsedEl.innerHTML = '';
      }
      if (link) {
        bodyEl.innerHTML += '<br><a class="magec-tip-link" href="' + link.replace(/"/g, '&quot;') + '" target="' + target.replace(/"/g, '&quot;') + '">🔗 See ' + getLinkDisplayName(link).replace(/</g, '&lt;') + '</a>';
      }

      tip.classList.add('open');
      var pageX = e.pageX, pageY = e.pageY;
      var vw = window.innerWidth, vh = window.innerHeight;
      tip.style.left = '0px'; tip.style.top = '0px';
      var rect = tip.getBoundingClientRect();
      var left = pageX + 14, top = pageY + 10;
      if (left + rect.width > window.scrollX + vw - 12) left = pageX - rect.width - 14;
      if (top + rect.height > window.scrollY + vh - 12) top = pageY - rect.height - 10;
      tip.style.left = Math.max(8, left) + 'px';
      tip.style.top = Math.max(8, top) + 'px';
    });
  });
})();
<\/script>`;

  const copyrightComment = `<!-- ${copyrightNoticeText()} -->\n`;
  return copyrightComment + imgTag + html + tooltipAssets;
}

async function exportAll() {
  if (!entities.length) return alert('No diagram loaded yet.');
  const exportWidth = Number(document.getElementById('export-width-select').value) || 600;
  const svgContent = buildSvgMarkup(exportWidth);
  const mapHtml = buildImageMapHtml(exportWidth);
  try {
    const resp = await fetch(window.location.pathname + window.location.search, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: 'action=save_both&name=' + encodeURIComponent(BASE_NAME)
        + '&svg=' + encodeURIComponent(svgContent)
        + '&map=' + encodeURIComponent(mapHtml)
    });
    const data = await resp.json();
    if (data.ok) {
      alert(`Saved ${data.svg_file} (${data.svg_bytes} bytes) and ${data.map_file} (${data.map_bytes} bytes) to the server.`);
    } else {
      alert('Export failed: ' + data.error);
    }
  } catch (e) {
    console.error('Export error:', e);
    alert('Export failed: ' + e.message);
  }
}

// Escape key closes modals / cancels link mode
document.addEventListener('keydown', (evt) => {
  if (evt.key !== 'Escape') return;
  ['entity-modal-overlay', 'rel-modal-overlay', 'view-modal-overlay'].forEach(id => {
    document.getElementById(id).classList.remove('open');
  });
  if (linkModeActive) { linkModeActive = false; linkFirstEntity = null; updateLinkModeUI(); renderDiagram(); }
});

window.addEventListener('beforeunload', (e) => {
  if (hasUnsavedChanges && !intentionalExit) { e.preventDefault(); e.returnValue = ''; }
});

loadDiagram();
</script>
</body>
</html>