// Collection Context Graph — interactive force-directed graph showing // how Daily reports flow into Weekly reports, then Monthly, with the // Overview aggregating everything. Modeled after ContextGraph. const CollectionGraph = (function () { // Node specs. Groups mirror the data model: // - overview: the aggregated view at the center // - monthly: 3 monthly progress reports // - weekly: 3 most recent weekly reports // - daily: daily shift reports (sample of 9, representing 438) // - photos: the photos section (side branch) const NODES = [ { id: 'overview', label: 'Overview', group: 'overview', r: 20 }, // Monthly — most recent 3 { id: 'm50', label: 'Monthly No. 50', sub: 'Mar 2026', group: 'monthly', r: 13 }, { id: 'm49', label: 'Monthly No. 49', sub: 'Feb 2026', group: 'monthly', r: 13 }, { id: 'm48', label: 'Monthly No. 48', sub: 'Jan 2026', group: 'monthly', r: 13 }, // Weekly — most recent 3 from each monthly { id: 'w220', label: 'Weekly 220', sub: 'Mar 29', group: 'weekly', r: 10 }, { id: 'w219', label: 'Weekly 219', sub: 'Mar 22', group: 'weekly', r: 10 }, { id: 'w218', label: 'Weekly 218', sub: 'Mar 15', group: 'weekly', r: 10 }, { id: 'w216', label: 'Weekly 216', sub: 'Feb 22', group: 'weekly', r: 10 }, { id: 'w212', label: 'Weekly 212', sub: 'Jan 25', group: 'weekly', r: 10 }, // Daily — sample shift reports { id: 'd12010', label: '12010', sub: 'Mar 02', group: 'daily', r: 7 }, { id: 'd12015', label: '12015', sub: 'Mar 09', group: 'daily', r: 7 }, { id: 'd12022', label: '12022', sub: 'Mar 16', group: 'daily', r: 7 }, { id: 'd12027', label: '12027', sub: 'Mar 23', group: 'daily', r: 7 }, { id: 'd12030', label: '12030', sub: 'Mar 26', group: 'daily', r: 7 }, { id: 'd12033', label: '12033', sub: 'Mar 28', group: 'daily', r: 7 }, { id: 'd11995', label: '11995', sub: 'Feb 22', group: 'daily', r: 7 }, { id: 'd11960', label: '11960', sub: 'Jan 25', group: 'daily', r: 7 }, { id: 'd11954', label: '11954', sub: 'Jan 18', group: 'daily', r: 7 }, // Photos — side branch { id: 'photos', label: 'Photos', sub: '6 albums', group: 'photos', r: 11 }, ]; // Edges describe rollup relationships: // daily → weekly → monthly → overview // photos → overview (referenced in reports, surfaced via overview) const EDGES = [ // Monthly → Overview ['m50', 'overview'], ['m49', 'overview'], ['m48', 'overview'], // Weekly → Monthly ['w220', 'm50'], ['w219', 'm50'], ['w218', 'm50'], ['w216', 'm49'], ['w212', 'm48'], // Daily → Weekly ['d12030', 'w220'], ['d12033', 'w220'], ['d12027', 'w220'], ['d12022', 'w219'], ['d12015', 'w218'], ['d12010', 'w218'], ['d11995', 'w216'], ['d11960', 'w212'], ['d11954', 'w212'], // Photos → Overview (aggregated reference) ['photos', 'overview'], // Photos surface in the latest weekly too ['photos', 'w220'], ]; const COLORS = { overview: '#33302E', monthly: '#B87514', weekly: '#445C7A', daily: '#6B8E6F', photos: '#8A6431', }; let nodes = [], links = []; let svgEl = null, rootG = null; let elNodes = null, elLinks = null; let width = 0, height = 0; let raf = null, running = false; let focusId = null, dragging = null; let viewTx = 0, viewTy = 0, viewScale = 1; let targetScale = 1, targetTx = 0, targetTy = 0; const MIN_SCALE = 0.25, MAX_SCALE = 3.5; const LABEL_FADE_START = 0.75, LABEL_FADE_END = 0.45; function buildGraph() { nodes = NODES.map((n) => ({ ...n, vx: 0, vy: 0 })); const byId = Object.fromEntries(nodes.map((n) => [n.id, n])); links = EDGES .filter(([a, b]) => byId[a] && byId[b]) .map(([a, b]) => ({ source: byId[a], target: byId[b] })); nodes.forEach((n) => { n.neighbors = new Set(); }); links.forEach((l) => { l.source.neighbors.add(l.target.id); l.target.neighbors.add(l.source.id); }); const cx = width / 2, cy = height / 2; nodes.forEach((n, i) => { if (n.id === 'overview') { n.x = cx; n.y = cy; return; } // Layered seeding: monthly close, weekly mid, daily far, photos to the side const layerR = n.group === 'monthly' ? 200 : n.group === 'weekly' ? 360 : n.group === 'daily' ? 540 : 280; const a = (i / nodes.length) * Math.PI * 2; n.x = cx + Math.cos(a) * layerR; n.y = cy + Math.sin(a) * layerR; }); } function render() { svgEl.innerHTML = ''; const NS = 'http://www.w3.org/2000/svg'; rootG = document.createElementNS(NS, 'g'); rootG.setAttribute('class', 'root'); svgEl.appendChild(rootG); const gLinks = document.createElementNS(NS, 'g'); gLinks.setAttribute('class', 'links'); const gNodes = document.createElementNS(NS, 'g'); gNodes.setAttribute('class', 'nodes'); rootG.appendChild(gLinks); rootG.appendChild(gNodes); elLinks = links.map((l) => { const line = document.createElementNS(NS, 'line'); line.setAttribute('class', 'cg-link'); line.__data = l; gLinks.appendChild(line); return line; }); elNodes = nodes.map((n) => { const g = document.createElementNS(NS, 'g'); g.setAttribute('class', 'cg-node'); g.__data = n; const c = document.createElementNS(NS, 'circle'); c.setAttribute('r', n.r); c.setAttribute('fill', COLORS[n.group] || '#888'); g.appendChild(c); const t = document.createElementNS(NS, 'text'); t.setAttribute('text-anchor', 'middle'); t.setAttribute('y', n.r + (n.id === 'overview' ? 18 : 14)); if (n.id === 'overview') t.setAttribute('font-weight', '500'); t.textContent = n.label; g.appendChild(t); if (n.sub) { const s = document.createElementNS(NS, 'text'); s.setAttribute('text-anchor', 'middle'); s.setAttribute('y', n.r + (n.id === 'overview' ? 34 : 28)); s.setAttribute('class', 'cg-sub'); s.textContent = n.sub; g.appendChild(s); } wireNode(g, n); gNodes.appendChild(g); return g; }); paint(); } function paint() { elLinks.forEach((line) => { const l = line.__data; line.setAttribute('x1', l.source.x); line.setAttribute('y1', l.source.y); line.setAttribute('x2', l.target.x); line.setAttribute('y2', l.target.y); if (focusId) { const linked = (l.source.id === focusId || l.target.id === focusId); line.classList.toggle('linked', linked); } else { line.classList.remove('linked'); } }); elNodes.forEach((g) => { const n = g.__data; g.setAttribute('transform', `translate(${n.x},${n.y})`); if (focusId) { const isFocus = n.id === focusId; const isLinked = !isFocus && nodes.find((x) => x.id === focusId)?.neighbors.has(n.id); g.classList.toggle('focus', isFocus); g.classList.toggle('linked', !!isLinked); } else { g.classList.remove('focus'); g.classList.remove('linked'); } }); } function tick() { const cx = width / 2, cy = height / 2; const k_rep = 7000; const k_spring = 0.012; const damping = 0.90; const center_g = 0.004; viewScale += (targetScale - viewScale) * 0.18; viewTx += (targetTx - viewTx) * 0.18; viewTy += (targetTy - viewTy) * 0.18; applyViewTransform(); for (let i = 0; i < nodes.length; i++) { const a = nodes[i]; if (a === dragging) continue; for (let j = i + 1; j < nodes.length; j++) { const b = nodes[j]; let dx = a.x - b.x, dy = a.y - b.y; let d2 = dx * dx + dy * dy; if (d2 < 1) { d2 = 1; dx = (Math.random() - 0.5); dy = (Math.random() - 0.5); } const d = Math.sqrt(d2); const f = k_rep / d2; const fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } links.forEach((l) => { const dx = l.target.x - l.source.x; const dy = l.target.y - l.source.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; // Layered ideal lengths: shorter for inner, longer for outer const isOverview = l.source.id === 'overview' || l.target.id === 'overview'; const isWMpair = (l.source.group === 'weekly' && l.target.group === 'monthly') || (l.target.group === 'weekly' && l.source.group === 'monthly'); const isDWpair = (l.source.group === 'daily' && l.target.group === 'weekly') || (l.target.group === 'daily' && l.source.group === 'weekly'); const ideal = isOverview ? 220 : isWMpair ? 180 : isDWpair ? 150 : 200; const f = (d - ideal) * k_spring; const fx = (dx / d) * f, fy = (dy / d) * f; if (l.source !== dragging) { l.source.vx += fx; l.source.vy += fy; } if (l.target !== dragging) { l.target.vx -= fx; l.target.vy -= fy; } }); let energy = 0; nodes.forEach((n) => { if (n === dragging) { n.vx = 0; n.vy = 0; return; } if (n.id === 'overview') { n.vx += (cx - n.x) * 0.08; n.vy += (cy - n.y) * 0.08; } else { n.vx += (cx - n.x) * center_g; n.vy += (cy - n.y) * center_g; } n.vx *= damping; n.vy *= damping; const sp = Math.sqrt(n.vx * n.vx + n.vy * n.vy); if (sp > 18) { n.vx = n.vx / sp * 18; n.vy = n.vy / sp * 18; } n.x += n.vx * 0.55; n.y += n.vy * 0.55; energy += n.vx * n.vx + n.vy * n.vy; }); paint(); const zoomEasing = Math.abs(targetScale - viewScale) > 0.001 || Math.abs(targetTx - viewTx) > 0.2 || Math.abs(targetTy - viewTy) > 0.2; if (energy > 0.04 || dragging || zoomEasing) { raf = requestAnimationFrame(tick); } else { running = false; raf = null; } } function simStep() { const cx = width / 2, cy = height / 2; const k_rep = 7000, k_spring = 0.012, damping = 0.90, center_g = 0.004; for (let i = 0; i < nodes.length; i++) { const a = nodes[i]; for (let j = i + 1; j < nodes.length; j++) { const b = nodes[j]; let dx = a.x - b.x, dy = a.y - b.y; let d2 = dx * dx + dy * dy; if (d2 < 1) { d2 = 1; dx = Math.random() - 0.5; dy = Math.random() - 0.5; } const d = Math.sqrt(d2); const f = k_rep / d2; const fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } links.forEach((l) => { const dx = l.target.x - l.source.x, dy = l.target.y - l.source.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const isOverview = l.source.id === 'overview' || l.target.id === 'overview'; const isWMpair = (l.source.group === 'weekly' && l.target.group === 'monthly') || (l.target.group === 'weekly' && l.source.group === 'monthly'); const isDWpair = (l.source.group === 'daily' && l.target.group === 'weekly') || (l.target.group === 'daily' && l.source.group === 'weekly'); const ideal = isOverview ? 220 : isWMpair ? 180 : isDWpair ? 150 : 200; const f = (d - ideal) * k_spring; const fx = (dx / d) * f, fy = (dy / d) * f; l.source.vx += fx; l.source.vy += fy; l.target.vx -= fx; l.target.vy -= fy; }); nodes.forEach((n) => { if (n.id === 'overview') { n.vx += (cx - n.x) * 0.08; n.vy += (cy - n.y) * 0.08; } else { n.vx += (cx - n.x) * center_g; n.vy += (cy - n.y) * center_g; } n.vx *= damping; n.vy *= damping; n.x += n.vx * 0.55; n.y += n.vy * 0.55; }); } function applyViewTransform() { if (!rootG) return; rootG.setAttribute('transform', `translate(${viewTx},${viewTy}) scale(${viewScale})`); let op = 1; if (viewScale <= LABEL_FADE_END) op = 0; else if (viewScale < LABEL_FADE_START) { op = (viewScale - LABEL_FADE_END) / (LABEL_FADE_START - LABEL_FADE_END); } svgEl.style.setProperty('--label-opacity', op.toFixed(3)); const MAX_LABEL_PX = 22, BASE_LABEL_PX = 12; const visual = BASE_LABEL_PX * viewScale; const fontSize = visual > MAX_LABEL_PX ? (MAX_LABEL_PX / viewScale) : BASE_LABEL_PX; svgEl.style.setProperty('--label-font', fontSize.toFixed(2) + 'px'); } function kick() { if (!running) { running = true; raf = requestAnimationFrame(tick); } } function svgPoint(cx, cy) { const rect = svgEl.getBoundingClientRect(); const sx = cx - rect.left; const sy = cy - rect.top; return { x: (sx - viewTx) / viewScale, y: (sy - viewTy) / viewScale }; } function wireNode(g, n) { g.addEventListener('mouseenter', () => { if (dragging) return; focusId = n.id; svgEl.classList.add('has-focus'); paint(); }); g.addEventListener('mouseleave', () => { if (dragging) return; focusId = null; svgEl.classList.remove('has-focus'); paint(); }); g.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); dragging = n; focusId = n.id; svgEl.classList.add('has-focus'); g.setPointerCapture(e.pointerId); kick(); const onMove = (ev) => { const pt = svgPoint(ev.clientX, ev.clientY); n.x = pt.x; n.y = pt.y; n.vx = 0; n.vy = 0; kick(); }; const onUp = (ev) => { dragging = null; g.removeEventListener('pointermove', onMove); g.removeEventListener('pointerup', onUp); g.removeEventListener('pointercancel', onUp); try { g.releasePointerCapture(ev.pointerId); } catch (_) { } kick(); }; g.addEventListener('pointermove', onMove); g.addEventListener('pointerup', onUp); g.addEventListener('pointercancel', onUp); }); } function fitToViewport(padding) { if (!nodes.length) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach((n) => { const r = n.r + 60; if (n.x - r < minX) minX = n.x - r; if (n.x + r > maxX) maxX = n.x + r; if (n.y - r < minY) minY = n.y - r; if (n.y + r > maxY) maxY = n.y + r; }); const pad = padding != null ? padding : 60; const w = Math.max(1, maxX - minX); const h = Math.max(1, maxY - minY); const sx = (width - pad * 2) / w; const sy = (height - pad * 2) / h; const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, Math.min(sx, sy))); targetScale = s; targetTx = (width - (minX + maxX) * s) / 2; targetTy = (height - (minY + maxY) * s) / 2; } function zoomAt(clientX, clientY, factor) { const rect = svgEl.getBoundingClientRect(); const sx = clientX - rect.left; const sy = clientY - rect.top; const next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, targetScale * factor)); const actualFactor = next / targetScale; targetTx = sx - (sx - targetTx) * actualFactor; targetTy = sy - (sy - targetTy) * actualFactor; targetScale = next; kick(); } function resize() { const rect = svgEl.getBoundingClientRect(); width = rect.width; height = rect.height; svgEl.setAttribute('viewBox', `0 0 ${width} ${height}`); } function setGroupFocus(group) { if (!elNodes) return; if (!group) { svgEl.classList.remove('has-group-focus'); elNodes.forEach((g) => g.classList.remove('group-on', 'group-off')); elLinks.forEach((line) => line.classList.remove('group-off')); return; } svgEl.classList.add('has-group-focus'); elNodes.forEach((g) => { const n = g.__data; const on = (n.group === group) || (n.id === 'overview'); g.classList.toggle('group-on', on); g.classList.toggle('group-off', !on); }); elLinks.forEach((line) => { const l = line.__data; const on = l.source.group === group || l.target.group === group || (l.source.id === 'overview' && l.target.group === group) || (l.target.id === 'overview' && l.source.group === group); line.classList.toggle('group-off', !on); }); } let panning = null; function mount(svg) { svgEl = svg; nodes = []; links = []; rootG = null; elNodes = null; elLinks = null; focusId = null; dragging = null; panning = null; if (raf) { cancelAnimationFrame(raf); raf = null; } running = false; svgEl.addEventListener('wheel', (e) => { e.preventDefault(); const factor = Math.exp(-e.deltaY * 0.0015); zoomAt(e.clientX, e.clientY, factor); }, { passive: false }); svgEl.addEventListener('pointerdown', (e) => { if (e.target.closest('.cg-node')) return; panning = { x: e.clientX, y: e.clientY, tx: targetTx, ty: targetTy, id: e.pointerId }; try { svgEl.setPointerCapture(e.pointerId); } catch (_) { } }); svgEl.addEventListener('pointermove', (e) => { if (!panning) return; const dx = e.clientX - panning.x, dy = e.clientY - panning.y; targetTx = panning.tx + dx; targetTy = panning.ty + dy; viewTx = targetTx; viewTy = targetTy; applyViewTransform(); paint(); }); const endPan = () => { if (!panning) return; try { svgEl.releasePointerCapture(panning.id); } catch (_) { } panning = null; }; svgEl.addEventListener('pointerup', endPan); svgEl.addEventListener('pointercancel', endPan); svgEl.addEventListener('pointerleave', endPan); } function start() { const run = () => { resize(); if (width < 10 || height < 10) { requestAnimationFrame(run); return; } buildGraph(); render(); viewScale = 1; viewTx = 0; viewTy = 0; targetScale = 1; targetTx = 0; targetTy = 0; applyViewTransform(); running = false; for (let i = 0; i < 100; i++) simStep(); fitToViewport(); viewScale = targetScale; viewTx = targetTx; viewTy = targetTy; applyViewTransform(); paint(); kick(); }; requestAnimationFrame(run); } function stop() { if (raf) cancelAnimationFrame(raf); raf = null; running = false; dragging = null; focusId = null; } function onWindowResize() { if (!svgEl) return; resize(); fitToViewport(); kick(); } return { mount, start, stop, setGroupFocus, onWindowResize, COLORS }; })(); /* ============ React wrapper ============ */ function CollectionContextGraphPage({ onBack }) { const svgRef = React.useRef(null); const [activeGroup, setActiveGroup] = React.useState(null); React.useEffect(() => { if (!svgRef.current) return; CollectionGraph.mount(svgRef.current); CollectionGraph.start(); const onResize = () => CollectionGraph.onWindowResize(); window.addEventListener('resize', onResize); const onKey = (e) => { if (e.key === 'Escape') onBack && onBack(); }; document.addEventListener('keydown', onKey); return () => { window.removeEventListener('resize', onResize); document.removeEventListener('keydown', onKey); CollectionGraph.stop(); }; }, []); const legend = [ { group: 'overview', label: 'Overview', color: CollectionGraph.COLORS.overview }, { group: 'monthly', label: 'Monthly', color: CollectionGraph.COLORS.monthly }, { group: 'weekly', label: 'Weekly', color: CollectionGraph.COLORS.weekly }, { group: 'daily', label: 'Daily', color: CollectionGraph.COLORS.daily }, { group: 'photos', label: 'Photos', color: CollectionGraph.COLORS.photos }, ]; return (