Orbits, Beams & Illustrationsv0.4.0New
Line Art Street
A living line-art street that runs from day to night and reacts to the pointer.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/line-art-street.jsonManual
Install the dependencies
npm install clsx lucide-react tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/line-art/daynight.ts/** * Day and night for line art. `tau` runs 0 to 1 over a day: 0 is midnight, 0.5 is noon. * By day the drawing is dark ink on warm paper; at night it inverts to pale ink on indigo paper. */ type Rgb = readonly [number, number, number]; const hex = (value: string): Rgb => { const v = value.replace("#", ""); return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]; }; const mix = (a: Rgb, b: Rgb, t: number) => `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * t)).join(" ")})`; const smooth = (edge0: number, edge1: number, x: number) => { const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); }; const DAY = { paper: hex("#fdfcf8"), ink: hex("#1c1c1c"), skyTop: hex("#cfe4f2"), skyLow: hex("#fdf6e6") }; const DUSK = { skyTop: hex("#f0a878"), skyLow: hex("#fbe3c2") }; const NIGHT = { paper: hex("#1d2140"), ink: hex("#e9e6f2"), skyTop: hex("#10132c"), skyLow: hex("#2a2f5c") }; export interface DayPalette { paper: string; ink: string; /** Sky gradient stops, top then horizon. */ sky: [string, string]; /** 0 by day, 1 in full night. Use it for window glow, stars and lamps. */ night: number; /** Height of the sun or moon above the horizon, 0 to 1. */ height: number; /** True while the body in the sky is the moon. */ moon: boolean; } /** How dark it is at `tau`: 0 from mid-morning to late afternoon, 1 deep at night. */ export function nightness(tau: number) { const t = ((tau % 1) + 1) % 1; return 1 - smooth(0.2, 0.3, t) * (1 - smooth(0.72, 0.82, t)); } export function palette(tau: number): DayPalette { const t = ((tau % 1) + 1) % 1; const night = nightness(t); // Warm light around sunrise and sunset. const dusk = Math.max(0, 1 - Math.abs(night - 0.5) * 2) * 0.85; const top = mix(hex(mixHex(DAY.skyTop, DUSK.skyTop, dusk)), NIGHT.skyTop, night); const low = mix(hex(mixHex(DAY.skyLow, DUSK.skyLow, dusk)), NIGHT.skyLow, night); // Paper darkens gradually; ink flips quickly in the middle, so the two never meet in gray. const ink = mix(DAY.ink, NIGHT.ink, smooth(0.42, 0.58, night)); const moon = t < 0.25 || t > 0.77; const arc = moon ? (t > 0.5 ? (t - 0.77) / 0.48 : (t + 0.23) / 0.48) : (t - 0.25) / 0.52; return { paper: mix(DAY.paper, NIGHT.paper, night), ink, sky: [top, low], night, height: Math.sin(Math.PI * Math.min(1, Math.max(0, arc))), moon }; } function mixHex(a: Rgb, b: Rgb, t: number) { return `#${a.map((value, i) => Math.round(value + (b[i] - value) * t).toString(16).padStart(2, "0")).join("")}`; } /** Pushes a flat color toward the night palette: darker and bluer as night falls. */ export function nightTint(color: string, tau: number) { return mix(hex(color), [58, 62, 110], nightness(tau) * 0.62); }lib/line-art/figures.tsimport { gait, joint, standing, GROUND, type GaitPose } from "@/lib/line-art/gait"; import { DETAIL_PEN, PEN, el, limb, line, paint, poly, shape, type Point } from "@/lib/line-art/ink"; import { pendulum, type Pendulum } from "@/lib/line-art/physics"; /** * Faceless, open-stroke figures in figure space (about 95 units tall, feet on y = 120, facing * +x). Each is one <g>: place it with a single transform. Poses come from the gait solver; hanging * things are pendulums; reactions are short keyed arm moves. */ export type FigureKind = "commuter" | "courier" | "jogger" | "cyclist"; export interface Figure { group: SVGGElement; /** Advances the figure by `dt` seconds at `factor` times its usual pace, with `wind` on its loose ends. Returns how far it travelled, in figure units, so feet and wheels never slip. */ step(dt: number, factor: number, wind: number): number; /** Starts the figure's reaction: a tipped hat, a wave, a look at a watch or a rung bell. */ react(): void; /** Draws a still pose. `reacting` holds the reaction at its peak. */ still(reacting?: boolean): void; /** True while a reaction is playing. */ busy(): boolean; /** What the reaction is, for labels: "tips a hat". */ reaction: string; /** Rough half-width of the figure, for hit areas. */ reach: number; } export interface FigureOptions { /** Flat accent for one small area: the scarf, the cap, the vest or the frame. */ accent?: string; /** Leave out what the figure carries (the parcel, the briefcase and scarf), for a plain passer-by. */ bare?: boolean; } const UPPER = 16; const FORE = 15; const circle = (cx: number, cy: number, r: number) => `M${(cx - r).toFixed(2)} ${cy.toFixed(2)}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`; const ease = (t: number) => t * t * (3 - 2 * t); /** 0 → 1 → 0 over a reaction: quick in, hold, quick out. */ const envelope = (t: number, length: number) => ease(Math.min(1, Math.max(0, t / 0.3))) * ease(Math.min(1, Math.max(0, (length - t) / 0.35))); const REACTIONS = { commuter: { label: "tips a hat", length: 1.5 }, courier: { label: "waves", length: 1.7 }, jogger: { label: "checks a watch", length: 1.7 }, cyclist: { label: "rings the bell", length: 1.1 }, } as const; function walker(parent: Element, kind: Exclude<FigureKind, "cyclist">, accent: string, bare: boolean): Figure { const group = el("g", {}, parent); const running = kind === "jogger"; const options = running ? { stride: 40, duty: 0.36, lift: 7 } : { stride: 26, duty: 0.62 }; const cadence = running ? 1.3 : 0.95; const lean = running ? 6 : 1.6; const torsoLength = 30; // Far side first, so the near side draws over it. const farArm = limb(group, 4.2); const farLeg = limb(group, 5.4); const farFoot = line(group); const bag = kind === "commuter" && !bare ? shape(group) : null; const bagHandle = bag ? line(group, "", DETAIL_PEN) : null; const parcel = kind === "courier" && !bare ? shape(group) : null; const parcelTape = parcel ? line(group, "", DETAIL_PEN) : null; const torso = kind === "jogger" ? paint(group, accent) : shape(group); const nearLeg = limb(group, 5.4); const nearFoot = line(group); const scarf = kind === "commuter" && !bare ? limb(group, 3.4, accent) : null; const head = shape(group); const hat = kind === "commuter" ? shape(group) : kind === "courier" ? paint(group, accent) : line(group); const nearArm = limb(group, 4.2); const watch = kind === "jogger" ? line(group, "", PEN * 1.6) : null; let phase = 0.1; let reactionAt = -1; const scarfSwing: Pendulum = { angle: -0.4, velocity: 0 }; const bagSwing: Pendulum = { angle: 0, velocity: 0 }; const reaction = REACTIONS[kind]; function draw(pose: GaitPose, moving: number, wind: number, dt: number) { const hip = pose.hip; const shoulder: Point = [hip[0] + lean, hip[1] - torsoLength]; const headAt: Point = [shoulder[0] + 1.6, shoulder[1] - 9.5]; const w = reactionAt >= 0 ? envelope(reactionAt, reaction.length) : 0; // Legs and feet. for (const [leg, legLimb, footLine] of [[pose.far, farLeg, farFoot], [pose.near, nearLeg, nearFoot]] as const) { legLimb.set([leg.hip, leg.knee, leg.ankle]); footLine.setAttribute("d", poly([[leg.ankle[0] - 1.5, leg.ankle[1] + 2.4], leg.toe])); } // Torso: a coat with a hem, or a short running top. const hem = running ? 1 : 6; const [sx, sy] = shoulder; const [hx, hy] = hip; torso.setAttribute( "d", `M${sx - 6.5} ${sy + 1.5}Q${sx} ${sy - 3.5} ${sx + 6.5} ${sy + 1.5}L${hx + 8} ${hy + hem}Q${hx} ${hy + hem + 2} ${hx - 8} ${hy + hem}Z`, ); // Arms swing against the legs. A reaction takes the near arm to a target with IK. const arm = (angle: number, bend: number): [Point, Point] => { const elbow: Point = [sx + Math.sin(angle) * UPPER, sy + 2 + Math.cos(angle) * UPPER]; return [elbow, [elbow[0] + Math.sin(angle + bend) * FORE, elbow[1] + Math.cos(angle + bend) * FORE]]; }; const amplitude = (running ? 0.75 : 0.42) * moving; const bend = running ? 1.35 : 0.22; const [farElbow, farHand] = arm(amplitude * pose.swing, bend + Math.max(0, pose.swing) * 0.3 * moving); farArm.set([[sx, sy + 2], farElbow, farHand]); // The courier's near arm holds the parcel instead of swinging. let [nearElbow, nearHand] = parcel ? arm(0.25, 1.25) : arm(-amplitude * pose.swing, bend + Math.max(0, -pose.swing) * 0.3 * moving); if (w > 0) { const t = reactionAt; const target: Point = kind === "commuter" ? [headAt[0] + 9, headAt[1] - 5] : kind === "courier" ? [sx + 15 + Math.sin(t * 11) * 4, sy - 15] : [sx + 10, sy + 9]; nearHand = [nearHand[0] + (target[0] - nearHand[0]) * w, nearHand[1] + (target[1] - nearHand[1]) * w]; nearElbow = joint([sx, sy + 2], nearHand, UPPER, FORE, 1); } nearArm.set([[sx, sy + 2], nearElbow, nearHand]); // Head, looking down a little while checking a watch. const nod = kind === "jogger" ? w * 1.6 : 0; const hxp = headAt[0] + nod; const hyp = headAt[1] + nod; head.setAttribute("d", circle(hxp, hyp, 7)); if (kind === "commuter") { // A brimmed hat that lifts off the head when tipped. const up = w * 4.5; hat.setAttribute("d", `M${hxp - 9.5} ${hyp - 4 - up}L${hxp + 10} ${hyp - 5 - up}M${hxp - 6} ${hyp - 4.3 - up}Q${hxp} ${hyp - 14 - up} ${hxp + 6.5} ${hyp - 4.8 - up}Z`); } else if (kind === "courier") { hat.setAttribute("d", `M${hxp - 6.6} ${hyp - 2.5}Q${hxp} ${hyp - 12} ${hxp + 6.8} ${hyp - 3}L${hxp + 12.5} ${hyp - 2}L${hxp + 6.8} ${hyp - 1.2}Z`); } else { hat.setAttribute("d", `M${hxp - 6.8} ${hyp - 2}L${hxp + 6.8} ${hyp - 3.2}`); } // Loose ends: the scarf trails behind and the briefcase swings from the far hand. if (scarf) { const a = pendulum(scarfSwing, -(0.5 + moving * 2.4) + wind * 9, dt, 9, 3); const root: Point = [sx - 3.5, sy + 1.5]; const mid: Point = [root[0] + Math.sin(a) * 8, root[1] + Math.cos(a) * 8]; scarf.set([root, mid, [mid[0] + Math.sin(a * 1.45) * 8, mid[1] + Math.cos(a * 1.45) * 8]]); } if (bag && bagHandle) { const a = pendulum(bagSwing, -pose.swing * 2.4 * moving + wind * 6, dt, 10, 2.6); const cos = Math.cos(a); const sin = Math.sin(a); const at = (x: number, y: number): Point => [farHand[0] + x * cos - y * sin, farHand[1] + x * sin + y * cos]; bag.setAttribute("d", poly([at(-7, 3), at(7, 3), at(7, 13), at(-7, 13)], true)); bagHandle.setAttribute("d", poly([at(-3, 3), at(-3, 0.5), at(3, 0.5), at(3, 3)])); } if (parcel && parcelTape) { const px = sx + 2; const py = sy + 12; parcel.setAttribute("d", poly([[px, py], [px + 13, py - 1], [px + 13.5, py + 9], [px + 0.5, py + 10]], true)); parcelTape.setAttribute("d", poly([[px + 6.5, py - 0.5], [px + 7, py + 9.5]])); } if (watch) watch.setAttribute("d", poly([[nearHand[0] - 2.2, nearHand[1] - 3], [nearHand[0] + 1.2, nearHand[1] - 4.4]])); } return { group, reaction: reaction.label, reach: 16, step(dt, factor, wind) { const before = phase; phase += cadence * factor * dt; if (reactionAt >= 0) { reactionAt += dt; if (reactionAt > reaction.length) reactionAt = -1; } draw(gait(phase, options), Math.min(1, factor), wind, dt); return ((phase - before) * options.stride) / options.duty; }, react() { reactionAt = 0; }, still(reacting = false) { reactionAt = reacting ? reaction.length / 2 : -1; scarfSwing.angle = -0.25; scarfSwing.velocity = 0; bagSwing.angle = 0; bagSwing.velocity = 0; draw(standing(), 0, 0, 0); reactionAt = -1; }, busy: () => reactionAt >= 0, }; } function cyclist(parent: Element, accent: string): Figure { const group = el("g", {}, parent); const R = 13; const rear: Point = [-18, GROUND - R]; const front: Point = [20, GROUND - R]; const bracket: Point = [-1, GROUND - R + 1.5]; const saddle: Point = [-8, 80]; const bars: Point = [14, 79]; const headTube: Point = [13, 86]; const hip: Point = [-7, 77.5]; const shoulder: Point = [6.5, 55]; const headAt: Point = [11, 46.5]; const CRANK = 6.5; const farArm = limb(group, 4); const farLeg = limb(group, 5.2); const farPedal = line(group); // Wheels turn as groups, spokes and all. const wheels = [rear, front].map((hub) => { const wheel = el("g", {}, group); shape(wheel, circle(hub[0], hub[1], R)); for (let i = 0; i < 3; i++) { const a = (i * Math.PI) / 3; line(wheel, poly([[hub[0] - Math.cos(a) * (R - 1), hub[1] - Math.sin(a) * (R - 1)], [hub[0] + Math.cos(a) * (R - 1), hub[1] + Math.sin(a) * (R - 1)]]), DETAIL_PEN); } return { wheel, hub }; }); // Frame: one flat accent, the rest ink. paint(group, accent, poly([bracket, [saddle[0] + 1, saddle[1] + 3], headTube], true), PEN); line(group, poly([rear, bracket]) + poly([rear, [saddle[0] + 1, saddle[1] + 3]]) + poly([headTube, front]) + poly([headTube, bars]) + poly([[bars[0] - 2, bars[1] - 1], [bars[0] + 3, bars[1] + 1.5]])); line(group, poly([[saddle[0] - 4, saddle[1]], [saddle[0] + 4, saddle[1] - 0.6]]), PEN * 1.5); const cranks = line(group); const lamp = paint(group, "var(--la-glow, #f6c453)", circle(headTube[0] + 3.5, headTube[1] - 2, 1.8), DETAIL_PEN); lamp.setAttribute("data-lamp", ""); const torso = shape(group); const nearLeg = limb(group, 5.2); const nearPedal = line(group); const head = shape(group); const helmet = paint(group, accent); const nearArm = limb(group, 4); const bell = line(group, "", DETAIL_PEN); let distance = 0; let reactionAt = -1; const reaction = REACTIONS.cyclist; function draw() { // Rolling without slipping: the wheel's angle is the distance travelled over its radius. const wheelAngle = (distance / R) * (180 / Math.PI); for (const { wheel, hub } of wheels) wheel.setAttribute("transform", `rotate(${wheelAngle.toFixed(2)} ${hub[0]} ${hub[1]})`); const crankAngle = (distance / R) * 0.42; const pedal = (offset: number): Point => [bracket[0] + Math.cos(crankAngle + offset) * CRANK, bracket[1] + Math.sin(crankAngle + offset) * CRANK]; const near = pedal(0); const far = pedal(Math.PI); cranks.setAttribute("d", poly([far, near])); for (const [foot, legLimb, pedalLine] of [[far, farLeg, farPedal], [near, nearLeg, nearPedal]] as const) { const ankle: Point = [foot[0] - 1.5, foot[1] - 3]; legLimb.set([hip, joint(hip, ankle, 21, 21, -1), ankle]); pedalLine.setAttribute("d", poly([[foot[0] - 3.5, foot[1]], [foot[0] + 4, foot[1] - 0.6]])); } torso.setAttribute("d", `M${hip[0] - 5.5} ${hip[1] + 2}Q${hip[0] - 5} ${shoulder[1] + 4} ${shoulder[0] - 3} ${shoulder[1] - 2}Q${shoulder[0] + 3} ${shoulder[1] - 3} ${shoulder[0] + 5} ${shoulder[1] + 3}L${hip[0] + 6} ${hip[1] + 3}Z`); head.setAttribute("d", circle(headAt[0], headAt[1], 6.8)); helmet.setAttribute("d", `M${headAt[0] - 7} ${headAt[1] - 1.5}Q${headAt[0]} ${headAt[1] - 12} ${headAt[0] + 7.4} ${headAt[1] - 1}Z`); const grip: Point = [bars[0] + 0.5, bars[1]]; const start: Point = [shoulder[0] + 1, shoulder[1] + 2]; farArm.set([start, joint(start, grip, 15, 14, -1), grip]); // Ringing the bell: the thumb works and three short marks flash by the bars. const w = reactionAt >= 0 ? envelope(reactionAt, reaction.length) : 0; const press: Point = [grip[0] - 1.5 + Math.sin(reactionAt * 24) * 0.8 * w, grip[1] - 1.5 * w]; nearArm.set([start, joint(start, press, 15, 14, -1), press]); const flash = w > 0 && Math.floor(reactionAt * 9) % 2 === 0; bell.setAttribute("d", flash ? `M${bars[0] + 5} ${bars[1] - 5}l3 -3M${bars[0] + 6.5} ${bars[1] - 1.5}l4 -0.6M${bars[0] + 2.5} ${bars[1] - 7}l1 -4` : ""); } return { group, reaction: reaction.label, reach: 34, step(dt, factor) { const moved = 118 * factor * dt; distance += moved; if (reactionAt >= 0) { reactionAt += dt; if (reactionAt > reaction.length) reactionAt = -1; } draw(); return moved; }, react() { reactionAt = 0; }, still(reacting = false) { reactionAt = reacting ? 0.34 : -1; draw(); reactionAt = -1; }, busy: () => reactionAt >= 0, }; } /** Builds a figure inside `parent`. It draws nothing until its first `step` or `still`. */ export function createFigure(parent: Element, kind: FigureKind, options: FigureOptions = {}): Figure { const accent = options.accent ?? "#c8553d"; return kind === "cyclist" ? cyclist(parent, accent) : walker(parent, kind, accent, options.bare ?? false); }lib/line-art/gait.tsimport type { Point } from "@/lib/line-art/ink"; /** * A walk and run solver in figure space: about 95 units tall, feet on y = 120, facing +x. * Feet are placed first and everything else follows from them: a planted toe never slides, the * hip's height comes from the planted leg, and knees are solved with two-bone IK. */ export const GROUND = 120; export const THIGH = 22; export const SHIN = 22; /** How high the ankle sits above the sole, and how far behind the toe. */ const ANKLE_UP = 3.2; const FOOT = 6.5; /** * Two-bone IK: the middle joint between `a` and `b` for bones of length `l1` and `l2`. * `bend` picks the side the joint folds to (1 or -1). Out of reach, the chain straightens. */ export function joint(a: Point, b: Point, l1: number, l2: number, bend = 1): Point { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const distance = Math.min(Math.hypot(dx, dy), l1 + l2 - 0.001) || 0.001; const along = (l1 * l1 - l2 * l2 + distance * distance) / (2 * distance); const off = Math.sqrt(Math.max(0, l1 * l1 - along * along)) * bend; const length = Math.hypot(dx, dy) || 0.001; const nx = dx / length; const ny = dy / length; return [a[0] + nx * along - ny * off, a[1] + ny * along + nx * off]; } /** Minimum-jerk easing: starts and ends with zero velocity and acceleration. */ const minJerk = (u: number) => u * u * u * (10 - 15 * u + 6 * u * u); export interface GaitOptions { /** Distance a foot travels per step, in figure units. @default 26 */ stride?: number; /** Share of the cycle each foot spends on the ground: about 0.62 walking, 0.36 running. @default 0.62 */ duty?: number; /** How high a swinging foot lifts. Kept low. @default 3.2 */ lift?: number; } export interface Leg { hip: Point; knee: Point; ankle: Point; toe: Point; /** True while the toe is on the ground. */ planted: boolean; } export interface GaitPose { /** Hip joint, x relative to the figure's origin. */ hip: Point; near: Leg; far: Leg; /** -1 to 1, in step with the near leg: drive the arms against it. */ swing: number; } function foot(phase: number, stride: number, duty: number, lift: number) { const p = ((phase % 1) + 1) % 1; if (p < duty) { // Stance: the toe is fixed on the ground, so relative to the hip it slides straight back. const u = p / duty; const toeX = stride / 2 - stride * u + FOOT * 0.5; // Heel-off: over the last 14% of stance the foot pivots on the toe and the heel rises. const heel = Math.max(0, (u - 0.86) / 0.14); const angle = heel * heel * 0.7; return { toe: [toeX, GROUND] as Point, ankle: [toeX - FOOT * Math.cos(angle) + ANKLE_UP * Math.sin(angle) * 0.4, GROUND - ANKLE_UP - FOOT * Math.sin(angle)] as Point, planted: true }; } // Swing: a minimum-jerk reach from behind to in front, with a low lift. const u = (p - duty) / (1 - duty); const reach = minJerk(u); const toeX = -stride / 2 + stride * reach + FOOT * 0.5; const raise = Math.sin(Math.PI * u) * lift; const angle = 0.7 * (1 - u) * (1 - u) - 0.18 * Math.sin(Math.PI * u) * u; return { toe: [toeX, GROUND - raise] as Point, ankle: [toeX - FOOT * Math.cos(angle), GROUND - raise - ANKLE_UP - FOOT * Math.sin(angle)] as Point, planted: false }; } /** Solves one instant of the cycle. `phase` runs 0 to 1 per two steps. */ export function gait(phase: number, options: GaitOptions = {}): GaitPose { const stride = options.stride ?? 26; const duty = options.duty ?? 0.62; const lift = options.lift ?? 3.2; const near = foot(phase, stride, duty, lift); const far = foot(phase + 0.5, stride, duty, lift); // The hip is as high as the planted leg allows when almost straight, and never higher. const reach = (THIGH + SHIN) * 0.985; const hipOver = (ankle: Point) => ankle[1] - Math.sqrt(Math.max(reach * reach - ankle[0] * ankle[0], 1)); let hipY = -Infinity; for (const leg of [near, far]) if (leg.planted) hipY = Math.max(hipY, hipOver(leg.ankle)); if (hipY === -Infinity) { // Flight (running): both feet are off the ground. The hip rides a shallow arc from where the // pushing leg left it to where the landing leg will take it. const p = ((phase % 0.5) + 0.5) % 0.5; const u = Math.min(1, Math.max(0, (p - duty) / (0.5 - duty))); const takeoff = hipOver(foot(duty - 1e-6, stride, duty, lift).ankle); const landing = hipOver(foot(0, stride, duty, lift).ankle); hipY = takeoff + (landing - takeoff) * u - Math.sin(Math.PI * u) * 2.2; } const hip: Point = [0, hipY]; const leg = (f: typeof near): Leg => { // A swinging foot that reaches past the leg's length is pulled back in, so bones never stretch. const dx = f.ankle[0] - hip[0]; const dy = f.ankle[1] - hip[1]; const over = Math.hypot(dx, dy) / (THIGH + SHIN - 0.05); if (f.planted || over <= 1) return { hip, knee: joint(hip, f.ankle, THIGH, SHIN, -1), ankle: f.ankle, toe: f.toe, planted: f.planted }; const ankle: Point = [hip[0] + dx / over, hip[1] + dy / over]; const toe: Point = [f.toe[0] + ankle[0] - f.ankle[0], f.toe[1] + ankle[1] - f.ankle[1]]; return { hip, knee: joint(hip, ankle, THIGH, SHIN, -1), ankle, toe, planted: false }; }; return { hip, near: leg(near), far: leg(far), swing: Math.sin(phase * Math.PI * 2 + Math.PI * duty) }; } /** Standing still, feet a little apart. */ export function standing(): GaitPose { const reach = (THIGH + SHIN) * 0.992; const hip: Point = [0, GROUND - ANKLE_UP - reach]; const leg = (toeX: number): Leg => { const ankle: Point = [toeX - FOOT, GROUND - ANKLE_UP]; return { hip, knee: joint(hip, ankle, THIGH, SHIN, -1), ankle, toe: [toeX, GROUND], planted: true }; }; return { hip, near: leg(6), far: leg(3), swing: 0 }; }lib/line-art/ink.ts/** * Ink: a tiny, dependency-free SVG drawing kit for line art. Thin, slightly wobbly strokes in * `currentColor` over paper-colored fills, so a scene inverts cleanly at night by changing two * CSS colors. Browser only: call these from effects. */ export type Point = readonly [x: number, y: number]; /** Pen width in figure units (a figure is about 95 units tall). */ export const PEN = 1.35; /** Width of the detail pen, for mullions, brick courses and other fine marks. */ export const DETAIL_PEN = PEN * 0.5; /** Day colors. Night swaps them through CSS variables. */ export const INK = "#1c1c1c"; export const PAPER = "#fdfcf8"; const SVG_NS = "http://www.w3.org/2000/svg"; /** Creates an SVG element, sets its attributes and appends it to `parent` when given. */ export function el<K extends keyof SVGElementTagNameMap>(name: K, attributes: Record<string, string | number> = {}, parent?: Element): SVGElementTagNameMap[K] { const node = document.createElementNS(SVG_NS, name); for (const key in attributes) node.setAttribute(key, String(attributes[key])); parent?.appendChild(node); return node; } /** * Adds the one wobble filter a drawing needs and returns its `url(#…)` reference: light * turbulence displaces the strokes by under half a unit and a hair of blur softens them, so * lines read as ink on paper, not as vectors. Keep it on the drawing group only. */ export function inkDefs(svg: SVGSVGElement, id: string, seed = 3) { const defs = el("defs", {}, svg); const filter = el("filter", { id, x: "-5%", y: "-5%", width: "110%", height: "110%" }, defs); el("feTurbulence", { type: "fractalNoise", baseFrequency: 0.6, numOctaves: 1, seed, result: "noise" }, filter); el("feDisplacementMap", { in: "SourceGraphic", in2: "noise", scale: 0.45, xChannelSelector: "R", yChannelSelector: "G", result: "moved" }, filter); el("feGaussianBlur", { in: "moved", stdDeviation: 0.12 }, filter); return `url(#${id})`; } const STROKE = { fill: "none", stroke: "currentColor", "stroke-linecap": "round", "stroke-linejoin": "round" } as const; /** An open ink line. */ export function line(parent: Element, d = "", width = PEN) { return el("path", { d, ...STROKE, "stroke-width": width }, parent); } /** A closed outline filled with paper, so it hides what is behind it. */ export function shape(parent: Element, d = "", width = PEN) { return el("path", { d, ...STROKE, fill: "var(--la-paper)", "stroke-width": width }, parent); } /** A solid ink fill. */ export function blot(parent: Element, d = "") { return el("path", { d, fill: "currentColor", stroke: "currentColor", "stroke-width": PEN * 0.5, "stroke-linejoin": "round" }, parent); } /** A flat color fill with an ink outline. One or two of these per scene, in small areas. */ export function paint(parent: Element, color: string, d = "", width = PEN) { return el("path", { d, ...STROKE, fill: color, "stroke-width": width }, parent); } const n = (value: number) => value.toFixed(2); /** A smooth open path through points (quadratic segments through the midpoints). */ export function through(points: readonly Point[]) { if (points.length < 2) return ""; let d = `M${n(points[0][0])} ${n(points[0][1])}`; for (let i = 1; i < points.length - 1; i++) { const mx = (points[i][0] + points[i + 1][0]) / 2; const my = (points[i][1] + points[i + 1][1]) / 2; d += `Q${n(points[i][0])} ${n(points[i][1])} ${n(mx)} ${n(my)}`; } const last = points[points.length - 1]; return `${d}L${n(last[0])} ${n(last[1])}`; } /** A straight polyline. */ export function poly(points: readonly Point[], close = false) { return points.map(([x, y], i) => `${i ? "L" : "M"}${n(x)} ${n(y)}`).join("") + (close ? "Z" : ""); } /** The points of a curve pushed sideways by `offset` (positive is to the left of travel). */ function offsetPoints(points: readonly Point[], offset: number): Point[] { return points.map((point, i) => { const before = points[Math.max(0, i - 1)]; const after = points[Math.min(points.length - 1, i + 1)]; const dx = after[0] - before[0]; const dy = after[1] - before[1]; const length = Math.hypot(dx, dy) || 1; return [point[0] + (dy / length) * offset, point[1] - (dx / length) * offset] as const; }); } export interface Limb { /** Redraws the limb through its joints, root first. */ set(joints: readonly Point[]): void; group: SVGGElement; } /** * A limb drawn the way a pen would: two open strokes, one down each side of a curve through the * joints, with round caps and a paper fill between them. `fill` paints the inside a flat color. */ export function limb(parent: Element, width: number, fill = "var(--la-paper)"): Limb { const group = el("g", {}, parent); const inside = el("path", { fill, stroke: "none" }, group); const left = line(group); const right = line(group); return { group, set(joints) { const a = offsetPoints(joints, width / 2); const b = offsetPoints(joints, -width / 2); const da = through(a); const db = through(b); left.setAttribute("d", da); right.setAttribute("d", db); inside.setAttribute("d", `${da}${through([...b].reverse()).replace("M", "L")}Z`); }, }; }lib/line-art/loop.ts/** * The one animation loop a line-art scene runs: it ticks only while the host is on screen, the * tab is visible, nothing has paused it and the user has not asked for reduced motion. Under * reduced motion it calls `still` once instead, and again whenever asked to redraw. Browser only. */ export interface SceneLoopOptions { /** Element watched for visibility. */ host: Element; /** Advance by `dt` seconds (capped at 0.05) and draw. */ tick: (dt: number, time: number) => void; /** Draw a still picture. Called under reduced motion. */ still: () => void; /** Read before every frame; while true the loop rests. */ isPaused?: () => boolean; } export interface SceneLoop { /** Re-checks everything and starts, stops or redraws. Call it when props change. */ wake(): void; /** True when the user prefers reduced motion. */ reduced(): boolean; dispose(): void; } export function runSceneLoop({ host, tick, still, isPaused }: SceneLoopOptions): SceneLoop { const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let frame = 0; let last = 0; let time = 0; let inView = false; let disposed = false; const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !isPaused?.(); const step = (now: number) => { frame = 0; if (!shouldRun()) return; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; time += dt; tick(dt, time); frame = requestAnimationFrame(step); }; const wake = () => { if (disposed) return; if (shouldRun()) { if (!frame) { last = 0; frame = requestAnimationFrame(step); } return; } if (frame) cancelAnimationFrame(frame); frame = 0; if (reducedMotion.matches) still(); }; const observer = new IntersectionObserver( ([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }, { rootMargin: "80px" }, ); observer.observe(host); document.addEventListener("visibilitychange", wake); reducedMotion.addEventListener("change", wake); if (reducedMotion.matches) still(); return { wake, reduced: () => reducedMotion.matches, dispose() { disposed = true; if (frame) cancelAnimationFrame(frame); observer.disconnect(); document.removeEventListener("visibilitychange", wake); reducedMotion.removeEventListener("change", wake); }, }; }lib/line-art/physics.ts/** * Small physics for line art: a seeded generator, a spring, a pendulum for things that hang, * pointer speed as wind, and keyed pose timelines for reactions. Framework-free. */ /** A seeded generator (mulberry32): the same seed always gives the same scene. */ export function rng(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export interface Spring { value: number; velocity: number; } /** Steps a damped spring toward `target`. Stable for the frame times a canvas loop produces. */ export function spring(state: Spring, target: number, stiffness: number, damping: number, dt: number) { const step = Math.min(dt, 1 / 30); state.velocity += ((target - state.value) * stiffness - state.velocity * damping) * step; state.value += state.velocity * step; return state.value; } export interface Pendulum { /** Angle from hanging straight down, in radians. */ angle: number; velocity: number; } /** * Steps something that hangs: scarves, bags, signs, lanterns. `push` is a sideways acceleration * (wind, or the carrier speeding up), `length` sets how slowly it swings. */ export function pendulum(state: Pendulum, push: number, dt: number, length = 12, damping = 2.2) { const step = Math.min(dt, 1 / 30); state.velocity += (-(60 / length) * Math.sin(state.angle) + push * Math.cos(state.angle) - state.velocity * damping) * step; state.angle += state.velocity * step; return state.angle; } export interface PointerWind { /** Signed wind right now: positive blows toward +x. Decays to 0 when the pointer rests. */ read(dt: number): number; dispose(): void; } /** Turns pointer speed over `target` into wind. */ export function pointerWind(target: HTMLElement | SVGElement, strength = 0.02): PointerWind { let lastX = 0; let lastTime = 0; let gust = 0; const onMove = (event: Event) => { const pointer = event as PointerEvent; const now = performance.now(); if (lastTime && now - lastTime < 120) { const speed = (pointer.clientX - lastX) / Math.max(1, now - lastTime); gust += Math.max(-1, Math.min(1, speed * strength * 16)) * 0.35; gust = Math.max(-1.5, Math.min(1.5, gust)); } lastX = pointer.clientX; lastTime = now; }; target.addEventListener("pointermove", onMove, { passive: true }); return { read(dt) { gust *= Math.pow(0.08, dt); return gust; }, dispose() { target.removeEventListener("pointermove", onMove); }, }; } export type Key<T extends readonly number[]> = readonly [time: number, value: T]; /** * A keyed timeline of poses: `sample(t)` eases between the keys on either side of `t`. * Times are in seconds; before the first key and after the last, the nearest key holds. */ export function sequence<T extends readonly number[]>(keys: readonly Key<T>[]) { const end = keys[keys.length - 1][0]; return { duration: end, sample(t: number): number[] { if (t <= keys[0][0]) return [...keys[0][1]]; if (t >= end) return [...keys[keys.length - 1][1]]; let i = 1; while (keys[i][0] < t) i++; const [t0, a] = keys[i - 1]; const [t1, b] = keys[i]; const u = (t - t0) / (t1 - t0); const eased = u * u * (3 - 2 * u); return a.map((value, index) => value + (b[index] - value) * eased); }, }; }Copy the source
components/ui/line-art-street/line-art-street.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react"; import { Pause, Play } from "lucide-react"; import { cn } from "@/lib/utils"; import { nightTint, palette } from "@/lib/line-art/daynight"; import { createFigure, type Figure } from "@/lib/line-art/figures"; import { GROUND } from "@/lib/line-art/gait"; import { PEN, el, inkDefs, type Point } from "@/lib/line-art/ink"; import { runSceneLoop } from "@/lib/line-art/loop"; import { pendulum, pointerWind, rng, type Pendulum } from "@/lib/line-art/physics"; export type StreetFigure = "commuter" | "cyclist" | "child" | "shopkeeper"; export interface LineArtStreetProps extends Omit<ComponentProps<"div">, "children"> { /** Pins the time of day: 0 is midnight, 0.5 is noon. Leave it out to let the day run. */ tau?: number; /** Seconds from dawn to nightfall while the day runs. @default 60 */ dayLength?: number; /** Time of day the scene opens on. @default 0.34 */ startTau?: number; /** The flat accent for awnings, flowers and the bicycle frame. @default "#c8553d" */ accent?: string; /** Who is on the street: a list of figures, or how many to take from the default order. @default all four */ figures?: StreetFigure[] | number; /** Let people, windows, birds and the sun react to the pointer. @default true */ interactive?: boolean; /** Start paused. The Pause button toggles it from there. @default false */ paused?: boolean; /** Content laid over the paper, such as a headline. */ children?: ReactNode; } /** The street is drawn in these units. Figures are scaled down into it. */ const W = 520; const TOP = -52; const BOTTOM = 136; const S = 0.55; /** Pen widths in street units, matched to the figures' scaled strokes. */ const P = PEN * S; const D = P * 0.5; const ALL: StreetFigure[] = ["commuter", "cyclist", "child", "shopkeeper"]; const SECOND_ACCENT = "#3d4a8f"; const SECOND_ACCENT_VAR = "var(--la-accent-2)"; const TIME_NAMES = ["Midnight", "Night", "Dawn", "Morning", "Noon", "Afternoon", "Dusk", "Night", "Midnight"]; const timeName = (tau: number) => TIME_NAMES[Math.round((((tau % 1) + 1) % 1) * 8)]; export function LineArtStreet({ tau, dayLength = 60, startTau = 0.34, accent = "#c8553d", figures, interactive = true, paused = false, children, className, style, ...props }: LineArtStreetProps) { const id = useId().replace(/[^a-zA-Z0-9]/g, ""); const rootRef = useRef<HTMLDivElement>(null); const svgRef = useRef<SVGSVGElement>(null); const sliderRef = useRef<HTMLInputElement>(null); const [isPaused, setIsPaused] = useState(paused); const control = useRef<{ paused: boolean; setTau: (value: number) => void; wake: () => void }>({ paused, setTau: () => {}, wake: () => {} }); const latest = useRef({ tau, dayLength, interactive }); useEffect(() => { latest.current = { tau, dayLength, interactive }; control.current.paused = isPaused; control.current.wake(); }); const cast = (typeof figures === "number" ? ALL.slice(0, Math.max(0, figures)) : (figures ?? ALL)).join(","); useEffect(() => { const root = rootRef.current; const svg = svgRef.current; if (!root || !svg) return; svg.replaceChildren(); const controls = control.current; const random = rng(235); const who = cast ? (cast.split(",") as StreetFigure[]) : []; // ---- Layers, back to front. const defs = el("defs", {}, svg); const skyGradient = el("linearGradient", { id: `${id}-sky`, x1: 0, y1: 0, x2: 0, y2: 1 }, defs); const skyTop = el("stop", { offset: 0 }, skyGradient); const skyLow = el("stop", { offset: 1 }, skyGradient); const halo = el("radialGradient", { id: `${id}-halo` }, defs); el("stop", { offset: 0, "stop-color": "#ffd98a", "stop-opacity": 0.95 }, halo); el("stop", { offset: 1, "stop-color": "#ffd98a", "stop-opacity": 0 }, halo); el("rect", { x: 0, y: TOP, width: W, height: GROUND - TOP, fill: `url(#${id}-sky)` }, svg); const stars = el("g", { fill: "#f4f1ff" }, svg); const starDots = Array.from({ length: 26 }, () => { const star = el("circle", { cx: random() * W, cy: TOP + 4 + random() * 46, r: 0.5 + random() * 0.5 }, stars); return { star, phase: random() * 6.28, rate: 1 + random() * 2 }; }); const sky = el("g", {}, svg); const sun = el("circle", { r: 9, fill: "#ffd77a", stroke: "currentColor", "stroke-width": P }, sky); const moon = el("path", { d: "M0 -8a8 8 0 1 0 6.9 12 6.4 6.4 0 1 1 -6.9 -12z", fill: "#f1eedc", stroke: "currentColor", "stroke-width": P }, sky); const grab = el("circle", { r: 16, fill: "transparent", style: "cursor:grab;touch-action:none" }, sky); const filter = inkDefs(svg, `${id}-ink`, 5); svg.querySelector(`#${id}-ink feDisplacementMap`)?.setAttribute("scale", String(0.45 * S + 0.1)); const drawing = el("g", { filter }, svg); const scenery = el("g", {}, drawing); const moving = el("g", {}, drawing); const glow = el("g", { style: "mix-blend-mode:screen" }, svg); const hits = el("g", { fill: "transparent" }, svg); // ---- Drawing helpers, in street units. const stroke = { fill: "none", stroke: "currentColor", "stroke-linecap": "round", "stroke-linejoin": "round" } as const; const ink = (d: string, width = P, parent: Element = scenery) => el("path", { d, ...stroke, "stroke-width": width }, parent); const paper = (d: string, parent: Element = scenery) => el("path", { d, ...stroke, fill: "var(--la-paper)", "stroke-width": P }, parent); const flat = (d: string, color = "var(--la-accent)", parent: Element = scenery) => el("path", { d, ...stroke, fill: color, "stroke-width": P }, parent); const box = (x: number, y: number, w: number, h: number) => `M${x} ${y}h${w}v${h}h${-w}z`; const sign = (text: string, x: number, y: number, size = 5.4) => { const node = el("text", { x, y, "font-size": size, "text-anchor": "middle", fill: "currentColor", "letter-spacing": 0.9, "font-weight": 600 }, scenery); node.textContent = text.toUpperCase(); }; /** Short dashes in rows: brick courses, drawn with the detail pen and left mostly as paper. */ const courses = (x: number, y: number, w: number, h: number) => { let d = ""; for (let row = y + 3; row < y + h - 1; row += 5) for (let n = 0; n < w / 22; n++) d += `M${(x + 3 + random() * (w - 12)).toFixed(1)} ${row}h${(3 + random() * 5).toFixed(1)}`; ink(d, D); }; const windows: { lit: boolean; pane: SVGElement }[] = []; /** A window: frame, mullions, a warm pane on the glow layer and a click target that toggles its light. */ const windowAt = (x: number, y: number, w: number, h: number, columns: number, rows: number, startsLit: boolean) => { paper(box(x, y, w, h)); let d = ""; for (let c = 1; c < columns; c++) d += `M${x + (w / columns) * c} ${y}v${h}`; for (let r = 1; r < rows; r++) d += `M${x} ${y + (h / rows) * r}h${w}`; ink(d, D); ink(`M${x - 1.5} ${y + h + 1}h${w + 3}`); const pane = el("rect", { x: x - 2, y: y - 2, width: w + 4, height: h + 4, rx: 2, fill: "#ffd98a", opacity: 0 }, glow); const entry = { lit: startsLit, pane }; windows.push(entry); const target = el("rect", { x, y, width: w, height: h, style: "cursor:pointer" }, hits); target.addEventListener("click", () => { if (!latest.current.interactive) return; entry.lit = !entry.lit; paint(); }); }; const door = (x: number, w: number) => { paper(box(x, GROUND - 58, w, 58)); ink(`M${x + 3} ${GROUND - 54}h${w - 6}v22h${-(w - 6)}zM${x + w - 4.5} ${GROUND - 26}v3`, D); }; /** A striped awning from y to y + drop. */ const awning = (x: number, y: number, w: number, drop: number, stripes: number) => { paper(`M${x} ${y}h${w}l4 ${drop}h${-(w + 8)}z`); for (let i = 0; i < stripes; i += 2) { const a = x + (w / stripes) * i; const b = x + (w / stripes) * (i + 1); const spread = (v: number) => x - 4 + ((v - x) / w) * (w + 8); flat(`M${a} ${y}H${b}L${spread(b)} ${y + drop}H${spread(a)}z`); } }; // ---- Things that hang, and sway with the wind. const hanging: { state: Pendulum; length: number; draw: (angle: number) => void }[] = []; const hang = (x: number, y: number, length: number, make: (group: SVGGElement) => void) => { const group = el("g", {}, scenery); make(group); hanging.push({ state: { angle: 0, velocity: 0 }, length, draw: (angle) => group.setAttribute("transform", `rotate(${((angle * 180) / Math.PI).toFixed(2)} ${x} ${y})`) }); }; const R = -12; const F = 48; // Tram rails along the whole street. ink(`M0 ${GROUND + 7}H${W}M0 ${GROUND + 10}H${W}`, D); // A. The bakery. paper(box(8, R, 104, GROUND - R)); ink(`M8 ${R + 5}h104M8 ${F}h104`); courses(10, R + 6, 100, F - R - 8); windowAt(22, 2, 28, 34, 2, 2, true); windowAt(70, 2, 28, 34, 2, 2, false); paper(box(20, F + 1.5, 80, 9)); sign("Fernwick", 60, F + 8.2); awning(12, F + 13, 96, 9, 12); windowAt(16, F + 26, 54, 36, 1, 1, false); // Bread in the window: loaves, a baguette and a round. ink(`M22 ${GROUND - 14}q5 -8 10 0zM36 ${GROUND - 14}q6 -9 12 0zM52 ${GROUND - 14}q5 -7 10 0zM24 ${GROUND - 26}l16 -3M46 ${GROUND - 27}a4 4 0 1 0 8 0a4 4 0 1 0 -8 0M20 ${GROUND - 14}h46M20 ${GROUND - 24}h46`, D); door(78, 24); // B. The bookshop, a little taller, with a cat in the middle window. paper(box(116, R - 8, 90, GROUND - R + 8)); ink(`M116 ${R - 3}h90M116 ${F}h90`); courses(118, R - 2, 86, F - R); windowAt(124, -6, 18, 38, 1, 2, false); windowAt(152, -6, 18, 38, 1, 2, true); windowAt(180, -6, 18, 38, 1, 2, false); paper(`M156 32q0 -9 3 -10l1 -3l1.6 3h2l1.6 -3l1 3q3 3 2.4 10z`); ink(`M166.6 32q4 -1 3 -6`, D); paper(box(126, F + 1.5, 70, 9)); sign("Brightloom Books", 161, F + 8.2, 4.8); windowAt(122, F + 16, 50, 46, 3, 2, false); let books = ""; for (let shelf = 0; shelf < 2; shelf++) for (let b = 0; b < 15; b++) books += `M${124.5 + b * 3.1} ${F + 36 + shelf * 23}v${-(5 + random() * 6).toFixed(1)}`; ink(books, D); door(178, 22); // C. The flower stall, in front of a low garden wall. paper(box(210, 30, 56, GROUND - 30)); courses(212, 32, 52, 22); ink(`M210 30h56`); paper(`M212 56h52v7q-4.3 5 -8.6 0q-4.3 5 -8.7 0q-4.3 5 -8.7 0q-4.3 5 -8.6 0q-4.3 5 -8.7 0q-4.3 5 -8.7 0z`); ink(`M216 63V${GROUND}M260 63V${GROUND}`); for (const [bx, n] of [[222, 4], [238, 5], [252, 4]] as const) { paper(`M${bx - 6} ${GROUND - 20}h12l-2 20h-8z`); let stems = ""; for (let i = 0; i < n; i++) { const fx = bx - 5 + (10 / (n - 1)) * i; const fy = GROUND - 28 - random() * 8; stems += `M${bx - 3 + i * 1.5} ${GROUND - 20}L${fx.toFixed(1)} ${fy.toFixed(1)}`; flat(`M${(fx - 2.4).toFixed(1)} ${fy.toFixed(1)}a2.4 2.4 0 1 0 4.8 0a2.4 2.4 0 1 0 -4.8 0`, i % 2 ? "var(--la-accent)" : "var(--la-paper)"); } ink(stems, D); } for (const rx of [226, 239, 251]) hang(rx, 63, 9, (group) => flat(`M${rx - 1.2} 63v11l1.2 -2l1.2 2v-11z`, "var(--la-accent)", group)); // The street lamp. ink(`M272 ${GROUND}V40q0 -8 8 -8M269 ${GROUND}h6`); paper(`M276 32h9l-1.6 8h-5.8z`); const lampGlow = el("circle", { cx: 280.5, cy: 38, r: 34, fill: `url(#${id}-halo)`, opacity: 0 }, glow); // D. The tram stop. ink(`M286 ${GROUND}V56M348 ${GROUND}V56`); paper(`M280 56q37 -9 74 0v4h-74z`); ink(`M290 64v40h54v-40M308 64v40M326 64v40`, D); paper(box(294, GROUND - 16, 46, 3.5)); ink(`M298 ${GROUND - 12}v12M336 ${GROUND - 12}v12M358 ${GROUND}V38`); paper(`M352 32a6 6 0 1 0 12 0a6 6 0 1 0 -12 0`); sign("T", 358, 34.2, 6.5); // E. The corner café. paper(box(366, R - 4, 146, GROUND - R + 4)); ink(`M366 ${R + 1}h146M366 ${F}h146`); courses(368, R + 2, 142, F - R - 4); windowAt(380, 0, 26, 36, 2, 2, true); windowAt(426, 0, 26, 36, 2, 2, false); windowAt(472, 0, 26, 36, 2, 2, true); paper(box(398, F + 1.5, 82, 9)); sign("Café Halvane", 439, F + 8.2); awning(372, F + 13, 134, 9, 16); // The awning's fringe: a row of short tabs that swing together. for (let i = 0; i < 12; i++) { const fx = 374 + i * 11.6; hang(fx, F + 22, 5, (group) => ink(`M${fx} ${F + 22}v3.4`, P, group)); } windowAt(374, F + 26, 58, 36, 2, 1, false); door(440, 24); windowAt(472, F + 26, 34, 36, 1, 1, false); // A hanging sign on a bracket. ink(`M366 44h-13`); hang(357, 44, 12, (group) => { paper(`M350 47h14v10h-14z`, group); ink(`M353 44v3M361 44v3M354 52h6`, D, group); }); // A bistro table and two chairs. ink(`M388 ${GROUND - 20}h18M397 ${GROUND - 20}V${GROUND}M393 ${GROUND}h8`); ink(`M378 ${GROUND - 30}v30M378 ${GROUND - 14}h9v14M378 ${GROUND - 30}q3 -3 6 0M416 ${GROUND - 30}v30M416 ${GROUND - 14}h-9v14M416 ${GROUND - 30}q-3 -3 -6 0`, D * 1.4); // The pavement line, drawn last so it sits over the walls' feet. ink(`M0 ${GROUND + 0.6}Q130 ${GROUND + 1.4} 260 ${GROUND + 0.4}T${W} ${GROUND + 0.8}`); // A wire between the roofs, and three birds on it. ink(`M112 ${R}Q240 ${R + 16} 366 ${R - 4}`, D); const wireY = (x: number) => { const t = (x - 112) / 254; return (1 - t) * (1 - t) * R + 2 * (1 - t) * t * (R + 16) + t * t * (R - 4); }; const birds = [196, 214, 252].map((home, i) => { const group = el("g", {}, moving); el("path", { d: "M-3 0q0 -4.5 3 -4.5q2.6 0 3.2 2.4l2 0.6l-2 0.5q-0.6 3.6 -3.2 3.6q-3 0 -3 -2.6z", ...stroke, fill: "var(--la-paper)", "stroke-width": P }, group); el("path", { d: "M-2.6 0l-3 1.6", ...stroke, "stroke-width": D }, group); return { group, home, x: home, y: wireY(home), away: 0, hop: random() * 4, seed: i }; }); const birdTarget = el("rect", { x: 186, y: R - 6, width: 78, height: 22, style: "cursor:pointer" }, hits); const scatter = () => { if (!latest.current.interactive) return; for (const bird of birds) if (bird.away <= 0) bird.away = 0.001; loop.wake(); }; birdTarget.addEventListener("click", scatter); birdTarget.addEventListener("pointerenter", scatter); // ---- People. interface Walker { kind: StreetFigure; figure: Figure; x: number; direction: 1 | -1; scale: number; pace: number; wait: number; } const people: Walker[] = []; const accentVar = "var(--la-accent)"; const add = (kind: StreetFigure, figure: Figure, x: number, direction: 1 | -1, scale: number, pace: number) => { const person = { kind, figure, x, direction, scale, pace, wait: 0 }; people.push(person); // A generous, invisible area over the figure takes the click. const target = el("rect", { x: -figure.reach, y: 18, width: figure.reach * 2, height: 104, fill: "transparent", style: "cursor:pointer" }, figure.group); target.addEventListener("click", () => { if (!latest.current.interactive) return; if (loop.reduced()) { figure.still(true); window.setTimeout(() => !disposed && stillFrame(), 1200); } else figure.react(); }); return person; }; let broom: SVGElement | null = null; let dog: SVGGElement | null = null; let dogLegs: SVGElement | null = null; let leash: SVGElement | null = null; let bikeGlow: SVGElement | null = null; if (who.includes("shopkeeper")) { add("shopkeeper", createFigure(moving, "courier", { accent: accentVar, bare: true }), 96, 1, S, 0); broom = el("path", { ...stroke, "stroke-width": P * 1.2 }, moving); } if (who.includes("child")) { const group = el("g", {}, moving); dog = el("g", {}, group); el("path", { d: "M-9 -9q0 -4 5 -4h9q4 0 5 -4l3 -1l1 -3l1.6 3l3.4 1.4l-1 2.6l-3 0.4q-1 6 -6 7.6h-13q-5 0 -5 -3z", ...stroke, fill: "var(--la-paper)", "stroke-width": P / 0.36 / 2.2 }, dog); dogLegs = el("path", { ...stroke, "stroke-width": P / 0.36 / 2.2 }, dog); leash = el("path", { ...stroke, "stroke-width": D }, moving); add("child", createFigure(moving, "courier", { accent: accentVar, bare: true }), 330, -1, S * 0.68, 0.82); } if (who.includes("commuter")) add("commuter", createFigure(moving, "commuter", { accent: SECOND_ACCENT_VAR }), 150, 1, S, 1); if (who.includes("cyclist")) { add("cyclist", createFigure(moving, "cyclist", { accent: accentVar }), 440, -1, S, 0.75); bikeGlow = el("circle", { r: 16, fill: `url(#${id}-halo)`, opacity: 0 }, glow); } // ---- Time of day. let now = latest.current.tau ?? startTau; let painted = -1; let time = 0; let disposed = false; let dragging = false; function paint() { const colors = palette(now); root!.style.setProperty("--la-paper", colors.paper); root!.style.setProperty("--la-ink", colors.ink); root!.style.setProperty("--la-accent", nightTint(accent, now)); root!.style.setProperty("--la-accent-2", nightTint(SECOND_ACCENT, now)); skyTop.setAttribute("stop-color", colors.sky[0]); skyLow.setAttribute("stop-color", colors.sky[1]); stars.setAttribute("opacity", colors.night.toFixed(3)); // By day a lit window barely shows; at night it glows. for (const entry of windows) entry.pane.setAttribute("opacity", entry.lit ? (0.12 + colors.night * 0.78).toFixed(3) : "0"); lampGlow.setAttribute("opacity", colors.night.toFixed(3)); bikeGlow?.setAttribute("opacity", colors.night.toFixed(3)); // The sun crosses by day and the moon by night, on the same arc. const t = ((now % 1) + 1) % 1; const along = colors.moon ? (t > 0.5 ? (t - 0.77) / 0.48 : (t + 0.23) / 0.48) : (t - 0.25) / 0.52; const bx = 36 + Math.min(1, Math.max(0, along)) * (W - 72); const by = 4 - colors.height * 40; sun.setAttribute("transform", `translate(${bx.toFixed(1)} ${by.toFixed(1)})`); moon.setAttribute("transform", `translate(${bx.toFixed(1)} ${by.toFixed(1)})`); grab.setAttribute("transform", `translate(${bx.toFixed(1)} ${by.toFixed(1)})`); sun.setAttribute("opacity", colors.moon ? "0" : "1"); moon.setAttribute("opacity", colors.moon ? "1" : "0"); painted = now; const slider = sliderRef.current; if (slider && !dragging && document.activeElement !== slider) slider.value = t.toFixed(3); slider?.setAttribute("aria-valuetext", timeName(t)); } controls.setTau = (value) => { now = value; paint(); if (loop.reduced()) stillFrame(); }; // ---- Layout: the street crops to its center in narrow containers. let view = W; function layout() { const narrow = root!.clientWidth < 520; view = narrow ? 300 : W; const x = narrow ? 118 : 0; svg!.setAttribute("viewBox", `${x} ${TOP} ${view} ${BOTTOM - TOP}`); // Fewer figures when cropped: the cyclist and the child stay home. for (const person of people) person.figure.group.style.display = narrow && (person.kind === "cyclist" || person.kind === "child") ? "none" : ""; if (dog) dog.style.display = narrow ? "none" : ""; if (leash) leash.style.display = narrow ? "none" : ""; } function place(person: Walker) { person.figure.group.setAttribute("transform", `translate(${person.x.toFixed(2)} ${(GROUND * (1 - person.scale)).toFixed(2)}) scale(${(person.direction * person.scale).toFixed(3)} ${person.scale})`); } function drawExtras(sweep: number) { const keeper = people.find((person) => person.kind === "shopkeeper"); if (keeper && broom) { // The broom swings from the hands; its head brushes the pavement. const hand: Point = [keeper.x + 4, GROUND - 28]; const head: Point = [keeper.x + 15 + Math.sin(sweep) * 7, GROUND - 0.5]; broom.setAttribute("d", `M${hand[0]} ${hand[1]}L${head[0].toFixed(2)} ${head[1]}M${(head[0] - 3.5).toFixed(2)} ${head[1]}h7`); } const child = people.find((person) => person.kind === "child"); if (child && dog && dogLegs && leash) { const dx = child.x + child.direction * 21; const trot = time * 9; dog.setAttribute("transform", `translate(${dx.toFixed(2)} ${GROUND}) scale(${(child.direction * 0.36).toFixed(3)} 0.36)`); const step = (phase: number) => (Math.sin(trot + phase) * 3.4).toFixed(2); dogLegs.setAttribute("d", `M-6 -8l${step(0)} 8M-2 -8l${step(3.14)} 8M8 -8l${step(3.14)} 8M12 -8l${step(0)} 8M-9 -11q-5 ${(-3 + Math.sin(trot * 1.6) * 2).toFixed(2)} -6 -8`); leash.setAttribute("d", `M${(child.x + child.direction * 5).toFixed(2)} ${GROUND - 21}Q${(child.x + child.direction * 13).toFixed(2)} ${GROUND - 9} ${(dx + child.direction * 3.6).toFixed(2)} ${GROUND - 6.4}`); } const rider = people.find((person) => person.kind === "cyclist"); if (rider && bikeGlow) { bikeGlow.setAttribute("cx", (rider.x + rider.direction * 9).toFixed(1)); bikeGlow.setAttribute("cy", String(GROUND - 19)); } } function stillFrame() { // Reduced motion: one figure mid-stride, everyone else at rest, at whatever time is set. people.forEach((person, i) => { if (i === people.findIndex((p) => p.kind === "commuter")) person.figure.step(0, 1, 0); else person.figure.still(); place(person); }); for (const item of hanging) item.draw(0); for (const bird of birds) bird.group.setAttribute("transform", `translate(${bird.home} ${wireY(bird.home) - 3.4})`); drawExtras(0.6); paint(); } const wind = pointerWind(root); const loop = runSceneLoop({ host: root, isPaused: () => controls.paused, still: stillFrame, tick: (dt, clock) => { time = clock; const fixed = latest.current.tau; if (fixed !== undefined) now = fixed; // Dawn to nightfall is 0.6 of a day. else if (!dragging) now = (now + (0.6 / Math.max(1, latest.current.dayLength)) * dt) % 1; if (Math.abs(now - painted) > 0.0015) paint(); const gust = latest.current.interactive ? wind.read(dt) : 0; const breeze = gust + Math.sin(clock * 0.7) * 0.05; for (const person of people) { if (person.kind === "shopkeeper") { person.figure.step(dt, 0, breeze); place(person); continue; } if (person.wait > 0) { person.wait -= dt; continue; } person.x += person.figure.step(dt, person.pace, breeze) * person.scale * person.direction; // Off one end, a short wait, then back in from the other. if (person.x > W + 40 || person.x < -40) { person.x = person.direction === 1 ? -40 : W + 40; person.wait = 1.5 + random() * 4; } place(person); } drawExtras(clock * 2.6); for (const item of hanging) item.draw(pendulum(item.state, breeze * 9, dt, item.length, 1.6)); // Birds hop on the wire, take off when startled and come back after a while. for (const bird of birds) { if (bird.away > 0) { bird.away += dt; const out = Math.min(1, bird.away / 1.2); const back = Math.min(1, Math.max(0, (bird.away - 6) / 1.4)); const lift = out * (1 - back); bird.x = bird.home + lift * (40 + bird.seed * 26) * (bird.seed % 2 ? 1 : -1); bird.y = wireY(bird.home) - 3.4 - lift * (34 + bird.seed * 7) - Math.sin(bird.away * 18) * 1.2 * (lift > 0.05 && lift < 0.98 ? 1 : 0); if (back >= 1) bird.away = 0; } else { bird.hop += dt; const hop = bird.hop % 4 < 0.25 ? Math.sin(((bird.hop % 4) / 0.25) * Math.PI) * 1.6 : 0; bird.x = bird.home; bird.y = wireY(bird.home) - 3.4 - hop; } bird.group.setAttribute("transform", `translate(${bird.x.toFixed(2)} ${bird.y.toFixed(2)})`); } const night = palette(now).night; if (night > 0.05) for (const dot of starDots) dot.star.setAttribute("opacity", (0.55 + Math.sin(clock * dot.rate + dot.phase) * 0.45).toFixed(2)); }, }); // ---- Dragging the sun or moon scrubs the time. const onGrab = (event: PointerEvent) => { if (!latest.current.interactive || latest.current.tau !== undefined) return; dragging = true; grab.setPointerCapture(event.pointerId); }; const onDrag = (event: PointerEvent) => { if (!dragging) return; const rect = svg.getBoundingClientRect(); const viewX = Number(svg.getAttribute("viewBox")?.split(" ")[0] ?? 0); const x = viewX + ((event.clientX - rect.left) / rect.width) * view; const along = Math.min(0.995, Math.max(0.005, (x - 36) / (W - 72))); now = palette(now).moon ? (0.77 + along * 0.48) % 1 : 0.25 + along * 0.52; paint(); if (loop.reduced()) stillFrame(); }; const onRelease = () => { dragging = false; }; grab.addEventListener("pointerdown", onGrab); grab.addEventListener("pointermove", onDrag); grab.addEventListener("pointerup", onRelease); grab.addEventListener("pointercancel", onRelease); const observer = new ResizeObserver(layout); observer.observe(root); layout(); stillFrame(); controls.wake = loop.wake; loop.wake(); return () => { disposed = true; controls.setTau = () => {}; controls.wake = () => {}; observer.disconnect(); loop.dispose(); wind.dispose(); svg.replaceChildren(); }; }, [id, cast, accent, startTau]); // Pausing and a pinned time are read by the loop; nudge it when they change. useEffect(() => { if (tau !== undefined) control.current.setTau(tau); }, [tau]); const day = palette(tau ?? startTau); return ( <div ref={rootRef} style={{ "--la-paper": day.paper, "--la-ink": day.ink, "--la-accent": accent, "--la-accent-2": SECOND_ACCENT, ...style } as CSSProperties} className={cn("@container relative w-full overflow-hidden rounded-2xl bg-(--la-paper) text-(--la-ink)", className)} {...props} > <svg ref={svgRef} role="img" aria-label="A line drawing of a street: a bakery, a bookshop, a flower stall, a tram stop and a corner café, with people passing by as the day turns to night" className="block aspect-[520/188] w-full font-sans text-(--la-ink) @max-[32.5rem]:aspect-[300/188]" /> {children ? <div className="pointer-events-none absolute inset-x-0 top-0 p-5 *:pointer-events-auto">{children}</div> : null} <div className="flex items-center gap-3 px-3 pb-3"> <button type="button" aria-label={isPaused ? "Play" : "Pause"} onClick={() => setIsPaused((value) => !value)} className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full border border-(--la-ink)/25 outline-none transition-colors hover:bg-(--la-ink)/10 focus-visible:ring-2 focus-visible:ring-(--la-ink) motion-reduce:hidden" > {isPaused ? <Play aria-hidden="true" className="size-4" fill="currentColor" strokeWidth={0} /> : <Pause aria-hidden="true" className="size-4" fill="currentColor" strokeWidth={0} />} </button> <label className="flex min-w-0 flex-1 items-center gap-3 text-xs font-medium"> <span className="shrink-0">Time of day</span> <input ref={sliderRef} type="range" min={0} max={1} step={0.005} defaultValue={tau ?? startTau} disabled={tau !== undefined} aria-valuetext={timeName(tau ?? startTau)} onInput={(event) => control.current.setTau(Number(event.currentTarget.value))} className="h-10 min-w-0 flex-1 cursor-pointer accent-(--la-ink) disabled:cursor-default disabled:opacity-50" /> </label> </div> </div> ); }
Usage
import { LineArtStreet } from "@/components/ui/line-art-street/line-art-street";
export function Hero() {
return (
<LineArtStreet dayLength={90}>
<h1 className="text-2xl font-semibold">Good morning, neighbor.</h1>
</LineArtStreet>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
tau | number | – | Pins the time of day: 0 is midnight, 0.5 is noon. Leave it out to let the day run. |
dayLength | number | 60 | Seconds from dawn to nightfall while the day runs. |
startTau | number | 0.34 | Time of day the scene opens on. |
accent | string | "#c8553d" | The flat accent for awnings, flowers and the bicycle frame. |
figures | StreetFigure[] | number | all four | Who is on the street: a list of figures, or how many to take from the default order. |
interactive | boolean | true | Let people, windows, birds and the sun react to the pointer. |
paused | boolean | false | Start paused. The Pause button toggles it from there. |
children | ReactNode | – | Content laid over the paper, such as a headline. |
Also accepts every native <div> attribute, including ref and className.
- One SVG, built with the toolkit in src/lib/line-art. The wobble filter sits on the drawing group only; the gradient sky, the stars and the warm glows (on a mix-blend-mode: screen layer) stay outside it.
- One animation frame loop with seeded schedules writes attributes directly: no React state per frame. It pauses offscreen and in hidden tabs.
- The day runs from dawn to nightfall in dayLength seconds and keeps going through the night. tau pins the time (0 is midnight, 0.5 is noon) and disables the slider and the sun drag.
- Pointer speed is wind on the awning fringe, the hanging sign, the flower stall's ribbons and the commuter's scarf. Clicking a person plays a reaction, clicking a window toggles its light, and the birds fly off when approached and come back.
- The bicycle's wheels roll without slipping and its cranks turn; walkers use the solved gait, so feet do not slide. In containers under 520px the street crops to its center and the cyclist and the child stay home.
- Signboards use fictional names. The line style and motion rules learn from line-art-animation by JDhruv14 (MIT); the scene, figures and code are our own.
Accessibility
- The scene is an image with a label that describes the street. Nothing in it is needed to use the page.
- Two real controls sit under it: a Pause button and a "Time of day" slider whose value text reads as words, such as "Dusk" or "Night".
- Clicking people, windows, birds and the sun are pointer extras on top of that; the slider does the same job as dragging the sun.
- Ink and paper invert together at night, so line contrast holds through the whole day.
- With reduced motion enabled the scene is a still daytime drawing with one figure mid-stride, the Pause button is hidden, and the slider still changes day and night at once.