"use client"; import { useEffect, useId, useRef, useState } from "react"; import { useInView, useReducedMotion } from "motion/react"; type TrainStep = { title: string; text: string; time?: string }; type ProcessTrainProps = { /** One station each, in order. */ steps?: TrainStep[]; /** Ms the train waits at a station before it runs on by itself. */ hold?: number; /** Called when the train stops at a station. */ onArrive?: (index: number) => void; /** Set the width here. Everything scales with it. */ className?: string; }; const STEPS: TrainStep[] = [ { title: "Enquiry", text: "Tell us what you need. We reply the same day with a plain price.", time: "Day 1" }, { title: "Plan", text: "We map the work on one page, so you know each stop before we leave.", time: "Day 2" }, { title: "Build", text: "Your first version comes in a week. You check it, we fix it.", time: "Week 1" }, { title: "Launch", text: "We go live together and stay on the line for the first month.", time: "Week 2" }, ]; const W = 640; const H = 232; const RAIL = 192; const GAP = 560; // track between two stations const ENGINE = 372; // back of the engine on screen const START = -470; // the train rolls in from here const COACH = 104; const PUFFS = 12; const BRAKE = 95; const ROOF = (i: number) => (i * 37 + 11) % 90; function hills(from: number, to: number, base: number, lift: number, seed: number) { let d = `M${from} ${H}`; for (let x = from; x <= to; x += 24) { const y = base - (Math.sin(x * 0.009 + seed) * lift + Math.sin(x * 0.023 + seed * 2) * lift * 0.45); d += ` L${x} ${y.toFixed(1)}`; } return `${d} L${to} ${H} Z`; } function Wheel({ x, r, spokes = 6 }: { x: number; r: number; spokes?: number }) { return ( {Array.from({ length: spokes }, (_, i) => ( ))} ); } function Coach({ x, lit, label }: { x: number; lit: boolean; label: number }) { const glow = lit ? "#ffd98a" : "#26395f"; return ( {[8, 24, 56, 72].map((wx) => ( ))} {label} ); } function Station({ index, title, passed }: { index: number; title: string; passed: boolean }) { const wide = Math.max(92, title.length * 10.5 + 40); return ( {/* Platform. */} {Array.from({ length: 11 }, (_, i) => ( ))} {/* Shelter. */} {/* Board. */} {title.toUpperCase()} {/* Signal past the end of the platform. */} ); } /* ProcessTrain: a how-it-works section told as a railway line at dusk. Each step is a station with a yellow name board. The train runs from one to the next as the land slides past on three layers, a coach lights up at each stop, and pressing any stop on the route sends it there, backwards if needed. */ export function ProcessTrain({ steps = STEPS, hold = 3800, onArrive, className = "w-full max-w-3xl" }: ProcessTrainProps) { const reduce = useReducedMotion(); const id = useId().replace(/[^a-zA-Z0-9]/g, ""); const box = useRef(null); const scene = useRef(null); const seen = useInView(box, { once: true, amount: 0.35 }); const n = steps.length; const end = n * GAP + 700; const [at, setAt] = useState(0); // where the train is heading const [here, setHere] = useState(-1); // where it stands, -1 while moving const [best, setBest] = useState(-1); // the furthest stop so far const [auto, setAuto] = useState(true); const [paused, setPaused] = useState(false); const cam = useRef(START); const vel = useRef(0); const puffs = useRef(Array.from({ length: PUFFS }, () => ({ x: 0, y: 0, age: 9, life: 1 }))); const told = useRef(onArrive); useEffect(() => { told.current = onArrive; }, [onArrive]); useEffect(() => { if (!seen) return; const svg = scene.current; if (!svg) return; const world = svg.querySelector("[data-layer='world']"); const mid = svg.querySelector("[data-layer='mid']"); const far = svg.querySelector("[data-layer='far']"); const rod = svg.querySelector("[data-rod]"); const wheels = Array.from(svg.querySelectorAll("[data-wheel]")); const clouds = Array.from(svg.querySelectorAll("[data-puff]")); const target = at * GAP; let raf = 0; let last = performance.now(); let chuff = 0; let stopped = false; // A far stop takes about as long as a near one: it just brakes harder. const brake = BRAKE * Math.max(1, Math.abs(target - cam.current) / GAP); const tick = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000); last = now; if (reduce) { cam.current = target; vel.current = 0; } else { const d = target - cam.current; // Brakes at a steady rate, so it stops in a clean line, not by creeping in. const top = Math.max(230, Math.abs(d) / 1.2); const want = Math.sign(d) * Math.min(top, Math.sqrt(2 * brake * Math.abs(d))); const push = brake * 2.4 * dt; vel.current += Math.max(-push, Math.min(push, want - vel.current)); cam.current += vel.current * dt; if (Math.abs(d) < 1.2) { cam.current = target; vel.current = 0; } } const x = cam.current; world?.setAttribute("transform", `translate(${-x} 0)`); mid?.setAttribute("transform", `translate(${-x * 0.45} 0)`); far?.setAttribute("transform", `translate(${-x * 0.14} 0)`); const turn = x / 11; for (const w of wheels) w.setAttribute("transform", `rotate(${((turn * 180) / Math.PI).toFixed(1)})`); rod?.setAttribute("transform", `translate(${(Math.cos(turn) * 5).toFixed(2)} ${(Math.sin(turn) * 5).toFixed(2)})`); // Smoke leaves the chimney and is left behind by the speed of the train. const speed = Math.abs(vel.current); chuff -= dt * (1 + speed / 70); if (chuff <= 0 && speed > 5 && !reduce) { chuff = 0.3; const free = puffs.current.find((p) => p.age >= p.life); if (free) { free.x = ENGINE + 94; free.y = RAIL - 72; free.age = 0; free.life = 1.1 + Math.random() * 0.7; } } let alive = false; puffs.current.forEach((p, i) => { const c = clouds[i]; if (!c) return; if (p.age >= p.life) { c.setAttribute("opacity", "0"); return; } alive = true; p.age += dt; p.x -= (vel.current * 0.75 + 9) * dt; p.y -= (26 - p.age * 10) * dt; const t = p.age / p.life; c.setAttribute("cx", p.x.toFixed(1)); c.setAttribute("cy", p.y.toFixed(1)); c.setAttribute("r", (4 + t * 15).toFixed(1)); c.setAttribute("opacity", ((1 - t) * 0.62).toFixed(2)); }); const still = x === target; if (still && !stopped) { stopped = true; setHere(at); setBest((b) => Math.max(b, at)); told.current?.(at); } if (!still || alive) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [seen, at, reduce]); useEffect(() => { if (!auto || reduce || paused || here !== at || at >= n - 1) return; const t = setTimeout(() => { setHere(-1); setAt((a) => a + 1); }, hold); return () => clearTimeout(t); }, [auto, reduce, paused, here, at, n, hold]); const go = (i: number) => { setAuto(false); if (i === at) return; setHere(-1); setAt(i); }; const step = steps[at]; return (
setPaused(true)} onPointerLeave={() => setPaused(false)} onFocusCapture={() => setPaused(true)} onBlurCapture={() => setPaused(false)} >
{Array.from({ length: Math.ceil((end + 900) / 170) }, (_, i) => { const x = -900 + i * 170 + ROOF(i); const tall = 34 + (i % 3) * 9; return ( ); })} {steps.map((s, i) => ( ))} {/* Engine. Its nose points right. */} {[46, 62, 78].map((bx) => ( ))} {steps.map((_, i) => ( ))} {Array.from({ length: PUFFS }, (_, i) => ( ))}
{at + 1}

{step?.title} {step?.time && {here === at ? step.time : "Arriving"}}

{step?.text}

    {steps.map((s, i) => { const now = i === at; return (
  1. ); })}
); }