/* Ecosystem Scene — square version, optimized for small display. Circular flywheel layout. No headline / chrome / masthead. Designed for 1080×1080; legible down to ~280px. */ const ink = '#171717'; const ink3 = '#555555'; const ink4 = '#777777'; const accent = '#026203'; const accentDk = '#024203'; const rule = '#d8d8d0'; const paper = '#ffffff'; /* Draws an SVG path on via stroke-dashoffset. */ function Stroke({ d, color = ink, width = 3, drawStart = 0, drawEnd = 1, ease = Easing.easeInOutCubic, cap = 'round', join = 'round', fill = 'none', opacity = 1, }) { const ref = React.useRef(null); const [len, setLen] = React.useState(null); React.useEffect(() => { if (ref.current && ref.current.getTotalLength) { try { setLen(ref.current.getTotalLength() || 1); } catch {} } }, [d]); const t = useTime(); const span = Math.max(0.001, drawEnd - drawStart); const raw = (t - drawStart) / span; const p = ease(clamp(raw, 0, 1)); const hidden = (raw <= 0) || len == null; return ( ); } function FadeInSvg({ at, dur = 0.5, y = 6, children }) { const t = useTime(); const p = Easing.easeOutCubic(clamp((t - at) / dur, 0, 1)); return ( {children} ); } function DotAt({ cx, cy, at, color = '#024203', r0 = 4 }) { const t = useTime(); const p = Easing.easeOutBack(clamp((t - at) / 0.35, 0, 1)); return ; } /* ── Node icon components — drawn from local origin (0,0) ── */ function ResearchIcon({ start }) { // document: 220 × 260 return ( {/* lines */} {/* green highlight */} ); } function MicIcon({ start }) { return ( {/* capsule */} {/* grille lines */} {/* U-stand */} {/* stem */} {/* base */} {/* waves */} ); } function AudienceIcon({ start }) { // 280 × 240 frame: meridian split, dots populating return ( {/* Europe dots (left) */} {[ [-104, -78], [-66, -86], [-36, -58], [-114, -36], [-78, -44], [-44, -20], [-96, 4], [-60, -4], [-28, 12], [-104, 38], [-70, 50], [-36, 36], [-90, 78], [-44, 86], ].map(([cx, cy], i) => ( ))} {/* Africa dots (right) */} {[ [ 36, -80], [ 74, -58], [108, -68], [ 24, -36], [ 62, -20], [104, -34], [ 32, 8], [ 74, 0], [112, 22], [ 46, 42], [ 86, 46], [122, 56], [ 54, 86], [ 96, 92], [ 28, 92], ].map(([cx, cy], i) => ( ))} ); } function BubbleIcon({ start }) { return ( {/* ? */} ); } /* Pulse dot that runs around the orbit forever from `start`. */ function OrbitPulse({ start, cx, cy, r, period = 6, color = accent }) { const t = useTime(); if (t < start) return null; const phase = ((t - start) / period) % 1; const angle = -Math.PI / 2 + phase * Math.PI * 2; // start at top, clockwise const x = cx + Math.cos(angle) * r; const y = cy + Math.sin(angle) * r; return ( ); } /* Arc between two angles (in degrees) around (cx, cy, r), with arrowhead at end. */ function OrbitArc({ start, drawStart, drawEnd, fromDeg, toDeg, cx, cy, r, color = ink, width = 4 }) { // path from fromDeg to toDeg (clockwise) const toRad = d => (d * Math.PI) / 180; const ax = cx + Math.cos(toRad(fromDeg)) * r; const ay = cy + Math.sin(toRad(fromDeg)) * r; const bx = cx + Math.cos(toRad(toDeg)) * r; const by = cy + Math.sin(toRad(toDeg)) * r; // sweep flag: 1 for clockwise (since SVG y is flipped relative to math) const sweep = 1; const large = (((toDeg - fromDeg) % 360) + 360) % 360 > 180 ? 1 : 0; const d = `M ${ax} ${ay} A ${r} ${r} 0 ${large} ${sweep} ${bx} ${by}`; // arrowhead: tangent at end angle, pointing along clockwise tangent const tanAngle = toRad(toDeg) + Math.PI / 2; // tangent perpendicular to radius (clockwise) const aLen = 18; const aWide = 10; const tipX = bx; const tipY = by; const baseX = tipX - Math.cos(tanAngle) * aLen; const baseY = tipY - Math.sin(tanAngle) * aLen; const leftX = baseX + Math.cos(tanAngle + Math.PI / 2) * aWide; const leftY = baseY + Math.sin(tanAngle + Math.PI / 2) * aWide; const rightX = baseX + Math.cos(tanAngle - Math.PI / 2) * aWide; const rightY = baseY + Math.sin(tanAngle - Math.PI / 2) * aWide; const head = `M ${leftX} ${leftY} L ${tipX} ${tipY} L ${rightX} ${rightY}`; return ( ); } /* ──────────────────────────────────────────────── THE SCENE — square 1080×1080 ──────────────────────────────────────────────── */ function EcosystemScene() { const cx = 540, cy = 540, R = 240; /* node positions (clock face, clockwise) */ // top = -90, right = 0, bottom = 90, left = 180 const node = (deg) => ({ x: cx + Math.cos((deg * Math.PI) / 180) * R, y: cy + Math.sin((deg * Math.PI) / 180) * R, deg, }); const N1 = node(-90); // research — top const N2 = node(0); // podcast — right const N3 = node(90); // audience — bottom const N4 = node(180); // questions — left /* timing (total 9s) */ const T = { n1: 0.10, // research draws n1lbl: 0.70, a1: 0.95, // arc research→podcast n2: 1.55, // podcast draws n2lbl: 2.15, a2: 2.40, // arc podcast→audience n3: 3.00, // audience draws n3lbl: 3.60, a3: 3.85, // arc audience→questions n4: 4.45, // questions draws n4lbl: 5.05, a4: 5.30, // arc questions→research (closes loop, slower & green) pulse: 6.70, // orbit pulse begins once loop is closed }; // Arc gap: shrink the arc so it doesn't overlap the icons const gap = 18; // degrees of padding on each end return (
{/* faint guide ring — drawn very softly so you sense the orbit */} {/* ────── Nodes ────── */} {/* ────── Node labels — all placed radially outward, kept well inside the 1080×1080 frame ────── */} {/* Research — above top icon */} Research findings & papers {/* Podcast — to the right of right icon */} Podcast conversations {/* Audience — below bottom icon */} Audience two continents {/* Questions — to the left of left icon */} Questions & connections {/* ────── Connecting arcs (clockwise around the orbit) ────── */} {/* a1: top → right */} {/* a2: right → bottom */} {/* a3: bottom → left */} {/* a4: left → top (closes loop) — green! */} {/* ────── Orbiting pulse — runs continuously after the loop closes ────── */} ); } Object.assign(window, { EcosystemSceneSquare: EcosystemScene });