diff --git a/apps/site/src/components/Aurora.astro b/apps/site/src/components/Aurora.astro index 24aa18f..888a982 100644 --- a/apps/site/src/components/Aurora.astro +++ b/apps/site/src/components/Aurora.astro @@ -155,6 +155,7 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag import type { SiteTimes } from "../scripts/aurora.ahead"; import type { AuroraMessage, + PageBox, SkyLabel, SkyLine, WorkerMessage, @@ -206,6 +207,61 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag const info = document.querySelector(".sky-info-button"); let post: ((message: AuroraMessage) => void) | null = null; + // Huginn's fly-in draws in the header, title, intro and cards as he + // reaches them. Base.astro hides them (`ng-await`) until the worker says + // he has reached each one; without a fly-in they come in by the usual + // entrance instead. The panes go first (the header, then the cards): + // the worker plans his path through the first two. + const page = document.documentElement; + const panes = page.classList.contains("ng-await") + ? [ + ...document.querySelectorAll(".ng-enter-pane"), + ...document.querySelectorAll(".ng-enter-text"), + ] + : []; + function isAwaiting(): boolean { + return page.classList.contains("ng-await"); + } + function paneBoxes(): PageBox[] { + if (!isAwaiting()) return []; + return panes.map((el) => { + const r = el.getBoundingClientRect(); + return [r.left, r.top, r.width, r.height]; + }); + } + function showAllPanes(): void { + page.classList.remove("ng-await"); + } + let lastAngle = 135; + let backstop = 0; + // Sweep the pane in toward `angle` (a CSS gradient angle), drifting a + // little along his line, over `duration` seconds. + function brushPane(i: number, angle: number, duration: number): void { + const el = panes[i]; + if (!el || !isAwaiting() || el.classList.contains("ng-brushed")) return; + lastAngle = angle; + const a = (angle * Math.PI) / 180; + const [dx, dy] = [Math.sin(a), -Math.cos(a)]; + const k = Math.max(Math.abs(dx), Math.abs(dy)) || 1; + el.style.setProperty("--ng-angle", `${angle}deg`); + el.style.setProperty( + "--ng-from", + `${50 + (50 * dx) / k}% ${50 + (50 * dy) / k}%`, + ); + el.style.setProperty( + "--ng-to", + `${50 - (50 * dx) / k}% ${50 - (50 * dy) / k}%`, + ); + el.style.setProperty("--ng-dx", dx.toFixed(3)); + el.style.setProperty("--ng-dy", dy.toFixed(3)); + el.style.setProperty("--ng-brush-duration", `${duration}s`); + el.classList.add("ng-brushed"); + // Should the rest never come (the worker stalled), they follow anyway. + backstop ||= window.setTimeout(() => { + panes.forEach((_, j) => brushPane(j, lastAngle, 1.6)); + }, 8000); + } + // All WebGL runs in the worker. Without OffscreenCanvas the CSS gradient // stays, so the main thread never pays for the effect. const canvas = document.querySelector(".aurora canvas"); @@ -235,12 +291,16 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag site: chosen, still: motion.matches, hidden: document.hidden, + targets: paneBoxes(), }, [offscreen], ); worker.onmessage = ({ data }: MessageEvent) => { if (typeof data === "object") { - if ("pick" in data) { + if ("brush" in data) { + if (data.brush < 0) showAllPanes(); + else brushPane(data.brush, data.angle ?? 135, data.duration ?? 1.6); + } else if ("pick" in data) { showPick(data.pick); } else if ("times" in data) { showSiteTimes(data.times, data.zone); @@ -251,7 +311,10 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag return; } const on = data === "on"; - if (!on) showPick(null); + if (!on) { + showPick(null); + showAllPanes(); + } canvas.classList.toggle("on", on); if (info) info.hidden = !on; }; @@ -270,12 +333,16 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag type: "size", width: canvas.clientWidth, height: canvas.clientHeight, + targets: paneBoxes(), }), 150, ); }).observe(canvas); } - if ("requestIdleCallback" in window) { + // The header and cards wait for the fly-in: start at once. + if (isAwaiting()) { + startWorker(canvas); + } else if ("requestIdleCallback" in window) { requestIdleCallback(() => startWorker(canvas), { timeout: 1500 }); } else { setTimeout(() => startWorker(canvas), 200); diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro index 663fc6f..33a6af1 100644 --- a/apps/site/src/layouts/Base.astro +++ b/apps/site/src/layouts/Base.astro @@ -35,6 +35,27 @@ const { type="font/woff2" crossorigin /> + { + entrance && ( + + ) + }
@@ -160,6 +181,86 @@ const { } } + /* ---- Huginn's wake ---- + * On the home page the header, title, intro and cards wait, hidden, for + * the fly-in + * (`ng-await`, set in the head). As Huginn passes each one the page adds + * `ng-brushed` with his heading: the same aurora light sweeps across it in + * the direction he flies (`--ng-angle`, from `--ng-from` to `--ng-to`), + * paced to him (`--ng-brush-duration`), and the pane drifts a few pixels + * along his line into place, as if his wings brushed it there. + * + * The mask is three times the pane's size, so the sweep's edge enters the + * pane a quarter of the way through and leaves it at three quarters: at a + * steady pace it trails just behind him as he crosses. */ + html.ng-await :is(.ng-enter-pane, .ng-enter-text):not(.ng-brushed) { + animation-name: none; + opacity: 0; + } + :is(.ng-enter-pane, .ng-enter-text).ng-brushed { + --ng-sweep: linear-gradient( + var(--ng-angle, 135deg), + #000 38%, + transparent 62% + ); + mask-size: 300% 300%; + mask-repeat: no-repeat; + animation-duration: var(--ng-brush-duration, 1.6s); + animation-timing-function: linear; + animation-delay: 0s; + } + .ng-enter-pane.ng-brushed { + animation-name: ng-brush-pane; + } + .ng-enter-text.ng-brushed { + animation-name: ng-brush-text; + } + @keyframes ng-brush-text { + 0% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-from, 100% 100%); + translate: calc(var(--ng-dx, 0.7) * -10px) calc(var(--ng-dy, 0.7) * -10px); + filter: blur(12px); + color: var(--ng-aurora); + text-shadow: 0 0 24px color(from var(--ng-aurora) srgb r g b / 0.7); + box-shadow: 0 0 48px 32px transparent; + } + 45% { + filter: blur(3px); + color: var(--ng-aurora); + text-shadow: 0 0 18px color(from var(--ng-aurora) srgb r g b / 0.45); + } + 100% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-to, 0% 0%); + translate: 0 0; + filter: blur(0); + text-shadow: 0 0 0 transparent; + box-shadow: 0 0 48px 32px transparent; + } + } + @keyframes ng-brush-pane { + 0% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-from, 100% 100%); + translate: calc(var(--ng-dx, 0.7) * -10px) calc(var(--ng-dy, 0.7) * -10px); + filter: blur(10px); + border-color: color(from var(--ng-aurora) srgb r g b / 0.7); + box-shadow: 0 0 44px -4px color(from var(--ng-aurora) srgb r g b / 0.5); + } + 45% { + filter: blur(2px); + border-color: color(from var(--ng-aurora) srgb r g b / 0.45); + box-shadow: 0 0 32px -6px color(from var(--ng-aurora) srgb r g b / 0.3); + } + 100% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-to, 0% 0%); + translate: 0 0; + filter: blur(0); + } + } + /* Reduced motion: no drifting light or blur, just a short fade. */ @media (prefers-reduced-motion: reduce) { .ng-enter-text, diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 7584170..ad79027 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -50,9 +50,10 @@ const repos = [ pointer-events: auto; } - /* The entrance (`.ng-enter-*`, Base.astro): the header leads, then the - title, the line under it, and the cards in turn, landing as the sky's own - fade-in (1.6s) finishes. */ + /* Usually Huginn's fly-in draws these in as he reaches them (Aurora.astro). + Without it, the entrance (`.ng-enter-*`, Base.astro) plays: the header + leads, then the title, the line under it, and the cards in turn, landing + as the sky's own fade-in (1.6s) finishes. */ h1 { --ng-delay: 0.25s; } diff --git a/apps/site/src/scripts/aurora.raven.ts b/apps/site/src/scripts/aurora.raven.ts index 36953ee..d5da40e 100644 --- a/apps/site/src/scripts/aurora.raven.ts +++ b/apps/site/src/scripts/aurora.raven.ts @@ -2,9 +2,16 @@ // 3D world as the sky. Units: metres and seconds. World frame: x east, y up, // z north, the viewer's eye at the origin. // -// Each flight is planned in the camera's frame so that it crosses the window: -// usually a pass along a gentle curve 20 to 65 m away, sometimes a few lazy -// circles farther out, low over the horizon where the aurora lights the sky. The bird is a small mesh posed every frame. It flaps in +// The first flight is a fly-in: he comes in over the viewer's shoulder from +// just past the top-left corner, close enough to fill a good part of the +// screen, glides out over the page, swoops low to the bottom right and +// leaves into the distance, a little differently every time. His flight +// draws in the page's header, title and cards (see aurora.worker.ts). +// +// After that each flight is planned in the camera's frame so that it crosses +// the window: usually a pass along a gentle curve 20 to 65 m away, sometimes +// a few lazy circles farther out, low over the horizon where the aurora +// lights the sky. The bird is a small mesh posed every frame. It flaps in // bouts at about 3.4 beats a second, the hand folding back on the upstroke, // and glides in between. It banks into its turns and now and then rolls onto // its back for a moment, as ravens do. Proportions follow the common raven: @@ -27,18 +34,27 @@ export type RavenView = { /** The highest a flight's middle goes on screen (-1 bottom, 1 top): clear * of the page's cards. */ ceiling: number; + /** The boxes a fly-in should pass behind, header first, on screen (-1 to + * 1, y up). Empty for a fly-in over the usual places. */ + targets: ScreenBox[]; }; +/** A box on screen: left, bottom, right, top, each -1 to 1, y up. */ +export type ScreenBox = [number, number, number, number]; + export type RavenTiming = { - /** Seconds before the first flight. */ - first: [number, number]; + /** Seconds before the fly-in, once the sky is up. */ + entrance: [number, number]; /** Seconds between flights. */ gap: [number, number]; }; -/** The first flight as the page opens, then a new one about 30 s after - * each ends. */ -export const RAVEN_TIMING: RavenTiming = { first: [0.5, 1.5], gap: [27, 33] }; +/** The fly-in as the sky fades in, then a new flight 15 to 20 s after each + * ends. */ +export const RAVEN_TIMING: RavenTiming = { + entrance: [0.5, 0.8], + gap: [15, 20], +}; const G = 9.81; /** Metres over which the bird fades in at a flight's start and out at its @@ -392,6 +408,16 @@ type Path = { length: number; /** Airspeed, m/s. */ speed: number; + /** For a flown path (see fly()), the speed at each point; null for + * `speed` all the way. */ + speeds: number[] | null; + /** Slow motion near the viewer, as wildlife film does it: time runs + * slower the nearer he is (see timeScaleAt), so up close he is big and + * unhurried and his speed across the screen stays calm. */ + slowNear: boolean; + /** Whether to fade in over the first FADE metres: not for a fly-in, which + * starts out of frame and close by. */ + fadeIn: boolean; soar: boolean; /** Distance along the path at which to roll over, or -1 for none. */ rollAt: number; @@ -449,21 +475,58 @@ function curve(through: V[], speed: number, soar: boolean, roll: number): Path { } const length = dist[dist.length - 1]!; const rollAt = Math.random() < roll ? length * randomBetween(0.35, 0.6) : -1; - return { points, dist, length, speed, soar, rollAt }; + return { + points, + dist, + length, + speed, + speeds: null, + slowNear: false, + fadeIn: true, + soar, + rollAt, + }; } -/** Where along the path, by distance flown. */ -function along(path: Path, s: number): V { - const { points, dist } = path; +/** The airspeed a distance s along the path. */ +function speedAt(path: Path, s: number): number { + const { speeds, dist } = path; + if (!speeds) return path.speed; + const [lo, hi, k] = bracket(path, s); + return lerpNumber(speeds[lo]!, speeds[hi]!, dist[hi] === dist[lo] ? 0 : k); +} + +/** Within NEAR_SLOW metres time runs slower, in step with his distance, + * down to NEAR_SLOWEST of real time (at 3 m). */ +const NEAR_SLOW = 12; +const NEAR_SLOWEST = 0.25; + +/** How fast time runs for him a distance s along the path. */ +function timeScaleAt(path: Path, s: number): number { + if (!path.slowNear) return 1; + const d = Math.hypot(...along(path, s)); + return Math.min(1, Math.max(NEAR_SLOWEST, d / NEAR_SLOW)); +} + +/** The two points either side of a distance along the path, and how far + * between them it is. */ +function bracket(path: Path, s: number): [number, number, number] { + const { dist } = path; const c = Math.min(Math.max(s, 0), path.length); let [lo, hi] = [0, dist.length - 1]; - while (hi - lo > 1) { + for (let step = 0; step < 32 && hi - lo > 1; step++) { const mid = (lo + hi) >> 1; if (dist[mid]! <= c) lo = mid; else hi = mid; } const span = dist[hi]! - dist[lo]! || 1; - return lerpVectors(points[lo]!, points[hi]!, (c - dist[lo]!) / span); + return [lo, hi, (c - dist[lo]!) / span]; +} + +/** Where along the path, by distance flown. */ +function along(path: Path, s: number): V { + const [lo, hi, k] = bracket(path, s); + return lerpVectors(path.points[lo]!, path.points[hi]!, k); } /** The view's axes, and whether a point is within the frame (with a margin). */ @@ -505,12 +568,25 @@ function frame(view: RavenView) { Math.max(horizon + lo, Math.min(horizon + hi, view.ceiling)), ); } + // The point at a depth (metres along the view) behind a point on screen. + function pointAtDepth(nx: number, ny: number, depth: number): V { + return addVectors( + scaleVector(fwd, depth), + addVectors( + scaleVector(right, nx * tanX * depth), + scaleVector(up, ny * view.tanY * depth), + ), + ); + } return { right, up, fwd, + tanX, + horizon, isInFrame, rayThroughScreen, + pointAtDepth, randomHeightAboveHorizon, }; } @@ -538,9 +614,88 @@ function leave(p: V, d: V, isInFrame: (p: V) => boolean): V { return addVectors(p, scaleVector(d, 100)); } +/** Degrees either side of a bearing over which the skyline is taken at its + * highest, so that a path kept clear of it does not follow every notch. */ +const SKYLINE_SPREAD = 4; +/** How far above the skyline a path keeps (rad): seen against the sky, never + * in front of the mountains (Ísafjörður's fill the left of its view). */ +const SKYLINE_CLEARANCE = 2.5 * RAD; + +/** The highest the skyline stands within SKYLINE_SPREAD of a bearing + * (degrees), radians above the horizontal. */ +function skylineNear(view: RavenView, az: number): number { + let top = -Math.PI / 2; + for (let d = -SKYLINE_SPREAD; d <= SKYLINE_SPREAD; d++) { + top = Math.max(top, view.ridge(az + d)); + } + return top; +} + +/** The point raised, if need be, so that it is seen against the sky clear + * of the skyline. (Every ridge is far beyond his flights, so he is never + * behind one, only ever in front.) */ +function clearOfSkyline(p: V, view: RavenView, extra = 0): V { + const flat = Math.hypot(p[0], p[2]); + if (flat < 0.5) return p; + const az = Math.atan2(p[0], p[2]) / RAD; + const least = + flat * Math.tan(skylineNear(view, az) + SKYLINE_CLEARANCE + extra * RAD); + return p[1] >= least ? p : [p[0], least, p[2]]; +} + +/** Metres either side over which a lift clear of the skyline is eased. */ +const LIFT_EASE = 4; + +/** The path with every point kept clear of the skyline. */ +function keptClearOfSkyline(path: Path, view: RavenView): Path { + // How far each point must rise, then eased along the path (the most + // within a few metres either side, then averaged) so that lifting him + // clear never puts a kink in his line. + const rise = path.points.map((p) => clearOfSkyline(p, view)[1] - p[1]); + if (!rise.some((r) => r > 0)) return path; + function near(i: number, f: (j: number) => void): void { + const { dist } = path; + for (let j = i; j >= 0 && dist[i]! - dist[j]! <= LIFT_EASE; j--) f(j); + for ( + let j = i + 1; + j < rise.length && dist[j]! - dist[i]! <= LIFT_EASE; + j++ + ) { + f(j); + } + } + const most = rise.map((_, i) => { + let m = 0; + near(i, (j) => (m = Math.max(m, rise[j]!))); + return m; + }); + const points = path.points.map((p, i): V => { + let [sum, n] = [0, 0]; + near(i, (j) => { + sum += most[j]!; + n++; + }); + return [p[0], p[1] + Math.max(rise[i]!, sum / n), p[2]]; + }); + const dist = [0]; + for (let i = 1; i < points.length; i++) { + dist.push( + dist[i - 1]! + Math.hypot(...subtractVectors(points[i]!, points[i - 1]!)), + ); + } + const length = dist[dist.length - 1]!; + return { + ...path, + points, + dist, + length, + rollAt: Math.min(path.rollAt, length), + }; +} + /** The point raised, if need be, to the least height above the ground. */ -function keepAboveGround(p: V, view: RavenView): V { - return [p[0], Math.max(p[1], GROUND - view.eye), p[2]]; +function keepAboveGround(p: V, view: RavenView, least = GROUND): V { + return [p[0], Math.max(p[1], least - view.eye), p[2]]; } /** A pass across the view along a gentle curve. */ @@ -603,6 +758,28 @@ function pass(view: RavenView): Path | null { return spare; } +/** Points every 30 degrees round a level circle, for so many turns, climbing + * by `climb` metres a turn: x is r cos a and z is r sin a, from angle `start`, + * turning one way or the other by `sense`. */ +function ringAround( + centre: V, + r: number, + turns: number, + sense: number, + climb: number, + start: number, +): V[] { + const steps = Math.ceil(turns * 12); + return Array.from({ length: steps + 1 }, (_, k): V => { + const a = start + (sense * k * Math.PI) / 6; + return addVectors(centre, [ + r * Math.cos(a), + (climb * k) / 12, + r * Math.sin(a), + ]); + }); +} + /** A few slow circles on still air, then away. */ function circles(view: RavenView): Path | null { const { isInFrame, rayThroughScreen, randomHeightAboveHorizon } = frame(view); @@ -619,18 +796,8 @@ function circles(view: RavenView): Path | null { const sense = Math.random() < 0.5 ? -1 : 1; const climb = randomBetween(-4, 4); const start = randomBetween(0, 2 * Math.PI); - const steps = Math.ceil(turns * 12); - const ring: V[] = []; - for (let k = 0; k <= steps; k++) { - const a = start + (sense * k * Math.PI) / 6; - ring.push( - addVectors(centre, [ - r * Math.cos(a), - (climb * k) / 12, - r * Math.sin(a), - ]), - ); - } + const ring = ringAround(centre, r, turns, sense, climb, start); + const steps = ring.length - 1; if (!ring.every((p) => skyward(p, view) && isInFrame(p, 0.95))) continue; // Join and leave the circle where the tangent leaves the frame soonest, // within its first and last turn, keeping at least three quarters of a @@ -679,6 +846,351 @@ function circles(view: RavenView): Path | null { return null; } +/** The least height of a fly-in, metres above the ground or sea: he skims + * low over the water as he dives past the cards. */ +const LOW = 0.5; + +// --------------------------------------------------------------------------- +// Flying a path: rather than following a drawn curve, the fly-in is flown by +// a simple glider steered through its waypoints, so its speed, turns and +// pull-outs are what a raven's wings could really do. + +/** A raven's best glide: at about this speed (m/s), some 9 m on for each + * metre down. Slower or faster costs more. */ +const BEST_GLIDE = 8.5; +const LIFT_TO_DRAG = 9; +/** The most lift his wings bear in a turn or pull-out, in g. */ +const MOST_LOAD = 2; +/** Below this speed (m/s) he flaps to hold it, and how hard (m/s²). */ +const SLOWEST = 6; +const THRUST = 3; +/** About how long (s) he takes to swing onto a new heading. */ +const STEER = 0.9; +/** The step the flight is worked out in (s), and the longest it may run. */ +const FLY_STEP = 1 / 60; +const FLY_LONGEST = 40; + +/** Beyond this (metres) he is a speck, and a flown path ends. */ +const FLY_FARTHEST = 110; + +/** Fly through the waypoints from the first, setting off toward the second + * at `speed`: gravity, drag on a raven's glide polar, lift up to MOST_LOAD + * steering him at each waypoint in turn (easing toward the one after as he + * nears it), and flapping when he is slow or climbing. `floor` is the + * lowest he goes (y, metres). It ends once he has been in view and left + * it, or is FLY_FARTHEST off. Each step's position and speed. */ +function fly( + through: V[], + speed: number, + floor: number, + isInFrame: (p: V, margin?: number) => boolean, +): { points: V[]; speeds: number[] } { + let p = through[0]!; + let v = scaleVector(normalize(subtractVectors(through[1]!, p)), speed); + const points: V[] = [p]; + const speeds = [speed]; + let k = 1; + let seen = false; + for (let i = 0; i < FLY_LONGEST / FLY_STEP && k < through.length; i++) { + const shown = isInFrame(p, 1.2); + seen ||= shown; + if ((seen && !shown) || Math.hypot(...p) > FLY_FARTHEST) break; + const vNow = Math.hypot(...v); + const dir = scaleVector(v, 1 / vNow); + const to = subtractVectors(through[k]!, p); + const away = Math.hypot(...to); + // Reached (or flown past) this waypoint: on to the next. + if (away < Math.max(1.5, vNow * 0.4) || dotProduct(to, dir) < 0) { + k++; + continue; + } + let aim = scaleVector(to, 1 / away); + const next = through[k + 1]; + if (next) { + const ease = 0.6 * smoothstep(1 - away / (vNow * 2)); + aim = normalize( + lerpVectors(aim, normalize(subtractVectors(next, p)), ease), + ); + } + // Low over the water, he pulls up. + if (p[1] < floor + 1.5) { + aim = normalize(addVectors(aim, [0, (floor + 1.5 - p[1]) * 0.6, 0])); + } + // Lift holds him up across his line and turns him toward his aim, + // within what his wings bear. + const across = subtractVectors(aim, scaleVector(dir, dotProduct(aim, dir))); + const angle = Math.acos(Math.min(1, Math.max(-1, dotProduct(aim, dir)))); + const turn = + Math.hypot(...across) > 1e-6 + ? scaleVector(normalize(across), (vNow * angle) / STEER) + : ([0, 0, 0] as V); + let lift = addVectors( + subtractVectors([0, G, 0], scaleVector(dir, G * dir[1])), + turn, + ); + const load = Math.hypot(...lift); + if (load > MOST_LOAD * G) lift = scaleVector(lift, (MOST_LOAD * G) / load); + // Drag on the glide polar: least at the best glide speed. + const ratio = (vNow / BEST_GLIDE) ** 2; + const drag = ((G / LIFT_TO_DRAG) * (ratio + 1 / ratio)) / 2; + // Flapping: to hold his speed when slow, and to carry him up a climb. + const thrust = Math.max( + vNow < SLOWEST ? THRUST + (SLOWEST - vNow) * 2 : 0, + dir[1] > 0.05 ? G * dir[1] * 0.9 : 0, + ); + const accel = addVectors( + addVectors(lift, [0, -G, 0]), + scaleVector(dir, thrust - drag), + ); + v = addVectors(v, scaleVector(accel, FLY_STEP)); + p = addVectors(p, scaleVector(v, FLY_STEP)); + points.push(p); + speeds.push(Math.hypot(...v)); + } + return { points, speeds }; +} + +/** A path along flown points. */ +function flownPath(points: V[], speeds: number[]): Path { + const dist = [0]; + for (let i = 1; i < points.length; i++) { + dist.push( + dist[i - 1]! + Math.hypot(...subtractVectors(points[i]!, points[i - 1]!)), + ); + } + return { + points, + dist, + length: dist[dist.length - 1]!, + speed: speeds[0]!, + speeds, + slowNear: false, + fadeIn: true, + soar: false, + rollAt: -1, + }; +} + +/** Where the header and cards usually sit on screen, for a fly-in with none + * given (after switching sky, when the cards are already up). */ +const USUAL_TARGETS: ScreenBox[] = [ + [-1, 0.84, 1, 1], + [-0.62, -0.25, 0.62, 0.35], +]; + +/** The variations on a fly-in: a plain swoop, a roll while he is still + * close, a steeper and lower dive, a few wide circles in the distance, or an + * S-bend on the way down. */ +type Flourish = "swoop" | "roll" | "stoop" | "spiral" | "weave"; +const FLOURISHES: Flourish[] = ["swoop", "roll", "stoop", "spiral", "weave"]; + +/** Which way a fly-in leaves after the swoop: circling back to the left + * into the distance, on into the background more or less straight ahead, + * or off to the right. */ +type Exit = "left" | "away" | "right"; +const EXITS: Exit[] = ["left", "away", "right"]; + +/** For each way out: where on screen (x, -1 to 1) he turns and where he + * heads, and how far off that is (metres; nearer on a tall screen, where he + * is close as he comes out of the swoop). Off to the right he heads out of + * frame. */ +const EXIT_SHAPES: Record< + Exit, + { + turnX: [number, number]; + farX: [number, number]; + depth: [number, number]; + tallDepth: [number, number]; + } +> = { + left: { + turnX: [0.5, 0.85], + farX: [-0.7, -0.25], + depth: [70, 110], + tallDepth: [45, 70], + }, + away: { + turnX: [0.3, 0.55], + farX: [-0.15, 0.3], + depth: [90, 130], + tallDepth: [55, 80], + }, + right: { + turnX: [0.75, 0.95], + farX: [1.15, 1.4], + depth: [35, 55], + tallDepth: [18, 30], + }, +}; + +/** The point moved to one side of the line from a to b (sideways and level), + * by k times the line's length. */ +function besideLine(p: V, a: V, b: V, k: number): V { + const d = subtractVectors(b, a); + const aside = normalize([-d[2], 0, d[0]]); + return addVectors(p, scaleVector(aside, k * Math.hypot(...d))); +} + +/** The fly-in: from just past the top-left corner, close enough to fill a + * good part of the screen, a long swoop down past the header and cards to + * low over the water at the bottom right, then away: circling back to the + * left, on into the background, or off to the right. */ +function entrance(view: RavenView): Path { + const { fwd, tanX, horizon, isInFrame, pointAtDepth } = frame(view); + const boxes = view.targets.length ? view.targets : USUAL_TARGETS; + const card = boxes[1] ?? USUAL_TARGETS[1]!; + const flourish = FLOURISHES[Math.floor(Math.random() * FLOURISHES.length)]!; + const stoop = flourish === "stoop"; + const side = Math.random() < 0.5 ? -1 : 1; + + // A point at a depth behind a point on screen, brought nearer if need be + // so that it stays above the water (below the horizon a far point would + // be under it), but no nearer than `least`: past that it is raised. + function lowPoint(nx: number, ny: number, depth: number, least = 4): V { + const slope = pointAtDepth(nx, ny, 1)[1]; + const floor = LOW - view.eye; + const most = slope < 0 && floor < 0 ? floor / slope : Infinity; + return keepAboveGround( + pointAtDepth(nx, ny, Math.min(depth, Math.max(most, least))), + view, + LOW, + ); + } + + // In over the top edge at the far left of the screen, close over the + // viewer's shoulder, wings at full span: his middle just above the frame + // so that his wings are in view from the start and he comes into it + // while still under 2 m off, big on the screen. + // Never wider than about half the screen (his span is 1.2 m), so on a + // narrow screen he comes in a little farther off. + const near = Math.max(randomBetween(1.25, 1.45), 1.2 / (2 * tanX * 0.5)); + const start = pointAtDepth( + randomBetween(-0.85, -0.65), + 1 + 0.4 / (near * view.tanY), + near, + ); + // He swoops down and away over the title and through the first card to + // the bottom of his dive, low over the water. + const tall = horizon > -0.3; + const [x0, y0, x1, y1] = card; + const dip = lowPoint( + lerpNumber(x0, x1, randomBetween(0.3, 0.7)), + tall + ? lerpNumber(y0, y1, randomBetween(0.1, 0.4)) + : Math.max(horizon + 0.03, -0.85), + randomBetween(9, 13), + ); + // Then the long swoop: down past the cards and low over the water toward + // the bottom-right corner. On a wide screen the horizon is near the + // bottom, so he stays just above it; on a tall one the cards are over + // the water and he comes down close, skimming it, to pass below them. + const lowY = Math.max(-0.92, randomBetween(-0.88, -0.6) - (stoop ? 0.08 : 0)); + const low = lowPoint( + randomBetween(0.35, 0.75), + tall ? lowY : Math.max(lowY, horizon + 0.04), + randomBetween(14, 20) * (stoop ? 0.75 : 1), + // Always on away from the viewer, never back toward him. + dotProduct(dip, fwd) + 3, + ); + // Then out of the swoop, still low, banking the way he leaves... + const exit = EXITS[Math.floor(Math.random() * EXITS.length)]!; + const shape = EXIT_SHAPES[exit]; + const turning = lowPoint( + randomBetween(...shape.turnX), + (tall ? lowY : Math.max(lowY, horizon + 0.04)) + randomBetween(0.05, 0.15), + // On a tall screen he is close down there; a nearer turn keeps him + // from shooting up toward the horizon as he draws away. + tall ? randomBetween(7, 11) : randomBetween(22, 32), + dotProduct(low, fwd) + 3, + ); + // ...and on into the distance, rising only as the distance lifts him + // toward the horizon, against the sky above the skyline. + function distantPoint(): V { + let p: V = [0, 0, 0]; + for (let tries = 0; tries < 10; tries++) { + p = pointAtDepth( + randomBetween(...shape.farX), + Math.min(0.6, horizon + randomBetween(0.08, 0.2) + tries * 0.05), + randomBetween(...(tall ? shape.tallDepth : shape.depth)), + ); + if (skyward(p, view)) return p; + } + return keepAboveGround(p, view); + } + const far = distantPoint(); + + const through: V[] = [start, dip]; + if (flourish === "weave") { + through.push(besideLine(lerpVectors(dip, low, 0.5), dip, low, side * 0.2)); + } + through.push(low, turning, far); + + const flat = normalize([far[0] - turning[0], 0, far[2] - turning[2]]); + // A few wide circles around a centre beside the distant point, joined + // where he arrives there and turning the way he is going. + function spiral(): V[] | null { + const r = randomBetween(10, 16); + const centre = besideLine(far, [0, 0, 0], flat, side * r); + const a0 = Math.atan2(far[2] - centre[2], far[0] - centre[0]); + const tangent: V = [-Math.sin(a0), 0, Math.cos(a0)]; + const sense = dotProduct(tangent, flat) >= 0 ? 1 : -1; + const ring = ringAround( + centre, + r, + randomBetween(1, 1.75), + sense, + randomBetween(2, 5), + a0, + ).slice(1); + return ring.every((p) => skyward(p, view) && isInFrame(p, 0.98)) + ? ring + : null; + } + const ring = flourish === "spiral" ? spiral() : null; + if (ring) { + through.push(...ring); + } else { + // Gliding on the way he is going, a little higher. + through.push( + addVectors( + far, + addVectors(scaleVector(flat, randomBetween(25, 35)), [ + 0, + randomBetween(1, 3), + 0, + ]), + ), + ); + } + const last = through[through.length - 1]!; + const before = through[through.length - 2]!; + through.push( + keepAboveGround( + leave(last, normalize(subtractVectors(last, before)), isInFrame), + view, + ), + ); + + // Flown, not drawn: his speed comes from gravity and his height, his + // turns from what his wings bear. Every waypoint but the first is kept + // clear of the skyline first, so his line does not need lifting after. + const { points, speeds } = fly( + [start, ...through.slice(1).map((q) => clearOfSkyline(q, view, 1.5))], + randomBetween(6.5, 7.2), + LOW - view.eye, + isInFrame, + ); + const path = flownPath(points, speeds); + path.slowNear = true; + path.fadeIn = false; + // The roll comes as he pulls out of the dive. + if (flourish === "roll") { + path.rollAt = + Math.hypot(...subtractVectors(dip, start)) + randomBetween(2, 5); + } + return path; +} + // --------------------------------------------------------------------------- export type Raven = { @@ -688,6 +1200,12 @@ export type Raven = { /** Land: no bird until the next flight is due, after `wait` seconds (a * fresh gap if not given). */ reset(wait?: number): void; + /** Land, and make the next flight a fly-in, after `wait` seconds. */ + enter(wait: number): void; + /** Whether the flight under way is a fly-in. */ + entering: boolean; + /** Where the bird is, in world space. */ + position: V; /** The bird's triangles in world space (RAVEN_STRIDE floats a vertex). */ mesh: Float32Array; /** Vertices to draw, 0 when there is no bird. */ @@ -698,7 +1216,8 @@ export type Raven = { export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { const mesh = new Float32Array(TRIS * 3 * RAVEN_STRIDE); - let wait = randomBetween(...timing.first); + let wait = randomBetween(...timing.entrance); + let flyIn = true; let path: Path | null = null; let s = 0; let size = 1; @@ -719,20 +1238,37 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { mesh, count: 0, alpha: 0, + entering: false, + position: [0, 0, 0], reset(next) { path = null; bird.count = 0; + bird.entering = false; wait = next ?? randomBetween(...timing.gap); }, + enter(next) { + bird.reset(next); + flyIn = true; + }, step(dt, view) { if (!path) { wait -= dt; if (wait > 0) return; - path = Math.random() < 0.3 ? circles(view) : pass(view); - if (!path) return bird.reset(); + path = flyIn + ? entrance(view) + : Math.random() < 0.3 + ? circles(view) + : pass(view); + // No flight fits this view: try again shortly rather than leave the + // sky empty for a whole gap. + if (!path) return bird.reset(randomBetween(1, 2)); + path = keptClearOfSkyline(path, view); + bird.entering = flyIn; + flyIn = false; s = 0; size = randomBetween(0.94, 1.08); - rate = randomBetween(3.2, 3.6); + // Slower, deeper wingbeats on the fly-in. + rate = bird.entering ? randomBetween(2.7, 3) : randomBetween(3.2, 3.6); phase = LEVEL_PHASE; flap = flapTarget = path.soar ? 0 : 1; wantFlap = !path.soar; @@ -741,9 +1277,13 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { rollT = -1; up = null; } - s += path.speed * dt; + dt *= timeScaleAt(path, s); + const speed = speedAt(path, s); + s += speed * dt; if (s >= path.length) return bird.reset(); - bird.alpha = smoothstep(s / FADE) * smoothstep((path.length - s) / FADE); + bird.alpha = + (path.fadeIn ? smoothstep(s / FADE) : 1) * + smoothstep((path.length - s) / FADE); // Heading and bank. The lift must carry the bird's weight and turn it, // so it leans along gravity plus the turn's acceleration (a coordinated @@ -752,8 +1292,9 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { const p = along(path, s); const ahead = along(path, s + ds); const behind = along(path, s - ds); + bird.position = p; const fwd = normalize(subtractVectors(ahead, behind)); - const k = (path.speed / ds) ** 2; + const k = (speed / ds) ** 2; const accel = scaleVector( subtractVectors(addVectors(ahead, behind), scaleVector(p, 2)), k, @@ -799,13 +1340,19 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { ? wantFlap ? randomBetween(0.6, 1.2) : randomBetween(5, 10) - : wantFlap - ? randomBetween(1, 2.6) - : randomBetween(0.7, 2.2); + : bird.entering + ? wantFlap + ? randomBetween(0.8, 1.5) + : randomBetween(2, 4) + : wantFlap + ? randomBetween(1, 2.6) + : randomBetween(0.7, 2.2); } } let flapping = wantFlap && !rolling; if (!rolling && fwd[1] > 0.07) flapping = true; + // On a flown path he flaps whenever he needed the thrust. + if (!rolling && path.speeds && speed < SLOWEST + 0.3) flapping = true; if (fwd[1] < -0.07) flapping = false; if (flapping && flap === 0) phase = LEVEL_PHASE; if (flapping) flapTarget = 1; @@ -829,7 +1376,8 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { smoothstep((-fwd[1] - 0.06) / 0.14), rolling ? 0.6 : 0, ); - rest = blendPoses(rest, TUCK, tuck); + // On the fly-in's swoop the wings stay half open, broad on screen. + rest = blendPoses(rest, TUCK, tuck * (bird.entering ? 0.3 : 1)); const pose = flap > 0 ? blendPoses(rest, beat(phase), smoothstep(flap)) : rest; diff --git a/apps/site/src/scripts/aurora.worker.ts b/apps/site/src/scripts/aurora.worker.ts index 73c5dc2..8b9455f 100644 --- a/apps/site/src/scripts/aurora.worker.ts +++ b/apps/site/src/scripts/aurora.worker.ts @@ -31,7 +31,12 @@ import { import { getSystemTimeZone, getUnixNow } from "@northguild/gmt"; import { SUN_UP, lookAhead, type Seer, type SiteTimes } from "./aurora.ahead"; import { MOON } from "./aurora.moon"; -import { RAVEN_STRIDE, RAVEN_TIMING, raven } from "./aurora.raven"; +import { + RAVEN_STRIDE, + RAVEN_TIMING, + raven, + type ScreenBox, +} from "./aurora.raven"; import { SITES, type Site } from "./aurora.sites"; import { PLANETS, @@ -46,13 +51,15 @@ import { SKYLINE as HELSINKI } from "./skylines/helsinki"; import { SKYLINE as ISAFJORDUR } from "./skylines/isafjordur"; const FRAME_MS = 1000 / 30; +/** During the fly-in, when he is close and crossing the screen fast. */ +const FLY_IN_FRAME_MS = 1000 / 60; const BASE_SCALE = 0.6; const MAX_EDGE = 1280; const SPARKS = 4000; const RAD = Math.PI / 180; /** The raven's own texture, square: big enough that scaling it down to the * bird's size on screen smooths its edges. */ -const RAVEN_TEX = 256; +const RAVEN_TEX = 512; // Wall-clock epoch milliseconds from Temporal (via gmt): the stars, planets, // moon and sun need the real time. Frame timing uses rAF stamps, not the wall @@ -82,9 +89,13 @@ export type AuroraMessage = site: number; still: boolean; hidden: boolean; + /** The page's header, cards, title and intro, to draw in as the + * fly-in reaches them, in CSS pixels: left, top, width, height. Later + * fly-ins aim at them too. Empty when the page draws them in itself. */ + targets: PageBox[]; } | { type: "site"; site: number } - | { type: "size"; width: number; height: number } + | { type: "size"; width: number; height: number; targets: PageBox[] } | { type: "still"; still: boolean } | { type: "hidden"; hidden: boolean } /** What is drawn near this point (CSS pixels), within reach pixels. */ @@ -95,6 +106,9 @@ export type AuroraMessage = * about to be). */ | { type: "times"; want: boolean }; +/** A box on the page in CSS pixels: left, top, width, height. */ +export type PageBox = [number, number, number, number]; + /** One unbroken run of a constellation line in CSS pixels, [x, y, x, y, …], * drawn from the end nearer the hovered star; step counts lines out from it. */ export type SkyLine = { step: number; points: number[] }; @@ -126,6 +140,10 @@ export type WorkerMessage = | "off" | { site: number } | { pick: SkyLabel | null } + /** Paint in the page's box `brush` (an index into the init's targets) now, + * sweeping toward `angle` (a CSS gradient angle) over `duration` seconds; + * -1 paints them all in, as there will be no fly-in. */ + | { brush: number; angle?: number; duration?: number } /** When the sun and moon next show in this window, per site in SITES order. */ | { times: SiteTimes[]; zone: string }; @@ -634,8 +652,136 @@ function start(init: Extract): void { // Landscape: below the middle of the cards. Portrait: the cards sit // below the horizon, so up toward the top of the frame. ceiling: 0.3 + 0.55 * portrait, + // Where the page's boxes are, for every fly-in, so the one after a + // sky switch flies the same line as the first. + targets: targets.map(onScreen), }; } + + // The page's header, title, intro and cards, hidden until the fly-in + // reaches them: his flight draws each one in. As he reaches a box the page + // starts a sweep across it the way he is flying, paced so its edge + // follows just behind him (it enters the box a quarter of the way into + // the sweep and crosses it in the middle half; see Base.astro). A box he + // never reaches is drawn in once he is past its middle on his way down + // the screen (top left to bottom right), or soon after the last one. + let targets: PageBox[] = init.targets; + let painted = targets.map(() => false); + let seen: [number, number] | null = null; + let heading: [number, number] = [Math.SQRT1_2, Math.SQRT1_2]; + // His speed across the screen, CSS pixels a second, and the seconds since + // a box was last drawn in. + let pace = 0; + let sincePaint = 0; + /** A page box on screen, -1 to 1 with y up, as the raven plans in. */ + function onScreen([x, y, w, h]: PageBox): ScreenBox { + const [W, H] = [width || 1, height || 1]; + return [ + (2 * x) / W - 1, + 1 - (2 * (y + h)) / H, + (2 * (x + w)) / W - 1, + 1 - (2 * y) / H, + ]; + } + /** The box's corners along the heading: the nearest, and how far across. */ + function spanAlongHeading([x, y, w, h]: PageBox): [number, number] { + const along = [ + [x, y], + [x + w, y], + [x, y + h], + [x + w, y + h], + ].map(([cx, cy]) => cx! * heading[0] + cy! * heading[1]); + const lead = Math.min(...along); + return [lead, Math.max(...along) - lead]; + } + function paint(i: number): void { + const [, span] = spanAlongHeading(targets[i]!); + painted[i] = true; + sincePaint = 0; + scope.postMessage({ + brush: i, + angle: Math.round( + (Math.atan2(heading[0], -heading[1]) / RAD + 360) % 360, + ), + // The sweep's edge crosses the box in the middle half of the sweep, + // a little slower than he is going now: he slows on screen as he + // draws away, and the edge should never overtake him. + duration: + Math.round( + Math.min(5, Math.max(1.6, (2 * span) / (0.7 * pace || 1))) * 100, + ) / 100, + }); + } + function paintTheRest(): void { + painted.forEach((done, i) => { + if (!done) paint(i); + }); + } + function paintInWake(dt: number): void { + if (!painted.includes(false)) return; + if (!bird.entering) { + // The fly-in is over and he never passed some: paint them now. + if (seen) paintTheRest(); + return; + } + const at = project({ cam, tanY }, width, height, bird.position); + if (!at) return; + if (seen) { + const [dx, dy] = [at[0] - seen[0], at[1] - seen[1]]; + const moved = Math.hypot(dx, dy); + if (moved > 0.5 && dt > 0) { + // Smoothed, so a wobble in the curve does not swing the wave. + const hx = heading[0] * 0.6 + (dx / moved) * 0.4; + const hy = heading[1] * 0.6 + (dy / moved) * 0.4; + const n = Math.hypot(hx, hy) || 1; + heading = [hx / n, hy / n]; + pace = moved / dt; + } + } + seen = at; + const [bx0, by0, bx1, by1] = birdOnPage(); + targets.forEach((box, i) => { + if (painted[i]) return; + const [x, y, w, h] = box; + // His body reaching it, not just a wing tip: his middle within a + // quarter of his size of the box. The header, a thin strip across the + // top, he sweeps with a wing as he comes in: any of him will do. + const [mx, my] = + i === 0 + ? [(bx1 - bx0) / 2, (by1 - by0) / 2] + : [(bx1 - bx0) / 4, (by1 - by0) / 4]; + const over = + at[0] > x - mx && + at[0] < x + w + mx && + at[1] > y - my && + at[1] < y + h + my; + // Along the screen's diagonal, which his swoop follows overall; his + // heading at the moment can swing back as he turns. + const [W, H] = [width || 1, height || 1]; + const past = at[0] * W + at[1] * H >= (x + w / 2) * W + (y + h / 2) * H; + if (over || past) paint(i); + // One his path never reaches (a card below his swoop on a phone) + // follows on soon after the last, while he is clear of it. + else if (sincePaint > 1.1 && painted.includes(true)) paint(i); + }); + sincePaint += dt; + } + /** The bird's extent on the page in CSS pixels: left, top, right, bottom. */ + function birdOnPage(): [number, number, number, number] { + const m = bird.mesh; + let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; + for (let i = 0; i < bird.count * RAVEN_STRIDE; i += RAVEN_STRIDE) { + const xy = project({ cam, tanY }, width, height, [ + m[i]!, + m[i + 1]!, + m[i + 2]!, + ]); + if (!xy) continue; + [x0, x1] = [Math.min(x0, xy[0]), Math.max(x1, xy[0])]; + [y0, y1] = [Math.min(y0, xy[1]), Math.max(y1, xy[1])]; + } + return [x0, y0, x1, y1]; + } function moveToSite(index: number): void { site = SITES[index] ?? SITES[0]!; // Field lines climb toward magnetic south at the site's dip. @@ -1126,14 +1272,27 @@ function start(init: Extract): void { } } + // Whether the fly-in can run at 60 fps here, and the time between its + // frames lately (ms). + let smooth = true; + let paceMs = FLY_IN_FRAME_MS; function onFrame(stamp: number): void { raf = nextFrame(onFrame); const elapsed = stamp - last; - if (elapsed < FRAME_MS - 2) return; + // The fly-in at 60 fps while he is close and crossing the screen fast, + // and only while frames keep up: an uneven 60 looks worse than a steady + // 30. + const fast = bird.entering && smooth && Math.hypot(...bird.position) < 15; + if (elapsed < (fast ? FLY_IN_FRAME_MS : FRAME_MS) - 2) return; + if (fast && last && elapsed < 200) { + paceMs = paceMs * 0.9 + elapsed * 0.1; + if (paceMs > 22) smooth = false; + } if (last) { const dt = Math.min(elapsed, 100) / 1000; t += dt; bird.step(dt, currentRavenView()); + paintInWake(dt); adaptQuality(elapsed); } last = stamp; @@ -1150,6 +1309,11 @@ function start(init: Extract): void { resizeCanvas(); drawFrame(); scope.postMessage("on"); + // No raven to draw, or no motion: the page paints its boxes in itself. + if (targets.length && (!ravenOk || still)) { + painted = painted.map(() => true); + scope.postMessage({ brush: -1 }); + } startLoop(); // Stars arrive after the first frame so they never delay it, then the star @@ -1184,7 +1348,11 @@ function start(init: Extract): void { if (level > 2) return; if (data.type === "site") { held = NOTHING; - bird.reset(); + // Anything not yet painted in comes in now; the new sky gets its own + // fly-in once it is up. + paintTheRest(); + if (still) bird.reset(); + else bird.enter(1); moveToSite(data.site); skyAt = readWallClock(); now = sky(site, skyAt); @@ -1194,6 +1362,7 @@ function start(init: Extract): void { } else if (data.type === "size") { width = data.width; height = data.height; + if (data.targets.length === targets.length) targets = data.targets; resizeCanvas(); drawFrame(); followHeld();