// Mandala3D — the centerpiece. Renders a circular sector chart on an // SVG plane that we tilt and rotate in CSS 3D space. The "camera" is // virtual: we move the plane, not a camera, but the perceived effect // is identical for a single observer. // // Props: // focusSector: id of the highlighted sector (or null) // focusRing: index of the highlighted ring (or null) // tone: 'serif-dark' | 'serif-paper' | 'editorial' const { useMemo } = React; window.Mandala3D = function Mandala3D({ focusSector, focusRing, history, totalQuestions, isFinal = false, tone = 'serif-dark', size = 1100, depth = true }) { const sectors = window.TAXONOMY.sectors; const rings = window.TAXONOMY.rings; const cx = size / 2, cy = size / 2; const rOuter = size * 0.46; // Camera params are derived AFTER the path useMemo so the disc can be // rotated to put the LATEST vertex of the itinerary at the top (north). // That keeps the most recent reasoning step in focus while the older // vertices fan out toward the front/bottom — the area closest to the // viewer with the X-tilt — so the most of the trail stays visible. const tiltX = depth ? 28 : 0; // Reasoning-path polyline. One vertex per answered question; the radius // shrinks monotonically with each answer (outer rim → centre) so // points NEVER overlap on the same circle. The angle at each step is the // centroid of all cumulative sector scores, which produces an irregular, // organic curve that bends toward whichever technique is leading. // Labels are placed RADIALLY outward from each vertex to avoid stacking. const { path, leaderInfo } = useMemo(() => { if (!history || history.length === 0) return { path: [], leaderInfo: null }; const acc = {}; // Three concentric BANDS, one per ring: // ring 0 (Analytical goal) → outermost band, anchored on the outer ring // ring 1 (Data structure) → middle band, anchored on the middle ring // ring 2 (Assumptions) → innermost band, anchored on the inner ring // Each answered question gets its own sub-radius within its ring's band // (chronological order of that ring's answers). This gives the user the // visual story they asked for: goal questions on the outside, data // questions in the middle, assumption questions near the centre — and // multiple questions of the same type stack as concentric sub-circles. const r0 = rings[0] ? rings[0].rNorm : 0.95; const r1 = rings[1] ? rings[1].rNorm : 0.65; const r2 = rings[2] ? rings[2].rNorm : 0.35; const ringBands = [ { outer: r0, inner: (r0 + r1) / 2 }, // ring 0 band { outer: (r0 + r1) / 2, inner: (r1 + r2) / 2 }, // ring 1 band { outer: (r1 + r2) / 2, inner: Math.max(0.06, r2 * 0.25) }, // ring 2 band ]; // Count how many questions exist per ring in the bank (for sub-radius // spacing). Falls back to history-only counts if the bank isn't loaded. const expectedPerRing = [0, 0, 0]; const bank = (window.QUESTIONS || []); bank.forEach(q => { const r = Math.max(0, Math.min(2, q.ring ?? 0)); expectedPerRing[r]++; }); if (expectedPerRing.every(n => n === 0)) { history.forEach(h => { const r = Math.max(0, Math.min(2, h.ring ?? 0)); expectedPerRing[r]++; }); } const ringSlot = [0, 0, 0]; const built = history.map((h, idx) => { Object.entries(h.weights || {}).forEach(([k, v]) => { acc[k] = (acc[k] || 0) + v; }); // centroid angle from all sectors with non-zero score (vector sum) let sumX = 0, sumY = 0, totalScore = 0; for (const sec of sectors) { const score = acc[sec.id] || 0; if (score <= 0) continue; const ang = (sec.angle - 90) * Math.PI / 180; sumX += score * Math.cos(ang); sumY += score * Math.sin(ang); totalScore += score; } const angleRad = totalScore > 0 ? Math.atan2(sumY, sumX) : -Math.PI / 2; // Radius from this question's ring band + sub-slot const ring = Math.max(0, Math.min(2, h.ring ?? 0)); const band = ringBands[ring]; const slot = ringSlot[ring]; ringSlot[ring]++; const total = Math.max(1, expectedPerRing[ring]); const sub = total > 1 ? slot / (total - 1) : 0; // 0 (outer of band) → 1 (inner of band) const radiusNorm = band.outer - (band.outer - band.inner) * sub; const radius = rOuter * radiusNorm; const x = cx + radius * Math.cos(angleRad); const y = cy + radius * Math.sin(angleRad); const labelDist = size * 0.045; const lx = cx + (radius + labelDist) * Math.cos(angleRad); const ly = cy + (radius + labelDist) * Math.sin(angleRad); const cosA = Math.cos(angleRad), sinA = Math.sin(angleRad); const anchor = cosA > 0.4 ? 'start' : cosA < -0.4 ? 'end' : 'middle'; const baseline = sinA > 0.5 ? 'hanging' : sinA < -0.5 ? 'auto' : 'middle'; const ranked = Object.entries(acc).sort((a, b) => b[1] - a[1]); const leaderId = ranked.length ? ranked[0][0] : null; const sector = sectors.find(s => s.id === leaderId) || sectors[0]; return { x, y, lx, ly, anchor, baseline, sectorId: sector.id, hue: sector.hue, label: shortAnswer(h.answerLabel), idx: idx + 1, ring, isLast: idx === history.length - 1, }; }); const ranked = Object.entries(acc).sort((a, b) => b[1] - a[1]); const leaderId = ranked.length ? ranked[0][0] : null; const leader = sectors.find(s => s.id === leaderId); return { path: built, leaderInfo: leader ? { label: leader.label, hue: leader.hue, sub: leader.sub } : null }; }, [history, sectors, rings, rOuter, cx, cy, size, totalQuestions]); // Rotate the disc so that the LATEST vertex of the itinerary lands at // the top (north). That keeps the most recent reasoning step in // focus while pushing the rest of the trail toward the front-bottom // of the screen, which is the area closest to the viewer with the // X-tilt — i.e. the most visible. // // We also shift the disc DOWN by ~18% of its size: with the centred // 3D layout the disc's projected height (size * cos28° ≈ 0.88·size) // overflows shorter viewports, hiding the north pole — i.e. the // latest vertex. The downward shift keeps the upper half (where the // path always lives, since the latest vertex is rotated to north and // the path runs inward toward the centre) inside the viewport at the // cost of letting the empty bottom half slip below the fold. This // matches the user request: "always half a sphere visible, so the // path is always on screen". const { rotZ, zoom, translateY } = useMemo(() => { const shift = depth ? size * 0.06 : 0; if (!depth) return { rotZ: 0, zoom: 1, translateY: 0 }; if (path.length === 0) { // No history yet — fall back to focusSector if any if (!focusSector) return { rotZ: 0, zoom: 1, translateY: shift }; const s = sectors.find(s => s.id === focusSector); if (!s) return { rotZ: 0, zoom: 1, translateY: shift }; return { rotZ: ((-s.angle) + 360) % 360, zoom: 1, translateY: shift }; } const last = path[path.length - 1]; const mathAngleDeg = Math.atan2(last.y - cy, last.x - cx) * 180 / Math.PI; const sectorAngleDeg = mathAngleDeg + 90; // 0° = top in our convention const rotZ = ((-sectorAngleDeg) + 360) % 360; return { rotZ, zoom: 1, translateY: shift }; }, [path, focusSector, depth, sectors, cx, cy, size]); // Theme palette per tone. const theme = TONES[tone] || TONES['serif-dark']; // Build sector wedge paths. With N sectors, each spans 360/N degrees. const SPAN = 360 / sectors.length; const wedge = (angleDeg, span = SPAN, rIn = 0, rOut = rOuter) => { const a0 = ((angleDeg - span / 2) - 90) * Math.PI / 180; const a1 = ((angleDeg + span / 2) - 90) * Math.PI / 180; const x0o = cx + rOut * Math.cos(a0), y0o = cy + rOut * Math.sin(a0); const x1o = cx + rOut * Math.cos(a1), y1o = cy + rOut * Math.sin(a1); const x0i = cx + rIn * Math.cos(a0), y0i = cy + rIn * Math.sin(a0); const x1i = cx + rIn * Math.cos(a1), y1i = cy + rIn * Math.sin(a1); const large = span > 180 ? 1 : 0; if (rIn === 0) { return `M ${cx} ${cy} L ${x0o} ${y0o} A ${rOut} ${rOut} 0 ${large} 1 ${x1o} ${y1o} Z`; } return `M ${x0i} ${y0i} L ${x0o} ${y0o} A ${rOut} ${rOut} 0 ${large} 1 ${x1o} ${y1o} L ${x1i} ${y1i} A ${rIn} ${rIn} 0 ${large} 0 ${x0i} ${y0i} Z`; }; return (