// Semantic-map view — a meaning-space scatter of the graph. // // Each node is pinned at the 2-D projection of its embedding // (window._semanticPositions, from the layout's __SEMANTIC_POSITIONS__ token), // shaded by cluster (__SEMANTIC_CLUSTERS__). Positions are computed once in // Python and never simulated here (layout-once rule) except under the explicit // Structural toggle. Hovering a node lights up its precomputed nearest // neighbors and lists its graph relations. Node detail is read from // window._vizNodeById / window._vizLinks (exposed by story_view.js). (function () { 'use strict'; const CLUSTERS = __SEMANTIC_CLUSTERS__; // Recall events from the session layer (same token the Memory tab uses). Each // 'search' event carries the node_ids a recall query retrieved — the overlay // lights up where that query landed in meaning-space. const SEARCH_EVENTS = __SEARCH_EVENTS__; function nodeById(id) { return (window._vizNodeById && window._vizNodeById[id]) || null; } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Pure view decisions (no DOM, no d3) ────────────────────────────────── // Linear map with no clamping — matches d3.scaleLinear's default extrapolation. function mapLinear(v, d0, d1, r0, r1) { return r0 + ((v - d0) / (d1 - d0)) * (r1 - r0); } // Pinned embedding coords ([-1.2, 1.2] box) -> screen pixels. y is inverted. function screenPositions(positions, width, height, pad) { const out = {}; Object.keys(positions).forEach((id) => { out[id] = { x: mapLinear(positions[id].x, -1.2, 1.2, pad, width - pad), y: mapLinear(positions[id].y, -1.2, 1.2, height - pad, pad), }; }); return out; } function isolationOpacity(id, ctx) { if (state.colorBy === 'type') { if (state.isolatedType == null) return 1; return ctx.typeById[id] === state.isolatedType ? 1 : 0.12; } return state.isolatedCluster == null || ctx.nodeCluster[id] === state.isolatedCluster ? 1 : 0.12; } // Per-node visual attributes. The recall overlay (state.recall is a Set) wins: // retrieved nodes get a red ring and full opacity, everything else dims. With // no overlay, the legend isolation applies. function styleFor(id, ctx) { const hit = !!(state.recall && state.recall.has(id)); return { opacity: state.recall ? (hit ? 1 : 0.06) : isolationOpacity(id, ctx), stroke: hit ? '#ff3b3b' : 'rgba(0,0,0,0.25)', strokeWidth: hit ? 2.5 : 0.5, r: hit ? 7 : 5, }; } function fillFor(id, ctx) { if (state.colorBy === 'type') return ctx.colorById[id] || '#8a8a8a'; const cid = ctx.nodeCluster[id]; return cid == null ? '#8a8a8a' : ctx.clusterColors[cid]; } function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; } // On-screen centroid + display label per non-empty cluster. function clusterCentroids(clusters, screenPos) { const out = []; clusters.forEach((c) => { const pts = (c.node_ids || []).map((id) => screenPos[id]).filter(Boolean); if (!pts.length) return; let sx = 0; let sy = 0; pts.forEach((p) => { sx += p.x; sy += p.y; }); out.push({ id: c.id, x: sx / pts.length, y: sy / pts.length, label: truncate(c.label, 34) }); }); return out; } // Legend rows follow the color mode. Each row carries what the click handler // needs: kind ('type'|'cluster') + value (the type name or cluster id). function legendModel(clusters, ids, ctx) { if (state.colorBy === 'type') { const typeColor = {}; ids.forEach((id) => { const t = ctx.typeById[id]; if (t && !(t in typeColor)) typeColor[t] = ctx.colorById[id] || '#8a8a8a'; }); return Object.keys(typeColor) .sort() .map((t) => ({ kind: 'type', value: t, color: typeColor[t], text: t })); } return clusters.map((c) => ({ kind: 'cluster', value: c.id, color: ctx.clusterColors[c.id], text: c.label, })); } function recallQueries(events) { return (events || []).filter((e) => e && e.kind === 'search' && (e.node_ids || []).length); } function recallOnMap(query, positions) { return (query.node_ids || []).filter((id) => positions[String(id)]).length; } // ── View state + d3/DOM mechanics ──────────────────────────────────────── // Single mutable view state; the pure helpers read it, never mutate it. const state = { colorBy: 'cluster', layoutMode: 'semantic', isolatedCluster: null, isolatedType: null, recall: null, // Set of node_ids from the active recall overlay, or null }; let zoomBehavior = null; // Retained render artifacts so overlay/isolation changes restyle in place // (no full re-render, no force-sim re-run) instead of via a back-pointer. let currentCircles = null; let currentCtx = null; // Structural layout: relax the same nodes over graph topology (window._vizLinks) // with a bounded, synchronous force sim. This is the one place a sim is allowed — // it explicitly leaves the pinned semantic view. Bounded by the capped node // payload and a fixed tick count, seeded from the semantic screen positions. function structuralPositions(ids, semanticScreen, width, height) { const idSet = new Set(ids); const nodes = ids.map((id) => ({ id, x: semanticScreen[id].x, y: semanticScreen[id].y })); const links = (window._vizLinks || []) .filter((l) => idSet.has(String(l.source)) && idSet.has(String(l.target))) .map((l) => ({ source: String(l.source), target: String(l.target) })); const sim = d3.forceSimulation(nodes) .force('link', d3.forceLink(links).id((d) => d.id).distance(40)) .force('charge', d3.forceManyBody().strength(-30)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collide', d3.forceCollide(8)) .stop(); for (let i = 0; i < 200; i++) sim.tick(); const out = {}; nodes.forEach((n) => { out[n.id] = { x: n.x, y: n.y }; }); return out; } function clusterColor(clusters) { // Deterministic palette indexed by cluster id. const map = {}; const n = Math.max(1, clusters.length); clusters.forEach((c, i) => { map[c.id] = d3.interpolateTurbo((i + 0.5) / n); }); return map; } function relationsFor(id) { const links = window._vizLinks || []; const out = []; for (const l of links) { const s = String(l.source), t = String(l.target); if (s === id) out.push({ other: t, rel: l.relation || l.label || 'related' }); else if (t === id) out.push({ other: s, rel: l.relation || l.label || 'related' }); if (out.length >= 8) break; } return out; } function showPanel(id, clusterColors, nodeCluster) { const panel = document.getElementById('semantic-panel'); if (!panel) return; const nd = nodeById(id) || {}; const cid = nodeCluster[id]; const cluster = (CLUSTERS.clusters || []).find((c) => c.id === cid); const neighbors = (CLUSTERS.neighbors && CLUSTERS.neighbors[id]) || []; const rels = relationsFor(id); let html = '
' + esc(nd.name || id) + '
'; html += '
' + esc(nd.type || 'node') + '
'; if (cluster) { html += '
' + '' + '' + esc(cluster.label) + '
'; } if (neighbors.length) { html += '
Nearest in meaning
'; html += '
'; neighbors.forEach((nid) => { const nn = nodeById(nid) || {}; html += '
' + esc(nn.name || nid) + '
'; }); html += '
'; } if (rels.length) { html += '
Relations
'; rels.forEach((r) => { const on = nodeById(r.other) || {}; html += '
' + esc(r.rel) + ' → ' + esc(on.name || r.other) + '
'; }); } panel.innerHTML = html; panel.style.display = 'block'; } // Restyle the retained circles from current state. A no-op before the first // render (currentCircles is null); render() calls it at the end, so a state // change made while hidden still lands on next render. function repaint() { if (!currentCircles) return; currentCircles.each(function (id) { const s = styleFor(id, currentCtx); d3.select(this) .attr('opacity', s.opacity) .attr('stroke', s.stroke) .attr('stroke-width', s.strokeWidth) .attr('r', s.r); }); } function render(preserve) { const svgEl = document.getElementById('semantic-svg'); const empty = document.getElementById('semantic-empty'); const positions = window._semanticPositions || null; const ids = positions ? Object.keys(positions) : []; if (!positions || ids.length === 0 || !CLUSTERS) { if (empty) empty.style.display = 'flex'; if (svgEl) svgEl.style.display = 'none'; return; } if (empty) empty.style.display = 'none'; if (svgEl) svgEl.style.display = 'block'; const svg = d3.select(svgEl); const width = svgEl.clientWidth || 800; const height = svgEl.clientHeight || 600; const pad = 60; const clusters = CLUSTERS.clusters || []; const nodeCluster = CLUSTERS.node_cluster || {}; const clusterColors = clusterColor(clusters); // Per-id maps precomputed once per render; the pure helpers read these. const typeById = {}; const colorById = {}; ids.forEach((id) => { const nd = nodeById(id) || {}; typeById[id] = nd.type; colorById[id] = nd.color; }); const ctx = { nodeCluster, typeById, colorById, clusterColors }; const semanticScreen = screenPositions(positions, width, height, pad); const screenPos = state.layoutMode === 'structural' ? structuralPositions(ids, semanticScreen, width, height) : semanticScreen; const prevTransform = preserve ? d3.zoomTransform(svgEl) : d3.zoomIdentity; svg.selectAll('*').remove(); const g = svg.append('g'); // Cluster labels at the on-screen centroid of each cluster's members. clusterCentroids(clusters, screenPos).forEach((c) => { g.append('text') .attr('x', c.x).attr('y', c.y) .attr('text-anchor', 'middle') .attr('fill', clusterColors[c.id]) .attr('font-size', 13).attr('font-weight', 700) .attr('opacity', 0.85) .attr('pointer-events', 'none') .text(c.label); }); const circles = g.selectAll('circle').data(ids, (d) => d).enter().append('circle') .attr('cx', (id) => screenPos[id].x) .attr('cy', (id) => screenPos[id].y) .attr('r', 5) .attr('fill', (id) => fillFor(id, ctx)) .attr('stroke', 'rgba(0,0,0,0.25)').attr('stroke-width', 0.5) .style('cursor', 'pointer'); currentCircles = circles; currentCtx = ctx; circles.on('mouseover', function (event, id) { const nbrs = new Set([(id), ...((CLUSTERS.neighbors && CLUSTERS.neighbors[id]) || [])]); circles.attr('opacity', (d) => (nbrs.has(d) ? 1 : 0.15)); d3.select(this).attr('r', 8); showPanel(id, clusterColors, nodeCluster); }).on('mouseout', function () { repaint(); const panel = document.getElementById('semantic-panel'); if (panel) panel.style.display = 'none'; }); zoomBehavior = d3.zoom().scaleExtent([0.2, 12]).on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoomBehavior); svg.call(zoomBehavior.transform, prevTransform); repaint(); renderLegend(clusters, ids, ctx); const status = document.getElementById('semantic-status'); if (status) { status.textContent = ids.length + ' nodes · ' + clusters.length + ' clusters'; } } function legendRow(color, text, onClick) { const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;padding:1px 0;'; row.innerHTML = '' + '' + esc(truncate(text, 30)) + ''; row.addEventListener('click', onClick); return row; } function renderLegend(clusters, ids, ctx) { const legend = document.getElementById('semantic-legend'); if (!legend) return; legend.innerHTML = ''; // Rows follow the color mode (cluster vs type); clicking one isolates it. legendModel(clusters, ids, ctx).forEach((rowModel) => { legend.appendChild(legendRow(rowModel.color, rowModel.text, () => { if (rowModel.kind === 'type') { state.isolatedType = state.isolatedType === rowModel.value ? null : rowModel.value; } else { state.isolatedCluster = state.isolatedCluster === rowModel.value ? null : rowModel.value; } repaint(); })); }); } window._renderSemanticView = function (preserve) { render(preserve); }; function setActive(group, btn) { document.querySelectorAll(group).forEach((b) => { b.classList.remove('active'); b.style.background = 'var(--surface)'; b.style.color = 'var(--text2)'; b.style.border = '1px solid var(--border)'; }); btn.classList.add('active'); btn.style.background = 'var(--accent)'; btn.style.color = '#fff'; btn.style.border = 'none'; } // Color-mode toggle (Cluster / Type). document.querySelectorAll('.sem-color-btn').forEach((btn) => { btn.addEventListener('click', () => { setActive('.sem-color-btn', btn); state.colorBy = btn.dataset.colorby; state.isolatedCluster = null; state.isolatedType = null; // isolation is per-mode render(true); }); }); // Layout toggle (Semantic / Structural). document.querySelectorAll('.sem-layout-btn').forEach((btn) => { btn.addEventListener('click', () => { setActive('.sem-layout-btn', btn); state.layoutMode = btn.dataset.layout; render(true); }); }); // Zoom controls. function zoomBy(factor) { const svgEl = document.getElementById('semantic-svg'); if (svgEl && zoomBehavior) d3.select(svgEl).transition().duration(150).call(zoomBehavior.scaleBy, factor); } const zi = document.getElementById('semantic-zoom-in'); const zo = document.getElementById('semantic-zoom-out'); const zf = document.getElementById('semantic-zoom-fit'); if (zi) zi.addEventListener('click', () => zoomBy(1.4)); if (zo) zo.addEventListener('click', () => zoomBy(1 / 1.4)); if (zf) zf.addEventListener('click', () => { const svgEl = document.getElementById('semantic-svg'); if (svgEl && zoomBehavior) d3.select(svgEl).transition().duration(200).call(zoomBehavior.transform, d3.zoomIdentity); }); // Recall overlay: pick a past recall query, light up the nodes it retrieved. (function initRecall() { const wrap = document.getElementById('semantic-recall-wrap'); const select = document.getElementById('semantic-recall'); const note = document.getElementById('semantic-recall-note'); if (!select) return; const queries = recallQueries(SEARCH_EVENTS); if (!queries.length) { if (wrap) wrap.style.display = 'none'; // nothing to overlay return; } queries.forEach((e, i) => { const opt = document.createElement('option'); opt.value = String(i); const q = (e.question || 'recall ' + (i + 1)).trim(); opt.textContent = truncate(q, 40); select.appendChild(opt); }); select.addEventListener('change', () => { const idx = select.value; if (idx === '') { state.recall = null; if (note) note.textContent = ''; } else { const evt = queries[+idx]; state.recall = new Set((evt.node_ids || []).map(String)); const positions = window._semanticPositions || {}; if (note) note.textContent = recallOnMap(evt, positions) + ' of ' + (evt.node_ids || []).length + ' on map'; } repaint(); }); })(); // Deep link: #semantic opens the tab on load. if (window.location.hash === '#semantic') { const btn = document.querySelector('.tab-btn[data-view="semantic"]'); if (btn) btn.click(); } })();