);
}
// Streak palette and layout helper. Streaks always start at heroEnd (just
// past the helmet zoom) and fall the full remaining document height.
const STREAK_PALETTE = ["#ff7a00", "#ffa040", "#ffd166", "#00d9ff", "#7ae6ff"];
function getLayout() {
const vh = window.innerHeight;
// Spawn well inside the helmet's sticky coverage (viewport 0–100vh) so new
// streaks are fully hidden by the helmet image and only emerge as they
// fall past it. Fall distance grows accordingly so streaks still reach
// the bottom of the document.
const heroEnd = vh * 0.5;
const docHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight,
);
const fallDistance = Math.max(vh * 6, docHeight - heroEnd);
return { heroEnd, fallDistance };
}
// Minimum horizontal separation (in percent of container width) between any
// two streaks. With 8 slots and 100% width, an even distribution lands at
// 12.5% gaps - leaving us comfortable headroom at 10%.
const MIN_LEFT_GAP = 10;
function pickSpacedLeft(existingLefts) {
// Try a handful of random samples, accept the first one that clears the
// gap threshold. If none does, fall back to the candidate with the
// largest minimum distance - keeps streaks as far apart as possible even
// when the slot pool is already saturated.
let bestLeft = Math.random() * 100;
let bestMinDist = -Infinity;
for (let i = 0; i < 30; i++) {
const candidate = Math.random() * 100;
let minDist = Infinity;
for (const ex of existingLefts) {
const d = Math.abs(candidate - ex);
if (d < minDist) minDist = d;
}
if (existingLefts.length === 0 || minDist >= MIN_LEFT_GAP) {
return candidate;
}
if (minDist > bestMinDist) {
bestMinDist = minDist;
bestLeft = candidate;
}
}
return bestLeft;
}
function makeStreakConfig({ initial, existingLefts = [] }) {
// Streaks now fall at a constant px/s rate; the Streak component reads the
// current fallDistance every frame via a ref, so a streak in flight when
// the document grows (images/fonts loaded late) automatically continues
// until it reaches the new bottom rather than despawning mid-page.
return {
id: Math.random().toString(36).slice(2),
left: pickSpacedLeft(existingLefts),
color: STREAK_PALETTE[Math.floor(Math.random() * STREAK_PALETTE.length)],
// One unified rate for every streak (initial wave + replacements). An
// earlier version used 150 px/s for the first wave and 380 px/s for
// steady-state, but when the slow wave finished the rate jumped 2.5x
// all at once - users noticed it as the streaks "suddenly going fast."
// Constant 200 px/s sidesteps the discontinuity entirely.
fallRate: 200,
// Positive delays stagger the initial wave so streaks emerge one by one
// from the top instead of appearing already mid-flight.
delay: initial ? Math.random() * 8 : 0,
height: 70 + Math.random() * 140,
};
}
// Single streak - animated by requestAnimationFrame in JS. We previously
// used a CSS animation with `translateY(var(--fall))`, but the @property +
// custom-property interpolation is unreliable across browsers (in some
// browsers the transform snaps from 0 to the final value with no visible
// fall, which made streaks appear and then "disappear" before reaching the
// user). JS animation is bulletproof: every frame, we set the transform
// based on real elapsed time, and the streak truly travels from heroEnd
// down to docBottom.
const Streak = React.memo(function Streak({ slot, cfg, heroEndPx, fallDistance, onDone }) {
const ref = useRef(null);
// Live fallDistance ref so the animate loop reads the latest value every
// frame. Prevents existing streaks from despawning at a stale boundary if
// the document grows mid-flight (images/fonts/lazy content), and avoids
// restarting the RAF when the prop changes.
const fallDistanceRef = useRef(fallDistance);
useEffect(() => { fallDistanceRef.current = fallDistance; }, [fallDistance]);
useEffect(() => {
let raf = 0;
let cancelled = false;
// Per-frame delta accumulation rather than (now - startTime). When the
// tab is hidden, RAF pauses but performance.now() keeps advancing - if
// we computed total elapsed on resume, every streak would teleport past
// the bottom and fire onDone in unison, spawning a synchronized burst
// of replacements. Clamping dt to 100ms makes the streak effectively
// pause while the tab is away and resume from where it left off.
let lastTs = null;
let waitedMs = 0;
let fallen = 0;
const totalDelayMs = cfg.delay * 1000;
if (ref.current) {
ref.current.style.transform = "translateY(0px)";
}
const animate = (now) => {
if (cancelled) return;
if (lastTs !== null) {
const dt = Math.min(now - lastTs, 100);
if (waitedMs < totalDelayMs) {
waitedMs += dt;
} else {
fallen += (dt / 1000) * cfg.fallRate;
}
}
lastTs = now;
const fd = fallDistanceRef.current;
if (ref.current) {
ref.current.style.transform = `translateY(${Math.min(fallen, fd)}px)`;
}
if (fallen < fd) {
raf = requestAnimationFrame(animate);
} else {
onDone(slot);
}
};
raf = requestAnimationFrame(animate);
return () => {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
};
}, [slot, cfg.id, cfg.fallRate, cfg.delay, onDone]);
return (
);
});
// Visor exhaust - 5 streaks falling from heroEnd all the way down to the
// bottom of the document. Each is animated by its own RAF loop. After the
// fall completes naturally, the slot's React key changes and a new streak
// mounts to start the journey over.
function VisorExhaust() {
const containerRef = useRef(null);
const [layout, setLayout] = useState(getLayout);
const [slots, setSlots] = useState(() => {
// Build the initial 8 sequentially so each new streak knows the lefts
// already taken and can pick a spaced-out position.
const arr = [];
for (let i = 0; i < 8; i++) {
arr.push(makeStreakConfig({
initial: true,
existingLefts: arr.map((s) => s.left),
}));
}
return arr;
});
const replaceSlot = useCallback((slot) => {
setSlots((prev) => {
const next = [...prev];
const others = prev.filter((_, i) => i !== slot).map((s) => s.left);
next[slot] = makeStreakConfig({ initial: false, existingLefts: others });
return next;
});
}, []);
useEffect(() => {
const remeasure = () => setLayout(getLayout());
const onScroll = () => {
const el = containerRef.current;
if (!el) return;
const y = window.scrollY;
const vh = window.innerHeight;
// Trigger fade-in over the last bit of the hero so streaks become
// visible right as the helmet exits the viewport.
const start = vh * 1.7;
const end = vh * 2;
el.style.opacity = String(Math.max(0, Math.min(1, (y - start) / (end - start))));
};
const onResize = () => {
remeasure();
onScroll();
};
onScroll();
remeasure();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
window.addEventListener("load", remeasure);
// The document keeps growing after initial mount as images, fonts, and
// late-laying-out sections settle. Without re-measuring, the streaks'
// fallDistance freezes at the early-mount value and they despawn mid-
// page (around the Activities section) instead of reaching the footer.
let ro = null;
if (typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(remeasure);
ro.observe(document.body);
}
document.fonts?.ready?.then(remeasure).catch(() => {});
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
window.removeEventListener("load", remeasure);
if (ro) ro.disconnect();
};
}, []);
return (