@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.
@@ -0,0 +1,279 @@
1
+ // Following the play: where the camera looks while a replay plays, as arithmetic on numbers. `followShot` is the shot the play asks for at a moment (the skaters near the puck, padded, or the
2
+ // puck itself, or the faceoff dot in a stoppage); `followCamera` eases toward it with a critically damped spring, a dead zone and a leash that keeps the puck in the frame; `followRunner` is
3
+ // what a page's replay calls on each change of its clock. Nothing here reads the page or a clock: the caller says how many seconds went by, and a test does it with numbers.
4
+ import { replayEvents } from "./replay.js";
5
+ /** The modes, in the order the controls list them after "off". */
6
+ export const FOLLOW_MODES = ["off", "action", "puck"];
7
+ /** A mode from anything: `action` and `puck` are themselves, everything else is `off`. */
8
+ export const followMode = (value) => (value === "action" || value === "puck" ? value : "off");
9
+ /** 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. */
10
+ export const FOLLOW_LIMITS = { maxZoom: 2.5, puckZoom: 2, near: 5, pad: 0.06, dead: 0.08, omega: 3.2, leash: 0.06 };
11
+ const clamp = (value, least, most) => Math.min(most, Math.max(least, value));
12
+ const isNum = (value) => typeof value === "number" && Number.isFinite(value);
13
+ /** The whole surface: the shot of `off`. */
14
+ export const wholeShot = (box) => ({ x: box.x + box.w / 2, y: box.y + box.h / 2, zoom: 1 });
15
+ /** 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). */
16
+ export function clampShot(shot, box, maxZoom = FOLLOW_LIMITS.maxZoom) {
17
+ const zoom = clamp(isNum(shot.zoom) ? shot.zoom : 1, 1, Math.max(1, maxZoom));
18
+ const [hw, hh] = [box.w / zoom / 2, box.h / zoom / 2];
19
+ const middle = wholeShot(box);
20
+ return { x: clamp(isNum(shot.x) ? shot.x : middle.x, box.x + hw, box.x + box.w - hw), y: clamp(isNum(shot.y) ? shot.y : middle.y, box.y + hh, box.y + box.h - hh), zoom };
21
+ }
22
+ /** The frame a shot shows: the surface's size over the zoom, round the middle. */
23
+ export const frameOf = (shot, box) => ({ x: shot.x - box.w / shot.zoom / 2, y: shot.y - box.h / shot.zoom / 2, w: box.w / shot.zoom, h: box.h / shot.zoom });
24
+ const onSurface = (p, box) => [clamp(p[0], box.x, box.x + box.w), clamp(p[1], box.y, box.y + box.h)];
25
+ /** The puck or the ball, on the surface, or `undefined` when there is none. */
26
+ function puckOf(input) {
27
+ const puck = input.objects.find((o) => (o.team === "puck" || o.team === "ball") && isNum(o.x) && isNum(o.y));
28
+ return puck === undefined ? undefined : onSurface([puck.x, puck.y], input.box);
29
+ }
30
+ /**
31
+ * 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
32
+ * 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
33
+ * 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.
34
+ * @example
35
+ * ```ts
36
+ * import { followShot } from "@johnmorrisdotca/kyogijo-stadiums/replay";
37
+ * const box = { x: 0, y: 0, w: 200, h: 85 };
38
+ * 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 }];
39
+ * const action = followShot("action", { objects, box });
40
+ * console.log(Math.round(action.x), action.y, Math.round(action.zoom * 100) / 100);
41
+ * console.log(followShot("puck", { objects, box }));
42
+ * // 148 40 1.93
43
+ * // { x: 150, y: 40, zoom: 2 }
44
+ * ```
45
+ */
46
+ export function followShot(mode, input, limits = {}) {
47
+ const { box } = input;
48
+ const at = { ...FOLLOW_LIMITS, ...limits };
49
+ if (mode === "off" || !(box.w > 0 && box.h > 0))
50
+ return wholeShot(box);
51
+ if (input.stop !== undefined) {
52
+ const [x, y] = onSurface(input.stop, box);
53
+ return clampShot({ x, y, zoom: Math.min(at.maxZoom, mode === "puck" ? at.puckZoom : at.maxZoom) }, box, at.maxZoom);
54
+ }
55
+ const puck = puckOf(input);
56
+ const players = input.objects.filter((o) => (o.team === "home" || o.team === "away") && isNum(o.x) && isNum(o.y)).map((o) => onSurface([o.x, o.y], box));
57
+ if (mode === "puck")
58
+ return puck === undefined ? followShot("action", input, limits) : clampShot({ x: puck[0], y: puck[1], zoom: at.puckZoom }, box, at.maxZoom);
59
+ if (puck === undefined && players.length === 0)
60
+ return wholeShot(box);
61
+ const anchor = puck ?? [players.reduce((s, p) => s + p[0], 0) / players.length, players.reduce((s, p) => s + p[1], 0) / players.length];
62
+ const nearest = [...players].sort((a, b) => Math.hypot(a[0] - anchor[0], a[1] - anchor[1]) - Math.hypot(b[0] - anchor[0], b[1] - anchor[1])).slice(0, Math.max(0, Math.round(at.near)));
63
+ const held = [anchor, ...nearest];
64
+ const xs = held.map((p) => p[0]);
65
+ const ys = held.map((p) => p[1]);
66
+ const pad = Math.max(box.w, box.h) * at.pad;
67
+ const [x0, x1, y0, y1] = [Math.min(...xs) - pad, Math.max(...xs) + pad, Math.min(...ys) - pad, Math.max(...ys) + pad];
68
+ const zoom = Math.min(box.w / (x1 - x0), box.h / (y1 - y0), at.maxZoom);
69
+ return clampShot({ x: (x0 + x1) / 2, y: (y0 + y1) / 2, zoom }, box, at.maxZoom);
70
+ }
71
+ /**
72
+ * 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.
73
+ * @example
74
+ * ```ts
75
+ * import { springStep } from "@johnmorrisdotca/kyogijo-stadiums/replay";
76
+ * let [x, v] = [0, 0];
77
+ * for (let i = 0; i < 60; i += 1) [x, v] = springStep(x, v, 10, 1 / 60, 4);
78
+ * console.log(Math.round(x * 100) / 100, x < 10);
79
+ * // 9.08 true
80
+ * ```
81
+ */
82
+ export function springStep(at, speed, goal, dt, omega) {
83
+ if (!(dt > 0))
84
+ return [at, speed];
85
+ const off = at - goal;
86
+ const pull = speed + omega * off;
87
+ const fade = Math.exp(-omega * dt);
88
+ return [goal + (off + pull * dt) * fade, (speed - omega * pull * dt) * fade];
89
+ }
90
+ /**
91
+ * 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
92
+ * 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
93
+ * it comes to rest and stays exactly there.
94
+ * @example
95
+ * ```ts
96
+ * import { followCamera } from "@johnmorrisdotca/kyogijo-stadiums/replay";
97
+ * const camera = followCamera();
98
+ * const input = { box: { x: 0, y: 0, w: 200, h: 85 }, objects: [{ team: "puck" as const, x: 180, y: 40 }] };
99
+ * const first = camera.update("puck", input, 1 / 60);
100
+ * let shot = first;
101
+ * for (let i = 0; i < 600; i += 1) shot = camera.update("puck", input, 1 / 60);
102
+ * console.log(first.zoom < 1.01, shot);
103
+ * // true { x: 150, y: 40, zoom: 2 }
104
+ * ```
105
+ */
106
+ export function followCamera(limits = {}) {
107
+ const at = { ...FOLLOW_LIMITS, ...limits };
108
+ let now;
109
+ let goal;
110
+ let speed = [0, 0, 0];
111
+ const settle = (shot) => {
112
+ now = shot;
113
+ goal = shot;
114
+ speed = [0, 0, 0];
115
+ };
116
+ return {
117
+ update(mode, input, dt) {
118
+ const { box } = input;
119
+ const asked = followShot(mode, input, at);
120
+ const from = now ?? wholeShot(box);
121
+ // The dead zone: a shot that moved little from the one the camera is going to is not a new goal.
122
+ if (goal === undefined || mode === "off" || dt === undefined)
123
+ goal = asked;
124
+ else {
125
+ const frame = frameOf(goal, box);
126
+ const moved = Math.abs(asked.x - goal.x) > frame.w * at.dead || Math.abs(asked.y - goal.y) > frame.h * at.dead;
127
+ const zoomed = Math.abs(Math.log(asked.zoom / goal.zoom)) > Math.log(1 + at.dead);
128
+ goal = { x: moved ? asked.x : goal.x, y: moved ? asked.y : goal.y, zoom: zoomed ? asked.zoom : goal.zoom };
129
+ }
130
+ if (dt === undefined) {
131
+ settle(goal);
132
+ return { ...goal };
133
+ }
134
+ const [x, vx] = springStep(from.x, speed[0], goal.x, dt, at.omega);
135
+ const [y, vy] = springStep(from.y, speed[1], goal.y, dt, at.omega);
136
+ const [z, vz] = springStep(Math.log(from.zoom), speed[2], Math.log(goal.zoom), dt, at.omega);
137
+ speed = [vx, vy, vz];
138
+ let next = clampShot({ x, y, zoom: Math.exp(z) }, box, at.maxZoom);
139
+ // The leash: a puck that runs ahead of the easing carries the frame with it, so that it never leaves it; the frame moves as the puck does, never by a jump.
140
+ const puck = mode === "off" ? undefined : puckOf(input);
141
+ if (puck !== undefined) {
142
+ const frame = frameOf(next, box);
143
+ const [mx, my] = [Math.min(frame.w * at.leash, frame.w / 2), Math.min(frame.h * at.leash, frame.h / 2)];
144
+ const pull = (p, middle, half, margin) => (p > middle + half - margin ? p - half + margin : p < middle - half + margin ? p + half - margin : middle);
145
+ next = clampShot({ x: pull(puck[0], next.x, frame.w / 2, mx), y: pull(puck[1], next.y, frame.h / 2, my), zoom: next.zoom }, box, at.maxZoom);
146
+ }
147
+ // At rest: close enough to the goal and slow enough is the goal itself, so that a still play writes nothing more.
148
+ const near = Math.abs(next.x - goal.x) + Math.abs(next.y - goal.y) < Math.max(box.w, box.h) * 1e-4 && Math.abs(Math.log(next.zoom / goal.zoom)) < 1e-4;
149
+ if (near && Math.abs(speed[0]) + Math.abs(speed[1]) < Math.max(box.w, box.h) * 1e-3 && Math.abs(speed[2]) < 1e-3)
150
+ settle(goal);
151
+ else
152
+ now = next;
153
+ return { ...now };
154
+ },
155
+ shot: () => (now === undefined ? undefined : { ...now }),
156
+ resting: () => now === undefined || (now.zoom === 1 && goal !== undefined && goal.zoom === 1 && now.x === goal.x && now.y === goal.y && speed.every((v) => v === 0)),
157
+ reset() {
158
+ now = undefined;
159
+ goal = undefined;
160
+ speed = [0, 0, 0];
161
+ },
162
+ };
163
+ }
164
+ /**
165
+ * 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
166
+ * 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.
167
+ */
168
+ export function zoomFor(zoom, room, maxZoom = FOLLOW_LIMITS.maxZoom) {
169
+ if (!(zoom > 1) || !(maxZoom > 1))
170
+ return 1;
171
+ return Math.pow(Math.max(1, room) * maxZoom, Math.log(Math.min(zoom, maxZoom)) / Math.log(maxZoom));
172
+ }
173
+ /**
174
+ * 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
175
+ * 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).
176
+ * @example
177
+ * ```ts
178
+ * import { followWindow } from "@johnmorrisdotca/kyogijo-stadiums/replay";
179
+ * const box = { x: 0, y: 0, w: 200, h: 85 };
180
+ * const view = { x: -8, y: -8, w: 216, h: 101 };
181
+ * console.log(followWindow({ x: 100, y: 42.5, zoom: 1 }, box, view));
182
+ * console.log(followWindow({ x: 160, y: 42.5, zoom: 2.5 }, box, view));
183
+ * // { x: -8, y: -8, w: 216, h: 101 }
184
+ * // { x: 120, y: 23.8, w: 80, h: 37.41 }
185
+ * ```
186
+ */
187
+ export function followWindow(shot, box, view, map = (p) => p, maxZoom = FOLLOW_LIMITS.maxZoom) {
188
+ const span = (points) => {
189
+ const xs = points.map((p) => p[0]);
190
+ const ys = points.map((p) => p[1]);
191
+ return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
192
+ };
193
+ const corners = (b) => [[b.x, b.y], [b.x + b.w, b.y], [b.x, b.y + b.h], [b.x + b.w, b.y + b.h]].map((p) => map(p));
194
+ const surface = span(corners(box));
195
+ const by = zoomFor(shot.zoom, surface.w > 0 && surface.h > 0 ? Math.min(view.w / surface.w, view.h / surface.h) : 1, maxZoom);
196
+ const [w, h] = [view.w / by, view.h / by];
197
+ const [cx, cy] = map([shot.x, shot.y]);
198
+ const round = (n) => Math.round(n * 100) / 100;
199
+ return { x: round(clamp(cx - w / 2, view.x, view.x + view.w - w)), y: round(clamp(cy - h / 2, view.y, view.y + view.h - h)), w: round(w), h: round(h) };
200
+ }
201
+ /** The kinds of event that stop the play, and the one that starts it again. */
202
+ const STOPS = ["whistle", "goal"];
203
+ /**
204
+ * 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.
205
+ * `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.
206
+ * @example
207
+ * ```ts
208
+ * import { followStop } from "@johnmorrisdotca/kyogijo-stadiums/replay";
209
+ * const timeline = { frames: [{ t: 0, objects: [] }, { t: 30, objects: [] }], events: [{ t: 10, kind: "whistle" }, { t: 20, kind: "faceoff", x: 69, y: 22 }] };
210
+ * console.log(followStop(timeline, 5), followStop(timeline, 15), followStop(timeline, 25));
211
+ * // undefined [ 69, 22 ] undefined
212
+ * ```
213
+ */
214
+ export function followStop(timeline, t) {
215
+ const events = replayEvents(timeline);
216
+ let stopped = false;
217
+ for (const e of events) {
218
+ if (e.t > t) {
219
+ if (stopped && e.kind === "faceoff" && isNum(e.x) && isNum(e.y))
220
+ return [e.x, e.y];
221
+ if (stopped && e.kind === "faceoff")
222
+ return undefined;
223
+ continue;
224
+ }
225
+ if (STOPS.includes(e.kind))
226
+ stopped = true;
227
+ else if (e.kind === "faceoff")
228
+ stopped = false;
229
+ }
230
+ return undefined;
231
+ }
232
+ /**
233
+ * 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
234
+ * 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.
235
+ */
236
+ export function followRunner(source) {
237
+ const camera = followCamera(source.limits);
238
+ let last;
239
+ let shown;
240
+ const show = (shot) => {
241
+ const same = shot === undefined ? shown === undefined : shown !== undefined && shot.x === shown.x && shot.y === shown.y && shot.zoom === shown.zoom;
242
+ shown = shot;
243
+ if (!same)
244
+ source.show(shot);
245
+ };
246
+ return {
247
+ paint({ state, reason }) {
248
+ const mode = state.followHeld === true ? "held" : (state.follow ?? "off");
249
+ const tick = reason === "tick";
250
+ if (!tick)
251
+ last = undefined;
252
+ if (mode === "held" || (mode === "off" && camera.resting()))
253
+ return;
254
+ if (!tick && !["seek", "step", "motion", "follow"].includes(reason))
255
+ return;
256
+ // Turned on or off, or resumed, while playing: the ticks ease it there.
257
+ if (reason === "follow" && state.playing)
258
+ return;
259
+ const stamp = source.now();
260
+ const dt = tick ? (last === undefined ? 0 : Math.min(0.1, Math.max(0, (stamp - last) / 1000))) : undefined;
261
+ if (tick)
262
+ last = stamp;
263
+ if (dt === 0)
264
+ return;
265
+ const input = source.at(state.t, mode === "off" ? undefined : followStop(source.timeline, state.t));
266
+ const shot = camera.update(mode, input, dt);
267
+ show(mode === "off" && camera.resting() ? undefined : shot);
268
+ },
269
+ again() {
270
+ if (shown !== undefined)
271
+ source.show(shown);
272
+ },
273
+ reset() {
274
+ camera.reset();
275
+ last = undefined;
276
+ shown = undefined;
277
+ },
278
+ };
279
+ }
@@ -117,6 +117,8 @@ export interface ReplaySettings extends ReplayLook {
117
117
  stepSeconds?: number;
118
118
  /** The time to begin at, in seconds from the first frame. 0 by default. */
119
119
  start?: number;
120
+ /** The camera follows the play: `action` (the players near the puck or ball), `puck` (the puck or ball), or `off` (the default). See `followShot`. */
121
+ follow?: "off" | "action" | "puck";
120
122
  }
121
123
  /** A tracked object at a moment, and the trail behind it. */
122
124
  export interface ReplayTrail {
@@ -154,9 +156,12 @@ export interface ReplayState {
154
156
  loop: boolean;
155
157
  /** `step` for a reader who prefers reduced motion: play moves a step, never by itself. */
156
158
  motion: "smooth" | "step";
159
+ /** What the camera follows, and whether the reader's own turn or zoom of a 3D view holds it until it is resumed. */
160
+ follow: "off" | "action" | "puck";
161
+ followHeld: boolean;
157
162
  }
158
163
  /** Why the controller told its listeners. */
159
- export type ReplayReason = "play" | "pause" | "seek" | "step" | "tick" | "speed" | "loop" | "motion";
164
+ export type ReplayReason = "play" | "pause" | "seek" | "step" | "tick" | "speed" | "loop" | "motion" | "follow";
160
165
  /** What a listener of the controller is told. */
161
166
  export interface ReplayChange {
162
167
  state: Readonly<ReplayState>;
@@ -1010,6 +1010,16 @@ export interface KyogijoWords {
1010
1010
  keysTurn: string;
1011
1011
  keysTurnStep: string;
1012
1012
  region: string;
1013
+ /** The camera that follows the play: the name of its buttons, each button's word and what it does, and the button that lets it follow again after the reader turned the view. */
1014
+ follow: string;
1015
+ followAction: string;
1016
+ followActionLabel: string;
1017
+ followPuck: string;
1018
+ followPuckLabel: string;
1019
+ followBall: string;
1020
+ followBallLabel: string;
1021
+ followResume: string;
1022
+ followResumeLabel: string;
1013
1023
  };
1014
1024
  /** The words of the 3D view: its name, the keys, the viewpoints, the controls, what is announced, and the names of what stands on the surface. */
1015
1025
  turn: {
@@ -447,6 +447,7 @@ export const WORDS_EN = {
447
447
  keysTurn: "Replay of the play on the 3D view. Space plays or pauses; comma and full stop step back and on by a frame, and with Shift by 5 seconds; End goes to the end. The arrow keys still turn the view. On the replay's controls the arrow keys step, and Home and End go to the ends.",
448
448
  keysTurnStep: "Replay of the play on the 3D view, in steps. Space or full stop moves on, comma goes back, and End goes to the end. The arrow keys still turn the view. On the replay's controls the right arrow key moves on and the left goes back.",
449
449
  region: "Replay",
450
+ follow: "Follow the play", followAction: "Action", followActionLabel: "Follow the action: close in on the puck or ball and those nearest it", followPuck: "Puck", followPuckLabel: "Follow the puck", followBall: "Ball", followBallLabel: "Follow the ball", followResume: "Resume", followResumeLabel: "Follow the action again",
450
451
  },
451
452
  turn: {
452
453
  region: "3D view", group: "Viewpoint",
@@ -472,6 +472,7 @@ export const WORDS_JA = {
472
472
  keysTurn: "3Dビューでのプレーのリプレイです。スペースキーで再生と一時停止、カンマキーで1コマ戻り、ピリオドキーで1コマ進みます。Shiftキーを押しながらカンマキーかピリオドキーで5秒ずつ移動し、Endキーで最後に移動します。矢印キーではこれまでどおりビューを回せます。リプレイの操作ボタンにフォーカスがあるときは、左右の矢印キーで1コマずつ前後に移動し、Homeキーで最初、Endキーで最後に移動します。",
473
473
  keysTurnStep: "3Dビューでのプレーのリプレイです。コマ送りで表示しています。スペースキーまたはピリオドキーで進み、カンマキーで戻り、Endキーで最後に移動します。矢印キーではこれまでどおりビューを回せます。リプレイの操作ボタンにフォーカスがあるときは、右矢印キーで進み、左矢印キーで戻ります。",
474
474
  region: "リプレイ",
475
+ follow: "プレーを追う", followAction: "プレー", followActionLabel: "プレーを追う:パックやボールと、その近くの選手に寄って映す", followPuck: "パック", followPuckLabel: "パックを追う", followBall: "ボール", followBallLabel: "ボールを追う", followResume: "再び追う", followResumeLabel: "もう一度プレーを追う",
475
476
  },
476
477
  turn: {
477
478
  region: "3D表示", group: "視点",
@@ -129,9 +129,10 @@ export declare function multiply(a: Mat4, b: Mat4): number[];
129
129
  /**
130
130
  * The matrix that puts a point of the scene on the screen for a view in a placement: the scene (whose middle is the origin) is scaled, turned about the vertical,
131
131
  * tipped back, shifted to where the fit puts it, and seen with a perspective `distance` pixels deep. This is `project`'s arithmetic as one matrix, so that an
132
- * element can be given its whole place in one `matrix3d` and need no 3D context of the browser's: a browser that draws nested 3D layers flat still draws it right.
132
+ * element can be given its whole place in one `matrix3d` and need no 3D context of the browser's: a browser that draws nested 3D layers flat still draws it right. `focus`, a point of the floor
133
+ * in the scene's pixels from its middle, is put where the middle would be (and, by its third number from 0 to 1, that share of the way to the container's middle): the camera looks at it, and turns and tips about it.
133
134
  */
134
- export declare function cameraMatrix(view: TurnView, at: Placement, distance: number): number[];
135
+ export declare function cameraMatrix(view: TurnView, at: Placement, distance: number, focus?: readonly [number, number] | readonly [number, number, number]): number[];
135
136
  /** A matrix as the CSS `transform` that applies it. */
136
137
  export declare const matrix3d: (m: Mat4) => string;
137
138
  /** How far a point is from the eye's plane after the scene is turned and tipped, in pixels towards the eye: bigger is nearer. Planes are drawn far first. */
@@ -208,9 +208,10 @@ const translation = (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1
208
208
  /**
209
209
  * The matrix that puts a point of the scene on the screen for a view in a placement: the scene (whose middle is the origin) is scaled, turned about the vertical,
210
210
  * tipped back, shifted to where the fit puts it, and seen with a perspective `distance` pixels deep. This is `project`'s arithmetic as one matrix, so that an
211
- * element can be given its whole place in one `matrix3d` and need no 3D context of the browser's: a browser that draws nested 3D layers flat still draws it right.
211
+ * element can be given its whole place in one `matrix3d` and need no 3D context of the browser's: a browser that draws nested 3D layers flat still draws it right. `focus`, a point of the floor
212
+ * in the scene's pixels from its middle, is put where the middle would be (and, by its third number from 0 to 1, that share of the way to the container's middle): the camera looks at it, and turns and tips about it.
212
213
  */
213
- export function cameraMatrix(view, at, distance) {
214
+ export function cameraMatrix(view, at, distance, focus) {
214
215
  const k = at.k * view.zoom;
215
216
  const [yaw, pitch] = [view.yaw * RAD, view.pitch * RAD];
216
217
  const [cy, sy, cp, sp] = [Math.cos(yaw), Math.sin(yaw), Math.cos(pitch), Math.sin(pitch)];
@@ -218,10 +219,14 @@ export function cameraMatrix(view, at, distance) {
218
219
  const rotateZ = [cy, sy, 0, 0, -sy, cy, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
219
220
  const rotateX = [1, 0, 0, 0, 0, cp, sp, 0, 0, -sp, cp, 0, 0, 0, 0, 1];
220
221
  const eye = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -1 / distance, 0, 0, 0, 1];
221
- let m = multiply(eye, translation(at.tx, at.ty, 0));
222
+ // A focus is brought toward the middle of the container by its share (the fit puts the scene's middle where its whole box is centred, which a tall scoreboard moves).
223
+ const centring = 1 - Math.min(1, Math.max(0, focus?.[2] ?? 0));
224
+ let m = multiply(eye, translation(at.tx * centring, at.ty * centring, 0));
222
225
  m = multiply(m, rotateX);
223
226
  m = multiply(m, rotateZ);
224
- return multiply(m, scale);
227
+ m = multiply(m, scale);
228
+ // A point of the floor to look at (a camera following the play), in the scene's pixels from its middle: it takes the place the middle had.
229
+ return focus === undefined || (focus[0] === 0 && focus[1] === 0) ? m : multiply(m, translation(-focus[0], -focus[1], 0));
225
230
  }
226
231
  /**
227
232
  * A number of a matrix as CSS writes it: to ten decimals, with no exponent. The perspective terms of a camera matrix are tiny (a few ten-thousandths) and are multiplied by
@@ -81,6 +81,11 @@ export declare function turnSupported(win?: {
81
81
  supports?: (property: string, value: string) => boolean;
82
82
  } | null;
83
83
  } | undefined): boolean;
84
+ /** Where a camera that follows the play looks: a point of the floor, in the scene's pixels from its middle, and a zoom on top of the view's own. */
85
+ export interface Aim {
86
+ focus: [number, number];
87
+ zoom: number;
88
+ }
84
89
  /**
85
90
  * Mount a surface as a 3D view in a container, over the static drawing it would otherwise hold: the drawing is the floor, tilted and turned by the pointer, the
86
91
  * touch, the wheel and the keys, with what stands on the surface (a net and its posts, a basket, a goal, the posts of a football field) standing on it.
package/dist/turn.dom.js CHANGED
@@ -35,6 +35,13 @@ export function turnSupported(win = typeof window === "undefined" ? undefined :
35
35
  return css.supports("transform", "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)");
36
36
  }
37
37
  const KEYS = { " ": true, ArrowLeft: true, ArrowRight: true, ArrowUp: true, ArrowDown: true, "+": true, "=": true, "-": true, _: true, "0": true, Home: true, "1": true, "2": true, "3": true, "4": true };
38
+ /** The point an aim looks at, brought toward the middle of the view as it closes in: not at all at its zoom of 1, so that turning it on does not move the view. */
39
+ const focusOf = (aim) => (aim === undefined ? undefined : [aim.focus[0], aim.focus[1], 1 - 1 / Math.max(1, aim.zoom)]);
40
+ /** An aim part of the way from one to another (none is the middle at a zoom of 1). */
41
+ const aimBetween = (from, to, t) => {
42
+ const [a, b] = [from ?? { focus: [0, 0], zoom: 1 }, to ?? { focus: [0, 0], zoom: 1 }];
43
+ return { focus: [a.focus[0] + (b.focus[0] - a.focus[0]) * t, a.focus[1] + (b.focus[1] - a.focus[1]) * t], zoom: a.zoom * Math.pow(b.zoom / a.zoom, t) };
44
+ };
38
45
  /** The view part of the way from one to another, `t` from 0 to 1, the turn taken the short way round. */
39
46
  const between = (from, to, t) => ({ yaw: from.yaw + (nearest(from.yaw, to.yaw) - from.yaw) * t, pitch: from.pitch + (to.pitch - from.pitch) * t, zoom: from.zoom * Math.pow(to.zoom / from.zoom, t) });
40
47
  /**
@@ -176,6 +183,14 @@ export function mountTurn(host, options, settings, build, replayable = false, le
176
183
  let shown = view;
177
184
  let opening = model.view;
178
185
  let flight;
186
+ /** What a camera that follows the play looks at (a point of the floor, and a zoom on top of the view's), where it is going in a glide, and who is told when the reader takes the view over. */
187
+ let aim;
188
+ let aimTo;
189
+ const users = new Set();
190
+ const took = (kind) => {
191
+ for (const user of [...users])
192
+ user(kind);
193
+ };
179
194
  let spoken;
180
195
  // The auto-turn: whether it is on, the frame that will advance it, the time of the last one, and whether it has moved the view since the page was last told.
181
196
  let playing = false;
@@ -273,7 +288,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
273
288
  model = decorate === undefined ? plain : decorate(plain);
274
289
  ceiling = ceilingFor(model, stage.clientWidth || 960, stage.clientHeight || 600, shown.zoom);
275
290
  raster = rasterFor(model);
276
- const layout = layoutOf(model, shown, stage.clientWidth || 960, stage.clientHeight || 600, settings.lens, false, raster);
291
+ const layout = layoutOf(model, aim === undefined ? shown : { ...shown, zoom: shown.zoom * aim.zoom }, stage.clientWidth || 960, stage.clientHeight || 600, settings.lens, false, raster, focusOf(aim));
277
292
  const world = root.querySelector(".ky-turn-world");
278
293
  world.innerHTML = worldMarkup(model, layout, raster);
279
294
  // The look the floor is painted in (and the theme) are attributes of the world.
@@ -342,10 +357,11 @@ export function mountTurn(host, options, settings, build, replayable = false, le
342
357
  settling = next === ceiling ? undefined : win.setTimeout(settleRaster, next > ceiling ? 160 : 1500);
343
358
  };
344
359
  /** Put a view on the screen. */
345
- const paint = (now) => {
360
+ const paint = (asked) => {
346
361
  const width = stage.clientWidth || 960;
347
362
  const height = stage.clientHeight || 600;
348
- const layout = layoutOf(model, now, width, height, settings.lens, true, raster);
363
+ const now = aim === undefined ? asked : { ...asked, zoom: asked.zoom * aim.zoom };
364
+ const layout = layoutOf(model, now, width, height, settings.lens, true, raster, focusOf(aim));
349
365
  outside?.zoom(now.zoom);
350
366
  const culled = layout.culled;
351
367
  elements.forEach((one, i) => {
@@ -389,11 +405,16 @@ export function mountTurn(host, options, settings, build, replayable = false, le
389
405
  };
390
406
  /** Go to a view: by a glide of `ms` milliseconds, or at once. */
391
407
  const show = (to, ms) => {
408
+ // A camera that follows the play and is asked to let go glides back with the view (and goes on doing so when another glide takes over).
409
+ const aiming = aimTo !== undefined ? { from: aim, to: aimTo.to } : flight?.aim === undefined ? undefined : { from: aim, to: flight.aim.to };
410
+ aimTo = undefined;
392
411
  stop();
393
412
  view = to;
394
413
  label();
395
414
  if (ms <= 0 || calm() || win === null) {
396
415
  shown = to;
416
+ if (aiming !== undefined)
417
+ aim = aiming.to;
397
418
  paint(shown);
398
419
  return;
399
420
  }
@@ -404,13 +425,15 @@ export function mountTurn(host, options, settings, build, replayable = false, le
404
425
  const t = Math.min(1, (stamp - flight.start) / flight.ms);
405
426
  const eased = 1 - Math.pow(1 - t, 3);
406
427
  shown = t >= 1 ? flight.to : between(flight.from, flight.to, eased);
428
+ if (flight.aim !== undefined)
429
+ aim = t >= 1 ? flight.aim.to : aimBetween(flight.aim.from, flight.aim.to, eased);
407
430
  paint(shown);
408
431
  if (t < 1)
409
432
  flight.frame = win.requestAnimationFrame(run);
410
433
  else
411
434
  flight = undefined;
412
435
  };
413
- flight = { from, to, start: win.performance.now(), ms, frame: 0 };
436
+ flight = { from, to, start: win.performance.now(), ms, frame: 0, aim: aiming };
414
437
  flight.frame = win.requestAnimationFrame(run);
415
438
  };
416
439
  const say = (text, wait = 0) => {
@@ -543,6 +566,12 @@ export function mountTurn(host, options, settings, build, replayable = false, le
543
566
  if (target === null || target === undefined || !root.contains(target))
544
567
  return;
545
568
  const name = target.dataset.preset;
569
+ const act = target.dataset.act ?? "";
570
+ if (name !== undefined || act === "reset")
571
+ took("preset");
572
+ // (The replay's own buttons are in the view too: only the view's turn it.)
573
+ else if (act !== "" && act !== "play" && act !== "reverse")
574
+ took("turn");
546
575
  if (name !== undefined)
547
576
  return toPreset(name);
548
577
  const big = event.shiftKey === true;
@@ -569,6 +598,8 @@ export function mountTurn(host, options, settings, build, replayable = false, le
569
598
  const yaw = big ? STEP.yawBig : STEP.yaw;
570
599
  const pitch = big ? STEP.pitchBig : STEP.pitch;
571
600
  event.preventDefault();
601
+ if (event.key !== " ")
602
+ took(/^[0-4]$|^Home$/.test(event.key) ? "preset" : "turn");
572
603
  switch (event.key) {
573
604
  // A key takes the surface toward its side as a drag toward that side does: the side nearest the eye goes to the right on ArrowRight, and ArrowUp tips it as a drag up does.
574
605
  case "ArrowLeft": return go(nudge(view, "left", yaw), 120);
@@ -629,9 +660,13 @@ export function mountTurn(host, options, settings, build, replayable = false, le
629
660
  }
630
661
  else {
631
662
  view = drag(view, here.x - before.x, here.y - before.y, tilts(event.pointerType));
632
- if (Math.abs(here.x - before.x) + Math.abs(here.y - before.y) > 0)
663
+ if (Math.abs(here.x - before.x) + Math.abs(here.y - before.y) > 0 && !moved) {
633
664
  moved = true;
665
+ took("turn");
666
+ }
634
667
  }
668
+ if (pinch !== undefined)
669
+ took("turn");
635
670
  shown = view;
636
671
  paint(shown);
637
672
  };
@@ -655,7 +690,11 @@ export function mountTurn(host, options, settings, build, replayable = false, le
655
690
  stage.addEventListener("pointermove", onMove);
656
691
  stage.addEventListener("pointerup", onUp);
657
692
  stage.addEventListener("pointercancel", onUp);
658
- stage.addEventListener("dblclick", toOpening);
693
+ const onDouble = () => {
694
+ took("preset");
695
+ toOpening();
696
+ };
697
+ stage.addEventListener("dblclick", onDouble);
659
698
  // ---- The wheel over the view zooms it, as a map does; with the pointer anywhere else the page scrolls. Ctrl or Command and the wheel (a pinch on a trackpad) zoom too. Shift and the
660
699
  // wheel, or a sideways swipe, turn it. Tipping is a drag, the keys and the buttons. A wheel that starts while the zoom is already at its end that way goes on to scroll the page, so the page is
661
700
  // never trapped; one that reaches the end part-way through stops there rather than flinging the page. ----
@@ -667,6 +706,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
667
706
  if (!pinch && (event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))) {
668
707
  event.preventDefault();
669
708
  wheelTaken = now;
709
+ took("turn");
670
710
  grab();
671
711
  view = clampView({ ...view, yaw: view.yaw + (event.deltaX || event.deltaY) * 0.25 });
672
712
  }
@@ -680,6 +720,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
680
720
  wheelTaken = now;
681
721
  if (next.zoom === view.zoom)
682
722
  return;
723
+ took("turn");
683
724
  grab();
684
725
  view = next;
685
726
  }
@@ -721,6 +762,21 @@ export function mountTurn(host, options, settings, build, replayable = false, le
721
762
  if (alive)
722
763
  paint(shown);
723
764
  },
765
+ aim(next, glide = false) {
766
+ if (!alive)
767
+ return;
768
+ if (glide) {
769
+ aimTo = { to: next };
770
+ // (A glide of the view that is about to start carries it; otherwise it glides alone.)
771
+ show(view, 560);
772
+ return;
773
+ }
774
+ aim = next;
775
+ },
776
+ onUser(listener) {
777
+ users.add(listener);
778
+ return () => void users.delete(listener);
779
+ },
724
780
  requestFrame,
725
781
  cancelFrame,
726
782
  });
@@ -864,6 +920,7 @@ export function mountTurn(host, options, settings, build, replayable = false, le
864
920
  probe?.stop();
865
921
  frames.clear();
866
922
  seen?.disconnect();
923
+ users.clear();
867
924
  doc.removeEventListener("visibilitychange", onVisible);
868
925
  clearTimeout(spoken);
869
926
  observer?.disconnect();
@@ -0,0 +1,22 @@
1
+ import type { ReplayController } from "./replay.control.ts";
2
+ import { type FollowShot } from "./replay.follow.ts";
3
+ import type { ReplayChange, ReplayLook, ReplayTimeline } from "./replay.types.ts";
4
+ import type { Aim } from "./turn.dom.ts";
5
+ import type { TurnModel } from "./turn.model.ts";
6
+ import type { TurnInternals } from "./turn.replay.dom.ts";
7
+ import type { ScoreOptions, SurfaceOptions } from "./types.ts";
8
+ /**
9
+ * What a 3D view looks at for a shot: the shot's middle as a point of the floor in the scene's pixels from its middle, and the zoom on top of the view's. The scene at a zoom of 1 holds more
10
+ * than the surface (the boards, the stands): the zoom is worked out against how much more, so that the closest shot frames the same share of the surface as a flat drawing's.
11
+ */
12
+ export declare function aimOf(shot: FollowShot, model: Pick<TurnModel, "scale" | "centre" | "box">, surface: {
13
+ w: number;
14
+ h: number;
15
+ }): Aim;
16
+ /** What `turnFollow` gives back: the paint the replay calls on each change of its clock, and `destroy`. */
17
+ export interface TurnFollow {
18
+ paint(change: ReplayChange): void;
19
+ destroy(): void;
20
+ }
21
+ /** The camera of a replay on a 3D view. `floor` is the options the floor is drawn with (the replay's), or `undefined` before it is laid. */
22
+ export declare function turnFollow(view: TurnInternals, controller: () => ReplayController, timeline: ReplayTimeline, look: ReplayLook, floor: () => SurfaceOptions | ScoreOptions | undefined): TurnFollow;