@johnmorrisdotca/kyogijo-stadiums 0.59.0 → 0.60.1

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,18 @@ All notable changes to this project are written here. The format follows
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.60.1] - 2026-11-03
11
+
12
+ ### Fixed
13
+
14
+ - **The home-screen icon loads under a strict content policy (MODULES-256)**: the demo pages' `apple-touch-icon` link is now relative to the page, so a page that allows images from its own site only (`img-src 'self'`) loads it, and a copy of the site served under another address finds it. The sharing pictures (`og:image`, `twitter:image`) stay absolute. The family's thumbnail file is version 2026-11-03.2.
15
+
16
+ ## [0.60.0] - 2026-11-03
17
+
18
+ ### Added
19
+
20
+ - **The camera follows the play (KYOGIJO-STADIUMS-46)** ([following the play](./docs/replay.md#following-the-play)): a replay can be watched with a camera that closes in where the action is and pulls back as the players spread, or moves with the puck. Off by default; the replay's bar has **Action** and **Puck** (**Ball** for a ball game) toggles, the setting `follow` (`"action"`, `"puck"`, `"off"`), the controller's `setFollow` and `holdFollow`, and the element's `follow` attribute. Action frames the puck and the five players nearest it, padded, never closer than 2.5 times the surface; Puck centres the puck at twice; from a whistle or a goal to the next `faceoff` event with a place, it goes to that faceoff dot. It eases with a critically damped spring, holds still for small moves (a dead zone), never leaves the surface, always holds the puck, and at rest writes nothing. Flat, it writes the drawing's `viewBox`; in 3D it looks at a point of the floor with a zoom over the view's, so the auto-turn and every viewpoint go on while it follows; the reader's drag, zoom, arrows or a viewpoint hold it until **Resume**. Reduced motion steps. It moves inside the replay's own animation frame, asks for none of its own, and runs nothing while paused, hidden or out of sight. The pure parts are in `/replay`: `followShot`, `followCamera`, `followStop`, `followWindow`, `clampShot`, `frameOf`, `springStep`, `zoomFor`, `FOLLOW_LIMITS`. The demo keeps the choice in its address (`follow=action`, `follow=puck`) for the hero's game, the Try it surface, the game section and the arenas page. `docs/replay.ja.md` is new: the camera in Japanese.
21
+
10
22
  ## [0.59.0] - 2026-11-03
11
23
 
12
24
  ### Fixed
package/README.md CHANGED
@@ -180,6 +180,8 @@ Real buildings drawn in the outline OpenStreetMap maps for them, each with a pla
180
180
  </tr>
181
181
  </table>
182
182
 
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
+
183
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)).
184
186
 
185
187
  **Surfaces** ([the surfaces](./docs/surfaces.md), [score and layout](./docs/score.md))
package/catalogue.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "generatedAt": "2026-11-03",
4
4
  "package": {
5
5
  "name": "@johnmorrisdotca/kyogijo-stadiums",
6
- "version": "0.59.0"
6
+ "version": "0.60.1"
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.59.0"
6
+ "version": "0.60.1"
7
7
  },
8
8
  "address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
9
9
  "site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
package/dist/element.d.ts CHANGED
@@ -7,12 +7,12 @@ import type { ScoreOptions, SurfaceOptions } from "./types.ts";
7
7
  /** The tag of the element. */
8
8
  export declare const KYOGIJO_TAG = "kyogijo-field";
9
9
  /** The attributes the element reads. */
10
- export declare const KYOGIJO_ATTRIBUTES: readonly ["sport", "league", "size", "theme", "language", "orientation", "park", "ground", "arena", "year", "detail", "margin", "stripes", "exact-lines", "uppercase", "pitch", "pitch-type", "half", "range", "range-end", "goals", "stripe-pattern", "corner-flags", "look", "line-width", "line-dash", "line-opacity", "landmarks", "walls", "quirks", "home", "away", "home-abbr", "away-abbr", "home-score", "away-score", "home-penalties", "away-penalties", "score", "home-logo", "away-logo", "home-flag", "away-flag", "home-color", "away-color", "status", "competition", "date", "label", "winner", "home-side", "pageant", "description", "options", "loop", "speed", "autoplay", "controls", "trail", "turn", "turn-view", "turn-controls", "turn-uprights", "turn-score", "turn-touch", "turn-venue", "turn-roof", "turn-autoplay", "turn-speed", "turn-direction"];
10
+ export declare const KYOGIJO_ATTRIBUTES: readonly ["sport", "league", "size", "theme", "language", "orientation", "park", "ground", "arena", "year", "detail", "margin", "stripes", "exact-lines", "uppercase", "pitch", "pitch-type", "half", "range", "range-end", "goals", "stripe-pattern", "corner-flags", "look", "line-width", "line-dash", "line-opacity", "landmarks", "walls", "quirks", "home", "away", "home-abbr", "away-abbr", "home-score", "away-score", "home-penalties", "away-penalties", "score", "home-logo", "away-logo", "home-flag", "away-flag", "home-color", "away-color", "status", "competition", "date", "label", "winner", "home-side", "pageant", "description", "options", "loop", "speed", "autoplay", "controls", "trail", "follow", "turn", "turn-view", "turn-controls", "turn-uprights", "turn-score", "turn-touch", "turn-venue", "turn-roof", "turn-autoplay", "turn-speed", "turn-direction"];
11
11
  /** The settings of a 3D view an element's attributes make: `turn-view` names the viewpoint it opens on, `turn-controls="false"` and `turn-uprights="false"` leave the buttons and what stands on the surface out, `turn-score="floor"` leaves the score panel on the floor, `turn-touch="turn"` keeps every touch for the view, `turn-venue="false"` leaves out the stands and the room (they are raised for a drawing with a crowd and for a dojo), `turn-roof` puts a roof or canopy over the stands, `turn-autoplay` starts the auto-turn (`force` even for a reader who prefers reduced motion) and `turn-speed` is its degrees a second. */
12
12
  export declare function turnSettingsFromAttributes(read: (name: string) => string | null, base?: TurnSettings): TurnSettings;
13
13
  /** Whether an element's `turn` attribute asks for the 3D view: present, and not `false`. */
14
14
  export declare const turnWanted: (read: (name: string) => string | null) => boolean;
15
- /** The settings of a replay an element's attributes make: `loop`, `autoplay` and `controls="false"` are switches, `speed` and `trail` numbers. */
15
+ /** The settings of a replay an element's attributes make: `loop`, `autoplay` and `controls="false"` are switches, `speed` and `trail` numbers, `follow` what the camera follows (`action`, `puck` or `off`). */
16
16
  export declare function replaySettingsFromAttributes(read: (name: string) => string | null, base?: ReplaySettings): ReplaySettings;
17
17
  /** The options an element's attributes (and its `options` attribute and property) make. */
18
18
  export declare function optionsFromAttributes(read: (name: string) => string | null, base?: Partial<ScoreOptions>): SurfaceOptions | ScoreOptions;
@@ -25,7 +25,7 @@ export declare function optionsFromAttributes(read: (name: string) => string | n
25
25
  declare const Base: typeof HTMLElement;
26
26
  export declare class KyogijoField extends Base {
27
27
  #private;
28
- static observedAttributes: readonly ["sport", "league", "size", "theme", "language", "orientation", "park", "ground", "arena", "year", "detail", "margin", "stripes", "exact-lines", "uppercase", "pitch", "pitch-type", "half", "range", "range-end", "goals", "stripe-pattern", "corner-flags", "look", "line-width", "line-dash", "line-opacity", "landmarks", "walls", "quirks", "home", "away", "home-abbr", "away-abbr", "home-score", "away-score", "home-penalties", "away-penalties", "score", "home-logo", "away-logo", "home-flag", "away-flag", "home-color", "away-color", "status", "competition", "date", "label", "winner", "home-side", "pageant", "description", "options", "loop", "speed", "autoplay", "controls", "trail", "turn", "turn-view", "turn-controls", "turn-uprights", "turn-score", "turn-touch", "turn-venue", "turn-roof", "turn-autoplay", "turn-speed", "turn-direction"];
28
+ static observedAttributes: readonly ["sport", "league", "size", "theme", "language", "orientation", "park", "ground", "arena", "year", "detail", "margin", "stripes", "exact-lines", "uppercase", "pitch", "pitch-type", "half", "range", "range-end", "goals", "stripe-pattern", "corner-flags", "look", "line-width", "line-dash", "line-opacity", "landmarks", "walls", "quirks", "home", "away", "home-abbr", "away-abbr", "home-score", "away-score", "home-penalties", "away-penalties", "score", "home-logo", "away-logo", "home-flag", "away-flag", "home-color", "away-color", "status", "competition", "date", "label", "winner", "home-side", "pageant", "description", "options", "loop", "speed", "autoplay", "controls", "trail", "follow", "turn", "turn-view", "turn-controls", "turn-uprights", "turn-score", "turn-touch", "turn-venue", "turn-roof", "turn-autoplay", "turn-speed", "turn-direction"];
29
29
  /** The options given as a property: an object, laid under the attributes. */
30
30
  get options(): Partial<ScoreOptions> | undefined;
31
31
  set options(value: Partial<ScoreOptions> | undefined);
package/dist/element.js CHANGED
@@ -26,11 +26,11 @@ export const KYOGIJO_ATTRIBUTES = [
26
26
  "pitch", "pitch-type", "half", "range", "range-end", "goals", "stripe-pattern", "corner-flags", "look", "line-width", "line-dash", "line-opacity", "landmarks", "walls", "quirks",
27
27
  "home", "away", "home-abbr", "away-abbr", "home-score", "away-score", "home-penalties", "away-penalties", "score", "home-logo", "away-logo", "home-flag", "away-flag", "home-color", "away-color",
28
28
  "status", "competition", "date", "label", "winner", "home-side", "pageant", "description", "options",
29
- "loop", "speed", "autoplay", "controls", "trail",
29
+ "loop", "speed", "autoplay", "controls", "trail", "follow",
30
30
  "turn", "turn-view", "turn-controls", "turn-uprights", "turn-score", "turn-touch", "turn-venue", "turn-roof", "turn-autoplay", "turn-speed", "turn-direction",
31
31
  ];
32
32
  /** The attributes that are about a replay and not about the drawing. */
33
- const REPLAY_ATTRIBUTES = ["loop", "speed", "autoplay", "controls", "trail"];
33
+ const REPLAY_ATTRIBUTES = ["loop", "speed", "autoplay", "controls", "trail", "follow"];
34
34
  /** The settings of a 3D view an element's attributes make: `turn-view` names the viewpoint it opens on, `turn-controls="false"` and `turn-uprights="false"` leave the buttons and what stands on the surface out, `turn-score="floor"` leaves the score panel on the floor, `turn-touch="turn"` keeps every touch for the view, `turn-venue="false"` leaves out the stands and the room (they are raised for a drawing with a crowd and for a dojo), `turn-roof` puts a roof or canopy over the stands, `turn-autoplay` starts the auto-turn (`force` even for a reader who prefers reduced motion) and `turn-speed` is its degrees a second. */
35
35
  export function turnSettingsFromAttributes(read, base = {}) {
36
36
  const out = { ...base };
@@ -66,7 +66,7 @@ export function turnSettingsFromAttributes(read, base = {}) {
66
66
  }
67
67
  /** Whether an element's `turn` attribute asks for the 3D view: present, and not `false`. */
68
68
  export const turnWanted = (read) => read("turn") !== null && read("turn") !== "false";
69
- /** The settings of a replay an element's attributes make: `loop`, `autoplay` and `controls="false"` are switches, `speed` and `trail` numbers. */
69
+ /** The settings of a replay an element's attributes make: `loop`, `autoplay` and `controls="false"` are switches, `speed` and `trail` numbers, `follow` what the camera follows (`action`, `puck` or `off`). */
70
70
  export function replaySettingsFromAttributes(read, base = {}) {
71
71
  const out = { ...base };
72
72
  const on = (name) => (read(name) === null ? undefined : read(name) !== "false");
@@ -89,6 +89,9 @@ export function replaySettingsFromAttributes(read, base = {}) {
89
89
  const trail = number("trail");
90
90
  if (trail !== undefined)
91
91
  out.trail = trail;
92
+ const follow = read("follow");
93
+ if (follow !== null)
94
+ out.follow = follow === "action" || follow === "puck" ? follow : "off";
92
95
  return out;
93
96
  }
94
97
  /** `3-2`, `3–2` or `3:2` as home and away. */
@@ -350,7 +353,9 @@ export class KyogijoField extends Base {
350
353
  __classPrivateFieldGet(this, _KyogijoField_turn, "f").preset(view);
351
354
  return;
352
355
  }
353
- if (REPLAY_ATTRIBUTES.includes(name) && __classPrivateFieldGet(this, _KyogijoField_replay, "f") !== undefined)
356
+ if (name === "follow" && __classPrivateFieldGet(this, _KyogijoField_replay, "f") !== undefined)
357
+ __classPrivateFieldGet(this, _KyogijoField_replay, "f").setFollow(replaySettingsFromAttributes((key) => this.getAttribute(key)).follow ?? "off");
358
+ else if (REPLAY_ATTRIBUTES.includes(name) && __classPrivateFieldGet(this, _KyogijoField_replay, "f") !== undefined)
354
359
  __classPrivateFieldGet(this, _KyogijoField_instances, "m", _KyogijoField_attach).call(this, name !== "speed" && name !== "loop");
355
360
  else
356
361
  __classPrivateFieldGet(this, _KyogijoField_instances, "m", _KyogijoField_draw).call(this);
@@ -371,7 +376,7 @@ _KyogijoField_mount = new WeakMap(), _KyogijoField_host = new WeakMap(), _Kyogij
371
376
  const from = __classPrivateFieldGet(this, _KyogijoField_replay, "f") === undefined || __classPrivateFieldGet(this, _KyogijoField_replayOf, "f") !== __classPrivateFieldGet(this, _KyogijoField_timeline, "f") ? undefined : __classPrivateFieldGet(this, _KyogijoField_replay, "f").state();
372
377
  __classPrivateFieldGet(this, _KyogijoField_replay, "f")?.destroy();
373
378
  __classPrivateFieldSet(this, _KyogijoField_replayOf, __classPrivateFieldGet(this, _KyogijoField_timeline, "f"), "f");
374
- __classPrivateFieldSet(this, _KyogijoField_replay, on.replay(__classPrivateFieldGet(this, _KyogijoField_timeline, "f"), from === undefined ? settings : { ...settings, start: from.t - from.start }), "f");
379
+ __classPrivateFieldSet(this, _KyogijoField_replay, on.replay(__classPrivateFieldGet(this, _KyogijoField_timeline, "f"), from === undefined ? settings : { ...settings, start: from.t - from.start, follow: from.follow }), "f");
375
380
  }, _KyogijoField_draw = function _KyogijoField_draw() {
376
381
  if (__classPrivateFieldGet(this, _KyogijoField_host, "f") === undefined || !this.isConnected)
377
382
  return;
@@ -8,5 +8,7 @@ export { alongPool, distanceAt, progressOf, raceTimeline } from "./pool.replay.t
8
8
  export type { Progress, RaceReplayOptions } from "./pool.replay.ts";
9
9
  export { formatTime, parseTime } from "./pool.race.ts";
10
10
  export { REPLAY_SPEEDS, REPLAY_SPEED_RANGE, createReplay } from "./replay.control.ts";
11
+ export { FOLLOW_LIMITS, FOLLOW_MODES, clampShot, followCamera, followMode, followShot, followStop, followWindow, frameOf, springStep, zoomFor } from "./replay.follow.ts";
12
+ export type { FollowBox, FollowCamera, FollowInput, FollowLimits, FollowMode, FollowShot } from "./replay.follow.ts";
11
13
  export type { ReplayController } from "./replay.control.ts";
12
14
  export type { ActiveEvent, Possession, ReplayChange, ReplayEvent, ReplayEventKind, ReplayFrame, ReplayLook, ReplayObject, ReplayPeriod, ReplayReason, ReplaySettings, ReplayState, ReplayTeam, ReplayTimeline, ReplayTrail, } from "./replay.types.ts";
@@ -8,3 +8,4 @@ export { boardClock, replayBoard, replayPeriods } from "./replay.board.js";
8
8
  export { alongPool, distanceAt, progressOf, raceTimeline } from "./pool.replay.js";
9
9
  export { formatTime, parseTime } from "./pool.race.js";
10
10
  export { REPLAY_SPEEDS, REPLAY_SPEED_RANGE, createReplay } from "./replay.control.js";
11
+ export { FOLLOW_LIMITS, FOLLOW_MODES, clampShot, followCamera, followMode, followShot, followStop, followWindow, frameOf, springStep, zoomFor } from "./replay.follow.js";
@@ -22,6 +22,10 @@ export interface ReplayController {
22
22
  setLoop(loop: boolean): void;
23
23
  /** `step` for a reader who prefers reduced motion: playing stops, and play steps. */
24
24
  setMotion(motion: "smooth" | "step"): void;
25
+ /** What the camera follows (`off`, `action`, `puck`); a change lets go of a hold. */
26
+ setFollow(mode: "off" | "action" | "puck"): void;
27
+ /** Hold the camera where it is (the reader turned or zoomed the view), or let it follow again (`false`). */
28
+ holdFollow(held: boolean): void;
25
29
  /** Tell the clock that seconds went by: while playing, the time moves by that many seconds times the speed. */
26
30
  advance(seconds: number): void;
27
31
  /** Be told of every change. Returns a function that stops it. */
@@ -39,6 +39,8 @@ export function createReplay(timeline, settings = {}) {
39
39
  speed: isNum(settings.speed) ? clamp(settings.speed, REPLAY_SPEED_RANGE[0], REPLAY_SPEED_RANGE[1]) : 1,
40
40
  loop: settings.loop === true,
41
41
  motion: settings.motion === "step" ? "step" : "smooth",
42
+ follow: settings.follow === "action" || settings.follow === "puck" ? settings.follow : "off",
43
+ followHeld: false,
42
44
  };
43
45
  const listeners = new Set();
44
46
  const tell = (reason, crossed = []) => {
@@ -148,6 +150,20 @@ export function createReplay(timeline, settings = {}) {
148
150
  state.playing = false;
149
151
  tell("motion");
150
152
  },
153
+ setFollow(mode) {
154
+ const next = mode === "action" || mode === "puck" ? mode : "off";
155
+ if (next === state.follow && !state.followHeld)
156
+ return;
157
+ state.follow = next;
158
+ state.followHeld = false;
159
+ tell("follow");
160
+ },
161
+ holdFollow(held) {
162
+ if (state.followHeld === held || (held && state.follow === "off"))
163
+ return;
164
+ state.followHeld = held;
165
+ tell("follow");
166
+ },
151
167
  advance(seconds) {
152
168
  if (!state.playing || state.motion === "step" || !isNum(seconds) || seconds <= 0)
153
169
  return;
@@ -4,6 +4,7 @@
4
4
  import { num } from "./format.js";
5
5
  import { renderLayers } from "./layers.js";
6
6
  import { REPLAY_SPEEDS, createReplay } from "./replay.control.js";
7
+ import { FOLLOW_STYLE, flatFollow, followControls } from "./replay.follow.dom.js";
7
8
  import { describeEvent, drawFrame, replayClock, replayGroups, replaySpoken, replayInfo, replayPlacer, replaySnapshot } from "./replay.js";
8
9
  import { KYOGIJO_STRINGS, fill } from "./strings.core.js";
9
10
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -175,7 +176,7 @@ export function replayDriver(doc, timeline, settings, source) {
175
176
  let language = source.language;
176
177
  const words = () => KYOGIJO_STRINGS[language].replay;
177
178
  const style = doc.createElement("style");
178
- style.textContent = REPLAY_STYLE;
179
+ style.textContent = REPLAY_STYLE + FOLLOW_STYLE;
179
180
  const hint = doc.createElement("p");
180
181
  hint.className = "ky-hint";
181
182
  hint.id = `${id}-keys`;
@@ -236,7 +237,8 @@ export function replayDriver(doc, timeline, settings, source) {
236
237
  play.hidden = stepMode;
237
238
  speed.hidden = stepMode;
238
239
  loop.hidden = stepMode;
239
- bar.append(back, play, forward, speed, loop, range, clock);
240
+ const follow = followControls(doc, w, controller, timeline);
241
+ bar.append(back, play, forward, speed, loop, range, clock, ...follow.parts);
240
242
  hint.textContent = source.help === "turn" ? (stepMode ? w.keysTurnStep : w.keysTurn) : stepMode ? w.keysStep : w.keys;
241
243
  if (source.labelled !== undefined) {
242
244
  source.labelled.setAttribute("aria-label", w.region);
@@ -267,6 +269,7 @@ export function replayDriver(doc, timeline, settings, source) {
267
269
  loop.setAttribute("aria-pressed", String(s.loop));
268
270
  source.holder.dataset.state = s.ended ? "ended" : s.playing ? "playing" : "paused";
269
271
  source.holder.dataset.motion = s.motion;
272
+ follow.sync(s);
270
273
  };
271
274
  sync(undefined);
272
275
  };
@@ -432,13 +435,27 @@ export function createReplayView(host, timeline, settings, initial) {
432
435
  function drawStage(t) {
433
436
  stage.innerHTML = drawFrame(options, timeline, t, settings);
434
437
  svg = stage.querySelector("svg.kyogijo");
438
+ // (A drawing written again is shown at the camera's shot.)
439
+ camera.again();
435
440
  }
436
- const driver = replayDriver(doc, timeline, settings, { paint: (t) => painter.paint(t), language, keysOn: root, holder: root, labelled: stage });
441
+ const camera = flatFollow({ svg: () => svg, options: () => options, timeline, look: settings, now: () => performance.now() });
442
+ const driver = replayDriver(doc, timeline, settings, {
443
+ paint: (t, change) => {
444
+ painter.paint(t);
445
+ if (change !== undefined)
446
+ camera.paint(change);
447
+ },
448
+ language,
449
+ keysOn: root,
450
+ holder: root,
451
+ labelled: stage,
452
+ });
437
453
  const { style, hint, bar, live } = driver.parts;
438
454
  root.append(style, stage, hint, bar, live);
439
455
  host.replaceChildren(root);
440
456
  drawStage(driver.controller.state().t);
441
457
  painter.bind(driver.controller.state().t);
458
+ camera.paint({ state: driver.controller.state(), reason: "follow", crossed: [] });
442
459
  if (settings.autoplay === true && driver.controller.state().motion === "smooth" && doc.visibilityState !== "hidden")
443
460
  driver.controller.play();
444
461
  return {
@@ -0,0 +1,164 @@
1
+ import type { ReplayChange, ReplayObject, ReplayTimeline } from "./replay.types.ts";
2
+ /** What the camera follows: nothing (the whole surface), the action (the players near the puck or ball, and it), or the puck or ball alone. */
3
+ export type FollowMode = "off" | "action" | "puck";
4
+ /** The modes, in the order the controls list them after "off". */
5
+ export declare const FOLLOW_MODES: readonly FollowMode[];
6
+ /** A mode from anything: `action` and `puck` are themselves, everything else is `off`. */
7
+ export declare const followMode: (value: unknown) => FollowMode;
8
+ /** A rectangle in the drawing's own feet. */
9
+ export interface FollowBox {
10
+ x: number;
11
+ y: number;
12
+ w: number;
13
+ h: number;
14
+ }
15
+ /** Where the camera looks: the middle of the frame, in the drawing's feet, and how much it magnifies the surface (1 is the whole of it: the frame is the surface's size over `zoom`). */
16
+ export interface FollowShot {
17
+ x: number;
18
+ y: number;
19
+ zoom: number;
20
+ }
21
+ /** How the camera frames and moves. Shares are of the surface's length (its longer side). */
22
+ export interface FollowLimits {
23
+ /** The closest the camera comes: the frame is never smaller than the surface over this. */
24
+ maxZoom: number;
25
+ /** The zoom of `puck`. */
26
+ puckZoom: number;
27
+ /** How many of the players nearest the puck the action is framed round. */
28
+ near: number;
29
+ /** The room left round the action, a share of the surface's length on each side. */
30
+ pad: number;
31
+ /** A move of the shot smaller than this share of the frame does not move the camera, and a zoom that changes by less than this share does not zoom it. */
32
+ dead: number;
33
+ /** How quickly the spring follows, in radians a second: about 4.7 over this many seconds settles it. */
34
+ omega: number;
35
+ /** The room kept between the puck and the edge of the frame, a share of the frame. */
36
+ leash: number;
37
+ }
38
+ /** The camera's limits by default: no closer than two and a half times, the puck followed at twice, the five nearest players, settling in about a second and a half. */
39
+ export declare const FOLLOW_LIMITS: Readonly<FollowLimits>;
40
+ /** What the shot is worked out from at a moment: the objects in the drawing's feet, the surface, and the faceoff dot when the play is stopped. */
41
+ export interface FollowInput {
42
+ objects: readonly Pick<ReplayObject, "team" | "x" | "y">[];
43
+ box: FollowBox;
44
+ /** The faceoff dot while the play is stopped (see `followStop`): the camera goes there. */
45
+ stop?: readonly [number, number];
46
+ }
47
+ /** The whole surface: the shot of `off`. */
48
+ export declare const wholeShot: (box: FollowBox) => FollowShot;
49
+ /** A shot held to the limits: its zoom from 1 to `maxZoom`, and its frame on the surface (the middle moved in until no side of the frame is off it). */
50
+ export declare function clampShot(shot: FollowShot, box: FollowBox, maxZoom?: number): FollowShot;
51
+ /** The frame a shot shows: the surface's size over the zoom, round the middle. */
52
+ export declare const frameOf: (shot: FollowShot, box: FollowBox) => FollowBox;
53
+ /**
54
+ * The shot the play asks for at a moment. `action` frames the puck (or the ball) and the `near` players nearest it, with `pad` round them, never closer than `maxZoom`: when they bunch at one end
55
+ * it closes in, and when they spread it pulls back. `puck` puts the puck in the middle at `puckZoom`. A stopped play (`stop`) frames the faceoff dot as closely as the limits allow. The frame is
56
+ * always on the surface and always holds the puck. Without a puck the action is the players' middle; with nothing at all it is the whole surface.
57
+ * @example
58
+ * ```ts
59
+ * import { followShot } from "@johnmorrisdotca/kyogijo-stadiums/replay";
60
+ * const box = { x: 0, y: 0, w: 200, h: 85 };
61
+ * const objects = [{ team: "puck" as const, x: 170, y: 40 }, { team: "home" as const, x: 165, y: 30 }, { team: "away" as const, x: 175, y: 50 }];
62
+ * const action = followShot("action", { objects, box });
63
+ * console.log(Math.round(action.x), action.y, Math.round(action.zoom * 100) / 100);
64
+ * console.log(followShot("puck", { objects, box }));
65
+ * // 148 40 1.93
66
+ * // { x: 150, y: 40, zoom: 2 }
67
+ * ```
68
+ */
69
+ export declare function followShot(mode: FollowMode, input: FollowInput, limits?: Partial<FollowLimits>): FollowShot;
70
+ /**
71
+ * One step of a critically damped spring toward `goal`: where it is and how fast it goes after `dt` seconds. It never overshoots and never rings, and a long step lands nearer, never past.
72
+ * @example
73
+ * ```ts
74
+ * import { springStep } from "@johnmorrisdotca/kyogijo-stadiums/replay";
75
+ * let [x, v] = [0, 0];
76
+ * for (let i = 0; i < 60; i += 1) [x, v] = springStep(x, v, 10, 1 / 60, 4);
77
+ * console.log(Math.round(x * 100) / 100, x < 10);
78
+ * // 9.08 true
79
+ * ```
80
+ */
81
+ export declare function springStep(at: number, speed: number, goal: number, dt: number, omega: number): [number, number];
82
+ /** What `followCamera` gives back. */
83
+ export interface FollowCamera {
84
+ /** Move toward the shot the play asks for: by `dt` seconds of the spring, or at once (`dt` undefined). Gives where it is now. */
85
+ update(mode: FollowMode, input: FollowInput, dt?: number): FollowShot;
86
+ /** Where it is now. */
87
+ shot(): FollowShot | undefined;
88
+ /** Whether it is at rest on the whole surface: `off`, and nothing left to ease back. */
89
+ resting(): boolean;
90
+ /** Forget where it was: the next update starts from the whole surface. */
91
+ reset(): void;
92
+ }
93
+ /**
94
+ * The camera that follows the play: it eases toward `followShot` with a critically damped spring on the middle and on the zoom (the zoom eased as its logarithm, so a zoom in and a zoom out
95
+ * take the same time), holds still while the shot moves less than a dead zone, and is kept on the surface, never closer than the limit, with the puck inside the frame. When the play is still
96
+ * it comes to rest and stays exactly there.
97
+ * @example
98
+ * ```ts
99
+ * import { followCamera } from "@johnmorrisdotca/kyogijo-stadiums/replay";
100
+ * const camera = followCamera();
101
+ * const input = { box: { x: 0, y: 0, w: 200, h: 85 }, objects: [{ team: "puck" as const, x: 180, y: 40 }] };
102
+ * const first = camera.update("puck", input, 1 / 60);
103
+ * let shot = first;
104
+ * for (let i = 0; i < 600; i += 1) shot = camera.update("puck", input, 1 / 60);
105
+ * console.log(first.zoom < 1.01, shot);
106
+ * // true { x: 150, y: 40, zoom: 2 }
107
+ * ```
108
+ */
109
+ export declare function followCamera(limits?: Partial<FollowLimits>): FollowCamera;
110
+ /**
111
+ * How much a view magnifies for a shot's zoom, when the view at 1 shows more than the surface (`room` times it: the margin round a flat drawing, the stands round a 3D one). At a zoom of 1 the
112
+ * view is as it was; at `maxZoom` the frame fills it; in between the two are joined smoothly, and the view never shows less than the frame.
113
+ */
114
+ export declare function zoomFor(zoom: number, room: number, maxZoom?: number): number;
115
+ /**
116
+ * The part of a flat drawing to show for a shot: a rectangle in the drawing's `viewBox` units, of the viewBox's shape, round the shot's middle, inside the viewBox, and never smaller than the
117
+ * shot's frame. `map` takes a point of the surface's frame to the viewBox's (a surface laid tall is turned a quarter in its drawing).
118
+ * @example
119
+ * ```ts
120
+ * import { followWindow } from "@johnmorrisdotca/kyogijo-stadiums/replay";
121
+ * const box = { x: 0, y: 0, w: 200, h: 85 };
122
+ * const view = { x: -8, y: -8, w: 216, h: 101 };
123
+ * console.log(followWindow({ x: 100, y: 42.5, zoom: 1 }, box, view));
124
+ * console.log(followWindow({ x: 160, y: 42.5, zoom: 2.5 }, box, view));
125
+ * // { x: -8, y: -8, w: 216, h: 101 }
126
+ * // { x: 120, y: 23.8, w: 80, h: 37.41 }
127
+ * ```
128
+ */
129
+ export declare function followWindow(shot: FollowShot, box: FollowBox, view: FollowBox, map?: (p: [number, number]) => [number, number], maxZoom?: number): FollowBox;
130
+ /**
131
+ * The faceoff dot, in the timeline's frame, while the play is stopped at `t`: from a whistle or a goal until the next `faceoff` event that has a place (`x` and `y`), the place of that faceoff.
132
+ * `undefined` while the play goes on, and always for a timeline with no `faceoff` events: the camera then follows the puck through the stoppage as at any other time.
133
+ * @example
134
+ * ```ts
135
+ * import { followStop } from "@johnmorrisdotca/kyogijo-stadiums/replay";
136
+ * const timeline = { frames: [{ t: 0, objects: [] }, { t: 30, objects: [] }], events: [{ t: 10, kind: "whistle" }, { t: 20, kind: "faceoff", x: 69, y: 22 }] };
137
+ * console.log(followStop(timeline, 5), followStop(timeline, 15), followStop(timeline, 25));
138
+ * // undefined [ 69, 22 ] undefined
139
+ * ```
140
+ */
141
+ export declare function followStop(timeline: ReplayTimeline, t: number): [number, number] | undefined;
142
+ /** What a runner is given: the timeline, where its objects are in the drawing at a time, the surface, a clock in milliseconds, and how to show a shot (`undefined` gives back the whole drawing). */
143
+ export interface FollowSource {
144
+ timeline: ReplayTimeline;
145
+ /** The objects at a time and the faceoff dot of a stoppage, in the drawing's feet. */
146
+ at(t: number, stop: readonly [number, number] | undefined): FollowInput;
147
+ now(): number;
148
+ show(shot: FollowShot | undefined): void;
149
+ limits?: Partial<FollowLimits>;
150
+ }
151
+ /** What `followRunner` gives back. */
152
+ export interface FollowRunner {
153
+ /** The replay's clock changed: move the camera for it. */
154
+ paint(change: ReplayChange): void;
155
+ /** Show the shot again (the drawing was written again). */
156
+ again(): void;
157
+ /** Forget the shot: the camera starts from the whole surface. */
158
+ reset(): void;
159
+ }
160
+ /**
161
+ * The camera of a replay in a page: on each tick of the clock the spring moves by the seconds that went by; a seek, a step, or a change of mode while paused puts the camera where it should be at
162
+ * once (a reader who prefers reduced motion gets only steps); a play, a pause, a change of speed or loop moves nothing. Nothing runs between changes, and off and at rest it does nothing at all.
163
+ */
164
+ export declare function followRunner(source: FollowSource): FollowRunner;
@@ -0,0 +1,30 @@
1
+ import type { ReplayController } from "./replay.control.ts";
2
+ import { type FollowRunner } from "./replay.follow.ts";
3
+ import type { ReplayLook, ReplayState, ReplayTimeline } from "./replay.types.ts";
4
+ import type { KyogijoWords } from "./strings.core.ts";
5
+ import type { ScoreOptions, SurfaceOptions } from "./types.ts";
6
+ /** The buttons' own rules, added to the replay's stylesheet: a word beside each icon. */
7
+ export declare const FOLLOW_STYLE: string;
8
+ /** The buttons of the follow camera, for the replay's bar, and how to make them say the state. */
9
+ export interface FollowControls {
10
+ parts: HTMLElement[];
11
+ sync(state: Readonly<ReplayState>): void;
12
+ }
13
+ /**
14
+ * The buttons: Action and Puck (Ball for a game with a ball) press in and out, one at a time, and pressing the one that is in turns the camera off (held or not); Resume shows only while the
15
+ * reader's own turn or zoom holds the camera.
16
+ */
17
+ export declare function followControls(doc: Document, words: KyogijoWords["replay"], controller: ReplayController, timeline: ReplayTimeline): FollowControls;
18
+ /** What the flat camera is given: the drawing now, what it was drawn from, the timeline and how its frames are drawn. */
19
+ export interface FlatFollowSource {
20
+ svg(): SVGSVGElement | null;
21
+ options(): SurfaceOptions | ScoreOptions;
22
+ timeline: ReplayTimeline;
23
+ look: ReplayLook;
24
+ now(): number;
25
+ }
26
+ /**
27
+ * The camera of a flat replay: it shows the part of the drawing the shot frames by writing the drawing's `viewBox`, one attribute, and puts the whole drawing back when it is off. A drawing
28
+ * written again (a theme, a score) is shown at the same shot by `again`.
29
+ */
30
+ export declare function flatFollow(source: FlatFollowSource): FollowRunner;
@@ -0,0 +1,118 @@
1
+ // Following the play in a page: the buttons in the replay's bar (Action, Puck or Ball, and Resume), and the camera of a flat replay, which shows part of the drawing by its `viewBox`. The
2
+ // arithmetic is replay.follow.ts's; the 3D view's camera is turn.follow.ts's.
3
+ import { outlineOf } from "./outline.js";
4
+ import { FOLLOW_LIMITS, followRunner, followWindow } from "./replay.follow.js";
5
+ import { replayPlacer, replaySnapshot } from "./replay.js";
6
+ /** The buttons' own rules, added to the replay's stylesheet: a word beside each icon. */
7
+ export const FOLLOW_STYLE = `
8
+ .ky-replay .ky-follow{display:inline-flex;flex-wrap:wrap;gap:.5rem}
9
+ .ky-replay .ky-follow button{gap:.35rem}
10
+ .ky-replay .ky-follow svg{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
11
+ `.trim();
12
+ const SHAPES = {
13
+ action: "M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5M9 12h6M12 9v6",
14
+ puck: "M12 3v3M12 18v3M3 12h3M18 12h3M12 8a4 4 0 1 0 0.01 0",
15
+ resume: "M4 12a8 8 0 1 0 2.3-5.7M4 4v5h5",
16
+ };
17
+ /** What the timeline follows: a puck, a ball, or neither (then there is no Puck button). */
18
+ const followed = (timeline) => {
19
+ for (const frame of timeline.frames)
20
+ for (const o of frame.objects)
21
+ if (o.team === "puck" || o.team === "ball")
22
+ return o.team;
23
+ return undefined;
24
+ };
25
+ /**
26
+ * The buttons: Action and Puck (Ball for a game with a ball) press in and out, one at a time, and pressing the one that is in turns the camera off (held or not); Resume shows only while the
27
+ * reader's own turn or zoom holds the camera.
28
+ */
29
+ export function followControls(doc, words, controller, timeline) {
30
+ const group = doc.createElement("span");
31
+ group.className = "ky-follow";
32
+ group.setAttribute("role", "group");
33
+ group.setAttribute("aria-label", words.follow);
34
+ const button = (name, word, label, onClick) => {
35
+ const b = doc.createElement("button");
36
+ b.type = "button";
37
+ b.className = `ky-follow-${name}`;
38
+ b.setAttribute("aria-label", label);
39
+ b.title = label;
40
+ b.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${SHAPES[name]}"/></svg><span>${word}</span>`;
41
+ b.addEventListener("click", onClick);
42
+ return b;
43
+ };
44
+ const press = (mode) => () => controller.setFollow(controller.state().follow === mode ? "off" : mode);
45
+ const ball = followed(timeline);
46
+ const action = button("action", words.followAction, words.followActionLabel, press("action"));
47
+ const puck = button("puck", ball === "ball" ? words.followBall : words.followPuck, ball === "ball" ? words.followBallLabel : words.followPuckLabel, press("puck"));
48
+ puck.hidden = ball === undefined;
49
+ const resume = button("resume", words.followResume, words.followResumeLabel, () => controller.holdFollow(false));
50
+ group.append(action, puck, resume);
51
+ return {
52
+ parts: [group],
53
+ sync(state) {
54
+ action.setAttribute("aria-pressed", String(state.follow === "action"));
55
+ puck.setAttribute("aria-pressed", String(state.follow === "puck"));
56
+ resume.hidden = !state.followHeld;
57
+ group.dataset.follow = state.followHeld ? "held" : state.follow;
58
+ },
59
+ };
60
+ }
61
+ /** The matrix that takes the replay's layers to the drawing's own units: a surface laid tall is turned a quarter inside its drawing. */
62
+ function layersToDrawing(svg) {
63
+ const layers = svg.querySelector("g.ky-layers");
64
+ let m = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
65
+ for (let el = layers?.parentElement ?? null; el !== null && el !== svg; el = el.parentElement) {
66
+ const t = el.transform?.baseVal?.consolidate?.()?.matrix;
67
+ if (t === undefined || t === null)
68
+ continue;
69
+ // The outer transform after the inner: t times m.
70
+ m = { a: t.a * m.a + t.c * m.b, b: t.b * m.a + t.d * m.b, c: t.a * m.c + t.c * m.d, d: t.b * m.c + t.d * m.d, e: t.a * m.e + t.c * m.f + t.e, f: t.b * m.e + t.d * m.f + t.f };
71
+ }
72
+ return ([x, y]) => [m.a * x + m.c * y + m.e, m.b * x + m.d * y + m.f];
73
+ }
74
+ /**
75
+ * The camera of a flat replay: it shows the part of the drawing the shot frames by writing the drawing's `viewBox`, one attribute, and puts the whole drawing back when it is off. A drawing
76
+ * written again (a theme, a score) is shown at the same shot by `again`.
77
+ */
78
+ export function flatFollow(source) {
79
+ const whole = new WeakMap();
80
+ const own = (svg) => {
81
+ let known = whole.get(svg);
82
+ if (known === undefined) {
83
+ const text = svg.getAttribute("viewBox");
84
+ const [x, y, w, h] = (text ?? "").split(/[\s,]+/).map(Number);
85
+ known = { view: [x, y, w, h].every(Number.isFinite) ? { x, y, w, h } : { x: 0, y: 0, w: 0, h: 0 }, text, map: layersToDrawing(svg) };
86
+ whole.set(svg, known);
87
+ }
88
+ return known;
89
+ };
90
+ const box = () => outlineOf(source.options()).box;
91
+ return followRunner({
92
+ timeline: source.timeline,
93
+ now: source.now,
94
+ at(t, stop) {
95
+ const place = replayPlacer(source.options(), source.timeline);
96
+ const objects = replaySnapshot(source.timeline, t, source.look).objects;
97
+ const placed = place(objects.map((o) => [o.x, o.y]));
98
+ return { box: box(), objects: objects.map((o, i) => ({ team: o.team, x: placed[i][0], y: placed[i][1] })), stop: stop === undefined ? undefined : place([[stop[0], stop[1]]])[0] };
99
+ },
100
+ show(shot) {
101
+ const svg = source.svg();
102
+ if (svg === null)
103
+ return;
104
+ const known = own(svg);
105
+ if (shot === undefined || !(known.view.w > 0)) {
106
+ if (known.text === null)
107
+ svg.removeAttribute("viewBox");
108
+ else
109
+ svg.setAttribute("viewBox", known.text);
110
+ return;
111
+ }
112
+ const w = followWindow(shot, box(), known.view, known.map, FOLLOW_LIMITS.maxZoom);
113
+ const text = `${w.x} ${w.y} ${w.w} ${w.h}`;
114
+ if (svg.getAttribute("viewBox") !== text)
115
+ svg.setAttribute("viewBox", text);
116
+ },
117
+ });
118
+ }