// 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 (
); } function _HoverImage_unused({ src, pt, off }) { const boxRef = useDRef(null); const [c, setC] = useDState(null); const measure = useDCallback(() => { const el = boxRef.current; if (!el) return; const vw = window.innerWidth, vh = window.innerHeight; const w = el.offsetWidth || 186; const h = 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 + w / 2, Math.min(vw - margin - w / 2, cx)); cy = Math.max(margin + h / 2, Math.min(vh - margin - h / 2, cy)); setC({ cx, cy }); }, [pt.x, pt.y, off.dx, off.dy]); React.useLayoutEffect(() => { measure(); }, [measure, src]); return (
); } function EndDot({ x, y }) { return (
); } function AboutDiagram({ projects, locked, onOpen, onHover, hoveredId, mobile = false }) { const [pos, setPos] = useDState(() => loadPositions(projects)); const dragId = useDRef(null); const layerRef = useDRef(null); const movedRef = useDRef(false); const persist = (next) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (_) {} }; const onPointerDown = useDCallback((e, id) => { if (locked) return; e.preventDefault(); e.stopPropagation(); dragId.current = id; movedRef.current = false; }, [locked]); React.useEffect(() => { if (locked) return; const onMove = (e) => { if (!dragId.current || !layerRef.current) return; const r = layerRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)); const y = Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100)); movedRef.current = true; setPos((prev) => ({ ...prev, [dragId.current]: { x, y } })); }; const onUp = () => { if (dragId.current) { dragId.current = null; setPos((p) => { persist(p); return p; }); } }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [locked]); const lineCol = 'rgba(255,255,255,0.85)'; const label = { position: 'absolute', fontFamily: FONT, fontSize: 8, color: '#fff', letterSpacing: '0.02em', whiteSpace: 'nowrap', pointerEvents: 'none', }; return (
{/* ---- axes (SVG, non-scaling stroke) ---- */} {/* ---- axis end dots ---- */} {/* ---- axis labels ---- */}
CURSED
BLESSED
FOR US
FOR OUR ENEMIES
{/* ---- nodes ---- */} {projects.map((p) => { const pt = pos[p.id] || { x: 50, y: 50 }; const hot = hoveredId === p.id; return (
locked && onHover(p)} onMouseLeave={() => locked && onHover(null)} onMouseDown={(e) => onPointerDown(e, p.id)} onClick={() => { if (locked && !movedRef.current) onOpen(p); }} style={{ position: 'absolute', left: pt.x + '%', top: pt.y + '%', width: 13, height: 13, marginLeft: -6.5, marginTop: -6.5, borderRadius: '50%', background: hot ? '#fff' : 'rgb(217,217,217)', cursor: locked ? 'pointer' : 'grab', pointerEvents: 'auto', transform: hot ? 'scale(1.5)' : 'scale(1)', boxShadow: hot ? '0 0 14px 3px rgba(255,255,255,0.9), 0 0 26px 8px rgba(255,255,255,0.4)' : '0 0 0 0 rgba(255,255,255,0)', transition: dragId.current === p.id ? 'none' : 'transform .2s ease, box-shadow .2s ease, background .2s ease', }} /> ); })} {/* ---- hover image: first project image, offset near the node, clamped on-screen ---- */} {(() => { if (!hoveredId) return null; const hp = projects.find((p) => p.id === hoveredId); if (!hp) return null; const firstImg = topImageFor(hp); if (!firstImg) return null; const pt = pos[hoveredId] || { x: 50, y: 50 }; const off = CENTER_BIAS[hoveredId] ? centerOffset(pt) : offsetFor(hoveredId); return ; })()} {/* ---- unlocked hint ---- */} {!locked && (
Unlocked — drag nodes to position, then lock in Tweaks
)}
); } window.AboutDiagram = AboutDiagram; window.resetDiagramPositions = function () { try { localStorage.removeItem(STORAGE_KEY); } catch (_) {} };