// ML Systems Taxonomy, presented the way established taxonomies are (MITRE // ATT&CK/ATLAS, SFIA, ESCO): a concept-map landing view, the whole taxonomy as // one matrix (#view=matrix), and an entry page per area, subject, and concept // with a sidebar tree, a facts box, and tables. Every view has a stable hash: // # / #view=map concept map #area= area page // #subject= subject page #concept=[&to=] concept page // #view=matrix matrix #overlay=role:|path:|vol: // #view=glossary[&term=] #bok-how-to-use / #bok-release // #view=guides[&role=|&symptom=|&path=|§ion=] roles, symptoms, learning paths // Older hashes (#row=, #view=concept, #view=graph…, #view=index) still resolve. (() => { 'use strict'; const $ = (id) => document.getElementById(id); const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const push = (map, key, value) => (map.get(key) || map.set(key, []).get(key)).push(value); const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1); const VOLUME = { vol1: 'Vol I', vol2: 'Vol II', vol3: 'Vol III', vol4: 'Vol IV' }; const BAND_ORDER = ['lifecycle', 'stack', 'qualities', 'foundations', 'system-types']; // Short pill labels for context facets from data.framework.dimensions.contexts. const LENS_SHORT_LABELS = { generative: 'Generative', agentic: 'Agentic', physical: 'Physical', edge: 'Edge', distributed: 'Distributed', recommendation: 'Recommendation', multimodal: 'Multimodal', 'cloud-datacenter': 'Cloud / DC', 'on-device': 'On-device', 'safety-critical': 'Safety-critical', regulated: 'Regulated', 'multi-tenant': 'Multi-tenant', scientific: 'Scientific', }; const MAP_LIMIT = 6; let data, groups, areas, rows, concepts, homeOf, areaOf, outgoing, incoming, secondaryOf, linksOf, gapsOf, relations, neighbors, lenses; const state = { view: 'landing', area: null, subject: null, concept: null, to: null, term: null, guide: null, lens: null, overlay: null, pathStep: 0, showConcepts: false, couplingMode: 'wheel', couplingFamily: 'all', couplingSel: null, activeRole: null, activeAreaRole: null, activeSymptom: null, activeDiagNode: null, vizFull: null, }; const ICON_EXPAND = 'M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4'; const ICON_EXIT = 'M6 2v4H2M14 6h-4V2M10 14v-4h4M2 10h4v4'; const expandBtn = (figId) => ``; const visited = []; async function load() { try { const response = await fetch('framework-data.json', { cache: 'no-cache' }); if (!response.ok) throw new Error(response.statusText); data = await response.json(); } catch (error) { $('bok-matrix').innerHTML = '

The taxonomy could not be loaded. Read the written taxonomy instead.

'; return; } index(); renderMeta(); renderLenses(); renderOverlays(); renderMatrix(); renderRelease(); renderGlossary(); renderPrintOutline(); bind(); restore(); } function index() { const fw = data.framework; groups = fw.groups?.length ? fw.groups : [{ id: 'all', label: 'Areas', question: '' }]; areas = fw.areas.map((a) => ({ ...a, group: a.group || 'all' })); rows = new Map(); areaOf = new Map(); homeOf = new Map(); secondaryOf = new Map(); for (const area of areas) { for (const row of area.rows) { rows.set(row.id, row); areaOf.set(row.id, area); for (const id of row.concepts) homeOf.set(id, row); for (const id of row.secondary_concepts || []) push(secondaryOf, id, row); } } concepts = new Map(data.concepts.map((c) => [c.id, c])); relations = new Map((data.relations || []).map((r) => [r.id, r])); outgoing = new Map(); incoming = new Map(); neighbors = new Map(); for (const rel of data.relationships) { push(outgoing, rel.source, rel); push(incoming, rel.target, rel); push(neighbors, rel.source, rel.target); push(neighbors, rel.target, rel.source); } linksOf = new Map(); for (const link of data.book_links) push(linksOf, link.concept, link); gapsOf = new Map(); for (const gap of fw.gaps || []) if (gap.subject_row) push(gapsOf, gap.subject_row, gap); const used = new Set(areas.flatMap((a) => a.rows.flatMap((r) => r.contexts || []))); const rawContexts = fw.dimensions?.contexts || []; const primaryOrder = Object.keys(LENS_SHORT_LABELS); lenses = rawContexts .filter((c) => used.has(c.id)) .sort((a, b) => { const ia = primaryOrder.indexOf(a.id); const ib = primaryOrder.indexOf(b.id); return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib); }) .map((c) => [c.id, LENS_SHORT_LABELS[c.id] || c.label.replace(/\s+(?:systems|services|settings)$/i, '')]); } const groupOf = (area) => groups.find((g) => g.id === area.group); const conceptCount = (area) => area.rows.reduce((n, r) => n + r.concepts.length, 0); const code = (value) => (value ? `${esc(value)}` : ''); const subjectHref = (row) => `#subject=${encodeURIComponent(row.id)}`; const conceptHref = (id) => `#concept=${encodeURIComponent(id)}`; const conceptLink = (id) => `${esc(concepts.get(id)?.label || id)}`; const subjectLink = (row) => `${code(row.code)}${esc(row.label)}`; // Preserve the reviewed proposition; a relation category can be broader. const verb = (rel) => rel.predicate || relations.get(rel.relation)?.forward || rel.relation || ''; const verbMeasure = document.createElement('canvas').getContext('2d'); const shortVerb = (rel, width = 90) => { const text = verb(rel); verbMeasure.font = '500 10.5px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; if (verbMeasure.measureText(text).width <= width) return text; let shortened = text; while (shortened && verbMeasure.measureText(shortened + '…').width > width) shortened = shortened.slice(0, -1); return shortened.trimEnd() + '…'; }; const crossStepRelationships = (stepIndex) => { const matches = []; for (const rel of data.relationships || []) { const u = homeOf.get(rel.source); const v = homeOf.get(rel.target); if (!u || !v || u.id === v.id) continue; if (!stepIndex.has(u.id) || !stepIndex.has(v.id)) continue; matches.push({ rel, iu: stepIndex.get(u.id), iv: stepIndex.get(v.id) }); } return matches; }; const bandOrder = () => [...BAND_ORDER.filter((id) => groups.some((g) => g.id === id)), ...groups.map((g) => g.id).filter((id) => !BAND_ORDER.includes(id))]; function renderMeta() { const fw = data.framework; $('bok-stats').textContent = `${areas.length} areas · ${rows.size} subjects · ${concepts.size} concepts · ${data.relationships.length} relationships`; $('bok-overview').textContent = `${areas.length} areas · ${rows.size} subjects · ${concepts.size} concepts · Editorial draft for review`; $('bok-version').textContent = fw.version ? `v${fw.version.replace(/-draft$/, '')}` : ''; const gapList = (fw.gaps || []).map((g) => { const row = rows.get(g.subject_row); return `
  • ${esc(g.topic || g.label)}${row ? ` · ${subjectLink(row)}` : ''}
  • `; }).join(''); if (gapList) $('bok-gaps').innerHTML = `

    Known gaps. Topics this version does not yet cover, declared so they are not mistaken for omissions:

      ${gapList}
    `; const rules = (fw.tagging_rules || []).map((r) => `
  • ${esc(r)}
  • `).join(''); const home = (fw.rationale || []).find((r) => r.startsWith('Home rule.')); const cases = (fw.placement_examples || []).filter((x) => concepts.has(x.concept) && rows.has(x.home)) .map((x) => `
  • ${conceptLink(x.concept)} → ${subjectLink(rows.get(x.home))}: ${esc(x.why)}
  • `).join(''); if (rules) $('bok-tagging').innerHTML = `

    How to tag with it. Chapters, quizzes, questions, slides, and labs are tagged with the same concepts and subjects:

      ${rules}
    ` + (home ? `

    Where a concept lives. ${esc(home.replace(/^Home rule\. /, ''))}

    ` : '') + (cases ? `
      ${cases}
    ` : ''); $('bok-method').textContent = `Version ${fw.version}. Definitions, relationships, and downloads are generated from the same maintained vocabulary.`; } // -------------------------------------------------------------------- matrix // One band per group; each area is a column and each subject a cell. function renderLenses() { $('bok-lenses').innerHTML = 'Highlight' + lenses.map(([id, label]) => ``).join(''); } function renderOverlays() { const el = $('bok-overlays'); if (!el) return; const g = data.framework.guides || { roles: [], learning_paths: [] }; const roleOpts = (g.roles || []).map((r) => ``).join(''); const pathOpts = (g.learning_paths || []).map((p) => ``).join(''); const volOpts = Object.entries(VOLUME).map(([k, v]) => ``).join(''); el.innerHTML = `Overlay`; } function overlayCellState(row) { if (!state.overlay) return { hit: true, active: false, badge: '' }; const g = data.framework.guides || { roles: [], learning_paths: [] }; const [kind, id] = state.overlay.split(':'); if (kind === 'role') { const role = (g.roles || []).find((r) => r.id === id); if (!role) return { hit: true, active: false, badge: '' }; if ((role.core || []).includes(row.id)) { return { hit: true, active: false, badge: '' }; } if ((role.working || []).includes(row.id)) { return { hit: true, active: false, badge: '' }; } return { hit: false, active: false, badge: '' }; } if (kind === 'path') { const lp = (g.learning_paths || []).find((p) => p.id === id); if (!lp) return { hit: true, active: false, badge: '' }; const idx = (lp.steps || []).findIndex((s) => s.subject === row.id); if (idx === -1) return { hit: false, active: false, badge: '' }; const isActive = idx === state.pathStep; return { hit: true, active: isActive, badge: `${idx + 1}`, }; } if (kind === 'vol') { const hasVol = (row.book_volumes || []).includes(id); const shortVol = id.replace('vol', 'V'); return { hit: hasVol, active: false, badge: hasVol ? `${esc(shortVol)}` : '', }; } return { hit: true, active: false, badge: '' }; } function renderOverlayBanner() { const banner = $('bok-overlay-banner'); if (!banner) return; const sel = $('bok-overlay-select'); if (sel) { sel.value = state.overlay || ''; sel.classList.toggle('is-active', Boolean(state.overlay)); } if (!state.overlay) { banner.hidden = true; banner.innerHTML = ''; return; } const g = data.framework.guides || { roles: [], learning_paths: [] }; const [kind, id] = state.overlay.split(':'); if (kind === 'role') { const role = (g.roles || []).find((r) => r.id === id); if (!role) { banner.hidden = true; return; } banner.hidden = false; banner.innerHTML = `
    ${esc(role.label)}${esc(role.summary || '')}
    Core (${(role.core || []).length}) Working (${(role.working || []).length}) Open in Guides →
    `; return; } if (kind === 'path') { const lp = (g.learning_paths || []).find((p) => p.id === id); if (!lp || !lp.steps?.length) { banner.hidden = true; return; } const stepIdx = Math.max(0, Math.min(lp.steps.length - 1, state.pathStep || 0)); state.pathStep = stepIdx; const cur = lp.steps[stepIdx]; const curRow = rows.get(cur.subject); const strip = lp.steps.map((st, i) => { const r = rows.get(st.subject); return ``; }).join(''); banner.hidden = false; banner.innerHTML = `
    Learning path: ${esc(lp.label)}${esc(lp.audience || '')}
    ${strip}
    Step ${stepIdx + 1} of ${lp.steps.length}: ${curRow ? subjectLink(curRow) : esc(cur.subject)} · ${esc(cur.why || '')}
    `; return; } if (kind === 'vol') { const label = VOLUME[id] || id; let count = 0; for (const r of rows.values()) if ((r.book_volumes || []).includes(id)) count++; banner.hidden = false; banner.innerHTML = `
    MLSysBook ${esc(label)}${count} of ${rows.size} subjects have linked chapters in ${esc(label)}.
    `; } } function renderMatrix() { renderOverlayBanner(); const bands = bandOrder().map((id) => { const group = groups.find((g) => g.id === id); const members = areas.filter((a) => a.group === id); const columns = members.map((area) => `
    ${code(area.code)}${esc(area.display_label || area.label)}

    ${area.rows.length} subjects

      ${area.rows.map((row) => { const lensHit = !state.lens || (row.contexts || []).includes(state.lens); const ov = overlayCellState(row); const classes = ['bok-cell', !lensHit ? 'is-dim' : '', state.overlay && !ov.hit ? 'is-overlay-dim' : '', ov.active ? 'is-overlay-active' : ''].filter(Boolean).join(' '); const list = state.showConcepts ? `${row.concepts.map((cid) => esc(concepts.get(cid)?.label)).join(' · ')}` : ''; return `
    • ${code(row.code)}${ov.badge}${esc(row.display_label || row.label)}${list}
    • `; }).join('')}
    `).join(''); return `

    ${code(group.code)}${esc(group.label)}${esc(group.question)}

    ${columns}
    `; }).join(''); $('bok-matrix').innerHTML = bands; for (const b of document.querySelectorAll('#bok-lenses button')) b.setAttribute('aria-pressed', String(b.dataset.lens === state.lens)); $('bok-show-concepts').setAttribute('aria-pressed', String(state.showConcepts)); $('bok-show-concepts').textContent = state.showConcepts ? 'Hide concepts' : 'Show concepts'; } function renderRelease() { const fw = data.framework; const citation = `Vijay Janapa Reddi. ML Systems Taxonomy, version ${fw.version}. Machine Learning Systems, 2026. ${data.url}`; $('bok-cite-text').textContent = citation; $('bok-cite-meta').innerHTML = `Version ${esc(fw.version)} · Taxonomy data licensed CC BY 4.0 · Editorial draft for expert review · Suggest a change on GitHub`; $('bok-copy-cite').addEventListener('click', async () => { try { await navigator.clipboard.writeText(citation); $('bok-copy-cite').textContent = 'Copied'; } catch { $('bok-copy-cite').textContent = 'Select the text to copy'; } setTimeout(() => { $('bok-copy-cite').textContent = 'Copy citation'; }, 1800); }); } // The full outline exists only for printing; on screen the matrix and the // entry pages carry the same content. function renderPrintOutline() { $('bok-print-outline').innerHTML = `

    The ML Systems Taxonomy, version ${esc(data.framework.version)}

    ` + bandOrder().map((id) => { const group = groups.find((g) => g.id === id); return `

    ${esc(group.code || '')} ${esc(group.label)}

    ` + areas.filter((a) => a.group === id).map((area) => `

    ${esc(area.code || '')} ${esc(area.label)}

    ${area.rows.map((row) => `
    ${esc(row.code || '')} ${esc(row.label)}
    ${esc(row.definition)}
    ${row.concepts.map((cid) => esc(concepts.get(cid)?.label)).join(' · ')}
    `).join('')}
    `).join(''); }).join(''); } // --------------------------------------------------------------- entry pages // Sidebar tree: groups, areas, and the subjects of the current area. function renderTree(activeArea, activeSubject) { $('bok-tree').innerHTML = `
    Browse the taxonomy` + bandOrder().map((id) => { const group = groups.find((g) => g.id === id); return `

    ${esc(group.label)}

    `; }).join('') + '
    '; } function crumbs(parts) { $('bok-crumbs').innerHTML = [`Matrix`, ...parts].join(''); } const facts = (items) => `
    ${items.filter(([, v]) => v).map(([k, v]) => `

    ${esc(k)}${v}

    `).join('')}
    `; function scopeNote(text) { return esc(text).replace(/\[\[([^\]]+)\]\]/g, (_, id) => { const row = rows.get(id); if (row) return subjectLink(row); const area = areas.find((a) => a.id === id); return area ? `${code(area.code)}${esc(area.label)}` : esc(id); }); } function conceptTable(ids, caption) { if (!ids.length) return ''; return `

    ${esc(caption)}

    ${ids.map((id) => ``).join('')}
    ConceptDefinition
    ${conceptLink(id)}${concepts.get(id)?.scope === 'background' ? ' background' : ''}${esc(concepts.get(id)?.definition)}
    `; } function renderArea() { const area = areas.find((a) => a.id === state.area); const group = groupOf(area); renderTree(area.id, null); crumbs([`${esc(group.label)}`, `${esc(area.code || '')} ${esc(area.label)}`]); $('bok-entry-body').innerHTML = `

    ${code(area.code)}${esc(area.label)}

    ${esc(area.question)}

    ${esc(area.scope)}

    ${facts([['Code', esc(area.code)], ['Group', esc(group.label)], ['Subjects', String(area.rows.length)], ['Concepts', String(conceptCount(area))]])}

    Subjects

    ${area.rows.map((row) => ``).join('')}
    CodeSubjectDefinitionConcepts
    ${esc(row.code || '')}${esc(row.label)}${esc(row.definition)}${row.concepts.length}
    `; } function renderSubject() { const row = rows.get(state.subject); const area = areaOf.get(row.id); const group = groupOf(area); renderTree(area.id, row.id); crumbs([`${esc(area.code || '')} ${esc(area.label)}`, `${esc(row.code || '')}`]); const contexts = (row.contexts || []).map((id) => lenses.find(([l]) => l === id)?.[1]).filter(Boolean).join(', '); const chapters = (row.book_chapters || []).map((c) => `${esc(VOLUME[c.volume] || c.volume)}: ${esc(c.title)}`).join('
    '); const elsewhere = (row.excludes || []).map((t) => `
  • ${scopeNote(t)}
  • `).join(''); const covers = (row.includes || []).map((t) => `
  • ${scopeNote(t)}
  • `).join(''); const gaps = (gapsOf.get(row.id) || []).map((g) => `
  • ${esc(g.topic || g.label)}. ${esc(g.reason.replace(/^Known gap for v[\d.]+\.\s*/, ''))}
  • `).join(''); $('bok-entry-body').innerHTML = `

    ${code(row.code)}${esc(row.label)}

    ${esc(row.definition)}

    ${covers ? `

    Covers

      ${covers}
    ` : ''}
    ${facts([['Code', esc(row.code)], ['Area', `${esc(area.code || '')} ${esc(area.label)}`], ['Group', esc(group.label)], ['Concepts', String(row.concepts.length)], ['Systems', esc(contexts)], ['Taught in', chapters]])}
    ${conceptTable(row.concepts, 'Concepts')} ${conceptTable(row.secondary_concepts || [], 'Related concepts from other subjects')} ${elsewhere ? `

    Not in this subject

      ${elsewhere}
    ` : ''} ${gaps ? `

    Known gaps

      ${gaps}
    ` : ''}`; } // ------------------------------------------------------------- concept map let cy = null; let palette = null; function readPalette() { const style = getComputedStyle($('bok-root')); const v = (name) => style.getPropertyValue(name).trim(); palette = { ink: v('--bok-ink'), muted: v('--bok-muted'), faint: v('--bok-faint'), surface: v('--bok-surface'), bg: v('--bok-page') || v('--bok-surface'), line: v('--bok-line-strong'), edge: v('--bok-edge'), accent: v('--bok-accent') }; } // Concepts are text chips: outlined ink, the centered concept solid, // background concepts dashed. Connectors share one spine per side. function mapStyle() { const c = palette; return [ { selector: 'node', style: { shape: 'round-rectangle', width: 'data(w)', height: 30, 'background-color': c.surface, 'border-width': 1, 'border-color': c.line, label: 'data(label)', color: c.ink, 'font-family': 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', 'font-size': 13, 'font-weight': 500, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': 'data(tw)', } }, { selector: 'node[?background]', style: { 'border-style': 'dashed', color: c.muted } }, { selector: 'node.center', style: { height: 38, 'background-color': c.ink, 'border-color': c.ink, color: c.bg, 'font-size': 15, 'font-weight': 700 } }, { selector: 'node.group-heading', style: { 'border-width': 0, 'background-opacity': 0, color: c.muted, 'font-size': 12, 'font-weight': 700, events: 'no' } }, { selector: 'node.more', style: { 'border-width': 0, 'background-opacity': 0, color: c.faint, 'font-size': 12, events: 'no' } }, { selector: 'edge', style: { width: 1.2, 'line-color': c.edge, 'target-arrow-color': c.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.7, 'curve-style': 'taxi', 'taxi-direction': 'horizontal', 'taxi-turn': '50%' } }, // Desktop: each line carries its verb above its own segment, next to the // outer concept, so every link reads left to right as a sentence. { selector: 'edge.into, edge.from', style: { 'font-size': 10.5, 'font-weight': 500, color: c.muted, 'font-family': 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' } }, { selector: 'edge.into', style: { 'taxi-turn': (e) => e.data('turn'), 'source-label': 'data(label)', 'source-text-offset': (e) => e.data('offset'), 'source-text-margin-y': -8 } }, { selector: 'edge.from', style: { 'taxi-turn': (e) => e.data('turn'), 'target-label': 'data(label)', 'target-text-offset': (e) => e.data('offset'), 'target-text-margin-y': -8 } }, { selector: 'node.hover', style: { 'border-color': c.accent, 'border-width': 2 } }, { selector: 'edge.hover', style: { 'line-color': c.accent, 'target-arrow-color': c.accent, width: 2 } }, ]; } function ensureMap(container) { if (cy && cy.container() === container) return cy; if (typeof window.cytoscape !== 'function') return null; if (cy) cy.destroy(); readPalette(); cy = window.cytoscape({ container, style: mapStyle(), userZoomingEnabled: false, userPanningEnabled: false, boxSelectionEnabled: false, autoungrabify: true, autounselectify: true }); cy.on('tap', 'node', (e) => { if (!e.target.hasClass('center') && concepts.has(e.target.id())) location.hash = conceptHref(e.target.id()); }); cy.on('mouseover', 'node', (e) => { if (!concepts.has(e.target.id())) return; e.target.addClass('hover'); e.target.connectedEdges().addClass('hover'); container.style.cursor = e.target.hasClass('center') ? '' : 'pointer'; }); cy.on('mouseout', 'node', () => { cy.elements().removeClass('hover'); container.style.cursor = ''; }); return cy; } const chipWidth = (label, max = 220) => Math.min(max, Math.round(label.length * 7.1 + 26)); // Incoming links on the left, outgoing links on the right, grouped by relation; at most // MAP_LIMIT per group, with every relationship in the table below. Text // stays at full size: the canvas grows to fit instead of zooming out. const COLUMN = 360; const SPINE = 150; function drawMap(id, container) { if (!ensureMap(container)) return; // Narrow screens get the relationships table instead of a drawing: a // map does not read at phone width, and the table carries the same links. const box = container.closest('.bok-mapbox'); box.hidden = false; if (container.clientWidth < 640) { box.hidden = true; return; } const isActionableKind = (relId) => { const k = relations.get(relId)?.kind; return k === 'constraint' || k === 'failure' || k === 'mechanism'; }; const blocksFor = (list, direction) => { const byRelation = new Map(); for (const rel of list) push(byRelation, rel.relation || rel.predicate, rel); return [...byRelation.entries()].sort((a, b) => (isActionableKind(b[0]) - isActionableKind(a[0])) || b[1].length - a[1].length) .map(([relation, rels]) => ({ title: relation, rels, direction })); }; const els = []; const seen = new Set([id]); // Leave at least 24px between the chip and the spine: Cytoscape ignores a // taxi turn closer than its 10px minimum and reroutes the edges through // the neighbors, stacking their verb labels. Long labels wrap instead. const centerWidth = Math.min(2 * SPINE - 48, Math.round(concepts.get(id).label.length * 8.8 + 40)); const centerHalf = centerWidth / 2; const ys = []; const place = (blocks, x, top) => { let y = top; for (const block of blocks) { const relation = relations.get(block.title); const heading = block.direction === 'in' ? relation?.inverse : relation?.forward; if (heading) { els.push({ group: 'nodes', classes: 'group-heading', position: { x, y }, data: { id: `heading-${block.direction}-${block.title}`, label: cap(heading), w: 200, tw: 200 } }); y += 32; } for (const rel of block.rels.slice(0, MAP_LIMIT)) { const other = block.direction === 'in' ? rel.source : rel.target; if (seen.has(other)) continue; seen.add(other); const c = concepts.get(other); const w = chipWidth(c.label, 200); els.push({ group: 'nodes', position: { x, y }, data: { id: other, label: c.label, w, tw: 180, background: c.scope === 'background' } }); ys.push(y); const into = rel.target === id; // The outer segment runs from the concept to the spine; the verb sits on it. const segment = COLUMN - w / 2 - SPINE; els.push({ group: 'edges', classes: into ? 'into' : 'from', data: { id: rel.id, source: rel.source, target: rel.target, label: shortVerb(rel, segment - 24), offset: segment / 2, turn: into ? segment : SPINE - centerHalf } }); y += 44; } if (block.rels.length > MAP_LIMIT) { els.push({ group: 'nodes', classes: 'more', position: { x, y: y - 8 }, data: { id: `m-${block.direction}-${block.title}`, label: `+ ${block.rels.length - MAP_LIMIT} more in the table`, w: 160, tw: 160 } }); y += 26; } y += 14; } return y - top; }; const ins = blocksFor(incoming.get(id) || [], 'in'); const outs = blocksFor(outgoing.get(id) || [], 'out'); const center = concepts.get(id); const tall = Math.max(place(ins, -COLUMN, 30), place(outs, COLUMN, 30), 80); // Center on the span of the placed concepts, not on the column height: an // off-center chip runs its edges through a neighbor's row, so they enter it // vertically and their verb labels stack (seen with two-link concepts). let centerY = ys.length ? (Math.min(...ys) + Math.max(...ys)) / 2 : 30 + tall / 2 - 20; // A neighbor a few pixels off the center line would also be entered // vertically; put the center on its row so that edge runs straight. const near = ys.find((y) => y !== centerY && Math.abs(y - centerY) < 20); if (near !== undefined) centerY = near; els.unshift({ group: 'nodes', classes: 'center', position: { x: 0, y: centerY }, data: { id, label: center.label, w: centerWidth, tw: centerWidth - 20 } }); const height = tall + 40; cy.elements().remove(); cy.add(els); cy.layout({ name: 'preset', fit: false }).run(); const zoom = Math.min(1, (container.clientWidth - 24) / cy.elements().boundingBox().w); container.style.height = `${Math.max(160, Math.round(height * zoom))}px`; cy.resize(); cy.zoom(zoom); cy.center(); } // Shortest chains of relationships between two concepts, read as sentences. function paths(from, to, k = 3) { const found = []; const banned = new Set(); for (let attempt = 0; attempt < 12 && found.length < k; attempt++) { const prev = new Map([[from, null]]); const queue = [from]; for (let i = 0; i < queue.length && !prev.has(to); i++) { for (const rel of [...(outgoing.get(queue[i]) || []), ...(incoming.get(queue[i]) || [])]) { if (banned.has(rel.id)) continue; const next = rel.source === queue[i] ? rel.target : rel.source; if (!prev.has(next)) { prev.set(next, { node: queue[i], rel }); queue.push(next); } } } if (!prev.has(to)) break; const steps = []; for (let x = to; prev.get(x); x = prev.get(x).node) steps.unshift({ from: prev.get(x).node, to: x, rel: prev.get(x).rel }); const key = steps.map((s) => s.rel.id).join(','); if (!found.some((p) => p.key === key)) found.push({ key, steps }); // Ban the busiest step so the next search finds a different route. banned.add(steps.reduce((best, s) => ((neighbors.get(s.to)?.length || 0) > (neighbors.get(best.to)?.length || 0) ? s : best), steps[0]).rel.id); } return found.sort((a, b) => a.steps.length - b.steps.length); } function relationshipTable(id) { const groupsIn = []; const groupsOut = []; for (const [direction, list, bucket] of [['in', incoming.get(id) || [], groupsIn], ['out', outgoing.get(id) || [], groupsOut]]) { const byRelation = new Map(); for (const rel of list) push(byRelation, rel.relation || rel.predicate, rel); for (const [relation, rels] of byRelation) bucket.push({ relation, rels, direction, lever: direction === 'in' && ['mechanism', 'failure'].includes(relations.get(relation)?.kind) }); } const ordered = [...groupsIn.filter((g) => g.lever), ...groupsIn.filter((g) => !g.lever), ...groupsOut]; if (!ordered.length) return ''; // Each row is a complete proposition ("A increases B"), so it reads // correctly on its own, on any screen, without headers or arrows. const self = `${esc(concepts.get(id).label)}`; const name = (other) => (other === id ? self : conceptLink(other)); return `

    Relationships

    ${ordered.map((g) => g.rels.map((rel, i) => ``).join('')).join('')}
    RelationshipHolds when
    ${name(rel.source)} ${esc(verb(rel))} ${name(rel.target)}${esc(rel.conditions || '')}
    `; } function renderPathChainSvg(steps) { if (!steps || !steps.length) return ''; const nodes = [steps[0].from, ...steps.map((s) => s.to)]; const n = nodes.length; const boxW = 148; const gapW = 104; const padX = 16; const totalW = padX * 2 + n * boxW + (n - 1) * gapW; const h = 64; const cy = 34; let svg = `
    `; for (let i = 0; i < steps.length; i++) { const s = steps[i]; const x1 = padX + i * (boxW + gapW) + boxW; const x2 = x1 + gapW; const mx = (x1 + x2) / 2; const dirOut = s.rel.source === s.from; const vText = shortVerb(s.rel); const lineX1 = dirOut ? x1 : x1 + 5; const lineX2 = dirOut ? x2 - 5 : x2; svg += ``; const arrowX = dirOut ? x2 - 5 : x1 + 5; const arrowPath = dirOut ? `M${arrowX - 4},${cy - 3.5} L${arrowX},${cy} L${arrowX - 4},${cy + 3.5}` : `M${arrowX + 4},${cy - 3.5} L${arrowX},${cy} L${arrowX + 4},${cy + 3.5}`; svg += ``; svg += `${esc(vText.length > 20 ? vText.slice(0, 19) + '…' : vText)}`; } for (let i = 0; i < n; i++) { const cid = nodes[i]; const c = concepts.get(cid); const r = homeOf.get(cid); const x = padX + i * (boxW + gapW); const isEndpoint = i === 0 || i === n - 1; const label = c?.label || cid; const shortLbl = label.length > 19 ? label.slice(0, 18) + '…' : label; svg += ` ${esc(r?.code || '')} ${esc(shortLbl)} `; } svg += '
    '; return svg; } function relateHtml(id) { let result = ''; if (state.to && state.to !== id) { const found = paths(id, state.to); result = found.length ? found.map((p, i) => `

    ${i ? 'Another route' : 'Shortest chain'} · ${p.steps.length} step${p.steps.length > 1 ? 's' : ''}

    ${i === 0 ? renderPathChainSvg(p.steps) : ''}
      ${p.steps.map((s) => `
    1. ${conceptLink(s.rel.source)} ${esc(verb(s.rel))} ${conceptLink(s.rel.target)}

      ${esc(s.rel.conditions || '')}

    2. `).join('')}
    `).join('') : '

    No chain of relationships links these concepts yet.

    '; } return `

    How does this relate to…

    ${result}
    `; } function renderConcept() { const id = state.concept; const c = concepts.get(id); const row = homeOf.get(id); const area = row && areaOf.get(row.id); if (visited[visited.length - 1] !== id) visited.push(id); renderTree(area?.id, row?.id); crumbs(area ? [`Concept map`, `${esc(area.code || '')} ${esc(area.label)}`, `${esc(row.code || '')} ${esc(row.label)}`, `${esc(c.label)}`] : [`Concept map`, `${esc(c.label)}`]); const also = (secondaryOf.get(id) || []).map((r) => subjectLink(r)).join('
    '); const passages = (linksOf.get(id) || []).map((l) => `${esc(VOLUME[l.volume] || l.volume)}${esc(l.chapter_title)}${esc(l.label)}`).join(''); const chapters = (row?.book_chapters || []).map((ch) => `${esc(VOLUME[ch.volume] || ch.volume)}${esc(ch.title)}Teaches ${esc(row.label)}`).join(''); const links = (outgoing.get(id)?.length || 0) + (incoming.get(id)?.length || 0); const feedback = new URL('https://github.com/harvard-edge/cs249r_book/issues/new'); feedback.search = new URLSearchParams({ template: 'taxonomy_feedback.yml', title: `[Taxonomy] ${c.label}`, target: `${id} (version ${data.framework.version})` }).toString(); $('bok-entry-body').innerHTML = `

    ${esc(c.label)}

    ${c.aliases?.length ? `

    Search terms: ${c.aliases.map(esc).join(', ')}

    ` : ''}

    ${esc(c.definition)}

    Suggest a correction

    ${c.scope === 'background' ? '

    A background concept, from an adjacent discipline that ML systems build on.

    ' : ''}
    ${facts([['Identifier', `${esc(id)}`], ['Subject', row ? subjectLink(row) : ''], ['Area', area ? `${esc(area.code || '')} ${esc(area.label)}` : ''], ['Also part of', also], ['Relationships', String(links)]])}
    Read each group from the selected concept: for example, “is limited by” names its constraints. Incoming relationships are on the left and outgoing relationships on the right. Full statements and conditions appear below. Select a concept to move to it, or open the full field coupling map.
    ${relationshipTable(id)} ${passages || chapters ? `

    In the books

    ${passages || chapters}
    VolumeChapter${passages ? 'Passage' : 'Scope'}
    ` : ''} ${relateHtml(id)} ${visited.length > 1 ? `

    Your path${visited.slice(-8).map((x) => conceptLink(x)).join('')}

    ` : ''}

    Relationships are editorial drafts, each holding under its stated condition. Book links do not assess teaching depth.

    `; wirePicker('bok-relate', 'bok-relate-list', (r) => { location.hash = `${conceptHref(id)}&to=${encodeURIComponent(r.id)}`; }, ['Concept']); document.querySelectorAll('[data-copy-concept]').forEach((button) => button.addEventListener('click', async () => { const value = button.dataset.copyConcept === 'id' ? id : new URL(conceptHref(id), location.href).href; const status = $('bok-action-status'); try { await navigator.clipboard.writeText(value); status.textContent = button.dataset.copyConcept === 'id' ? 'Concept ID copied.' : 'Concept link copied.'; } catch { status.innerHTML = ''; const field = status.querySelector('input'); field.value = value; field.focus(); field.select(); } })); drawMap(id, $('bok-canvas')); } // --------------------------------------------- field coupling visualizations const REL_FAMILIES = [ ['all', 'All relationships'], ['limits', 'Bottlenecks & limits'], ['levers', 'Levers & mitigations'], ['structure', 'Dependencies & structure'], ['eval', 'Verification & trade-offs'], ]; function relInFamily(rel, fam) { if (!fam || fam === 'all') return true; const r = rel.relation; const kind = relations.get(r)?.kind || rel.kind; if (fam === 'limits') return kind === 'constraint' || kind === 'failure'; if (fam === 'levers') return kind === 'mechanism'; if (fam === 'structure') return kind === 'dependency' || kind === 'composition'; if (fam === 'eval') return kind === 'evaluation' || kind === 'tradeoff'; return true; } function filteredRelationships() { return (data.relationships || []).filter((r) => relInFamily(r, state.couplingFamily)); } // Shared classifier so the wheel, DSM, and coupling panel always agree on counts. function couplingScope(sel) { if (!sel) return null; const areaRows = (id) => new Set((areas.find((a) => a.id === id)?.rows || []).map((r) => r.id)); if (sel.kind === 'subject' && rows.has(sel.id)) return { kind: 'subject', from: new Set([sel.id]) }; if (sel.kind === 'area') return { kind: 'area', from: areaRows(sel.id) }; if (sel.kind === 'pair') return { kind: 'pair', from: areaRows(sel.fromArea), to: areaRows(sel.toArea), diagonal: sel.fromArea === sel.toArea }; return null; } // Returns 'out' | 'in' | 'between' (between two subjects of the selection) | 'inside' (within one subject) | null function classifyRel(rel, scope) { if (!scope) return null; const u = homeOf.get(rel.source)?.id; const v = homeOf.get(rel.target)?.id; if (!u || !v) return null; const uIn = scope.from.has(u); const vIn = scope.from.has(v); if (scope.to) { if (!uIn || !scope.to.has(v)) return null; return scope.diagonal ? (u === v ? 'inside' : 'between') : 'out'; } if (uIn && vIn) return u === v ? 'inside' : 'between'; return uIn ? 'out' : vIn ? 'in' : null; } // Uniform cubic B-spline through control points with clamped endpoints. function bsplinePath(pts) { if (pts.length < 2) return ''; if (pts.length === 2) return `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}L${pts[1][0].toFixed(1)},${pts[1][1].toFixed(1)}`; const p = [pts[0], pts[0], pts[0], ...pts.slice(1, -1), pts[pts.length - 1], pts[pts.length - 1], pts[pts.length - 1]]; let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; for (let i = 0; i < p.length - 3; i++) { const p0 = p[i], p1 = p[i + 1], p2 = p[i + 2], p3 = p[i + 3]; const c1x = (2 * p1[0] + p2[0]) / 3; const c1y = (2 * p1[1] + p2[1]) / 3; const c2x = (p1[0] + 2 * p2[0]) / 3; const c2y = (p1[1] + 2 * p2[1]) / 3; const ex = (p1[0] + 4 * p2[0] + p3[0]) / 6; const ey = (p1[1] + 4 * p2[1] + p3[1]) / 6; d += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${ex.toFixed(1)},${ey.toFixed(1)}`; } return d; } function bundleCurve(uPt, uAreaPt, uGrpPt, rootPt, vGrpPt, vAreaPt, vPt, sameArea, sameGroup, beta = 0.85) { let raw; if (sameArea) raw = [uPt, uAreaPt, vPt]; else if (sameGroup) raw = [uPt, uAreaPt, uGrpPt, vAreaPt, vPt]; else raw = [uPt, uAreaPt, uGrpPt, rootPt, vGrpPt, vAreaPt, vPt]; const k = raw.length - 1; const bundled = raw.map((pt, i) => { if (i === 0 || i === k) return pt; const t = i / k; const lx = uPt[0] + t * (vPt[0] - uPt[0]); const ly = uPt[1] + t * (vPt[1] - uPt[1]); return [beta * pt[0] + (1 - beta) * lx, beta * pt[1] + (1 - beta) * ly]; }); return bsplinePath(bundled); } function polar(cx, cy, r, a) { return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; } function svgArc(cx, cy, r, a0, a1, ccw = false) { const [x0, y0] = polar(cx, cy, r, ccw ? a1 : a0); const [x1, y1] = polar(cx, cy, r, ccw ? a0 : a1); const large = Math.abs(a1 - a0) > Math.PI ? 1 : 0; const sweep = ccw ? 0 : 1; return `M${x0.toFixed(1)},${y0.toFixed(1)} A${r},${r} 0 ${large} ${sweep} ${x1.toFixed(1)},${y1.toFixed(1)}`; } function canonicalOrder() { const orderedGroups = bandOrder().map((gid) => groups.find((g) => g.id === gid)).filter(Boolean); const orderedAreas = []; const orderedSubjects = []; for (const g of orderedGroups) { for (const a of areas.filter((ar) => ar.group === g.id)) { orderedAreas.push(a); for (const r of a.rows) orderedSubjects.push(r); } } return { orderedGroups, orderedAreas, orderedSubjects }; } function drawCouplingWheel(stageEl) { const { orderedGroups, orderedAreas, orderedSubjects } = canonicalOrder(); const W = 880, H = 860, cx = 440, cy = 430; const R_node = 204; const R_area = 216; const R_area_lbl = 228; const R_group = 242; const R_group_lbl = 251; const R_subj_lbl = 265; // Assign angles around circle with gaps between areas and groups. const groupGap = 3.0; const areaGap = 0.85; let totalUnits = 0; let prevG = null, prevA = null; for (const s of orderedSubjects) { const a = areaOf.get(s.id); if (prevG !== null && a.group !== prevG) totalUnits += groupGap; else if (prevA !== null && a.id !== prevA) totalUnits += areaGap; totalUnits += 1; prevG = a.group; prevA = a.id; } const subjAngle = new Map(); const areaAngles = new Map(); const groupAngles = new Map(); let cursor = 0; prevG = null; prevA = null; for (const s of orderedSubjects) { const a = areaOf.get(s.id); if (prevG !== null && a.group !== prevG) cursor += groupGap; else if (prevA !== null && a.id !== prevA) cursor += areaGap; const ang = -Math.PI / 2 + ((cursor + 0.5) / totalUnits) * 2 * Math.PI; subjAngle.set(s.id, ang); push(areaAngles, a.id, ang); push(groupAngles, a.group, ang); cursor += 1; prevG = a.group; prevA = a.id; } const subjPt = new Map(); const areaPt = new Map(); const groupPt = new Map(); const rootPt = [cx, cy]; for (const s of orderedSubjects) subjPt.set(s.id, polar(cx, cy, R_node, subjAngle.get(s.id))); for (const a of orderedAreas) { const angs = areaAngles.get(a.id); const mid = (angs[0] + angs[angs.length - 1]) / 2; areaPt.set(a.id, polar(cx, cy, R_node * 0.62, mid)); } for (const g of orderedGroups) { const angs = groupAngles.get(g.id); const mid = (angs[0] + angs[angs.length - 1]) / 2; groupPt.set(g.id, polar(cx, cy, R_node * 0.28, mid)); } // Aggregate cross-subject edges. const rels = filteredRelationships(); const pairMap = new Map(); let crossSubjTotal = 0; for (const rel of rels) { const u = homeOf.get(rel.source); const v = homeOf.get(rel.target); if (!u || !v || u.id === v.id) continue; crossSubjTotal++; const key = `${u.id}__${v.id}`; if (!pairMap.has(key)) pairMap.set(key, { u, v, count: 0, rels: [] }); const entry = pairMap.get(key); entry.count++; entry.rels.push(rel); } // Determine active selection scope via shared classifier. const sel = state.couplingSel; const scope = couplingScope(sel); const activeSubjects = scope ? scope.from : null; const neighborSubjects = new Set(); const restEdges = []; const activeInternalEdges = []; const activeInEdges = []; const activeOutEdges = []; for (const edge of pairMap.values()) { const uId = edge.u.id, vId = edge.v.id; const uArea = areaOf.get(uId), vArea = areaOf.get(vId); const role = scope ? classifyRel(edge.rels[0], scope) : null; // Separate bidirectional curves when selected by using beta = 0.78 for incoming vs 0.85 for outgoing. const beta = role === 'in' ? 0.78 : 0.85; const d = bundleCurve( subjPt.get(uId), areaPt.get(uArea.id), groupPt.get(uArea.group), rootPt, groupPt.get(vArea.group), areaPt.get(vArea.id), subjPt.get(vId), uArea.id === vArea.id, uArea.group === vArea.group, beta ); const w = Math.min(2.6, 0.55 + Math.sqrt(edge.count) * 0.52).toFixed(2); let cls = 'viz-bundle-edge'; let isHigh = false; if (scope) { if (role === 'out') { cls += ' is-out'; isHigh = true; neighborSubjects.add(uId); neighborSubjects.add(vId); } else if (role === 'in') { cls += ' is-in'; isHigh = true; neighborSubjects.add(uId); neighborSubjects.add(vId); } else if (role === 'between') { cls += ' is-internal'; isHigh = true; neighborSubjects.add(uId); neighborSubjects.add(vId); } else { cls += ' is-dim'; } } const pathHtml = ``; if (role === 'out') activeOutEdges.push(pathHtml); else if (role === 'in') activeInEdges.push(pathHtml); else if (role === 'between') activeInternalEdges.push(pathHtml); else restEdges.push(pathHtml); } // Group & area arcs. let defs = ''; let arcsHtml = ''; const halfStep = (Math.PI / totalUnits) * 0.72; for (const g of orderedGroups) { const angs = groupAngles.get(g.id); const a0 = angs[0] - halfStep; const a1 = angs[angs.length - 1] + halfStep; const mid = (a0 + a1) / 2; const bottom = Math.sin(mid) > 0.25; const arcId = `bok-grp-arc-${g.id}`; defs += ``; arcsHtml += ``; arcsHtml += `${esc(g.label)}`; } for (const a of orderedAreas) { const angs = areaAngles.get(a.id); const a0 = angs[0] - halfStep * 0.85; const a1 = angs[angs.length - 1] + halfStep * 0.85; const mid = (a0 + a1) / 2; const [lx, ly] = polar(cx, cy, R_area_lbl, mid); const isAreaSel = sel?.kind === 'area' && sel.id === a.id; arcsHtml += ` ${esc(a.code)} `; } // Subject dots and radial labels. let nodesHtml = ''; for (const s of orderedSubjects) { const ang = subjAngle.get(s.id); const [nx, ny] = subjPt.get(s.id); const [tx0, ty0] = polar(cx, cy, R_node + 3, ang); const [tx1, ty1] = polar(cx, cy, R_area - 2, ang); const [lx, ly] = polar(cx, cy, R_subj_lbl, ang); const deg = (ang * 180) / Math.PI; const leftHalf = Math.cos(ang) < 0; const rot = leftHalf ? deg + 180 : deg; const anchor = leftHalf ? 'end' : 'start'; const nameStr = s.display_label || s.label; const isSelected = activeSubjects?.has(s.id); const isNeighbor = neighborSubjects.has(s.id); const isDim = activeSubjects && !isSelected && !isNeighbor; const cls = ['viz-subj-group', isSelected ? 'is-selected' : '', isNeighbor ? 'is-neighbor' : '', isDim ? 'is-dim' : ''].filter(Boolean).join(' '); const labelBody = leftHalf ? `${esc(nameStr)} ${esc(s.code)}` : `${esc(s.code)} ${esc(nameStr)}`; nodesHtml += ` ${labelBody} `; } const showInternalLegend = sel?.kind === 'area' || (sel?.kind === 'pair' && sel.fromArea === sel.toArea); const legendHtml = ``; stageEl.innerHTML = ` Circular map of relationships across the ${orderedSubjects.length} ML systems subjects Subjects are grouped into ${orderedAreas.length} areas and ${orderedGroups.length} groups around the circle; curves show ${crossSubjTotal} relationships across ${pairMap.size} subject pairs. ${defs} ${restEdges.join('')}${activeInternalEdges.join('')}${activeInEdges.join('')}${activeOutEdges.join('')} ${arcsHtml} ${nodesHtml} ${legendHtml} `; } function drawCouplingDsm(stageEl) { const { orderedGroups, orderedAreas } = canonicalOrder(); const rels = filteredRelationships(); const counts = new Map(); const rowTotals = new Map(); const colTotals = new Map(); for (const a of orderedAreas) { rowTotals.set(a.id, 0); colTotals.set(a.id, 0); } for (const rel of rels) { const u = homeOf.get(rel.source); const v = homeOf.get(rel.target); if (!u || !v) continue; const ua = areaOf.get(u.id), va = areaOf.get(v.id); if (!ua || !va) continue; const k = `${ua.id}__${va.id}`; counts.set(k, (counts.get(k) || 0) + 1); if (ua.id !== va.id) { rowTotals.set(ua.id, (rowTotals.get(ua.id) || 0) + 1); colTotals.set(va.id, (colTotals.get(va.id) || 0) + 1); } } const offDiag = []; for (const [k, c] of counts.entries()) { const [u, v] = k.split('__'); if (u !== v && c > 0) offDiag.push(c); } const maxOff = Math.max(1, ...offDiag); const maxMarginal = Math.max(1, ...rowTotals.values(), ...colTotals.values()); const binOpacities = [0.11, 0.22, 0.36, 0.54, 0.74]; const binFor = (c) => { if (!c) return 0; const ratio = Math.log(c + 1) / Math.log(maxOff + 1); return Math.min(4, Math.floor(ratio * 4.99)); }; const cell = 22; const grpGap = 4; const x0 = 192, y0 = 160; const pos = []; let curOffset = 0; let prevG = null; const groupBounds = []; for (let i = 0; i < orderedAreas.length; i++) { const a = orderedAreas[i]; if (prevG !== null && a.group !== prevG) { groupBounds.push(curOffset + grpGap / 2); curOffset += grpGap; } pos.push(curOffset); curOffset += cell; prevG = a.group; } const gridSpan = curOffset; const W = x0 + gridSpan + 92; const H = y0 + gridSpan + 84; const sel = state.couplingSel; let cellsHtml = ''; for (let r = 0; r < orderedAreas.length; r++) { const ar = orderedAreas[r]; const ry = y0 + pos[r]; for (let c = 0; c < orderedAreas.length; c++) { const ac = orderedAreas[c]; const cx = x0 + pos[c]; const cnt = counts.get(`${ar.id}__${ac.id}`) || 0; const isDiag = r === c; const isSelected = sel?.kind === 'pair' && sel.fromArea === ar.id && sel.toArea === ac.id; const bIdx = !isDiag && cnt > 0 ? binFor(cnt) : -1; const fill = isDiag ? 'url(#bok-dsm-hatch)' : (cnt > 0 ? 'var(--bok-ink)' : 'var(--bok-band)'); const fillOp = isDiag ? '1' : (cnt > 0 ? String(binOpacities[bIdx]) : '0.45'); const showNum = cnt > 0 || isSelected; const textFill = !isDiag && bIdx >= 3 ? 'var(--bok-surface)' : 'var(--bok-ink)'; cellsHtml += ` ${esc(ar.code)} (${esc(ar.display_label || ar.label)}) → ${esc(ac.code)} (${esc(ac.display_label || ac.label)}): ${cnt} relationships ${showNum ? `${cnt}` : ''} `; } } // Group rules. let rulesHtml = ''; for (const b of groupBounds) { rulesHtml += ``; rulesHtml += ``; } // Row and column headers + marginal bars. let headersHtml = ''; for (let i = 0; i < orderedAreas.length; i++) { const a = orderedAreas[i]; const p = pos[i]; const fullLbl = a.display_label || a.label; const isRowSel = (sel?.kind === 'pair' && sel.fromArea === a.id) || (sel?.kind === 'area' && sel.id === a.id); const isColSel = (sel?.kind === 'pair' && sel.toArea === a.id) || (sel?.kind === 'area' && sel.id === a.id); const rTot = rowTotals.get(a.id) || 0; const cTot = colTotals.get(a.id) || 0; const rBarW = Math.round((rTot / maxMarginal) * 38); const cBarH = Math.round((cTot / maxMarginal) * 28); headersHtml += ` ${esc(a.code)} ${esc(fullLbl)} `; headersHtml += ` ${esc(a.code)} ${esc(fullLbl)} `; // Marginal bars: right (outgoing cross-area) and bottom (incoming cross-area) headersHtml += `${esc(a.code)} outgoing to other areas: ${rTot}`; headersHtml += `${rTot}`; headersHtml += `${esc(a.code)} incoming from other areas: ${cTot}`; headersHtml += `${cTot}`; } const selectedRow = orderedAreas.findIndex((a) => a.id === (sel?.fromArea || sel?.id)); const selectedCol = orderedAreas.findIndex((a) => a.id === (sel?.toArea || sel?.id)); const crosshair = [ selectedRow >= 0 ? `` : '', selectedCol >= 0 ? `` : '', ].join(''); stageEl.innerHTML = ` Relationships between the ${orderedAreas.length} areas Rows are source areas and columns are target areas across ${rels.length} relationships. row → col Out In ${rulesHtml} ${cellsHtml} ${crosshair} ${headersHtml} `; } function renderCouplingPanel(panelEl) { const rels = filteredRelationships(); const sel = state.couplingSel; const famLabel = REL_FAMILIES.find(([k]) => k === state.couplingFamily)?.[1] || 'All relationships'; const PREVIEW = 25; const formatProp = (rel) => { const uSubj = homeOf.get(rel.source); const vSubj = homeOf.get(rel.target); return `
  • ${conceptLink(rel.source)} (${esc(uSubj?.code || '')}) ${esc(verb(rel))} ${conceptLink(rel.target)} (${esc(vSubj?.code || '')})${rel.conditions ? `

    ${esc(rel.conditions)}

    ` : ''}
  • `; }; const propList = (list) => !list.length ? '

    None in current filter.

    ' : `
      ${list.slice(0, PREVIEW).map(formatProp).join('')}
    ` + (list.length > PREVIEW ? `
    Show the other ${list.length - PREVIEW} of ${list.length} relationships
      ${list.slice(PREVIEW).map(formatProp).join('')}
    ` : ''); if (!sel) { // Compute exact 3-way decomposition and top cross-area couplings for default overview. const areaPairs = new Map(); const subjPairs = new Set(); let crossAreaTotal = 0, betweenSubjSameArea = 0, sameSubjTotal = 0; for (const rel of rels) { const u = homeOf.get(rel.source), v = homeOf.get(rel.target); if (!u || !v) continue; const ua = areaOf.get(u.id), va = areaOf.get(v.id); if (!ua || !va) continue; if (u.id === v.id) { sameSubjTotal++; } else { subjPairs.add(`${u.id}__${v.id}`); if (ua.id === va.id) betweenSubjSameArea++; else { crossAreaTotal++; const k = `${ua.id}__${va.id}`; areaPairs.set(k, (areaPairs.get(k) || 0) + 1); } } } const crossSubjTotal = crossAreaTotal + betweenSubjSameArea; const intraAreaTotal = betweenSubjSameArea + sameSubjTotal; const modeNote = state.couplingMode === 'dsm' ? `Off-diagonal cells count ${crossAreaTotal} relationships between areas; diagonal cells count ${intraAreaTotal} within the same area.` : `The circle draws the ${crossSubjTotal} relationships between subjects (${subjPairs.size} subject pairs); ${sameSubjTotal} stay within one subject.`; const topPairs = [...areaPairs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); panelEl.innerHTML = `
    ${esc(famLabel)}

    ${rels.length} relationships: ${crossAreaTotal} across areas · ${betweenSubjSameArea} between subjects in one area · ${sameSubjTotal} within one subject

    ${esc(modeNote)}

    Most connected area pairs (select to inspect)

    ${topPairs.map(([k, cnt]) => { const [a1Id, a2Id] = k.split('__'); const a1 = areas.find((a) => a.id === a1Id), a2 = areas.find((a) => a.id === a2Id); return ``; }).join('')}
    `; return; } const scope = couplingScope(sel); if (sel.kind === 'pair') { const a1 = areas.find((a) => a.id === sel.fromArea); const a2 = areas.find((a) => a.id === sel.toArea); if (scope.diagonal) { const betweenProps = []; const insideProps = []; for (const rel of rels) { const role = classifyRel(rel, scope); if (role === 'between') betweenProps.push(rel); else if (role === 'inside') insideProps.push(rel); } const tot = betweenProps.length + insideProps.length; panelEl.innerHTML = `
    ${esc(a1?.code)} → ${esc(a1?.code)} · Within ${esc(a1?.display_label || a1?.label)}

    ${tot} relationships within this area (${betweenProps.length} between subjects · ${insideProps.length} within one subject)

    Between subjects in ${esc(a1?.code)} (${betweenProps.length})

    ${propList(betweenProps)}

    Within one subject in ${esc(a1?.code)} (${insideProps.length})

    ${propList(insideProps)}
    `; return; } const forward = []; const reverse = []; for (const rel of rels) { const u = homeOf.get(rel.source), v = homeOf.get(rel.target); if (!u || !v) continue; const ua = areaOf.get(u.id), va = areaOf.get(v.id); if (ua?.id === a1?.id && va?.id === a2?.id) forward.push(rel); if (ua?.id === a2?.id && va?.id === a1?.id) reverse.push(rel); } panelEl.innerHTML = `
    ${esc(a1?.code)} → ${esc(a2?.code)} · ${esc(a1?.display_label || a1?.label)} → ${esc(a2?.display_label || a2?.label)}

    ${esc(a1?.code)} → ${esc(a2?.code)}: ${forward.length} · ${esc(a2?.code)} → ${esc(a1?.code)}: ${reverse.length}

    ${reverse.length ? `

    ` : ''}

    Relationships (${forward.length})

    ${propList(forward)}
    `; return; } // Subject or Area selection via shared classifier. const isSubj = sel.kind === 'subject'; const targetRow = isSubj ? rows.get(sel.id) : null; const targetArea = isSubj ? areaOf.get(sel.id) : areas.find((a) => a.id === sel.id); const outProps = []; const inProps = []; const betweenProps = []; const insideProps = []; for (const rel of rels) { const role = classifyRel(rel, scope); if (role === 'out') outProps.push(rel); else if (role === 'in') inProps.push(rel); else if (role === 'between') betweenProps.push(rel); else if (role === 'inside') insideProps.push(rel); } const title = isSubj ? `${targetRow?.code} ${targetRow?.label}` : `${targetArea?.code} ${targetArea?.label}`; const openHref = isSubj ? subjectHref(targetRow) : `#area=${encodeURIComponent(targetArea?.id || '')}`; const subSummary = isSubj ? `${outProps.length} outgoing · ${inProps.length} incoming · ${insideProps.length} within ${targetRow?.code || 'subject'}` : `${outProps.length} outgoing · ${inProps.length} incoming · ${betweenProps.length + insideProps.length} within ${targetArea?.code} (${betweenProps.length} between subjects · ${insideProps.length} inside one subject)`; panelEl.innerHTML = `
    ${esc(title)}

    ${esc(subSummary)} · Open page →

    Outgoing (${outProps.length}): acts on other ${isSubj ? 'subjects' : 'areas and subjects'}

    ${propList(outProps)}

    Incoming (${inProps.length}): acted on by other ${isSubj ? 'subjects' : 'areas and subjects'}

    ${propList(inProps)}
    ${!isSubj ? `

    Between subjects in ${esc(targetArea?.code)} (${betweenProps.length})

    ${propList(betweenProps)}
    ` : ''} ${insideProps.length ? `

    Within ${isSubj ? esc(targetRow?.code) : 'one subject'} (${insideProps.length})

    ${propList(insideProps)}
    ` : ''}`; } function refreshCouplingFigure() { const stageEl = $('bok-coupling-stage'); const panelEl = $('bok-coupling-panel'); const capEl = $('bok-coupling-caption'); if (!stageEl || !panelEl) return; const focused = stageEl.contains(document.activeElement) ? document.activeElement : null; const focusAttributes = focused ? [...focused.attributes].filter((a) => a.name.startsWith('data-')) : []; const focusSelector = focusAttributes.map((a) => `[${a.name}="${CSS.escape(a.value)}"]`).join(''); for (const b of document.querySelectorAll('[data-coupling-mode]')) { b.setAttribute('aria-pressed', String(b.dataset.couplingMode === state.couplingMode)); } for (const b of document.querySelectorAll('[data-coupling-fam]')) { b.setAttribute('aria-pressed', String(b.dataset.couplingFam === state.couplingFamily)); } if (state.couplingMode === 'dsm') { drawCouplingDsm(stageEl); if (capEl) capEl.innerHTML = `Relationships between the ${areas.length} areas (row acts on column). Each cell counts relationships from concepts in the row area to concepts in the column area; hatched diagonal cells count relationships within the same area. Bars on the right and bottom show each area's total outgoing and incoming links with other areas. Select any cell or area label to read the relationships on the right. With a cell focused, use arrow keys to move and Enter to select. Counts describe recorded links, not importance or completeness.`; } else { drawCouplingWheel(stageEl); if (capEl) capEl.innerHTML = `Relationships across the ${groups.length} groups, ${areas.length} areas, and ${rows.size} subjects. Thicker curves show more relationships between two subjects. Select a subject or an area on the ring to show what it acts on (crimson) and what acts on it (ink), and to list the relationships on the right.`; } const fromPicker = $('bok-explore-area'); const toPicker = $('bok-explore-target'); if (fromPicker && toPicker) { $('bok-explore-label').textContent = state.couplingMode === 'dsm' ? 'From area' : 'Focus area'; $('bok-explore-target-wrap').hidden = state.couplingMode !== 'dsm'; fromPicker.value = state.couplingSel?.kind === 'pair' ? state.couplingSel.fromArea : state.couplingSel?.kind === 'area' ? state.couplingSel.id : ''; toPicker.value = state.couplingSel?.kind === 'pair' ? state.couplingSel.toArea : ''; toPicker.disabled = !fromPicker.value; } renderCouplingPanel(panelEl); if (focusSelector) stageEl.querySelector(focusSelector)?.focus({ preventScroll: true }); } function renderConceptLanding() { renderTree(null, null); crumbs(['Concept map']); const central = [...concepts.keys()].filter((id) => concepts.get(id).scope !== 'background') .sort((a, b) => (neighbors.get(b)?.length || 0) - (neighbors.get(a)?.length || 0)).slice(0, 15); const lastVisited = visited.length ? visited[visited.length - 1] : null; const famBtns = REL_FAMILIES.map(([k, lbl]) => ``).join(''); $('bok-entry-body').innerHTML = `

    Concept map

    Recorded relationships show what concepts depend on, enable, limit, or mitigate. Some concepts do not yet have reviewed relationships. Use the figure below to see how the ${areas.length} areas and ${rows.size} subjects connect across the taxonomy, or open any concept for its local neighborhood map.

    ${lastVisited && concepts.has(lastVisited) ? `

    Recently viewed: ${conceptLink(lastVisited)} →

    ` : ''}
    View
    Relations
    ${famBtns}
    ${expandBtn('bok-coupling-figure')}
    ${conceptTable(central, 'Most connected concepts')}`; refreshCouplingFigure(); } // ------------------------------------------------------------------ glossary function renderGlossary() { const sorted = [...concepts.values()].sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); const byLetter = new Map(); for (const c of sorted) push(byLetter, /[a-z]/i.test(c.label[0]) ? c.label[0].toUpperCase() : '#', c); const options = areas.map((a) => ``).join(''); $('bok-glossary').innerHTML = `

    Glossary

    Every concept in the taxonomy with its definition and the subject it belongs to.

    ${[...byLetter.entries()].map(([letter, list]) => `${list.map((c) => { const row = homeOf.get(c.id); return ``; }).join('')}`).join('')}
    TermDefinitionSubject
    ${esc(letter)}
    ${conceptLink(c.id)}${c.aliases?.length ? `${c.aliases.map(esc).join(' · ')}` : ''} ${esc(c.definition)}${row ? `${esc(row.code || row.label)}` : ''}
    `; } // ------------------------------------------------ guides & flow diagrams function sankeyRibbon(x0, y0, x1, y1, h0, h1) { const mx = (x0 + x1) / 2; return `M${x0.toFixed(1)},${y0.toFixed(1)} C${mx.toFixed(1)},${y0.toFixed(1)} ${mx.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)} L${x1.toFixed(1)},${(y1 + h1).toFixed(1)} C${mx.toFixed(1)},${(y1 + h1).toFixed(1)} ${mx.toFixed(1)},${(y0 + h0).toFixed(1)} ${x0.toFixed(1)},${(y0 + h0).toFixed(1)} Z`; } function drawRoleSankey() { const stage = $('bok-role-sankey-stage'); const bar = $('bok-role-sankey-status'); if (!stage) return; const g = data.framework.guides || { roles: [] }; const { orderedGroups, orderedAreas } = canonicalOrder(); const areaIndex = new Map(orderedAreas.map((a, i) => [a.id, i])); // Order roles by weighted barycenter across areas (Core = 2, Working = 1). const rolesOrdered = [...(g.roles || [])].map((r) => { let wSum = 0, idxSum = 0; for (const sid of r.core || []) { const a = areaOf.get(sid); if (a && areaIndex.has(a.id)) { wSum += 2; idxSum += 2 * areaIndex.get(a.id); } } for (const sid of r.working || []) { const a = areaOf.get(sid); if (a && areaIndex.has(a.id)) { wSum += 1; idxSum += 1 * areaIndex.get(a.id); } } return { role: r, totalW: wSum || 1, bary: wSum ? idxSum / wSum : 0 }; }).sort((a, b) => a.bary - b.bary); // Compute weights for Role -> Group and Group -> Area. const rgLinks = []; const gaMap = new Map(); const groupW = new Map(orderedGroups.map((gr) => [gr.id, 0])); const areaW = new Map(orderedAreas.map((ar) => [ar.id, 0])); for (const { role } of rolesOrdered) { const byGrp = new Map(); const addSubj = (sid, kind, w) => { const a = areaOf.get(sid); if (!a) return; groupW.set(a.group, (groupW.get(a.group) || 0) + w); areaW.set(a.id, (areaW.get(a.id) || 0) + w); const gk = `${a.group}__${kind}`; byGrp.set(gk, (byGrp.get(gk) || 0) + w); const gak = `${role.id}__${a.group}__${a.id}__${kind}`; gaMap.set(gak, (gaMap.get(gak) || 0) + w); }; for (const sid of role.core || []) addSubj(sid, 'core', 2); for (const sid of role.working || []) addSubj(sid, 'working', 1); for (const gr of orderedGroups) { for (const kind of ['core', 'working']) { const w = byGrp.get(`${gr.id}__${kind}`) || 0; if (w > 0) rgLinks.push({ roleId: role.id, groupId: gr.id, kind, w }); } } } const totalWeight = [...groupW.values()].reduce((a, b) => a + b, 0) || 1; const W = 920, H = 620; const topY = 22, botY = H - 18; const availH = botY - topY; const xRole = 248, xGrp = 476, xArea = 700; const barW = 3; const layoutCol = (items, getW, pad, minH = 5) => { const n = items.length; const totPad = pad * (n - 1); const rawHeights = items.map((it) => Math.max(minH, getW(it) * ((availH - totPad) / totalWeight))); const rawSum = rawHeights.reduce((a, b) => a + b, 0) || 1; const factor = (availH - totPad) / rawSum; let y = topY; const boxes = new Map(); items.forEach((it, idx) => { const h = Math.max(minH, rawHeights[idx] * factor); boxes.set(it.id, { y, h, inY: y, outY: y, item: it }); y += h + pad; }); return { boxes }; }; const roleCol = layoutCol(rolesOrdered.map((x) => ({ id: x.role.id, label: x.role.label, w: x.totalW, role: x.role })), (x) => x.w, 10, 9); const grpCol = layoutCol(orderedGroups.map((gr) => ({ id: gr.id, label: gr.label, code: gr.code, w: groupW.get(gr.id) || 1 })), (x) => x.w, 20, 14); const areaCol = layoutCol(orderedAreas.map((ar) => ({ id: ar.id, label: ar.display_label || ar.label, code: ar.code, w: areaW.get(ar.id) || 1 })), (x) => x.w, 8, 8); const activeRole = state.activeRole; const activeArea = state.activeAreaRole; const areaShare = new Map(); if (activeArea) { for (const { role } of rolesOrdered) { const core = (role.core || []).filter((s) => areaOf.get(s)?.id === activeArea).length; const working = (role.working || []).filter((s) => areaOf.get(s)?.id === activeArea).length; if (core || working) areaShare.set(role.id, { core, working }); } } // Build Role -> Group ribbons. let ribbonsHtml = ''; for (const lnk of rgLinks) { const rBox = roleCol.boxes.get(lnk.roleId); const gBox = grpCol.boxes.get(lnk.groupId); const h0 = (lnk.w / rBox.item.w) * rBox.h; const h1 = (lnk.w / gBox.item.w) * gBox.h; const y0 = rBox.outY, y1 = gBox.inY; const d = sankeyRibbon(xRole + barW, y0, xGrp, y1, h0, h1); rBox.outY += h0; gBox.inY += h1; let cls = `viz-ribbon${lnk.kind === 'working' ? ' is-working' : ''}`; if (activeRole) { cls += lnk.roleId === activeRole ? ' is-active' : ' is-dim'; ribbonsHtml += ``; } else if (activeArea) { ribbonsHtml += ``; const wA = gaMap.get(`${lnk.roleId}__${lnk.groupId}__${activeArea}__${lnk.kind}`) || 0; if (wA > 0) { const dSub = sankeyRibbon(xRole + barW, y0, xGrp, y1, (h0 * wA) / lnk.w, (h1 * wA) / lnk.w); ribbonsHtml += ``; } } else { ribbonsHtml += ``; } } // Build Group -> Area ribbons (stacked by canonical area order, then role order). for (const gr of orderedGroups) { const gBox = grpCol.boxes.get(gr.id); for (const ar of orderedAreas.filter((a) => a.group === gr.id)) { const aBox = areaCol.boxes.get(ar.id); for (const { role } of rolesOrdered) { for (const kind of ['core', 'working']) { const w = gaMap.get(`${role.id}__${gr.id}__${ar.id}__${kind}`) || 0; if (!w) continue; const h0 = (w / gBox.item.w) * gBox.h; const h1 = (w / aBox.item.w) * aBox.h; const d = sankeyRibbon(xGrp + barW, gBox.outY, xArea, aBox.inY, h0, h1); gBox.outY += h0; aBox.inY += h1; let cls = `viz-ribbon${kind === 'working' ? ' is-working' : ''}`; if (activeRole) cls += role.id === activeRole ? ' is-active' : ' is-dim'; else if (activeArea) cls += ar.id === activeArea ? ' is-active' : ' is-dim'; ribbonsHtml += ``; } } } } let nodesHtml = `Roles (${rolesOrdered.length}) Groups (${orderedGroups.length}) Taxonomy Areas (${orderedAreas.length})`; for (const [id, b] of roleCol.boxes) { const isSel = activeRole === id || (activeArea && areaShare.has(id)); const isDim = (activeRole && activeRole !== id) || (activeArea && !areaShare.has(id)); const cy = b.y + b.h / 2; nodesHtml += ` ${esc(b.item.label)} `; } for (const [, b] of grpCol.boxes) { const cy = b.y + b.h / 2; const pillW = b.item.label.length * 7.2 + 14; nodesHtml += ` ${esc(b.item.label)} `; } for (const [id, b] of areaCol.boxes) { const isSel = activeArea === id; const cy = b.y + b.h / 2; nodesHtml += ` ${esc(b.item.code)} ${esc(b.item.label)} `; } stage.innerHTML = ` How ${rolesOrdered.length} ML systems roles draw on the ${orderedGroups.length} groups and ${orderedAreas.length} areas Shows how core and working-knowledge subjects for each role are distributed across the taxonomy. ${ribbonsHtml} ${nodesHtml} `; if (bar) { if (activeRole) { const r = (g.roles || []).find((x) => x.id === activeRole); bar.innerHTML = `Selected role: ${esc(r?.label || activeRole)} (${(r?.core || []).length} core, ${(r?.working || []).length} working subjects) Highlight on Matrix ↗`; } else if (activeArea) { const a = areas.find((x) => x.id === activeArea); const coreRoles = [...areaShare.values()].filter((v) => v.core > 0).length; const workRoles = [...areaShare.values()].filter((v) => v.working > 0).length; const bothRoles = [...areaShare.values()].filter((v) => v.core > 0 && v.working > 0).length; const detail = `${coreRoles} core, ${workRoles} working${bothRoles ? `; ${bothRoles} with both` : ''}`; bar.innerHTML = `Selected area: ${esc(a?.code)} ${esc(a?.label)} · drawn on by ${areaShare.size} of ${rolesOrdered.length} roles (${detail}) `; } else { bar.innerHTML = `Select a role on the left or an area on the right to highlight its connections across the taxonomy.`; } } } function drawSymptomFlow() { const stage = $('bok-symptom-stage'); const detailEl = $('bok-symptom-detail'); if (!stage) return; const g = data.framework.guides || { symptoms: [] }; const symptoms = g.symptoms || []; if (!symptoms.length) return; const symId = state.activeSymptom || symptoms[0].id; state.activeSymptom = symId; const sym = symptoms.find((s) => s.id === symId) || symptoms[0]; const selEl = $('bok-symptom-select'); if (selEl && selEl.value !== sym.id) selEl.value = sym.id; for (const b of document.querySelectorAll('[data-symptom-pick]')) { b.setAttribute('aria-pressed', String(b.dataset.symptomPick === sym.id)); } const kList = [...(sym.checks || [])]; const cList = [...(sym.quantities || []), ...(sym.causes || [])]; // the measured quantity sits with the causes in the middle column const lList = [...(sym.levers || [])]; const kSet = new Set(kList), cSet = new Set(cList), lSet = new Set(lList); const allSet = new Set([...kList, ...cList, ...lList]); const directEdges = (data.relationships || []).filter((r) => allSet.has(r.source) && allSet.has(r.target)); // Order Causes by connectivity, then order Checks and Levers by barycenter against Causes. const cPos = new Map(cList.map((id, i) => [id, i])); const orderByCauses = (list) => [...list].sort((a, b) => { const bary = (id) => { const hits = directEdges.filter((r) => (r.source === id && cSet.has(r.target)) || (r.target === id && cSet.has(r.source))); if (!hits.length) return 99; return hits.reduce((acc, r) => acc + (cPos.get(cSet.has(r.source) ? r.source : r.target) ?? 0), 0) / hits.length; }; return bary(a) - bary(b); }); const orderedK = orderByCauses(kList); const orderedC = cList; const orderedL = orderByCauses(lList); const maxRows = Math.max(orderedK.length, orderedC.length, orderedL.length, 3); const rowStep = 46; const topPad = 46; const H = Math.max(250, topPad + maxRows * rowStep + 22); const W = 960; const colX = { K: 20, C: 366, L: 712 }; const colW = 228; const nodeCoords = new Map(); // Key by `${colKey}:${id}` so a concept listed in two columns renders in both without overwriting. const placeCol = (items, colKey) => { const startY = topPad + 14; items.forEach((id, i) => { nodeCoords.set(`${colKey}:${id}`, { id, x: colX[colKey], y: startY + i * rowStep, col: colKey, idx: i }); }); }; placeCol(orderedK, 'K'); placeCol(orderedC, 'C'); placeCol(orderedL, 'L'); // Resolve each relationship to the best (sourceCol, targetCol) endpoint pair. const resolveEndpoints = (rel) => { const uCols = ['K', 'C', 'L'].filter((c) => nodeCoords.has(`${c}:${rel.source}`)); const vCols = ['K', 'C', 'L'].filter((c) => nodeCoords.has(`${c}:${rel.target}`)); let best = null, bestScore = 99; for (const uc of uCols) { for (const vc of vCols) { // Prefer cross-column through C (score 1), then same-column (score 2), then K<->L (score 3). const score = (uc !== vc && (uc === 'C' || vc === 'C')) ? 1 : (uc === vc ? 2 : 3); if (score < bestScore) { bestScore = score; best = [nodeCoords.get(`${uc}:${rel.source}`), nodeCoords.get(`${vc}:${rel.target}`)]; } } } return best; }; // Identify which nodes in Column C are directly measured by a Check (`measures` relation). const measuredByCheck = new Set(); for (const r of directEdges) { if (r.relation === 'measures' && kSet.has(r.source) && cSet.has(r.target)) { measuredByCheck.add(r.target); } } // Determine active node and its truthful chain (direct edges + 1-hop onward across C to opposite outer column). const activeNode = state.activeDiagNode && allSet.has(state.activeDiagNode) ? state.activeDiagNode : null; const directSet = new Set(); const viaSet = new Set(); const viaMap = new Map(); const activeNeighbors = new Set(); if (activeNode) { activeNeighbors.add(activeNode); const isCauseOnly = cSet.has(activeNode) && !kSet.has(activeNode) && !lSet.has(activeNode); const touches = (r, id) => r.source === id || r.target === id; const other = (r, id) => (r.source === id ? r.target : r.source); const directList = directEdges.filter((r) => touches(r, activeNode)); for (const r of directList) { directSet.add(r); activeNeighbors.add(r.source); activeNeighbors.add(r.target); } if (!isCauseOnly) { const wantLever = kSet.has(activeNode); const oppSet = wantLever ? lSet : kSet; for (const r1 of directList) { const mid = other(r1, activeNode); if (!cSet.has(mid)) continue; const onward = directEdges.filter((r2) => r2 !== r1 && !directSet.has(r2) && touches(r2, mid) && oppSet.has(other(r2, mid))); if (onward.length) { viaMap.set(mid, onward); for (const r2 of onward) { viaSet.add(r2); activeNeighbors.add(r2.source); activeNeighbors.add(r2.target); } } } } } // Compute per-port vertical offsets so multiple curves at the same box side fan out cleanly. const portSlots = new Map(); for (const rel of directEdges) { const pts = resolveEndpoints(rel); if (!pts) continue; const [u, v] = pts; if (u.col === v.col) continue; const uSide = u.x < v.x ? 'R' : 'L'; const vSide = u.x < v.x ? 'L' : 'R'; push(portSlots, `${u.col}:${rel.source}__${uSide}`, { rel, otherY: v.y }); push(portSlots, `${v.col}:${rel.target}__${vSide}`, { rel, otherY: u.y }); } const portOffset = new Map(); for (const [pk, list] of portSlots) { list.sort((a, b) => a.otherY - b.otherY); const step = Math.min(4.2, 24 / Math.max(1, list.length - 1)); list.forEach((item, idx) => { const off = (idx - (list.length - 1) / 2) * step; portOffset.set(`${pk}__${item.rel.source}__${item.rel.target}__${item.rel.relation}`, off); }); } let edgesHtml = ''; let verbLabelsHtml = ''; const placedVerbs = []; const activeVerbGroups = new Map(); for (const rel of directEdges) { const pts = resolveEndpoints(rel); if (!pts) continue; const [u, v] = pts; const isDirect = directSet.has(rel); const isVia = viaSet.has(rel); const isEdgeActive = isDirect || isVia; const isLeverEdge = u.col === 'L' || v.col === 'L' || relInFamily(rel, 'levers'); const sameCol = u.col === v.col; const skipMiddle = !sameCol && u.col !== 'C' && v.col !== 'C'; let d = ''; let vx = 0, vy = 0; if (sameCol) { const sideX = u.col === 'L' ? u.x + colW + 22 : u.x - 22; const anchorX = u.col === 'L' ? u.x + colW : u.x; vx = sideX; vy = (u.y + v.y) / 2; d = `M${anchorX},${u.y} Q${sideX},${vy} ${anchorX},${v.y}`; } else if (skipMiddle) { const leftFirst = u.x < v.x; const x1 = leftFirst ? u.x + colW : u.x; const x2 = leftFirst ? v.x : v.x + colW; const xm = (x1 + x2) / 2; const apexY = 34; const ym = 2 * apexY - 0.5 * (u.y + v.y); vx = xm; vy = apexY; d = `M${x1.toFixed(1)},${u.y.toFixed(1)} Q${xm.toFixed(1)},${ym.toFixed(1)} ${x2.toFixed(1)},${v.y.toFixed(1)}`; } else { const leftFirst = u.x < v.x; const uSide = leftFirst ? 'R' : 'L'; const vSide = leftFirst ? 'L' : 'R'; const uOff = portOffset.get(`${u.col}:${rel.source}__${uSide}__${rel.source}__${rel.target}__${rel.relation}`) || 0; const vOff = portOffset.get(`${v.col}:${rel.target}__${vSide}__${rel.source}__${rel.target}__${rel.relation}`) || 0; const x1 = leftFirst ? u.x + colW : u.x; const y1 = u.y + uOff; const x2 = leftFirst ? v.x : v.x + colW; const y2 = v.y + vOff; const mx = (x1 + x2) / 2; d = `M${x1.toFixed(1)},${y1.toFixed(1)} C${mx.toFixed(1)},${y1.toFixed(1)} ${mx.toFixed(1)},${y2.toFixed(1)} ${x2.toFixed(1)},${y2.toFixed(1)}`; vx = mx; vy = (y1 + y2) / 2 - 2; } const cls = [ 'viz-diag-edge', isLeverEdge ? 'is-lever' : '', sameCol ? 'is-causal' : '', activeNode ? (isDirect ? 'is-active' : (isVia ? 'is-active is-via' : 'is-dim')) : '', ].filter(Boolean).join(' '); const markerUrl = isEdgeActive ? 'url(#bok-diag-arrow-on)' : 'url(#bok-diag-arrow)'; const vStr = shortVerb(rel); edgesHtml += `${esc(concepts.get(rel.source)?.label)} ${esc(verb(rel))} ${esc(concepts.get(rel.target)?.label)}`; if (isEdgeActive && !sameCol) { const hubId = cSet.has(rel.source) ? rel.source : rel.target; const gk = `${u.col}__${v.col}__${hubId}__${vStr}`; push(activeVerbGroups, gk, { vx, vy, vStr }); } } for (const grp of activeVerbGroups.values()) { grp.sort((a, b) => a.vy - b.vy); const mid = grp[Math.floor(grp.length / 2)]; const labelStr = grp.length > 1 ? `${mid.vStr} (×${grp.length})` : mid.vStr; const boxW = Math.max(52, labelStr.length * 5.3 + 10); let vx = mid.vx, vy = mid.vy; while (placedVerbs.some((p) => Math.abs(p.x - vx) < (p.w + boxW) / 2 + 6 && Math.abs(p.y - vy) < 14)) { vy += 14; } placedVerbs.push({ x: vx, y: vy, w: boxW }); verbLabelsHtml += ` ${esc(labelStr)} `; } let nodesHtml = ` 1. What to check (${orderedK.length}) 2. Symptom metric & likely causes (${orderedC.length}) 3. What to change (${orderedL.length}) `; for (const [, pt] of nodeCoords) { const id = pt.id; const c = concepts.get(id); const r = homeOf.get(id); const lbl = c?.label || id; const shortLbl = lbl.length > 30 ? lbl.slice(0, 29) + '…' : lbl; const subjShort = r?.display_label || r?.label || ''; const metricTag = pt.col === 'C' && measuredByCheck.has(id) ? ' · Metric' : ''; const isAct = activeNode === id; const isNbr = activeNeighbors.has(id); const isDim = activeNode && !isNbr; const cls = ['viz-diag-node', isAct || isNbr ? 'is-active' : '', isDim ? 'is-dim' : ''].filter(Boolean).join(' '); nodesHtml += ` ${esc(r?.code || '')} · ${esc(subjShort)}${esc(metricTag)} ${esc(shortLbl)} `; } stage.innerHTML = ` Diagnostic diagram for ${esc(sym.label)} Connects ${orderedK.length} checks, ${orderedC.length} likely causes, and ${orderedL.length} changes across ${directEdges.length} relationships. ${edgesHtml} ${nodesHtml} ${verbLabelsHtml} `; if (detailEl) { const renderRelItem = (rel) => `
  • ${conceptLink(rel.source)} ${esc(verb(rel))} ${conceptLink(rel.target)}${rel.conditions ? `

    ${esc(rel.conditions)}

    ` : ''}
  • `; if (!activeNode) { detailEl.innerHTML = `
    ${esc(sym.label)}: ${esc(sym.signal)} (${directEdges.length} relationships connecting checks, causes, and changes; select any box above to trace its connections)
      ${directEdges.map(renderRelItem).join('')}
    `; } else { const directArr = [...directSet]; const viaGroupsHtml = [...viaMap.entries()].map(([midId, list]) => `

    Connected through ${esc(concepts.get(midId)?.label || midId)} (${list.length})

      ${list.map(renderRelItem).join('')}
    `).join(''); detailEl.innerHTML = `
    Showing connections for ${conceptLink(activeNode)} (${directArr.length} direct${viaSet.size ? ` · ${viaSet.size} through a connected cause` : ''})

    Direct relationships (${directArr.length})

    ${directArr.length ? `
      ${directArr.map(renderRelItem).join('')}
    ` : '

    No direct links within this symptom view; open the concept page for its full neighborhood.

    '}
    ${viaGroupsHtml}`; } } } function buildPathArcHtml(lp) { const steps = lp.steps || []; const n = steps.length; if (!n) return ''; const stepIndex = new Map(steps.map((st, i) => [st.subject, i])); const pairMap = new Map(); for (const { rel, iu, iv } of crossStepRelationships(stepIndex)) { const key = `${iu}__${iv}`; if (!pairMap.has(key)) pairMap.set(key, { uIdx: iu, vIdx: iv, count: 0, rels: [] }); const entry = pairMap.get(key); entry.count++; entry.rels.push(rel); } const W = 960, H = 196; const padX = 72; const baseY = 118; const stepX = (i) => padX + (n > 1 ? (i / (n - 1)) * (W - padX * 2) : (W - padX * 2) / 2); let arcsHtml = ''; const totalProps = [...pairMap.values()].reduce((s, x) => s + x.count, 0); for (const arc of pairMap.values()) { const a = Math.min(arc.uIdx, arc.vIdx); const b = Math.max(arc.uIdx, arc.vIdx); const isFwd = arc.uIdx < arc.vIdx; const x1 = stepX(arc.uIdx), x2 = stepX(arc.vIdx); const span = b - a; const archBase = 18 + (span / Math.max(1, n - 1)) * 86; const archH = isFwd ? archBase : archBase * 0.82; const xm = (x1 + x2) / 2; const ym = baseY - archH * 1.65; const w = Math.min(2.3, 0.75 + Math.sqrt(arc.count) * 0.45).toFixed(2); arcsHtml += ``; } let stationsHtml = ``; for (let i = 0; i < n; i++) { const st = steps[i]; const r = rows.get(st.subject); const x = stepX(i); const stagger = i % 2 === 1; const lblY = stagger ? baseY + 42 : baseY + 22; const shortName = r?.display_label || r?.label || st.subject; stationsHtml += ` ${i + 1} ${esc(r?.code || '')} ${esc(shortName)} `; } const defaultSummary = `${n} subjects in order, linked by ${totalProps} relationships across ${pairMap.size} subject pairs. Select any step to see what it acts on (crimson) and what acts on it (ink) along the path.`; return `
    ${arcsHtml} ${stationsHtml}
    ${defaultSummary}
    `; } // Guides: ways in for a role, a production symptom, or a learning goal. Every // entry links to the subject and concept pages; nothing here is a new claim. function renderGuides() { const g = data.framework.guides || { roles: [], symptoms: [], learning_paths: [] }; if (!state.activeSymptom && g.symptoms?.length) state.activeSymptom = g.symptoms[0].id; const subj = (id) => (rows.has(id) ? `${code(rows.get(id).code)}${esc(rows.get(id).display_label || rows.get(id).label)}` : esc(id)); const list = (ids, f) => (ids && ids.length ? `
      ${ids.map((id) => `
    • ${f(id)}
    • `).join('')}
    ` : '—'); const roles = g.roles.map((r) => `${esc(r.label)}${esc(r.summary)} Highlight on Matrix ↗ ${list(r.core, subj)}${list(r.working, subj)}`).join(''); const symptoms = g.symptoms.map((s) => `${esc(s.label)}${esc(s.signal)} ${s.quantities && s.quantities.length ? `Measured by: ${s.quantities.map((id) => conceptLink(id)).join(', ')}` : ''} ${list(s.causes, conceptLink)}${list(s.checks, conceptLink)}${list(s.levers, conceptLink)}`).join(''); const paths = g.learning_paths.map((lp) => `

    ${esc(lp.label)}

    Highlight path on Matrix ↗

    ${esc(lp.audience)}

    ${buildPathArcHtml(lp)}
      ${lp.steps.map((st, idx) => `
    1. ${subj(st.subject)} ${esc(st.why)}
    2. `).join('')}
    `).join(''); const SYMPTOM_SHORT_LABELS = { 'ttft-spike': 'TTFT under load', 'training-oom': 'Training OOM', 'low-utilization': 'Low GPU utilization', 'loss-spikes': 'Loss spikes / NaNs', 'slow-decode': 'Slow token decode', 'quality-drop': 'Quality drop', 'runaway-agent': 'Runaway agent', }; const quickSymptomTabs = g.symptoms.slice(0, 7).map((s) => [ s.id, SYMPTOM_SHORT_LABELS[s.id] || (s.label.length > 22 ? s.label.slice(0, 20) + '…' : s.label), ]); $('bok-guides').innerHTML = `

    Guides

    Ways into the taxonomy for a role, a production symptom, or a learning goal. Each entry links to the subject and concept pages.

    By role

    ${esc(g.roles_note || '')}

    ${expandBtn('bok-role-sankey-fig')}
    How each role draws on the groups and areas of the taxonomy. Darker ribbons show core subjects for a role, and lighter ribbons show working knowledge. Select a role on the left or an area on the right to highlight its connections.
    ${roles}
    RoleCore subjectsWorking knowledge

    By symptom

    ${esc(g.symptoms_note || '')}

    ${quickSymptomTabs.map(([id, tabLbl]) => ``).join('')}
    ${expandBtn('bok-symptom-flow-fig')}
    From symptom to cause and fix. Solid lines show what a check measures or what a cause limits; dashed lines show what a change reduces or mitigates; dotted lines connect concepts in the same column. Select any concept to trace its connections.
    ${symptoms}
    SymptomLikely causesCheckChange

    Learning paths

    ${esc(g.paths_note || '')}

    ${paths}`; drawRoleSankey(); drawSymptomFlow(); } function filterGlossary() { const q = $('bok-gloss-filter').value.trim().toLowerCase(); const area = $('bok-gloss-area').value; let shown = 0; for (const tr of document.querySelectorAll('.bok-term')) { const hit = (!q || tr.dataset.search.includes(q)) && (!area || tr.dataset.area === area); tr.hidden = !hit; shown += hit; } for (const body of document.querySelectorAll('.bok-gloss tbody')) body.hidden = !body.querySelector('.bok-term:not([hidden])'); for (const link of document.querySelectorAll('.bok-jump a')) link.classList.toggle('is-empty', !document.getElementById(`letter-${link.dataset.letter}`)?.querySelector('.bok-term:not([hidden])')); $('bok-gloss-count').textContent = `${shown} of ${concepts.size} terms`; } // -------------------------------------------------------------------- search function search(query, kinds = ['Concept', 'Subject', 'Area']) { const q = query.trim().toLowerCase(); if (q.length < 2) return []; const scored = []; const score = (text, weight) => { const t = String(text || '').toLowerCase(); if (!t) return 0; if (t === q) return 100 * weight; if (t.startsWith(q)) return 60 * weight; if (t.split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) return 40 * weight; return t.includes(q) ? 20 * weight : 0; }; if (kinds.includes('Concept')) for (const c of concepts.values()) { const s = Math.max(score(c.label, 1.2), ...(c.aliases || []).map((a) => score(a, 1.1)), score(c.definition, 0.3)); if (s) scored.push({ s, kind: 'Concept', id: c.id, label: c.label, where: homeOf.get(c.id)?.code || '', description: c.definition, subject: homeOf.get(c.id)?.label || '', matched: (c.aliases || []).find((a) => score(a, 1.1) > score(c.label, 1.2)) || '' }); } if (kinds.includes('Subject')) for (const r of rows.values()) { const s = Math.max(score(r.label, 1), score(r.code, 1.5), score(r.definition, 0.25)); if (s) scored.push({ s, kind: 'Subject', id: r.id, label: r.label, where: r.code || '', description: r.definition }); } if (kinds.includes('Area')) for (const a of areas) { const s = Math.max(score(a.label, 1.3), score(a.code, 1.5)); if (s) scored.push({ s, kind: 'Area', id: a.id, label: a.label, where: a.code || '', description: a.question || a.scope }); } return scored.sort((a, b) => b.s - a.s || a.label.localeCompare(b.label)).slice(0, 10); } function wirePicker(inputId, listId, onPick, kinds) { const input = $(inputId); const list = $(listId); let found = []; let active = -1; input.setAttribute('role', 'combobox'); input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('aria-controls', listId); input.setAttribute('aria-expanded', 'false'); if (inputId === 'bok-search') input.addEventListener('focus', () => { if (input.getBoundingClientRect().bottom > window.innerHeight * 0.55) { input.closest('.bok-toolbar').scrollIntoView({ block: 'start' }); } }); const close = () => { list.hidden = true; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); }; const show = () => { list.innerHTML = found.map((r, i) => `
  • ${r.kind}${esc(r.label)}${esc(r.where)}${esc(r.description || '')}${r.subject || r.matched ? `${esc(r.subject || '')}${r.matched ? ` · Matched search term: ${esc(r.matched)}` : ''}` : ''}
  • `).join('') || (input.value.trim().length >= 2 ? '
  • No matches. Try a broader term or a subject code.
  • ' : ''); list.hidden = !list.innerHTML; input.setAttribute('aria-expanded', String(!list.hidden)); if (active >= 0 && found[active]) { input.setAttribute('aria-activedescendant', `${listId}-${active}`); document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant'); }; input.addEventListener('input', () => { found = search(input.value, kinds); active = -1; show(); }); input.addEventListener('keydown', (e) => { if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && found.length) { e.preventDefault(); active = active < 0 ? (e.key === 'ArrowDown' ? 0 : found.length - 1) : (active + (e.key === 'ArrowDown' ? 1 : found.length - 1)) % found.length; show(); } else if (e.key === 'Enter' && !list.hidden && found.length) { e.preventDefault(); close(); input.blur(); onPick(found[Math.max(0, active)]); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } else if (e.key === 'Tab') close(); }); input.addEventListener('blur', () => setTimeout(close, 150)); list.addEventListener('mousedown', (e) => { const item = e.target.closest('[data-i]'); if (!item) return; e.preventDefault(); close(); input.blur(); onPick(found[Number(item.dataset.i)]); }); } function choose(r) { if (r.kind === 'Concept') location.hash = conceptHref(r.id); else if (r.kind === 'Subject') location.hash = subjectHref(rows.get(r.id)); else location.hash = `#area=${encodeURIComponent(r.id)}`; } // ---------------------------------------------------------------- navigation function restore() { state.vizFull = null; const rawHash = location.hash.slice(1); if (rawHash === 'bok-how-to-use') { state.view = 'landing'; render(); const howTo = $('bok-how-to-use'); if (howTo) { howTo.open = true; howTo.scrollIntoView({ behavior: 'smooth', block: 'start' }); } return; } if (rawHash === 'bok-release') { if (!['landing', 'home'].includes(state.view)) state.view = 'landing'; render(); $('bok-release')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } const p = new URLSearchParams(rawHash); const view = p.get('view'); const replaced = (data.retired || []).find((r) => r.id === p.get('concept'))?.replaced_by; const concept = concepts.has(p.get('concept')) ? p.get('concept') : replaced || null; const subject = [p.get('subject'), p.get('row')].find((id) => rows.has(id)) || null; const area = areas.some((a) => a.id === p.get('area')) ? p.get('area') : null; Object.assign(state, { area, subject, concept, to: concepts.has(p.get('to')) ? p.get('to') : null, term: concepts.has(p.get('term')) ? p.get('term') : null }); if (p.get('overlay')) { state.overlay = p.get('overlay'); state.pathStep = 0; } else if (location.hash && view !== 'matrix') { state.overlay = null; state.pathStep = 0; } if (p.get('role')) state.activeRole = p.get('role'); if (p.get('symptom')) { state.activeSymptom = p.get('symptom'); state.activeDiagNode = null; } const guide = ['role', 'symptom', 'path', 'section'].map((k) => p.get(k) && `${k}-${p.get(k)}`).find(Boolean) || null; state.guide = guide; if (view === 'guides') state.view = 'guides'; else if (view === 'glossary' || view === 'index') state.view = 'glossary'; else if (concept) state.view = 'concept'; else if (view === 'matrix' || p.get('overlay')) state.view = 'home'; else if (view === 'concept' || view === 'graph' || view === 'map') state.view = 'landing'; else if (subject) state.view = 'subject'; else if (area) state.view = 'area'; else state.view = 'landing'; render(); } function markFull(fig, on) { fig.classList.toggle('is-full', on); if (on) { fig.setAttribute('role', 'dialog'); fig.setAttribute('aria-modal', 'true'); fig.setAttribute('aria-label', fig.querySelector('figcaption strong')?.textContent || 'Expanded figure'); } else { ['role', 'aria-modal', 'aria-label'].forEach((a) => fig.removeAttribute(a)); } const btn = fig.querySelector('[data-viz-expand]'); if (!btn) return; btn.setAttribute('aria-pressed', String(on)); btn.querySelector('path')?.setAttribute('d', on ? ICON_EXIT : ICON_EXPAND); const lbl = btn.querySelector('.bok-viz-expand-label'); if (lbl) lbl.textContent = on ? 'Exit full screen (Esc)' : 'Expand to full screen'; } function syncFull() { const fig = state.vizFull && document.getElementById(state.vizFull); const live = !!fig && !fig.closest('[hidden]'); if (!live) state.vizFull = null; for (const f of document.querySelectorAll('.bok-viz-figure.is-full')) { if (f !== fig || !live) markFull(f, false); } if (live) markFull(fig, true); document.documentElement.classList.toggle('bok-viz-locked', live); for (const el of document.querySelectorAll('.bok > *')) { if (live && !el.contains(fig)) el.setAttribute('inert', ''); else el.removeAttribute('inert'); } } function clearVizSelection(figId) { const any = !figId; if ((any ? state.view === 'landing' : figId === 'bok-coupling-figure') && state.couplingSel) { state.couplingSel = null; refreshCouplingFigure(); return true; } if ((any ? state.view === 'guides' : figId === 'bok-role-sankey-fig') && (state.activeRole || state.activeAreaRole)) { state.activeRole = null; state.activeAreaRole = null; drawRoleSankey(); return true; } if ((any ? state.view === 'guides' : figId === 'bok-symptom-flow-fig') && state.activeDiagNode) { state.activeDiagNode = null; drawSymptomFlow(); return true; } return false; } function render() { const v = state.view; const entry = ['area', 'subject', 'concept', 'landing'].includes(v); $('bok-home').hidden = v !== 'home'; $('bok-entry').hidden = !entry; $('bok-tree').hidden = v === 'landing'; $('bok-crumbs').hidden = v === 'landing'; $('bok-entry').classList.toggle('is-full', v === 'landing'); $('bok-glossary').hidden = v !== 'glossary'; $('bok-guides').hidden = v !== 'guides'; if ($('bok-release')) $('bok-release').hidden = !['landing', 'home'].includes(v); for (const [tab, views] of [['bok-tab-concept', ['concept', 'landing']], ['bok-tab-home', ['home', 'area', 'subject']], ['bok-tab-guides', ['guides']], ['bok-tab-glossary', ['glossary']]]) { if (views.includes(v)) $(tab).setAttribute('aria-current', 'page'); else $(tab).removeAttribute('aria-current'); } if (v === 'home') renderMatrix(); else if (v === 'area') renderArea(); else if (v === 'subject') renderSubject(); else if (v === 'concept') renderConcept(); else if (v === 'landing') renderConceptLanding(); else if (v === 'glossary') filterGlossary(); else if (v === 'guides') { if (!$('bok-guides').innerHTML) renderGuides(); else { drawRoleSankey(); drawSymptomFlow(); } } syncFull(); const target = v === 'glossary' && state.term ? document.getElementById(`term-${state.term}`) : v === 'guides' && state.guide ? document.getElementById(`guide-${state.guide}`) : null; if (target) { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); target.classList.add('is-focus'); setTimeout(() => target.classList.remove('is-focus'), 1600); } else if (!state.vizFull) { const top = document.querySelector('.bok-toolbar'); if (top) { const rect = top.getBoundingClientRect(); if (rect.top < 0 || (location.hash && rect.top > window.innerHeight * 0.55)) { top.scrollIntoView({ block: 'start', behavior: 'smooth' }); } } } } function printOutline() { document.body.classList.add('bok-printing'); window.print(); document.body.classList.remove('bok-printing'); } function highlightPathStation(pathId, stationIdx) { const container = document.querySelector(`[data-path-viz="${pathId}"]`); if (!container) return; const g = data.framework.guides || { learning_paths: [] }; const lp = (g.learning_paths || []).find((p) => p.id === pathId); if (!lp) return; for (const stEl of container.querySelectorAll('.viz-path-station')) { stEl.classList.toggle('is-active', Number(stEl.dataset.stationIdx) === stationIdx); } const stepIndex = new Map((lp.steps || []).map((st, i) => [st.subject, i])); let earlToThis = 0, thisToEarl = 0, thisToLate = 0, lateToThis = 0; const earlSet = new Set(), lateSet = new Set(); const stationProps = []; for (const arcEl of container.querySelectorAll('.viz-path-arc')) { const u = Number(arcEl.dataset.arcU), v = Number(arcEl.dataset.arcV); arcEl.classList.remove('is-in', 'is-out', 'is-dim'); if (stationIdx === null) continue; if (u === stationIdx) { arcEl.classList.add('is-out'); } else if (v === stationIdx) { arcEl.classList.add('is-in'); } else { arcEl.classList.add('is-dim'); } } if (stationIdx !== null) { for (const { rel, iu, iv } of crossStepRelationships(stepIndex)) { if (iu !== stationIdx && iv !== stationIdx) continue; stationProps.push(rel); const otherIdx = iu === stationIdx ? iv : iu; const otherCode = rows.get(lp.steps[otherIdx]?.subject)?.code || `Step ${otherIdx + 1}`; if (otherIdx < stationIdx) { earlSet.add(otherCode); if (iu < stationIdx) earlToThis++; else thisToEarl++; } else { lateSet.add(otherCode); if (iu === stationIdx) thisToLate++; else lateToThis++; } } } const pathSec = container.closest('.bok-guide-path'); if (pathSec) { for (const li of pathSec.querySelectorAll('[data-path-step-item]')) { li.classList.toggle('is-active', stationIdx !== null && li.dataset.pathStepItem === `${pathId}:${stationIdx}`); } } const statusEl = $(`path-status-${pathId}`); if (statusEl) { if (stationIdx === null) { statusEl.innerHTML = `${container.dataset.defaultSummary || ''}`; return; } const st = lp.steps[stationIdx]; const r = rows.get(st?.subject); const earlTot = earlToThis + thisToEarl; const lateTot = thisToLate + lateToThis; const earlStr = earlTot ? `earlier steps (${[...earlSet].join(', ')}): ${earlTot} (${thisToEarl} outgoing · ${earlToThis} incoming)` : 'earlier steps: 0'; const lateStr = lateTot ? `later steps (${[...lateSet].join(', ')}): ${lateTot} (${thisToLate} outgoing · ${lateToThis} incoming)` : 'later steps: 0'; const propItems = stationProps.map((rel) => { const uS = homeOf.get(rel.source), vS = homeOf.get(rel.target); return `
  • ${conceptLink(rel.source)} (${esc(uS?.code || '')}) ${esc(verb(rel))} ${conceptLink(rel.target)} (${esc(vS?.code || '')})
  • `; }).join(''); statusEl.innerHTML = `
    Step ${stationIdx + 1} (${esc(r?.code || '')} ${esc(r?.display_label || r?.label || '')}): ${esc(earlStr)} · ${esc(lateStr)}
    ${stationProps.length ? `
    Show the ${stationProps.length} relationships between Step ${stationIdx + 1} and other steps
      ${propItems}
    ` : ''}
    `; } } function bind() { const siteHeader = document.getElementById('quarto-header'); if (siteHeader) { const updateHeaderOffset = () => $('bok-root').style.setProperty('--bok-header-offset', `${siteHeader.getBoundingClientRect().height + 16}px`); updateHeaderOffset(); new ResizeObserver(updateHeaderOffset).observe(siteHeader); } window.addEventListener('hashchange', restore); window.matchMedia('(min-width: 901px)').addEventListener('change', (event) => { const tree = document.querySelector('.bok-tree-box'); if (tree) tree.open = event.matches; }); document.querySelectorAll('[data-try-lens]').forEach((a) => a.addEventListener('click', (e) => { e.preventDefault(); if (location.hash !== '#view=matrix') { history.pushState(null, '', location.pathname + location.search + '#view=matrix'); restore(); } const b = document.querySelector(`#bok-lenses [data-lens="${a.dataset.tryLens}"]`); if (b && b.getAttribute('aria-pressed') !== 'true') b.click(); $('bok-matrix').scrollIntoView({ behavior: 'smooth', block: 'start' }); })); $('bok-tab-concept').addEventListener('click', (e) => { e.preventDefault(); history.pushState(null, '', location.pathname + location.search); restore(); }); $('bok-lenses').addEventListener('click', (e) => { const button = e.target.closest('button[data-lens]'); if (!button) return; state.lens = state.lens === button.dataset.lens ? null : button.dataset.lens; renderMatrix(); }); $('bok-overlays')?.addEventListener('change', (e) => { if (e.target.id !== 'bok-overlay-select') return; state.overlay = e.target.value || null; state.pathStep = 0; renderMatrix(); }); $('bok-overlay-banner')?.addEventListener('click', (e) => { if (e.target.closest('[data-clear-overlay]')) { state.overlay = null; state.pathStep = 0; if (location.hash.startsWith('#overlay=')) history.pushState(null, '', location.pathname + location.search + '#view=matrix'); renderMatrix(); return; } const stepBtn = e.target.closest('[data-path-step]'); if (stepBtn) { state.pathStep = Number(stepBtn.dataset.pathStep); renderMatrix(); return; } const deltaBtn = e.target.closest('[data-step-delta]'); if (deltaBtn) { state.pathStep = Math.max(0, state.pathStep + Number(deltaBtn.dataset.stepDelta)); renderMatrix(); } }); document.addEventListener('change', (e) => { if (!e.target.matches('#bok-explore-area, #bok-explore-target')) return; const fromArea = $('bok-explore-area').value; const toArea = $('bok-explore-target').value; state.couplingSel = !fromArea ? null : state.couplingMode === 'dsm' && toArea ? { kind: 'pair', fromArea, toArea } : { kind: 'area', id: fromArea }; refreshCouplingFigure(); }); $('bok-show-concepts').addEventListener('click', () => { state.showConcepts = !state.showConcepts; renderMatrix(); }); $('bok-gloss-filter').addEventListener('input', filterGlossary); $('bok-gloss-area').addEventListener('input', filterGlossary); $('bok-print').addEventListener('click', (e) => { e.preventDefault(); printOutline(); }); document.addEventListener('click', (e) => { if (e.target.closest('[data-print-outline]')) { e.preventDefault(); printOutline(); return; } const expandTrigger = e.target.closest('[data-viz-expand]'); if (expandTrigger) { const figId = expandTrigger.dataset.vizExpand; state.vizFull = state.vizFull === figId ? null : figId; syncFull(); expandTrigger.focus({ preventScroll: true }); if (!state.vizFull) expandTrigger.closest('figure')?.scrollIntoView({ block: 'nearest' }); return; } const letter = e.target.closest('a[data-letter]'); if (letter) { e.preventDefault(); document.getElementById(`letter-${letter.dataset.letter}`)?.scrollIntoView({ block: 'start', behavior: 'smooth' }); return; } // Coupling figure interactions const modeBtn = e.target.closest('[data-coupling-mode]'); if (modeBtn) { state.couplingMode = modeBtn.dataset.couplingMode; refreshCouplingFigure(); return; } const famBtn = e.target.closest('[data-coupling-fam]'); if (famBtn) { state.couplingFamily = famBtn.dataset.couplingFam; refreshCouplingFigure(); return; } if (e.target.closest('[data-clear-coupling]')) { state.couplingSel = null; refreshCouplingFigure(); return; } const pairBtn = e.target.closest('[data-select-pair]'); if (pairBtn) { const [fromArea, toArea] = pairBtn.dataset.selectPair.split('__'); state.couplingSel = { kind: 'pair', fromArea, toArea }; refreshCouplingFigure(); return; } const wheelSubj = e.target.closest('[data-wheel-subj]'); if (wheelSubj) { const id = wheelSubj.dataset.wheelSubj; state.couplingSel = state.couplingSel?.kind === 'subject' && state.couplingSel.id === id ? null : { kind: 'subject', id }; refreshCouplingFigure(); return; } const areaTrigger = e.target.closest('[data-wheel-area], [data-dsm-area]'); if (areaTrigger) { const id = areaTrigger.dataset.wheelArea || areaTrigger.dataset.dsmArea; state.couplingSel = state.couplingSel?.kind === 'area' && state.couplingSel.id === id ? null : { kind: 'area', id }; refreshCouplingFigure(); return; } const dsmCell = e.target.closest('[data-dsm-from]'); if (dsmCell) { const fromArea = dsmCell.dataset.dsmFrom, toArea = dsmCell.dataset.dsmTo; state.couplingSel = state.couplingSel?.kind === 'pair' && state.couplingSel.fromArea === fromArea && state.couplingSel.toArea === toArea ? null : { kind: 'pair', fromArea, toArea }; refreshCouplingFigure(); return; } // Guides: Role Sankey interactions const sankeyRole = e.target.closest('[data-sankey-role]'); if (sankeyRole) { const id = sankeyRole.dataset.sankeyRole; state.activeRole = state.activeRole === id ? null : id; state.activeAreaRole = null; drawRoleSankey(); return; } const pickRole = e.target.closest('[data-pick-role]'); if (pickRole) { state.activeRole = pickRole.dataset.pickRole; state.activeAreaRole = null; drawRoleSankey(); $('bok-role-sankey-fig')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } const sankeyArea = e.target.closest('[data-sankey-area]'); if (sankeyArea && !e.target.closest('[data-sankey-role]')) { const id = sankeyArea.dataset.sankeyArea; state.activeAreaRole = state.activeAreaRole === id ? null : id; state.activeRole = null; drawRoleSankey(); return; } if (e.target.closest('[data-clear-sankey]')) { state.activeRole = null; state.activeAreaRole = null; drawRoleSankey(); return; } // Guides: Symptom triage flow interactions const symPick = e.target.closest('[data-symptom-pick]'); if (symPick) { state.activeSymptom = symPick.dataset.symptomPick; state.activeDiagNode = null; drawSymptomFlow(); if (symPick.closest('table')) $('bok-symptom-flow-fig')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } const diagNode = e.target.closest('[data-diag-node]'); if (diagNode) { const id = diagNode.dataset.diagNode; state.activeDiagNode = state.activeDiagNode === id ? null : id; drawSymptomFlow(); return; } if (e.target.closest('[data-clear-diag]')) { state.activeDiagNode = null; drawSymptomFlow(); return; } // Guides: Learning path station interactions const clearStation = e.target.closest('[data-clear-station]'); if (clearStation) { const pid = clearStation.dataset.clearStation; const container = document.querySelector(`[data-path-viz="${pid}"]`); if (container) delete container.dataset.pinnedStation; highlightPathStation(pid, null); return; } const pathStation = e.target.closest('[data-station-idx]'); if (pathStation) { const pid = pathStation.dataset.pathId; const idx = Number(pathStation.dataset.stationIdx); const container = document.querySelector(`[data-path-viz="${pid}"]`); if (container) { if (container.dataset.pinnedStation === String(idx)) { delete container.dataset.pinnedStation; highlightPathStation(pid, null); } else { container.dataset.pinnedStation = String(idx); highlightPathStation(pid, idx); } } } }); document.addEventListener('change', (e) => { if (e.target.id === 'bok-symptom-select') { state.activeSymptom = e.target.value; state.activeDiagNode = null; drawSymptomFlow(); } }); document.addEventListener('mouseover', (e) => { const pathStation = e.target.closest('[data-station-idx]'); if (pathStation) { const pid = pathStation.dataset.pathId; const container = document.querySelector(`[data-path-viz="${pid}"]`); if (container && container.dataset.pinnedStation !== undefined) return; highlightPathStation(pid, Number(pathStation.dataset.stationIdx)); } }); document.addEventListener('mouseout', (e) => { const pathStation = e.target.closest('[data-station-idx]'); if (pathStation && !pathStation.contains(e.relatedTarget)) { const pid = pathStation.dataset.pathId; const container = document.querySelector(`[data-path-viz="${pid}"]`); if (container && container.dataset.pinnedStation === undefined) { highlightPathStation(pid, null); } } }); wirePicker('bok-search', 'bok-results', choose); document.addEventListener('keydown', (e) => { if (e.key === 'Tab' && state.vizFull) { const figure = document.getElementById(state.vizFull); const items = [...figure.querySelectorAll('button, a[href], input, select, [tabindex]')] .filter((el) => el.tabIndex >= 0 && !el.disabled && el.getClientRects().length && !el.closest('[hidden], [inert]')); const first = items[0], last = items[items.length - 1]; if (!figure.contains(document.activeElement) || (e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) { e.preventDefault(); (e.shiftKey ? last : first)?.focus(); } } const gridCell = document.activeElement?.closest('[data-grid-row]'); if (gridCell && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { e.preventDefault(); let row = Number(gridCell.dataset.gridRow), col = Number(gridCell.dataset.gridCol); if (e.key === 'ArrowUp') row--; if (e.key === 'ArrowDown') row++; if (e.key === 'ArrowLeft') col--; if (e.key === 'ArrowRight') col++; if (e.key === 'Home') col = 0; if (e.key === 'End') col = areas.length - 1; row = Math.max(0, Math.min(areas.length - 1, row)); col = Math.max(0, Math.min(areas.length - 1, col)); const next = document.querySelector(`[data-grid-row="${row}"][data-grid-col="${col}"]`); if (next) { document.querySelectorAll('[data-grid-row]').forEach((cell) => cell.setAttribute('tabindex', '-1')); next.setAttribute('tabindex', '0'); next.focus({ preventScroll: true }); } return; } if ((e.key === 'Enter' || e.key === ' ') && document.activeElement?.matches('[role="button"]:not(button)')) { e.preventDefault(); document.activeElement.dispatchEvent(new MouseEvent('click', { bubbles: true })); return; } if (e.key === '/' && !state.vizFull && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)) { e.preventDefault(); $('bok-search').focus(); $('bok-search').select(); } else if (e.key === 'Escape') { if (!state.vizFull && clearVizSelection(null)) return; if (state.vizFull) { const btn = document.querySelector(`[data-viz-expand="${state.vizFull}"]`); state.vizFull = null; syncFull(); btn?.focus({ preventScroll: true }); } } else if (state.view === 'home' && state.overlay?.startsWith('path:') && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)) { if (e.key === 'ArrowRight') { state.pathStep++; renderMatrix(); } else if (e.key === 'ArrowLeft') { state.pathStep = Math.max(0, state.pathStep - 1); renderMatrix(); } } }); let width = window.innerWidth; window.addEventListener('resize', () => { if (state.view !== 'concept' || window.innerWidth === width) return; width = window.innerWidth; drawMap(state.concept, $('bok-canvas')); }); let dark = document.body.classList.contains('quarto-dark'); new MutationObserver(() => { if (dark === document.body.classList.contains('quarto-dark')) return; dark = !dark; if (!cy) return; readPalette(); cy.style(mapStyle()); }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } load(); })();