// Context Graph — interactive force-directed visualization of hubs. // Modeled after the reference shell, adapted to Proxa × Traylor hubs. const ContextGraph = (function () { // A real representation of the encoded model. The org (center) is a // composition of DOMAIN models (hubs); Finance is the built vertical, shown // all the way down: source files → schedules (ODP) → data products (ADP) → // Proxa-modeled schedules. Cross-domain edges are the dependency graph. // Compound graph, traversed by drill-down / semantic zoom. Level 1 is the // Workspace: each hub is a card spoked off a central Workspace node (hubs // aren't linked to each other yet). Clicking a hub (drillTo) loads its own // Level-2 graph. Company Overview drills into the full encoded model; the // other hubs get a small representative stub for now. const DATASETS = { // ── Level 1 · Workspace ── workspace: { nodes: [ { id: 'ws', kind: 'org', label: 'Cascade Ridge Foods', group: 'org' }, { id: 'h:company', kind: 'domain', label: 'Finance', group: 'domain', drillTo: 'company' }, { id: 'h:customer', kind: 'domain', label: 'Customer', group: 'domain', drillTo: 'customer' }, { id: 'h:tenney', kind: 'domain', label: 'Tenney North Park', group: 'domain', drillTo: 'tenney' }, ], links: [ ['ws', 'h:company'], ['ws', 'h:customer'], ['ws', 'h:tenney'], ], }, // ── Level 2 · Company Overview — the full encoded model ── // source files → schedules (ODP) → data products (ADP) → Proxa-modeled. company: { nodes: [ { id: '__col', kind: 'org', label: 'Finance', r: 18, group: 'org' }, { id: 'd:finance', kind: 'domain', label: 'Finance', r: 13, group: 'domain' }, { id: 'd:contracts', kind: 'domain', label: 'Contracts', r: 12, group: 'domain' }, { id: 'd:people', kind: 'planned', label: 'People', r: 11, group: 'planned' }, { id: 'd:customers', kind: 'planned', label: 'Customers', r: 11, group: 'planned' }, { id: 'd:ops', kind: 'planned', label: 'Operations', r: 11, group: 'planned' }, { id: 'cs:legal', kind: 'source', label: 'Legal corpus (40 docs)', r: 8, group: 'source' }, { id: 'cp:register', kind: 'schedule', label: 'Tabular Review', r: 7, group: 'schedule' }, { id: 'cp:expiry', kind: 'modeled', label: 'Contract Expiration Schedule', r: 7, group: 'modeled' }, { id: 's:model', kind: 'source', label: 'Financial Model.xlsx', r: 8, group: 'source' }, { id: 's:gl', kind: 'source', label: 'GL Export', r: 8, group: 'source' }, { id: 's:prod', kind: 'source', label: 'Production Log', r: 8, group: 'source' }, { id: 'o:gldetail', kind: 'schedule', label: 'GL Detail', r: 7, group: 'schedule' }, { id: 'o:prodsched', kind: 'schedule', label: 'Production Schedule', r: 7, group: 'schedule' }, { id: 'p:is', kind: 'product', label: 'Income Statement', r: 9, group: 'product' }, { id: 'p:bs', kind: 'product', label: 'Balance Sheet', r: 9, group: 'product' }, { id: 'p:cf', kind: 'product', label: 'Cash Flow', r: 9, group: 'product' }, { id: 'p:rev', kind: 'product', label: 'Revenue Build', r: 7, group: 'product' }, { id: 'm:cap', kind: 'modeled', label: 'Capacity & Utilization', r: 7, group: 'modeled' }, { id: 'm:rbu', kind: 'modeled', label: 'Revenue Build (bottom-up)', r: 7, group: 'modeled' }, ], links: [ ['__col', 'd:finance'], ['__col', 'd:people'], ['__col', 'd:customers'], ['__col', 'd:contracts'], ['__col', 'd:ops'], ['d:finance', 'd:people'], ['d:finance', 'd:customers'], ['d:finance', 'd:ops'], ['d:customers', 'd:contracts'], ['d:finance', 's:model'], ['d:finance', 's:gl'], ['d:finance', 's:prod'], ['s:gl', 'o:gldetail'], ['s:prod', 'o:prodsched'], ['o:gldetail', 'p:is'], ['o:gldetail', 'p:bs'], ['o:prodsched', 'm:cap'], ['o:prodsched', 'm:rbu'], ['s:model', 'p:is'], ['s:model', 'p:bs'], ['s:model', 'p:cf'], ['s:model', 'p:rev'], ['p:is', 'p:cf'], ['d:contracts', 'cs:legal'], ['cs:legal', 'cp:register'], ['cp:register', 'cp:expiry'], ['p:rev', 'cp:register'], ], }, // ── Level 2 · Customer (stub) ── customer: { nodes: [ { id: 'c:hub', kind: 'org', label: 'Customer', r: 16, group: 'org' }, { id: 'c:legal', kind: 'source', label: 'Legal corpus (40 docs)', r: 8, group: 'source' }, { id: 'c:sf', kind: 'source', label: 'Salesforce export', r: 8, group: 'source' }, { id: 'c:register', kind: 'schedule', label: 'Tabular Review', r: 7, group: 'schedule' }, { id: 'c:expiry', kind: 'modeled', label: 'Contract Expiration Schedule', r: 7, group: 'modeled' }, { id: 'c:360', kind: 'product', label: 'Customer 360', r: 9, group: 'product' }, ], links: [ ['c:hub', 'c:legal'], ['c:hub', 'c:sf'], ['c:legal', 'c:register'], ['c:register', 'c:expiry'], ['c:register', 'c:360'], ['c:sf', 'c:360'], ], }, // ── Level 2 · Tenney North Park (stub) ── tenney: { nodes: [ { id: 't:hub', kind: 'org', label: 'Tenney North Park', r: 16, group: 'org' }, { id: 't:rent', kind: 'source', label: 'Rent roll', r: 8, group: 'source' }, { id: 't:model', kind: 'source', label: 'Underwriting model.xlsx', r: 8, group: 'source' }, { id: 't:lease', kind: 'schedule', label: 'Lease-up schedule', r: 7, group: 'schedule' }, { id: 't:val', kind: 'product', label: 'Valuation', r: 9, group: 'product' }, { id: 't:noi', kind: 'modeled', label: 'NOI & cash flows', r: 7, group: 'modeled' }, ], links: [ ['t:hub', 't:rent'], ['t:hub', 't:model'], ['t:rent', 't:lease'], ['t:lease', 't:val'], ['t:model', 't:val'], ['t:model', 't:noi'], ], }, // ══ HLA Typing Lab (domain 'lab') ═════════════════════════════════════ // Same grammar as Cascade Ridge: sources → schedules (ODP) → data products // (ADP) → modeled. The reference release is a source in its own right, // which is what makes re-resolution a computation rather than a migration. // ── Level 1 · Workspace ── labworkspace: { nodes: [ { id: 'lws', kind: 'org', label: 'HLA Typing Lab', group: 'org' }, { id: 'lh:typing', kind: 'domain', label: 'Typing', group: 'domain', drillTo: 'labtyping' }, { id: 'lh:studies', kind: 'domain', label: 'Studies & Cohorts', group: 'domain', drillTo: 'labstudies' }, { id: 'lh:match', kind: 'planned', label: 'Matching', group: 'planned' }, { id: 'lh:method', kind: 'planned', label: 'Method & Validation', group: 'planned' }, ], links: [ ['lws', 'lh:typing'], ['lws', 'lh:studies'], ['lws', 'lh:match'], ['lws', 'lh:method'], ], }, // ── Level 2 · Typing — the assay, all the way down ── labtyping: { nodes: [ { id: 'lt:hub', kind: 'org', label: 'Typing', r: 18, group: 'org' }, { id: 'lt:studies', kind: 'domain', label: 'Studies & Cohorts', r: 12, group: 'domain' }, { id: 'lt:match', kind: 'planned', label: 'Matching', r: 11, group: 'planned' }, { id: 'lt:ligands', kind: 'planned', label: 'Ligands & Epitopes', r: 11, group: 'planned' }, { id: 'lt:traces', kind: 'source', label: 'sequence-traces.zip', r: 8, group: 'source' }, { id: 'lt:runs', kind: 'source', label: 'SBT-Run-Manifest.xlsx', r: 8, group: 'source' }, { id: 'lt:imgt', kind: 'source', label: 'IMGT/HLA 3.60.0', r: 8, group: 'source' }, { id: 'lt:kir', kind: 'source', label: 'KIR-Genotypes.csv', r: 8, group: 'source' }, { id: 'lt:seq', kind: 'schedule', label: 'Sequences', r: 7, group: 'schedule' }, { id: 'lt:runsch', kind: 'schedule', label: 'Run schedule', r: 7, group: 'schedule' }, { id: 'lt:ledger', kind: 'product', label: 'Assignment Ledger', r: 9, group: 'product' }, { id: 'lt:amb', kind: 'product', label: 'Ambiguity Queue', r: 7, group: 'product' }, { id: 'lt:ligand', kind: 'product', label: 'KIR–HLA Ligand Pairing', r: 7, group: 'product' }, { id: 'lt:rerun', kind: 'modeled', label: 'Re-resolution Diff', r: 7, group: 'modeled' }, { id: 'lt:perf', kind: 'modeled', label: 'Method Performance', r: 7, group: 'modeled' }, ], links: [ ['lt:hub', 'lt:studies'], ['lt:hub', 'lt:match'], ['lt:hub', 'lt:ligands'], ['lt:hub', 'lt:traces'], ['lt:hub', 'lt:runs'], ['lt:hub', 'lt:imgt'], ['lt:hub', 'lt:kir'], ['lt:traces', 'lt:seq'], ['lt:runs', 'lt:runsch'], ['lt:seq', 'lt:ledger'], ['lt:runsch', 'lt:ledger'], ['lt:imgt', 'lt:ledger'], ['lt:seq', 'lt:amb'], ['lt:ledger', 'lt:amb'], ['lt:ledger', 'lt:rerun'], ['lt:imgt', 'lt:rerun'], ['lt:runsch', 'lt:perf'], ['lt:ledger', 'lt:perf'], ['lt:ledger', 'lt:ligand'], ['lt:kir', 'lt:ligand'], ['lt:ledger', 'lt:match'], ['lt:ledger', 'lt:studies'], ], }, // ── Level 2 · Studies & Cohorts ── labstudies: { nodes: [ { id: 'ls:hub', kind: 'org', label: 'Studies & Cohorts', r: 16, group: 'org' }, { id: 'ls:typing', kind: 'domain', label: 'Typing', r: 12, group: 'domain' }, { id: 'ls:manif', kind: 'source', label: 'Study-Cohorts.xlsx', r: 8, group: 'source' }, { id: 'ls:consent', kind: 'source', label: 'Consent scope', r: 8, group: 'source' }, { id: 'ls:cohort', kind: 'schedule', label: 'Cohort membership', r: 7, group: 'schedule' }, { id: 'ls:freq', kind: 'product', label: 'Cohort Allele Frequencies', r: 9, group: 'product' }, { id: 'ls:assoc', kind: 'product', label: 'Association Tables', r: 8, group: 'product' }, { id: 'ls:contrib', kind: 'modeled', label: 'Contribution Ledger', r: 7, group: 'modeled' }, ], links: [ ['ls:hub', 'ls:typing'], ['ls:hub', 'ls:manif'], ['ls:hub', 'ls:consent'], ['ls:manif', 'ls:cohort'], ['ls:consent', 'ls:cohort'], ['ls:cohort', 'ls:freq'], ['ls:typing', 'ls:freq'], ['ls:freq', 'ls:assoc'], ['ls:cohort', 'ls:contrib'], ], }, }; let activeKey = 'workspace'; let NODES = DATASETS[activeKey].nodes; let LINKS = DATASETS[activeKey].links; const COLORS = { org: '#171412', // the organization domain: '#52B26E', // a built domain model (Finance) planned: '#B7C1BA', // a planned domain (stub) source: '#4378E0', // a source file schedule: '#8A8580', // a schedule (ODP) product: '#33302E', // a data product (ADP / statement) modeled: '#724C9E', // computed in Proxa center: '#171412', }; // Node glyphs — the app's own icon set (icons.jsx), so the graph matches the // rest of the POC. Each is a full with its icon's native viewBox. const svgIcon = (vb, body, sw) => '' + body + ''; const ICON = { // org → IconLayers (a composition of domains) org: svgIcon(24, ''), // domain → IconCube (the rail hub icon) domain: svgIcon(20, '', 1.4), // source → IconSheet source: svgIcon(24, ''), // schedule → IconGrid schedule: svgIcon(24, ''), // product → IconArtifact product: svgIcon(24, ''), // modeled → IconChip ( — the Model icon) modeled: svgIcon(24, ''), }; ICON.planned = ICON.domain; // Card footprint per group (width × height in graph units). const CARD = { org: { w: 210, h: 46 }, domain: { w: 150, h: 40 }, planned: { w: 150, h: 40 }, source: { w: 178, h: 34 }, schedule: { w: 190, h: 34 }, product: { w: 172, h: 34 }, modeled: { w: 216, h: 34 }, }; const cardOf = (n) => CARD[n.group] || CARD.product; let nodes = [], links = []; let svgEl = null, rootG = null; let elNodes = null, elLinks = null; let width = 0, height = 0; let raf = null, running = false; let focusId = null, dragging = null; let onActivate = null; // callback(node) when a drillable node is clicked let viewTx = 0, viewTy = 0, viewScale = 1; let targetScale = 1, targetTx = 0, targetTy = 0; const MIN_SCALE = 0.25, MAX_SCALE = 3.5; const LABEL_FADE_START = 0.75, LABEL_FADE_END = 0.45; function buildGraph() { nodes = NODES.map((n) => ({ ...n, r: cardOf(n).w / 2 })); links = LINKS.map(([source, target]) => ({ source, target })); // Resolve string ids to refs and build neighbor sets const byId = Object.fromEntries(nodes.map((n) => [n.id, n])); links = links .filter((l) => byId[l.source] && byId[l.target]) .map((l) => ({ source: byId[l.source], target: byId[l.target] })); nodes.forEach((n) => { n.neighbors = new Set(); }); links.forEach((l) => { l.source.neighbors.add(l.target.id); l.target.neighbors.add(l.source.id); }); // ── Layered (left→right) layout ── // Rank = longest path from a root; each rank is a column, laid out L→R. const rank = {}; nodes.forEach((n) => { rank[n.id] = 0; }); for (let it = 0; it < nodes.length; it++) { let changed = false; links.forEach((l) => { const nr = rank[l.source.id] + 1; if (nr > rank[l.target.id]) { rank[l.target.id] = nr; changed = true; } }); if (!changed) break; } nodes.forEach((n) => { n.rank = rank[n.id]; }); // Group nodes by column and distribute vertically, centered on y = 0. const cols = {}; nodes.forEach((n) => { (cols[n.rank] = cols[n.rank] || []).push(n); }); const COL_GAP = 300, ROW_GAP = 66; Object.keys(cols).forEach((rk) => { const col = cols[rk]; const x = (+rk) * COL_GAP; const totalH = (col.length - 1) * ROW_GAP; col.forEach((n, i) => { n.x = x; n.y = i * ROW_GAP - totalH / 2; n.vx = 0; n.vy = 0; }); }); } function render() { svgEl.innerHTML = ''; const NS = 'http://www.w3.org/2000/svg'; rootG = document.createElementNS(NS, 'g'); rootG.setAttribute('class', 'root'); svgEl.appendChild(rootG); const gLinks = document.createElementNS(NS, 'g'); gLinks.setAttribute('class', 'links'); const gNodes = document.createElementNS(NS, 'g'); gNodes.setAttribute('class', 'nodes'); rootG.appendChild(gLinks); rootG.appendChild(gNodes); elLinks = links.map((l) => { const path = document.createElementNS(NS, 'path'); path.setAttribute('class', 'cg-link'); path.__data = l; gLinks.appendChild(path); return path; }); const XHTML = 'http://www.w3.org/1999/xhtml'; elNodes = nodes.map((n) => { const g = document.createElementNS(NS, 'g'); g.setAttribute('class', 'cg-node cg-node--' + n.group + (n.drillTo ? ' cg-node--drill' : '')); g.__data = n; const spec = cardOf(n); const fo = document.createElementNS(NS, 'foreignObject'); fo.setAttribute('x', -spec.w / 2); fo.setAttribute('y', -spec.h / 2); fo.setAttribute('width', spec.w); fo.setAttribute('height', spec.h); fo.setAttribute('overflow', 'visible'); const card = document.createElementNS(XHTML, 'div'); card.setAttribute('class', 'cg-card cg-card--' + n.group); card.style.setProperty('--accent', COLORS[n.group] || '#888'); card.innerHTML = '' + (ICON[n.group] || ICON.product) + '' + '' + n.label + ''; fo.appendChild(card); g.appendChild(fo); wireNode(g, n); gNodes.appendChild(g); return g; }); paint(); } function paint() { elLinks.forEach((path) => { const l = path.__data; // Horizontal S-curve so the left→right flow reads cleanly. const x1 = l.source.x, y1 = l.source.y, x2 = l.target.x, y2 = l.target.y; const mx = (x1 + x2) / 2; path.setAttribute('d', `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`); if (focusId) { const linked = (l.source.id === focusId || l.target.id === focusId); path.classList.toggle('linked', linked); } else { path.classList.remove('linked'); } }); elNodes.forEach((g) => { const n = g.__data; g.setAttribute('transform', `translate(${n.x},${n.y})`); if (focusId) { const isFocus = n.id === focusId; const isLinked = !isFocus && nodes.find((x) => x.id === focusId)?.neighbors.has(n.id); g.classList.toggle('focus', isFocus); g.classList.toggle('linked', !!isLinked); } else { g.classList.remove('focus'); g.classList.remove('linked'); } }); } // Static layout: the loop only eases the view transform (zoom/pan), then stops. function tick() { viewScale += (targetScale - viewScale) * 0.18; viewTx += (targetTx - viewTx) * 0.18; viewTy += (targetTy - viewTy) * 0.18; applyViewTransform(); const easing = Math.abs(targetScale - viewScale) > 0.001 || Math.abs(targetTx - viewTx) > 0.2 || Math.abs(targetTy - viewTy) > 0.2; if (easing) { raf = requestAnimationFrame(tick); } else { viewScale = targetScale; viewTx = targetTx; viewTy = targetTy; applyViewTransform(); running = false; raf = null; } } // Positions come from the layered layout in buildGraph(); no physics to run. function simStep() {} function applyViewTransform() { if (!rootG) return; rootG.setAttribute('transform', `translate(${viewTx},${viewTy}) scale(${viewScale})`); let op = 1; if (viewScale <= LABEL_FADE_END) op = 0; else if (viewScale < LABEL_FADE_START) { op = (viewScale - LABEL_FADE_END) / (LABEL_FADE_START - LABEL_FADE_END); } svgEl.style.setProperty('--label-opacity', op.toFixed(3)); const MAX_LABEL_PX = 22, BASE_LABEL_PX = 13; const visual = BASE_LABEL_PX * viewScale; const fontSize = visual > MAX_LABEL_PX ? (MAX_LABEL_PX / viewScale) : BASE_LABEL_PX; svgEl.style.setProperty('--label-font', fontSize.toFixed(2) + 'px'); } function kick() { if (!running) { running = true; raf = requestAnimationFrame(tick); } } function svgPoint(cx, cy) { const rect = svgEl.getBoundingClientRect(); const sx = cx - rect.left; const sy = cy - rect.top; return { x: (sx - viewTx) / viewScale, y: (sy - viewTy) / viewScale }; } // Static layout: nodes don't drag. Hover highlights lineage; a clean click on // a drillable node opens its sub-graph. Panning is handled on the background. function wireNode(g, n) { g.addEventListener('mouseenter', () => { focusId = n.id; svgEl.classList.add('has-focus'); paint(); }); g.addEventListener('mouseleave', () => { focusId = null; svgEl.classList.remove('has-focus'); paint(); }); // Nodes don't move, and panning starts on the background, so a plain click // on a drillable node is unambiguous. if (n.drillTo) { g.style.cursor = 'pointer'; g.addEventListener('click', () => { if (onActivate) onActivate(n); }); } } function fitToViewport(padding) { if (!nodes.length) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach((n) => { const r = n.r + 60; if (n.x - r < minX) minX = n.x - r; if (n.x + r > maxX) maxX = n.x + r; if (n.y - r < minY) minY = n.y - r; if (n.y + r > maxY) maxY = n.y + r; }); const pad = padding != null ? padding : 60; const w = Math.max(1, maxX - minX); const h = Math.max(1, maxY - minY); const sx = (width - pad * 2) / w; const sy = (height - pad * 2) / h; const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, Math.min(sx, sy))); targetScale = s; targetTx = (width - (minX + maxX) * s) / 2; targetTy = (height - (minY + maxY) * s) / 2; } function zoomAt(clientX, clientY, factor) { const rect = svgEl.getBoundingClientRect(); const sx = clientX - rect.left; const sy = clientY - rect.top; const next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, targetScale * factor)); const actualFactor = next / targetScale; targetTx = sx - (sx - targetTx) * actualFactor; targetTy = sy - (sy - targetTy) * actualFactor; targetScale = next; kick(); } function resize() { const rect = svgEl.getBoundingClientRect(); width = rect.width; height = rect.height; svgEl.setAttribute('viewBox', `0 0 ${width} ${height}`); } // Legend group highlight function setGroupFocus(group) { if (!elNodes) return; if (!group) { svgEl.classList.remove('has-group-focus'); elNodes.forEach((g) => g.classList.remove('group-on', 'group-off')); elLinks.forEach((line) => line.classList.remove('group-off')); return; } svgEl.classList.add('has-group-focus'); elNodes.forEach((g) => { const n = g.__data; const on = (n.group === group) || (n.id === '__col'); g.classList.toggle('group-on', on); g.classList.toggle('group-off', !on); }); elLinks.forEach((line) => { const l = line.__data; const on = l.source.group === group || l.target.group === group || (l.source.id === '__col' && l.target.group === group) || (l.target.id === '__col' && l.source.group === group); line.classList.toggle('group-off', !on); }); } let panning = null; function mount(svg) { // Re-wire on each mount because React creates a fresh SVG node svgEl = svg; // Reset state nodes = []; links = []; rootG = null; elNodes = null; elLinks = null; focusId = null; dragging = null; panning = null; if (raf) { cancelAnimationFrame(raf); raf = null; } running = false; svgEl.addEventListener('wheel', (e) => { e.preventDefault(); const factor = Math.exp(-e.deltaY * 0.0015); zoomAt(e.clientX, e.clientY, factor); }, { passive: false }); svgEl.addEventListener('pointerdown', (e) => { if (e.target.closest('.cg-node')) return; panning = { x: e.clientX, y: e.clientY, tx: targetTx, ty: targetTy, id: e.pointerId }; try { svgEl.setPointerCapture(e.pointerId); } catch (_) { } }); svgEl.addEventListener('pointermove', (e) => { if (!panning) return; const dx = e.clientX - panning.x, dy = e.clientY - panning.y; targetTx = panning.tx + dx; targetTy = panning.ty + dy; viewTx = targetTx; viewTy = targetTy; applyViewTransform(); paint(); }); const endPan = (e) => { if (!panning) return; try { svgEl.releasePointerCapture(panning.id); } catch (_) { } panning = null; }; svgEl.addEventListener('pointerup', endPan); svgEl.addEventListener('pointercancel', endPan); svgEl.addEventListener('pointerleave', endPan); } function start() { const run = () => { resize(); // If layout hasn't settled yet, retry next frame if (width < 10 || height < 10) { requestAnimationFrame(run); return; } buildGraph(); render(); viewScale = 1; viewTx = 0; viewTy = 0; targetScale = 1; targetTx = 0; targetTy = 0; applyViewTransform(); running = false; for (let i = 0; i < 80; i++) simStep(); fitToViewport(); viewScale = targetScale; viewTx = targetTx; viewTy = targetTy; applyViewTransform(); paint(); kick(); }; requestAnimationFrame(run); } function stop() { if (raf) cancelAnimationFrame(raf); raf = null; running = false; dragging = null; focusId = null; } // Swap to another dataset (drill-down / back) and re-lay-out in place. function load(key) { if (!DATASETS[key] || !svgEl) return; activeKey = key; NODES = DATASETS[key].nodes; LINKS = DATASETS[key].links; focusId = null; dragging = null; resize(); if (width < 10 || height < 10) { requestAnimationFrame(() => load(key)); return; } buildGraph(); render(); viewScale = 1; viewTx = 0; viewTy = 0; targetScale = 1; targetTx = 0; targetTy = 0; applyViewTransform(); running = false; for (let i = 0; i < 80; i++) simStep(); fitToViewport(); viewScale = targetScale; viewTx = targetTx; viewTy = targetTy; applyViewTransform(); paint(); kick(); } function onWindowResize() { if (!svgEl) return; resize(); fitToViewport(); kick(); } return { mount, start, stop, load, setGroupFocus, onWindowResize, COLORS, setActivate: (fn) => { onActivate = fn; }, }; })(); /* ============ React wrapper ============ */ function ContextGraphPage({ onBack, domain }) { const svgRef = React.useRef(null); const [activeGroup, setActiveGroup] = React.useState(null); // The graph follows the workspace: each domain has its own Level-1 dataset // and its own hub sub-graphs. const pack = window.proxaDomain ? window.proxaDomain(domain) : null; const rootKey = domain === 'lab' ? 'labworkspace' : 'workspace'; const orgLabel = (pack && pack.built && pack.orgName) || 'Cascade Ridge Foods'; const [level, setLevel] = React.useState(rootKey); const CG_HUB_LABELS = { company: 'Finance', customer: 'Customer', tenney: 'Tenney North Park', labtyping: 'Typing', labstudies: 'Studies & Cohorts', }; React.useEffect(() => { if (!svgRef.current) return; ContextGraph.mount(svgRef.current); // ContextGraph is a module singleton and remembers the last dataset it // loaded, so always load this workspace's root — otherwise switching // workspaces shows the previous one's model under the new header. ContextGraph.load(rootKey); // Drill into a hub's sub-graph when its card is clicked. ContextGraph.setActivate((n) => { setLevel(n.drillTo); ContextGraph.load(n.drillTo); }); ContextGraph.start(); const onResize = () => ContextGraph.onWindowResize(); window.addEventListener('resize', onResize); const onKey = (e) => { if (e.key === 'Escape') onBack && onBack(); }; document.addEventListener('keydown', onKey); return () => { window.removeEventListener('resize', onResize); document.removeEventListener('keydown', onKey); ContextGraph.setActivate(null); ContextGraph.stop(); }; }, []); const atL1 = level === rootKey; const goWorkspace = () => { setLevel(rootKey); ContextGraph.load(rootKey); setActiveGroup(null); ContextGraph.setGroupFocus(null); }; const legend = [ { group: 'domain', label: 'Domain model', color: ContextGraph.COLORS.domain }, { group: 'planned', label: 'Planned', color: ContextGraph.COLORS.planned }, { group: 'source', label: 'Source file', color: ContextGraph.COLORS.source }, { group: 'schedule', label: 'Schedule (ODP)', color: ContextGraph.COLORS.schedule }, { group: 'product', label: 'Data product', color: ContextGraph.COLORS.product }, { group: 'modeled', label: 'Modeled in Proxa', color: ContextGraph.COLORS.modeled }, ]; return (
{atL1 ? `${orgLabel} · Encoded model` : } {!atL1 && / } {!atL1 && {CG_HUB_LABELS[level] || 'Hub'}}
{atL1 ? 'Workspace' : (CG_HUB_LABELS[level] || 'Hub')}
{!atL1 && (
{legend.map((l) => ( { setActiveGroup(l.group); ContextGraph.setGroupFocus(l.group); }} onMouseLeave={() => { setActiveGroup(null); ContextGraph.setGroupFocus(null); }}> {l.label} ))}
)}
{atL1 ? 'Click a hub to open its graph · Scroll to zoom · Drag to pan' : 'Scroll to zoom · Drag to pan · Hover to trace lineage'}
{!atL1 && (
{legend.map((l) => ( {l.label} ))}
)}
); } Object.assign(window, { ContextGraphPage });