/*
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 (
);
}
Object.assign(window, { EcosystemSceneSquare: EcosystemScene });