@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ All notable changes to this project are written here. The format follows
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.56.0] - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - **Bring your own race feed (KYOGIJO-STADIUMS-38)** ([the race feed](./docs/race-feed.md), [where to find a feed](./docs/where-to-find-a-feed.md)): a race on a circuit plays from a feed the reader chooses instead of the simulation. kyogijo's open race timeline (`kind: "kyogijo-race"`: cars, lap crossings, pit stops, retirements, location samples, `live`) is read strictly by `readRaceFeed` and played by `raceFromFeed` (between the times a feed gives, the cars run at the lap's own speeds; location samples are laid on the circuit's line by how far round the lap they are); `openF1Feed` adapts OpenF1-style lap, pit and driver rows. `loadRaceFeed` reads an address with the reader's own browser (no server or proxy of ours, nothing kept) and says in words when a source will not let a page read it (CORS), refuses (403) or is busy (429); `feedFromText` reads pasted text or a file; `followRaceFeed` asks a live race again every 15 seconds, only while the page is visible, and stops when it is over or refused; `mountCircuitRace` takes `race` and `replace`. `raceToFeed` writes any race in the format: the demo's **Sample feed (made up)** (`examples/races/las-vegas-sample.json`) is the simulated race so written, listed first among the suggestions of `examples/feeds/where-to-find.json` (OpenF1, Jolpica F1), which the circuits page shows beside the address field with what each gives, how to find a race and its docs and terms. Every test runs on made-up stubs; none makes a real request.
15
+
16
+ ### Fixed
17
+
18
+ - A 3D view that goes asks for no frame after it: the measure of the display a view takes in its first moments is stopped on destroy (a view destroyed then asked for one more frame, seen in WebKit when Play a game stopped on another surface), and the frame the auto-turn, a replay and a race share is cleared, a request made in the same tick included (`src/turn.frames.ts`, with unit tests).
19
+ - The docs of the race and of the race feed link straight into the live demo (`circuits.html?race=1`, `circuits.html?race=1&feed=sample`).
20
+
21
+ ## [0.55.0] - 2026-11-03
22
+
23
+ ### Added
24
+
25
+ - **A simulated race on a circuit (KYOGIJO-STADIUMS-32)** ([a race](./docs/circuits.md#a-race)): twenty made-up cars as dots from the grid to the chequered flag over a full race distance (50 laps of Las Vegas, about an hour and twenty-two minutes), with gaps, passes, trains, one or two pit stops each and now and then a retirement, all from a seed. `simulateRace` works it out (the lap's speeds from the bends, `paceProfile`), `raceAt` gives any moment (the order, the gaps to the leader and the car in front, the last and best laps, the stops), `drawRace` and `raceBoard` draw it, and `mountCircuitRace` plays it in a page, flat or in 3D (the cars standing as discs), with a race clock, play, 1×, 10× and 60×, a slider over the whole race and the timing board, in English and Japanese. It is labelled a simulation: every team, driver, code and colour is invented, and no real race is in it; the model's figures were guided by public timing data read once and kept outside the package. Nothing runs while it is paused, hidden or out of sight, the 3D view's auto-turn and the race share one frame, and destroy leaves nothing. The demo's circuits page has a **Race** button, kept in the address with the speed and the moment.
26
+
10
27
  ## [0.54.0] - 2026-11-03
11
28
 
12
29
  ### Added
package/README.md CHANGED
@@ -114,6 +114,13 @@ Real buildings drawn in the outline OpenStreetMap maps for them, each with a pla
114
114
  </picture>
115
115
  <br><em>The same lap in 3D</em>
116
116
  </td>
117
+ <td align="center" valign="top">
118
+ <picture>
119
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kyogijo-stadiums/main/docs/images/circuits-race-desk-dark.webp">
120
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/kyogijo-stadiums/main/docs/images/circuits-race-desk-light.webp" alt="A simulated race on the same circuit half an hour in: twenty coloured dots with their positions, the race clock at Lap 18 of 50 and the timing board under it." width="250">
121
+ </picture>
122
+ <br><em>A simulated race, with its clock and timing board</em>
123
+ </td>
117
124
  </tr>
118
125
  </table>
119
126
 
@@ -247,7 +254,8 @@ console.log(svg.startsWith("<svg"), svg.includes("USA 3, Canada 2"));
247
254
  - **A catalogue of venues**: every venue drawn today, the ones being built and the ones wanted (every NBA, MLB, NFL, CFL and NHL venue of the season, world soccer, cricket, rugby, the Olympics, Formula One and more), with ids, names in English and Japanese and Wikidata items, as a [JSON file](https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json) and a typed entry. [The catalogue](./docs/catalogue.md).
248
255
  - **Real venues**: arenas and stadiums in the outline of their buildings, districts of several venues on one plan, the crowd in the stands and outside. [Ballparks](./docs/ballparks.md), [venues](./docs/venues.md), [districts](./docs/district.md).
249
256
  - **A 3D view** of any surface or venue that turns by mouse, touch or keys, with the roof and the walls to switch and a quality level for every device. [The 3D view](./docs/turn.md), [quality](./docs/quality.md).
250
- - **Circuits**: a famous race track as OpenStreetMap maps it, the Las Vegas Strip Circuit first, with its turns numbered and its pit building standing in 3D. [Circuits](./docs/circuits.md).
257
+ - **Circuits**: a famous race track as OpenStreetMap maps it, the Las Vegas Strip Circuit first, with its turns numbered and its pit building standing in 3D, and a simulated race on it: twenty made-up cars over a full race distance, with a race clock, a timing board and 1×, 10× and 60×. [Circuits](./docs/circuits.md#a-race).
258
+ - **Bring your own race feed**: a race plays from a feed you choose, read by your own browser from an address, pasted or from a file, and followed while it is live: kyogijo's open race timeline or OpenF1-style lap rows, with our made-up sample feed first. [The race feed](./docs/race-feed.md), [Where to find a feed](./docs/where-to-find-a-feed.md).
251
259
  - **Tournaments**: several surfaces on one floor, who judges where, boards laid over a surface. [Layouts](./docs/multi.md), [judging](./docs/judging.md), [boards](./docs/boards.md).
252
260
  - **Data on a surface**: points, heat maps, shot and pass charts, team shapes and a replay, flat or in 3D with the players standing on the ice, in 48 providers' coordinate frames. [Layers](./docs/layers.md), [frames](./docs/frames.md), [replay](./docs/replay.md), [an example game](./docs/example-games.md).
253
261
  - **Looks and flags**: eight named looks, a flag or crest on each half, trophies and crests from Dougu Sports, flags from Hata, light and dark. [Theming](./docs/theming.md), [the pageant](./docs/pageant.md).
@@ -262,7 +270,7 @@ console.log(svg.startsWith("<svg"), svg.includes("USA 3, Canada 2"));
262
270
  | `drawKyogijo`, `drawSurface`, `drawScore`, `drawBadge` | The drawing, as a string of SVG. |
263
271
  | `SPORTS`, `PARKS`, `COURT_THEMES`, `SOCCER_PITCHES`, `GROUNDS`, `EVENTS` | The forty-three sports, the 94 ballparks, the court looks, the pitches, the 64 grounds, the 18 special games. |
264
272
  | `./venues`, `./stadiums`, `./district`, `./turn` | Real arenas and stadiums in their buildings' outlines; districts on one plan; a surface as a 3D view that turns. |
265
- | `./circuits` | Race tracks as OpenStreetMap maps them, in 2D and 3D ([demo](https://johnmorrisdotca.github.io/kyogijo-stadiums/circuits.html)). |
273
+ | `./circuits` | Race tracks as OpenStreetMap maps them, in 2D and 3D, and a simulated race on them with its clock and timing board ([demo](https://johnmorrisdotca.github.io/kyogijo-stadiums/circuits.html?race=1)). |
266
274
  | `./multi`, `./judging`, `./layouts` | Several surfaces for one event ([demo](https://johnmorrisdotca.github.io/kyogijo-stadiums/halls.html)); who judges where; small multiples and backgrounds. |
267
275
  | `./layers`, `./charts`, `./frames`, `./zones`, `./pointer`, `./replay` | Data layers, charts, the providers' frames, zones by the rules, a click as a place, tracking data played on a surface. |
268
276
  | `./athletics`, `./budo`, `./skating` | The athletics stadium, the budō (areas, dohyō, the dojo builder) and the skating rinks. |
package/catalogue.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "generatedAt": "2026-11-03",
4
4
  "package": {
5
5
  "name": "@johnmorrisdotca/kyogijo-stadiums",
6
- "version": "0.54.0"
6
+ "version": "0.56.0"
7
7
  },
8
8
  "address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
9
9
  "site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
@@ -3,7 +3,7 @@ export const CATALOGUE_DATA = {
3
3
  "generatedAt": "2026-11-03",
4
4
  "package": {
5
5
  "name": "@johnmorrisdotca/kyogijo-stadiums",
6
- "version": "0.54.0"
6
+ "version": "0.56.0"
7
7
  },
8
8
  "address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
9
9
  "site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
@@ -9,3 +9,23 @@ export { CIRCUIT_WORDS } from "./circuits.words.ts";
9
9
  export type { CircuitWords } from "./circuits.words.ts";
10
10
  export { lineLength } from "./circuits.geo.ts";
11
11
  export type { Circuit, CircuitBasis, CircuitData, CircuitFact, CircuitOptions, CircuitOsm, CircuitOsmPoint } from "./circuits.types.ts";
12
+ export { RACE_PACE, cornerSpeed, paceProfile, speedAt } from "./circuits.pace.ts";
13
+ export type { PaceProfile } from "./circuits.pace.ts";
14
+ export { RACE_DISTANCE, raceLaps, simulateRace } from "./circuits.race.ts";
15
+ export type { CircuitRace, RaceCar, RaceOptions, RaceStop } from "./circuits.race.ts";
16
+ export { carAt, raceAt, racePoint } from "./circuits.race.moment.ts";
17
+ export type { RaceMoment, RaceStanding, RaceStatus } from "./circuits.race.moment.ts";
18
+ export { RACE_STYLE, drawRace, gapText, lapTime, raceBoard, raceClock, raceDrawing, raceStage } from "./circuits.race.draw.ts";
19
+ export type { RaceDrawOptions, RaceDrawing } from "./circuits.race.draw.ts";
20
+ export { RACE_SPEEDS, mountCircuitRace } from "./circuits.race.dom.ts";
21
+ export type { KyogijoRace, RaceState, RaceViewOptions, RaceViewSettings } from "./circuits.race.dom.ts";
22
+ export { RACE_CARS_MAX, RACE_DRIVERS, RACE_TEAMS } from "./circuits.race.field.ts";
23
+ export type { RaceDriver, RaceTeam } from "./circuits.race.field.ts";
24
+ export { RACE_WORDS } from "./circuits.race.words.ts";
25
+ export type { RaceWords } from "./circuits.race.words.ts";
26
+ export { FEED_LIMITS, raceFromFeed, raceToFeed, readRaceFeed } from "./circuits.feed.ts";
27
+ export type { FeedResult, RaceFeed, RaceFeedCar } from "./circuits.feed.ts";
28
+ export { looksLikeOpenF1Laps, openF1Feed } from "./circuits.feed.openf1.ts";
29
+ export type { OpenF1Driver, OpenF1Lap, OpenF1Location, OpenF1Pit, OpenF1Rows } from "./circuits.feed.openf1.ts";
30
+ export { FEED_MAX_BYTES, FOLLOW_EVERY, RaceFeedError, feedAddress, feedFromText, followRaceFeed, loadRaceFeed, openF1Siblings } from "./circuits.feed.fetch.ts";
31
+ export type { FeedFollower, FeedProblemKind } from "./circuits.feed.fetch.ts";
@@ -7,3 +7,15 @@ export { CIRCUIT_MARKER, CIRCUIT_PLANES_MAX, CIRCUIT_PRESETS, buildCircuitTurn,
7
7
  export { KYOGIJO_CIRCUIT_STYLE } from "./circuits.style.js";
8
8
  export { CIRCUIT_WORDS } from "./circuits.words.js";
9
9
  export { lineLength } from "./circuits.geo.js";
10
+ // A simulated race on any circuit: made-up cars from the grid to the flag, its moments, its drawing and board, and its view in a page.
11
+ export { RACE_PACE, cornerSpeed, paceProfile, speedAt } from "./circuits.pace.js";
12
+ export { RACE_DISTANCE, raceLaps, simulateRace } from "./circuits.race.js";
13
+ export { carAt, raceAt, racePoint } from "./circuits.race.moment.js";
14
+ export { RACE_STYLE, drawRace, gapText, lapTime, raceBoard, raceClock, raceDrawing, raceStage } from "./circuits.race.draw.js";
15
+ export { RACE_SPEEDS, mountCircuitRace } from "./circuits.race.dom.js";
16
+ export { RACE_CARS_MAX, RACE_DRIVERS, RACE_TEAMS } from "./circuits.race.field.js";
17
+ export { RACE_WORDS } from "./circuits.race.words.js";
18
+ // A race from a feed: the open race timeline, an adapter for OpenF1-style rows, and reading a feed the user names (a URL, pasted text, a file), once or while a race goes on.
19
+ export { FEED_LIMITS, raceFromFeed, raceToFeed, readRaceFeed } from "./circuits.feed.js";
20
+ export { looksLikeOpenF1Laps, openF1Feed } from "./circuits.feed.openf1.js";
21
+ export { FEED_MAX_BYTES, FOLLOW_EVERY, RaceFeedError, feedAddress, feedFromText, followRaceFeed, loadRaceFeed, openF1Siblings } from "./circuits.feed.fetch.js";
@@ -0,0 +1,101 @@
1
+ import type { CircuitRace } from "./circuits.race.ts";
2
+ /** A car of a feed: an id the rest of the feed uses, and what the board shows of it. Everything but `id` is optional. */
3
+ export interface RaceFeedCar {
4
+ id: string;
5
+ /** A short code, as a timing board shows one: three letters. Missing, the id is used. */
6
+ code?: string;
7
+ name?: string;
8
+ team?: string;
9
+ /** The team's colour, `#rrggbb`. Missing, one of the package's is used. */
10
+ colour?: string;
11
+ /** Its place on the grid, 1 for pole. Missing, the order of `cars`. */
12
+ grid?: number;
13
+ }
14
+ /** kyogijo's open race timeline: any feed, file or program can write it. Times are seconds from the start (the lights going out). */
15
+ export interface RaceFeed {
16
+ kind: "kyogijo-race";
17
+ version: 1;
18
+ /** The circuit's key (`las-vegas`). A page may draw a feed on another circuit; the lap's shape is then the page's. */
19
+ circuit?: string;
20
+ /** The laps of the race, if the feed knows them; else the most any car has done. */
21
+ laps?: number;
22
+ /** A line about where the race comes from, shown with it. */
23
+ title?: string;
24
+ cars: RaceFeedCar[];
25
+ /** When each car completed each lap: lap 1 is the first time it crossed the line after the start. */
26
+ crossings: {
27
+ car: string;
28
+ lap: number;
29
+ t: number;
30
+ }[];
31
+ /** When a car left the track for the pit lane and joined it again, and how long it stood still. */
32
+ pits?: {
33
+ car: string;
34
+ in: number;
35
+ out: number;
36
+ stop?: number;
37
+ }[];
38
+ /** When a car retired. */
39
+ out?: {
40
+ car: string;
41
+ t: number;
42
+ }[];
43
+ /** Where a car was, in the feed's own frame (any unit, any way up): laid on the circuit's line by how far round the lap it is. */
44
+ positions?: {
45
+ car: string;
46
+ t: number;
47
+ x: number;
48
+ y: number;
49
+ }[];
50
+ /** True while the race is going on: the last lap seen is not the end. */
51
+ live?: boolean;
52
+ }
53
+ /** What reading a feed gives: the feed, or why it is not one, in words. */
54
+ export type FeedResult = {
55
+ ok: true;
56
+ feed: RaceFeed;
57
+ } | {
58
+ ok: false;
59
+ error: string;
60
+ };
61
+ /** The most a feed may hold: cars, and rows of each list. */
62
+ export declare const FEED_LIMITS: Readonly<{
63
+ cars: 40;
64
+ rows: 200000;
65
+ laps: 200;
66
+ }>;
67
+ /**
68
+ * 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).
69
+ * @example
70
+ * ```ts
71
+ * import { readRaceFeed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
72
+ * const read = readRaceFeed({ kind: "kyogijo-race", version: 1, cars: [{ id: "a" }], crossings: [{ car: "a", lap: 1, t: 101 }] });
73
+ * console.log(read.ok, readRaceFeed({ kind: "something" }).ok);
74
+ * // true false
75
+ * ```
76
+ */
77
+ export declare function readRaceFeed(input: unknown): FeedResult;
78
+ /**
79
+ * 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
80
+ * circuit it shows). Between the moments a feed gives, the cars run at the lap's own speeds.
81
+ * @example
82
+ * ```ts
83
+ * import { raceAt, raceFromFeed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
84
+ * 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");
85
+ * const end = raceAt(race, race.duration);
86
+ * console.log(race.laps, end.standings[0].car.driver.code, end.standings[1].gap);
87
+ * // 2 AAA 2
88
+ * ```
89
+ */
90
+ export declare function raceFromFeed(feed: RaceFeed, circuitId?: string): CircuitRace;
91
+ /**
92
+ * 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.
93
+ * @example
94
+ * ```ts
95
+ * import { raceToFeed, simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
96
+ * const feed = raceToFeed(simulateRace({ circuit: "las-vegas", laps: 3, cars: 4 }));
97
+ * console.log(feed.kind, feed.cars.length, feed.crossings.length);
98
+ * // kyogijo-race 4 12
99
+ * ```
100
+ */
101
+ export declare function raceToFeed(race: CircuitRace, title?: string): RaceFeed;
@@ -0,0 +1,69 @@
1
+ import { type FeedResult, type RaceFeed } from "./circuits.feed.ts";
2
+ /** Why a feed could not be read. */
3
+ export type FeedProblemKind = "address" | "blocked" | "refused" | "busy" | "http" | "big" | "json" | "format";
4
+ /** A feed that could not be read: what kind of problem, the answer's status if there was one, and a sentence. */
5
+ export declare class RaceFeedError extends Error {
6
+ readonly kind: FeedProblemKind;
7
+ readonly status?: number;
8
+ constructor(kind: FeedProblemKind, message: string, status?: number);
9
+ }
10
+ /** The most text a feed may be: 30 MB. */
11
+ export declare const FEED_MAX_BYTES = 30000000;
12
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
13
+ /**
14
+ * Read a feed from text (pasted, or a file's): kyogijo's open race timeline, or the lap rows of an OpenF1-style API. The answer is a feed, or a sentence that says why not.
15
+ * @example
16
+ * ```ts
17
+ * import { feedFromText } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
18
+ * console.log(feedFromText('{"kind":"kyogijo-race","version":1,"cars":[{"id":"a"}],"crossings":[{"car":"a","lap":1,"t":99}]}').ok, feedFromText("not json").ok);
19
+ * // true false
20
+ * ```
21
+ */
22
+ export declare function feedFromText(text: string): FeedResult;
23
+ /** An address the user typed, if it is one a page may read: http or https, nothing else. */
24
+ export declare function feedAddress(text: string, base?: string): URL | undefined;
25
+ /** For an OpenF1-style lap address (`…/laps?session_key=…`), the same source's pit and driver addresses for that session. */
26
+ export declare function openF1Siblings(url: URL): {
27
+ pit: string;
28
+ drivers: string;
29
+ } | undefined;
30
+ /**
31
+ * Read a race feed from an address the user gave, with the user's browser: kyogijo's open race timeline, or an OpenF1-style lap list (whose pit stops and drivers are then asked for from the same source and session,
32
+ * if it has them). It throws a `RaceFeedError` whose message says what went wrong in words: a source that does not let pages read it (CORS), a refusal (403), too many requests (429), not JSON, not a feed.
33
+ * @example
34
+ * ```ts no-run
35
+ * import { loadRaceFeed, raceFromFeed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
36
+ * const feed = await loadRaceFeed("https://example.org/my-race.json");
37
+ * const race = raceFromFeed(feed, "las-vegas");
38
+ * ```
39
+ */
40
+ export declare function loadRaceFeed(address: string, options?: {
41
+ fetch?: Fetch;
42
+ signal?: AbortSignal;
43
+ base?: string;
44
+ }): Promise<RaceFeed>;
45
+ /** What `followRaceFeed` gives back. */
46
+ export interface FeedFollower {
47
+ /** Stop asking. */
48
+ stop(): void;
49
+ /** Whether it is still following. */
50
+ readonly following: boolean;
51
+ }
52
+ /** How often a feed is asked again while it is followed: no faster than every 10 seconds, every 15 by default. */
53
+ export declare const FOLLOW_EVERY: Readonly<{
54
+ least: 10000;
55
+ normal: 15000;
56
+ most: 120000;
57
+ }>;
58
+ /**
59
+ * Follow a race that is going on: ask the user's address again every 15 seconds (never faster than 10), only while the page is visible, and hand each new feed to `onFeed`. It stops by itself when the feed is no longer
60
+ * live (the race is over), when the source refuses (403) or says it is busy (429), and when `stop` is called; after any other failure it waits twice as long each time, up to two minutes. A hidden page asks nothing.
61
+ */
62
+ export declare function followRaceFeed(address: string, onFeed: (feed: RaceFeed) => void, options?: {
63
+ every?: number;
64
+ fetch?: Fetch;
65
+ doc?: Document;
66
+ onError?: (error: RaceFeedError) => void;
67
+ onStop?: (why: "over" | "refused" | "stopped") => void;
68
+ }): FeedFollower;
69
+ export {};
@@ -0,0 +1,205 @@
1
+ // A race feed the user names: read from a URL by the user's own browser, from pasted text or from a file, and followed while a race is going on. Nothing goes through a server of ours (there is none), nothing is kept
2
+ // beyond the page, and nothing is sent anywhere but to the address the user typed (and, for an OpenF1-style address, to the same source's pit and driver lists of the same session). A source that does not let a page
3
+ // read it (CORS) is said to be so, in words, with the way round it: paste the answer or choose a file.
4
+ import { readRaceFeed } from "./circuits.feed.js";
5
+ import { looksLikeOpenF1Laps, openF1Feed } from "./circuits.feed.openf1.js";
6
+ /** A feed that could not be read: what kind of problem, the answer's status if there was one, and a sentence. */
7
+ export class RaceFeedError extends Error {
8
+ constructor(kind, message, status) {
9
+ super(message);
10
+ this.name = "RaceFeedError";
11
+ this.kind = kind;
12
+ if (status !== undefined)
13
+ this.status = status;
14
+ }
15
+ }
16
+ /** The most text a feed may be: 30 MB. */
17
+ export const FEED_MAX_BYTES = 30000000;
18
+ /**
19
+ * Read a feed from text (pasted, or a file's): kyogijo's open race timeline, or the lap rows of an OpenF1-style API. The answer is a feed, or a sentence that says why not.
20
+ * @example
21
+ * ```ts
22
+ * import { feedFromText } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
23
+ * console.log(feedFromText('{"kind":"kyogijo-race","version":1,"cars":[{"id":"a"}],"crossings":[{"car":"a","lap":1,"t":99}]}').ok, feedFromText("not json").ok);
24
+ * // true false
25
+ * ```
26
+ */
27
+ export function feedFromText(text) {
28
+ if (text.length > FEED_MAX_BYTES)
29
+ return { ok: false, error: "This is too big to be a race feed (over 30 MB)." };
30
+ let data;
31
+ try {
32
+ data = JSON.parse(text);
33
+ }
34
+ catch {
35
+ return { ok: false, error: "This is not JSON." };
36
+ }
37
+ if (looksLikeOpenF1Laps(data))
38
+ return { ok: true, feed: openF1Feed({ laps: data }) };
39
+ return readRaceFeed(data);
40
+ }
41
+ /** An address the user typed, if it is one a page may read: http or https, nothing else. */
42
+ export function feedAddress(text, base) {
43
+ try {
44
+ const url = new URL(text.trim(), base);
45
+ return url.protocol === "https:" || url.protocol === "http:" ? url : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ async function getJson(fetcher, url, signal) {
52
+ let answer;
53
+ try {
54
+ // No cookies, no referrer: the request carries nothing of the page's.
55
+ answer = await fetcher(url, { signal, credentials: "omit", referrerPolicy: "no-referrer", cache: "no-store" });
56
+ }
57
+ catch (error) {
58
+ if (error?.name === "AbortError")
59
+ throw error;
60
+ throw new RaceFeedError("blocked", "The browser could not read this address: the source may not let pages on other sites read it (CORS), or it could not be reached. Paste its answer, or save it and choose the file.");
61
+ }
62
+ if (answer.status === 403 || answer.status === 401)
63
+ throw new RaceFeedError("refused", `The source refused the request (${answer.status}): it may need a sign-in or a key, or not allow this use.`, answer.status);
64
+ if (answer.status === 429)
65
+ throw new RaceFeedError("busy", "The source says it has had too many requests (429): wait before trying again.", 429);
66
+ if (!answer.ok)
67
+ throw new RaceFeedError("http", `The source answered ${answer.status}.`, answer.status);
68
+ const text = await answer.text();
69
+ if (text.length > FEED_MAX_BYTES)
70
+ throw new RaceFeedError("big", "The answer is too big to be a race feed (over 30 MB).");
71
+ try {
72
+ return JSON.parse(text);
73
+ }
74
+ catch {
75
+ throw new RaceFeedError("json", "The answer is not JSON.");
76
+ }
77
+ }
78
+ /** For an OpenF1-style lap address (`…/laps?session_key=…`), the same source's pit and driver addresses for that session. */
79
+ export function openF1Siblings(url) {
80
+ const session = url.searchParams.get("session_key");
81
+ if (!/\/laps\/?$/.test(url.pathname) || session === null)
82
+ return undefined;
83
+ const base = url.pathname.replace(/\/laps\/?$/, "");
84
+ const at = (name) => `${url.origin}${base}/${name}?session_key=${encodeURIComponent(session)}`;
85
+ return { pit: at("pit"), drivers: at("drivers") };
86
+ }
87
+ /**
88
+ * Read a race feed from an address the user gave, with the user's browser: kyogijo's open race timeline, or an OpenF1-style lap list (whose pit stops and drivers are then asked for from the same source and session,
89
+ * if it has them). It throws a `RaceFeedError` whose message says what went wrong in words: a source that does not let pages read it (CORS), a refusal (403), too many requests (429), not JSON, not a feed.
90
+ * @example
91
+ * ```ts no-run
92
+ * import { loadRaceFeed, raceFromFeed } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
93
+ * const feed = await loadRaceFeed("https://example.org/my-race.json");
94
+ * const race = raceFromFeed(feed, "las-vegas");
95
+ * ```
96
+ */
97
+ export async function loadRaceFeed(address, options = {}) {
98
+ const url = feedAddress(address, options.base);
99
+ if (url === undefined)
100
+ throw new RaceFeedError("address", "This is not a web address (it needs to start with https://).");
101
+ const fetcher = options.fetch ?? ((u, init) => fetch(u, init));
102
+ const data = await getJson(fetcher, url.href, options.signal);
103
+ if (looksLikeOpenF1Laps(data)) {
104
+ const more = openF1Siblings(url);
105
+ // The pit stops and the drivers make the board readable; without them the race still plays, by car numbers.
106
+ const optional = async (href) => {
107
+ if (href === undefined)
108
+ return undefined;
109
+ try {
110
+ const list = await getJson(fetcher, href, options.signal);
111
+ return Array.isArray(list) ? list : undefined;
112
+ }
113
+ catch (error) {
114
+ if (error?.name === "AbortError")
115
+ throw error;
116
+ return undefined;
117
+ }
118
+ };
119
+ const pit = await optional(more?.pit);
120
+ const drivers = await optional(more?.drivers);
121
+ return openF1Feed({ laps: data, ...(pit === undefined ? {} : { pit }), ...(drivers === undefined ? {} : { drivers }) });
122
+ }
123
+ const read = readRaceFeed(data);
124
+ if (!read.ok)
125
+ throw new RaceFeedError("format", read.error);
126
+ return read.feed;
127
+ }
128
+ /** How often a feed is asked again while it is followed: no faster than every 10 seconds, every 15 by default. */
129
+ export const FOLLOW_EVERY = Object.freeze({ least: 10000, normal: 15000, most: 120000 });
130
+ /**
131
+ * Follow a race that is going on: ask the user's address again every 15 seconds (never faster than 10), only while the page is visible, and hand each new feed to `onFeed`. It stops by itself when the feed is no longer
132
+ * live (the race is over), when the source refuses (403) or says it is busy (429), and when `stop` is called; after any other failure it waits twice as long each time, up to two minutes. A hidden page asks nothing.
133
+ */
134
+ export function followRaceFeed(address, onFeed, options = {}) {
135
+ const doc = options.doc ?? (typeof document === "undefined" ? undefined : document);
136
+ const every = Math.min(FOLLOW_EVERY.most, Math.max(FOLLOW_EVERY.least, options.every ?? FOLLOW_EVERY.normal));
137
+ let wait = every;
138
+ let timer;
139
+ let busy;
140
+ let alive = true;
141
+ const end = (why) => {
142
+ if (!alive)
143
+ return;
144
+ alive = false;
145
+ if (timer !== undefined)
146
+ clearTimeout(timer);
147
+ timer = undefined;
148
+ busy?.abort();
149
+ doc?.removeEventListener("visibilitychange", seen);
150
+ options.onStop?.(why);
151
+ };
152
+ const hidden = () => doc?.visibilityState === "hidden";
153
+ const later = () => {
154
+ if (!alive || hidden() || timer !== undefined || busy !== undefined)
155
+ return;
156
+ timer = setTimeout(ask, wait);
157
+ };
158
+ async function ask() {
159
+ timer = undefined;
160
+ if (!alive || hidden())
161
+ return;
162
+ busy = new AbortController();
163
+ try {
164
+ const feed = await loadRaceFeed(address, { ...(options.fetch === undefined ? {} : { fetch: options.fetch }), signal: busy.signal });
165
+ busy = undefined;
166
+ if (!alive)
167
+ return;
168
+ wait = every;
169
+ onFeed(feed);
170
+ if (feed.live !== true)
171
+ return end("over");
172
+ }
173
+ catch (error) {
174
+ busy = undefined;
175
+ if (!alive || error?.name === "AbortError")
176
+ return;
177
+ const problem = error instanceof RaceFeedError ? error : new RaceFeedError("http", String(error?.message ?? error));
178
+ options.onError?.(problem);
179
+ if (problem.kind === "refused" || problem.kind === "busy")
180
+ return end("refused");
181
+ wait = Math.min(FOLLOW_EVERY.most, wait * 2);
182
+ }
183
+ later();
184
+ }
185
+ // Hidden, the timer is let go; shown again, the feed is asked at once and then on the clock.
186
+ function seen() {
187
+ if (!alive)
188
+ return;
189
+ if (hidden()) {
190
+ if (timer !== undefined)
191
+ clearTimeout(timer);
192
+ timer = undefined;
193
+ }
194
+ else if (timer === undefined && busy === undefined)
195
+ void ask();
196
+ }
197
+ doc?.addEventListener("visibilitychange", seen);
198
+ void ask();
199
+ return {
200
+ stop: () => end("stopped"),
201
+ get following() {
202
+ return alive;
203
+ },
204
+ };
205
+ }