/* Waypath Platform · Atlas (ATL-01) Correlation view — petri-dish cluster of nodes + faint warm-red edge haze. Canvas-rendered, pan/zoom, d3-force layout (synchronous on mount). */ const NODE_COLORS = { Customer: '#3b82f6', Segment: '#6366f1', Touchpoint: '#10b981', Campaign: '#f59e0b', Stage: '#14b8a6', Channel: '#ec4899', Opportunity: '#ef4444', Cohort: '#818cf8', Journey: '#f43f5e', Product: '#8b5cf6', Event: '#64748b', Newsletter: '#fb923c', Audience: '#a78bfa', Broadcast: '#fbbf24', Goal: '#E8522B', }; const TYPE_BONUS = { Campaign: 6.0, Opportunity: 4.0, Segment: 2.5, Stage: 2.0, Customer: 1.5 }; function atlasNodeRadius(degree, type) { const clamped = Math.min(degree, 30); const base = 1.0 + Math.pow(clamped + 1, 0.3) * 0.7; const bonus = TYPE_BONUS[type] || 0; return Math.max(1.0, Math.min(base + bonus, 12)); } function atlasCollideRadius(n) { const base = Math.max(2.5, Math.sqrt((n.degree || 0) + 1) * 2.0); if (n.type === 'Campaign') return base + 15; if (n.type === 'Opportunity') return base + 8; return base; } function atlasGenerateSeed() { const nodes = []; const links = []; for (let i = 0; i < 2; i++) nodes.push({ id: `cmp${i}`, name: `Campaign ${i+1}`, type: 'Campaign', degree: 0 }); for (let i = 0; i < 4; i++) nodes.push({ id: `stg${i}`, name: `Stage ${i+1}`, type: 'Stage', degree: 0 }); for (let i = 0; i < 6; i++) nodes.push({ id: `chn${i}`, name: `Channel ${i+1}`, type: 'Channel', degree: 0 }); for (let i = 0; i < 3; i++) nodes.push({ id: `seg${i}`, name: `Segment ${i+1}`, type: 'Segment', degree: 0 }); for (let i = 0; i < 5; i++) nodes.push({ id: `opp${i}`, name: `Opportunity ${i+1}`, type: 'Opportunity', degree: 0 }); for (let i = 0; i < 30; i++) nodes.push({ id: `cus${i}`, name: `Customer ${i+1}`, type: 'Customer', degree: 0 }); for (let i = 0; i < 150; i++) nodes.push({ id: `tp${i}`, name: `Touchpoint ${i+1}`, type: 'Touchpoint', degree: 0 }); const idMap = Object.fromEntries(nodes.map(n => [n.id, n])); const addLink = (a, b) => { links.push({ source: a, target: b }); idMap[a].degree++; idMap[b].degree++; }; for (let i = 0; i < 30; i++) { const cid = `cus${i}`; const tpCount = 3 + Math.floor(Math.random() * 6); for (let t = 0; t < tpCount; t++) addLink(cid, `tp${Math.floor(Math.random() * 150)}`); addLink(cid, `stg${Math.floor(Math.random() * 4)}`); addLink(cid, `chn${Math.floor(Math.random() * 6)}`); if (Math.random() < 0.6) addLink(cid, `cmp${Math.floor(Math.random() * 2)}`); if (Math.random() < 0.2) addLink(cid, `opp${Math.floor(Math.random() * 5)}`); if (Math.random() < 0.5) addLink(cid, `seg${Math.floor(Math.random() * 3)}`); } for (let i = 0; i < 4; i++) addLink(`stg${i}`, `cmp${i % 2}`); return { nodes, links }; } const ATLAS_PARAMS = { charge: -40, theta: 0.9, centerStrength: 0.08, linkDistance: 25, linkStrength: 0.15, alphaDecay: 0.015, velocityDecay: 0.4, }; function atlasRunLayout(nodes, links) { const d3 = window.d3; // Seed positions: exponential radial decay (dense core, sparse streaks). const maxR = Math.max(100, Math.sqrt(nodes.length) * 5); for (const n of nodes) { const angle = Math.random() * Math.PI * 2; const r = maxR * Math.pow(Math.random(), 0.9) * (0.8 + Math.random() * 0.4); n.x = Math.cos(angle) * r; n.y = Math.sin(angle) * r; } const linkStrengthFn = (link) => ATLAS_PARAMS.linkStrength / Math.sqrt(Math.max(link.source.degree || 1, link.target.degree || 1)); const sim = d3.forceSimulation(nodes) .force('charge', d3.forceManyBody().strength(ATLAS_PARAMS.charge).theta(ATLAS_PARAMS.theta)) .force('center', d3.forceCenter(0, 0).strength(ATLAS_PARAMS.centerStrength)) .force('link', d3.forceLink(links).id((d) => d.id).distance(ATLAS_PARAMS.linkDistance).strength(linkStrengthFn)) .force('collide', d3.forceCollide().radius((d) => atlasCollideRadius(d) + 0.5).iterations(3)) .alphaDecay(ATLAS_PARAMS.alphaDecay) .velocityDecay(ATLAS_PARAMS.velocityDecay) .stop(); for (let i = 0; i <= 400; i++) sim.tick(); // Normalize 2nd–98th percentile → 1600 units, centered. const xs = nodes.map(n => n.x).sort((a, b) => a - b); const ys = nodes.map(n => n.y).sort((a, b) => a - b); const N = nodes.length; const lo = Math.floor(N * 0.02), hi = Math.floor(N * 0.98); const range = Math.max(xs[hi] - xs[lo], ys[hi] - ys[lo]) || 1; const scale = 1600 / range; const cx = (xs[lo] + xs[hi]) / 2; const cy = (ys[lo] + ys[hi]) / 2; for (const n of nodes) { n.x = (n.x - cx) * scale; n.y = (n.y - cy) * scale; } } /* ───────── AtlasView ───────── */ function AtlasView() { const wrapRef = React.useRef(null); const canvasRef = React.useRef(null); const stateRef = React.useRef({ tx: 0, ty: 0, k: 0.55, nodes: [], links: [], selected: null, drag: false, dragMoved: false, lastX: 0, lastY: 0, ready: false, }); const rafRef = React.useRef(null); const [ready, setReady] = React.useState(false); const [hud, setHud] = React.useState({ k: 0.55, sel: null }); const draw = React.useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const { tx, ty, k, nodes, links, selected } = stateRef.current; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#080808'; ctx.fillRect(0, 0, w, h); ctx.translate(w / 2 + tx, h / 2 + ty); ctx.scale(k, k); // Edges — faint warm red, single batched stroke ctx.strokeStyle = 'rgba(140, 68, 68, 0.14)'; ctx.lineWidth = 0.5 / k; ctx.beginPath(); for (const l of links) { ctx.moveTo(l.source.x, l.source.y); ctx.lineTo(l.target.x, l.target.y); } ctx.stroke(); // Nodes for (const n of nodes) { ctx.fillStyle = NODE_COLORS[n.type] || '#94a3b8'; const r = atlasNodeRadius(n.degree || 0, n.type); ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fill(); } // Selection ring if (selected) { const r = atlasNodeRadius(selected.degree || 0, selected.type); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2 / k; ctx.beginPath(); ctx.arc(selected.x, selected.y, r + 2 / k, 0, Math.PI * 2); ctx.stroke(); } // Labels only at high zoom if (k > 1.8) { ctx.fillStyle = 'rgba(250,250,249,0.6)'; ctx.font = `${10 / k}px "JetBrains Mono", "DM Mono", "Courier New", monospace`; ctx.textAlign = 'center'; for (const n of nodes) { const r = atlasNodeRadius(n.degree || 0, n.type); ctx.fillText(n.name, n.x, n.y - r - 3 / k); } } }, []); const requestDraw = React.useCallback(() => { if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = null; draw(); }); }, [draw]); const resize = React.useCallback(() => { const canvas = canvasRef.current; const wrap = wrapRef.current; if (!canvas || !wrap) return; const dpr = window.devicePixelRatio || 1; const rect = wrap.getBoundingClientRect(); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); requestDraw(); }, [requestDraw]); React.useEffect(() => { let cancelled = false; const waitD3 = () => new Promise((res) => { const check = () => { if (window.d3 && window.d3.forceSimulation) res(); else setTimeout(check, 40); }; check(); }); const init = async () => { await waitD3(); if (cancelled) return; const { nodes, links } = atlasGenerateSeed(); atlasRunLayout(nodes, links); const s = stateRef.current; s.nodes = nodes; s.links = links; // Fit-to-screen initial zoom — favour visibility over fitting the whole cluster const wrap = wrapRef.current; if (wrap) { const r = wrap.getBoundingClientRect(); const fit = Math.min(r.width, r.height) / 1200; s.k = Math.max(0.55, Math.min(1.1, fit)); } s.ready = true; setReady(true); setHud({ k: s.k, sel: null }); resize(); }; init(); const ro = new ResizeObserver(resize); if (wrapRef.current) ro.observe(wrapRef.current); const canvas = canvasRef.current; const onWheel = (e) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - rect.width / 2; const my = e.clientY - rect.top - rect.height / 2; const s = stateRef.current; const factor = Math.exp(-e.deltaY * 0.0015); const newK = Math.max(0.3, Math.min(4, s.k * factor)); const wx = (mx - s.tx) / s.k; const wy = (my - s.ty) / s.k; s.tx = mx - wx * newK; s.ty = my - wy * newK; s.k = newK; setHud({ k: newK, sel: s.selected }); requestDraw(); }; const onDown = (e) => { if (e.button !== 0) return; const s = stateRef.current; s.drag = true; s.dragMoved = false; s.lastX = e.clientX; s.lastY = e.clientY; }; const onMove = (e) => { const s = stateRef.current; if (!s.drag) return; const dx = e.clientX - s.lastX; const dy = e.clientY - s.lastY; if (Math.abs(dx) + Math.abs(dy) > 2) s.dragMoved = true; s.tx += dx; s.ty += dy; s.lastX = e.clientX; s.lastY = e.clientY; requestDraw(); }; const onUp = (e) => { const s = stateRef.current; if (s.drag && !s.dragMoved) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - rect.width / 2; const my = e.clientY - rect.top - rect.height / 2; const wx = (mx - s.tx) / s.k; const wy = (my - s.ty) / s.k; let hit = null, best = Infinity; for (const n of s.nodes) { const r = atlasNodeRadius(n.degree || 0, n.type); const ex = n.x - wx, ey = n.y - wy; const d2 = ex * ex + ey * ey; const hitR = Math.max(r, 4); // generous hit radius for tiny dots if (d2 < hitR * hitR && d2 < best) { best = d2; hit = n; } } s.selected = hit; setHud({ k: s.k, sel: hit }); requestDraw(); } s.drag = false; }; canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { cancelled = true; ro.disconnect(); canvas.removeEventListener('wheel', onWheel); canvas.removeEventListener('mousedown', onDown); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [resize, requestDraw]); const legendItems = [ { type: 'Campaign', label: 'Campaign · hub' }, { type: 'Opportunity', label: 'Opportunity' }, { type: 'Segment', label: 'Segment' }, { type: 'Stage', label: 'Stage' }, { type: 'Channel', label: 'Channel' }, { type: 'Customer', label: 'Customer' }, { type: 'Touchpoint', label: 'Touchpoint' }, ]; const sel = hud.sel; const counts = stateRef.current.nodes.reduce((m, n) => { m[n.type] = (m[n.type] || 0) + 1; return m; }, {}); const totalNodes = stateRef.current.nodes.length; const totalLinks = stateRef.current.links.length; return (