Module: MendixBridge::HtmlViewer

Defined in:
lib/mendix_bridge/html_viewer.rb

Overview

Generates a self-contained HTML viewer for an imported Mendix inventory.

The viewer embeds the project tree and per-element details as JSON, so the produced file works offline from a plain file:// URL with no server. It is a read-only view over the artifacts written by mendix-ruby import.

Constant Summary collapse

TREE_FILE =
File.join("inventory", "project-tree.json")
DETAILS_FILE =
File.join("inventory", "element-details.json")
METADATA_FILE =
"mendix-project.json"
TEMPLATE =
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Mendix Inventory Viewer</title>\n<style>\n  :root {\n    --bg: #0f1420; --panel: #161d2b; --panel2: #1d2636; --line: #2a3547;\n    --fg: #d7e0ee; --muted: #8595ac; --accent: #6ea8fe; --accent2: #4ade80;\n    --chip: #24304a; --code: #0c1018;\n  }\n  * { box-sizing: border-box; }\n  body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }\n  header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;\n    padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }\n  header h1 { font-size: 15px; margin: 0; font-weight: 600; }\n  header .meta { color: var(--muted); font-size: 12px; }\n  header .spacer { flex: 1; }\n  header input[type=search] { background: var(--panel2); border: 1px solid var(--line);\n    color: var(--fg); padding: 6px 10px; border-radius: 6px; width: 240px; }\n  header label { color: var(--muted); font-size: 12px; user-select: none; cursor: pointer; }\n  .layout { display: flex; height: calc(100vh - 49px); }\n  aside { width: 380px; min-width: 240px; overflow: auto; border-right: 1px solid var(--line);\n    background: var(--panel); padding: 8px 0; resize: horizontal; }\n  main { flex: 1; overflow: auto; padding: 20px 28px; }\n  ul.tree { list-style: none; margin: 0; padding: 0; }\n  ul.tree ul { list-style: none; margin: 0; padding-left: 16px; }\n  .node { display: flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 5px;\n    cursor: pointer; white-space: nowrap; }\n  .node:hover { background: var(--panel2); }\n  .node.selected { background: #23406b; }\n  .node .twist { width: 12px; color: var(--muted); font-size: 10px; flex: none; }\n  .node .icon { flex: none; }\n  .node .label { overflow: hidden; text-overflow: ellipsis; }\n  .node .count { color: var(--muted); font-size: 11px; }\n  .collapsed > ul { display: none; }\n  .badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px;\n    background: var(--chip); color: var(--muted); }\n  .hidden { display: none !important; }\n  main h2 { margin: 0 0 2px; font-size: 20px; }\n  main .qn { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; margin-bottom: 18px; }\n  section.block { background: var(--panel); border: 1px solid var(--line); border-radius: 8px;\n    margin-bottom: 16px; overflow: hidden; }\n  section.block > h3 { margin: 0; padding: 8px 14px; font-size: 12px; text-transform: uppercase;\n    letter-spacing: .04em; color: var(--muted); background: var(--panel2); border-bottom: 1px solid var(--line); }\n  section.block .body { padding: 12px 14px; }\n  table { border-collapse: collapse; width: 100%; font-size: 13px; }\n  th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }\n  th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; }\n  tr:last-child td { border-bottom: none; }\n  pre { margin: 0; padding: 12px 14px; background: var(--code); border-radius: 6px; overflow: auto;\n    font: 12px/1.5 ui-monospace, monospace; color: #cbd5e1; }\n  .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }\n  .kv dt { color: var(--muted); }\n  .kv dd { margin: 0; }\n  code.inline { background: var(--code); padding: 1px 5px; border-radius: 4px;\n    font: 12px ui-monospace, monospace; }\n  .empty { color: var(--muted); font-style: italic; }\n  .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; background: var(--chip); margin-left: 6px; }\n  .pill.ok { background: #14351f; color: var(--accent2); }\n  .pill.warn { background: #3a2a12; color: #fbbf24; }\n  a.ref { color: var(--accent); cursor: pointer; text-decoration: none; }\n  a.ref:hover { text-decoration: underline; }\n  .diagram { width: 100%; overflow: auto; background: var(--code); border-radius: 6px; }\n  .diagram svg { display: block; }\n  .diagram .box { fill: var(--panel2); stroke: var(--line); stroke-width: 1; }\n  .diagram .box.decision { fill: #2a2340; stroke: #6d5bd0; }\n  .diagram .box.terminal { fill: #14351f; stroke: #2f7d4d; }\n  .diagram .box.validation { fill: #3a2a12; stroke: #b5851f; }\n  .diagram .box.entity-head { fill: #23406b; stroke: #3a5ea8; }\n  .diagram .box.entity-body { fill: var(--panel2); stroke: #3a5ea8; }\n  .diagram text { fill: var(--fg); font: 11px system-ui, sans-serif; }\n  .diagram text.cap { fill: var(--muted); }\n  .diagram text.attr { fill: #cbd5e1; font: 10px ui-monospace, monospace; }\n  .diagram .edge { stroke: #6a7a94; stroke-width: 1.4; fill: none; }\n  .diagram .edge-label { fill: var(--muted); font: 10px system-ui; }\n  .diagram .nodeg { cursor: pointer; }\n  .diagram .nodeg:hover .box { stroke: var(--accent); }\n</style>\n</head>\n<body>\n<header>\n  <h1>Mendix Inventory Viewer</h1>\n  <span class=\"meta\" id=\"meta\"></span>\n  <span class=\"spacer\"></span>\n  <input type=\"search\" id=\"search\" placeholder=\"Filter by name\u2026\" autocomplete=\"off\">\n  <label><input type=\"checkbox\" id=\"pagesOnly\"> Pages only</label>\n</header>\n<div class=\"layout\">\n  <aside><ul class=\"tree\" id=\"tree\"></ul></aside>\n  <main id=\"detail\"><p class=\"empty\">Select an element on the left to see its details.</p></main>\n</div>\n\n<script type=\"application/json\" id=\"data-tree\">__TREE_JSON__</script>\n<script type=\"application/json\" id=\"data-details\">__DETAILS_JSON__</script>\n<script type=\"application/json\" id=\"data-meta\">__META_JSON__</script>\n<script>\n(function () {\n  const TREE = JSON.parse(document.getElementById(\"data-tree\").textContent);\n  const DETAILS = JSON.parse(document.getElementById(\"data-details\").textContent);\n  const META = JSON.parse(document.getElementById(\"data-meta\").textContent);\n\n  const ICONS = {\n    module: \"\u{1F4E6}\", folder: \"\u{1F4C1}\", page: \"\u{1F4C4}\", entity: \"\u{1F5C4}\uFE0F\", association: \"\u{1F517}\",\n    microflow: \"\u2699\uFE0F\", nanoflow: \"\u{1F527}\", enumeration: \"\u{1F524}\", layout: \"\u{1F9E9}\", snippet: \"\u2702\uFE0F\",\n    domainmodel: \"\u{1F5C2}\uFE0F\", security: \"\u{1F512}\", projectsecurity: \"\u{1F512}\", userrole: \"\u{1F464}\",\n    modulerole: \"\u{1F464}\", navigation: \"\u{1F9ED}\", settings: \"\u2699\uFE0F\", javascriptaction: \"\u{1F7E8}\",\n    javaaction: \"\u2615\", buildingblock: \"\u{1F9F1}\", pagetemplate: \"\u{1F4D1}\", imagecollection: \"\u{1F5BC}\uFE0F\",\n    constant: \"\u{1F522}\", jsonstructure: \"{}\", importmapping: \"\u2B07\uFE0F\", exportmapping: \"\u2B06\uFE0F\",\n    systemoverview: \"\u{1F310}\", workflow: \"\u{1F500}\"\n  };\n  const icon = (t) => ICONS[t] || \"\u2022\";\n\n  const nodes = document.getElementById(\"tree\");\n  const detail = document.getElementById(\"detail\");\n  const search = document.getElementById(\"search\");\n  const pagesOnly = document.getElementById(\"pagesOnly\");\n\n  // ---- metadata line -------------------------------------------------\n  const metaBits = [];\n  if (META.source_project) metaBits.push(META.source_project.split(\"/\").pop());\n  if (META.imported_at) metaBits.push(\"imported \" + META.imported_at);\n  if (META.element_count) metaBits.push(META.element_count + \" elements\");\n  document.getElementById(\"meta\").textContent = metaBits.join(\" \u00B7 \");\n\n  // ---- tree building -------------------------------------------------\n  let selectedEl = null;\n  const byQn = {}; // qualifiedName -> node <li>\n\n  function childrenOf(n) { return n.children || n.nodes || []; }\n\n  function makeNode(n) {\n    const li = document.createElement(\"li\");\n    li.dataset.type = n.type || \"\";\n    li.dataset.qn = n.qualifiedName || \"\";\n    li.dataset.label = (n.label || n.qualifiedName || \"\").toLowerCase();\n\n    const kids = childrenOf(n);\n    const row = document.createElement(\"div\");\n    row.className = \"node\";\n\n    const twist = document.createElement(\"span\");\n    twist.className = \"twist\";\n    twist.textContent = kids.length ? \"\u25B6\" : \"\";\n    row.appendChild(twist);\n\n    const ic = document.createElement(\"span\");\n    ic.className = \"icon\";\n    ic.textContent = icon(n.type);\n    row.appendChild(ic);\n\n    const label = document.createElement(\"span\");\n    label.className = \"label\";\n    label.textContent = n.label || n.qualifiedName || \"(unnamed)\";\n    row.appendChild(label);\n\n    if (kids.length) {\n      const c = document.createElement(\"span\");\n      c.className = \"count\";\n      c.textContent = kids.length;\n      row.appendChild(c);\n    }\n\n    const hasDetail = n.qualifiedName && DETAILS[n.qualifiedName];\n    row.addEventListener(\"click\", (ev) => {\n      ev.stopPropagation();\n      if (kids.length && ev.target === twist) { li.classList.toggle(\"collapsed\"); return; }\n      if (kids.length && !hasDetail) { li.classList.toggle(\"collapsed\"); return; }\n      if (hasDetail) selectNode(li, n);\n      else if (kids.length) li.classList.toggle(\"collapsed\");\n    });\n\n    li.appendChild(row);\n    if (n.qualifiedName) byQn[n.qualifiedName] = li;\n\n    if (kids.length) {\n      const ul = document.createElement(\"ul\");\n      kids.forEach((k) => ul.appendChild(makeNode(k)));\n      li.appendChild(ul);\n      if (n.type === \"module\" || n.type === \"folder\") li.classList.add(\"collapsed\");\n    }\n    return li;\n  }\n\n  (Array.isArray(TREE) ? TREE : [TREE]).forEach((n) => nodes.appendChild(makeNode(n)));\n\n  function selectNode(li, n) {\n    if (selectedEl) selectedEl.classList.remove(\"selected\");\n    selectedEl = li.querySelector(\".node\");\n    selectedEl.classList.add(\"selected\");\n    renderDetail(n);\n  }\n\n  function selectByQn(qn) {\n    const li = byQn[qn];\n    if (!li) return;\n    // expand ancestors\n    let p = li.parentElement;\n    while (p && p !== nodes) {\n      if (p.tagName === \"LI\") p.classList.remove(\"collapsed\");\n      p = p.parentElement;\n    }\n    li.querySelector(\".node\").click();\n    li.scrollIntoView({ block: \"center\" });\n  }\n  window.__selectByQn = selectByQn;\n\n  // ---- filtering -----------------------------------------------------\n  function applyFilter() {\n    const q = search.value.trim().toLowerCase();\n    const only = pagesOnly.checked;\n    function visit(li) {\n      const type = li.dataset.type;\n      const label = li.dataset.label;\n      const childLis = Array.from(li.children).filter((c) => c.tagName === \"UL\")\n        .flatMap((ul) => Array.from(ul.children));\n      let anyChild = false;\n      childLis.forEach((c) => { if (visit(c)) anyChild = true; });\n      const selfMatch = (!only || type === \"page\") && (!q || label.includes(q));\n      const show = selfMatch || anyChild;\n      li.classList.toggle(\"hidden\", !show);\n      if (anyChild && (q || only)) li.classList.remove(\"collapsed\");\n      return show;\n    }\n    Array.from(nodes.children).forEach(visit);\n  }\n  search.addEventListener(\"input\", applyFilter);\n  pagesOnly.addEventListener(\"change\", applyFilter);\n\n  // ---- detail rendering ---------------------------------------------\n  const el = (tag, cls, txt) => {\n    const e = document.createElement(tag);\n    if (cls) e.className = cls;\n    if (txt != null) e.textContent = txt;\n    return e;\n  };\n\n  // fields that are references to other elements (clickable)\n  const REF_KEYS = new Set([\"microflow_calls\", \"nanoflow_calls\", \"page_links\", \"calls\", \"generalization\"]);\n  const isQn = (s) => typeof s === \"string\" && /^[A-Za-z0-9_]+\\.[A-Za-z0-9_]+/.test(s);\n\n  function refLink(qn) {\n    const a = el(\"a\", \"ref\", qn);\n    a.addEventListener(\"click\", () => window.__selectByQn(qn.split(/[\\s(]/)[0]));\n    return a;\n  }\n\n  function renderScalar(v) {\n    if (typeof v === \"string\" && isQn(v) && DETAILS[v]) return refLink(v);\n    const span = el(\"span\");\n    span.textContent = String(v);\n    return span;\n  }\n\n  function renderArray(key, arr) {\n    if (arr.length === 0) return el(\"p\", \"empty\", \"none\");\n    const objs = arr.filter((x) => x && typeof x === \"object\" && !Array.isArray(x));\n    if (objs.length === arr.length) {\n      // table with the union of keys\n      const cols = [];\n      arr.forEach((o) => Object.keys(o).forEach((k) => { if (!cols.includes(k)) cols.push(k); }));\n      const table = el(\"table\");\n      const thead = el(\"thead\"); const htr = el(\"tr\");\n      cols.forEach((c) => htr.appendChild(el(\"th\", null, c)));\n      thead.appendChild(htr); table.appendChild(thead);\n      const tbody = el(\"tbody\");\n      arr.forEach((o) => {\n        const tr = el(\"tr\");\n        cols.forEach((c) => {\n          const td = el(\"td\");\n          const v = o[c];\n          if (v == null) td.appendChild(el(\"span\", \"empty\", \"\u2014\"));\n          else if (typeof v === \"object\") td.appendChild(el(\"code\", \"inline\", JSON.stringify(v)));\n          else td.appendChild(renderScalar(v));\n          tr.appendChild(td);\n        });\n        tbody.appendChild(tr);\n      });\n      table.appendChild(tbody);\n      return table;\n    }\n    // list of scalars\n    const ul = el(\"ul\");\n    ul.style.margin = \"0\"; ul.style.paddingLeft = \"18px\";\n    arr.forEach((v) => {\n      const li = el(\"li\");\n      li.appendChild(REF_KEYS.has(key) || isQn(v) ? renderScalar(v) : el(\"span\", null, String(v)));\n      ul.appendChild(li);\n    });\n    return ul;\n  }\n\n  // ---- SVG helpers ---------------------------------------------------\n  const SVGNS = \"http://www.w3.org/2000/svg\";\n  const svg = (tag, attrs, txt) => {\n    const e = document.createElementNS(SVGNS, tag);\n    if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);\n    if (txt != null) e.textContent = txt;\n    return e;\n  };\n  const clip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + \"\u2026\" : (s || \"\"));\n\n  // ---- microflow / nanoflow flowchart (parsed from MDL) --------------\n  // Best-effort structured parse of the MDL body into nodes + edges.\n  function parseFlow(mdl) {\n    const begin = mdl.indexOf(\"\\nbegin\");\n    if (begin === -1) return null;\n    let body = mdl.slice(begin + 6);\n    body = body.replace(/\\nend;\\s*$/, \"\\n\");\n\n    const nodes = [];\n    const edges = [];\n    let counter = 0;\n    const add = (kind, label, pos) => {\n      const id = counter++;\n      nodes.push({ id, kind, label: clip(label, 46), x: pos ? pos.x : null, y: pos ? pos.y : null });\n      return id;\n    };\n    const startId = add(\"terminal\", \"Start\", null);\n    let prevs = [{ id: startId, label: null }];\n    const stack = [];\n    let pos = null, caption = null;\n    const connect = (to) => prevs.forEach((p) => edges.push({ from: p.id, to, label: p.label }));\n    const kindOf = (t) => {\n      if (/^return\\b/.test(t)) return \"terminal\";\n      if (/^validation\\b/.test(t)) return \"validation\";\n      if (/\\bcall\\b/.test(t)) return \"action\";\n      if (/^(declare|set)\\b/.test(t)) return \"assign\";\n      return \"action\";\n    };\n\n    // tokenize into logical statements, keeping if/else/end-if structure\n    const lines = body.split(\"\\n\");\n    let buf = \"\";\n    const flush = (raw) => {\n      const t = raw.trim();\n      if (!t) return;\n      if (/^if\\b/.test(t)) {\n        const expr = t.replace(/^if\\s+/, \"\").replace(/\\s+then$/, \"\");\n        const id = add(\"decision\", caption || expr, pos);\n        connect(id); pos = null; caption = null;\n        stack.push({ id, inElse: false, trueTails: [] });\n        prevs = [{ id, label: \"true\" }];\n      } else if (/^else$/.test(t)) {\n        const ctx = stack[stack.length - 1];\n        ctx.trueTails = prevs; ctx.inElse = true;\n        prevs = [{ id: ctx.id, label: \"false\" }];\n      } else if (/^end if;?$/.test(t)) {\n        const ctx = stack.pop();\n        const falseTails = ctx.inElse ? prevs : [{ id: ctx.id, label: \"false\" }];\n        const trueTails = ctx.inElse ? ctx.trueTails : prevs;\n        prevs = trueTails.concat(falseTails);\n      } else if (/^(begin|end)\\b/.test(t) || t.startsWith(\"@\")) {\n        // annotations handled separately; skip\n      } else {\n        const id = add(kindOf(t), caption || t, pos);\n        connect(id); pos = null; caption = null;\n        prevs = [{ id, label: null }];\n      }\n    };\n\n    lines.forEach((line) => {\n      const l = line.trim();\n      const mp = l.match(/@position\\((-?\\d+),\\s*(-?\\d+)\\)/);\n      if (mp) { pos = { x: +mp[1], y: +mp[2] }; return; }\n      const mc = l.match(/@caption\\s+'([^']*)'/);\n      if (mc) { caption = mc[1]; return; }\n      if (l.startsWith(\"@\")) return; // other annotations\n      // accumulate until a statement terminator (; or `then`) or control kw\n      buf += (buf ? \" \" : \"\") + l;\n      if (/;$/.test(l) || /\\bthen$/.test(l) || /^else$/.test(l) || /^end if;?$/.test(l) ||\n          /^if\\b.*\\bthen$/.test(buf)) {\n        flush(buf); buf = \"\";\n      }\n    });\n    if (buf.trim()) flush(buf);\n    return { nodes, edges };\n  }\n\n  function renderFlow(mdl) {\n    const g = parseFlow(mdl);\n    if (!g || !g.nodes.length) return null;\n    const W = 168, H = 46, PADX = 26, PADY = 22;\n    // place nodes lacking @position by inheriting from their predecessor\n    const byId = Object.fromEntries(g.nodes.map((n) => [n.id, n]));\n    const posNodes = g.nodes.filter((n) => n.x != null);\n    const baseX = posNodes.length ? Math.round(posNodes.reduce((s, n) => s + n.x, 0) / posNodes.length) : 0;\n    const topY = posNodes.length ? Math.min(...posNodes.map((n) => n.y)) : 0;\n    if (byId[0] && byId[0].x == null) { byId[0].x = baseX; byId[0].y = topY - 110; }\n    for (let pass = 0; pass < 5; pass++) {\n      g.edges.forEach((e) => {\n        const to = byId[e.to], from = byId[e.from];\n        if (to && to.x == null && from && from.x != null) { to.x = from.x; to.y = from.y + 110; }\n      });\n    }\n    let autoY = topY;\n    g.nodes.forEach((n) => { if (n.x == null) { n.x = baseX + 230; n.y = autoY; autoY += 90; } });\n    const xs = g.nodes.map((n) => n.x), ys = g.nodes.map((n) => n.y);\n    const minX = Math.min(...xs), minY = Math.min(...ys);\n    const maxX = Math.max(...xs), maxY = Math.max(...ys);\n    const sx = 0.42, sy = 0.62; // Mendix coords are roomy; compress\n    const px = (x) => (x - minX) * sx + PADX;\n    const py = (y) => (y - minY) * sy + PADY;\n    const width = (maxX - minX) * sx + W + PADX * 2;\n    const height = (maxY - minY) * sy + H + PADY * 2;\n    const pos = {}; g.nodes.forEach((n) => { pos[n.id] = { x: px(n.x), y: py(n.y) }; });\n\n    const s = svg(\"svg\", { width, height, viewBox: `0 0 ${width} ${height}` });\n    const defs = svg(\"defs\");\n    const marker = svg(\"marker\", { id: \"arrow\", markerWidth: 8, markerHeight: 8,\n      refX: 7, refY: 3, orient: \"auto\", markerUnits: \"strokeWidth\" });\n    marker.appendChild(svg(\"path\", { d: \"M0,0 L7,3 L0,6 Z\", fill: \"#6a7a94\" }));\n    defs.appendChild(marker); s.appendChild(defs);\n\n    g.edges.forEach((e) => {\n      const a = pos[e.from], b = pos[e.to];\n      if (!a || !b) return;\n      const x1 = a.x + W / 2, y1 = a.y + H, x2 = b.x + W / 2, y2 = b.y;\n      const my = (y1 + y2) / 2;\n      const d = `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`;\n      s.appendChild(svg(\"path\", { class: \"edge\", d, \"marker-end\": \"url(#arrow)\" }));\n      if (e.label) s.appendChild(svg(\"text\", { class: \"edge-label\", x: (x1 + x2) / 2 + 4, y: my - 2 }, e.label));\n    });\n    g.nodes.forEach((n) => {\n      const p = pos[n.id];\n      const grp = svg(\"g\", { class: \"nodeg\" });\n      const cls = \"box \" + (n.kind === \"decision\" ? \"decision\" : n.kind === \"terminal\" ? \"terminal\"\n        : n.kind === \"validation\" ? \"validation\" : \"\");\n      const r = n.kind === \"terminal\" ? H / 2 : 6;\n      grp.appendChild(svg(\"rect\", { class: cls, x: p.x, y: p.y, width: W, height: H, rx: r, ry: r }));\n      grp.appendChild(svg(\"text\", { x: p.x + W / 2, y: p.y + H / 2 + 4, \"text-anchor\": \"middle\" }, n.label));\n      s.appendChild(grp);\n    });\n    const wrap = el(\"div\", \"diagram\");\n    wrap.appendChild(s);\n    return wrap;\n  }\n\n  // ---- domain ER diagram (selected entity + direct neighbours) -------\n  const ASSOCS = Object.entries(DETAILS)\n    .filter(([, v]) => v && v.from && v.to)\n    .map(([qn, v]) => ({ qn, from: v.from, to: v.to, type: v.association_type, owner: v.owner }));\n\n  function entityBox(qn, x, y) {\n    const d = DETAILS[qn] || {};\n    const attrs = Array.isArray(d.attributes) ? d.attributes : [];\n    const shown = attrs.slice(0, 8);\n    const name = qn.split(\".\").pop();\n    const W = 190, HEAD = 26, ROW = 16;\n    const h = HEAD + Math.max(shown.length, 1) * ROW + 6;\n    const g = svg(\"g\", { class: \"nodeg\" });\n    g.setAttribute(\"data-qn\", qn);\n    g.appendChild(svg(\"rect\", { class: \"box entity-body\", x, y, width: W, height: h, rx: 6, ry: 6 }));\n    g.appendChild(svg(\"rect\", { class: \"box entity-head\", x, y, width: W, height: HEAD, rx: 6, ry: 6 }));\n    g.appendChild(svg(\"text\", { x: x + 9, y: y + 17, style: \"font-weight:600\" }, clip(name, 24)));\n    shown.forEach((a, i) => {\n      const label = (a.name || a.Name || \"?\") + (a.type ? \" : \" + a.type : \"\");\n      g.appendChild(svg(\"text\", { class: \"attr\", x: x + 9, y: y + HEAD + 13 + i * ROW }, clip(label, 30)));\n    });\n    if (attrs.length > shown.length) {\n      g.appendChild(svg(\"text\", { class: \"cap\", x: x + 9, y: y + h - 4 }, \"+\" + (attrs.length - shown.length) + \" more\"));\n    }\n    return { g, w: W, h };\n  }\n\n  function renderER(centerQn) {\n    const related = ASSOCS.filter((a) => a.from === centerQn || a.to === centerQn);\n    if (!related.length && !DETAILS[centerQn]) return null;\n    const neighbours = [];\n    related.forEach((a) => {\n      const other = a.from === centerQn ? a.to : a.from;\n      if (other !== centerQn && !neighbours.includes(other)) neighbours.push(other);\n    });\n\n    const colX = [30, 320, 610];\n    const layout = {}; // qn -> {x,y}\n    layout[centerQn] = { col: 1, i: 0 };\n    let li = 0, ri = 0;\n    neighbours.forEach((qn, idx) => {\n      if (idx % 2 === 0) layout[qn] = { col: 0, i: li++ };\n      else layout[qn] = { col: 2, i: ri++ };\n    });\n    const boxes = {};\n    const wrap = el(\"div\", \"diagram\");\n    const s = svg(\"svg\", {});\n    const defs = svg(\"defs\");\n    const m = svg(\"marker\", { id: \"erarrow\", markerWidth: 9, markerHeight: 9, refX: 8, refY: 3,\n      orient: \"auto\", markerUnits: \"strokeWidth\" });\n    m.appendChild(svg(\"path\", { d: \"M0,0 L8,3 L0,6 Z\", fill: \"#6a7a94\" }));\n    defs.appendChild(m); s.appendChild(defs);\n\n    let maxBottom = 0, maxRight = 0;\n    const place = (qn) => {\n      const lo = layout[qn]; if (!lo || boxes[qn]) return;\n      const x = colX[lo.col];\n      const y = 20 + lo.i * 130;\n      const b = entityBox(qn, x, y);\n      b.x = x; b.y = y; b.cx = x + b.w / 2; b.cy = y + b.h / 2;\n      boxes[qn] = b;\n      maxBottom = Math.max(maxBottom, y + b.h);\n      maxRight = Math.max(maxRight, x + b.w);\n    };\n    place(centerQn); neighbours.forEach(place);\n\n    related.forEach((a) => {\n      const from = boxes[a.from], to = boxes[a.to];\n      if (!from || !to) return;\n      const x1 = from.cx, y1 = from.cy, x2 = to.cx, y2 = to.cy;\n      s.appendChild(svg(\"line\", { class: \"edge\", x1, y1, x2, y2, \"marker-end\": \"url(#erarrow)\" }));\n      const lbl = (a.type === \"ReferenceSet\" ? \"* \" : \"1 \") + (a.qn.split(\".\").pop());\n      s.appendChild(svg(\"text\", { class: \"edge-label\", x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 3, \"text-anchor\": \"middle\" }, clip(lbl, 28)));\n    });\n    Object.values(boxes).forEach((b) => s.appendChild(b.g));\n\n    const width = Math.max(maxRight + 30, 640), height = maxBottom + 30;\n    s.setAttribute(\"width\", width); s.setAttribute(\"height\", height);\n    s.setAttribute(\"viewBox\", `0 0 ${width} ${height}`);\n    // clicking a neighbour navigates to it\n    s.addEventListener(\"click\", (ev) => {\n      const g = ev.target.closest(\"g[data-qn]\");\n      if (g && g.getAttribute(\"data-qn\") !== centerQn) window.__selectByQn(g.getAttribute(\"data-qn\"));\n    });\n    wrap.appendChild(s);\n    return wrap;\n  }\n\n  function buildDiagram(n, d) {\n    const t = n.type;\n    if ((t === \"microflow\" || t === \"nanoflow\") && d.mdl) return renderFlow(d.mdl);\n    if (t === \"entity\") return renderER(n.qualifiedName);\n    return null;\n  }\n\n  function block(title, node) {\n    const s = el(\"section\", \"block\");\n    s.appendChild(el(\"h3\", null, title.replace(/_/g, \" \")));\n    const body = el(\"div\", \"body\");\n    body.appendChild(node);\n    s.appendChild(body);\n    return s;\n  }\n\n  function renderDetail(n) {\n    const qn = n.qualifiedName;\n    const d = DETAILS[qn] || {};\n    detail.innerHTML = \"\";\n\n    const h = el(\"h2\", null, n.label || qn);\n    detail.appendChild(h);\n    const sub = el(\"div\", \"qn\");\n    sub.textContent = (n.type || \"element\") + \"  \u00B7  \" + qn;\n    if (d.parse_status) {\n      const p = el(\"span\", \"pill \" + (d.parse_status === \"parsed\" ? \"ok\" : \"warn\"), d.parse_status);\n      sub.appendChild(p);\n    }\n    detail.appendChild(sub);\n\n    // visual diagram first (flowchart for flows, ER for entities)\n    const diagram = buildDiagram(n, d);\n    if (diagram) {\n      const label = (n.type === \"entity\") ? \"Domain (neighbourhood)\" : \"Flowchart\";\n      detail.appendChild(block(label, diagram));\n    }\n\n    // scalar summary (short string/number/bool fields) as a key/value grid\n    const scalars = Object.entries(d).filter(([k, v]) =>\n      k !== \"parse_status\" && (typeof v !== \"object\") && !(typeof v === \"string\" && v.length > 120));\n    if (scalars.length) {\n      const dl = el(\"dl\", \"kv\");\n      scalars.forEach(([k, v]) => {\n        dl.appendChild(el(\"dt\", null, k.replace(/_/g, \" \")));\n        const dd = el(\"dd\");\n        dd.appendChild(renderScalar(v));\n        dl.appendChild(dd);\n      });\n      detail.appendChild(block(\"summary\", dl));\n    }\n\n    // everything else, in a stable, page-friendly order\n    const ORDER = [\"parameters\", \"widget_types\", \"widgets\", \"data_sources\", \"attributes\",\n      \"actions\", \"microflow_calls\", \"nanoflow_calls\", \"page_links\", \"view_roles\",\n      \"access_rules\", \"execute_roles\", \"variables\", \"activities\", \"calls\", \"generalization\"];\n    const keys = Object.keys(d).filter((k) =>\n      !scalars.some(([sk]) => sk === k) && k !== \"parse_status\" && k !== \"mdl\");\n    keys.sort((a, b) => {\n      const ia = ORDER.indexOf(a), ib = ORDER.indexOf(b);\n      return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);\n    });\n\n    keys.forEach((k) => {\n      const v = d[k];\n      let node;\n      if (Array.isArray(v)) node = renderArray(k, v);\n      else if (v && typeof v === \"object\") {\n        const dl = el(\"dl\", \"kv\");\n        Object.entries(v).forEach(([kk, vv]) => {\n          dl.appendChild(el(\"dt\", null, kk));\n          const dd = el(\"dd\");\n          dd.appendChild(renderScalar(vv));\n          dl.appendChild(dd);\n        });\n        node = dl;\n      } else node = renderScalar(v);\n      detail.appendChild(block(k, node));\n    });\n\n    if (d.mdl) detail.appendChild(block(\"MDL\", el(\"pre\", null, d.mdl)));\n\n    if (!Object.keys(d).length) {\n      detail.appendChild(el(\"p\", \"empty\", \"This node is a container; it has no element details.\"));\n    }\n  }\n})();\n</script>\n</body>\n</html>\n"

Class Method Summary collapse