@johnmorrisdotca/kyogijo-stadiums 0.54.0 → 0.56.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.
@@ -0,0 +1,332 @@
1
+ // A race from a feed: kyogijo's open race timeline (`kyogijo-race`, version 1), read strictly, and turned into the same race a simulation gives (`CircuitRace`), so that every moment, drawing, board and page of a
2
+ // simulated race plays it unchanged. A feed says when each car completed each lap, and may say when it was in the pit lane, when it retired and where it was (location samples in the feed's own frame); the cars run
3
+ // between those moments at the lap's own speeds (`paceProfile`), and location samples are laid on the circuit's own line by how far round the lap they are, so the dots stay on the road. Pure: no request is made here.
4
+ import { nearestOn } from "./circuits.geo.js";
5
+ import { circuitOf, circuitPlan } from "./circuits.js";
6
+ import { lineLength } from "./circuits.geo.js";
7
+ import { paceProfile } from "./circuits.pace.js";
8
+ import { FEET_PER_METRE as FT } from "./geometry.js";
9
+ /** The most a feed may hold: cars, and rows of each list. */
10
+ export const FEED_LIMITS = Object.freeze({ cars: 40, rows: 200000, laps: 200 });
11
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
12
+ const isText = (v) => typeof v === "string" && v.length > 0 && v.length <= 80;
13
+ const COLOUR = /^#[0-9a-f]{6}$/i;
14
+ /** The colours a feed's car takes when it gives none, in turn (the made-up teams' own). */
15
+ const FALLBACK = ["#1d4ed8", "#0f766e", "#db2777", "#c026d3", "#65a30d", "#0ea5e9", "#b91c1c", "#94a3b8", "#ca8a04", "#7c3aed"];
16
+ /**
17
+ * Read a feed strictly: anything that is not the open race timeline is refused with a sentence that says why, and anything a feed carries that the format does not name is left behind. Text is kept as text (never markup).
18
+ * @example
19
+ * ```ts
20
+ * import { readRaceFeed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
21
+ * const read = readRaceFeed({ kind: "kyogijo-race", version: 1, cars: [{ id: "a" }], crossings: [{ car: "a", lap: 1, t: 101 }] });
22
+ * console.log(read.ok, readRaceFeed({ kind: "something" }).ok);
23
+ * // true false
24
+ * ```
25
+ */
26
+ export function readRaceFeed(input) {
27
+ if (typeof input !== "object" || input === null || Array.isArray(input))
28
+ return { ok: false, error: "A race feed is a JSON object." };
29
+ const o = input;
30
+ if (o.kind !== "kyogijo-race")
31
+ return { ok: false, error: 'This is not a race feed: its "kind" is not "kyogijo-race".' };
32
+ if (o.version !== 1)
33
+ return { ok: false, error: "This race feed is of a version this package does not read (it reads version 1)." };
34
+ if (!Array.isArray(o.cars) || o.cars.length === 0)
35
+ return { ok: false, error: "The race feed has no cars." };
36
+ if (o.cars.length > FEED_LIMITS.cars)
37
+ return { ok: false, error: `The race feed has more than ${FEED_LIMITS.cars} cars.` };
38
+ const cars = [];
39
+ const ids = new Set();
40
+ for (const raw of o.cars) {
41
+ const c = (typeof raw === "object" && raw !== null ? raw : {});
42
+ const id = typeof c.id === "number" ? String(c.id) : c.id;
43
+ if (!isText(id) || ids.has(id))
44
+ return { ok: false, error: "Each car of the race feed needs an id of its own." };
45
+ ids.add(id);
46
+ cars.push({
47
+ id,
48
+ ...(isText(c.code) ? { code: c.code.slice(0, 4) } : {}),
49
+ ...(isText(c.name) ? { name: c.name } : {}),
50
+ ...(isText(c.team) ? { team: c.team } : {}),
51
+ ...(typeof c.colour === "string" && COLOUR.test(c.colour) ? { colour: c.colour } : {}),
52
+ ...(isNum(c.grid) && c.grid >= 1 ? { grid: Math.round(c.grid) } : {}),
53
+ });
54
+ }
55
+ const rows = (name, keep) => {
56
+ const list = o[name];
57
+ if (list === undefined)
58
+ return [];
59
+ if (!Array.isArray(list))
60
+ return `The race feed's "${name}" is not a list.`;
61
+ if (list.length > FEED_LIMITS.rows)
62
+ return `The race feed's "${name}" has more than ${FEED_LIMITS.rows} rows.`;
63
+ const out = [];
64
+ for (const r of list) {
65
+ if (typeof r !== "object" || r === null)
66
+ continue;
67
+ const row = r;
68
+ const car = typeof row.car === "number" ? String(row.car) : row.car;
69
+ if (typeof car !== "string" || !ids.has(car))
70
+ continue;
71
+ const kept = keep({ ...row, car });
72
+ if (kept !== undefined)
73
+ out.push(kept);
74
+ }
75
+ return out;
76
+ };
77
+ const crossings = rows("crossings", (r) => (isNum(r.lap) && r.lap >= 1 && r.lap <= FEED_LIMITS.laps && isNum(r.t) && r.t > 0 ? { car: r.car, lap: Math.round(r.lap), t: r.t } : undefined));
78
+ const pits = rows("pits", (r) => (isNum(r.in) && isNum(r.out) && r.out > r.in ? { car: r.car, in: r.in, out: r.out, ...(isNum(r.stop) && r.stop >= 0 ? { stop: r.stop } : {}) } : undefined));
79
+ const out = rows("out", (r) => (isNum(r.t) && r.t >= 0 ? { car: r.car, t: r.t } : undefined));
80
+ const positions = rows("positions", (r) => (isNum(r.t) && isNum(r.x) && isNum(r.y) ? { car: r.car, t: r.t, x: r.x, y: r.y } : undefined));
81
+ for (const list of [crossings, pits, out, positions])
82
+ if (typeof list === "string")
83
+ return { ok: false, error: list };
84
+ if (crossings.length === 0)
85
+ return { ok: false, error: "The race feed has no lap times: it needs at least one car's completed lap." };
86
+ return {
87
+ ok: true,
88
+ feed: {
89
+ kind: "kyogijo-race",
90
+ version: 1,
91
+ ...(isText(o.circuit) ? { circuit: o.circuit } : {}),
92
+ ...(isNum(o.laps) && o.laps >= 1 && o.laps <= FEED_LIMITS.laps ? { laps: Math.round(o.laps) } : {}),
93
+ ...(typeof o.title === "string" && o.title.length <= 200 ? { title: o.title } : {}),
94
+ cars,
95
+ crossings: crossings,
96
+ ...(pits.length > 0 ? { pits: pits } : {}),
97
+ ...(out.length > 0 ? { out: out } : {}),
98
+ ...(positions.length > 0 ? { positions: positions } : {}),
99
+ ...(o.live === true ? { live: true } : {}),
100
+ },
101
+ };
102
+ }
103
+ /** The colour of what is written on a colour: dark on a light one, white on a dark one. */
104
+ const inkOn = (hex) => {
105
+ const n = Number.parseInt(hex.slice(1), 16);
106
+ const lum = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255);
107
+ return lum > 150 ? "#10151b" : "#ffffff";
108
+ };
109
+ const mod = (a, n) => ((a % n) + n) % n;
110
+ /** Where the location samples of a car lie round the lap, as shares of it from the timing line: the feed's own line is the path of the car with the most samples over its second lap. */
111
+ function lapShares(feed, crossed) {
112
+ const out = new Map();
113
+ const positions = feed.positions ?? [];
114
+ if (positions.length === 0)
115
+ return out;
116
+ const byCar = new Map();
117
+ for (const p of positions) {
118
+ let list = byCar.get(p.car);
119
+ if (list === undefined)
120
+ byCar.set(p.car, (list = []));
121
+ list.push({ t: p.t, at: [p.x, p.y] });
122
+ }
123
+ for (const list of byCar.values())
124
+ list.sort((a, b) => a.t - b.t);
125
+ // The reference: a lap of one car, from one crossing to the next.
126
+ let ref = [];
127
+ let refStart;
128
+ for (const [car, list] of byCar) {
129
+ const cr = crossed.get(car) ?? [];
130
+ for (let k = 1; k < cr.length; k += 1) {
131
+ const lap = list.filter((s) => s.t >= cr[k - 1] && s.t <= cr[k]);
132
+ if (lap.length > ref.length) {
133
+ ref = lap.map((s) => s.at);
134
+ refStart = lap[0].at;
135
+ }
136
+ }
137
+ }
138
+ if (ref.length < 20 || refStart === undefined)
139
+ return out;
140
+ const total = lineLength(ref, true);
141
+ const zero = nearestOn(ref, refStart, true).along;
142
+ for (const [car, list] of byCar)
143
+ out.set(car, list.map((s) => ({ t: s.t, share: mod(nearestOn(ref, s.at, true).along - zero, total) / total })));
144
+ return out;
145
+ }
146
+ /**
147
+ * A feed as a race (`CircuitRace`): the same shape a simulation gives, so that `raceAt`, `drawRace`, `raceBoard` and `mountCircuitRace` play it. The circuit is the feed's, or the one named here (a page draws a feed on the
148
+ * circuit it shows). Between the moments a feed gives, the cars run at the lap's own speeds.
149
+ * @example
150
+ * ```ts
151
+ * import { raceAt, raceFromFeed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
152
+ * const race = raceFromFeed({ kind: "kyogijo-race", version: 1, cars: [{ id: "a", code: "AAA" }, { id: "b", code: "BBB" }], crossings: [{ car: "a", lap: 1, t: 101 }, { car: "b", lap: 1, t: 102.5 }, { car: "a", lap: 2, t: 198 }, { car: "b", lap: 2, t: 200 }] }, "las-vegas");
153
+ * const end = raceAt(race, race.duration);
154
+ * console.log(race.laps, end.standings[0].car.driver.code, end.standings[1].gap);
155
+ * // 2 AAA 2
156
+ * ```
157
+ */
158
+ export function raceFromFeed(feed, circuitId) {
159
+ const circuit = circuitOf(circuitId ?? feed.circuit ?? "las-vegas");
160
+ const plan = circuitPlan(circuit);
161
+ const pace = paceProfile(plan);
162
+ const L = pace.length;
163
+ const lineAlong = plan.finish.along / FT;
164
+ const entry = nearestOn(plan.lap, plan.pitLane[0], true).along / FT;
165
+ const exit = nearestOn(plan.lap, plan.pitLane[plan.pitLane.length - 1], true).along / FT;
166
+ const pitLength = lineLength(plan.pitLane, false) / FT;
167
+ const bypass = mod(exit - entry, L);
168
+ let ahead = mod(plan.start.along / FT - lineAlong, L);
169
+ if (ahead > L / 2)
170
+ ahead -= L;
171
+ const order = [...feed.cars].map((c, i) => ({ c, i })).sort((a, b) => (a.c.grid ?? a.i + 1) - (b.c.grid ?? b.i + 1));
172
+ const cars = order.map(({ c, i }, place) => {
173
+ const colour = c.colour ?? FALLBACK[i % FALLBACK.length];
174
+ const team = { id: `feed-${c.team ?? c.id}`, name: { en: c.team ?? "", ja: c.team ?? "" }, colour, ink: inkOn(colour) };
175
+ const driver = { code: c.code ?? c.id.slice(0, 4).toUpperCase(), name: { en: c.name ?? c.code ?? c.id, ja: c.name ?? c.code ?? c.id }, team: team.id };
176
+ return { id: c.id, driver, team, grid: place + 1 };
177
+ });
178
+ const crossed = new Map(cars.map((c) => [c.id, []]));
179
+ for (const row of [...feed.crossings].sort((a, b) => a.lap - b.lap || a.t - b.t)) {
180
+ const list = crossed.get(row.car);
181
+ // Lap n is the n-th crossing: a lap the feed skipped is left as the feed has it, and a crossing that goes back in time is dropped.
182
+ if (list.length < row.lap && (list.length === 0 || row.t > list[list.length - 1]))
183
+ list.push(row.t);
184
+ }
185
+ const maxLaps = Math.max(...[...crossed.values()].map((l) => l.length));
186
+ const laps = feed.live === true ? Math.max(feed.laps ?? 0, maxLaps) : Math.min(feed.laps ?? maxLaps, Math.max(maxLaps, 1));
187
+ const outAt = new Map((feed.out ?? []).map((o) => [o.car, o.t]));
188
+ const lastSeen = Math.max(...feed.crossings.map((c) => c.t), ...(feed.out ?? []).map((o) => o.t), ...(feed.pits ?? []).map((p) => p.out));
189
+ // The flag: the first car to complete the race's laps, when the feed says the race has its end.
190
+ const leaderAt = [...crossed.values()].map((l) => l[laps - 1]).filter((t) => t !== undefined);
191
+ const flag = feed.live === true || leaderAt.length === 0 ? Number.NaN : Math.min(...leaderAt);
192
+ const duration = Math.ceil(lastSeen + (Number.isNaN(flag) ? 0 : 20));
193
+ const size = duration + 1;
194
+ const shares = lapShares(feed, crossed);
195
+ // The lap's own time to each place round it, from the timing line: the shape of the run between two moments.
196
+ const timeTo = (s) => {
197
+ const whole = Math.floor(s / L);
198
+ const along = mod(lineAlong + s, L);
199
+ const at = (x) => {
200
+ const i = Math.min(Math.floor(x / pace.step), pace.speed.length - 1);
201
+ const f = x / pace.step - i;
202
+ return pace.time[i] + f * (pace.time[i + 1] - pace.time[i]);
203
+ };
204
+ const fromLine = mod(at(along) - at(mod(lineAlong, L)), pace.lap);
205
+ return whole * pace.lap + fromLine;
206
+ };
207
+ /** The race distance between `a` (at `ta`) and `b` (at `tb`) at a time, at the lap's speeds. */
208
+ const between = (a, b, ta, tb, t) => {
209
+ if (tb <= ta || b <= a)
210
+ return a;
211
+ const target = timeTo(a) + ((t - ta) / (tb - ta)) * (timeTo(b) - timeTo(a));
212
+ let lo = a;
213
+ let hi = b;
214
+ for (let i = 0; i < 30; i += 1) {
215
+ const mid = (lo + hi) / 2;
216
+ if (timeTo(mid) < target)
217
+ lo = mid;
218
+ else
219
+ hi = mid;
220
+ }
221
+ return (lo + hi) / 2;
222
+ };
223
+ const distance = cars.map(() => new Float64Array(size));
224
+ const pit = cars.map(() => new Float32Array(size).fill(Number.NaN));
225
+ const stops = cars.map(() => []);
226
+ const finish = cars.map(() => undefined);
227
+ const out = cars.map(() => undefined);
228
+ cars.forEach((car, c) => {
229
+ const cr = crossed.get(car.id);
230
+ const start = ahead - 8 * (car.grid - 1);
231
+ // The moments of this car: the start, every crossing, every location sample, and the ends of the pit lane.
232
+ const points = [{ t: 0, s: start }, ...cr.map((t, k) => ({ t, s: (k + 1) * L }))];
233
+ const lapsAt = (t) => cr.filter((x) => x <= t).length;
234
+ for (const sample of shares.get(car.id) ?? []) {
235
+ const k = lapsAt(sample.t);
236
+ let s = k * L + sample.share * L;
237
+ // A sample just after a crossing whose share is still near the end of the lap belongs to the lap before.
238
+ if (sample.share > 0.9 && k > 0 && sample.t - cr[k - 1] < 10)
239
+ s -= L;
240
+ if (s > start)
241
+ points.push({ t: sample.t, s });
242
+ }
243
+ const pits = (feed.pits ?? []).filter((p) => p.car === car.id).sort((a, b) => a.in - b.in);
244
+ for (const p of pits) {
245
+ const k = lapsAt(p.in);
246
+ const sIn = k * L + mod(entry - lineAlong, L);
247
+ points.push({ t: p.in, s: sIn, pitTo: p.out }, { t: p.out, s: sIn + bypass });
248
+ stops[c].push({ lap: k + 1, in: p.in, out: p.out, stop: p.stop ?? 0 });
249
+ }
250
+ // A car still running when the feed ends goes on round at its last lap's pace, so that a race that is going on does not hold its dots on the line.
251
+ const retired0 = outAt.get(car.id);
252
+ const done = !Number.isNaN(flag) && cr.some((t) => t >= flag);
253
+ if (retired0 === undefined && !done && cr.length > 0) {
254
+ const lastLap = cr.length > 1 ? cr[cr.length - 1] - cr[cr.length - 2] : pace.lap;
255
+ points.push({ t: cr[cr.length - 1] + lastLap, s: (cr.length + 1) * L });
256
+ }
257
+ points.sort((a, b) => a.t - b.t);
258
+ // Distance never goes back.
259
+ for (let i = 1; i < points.length; i += 1)
260
+ if (points[i].s < points[i - 1].s)
261
+ points[i].s = points[i - 1].s;
262
+ const retired = outAt.get(car.id);
263
+ if (retired !== undefined)
264
+ out[c] = retired;
265
+ if (!Number.isNaN(flag))
266
+ finish[c] = cr.find((t) => t >= flag);
267
+ let j = 0;
268
+ for (let second = 0; second < size; second += 1) {
269
+ const t = second;
270
+ while (j + 1 < points.length && points[j + 1].t <= t)
271
+ j += 1;
272
+ const a = points[j];
273
+ const b = points[j + 1];
274
+ const pitWindow = a.pitTo !== undefined && t < a.pitTo;
275
+ if (pitWindow) {
276
+ const f = (t - a.t) / (a.pitTo - a.t);
277
+ pit[c][second] = f * pitLength;
278
+ distance[c][second] = a.s + f * bypass;
279
+ }
280
+ else if (b === undefined || (retired !== undefined && t >= retired)) {
281
+ distance[c][second] = second === 0 ? a.s : distance[c][second - 1];
282
+ }
283
+ else
284
+ distance[c][second] = between(a.s, b.s, a.t, b.t, t);
285
+ }
286
+ });
287
+ return Object.freeze({
288
+ circuit,
289
+ plan,
290
+ seed: 0,
291
+ laps,
292
+ cars: Object.freeze(cars),
293
+ lapLength: L,
294
+ lineAlong,
295
+ pitLane: { entry, exit, length: pitLength },
296
+ pace,
297
+ sample: 1,
298
+ flag: Number.isNaN(flag) ? duration + 1 : flag,
299
+ duration,
300
+ distance,
301
+ pit,
302
+ crossings: cars.map((car) => crossed.get(car.id)),
303
+ stops,
304
+ finish,
305
+ out,
306
+ });
307
+ }
308
+ /**
309
+ * A race as the open race timeline: what a simulated race is, written as a feed (the demo's sample feed is one). Lap times are rounded to thousandths.
310
+ * @example
311
+ * ```ts
312
+ * import { raceToFeed, simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
313
+ * const feed = raceToFeed(simulateRace({ circuit: "las-vegas", laps: 3, cars: 4 }));
314
+ * console.log(feed.kind, feed.cars.length, feed.crossings.length);
315
+ * // kyogijo-race 4 12
316
+ * ```
317
+ */
318
+ export function raceToFeed(race, title) {
319
+ const r3 = (n) => Math.round(n * 1000) / 1000;
320
+ const feed = {
321
+ kind: "kyogijo-race",
322
+ version: 1,
323
+ circuit: race.circuit.id,
324
+ laps: race.laps,
325
+ ...(title === undefined ? {} : { title }),
326
+ cars: race.cars.map((c) => ({ id: c.id, code: c.driver.code, name: c.driver.name.en, team: c.team.name.en, colour: c.team.colour, grid: c.grid })),
327
+ crossings: race.cars.flatMap((c, i) => race.crossings[i].map((t, k) => ({ car: c.id, lap: k + 1, t: r3(t) }))),
328
+ };
329
+ const pits = race.cars.flatMap((c, i) => race.stops[i].map((s) => ({ car: c.id, in: r3(s.in), out: r3(s.out), stop: r3(s.stop) })));
330
+ const out = race.cars.flatMap((c, i) => (race.out[i] === undefined ? [] : [{ car: c.id, t: r3(race.out[i]) }]));
331
+ return { ...feed, ...(pits.length > 0 ? { pits } : {}), ...(out.length > 0 ? { out } : {}) };
332
+ }
@@ -0,0 +1,61 @@
1
+ import type { RaceFeed } from "./circuits.feed.ts";
2
+ /** A lap row: who, which lap, when it started and how long it took. */
3
+ export interface OpenF1Lap {
4
+ driver_number: number;
5
+ lap_number: number;
6
+ date_start?: string | null;
7
+ lap_duration?: number | null;
8
+ }
9
+ /** A pit row: who, on which lap, when it entered the pit lane and how long it was in it and standing. */
10
+ export interface OpenF1Pit {
11
+ driver_number: number;
12
+ lap_number?: number;
13
+ date: string;
14
+ pit_duration?: number | null;
15
+ lane_duration?: number | null;
16
+ stop_duration?: number | null;
17
+ }
18
+ /** A driver row: the code, the name and the team as the source gives them. */
19
+ export interface OpenF1Driver {
20
+ driver_number: number;
21
+ name_acronym?: string;
22
+ broadcast_name?: string;
23
+ last_name?: string;
24
+ team_name?: string;
25
+ team_colour?: string;
26
+ }
27
+ /** A location row: where a car was, in the source's own frame. */
28
+ export interface OpenF1Location {
29
+ driver_number: number;
30
+ date: string;
31
+ x: number;
32
+ y: number;
33
+ }
34
+ /** What the adapter is given: the rows of each answer the user's source gave. Only `laps` is needed. */
35
+ export interface OpenF1Rows {
36
+ laps: readonly OpenF1Lap[];
37
+ pit?: readonly OpenF1Pit[];
38
+ drivers?: readonly OpenF1Driver[];
39
+ location?: readonly OpenF1Location[];
40
+ /** The laps of the race, if known. */
41
+ totalLaps?: number;
42
+ /** True while the session is going on. */
43
+ live?: boolean;
44
+ }
45
+ /** Whether a list looks like OpenF1's lap rows: objects with a driver number and a lap number. */
46
+ export declare const looksLikeOpenF1Laps: (input: unknown) => input is OpenF1Lap[];
47
+ /**
48
+ * OpenF1-style rows as a race feed. The start is the earliest start of a first lap; a lap is completed at its start plus its duration (or, where the duration is missing, at the next lap's start). A pit stop runs from
49
+ * its row's time for its lane duration. Location rows, when given, become the feed's positions.
50
+ * @example
51
+ * ```ts
52
+ * import { openF1Feed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
53
+ * const feed = openF1Feed({ laps: [
54
+ * { driver_number: 7, lap_number: 1, date_start: "2030-01-01T20:00:00Z", lap_duration: 101 },
55
+ * { driver_number: 7, lap_number: 2, date_start: "2030-01-01T20:01:41Z", lap_duration: 97.5 },
56
+ * ], drivers: [{ driver_number: 7, name_acronym: "ABC", team_name: "Made-up Team", team_colour: "3366CC" }] });
57
+ * console.log(feed.cars[0].code, feed.cars[0].colour, feed.crossings.map((c) => c.t).join(" "));
58
+ * // ABC #3366cc 101 198.5
59
+ * ```
60
+ */
61
+ export declare function openF1Feed(rows: OpenF1Rows): RaceFeed;
@@ -0,0 +1,65 @@
1
+ const ms = (date) => (typeof date === "string" ? Date.parse(date) : Number.NaN);
2
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
3
+ /** Whether a list looks like OpenF1's lap rows: objects with a driver number and a lap number. */
4
+ export const looksLikeOpenF1Laps = (input) => Array.isArray(input) && input.length > 0 && input.slice(0, 5).every((r) => typeof r === "object" && r !== null && isNum(r.driver_number) && isNum(r.lap_number));
5
+ /**
6
+ * OpenF1-style rows as a race feed. The start is the earliest start of a first lap; a lap is completed at its start plus its duration (or, where the duration is missing, at the next lap's start). A pit stop runs from
7
+ * its row's time for its lane duration. Location rows, when given, become the feed's positions.
8
+ * @example
9
+ * ```ts
10
+ * import { openF1Feed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
11
+ * const feed = openF1Feed({ laps: [
12
+ * { driver_number: 7, lap_number: 1, date_start: "2030-01-01T20:00:00Z", lap_duration: 101 },
13
+ * { driver_number: 7, lap_number: 2, date_start: "2030-01-01T20:01:41Z", lap_duration: 97.5 },
14
+ * ], drivers: [{ driver_number: 7, name_acronym: "ABC", team_name: "Made-up Team", team_colour: "3366CC" }] });
15
+ * console.log(feed.cars[0].code, feed.cars[0].colour, feed.crossings.map((c) => c.t).join(" "));
16
+ * // ABC #3366cc 101 198.5
17
+ * ```
18
+ */
19
+ export function openF1Feed(rows) {
20
+ const laps = rows.laps.filter((l) => isNum(l.driver_number) && isNum(l.lap_number));
21
+ const firsts = laps.filter((l) => l.lap_number === 1).map((l) => ms(l.date_start)).filter(Number.isFinite);
22
+ const zero = firsts.length > 0 ? Math.min(...firsts) : Math.min(...laps.map((l) => ms(l.date_start)).filter(Number.isFinite));
23
+ const at = (date) => (ms(date) - zero) / 1000;
24
+ const numbers = [...new Set(laps.map((l) => l.driver_number))];
25
+ const drivers = new Map((rows.drivers ?? []).map((d) => [d.driver_number, d]));
26
+ const cars = numbers.map((n) => {
27
+ const d = drivers.get(n);
28
+ const colour = typeof d?.team_colour === "string" && /^[0-9a-f]{6}$/i.test(d.team_colour) ? `#${d.team_colour.toLowerCase()}` : undefined;
29
+ return {
30
+ id: String(n),
31
+ code: (d?.name_acronym ?? String(n)).slice(0, 4),
32
+ name: d?.last_name ?? d?.broadcast_name ?? String(n),
33
+ ...(d?.team_name === undefined ? {} : { team: d.team_name }),
34
+ ...(colour === undefined ? {} : { colour }),
35
+ };
36
+ });
37
+ const crossings = [];
38
+ for (const n of numbers) {
39
+ const mine = laps.filter((l) => l.driver_number === n).sort((a, b) => a.lap_number - b.lap_number);
40
+ mine.forEach((l, i) => {
41
+ const next = mine[i + 1];
42
+ const end = isNum(l.lap_duration) && Number.isFinite(at(l.date_start)) ? at(l.date_start) + l.lap_duration : next !== undefined && next.lap_number === l.lap_number + 1 ? at(next.date_start) : Number.NaN;
43
+ if (Number.isFinite(end) && end > 0)
44
+ crossings.push({ car: String(n), lap: l.lap_number, t: Math.round(end * 1000) / 1000 });
45
+ });
46
+ }
47
+ const pits = (rows.pit ?? [])
48
+ .filter((p) => isNum(p.driver_number) && Number.isFinite(at(p.date)))
49
+ .map((p) => {
50
+ const lane = [p.lane_duration, p.pit_duration].find(isNum) ?? 20;
51
+ return { car: String(p.driver_number), in: at(p.date), out: at(p.date) + lane, ...(isNum(p.stop_duration) ? { stop: p.stop_duration } : {}) };
52
+ })
53
+ .filter((p) => p.in >= 0);
54
+ const positions = (rows.location ?? []).filter((p) => isNum(p.driver_number) && isNum(p.x) && isNum(p.y) && Number.isFinite(at(p.date))).map((p) => ({ car: String(p.driver_number), t: at(p.date), x: p.x, y: p.y }));
55
+ return {
56
+ kind: "kyogijo-race",
57
+ version: 1,
58
+ ...(isNum(rows.totalLaps) ? { laps: rows.totalLaps } : {}),
59
+ cars,
60
+ crossings,
61
+ ...(pits.length > 0 ? { pits } : {}),
62
+ ...(positions.length > 0 ? { positions } : {}),
63
+ ...(rows.live === true ? { live: true } : {}),
64
+ };
65
+ }
@@ -0,0 +1,96 @@
1
+ import type { Pt } from "./crowd.ts";
2
+ import type { CircuitPlan } from "./circuits.ts";
3
+ /** The figures of the model, in metres and seconds. */
4
+ export declare const RACE_PACE: Readonly<{
5
+ /** The fastest the car goes on a straight: 94 m/s, about 338 km/h. */
6
+ top: 94;
7
+ /** Grip across the car: `grip + downforce × v²` metres a second squared at a speed `v`, about 2.4 g in a hairpin and 5 g in a fast bend. */
8
+ grip: 22;
9
+ downforce: 0.0036;
10
+ /** How hard it brakes, and how hard it pulls away at rest; the pull falls to nothing at `top`. */
11
+ brake: 34;
12
+ launch: 17;
13
+ /** The line is read every `step` metres, and a bend is measured over `reach` either side: a racing car does not follow the middle of the road, so a short kink of it is not a bend. */
14
+ step: 5;
15
+ reach: 32;
16
+ /** The speed limit of the pit lane: 80 km/h. */
17
+ pitLimit: number;
18
+ }>;
19
+ /** A lap's speeds: one each `step` metres round the line from its first point, the time at each, and the lap's time and length. */
20
+ export interface PaceProfile {
21
+ /** Metres between two samples. */
22
+ step: number;
23
+ /** The length of the line, in metres. */
24
+ length: number;
25
+ /** The speed at each sample, metres a second, and the seconds from the line's first point to each (one more than the speeds: the last is the lap's time). */
26
+ speed: Float64Array;
27
+ time: Float64Array;
28
+ /** The time of a flying lap, in seconds. */
29
+ lap: number;
30
+ }
31
+ /** The cumulative length of a ring at each of its points (the first 0, one more than the points: the last is the whole length). */
32
+ export declare function ringLengths(points: readonly Pt[]): Float64Array;
33
+ /** The point of a ring `along` units round it from its first point (wrapped), from its lengths (`ringLengths`): found by halving, so a page can ask for twenty a frame. */
34
+ export declare function ringPoint(points: readonly Pt[], lengths: Float64Array, along: number): Pt;
35
+ /** The same for an open line: held to its ends. */
36
+ export declare function linePoint(points: readonly Pt[], lengths: Float64Array, along: number): Pt;
37
+ /** The fastest speed a bend of radius `r` metres allows: `v² = r × (grip + downforce × v²)`, and the top speed where the downforce holds it at any speed. */
38
+ export declare function cornerSpeed(r: number, pace?: Readonly<{
39
+ /** The fastest the car goes on a straight: 94 m/s, about 338 km/h. */
40
+ top: 94;
41
+ /** Grip across the car: `grip + downforce × v²` metres a second squared at a speed `v`, about 2.4 g in a hairpin and 5 g in a fast bend. */
42
+ grip: 22;
43
+ downforce: 0.0036;
44
+ /** How hard it brakes, and how hard it pulls away at rest; the pull falls to nothing at `top`. */
45
+ brake: 34;
46
+ launch: 17;
47
+ /** The line is read every `step` metres, and a bend is measured over `reach` either side: a racing car does not follow the middle of the road, so a short kink of it is not a bend. */
48
+ step: 5;
49
+ reach: 32;
50
+ /** The speed limit of the pit lane: 80 km/h. */
51
+ pitLimit: number;
52
+ }>): number;
53
+ /** How hard the car can pull away at a speed: `launch` at rest, nothing at the top speed. */
54
+ export declare const pullAt: (v: number, pace?: Readonly<{
55
+ /** The fastest the car goes on a straight: 94 m/s, about 338 km/h. */
56
+ top: 94;
57
+ /** Grip across the car: `grip + downforce × v²` metres a second squared at a speed `v`, about 2.4 g in a hairpin and 5 g in a fast bend. */
58
+ grip: 22;
59
+ downforce: 0.0036;
60
+ /** How hard it brakes, and how hard it pulls away at rest; the pull falls to nothing at `top`. */
61
+ brake: 34;
62
+ launch: 17;
63
+ /** The line is read every `step` metres, and a bend is measured over `reach` either side: a racing car does not follow the middle of the road, so a short kink of it is not a bend. */
64
+ step: 5;
65
+ reach: 32;
66
+ /** The speed limit of the pit lane: 80 km/h. */
67
+ pitLimit: number;
68
+ }>) => number;
69
+ /**
70
+ * A plan's speeds round its lap (`circuitPlan`), from its line alone: the bends' radii give each sample's fastest speed, and two passes round (twice, so that the lap closes on itself) hold it to the engine and the
71
+ * brakes. The same plan always gives the same profile.
72
+ * @example
73
+ * ```ts
74
+ * import { circuitById, circuitPlan, paceProfile } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
75
+ * const pace = paceProfile(circuitPlan(circuitById("las-vegas")!));
76
+ * console.log(pace.lap > 90 && pace.lap < 105, Math.max(...pace.speed) * 3.6 > 300);
77
+ * // true true
78
+ * ```
79
+ */
80
+ export declare function paceProfile(plan: CircuitPlan, pace?: Readonly<{
81
+ /** The fastest the car goes on a straight: 94 m/s, about 338 km/h. */
82
+ top: 94;
83
+ /** Grip across the car: `grip + downforce × v²` metres a second squared at a speed `v`, about 2.4 g in a hairpin and 5 g in a fast bend. */
84
+ grip: 22;
85
+ downforce: 0.0036;
86
+ /** How hard it brakes, and how hard it pulls away at rest; the pull falls to nothing at `top`. */
87
+ brake: 34;
88
+ launch: 17;
89
+ /** The line is read every `step` metres, and a bend is measured over `reach` either side: a racing car does not follow the middle of the road, so a short kink of it is not a bend. */
90
+ step: 5;
91
+ reach: 32;
92
+ /** The speed limit of the pit lane: 80 km/h. */
93
+ pitLimit: number;
94
+ }>): PaceProfile;
95
+ /** The speed at `along` metres round the lap from the line's first point (wrapped), between the two samples either side. */
96
+ export declare function speedAt(profile: PaceProfile, along: number): number;