diff --git a/src/lib.rs b/src/lib.rs index 4204135..4596e4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,18 +3,18 @@ mod utils; use wasm_bindgen::prelude::*; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum TyreCompound { +pub enum TyreCompound { Soft, Medium, Hard, } -struct TyreParams { +pub struct TyreParams { offset_seconds: f32, degradation_per_lap_seconds: f32, } -struct TyreOpts { +pub struct TyreOpts { soft: TyreParams, medium: TyreParams, hard: TyreParams, @@ -22,7 +22,7 @@ struct TyreOpts { // #[wasm_bindgen] impl TyreParams { - fn new(offset_seconds: f32, degradation_per_lap_seconds: f32) -> Self { + pub fn new(offset_seconds: f32, degradation_per_lap_seconds: f32) -> Self { TyreParams { offset_seconds, degradation_per_lap_seconds, @@ -64,7 +64,11 @@ pub struct Model { impl Model { // T(a) = B + C + D(a - 1) where a is tyre_age_laps - fn predicted_lap_time_seconds(&self, compound: TyreCompound, tyre_age_laps: u8) -> Option { + pub fn predicted_lap_time_seconds( + &self, + compound: TyreCompound, + tyre_age_laps: u8, + ) -> Option { match tyre_age_laps { 0 => None, _ => { @@ -125,13 +129,3 @@ impl Strategy { self.stints.len().saturating_sub(1) } } - -#[wasm_bindgen] -extern "C" { - fn alert(s: &str); -} - -#[wasm_bindgen] -pub fn greet() { - alert("Hello, visual-strategy!"); -} diff --git a/web/index.html b/web/index.html index 3079133..24acdc1 100644 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,7 @@ name="description" content="Understand the strategy behind motorsport through interactive simulations." /> - Stint — Interactive race strategy + Stint, interactive race strategy
diff --git a/web/src/components/time-blocks.ts b/web/src/components/time-blocks.ts index e69de29..11ee76b 100644 --- a/web/src/components/time-blocks.ts +++ b/web/src/components/time-blocks.ts @@ -0,0 +1,61 @@ +import type { LapTime } from '../simulation/tyres.ts'; + +export interface PlotOptions { + laps: LapTime[]; + /** X axis extent, so an unfinished race still shows the laps ahead. */ + totalLaps?: number; + /** Draws a pit marker after each of these laps. */ + pitAfterLaps?: number[]; +} + +export interface TimeBlocksOptions extends PlotOptions { + /** Y axis extent, so the scale holds still while the strategy changes. */ + maxBlocks?: number; + blockSize?: number; + /** Markup placed under the x axis, aligned with the plot (e.g. a pit slider). */ + control?: string; +} + +/** Columns and pit marker only, so a redraw leaves the axis and control alone. */ +export function plotMarkup({ + laps, + totalLaps = laps.length, + pitAfterLaps = [], +}: PlotOptions): string { + const columns = Array.from({ length: totalLaps }, (_, i) => { + const lap = laps[i]; + if (!lap) return '
'; + return `
${''.repeat(lap.blocks)}
`; + }).join(''); + + const pits = pitAfterLaps + .map((lap) => `
P
`) + .join(''); + + return pits + columns; +} + +export function timeBlocks({ + laps, + totalLaps = laps.length, + maxBlocks, + pitAfterLaps = [], + blockSize = 26, + control = '', +}: TimeBlocksOptions): string { + const rows = maxBlocks ?? Math.max(1, ...laps.map((lap) => lap.blocks)); + + const ticks = Array.from( + { length: totalLaps }, + (_, i) => `${i + 1}`, + ).join(''); + + return ` +
+

Lap time
(blocks)

+
${plotMarkup({ laps, totalLaps, pitAfterLaps })}
+

Laps

+
${ticks}
+ ${control ? `
${control}
` : ''} +
`; +} diff --git a/web/src/components/total-bar.ts b/web/src/components/total-bar.ts new file mode 100644 index 0000000..7f00a78 --- /dev/null +++ b/web/src/components/total-bar.ts @@ -0,0 +1,48 @@ +import type { Compound } from '../simulation/tyres.ts'; + +export interface TotalSegment { + /** 'pit' draws the stop itself as a segment, once its cost is known. */ + compound: Compound | 'pit'; + blocks: number; +} + +/** One blue P per stop, for the stages where a stop has no number yet. */ +export function pitChips(stops: number): string { + return Array.from({ length: stops }, () => 'P').join( + '', + ); +} + +export interface TotalBarOptions { + label?: string; + segments: TotalSegment[]; + pitStops?: number; + /** Marks the bar as the quickest option available. */ + best?: boolean; +} + +export function totalBar({ + label = 'Race time', + segments, + pitStops = 0, + best = false, +}: TotalBarOptions): string { + const total = segments.reduce((sum, segment) => sum + segment.blocks, 0); + + const bar = segments + .filter((segment) => segment.blocks > 0) + .map( + (segment) => + `${segment.blocks}`, + ) + .join(''); + + const pits = pitStops > 0 ? ` + ${pitChips(pitStops)}` : ''; + + return ` +
+ ${label} +
${bar}
+ = ${total}${pits} +
`; +} diff --git a/web/src/components/track-map.ts b/web/src/components/track-map.ts new file mode 100644 index 0000000..93c7b5a --- /dev/null +++ b/web/src/components/track-map.ts @@ -0,0 +1,96 @@ +import type { Circuit } from '../simulation/circuits.ts'; + +/** Animation seconds per second of race time, plus a pause before the replay. */ +const PACE = 0.09; +const HOLD = 0.9; + +function runner(kind: 'pit' | 'racing', seconds: number, cycle: number): string { + const arrives = Number(((seconds * PACE) / cycle).toFixed(3)); + + return ``; +} + +function marker( + [x, y]: [number, number], + label: string, + anchor: 'start' | 'end', + below: boolean, +): string { + const dx = anchor === 'start' ? 14 : -14; + + return ` + + + ${label} + `; +} + +/** + * Starts the two cars. SMIL will not reliably begin for an SVG that was dropped + * in with innerHTML, so the motion runs on the Web Animations API instead: each + * car follows its own path and holds at the end until the cycle restarts, which + * keeps the pair in step however different their times are. + */ +export function startTrackMap(root: ParentNode): void { + root.querySelectorAll('.runner').forEach((car) => { + const path = root.querySelector(`#${car.dataset.path}`)?.getAttribute('d'); + if (!path) return; + + car.style.offsetPath = `path("${path}")`; + car.style.offsetRotate = '0deg'; + + car.animate( + [ + { offsetDistance: '0%', offset: 0 }, + { offsetDistance: '100%', offset: Number(car.dataset.arrives) }, + { offsetDistance: '100%', offset: 1 }, + ], + { duration: Number(car.dataset.cycle), iterations: Infinity }, + ); + }); +} + +export function trackMap(circuit: Circuit): string { + const slowest = Math.max(circuit.pitSeconds, circuit.trackSeconds); + const cycle = Number((slowest * PACE + HOLD).toFixed(2)); + const [ax, ay, angle] = circuit.arrow; + + // Each circuit keeps its own aspect, scaled to fit the same box. + const [, , vw, vh] = circuit.view.split(' ').map(Number) as number[]; + const scale = Math.min(620 / vw!, 340 / vh!); + + return ` +
+ + + + + + + + + + + + ${circuit.marks + .map( + (m) => + `${m.text}`, + ) + .join('\n ')} + + ${marker(circuit.entry, 'PIT ENTRY', circuit.entryAnchor ?? 'start', circuit.labelsBelow ?? false)} + ${marker(circuit.exit, 'REJOIN', circuit.exitAnchor ?? 'end', circuit.labelsBelow ?? false)} + + ${runner('pit', circuit.pitSeconds, cycle)} + ${runner('racing', circuit.trackSeconds, cycle)} + +
+ Schematic, drawn in the racing direction. Both cars leave the pit entry + together and are timed to the point where the pit lane rejoins. +
+
`; +} diff --git a/web/src/main.ts b/web/src/main.ts index 4fec56c..0acd2f7 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,22 +2,29 @@ import './style.css'; import { header } from './components/header.ts'; import { footer } from './components/footer.ts'; import { home } from './pages/home.ts'; -import { learn } from './pages/learn.ts'; +import { learn, mountLearn } from './pages/learn.ts'; import { playground } from './pages/playground.ts'; import { loadSimulation } from './simulation/wasm.ts'; -const routes: Record string> = { - '/': home, - '/learn': learn, - '/playground': playground, +interface Route { + view(param?: string): string; + mount?(root: HTMLElement, param?: string): void; +} + +const routes: Record = { + '': { view: home }, + learn: { view: learn, mount: mountLearn }, + playground: { view: playground }, }; const app = document.querySelector('#app')!; function render(): void { - const path = location.hash.slice(1) || '/'; - const page = routes[path] ?? home; - app.innerHTML = [header(path), page(), footer()].join('\n'); + const [section = '', param] = location.hash.replace(/^#\/?/, '').split('/'); + const route = routes[section] ?? routes['']!; + + app.innerHTML = [header(`/${section}`), route.view(param), footer()].join('\n'); + route.mount?.(app, param); } addEventListener('hashchange', render); diff --git a/web/src/pages/learn.ts b/web/src/pages/learn.ts index 70dc70f..c0c900b 100644 --- a/web/src/pages/learn.ts +++ b/web/src/pages/learn.ts @@ -1,7 +1,38 @@ -export function learn(): string { +import { stages, stageIndex } from '../stages/index.ts'; + +export function learn(param?: string): string { + const index = stageIndex(param); + const stage = stages[index]!; + + const dots = stages + .map( + (s, i) => + ``, + ) + .join(''); + + const next = + index < stages.length - 1 + ? `Next →` + : `Open playground →`; + return ` -
-

Learn

-

Race strategy, step by step.

+
+

Stage ${index + 1} of ${stages.length}

+

${stage.title}

+

${stage.lede}

+ +
${stage.render()}
+ +
`; } + +export function mountLearn(root: HTMLElement, param?: string): void { + const body = root.querySelector('.stage-body'); + if (body) stages[stageIndex(param)]?.mount?.(body); +} diff --git a/web/src/simulation/circuits.ts b/web/src/simulation/circuits.ts new file mode 100644 index 0000000..89390db --- /dev/null +++ b/web/src/simulation/circuits.ts @@ -0,0 +1,126 @@ +export interface Mark { + x: number; + y: number; + text: string; + anchor?: 'start' | 'middle' | 'end'; +} + +export interface Circuit { + id: string; + name: string; + /** Seconds to get between the two markers through the pit road. */ + pitSeconds: number; + /** Seconds to cover the same stretch at racing speed. */ + trackSeconds: number; + view: string; + /** Track either side of the measured section, for context only. */ + before: string; + after: string; + racing: string; + pit: string; + entry: [number, number]; + exit: [number, number]; + /** Direction of travel: [x, y, degrees]. */ + arrow: [number, number, number]; + marks: Mark[]; + entryAnchor?: 'start' | 'end'; + exitAnchor?: 'start' | 'end'; + /** Puts the two marker labels under their nodes instead of above. */ + labelsBelow?: boolean; +} + +/* + * Schematics of the pit lane and the stretch of track beside it, not surveys. + * Each one keeps the shape and the orientation of the circuit's own map, so the + * cars run the way they really do: right to left along the Rettilineo Box at + * Monza and along the Hamilton Straight at Silverstone, and uphill from Rascasse + * to Sainte Devote at Monaco. All three are clockwise, so the pit lane sits on + * the inside of the lap. + * + * Monza entry at the exit of Parabolica, rejoins before Prima Variante. + * Monaco entry just after Rascasse, pit lane along the harbour front, + * rejoins on the inside of Sainte Devote. + * Silverstone entry before Vale, skipping Vale and Club, rejoining at Farm + * Curve, so Abbey is missed too. + */ +export const CIRCUITS: Circuit[] = [ + { + id: 'monza', + name: 'Monza', + pitSeconds: 38, + trackSeconds: 16, + view: '0 40 640 220', + before: + 'M 470 70 C 540 70, 596 76, 618 102 C 638 126, 634 158, 614 178 C 596 196, 566 202, 536 203', + after: + 'M 120 206 L 96 206 C 78 206, 70 196, 62 184 C 54 172, 42 166, 28 166', + racing: 'M 520 204 L 120 206', + pit: 'M 520 204 C 506 203, 499 194, 495 184 L 152 182 C 138 182, 128 192, 120 206', + entry: [520, 204], + exit: [120, 206], + arrow: [330, 228, 180], + marks: [ + { x: 566, y: 140, text: 'Parabolica' }, + { x: 330, y: 166, text: 'pit lane' }, + { x: 330, y: 248, text: 'Rettilineo Box' }, + { x: 24, y: 190, text: 'Prima Variante', anchor: 'start' }, + ], + entryAnchor: 'end', + exitAnchor: 'start', + labelsBelow: true, + }, + { + id: 'monaco', + name: 'Monaco', + pitSeconds: 32, + trackSeconds: 12.5, + view: '40 15 330 300', + before: + 'M 345 292 C 310 300, 268 298, 244 284 C 226 274, 222 256, 232 244', + after: 'M 198 48 C 230 44, 262 52, 286 68', + racing: + 'M 224 236 C 212 226, 198 220, 186 213 C 170 205, 162 195, 159 183 L 126 86 C 121 70, 129 56, 146 50 L 198 48', + pit: 'M 224 236 C 226 222, 222 208, 190 196 L 157 92 C 152 76, 158 60, 172 52 C 181 47, 190 48, 198 48', + entry: [224, 236], + exit: [198, 48], + arrow: [140, 140, -108], + marks: [ + { x: 340, y: 310, text: 'Rascasse', anchor: 'end' }, + { x: 250, y: 258, text: 'Noghes', anchor: 'start' }, + { x: 200, y: 150, text: 'pit lane, harbour side', anchor: 'start' }, + { x: 120, y: 36, text: 'Sainte Dévote', anchor: 'end' }, + ], + entryAnchor: 'start', + exitAnchor: 'start', + }, + { + id: 'silverstone', + name: 'Silverstone', + pitSeconds: 45.5, + trackSeconds: 23.5, + view: '0 30 640 250', + before: 'M 586 48 C 604 66, 608 92, 600 112', + after: 'M 146 160 C 138 138, 124 120, 104 106', + racing: + 'M 596 124 C 588 146, 572 156, 564 172 C 556 188, 560 204, 548 214 C 538 222, 520 226, 496 226 L 212 226 C 182 226, 162 214, 152 194 C 147 182, 146 170, 146 160', + pit: 'M 596 124 C 576 142, 552 158, 534 168 L 200 186 C 180 188, 160 178, 146 160', + entry: [596, 124], + exit: [146, 160], + arrow: [360, 248, 180], + marks: [ + { x: 614, y: 44, text: 'Stowe', anchor: 'end' }, + { x: 604, y: 188, text: 'Vale', anchor: 'start' }, + { x: 556, y: 242, text: 'Club', anchor: 'start' }, + { x: 370, y: 166, text: 'pit lane' }, + { x: 360, y: 212, text: 'Hamilton Straight' }, + { x: 196, y: 250, text: 'Abbey', anchor: 'middle' }, + { x: 118, y: 148, text: 'Farm', anchor: 'end' }, + ], + entryAnchor: 'end', + exitAnchor: 'start', + }, +]; + +export function pitDelta(circuit: Circuit): number { + return Number((circuit.pitSeconds - circuit.trackSeconds).toFixed(1)); +} diff --git a/web/src/simulation/tyres.ts b/web/src/simulation/tyres.ts new file mode 100644 index 0000000..f27ae86 --- /dev/null +++ b/web/src/simulation/tyres.ts @@ -0,0 +1,69 @@ +export type Compound = 'soft' | 'medium' | 'hard'; + +export interface TyreSpec { + label: string; + blocksPerLap: number; + /** Laps a set stays usable before it has to come off. */ + maxLaps: number; +} + +// Placeholder pace model: a lap on a compound costs a whole number of blocks. +// Degradation (blocks growing with tyre age) comes later, from the Rust model. +export const TYRES: Record = { + soft: { label: 'Soft', blocksPerLap: 3, maxLaps: 8 }, + medium: { label: 'Medium', blocksPerLap: 4, maxLaps: 12 }, + hard: { label: 'Hard', blocksPerLap: 5, maxLaps: 20 }, +}; + +export const COMPOUNDS: Compound[] = ['soft', 'medium', 'hard']; + +export const PIT_LABEL = 'P'; + +export interface Stint { + compound: Compound; + laps: number; +} + +export interface LapTime { + lap: number; + compound: Compound; + blocks: number; +} + +/** One entry per lap, in race order, so a chart can draw a whole strategy. */ +export function expandStints(stints: Stint[]): LapTime[] { + const laps: LapTime[] = []; + + for (const stint of stints) { + for (let age = 1; age <= stint.laps; age++) { + laps.push({ + lap: laps.length + 1, + compound: stint.compound, + blocks: TYRES[stint.compound].blocksPerLap, + }); + } + } + + return laps; +} + +export function stintBlocks(stint: Stint): number { + return stint.laps * TYRES[stint.compound].blocksPerLap; +} + +export function totalBlocks(stints: Stint[]): number { + return stints.reduce((sum, stint) => sum + stintBlocks(stint), 0); +} + +/** Laps the car pits after, one per tyre change. */ +export function pitLaps(stints: Stint[]): number[] { + const laps: number[] = []; + let run = 0; + + for (const stint of stints.slice(0, -1)) { + run += stint.laps; + laps.push(run); + } + + return laps; +} diff --git a/web/src/stages/compound-pace.ts b/web/src/stages/compound-pace.ts new file mode 100644 index 0000000..feb22ca --- /dev/null +++ b/web/src/stages/compound-pace.ts @@ -0,0 +1,64 @@ +import { timeBlocks } from '../components/time-blocks.ts'; +import { totalBar } from '../components/total-bar.ts'; +import { + COMPOUNDS, + TYRES, + expandStints, + stintBlocks, + type Compound, +} from '../simulation/tyres.ts'; +import type { Stage } from './index.ts'; + +const LAPS = 20; + +let selected: Compound = 'soft'; + +function picker(): string { + return COMPOUNDS.map( + (compound) => ` + `, + ).join(''); +} + +function demo(): string { + const stint = { compound: selected, laps: LAPS }; + + return ` +
+
${picker()}
+ ${timeBlocks({ + laps: expandStints([stint]), + totalLaps: LAPS, + maxBlocks: TYRES.hard.blocksPerLap, + blockSize: 22, + })} + ${totalBar({ + label: `${LAPS} laps`, + segments: [{ compound: selected, blocks: stintBlocks(stint) }], + best: selected === 'soft', + })} +
`; +} + +export const compoundPace: Stage = { + id: 'compound-pace', + title: 'Softer tyres cost less time', + lede: `Ignore the seconds, imagine each tyre costs some number of units of time per lap, and the + compound can change the units of time. Consider a ${LAPS} lap race, select a compound and watch units of time add up.`, + render: demo, + mount(body) { + body.addEventListener('click', (event) => { + const choice = (event.target as HTMLElement).closest( + '.choice', + ); + if (!choice) return; + + selected = choice.dataset.compound as Compound; + body.innerHTML = demo(); + }); + }, +}; diff --git a/web/src/stages/how-many-stops.ts b/web/src/stages/how-many-stops.ts new file mode 100644 index 0000000..8ba4868 --- /dev/null +++ b/web/src/stages/how-many-stops.ts @@ -0,0 +1,190 @@ +import { timeBlocks } from '../components/time-blocks.ts'; +import { pitChips, totalBar } from '../components/total-bar.ts'; +import { + TYRES, + expandStints, + pitLaps, + stintBlocks, + totalBlocks, + type Stint, +} from '../simulation/tyres.ts'; +import type { Stage } from './index.ts'; + +const RACE_LAPS = 20; + +interface Plan { + name: string; + /** Imperative form, for the rules and the verdict. */ + call: string; + stints: Stint[]; +} + +const ONE: Plan = { + name: 'One stop', + call: 'Stop once', + stints: [ + { compound: 'soft', laps: 8 }, + { compound: 'medium', laps: 12 }, + ], +}; + +const TWO: Plan = { + name: 'Two stops', + call: 'Stop twice', + stints: [ + { compound: 'soft', laps: 8 }, + { compound: 'soft', laps: 8 }, + { compound: 'medium', laps: 4 }, + ], +}; + +const stops = (plan: Plan) => plan.stints.length - 1; +const onTrack = (plan: Plan) => totalBlocks(plan.stints); +const raceTime = (plan: Plan, cost: number) => onTrack(plan) + stops(plan) * cost; + +/** Where the plans cost the same: the blocks saved buy exactly one more stop. */ +const breakEven = (onTrack(ONE) - onTrack(TWO)) / (stops(TWO) - stops(ONE)); + +let pitCost = 5; + +function chart(plan: Plan): string { + return ` +
+

+ ${plan.name} + ${onTrack(plan)} on track + ${pitChips(stops(plan))} +

+ ${timeBlocks({ + laps: expandStints(plan.stints), + totalLaps: RACE_LAPS, + maxBlocks: TYRES.medium.blocksPerLap, + pitAfterLaps: pitLaps(plan.stints), + blockSize: 20, + })} +
`; +} + +/** The same two totals from the charts, worked down to a value for P. */ +function solve(): string { + const gap = onTrack(ONE) - onTrack(TWO); + + return ` +
+

+ ${ONE.name} + + ${TWO.name} + +

+

+ ${onTrack(ONE)} + ${pitChips(1)} + = + ${onTrack(TWO)} + ${pitChips(2)} + both race times, in time blocks +

+

+ ${onTrack(ONE)} + = + ${onTrack(TWO)} + ${pitChips(1)} + take one stop off each side +

+

+ ${gap} + = + ${pitChips(1)} + the stop is worth ${gap} time blocks +

+
`; +} + +function bar(plan: Plan, rival: Plan): string { + return totalBar({ + label: plan.name, + segments: [ + ...plan.stints.map((stint) => ({ + compound: stint.compound, + blocks: stintBlocks(stint), + })), + ...Array.from({ length: stops(plan) }, () => ({ + compound: 'pit' as const, + blocks: pitCost, + })), + ], + best: raceTime(plan, pitCost) < raceTime(rival, pitCost), + }); +} + +function verdict(): string { + const one = raceTime(ONE, pitCost); + const two = raceTime(TWO, pitCost); + + if (one === two) { + return ` +

+ P = ${pitCost} + Dead level. Both plans finish in ${one} time blocks. +

`; + } + + const winner = one < two ? ONE : TWO; + const margin = Math.abs(one - two); + + return ` +

+ P = ${pitCost} + ${winner.call}, quicker by ${margin} time block${margin === 1 ? '' : 's'}. +

`; +} + +function summary(): string { + return ` +${bar(ONE, TWO)} +${bar(TWO, ONE)} + +
    +
  • + ${pitChips(1)} longer than ${breakEven}: ${ONE.call.toLowerCase()} +
  • +
  • + ${pitChips(1)} shorter than ${breakEven}: ${TWO.call.toLowerCase()} +
  • +
+ +${verdict()}`; +} + +function demo(): string { + return ` +
+ ${chart(ONE)} + ${chart(TWO)} + ${solve()} + + + +
${summary()}
+
`; +} + +export const howManyStops: Stage = { + id: 'how-many-stops', + title: 'How many stops?', + lede: `Tyres wear out, so the last stage's trick is off the table: a set of softs is good + for ${TYRES.soft.maxLaps} laps here, mediums for ${TYRES.medium.maxLaps}. Over + ${RACE_LAPS} laps that leaves a real choice, and the winner depends entirely on what a + stop costs you.`, + render: demo, + mount(body) { + body.addEventListener('input', (event) => { + const input = event.target as HTMLInputElement; + if (input.name !== 'cost') return; + + pitCost = Number(input.value); + body.querySelector('.demo-sum')!.innerHTML = summary(); + body.querySelector('.slider-label strong')!.textContent = String(pitCost); + }); + }, +}; diff --git a/web/src/stages/index.ts b/web/src/stages/index.ts new file mode 100644 index 0000000..fc62cfb --- /dev/null +++ b/web/src/stages/index.ts @@ -0,0 +1,27 @@ +import { compoundPace } from './compound-pace.ts'; +import { howManyStops } from './how-many-stops.ts'; +import { pitDeltaStage } from './pit-delta.ts'; +import { twoCompoundRule } from './two-compound-rule.ts'; + +export interface Stage { + id: string; + title: string; + lede: string; + /** Markup for the demo area. */ + render(): string; + /** Wires the demo up. Listeners go on `body`, which survives a redraw. */ + mount?(body: HTMLElement): void; +} + +export const stages: Stage[] = [ + compoundPace, + twoCompoundRule, + howManyStops, + pitDeltaStage, +]; + +export function stageIndex(param: string | undefined): number { + const n = Number(param); + if (!Number.isFinite(n)) return 0; + return Math.min(Math.max(Math.trunc(n) - 1, 0), stages.length - 1); +} diff --git a/web/src/stages/pit-delta.ts b/web/src/stages/pit-delta.ts new file mode 100644 index 0000000..84dda3a --- /dev/null +++ b/web/src/stages/pit-delta.ts @@ -0,0 +1,76 @@ +import { startTrackMap, trackMap } from '../components/track-map.ts'; +import { CIRCUITS, pitDelta } from '../simulation/circuits.ts'; +import type { Stage } from './index.ts'; + +let selected = CIRCUITS[0]!.id; + +function demo(): string { + const circuit = CIRCUITS.find((c) => c.id === selected) ?? CIRCUITS[0]!; + + return ` +
+
+ ${CIRCUITS.map( + (c) => ` + `, + ).join('')} +
+ + ${trackMap(circuit)} + +
+ Through the pit road + Staying on track +
+ +
+

+ + ${circuit.pitSeconds} s + through the pit road +

+

+ − + ${circuit.trackSeconds} s + at racing speed +

+

+ = + ${pitDelta(circuit)} s + lost by stopping +

+
+ +

+ The pit lane at ${circuit.name} has its own length and its own speed limit, so the + cost of a stop is a property of the circuit, not of the car. This is the P you were + sliding a moment ago, in real seconds. +

+
`; +} + +export const pitDeltaStage: Stage = { + id: 'pit-delta', + title: 'What a stop actually costs', + lede: `P is not the few seconds the car sits still. It is the pit delta: the time through + the pit road minus the time to cover the same stretch of track at racing speed. Pick a + circuit and watch both cars run from one marker to the other.`, + render: demo, + mount(body) { + startTrackMap(body); + + body.addEventListener('click', (event) => { + const choice = (event.target as HTMLElement).closest( + '.choice', + ); + if (!choice) return; + + selected = choice.dataset.circuit!; + body.innerHTML = demo(); + startTrackMap(body); + }); + }, +}; diff --git a/web/src/stages/two-compound-rule.ts b/web/src/stages/two-compound-rule.ts new file mode 100644 index 0000000..76a10bf --- /dev/null +++ b/web/src/stages/two-compound-rule.ts @@ -0,0 +1,104 @@ +import { plotMarkup, timeBlocks } from '../components/time-blocks.ts'; +import { totalBar } from '../components/total-bar.ts'; +import { + TYRES, + expandStints, + stintBlocks, + totalBlocks, + type Stint, +} from '../simulation/tyres.ts'; +import type { Stage } from './index.ts'; + +const LAPS = 20; +const FIRST: Stint['compound'] = 'soft'; +const SECOND: Stint['compound'] = 'medium'; + +let pitAfter = 5; + +function strategy(pitLap: number): Stint[] { + return [ + { compound: FIRST, laps: pitLap }, + { compound: SECOND, laps: LAPS - pitLap }, + ]; +} + +/** Quickest split available, ignoring the fixed pit loss. */ +const fastest = Math.min( + ...Array.from({ length: LAPS - 1 }, (_, i) => totalBlocks(strategy(i + 1))), +); + +function plot(): string { + return plotMarkup({ + laps: expandStints(strategy(pitAfter)), + totalLaps: LAPS, + pitAfterLaps: [pitAfter], + }); +} + +/** Sits in the chart grid under the axis, so a lap number is a thumb position. */ +function slider(): string { + return ` +`; +} + +function summary(): string { + const stints = strategy(pitAfter); + const best = totalBlocks(stints) === fastest; + + return ` +${totalBar({ + segments: stints.map((stint) => ({ + compound: stint.compound, + blocks: stintBlocks(stint), + })), + pitStops: 1, + best, +})} +

+ ${ + best + ? `Quickest split there is: ${fastest} blocks + P. Stay on the softs for as long as the rule allows.` + : `Keep moving the stop. One split gets this down to ${fastest} blocks + P.` + } +

`; +} + +function demo(): string { + return ` +
+ ${timeBlocks({ + laps: expandStints(strategy(pitAfter)), + totalLaps: LAPS, + maxBlocks: TYRES[SECOND].blocksPerLap, + pitAfterLaps: [pitAfter], + blockSize: 30, + control: slider(), + })} +
${summary()}
+
`; +} + +export const twoCompoundRule: Stage = { + id: 'two-compound-rule', + title: 'You must use two compounds', + lede: `A dry Grand Prix forces every car onto at least two different compounds, so a pit + stop is never optional, and it costs you the time of a pitstop, P. Move the stop and find the cheapest way to + obey the rule over ${LAPS} laps.`, + render: demo, + mount(body) { + // Only the plot and the summary are redrawn, so dragging the slider is never + // interrupted by its own element being replaced. + body.addEventListener('input', (event) => { + const input = event.target as HTMLInputElement; + if (input.name !== 'pit') return; + + pitAfter = Number(input.value); + body.querySelector('.plot')!.innerHTML = plot(); + body.querySelector('.demo-sum')!.innerHTML = summary(); + body.querySelector('.slider-label strong')!.textContent = String(pitAfter); + }); + }, +}; diff --git a/web/src/style.css b/web/src/style.css index d682e7d..60b5156 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -7,6 +7,14 @@ --accent: #d4f24a; --accent-dim: rgba(212, 242, 74, 0.12); + --soft: #cf5050; + --medium: #d7a63c; + --hard: #c6cfdb; + --pit-line: #4f9bf5; + --pit-road: #d96bd0; + --racing: #4f9bf5; + --axis: #3b4552; + font: 16px/1.5 system-ui, 'Segoe UI', Roboto, sans-serif; color-scheme: dark; color: var(--text); @@ -248,3 +256,685 @@ footer a:hover { grid-template-columns: 1fr; } } + +/* tutorial stages */ + +.eyebrow { + margin: 0 0 10px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +.learn h1 { + font-size: 32px; +} + +.learn .lede { + max-width: 46rem; +} + +.stage-body { + display: flex; + justify-content: center; + width: 100%; + margin: 36px 0 40px; +} + +.stage-nav { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + margin-top: auto; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.stage-nav .btn { + margin-top: 0; + padding: 10px 18px; +} + +.dots { + display: flex; + gap: 8px; + margin: 0 auto; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--border); +} + +.dot.on { + background: var(--accent); +} + +/* demos */ + +.demo { + max-width: 100%; + overflow-x: auto; +} + +/* For demos whose width comes from the figure rather than its text. */ +.demo.narrow { + width: 620px; +} + +.demo-sum { + width: 0; + min-width: 100%; + margin-top: 4px; +} + +[data-compound='soft'] { + --c: var(--soft); +} +[data-compound='medium'] { + --c: var(--medium); +} +[data-compound='hard'] { + --c: var(--hard); +} +[data-compound='pit'] { + --c: var(--pit-line); +} + +.choices { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-bottom: 8px; +} + +.choice { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 14px; + cursor: pointer; +} + +.choice .swatch { + width: 14px; + height: 14px; + border-radius: 4px; + background: var(--c); + border: 1px solid rgba(0, 0, 0, 0.45); +} + +.choice small { + font-size: 12px; + color: var(--muted); +} + +.choice[aria-pressed='true'] { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* block chart */ + +.chart { + --gap: 4px; + display: grid; + grid-template-areas: + 'y plot x' + '. axis .' + '. control .'; + grid-template-columns: auto auto auto; + justify-content: start; + align-items: end; + gap: 8px 12px; + margin: 0; + padding-top: 44px; +} + +.chart-y { + grid-area: y; + align-self: start; + text-align: right; + font-size: 13px; + line-height: 1.3; + color: var(--muted); +} + +.chart-x { + grid-area: x; + font-size: 13px; + color: var(--muted); +} + +.plot { + grid-area: plot; + position: relative; + box-sizing: content-box; + display: flex; + align-items: flex-end; + gap: var(--gap); + height: calc(var(--rows) * var(--block) + (var(--rows) - 1) * var(--gap)); + border-left: 2px solid var(--axis); + border-bottom: 2px solid var(--axis); +} + +.col { + display: flex; + flex-direction: column-reverse; + gap: var(--gap); + width: var(--block); +} + +.col i { + display: block; + height: var(--block); + border-radius: 3px; + background: var(--c); + border: 1px solid rgba(0, 0, 0, 0.45); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22); +} + +.axis { + grid-area: axis; + display: flex; + gap: var(--gap); + padding-left: 2px; +} + +.axis span { + width: var(--block); + text-align: center; + font-size: 12px; + color: var(--muted); +} + +.pit-marker { + position: absolute; + top: -30px; + bottom: 0; + left: calc(var(--pit) * (var(--block) + var(--gap)) - var(--gap) / 2); + width: 2px; + transform: translateX(-1px); + background: var(--pit-line); + z-index: 1; +} + +.pit-marker span { + position: absolute; + top: -12px; + left: 50%; + transform: translateX(-50%); + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--pit-line); + color: #0d1117; + font-size: 13px; + font-weight: 700; +} + +/* slider */ + +.control { + grid-area: control; + padding-left: 2px; + margin-top: 20px; +} + +.slider { + display: flex; + flex-direction: column; + gap: 12px; +} + +.slider-label { + font-size: 13px; + color: var(--muted); +} + +.slider-label strong { + color: var(--text); +} + +.slider.plain { + max-width: 420px; + margin-top: 28px; +} + +.slider input { + --thumb: 16px; + appearance: none; + -webkit-appearance: none; + width: 100%; + margin: 0; + height: var(--thumb); + background: transparent; + cursor: pointer; +} + +/* In the chart grid the track spans lap --from to lap --to of the plot above, + so the thumb sits exactly where the pit marker is drawn. */ +.control .slider input { + width: calc( + (var(--to) - var(--from)) * (var(--block) + var(--gap)) + var(--thumb) + ); + margin-left: calc( + var(--from) * (var(--block) + var(--gap)) - var(--gap) / 2 - var(--thumb) / 2 + ); +} + +.slider input::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: var(--border); +} + +.slider input::-webkit-slider-thumb { + -webkit-appearance: none; + width: var(--thumb); + height: var(--thumb); + margin-top: calc((4px - var(--thumb)) / 2); + border: none; + border-radius: 50%; + background: var(--accent); +} + +.slider input::-moz-range-track { + height: 4px; + border-radius: 999px; + background: var(--border); +} + +.slider input::-moz-range-thumb { + width: var(--thumb); + height: var(--thumb); + border: none; + border-radius: 50%; + background: var(--accent); +} + +.slider input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 6px; +} + +/* totals */ + +.totals { + display: flex; + align-items: center; + gap: 14px; + margin-top: 24px; +} + +.totals-label { + width: 72px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.bar { + display: flex; + flex: 1; + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; +} + +.seg { + flex: var(--w) 1 0; + padding: 9px 0; + text-align: center; + font-weight: 700; + color: #1a1505; + background: var(--c); +} + +.seg[data-compound='soft'] { + color: #fff; +} + +.sum { + display: flex; + align-items: center; + gap: 7px; + font-size: 18px; + font-weight: 700; +} + +.totals.best .sum { + color: var(--accent); +} + +.pit-chip { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--pit-line); + color: #0d1117; + font-size: 12px; +} + +.note { + margin: 14px 0 0; + font-size: 13px; + color: var(--muted); +} + +.note.on { + color: var(--accent); +} + +/* strategy comparison */ + +.strategy { + margin-bottom: 26px; +} + +.strategy-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin: 0 0 4px; +} + +.strategy-name { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.strategy-sum, +.solve .side { + display: flex; + align-items: center; + gap: 7px; + font-size: 15px; + color: var(--muted); +} + +.strategy-sum b, +.solve b { + font-size: 19px; + color: var(--text); +} + +/* Solving for P: the two totals from the charts, worked down a line at a time. */ +.solve { + margin-top: 34px; +} + +.solve-row { + display: grid; + grid-template-columns: 1fr 34px 1fr; + align-items: center; + gap: 0 8px; + margin: 0 0 12px; +} + +.solve-row .side:first-child { + justify-content: flex-end; +} + +.solve-row .eq { + text-align: center; + font-size: 18px; + color: var(--muted); +} + +.solve-row .hint { + grid-column: 1 / -1; + margin-top: 4px; + font-size: 12px; + text-align: center; + color: var(--muted); +} + +.solve-row.heads .side { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.solve-row.solved { + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.solve-row.solved b { + color: var(--accent); + font-size: 22px; +} + +.rules { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; + margin: 22px 0 0; + padding: 0; + list-style: none; + font-size: 14px; + color: var(--muted); +} + +.rules li { + display: flex; + align-items: center; + gap: 8px; +} + +.rules li strong { + color: var(--muted); +} + +.rules li.on, +.rules li.on strong { + color: var(--text); +} + +.verdict { + display: flex; + align-items: center; + gap: 12px; + margin: 18px 0 0; + padding: 12px 16px; + border: 1px solid var(--accent); + border-radius: 10px; + background: var(--accent-dim); + font-size: 15px; +} + +.verdict-at { + padding: 3px 9px; + border-radius: 999px; + background: var(--accent); + color: #0d1117; + font-size: 12px; + font-weight: 700; +} + +.verdict.level { + border-color: var(--border); + background: var(--surface); + color: var(--muted); +} + +.verdict.level .verdict-at { + background: var(--border); + color: var(--text); +} + +/* pit delta */ + +.track { + width: 100%; + max-width: 620px; + margin: 18px 0 0; +} + +.track svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + overflow: visible; +} + +.track figcaption { + margin-top: 6px; + font-size: 12px; + color: var(--muted); +} + +.ribbon { + fill: none; + stroke: #273039; + stroke-width: 22; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ribbon.context { + stroke: #1d242c; +} + +.track .mark { + fill: var(--muted); + font-size: 11px; + letter-spacing: 0.04em; +} + +.track .marker text { + fill: var(--text); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.track .arrow { + fill: var(--muted); +} + +.path-pit, +.path-racing { + fill: none; + stroke-width: 4; + stroke-linecap: round; +} + +.path-pit { + stroke: var(--pit-road); +} + +.path-racing { + stroke: var(--racing); +} + +.node { + fill: none; + stroke: var(--text); + stroke-width: 3; +} + +.runner { + stroke: #0d1117; + stroke-width: 2; +} + +.runner-pit { + fill: var(--pit-road); +} + +.runner-racing { + fill: var(--racing); +} + +.legend { + display: flex; + gap: 20px; + margin-top: 14px; + font-size: 13px; +} + +.key { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.key::before { + content: ''; + width: 18px; + height: 4px; + border-radius: 2px; + background: currentColor; +} + +.key-pit::before { + background: var(--pit-road); +} + +.key-racing::before { + background: var(--racing); +} + +.delta { + margin-top: 24px; + font-size: 16px; + color: var(--muted); +} + +.delta p { + display: grid; + grid-template-columns: 20px 78px auto; + gap: 10px; + align-items: baseline; + margin: 0 0 8px; +} + +.delta .op { + text-align: right; +} + +.delta .num { + text-align: right; + font-weight: 700; +} + +.delta .num.pit-road { + color: var(--pit-road); +} + +.delta .num.racing { + color: var(--racing); +} + +.delta-total { + padding-top: 10px; + border-top: 1px solid var(--border); + color: var(--text); +} + +.delta-total strong { + font-size: 20px; +}