@johnmorrisdotca/kyogijo-stadiums 0.58.0 → 0.60.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -0
  3. package/catalogue.json +1 -1
  4. package/dist/athletics/plan.d.ts +2 -0
  5. package/dist/athletics/plan.js +20 -4
  6. package/dist/catalogue.data.js +1 -1
  7. package/dist/crowd.d.ts +18 -0
  8. package/dist/crowd.js +88 -18
  9. package/dist/draw.js +22 -7
  10. package/dist/element.d.ts +3 -3
  11. package/dist/element.js +10 -5
  12. package/dist/replay-entry.d.ts +2 -0
  13. package/dist/replay-entry.js +1 -0
  14. package/dist/replay.control.d.ts +4 -0
  15. package/dist/replay.control.js +16 -0
  16. package/dist/replay.dom.js +20 -3
  17. package/dist/replay.follow.d.ts +164 -0
  18. package/dist/replay.follow.dom.d.ts +30 -0
  19. package/dist/replay.follow.dom.js +118 -0
  20. package/dist/replay.follow.js +279 -0
  21. package/dist/replay.types.d.ts +6 -1
  22. package/dist/strings.core.d.ts +10 -0
  23. package/dist/strings.en.js +1 -0
  24. package/dist/strings.ja.js +1 -0
  25. package/dist/surfaces/artistic.js +3 -1
  26. package/dist/surfaces/athletics.js +2 -0
  27. package/dist/surfaces/hockey.js +3 -1
  28. package/dist/surfaces/surface.d.ts +7 -0
  29. package/dist/surfaces/surface.js +9 -0
  30. package/dist/turn.camera.d.ts +3 -2
  31. package/dist/turn.camera.js +9 -4
  32. package/dist/turn.dom.d.ts +5 -0
  33. package/dist/turn.dom.js +63 -6
  34. package/dist/turn.follow.d.ts +22 -0
  35. package/dist/turn.follow.js +46 -0
  36. package/dist/turn.markup.d.ts +1 -1
  37. package/dist/turn.markup.js +14 -3
  38. package/dist/turn.replay.dom.d.ts +5 -0
  39. package/dist/turn.replay.dom.js +8 -0
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +1 -1
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.0] - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - **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.
15
+
16
+ ## [0.59.0] - 2026-11-03
17
+
18
+ ### Fixed
19
+
20
+ - **The stands clear the whole floor, centred (KYOGIJO-STADIUMS-44)** ([the stands and the crowd](./docs/crowd.md#the-venues)): with `crowd`, the bowl's opening was laid out from the playing surface's box alone, so the floor drawn round it ran into the first rows: a court's run-off at the corners (volleyball, sepak takraw, netball), a table tennis area at its ends, a rink's boards, and an athletics track's sprint straight, jumps' runways and pits beyond the oval. The opening is now fitted to everything the surface draws round its playing area (its view with no margin, and an outline where the floor is not a rectangle: the track with its straight and runways, a rink's rounded boards), clear of it all round, corners included, by 2 feet; a floor bigger than the sport's distances (table tennis) gets an opening of its own shape. The bench side's extra room is shared by the two sides, so the opening is centred on the floor and the bowl in the drawing (a soccer ground's touchlines are 23.7 feet from the first row on both sides, where they were 19.7 and 27.7), and the margin holds the roof's shade. The tunnel, the camera platform and the media's desks stay in the seats, the media's desks no longer run round a small opening's corners, and the benches stand between the floor and the first row only where the gap holds them. An artistic swimming area with a crowd keeps the margin the stands need along its sides (it gave them four tenths of it). The flat drawing and the 3D view share the one layout (`crowdStands`), which now gives the `floor` it was fitted to. In 3D, a table tennis table's raised floor (the drawing lifted to the table's height, with the ground the stands stand on) was sorted by its middle with the stands and drawn over the far ones, so from Low corner or Broadcast the bowl looked cut open round a small tile: the bowl is now always drawn over a raised floor, and the faint near stands at a low viewpoint are the walls giving way to the eye, as before. New tests walk every sport with a crowd, at every size and orientation, light and dark: no part of the floor's outline within 2 feet of a seat, nothing of the stands on the floor, the opening centred on the floor and the bowl centred in the drawing and wholly inside it; and in 3D, at every viewpoint, zoom and box, the eye outside the whole scene, nothing near the eye's plane, and no plane of the bowl under the floor. Pictures of a court, a track, a rink, a ring and a table tennis area in their stands are in the docs.
21
+
10
22
  ## [0.58.0] - 2026-11-03
11
23
 
12
24
  ### Added
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.58.0"
6
+ "version": "0.60.0"
7
7
  },
8
8
  "address": "https://johnmorrisdotca.github.io/kyogijo-stadiums/catalogue.json",
9
9
  "site": "https://johnmorrisdotca.github.io/kyogijo-stadiums/",
@@ -42,6 +42,8 @@ export interface AthleticsPlan {
42
42
  box: MBox;
43
43
  /** Everything drawn: the box and what lies beyond it (the jump strip, the runways' ends, the straight). */
44
44
  extent: MBox;
45
+ /** What `extent` holds as points (the oval and what lies beyond it): what a crowd's stands clear. */
46
+ outline?: [number, number][];
45
47
  areas: Area[];
46
48
  shapes: Shape[];
47
49
  digits: Digits[];
@@ -224,13 +224,28 @@ export function athleticsPlan(o) {
224
224
  const box = { x0: -S / 2 - Rout, y0: -Rout, x1: S / 2 + Rout, y1: Rout };
225
225
  let extent = grow(box, 1);
226
226
  const edgeStraight = R + straight.lanes * straight.lane;
227
+ // What lies beyond the oval: the sprint straight, the jumps' strip, the javelin's runways.
228
+ const beyond = [];
227
229
  if (!indoor)
228
- extent = union([extent, { x0: straight.x0, y0: -Rout, x1: straight.x1, y1: edgeStraight }]);
230
+ beyond.push({ x0: straight.x0, y0: -Rout, x1: straight.x1, y1: edgeStraight });
229
231
  if (events.includes("long-jump") || events.includes("triple-jump"))
230
- extent = union([extent, jumpStrip()]);
231
- if (events.includes("javelin-throw"))
232
- extent = union([extent, ...inf.facilities.filter((f) => f.event === "javelin-throw").map((f) => f.box)].map((b) => ({ x0: Math.max(b.x0, -S / 2 - Rout - 6), y0: b.y0, x1: Math.min(b.x1, S / 2 + Rout + 6), y1: b.y1 })));
232
+ beyond.push(jumpStrip());
233
+ extent = union([extent, ...beyond]);
234
+ if (events.includes("javelin-throw")) {
235
+ const runways = inf.facilities.filter((f) => f.event === "javelin-throw").map((f) => f.box);
236
+ const cut = (b) => ({ x0: Math.max(b.x0, -S / 2 - Rout - 6), y0: b.y0, x1: Math.min(b.x1, S / 2 + Rout + 6), y1: b.y1 });
237
+ extent = union([extent, ...runways].map(cut));
238
+ beyond.push(...runways.map(cut));
239
+ }
233
240
  extent = grow(extent, 0);
241
+ const rim = Rout + 1;
242
+ const outline = [];
243
+ for (let i = 0; i <= 32; i += 1) {
244
+ const a = -Math.PI / 2 + (Math.PI * i) / 32;
245
+ outline.push([S / 2 + rim * Math.cos(a), rim * Math.sin(a)], [-S / 2 - rim * Math.cos(a), rim * Math.sin(a)]);
246
+ }
247
+ for (const b of beyond)
248
+ outline.push([b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]);
234
249
  const plan = {
235
250
  sport: "athletics",
236
251
  league: league.id,
@@ -243,6 +258,7 @@ export function athleticsPlan(o) {
243
258
  infield,
244
259
  box,
245
260
  extent,
261
+ outline,
246
262
  areas: parts.areas,
247
263
  shapes: parts.shapes,
248
264
  digits: parts.digits,
@@ -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.58.0"
6
+ "version": "0.60.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/crowd.d.ts CHANGED
@@ -70,6 +70,14 @@ export interface CrowdStands {
70
70
  stadium?: "purpose" | "nfl" | "cfl";
71
71
  /** The playing surface, in the wide frame. */
72
72
  box: Box;
73
+ /**
74
+ * The floor round the playing surface, where the surface draws more than its box (a court's run-off, a track's sprint straight, runways and pits): its bounding box and its outline as points, in the wide frame.
75
+ * The first row of seats clears all of it by `FLOOR_CLEAR`, and the opening is centred on its box. Missing for a real venue's stands and a ballpark's, which are laid out from their own data.
76
+ */
77
+ floor?: {
78
+ box: Box;
79
+ points: Pt[];
80
+ };
73
81
  rings: StandRing[];
74
82
  /** Boxes where the stands are left out (the open end of a horseshoe, the ends of two sides). */
75
83
  covers: [x0: number, y0: number, x1: number, y1: number][];
@@ -310,10 +318,20 @@ export interface LayoutInput {
310
318
  };
311
319
  /** A real venue's stands in place of the sport's usual tiers (see `venueBowl`). */
312
320
  real?: RealBowl;
321
+ /**
322
+ * Everything the surface draws round its playing area, in the wide frame: the bounding box (the surface's own view with no margin) and, where the floor is not that rectangle (an athletics track, an oval
323
+ * ground), its outline as points. The opening of the stands is centred on it and clears it. Without it the floor is the box.
324
+ */
325
+ floor?: {
326
+ box: Box;
327
+ points?: readonly (readonly [number, number])[];
328
+ };
313
329
  /** The ballpark's fence as points on the field, for a ballpark (see `parkLayout`). */
314
330
  fence?: readonly Pt[];
315
331
  /** The foul lines' length at the poles and the backstop's distance, for a ballpark. */
316
332
  backstop?: number;
317
333
  }
334
+ /** The room kept between the floor (everything the surface draws round its playing area) and the first row of seats, in feet. */
335
+ export declare const FLOOR_CLEAR = 2;
318
336
  /** The layout of a sport's stands round its surface: rings, covers, sectors and gaps. For a ballpark see `parkLayout`. */
319
337
  export declare function layoutOf(input: LayoutInput): CrowdStands;
package/dist/crowd.js CHANGED
@@ -330,6 +330,62 @@ function offsets(p, box, stadium) {
330
330
  const side = g.side + (f.width - box.h) / 2;
331
331
  return { left: end, right: end, top: side, bottom: side + g.bench };
332
332
  }
333
+ /** The room kept between the floor (everything the surface draws round its playing area) and the first row of seats, in feet. */
334
+ export const FLOOR_CLEAR = 2;
335
+ /** A point inside a rounded rectangle or an ellipse of half sizes `hx`, `hy` about the origin, with `clear` to spare. */
336
+ function insideOpening(ax, ay, hx, hy, r, oval, clear) {
337
+ const [w, h] = [hx - clear, hy - clear];
338
+ if (w <= 0 || h <= 0)
339
+ return false;
340
+ if (oval)
341
+ return sq(ax / w) + sq(ay / h) <= 1 + 1e-9;
342
+ const rr = Math.max(0, Math.min(r, hx, hy) - clear);
343
+ if (ax > w + 1e-9 || ay > h + 1e-9)
344
+ return false;
345
+ const kx = ax - (w - rr);
346
+ const ky = ay - (h - rr);
347
+ return kx <= 0 || ky <= 0 || sq(kx) + sq(ky) <= sq(rr) + 1e-9;
348
+ }
349
+ /** The first row's opening round a floor, centred on it (the bench room shared by both sides): the sport's distances, or the floor's own shape where they do not clear it, grown alike until its whole outline clears by `FLOOR_CLEAR`. */
350
+ function openingOf(input) {
351
+ const { off, corner, oval, box } = input;
352
+ const floor = input.floor?.box ?? box;
353
+ const cx = floor.x + floor.w / 2;
354
+ const cy = floor.y + floor.h / 2;
355
+ let hx = Math.max(cx - box.x, box.x + box.w - cx) + (off.left + off.right) / 2;
356
+ let hy = Math.max(cy - box.y, box.y + box.h - cy) + (off.top + off.bottom) / 2;
357
+ // A floor the sport's distances do not clear (table tennis): the floor's own shape.
358
+ if (Math.min(hx - floor.w / 2, hy - floor.h / 2) < FLOOR_CLEAR) {
359
+ hx = floor.w / 2 + FLOOR_CLEAR;
360
+ hy = floor.h / 2 + FLOOR_CLEAR;
361
+ }
362
+ const given = input.floor?.points;
363
+ const points = given !== undefined && given.length > 0
364
+ ? given.map(([x, y]) => [x, y])
365
+ : oval
366
+ ? Array.from({ length: 64 }, (_, i) => [cx + (floor.w / 2) * Math.cos((i * Math.PI) / 32), cy + (floor.h / 2) * Math.sin((i * Math.PI) / 32)])
367
+ : [[floor.x, floor.y], [floor.x + floor.w, floor.y], [floor.x + floor.w, floor.y + floor.h], [floor.x, floor.y + floor.h]];
368
+ // A sampled curve: a little to spare.
369
+ const clear = FLOOR_CLEAR + (given !== undefined || oval ? 0.5 : 0);
370
+ const fits = (d) => points.every(([x, y]) => insideOpening(Math.abs(x - cx), Math.abs(y - cy), hx + d, hy + d, corner, oval, clear));
371
+ if (!fits(0)) {
372
+ let lo = 0;
373
+ let hi = 4;
374
+ while (!fits(hi) && hi < 4096)
375
+ hi *= 2;
376
+ for (let i = 0; i < 40; i += 1) {
377
+ const mid = (lo + hi) / 2;
378
+ if (fits(mid))
379
+ hi = mid;
380
+ else
381
+ lo = mid;
382
+ }
383
+ hx += hi;
384
+ hy += hi;
385
+ }
386
+ const round = (v) => Math.round(v * 1e6) / 1e6;
387
+ return { off: { left: round(box.x - (cx - hx)), right: round(cx + hx - box.x - box.w), top: round(box.y - (cy - hy)), bottom: round(cy + hy - box.y - box.h) }, cx, cy, floor, points };
388
+ }
333
389
  /** The layout of a sport's stands round its surface: rings, covers, sectors and gaps. For a ballpark see `parkLayout`. */
334
390
  export function layoutOf(input) {
335
391
  const { sport, box } = input;
@@ -342,15 +398,16 @@ export function layoutOf(input) {
342
398
  const stadium = sport === "soccer" && input.real === undefined ? host ?? "purpose" : undefined;
343
399
  const p = stadium === "nfl" || stadium === "cfl" ? { ...PROFILES[stadium], shape: base.shape, away: base.away } : base;
344
400
  const shape = input.real !== undefined ? "bowl" : crowd.shape ?? p.shape;
345
- const off = input.real?.off ?? offsets(base, box, stadium ?? "purpose");
346
- const cx = box.x + box.w / 2;
347
- const cy = box.y + box.h / 2;
401
+ const fit = input.real === undefined ? openingOf({ off: offsets(base, box, stadium ?? "purpose"), corner: p.corner, oval: base.oval === true, box, floor: input.floor }) : undefined;
402
+ const off = input.real?.off ?? fit?.off ?? offsets(base, box, stadium ?? "purpose");
403
+ const cx = fit?.cx ?? box.x + box.w / 2;
404
+ const cy = fit?.cy ?? box.y + box.h / 2;
348
405
  const rings = input.real === undefined ? [] : input.real.rings.map((ring) => ({ ...ring }));
349
406
  const reachOf = input.real === undefined ? { left: 0, right: 0, top: 0, bottom: 0 } : { ...input.real.reach };
350
407
  for (const t of input.real === undefined ? p.tiers : []) {
351
408
  const at = (k) => {
352
409
  if (base.oval === true)
353
- return { kind: "ellipse", cx, cy, rx: box.w / 2 + (off.left + off.right) / 2 + k, ry: box.h / 2 + (off.top + off.bottom) / 2 + k };
410
+ return { kind: "ellipse", cx, cy, rx: (box.w + off.left + off.right) / 2 + k, ry: (box.h + off.top + off.bottom) / 2 + k };
354
411
  return { kind: "rrect", x0: box.x - off.left - k, y0: box.y - off.top - k, x1: box.x + box.w + off.right + k, y1: box.y + box.h + off.bottom + k, r: p.corner + k };
355
412
  };
356
413
  const region = at(t.to);
@@ -379,17 +436,18 @@ export function layoutOf(input) {
379
436
  const covers = [];
380
437
  const x0 = box.x - off.left;
381
438
  const x1 = box.x + box.w + off.right;
382
- const yTop = last === undefined ? box.y : box.y - reachOf.top - 4;
383
- const yBot = last === undefined ? box.y + box.h : box.y + box.h + reachOf.bottom + 4;
439
+ // The covers reach past the roof's shade (9 feet beyond the upper tier) too.
440
+ const yTop = last === undefined ? box.y : box.y - reachOf.top - 10;
441
+ const yBot = last === undefined ? box.y + box.h : box.y + box.h + reachOf.bottom + 10;
384
442
  if (shape === "horseshoe") {
385
443
  // The open end: everything beyond the surface's end line and the gap that is kept clear.
386
444
  if (crowd.open === "left")
387
- covers.push([box.x - reachOf.left - 4, yTop, x0 + p.corner * 0.5, yBot]);
445
+ covers.push([box.x - reachOf.left - 10, yTop, x0 + p.corner * 0.5, yBot]);
388
446
  else
389
- covers.push([x1 - p.corner * 0.5, yTop, box.x + box.w + reachOf.right + 4, yBot]);
447
+ covers.push([x1 - p.corner * 0.5, yTop, box.x + box.w + reachOf.right + 10, yBot]);
390
448
  }
391
449
  else if (shape === "sides") {
392
- covers.push([box.x - reachOf.left - 4, yTop, x0 + p.corner * 0.5, yBot], [x1 - p.corner * 0.5, yTop, box.x + box.w + reachOf.right + 4, yBot]);
450
+ covers.push([box.x - reachOf.left - 10, yTop, x0 + p.corner * 0.5, yBot], [x1 - p.corner * 0.5, yTop, box.x + box.w + reachOf.right + 10, yBot]);
393
451
  }
394
452
  const concourses = [];
395
453
  rings.forEach((ring, i) => {
@@ -398,6 +456,8 @@ export function layoutOf(input) {
398
456
  concourses.push({ region: ring.hole, hole: before.region });
399
457
  });
400
458
  const stands = { sport, shape, box, rings, covers, concourses, sectors: [], gaps: [], reach: reachOf, seats: rings.reduce((n, r) => n + r.seats, 0) };
459
+ if (fit !== undefined)
460
+ stands.floor = { box: fit.floor, points: fit.points };
401
461
  if (input.real !== undefined)
402
462
  stands.real = input.real.shell;
403
463
  if (input.real?.closedSectors !== undefined)
@@ -490,21 +550,31 @@ export function layoutOf(input) {
490
550
  }
491
551
  }
492
552
  if (crowd.gaps && shape !== "grandstand") {
493
- const bottom = box.y + box.h;
494
- const mid = box.x + box.w / 2;
553
+ // Nothing on the floor: the benches stand between the floor and the first row.
554
+ const edge = box.y + box.h + off.bottom;
555
+ const floorEdge = Math.max(box.y + box.h, fit === undefined ? -Infinity : fit.floor.y + fit.floor.h);
556
+ const mid = cx;
495
557
  // A tunnel through the lower stands at the middle of the bench side, the camera platform above them, and the benches in the gap.
496
- stands.gaps.push({ kind: "tunnel", box: [mid - 6, bottom + off.bottom - 1, mid + 6, bottom + off.bottom + lowerRing.to + 1] });
558
+ stands.gaps.push({ kind: "tunnel", box: [mid - 6, edge, mid + 6, edge + lowerRing.to + 1] });
497
559
  const media = rings[rings.length - 1];
498
560
  if (media !== undefined && media.tier === "upper")
499
- stands.gaps.push({ kind: "camera", box: [mid - 22, bottom + off.bottom + media.from + 3, mid + 22, bottom + off.bottom + media.from + 13] });
500
- if (off.bottom - off.top >= 4 || sport === "soccer" || sport === "nfl" || sport === "cfl" || sport === "ifaf" || sport === "rugby" || sport === "gaelic") {
561
+ stands.gaps.push({ kind: "camera", box: [mid - 22, edge + media.from + 3, mid + 22, edge + media.from + 13] });
562
+ if (base.bench >= 4 || sport === "soccer" || sport === "nfl" || sport === "cfl" || sport === "ifaf" || sport === "rugby" || sport === "gaelic") {
501
563
  const benchLen = Math.min(34, box.w * 0.12);
502
- for (const s of [-1, 1]) {
503
- const bx = mid + s * (box.w * 0.1 + benchLen / 2 + 4);
504
- stands.gaps.push({ kind: "bench", box: [bx - benchLen / 2, bottom + 2.5, bx + benchLen / 2, bottom + 2.5 + Math.min(5, off.bottom * 0.3)] });
564
+ const depth = Math.min(5, off.bottom * 0.3);
565
+ if (floorEdge + 2.5 + depth <= edge - 1) {
566
+ for (const s of [-1, 1]) {
567
+ const bx = box.x + box.w / 2 + s * (box.w * 0.1 + benchLen / 2 + 4);
568
+ stands.gaps.push({ kind: "bench", box: [bx - benchLen / 2, floorEdge + 2.5, bx + benchLen / 2, floorEdge + 2.5 + depth] });
569
+ }
505
570
  }
506
571
  }
507
- stands.gaps.push({ kind: "media", box: [mid - 55, bottom + off.bottom + 2, mid + 55, bottom + off.bottom + 6] });
572
+ // The media's desks: along the straight of that side only.
573
+ const hole = lowerRing.hole;
574
+ const straight = hole?.kind === "rrect" ? (hole.x1 - hole.x0) / 2 - Math.min(hole.r, (hole.x1 - hole.x0) / 2, (hole.y1 - hole.y0) / 2) : 55;
575
+ const half = Math.min(55, straight);
576
+ if (half >= 8)
577
+ stands.gaps.push({ kind: "media", box: [mid - half, edge + 2, mid + half, edge + 6] });
508
578
  }
509
579
  }
510
580
  return stands;
package/dist/draw.js CHANGED
@@ -436,7 +436,7 @@ function realStadiumOf(o, sport, real) {
436
436
  return stadium !== undefined && stadium.configs[config] !== undefined && (stadiumKind(stadium) === "park") === (config === "mlb") ? { stadium, config } : undefined;
437
437
  }
438
438
  /** The stands round a surface, laid out from its box (or, for a ballpark, from its fence). */
439
- function standsFor(o, surface, crowd) {
439
+ function standsFor(o, surface, crowd, floor) {
440
440
  const { sport } = chooseLeague(o);
441
441
  const awaySide = resolveHomeSide({ ...o, sport }) === "left" ? "right" : "left";
442
442
  if (sport === "mlb") {
@@ -469,7 +469,19 @@ function standsFor(o, surface, crowd) {
469
469
  // A dojo's crowd sits in its gallery, from the room's balcony data (in feet here); the arenas (the Budokan, the Kokugikan) keep their bowls.
470
470
  const hall = sport === "dojo" ? dojoGallery(o) : undefined;
471
471
  const gallery = hall === undefined ? undefined : { offset: hall.offset / 0.3048, depth: hall.depth / 0.3048, height: [hall.height[0] / 0.3048, hall.height[1] / 0.3048], rows: hall.rows, aisles: hall.aisles };
472
- return crowdLayout({ sport, box, crowd, awaySide, ground: o.ground, arena: o.budo?.hall === "budokan", ...(gallery === undefined ? {} : { gallery }) });
472
+ return crowdLayout({ sport, box, crowd, awaySide, ground: o.ground, arena: o.budo?.hall === "budokan", ...(gallery === undefined ? {} : { gallery }), ...(floor === undefined ? {} : { floor }) });
473
+ }
474
+ /** A surface's floor with no margin (its view, and its outline where it has one); none for a close-up. */
475
+ function floorOf(probe) {
476
+ if (probe.whole !== undefined)
477
+ return undefined;
478
+ const view = wideView(probe);
479
+ if (probe.floor === undefined)
480
+ return { box: view };
481
+ const xs = [view.x, view.x + view.w, ...probe.floor.map((p) => p[0])];
482
+ const ys = [view.y, view.y + view.h, ...probe.floor.map((p) => p[1])];
483
+ const [x0, y0] = [Math.min(...xs), Math.min(...ys)];
484
+ return { box: { x: x0, y: y0, w: Math.max(...xs) - x0, h: Math.max(...ys) - y0 }, points: probe.floor };
473
485
  }
474
486
  /** True when the drawing is asked to be small: `detail: "simple"` or a `width` under 480 pixels. */
475
487
  const smallDrawing = (o) => {
@@ -493,17 +505,20 @@ function venueFrame(o, crowd, surface) {
493
505
  /** A surface with the stands and the crowd round it: the ground is made as wide as the stands need, then the stands are laid on it under the surface. */
494
506
  function buildCrowded(o, portrait, id, score, scored, extra, crowd) {
495
507
  let margin = extra.margin;
508
+ // The floor the stands' opening clears and is centred on.
509
+ const probe = buildWhole({ ...o, margin: 0 }, portrait, id, score, scored, { ...extra, margin: undefined });
510
+ const floor = floorOf(probe);
496
511
  if (!(typeof o.margin === "number" && Number.isFinite(o.margin) && o.margin >= 0)) {
497
512
  // How much ground the stands need beyond what the surface has with none, then the surface is made again with it.
498
- const probe = buildWhole({ ...o, margin: 0 }, portrait, id, score, scored, { ...extra, margin: undefined });
499
- const stands = standsFor(o, probe, crowd);
513
+ const stands = standsFor(o, probe, crowd, floor);
500
514
  const view = wideView(probe);
501
515
  const have = { left: stands.box.x - view.x, right: view.x + view.w - stands.box.x - stands.box.w, top: stands.box.y - view.y, bottom: view.y + view.h - stands.box.y - stands.box.h };
502
516
  const need = Math.max(stands.reach.left - have.left, stands.reach.right - have.right, stands.reach.top - have.top, stands.reach.bottom - have.bottom);
503
- margin = Math.max(margin ?? 0, need + 6);
517
+ // (and the roof's shade, 9 feet beyond the upper tier)
518
+ margin = Math.max(margin ?? 0, need + (crowd.roof ? 10 : 6));
504
519
  }
505
520
  const surface = buildWhole(o, portrait, id, score, scored, { ...extra, margin });
506
- const stands = standsFor(o, surface, crowd);
521
+ const stands = standsFor(o, surface, crowd, floor);
507
522
  const seats = stands.seats;
508
523
  const ground = heldGround(o.ground)?.capacity;
509
524
  const fine = crowd.detail === "fine" || (crowd.detail === "auto" && !smallDrawing(o));
@@ -1220,5 +1235,5 @@ export function crowdStands(o) {
1220
1235
  if (crowd === undefined)
1221
1236
  return undefined;
1222
1237
  const probe = buildWhole({ ...o, margin: 0 }, false, drawingId(o), false, false, { features: undefined });
1223
- return standsFor(o, probe, crowd);
1238
+ return standsFor(o, probe, crowd, floorOf(probe));
1224
1239
  }
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 {