tutorials
This commit is contained in:
15 files changed
+1611
-28
No files matched your search
+9
-15
@@ -3,18 +3,18 @@ mod utils;
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum TyreCompound {
|
pub enum TyreCompound {
|
||||||
Soft,
|
Soft,
|
||||||
Medium,
|
Medium,
|
||||||
Hard,
|
Hard,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TyreParams {
|
pub struct TyreParams {
|
||||||
offset_seconds: f32,
|
offset_seconds: f32,
|
||||||
degradation_per_lap_seconds: f32,
|
degradation_per_lap_seconds: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TyreOpts {
|
pub struct TyreOpts {
|
||||||
soft: TyreParams,
|
soft: TyreParams,
|
||||||
medium: TyreParams,
|
medium: TyreParams,
|
||||||
hard: TyreParams,
|
hard: TyreParams,
|
||||||
@@ -22,7 +22,7 @@ struct TyreOpts {
|
|||||||
|
|
||||||
// #[wasm_bindgen]
|
// #[wasm_bindgen]
|
||||||
impl TyreParams {
|
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 {
|
TyreParams {
|
||||||
offset_seconds,
|
offset_seconds,
|
||||||
degradation_per_lap_seconds,
|
degradation_per_lap_seconds,
|
||||||
@@ -64,7 +64,11 @@ pub struct Model {
|
|||||||
|
|
||||||
impl Model {
|
impl Model {
|
||||||
// T(a) = B + C + D(a - 1) where a is tyre_age_laps
|
// 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<f32> {
|
pub fn predicted_lap_time_seconds(
|
||||||
|
&self,
|
||||||
|
compound: TyreCompound,
|
||||||
|
tyre_age_laps: u8,
|
||||||
|
) -> Option<f32> {
|
||||||
match tyre_age_laps {
|
match tyre_age_laps {
|
||||||
0 => None,
|
0 => None,
|
||||||
_ => {
|
_ => {
|
||||||
@@ -125,13 +129,3 @@ impl Strategy {
|
|||||||
self.stints.len().saturating_sub(1)
|
self.stints.len().saturating_sub(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
extern "C" {
|
|
||||||
fn alert(s: &str);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn greet() {
|
|
||||||
alert("Hello, visual-strategy!");
|
|
||||||
}
|
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Understand the strategy behind motorsport through interactive simulations."
|
content="Understand the strategy behind motorsport through interactive simulations."
|
||||||
/>
|
/>
|
||||||
<title>Stint — Interactive race strategy</title>
|
<title>Stint, interactive race strategy</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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
@@ -2,22 +2,29 @@ import './style.css';
|
|||||||
import { header } from './components/header.ts';
|
import { header } from './components/header.ts';
|
||||||
import { footer } from './components/footer.ts';
|
import { footer } from './components/footer.ts';
|
||||||
import { home } from './pages/home.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 { playground } from './pages/playground.ts';
|
||||||
import { loadSimulation } from './simulation/wasm.ts';
|
import { loadSimulation } from './simulation/wasm.ts';
|
||||||
|
|
||||||
const routes: Record<string, () => string> = {
|
interface Route {
|
||||||
'/': home,
|
view(param?: string): string;
|
||||||
'/learn': learn,
|
mount?(root: HTMLElement, param?: string): void;
|
||||||
'/playground': playground,
|
}
|
||||||
|
|
||||||
|
const routes: Record<string, Route> = {
|
||||||
|
'': { view: home },
|
||||||
|
learn: { view: learn, mount: mountLearn },
|
||||||
|
playground: { view: playground },
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = document.querySelector<HTMLDivElement>('#app')!;
|
const app = document.querySelector<HTMLDivElement>('#app')!;
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
const path = location.hash.slice(1) || '/';
|
const [section = '', param] = location.hash.replace(/^#\/?/, '').split('/');
|
||||||
const page = routes[path] ?? home;
|
const route = routes[section] ?? routes['']!;
|
||||||
app.innerHTML = [header(path), page(), footer()].join('\n');
|
|
||||||
|
app.innerHTML = [header(`/${section}`), route.view(param), footer()].join('\n');
|
||||||
|
route.mount?.(app, param);
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener('hashchange', render);
|
addEventListener('hashchange', render);
|
||||||
|
|||||||
+35
-4
@@ -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 →</a>`
|
||||||
|
: `<a class="btn primary" href="#/playground">Open playground →</a>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<main class="page">
|
<main class="page learn">
|
||||||
<h1>Learn</h1>
|
<p class="eyebrow">Stage ${index + 1} of ${stages.length}</p>
|
||||||
<p class="lede">Race strategy, step by step.</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}`}">← Back</a>
|
||||||
|
<span class="dots">${dots}</span>
|
||||||
|
${next}
|
||||||
|
</nav>
|
||||||
</main>`;
|
</main>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mountLearn(root: HTMLElement, param?: string): void {
|
||||||
|
const body = root.querySelector<HTMLElement>('.stage-body');
|
||||||
|
if (body) stages[stageIndex(param)]?.mount?.(body);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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">−</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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
--accent: #d4f24a;
|
--accent: #d4f24a;
|
||||||
--accent-dim: rgba(212, 242, 74, 0.12);
|
--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;
|
font: 16px/1.5 system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -248,3 +256,685 @@ footer a:hover {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user