@johnmorrisdotca/kyogijo-stadiums 0.28.0 → 0.31.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 (48) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE.md +17 -5
  3. package/README.md +80 -107
  4. package/dist/crowd.d.ts +20 -2
  5. package/dist/crowd.park.d.ts +6 -0
  6. package/dist/crowd.park.js +3 -3
  7. package/dist/district.d.ts +3 -3
  8. package/dist/district.js +14 -13
  9. package/dist/draw.js +25 -10
  10. package/dist/flythrough-entry.d.ts +1 -0
  11. package/dist/flythrough-entry.js +1 -0
  12. package/dist/flythrough.dom.js +52 -6
  13. package/dist/flythrough.game.d.ts +36 -0
  14. package/dist/flythrough.game.js +145 -0
  15. package/dist/flythrough.markup.js +1 -0
  16. package/dist/flythrough.path.d.ts +6 -4
  17. package/dist/flythrough.path.js +26 -22
  18. package/dist/flythrough.style.d.ts +1 -1
  19. package/dist/flythrough.style.js +3 -0
  20. package/dist/flythrough.words.d.ts +2 -0
  21. package/dist/flythrough.words.js +6 -2
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/stadiums-entry.d.ts +2 -2
  25. package/dist/stadiums-entry.js +2 -2
  26. package/dist/stadiums.d.ts +109 -28
  27. package/dist/stadiums.data.js +25 -0
  28. package/dist/stadiums.js +159 -30
  29. package/dist/stadiums.osm.data.js +114 -0
  30. package/dist/strings.d.ts +4 -0
  31. package/dist/strings.js +2 -2
  32. package/dist/turn.dom.d.ts +8 -2
  33. package/dist/turn.dom.js +10 -4
  34. package/dist/venue.d.ts +2 -0
  35. package/dist/venue.js +6 -5
  36. package/dist/venue.real.d.ts +7 -2
  37. package/dist/venue.real.js +124 -10
  38. package/dist/venues-entry.d.ts +2 -2
  39. package/dist/venues-entry.js +1 -1
  40. package/dist/venues.d.ts +14 -3
  41. package/dist/venues.data.js +72 -0
  42. package/dist/venues.geo.d.ts +22 -0
  43. package/dist/venues.geo.js +28 -0
  44. package/dist/venues.js +9 -1
  45. package/dist/venues.osm.data.js +366 -0
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +4 -2
package/dist/draw.js CHANGED
@@ -11,7 +11,7 @@ import { FOOTBALL } from "./geometry.js";
11
11
  import { renderLayers } from "./layers.js";
12
12
  import { arenaById, arenaCourtIn, arenaNameIn, arenaOfficialName, arenaRinkIn } from "./arenas.js";
13
13
  import { venueById, venueBowl } from "./venues.js";
14
- import { groundOfStadium, stadiumBowl, stadiumById, stadiumConfigOf, stadiumSeats } from "./stadiums.js";
14
+ import { planPark, stadiumBowl, stadiumById, stadiumConfigOf, stadiumKind, stadiumNames, stadiumSeats } from "./stadiums.js";
15
15
  import { groundById, groundNameIn, groundOfficialName } from "./grounds.js";
16
16
  import { BOARD, courtFor, hasSetsBoard, isNetLeague, isNetSport } from "./court.js";
17
17
  import { cricketBoardMarkup } from "./cricket.board.js";
@@ -419,6 +419,17 @@ function parkFence(o) {
419
419
  }
420
420
  return { points: fenceFor(parkById(o.park), feetFence(o.fence, unit), o.year), backstop: MLB_DIAMOND.backstop };
421
421
  }
422
+ /**
423
+ * The real stadium (and the configuration) the options draw, where `crowd: { real: true }` asks for one: a football field (`cfl`, `nfl`) or a soccer pitch in the stadium named by `stadium` or by the `ground`, a baseball field in
424
+ * the park named by `park`; none for a league of the caller's own, a stadium that has no such configuration, or a sport with none.
425
+ */
426
+ function realStadiumOf(o, sport, real) {
427
+ const config = real ? stadiumConfigOf(sport) : undefined;
428
+ if (config === undefined || chooseLeague(o).league !== undefined)
429
+ return undefined;
430
+ const stadium = stadiumById(config === "mlb" ? o.park : o.stadium ?? o.ground);
431
+ return stadium !== undefined && stadium.configs[config] !== undefined && (stadiumKind(stadium) === "park") === (config === "mlb") ? { stadium, config } : undefined;
432
+ }
422
433
  /** The stands round a surface, laid out from its box (or, for a ballpark, from its fence). */
423
434
  function standsFor(o, surface, crowd) {
424
435
  const { sport } = chooseLeague(o);
@@ -428,7 +439,12 @@ function standsFor(o, surface, crowd) {
428
439
  // The walls that have seats on top of them: the ones the package knows (Fenway Park's Green Monster), or, asked for, every wall of 25 feet or more.
429
440
  const park = parkById(o.park);
430
441
  const walls = park === undefined || crowd.wallSeats === false ? [] : parkWallsIn(park, o.year).filter((w) => (crowd.wallSeats === true ? w.height_ft >= 25 : w.label !== undefined && WALL_SEATS[park.id] === w.label));
431
- return parkLayout({ fence: fenceOutline(points), backstop, crowd, seatsOnWalls: walls.map((w) => ({ from: w.from, to: w.to, height: w.height_ft })) });
442
+ const input = { fence: fenceOutline(points), backstop, crowd, seatsOnWalls: walls.map((w) => ({ from: w.from, to: w.to, height: w.height_ft })) };
443
+ // A real ballpark: the building it stands in, the way home plate faces and a roof, with its stands as deep as its seats need (no visitors' block: no source places one).
444
+ const real = realStadiumOf(o, sport, crowd.real);
445
+ if (real !== undefined)
446
+ return planPark(real.stadium, { ...input, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd }).stands;
447
+ return parkLayout(input);
432
448
  }
433
449
  // An arena's real venue: the building it stands in and a bowl fitted to it, where the package has one for the arena and no league of the caller's own sets the surface.
434
450
  const box = surface.whole ?? surface.box ?? { x: 0, y: 0, w: 100, h: 100 };
@@ -437,10 +453,9 @@ function standsFor(o, surface, crowd) {
437
453
  if (venue !== undefined)
438
454
  return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: venueBowl(venue, box) });
439
455
  // A football field or a soccer pitch in a real stadium: its building and a bowl of the configuration's seats.
440
- const config = crowd.real ? stadiumConfigOf(sport) : undefined;
441
- const stadium = config !== undefined && chooseLeague(o).league === undefined ? stadiumById(o.stadium ?? o.ground) : undefined;
442
- if (stadium !== undefined && config !== undefined)
443
- return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: stadiumBowl(stadium, config, box) });
456
+ const real = realStadiumOf(o, sport, crowd.real);
457
+ if (real !== undefined)
458
+ return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: stadiumBowl(real.stadium, real.config, box) });
444
459
  // 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.
445
460
  const hall = sport === "dojo" ? dojoGallery(o) : undefined;
446
461
  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 };
@@ -733,10 +748,10 @@ export function describe(o) {
733
748
  const realCrowd = o.crowd === undefined || o.crowd === false ? undefined : resolveCrowd(o.crowd);
734
749
  const venue = sport === "nhl" && league === undefined && realCrowd?.real === true ? venueById(o.arena) : undefined;
735
750
  const realLine = venue === undefined || arenaName === undefined ? "" : `${gap}${fill(words.said.real, { arena: arenaName, seats: String(venue.seats.hockey), tiers: String(venue.tiers.count) })}${gap}${words.said[venue.osm.axis.basis === "mapped" ? "realMapped" : venue.osm.axis.basis === "building" ? "realBuilding" : "realNone"]}`;
736
- const config = realCrowd?.real === true ? stadiumConfigOf(sport) : undefined;
737
- const realStadium = config !== undefined && league === undefined ? stadiumById(o.stadium ?? o.ground) : undefined;
738
- const stadiumLine = realStadium === undefined || config === undefined ? "" : `${gap}${fill(words.said.stadium, { stadium: groundNameIn(groundOfStadium(realStadium), o.year, language), seats: String(stadiumSeats(realStadium, config)), tiers: String(realStadium.tiers.count) })}${gap}${words.said[realStadium.osm.axis.basis === "opening" ? "stadiumOpening" : realStadium.osm.axis.basis === "building" ? "stadiumBuilding" : "stadiumNone"]}`;
739
- const parkLine = (parkName === undefined ? `${groundLine}${halfLine}${arenaLine}${realLine}${stadiumLine}` : `${gap}${fill(words.said.park, { park: parkName })}${officially(parkOfficial)}${ballpark === undefined ? "" : ballparkSentences(ballpark).map((line) => `${gap}${line}`).join("")}`) + (special === undefined ? "" : `${gap}${special.text}`);
751
+ const real = realStadiumOf(o, sport, realCrowd?.real === true);
752
+ const stadiumKey = (basis) => (basis === "opening" ? "stadiumOpening" : basis === "pitch" ? "stadiumPitch" : basis === "infield" ? "stadiumInfield" : basis === "building" ? "stadiumBuilding" : "stadiumNone");
753
+ const stadiumLine = real === undefined ? "" : `${gap}${fill(real.config === "mlb" ? words.said.stadiumPark : words.said.stadium, { stadium: stadiumNames(real.stadium)[language], seats: String(stadiumSeats(real.stadium, real.config)), tiers: String(real.stadium.tiers.count) })}${gap}${words.said[stadiumKey(real.stadium.osm.axis.basis)]}`;
754
+ const parkLine = (parkName === undefined ? `${groundLine}${halfLine}${arenaLine}${realLine}${stadiumLine}` : `${gap}${fill(words.said.park, { park: parkName })}${officially(parkOfficial)}${ballpark === undefined ? "" : ballparkSentences(ballpark).map((line) => `${gap}${line}`).join("")}${stadiumLine}`) + (special === undefined ? "" : `${gap}${special.text}`);
740
755
  // A drawing of a past season says which: the markings are those of that season.
741
756
  const dated = typeof o.year === "number" && Number.isFinite(o.year);
742
757
  const eraLine = dated && league !== undefined
@@ -3,6 +3,7 @@ export type { Flythrough, FlythroughOptions, FlythroughSettings, FlythroughStart
3
3
  export { CHANNELS, PACE, RAMP, STAGE_IDS, cameraAt, flightPlan, foldTime, glide, glideSeconds, mainView, mapBox, mapFrame, monotoneSlopes, nextRate, stageAt, startOf, stagesOf } from "./flythrough.path.ts";
4
4
  export type { Camera, Channel, Flight, FlightInput, Keyframe, MapFrame, StageId } from "./flythrough.path.ts";
5
5
  export { flythroughMarkup, graticuleMarkup, mapMarkup } from "./flythrough.markup.ts";
6
+ export { ILLUSTRATIVE_PLAY, PLAY_LOOK, PLAY_PERIOD, PLAY_SECONDS, PLAY_STILL, playAlpha, playMarkup, playMoment } from "./flythrough.game.ts";
6
7
  export { FLYTHROUGH_STYLE } from "./flythrough.style.ts";
7
8
  export { FLYTHROUGH_WORDS } from "./flythrough.words.ts";
8
9
  export type { FlythroughWords } from "./flythrough.words.ts";
@@ -2,5 +2,6 @@
2
2
  export { mountFlythrough } from "./flythrough.dom.js";
3
3
  export { CHANNELS, PACE, RAMP, STAGE_IDS, cameraAt, flightPlan, foldTime, glide, glideSeconds, mainView, mapBox, mapFrame, monotoneSlopes, nextRate, stageAt, startOf, stagesOf } from "./flythrough.path.js";
4
4
  export { flythroughMarkup, graticuleMarkup, mapMarkup } from "./flythrough.markup.js";
5
+ export { ILLUSTRATIVE_PLAY, PLAY_LOOK, PLAY_PERIOD, PLAY_SECONDS, PLAY_STILL, playAlpha, playMarkup, playMoment } from "./flythrough.game.js";
5
6
  export { FLYTHROUGH_STYLE } from "./flythrough.style.js";
6
7
  export { FLYTHROUGH_WORDS } from "./flythrough.words.js";
@@ -5,6 +5,7 @@
5
5
  import { arenaById, arenaNameIn } from "./arenas.js";
6
6
  import { STAGE_IDS, cameraAt, coverSide, flightPlan, foldTime, glide, glideSeconds, lengthWords, mainView, mapBox, mapFrame, nextRate, niceLength, stageAt, startOf, stagesOf, } from "./flythrough.path.js";
7
7
  import { flythroughMarkup, graticuleMarkup, gridPath, mapMarkup, stageWords } from "./flythrough.markup.js";
8
+ import { PLAY_STILL, playAlpha, playMarkup, playMoment } from "./flythrough.game.js";
8
9
  import { FLYTHROUGH_WORDS, say } from "./flythrough.words.js";
9
10
  import { LENS } from "./turn.camera.js";
10
11
  import { mountKyogijoTurn } from "./turn.dom.js";
@@ -84,6 +85,7 @@ export function mountFlythrough(host, options, settings = {}) {
84
85
  const scaleBar = root.querySelector(".kf-scale-bar");
85
86
  const scaleText = root.querySelector(".kf-scale-text");
86
87
  const caption = root.querySelector(".kf-caption");
88
+ const badge = root.querySelector(".kf-badge");
87
89
  const live = root.querySelector(".kf-live");
88
90
  const media = win?.matchMedia?.("(prefers-reduced-motion: reduce)");
89
91
  const calm = () => settings.motion === "none" || (settings.motion !== "smooth" && media?.matches === true);
@@ -92,8 +94,25 @@ export function mountFlythrough(host, options, settings = {}) {
92
94
  let turn;
93
95
  // The settings object is kept: the view reads its `lens` at every paint, and the path shortens the lens as the camera comes into the bowl.
94
96
  const turnSettings = { preset: "overhead", controls: false, venue: { roof: true }, motion: "none", id: `${id}-turn`, lens: LENS };
95
- if (stageList.some((one) => one === "arena" || one === "bowl" || one === "rink"))
97
+ if (stageList.some((one) => one === "arena" || one === "bowl" || one === "rink" || one === "game"))
96
98
  turn = mountKyogijoTurn(turnHost, drawing, turnSettings);
99
+ // ---- The game: a plane that lies on the floor of the 3D arena, with the play's dots drawn on it by the replay's own layers. It takes the floor's own transform after every paint, and stands in the same layer of the stack
100
+ // as the floor, above it and under everything that stands up (the glass, the boards, the stands), so that what is in front of the ice hides the play as it hides the ice. ----
101
+ let gameEl;
102
+ let gameLayers = null;
103
+ let floorEl = null;
104
+ if (turn?.mode === "3d" && stageList.includes("game")) {
105
+ const world = turnHost.querySelector(".ky-turn-world");
106
+ floorEl = world?.querySelector(".ky-turn-floor") ?? null;
107
+ const box = turn.svg()?.getAttribute("viewBox");
108
+ if (world !== null && floorEl !== null && box !== null && box !== undefined) {
109
+ gameEl = doc.createElement("div");
110
+ gameEl.className = "kf-game";
111
+ gameEl.innerHTML = `<svg class="kyogijo ky-nhl" data-sport="nhl" xmlns="http://www.w3.org/2000/svg" viewBox="${box}" aria-hidden="true" focusable="false"${options.theme === undefined ? "" : ` data-theme="${options.theme}"`}><g class="kf-game-layers"></g></svg>`;
112
+ floorEl.after(gameEl);
113
+ gameLayers = gameEl.querySelector("g.kf-game-layers");
114
+ }
115
+ }
97
116
  // ---- The site plan: the building's outline and the rink, in the drawing's own feet; the sheet under it is the floor's. ----
98
117
  const plan = planVenue(venue);
99
118
  const rink = venueRink(venue);
@@ -169,8 +188,8 @@ export function mountFlythrough(host, options, settings = {}) {
169
188
  el.style.setProperty(prop, value);
170
189
  };
171
190
  const num = (value, places = 2) => String(Math.round(value * 10 ** places) / 10 ** places);
172
- /** Put a camera on the screen. */
173
- const render = (cam) => {
191
+ /** Put a camera on the screen; `clockAt` is the seconds the clock has run, from which the play's moment is worked out. */
192
+ const render = (cam, clockAt) => {
174
193
  current = cam;
175
194
  const W = width;
176
195
  const H = height;
@@ -217,6 +236,27 @@ export function mountFlythrough(host, options, settings = {}) {
217
236
  turn.set({ yaw: cam.yaw, pitch: cam.pitch, zoom: Math.exp(cam.logZoom) }, false);
218
237
  }
219
238
  }
239
+ // The play on the ice, drawn again a thirtieth of a second at a time, and the label that says it is made up.
240
+ const gameAt = startOf(flight, "game");
241
+ const gameShown = cam.game > 0.003 && live3d && gameEl !== undefined && floorEl !== null && gameAt !== undefined;
242
+ if (gameEl !== undefined)
243
+ put("game", gameEl, "visibility", gameShown ? "visible" : "hidden");
244
+ put("badge", badge, "visibility", gameShown ? "visible" : "hidden");
245
+ if (gameShown && gameEl !== undefined && floorEl !== null && gameAt !== undefined) {
246
+ const moment = playMoment(clockAt - gameAt);
247
+ const alpha = cam.game * playAlpha(moment);
248
+ put("game", gameEl, "opacity", num(alpha, 3));
249
+ put("badge", badge, "opacity", num(alpha, 3));
250
+ put("game", gameEl, "width", floorEl.style.width);
251
+ put("game", gameEl, "height", floorEl.style.height);
252
+ // The 3D view has just been painted: the plane takes the floor's place.
253
+ gameEl.style.transform = floorEl.style.transform;
254
+ const frame = String(Math.round(moment * 30));
255
+ if (written["game:frame"] !== frame && gameLayers !== null) {
256
+ written["game:frame"] = frame;
257
+ gameLayers.innerHTML = playMarkup(moment, { id: `${id}-play`, language });
258
+ }
259
+ }
220
260
  // The pin, the scale bar (true on the map and the plan, not in perspective) and the stage's words.
221
261
  put("pin", pin, "visibility", mapOn ? "visible" : "hidden");
222
262
  put("pin", pin, "opacity", num(cam.map, 3));
@@ -253,6 +293,8 @@ export function mountFlythrough(host, options, settings = {}) {
253
293
  };
254
294
  // ---- The clock. ----
255
295
  let time = 0;
296
+ /** The seconds the clock has run, not folded back by the turn's period as `time` is: the play's moment is worked out from it, so that the play does not jump when the turn comes round. */
297
+ let clock = 0;
256
298
  let rate = 0;
257
299
  let playing = false;
258
300
  let journey;
@@ -276,7 +318,7 @@ export function mountFlythrough(host, options, settings = {}) {
276
318
  const show = (at, announce = true) => {
277
319
  time = foldTime(flight, at);
278
320
  const cam = cameraAt(flight, time);
279
- render(cam);
321
+ render(cam, clock);
280
322
  enter(stageAt(flight, time), announce);
281
323
  };
282
324
  const stopLoop = () => {
@@ -300,12 +342,14 @@ export function mountFlythrough(host, options, settings = {}) {
300
342
  journey.elapsed += dt;
301
343
  const done = journey.elapsed >= journey.seconds;
302
344
  time = done ? journey.to : glide(journey.from, journey.to, journey.elapsed, journey.seconds);
345
+ clock = time;
303
346
  if (done)
304
347
  journey = undefined;
305
348
  }
306
349
  else {
307
350
  rate = nextRate(rate, playing, dt);
308
351
  time += rate * dt * speed;
352
+ clock += rate * dt * speed;
309
353
  }
310
354
  show(time, true);
311
355
  if (busy())
@@ -334,7 +378,8 @@ export function mountFlythrough(host, options, settings = {}) {
334
378
  };
335
379
  const stillStage = (to, announce = true) => {
336
380
  time = startOf(flight, to) ?? 0;
337
- render(flight.rest[to]);
381
+ clock = time;
382
+ render(flight.rest[to], to === "game" ? time + PLAY_STILL : time);
338
383
  enter(to, announce);
339
384
  };
340
385
  const scheduleStill = () => {
@@ -508,7 +553,8 @@ export function mountFlythrough(host, options, settings = {}) {
508
553
  stopLoop();
509
554
  journey = undefined;
510
555
  rate = 0;
511
- show(Math.max(0, seconds), false);
556
+ clock = Math.max(0, seconds);
557
+ show(clock, false);
512
558
  },
513
559
  destroy() {
514
560
  if (!alive)
@@ -0,0 +1,36 @@
1
+ import type { ReplayLook, ReplayTimeline } from "./replay.types.ts";
2
+ import type { Language } from "./types.ts";
3
+ /** A place on the rink, in feet from its top left corner as the drawing has it wide: along the rink (0 to 200) and across it (0 to 85). */
4
+ type P = readonly [number, number];
5
+ /** A skater's way: where he is at a time (seconds), from one waypoint to the next. */
6
+ type Way = readonly (readonly [number, number, number])[];
7
+ /** How long the play runs, and how long it is held at the end (the goal's marks still on the ice) before it starts again. */
8
+ export declare const PLAY_SECONDS = 10;
9
+ export declare const PLAY_HOLD = 1.5;
10
+ /** The seconds of the whole run, the play and the hold: what the loop's period is. */
11
+ export declare const PLAY_PERIOD: number;
12
+ /** The moment a still of the play (for a reader who prefers reduced motion) is taken at: the shot has just been taken. */
13
+ export declare const PLAY_STILL = 6.4;
14
+ /** The seconds the dots fade in and out at the ends of a run, so that the play does not jump back to its start in view. */
15
+ export declare const PLAY_FADE = 0.5;
16
+ /** A point of a way at a time: a cubic through the waypoints with the slope between the neighbours at each, so that a skater never stops or turns on a corner. */
17
+ export declare function at(way: Way, t: number): P;
18
+ /** The puck at a time. */
19
+ export declare function puckAt(t: number): P;
20
+ /** The play, as a replay's timeline in the surface's own feet. It is made once. */
21
+ export declare const ILLUSTRATIVE_PLAY: ReplayTimeline;
22
+ /** How the play is drawn: a short trail behind the puck, the dots large enough to see from the stands, events that stay a moment. */
23
+ export declare const PLAY_LOOK: ReplayLook;
24
+ /** Where a moment of a run is in the play, looping: `seconds` after the run began, brought into one period. */
25
+ export declare const playMoment: (seconds: number) => number;
26
+ /** How much of the play shows at a moment of a run: it fades in at the start and out at the end of each run. 0 to 1. */
27
+ export declare function playAlpha(moment: number): number;
28
+ /**
29
+ * The play at a moment of a run, as the layers' SVG (a `g.ky-layers` in the drawing's own feet, to go inside an `svg.kyogijo` with the floor's viewBox): the dots with their numbers, the puck and its trail, and the events that are on
30
+ * the ice at that time, drawn by the replay's own layers. The same moment always gives the same string.
31
+ */
32
+ export declare function playMarkup(moment: number, options: {
33
+ id: string;
34
+ language?: Language;
35
+ }): string;
36
+ export {};
@@ -0,0 +1,145 @@
1
+ // The game of the fly-through: a short play on the rink, made up for the picture and said to be: five skaters and a goalie a side, a breakout, two passes, a rush and a goal, as a replay's timeline
2
+ // (`ReplayTimeline` of `replay.types.ts`, the format the package already animates), so that the dots, their numbers, the puck, its trail and the events are drawn by the replay's own layers. It is NOT a real game's tracking data
3
+ // and nothing in it is a real player, a real team or a real play: the shirt numbers are only numbers. Pure and deterministic: the same moment always gives the same frame.
4
+ import { renderLayers } from "./layers.js";
5
+ import { replayLayers } from "./replay.js";
6
+ /** How long the play runs, and how long it is held at the end (the goal's marks still on the ice) before it starts again. */
7
+ export const PLAY_SECONDS = 10;
8
+ export const PLAY_HOLD = 1.5;
9
+ /** The seconds of the whole run, the play and the hold: what the loop's period is. */
10
+ export const PLAY_PERIOD = PLAY_SECONDS + PLAY_HOLD;
11
+ /** The moment a still of the play (for a reader who prefers reduced motion) is taken at: the shot has just been taken. */
12
+ export const PLAY_STILL = 6.4;
13
+ /** The seconds the dots fade in and out at the ends of a run, so that the play does not jump back to its start in view. */
14
+ export const PLAY_FADE = 0.5;
15
+ /** The home skaters, as waypoints `[seconds, x, y]`: the home team attacks to the right. */
16
+ const HOME = {
17
+ h4: { number: 4, way: [[0, 24, 26], [1.2, 30, 30], [3.5, 60, 24], [6, 110, 30], [8, 130, 35], [10, 135, 38]] },
18
+ h6: { number: 6, way: [[0, 24, 58], [1.5, 40, 60], [3.5, 75, 58], [6, 120, 55], [8, 140, 50], [10, 145, 48]] },
19
+ h17: { number: 17, way: [[0, 45, 12], [1.2, 48, 12], [2.6, 85, 14], [4, 130, 12], [5.4, 165, 18], [6.8, 176, 28], [10, 170, 30]] },
20
+ h9: { number: 9, way: [[0, 60, 40], [2.4, 80, 40], [3.6, 105, 36], [5, 150, 42], [6.2, 166, 42], [7, 180, 44], [10, 178, 46]] },
21
+ h22: { number: 22, way: [[0, 55, 70], [2.6, 90, 70], [4.2, 135, 64], [5.6, 165, 60], [6.4, 178, 50], [10, 176, 52]] },
22
+ };
23
+ /** The away skaters, who fall back into their own end as the rush comes. */
24
+ const AWAY = {
25
+ a11: { number: 11, way: [[0, 95, 40], [2, 110, 42], [4, 150, 44], [6, 170, 43], [10, 180, 44]] },
26
+ a14: { number: 14, way: [[0, 100, 20], [3, 125, 22], [5, 160, 24], [6.5, 175, 26], [10, 178, 30]] },
27
+ a27: { number: 27, way: [[0, 98, 65], [3, 128, 62], [5, 158, 62], [6.5, 176, 62], [10, 178, 60]] },
28
+ a5: { number: 5, way: [[0, 150, 30], [4, 160, 30], [6, 172, 34], [10, 180, 32]] },
29
+ a8: { number: 8, way: [[0, 150, 55], [4, 162, 54], [6, 172, 52], [10, 180, 54]] },
30
+ };
31
+ /** The goalies: the home one stays on his line, the away one moves across his crease as the shot comes. */
32
+ const HOME_GOALIE = [11, 42.5];
33
+ const AWAY_GOAL = [189, 42.5];
34
+ /** A point of a way at a time: a cubic through the waypoints with the slope between the neighbours at each, so that a skater never stops or turns on a corner. */
35
+ export function at(way, t) {
36
+ const last = way.length - 1;
37
+ if (t <= way[0][0])
38
+ return [way[0][1], way[0][2]];
39
+ if (t >= way[last][0])
40
+ return [way[last][1], way[last][2]];
41
+ let i = 0;
42
+ while (i < last - 1 && t >= way[i + 1][0])
43
+ i += 1;
44
+ const [t0, x0, y0] = way[i];
45
+ const [t1, x1, y1] = way[i + 1];
46
+ const slope = (k) => {
47
+ const a = way[Math.max(0, k - 1)];
48
+ const b = way[Math.min(last, k + 1)];
49
+ const span = b[0] - a[0];
50
+ return span === 0 ? [0, 0] : [(b[1] - a[1]) / span, (b[2] - a[2]) / span];
51
+ };
52
+ const [m0, m1] = [slope(i), slope(i + 1)];
53
+ const h = t1 - t0;
54
+ const s = (t - t0) / h;
55
+ const [s2, s3] = [s * s, s * s * s];
56
+ const [h00, h10, h01, h11] = [2 * s3 - 3 * s2 + 1, s3 - 2 * s2 + s, -2 * s3 + 3 * s2, s3 - s2];
57
+ return [h00 * x0 + h10 * h * m0[0] + h01 * x1 + h11 * h * m1[0], h00 * y0 + h10 * h * m0[1] + h01 * y1 + h11 * h * m1[1]];
58
+ }
59
+ const lerp = (a, b, u) => [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];
60
+ const round = (n) => Math.round(n * 100) / 100;
61
+ const LEGS = [
62
+ { from: 0, to: 1.2, carrier: "h4" },
63
+ { from: 1.2, to: 1.8, carrier: null },
64
+ { from: 1.8, to: 2.6, carrier: "h17" },
65
+ { from: 2.6, to: 3.6, carrier: null },
66
+ { from: 3.6, to: 6.2, carrier: "h9" },
67
+ { from: 6.2, to: 6.55, carrier: null },
68
+ { from: 6.55, to: PLAY_SECONDS, carrier: "net" },
69
+ ];
70
+ const WAYS = Object.fromEntries([...Object.entries(HOME), ...Object.entries(AWAY)].map(([id, one]) => [id, one.way]));
71
+ const where = (id, t) => (id === "net" ? AWAY_GOAL : at(WAYS[id], t));
72
+ /** The puck at a time. */
73
+ export function puckAt(t) {
74
+ const time = Math.min(Math.max(t, 0), PLAY_SECONDS);
75
+ const index = LEGS.findIndex((leg) => time <= leg.to);
76
+ const leg = LEGS[index < 0 ? LEGS.length - 1 : index];
77
+ const carried = (id, when) => {
78
+ if (id === "net")
79
+ return AWAY_GOAL;
80
+ const [x, y] = where(id, when);
81
+ return [x + 2.5, y];
82
+ };
83
+ if (leg.carrier !== null)
84
+ return carried(leg.carrier, time);
85
+ // A pass or a shot goes from the carrier before it, at its start, to the carrier after it, at its end.
86
+ const [before, after] = [LEGS[LEGS.indexOf(leg) - 1], LEGS[LEGS.indexOf(leg) + 1]];
87
+ return lerp(carried(before.carrier, leg.from), after.carrier === "net" ? AWAY_GOAL : carried(after.carrier, leg.to), (time - leg.from) / (leg.to - leg.from));
88
+ }
89
+ /** The away goalie's place: across his crease a little as the shot is taken, and back. */
90
+ function awayGoalie(t) {
91
+ const bump = Math.max(0, 1 - Math.abs(t - 6.45) / 0.6);
92
+ return [AWAY_GOAL[0] - 1, AWAY_GOAL[1] - 3 * bump];
93
+ }
94
+ /** Who has the puck at a time, as a replay says it: a skater's id, or `null` while it is passed or shot. */
95
+ const possessionAt = (t) => {
96
+ const leg = LEGS.find((one) => t >= one.from && t < one.to);
97
+ return leg === undefined || leg.carrier === null || leg.carrier === "net" ? null : leg.carrier;
98
+ };
99
+ /** The frames of the play, a tenth of a second apart. */
100
+ function frames() {
101
+ const out = [];
102
+ for (let step = 0; step <= PLAY_SECONDS * 10; step += 1) {
103
+ const t = step / 10;
104
+ const objects = [];
105
+ for (const [id, one] of Object.entries(HOME))
106
+ objects.push({ id, team: "home", number: one.number, x: round(at(one.way, t)[0]), y: round(at(one.way, t)[1]) });
107
+ objects.push({ id: "h30", team: "home", number: 30, x: HOME_GOALIE[0], y: HOME_GOALIE[1] });
108
+ for (const [id, one] of Object.entries(AWAY))
109
+ objects.push({ id, team: "away", number: one.number, x: round(at(one.way, t)[0]), y: round(at(one.way, t)[1]) });
110
+ const goalie = awayGoalie(t);
111
+ objects.push({ id: "a35", team: "away", number: 35, x: round(goalie[0]), y: round(goalie[1]) });
112
+ const puck = puckAt(t);
113
+ objects.push({ id: "puck", team: "puck", x: round(puck[0]), y: round(puck[1]) });
114
+ const holder = possessionAt(t);
115
+ out.push({ t, objects, possession: holder });
116
+ }
117
+ return out;
118
+ }
119
+ const EVENTS = [
120
+ { t: 1.2, kind: "pass", team: "home", by: "h4", to: "h17", until: 1.8 },
121
+ { t: 2.6, kind: "pass", team: "home", by: "h17", to: "h9", until: 3.6 },
122
+ { t: 6.2, kind: "shot", team: "home", by: "h9", x2: AWAY_GOAL[0], y2: AWAY_GOAL[1], until: 6.55 },
123
+ { t: 6.55, kind: "goal", team: "home", by: "h9", x: AWAY_GOAL[0], y: AWAY_GOAL[1] },
124
+ ];
125
+ /** The play, as a replay's timeline in the surface's own feet. It is made once. */
126
+ export const ILLUSTRATIVE_PLAY = Object.freeze({ frames: frames(), events: EVENTS });
127
+ /** How the play is drawn: a short trail behind the puck, the dots large enough to see from the stands, events that stay a moment. */
128
+ export const PLAY_LOOK = Object.freeze({ trail: 1.2, hold: 1.5, scale: 1.7, numbers: true });
129
+ /** Where a moment of a run is in the play, looping: `seconds` after the run began, brought into one period. */
130
+ export const playMoment = (seconds) => ((seconds % PLAY_PERIOD) + PLAY_PERIOD) % PLAY_PERIOD;
131
+ /** How much of the play shows at a moment of a run: it fades in at the start and out at the end of each run. 0 to 1. */
132
+ export function playAlpha(moment) {
133
+ const m = Math.min(Math.max(moment, 0), PLAY_PERIOD);
134
+ return Math.min(1, m / PLAY_FADE, (PLAY_PERIOD - m) / PLAY_FADE);
135
+ }
136
+ /**
137
+ * The play at a moment of a run, as the layers' SVG (a `g.ky-layers` in the drawing's own feet, to go inside an `svg.kyogijo` with the floor's viewBox): the dots with their numbers, the puck and its trail, and the events that are on
138
+ * the ice at that time, drawn by the replay's own layers. The same moment always gives the same string.
139
+ */
140
+ export function playMarkup(moment, options) {
141
+ const t = Math.min(playMoment(moment), PLAY_SECONDS);
142
+ const language = options.language === "ja" ? "ja" : "en";
143
+ const surface = { sport: "nhl", id: options.id, language };
144
+ return renderLayers(surface, replayLayers(ILLUSTRATIVE_PLAY, t, PLAY_LOOK, language)).svg;
145
+ }
@@ -75,6 +75,7 @@ export function flythroughMarkup(input) {
75
75
  `<div class="kf-layer kf-3d" inert></div>` +
76
76
  `<div class="kf-pin" aria-hidden="true"></div>` +
77
77
  `<div class="kf-scale" aria-hidden="true"><span class="kf-scale-bar"></span><span class="kf-scale-text"></span></div>` +
78
+ `<div class="kf-badge" aria-hidden="true">${esc(words.illustrative)}</div>` +
78
79
  `<div class="kf-caption" aria-hidden="true"><span class="kf-title">${esc(intro.name)}</span><span class="kf-text">${esc(intro.text)}</span></div>` +
79
80
  `</div>` +
80
81
  `<p class="kf-help" id="${esc(input.id)}-help">${esc(words.keys)}</p>` +
@@ -1,15 +1,16 @@
1
1
  /** The stages of the fly-through, in order. */
2
- export type StageId = "map" | "city" | "arena" | "bowl" | "rink";
2
+ export type StageId = "map" | "city" | "arena" | "bowl" | "rink" | "game";
3
3
  /** The stages that exist, in the order of the path. */
4
4
  export declare const STAGE_IDS: readonly StageId[];
5
5
  /** The channels of the camera. */
6
- export declare const CHANNELS: readonly ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom", "logLens", "roof"];
6
+ export declare const CHANNELS: readonly ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom", "logLens", "roof", "game"];
7
7
  export type Channel = (typeof CHANNELS)[number];
8
8
  /**
9
9
  * Where the camera is. `logSpan` is the natural logarithm of the metres the container's width shows (so that a zoom is a straight line); `ax` and `ay` are where the arena stands on the screen, as a
10
10
  * share of the width and of the height from the middle; `north` is how far the map and the site plan are turned, in degrees clockwise (the rink's long side lies along the page when it reaches the
11
11
  * venue's); `map`, `site` and `arena` are how much of the map, of the site plan and of the 3D arena shows (0 to 1); `yaw`, `pitch` and `logZoom` are the 3D view's turn and tip in degrees and the natural logarithm of its zoom;
12
- * `logLens` is the natural logarithm of the eye's lens, as a multiple of the stage's longer side (a long lens looks from far outside, a short one from inside the bowl) and `roof` is how much of the building's roof shows (0 to 1).
12
+ * `logLens` is the natural logarithm of the eye's lens, as a multiple of the stage's longer side (a long lens looks from far outside, a short one from inside the bowl), `roof` is how much of the building's roof shows and `game` how much of the
13
+ * play on the ice shows (each 0 to 1).
13
14
  */
14
15
  export type Camera = Record<Channel, number>;
15
16
  /** What the path needs to know of the page and the venue. */
@@ -92,7 +93,7 @@ export declare const stagesOf: (flight: Flight) => StageId[];
92
93
  export declare const turnPeriod: (flight: Flight) => number;
93
94
  /** A moment after the end brought back by whole turns of the arena to one near the end: the same view, and a time that does not grow without end. */
94
95
  export declare function foldTime(flight: Flight, time: number): number;
95
- /** How a path is paced: the seconds of each part. `hold` is how long the arena turns, whole, before the camera goes in; `over` and `settle` the two parts of the way into the bowl; `stay` how long a still run of the rink's turn is kept before the end. */
96
+ /** How a path is paced: the seconds of each part. `hold` is how long the arena turns, whole, before the camera goes in; `over` and `settle` the two parts of the way into the bowl; `stay` how long the rink turns bare before the play comes onto it, and `fade` how long the play takes to show. */
96
97
  export declare const PACE: {
97
98
  readonly dwell: 1.6;
98
99
  readonly step: 1.25;
@@ -105,6 +106,7 @@ export declare const PACE: {
105
106
  readonly over: 2.2;
106
107
  readonly settle: 2.2;
107
108
  readonly stay: 2;
109
+ readonly fade: 0.6;
108
110
  };
109
111
  /**
110
112
  * The path from the map to the rink, for the stages asked (all by default): the map holds a moment, then the camera falls to the city in four even steps of the logarithm of the distance (the
@@ -5,9 +5,9 @@
5
5
  // Node, tested frame by frame, and handed to the mount, which draws it.
6
6
  import { LENS, LONG_PRESETS, presetView } from "./turn.camera.js";
7
7
  /** The stages that exist, in the order of the path. */
8
- export const STAGE_IDS = ["map", "city", "arena", "bowl", "rink"];
8
+ export const STAGE_IDS = ["map", "city", "arena", "bowl", "rink", "game"];
9
9
  /** The channels of the camera. */
10
- export const CHANNELS = ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom", "logLens", "roof"];
10
+ export const CHANNELS = ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom", "logLens", "roof", "game"];
11
11
  const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
12
12
  const lerp = (a, b, t) => a + (b - a) * t;
13
13
  /** Zero to one with a zero slope at both ends. */
@@ -53,34 +53,33 @@ function hermite(times, values, slopes, time) {
53
53
  const s3 = s2 * s;
54
54
  return (2 * s3 - 3 * s2 + 1) * values[i] + (s3 - 2 * s2 + s) * h * slopes[i] + (-2 * s3 + 3 * s2) * values[i + 1] + (s3 - s2) * h * slopes[i + 1];
55
55
  }
56
- /** The speeds of every channel at every keyframe, worked out once for a path. */
57
- const SLOPES = new WeakMap();
58
- function slopesOf(keys) {
59
- const known = SLOPES.get(keys);
56
+ const TABLES = new WeakMap();
57
+ function tableOf(keys) {
58
+ const known = TABLES.get(keys);
60
59
  if (known !== undefined)
61
60
  return known;
62
61
  const times = keys.map((key) => key.t);
63
- const made = {};
62
+ const values = {};
63
+ const slopes = {};
64
64
  for (const channel of CHANNELS) {
65
- const slopes = monotoneSlopes(times, keys.map((key) => key.camera[channel]));
65
+ values[channel] = keys.map((key) => key.camera[channel]);
66
+ slopes[channel] = monotoneSlopes(times, values[channel]);
66
67
  keys.forEach((key, i) => {
67
68
  const own = key.slope?.[channel];
68
69
  if (own !== undefined)
69
- slopes[i] = own;
70
+ slopes[channel][i] = own;
70
71
  });
71
- made[channel] = slopes;
72
72
  }
73
- SLOPES.set(keys, made);
73
+ const made = { times, values, slopes };
74
+ TABLES.set(keys, made);
74
75
  return made;
75
76
  }
76
77
  /** The camera at a moment of a path. After the last keyframe the arena goes on turning and nothing else moves. Before the first, the first. */
77
78
  export function cameraAt(flight, time) {
78
- const { keys } = flight;
79
- const slopes = slopesOf(keys);
80
- const times = keys.map((key) => key.t);
79
+ const { times, values, slopes } = tableOf(flight.keys);
81
80
  const out = {};
82
81
  for (const channel of CHANNELS)
83
- out[channel] = hermite(times, keys.map((key) => key.camera[channel]), slopes[channel], time);
82
+ out[channel] = hermite(times, values[channel], slopes[channel], time);
84
83
  if (time > flight.end)
85
84
  out.yaw += flight.tail * (time - flight.end);
86
85
  return out;
@@ -107,9 +106,9 @@ export function foldTime(flight, time) {
107
106
  return flight.end + ((time - flight.end) % period);
108
107
  }
109
108
  /** The camera's defaults: the map, from far above, with nothing of the arena showing. */
110
- const BASE = { logSpan: 0, ax: 0, ay: 0, north: 0, map: 1, site: 0, arena: 0, yaw: 0, pitch: 0, logZoom: 0, logLens: Math.log(LENS), roof: 1 };
111
- /** How a path is paced: the seconds of each part. `hold` is how long the arena turns, whole, before the camera goes in; `over` and `settle` the two parts of the way into the bowl; `stay` how long a still run of the rink's turn is kept before the end. */
112
- export const PACE = { dwell: 1.6, step: 1.25, cityDwell: 0.9, approach: 1.6, rise: 0.8, hand: 0.7, tilt: 3.2, hold: 3.5, over: 2.2, settle: 2.2, stay: 2 };
109
+ const BASE = { logSpan: 0, ax: 0, ay: 0, north: 0, map: 1, site: 0, arena: 0, yaw: 0, pitch: 0, logZoom: 0, logLens: Math.log(LENS), roof: 1, game: 0 };
110
+ /** How a path is paced: the seconds of each part. `hold` is how long the arena turns, whole, before the camera goes in; `over` and `settle` the two parts of the way into the bowl; `stay` how long the rink turns bare before the play comes onto it, and `fade` how long the play takes to show. */
111
+ export const PACE = { dwell: 1.6, step: 1.25, cityDwell: 0.9, approach: 1.6, rise: 0.8, hand: 0.7, tilt: 3.2, hold: 3.5, over: 2.2, settle: 2.2, stay: 2, fade: 0.6 };
113
112
  /**
114
113
  * The path from the map to the rink, for the stages asked (all by default): the map holds a moment, then the camera falls to the city in four even steps of the logarithm of the distance (the
115
114
  * map turning as it comes to the venue's own orientation and giving way to the site plan), holds there, comes in until the plan is as large as the 3D arena seen from above, the arena rises out of it,
@@ -148,7 +147,7 @@ export function flightPlan(input) {
148
147
  // Where the turn has got to at each key of the arena, the bowl and the rink: the speed changes from `turn` to `rinkTurn` along the way, and each yaw is the one the two speeds either side of it come to.
149
148
  const yaws = [];
150
149
  let yaw = 0;
151
- for (const [from, to, seconds] of [[0, turn, PACE.tilt], [turn, turn, PACE.hold], [turn, (turn + rinkTurn) / 2, PACE.over], [(turn + rinkTurn) / 2, rinkTurn, PACE.settle], [rinkTurn, rinkTurn, PACE.stay]]) {
150
+ for (const [from, to, seconds] of [[0, turn, PACE.tilt], [turn, turn, PACE.hold], [turn, (turn + rinkTurn) / 2, PACE.over], [(turn + rinkTurn) / 2, rinkTurn, PACE.settle], [rinkTurn, rinkTurn, PACE.stay], [rinkTurn, rinkTurn, PACE.fade]]) {
152
151
  yaw -= ((from + to) / 2) * seconds;
153
152
  yaws.push(yaw);
154
153
  }
@@ -171,9 +170,13 @@ export function flightPlan(input) {
171
170
  },
172
171
  {
173
172
  id: "rink",
173
+ keys: [{ dt: PACE.settle, camera: { ...near, arena: 1, site: 0, yaw: yaws[3], pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0, game: 0 }, slope: { yaw: -rinkTurn } }],
174
+ },
175
+ {
176
+ id: "game",
174
177
  keys: [
175
- { dt: PACE.settle, camera: { ...near, arena: 1, site: 0, yaw: yaws[3], pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0 }, slope: { yaw: -rinkTurn } },
176
- { dt: PACE.stay, camera: { ...near, arena: 1, site: 0, yaw: yaws[4], pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0 }, slope: { yaw: -rinkTurn } },
178
+ { dt: PACE.stay, camera: { ...near, arena: 1, site: 0, yaw: yaws[4], pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0, game: 0 }, slope: { yaw: -rinkTurn } },
179
+ { dt: PACE.fade, camera: { ...near, arena: 1, site: 0, yaw: yaws[5], pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0, game: 1 }, slope: { yaw: -rinkTurn } },
177
180
  ],
178
181
  },
179
182
  ];
@@ -208,7 +211,8 @@ export function flightPlan(input) {
208
211
  city: { ...BASE, ...arrive },
209
212
  arena: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: view.pitch, logZoom: 0 },
210
213
  bowl: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: lerp(view.pitch, seat.pitch, 0.5), logZoom: ln(seat.zoom) * 0.55, logLens: lerp(ln(LENS), ln(seat.lens), 0.6), roof: 0 },
211
- rink: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0 },
214
+ rink: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0, game: 0 },
215
+ game: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: seat.pitch, logZoom: ln(seat.zoom), logLens: ln(seat.lens), roof: 0, game: 1 },
212
216
  };
213
217
  return {
214
218
  keys,
@@ -1,2 +1,2 @@
1
1
  /** The fly-through's stylesheet. */
2
- export declare const FLYTHROUGH_STYLE = ".kf{--kf-bg:#ffffff;--kf-ink:#10151b;--kf-muted:#434b54;--kf-panel:rgba(255,255,255,.9);--kf-line:#b9b3a3;--kf-accent:#1f5fa8;--kf-pin:#c0392b;--kf-grid:rgba(60,70,80,.2);--kf-sea:#cfe3ee;--kf-button:#ffffff;--kf-button-on:#10151b;--kf-button-on-ink:#ffffff;--kf-land:#e9e1c8;--kf-coast:#5d5a50;color:var(--kf-ink);font:16px/1.45 system-ui,-apple-system,\"Segoe UI\",\"Hiragino Sans\",\"Noto Sans JP\",sans-serif;color-scheme:light dark}\n@media (prefers-color-scheme:dark){.kf:not([data-theme=light]){--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3}}\n.kf[data-theme=dark]{--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3;color-scheme:dark}\n.kf[data-theme=light]{color-scheme:light}\n.kf *{box-sizing:border-box}\n.kf [hidden]{display:none!important}\n.kf-stage{position:relative;isolation:isolate;width:100%;aspect-ratio:16/10;min-height:260px;overflow:hidden;background:var(--kf-bg);border:1px solid var(--kf-line);border-radius:12px;outline-offset:2px;user-select:none;-webkit-user-select:none}\n.kf-stage:focus-visible{outline:3px solid var(--kf-accent)}\n@media (max-width:560px){.kf-stage{aspect-ratio:4/5}}\n.kf-layer{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n.kf-map{overflow:hidden}\n.kf-map-box{position:absolute;transform-origin:50% 50%;background:var(--kf-sea)}\n.kf-map-box svg{display:block;width:100%;height:100%}\n.kf-map-box .chizu{--cz-sea:var(--kf-sea);--cz-land:var(--kf-land);--cz-line:var(--kf-coast)}\n.kf-site{overflow:hidden}\n.kf-grid{fill:none;stroke:var(--kf-grid);stroke-width:1}\n.kf-rink{fill:var(--kf-bg);fill-opacity:.9;stroke:var(--kf-muted);stroke-width:.6;vector-effect:non-scaling-stroke}\n.kf-3d{overflow:hidden}\n.kf-3d .ky-turn{height:100%}\n.kf-3d .ky-turn-view{aspect-ratio:auto;height:100%;min-height:0;border-radius:0;cursor:default;touch-action:auto}\n.kf-3d .ky-turn-help,.kf-3d .ky-turn-live{display:none}\n.kf-3d .ky-up-roof{filter:opacity(var(--kf-roof,1))}\n.kf-3d[data-roof=\"0\"] .ky-up-roof{visibility:hidden}\n.kf-pin{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none}\n.kf-pin::before{content:\"\";position:absolute;left:-7px;top:-7px;width:14px;height:14px;border-radius:50%;background:var(--kf-pin);border:2px solid var(--kf-bg);box-shadow:0 0 0 2px var(--kf-pin)}\n.kf-scale{position:absolute;left:12px;bottom:12px;display:flex;flex-direction:column;gap:2px;align-items:flex-start;font-size:.8rem;color:var(--kf-ink);background:var(--kf-panel);padding:4px 8px;border-radius:8px;pointer-events:none}\n.kf-scale-bar{display:block;height:6px;border:2px solid var(--kf-ink);border-top:0}\n.kf-caption{position:absolute;right:12px;top:12px;max-width:min(26rem,calc(100% - 24px));padding:8px 12px;border-radius:10px;background:var(--kf-panel);color:var(--kf-ink);pointer-events:none}\n.kf-title{display:block;font-weight:800}\n.kf-text{display:block;font-size:.92rem;color:var(--kf-muted)}\n.kf-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0 0}\n.kf-group{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}\n.kf button{font:inherit;font-size:1rem;font-weight:700;min-height:44px;min-width:44px;padding:0 14px;border-radius:12px;border:2px solid var(--kf-ink);background:var(--kf-button);color:var(--kf-ink);cursor:pointer}\n.kf button[aria-pressed=true],.kf button[aria-current=step]{background:var(--kf-button-on);color:var(--kf-button-on-ink)}\n.kf button:focus-visible{outline:3px solid var(--kf-accent);outline-offset:2px}\n.kf-help,.kf-credit,.kf-still{margin:8px 0 0;font-size:.85rem;color:var(--kf-muted)}\n.kf-live{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}";
2
+ export declare const FLYTHROUGH_STYLE = ".kf{--kf-bg:#ffffff;--kf-ink:#10151b;--kf-muted:#434b54;--kf-panel:rgba(255,255,255,.9);--kf-line:#b9b3a3;--kf-accent:#1f5fa8;--kf-pin:#c0392b;--kf-grid:rgba(60,70,80,.2);--kf-sea:#cfe3ee;--kf-button:#ffffff;--kf-button-on:#10151b;--kf-button-on-ink:#ffffff;--kf-land:#e9e1c8;--kf-coast:#5d5a50;color:var(--kf-ink);font:16px/1.45 system-ui,-apple-system,\"Segoe UI\",\"Hiragino Sans\",\"Noto Sans JP\",sans-serif;color-scheme:light dark}\n@media (prefers-color-scheme:dark){.kf:not([data-theme=light]){--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3}}\n.kf[data-theme=dark]{--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3;color-scheme:dark}\n.kf[data-theme=light]{color-scheme:light}\n.kf *{box-sizing:border-box}\n.kf [hidden]{display:none!important}\n.kf-stage{position:relative;isolation:isolate;width:100%;aspect-ratio:16/10;min-height:260px;overflow:hidden;background:var(--kf-bg);border:1px solid var(--kf-line);border-radius:12px;outline-offset:2px;user-select:none;-webkit-user-select:none}\n.kf-stage:focus-visible{outline:3px solid var(--kf-accent)}\n@media (max-width:560px){.kf-stage{aspect-ratio:4/5}}\n.kf-layer{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n.kf-map{overflow:hidden}\n.kf-map-box{position:absolute;transform-origin:50% 50%;background:var(--kf-sea)}\n.kf-map-box svg{display:block;width:100%;height:100%}\n.kf-map-box .chizu{--cz-sea:var(--kf-sea);--cz-land:var(--kf-land);--cz-line:var(--kf-coast)}\n.kf-site{overflow:hidden}\n.kf-grid{fill:none;stroke:var(--kf-grid);stroke-width:1}\n.kf-rink{fill:var(--kf-bg);fill-opacity:.9;stroke:var(--kf-muted);stroke-width:.6;vector-effect:non-scaling-stroke}\n.kf-3d{overflow:hidden}\n.kf-3d .ky-turn{height:100%}\n.kf-3d .ky-turn-view{aspect-ratio:auto;height:100%;min-height:0;border-radius:0;cursor:default;touch-action:auto}\n.kf-3d .ky-turn-help,.kf-3d .ky-turn-live{display:none}\n.kf-3d .ky-up-roof{filter:opacity(var(--kf-roof,1))}\n.kf-3d[data-roof=\"0\"] .ky-up-roof{visibility:hidden}\n.kf-pin{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none}\n.kf-pin::before{content:\"\";position:absolute;left:-7px;top:-7px;width:14px;height:14px;border-radius:50%;background:var(--kf-pin);border:2px solid var(--kf-bg);box-shadow:0 0 0 2px var(--kf-pin)}\n.kf-scale{position:absolute;left:12px;bottom:12px;display:flex;flex-direction:column;gap:2px;align-items:flex-start;font-size:.8rem;color:var(--kf-ink);background:var(--kf-panel);padding:4px 8px;border-radius:8px;pointer-events:none}\n.kf-scale-bar{display:block;height:6px;border:2px solid var(--kf-ink);border-top:0}\n.kf-caption{position:absolute;right:12px;top:12px;max-width:min(26rem,calc(100% - 24px));padding:8px 12px;border-radius:10px;background:var(--kf-panel);color:var(--kf-ink);pointer-events:none}\n.kf-title{display:block;font-weight:800}\n.kf-text{display:block;font-size:.92rem;color:var(--kf-muted)}\n.kf-badge{position:absolute;left:12px;top:12px;padding:3px 10px;border-radius:999px;background:var(--kf-panel);border:1px solid var(--kf-line);font-size:.8rem;font-weight:700;color:var(--kf-ink);pointer-events:none;opacity:0;visibility:hidden}\n.kf-game{position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none;z-index:1}\n.kf-game svg{display:block;width:100%;height:100%;overflow:visible}\n.kf-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0 0}\n.kf-group{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}\n.kf button{font:inherit;font-size:1rem;font-weight:700;min-height:44px;min-width:44px;padding:0 14px;border-radius:12px;border:2px solid var(--kf-ink);background:var(--kf-button);color:var(--kf-ink);cursor:pointer}\n.kf button[aria-pressed=true],.kf button[aria-current=step]{background:var(--kf-button-on);color:var(--kf-button-on-ink)}\n.kf button:focus-visible{outline:3px solid var(--kf-accent);outline-offset:2px}\n.kf-help,.kf-credit,.kf-still{margin:8px 0 0;font-size:.85rem;color:var(--kf-muted)}\n.kf-live{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}";
@@ -33,6 +33,9 @@ export const FLYTHROUGH_STYLE = `.kf{${LIGHT};color:var(--kf-ink);font:16px/1.45
33
33
  .kf-caption{position:absolute;right:12px;top:12px;max-width:min(26rem,calc(100% - 24px));padding:8px 12px;border-radius:10px;background:var(--kf-panel);color:var(--kf-ink);pointer-events:none}
34
34
  .kf-title{display:block;font-weight:800}
35
35
  .kf-text{display:block;font-size:.92rem;color:var(--kf-muted)}
36
+ .kf-badge{position:absolute;left:12px;top:12px;padding:3px 10px;border-radius:999px;background:var(--kf-panel);border:1px solid var(--kf-line);font-size:.8rem;font-weight:700;color:var(--kf-ink);pointer-events:none;opacity:0;visibility:hidden}
37
+ .kf-game{position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none;z-index:1}
38
+ .kf-game svg{display:block;width:100%;height:100%;overflow:visible}
36
39
  .kf-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0 0}
37
40
  .kf-group{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}
38
41
  .kf button{font:inherit;font-size:1rem;font-weight:700;min-height:44px;min-width:44px;padding:0 14px;border-radius:12px;border:2px solid var(--kf-ink);background:var(--kf-button);color:var(--kf-ink);cursor:pointer}
@@ -25,6 +25,8 @@ export interface FlythroughWords {
25
25
  /** The credit of the map and of the building's outline, drawn with the picture. */
26
26
  creditMap: string;
27
27
  creditPlain: string;
28
+ /** The label on the picture while the play is on the ice: it is made up for the picture. */
29
+ illustrative: string;
28
30
  /** The sentence on a still stage, for a reader who prefers reduced motion. */
29
31
  still: string;
30
32
  }