@johnmorrisdotca/kyogijo-stadiums 0.55.0 → 0.57.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 +18 -0
- package/README.md +1 -0
- package/catalogue.json +1 -1
- package/dist/catalogue.data.js +1 -1
- package/dist/circuits-entry.d.ts +6 -0
- package/dist/circuits-entry.js +4 -0
- package/dist/circuits.feed.d.ts +101 -0
- package/dist/circuits.feed.fetch.d.ts +69 -0
- package/dist/circuits.feed.fetch.js +205 -0
- package/dist/circuits.feed.js +332 -0
- package/dist/circuits.feed.openf1.d.ts +61 -0
- package/dist/circuits.feed.openf1.js +65 -0
- package/dist/circuits.race.dom.d.ts +5 -0
- package/dist/circuits.race.dom.js +26 -4
- package/dist/circuits.race.draw.d.ts +2 -2
- package/dist/circuits.race.draw.js +4 -4
- package/dist/circuits.race.words.d.ts +4 -1
- package/dist/circuits.race.words.js +4 -0
- package/dist/turn.camera.d.ts +6 -3
- package/dist/turn.camera.js +10 -3
- package/dist/turn.dom.js +22 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ All notable changes to this project are written here. The format follows
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.57.0] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **The wheel zooms the 3D view** (KAZOKU-UI-45). The wheel over the view now zooms it, as a map does (up in, down out, about the view's middle, which is where the pinch and the buttons zoom; a notch about as much as the + key); before, the plain wheel was the page's and tipped a focused view. A trackpad's pinch (Ctrl or Command and the wheel) still zooms. Shift and the wheel, or a sideways swipe, turns the view. The wheel never tips it: a drag, the arrows and the buttons do. With the pointer anywhere else the wheel scrolls the page, and a wheel that starts while the zoom is already at its end that way scrolls the page too, so a page of views is never trapped; the steps of a turn that reaches the end part-way through stay with the view, so the page does not jump.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## [0.56.0] - 2026-11-03
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- 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).
|
|
26
|
+
- 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`).
|
|
27
|
+
|
|
10
28
|
## [0.55.0] - 2026-11-03
|
|
11
29
|
|
|
12
30
|
### Added
|
package/README.md
CHANGED
|
@@ -255,6 +255,7 @@ console.log(svg.startsWith("<svg"), svg.includes("USA 3, Canada 2"));
|
|
|
255
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).
|
|
256
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).
|
|
257
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).
|
|
258
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).
|
|
259
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).
|
|
260
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).
|
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.
|
|
6
|
+
"version": "0.57.0"
|
|
7
7
|
},
|
|
8
8
|
"address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
|
|
9
9
|
"site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
|
package/dist/catalogue.data.js
CHANGED
|
@@ -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.
|
|
6
|
+
"version": "0.57.0"
|
|
7
7
|
},
|
|
8
8
|
"address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
|
|
9
9
|
"site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
|
package/dist/circuits-entry.d.ts
CHANGED
|
@@ -23,3 +23,9 @@ export { RACE_CARS_MAX, RACE_DRIVERS, RACE_TEAMS } from "./circuits.race.field.t
|
|
|
23
23
|
export type { RaceDriver, RaceTeam } from "./circuits.race.field.ts";
|
|
24
24
|
export { RACE_WORDS } from "./circuits.race.words.ts";
|
|
25
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";
|
package/dist/circuits-entry.js
CHANGED
|
@@ -15,3 +15,7 @@ export { RACE_STYLE, drawRace, gapText, lapTime, raceBoard, raceClock, raceDrawi
|
|
|
15
15
|
export { RACE_SPEEDS, mountCircuitRace } from "./circuits.race.dom.js";
|
|
16
16
|
export { RACE_CARS_MAX, RACE_DRIVERS, RACE_TEAMS } from "./circuits.race.field.js";
|
|
17
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,8 @@ export interface RaceViewSettings {
|
|
|
23
23
|
motion?: "auto" | "smooth" | "step";
|
|
24
24
|
/** The timing board under the controls: on by default. */
|
|
25
25
|
board?: boolean;
|
|
26
|
+
/** A race already worked out to play instead of a simulation: one from a feed (`raceFromFeed`). */
|
|
27
|
+
race?: CircuitRace;
|
|
26
28
|
/** Told when the reader plays, pauses, seeks or changes speed, and when the race ends: not at every frame. */
|
|
27
29
|
onChange?: (state: RaceState) => void;
|
|
28
30
|
}
|
|
@@ -37,6 +39,7 @@ export interface RaceState {
|
|
|
37
39
|
}
|
|
38
40
|
/** What `mountCircuitRace` gives back. */
|
|
39
41
|
export interface KyogijoRace {
|
|
42
|
+
/** The race it plays. */
|
|
40
43
|
readonly race: CircuitRace;
|
|
41
44
|
/** The 3D view, when the race is on one. */
|
|
42
45
|
readonly turn: KyogijoTurn<CircuitOptions> | undefined;
|
|
@@ -53,6 +56,8 @@ export interface KyogijoRace {
|
|
|
53
56
|
setSpeed(speed: number): void;
|
|
54
57
|
/** Draw again with new options (the language, the theme): the clock and the race stay where they are. A new circuit, seed, laps or cars is a new race: mount it again. */
|
|
55
58
|
update(options: RaceViewOptions): void;
|
|
59
|
+
/** Play another race in the same view (a feed that came in again while a race is followed): the clock stays where it is, held to the new race's end, and stays playing or paused. */
|
|
60
|
+
replace(race: CircuitRace): void;
|
|
56
61
|
destroy(): void;
|
|
57
62
|
}
|
|
58
63
|
/**
|
|
@@ -29,7 +29,8 @@ export function mountCircuitRace(host, initial, settings = {}) {
|
|
|
29
29
|
const doc = host.ownerDocument;
|
|
30
30
|
let options = { ...initial };
|
|
31
31
|
let language = options.language === "ja" ? "ja" : "en";
|
|
32
|
-
|
|
32
|
+
let race = settings.race ?? simulateRace(options);
|
|
33
|
+
let fromFeed = settings.race !== undefined;
|
|
33
34
|
const media = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : undefined;
|
|
34
35
|
const motionNow = () => (settings.motion === "smooth" ? "smooth" : settings.motion === "step" ? "step" : media?.matches === true ? "step" : "smooth");
|
|
35
36
|
const clampSpeed = (n) => Math.min(600, Math.max(1, n));
|
|
@@ -100,7 +101,7 @@ export function mountCircuitRace(host, initial, settings = {}) {
|
|
|
100
101
|
}
|
|
101
102
|
};
|
|
102
103
|
const drawFlat = () => {
|
|
103
|
-
stage.innerHTML = raceDrawing({ ...options, t: state.t }).svg;
|
|
104
|
+
stage.innerHTML = raceDrawing({ ...options, t: state.t }, fromFeed ? race : undefined).svg;
|
|
104
105
|
bindFlat();
|
|
105
106
|
};
|
|
106
107
|
/** The model of the 3D view with a disc for each car at the clock's time. */
|
|
@@ -270,7 +271,7 @@ export function mountCircuitRace(host, initial, settings = {}) {
|
|
|
270
271
|
b.setAttribute("aria-pressed", String(n === state.speed));
|
|
271
272
|
}
|
|
272
273
|
range.setAttribute("aria-label", w.position);
|
|
273
|
-
setText(note, w.simulated);
|
|
274
|
+
setText(note, fromFeed ? w.feedNote : w.simulated);
|
|
274
275
|
root.dataset.state = state.ended ? "ended" : state.playing ? "playing" : "paused";
|
|
275
276
|
root.dataset.motion = state.motion;
|
|
276
277
|
};
|
|
@@ -310,7 +311,9 @@ export function mountCircuitRace(host, initial, settings = {}) {
|
|
|
310
311
|
frameId = request(frame);
|
|
311
312
|
}
|
|
312
313
|
const api = {
|
|
313
|
-
race
|
|
314
|
+
get race() {
|
|
315
|
+
return race;
|
|
316
|
+
},
|
|
314
317
|
get turn() {
|
|
315
318
|
return turn;
|
|
316
319
|
},
|
|
@@ -380,6 +383,25 @@ export function mountCircuitRace(host, initial, settings = {}) {
|
|
|
380
383
|
label();
|
|
381
384
|
paint(undefined, false);
|
|
382
385
|
},
|
|
386
|
+
replace(next) {
|
|
387
|
+
const cars = race.cars.length;
|
|
388
|
+
race = next;
|
|
389
|
+
fromFeed = true;
|
|
390
|
+
state.duration = race.duration;
|
|
391
|
+
range.max = String(race.duration);
|
|
392
|
+
if (state.t > race.duration)
|
|
393
|
+
state.t = race.duration;
|
|
394
|
+
state.ended = !state.playing && state.t >= race.duration;
|
|
395
|
+
moment = raceAt(race, state.t);
|
|
396
|
+
if (inner !== undefined)
|
|
397
|
+
inner.setDecorate(withCars, bindTurn);
|
|
398
|
+
else
|
|
399
|
+
drawFlat();
|
|
400
|
+
if (cars !== race.cars.length || boardRows.length !== race.cars.length)
|
|
401
|
+
buildBoard();
|
|
402
|
+
label();
|
|
403
|
+
paint(undefined, false);
|
|
404
|
+
},
|
|
383
405
|
destroy() {
|
|
384
406
|
stopFrames();
|
|
385
407
|
state.playing = false;
|
|
@@ -29,8 +29,8 @@ export interface RaceDrawing extends CircuitDrawing {
|
|
|
29
29
|
race: CircuitRace;
|
|
30
30
|
moment: RaceMoment;
|
|
31
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;
|
|
32
|
+
/** The race, the drawing of its circuit and the moment, with the cars on it: the race the options make, or one given (from a feed). */
|
|
33
|
+
export declare function raceDrawing(options: RaceDrawOptions, given?: CircuitRace): RaceDrawing;
|
|
34
34
|
/**
|
|
35
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
36
|
* @example
|
|
@@ -64,14 +64,14 @@ export function carsMarkup(moment, r, language) {
|
|
|
64
64
|
const lines = [...moment.standings].sort((a, b) => a.index - b.index);
|
|
65
65
|
return el("g", { class: "ky-ci-cars" }, lines.map((line) => carMarkup(line, r, language)).join(""));
|
|
66
66
|
}
|
|
67
|
-
/** The race, the drawing of its circuit and the moment, with the cars on it. */
|
|
68
|
-
export function raceDrawing(options) {
|
|
67
|
+
/** The race, the drawing of its circuit and the moment, with the cars on it: the race the options make, or one given (from a feed). */
|
|
68
|
+
export function raceDrawing(options, given) {
|
|
69
69
|
const language = options.language === "ja" ? "ja" : "en";
|
|
70
|
-
const race = simulateRace(options);
|
|
70
|
+
const race = given ?? simulateRace(options);
|
|
71
71
|
const moment = raceAt(race, options.t ?? 0);
|
|
72
72
|
const drawn = circuitDrawing(options);
|
|
73
73
|
const w = RACE_WORDS[language];
|
|
74
|
-
const extra = ` ${fill(w.description, { cars: race.cars.length, laps: race.laps })}`;
|
|
74
|
+
const extra = ` ${fill(given === undefined ? w.description : w.fromFeed, { cars: race.cars.length, laps: race.laps })}`;
|
|
75
75
|
const description = `${drawn.description}${language === "ja" ? extra.trim() : extra}`;
|
|
76
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
77
|
return { ...drawn, svg, description, race, moment };
|
|
@@ -3,6 +3,8 @@ import type { Language } from "./types.ts";
|
|
|
3
3
|
export interface RaceWords {
|
|
4
4
|
/** The line that says what this is: a model, not a real race. */
|
|
5
5
|
simulated: string;
|
|
6
|
+
/** The same line for a race from a feed the reader chose. */
|
|
7
|
+
feedNote: string;
|
|
6
8
|
/** The race clock: the lap the leader is on, and the line under it once the flag has fallen. */
|
|
7
9
|
lap: string;
|
|
8
10
|
finalLap: string;
|
|
@@ -43,7 +45,8 @@ export interface RaceWords {
|
|
|
43
45
|
/** What is said aloud as the race goes. */
|
|
44
46
|
leads: string;
|
|
45
47
|
wins: string;
|
|
46
|
-
/** The drawing's description, added to the circuit's. */
|
|
48
|
+
/** The drawing's description, added to the circuit's: a simulation's, and a race from a feed's. */
|
|
47
49
|
description: string;
|
|
50
|
+
fromFeed: string;
|
|
48
51
|
}
|
|
49
52
|
export declare const RACE_WORDS: Readonly<Record<Language, RaceWords>>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const RACE_WORDS = Object.freeze({
|
|
2
2
|
en: {
|
|
3
3
|
simulated: "A simulated race: made-up cars and drivers, worked out from a seed. No real race, driver or team.",
|
|
4
|
+
feedNote: "A race from a feed you chose: its names, times and results are the feed's, shown in this page only. Between the times it gives, each car runs at the lap's own speeds.",
|
|
4
5
|
lap: "Lap {lap} / {laps}",
|
|
5
6
|
finalLap: "Final lap",
|
|
6
7
|
flag: "Chequered flag",
|
|
@@ -19,9 +20,11 @@ export const RACE_WORDS = Object.freeze({
|
|
|
19
20
|
leads: "{name} leads, lap {lap}.",
|
|
20
21
|
wins: "Chequered flag: {name} wins.",
|
|
21
22
|
description: "{cars} made-up cars race {laps} laps from the grid to the chequered flag: a simulation, not a real race.",
|
|
23
|
+
fromFeed: "{cars} cars race {laps} laps, from a feed the reader chose: where each car is between the times it gives is worked out at the lap's own speeds.",
|
|
22
24
|
},
|
|
23
25
|
ja: {
|
|
24
26
|
simulated: "シミュレーションによるレースです。架空のマシンとドライバーで、シード値から計算しています。実在のレース・ドライバー・チームとは関係ありません。",
|
|
27
|
+
feedNote: "選んだフィードのレースです。名前、タイム、結果はフィードのもので、このページの中だけに表示します。フィードが示す時刻の間は、各マシンがそのコースの速度で走るものとして計算しています。",
|
|
25
28
|
lap: "{lap} / {laps}周",
|
|
26
29
|
finalLap: "ファイナルラップ",
|
|
27
30
|
flag: "チェッカーフラッグ",
|
|
@@ -40,5 +43,6 @@ export const RACE_WORDS = Object.freeze({
|
|
|
40
43
|
leads: "{lap}周目、{name}がトップ。",
|
|
41
44
|
wins: "チェッカーフラッグ:{name}が優勝。",
|
|
42
45
|
description: "架空のマシン{cars}台が、グリッドからチェッカーフラッグまで{laps}周を走ります。実際のレースではなく、シミュレーションです。",
|
|
46
|
+
fromFeed: "選んだフィードから、{cars}台のマシンが{laps}周を走ります。フィードが示す時刻の間の各マシンの位置は、そのコースの速度から計算しています。",
|
|
43
47
|
},
|
|
44
48
|
});
|
package/dist/turn.camera.d.ts
CHANGED
|
@@ -78,11 +78,14 @@ export declare function nudge(view: TurnView, toward: TurnToward, amount: number
|
|
|
78
78
|
* yaw falls) and a drag down tips the top toward the eye (the pitch falls).
|
|
79
79
|
*/
|
|
80
80
|
export declare function drag(view: TurnView, dx: number, dy: number, tilt?: boolean, degreesPerPixel?: number): TurnView;
|
|
81
|
+
/** Wheel steps closer together than this, in milliseconds, are one turn of the wheel or one swipe (with its glide). */
|
|
82
|
+
export declare const WHEEL_GESTURE_MS = 300;
|
|
81
83
|
/**
|
|
82
|
-
* The view after the wheel turns by `deltaY` (positive is down, as `WheelEvent.deltaY` says
|
|
83
|
-
*
|
|
84
|
+
* The view after the wheel turns by `deltaY` (positive is down, as `WheelEvent.deltaY` says; `deltaMode` 1 is lines and 2 is pages): the wheel up zooms in and the wheel down zooms out,
|
|
85
|
+
* about the view's middle, as the pinch and the buttons do (the view has no pan). A trackpad's pinch (`pinch`: it arrives as Ctrl and the wheel) comes in small steps; a plain wheel's
|
|
86
|
+
* notch is about 100 pixels and zooms about 1.2 times, as the + key does, and one step of it never zooms more than twice either way.
|
|
84
87
|
*/
|
|
85
|
-
export declare
|
|
88
|
+
export declare function wheelZoom(view: TurnView, deltaY: number, deltaMode?: number, pinch?: boolean): TurnView;
|
|
86
89
|
/** A box in the scene, in pixels from the scene's middle, the floor at `z0` and the highest thing at `z1`. */
|
|
87
90
|
export interface Box3 {
|
|
88
91
|
x0: number;
|
package/dist/turn.camera.js
CHANGED
|
@@ -100,11 +100,18 @@ export function nudge(view, toward, amount) {
|
|
|
100
100
|
export function drag(view, dx, dy, tilt = true, degreesPerPixel = 0.4) {
|
|
101
101
|
return clampView({ ...view, yaw: view.yaw - dx * degreesPerPixel, pitch: tilt ? view.pitch - dy * degreesPerPixel * 0.75 : view.pitch });
|
|
102
102
|
}
|
|
103
|
+
/** Wheel steps closer together than this, in milliseconds, are one turn of the wheel or one swipe (with its glide). */
|
|
104
|
+
export const WHEEL_GESTURE_MS = 300;
|
|
103
105
|
/**
|
|
104
|
-
* The view after the wheel turns by `deltaY` (positive is down, as `WheelEvent.deltaY` says
|
|
105
|
-
*
|
|
106
|
+
* The view after the wheel turns by `deltaY` (positive is down, as `WheelEvent.deltaY` says; `deltaMode` 1 is lines and 2 is pages): the wheel up zooms in and the wheel down zooms out,
|
|
107
|
+
* about the view's middle, as the pinch and the buttons do (the view has no pan). A trackpad's pinch (`pinch`: it arrives as Ctrl and the wheel) comes in small steps; a plain wheel's
|
|
108
|
+
* notch is about 100 pixels and zooms about 1.2 times, as the + key does, and one step of it never zooms more than twice either way.
|
|
106
109
|
*/
|
|
107
|
-
export
|
|
110
|
+
export function wheelZoom(view, deltaY, deltaMode = 0, pinch = false) {
|
|
111
|
+
const pixels = deltaMode === 1 ? deltaY * (pinch ? 5 : 33) : deltaMode === 2 ? deltaY * 800 : deltaY;
|
|
112
|
+
const factor = pinch ? Math.exp(clamp(-pixels * 0.01, -10, 10)) : clamp(Math.exp(-pixels * 0.002), 0.5, 2);
|
|
113
|
+
return clampView({ ...view, zoom: view.zoom * factor });
|
|
114
|
+
}
|
|
108
115
|
const RAD = Math.PI / 180;
|
|
109
116
|
/** Where a point of the scene falls on the screen, in pixels from the middle of the container, when the scene is placed as given and the eye is `distance` pixels away. */
|
|
110
117
|
export function project(point, view, at, distance) {
|
package/dist/turn.dom.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// The 3D view in a page: `mountKyogijoTurn` writes the scene (`turn.markup.ts`) into a container and makes it turn, as the cube of kyuubu turns: a drag turns and
|
|
2
|
-
// tips it,
|
|
2
|
+
// tips it, the wheel or a pinch zooms, the arrow keys step it, four viewpoints and a reset are a button or a number key away, a reader who prefers
|
|
3
3
|
// reduced motion gets jumps for glides, and where the browser cannot draw in 3D the flat drawing stays. Nothing is polled and nothing runs while the view is
|
|
4
4
|
// still: the elements' `transform`s are written when the view changes, and a glide between viewpoints is a short run of animation frames.
|
|
5
5
|
import { displayProbe, frameBatch } from "./turn.frames.js";
|
|
6
6
|
import { drawKyogijo } from "./draw.js";
|
|
7
|
-
import { STEP, TURN_PRESET_NAMES, clampView, drag, nearest, nudge, presetOf, presetView, step, turnDirection, viewScale,
|
|
7
|
+
import { STEP, TURN_PRESET_NAMES, clampView, drag, nearest, nudge, presetOf, presetView, step, turnDirection, viewScale, wheelZoom, WHEEL_GESTURE_MS, ZOOM_MAX, ZOOM_MIN } from "./turn.camera.js";
|
|
8
8
|
import { buildTurn, floorOptions, openingView, turnWords } from "./turn.model.js";
|
|
9
9
|
import { controlsMarkup, layoutOf, turnMarkupOf, worldMarkup } from "./turn.markup.js";
|
|
10
10
|
import { KYOGIJO_TURN_STYLE } from "./turn.style.js";
|
|
@@ -656,20 +656,33 @@ export function mountTurn(host, options, settings, build, replayable = false, le
|
|
|
656
656
|
stage.addEventListener("pointerup", onUp);
|
|
657
657
|
stage.addEventListener("pointercancel", onUp);
|
|
658
658
|
stage.addEventListener("dblclick", toOpening);
|
|
659
|
-
// ---- The wheel
|
|
659
|
+
// ---- The wheel over the view zooms it, as a map does; with the pointer anywhere else the page scrolls. Ctrl or Command and the wheel (a pinch on a trackpad) zoom too. Shift and the
|
|
660
|
+
// wheel, or a sideways swipe, turn it. Tipping is a drag, the keys and the buttons. A wheel that starts while the zoom is already at its end that way goes on to scroll the page, so the page is
|
|
661
|
+
// never trapped; one that reaches the end part-way through stops there rather than flinging the page. ----
|
|
662
|
+
let wheelTaken = Number.NEGATIVE_INFINITY;
|
|
660
663
|
const onWheel = (event) => {
|
|
661
|
-
|
|
664
|
+
const pinch = event.ctrlKey || event.metaKey;
|
|
665
|
+
const now = Number.isFinite(event.timeStamp) ? event.timeStamp : 0;
|
|
666
|
+
const continuing = now >= wheelTaken && now - wheelTaken < WHEEL_GESTURE_MS;
|
|
667
|
+
if (!pinch && (event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))) {
|
|
662
668
|
event.preventDefault();
|
|
669
|
+
wheelTaken = now;
|
|
663
670
|
grab();
|
|
664
|
-
view = clampView({ ...view,
|
|
671
|
+
view = clampView({ ...view, yaw: view.yaw + (event.deltaX || event.deltaY) * 0.25 });
|
|
665
672
|
}
|
|
666
|
-
else
|
|
673
|
+
else {
|
|
674
|
+
const next = wheelZoom(view, event.deltaY, event.deltaMode, pinch);
|
|
675
|
+
const atEnd = event.deltaY <= 0 ? view.zoom >= ZOOM_MAX : view.zoom <= ZOOM_MIN;
|
|
676
|
+
// Ctrl and the wheel is always the view's, or the browser would zoom the whole page.
|
|
677
|
+
if (!pinch && atEnd && !continuing)
|
|
678
|
+
return;
|
|
667
679
|
event.preventDefault();
|
|
680
|
+
wheelTaken = now;
|
|
681
|
+
if (next.zoom === view.zoom)
|
|
682
|
+
return;
|
|
668
683
|
grab();
|
|
669
|
-
view =
|
|
684
|
+
view = next;
|
|
670
685
|
}
|
|
671
|
-
else
|
|
672
|
-
return;
|
|
673
686
|
shown = view;
|
|
674
687
|
paint(shown);
|
|
675
688
|
label();
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package's version: the same as package.json's, and a test holds them to each other. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.57.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package's version: the same as package.json's, and a test holds them to each other. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.57.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/kyogijo-stadiums",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Kyogijo Stadiums 競技場, 40 sports' surfaces as SVG, drawn to scale from the rulebooks: NFL, CFL and IFAF football fields, an NHL rink, an NBA court, MLB ballparks, soccer pitches, curling sheets, athletics stadiums, speed skating, short track and figure skating rinks, swimming pools, water polo fields, diving wells, artistic swimming areas, and the courts and fields of tennis, volleyball (indoor, beach and sitting), badminton, pickleball, table tennis, padel, squash, racquetball, sepak takraw, teqball, roundnet, netball, lacrosse, rugby, Australian football, Gaelic games, cricket, field hockey and handball, and the budo of judo, karate, kendo, sumo and kyudo with a dojo of tatami laid as real units, the MMA cage, with each team's name and logo on its side and the score in the middle, stands and a crowd, a pageant of flags, and a 3D view. Light and dark, accessible, a compact badge for lists, a helper for React and a custom element. Zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sports",
|