tutorials

This commit is contained in:
SowinskiBraeden committed 2026-10-21 00:25:25 -07:00
1 parent 0376c84ed1
commit 01c7182fa9
15 files changed
+1611 -28

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
name="description"
content="Understand the strategy behind motorsport through interactive simulations."
/>
<title>Stint — Interactive race strategy</title>
<title>Stint, interactive race strategy</title>
</head>
<body>
<div id="app"></div>
+61
View File
@@ -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 '<div class="col"></div>';
return `<div class="col" data-compound="${lap.compound}">${'<i></i>'.repeat(lap.blocks)}</div>`;
}).join('');
const pits = pitAfterLaps
.map((lap) => `<div class="pit-marker" style="--pit: ${lap}"><span>P</span></div>`)
.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) => `<span>${i + 1}</span>`,
).join('');
return `
<figure class="chart" style="--laps: ${totalLaps}; --rows: ${rows}; --block: ${blockSize}px">
<p class="chart-y">Lap time<br /><span>(blocks)</span></p>
<div class="plot">${plotMarkup({ laps, totalLaps, pitAfterLaps })}</div>
<p class="chart-x">Laps</p>
<div class="axis">${ticks}</div>
${control ? `<div class="control">${control}</div>` : ''}
</figure>`;
}
+48
View File
@@ -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 }, () => '<span class="pit-chip">P</span>').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) =>
`<span class="seg" data-compound="${segment.compound}" style="--w: ${segment.blocks}">${segment.blocks}</span>`,
)
.join('');
const pits = pitStops > 0 ? ` + ${pitChips(pitStops)}` : '';
return `
<div class="totals${best ? ' best' : ''}">
<span class="totals-label">${label}</span>
<div class="bar">${bar}</div>
<span class="sum">= ${total}${pits}</span>
</div>`;
}
+96
View File
@@ -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 `<circle class="runner runner-${kind}" r="7"
data-path="${kind}-path" data-arrives="${arrives}" data-cycle="${cycle * 1000}" />`;
}
function marker(
[x, y]: [number, number],
label: string,
anchor: 'start' | 'end',
below: boolean,
): string {
const dx = anchor === 'start' ? 14 : -14;
return `
<g class="marker">
<circle class="node" cx="${x}" cy="${y}" r="11" />
<text x="${x + dx}" y="${y + (below ? 24 : -17)}" text-anchor="${anchor}">${label}</text>
</g>`;
}
/**
* 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<SVGCircleElement>('.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 `
<figure class="track">
<svg width="${Math.round(vw! * scale)}" height="${Math.round(vh! * scale)}"
viewBox="${circuit.view}" role="img"
aria-label="Schematic of the pit lane and the racing line at ${circuit.name}">
<path class="ribbon context" d="${circuit.before}" />
<path class="ribbon context" d="${circuit.after}" />
<path class="ribbon" d="${circuit.racing}" />
<path id="pit-path" class="path-pit" d="${circuit.pit}" />
<path id="racing-path" class="path-racing" d="${circuit.racing}" />
<g class="arrow" transform="translate(${ax} ${ay}) rotate(${angle})">
<path d="M -9 -6 L 5 0 L -9 6 Z" />
</g>
${circuit.marks
.map(
(m) =>
`<text class="mark" x="${m.x}" y="${m.y}" text-anchor="${m.anchor ?? 'middle'}">${m.text}</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)}
</svg>
<figcaption>
Schematic, drawn in the racing direction. Both cars leave the pit entry
together and are timed to the point where the pit lane rejoins.
</figcaption>
</figure>`;
}
+15 -8
View File
@@ -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, () => string> = {
'/': home,
'/learn': learn,
'/playground': playground,
interface Route {
view(param?: string): string;
mount?(root: HTMLElement, param?: string): void;
}
const routes: Record<string, Route> = {
'': { view: home },
learn: { view: learn, mount: mountLearn },
playground: { view: playground },
};
const app = document.querySelector<HTMLDivElement>('#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);
+35 -4
View File
@@ -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) =>
`<a class="dot${i === index ? ' on' : ''}" href="#/learn/${i + 1}" aria-label="Stage ${i + 1}: ${s.title}"></a>`,
)
.join('');
const next =
index < stages.length - 1
? `<a class="btn primary" href="#/learn/${index + 2}">Next &rarr;</a>`
: `<a class="btn primary" href="#/playground">Open playground &rarr;</a>`;
return `
<main class="page">
<h1>Learn</h1>
<p class="lede">Race strategy, step by step.</p>
<main class="page learn">
<p class="eyebrow">Stage ${index + 1} of ${stages.length}</p>
<h1>${stage.title}</h1>
<p class="lede">${stage.lede}</p>
<div class="stage-body">${stage.render()}</div>
<nav class="stage-nav">
<a class="btn" href="${index === 0 ? '#/' : `#/learn/${index}`}">&larr; Back</a>
<span class="dots">${dots}</span>
${next}
</nav>
</main>`;
}
export function mountLearn(root: HTMLElement, param?: string): void {
const body = root.querySelector<HTMLElement>('.stage-body');
if (body) stages[stageIndex(param)]?.mount?.(body);
}
+126
View File
@@ -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));
}
+69
View File
@@ -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<Compound, TyreSpec> = {
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;
}
+64
View File
@@ -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) => `
<button type="button" class="choice" data-compound="${compound}" aria-pressed="${compound === selected}">
<span class="swatch"></span>
<span class="choice-label">${TYRES[compound].label}</span>
<small>${TYRES[compound].blocksPerLap} blocks / lap</small>
</button>`,
).join('');
}
function demo(): string {
const stint = { compound: selected, laps: LAPS };
return `
<div class="demo">
<div class="choices" role="group" aria-label="Tyre compound">${picker()}</div>
${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',
})}
</div>`;
}
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<HTMLButtonElement>(
'.choice',
);
if (!choice) return;
selected = choice.dataset.compound as Compound;
body.innerHTML = demo();
});
},
};
+190
View File
@@ -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 `
<div class="strategy">
<p class="strategy-head">
<span class="strategy-name">${plan.name}</span>
<span class="strategy-sum"><b>${onTrack(plan)}</b> on track + ${pitChips(stops(plan))}</span>
</p>
${timeBlocks({
laps: expandStints(plan.stints),
totalLaps: RACE_LAPS,
maxBlocks: TYRES.medium.blocksPerLap,
pitAfterLaps: pitLaps(plan.stints),
blockSize: 20,
})}
</div>`;
}
/** The same two totals from the charts, worked down to a value for P. */
function solve(): string {
const gap = onTrack(ONE) - onTrack(TWO);
return `
<div class="solve">
<p class="solve-row heads">
<span class="side">${ONE.name}</span>
<span class="eq"></span>
<span class="side">${TWO.name}</span>
<span class="hint"></span>
</p>
<p class="solve-row">
<span class="side"><b>${onTrack(ONE)}</b> + ${pitChips(1)}</span>
<span class="eq">=</span>
<span class="side"><b>${onTrack(TWO)}</b> + ${pitChips(2)}</span>
<span class="hint">both race times, in time blocks</span>
</p>
<p class="solve-row">
<span class="side"><b>${onTrack(ONE)}</b></span>
<span class="eq">=</span>
<span class="side"><b>${onTrack(TWO)}</b> + ${pitChips(1)}</span>
<span class="hint">take one stop off each side</span>
</p>
<p class="solve-row solved">
<span class="side"><b>${gap}</b></span>
<span class="eq">=</span>
<span class="side">${pitChips(1)}</span>
<span class="hint">the stop is worth ${gap} time blocks</span>
</p>
</div>`;
}
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 class="verdict level">
<span class="verdict-at">P = ${pitCost}</span>
<span>Dead level. Both plans finish in ${one} time blocks.</span>
</p>`;
}
const winner = one < two ? ONE : TWO;
const margin = Math.abs(one - two);
return `
<p class="verdict">
<span class="verdict-at">P = ${pitCost}</span>
<span><strong>${winner.call}</strong>, quicker by ${margin} time block${margin === 1 ? '' : 's'}.</span>
</p>`;
}
function summary(): string {
return `
${bar(ONE, TWO)}
${bar(TWO, ONE)}
<ul class="rules">
<li class="${pitCost > breakEven ? 'on' : ''}">
${pitChips(1)} longer than ${breakEven}: <strong>${ONE.call.toLowerCase()}</strong>
</li>
<li class="${pitCost < breakEven ? 'on' : ''}">
${pitChips(1)} shorter than ${breakEven}: <strong>${TWO.call.toLowerCase()}</strong>
</li>
</ul>
${verdict()}`;
}
function demo(): string {
return `
<div class="demo">
${chart(ONE)}
${chart(TWO)}
${solve()}
<label class="slider plain">
<span class="slider-label">A pit stop costs <strong>${pitCost}</strong> time blocks</span>
<input type="range" name="cost" min="1" max="16" value="${pitCost}" />
</label>
<div class="demo-sum">${summary()}</div>
</div>`;
}
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);
});
},
};
+27
View File
@@ -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);
}
+76
View File
@@ -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 `
<div class="demo narrow">
<div class="choices" role="group" aria-label="Circuit">
${CIRCUITS.map(
(c) => `
<button type="button" class="choice" data-circuit="${c.id}" aria-pressed="${c.id === selected}">
<span class="choice-label">${c.name}</span>
<small>${pitDelta(c)} s delta</small>
</button>`,
).join('')}
</div>
${trackMap(circuit)}
<div class="legend">
<span class="key key-pit">Through the pit road</span>
<span class="key key-racing">Staying on track</span>
</div>
<div class="delta">
<p>
<span class="op"></span>
<span class="num pit-road">${circuit.pitSeconds} s</span>
<span>through the pit road</span>
</p>
<p>
<span class="op">&minus;</span>
<span class="num racing">${circuit.trackSeconds} s</span>
<span>at racing speed</span>
</p>
<p class="delta-total">
<span class="op">=</span>
<span class="num"><strong>${pitDelta(circuit)} s</strong></span>
<span>lost by stopping</span>
</p>
</div>
<p class="note">
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.
</p>
</div>`;
}
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<HTMLButtonElement>(
'.choice',
);
if (!choice) return;
selected = choice.dataset.circuit!;
body.innerHTML = demo();
startTrackMap(body);
});
},
};
+104
View File
@@ -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 `
<label class="slider" style="--from: 1; --to: ${LAPS - 1}">
<span class="slider-label">Pit after lap <strong>${pitAfter}</strong></span>
<input type="range" name="pit" min="1" max="${LAPS - 1}" value="${pitAfter}" />
</label>`;
}
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,
})}
<p class="note${best ? ' on' : ''}">
${
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.`
}
</p>`;
}
function demo(): string {
return `
<div class="demo">
${timeBlocks({
laps: expandStints(strategy(pitAfter)),
totalLaps: LAPS,
maxBlocks: TYRES[SECOND].blocksPerLap,
pitAfterLaps: [pitAfter],
blockSize: 30,
control: slider(),
})}
<div class="demo-sum">${summary()}</div>
</div>`;
}
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);
});
},
};
+690
View File
@@ -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;
}