@johnmorrisdotca/kyogijo-stadiums 0.54.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +9 -2
- package/catalogue.json +1 -1
- package/dist/catalogue.data.js +1 -1
- package/dist/circuits-entry.d.ts +14 -0
- package/dist/circuits-entry.js +8 -0
- package/dist/circuits.pace.d.ts +96 -0
- package/dist/circuits.pace.js +131 -0
- package/dist/circuits.race.d.ts +80 -0
- package/dist/circuits.race.dom.d.ts +68 -0
- package/dist/circuits.race.dom.js +434 -0
- package/dist/circuits.race.draw.d.ts +59 -0
- package/dist/circuits.race.draw.js +153 -0
- package/dist/circuits.race.field.d.ts +26 -0
- package/dist/circuits.race.field.js +41 -0
- package/dist/circuits.race.js +292 -0
- package/dist/circuits.race.moment.d.ts +68 -0
- package/dist/circuits.race.moment.js +147 -0
- package/dist/circuits.race.words.d.ts +49 -0
- package/dist/circuits.race.words.js +44 -0
- package/dist/turn.dom.d.ts +1 -1
- package/dist/turn.dom.js +38 -72
- package/dist/turn.frames.d.ts +33 -0
- package/dist/turn.frames.js +102 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// A simulated race in a page: the circuit flat or in 3D with a dot for each car, the race clock, the controls (play, the speeds 1×, 10× and 60×, and a slider over the whole race) and the timing board, all moving
|
|
2
|
+
// together. Each animation frame moves only the dots that moved and writes the board and the clock only where their words changed (the board at most four times a second); no element is made while it plays.
|
|
3
|
+
// Nothing runs until the reader plays it, and nothing runs while it is paused, the page is hidden or the race is out of sight. With reduced motion nothing plays by itself: play moves to the leader's next lap.
|
|
4
|
+
import { CIRCUIT_MARKER, buildCircuitTurn } from "./circuits.turn.js";
|
|
5
|
+
import { RACE_STYLE, carMarkup, carRadius, lapTime, raceBoard, raceClock, raceDrawing, raceStage, standingGap } from "./circuits.race.draw.js";
|
|
6
|
+
import { raceAt } from "./circuits.race.moment.js";
|
|
7
|
+
import { simulateRace } from "./circuits.race.js";
|
|
8
|
+
import { RACE_WORDS } from "./circuits.race.words.js";
|
|
9
|
+
import { num } from "./format.js";
|
|
10
|
+
import { fill } from "./strings.core.js";
|
|
11
|
+
import { mountTurn } from "./turn.dom.js";
|
|
12
|
+
/** The speeds the controls offer: real time, ten times and sixty times (a minute of the race a second). */
|
|
13
|
+
export const RACE_SPEEDS = Object.freeze([1, 10, 60]);
|
|
14
|
+
const ICONS = { play: "M8 5v14l11-7z", pause: "M6 5h4v14H6zM14 5h4v14h-4z", next: "M6 5l8.5 7L6 19zM16 5h2v14h-2z" };
|
|
15
|
+
const icon = (d) => `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="${d}"/></svg>`;
|
|
16
|
+
/** The plane of a car standing in a 3D view: a disc that faces the eye, its lower edge on the floor over its place. */
|
|
17
|
+
const carPlane = (id, x, y, d, svg) => ({ id: `car-${id}`, kind: "player", o: [x - d / 2, y, d], u: [d, 0, 0], v: [0, 0, -d], svg, billboard: true, lean: true });
|
|
18
|
+
/**
|
|
19
|
+
* Mount a simulated race on a circuit: the circuit flat (or in 3D) with a dot for each made-up car, the race clock, play and pause, the speeds 1×, 10× and 60×, a slider over the whole race and the timing board, moving
|
|
20
|
+
* together. It does nothing until it is played, and nothing while it is paused, hidden or out of sight.
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts no-run
|
|
23
|
+
* import { mountCircuitRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
24
|
+
* const race = mountCircuitRace(document.querySelector("#race")!, { circuit: "las-vegas" }, { speed: 10 });
|
|
25
|
+
* race.play();
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function mountCircuitRace(host, initial, settings = {}) {
|
|
29
|
+
const doc = host.ownerDocument;
|
|
30
|
+
let options = { ...initial };
|
|
31
|
+
let language = options.language === "ja" ? "ja" : "en";
|
|
32
|
+
const race = simulateRace(options);
|
|
33
|
+
const media = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : undefined;
|
|
34
|
+
const motionNow = () => (settings.motion === "smooth" ? "smooth" : settings.motion === "step" ? "step" : media?.matches === true ? "step" : "smooth");
|
|
35
|
+
const clampSpeed = (n) => Math.min(600, Math.max(1, n));
|
|
36
|
+
const state = { t: Math.min(Math.max(settings.start ?? 0, 0), race.duration), duration: race.duration, playing: false, ended: false, speed: clampSpeed(Number.isFinite(settings.speed) ? settings.speed : 1), motion: motionNow() };
|
|
37
|
+
const tell = () => settings.onChange?.({ ...state });
|
|
38
|
+
// ---- The parts: the stage, the clock, the controls, the note, the live region and the board. ----
|
|
39
|
+
const root = doc.createElement("div");
|
|
40
|
+
root.className = "ky-race";
|
|
41
|
+
const style = doc.createElement("style");
|
|
42
|
+
style.textContent = RACE_STYLE;
|
|
43
|
+
const stage = doc.createElement("div");
|
|
44
|
+
stage.className = "ky-race-view";
|
|
45
|
+
const clock = doc.createElement("div");
|
|
46
|
+
clock.className = "ky-race-clock";
|
|
47
|
+
const time = doc.createElement("span");
|
|
48
|
+
time.className = "ky-race-time";
|
|
49
|
+
const stageLine = doc.createElement("span");
|
|
50
|
+
stageLine.className = "ky-race-stage";
|
|
51
|
+
clock.append(time, stageLine);
|
|
52
|
+
const bar = doc.createElement("div");
|
|
53
|
+
bar.className = "ky-race-bar";
|
|
54
|
+
bar.setAttribute("role", "group");
|
|
55
|
+
const play = doc.createElement("button");
|
|
56
|
+
play.type = "button";
|
|
57
|
+
play.className = "ky-race-play";
|
|
58
|
+
const speeds = doc.createElement("span");
|
|
59
|
+
speeds.className = "ky-race-speeds";
|
|
60
|
+
speeds.setAttribute("role", "group");
|
|
61
|
+
const speedButtons = RACE_SPEEDS.map((n) => {
|
|
62
|
+
const b = doc.createElement("button");
|
|
63
|
+
b.type = "button";
|
|
64
|
+
b.dataset.speed = String(n);
|
|
65
|
+
b.textContent = `${n}×`;
|
|
66
|
+
speeds.append(b);
|
|
67
|
+
return b;
|
|
68
|
+
});
|
|
69
|
+
const range = doc.createElement("input");
|
|
70
|
+
range.type = "range";
|
|
71
|
+
range.className = "ky-race-position";
|
|
72
|
+
range.min = "0";
|
|
73
|
+
range.max = String(race.duration);
|
|
74
|
+
range.step = "1";
|
|
75
|
+
bar.append(play, speeds, range);
|
|
76
|
+
const note = doc.createElement("p");
|
|
77
|
+
note.className = "ky-race-note";
|
|
78
|
+
const live = doc.createElement("div");
|
|
79
|
+
live.className = "ky-race-live";
|
|
80
|
+
live.setAttribute("role", "status");
|
|
81
|
+
live.setAttribute("aria-live", "polite");
|
|
82
|
+
const boardWrap = doc.createElement("div");
|
|
83
|
+
boardWrap.className = "ky-race-board-wrap";
|
|
84
|
+
boardWrap.hidden = settings.board === false;
|
|
85
|
+
root.append(style, stage, clock, bar, note, live, boardWrap);
|
|
86
|
+
host.replaceChildren(root);
|
|
87
|
+
// ---- The drawing: flat, or the circuit's 3D view with the cars standing on it. ----
|
|
88
|
+
let turn;
|
|
89
|
+
let inner;
|
|
90
|
+
/** Each car's element (a group in the flat drawing, a plane's element in 3D), the text of its position, and what each was last given. */
|
|
91
|
+
let cars = new Map();
|
|
92
|
+
const planes = new Map();
|
|
93
|
+
let disc = 0;
|
|
94
|
+
let moment = raceAt(race, state.t);
|
|
95
|
+
const bindFlat = () => {
|
|
96
|
+
cars = new Map();
|
|
97
|
+
for (const el of stage.querySelectorAll(".ky-ci-car")) {
|
|
98
|
+
const id = el.getAttribute("data-car") ?? "";
|
|
99
|
+
cars.set(id, { el, text: el.querySelector("text"), r: Number(el.querySelector("circle")?.getAttribute("r") ?? 0), at: el.getAttribute("transform") ?? "", position: Number(el.querySelector("text")?.textContent ?? 0), status: el.getAttribute("data-status") ?? "" });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const drawFlat = () => {
|
|
103
|
+
stage.innerHTML = raceDrawing({ ...options, t: state.t }).svg;
|
|
104
|
+
bindFlat();
|
|
105
|
+
};
|
|
106
|
+
/** The model of the 3D view with a disc for each car at the clock's time. */
|
|
107
|
+
const withCars = (model) => {
|
|
108
|
+
const area = model.uprights.area ?? { x0: 0, y0: 0, x1: model.viewBox.w, y1: model.viewBox.h };
|
|
109
|
+
const k = Math.max(area.x1 - area.x0, area.y1 - area.y0) / 1000;
|
|
110
|
+
disc = 2 * carRadius(k) * (CIRCUIT_MARKER.size / 30);
|
|
111
|
+
const r = disc / 2;
|
|
112
|
+
planes.clear();
|
|
113
|
+
for (const line of [...moment.standings].sort((a, b) => a.index - b.index)) {
|
|
114
|
+
const svg = carMarkup({ ...line, at: [r, r] }, r * 0.9, language).replace(/ role="img"[^>]*?aria-label="[^"]*"/, "");
|
|
115
|
+
planes.set(line.car.id, carPlane(line.car.id, line.at[0], line.at[1], disc, svg));
|
|
116
|
+
}
|
|
117
|
+
return { ...model, planes: [...model.planes, ...planes.values()] };
|
|
118
|
+
};
|
|
119
|
+
const bindTurn = () => {
|
|
120
|
+
cars = new Map();
|
|
121
|
+
if (inner === undefined)
|
|
122
|
+
return;
|
|
123
|
+
for (const line of moment.standings) {
|
|
124
|
+
const el = inner.root.querySelector(`.ky-up[data-part="car-${line.car.id}"]`);
|
|
125
|
+
cars.set(line.car.id, { el, text: el?.querySelector("text") ?? null, r: (disc / 2) * 0.9, at: "", position: line.position, status: line.status });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const mountView = () => {
|
|
129
|
+
if (settings.view === "3d") {
|
|
130
|
+
const holder = doc.createElement("div");
|
|
131
|
+
stage.replaceChildren(holder);
|
|
132
|
+
turn = mountTurn(holder, options, settings.turn ?? {}, buildCircuitTurn, false, (lent) => {
|
|
133
|
+
inner = lent;
|
|
134
|
+
});
|
|
135
|
+
if (inner !== undefined) {
|
|
136
|
+
inner.setDecorate(withCars, bindTurn);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Where the browser cannot draw the 3D view, the race is drawn flat.
|
|
140
|
+
turn.destroy();
|
|
141
|
+
turn = undefined;
|
|
142
|
+
}
|
|
143
|
+
drawFlat();
|
|
144
|
+
};
|
|
145
|
+
// ---- Painting a moment: the dots that moved, and (at most four times a second, or at once when asked) the clock and the board. ----
|
|
146
|
+
let boardRows = [];
|
|
147
|
+
const buildBoard = () => {
|
|
148
|
+
boardWrap.innerHTML = raceBoard(moment, language);
|
|
149
|
+
boardRows = [...boardWrap.querySelectorAll("tbody tr")].map((row) => ({
|
|
150
|
+
row,
|
|
151
|
+
cells: new Map([...row.querySelectorAll("[data-col]")].map((cell) => [cell.dataset.col ?? "", cell])),
|
|
152
|
+
swatch: row.querySelector(".ky-race-swatch"),
|
|
153
|
+
code: row.querySelector(".ky-race-code"),
|
|
154
|
+
name: row.querySelector(".ky-race-name"),
|
|
155
|
+
}));
|
|
156
|
+
};
|
|
157
|
+
const setText = (el, text) => {
|
|
158
|
+
if (el !== null && el !== undefined && el.textContent !== text)
|
|
159
|
+
el.textContent = text;
|
|
160
|
+
};
|
|
161
|
+
const setAttr = (el, name, value) => {
|
|
162
|
+
if (el === null || el === undefined || el.getAttribute(name) === value)
|
|
163
|
+
return;
|
|
164
|
+
if (value === null)
|
|
165
|
+
el.removeAttribute(name);
|
|
166
|
+
else
|
|
167
|
+
el.setAttribute(name, value);
|
|
168
|
+
};
|
|
169
|
+
const writeBoard = () => {
|
|
170
|
+
const w = RACE_WORDS[language];
|
|
171
|
+
setText(time, raceClock(state.t));
|
|
172
|
+
setAttr(time, "aria-label", fill(w.elapsed, { time: raceClock(state.t) }));
|
|
173
|
+
setText(stageLine, raceStage(moment, language));
|
|
174
|
+
const at = String(Math.round(state.t));
|
|
175
|
+
if (range.value !== at)
|
|
176
|
+
range.value = at;
|
|
177
|
+
setAttr(range, "aria-valuetext", fill(w.positionValue, { time: raceClock(state.t), total: raceClock(race.duration) }));
|
|
178
|
+
moment.standings.forEach((line, i) => {
|
|
179
|
+
const r = boardRows[i];
|
|
180
|
+
if (r === undefined)
|
|
181
|
+
return;
|
|
182
|
+
setAttr(r.row, "data-car", line.car.id);
|
|
183
|
+
setAttr(r.row, "data-status", line.status);
|
|
184
|
+
setText(r.cells.get("pos"), String(line.position));
|
|
185
|
+
if (r.swatch !== null && r.swatch.style.background !== line.car.team.colour)
|
|
186
|
+
r.swatch.style.background = line.car.team.colour;
|
|
187
|
+
setText(r.code, line.car.driver.code);
|
|
188
|
+
setText(r.name, line.car.driver.name[language]);
|
|
189
|
+
setText(r.cells.get("team"), line.car.team.name[language]);
|
|
190
|
+
setText(r.cells.get("gap"), line.status === "pit" ? w.board.pit : standingGap(line, language, "gap"));
|
|
191
|
+
setText(r.cells.get("interval"), standingGap(line, language, "interval"));
|
|
192
|
+
setText(r.cells.get("last"), line.last === undefined ? "" : lapTime(line.last));
|
|
193
|
+
setText(r.cells.get("best"), line.best === undefined ? "" : lapTime(line.best));
|
|
194
|
+
const fastest = moment.fastest?.index === line.index;
|
|
195
|
+
setAttr(r.cells.get("best"), "data-fastest", fastest ? "true" : null);
|
|
196
|
+
setAttr(r.cells.get("best"), "title", fastest ? w.board.fastest : null);
|
|
197
|
+
setText(r.cells.get("stops"), String(line.stops));
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
let boardAt = -Infinity;
|
|
201
|
+
let leader = moment.standings[0]?.car.id;
|
|
202
|
+
let flagSaid = moment.flag;
|
|
203
|
+
const paint = (now, crossed) => {
|
|
204
|
+
moment = raceAt(race, state.t);
|
|
205
|
+
const w = RACE_WORDS[language];
|
|
206
|
+
let moved = false;
|
|
207
|
+
for (const line of moment.standings) {
|
|
208
|
+
const car = cars.get(line.car.id);
|
|
209
|
+
if (car === undefined)
|
|
210
|
+
continue;
|
|
211
|
+
if (inner !== undefined) {
|
|
212
|
+
const plane = planes.get(line.car.id);
|
|
213
|
+
if (plane !== undefined && (plane.o[0] !== line.at[0] - disc / 2 || plane.o[1] !== line.at[1])) {
|
|
214
|
+
plane.o = [line.at[0] - disc / 2, line.at[1], disc];
|
|
215
|
+
moved = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const at = `translate(${num(line.at[0])} ${num(line.at[1])})`;
|
|
220
|
+
if (car.at !== at) {
|
|
221
|
+
car.at = at;
|
|
222
|
+
car.el?.setAttribute("transform", at);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (car.position !== line.position) {
|
|
226
|
+
car.position = line.position;
|
|
227
|
+
setText(car.text, String(line.position));
|
|
228
|
+
setAttr(car.text, "font-size", num(car.r * (line.position >= 10 ? 1 : 1.15)));
|
|
229
|
+
if (inner === undefined)
|
|
230
|
+
setAttr(car.el, "aria-label", fill(w.car, { position: line.position, name: line.car.driver.name[language], team: line.car.team.name[language] }));
|
|
231
|
+
}
|
|
232
|
+
if (car.status !== line.status) {
|
|
233
|
+
car.status = line.status;
|
|
234
|
+
setAttr(car.el, "data-status", line.status);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (moved)
|
|
238
|
+
inner?.repaint();
|
|
239
|
+
// What is said aloud: a new leader, and the flag, only as the race plays forward.
|
|
240
|
+
if (crossed) {
|
|
241
|
+
const first = moment.standings[0];
|
|
242
|
+
if (moment.flag && !flagSaid && first !== undefined)
|
|
243
|
+
setText(live, fill(w.wins, { name: first.car.driver.name[language] }));
|
|
244
|
+
else if (first !== undefined && first.car.id !== leader && state.t > 0)
|
|
245
|
+
setText(live, fill(w.leads, { name: first.car.driver.name[language], lap: moment.lap }));
|
|
246
|
+
}
|
|
247
|
+
leader = moment.standings[0]?.car.id;
|
|
248
|
+
flagSaid = moment.flag;
|
|
249
|
+
if (now === undefined || now - boardAt >= 250) {
|
|
250
|
+
boardAt = now ?? -Infinity;
|
|
251
|
+
writeBoard();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
// ---- The controls. ----
|
|
255
|
+
const label = () => {
|
|
256
|
+
const w = RACE_WORDS[language];
|
|
257
|
+
bar.setAttribute("aria-label", w.group);
|
|
258
|
+
speeds.setAttribute("aria-label", w.speed);
|
|
259
|
+
const step = state.motion === "step";
|
|
260
|
+
const name = step ? w.nextLap : state.playing ? w.pause : w.play;
|
|
261
|
+
if (play.getAttribute("aria-label") !== name) {
|
|
262
|
+
play.setAttribute("aria-label", name);
|
|
263
|
+
play.title = name;
|
|
264
|
+
play.innerHTML = icon(step ? ICONS.next : state.playing ? ICONS.pause : ICONS.play);
|
|
265
|
+
}
|
|
266
|
+
speeds.hidden = step;
|
|
267
|
+
for (const b of speedButtons) {
|
|
268
|
+
const n = Number(b.dataset.speed);
|
|
269
|
+
b.setAttribute("aria-label", `${w.speed} ${fill(w.speedValue, { speed: n })}`);
|
|
270
|
+
b.setAttribute("aria-pressed", String(n === state.speed));
|
|
271
|
+
}
|
|
272
|
+
range.setAttribute("aria-label", w.position);
|
|
273
|
+
setText(note, w.simulated);
|
|
274
|
+
root.dataset.state = state.ended ? "ended" : state.playing ? "playing" : "paused";
|
|
275
|
+
root.dataset.motion = state.motion;
|
|
276
|
+
};
|
|
277
|
+
// ---- The clock on the page: animation frames while playing and in sight (the 3D view's own, so that its turn and the race share one), and nothing else. ----
|
|
278
|
+
let frameId = 0;
|
|
279
|
+
let last = 0;
|
|
280
|
+
let onScreen = true;
|
|
281
|
+
const request = (callback) => (inner !== undefined ? inner.requestFrame(callback) : requestAnimationFrame(callback));
|
|
282
|
+
const cancel = (id) => (inner !== undefined ? inner.cancelFrame(id) : cancelAnimationFrame(id));
|
|
283
|
+
const stopFrames = () => {
|
|
284
|
+
if (frameId !== 0)
|
|
285
|
+
cancel(frameId);
|
|
286
|
+
frameId = 0;
|
|
287
|
+
};
|
|
288
|
+
const want = () => {
|
|
289
|
+
if (frameId !== 0 || !onScreen || !state.playing)
|
|
290
|
+
return;
|
|
291
|
+
last = performance.now();
|
|
292
|
+
frameId = request(frame);
|
|
293
|
+
};
|
|
294
|
+
function frame(now) {
|
|
295
|
+
frameId = 0;
|
|
296
|
+
if (!state.playing || !onScreen)
|
|
297
|
+
return;
|
|
298
|
+
const dt = Math.min(0.25, Math.max(0, (now - last) / 1000));
|
|
299
|
+
last = now;
|
|
300
|
+
state.t = Math.min(race.duration, state.t + dt * state.speed);
|
|
301
|
+
if (state.t >= race.duration) {
|
|
302
|
+
state.playing = false;
|
|
303
|
+
state.ended = true;
|
|
304
|
+
paint(undefined, true);
|
|
305
|
+
label();
|
|
306
|
+
tell();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
paint(now, true);
|
|
310
|
+
frameId = request(frame);
|
|
311
|
+
}
|
|
312
|
+
const api = {
|
|
313
|
+
race,
|
|
314
|
+
get turn() {
|
|
315
|
+
return turn;
|
|
316
|
+
},
|
|
317
|
+
state: () => ({ ...state }),
|
|
318
|
+
moment: () => moment,
|
|
319
|
+
play() {
|
|
320
|
+
if (state.motion === "step")
|
|
321
|
+
return api.nextLap();
|
|
322
|
+
if (state.playing)
|
|
323
|
+
return;
|
|
324
|
+
if (state.t >= race.duration)
|
|
325
|
+
state.t = 0;
|
|
326
|
+
state.playing = true;
|
|
327
|
+
state.ended = false;
|
|
328
|
+
label();
|
|
329
|
+
tell();
|
|
330
|
+
want();
|
|
331
|
+
},
|
|
332
|
+
pause() {
|
|
333
|
+
if (!state.playing)
|
|
334
|
+
return;
|
|
335
|
+
state.playing = false;
|
|
336
|
+
stopFrames();
|
|
337
|
+
label();
|
|
338
|
+
paint(undefined, false);
|
|
339
|
+
tell();
|
|
340
|
+
},
|
|
341
|
+
toggle() {
|
|
342
|
+
if (state.playing)
|
|
343
|
+
api.pause();
|
|
344
|
+
else
|
|
345
|
+
api.play();
|
|
346
|
+
},
|
|
347
|
+
seek(t) {
|
|
348
|
+
if (!Number.isFinite(t))
|
|
349
|
+
return;
|
|
350
|
+
state.t = Math.min(Math.max(t, 0), race.duration);
|
|
351
|
+
state.ended = state.t >= race.duration;
|
|
352
|
+
paint(undefined, false);
|
|
353
|
+
label();
|
|
354
|
+
tell();
|
|
355
|
+
},
|
|
356
|
+
nextLap() {
|
|
357
|
+
const first = moment.standings[0];
|
|
358
|
+
const next = first === undefined ? undefined : race.crossings[first.index].find((at) => at > state.t + 1e-6);
|
|
359
|
+
api.pause();
|
|
360
|
+
api.seek(next ?? race.duration);
|
|
361
|
+
},
|
|
362
|
+
setSpeed(speed) {
|
|
363
|
+
if (!Number.isFinite(speed))
|
|
364
|
+
return;
|
|
365
|
+
const next = clampSpeed(speed);
|
|
366
|
+
if (next === state.speed)
|
|
367
|
+
return;
|
|
368
|
+
state.speed = next;
|
|
369
|
+
label();
|
|
370
|
+
tell();
|
|
371
|
+
},
|
|
372
|
+
update(next) {
|
|
373
|
+
options = { ...next, circuit: options.circuit, seed: options.seed, laps: options.laps, cars: options.cars };
|
|
374
|
+
language = options.language === "ja" ? "ja" : "en";
|
|
375
|
+
if (turn !== undefined)
|
|
376
|
+
turn.update(options);
|
|
377
|
+
else
|
|
378
|
+
drawFlat();
|
|
379
|
+
buildBoard();
|
|
380
|
+
label();
|
|
381
|
+
paint(undefined, false);
|
|
382
|
+
},
|
|
383
|
+
destroy() {
|
|
384
|
+
stopFrames();
|
|
385
|
+
state.playing = false;
|
|
386
|
+
seen?.disconnect();
|
|
387
|
+
doc.removeEventListener("visibilitychange", hidden);
|
|
388
|
+
if (typeof media?.removeEventListener === "function")
|
|
389
|
+
media.removeEventListener("change", motionChanged);
|
|
390
|
+
turn?.destroy();
|
|
391
|
+
turn = undefined;
|
|
392
|
+
inner = undefined;
|
|
393
|
+
cars = new Map();
|
|
394
|
+
host.replaceChildren();
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
play.addEventListener("click", () => api.toggle());
|
|
398
|
+
for (const b of speedButtons)
|
|
399
|
+
b.addEventListener("click", () => api.setSpeed(Number(b.dataset.speed)));
|
|
400
|
+
range.addEventListener("input", () => api.seek(Number(range.value)));
|
|
401
|
+
// Scrolled out of sight, the clock holds (no frame is asked for) and goes on when it is back.
|
|
402
|
+
let seen;
|
|
403
|
+
if (typeof IntersectionObserver === "function") {
|
|
404
|
+
seen = new IntersectionObserver((entries) => {
|
|
405
|
+
onScreen = entries[entries.length - 1]?.isIntersecting !== false;
|
|
406
|
+
if (onScreen)
|
|
407
|
+
want();
|
|
408
|
+
else
|
|
409
|
+
stopFrames();
|
|
410
|
+
});
|
|
411
|
+
seen.observe(stage);
|
|
412
|
+
}
|
|
413
|
+
// A page that is hidden does no work: the race stops, and the reader starts it again.
|
|
414
|
+
const hidden = () => {
|
|
415
|
+
if (doc.visibilityState === "hidden")
|
|
416
|
+
api.pause();
|
|
417
|
+
};
|
|
418
|
+
doc.addEventListener("visibilitychange", hidden);
|
|
419
|
+
const motionChanged = () => {
|
|
420
|
+
state.motion = motionNow();
|
|
421
|
+
if (state.motion === "step")
|
|
422
|
+
api.pause();
|
|
423
|
+
label();
|
|
424
|
+
};
|
|
425
|
+
if ((settings.motion === undefined || settings.motion === "auto") && typeof media?.addEventListener === "function")
|
|
426
|
+
media.addEventListener("change", motionChanged);
|
|
427
|
+
mountView();
|
|
428
|
+
buildBoard();
|
|
429
|
+
label();
|
|
430
|
+
paint(undefined, false);
|
|
431
|
+
if (settings.autoplay === true && state.motion === "smooth" && doc.visibilityState !== "hidden")
|
|
432
|
+
api.play();
|
|
433
|
+
return api;
|
|
434
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CircuitDrawing } from "./circuits.draw.ts";
|
|
2
|
+
import { type RaceMoment, type RaceStanding } from "./circuits.race.moment.ts";
|
|
3
|
+
import { type CircuitRace, type RaceOptions } from "./circuits.race.ts";
|
|
4
|
+
import type { CircuitOptions } from "./circuits.types.ts";
|
|
5
|
+
import type { Language } from "./types.ts";
|
|
6
|
+
/** A race clock: `23:45` or `1:23:45`. */
|
|
7
|
+
export declare function raceClock(seconds: number): string;
|
|
8
|
+
/** A lap's time: `1:36.512`. */
|
|
9
|
+
export declare function lapTime(seconds: number): string;
|
|
10
|
+
/** A gap: `+1.234`, `+1:02.345` past a minute, or the laps down in the words of the language. */
|
|
11
|
+
export declare function gapText(seconds: number | undefined, laps: number, language: Language): string;
|
|
12
|
+
/** The race clock's line at a moment: the lap, the final lap, the flag or the grid. */
|
|
13
|
+
export declare function raceStage(moment: RaceMoment, language: Language): string;
|
|
14
|
+
/** What a line of the board shows in its gap column: the leader, a gap, laps down, the pit lane or out. */
|
|
15
|
+
export declare function standingGap(line: RaceStanding, language: Language, of?: "gap" | "interval"): string;
|
|
16
|
+
/** The size of a car's dot on a drawing whose type and marks are sized by `k` (a thousandth of its longer side). */
|
|
17
|
+
export declare const carRadius: (k: number) => number;
|
|
18
|
+
/** The markup of one car's dot: a circle in the team's colour, edged in the ground's, with its position on it, laid at the origin (the page moves it with `transform`). */
|
|
19
|
+
export declare function carMarkup(line: RaceStanding, r: number, language: Language): string;
|
|
20
|
+
/** The cars of a moment as one group, the last car drawn first so that the leader lies on top. */
|
|
21
|
+
export declare function carsMarkup(moment: RaceMoment, r: number, language: Language): string;
|
|
22
|
+
/** What a race's drawing is made from: the circuit's options, the race's, and the moment. */
|
|
23
|
+
export interface RaceDrawOptions extends CircuitOptions, Omit<RaceOptions, "circuit"> {
|
|
24
|
+
/** Seconds from the start: 0 (the grid) by default. */
|
|
25
|
+
t?: number;
|
|
26
|
+
}
|
|
27
|
+
/** A race's drawing at a moment, and what it was made from. */
|
|
28
|
+
export interface RaceDrawing extends CircuitDrawing {
|
|
29
|
+
race: CircuitRace;
|
|
30
|
+
moment: RaceMoment;
|
|
31
|
+
}
|
|
32
|
+
/** The race, the drawing of its circuit and the moment, with the cars on it. */
|
|
33
|
+
export declare function raceDrawing(options: RaceDrawOptions): RaceDrawing;
|
|
34
|
+
/**
|
|
35
|
+
* A simulated race on a circuit at a moment, as one SVG string: the circuit's drawing with a dot for each made-up car where the race has it then, in its team's colour, with the position it holds written on it.
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { drawRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
39
|
+
* const svg = drawRace({ circuit: "las-vegas", laps: 3, cars: 6, t: 120 });
|
|
40
|
+
* console.log(svg.match(/class="ky-ci-car"/g)!.length, svg.includes("simulation"));
|
|
41
|
+
* // 6 true
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function drawRace(options: RaceDrawOptions): string;
|
|
45
|
+
/**
|
|
46
|
+
* The timing board at a moment, as a table: position, driver and team, the gap to the leader and to the car in front, the last and best laps and the pit stops, the fastest lap marked. The colours of the teams are
|
|
47
|
+
* swatches beside their names, never the only way a car is told.
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { raceAt, raceBoard, simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
51
|
+
* const race = simulateRace({ circuit: "las-vegas", laps: 3, cars: 4 });
|
|
52
|
+
* const table = raceBoard(raceAt(race, 200));
|
|
53
|
+
* console.log(table.startsWith("<table"), (table.match(/<tr data-car/g) ?? []).length);
|
|
54
|
+
* // true 4
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function raceBoard(moment: RaceMoment, language?: Language): string;
|
|
58
|
+
/** The rules of a race: the cars on the drawing, the clock, the controls and the board. Scoped to `.ky-race` and the drawing's cars, in the page's own colours. */
|
|
59
|
+
export declare const RACE_STYLE: string;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// A simulated race drawn: the circuit's drawing with a dot for each car where it is at a moment (in its team's colour, with the position it holds written on it), the race clock's words, and the timing board as a
|
|
2
|
+
// table. Pure: the same race, moment and language always give the same strings. The page that moves them is `circuits.race.dom.ts`.
|
|
3
|
+
import { circuitDrawing } from "./circuits.draw.js";
|
|
4
|
+
import { raceAt } from "./circuits.race.moment.js";
|
|
5
|
+
import { simulateRace } from "./circuits.race.js";
|
|
6
|
+
import { RACE_WORDS } from "./circuits.race.words.js";
|
|
7
|
+
import { el, esc, num } from "./format.js";
|
|
8
|
+
import { fill } from "./strings.core.js";
|
|
9
|
+
/** A race clock: `23:45` or `1:23:45`. */
|
|
10
|
+
export function raceClock(seconds) {
|
|
11
|
+
const whole = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
|
|
12
|
+
const s = String(whole % 60).padStart(2, "0");
|
|
13
|
+
const m = Math.floor(whole / 60);
|
|
14
|
+
return m < 60 ? `${m}:${s}` : `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}:${s}`;
|
|
15
|
+
}
|
|
16
|
+
/** A lap's time: `1:36.512`. */
|
|
17
|
+
export function lapTime(seconds) {
|
|
18
|
+
const thousandths = Math.round(Math.max(0, seconds) * 1000);
|
|
19
|
+
const m = Math.floor(thousandths / 60000);
|
|
20
|
+
const rest = thousandths - m * 60000;
|
|
21
|
+
return `${m}:${String(Math.floor(rest / 1000)).padStart(2, "0")}.${String(rest % 1000).padStart(3, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
/** A gap: `+1.234`, `+1:02.345` past a minute, or the laps down in the words of the language. */
|
|
24
|
+
export function gapText(seconds, laps, language) {
|
|
25
|
+
const w = RACE_WORDS[language].board;
|
|
26
|
+
if (laps >= 1)
|
|
27
|
+
return laps === 1 ? w.lapDown : fill(w.lapsDown, { n: laps });
|
|
28
|
+
if (seconds === undefined)
|
|
29
|
+
return "";
|
|
30
|
+
return seconds >= 60 ? `+${lapTime(seconds)}` : `+${seconds.toFixed(3)}`;
|
|
31
|
+
}
|
|
32
|
+
/** The race clock's line at a moment: the lap, the final lap, the flag or the grid. */
|
|
33
|
+
export function raceStage(moment, language) {
|
|
34
|
+
const w = RACE_WORDS[language];
|
|
35
|
+
if (moment.flag)
|
|
36
|
+
return w.flag;
|
|
37
|
+
if (moment.t <= 0)
|
|
38
|
+
return w.grid;
|
|
39
|
+
if (moment.lap === moment.laps && moment.laps > 1)
|
|
40
|
+
return `${fill(w.lap, { lap: moment.lap, laps: moment.laps })} · ${w.finalLap}`;
|
|
41
|
+
return fill(w.lap, { lap: moment.lap, laps: moment.laps });
|
|
42
|
+
}
|
|
43
|
+
/** What a line of the board shows in its gap column: the leader, a gap, laps down, the pit lane or out. */
|
|
44
|
+
export function standingGap(line, language, of = "gap") {
|
|
45
|
+
const w = RACE_WORDS[language].board;
|
|
46
|
+
if (line.status === "out")
|
|
47
|
+
return w.out;
|
|
48
|
+
if (line.position === 1)
|
|
49
|
+
return of === "gap" ? w.leader : "";
|
|
50
|
+
return of === "gap" ? gapText(line.gap, line.lapsDown, language) : gapText(line.interval, line.intervalLaps, language);
|
|
51
|
+
}
|
|
52
|
+
/** The size of a car's dot on a drawing whose type and marks are sized by `k` (a thousandth of its longer side). */
|
|
53
|
+
export const carRadius = (k) => 11 * k;
|
|
54
|
+
/** The markup of one car's dot: a circle in the team's colour, edged in the ground's, with its position on it, laid at the origin (the page moves it with `transform`). */
|
|
55
|
+
export function carMarkup(line, r, language) {
|
|
56
|
+
const w = RACE_WORDS[language];
|
|
57
|
+
const label = fill(w.car, { position: line.position, name: line.car.driver.name[language], team: line.car.team.name[language] });
|
|
58
|
+
const inner = el("circle", { class: "ky-ci-car-dot", r: num(r), fill: line.car.team.colour, "stroke-width": num(r * 0.2) }) +
|
|
59
|
+
el("text", { class: "ky-ci-car-n", y: num(r * 0.4), "font-size": num(r * (line.position >= 10 ? 1 : 1.15)), fill: line.car.team.ink }, String(line.position));
|
|
60
|
+
return el("g", { class: "ky-ci-car", "data-car": line.car.id, "data-status": line.status, transform: `translate(${num(line.at[0])} ${num(line.at[1])})`, role: "img", "aria-label": label }, inner);
|
|
61
|
+
}
|
|
62
|
+
/** The cars of a moment as one group, the last car drawn first so that the leader lies on top. */
|
|
63
|
+
export function carsMarkup(moment, r, language) {
|
|
64
|
+
const lines = [...moment.standings].sort((a, b) => a.index - b.index);
|
|
65
|
+
return el("g", { class: "ky-ci-cars" }, lines.map((line) => carMarkup(line, r, language)).join(""));
|
|
66
|
+
}
|
|
67
|
+
/** The race, the drawing of its circuit and the moment, with the cars on it. */
|
|
68
|
+
export function raceDrawing(options) {
|
|
69
|
+
const language = options.language === "ja" ? "ja" : "en";
|
|
70
|
+
const race = simulateRace(options);
|
|
71
|
+
const moment = raceAt(race, options.t ?? 0);
|
|
72
|
+
const drawn = circuitDrawing(options);
|
|
73
|
+
const w = RACE_WORDS[language];
|
|
74
|
+
const extra = ` ${fill(w.description, { cars: race.cars.length, laps: race.laps })}`;
|
|
75
|
+
const description = `${drawn.description}${language === "ja" ? extra.trim() : extra}`;
|
|
76
|
+
const svg = drawn.svg.replace(/<desc id="([^"]+)">[^<]*<\/desc>/, (_, id) => `<desc id="${id}">${esc(description)}</desc>`).replace(/<\/svg>$/, `${carsMarkup(moment, carRadius(drawn.k), language)}</svg>`);
|
|
77
|
+
return { ...drawn, svg, description, race, moment };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A simulated race on a circuit at a moment, as one SVG string: the circuit's drawing with a dot for each made-up car where the race has it then, in its team's colour, with the position it holds written on it.
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* import { drawRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
84
|
+
* const svg = drawRace({ circuit: "las-vegas", laps: 3, cars: 6, t: 120 });
|
|
85
|
+
* console.log(svg.match(/class="ky-ci-car"/g)!.length, svg.includes("simulation"));
|
|
86
|
+
* // 6 true
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function drawRace(options) {
|
|
90
|
+
return raceDrawing(options).svg;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The timing board at a moment, as a table: position, driver and team, the gap to the leader and to the car in front, the last and best laps and the pit stops, the fastest lap marked. The colours of the teams are
|
|
94
|
+
* swatches beside their names, never the only way a car is told.
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* import { raceAt, raceBoard, simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
98
|
+
* const race = simulateRace({ circuit: "las-vegas", laps: 3, cars: 4 });
|
|
99
|
+
* const table = raceBoard(raceAt(race, 200));
|
|
100
|
+
* console.log(table.startsWith("<table"), (table.match(/<tr data-car/g) ?? []).length);
|
|
101
|
+
* // true 4
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function raceBoard(moment, language = "en") {
|
|
105
|
+
const w = RACE_WORDS[language === "ja" ? "ja" : "en"].board;
|
|
106
|
+
const lang = language === "ja" ? "ja" : "en";
|
|
107
|
+
const heads = [w.position, w.driver, w.team, w.gap, w.interval, w.last, w.best, w.stops];
|
|
108
|
+
const cols = ["pos", "driver", "team", "gap", "interval", "last", "best", "stops"];
|
|
109
|
+
const head = `<thead><tr>${heads.map((h, i) => `<th scope="col" data-col="${cols[i]}">${esc(h)}</th>`).join("")}</tr></thead>`;
|
|
110
|
+
const rows = moment.standings.map((line) => {
|
|
111
|
+
const fastest = moment.fastest?.index === line.index;
|
|
112
|
+
const cells = [
|
|
113
|
+
`<th scope="row" data-col="pos">${line.position}</th>`,
|
|
114
|
+
`<td data-col="driver"><span class="ky-race-swatch" style="background:${esc(line.car.team.colour)}"></span><span class="ky-race-code">${esc(line.car.driver.code)}</span> <span class="ky-race-name">${esc(line.car.driver.name[lang])}</span></td>`,
|
|
115
|
+
`<td data-col="team">${esc(line.car.team.name[lang])}</td>`,
|
|
116
|
+
`<td data-col="gap">${esc(line.status === "pit" ? w.pit : standingGap(line, lang, "gap"))}</td>`,
|
|
117
|
+
`<td data-col="interval">${esc(standingGap(line, lang, "interval"))}</td>`,
|
|
118
|
+
`<td data-col="last">${line.last === undefined ? "" : lapTime(line.last)}</td>`,
|
|
119
|
+
`<td data-col="best"${fastest ? ` data-fastest="true" title="${esc(w.fastest)}"` : ""}>${line.best === undefined ? "" : lapTime(line.best)}</td>`,
|
|
120
|
+
`<td data-col="stops">${line.stops}</td>`,
|
|
121
|
+
];
|
|
122
|
+
return `<tr data-car="${esc(line.car.id)}" data-status="${line.status}">${cells.join("")}</tr>`;
|
|
123
|
+
});
|
|
124
|
+
return `<table class="ky-race-board"><caption>${esc(w.caption)}</caption>${head}<tbody>${rows.join("")}</tbody></table>`;
|
|
125
|
+
}
|
|
126
|
+
/** The rules of a race: the cars on the drawing, the clock, the controls and the board. Scoped to `.ky-race` and the drawing's cars, in the page's own colours. */
|
|
127
|
+
export const RACE_STYLE = `
|
|
128
|
+
.ky-circuit .ky-ci-car-dot,.ky-turn .ky-ci-car-dot{stroke:var(--ky-ci-ground,#fff)}
|
|
129
|
+
.ky-circuit .ky-ci-car-n,.ky-turn .ky-ci-car-n{font-family:var(--ky-font,system-ui,sans-serif);font-weight:800;text-anchor:middle;font-variant-numeric:tabular-nums}
|
|
130
|
+
.ky-circuit .ky-ci-car[data-status=out]{opacity:.45}
|
|
131
|
+
.ky-race{display:block;color:inherit}
|
|
132
|
+
.ky-race .ky-race-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.5rem}
|
|
133
|
+
.ky-race .ky-race-bar button{box-sizing:border-box;min-width:44px;min-height:44px;margin:0;padding:0 .7rem;border:1px solid currentColor;border-radius:8px;background:transparent;color:inherit;font:inherit;font-weight:700;cursor:pointer}
|
|
134
|
+
.ky-race .ky-race-bar button[aria-pressed=true]{background:color-mix(in srgb,currentColor 22%,transparent)}
|
|
135
|
+
.ky-race .ky-race-bar button:focus-visible,.ky-race .ky-race-bar input:focus-visible{outline:2px solid currentColor;outline-offset:2px}
|
|
136
|
+
.ky-race .ky-race-speeds{display:inline-flex;gap:.25rem}
|
|
137
|
+
.ky-race .ky-race-bar input[type=range]{flex:1 1 160px;min-width:120px;min-height:44px;margin:0;accent-color:currentColor}
|
|
138
|
+
.ky-race .ky-race-clock{display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem .75rem;margin-top:.5rem;font-variant-numeric:tabular-nums}
|
|
139
|
+
.ky-race .ky-race-time{font-size:1.5rem;font-weight:800}
|
|
140
|
+
.ky-race .ky-race-stage{font-weight:700}
|
|
141
|
+
.ky-race .ky-race-note{margin:.25rem 0 0;font-size:.85rem;opacity:.8}
|
|
142
|
+
.ky-race .ky-race-board-wrap{overflow-x:auto;margin-top:.5rem}
|
|
143
|
+
.ky-race .ky-race-board{border-collapse:collapse;width:100%;font-size:.85rem;font-variant-numeric:tabular-nums}
|
|
144
|
+
.ky-race .ky-race-board caption{text-align:start;font-weight:700;padding:.25rem 0}
|
|
145
|
+
.ky-race .ky-race-board th,.ky-race .ky-race-board td{padding:.2rem .45rem;border-bottom:1px solid color-mix(in srgb,currentColor 18%,transparent);text-align:start;white-space:nowrap}
|
|
146
|
+
.ky-race .ky-race-board td[data-col=gap],.ky-race .ky-race-board td[data-col=interval],.ky-race .ky-race-board td[data-col=last],.ky-race .ky-race-board td[data-col=best],.ky-race .ky-race-board td[data-col=stops]{text-align:end}
|
|
147
|
+
.ky-race .ky-race-board tr[data-status=out]{opacity:.6}
|
|
148
|
+
.ky-race .ky-race-board td[data-fastest=true]{font-weight:800;text-decoration:underline}
|
|
149
|
+
.ky-race .ky-race-swatch{display:inline-block;width:.7em;height:.7em;margin-inline-end:.35em;border-radius:50%;border:1px solid color-mix(in srgb,currentColor 40%,transparent);vertical-align:baseline}
|
|
150
|
+
.ky-race .ky-race-code{font-weight:800}
|
|
151
|
+
.ky-race .ky-race-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
|
|
152
|
+
@media (max-width:560px){.ky-race .ky-race-board [data-col=team],.ky-race .ky-race-board [data-col=best],.ky-race .ky-race-board [data-col=last],.ky-race .ky-race-board .ky-race-name{display:none}}
|
|
153
|
+
`.trim();
|