// diagram.jsx — the About/landing constellation diagram from Figma frame 3. // A conceptual plot: vertical axis CURSED↔BLESSED, horizontal axis FOR US↔FOR OUR ENEMIES, // a large crosshair, and 16 white project nodes. Nodes hover→readout, click→project. // When unlocked (authoring), nodes drag freely; positions persist in localStorage. // Exposes window.AboutDiagram const { useState: useDState, useRef: useDRef, useCallback: useDCallback } = React; // ----- geometry from Figma (frame 1728×1117), expressed as viewport percentages const FX = (x) => (x / 1728) * 100; const FY = (y) => (y / 1117) * 100; // Default node positions: 14 exact Figma ellipse centres + 2 added for projects 15/16. const DEFAULT_NODES = [ { x: FX(935.5), y: FY(814.5) }, { x: FX(1046.5), y: FY(588.5) }, { x: FX(1024.5), y: FY(713.5) }, { x: FX(857.5), y: FY(744.5) }, { x: FX(857.5), y: FY(356.5) }, { x: FX(1377.5), y: FY(343.5) }, { x: FX(1430.5), y: FY(467.5) }, { x: FX(1151.5), y: FY(498.5) }, { x: FX(1011.5), y: FY(575.5) }, { x: FX(922.5), y: FY(690.5) }, { x: FX(1157.5), y: FY(719.5) }, { x: FX(751.5), y: FY(888.5) }, { x: FX(801.5), y: FY(178.5) }, { x: FX(1399.5), y: FY(165.5) }, { x: FX(520), y: FY(500) }, // added — project 15 { x: FX(1520), y: FY(640) }, // added — project 16 { x: FX(620), y: FY(750) }, // added — project 17 { x: FX(1600), y: FY(820) }, // added — project 18 ]; // Axis geometry (percentages) const AXIS = { mainH: { y: FY(519), x1: FX(698), x2: FX(1554) }, mainV: { x: FX(1126), y1: FY(91), y2: FY(947) }, yLeg: { x: FX(698), y1: FY(391), y2: FY(646.5) }, // CURSED(top) / BLESSED(bottom) xLeg: { y: FY(947), x1: FX(998), x2: FX(1253.5) }, // FOR US(left) / FOR OUR ENEMIES(right) }; const STORAGE_KEY = 'diagram-node-pos-v1'; // deterministic per-id offset so each node's hover-image sits at a fixed random angle, // far enough out that the image never overlaps the node's glow. function offsetFor(id) { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 100000; const angle = (h % 360) / 360 * Math.PI * 2; const dist = 150 + (h % 70); return { dx: Math.cos(angle) * dist, dy: Math.sin(angle) * dist }; } // Projects whose hover image should appear toward the diagram centre instead of // at a random outward angle (their landscape previews read better near the middle). const CENTER_BIAS = { 'wind-sensor': true, 'rivers-dream': true }; // offset that points from a node toward the diagram centre (percent space → px). function centerOffset(pt) { const cx = 65, cy = 46; // crosshair centre, in viewport % const vw = window.innerWidth, vh = window.innerHeight; let pxX = ((cx - pt.x) / 100) * vw; let pxY = ((cy - pt.y) / 100) * vh; const len = Math.hypot(pxX, pxY) || 1; const dist = 140; return { dx: (pxX / len) * dist, dy: (pxY / len) * dist }; } // first image shown at the top of this project's index stack (respects saved reorder) function topImageFor(p) { let order = p.gallery.map((_, i) => i); try { const s = JSON.parse(localStorage.getItem('gallery-order-' + p.id)); if (Array.isArray(s) && s.length === p.gallery.length) order = s; } catch (_) {} const ordered = order.map((i) => p.gallery[i]); const first = ordered[0]; if (first && first.type === 'image') return first.src; const img = ordered.find((it) => it.type === 'image'); return img ? img.src : null; } function loadPositions(projects) { let saved = {}; try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } catch (_) {} const map = {}; projects.forEach((p, i) => { map[p.id] = saved[p.id] || DEFAULT_NODES[i % DEFAULT_NODES.length]; }); return map; } // Hover preview image. Sizes to a consistent target HEIGHT (so landscape and // portrait previews carry equal visual weight), positions near the node, then // clamps to the viewport so it can never clip off-screen. function HoverImage({ src, pt, off }) { const boxRef = useDRef(null); const [c, setC] = useDState(null); const [w, setW] = useDState(186); const onImgLoad = useDCallback((e) => { const im = e.target; const aspect = (im.naturalWidth && im.naturalHeight) ? im.naturalWidth / im.naturalHeight : 0.72; const TARGET_H = 220, MIN_W = 150, MAX_W = 360; setW(Math.round(Math.max(MIN_W, Math.min(MAX_W, TARGET_H * aspect)))); }, []); const measure = useDCallback(() => { const el = boxRef.current; if (!el) return; const vw = window.innerWidth, vh = window.innerHeight; const ww = el.offsetWidth || 186; const hh = el.offsetHeight || 140; const margin = 16; let cx = (pt.x / 100) * vw + off.dx; let cy = (pt.y / 100) * vh + off.dy; cx = Math.max(margin + ww / 2, Math.min(vw - margin - ww / 2, cx)); cy = Math.max(margin + hh / 2, Math.min(vh - margin - hh / 2, cy)); setC({ cx, cy }); }, [pt.x, pt.y, off.dx, off.dy]); React.useLayoutEffect(() => { measure(); }, [measure, src, w]); return (