@johnmorrisdotca/kyogijo-stadiums 0.61.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 +19 -0
- package/README.md +4 -3
- package/catalogue.json +1 -1
- package/dist/catalogue.data.js +1 -1
- package/dist/feeds-entry.d.ts +7 -0
- package/dist/feeds-entry.js +6 -0
- package/dist/feeds.core.d.ts +55 -0
- package/dist/feeds.core.js +21 -0
- package/dist/feeds.d.ts +26 -0
- package/dist/feeds.dom.d.ts +1 -0
- package/dist/feeds.dom.js +34 -0
- package/dist/feeds.fetch.d.ts +62 -0
- package/dist/feeds.fetch.js +177 -0
- package/dist/feeds.js +211 -0
- package/dist/feeds.nhl.d.ts +28 -0
- package/dist/feeds.nhl.js +132 -0
- package/dist/judging.mount.js +1 -1
- package/dist/mount-entry.d.ts +1 -0
- package/dist/mount.d.ts +4 -3
- package/dist/mount.js +3 -2
- package/dist/replay-entry.d.ts +7 -3
- package/dist/replay-entry.js +3 -1
- package/dist/replay.board.d.ts +75 -4
- package/dist/replay.board.js +113 -13
- package/dist/replay.d.ts +1 -1
- package/dist/replay.deadtime.d.ts +58 -0
- package/dist/replay.deadtime.js +169 -0
- package/dist/replay.feed.d.ts +54 -0
- package/dist/replay.feed.js +17 -0
- package/dist/replay.hockey.d.ts +143 -0
- package/dist/replay.hockey.js +276 -0
- package/dist/replay.js +12 -6
- package/dist/replay.types.d.ts +41 -0
- package/dist/strings.core.d.ts +1 -0
- package/dist/strings.en.js +1 -1
- package/dist/strings.ja.js +1 -1
- package/dist/turn-entry.d.ts +1 -0
- package/dist/turn.dom.d.ts +3 -1
- package/dist/turn.dom.js +5 -4
- package/dist/turn.replay.board.d.ts +1 -1
- package/dist/turn.replay.board.js +20 -3
- package/dist/venue.boards.d.ts +6 -0
- package/dist/venue.boards.js +20 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ 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
|
+
|
|
19
|
+
## [0.62.0] - 2026-11-03
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Penalties, power plays and the penalty box (KYOGIJO-STADIUMS-48)** ([penalties](./docs/replay.md#penalties-and-the-power-play)): a penalty in a replay is a stoppage with the face-off in the offending side's end; the penalized player skates to his side's penalty box, across the ice from the benches, and sits there for the penalty's time on the game clock while his side plays a skater short (two for overlapping penalties, never below three skaters), then comes back into the play. A timeline has `penalties` now; the board over the arena shows each side's strength and each penalty's clock (the time left in the short side's block of colour, PP in the other's), and `replayBoard` gives `strength` and `penalties`. One rule set, in `/replay`: `hockeyPenaltyEnds` (a minor 2 minutes, a double minor 4, a major 5, a misconduct 10 with a substitute; a power-play goal ends a minor but not a major; coincidental penalties keep the strength even; a third penalty waits), `hockeyPenaltyKind` (the NHL play-by-play's kinds), `hockeyPenaltyBox` (a stand-in for the rink's own boxes), `penaltiesAt` and `replayGameTime`. The example game has its seven penalties (`examples/games/canada-japan.json`, 1.1 MB). Players on the bench are not drawn.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Stoppages play like hockey (KYOGIJO-STADIUMS-45)** ([stoppages](./docs/replay.md#stoppages-the-clock-stops-the-players-line-up)): after a goal the example game no longer moves the puck from the net to centre ice as if it were passed. A timeline has `stoppages` now: in each the game clock stands still (the board over the arena shows the same time from the goal to the drop) while the replay's own clock moves on, the puck is dead (it stays where play stopped, in the net after a goal, then is out of sight until the drop), and the players glide to the face-off's line-up at the spot the rules give, a line going off by its bench; the clock runs again at the drop. One rule set for hockey, in `/replay`: `hockeyFaceoffDot` (centre after a goal and at a period's start, the end-zone spot after a save or a frozen puck, the offending side's end after icing or a penalty, the neutral-zone spot after offside, the nearest spot in the zone otherwise), `hockeyFormation` (the line-up of Rule 76), `HOCKEY_DEAD_TIME` and `HOCKEY_RULES` (the speeds and seconds), `hockeyStopOf` (the NHL play-by-play's words), and, for any sport, `addDeadTime`, `replayGameClock`, `replayStoppages`, `stoppageAt` and `restartsBetween`. A `faceoff` event is said at each drop and draws no mark. A trail begins again after a frame its object is not in. `examples/games/canada-japan.json` is made again with the rules: 66 stoppages, about fifteen minutes, 1.1 MB (was 1.3 MB). The other sports' dead time is planned in the docs, not built.
|
|
28
|
+
|
|
10
29
|
## [0.61.0] - 2026-11-03
|
|
11
30
|
|
|
12
31
|
### Added
|
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ Real buildings drawn in the outline OpenStreetMap maps for them, each with a pla
|
|
|
159
159
|
<td align="center" valign="top">
|
|
160
160
|
<picture>
|
|
161
161
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kyogijo-stadiums/main/docs/images/replay-game-3d-desk-dark.webp">
|
|
162
|
-
<img src="https://raw.githubusercontent.com/johnmorrisdotca/kyogijo-stadiums/main/docs/images/replay-game-3d-desk-light.webp" alt="A made-up game of Canada and Japan replayed on the rink in 3D from the main camera: the players standing up as discs with their numbers, red circles and blue squares, the score hung over centre ice, and a
|
|
162
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/kyogijo-stadiums/main/docs/images/replay-game-3d-desk-light.webp" alt="A made-up game of Canada and Japan replayed on the rink in 3D from the main camera: the players standing up as discs with their numbers, red circles and blue squares, the score hung over centre ice, and a goal's arrow into the net." width="250">
|
|
163
163
|
</picture>
|
|
164
164
|
<br><em>A made-up game, replayed in 3D</em>
|
|
165
165
|
</td>
|
|
@@ -182,7 +182,7 @@ Real buildings drawn in the outline OpenStreetMap maps for them, each with a pla
|
|
|
182
182
|
|
|
183
183
|
A camera can follow the play, flat or in 3D: it closes in where the action is and pulls back as the players spread, or moves with the puck, while the view goes on turning ([following the play](./docs/replay.md#following-the-play)).
|
|
184
184
|
|
|
185
|
-
The scoreboard hung over an arena's ice follows the replay: its score, period and game clock come from the same timeline and clock as the controls ([the scoreboard over the arena](./docs/replay.md#the-scoreboard-over-the-arena-follows-the-game)).
|
|
185
|
+
The scoreboard hung over an arena's ice follows the replay: its score, period and game clock come from the same timeline and clock as the controls ([the scoreboard over the arena](./docs/replay.md#the-scoreboard-over-the-arena-follows-the-game)). In a stoppage its clock stands still while the players glide to the face-off's line-up and the dead puck is out of sight, and it runs again at the drop ([stoppages](./docs/replay.md#stoppages-the-clock-stops-the-players-line-up)). A penalized player sits in his box for his time while his side plays short, and the board shows the power play and each penalty's clock ([penalties](./docs/replay.md#penalties-and-the-power-play)).
|
|
186
186
|
|
|
187
187
|
**Surfaces** ([the surfaces](./docs/surfaces.md), [score and layout](./docs/score.md))
|
|
188
188
|
|
|
@@ -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.
|
|
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/",
|
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.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
|
+
};
|
package/dist/feeds.d.ts
ADDED
|
@@ -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
|
+
}
|