@johnmorrisdotca/kyogijo-stadiums 0.62.0 → 0.63.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,15 @@ All notable changes to this project are written here. The format follows
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.63.0] - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - **Drop feed files on the demo, kept in the browser (KYOGIJO-STADIUMS-52)** ([feeds](./docs/feeds.md#in-the-demo)): drop one or more feed files (an NHL play-by-play file as the public answer has it, or a kyogijo timeline) on the game section's feed panel or its view, or choose several; the first good one plays, a file that is not a feed is refused by name, and every feed is kept in this browser (localStorage up to about 4 MB in all, IndexedDB past that), listed with its game, file, sport, date and size, with Play, Remove, Remove all and the space used, and still there after a reload. A whole folder can be dropped: files are read one at a time with progress, up to 200 at once and 200 MB kept in all; more is refused in words. Written only on a drop, a choice or a removal; nothing leaves the browser, and a browser that keeps nothing still plays the feed. The adapter is tested on an invented answer in the raw play-by-play shape (`src/feeds.nhl.stub.json`).
15
+ - **Bring your own feed: a file or a paste (KYOGIJO-STADIUMS-37)** ([feeds](./docs/feeds.md)): a new entry, `/feeds`, reads a game from a feed into a replay. `readFeed` and `readFeedText` read kyogijo's open timeline (a bare timeline or a game file, with its stoppages and penalties, so that the game clock stops where the replay's does; every value checked, every list capped) and, through a pure adapter, `nhlPlayByPlay`, a play-by-play shaped as the NHL's public one: the puck moved between the plays' places, the shots, goals and whistles, the periods on the game's clock and the sides and players named from the feed at run time. A `FeedError` says what was wrong. The demo's game section has **Bring your own feed**: choose a file or paste a feed, and it plays with the score, the period and the clock on the board.
16
+ - **Bring your own feed: an address, and where to find one (KYOGIJO-STADIUMS-37)** ([where to find a feed](./docs/where-to-find-a-feed.md)): with `/feeds` loaded, every mount's and 3D view's `replay` has `fromUrl(url, { format })`, fetched by the reader's browser with no cookie, no referrer and no cache, through no server of ours; `loadFeed` and `feedAddress` do it alone, and a refusal (CORS), a status or an answer too large is said plainly. The one list of where to find a feed, `examples/feeds/where-to-find.json`, has ice hockey beside racing (our made-up sample first, then the NHL's public play-by-play), with what each gives, a sample address, how to find a game, its docs and its terms; the docs page covers both sports in English and Japanese, and the demo's list beside the address field reads it, where **Use** fills the field.
17
+ - **Bring your own feed: live (KYOGIJO-STADIUMS-37)**: `replay.follow(url)` and `followFeed` ask the address every 15 seconds (never less than 10) only while the page is shown, play each answer that changed from where the reader was, wait twice as long after an error, and stop when the game is over or the server says 403 or 429. The demo has **Follow live**.
18
+
10
19
  ## [0.62.0] - 2026-11-03
11
20
 
12
21
  ### Added
package/README.md CHANGED
@@ -259,7 +259,7 @@ console.log(svg.startsWith("<svg"), svg.includes("USA 3, Canada 2"));
259
259
  - **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×; on a district's ground beside T-Mobile Arena among the Strip, with the Sphere, the High Roller and the Fountains of Bellagio, where the race runs too. [Circuits](./docs/circuits.md#a-race), [Las Vegas](./docs/district.md#las-vegas-a-circuit-on-the-ground-and-the-strips-landmarks), [a race on the Strip](./docs/district.md#a-race-on-the-strip).
260
260
  - **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).
261
261
  - **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).
262
- - **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).
262
+ - **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). Bring your own feed: a game from a file (drop several, kept in your browser), a paste or an address your browser fetches, live or after the game ([feeds](./docs/feeds.md), [where to find a feed](./docs/where-to-find-a-feed.md)).
263
263
  - **Sound**: the crowd of each kind of venue at a level from quiet to a roar, heard in the room its venue makes, and its game's sounds (the goal horn, the whistle, the buzzer, the bell) from a replay or live events; made in the browser, off until turned on. [Sound](./docs/sound.md).
264
264
  - **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).
265
265
  - **Four ways in**: a function that returns SVG text, a helper that draws into a container, a custom element, a frame for pages with no script; renderers for a canvas, d3, Observable Plot, GeoJSON and PNG. [Entry points](./docs/entry-points.md), [renderers](./docs/renderers.md).
@@ -277,6 +277,7 @@ console.log(svg.startsWith("<svg"), svg.includes("USA 3, Canada 2"));
277
277
  | `./multi`, `./judging`, `./layouts` | Several surfaces for one event ([demo](https://johnmorrisdotca.github.io/kyogijo-stadiums/halls.html)); who judges where; small multiples and backgrounds. |
278
278
  | `./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. |
279
279
  | `./sound` | A surface's crowd and its game's sounds, made in the browser with Web Audio ([demo](https://johnmorrisdotca.github.io/kyogijo-stadiums/venues.html?sound=on)). |
280
+ | `./feeds` | A game from a feed (kyogijo's open timeline, an NHL-style play-by-play) read into a replay; `replay.fromUrl` and `replay.follow`. |
280
281
  | `./athletics`, `./budo`, `./skating` | The athletics stadium, the budō (areas, dohyō, the dojo builder) and the skating rinks. |
281
282
  | `./scene`, `./canvas`, `./d3`, `./plot`, `./geojson`, `./png` | The drawing as data, on a canvas, in d3, as an Observable Plot mark, as GeoJSON, as a PNG. |
282
283
  | `./mount`, `./react`, `./element`, `./element/define` | Draw into a container; React props; the `<kyogijo-field>` element and its registration. |
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.62.0"
6
+ "version": "0.63.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.62.0"
6
+ "version": "0.63.0"
7
7
  },
8
8
  "address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
9
9
  "site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
@@ -0,0 +1,7 @@
1
+ import "./feeds.dom.ts";
2
+ export { FEED_FORMATS, FEED_LIMITS, FeedError, feedFormatOf, feedText, kyogijoFeed, readFeed, readFeedText } from "./feeds.ts";
3
+ export type { FeedFormat, FeedGame, FeedPlayer, FeedSide } from "./feeds.ts";
4
+ export { NHL_OVERTIME, NHL_PERIOD, nhlPlayByPlay } from "./feeds.nhl.ts";
5
+ export { FEED_MOST, FOLLOW_EVERY, feedAddress, fetchFeedText, followFeed, loadFeed } from "./feeds.fetch.ts";
6
+ export type { FeedFetchOptions, FeedFollow, FeedFollowOptions, FeedFollowState } from "./feeds.fetch.ts";
7
+ export type { FeedReplay, FeedReplayOptions, ReplayCall, ReplayFollow, ReplayFollowOptions } from "./replay.feed.ts";
@@ -0,0 +1,6 @@
1
+ // Feeds: a game from a file, pasted text or an address the reader gives, read into a replay. kyogijo's own open timeline, and adapters (pure functions) for common play-by-play shapes; the fetch is the reader's browser's,
2
+ // and a live follow asks no faster than every ten seconds, only while the page is shown. Loading this entry also gives every mount's and 3D view's `replay` its `fromUrl` and `follow`.
3
+ import "./feeds.dom.js";
4
+ export { FEED_FORMATS, FEED_LIMITS, FeedError, feedFormatOf, feedText, kyogijoFeed, readFeed, readFeedText } from "./feeds.js";
5
+ export { NHL_OVERTIME, NHL_PERIOD, nhlPlayByPlay } from "./feeds.nhl.js";
6
+ export { FEED_MOST, FOLLOW_EVERY, feedAddress, fetchFeedText, followFeed, loadFeed } from "./feeds.fetch.js";
@@ -0,0 +1,55 @@
1
+ import type { ReplayTimeline } from "./replay.types.ts";
2
+ /** The shapes a feed can be read in: `kyogijo` (the open timeline, or a game file), `nhl` (a play-by-play shaped as the NHL's public one), or `auto` to tell them apart. */
3
+ export type FeedFormat = "auto" | "kyogijo" | "nhl";
4
+ /** The formats there are, `auto` first. */
5
+ export declare const FEED_FORMATS: readonly FeedFormat[];
6
+ /** A player a feed names: its side, its number and its name, as the feed gives them (at run time only). */
7
+ export interface FeedPlayer {
8
+ side: "home" | "away";
9
+ number?: number | string;
10
+ name?: string;
11
+ }
12
+ /** A side of a feed's game, as the feed names it (at run time only): its letters, its name and its colour. */
13
+ export interface FeedSide {
14
+ abbr?: string;
15
+ name?: string;
16
+ color?: string;
17
+ }
18
+ /** A feed read into a game. */
19
+ export interface FeedGame {
20
+ /** The format it was read in. */
21
+ format: Exclude<FeedFormat, "auto">;
22
+ /** The sport whose surface it is played on, when the feed says (`nhl`). */
23
+ sport?: string;
24
+ /** The replay: frames, events, and the periods for a scoreboard. */
25
+ timeline: ReplayTimeline;
26
+ /** The two sides as the feed names them. */
27
+ teams: {
28
+ home: FeedSide;
29
+ away: FeedSide;
30
+ };
31
+ /** Whether the feed says the game is over: a live follow stops then. */
32
+ ended: boolean;
33
+ /** The players the feed names, by the id the events use in `by`. */
34
+ players: Record<string, FeedPlayer>;
35
+ }
36
+ /** Why a feed could not be read. */
37
+ export declare class FeedError extends Error {
38
+ /** `format`: not a feed of the format; `empty`: nothing to play; `cors`, `network`, `http`, `too-large`, `address`: from the fetch (`feeds.fetch.ts`). */
39
+ readonly kind: "format" | "empty" | "cors" | "network" | "http" | "too-large" | "address";
40
+ /** The HTTP status, for `http`. */
41
+ readonly status?: number;
42
+ constructor(kind: FeedError["kind"], message: string, status?: number);
43
+ }
44
+ /** The most a feed may hold: more is cut off, so that a hostile or broken answer cannot hold the page. */
45
+ export declare const FEED_LIMITS: {
46
+ readonly frames: 200000;
47
+ readonly objects: 64;
48
+ readonly events: 50000;
49
+ readonly periods: 20;
50
+ readonly stoppages: 5000;
51
+ readonly penalties: 1000;
52
+ readonly text: 80;
53
+ };
54
+ /** Text from a feed: a string, without control characters, cut to a length. */
55
+ export declare const feedText: (value: unknown, most?: number) => string | undefined;
@@ -0,0 +1,21 @@
1
+ /** The formats there are, `auto` first. */
2
+ export const FEED_FORMATS = ["auto", "kyogijo", "nhl"];
3
+ /** Why a feed could not be read. */
4
+ export class FeedError extends Error {
5
+ constructor(kind, message, status) {
6
+ super(message);
7
+ this.name = "FeedError";
8
+ this.kind = kind;
9
+ if (status !== undefined)
10
+ this.status = status;
11
+ }
12
+ }
13
+ /** The most a feed may hold: more is cut off, so that a hostile or broken answer cannot hold the page. */
14
+ export const FEED_LIMITS = { frames: 200000, objects: 64, events: 50000, periods: 20, stoppages: 5000, penalties: 1000, text: 80 };
15
+ /** Text from a feed: a string, without control characters, cut to a length. */
16
+ export const feedText = (value, most = FEED_LIMITS.text) => {
17
+ if (typeof value !== "string")
18
+ return undefined;
19
+ const clean = [...value].filter((ch) => ch.charCodeAt(0) >= 32 && ch.charCodeAt(0) !== 127).join("").trim().slice(0, most);
20
+ return clean === "" ? undefined : clean;
21
+ };
@@ -0,0 +1,26 @@
1
+ import { type FeedFormat, type FeedGame } from "./feeds.core.ts";
2
+ export { FEED_FORMATS, FEED_LIMITS, FeedError, feedText } from "./feeds.core.ts";
3
+ export type { FeedFormat, FeedGame, FeedPlayer, FeedSide } from "./feeds.core.ts";
4
+ /**
5
+ * Read kyogijo's own open timeline: a bare timeline (`{ frames, events?, periods?, stoppages?, penalties?, teams?, from? }`) or a game file (`{ kind: "kyogijo-game", sport, teams, periods, timeline }`, as
6
+ * `examples/games` has them). Every frame, object, event, period, stoppage and penalty is checked; what is not of the format is left out, and a timeline with no frame is an error.
7
+ */
8
+ export declare function kyogijoFeed(json: unknown): FeedGame;
9
+ /** Which format a feed's answer is in: a timeline or a game file of kyogijo's, or a play-by-play shaped as the NHL's. `undefined` when it is neither. */
10
+ export declare function feedFormatOf(json: unknown): Exclude<FeedFormat, "auto"> | undefined;
11
+ /**
12
+ * Read a feed's answer into a game: in the format asked, or the one `feedFormatOf` finds. Throws a `FeedError` (`format`, `empty`) that says what was wrong in plain words.
13
+ * @example
14
+ * ```ts
15
+ * import { readFeed } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
16
+ * const answer = { frames: [{ t: 0, objects: [{ id: "puck", team: "puck", x: 0, y: 0 }] }, { t: 30, objects: [{ id: "puck", team: "puck", x: 80, y: 4 }] }], events: [{ t: 30, kind: "goal", team: "home" }], from: "nhl-api" };
17
+ * const game = readFeed(answer);
18
+ * console.log(game.format, game.timeline.frames.length, game.timeline.events?.length);
19
+ * try { readFeed({ hello: "world" }); } catch (error) { console.log((error as Error).name, (error as { kind?: string }).kind); }
20
+ * // kyogijo 2 1
21
+ * // FeedError format
22
+ * ```
23
+ */
24
+ export declare function readFeed(json: unknown, format?: FeedFormat): FeedGame;
25
+ /** Read text (a file or what was pasted) as JSON and then as a feed. */
26
+ export declare function readFeedText(text: string, format?: FeedFormat): FeedGame;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ // A feed played on a mount or a 3D view: `replay.fromUrl` fetches it once and plays it, `replay.follow` follows it live and plays each answer that changed, from where the reader was. Loaded by the `/feeds` entry, it
2
+ // puts its code into every `replay` (`replay.feed.ts`).
3
+ import { followFeed, loadFeed } from "./feeds.fetch.js";
4
+ import { useFeedKit } from "./replay.feed.js";
5
+ const kit = {
6
+ async fromUrl(play, url, options = {}) {
7
+ const feed = await loadFeed(url, options);
8
+ return Object.assign(play(feed.timeline, options.settings), { feed });
9
+ },
10
+ follow(play, url, options = {}) {
11
+ let replay;
12
+ const follow = followFeed(url, {
13
+ ...options,
14
+ onGame(game) {
15
+ // From where the reader was; at the newest moment when it was there (or when it is the first answer).
16
+ const at = replay?.state();
17
+ const edge = at === undefined || at.t >= at.end - 0.5;
18
+ replay?.destroy();
19
+ replay = play(game.timeline, { ...options.settings, ...(edge ? { start: Number.MAX_SAFE_INTEGER } : { start: at.t - at.start, speed: at.speed }) });
20
+ if (at?.playing === true && !edge)
21
+ replay.play();
22
+ options.onGame?.(game, replay);
23
+ },
24
+ onError: options.onError,
25
+ onState: options.onState,
26
+ });
27
+ return {
28
+ replay: () => replay,
29
+ state: follow.state,
30
+ stop: follow.stop,
31
+ };
32
+ },
33
+ };
34
+ useFeedKit(kit);
@@ -0,0 +1,62 @@
1
+ import { FeedError, type FeedFormat, type FeedGame } from "./feeds.core.ts";
2
+ /** What a fetch of a feed can be told. */
3
+ export interface FeedFetchOptions {
4
+ /** The format to read the answer in; `auto` (the default) tells the formats apart. */
5
+ format?: FeedFormat;
6
+ /** The address a relative one is read against: the page's own by default. */
7
+ base?: string;
8
+ /** The most an answer may be, in bytes: 20 MB by default. */
9
+ most?: number;
10
+ /** Stop the request. */
11
+ signal?: AbortSignal;
12
+ /** The function that fetches: the browser's own `fetch` by default (a test gives its own). */
13
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
14
+ }
15
+ /** The most an answer may be, by default: 20 MB. */
16
+ export declare const FEED_MOST: number;
17
+ /** An address the reader gave, as a URL: `http` or `https` only (a relative address is read against the page's). Throws a `FeedError` (`address`) for anything else. */
18
+ export declare function feedAddress(url: string, base?: string): URL;
19
+ /** Fetch an address's answer as text, by the reader's browser: no cookie, no referrer, no cache. */
20
+ export declare function fetchFeedText(url: string, options?: Omit<FeedFetchOptions, "format">): Promise<string>;
21
+ /**
22
+ * Fetch a feed from an address the reader gave and read it into a game. Throws a `FeedError` that says what was wrong: `address`, `cors`, `network`, `http` (with its `status`), `too-large`, `format` or `empty`.
23
+ * @example
24
+ * ```ts
25
+ * import { loadFeed } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
26
+ * const stub = async () => new Response(JSON.stringify({ frames: [{ t: 0, objects: [] }, { t: 5, objects: [] }] }));
27
+ * const game = await loadFeed("https://example.com/feed.json", { fetch: stub });
28
+ * console.log(game.format, game.timeline.frames.length);
29
+ * const refused = async () => { throw new TypeError("Failed to fetch"); };
30
+ * await loadFeed("https://example.com/feed.json", { fetch: refused }).catch((error) => console.log(error.kind));
31
+ * // kyogijo 2
32
+ * // cors
33
+ * ```
34
+ */
35
+ export declare function loadFeed(url: string, options?: FeedFetchOptions): Promise<FeedGame>;
36
+ /** What a live follow can be told, beyond a fetch's options. */
37
+ export interface FeedFollowOptions extends FeedFetchOptions {
38
+ /** Seconds between asks: 15 by default, never less than 10. */
39
+ every?: number;
40
+ /** A new answer, read: only when it differs from the one before. */
41
+ onGame?: (game: FeedGame) => void;
42
+ /** An answer that could not be had or read. The follow goes on, waiting longer, unless it is a 403 or a 429. */
43
+ onError?: (error: FeedError) => void;
44
+ /** Told each time the follow's state changes. */
45
+ onState?: (state: FeedFollowState) => void;
46
+ /** The document whose visibility it follows: the page's by default. */
47
+ doc?: Document;
48
+ }
49
+ /** What a follow is doing: `asking`, `waiting` for the next ask, `hidden` (the page is not shown: no ask until it is), `ended` (the game is over), `refused` (403 or 429) or `stopped`. */
50
+ export type FeedFollowState = "asking" | "waiting" | "hidden" | "ended" | "refused" | "stopped";
51
+ /** A live follow: its state, and a way to stop it. */
52
+ export interface FeedFollow {
53
+ state(): FeedFollowState;
54
+ stop(): void;
55
+ }
56
+ /** The least and the greatest wait between asks, in seconds. */
57
+ export declare const FOLLOW_EVERY: readonly [number, number];
58
+ /**
59
+ * Follow a feed live: ask the address now and again every `every` seconds (15 by default, never less than 10) while the page is shown, and tell `onGame` of each answer that changed. Asking stops while the page is
60
+ * hidden and starts again when it is shown; after an error the wait doubles, up to two minutes; a 403 or a 429 stops it (`refused`), and so does a game the feed says is over (`ended`). One timer at most at any time.
61
+ */
62
+ export declare function followFeed(url: string, options?: FeedFollowOptions): FeedFollow;
@@ -0,0 +1,177 @@
1
+ // A feed fetched by the reader's own browser, from an address the reader gave: never through a server of ours, with no cookie and no referrer, and nothing kept beyond the open page (no cache, no storage).
2
+ // A refusal is said in plain words: a server that does not let another site's page read it (CORS), a status, an answer too large. A live follow asks again no faster than every ten seconds, only while the page
3
+ // is shown, waits longer after an error, and stops when the game is over or the server says no (403, 429).
4
+ import { FeedError } from "./feeds.core.js";
5
+ import { readFeedText } from "./feeds.js";
6
+ /** The most an answer may be, by default: 20 MB. */
7
+ export const FEED_MOST = 20 * 1024 * 1024;
8
+ /** An address the reader gave, as a URL: `http` or `https` only (a relative address is read against the page's). Throws a `FeedError` (`address`) for anything else. */
9
+ export function feedAddress(url, base) {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(url.trim(), base ?? (typeof location === "undefined" ? undefined : location.href));
13
+ }
14
+ catch {
15
+ throw new FeedError("address", "not an address: give one that starts with https://");
16
+ }
17
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
18
+ throw new FeedError("address", "only an http or https address can be read");
19
+ if (parsed.username !== "" || parsed.password !== "")
20
+ throw new FeedError("address", "an address with a login in it is not read: give the feed's address alone");
21
+ return parsed;
22
+ }
23
+ /** Fetch an address's answer as text, by the reader's browser: no cookie, no referrer, no cache. */
24
+ export async function fetchFeedText(url, options = {}) {
25
+ const address = feedAddress(url, options.base);
26
+ const get = options.fetch ?? ((input, init) => fetch(input, init));
27
+ const most = options.most ?? FEED_MOST;
28
+ let answer;
29
+ try {
30
+ answer = await get(address.href, { credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", mode: "cors", redirect: "follow", signal: options.signal });
31
+ }
32
+ catch (error) {
33
+ if (error?.name === "AbortError")
34
+ throw error;
35
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
36
+ throw offline
37
+ ? new FeedError("network", "the browser is offline: the feed could not be reached")
38
+ : new FeedError("cors", "the browser could not read the answer: the server does not let a page of another site read it (CORS), or it could not be reached. Paste the answer or pick it as a file, or point at a relay of your own");
39
+ }
40
+ if (!answer.ok)
41
+ throw new FeedError("http", `the server answered ${answer.status}${answer.status === 403 || answer.status === 429 ? ": it does not want to be asked now" : ""}`, answer.status);
42
+ const length = Number(answer.headers.get("content-length"));
43
+ if (Number.isFinite(length) && length > most)
44
+ throw new FeedError("too-large", `the answer is ${Math.round(length / 1e6)} MB: more than this page reads`);
45
+ const text = await answer.text();
46
+ if (text.length > most)
47
+ throw new FeedError("too-large", "the answer is larger than this page reads");
48
+ return text;
49
+ }
50
+ /**
51
+ * Fetch a feed from an address the reader gave and read it into a game. Throws a `FeedError` that says what was wrong: `address`, `cors`, `network`, `http` (with its `status`), `too-large`, `format` or `empty`.
52
+ * @example
53
+ * ```ts
54
+ * import { loadFeed } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
55
+ * const stub = async () => new Response(JSON.stringify({ frames: [{ t: 0, objects: [] }, { t: 5, objects: [] }] }));
56
+ * const game = await loadFeed("https://example.com/feed.json", { fetch: stub });
57
+ * console.log(game.format, game.timeline.frames.length);
58
+ * const refused = async () => { throw new TypeError("Failed to fetch"); };
59
+ * await loadFeed("https://example.com/feed.json", { fetch: refused }).catch((error) => console.log(error.kind));
60
+ * // kyogijo 2
61
+ * // cors
62
+ * ```
63
+ */
64
+ export async function loadFeed(url, options = {}) {
65
+ return readFeedText(await fetchFeedText(url, options), options.format);
66
+ }
67
+ /** The least and the greatest wait between asks, in seconds. */
68
+ export const FOLLOW_EVERY = [10, 120];
69
+ /**
70
+ * Follow a feed live: ask the address now and again every `every` seconds (15 by default, never less than 10) while the page is shown, and tell `onGame` of each answer that changed. Asking stops while the page is
71
+ * hidden and starts again when it is shown; after an error the wait doubles, up to two minutes; a 403 or a 429 stops it (`refused`), and so does a game the feed says is over (`ended`). One timer at most at any time.
72
+ */
73
+ export function followFeed(url, options = {}) {
74
+ const doc = options.doc ?? (typeof document === "undefined" ? undefined : document);
75
+ const win = doc?.defaultView ?? (typeof window === "undefined" ? undefined : window);
76
+ const every = Math.min(FOLLOW_EVERY[1], Math.max(FOLLOW_EVERY[0], typeof options.every === "number" && Number.isFinite(options.every) ? options.every : 15));
77
+ let state = "waiting";
78
+ let wait = every;
79
+ let timer;
80
+ let last = "";
81
+ let due = 0;
82
+ let controller;
83
+ const set = (next) => {
84
+ if (next === state)
85
+ return;
86
+ state = next;
87
+ options.onState?.(state);
88
+ };
89
+ const over = () => state === "ended" || state === "refused" || state === "stopped";
90
+ /** Stop for good: no timer, no listener, no request. */
91
+ const finish = (next) => {
92
+ if (timer !== undefined)
93
+ (win ?? globalThis).clearTimeout(timer);
94
+ timer = undefined;
95
+ controller?.abort();
96
+ doc?.removeEventListener("visibilitychange", onVisible);
97
+ set(next);
98
+ };
99
+ const hidden = () => doc?.visibilityState === "hidden";
100
+ const schedule = (seconds) => {
101
+ if (over())
102
+ return;
103
+ due = Date.now() + seconds * 1000;
104
+ if (hidden()) {
105
+ set("hidden");
106
+ return;
107
+ }
108
+ set("waiting");
109
+ timer = (win ?? globalThis).setTimeout(ask, seconds * 1000);
110
+ };
111
+ async function ask() {
112
+ timer = undefined;
113
+ if (over())
114
+ return;
115
+ if (hidden()) {
116
+ set("hidden");
117
+ return;
118
+ }
119
+ set("asking");
120
+ controller = typeof AbortController === "function" ? new AbortController() : undefined;
121
+ try {
122
+ const text = await fetchFeedText(url, { ...options, signal: controller?.signal ?? options.signal });
123
+ if (over())
124
+ return;
125
+ if (text !== last) {
126
+ const game = readFeedText(text, options.format);
127
+ last = text;
128
+ options.onGame?.(game);
129
+ if (game.ended) {
130
+ finish("ended");
131
+ return;
132
+ }
133
+ }
134
+ wait = every;
135
+ schedule(wait);
136
+ }
137
+ catch (error) {
138
+ if (over())
139
+ return;
140
+ if (!(error instanceof FeedError)) {
141
+ if (error?.name === "AbortError")
142
+ return;
143
+ throw error;
144
+ }
145
+ options.onError?.(error);
146
+ if (error.kind === "http" && (error.status === 403 || error.status === 429)) {
147
+ finish("refused");
148
+ return;
149
+ }
150
+ if (error.kind === "address") {
151
+ finish("stopped");
152
+ return;
153
+ }
154
+ wait = Math.min(FOLLOW_EVERY[1], wait * 2);
155
+ schedule(wait);
156
+ }
157
+ }
158
+ // Shown again: ask now if the time came while it was hidden, else wait for what is left of it.
159
+ const onVisible = () => {
160
+ if (over() || hidden() || state !== "hidden")
161
+ return;
162
+ const left = Math.max(0, (due - Date.now()) / 1000);
163
+ if (left === 0)
164
+ void ask();
165
+ else
166
+ schedule(left);
167
+ };
168
+ doc?.addEventListener("visibilitychange", onVisible);
169
+ void ask();
170
+ return {
171
+ state: () => state,
172
+ stop() {
173
+ if (state !== "stopped")
174
+ finish("stopped");
175
+ },
176
+ };
177
+ }
package/dist/feeds.js ADDED
@@ -0,0 +1,211 @@
1
+ // A feed of a game read into a replay: kyogijo's own open timeline (a bare timeline, or a game file with its sides and periods) or a play-by-play of a shape that is common, each read by a pure function of
2
+ // its own (an adapter). The answer is the user's: it comes from a file they picked, text they pasted or an address they gave, and nothing in it is trusted. Every number is checked, every list is capped,
3
+ // and a name is only ever text. Nothing here fetches: `feeds.fetch.ts` is the page's side.
4
+ import { FEED_LIMITS, FeedError, feedText } from "./feeds.core.js";
5
+ import { nhlPlayByPlay } from "./feeds.nhl.js";
6
+ export { FEED_FORMATS, FEED_LIMITS, FeedError, feedText } from "./feeds.core.js";
7
+ const TEAMS = ["home", "away", "ball", "puck", "official"];
8
+ const isNum = (value) => typeof value === "number" && Number.isFinite(value);
9
+ const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ /** A side of a feed's game, as a scoreboard writes it: its letters, name and colour as text. */
11
+ const sideOf = (value) => {
12
+ if (!isObj(value))
13
+ return undefined;
14
+ const name = isObj(value.name) ? feedText(value.name.en ?? Object.values(value.name)[0]) : feedText(value.name);
15
+ const side = {};
16
+ const abbr = feedText(value.abbr, 8);
17
+ const color = feedText(value.color, 32);
18
+ if (abbr !== undefined)
19
+ side.abbr = abbr;
20
+ if (name !== undefined)
21
+ side.name = name;
22
+ if (color !== undefined && /^#[0-9a-f]{3,8}$/i.test(color))
23
+ side.color = color;
24
+ return side;
25
+ };
26
+ /** An object of a frame, checked: an id, a known team and a place, else nothing. */
27
+ function objectOf(value) {
28
+ if (!isObj(value) || !isNum(value.x) || !isNum(value.y))
29
+ return undefined;
30
+ const id = feedText(value.id, 40);
31
+ const team = TEAMS.find((t) => t === value.team);
32
+ if (id === undefined || team === undefined)
33
+ return undefined;
34
+ const o = { id, team, x: value.x, y: value.y };
35
+ const label = feedText(value.label);
36
+ if (label !== undefined)
37
+ o.label = label;
38
+ if (isNum(value.number) || typeof value.number === "string")
39
+ o.number = typeof value.number === "string" ? (feedText(value.number, 4) ?? "") : value.number;
40
+ return o;
41
+ }
42
+ /** An event, checked: a time and a kind, and what else it has that is of the right type. */
43
+ function eventOf(value) {
44
+ if (!isObj(value) || !isNum(value.t))
45
+ return undefined;
46
+ const kind = feedText(value.kind, 24);
47
+ if (kind === undefined)
48
+ return undefined;
49
+ const e = { t: value.t, kind };
50
+ if (value.team === "home" || value.team === "away")
51
+ e.team = value.team;
52
+ for (const key of ["x", "y", "x2", "y2", "until"])
53
+ if (isNum(value[key]))
54
+ e[key] = value[key];
55
+ for (const key of ["by", "to"]) {
56
+ const text = feedText(value[key], 40);
57
+ if (text !== undefined)
58
+ e[key] = text;
59
+ }
60
+ const label = feedText(value.label);
61
+ if (label !== undefined)
62
+ e.label = label;
63
+ return e;
64
+ }
65
+ /** A period, checked: a start and an end no earlier than it, and its number and game-clock length when they are numbers. */
66
+ function periodOf(value) {
67
+ if (!isObj(value) || !isNum(value.start) || !isNum(value.end) || value.end < value.start)
68
+ return undefined;
69
+ return { start: value.start, end: value.end, ...(isNum(value.number) ? { number: value.number } : {}), ...(isNum(value.length) && value.length > 0 ? { length: value.length } : {}) };
70
+ }
71
+ /** A stoppage, checked: a start and an end no earlier than it, and its reason, the place of its restart and the side it was called against when they are of the right type. */
72
+ function stoppageOf(value) {
73
+ if (!isObj(value) || !isNum(value.start) || !isNum(value.end) || value.end < value.start)
74
+ return undefined;
75
+ const s = { start: value.start, end: value.end };
76
+ const reason = feedText(value.reason, 24);
77
+ if (reason !== undefined)
78
+ s.reason = reason;
79
+ if (isNum(value.x))
80
+ s.x = value.x;
81
+ if (isNum(value.y))
82
+ s.y = value.y;
83
+ if (value.team === "home" || value.team === "away")
84
+ s.team = value.team;
85
+ return s;
86
+ }
87
+ /** A penalty, checked: a side, a start and its minutes on the game clock, and who serves it, when he leaves the box, its kind, whether his side plays short and whether a goal ended it, when they are of the right type. */
88
+ function penaltyOf(value) {
89
+ if (!isObj(value) || (value.team !== "home" && value.team !== "away") || !isNum(value.start) || !isNum(value.minutes) || value.minutes <= 0)
90
+ return undefined;
91
+ const p = { team: value.team, start: value.start, minutes: value.minutes };
92
+ if (isNum(value.end) && value.end >= value.start)
93
+ p.end = value.end;
94
+ const by = feedText(value.by, 40);
95
+ if (by !== undefined)
96
+ p.by = by;
97
+ const kind = feedText(value.kind, 24);
98
+ if (kind !== undefined)
99
+ p.kind = kind;
100
+ if (typeof value.short === "boolean")
101
+ p.short = value.short;
102
+ if (value.endedBy === "goal")
103
+ p.endedBy = "goal";
104
+ return p;
105
+ }
106
+ const FROM = /^[a-z0-9-]{1,32}$/;
107
+ /**
108
+ * Read kyogijo's own open timeline: a bare timeline (`{ frames, events?, periods?, stoppages?, penalties?, teams?, from? }`) or a game file (`{ kind: "kyogijo-game", sport, teams, periods, timeline }`, as
109
+ * `examples/games` has them). Every frame, object, event, period, stoppage and penalty is checked; what is not of the format is left out, and a timeline with no frame is an error.
110
+ */
111
+ export function kyogijoFeed(json) {
112
+ if (!isObj(json))
113
+ throw new FeedError("format", "not a kyogijo timeline: the answer is not a JSON object");
114
+ const game = json.kind === "kyogijo-game" && isObj(json.timeline);
115
+ const source = game ? json.timeline : json;
116
+ if (!Array.isArray(source.frames))
117
+ throw new FeedError("format", "not a kyogijo timeline: it has no list of frames");
118
+ const frames = [];
119
+ for (const f of source.frames.slice(0, FEED_LIMITS.frames)) {
120
+ if (!isObj(f) || !isNum(f.t) || !Array.isArray(f.objects))
121
+ continue;
122
+ const objects = f.objects.slice(0, FEED_LIMITS.objects).map(objectOf).filter((o) => o !== undefined);
123
+ const frame = { t: f.t, objects };
124
+ if (f.possession === null || typeof f.possession === "string")
125
+ frame.possession = f.possession === null ? null : feedText(f.possession, 40) ?? null;
126
+ frames.push(frame);
127
+ }
128
+ if (frames.length === 0)
129
+ throw new FeedError("empty", "the timeline has no frame to play");
130
+ const events = (Array.isArray(source.events) ? source.events.slice(0, FEED_LIMITS.events) : []).map(eventOf).filter((e) => e !== undefined);
131
+ const periodsIn = game ? json.periods : source.periods;
132
+ const periods = (Array.isArray(periodsIn) ? periodsIn.slice(0, FEED_LIMITS.periods) : []).map(periodOf).filter((p) => p !== undefined);
133
+ const stoppages = (Array.isArray(source.stoppages) ? source.stoppages.slice(0, FEED_LIMITS.stoppages) : [])
134
+ .map(stoppageOf)
135
+ .filter((s) => s !== undefined)
136
+ .sort((a, b) => a.start - b.start);
137
+ const penalties = (Array.isArray(source.penalties) ? source.penalties.slice(0, FEED_LIMITS.penalties) : [])
138
+ .map(penaltyOf)
139
+ .filter((p) => p !== undefined)
140
+ .sort((a, b) => a.start - b.start);
141
+ const teamsIn = game ? json.teams : source.teams;
142
+ const home = isObj(teamsIn) ? sideOf(teamsIn.home) : undefined;
143
+ const away = isObj(teamsIn) ? sideOf(teamsIn.away) : undefined;
144
+ const timeline = {
145
+ frames,
146
+ events,
147
+ ...(typeof source.from === "string" && FROM.test(source.from) ? { from: source.from } : {}),
148
+ ...(periods.length > 0 ? { periods } : {}),
149
+ ...(source.clock === "up" || source.clock === "down" ? { clock: source.clock } : {}),
150
+ ...(stoppages.length > 0 ? { stoppages } : {}),
151
+ ...(penalties.length > 0 ? { penalties } : {}),
152
+ };
153
+ const players = {};
154
+ const rosters = game && isObj(json.rosters) ? json.rosters : {};
155
+ for (const side of ["home", "away"]) {
156
+ const roster = rosters[side];
157
+ if (!Array.isArray(roster))
158
+ continue;
159
+ for (const p of roster.slice(0, 80)) {
160
+ const id = isObj(p) ? feedText(p.id, 40) : undefined;
161
+ if (id === undefined || !isObj(p))
162
+ continue;
163
+ const name = isObj(p.name) ? feedText(p.name.en ?? Object.values(p.name)[0]) : feedText(p.name);
164
+ players[id] = { side, ...(isNum(p.number) ? { number: p.number } : {}), ...(name === undefined ? {} : { name }) };
165
+ }
166
+ }
167
+ const sport = game ? feedText(json.sport, 24) : undefined;
168
+ return { format: "kyogijo", ...(sport === undefined ? {} : { sport }), timeline, teams: { home: home ?? {}, away: away ?? {} }, ended: true, players };
169
+ }
170
+ /** Which format a feed's answer is in: a timeline or a game file of kyogijo's, or a play-by-play shaped as the NHL's. `undefined` when it is neither. */
171
+ export function feedFormatOf(json) {
172
+ if (!isObj(json))
173
+ return undefined;
174
+ if (json.kind === "kyogijo-game" || Array.isArray(json.frames))
175
+ return "kyogijo";
176
+ if (Array.isArray(json.plays) && (isObj(json.homeTeam) || isObj(json.awayTeam)))
177
+ return "nhl";
178
+ return undefined;
179
+ }
180
+ /**
181
+ * Read a feed's answer into a game: in the format asked, or the one `feedFormatOf` finds. Throws a `FeedError` (`format`, `empty`) that says what was wrong in plain words.
182
+ * @example
183
+ * ```ts
184
+ * import { readFeed } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
185
+ * const answer = { frames: [{ t: 0, objects: [{ id: "puck", team: "puck", x: 0, y: 0 }] }, { t: 30, objects: [{ id: "puck", team: "puck", x: 80, y: 4 }] }], events: [{ t: 30, kind: "goal", team: "home" }], from: "nhl-api" };
186
+ * const game = readFeed(answer);
187
+ * console.log(game.format, game.timeline.frames.length, game.timeline.events?.length);
188
+ * try { readFeed({ hello: "world" }); } catch (error) { console.log((error as Error).name, (error as { kind?: string }).kind); }
189
+ * // kyogijo 2 1
190
+ * // FeedError format
191
+ * ```
192
+ */
193
+ export function readFeed(json, format = "auto") {
194
+ const as = format === "auto" ? feedFormatOf(json) : format;
195
+ if (as === "kyogijo")
196
+ return kyogijoFeed(json);
197
+ if (as === "nhl")
198
+ return nhlPlayByPlay(json);
199
+ throw new FeedError("format", "not a feed this package reads: neither a kyogijo timeline nor an NHL-style play-by-play");
200
+ }
201
+ /** Read text (a file or what was pasted) as JSON and then as a feed. */
202
+ export function readFeedText(text, format = "auto") {
203
+ let json;
204
+ try {
205
+ json = JSON.parse(text);
206
+ }
207
+ catch {
208
+ throw new FeedError("format", "not JSON: the text could not be read");
209
+ }
210
+ return readFeed(json, format);
211
+ }
@@ -0,0 +1,28 @@
1
+ import { type FeedGame } from "./feeds.core.ts";
2
+ /** A regulation period, and an overtime in the regular season (the playoffs play twenty-minute overtimes): seconds. */
3
+ export declare const NHL_PERIOD = 1200;
4
+ export declare const NHL_OVERTIME = 300;
5
+ /**
6
+ * Read a play-by-play shaped as the NHL's public one into a game: the puck's places at the plays that have one (between them it moves in a straight line), a mark for each shot, goal and whistle, the periods with their
7
+ * clocks (a twenty-minute period, the board counting it down, an overtime of five minutes in the regular season and twenty in the playoffs, the shoot-out left out), each side's letters and name from the feed, and the players it names
8
+ * (by `p` and their id). Times are the game's, so the board's clock is the game's: period 2 starts at 1200 s. Throws a `FeedError` when it is not of the shape, or has no play with a place.
9
+ * @example
10
+ * ```ts
11
+ * import { nhlPlayByPlay } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
12
+ * const answer = {
13
+ * gameState: "LIVE", gameType: 2,
14
+ * homeTeam: { id: 1, abbrev: "AAA", commonName: { default: "Aardvarks" } }, awayTeam: { id: 2, abbrev: "BBB" },
15
+ * plays: [
16
+ * { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "00:00", typeDescKey: "faceoff", sortOrder: 1, details: { xCoord: 0, yCoord: 0 } },
17
+ * { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "01:30", typeDescKey: "goal", sortOrder: 2, details: { xCoord: 80, yCoord: -4, eventOwnerTeamId: 1, scoringPlayerId: 9 } },
18
+ * ],
19
+ * rosterSpots: [{ teamId: 1, playerId: 9, sweaterNumber: 19, firstName: { default: "Ada" }, lastName: { default: "Example" } }],
20
+ * };
21
+ * const game = nhlPlayByPlay(answer);
22
+ * console.log(game.timeline.events, game.teams.home, game.ended);
23
+ * console.log(game.players.p9);
24
+ * // [ { t: 90, kind: 'goal', x: 80, y: -4, team: 'home', by: 'p9' } ] { abbr: 'AAA', name: 'Aardvarks' } false
25
+ * // { side: 'home', number: 19, name: 'Ada Example' }
26
+ * ```
27
+ */
28
+ export declare function nhlPlayByPlay(json: unknown): FeedGame;
@@ -0,0 +1,132 @@
1
+ // An adapter: a play-by-play shaped as the NHL's public one (`plays`, each with `periodDescriptor`, `timeInPeriod`, `typeDescKey` and `details` with `xCoord` and `yCoord` in feet from centre ice; `homeTeam` and
2
+ // `awayTeam` with their `id` and `abbrev`; `rosterSpots`; `gameState`) read into a replay. A play-by-play has events and no tracking: the puck goes from one event's place to the next in a straight line (it is
3
+ // modelled between the known places), the shots, the goals and the whistles are marked where they were, and the clock is the game's (twenty minutes a period). Pure; every value is checked. The shape was read
4
+ // from public descriptions of the answer; tests run on answers we made up.
5
+ import { FEED_LIMITS, FeedError, feedText } from "./feeds.core.js";
6
+ const isNum = (value) => typeof value === "number" && Number.isFinite(value);
7
+ const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8
+ /** A text field of the NHL's shape: a string, or `{ default: string }`. */
9
+ const textOf = (value, most) => feedText(isObj(value) ? value.default : value, most);
10
+ /** `mm:ss` as seconds; `undefined` for anything else. */
11
+ const secondsOf = (value) => {
12
+ const m = typeof value === "string" ? /^(\d{1,3}):([0-5]\d)$/.exec(value.trim()) : null;
13
+ return m === null ? undefined : Number(m[1]) * 60 + Number(m[2]);
14
+ };
15
+ /** A regulation period, and an overtime in the regular season (the playoffs play twenty-minute overtimes): seconds. */
16
+ export const NHL_PERIOD = 1200;
17
+ export const NHL_OVERTIME = 300;
18
+ /** What a kind of play is on the replay: the shots, the goal, and a whistle for a stoppage or a penalty. The rest only moves the puck. */
19
+ const KINDS = { "shot-on-goal": "shot", "missed-shot": "shot", "blocked-shot": "shot", goal: "goal", stoppage: "whistle", penalty: "whistle", "delayed-penalty": "whistle" };
20
+ /** The states of a game the NHL's shape says are over. */
21
+ const OVER = new Set(["OFF", "FINAL"]);
22
+ /**
23
+ * Read a play-by-play shaped as the NHL's public one into a game: the puck's places at the plays that have one (between them it moves in a straight line), a mark for each shot, goal and whistle, the periods with their
24
+ * clocks (a twenty-minute period, the board counting it down, an overtime of five minutes in the regular season and twenty in the playoffs, the shoot-out left out), each side's letters and name from the feed, and the players it names
25
+ * (by `p` and their id). Times are the game's, so the board's clock is the game's: period 2 starts at 1200 s. Throws a `FeedError` when it is not of the shape, or has no play with a place.
26
+ * @example
27
+ * ```ts
28
+ * import { nhlPlayByPlay } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
29
+ * const answer = {
30
+ * gameState: "LIVE", gameType: 2,
31
+ * homeTeam: { id: 1, abbrev: "AAA", commonName: { default: "Aardvarks" } }, awayTeam: { id: 2, abbrev: "BBB" },
32
+ * plays: [
33
+ * { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "00:00", typeDescKey: "faceoff", sortOrder: 1, details: { xCoord: 0, yCoord: 0 } },
34
+ * { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "01:30", typeDescKey: "goal", sortOrder: 2, details: { xCoord: 80, yCoord: -4, eventOwnerTeamId: 1, scoringPlayerId: 9 } },
35
+ * ],
36
+ * rosterSpots: [{ teamId: 1, playerId: 9, sweaterNumber: 19, firstName: { default: "Ada" }, lastName: { default: "Example" } }],
37
+ * };
38
+ * const game = nhlPlayByPlay(answer);
39
+ * console.log(game.timeline.events, game.teams.home, game.ended);
40
+ * console.log(game.players.p9);
41
+ * // [ { t: 90, kind: 'goal', x: 80, y: -4, team: 'home', by: 'p9' } ] { abbr: 'AAA', name: 'Aardvarks' } false
42
+ * // { side: 'home', number: 19, name: 'Ada Example' }
43
+ * ```
44
+ */
45
+ export function nhlPlayByPlay(json) {
46
+ if (!isObj(json) || !Array.isArray(json.plays))
47
+ throw new FeedError("format", "not an NHL-style play-by-play: it has no list of plays");
48
+ const home = isObj(json.homeTeam) ? json.homeTeam : {};
49
+ const away = isObj(json.awayTeam) ? json.awayTeam : {};
50
+ const sideOf = (teamId) => (teamId !== undefined && teamId === home.id ? "home" : teamId !== undefined && teamId === away.id ? "away" : undefined);
51
+ const playoffs = json.gameType === 3;
52
+ const lengthOf = (number, type) => (type === "OT" || (type === undefined && number > 3) ? (playoffs ? NHL_PERIOD : NHL_OVERTIME) : NHL_PERIOD);
53
+ // The plays in order, the shoot-out left out (it has no clock, and its goals are not the score's).
54
+ const plays = json.plays
55
+ .slice(0, FEED_LIMITS.events)
56
+ .filter(isObj)
57
+ .map((play, i) => ({ play, order: isNum(play.sortOrder) ? play.sortOrder : i }))
58
+ .sort((a, b) => a.order - b.order)
59
+ .map((one) => one.play);
60
+ const periods = new Map();
61
+ const frames = [];
62
+ const events = [];
63
+ /** The time a period starts on the game's clock: the lengths of the periods before it. */
64
+ const startOf = (number, type) => {
65
+ let start = 0;
66
+ for (let p = 1; p < number; p += 1)
67
+ start += lengthOf(p, p <= 3 ? "REG" : type);
68
+ return start;
69
+ };
70
+ for (const play of plays) {
71
+ const d = isObj(play.periodDescriptor) ? play.periodDescriptor : {};
72
+ const number = isNum(d.number) && d.number >= 1 && d.number <= FEED_LIMITS.periods ? Math.trunc(d.number) : undefined;
73
+ if (number === undefined || d.periodType === "SO")
74
+ continue;
75
+ const length = lengthOf(number, d.periodType);
76
+ const start = startOf(number, d.periodType);
77
+ if (!periods.has(number))
78
+ periods.set(number, { number, start, end: start + length });
79
+ const into = secondsOf(play.timeInPeriod);
80
+ if (into === undefined)
81
+ continue;
82
+ const t = start + Math.min(length, into);
83
+ const details = isObj(play.details) ? play.details : {};
84
+ const placed = isNum(details.xCoord) && isNum(details.yCoord);
85
+ const [x, y] = placed ? [Math.max(-100, Math.min(100, details.xCoord)), Math.max(-42.5, Math.min(42.5, details.yCoord))] : [0, 0];
86
+ const type = typeof play.typeDescKey === "string" ? play.typeDescKey : "";
87
+ // A period starts with the puck at centre ice.
88
+ if (type === "period-start")
89
+ frames.push({ t, objects: [{ id: "puck", team: "puck", x: 0, y: 0 }] });
90
+ if (placed)
91
+ frames.push({ t, objects: [{ id: "puck", team: "puck", x, y }] });
92
+ const kind = KINDS[type];
93
+ if (kind === undefined)
94
+ continue;
95
+ const team = sideOf(details.eventOwnerTeamId);
96
+ const by = isNum(details.scoringPlayerId) ? details.scoringPlayerId : isNum(details.shootingPlayerId) ? details.shootingPlayerId : isNum(details.committedByPlayerId) ? details.committedByPlayerId : undefined;
97
+ events.push({ t, kind, ...(placed ? { x, y } : {}), ...(team === undefined ? {} : { team }), ...(by === undefined ? {} : { by: `p${by}` }) });
98
+ }
99
+ if (frames.length === 0)
100
+ throw new FeedError("empty", "the play-by-play has no play with a place on the ice yet");
101
+ // Frames at one time keep the last; the timeline is in order.
102
+ const byTime = new Map();
103
+ for (const f of frames)
104
+ byTime.set(f.t, f);
105
+ const ordered = [...byTime.values()].sort((a, b) => a.t - b.t);
106
+ const side = (team) => {
107
+ const abbr = textOf(team.abbrev, 8);
108
+ const name = textOf(team.commonName) ?? textOf(team.name) ?? textOf(team.placeName);
109
+ return { ...(abbr === undefined ? {} : { abbr }), ...(name === undefined ? {} : { name }) };
110
+ };
111
+ const players = {};
112
+ if (Array.isArray(json.rosterSpots)) {
113
+ for (const spot of json.rosterSpots.slice(0, 120)) {
114
+ if (!isObj(spot) || !isNum(spot.playerId))
115
+ continue;
116
+ const s = sideOf(spot.teamId);
117
+ if (s === undefined)
118
+ continue;
119
+ const name = [textOf(spot.firstName, 40), textOf(spot.lastName, 40)].filter((part) => part !== undefined).join(" ");
120
+ players[`p${spot.playerId}`] = { side: s, ...(isNum(spot.sweaterNumber) ? { number: spot.sweaterNumber } : {}), ...(name === "" ? {} : { name }) };
121
+ }
122
+ }
123
+ const state = typeof json.gameState === "string" ? json.gameState : "";
124
+ return {
125
+ format: "nhl",
126
+ sport: "nhl",
127
+ timeline: { from: "nhl-api", frames: ordered, events, periods: [...periods.values()].sort((a, b) => a.start - b.start) },
128
+ teams: { home: side(home), away: side(away) },
129
+ ended: OVER.has(state),
130
+ players,
131
+ };
132
+ }
@@ -62,7 +62,7 @@ export function mountJudgingBroadcast(host, options, settings = {}) {
62
62
  direction: () => turn.direction(),
63
63
  setDirection: (direction) => turn.setDirection(direction),
64
64
  togglePlay: () => turn.togglePlay(),
65
- replay: (timeline, replaySettings) => turn.replay(timeline, replaySettings),
65
+ replay: turn.replay,
66
66
  get quality() {
67
67
  return turn.quality;
68
68
  },
@@ -2,3 +2,4 @@ import "./full.ts";
2
2
  import "./venues.all.ts";
3
3
  export { containerAspect, kyogijoElement, mountKyogijo } from "./mount.ts";
4
4
  export type { KyogijoMount, KyogijoReplay, MountSettings } from "./mount.ts";
5
+ export type { FeedReplay, FeedReplayOptions, ReplayCall, ReplayFollow, ReplayFollowOptions } from "./replay.feed.ts";
package/dist/mount.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ReplayController } from "./replay.control.ts";
2
- import type { ReplaySettings, ReplayTimeline } from "./replay.types.ts";
2
+ import { type ReplayCall } from "./replay.feed.ts";
3
3
  import type { ScoreOptions, SurfaceOptions } from "./types.ts";
4
4
  /** A replay on a mount: the controller of its clock, and `destroy` to take the replay away and leave the surface. */
5
5
  export type KyogijoReplay = ReplayController & {
@@ -16,9 +16,10 @@ export interface KyogijoMount {
16
16
  /**
17
17
  * Play tracking data on the surface: the players, the ball and the events of a timeline, with controls under the drawing (play, pause, step,
18
18
  * speed, loop, position), the keyboard (space, the arrow keys, Home and End) and a live region for goals and shots. Only the marks that move are
19
- * moved, on animation frames. A reader who prefers reduced motion gets steps, and nothing plays by itself; a hidden page pauses. `update` keeps the replay.
19
+ * moved, on animation frames. A reader who prefers reduced motion gets steps, and nothing plays by itself; a hidden page pauses. `update` keeps the replay. `replay.fromUrl(url, options)` plays a feed from an
20
+ * address the reader gives, and `replay.follow(url, options)` follows one live, once the `/feeds` entry is loaded (see `docs/feeds.md`).
20
21
  */
21
- replay(timeline: ReplayTimeline, settings?: ReplaySettings): KyogijoReplay;
22
+ replay: ReplayCall;
22
23
  /** Stop watching the container and empty it. */
23
24
  destroy(): void;
24
25
  }
package/dist/mount.js CHANGED
@@ -2,6 +2,7 @@ import { drawKyogijo, isPortrait } from "./draw.js";
2
2
  import { resolveHomeSide } from "./homeside.js";
3
3
  import { chooseLeague } from "./leagues.js";
4
4
  import { createReplayView } from "./replay.dom.js";
5
+ import { withFeeds } from "./replay.feed.js";
5
6
  const isScored = (o) => o.home !== undefined && o.away !== undefined;
6
7
  const scoreText = (team) => (team.score === undefined || team.score === null ? "" : String(team.score));
7
8
  /**
@@ -120,7 +121,7 @@ export function mountKyogijo(host, options, settings = {}) {
120
121
  if (settings.highlight !== false)
121
122
  highlightScores(host, before, next);
122
123
  },
123
- replay(timeline, replaySettings = {}) {
124
+ replay: withFeeds((timeline, replaySettings = {}) => {
124
125
  if (!alive)
125
126
  throw new Error("this mount was destroyed");
126
127
  view?.destroy();
@@ -136,7 +137,7 @@ export function mountKyogijo(host, options, settings = {}) {
136
137
  render();
137
138
  },
138
139
  };
139
- },
140
+ }),
140
141
  svg: () => (alive ? host.querySelector("svg.kyogijo") : null),
141
142
  orientation: () => (portrait ? "portrait" : "landscape"),
142
143
  destroy() {
@@ -0,0 +1,54 @@
1
+ import type { FeedFormat, FeedGame } from "./feeds.core.ts";
2
+ import type { FeedError } from "./feeds.core.ts";
3
+ import type { FeedFollowState } from "./feeds.fetch.ts";
4
+ import type { KyogijoReplay } from "./mount.ts";
5
+ import type { ReplaySettings, ReplayTimeline } from "./replay.types.ts";
6
+ /** What a feed played from an address can be told: its format, the replay's settings, and for a test the function that fetches. */
7
+ export interface FeedReplayOptions {
8
+ format?: FeedFormat;
9
+ settings?: ReplaySettings;
10
+ base?: string;
11
+ signal?: AbortSignal;
12
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
13
+ }
14
+ /** A replay of a feed: the replay's controller, and the game the feed was read into. */
15
+ export type FeedReplay = KyogijoReplay & {
16
+ feed: FeedGame;
17
+ };
18
+ /** What a live follow can be told, beyond `FeedReplayOptions`. */
19
+ export interface ReplayFollowOptions extends FeedReplayOptions {
20
+ /** Seconds between asks: 15 by default, never less than 10. */
21
+ every?: number;
22
+ /** Each new answer, after it is played. */
23
+ onGame?: (game: FeedGame, replay: KyogijoReplay) => void;
24
+ onError?: (error: FeedError) => void;
25
+ onState?: (state: FeedFollowState) => void;
26
+ /** The document whose visibility it follows: the page's by default. */
27
+ doc?: Document;
28
+ }
29
+ /** A replay that follows a feed live: the replay playing now (a new one on each answer that changed), the follow's state, and `stop`, which stops asking and leaves the replay. */
30
+ export interface ReplayFollow {
31
+ replay(): KyogijoReplay | undefined;
32
+ state(): FeedFollowState;
33
+ stop(): void;
34
+ }
35
+ /** A mount's or a view's `replay`: play a timeline, or a feed from an address. */
36
+ export interface ReplayCall {
37
+ (timeline: ReplayTimeline, settings?: ReplaySettings): KyogijoReplay;
38
+ /**
39
+ * Fetch a feed from an address, by the reader's browser (no server of ours, no cookie, nothing kept), read it (`readFeed`: kyogijo's open timeline, or an NHL-style play-by-play) and play it. Rejects with a
40
+ * `FeedError` that says what was wrong: a server that does not let the page read it (`cors`), a status (`http`), an answer that is not a feed (`format`).
41
+ */
42
+ fromUrl(url: string, options?: FeedReplayOptions): Promise<FeedReplay>;
43
+ /** Follow a feed live: ask it every 15 seconds or more while the page is shown, play each answer that changed from where the replay was (or at its newest moment, when it was there), and stop when the game is over or the server says no. */
44
+ follow(url: string, options?: ReplayFollowOptions): ReplayFollow;
45
+ }
46
+ /** What `feeds.dom.ts` lends a `replay`. */
47
+ export interface FeedKit {
48
+ fromUrl(play: (timeline: ReplayTimeline, settings?: ReplaySettings) => KyogijoReplay, url: string, options?: FeedReplayOptions): Promise<FeedReplay>;
49
+ follow(play: (timeline: ReplayTimeline, settings?: ReplaySettings) => KyogijoReplay, url: string, options?: ReplayFollowOptions): ReplayFollow;
50
+ }
51
+ /** Put the feeds' code in: called once by `feeds.dom.ts`. */
52
+ export declare function useFeedKit(next: FeedKit): void;
53
+ /** A `replay` with `fromUrl` and `follow` on it. */
54
+ export declare function withFeeds(play: (timeline: ReplayTimeline, settings?: ReplaySettings) => KyogijoReplay): ReplayCall;
@@ -0,0 +1,17 @@
1
+ let kit;
2
+ /** Put the feeds' code in: called once by `feeds.dom.ts`. */
3
+ export function useFeedKit(next) {
4
+ kit = next;
5
+ }
6
+ const need = () => {
7
+ if (kit === undefined)
8
+ throw new Error("the feeds are not loaded: import \"@johnmorrisdotca/kyogijo-stadiums/feeds\" once, before replay.fromUrl or replay.follow");
9
+ return kit;
10
+ };
11
+ /** A `replay` with `fromUrl` and `follow` on it. */
12
+ export function withFeeds(play) {
13
+ return Object.assign(play, {
14
+ fromUrl: (url, options) => need().fromUrl(play, url, options),
15
+ follow: (url, options) => need().follow(play, url, options),
16
+ });
17
+ }
@@ -6,6 +6,7 @@ export { discSize, keepOnFloor, replayTurnModel } from "./turn.replay.ts";
6
6
  export type { TurnReplayLook, TurnReplayModel } from "./turn.replay.ts";
7
7
  export type { TurnReplaySettings } from "./turn.replay.dom.ts";
8
8
  import "./turn.replay.dom.ts";
9
+ export type { FeedReplay, FeedReplayOptions, ReplayCall, ReplayFollow, ReplayFollowOptions } from "./replay.feed.ts";
9
10
  export { buildTurn, floorOptions, openingView } from "./turn.model.ts";
10
11
  export { turnDirection } from "./turn.camera.ts";
11
12
  export type { TurnDirection } from "./turn.camera.ts";
@@ -4,6 +4,7 @@ import type { ScoreOptions, SurfaceOptions } from "./types.ts";
4
4
  import type { VenueSettings } from "./venue.ts";
5
5
  import { type Quality, type QualityTier } from "./quality.ts";
6
6
  import type { OutsideLevel } from "./outside.paint.ts";
7
+ import { type ReplayCall } from "./replay.feed.ts";
7
8
  import type { ReplaySettings, ReplayTimeline } from "./replay.types.ts";
8
9
  import type { KyogijoReplay } from "./mount.ts";
9
10
  import type { TurnInternals } from "./turn.replay.dom.ts";
@@ -70,8 +71,9 @@ export interface KyogijoTurn<O = SurfaceOptions | ScoreOptions> {
70
71
  * Play tracking data on the view: the players stand up on the surface as discs that face the eye (home circles, away squares, numbers), the ball or the puck runs on the floor with its trail, and the passes, shots and
71
72
  * goals are drawn on the floor as in the flat replay. The replay's controls go under the view's; the view still turns, tips and zooms while it plays. Space on the view plays and pauses, comma and full stop step; the
72
73
  * arrow keys still turn the view. `destroy` on what it gives takes the replay away and leaves the view. A tournament layout has no replay. Where the browser cannot draw the 3D view, the flat replay plays instead.
74
+ * `replay.fromUrl(url, options)` plays a feed from an address the reader gives, and `replay.follow(url, options)` follows one live, once the `/feeds` entry is loaded (see `docs/feeds.md`).
73
75
  */
74
- replay(timeline: ReplayTimeline, settings?: ReplaySettings): KyogijoReplay;
76
+ replay: ReplayCall;
75
77
  /** Stop listening and empty the container. */
76
78
  destroy(): void;
77
79
  }
package/dist/turn.dom.js CHANGED
@@ -13,6 +13,7 @@ import { turnBoardMarkup } from "./overlay.js";
13
13
  import { attachOutside3d } from "./outside.dom.js";
14
14
  import { TIER_PROFILES, createGovernor, handheld, layerBudget, qualityOf, rasterOf, rasterShares, tierOf, viewShare, FLOOR_SHARE } from "./quality.js";
15
15
  import { size } from "./turn.kit.js";
16
+ import { withFeeds } from "./replay.feed.js";
16
17
  /**
17
18
  * The replay's own code, which `turn.replay.dom.ts` puts in when it is loaded (the `/turn` entry and the element load it): a page that only turns a view (a district, a tournament layout) carries none of the replay
18
19
  * and its layers.
@@ -148,7 +149,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
148
149
  },
149
150
  svg: () => (alive ? host.querySelector("svg.kyogijo") : null),
150
151
  // The flat replay, where the 3D view cannot be drawn.
151
- replay(timeline, replaySettings = {}) {
152
+ replay: withFeeds((timeline, replaySettings = {}) => {
152
153
  if (!replayable)
153
154
  throw new Error("a replay plays on one surface, not on a layout");
154
155
  flatReplay?.view.destroy();
@@ -165,7 +166,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
165
166
  flat(current);
166
167
  },
167
168
  };
168
- },
169
+ }),
169
170
  destroy: () => {
170
171
  alive = false;
171
172
  flatReplay?.view.destroy();
@@ -887,7 +888,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
887
888
  return last === undefined ? undefined : { ms: last.ms, people: last.stats.people, mode: last.stats.mode, pixels: last.pixels };
888
889
  },
889
890
  svg: () => (alive ? root.querySelector(".ky-turn-floor svg.kyogijo") : null),
890
- replay(timeline, replaySettings = {}) {
891
+ replay: withFeeds((timeline, replaySettings = {}) => {
891
892
  if (!alive)
892
893
  throw new Error("this view was destroyed");
893
894
  if (!replayable)
@@ -904,7 +905,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
904
905
  mine.destroy();
905
906
  },
906
907
  };
907
- },
908
+ }),
908
909
  destroy() {
909
910
  if (!alive)
910
911
  return;
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.62.0";
2
+ export declare const VERSION = "0.63.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.62.0";
2
+ export const VERSION = "0.63.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kyogijo-stadiums",
3
- "version": "0.62.0",
3
+ "version": "0.63.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",
@@ -68,6 +68,7 @@
68
68
  "sideEffects": [
69
69
  "./dist/element-define.js",
70
70
  "./dist/turn.replay.dom.js",
71
+ "./dist/feeds.dom.js",
71
72
  "./dist/frames.providers.js",
72
73
  "./dist/full.js",
73
74
  "./dist/arenas.js",
@@ -244,6 +245,11 @@
244
245
  "import": "./dist/sound-entry.js",
245
246
  "default": "./dist/sound-entry.js"
246
247
  },
248
+ "./feeds": {
249
+ "types": "./dist/feeds-entry.d.ts",
250
+ "import": "./dist/feeds-entry.js",
251
+ "default": "./dist/feeds-entry.js"
252
+ },
247
253
  "./scene": {
248
254
  "types": "./dist/scene-entry.d.ts",
249
255
  "import": "./dist/scene-entry.js",