/* * The cars on up1512001.com. * * Written by hand in TypeScript. No animation library: the shapes are SVG path * data, the movement is a bicycle model, and the drawing is plain canvas 2D. * * This is 6 files from the repository joined end to end so they can be read * in one go. Nothing has been minified or rewritten, so the imports between * them are still here and will not resolve in this combined form. * * src/animation/geometry.ts * src/animation/vector.ts * src/animation/car.ts * src/animation/paint.ts * src/animation/scene.ts * src/main.ts * * Cars steer around anything marked .no-drive, which is the photo and the text * column. One follows your pointer, one avoids it, one mostly ignores you. */ /* =================================================== src/animation/geometry.ts */ /** * A 911 seen from directly above, drawn by hand. * * One source of truth: the canvas builds Path2D objects from these strings at * runtime, and the Vite plugin uses the same strings to emit favicon.svg. Change * the shape once and both follow. * * Coordinate system: the car points along +X. The origin is the centre of the * car. Length runs from x = -47.8 (tail) to x = +50 (nose), so roughly 98 units * long by 40 wide, which is close to the real 4.52 m by 1.85 m proportion. * * What makes it read as a 911 rather than a generic car at 80 px wide: the * front is visibly narrower than the back, the widest point sits over the rear * axle where the engine is, the greenhouse is small and pushed rearward, and * the headlights are round and sit up on the front fenders. */ export const CAR_LENGTH = 97.8; export const CAR_WIDTH = 40; /** Distance from centre to the front and rear axles. */ export const FRONT_AXLE_X = 30; export const REAR_AXLE_X = -26; /** * Half track, measured to the middle of a tyre. Narrower at the front, same as * the real car. Both sit inside the body outline because the tyres are painted * over the shell, which reads as an open wheel arch. */ export const FRONT_TRACK_HALF = 13.4; export const REAR_TRACK_HALF = 14.6; export const FRONT_TYRE = { length: 15.5, width: 7.4 } as const; export const REAR_TYRE = { length: 17, width: 9.2 } as const; /** Outer body shell, closed and symmetric about the X axis. */ export const BODY_PATH = "M 50 0" + "C 49.6 3.6, 48.4 6.8, 46 9.2" + "C 44 11.2, 42.4 12.6, 41 14" + "C 38.6 16.2, 35 17.4, 30.5 17.6" + "C 25 17.8, 18 17.4, 12 16.9" + "C 4 16.3, -4 16.6, -11 17.6" + "C -17 18.4, -22 19.4, -26 20" + "C -31 20.4, -36 20.2, -40 19.4" + "C -44.4 18.6, -46.8 16.8, -47.3 13.4" + "C -47.7 9.2, -47.8 4.6, -47.8 0" + "C -47.8 -4.6, -47.7 -9.2, -47.3 -13.4" + "C -46.8 -16.8, -44.4 -18.6, -40 -19.4" + "C -36 -20.2, -31 -20.4, -26 -20" + "C -22 -19.4, -17 -18.4, -11 -17.6" + "C -4 -16.6, 4 -16.3, 12 -16.9" + "C 18 -17.4, 25 -17.8, 30.5 -17.6" + "C 35 -17.4, 38.6 -16.2, 41 -14" + "C 42.4 -12.6, 44 -11.2, 46 -9.2" + "C 48.4 -6.8, 49.6 -3.6, 50 0" + "Z"; /** Front lid. The 911 bonnet dips between two raised fenders. */ export const HOOD_PATH = "M 43 0" + "C 42.6 3, 41.6 5.6, 40 7.4" + "C 37.6 10, 33 11.2, 28 11.4" + "C 25 11.5, 22 11.2, 20.4 10.6" + "C 20 7, 20 3.6, 20 0" + "C 20 -3.6, 20 -7, 20.4 -10.6" + "C 22 -11.2, 25 -11.5, 28 -11.4" + "C 33 -11.2, 37.6 -10, 40 -7.4" + "C 41.6 -5.6, 42.6 -3, 43 0" + "Z"; /** * The two pieces of glass you can actually see from straight above. Side * windows are edge on from here, so they contribute nothing, and the roof * between these two shapes stays body colour. * * Drawing the greenhouse as one dark teardrop with a lighter roof inside it * was the first attempt. Two concentric ovals read as an eye, not a car. */ export const WINDSCREEN_PATH = "M 16.8 0" + "C 16.5 3, 15.2 5.6, 13 7" + "C 10.6 8.6, 7.8 9.6, 5.2 10.6" + "C 4.6 10.8, 4.4 10.9, 4.2 11" + "C 3.4 5, 3.4 -5, 4.2 -11" + "C 4.4 -10.9, 4.6 -10.8, 5.2 -10.6" + "C 7.8 -9.6, 10.6 -8.6, 13 -7" + "C 15.2 -5.6, 16.5 -3, 16.8 0" + "Z"; export const REAR_GLASS_PATH = "M -28.2 0" + "C -28 2.4, -27.2 4.4, -25.6 6" + "C -23.8 7.8, -21.4 9.2, -18.9 10.1" + "C -19.7 5.2, -19.7 -5.2, -18.9 -10.1" + "C -21.4 -9.2, -23.8 -7.8, -25.6 -6" + "C -27.2 -4.4, -28 -2.4, -28.2 0" + "Z"; /** Engine lid over the flat six, behind the rear glass. */ export const DECK_PATH = "M -30 0" + "C -30 4, -30.2 8, -30.8 11.6" + "C -34 12.6, -38 12.6, -41.4 11.4" + "C -42.2 8, -42.4 4, -42.4 0" + "C -42.4 -4, -42.2 -8, -41.4 -11.4" + "C -38 -12.6, -34 -12.6, -30.8 -11.6" + "C -30.2 -8, -30 -4, -30 0" + "Z"; /** Full width tail light bar, stroked rather than filled. */ export const TAIL_BAR_PATH = "M -43.6 -14.6" + "C -45.6 -10.4, -46.6 -5.4, -46.6 0" + "C -46.6 5.4, -45.6 10.4, -43.6 14.6"; /** Wing mirror on the +Y side. The -Y one is this mirrored. */ export const MIRROR_PATH = "M 14.6 15.4" + "C 16.8 15.6, 18 16.8, 17.9 18.6" + "C 17.8 20, 16.4 20.6, 15 20.2" + "C 13.6 19.8, 13 18.6, 13.2 16.8" + "Z"; /** Round headlights, the one detail a 911 cannot be recognised without. */ export const HEADLIGHTS: readonly { x: number; y: number; r: number }[] = [ { x: 36, y: 12.4, r: 3.3 }, { x: 36, y: -12.4, r: 3.3 }, ]; /* ===================================================== src/animation/vector.ts */ export interface Vec2 { x: number; y: number; } export interface Rect { readonly left: number; readonly top: number; readonly right: number; readonly bottom: number; } export const TAU = Math.PI * 2; export function clamp(value: number, min: number, max: number): number { return value < min ? min : value > max ? max : value; } export function lerp(from: number, to: number, t: number): number { return from + (to - from) * t; } export function randomBetween(min: number, max: number): number { return Math.random() * (max - min) + min; } export function pick(items: readonly T[]): T { const index = Math.floor(Math.random() * items.length); return items[index] ?? items[0]!; } /** Shortest signed turn from one angle to another, in radians. */ export function angleDelta(from: number, to: number): number { let delta = (to - from) % TAU; if (delta > Math.PI) delta -= TAU; if (delta < -Math.PI) delta += TAU; return delta; } export function distance(a: Vec2, b: Vec2): number { return Math.hypot(a.x - b.x, a.y - b.y); } /** Closest point to `point` on the border or interior of `rect`. */ export function nearestPointInRect(rect: Rect, point: Vec2): Vec2 { return { x: clamp(point.x, rect.left, rect.right), y: clamp(point.y, rect.top, rect.bottom), }; } export function rectContains(rect: Rect, point: Vec2): boolean { return ( point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom ); } /** * Distance from a point to a rectangle. Zero when the point is inside, which is * exactly what the avoidance code wants: inside means panic, not negative. */ export function distanceToRect(rect: Rect, point: Vec2): number { const near = nearestPointInRect(rect, point); return Math.hypot(point.x - near.x, point.y - near.y); } /** * Shortest way out of a rectangle you are already inside, as an angle. * * Heading away from the centre sounds right and is not: in a tall column of * text a car near the middle gets pushed along the long axis and stays stuck. * Pick the nearest of the four edges instead. */ export function exitAngle(rect: Rect, point: Vec2): number { const left = point.x - rect.left; const right = rect.right - point.x; const top = point.y - rect.top; const bottom = rect.bottom - point.y; const shortest = Math.min(left, right, top, bottom); if (shortest === left) return Math.PI; if (shortest === right) return 0; if (shortest === top) return -Math.PI / 2; return Math.PI / 2; } /* ======================================================== src/animation/car.ts */ import { CAR_LENGTH, FRONT_AXLE_X, REAR_AXLE_X, REAR_TRACK_HALF } from "./geometry.ts"; import { angleDelta, clamp, distance, distanceToRect, exitAngle, nearestPointInRect, randomBetween, rectContains, TAU, type Rect, type Vec2, } from "./vector.ts"; /** * Three drivers, three temperaments. This is the part of the reference site I * liked most: the creatures are not decoration, they have opinions about you. * * chaser follows the cursor and slows to a crawl next to it * wary wants nothing to do with you and leaves for the horizon * cruiser holds its line and only steers when it has to */ export type Temperament = "chaser" | "wary" | "cruiser"; export interface CarPaint { readonly body: string; readonly accent: string; } /** Porsche factory colours, near enough. */ export const PAINT: Readonly> = { chaser: { body: "#c8121f", accent: "#f0454f" }, wary: { body: "#9ea3a8", accent: "#d5d9dc" }, cruiser: { body: "#1c6ea8", accent: "#4ea6dd" }, }; /** All speeds are px per second, all angles radians, all rates per second. */ const CRUISE_SPEED = 92; const CRAWL_SPEED = 20; const ESCAPE_SPEED = 300; const ACCELERATION = 130; const BRAKING = 340; const MAX_STEER = 0.62; const STEER_RATE = 3.4; const STEER_GAIN = 1.9; /** How far the rear steps out. Higher means more theatre in the corners. */ const SLIP_GAIN = 0.0042; const MAX_SLIP = 0.34; const SKID_SLIP = 0.1; /** Cursor and text detection ranges, in multiples of car length. */ const CURSOR_RANGE = 3.2; /** * Where a car first notices the pointer and starts leaning, well before it * commits. At one car length of reaction radius the page felt dead unless you * happened to drive the pointer straight into a car. */ const CURSOR_NOTICE_RANGE = 7; const NOTICE_STRENGTH = 0.3; const ZONE_RANGE = 0.9; const PROBE_AHEAD = 1.15; const WANDER_MIN_PERIOD = 6; const WANDER_MAX_PERIOD = 17; const WANDER_MAX_AMPLITUDE = 0.5; const SKID_MAX_POINTS = 44; const SKID_LIFETIME = 2.6; export interface SkidPoint { readonly x: number; readonly y: number; /** Seconds of life remaining. */ life: number; } export interface WorldView { readonly width: number; readonly height: number; readonly cursor: Vec2 | null; readonly zones: readonly Rect[]; } export class Car { readonly temperament: Temperament; readonly paint: CarPaint; readonly scale: number; readonly length: number; pos: Vec2; heading: number; speed: number; /** Front wheel angle, kept between frames so the steering looks mechanical. */ steer = 0; /** Angle between where the nose points and where the car actually goes. */ slip = 0; /** Rises when the car sheds speed, drives the brake light. */ braking = 0; /** One trail per rear tyre, so the two marks stay parallel. */ readonly skids: readonly [SkidPoint[], SkidPoint[]] = [[], []]; private readonly wanderPeriod: number; private readonly wanderAmplitude: number; private readonly wanderPhase: number; private targetSpeed = CRUISE_SPEED; constructor(temperament: Temperament, pos: Vec2) { this.temperament = temperament; this.paint = PAINT[temperament]; this.length = randomBetween(66, 88); this.scale = this.length / CAR_LENGTH; this.pos = pos; this.heading = Math.random() * TAU; this.speed = CRUISE_SPEED; this.wanderPeriod = randomBetween(WANDER_MIN_PERIOD, WANDER_MAX_PERIOD); this.wanderAmplitude = randomBetween(0.12, WANDER_MAX_AMPLITUDE); this.wanderPhase = Math.random() * TAU; } get wheelbase(): number { return (FRONT_AXLE_X - REAR_AXLE_X) * this.scale; } /** Where the car is actually travelling, which is not where it points. */ get courseAngle(): number { return this.heading - this.slip; } update(dt: number, time: number, world: WorldView): void { const desired = this.chooseHeading(time, world); this.applySteering(desired, dt); this.applyThrottle(dt); this.integrate(dt, world); this.ageSkids(dt); } /** * Priority order: get out of the text first, react to the cursor second, * wander the rest of the time. Each rule returns an absolute heading. */ private chooseHeading(time: number, world: WorldView): number { const escape = this.avoidZones(world); if (escape !== null) return escape; const reaction = this.reactToCursor(world); if (reaction !== null) return reaction; this.targetSpeed = CRUISE_SPEED; const wander = Math.sin((time / this.wanderPeriod) * TAU + this.wanderPhase) * this.wanderAmplitude; return this.heading + wander; } private avoidZones(world: WorldView): number | null { if (world.zones.length === 0) return null; const ahead = this.probePoint(); let closest: Rect | null = null; let closestDistance = Infinity; for (const zone of world.zones) { if (rectContains(zone, this.pos)) { // Scrolling slid a paragraph over the car, which happens constantly on // the way down the page. Take the nearest edge out at a brisk but not // startling pace. Bolting at escape speed every time the reader // scrolled was the old behaviour and it looked broken. this.targetSpeed = CRUISE_SPEED * 1.35; return exitAngle(zone, this.pos); } const gap = distanceToRect(zone, ahead); if (gap < closestDistance) { closestDistance = gap; closest = zone; } } if (closest === null || closestDistance > this.length * ZONE_RANGE) return null; const contact = nearestPointInRect(closest, ahead); const away = Math.atan2(this.pos.y - contact.y, this.pos.x - contact.x); // Ease off the throttle for the corner rather than understeering into text. this.targetSpeed = CRUISE_SPEED * 0.72; return away; } private reactToCursor(world: WorldView): number | null { const cursor = world.cursor; if (cursor === null) return null; const gap = distance(this.pos, cursor); const committed = this.length * CURSOR_RANGE; const noticed = this.length * CURSOR_NOTICE_RANGE; if (gap > noticed) return null; const toward = Math.atan2(cursor.y - this.pos.y, cursor.x - this.pos.x); if (gap > committed) { // Long range. Every car leans, none of them commits, and the pull fades // out with distance so there is no line you can feel them cross. const pull = (1 - (gap - committed) / (noticed - committed)) * NOTICE_STRENGTH; const interest = this.temperament === "wary" ? toward + Math.PI : toward; this.targetSpeed = CRUISE_SPEED; return this.heading + angleDelta(this.heading, interest) * pull; } switch (this.temperament) { case "chaser": { this.targetSpeed = gap < this.length * 0.5 ? CRAWL_SPEED : CRUISE_SPEED * 0.8; return toward; } case "wary": { this.targetSpeed = ESCAPE_SPEED * 0.75; return toward + Math.PI; } case "cruiser": { this.targetSpeed = CRUISE_SPEED; // A lift and a small correction, not a panic. return this.heading + angleDelta(this.heading, toward + Math.PI) * 0.45; } } } private probePoint(): Vec2 { const reach = this.length * PROBE_AHEAD; return { x: this.pos.x + Math.cos(this.heading) * reach, y: this.pos.y + Math.sin(this.heading) * reach, }; } private applySteering(desiredHeading: number, dt: number): void { const error = angleDelta(this.heading, desiredHeading); const wanted = clamp(error * STEER_GAIN, -MAX_STEER, MAX_STEER); const step = STEER_RATE * dt; this.steer += clamp(wanted - this.steer, -step, step); } private applyThrottle(dt: number): void { const previous = this.speed; if (this.speed < this.targetSpeed) { this.speed = Math.min(this.targetSpeed, this.speed + ACCELERATION * dt); } else { this.speed = Math.max(this.targetSpeed, this.speed - BRAKING * dt); } const deceleration = dt > 0 ? (previous - this.speed) / dt : 0; const target = deceleration > 40 ? 1 : 0; this.braking += (target - this.braking) * Math.min(1, dt * 9); } private integrate(dt: number, world: WorldView): void { // Bicycle model. Yaw rate falls out of the wheelbase and the steering angle, // so a slow car turns tightly and a fast one runs wide, like the real thing. const yawRate = (this.speed / this.wheelbase) * Math.tan(this.steer); this.heading = (this.heading + yawRate * dt) % TAU; const wantedSlip = clamp(yawRate * this.speed * SLIP_GAIN, -MAX_SLIP, MAX_SLIP); this.slip += (wantedSlip - this.slip) * Math.min(1, dt * 7); const course = this.courseAngle; this.pos = { x: this.pos.x + Math.cos(course) * this.speed * dt, y: this.pos.y + Math.sin(course) * this.speed * dt, }; if (Math.abs(this.slip) > SKID_SLIP && this.speed > 45) this.layRubber(); const margin = this.length; if (this.pos.x < -margin) this.pos.x = world.width + margin; if (this.pos.x > world.width + margin) this.pos.x = -margin; if (this.pos.y < -margin) this.pos.y = world.height + margin; if (this.pos.y > world.height + margin) this.pos.y = -margin; } /** Both rear tyres mark the tarmac while the back end is stepped out. */ private layRubber(): void { const sin = Math.sin(this.heading); const cos = Math.cos(this.heading); const axleX = REAR_AXLE_X * this.scale; const trackY = REAR_TRACK_HALF * this.scale; const offsets = [trackY, -trackY] as const; for (let side = 0; side < offsets.length; side += 1) { const y = offsets[side]!; const trail = this.skids[side === 0 ? 0 : 1]; trail.push({ x: this.pos.x + cos * axleX - sin * y, y: this.pos.y + sin * axleX + cos * y, life: SKID_LIFETIME, }); while (trail.length > SKID_MAX_POINTS) trail.shift(); } } private ageSkids(dt: number): void { for (const trail of this.skids) { for (const mark of trail) mark.life -= dt; while (trail.length > 0 && trail[0]!.life <= 0) trail.shift(); } } } /* ====================================================== src/animation/paint.ts */ import type { Car } from "./car.ts"; import { BODY_PATH, DECK_PATH, FRONT_AXLE_X, FRONT_TRACK_HALF, FRONT_TYRE, HEADLIGHTS, HOOD_PATH, MIRROR_PATH, REAR_AXLE_X, REAR_GLASS_PATH, REAR_TRACK_HALF, REAR_TYRE, TAIL_BAR_PATH, WINDSCREEN_PATH, } from "./geometry.ts"; /** Built once. Path2D is immutable in use, so all cars share these. */ const body = new Path2D(BODY_PATH); const hood = new Path2D(HOOD_PATH); const windscreen = new Path2D(WINDSCREEN_PATH); const rearGlass = new Path2D(REAR_GLASS_PATH); const deck = new Path2D(DECK_PATH); const tailBar = new Path2D(TAIL_BAR_PATH); const mirror = new Path2D(MIRROR_PATH); const GLASS_FILL = "#10161d"; const TYRE_FILL = "#131619"; const LAMP_FILL = "#f4eeda"; const TAIL_DARK = "#3d1116"; const TAIL_LIT = "#ff2f24"; /** Sun sits in the north west, same as the reference site. */ const SHADOW_OFFSET = 11; const SHADOW_BLUR = 20; const CONTACT_OFFSET = 1.5; const CONTACT_BLUR = 5; /** * Direction from the car to the light, in world space. North west, matching the * shadow offset above. The paint highlight is rotated by this minus the car's * heading, so the sun stays put while the car turns under it. Baking the * highlight into body space instead, which is what this did at first, spins the * sun around with the car and kills any sense that it is a solid object. */ const LIGHT_ANGLE = (-3 * Math.PI) / 4; const SHEEN_REACH = 24; const BEAM_REACH = 215; const BEAM_SPREAD = 48; function mix(hex: string, target: string, amount: number): string { const from = parseHex(hex); const to = parseHex(target); const channel = (a: number, b: number): number => Math.round(a + (b - a) * amount); return `rgb(${channel(from[0], to[0])} ${channel(from[1], to[1])} ${channel(from[2], to[2])})`; } function parseHex(hex: string): [number, number, number] { const value = Number.parseInt(hex.slice(1), 16); return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; } /** Rubber on tarmac. Drawn in page space, under every car. */ export function paintSkids(ctx: CanvasRenderingContext2D, car: Car): void { ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.lineWidth = REAR_TYRE.width * car.scale * 0.62; for (const trail of car.skids) { for (let i = 1; i < trail.length; i += 1) { const from = trail[i - 1]!; const to = trail[i]!; // Marks fade with age and are never fully black, so they read as rubber // rather than as ink. ctx.strokeStyle = `rgba(12, 13, 15, ${Math.max(0, to.life / 2.6) * 0.5})`; ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); } } ctx.restore(); } /** Two warm cones thrown forward. Additive, so they pile up where they cross. */ export function paintHeadlightBeams(ctx: CanvasRenderingContext2D, car: Car): void { ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.translate(car.pos.x, car.pos.y); ctx.rotate(car.heading); ctx.scale(car.scale, car.scale); for (const lamp of HEADLIGHTS) { const gradient = ctx.createLinearGradient(lamp.x, 0, lamp.x + BEAM_REACH, 0); gradient.addColorStop(0, "rgba(255, 233, 186, 0.16)"); gradient.addColorStop(0.35, "rgba(255, 226, 170, 0.06)"); gradient.addColorStop(1, "rgba(255, 220, 160, 0)"); ctx.fillStyle = gradient; // The far edge sits a little wider than the lamp, so the two cones overlap // in front of the nose instead of leaving a dark wedge between them. const far = lamp.y * 2.2; ctx.beginPath(); ctx.moveTo(lamp.x, lamp.y - lamp.r); ctx.lineTo(lamp.x + BEAM_REACH, far - BEAM_SPREAD); ctx.lineTo(lamp.x + BEAM_REACH, far + BEAM_SPREAD); ctx.lineTo(lamp.x, lamp.y + lamp.r); ctx.closePath(); ctx.fill(); } ctx.restore(); } export function paintCar(ctx: CanvasRenderingContext2D, car: Car): void { const { body: colour, accent } = car.paint; ctx.save(); ctx.translate(car.pos.x, car.pos.y); ctx.rotate(car.heading); ctx.scale(car.scale, car.scale); const unit = 1 / car.scale; // Shadow offsets ignore the current transform, so these stay in page pixels // and the sun keeps sitting in the north west no matter which way the car // points. Two passes: a soft cast shadow thrown south east, then a tight one // right under the car. Without the tight pass the car reads as a sticker // floating over the page instead of something resting on it. ctx.save(); ctx.fillStyle = colour; ctx.shadowColor = "rgba(0, 0, 0, 0.45)"; ctx.shadowBlur = SHADOW_BLUR; ctx.shadowOffsetX = SHADOW_OFFSET; ctx.shadowOffsetY = SHADOW_OFFSET; ctx.fill(body); ctx.shadowColor = "rgba(0, 0, 0, 0.55)"; ctx.shadowBlur = CONTACT_BLUR; ctx.shadowOffsetX = CONTACT_OFFSET; ctx.shadowOffsetY = CONTACT_OFFSET; ctx.fill(body); ctx.restore(); ctx.fillStyle = colour; ctx.fill(body); // Tyres go over the shell, which reads as an open arch. Under it they would // be invisible at this size and the car would look like a bar of soap. paintTyres(ctx, car); ctx.fillStyle = mix(colour, "#000000", 0.16); ctx.fill(hood); ctx.fillStyle = mix(colour, "#000000", 0.34); ctx.fill(deck); ctx.fillStyle = colour; ctx.fill(mirror); ctx.save(); ctx.scale(1, -1); ctx.fill(mirror); ctx.restore(); ctx.fillStyle = GLASS_FILL; ctx.fill(windscreen); ctx.fill(rearGlass); // Paint catching the sun. The gradient runs along the light direction taken // back into body space, so as the car turns the highlight sweeps across it // and the far flank falls into shadow. This is the whole trick behind the car // reading as a rounded solid rather than a flat cutout. ctx.save(); ctx.clip(body); const towardLight = LIGHT_ANGLE - car.heading; const litX = Math.cos(towardLight) * SHEEN_REACH; const litY = Math.sin(towardLight) * SHEEN_REACH; const sheen = ctx.createLinearGradient(litX, litY, -litX, -litY); sheen.addColorStop(0, "rgba(255, 255, 255, 0.26)"); sheen.addColorStop(0.45, "rgba(255, 255, 255, 0.02)"); sheen.addColorStop(1, "rgba(0, 0, 0, 0.26)"); ctx.fillStyle = sheen; ctx.fillRect(-52, -24, 106, 48); ctx.restore(); ctx.lineWidth = unit * 0.9; ctx.strokeStyle = accent; ctx.stroke(body); paintLamps(ctx, car); ctx.restore(); } function paintTyres(ctx: CanvasRenderingContext2D, car: Car): void { ctx.fillStyle = TYRE_FILL; for (const flip of [1, -1]) { ctx.save(); ctx.translate(REAR_AXLE_X, REAR_TRACK_HALF * flip); ctx.beginPath(); ctx.roundRect( -REAR_TYRE.length / 2, -REAR_TYRE.width / 2, REAR_TYRE.length, REAR_TYRE.width, 2.6, ); ctx.fill(); ctx.restore(); ctx.save(); ctx.translate(FRONT_AXLE_X, FRONT_TRACK_HALF * flip); ctx.rotate(car.steer); ctx.beginPath(); ctx.roundRect( -FRONT_TYRE.length / 2, -FRONT_TYRE.width / 2, FRONT_TYRE.length, FRONT_TYRE.width, 2.4, ); ctx.fill(); ctx.restore(); } } function paintLamps(ctx: CanvasRenderingContext2D, car: Car): void { ctx.fillStyle = LAMP_FILL; for (const lamp of HEADLIGHTS) { ctx.beginPath(); ctx.arc(lamp.x, lamp.y, lamp.r, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(lamp.x, lamp.y, lamp.r * 0.45, 0, Math.PI * 2); ctx.fillStyle = "#ffffff"; ctx.fill(); ctx.fillStyle = LAMP_FILL; } const heat = car.braking; ctx.save(); ctx.lineCap = "round"; ctx.lineWidth = 2.8; ctx.strokeStyle = heat > 0.02 ? mix(TAIL_DARK, TAIL_LIT, heat) : TAIL_DARK; if (heat > 0.02) { ctx.shadowColor = `rgba(255, 47, 36, ${0.75 * heat})`; ctx.shadowBlur = 16 * heat; } ctx.stroke(tailBar); ctx.restore(); } /* ====================================================== src/animation/scene.ts */ import { Car, type Temperament, type WorldView } from "./car.ts"; import { paintCar, paintHeadlightBeams, paintSkids } from "./paint.ts"; import { rectContains, type Rect, type Vec2 } from "./vector.ts"; /** Elements carrying this class are treated as walls the cars steer around. */ const NO_DRIVE_SELECTOR = ".no-drive"; const DESKTOP_GRID: readonly Temperament[] = ["chaser", "wary", "cruiser"]; const NARROW_GRID: readonly Temperament[] = ["chaser", "cruiser"]; const NARROW_WIDTH = 720; /** Retina looks better, three times retina costs frames for nothing. */ const MAX_PIXEL_RATIO = 2; /** A backgrounded tab hands back one huge frame. Clamp it. */ const MAX_FRAME = 0.05; export interface Scene { start(): void; stop(): void; } function requireContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { const ctx = canvas.getContext("2d", { alpha: true }); if (ctx === null) throw new Error("2d canvas context unavailable"); return ctx; } export function createScene(canvas: HTMLCanvasElement): Scene { const ctx = requireContext(canvas); const walls = Array.from(document.querySelectorAll(NO_DRIVE_SELECTOR)); let zones: Rect[] = []; let cursor: Vec2 | null = null; let cars: Car[] = []; let frame = 0; let last = 0; let ratio = 0; let measureQueued = false; let zoneMeasureQueued = false; const world = (): WorldView => ({ width: canvas.clientWidth, height: canvas.clientHeight, cursor, zones, }); /** * Resize the backing store, but only when the size actually changed. * * Assigning canvas.width or canvas.height wipes the canvas and resets the * context, even when you assign the value it already had. This used to run on * every scroll event from its own animation frame, which could land after the * frame had been painted and erase it. That was the flicker while scrolling. */ function measureCanvas(): void { const next = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO); const width = Math.round(canvas.clientWidth * next); const height = Math.round(canvas.clientHeight * next); if (canvas.width === width && canvas.height === height && ratio === next) return; ratio = next; canvas.width = width; canvas.height = height; } /** * Viewport coordinates, because the canvas is fixed to the viewport. Cheap * enough to redo on scroll, and it has to be, since the text slides under the * cars as the page moves. */ function measureZones(): void { zones = walls.map((element) => { const box = element.getBoundingClientRect(); return { left: box.left, top: box.top, right: box.right, bottom: box.bottom }; }); } function measure(): void { measureQueued = false; measureCanvas(); measureZones(); } function queueMeasure(): void { if (measureQueued) return; measureQueued = true; requestAnimationFrame(measure); } /** Scrolling moves the walls, not the canvas. Do not touch the backing store. */ function queueZoneMeasure(): void { if (zoneMeasureQueued) return; zoneMeasureQueued = true; requestAnimationFrame(() => { zoneMeasureQueued = false; measureZones(); }); } function spawn(): void { const grid = canvas.clientWidth < NARROW_WIDTH ? NARROW_GRID : DESKTOP_GRID; cars = grid.map((temperament) => new Car(temperament, freeSpot())); } /** Never start a car on top of a paragraph. */ function freeSpot(): Vec2 { for (let attempt = 0; attempt < 60; attempt += 1) { const spot = { x: Math.random() * canvas.clientWidth, y: Math.random() * canvas.clientHeight, }; if (!zones.some((zone) => rectContains(zone, spot))) return spot; } return { x: canvas.clientWidth * 0.1, y: canvas.clientHeight * 0.9 }; } function tick(now: number): void { frame = requestAnimationFrame(tick); const dt = last === 0 ? 1 / 60 : Math.min((now - last) / 1000, MAX_FRAME); last = now; const time = now / 1000; const view = world(); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, view.width, view.height); for (const car of cars) car.update(dt, time, view); for (const car of cars) paintSkids(ctx, car); for (const car of cars) paintHeadlightBeams(ctx, car); for (const car of cars) paintCar(ctx, car); } function onPointerMove(event: PointerEvent): void { cursor = { x: event.clientX, y: event.clientY }; } function onPointerLeave(): void { cursor = null; } function onVisibility(): void { if (document.hidden) { pause(); } else if (frame === 0) { resume(); } } function pause(): void { if (frame !== 0) cancelAnimationFrame(frame); frame = 0; last = 0; } function resume(): void { if (frame === 0) frame = requestAnimationFrame(tick); } return { start(): void { measure(); spawn(); window.addEventListener("resize", queueMeasure, { passive: true }); window.addEventListener("scroll", queueZoneMeasure, { passive: true }); window.addEventListener("pointermove", onPointerMove, { passive: true }); document.addEventListener("pointerleave", onPointerLeave); document.addEventListener("visibilitychange", onVisibility); resume(); }, stop(): void { pause(); window.removeEventListener("resize", queueMeasure); window.removeEventListener("scroll", queueZoneMeasure); window.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerleave", onPointerLeave); document.removeEventListener("visibilitychange", onVisibility); }, }; } /* ================================================================= src/main.ts */ import "./styles.css"; import { createScene, type Scene } from "./animation/scene.ts"; const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; /** One line, picked fresh on every load. */ function revealGem(): void { const holder = document.querySelector("[data-gems]"); if (holder === null) return; const lines = Array.from(holder.querySelectorAll("blockquote")); if (lines.length === 0) return; const chosen = lines[Math.floor(Math.random() * lines.length)]; for (const line of lines) line.style.display = line === chosen ? "block" : "none"; } function driveCars(): void { const canvas = document.querySelector("#road"); if (canvas === null) return; const motion = window.matchMedia(REDUCED_MOTION); let scene: Scene | null = null; const sync = (): void => { if (motion.matches) { scene?.stop(); scene = null; return; } if (scene !== null) return; scene = createScene(canvas); scene.start(); }; motion.addEventListener("change", sync); sync(); } revealGem(); driveCars();