// PitstopCanvas - animates the moving IndyCar approaching, stopping, getting // serviced, and exiting. Driven by node_times produced by the Monte Carlo // simulator. Coordinate space: viewBox 0..W × 0..H (1200 × 500). Car image is // the transparent IndyCar PNG bundled at assets/indycar.png. const W = 1200, H = 500; const CAR_W = 330, CAR_H = 220; const CAR_X = (W - CAR_W) / 2; const CAR_Y = 170; const WHEEL_R = 13; const FRONT_X_FRAC = 0.23, REAR_X_FRAC = 0.82; const TOP_Y_FRAC = 0.24, BOTTOM_Y_FRAC = 0.76; const JACK_PLUG_X_FRAC = 0.92, JACK_PLUG_Y_FRAC = 0.50; const FUEL_X_FRAC = 0.55, FUEL_Y_FRAC = 0.58; const PIT_LANE_CAR_Y = 10; const APPROACH_DUR = 1.6; const EXIT_DUR = 1.6; const PIT_LANE_MARKER_Y = 110; const STOP_X = CAR_X; const POSITION_COLORS_PC = { "RF Changer": "#3b82f6", "LF Changer": "#22c55e", "RR Changer": "#f97316", "LR Changer": "#a855f7", "Jackman": "#ef4444", "Fueler": "#fbbf24", }; function pointOnCar(xFrac, yFrac) { return { x: CAR_X + CAR_W * xFrac, y: CAR_Y + CAR_H * yFrac }; } function getWheels(carX, carY) { const fX = carX + CAR_W * FRONT_X_FRAC; const rX = carX + CAR_W * REAR_X_FRAC; const tY = carY + CAR_H * TOP_Y_FRAC; const bY = carY + CAR_H * BOTTOM_Y_FRAC; return { rf: { x: fX, y: tY, label: "RF" }, lf: { x: fX, y: bY, label: "LF" }, rr: { x: rX, y: tY, label: "RR" }, lr: { x: rX, y: bY, label: "LR" }, }; } function getWheelState(prefix, t, n) { const offS = n[`${prefix}_nut_off_start`] ?? 999; const offF = n[`${prefix}_nut_off_finish`] ?? 999; const pull = n[`${prefix}_pull`] ?? 999; const mnt = n[`${prefix}_mount`] ?? 999; const onS = n[`${prefix}_nut_on_start`] ?? 999; const onF = n[`${prefix}_nut_on_finish`] ?? 999; if (t < offS) return { state: "waiting", color: "#374151" }; if (t < offF) return { state: "off", color: "#ef4444" }; if (t < pull) return { state: "loose", color: "#f97316" }; if (t < mnt) return { state: "empty", color: "#1f2937" }; if (t < onS) return { state: "mount", color: "#3b82f6" }; if (t < onF) return { state: "on", color: "#22c55e" }; return { state: "done", color: "#10b981" }; } function getJackState(t, n) { const jp = n.jack_plug ?? 999, cu = n.car_up ?? 999, cd = n.car_drop ?? 999; if (t < jp) return { color: "#374151", state: "wait" }; if (t < cu) return { color: "#ef4444", state: "plug" }; if (t < cd) return { color: "#3b82f6", state: "lift" }; return { color: "#10b981", state: "done" }; } function getFuelState(t, n) { const fi = n.fuel_plug_in ?? 999, fo = n.fuel_unplug ?? 999; if (t < fi) return { color: "#374151", state: "wait" }; if (t < fo) return { color: "#fbbf24", state: "fuel" }; return { color: "#10b981", state: "done" }; } function getCrewPos(role, t, n, carX, carY) { const wheels = getWheels(carX, carY); if (role === "Jackman") { const jp = n.jack_plug ?? 999, cu = n.car_up ?? 999, cd = n.car_drop ?? 999; const plug = pointOnCar(JACK_PLUG_X_FRAC, JACK_PLUG_Y_FRAC); const startX = plug.x, startY = H - 40; if (t < jp) { const f = Math.min(1, Math.max(0, t) / Math.max(jp, 0.01)); return { x: startX + (plug.x - startX) * f, y: startY + (plug.y - startY) * f }; } if (t < cu) return { x: plug.x, y: plug.y }; if (t < cd) return { x: plug.x, y: plug.y }; return { x: plug.x, y: plug.y, done: true }; } const wheelMap = { "RF Changer": { wheel: wheels.rf, prefix: "rf" }, "LF Changer": { wheel: wheels.lf, prefix: "lf" }, "RR Changer": { wheel: wheels.rr, prefix: "rr" }, "LR Changer": { wheel: wheels.lr, prefix: "lr" }, }; const info = wheelMap[role]; if (info) { const offS = n[`${info.prefix}_nut_off_start`] ?? 0; const onF = n[`${info.prefix}_nut_on_finish`] ?? 999; const isTop = info.wheel.y < CAR_Y + CAR_H / 2; const readyY = isTop ? CAR_Y - 20 : CAR_Y + CAR_H + 20; if (t < offS) return { x: info.wheel.x, y: readyY }; if (t < onF) return { x: info.wheel.x, y: info.wheel.y }; return { x: info.wheel.x, y: readyY, done: true }; } if (role === "Fueler") { const fi = n.fuel_plug_in ?? 0, fo = n.fuel_unplug ?? 999; const plug = pointOnCar(FUEL_X_FRAC, FUEL_Y_FRAC); const startX = CAR_X + CAR_W * 0.85, startY = H - 40; const walkDur = 0.6, walkStart = fi - walkDur; if (t < walkStart) return { x: startX, y: startY }; if (t < fi) { const f = (t - walkStart) / walkDur; return { x: startX + (plug.x - startX) * f, y: startY + (plug.y - startY) * f }; } if (t < fo) return { x: plug.x, y: plug.y }; const f = Math.min(1, (t - fo) / 0.4); return { x: plug.x + (startX - plug.x) * f, y: plug.y + (startY - plug.y) * f, done: f >= 1 }; } return { x: 0, y: 0 }; } window.PitstopCanvas = function PitstopCanvas({ animationData, accentColor = "#ff2e3e" }) { const [t, setT] = React.useState(-APPROACH_DUR); const [playing, setPlaying] = React.useState(false); // Playback speed is locked to real-time (1x) - speed selector removed by request. const speed = 1; const lastFrame = React.useRef(null); const rafRef = React.useRef(0); const nodes = animationData?.node_times ?? {}; const totalTime = animationData?.total_time ?? 5.0; const exitStart = nodes.car_goes ?? totalTime; const startTime = -APPROACH_DUR; const endTime = exitStart + EXIT_DUR; React.useEffect(() => { if (!animationData) return; lastFrame.current = null; setT(startTime); setPlaying(true); }, [animationData]); React.useEffect(() => { if (!playing || !animationData) return; const animate = (ts) => { if (lastFrame.current !== null) { const dt = ((ts - lastFrame.current) / 1000) * speed; setT((tt) => { const next = tt + dt; if (next >= endTime) { setPlaying(false); return endTime; } return next; }); } lastFrame.current = ts; rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); lastFrame.current = null; }; }, [playing, speed, animationData, endTime]); const rawPos = (tt) => { const entryFrom = W + 400; const exitTo = -CAR_W - 400; if (tt < 0) { const u = Math.min(1, Math.max(0, (tt - startTime) / APPROACH_DUR)); const cruiseFrac = 0.38, cruiseDxRatio = 0.55; const totalDx = STOP_X - entryFrom; const cruiseDx = totalDx * cruiseDxRatio; if (u < cruiseFrac) { const v = u / cruiseFrac; return { x: entryFrom + cruiseDx * v, y: PIT_LANE_CAR_Y }; } const v = (u - cruiseFrac) / (1 - cruiseFrac); const ex = 1 - Math.pow(1 - v, 1.8); const ey = v * v * (3 - 2 * v); const cruiseEndX = entryFrom + cruiseDx; return { x: cruiseEndX + (STOP_X - cruiseEndX) * ex, y: PIT_LANE_CAR_Y + (CAR_Y - PIT_LANE_CAR_Y) * ey }; } if (tt > exitStart) { const u = Math.min(1, (tt - exitStart) / EXIT_DUR); const sFrac = 0.22, cFrac = 0.42; const sDxR = 0.18, cDxR = 0.32; const totalDx = exitTo - STOP_X; const sDx = totalDx * sDxR, cDx = totalDx * cDxR; if (u < sFrac) { const v = u / sFrac; return { x: STOP_X + sDx * Math.pow(v, 1.6), y: CAR_Y }; } if (u < sFrac + cFrac) { const v = (u - sFrac) / cFrac; const ey = v * v * (3 - 2 * v); const cStart = STOP_X + sDx; return { x: cStart + cDx * v, y: CAR_Y + (PIT_LANE_CAR_Y - CAR_Y) * ey }; } const v = (u - sFrac - cFrac) / (1 - sFrac - cFrac); const cEnd = STOP_X + sDx + cDx; return { x: cEnd + (exitTo - cEnd) * v, y: PIT_LANE_CAR_Y }; } return { x: STOP_X, y: CAR_Y }; }; const pos = rawPos(t); const carX = pos.x, carY = pos.y; const inMotion = t < 0 || t > exitStart; let rot = 0; if (inMotion) { const ahead = rawPos(t + 0.03); const dx = ahead.x - carX, dy = ahead.y - carY; const m = Math.hypot(dx, dy); rot = m < 0.01 ? 0 : (Math.atan2(dy, dx) * 180) / Math.PI - 180; } const carUp = nodes.car_up ?? 999, carDrop = nodes.car_drop ?? 999; const isLifted = t >= carUp && t < carDrop; const carYOffset = isLifted ? -10 : 0; const flashUp = t >= carUp && t < carUp + 0.55; const flashDrop = t >= carDrop && t < carDrop + 0.55; const wheels = getWheels(carX, carY); // Stationary phase only - crew, wheel state circles, and jack/fuel circles // are visible only when the car is parked in the box (t in [0, exitStart]). // During approach (t < 0) and exit (t > exitStart) we hide all of them so // they don't appear glued to the moving car body. const stationary = t >= 0 && t < exitStart; const crewRoles = ["Jackman", "RF Changer", "LF Changer", "RR Changer", "LR Changer", "Fueler"]; return (
{/* Pit wall */} PIT WALL {/* Pit lane */} PIT LANE {/* Pit box */} {(() => { const ti = CAR_H * 0.14, bi = CAR_H * 0.12; const bx = CAR_X, by = CAR_Y + ti, bw = CAR_W, bh = CAR_H - ti - bi; return ( ); })()} {/* Car */} {stationary && Object.entries(wheels).map(([k, w]) => { const ws = getWheelState(k, t, nodes); return ( {ws.state === "done" && } ); })} {/* Jack + fuel state circles */} {stationary && (() => { const js = getJackState(t, nodes), fs = getFuelState(t, nodes); const jp = pointOnCar(JACK_PLUG_X_FRAC, JACK_PLUG_Y_FRAC); const fp = pointOnCar(FUEL_X_FRAC, FUEL_Y_FRAC); return ( {js.state === "done" && } {fs.state === "done" && } ); })()} {/* Crew dots */} {stationary && crewRoles.map((role) => { const p = getCrewPos(role, t, nodes, carX, carY); const c = POSITION_COLORS_PC[role] || "#888"; return ( {role.replace(" Changer", "")} ); })} {/* Timer */} {t >= 0 ? `${Math.min(t, totalTime).toFixed(2)}s` : ""} STOP TIME {/* Flashes - glow drawn first as backdrop, sharp text on top so the label reads clearly instead of looking blurred-out. */} {flashUp && ( CAR UP CAR UP )} {flashDrop && ( CAR DROP CAR DROP )}
{ setPlaying(false); setT(parseInt(e.target.value, 10) / 1000); }} className="pc-scrub" style={{ accentColor }} /> {Math.max(0, Math.min(t, totalTime)).toFixed(2)}s
); };