diff --git a/app/globals.css b/app/globals.css index 40baff4e..69341b60 100644 --- a/app/globals.css +++ b/app/globals.css @@ -289,6 +289,96 @@ input, .brutalist-root[data-contrast="high"] :focus-visible { outline: 3px solid #fff; outline-offset: 3px; box-shadow: 0 0 0 6px #000; } .field input:focus-visible, .field textarea:focus-visible { outline-offset: 6px; border-bottom-width: 2px; } +/* ---- Scroll motion ---------------------------------------- * + * Two techniques. Scrubbed (CSS scroll timelines) follows scroll position and + * is progressive enhancement: without support the element just shows its final + * state. One-shot (`.is-in`, added by useInView) plays once on entry; the hidden + * start state applies only when scripting is on, so no-JS never sees blank + * content. Both are fenced to no-preference — scroll timelines ignore the + * duration-shrinking reduced-motion rule below, so they must be opted out. */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: scroll()) { + .scroll-progress { + position: fixed; + inset: 0 0 auto 0; + height: 3px; + background: var(--accent); + transform: scaleX(0); + transform-origin: left; + z-index: 55; /* above .site-nav (50) */ + pointer-events: none; + animation: scroll-progress linear both; + animation-timeline: scroll(root); + } + } + @supports (animation-timeline: view()) { + .binary-rule { + animation: rule-wipe steps(14, end) both; + animation-timeline: view(); + animation-range: entry 10% entry 100%; + } + .timeline::before { + content: ""; + position: absolute; + left: 4px; + top: 0; + bottom: 0; + width: 2px; + background: var(--accent); + transform-origin: top; + animation: timeline-spine linear both; + animation-timeline: view(); + animation-range: entry 30% cover 55%; + } + } + @media (scripting: enabled) { + .projects-grid:not(.is-in) .work-card { clip-path: inset(0 0 100% 0); } + .continent-list:not(.is-in) .continent-fill { transform: scaleX(0); } + .continent-list:not(.is-in) .continent-count { opacity: 0; } + } + .projects-grid.is-in .work-card { + /* `backwards` only: a forwards fill would pin transform and kill the hover lift. */ + animation: card-stamp var(--dur-slow) var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 70ms); + } + .continent-list.is-in .continent-fill { + animation: bar-fill var(--dur-slow) var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 60ms); + } + + /* Flight globe intro. Timings mirror useGlobeIntro.ts (DRAW_MS = 900). Routes + carry pathLength=1, so dash units are fractions of each route's length. + The classes drop away after the intro, so routes that rotate into view + later render solid instead of redrawing. */ + .flight-line.is-pending { stroke-dasharray: 1; stroke-dashoffset: 1; } + .flight-line.is-drawing { + animation: route-draw 900ms var(--ease-out) backwards; + animation-delay: var(--delay, 0ms); + } + .airport-dot { transform-box: fill-box; transform-origin: center; } + .airport-dot.is-pending { transform: scale(0); } + .airport-dot.is-drawing { + animation: airport-pop var(--dur-base) var(--ease-out) backwards; + animation-delay: 900ms; + } +} +@keyframes route-draw { + from { stroke-dasharray: 1; stroke-dashoffset: 1; } + to { stroke-dasharray: 1; stroke-dashoffset: 0; } +} +@keyframes airport-pop { from { transform: scale(0); } to { transform: scale(1); } } +@keyframes scroll-progress { to { transform: scaleX(1); } } +@keyframes rule-wipe { + from { clip-path: inset(0 100% 0 0); color: var(--accent-text); } + to { clip-path: inset(0 0 0 0); } +} +@keyframes timeline-spine { from { transform: scaleY(0); } to { transform: scaleY(1); } } +@keyframes card-stamp { + from { clip-path: inset(0 0 100% 0); transform: translateY(14px); } + to { clip-path: inset(0 0 0 0); transform: none; } +} +@keyframes bar-fill { from { transform: scaleX(0); } } + /* ---- Reduced motion --------------------------------------- */ @media (prefers-reduced-motion: reduce) { .brutalist-root *, .brutalist-root *::before, .brutalist-root *::after { @@ -603,7 +693,14 @@ input, position: relative; overflow: hidden; } -.continent-fill { display: block; height: 100%; background: var(--accent); } +.continent-fill { + display: block; + height: 100%; + background: var(--accent); + transform: scaleX(var(--p)); + transform-origin: left; + transition: transform var(--dur-slow) var(--ease-out); +} .continent-count { font-family: var(--font-mono); font-size: 13px; @@ -996,7 +1093,7 @@ input, gap: var(--s-8); align-items: start; } -.timeline { list-style: none; padding: 0; margin: 0; } +.timeline { list-style: none; padding: 0 0 0 var(--s-5); margin: 0; position: relative; } .timeline-row { display: grid; grid-template-columns: 140px 1fr 1.4fr; diff --git a/components/landing/BrutalistLanding.tsx b/components/landing/BrutalistLanding.tsx index 4f0b4b8c..e0f76ed3 100644 --- a/components/landing/BrutalistLanding.tsx +++ b/components/landing/BrutalistLanding.tsx @@ -68,6 +68,7 @@ export function BrutalistLanding({ travel }: { travel: LandingTravelData }) { return (
+
-
-
+

diff --git a/components/landing/useInView.ts b/components/landing/useInView.ts new file mode 100644 index 00000000..cdb5edba --- /dev/null +++ b/components/landing/useInView.ts @@ -0,0 +1,28 @@ +'use client' + +// useInView — flips `inView` to true once, the first time the element nears the +// viewport. One-shot by design: scroll-back never replays the entrance. +import { useEffect, useRef, useState } from 'react' + +export function useInView() { + const ref = useRef(null) + const [inView, setInView] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + const io = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return + setInView(true) + io.disconnect() + }, + // Inset the bottom edge so tall elements trigger before they're half read. + { rootMargin: '0px 0px -10% 0px' } + ) + io.observe(el) + return () => io.disconnect() + }, []) + + return { ref, inView } +} diff --git a/components/travel/TravelMap.tsx b/components/travel/TravelMap.tsx index 9780b307..261f312f 100644 --- a/components/travel/TravelMap.tsx +++ b/components/travel/TravelMap.tsx @@ -11,6 +11,13 @@ import { } from "react-simple-maps"; import { geoOrthographic } from "d3-geo"; import { Country, FlightRoute } from "@/lib/content/travel"; +import { + INTRO_START_LON, + MAX_STAGGER_MS, + prefersReducedMotion, + useGlobeSpin, + useIntroDone, +} from "./useGlobeIntro"; // Served locally so a CDN hiccup can't blank the globe — the site otherwise // has no runtime third-party dependency. @@ -81,6 +88,8 @@ interface TravelMapProps { flightRoutes?: FlightRoute[]; selectedYear?: number; showFlights?: boolean; + /** Flips true once the globe scrolls into view; starts the route draw-in. */ + drawIn?: boolean; } interface TooltipData { @@ -104,12 +113,30 @@ export function TravelMap({ flightRoutes = [], selectedYear, showFlights = false, + drawIn = false, }: TravelMapProps) { const [tooltip, setTooltip] = useState(null); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + // Starts off-axis so the intro spin has somewhere to settle from; the map is + // client-only (dynamic, ssr:false), so reading matchMedia here is safe. const [rotation, setRotation] = useState<[number, number, number]>([ - 0, -20, 0, + prefersReducedMotion() ? 0 : INTRO_START_LON, + -20, + 0, ]); + const userRotated = useRef(false); + const introDone = useIntroDone(drawIn); + useGlobeSpin(drawIn, setRotation, userRotated); + const routeClass = !drawIn + ? "flight-line is-pending" + : introDone + ? "flight-line" + : "flight-line is-drawing"; + const airportClass = !drawIn + ? "airport-dot is-pending" + : introDone + ? "airport-dot" + : "airport-dot is-drawing"; const [isDragging, setIsDragging] = useState(false); const dragStart = useRef<{ x: number; @@ -183,6 +210,7 @@ export function TravelMap({ const handleMouseDown = useCallback( (e: React.PointerEvent) => { + userRotated.current = true; setIsDragging(true); dragStart.current = { x: e.clientX, @@ -259,6 +287,7 @@ export function TravelMap({ aria-label={`Globe showing ${displayedCountries.length} visited countries. Drag or use the arrow keys to rotate.`} onKeyDown={(e) => { const step = 10; + userRotated.current = true; const [lon, lat] = rotation; // Match drag: dragging up increases rotation[1] (dy < 0, so // rotation[1] - dy*sensitivity grows), so ArrowUp must also increase it. @@ -354,7 +383,15 @@ export function TravelMap({ strokeOpacity={0.6} strokeWidth={Math.min(0.8 + route.count * 0.15, 3)} strokeLinecap="round" - style={{ cursor: "pointer" }} + // Normalises dash lengths so the draw-in works for any route length. + pathLength={1} + className={routeClass} + style={ + { + cursor: "pointer", + "--delay": `${Math.min(idx * 25, MAX_STAGGER_MS)}ms`, + } as React.CSSProperties + } onMouseEnter={() => showFlightTooltip(route.from, route.to, route.count) } @@ -382,6 +419,7 @@ export function TravelMap({ r={Math.min(2.5 + airport.count * 0.05, 5)} stroke="var(--map-stroke)" strokeWidth={0.5} + className={airportClass} style={{ fill: "var(--accent)", cursor: "pointer" }} /> diff --git a/components/travel/useGlobeIntro.ts b/components/travel/useGlobeIntro.ts new file mode 100644 index 00000000..824b59fd --- /dev/null +++ b/components/travel/useGlobeIntro.ts @@ -0,0 +1,50 @@ +'use client' + +// Intro choreography for the flight globe: the globe drifts into place while the +// routes draw in (CSS, see "Flight globe intro" in globals.css). Timings here +// must stay in step with the --draw-ms keyframe duration there. +import { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react' + +type Rotation = [number, number, number] + +export const INTRO_START_LON = -50 +export const DRAW_MS = 900 +export const MAX_STAGGER_MS = 1200 +const SPIN_MS = 1400 +const INTRO_TOTAL_MS = MAX_STAGGER_MS + DRAW_MS + 300 + +export const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + +/** True once the intro window after `active` has elapsed. Stays true afterwards. */ +export function useIntroDone(active: boolean) { + const [done, setDone] = useState(false) + useEffect(() => { + if (!active) return + const id = setTimeout(() => setDone(true), INTRO_TOTAL_MS) + return () => clearTimeout(id) + }, [active]) + return done +} + +/** Eases longitude from INTRO_START_LON to 0 once `active`; yields to any user input. */ +export function useGlobeSpin( + active: boolean, + setRotation: Dispatch>, + interrupted: RefObject +) { + useEffect(() => { + if (!active || prefersReducedMotion()) return + const start = performance.now() + let raf = 0 + const step = (now: number) => { + if (interrupted.current) return + const t = Math.min(1, (now - start) / SPIN_MS) + const eased = 1 - Math.pow(1 - t, 3) + setRotation(([, lat]) => [INTRO_START_LON * (1 - eased), lat, 0]) + if (t < 1) raf = requestAnimationFrame(step) + } + raf = requestAnimationFrame(step) + return () => cancelAnimationFrame(raf) + }, [active, setRotation, interrupted]) +}