@johnmorrisdotca/kyogijo-stadiums 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ All notable changes to this project are written here. The format follows
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.0] - 2026-10-20
11
+
12
+ ### Added
13
+
14
+ - **The fly-through goes into the bowl** (`bowl` and `rink`, two stages after `arena`): the roof lifts away, and the camera comes in over the stands, tipping lower and nearer and through a shorter lens, to a seat's viewpoint inside the bowl, from where the rink turns. The 3D arena's own `lens` setting does it (a long lens from outside, about 1 from the seat, so that the eye is inside the building and the bowl has a real perspective), and the roof is dissolved with a CSS `filter: opacity()`, so that it composes with the 3D view's own cutting away of the walls. The seat is a place and a height of our own choosing (a pitch, a zoom and a lens, `seat` of `flightPlan`), said to be so, never a real seat; the bowl's words give the venue's seats and tiers.
15
+ - **The camera has two more channels** (`logLens`, `roof`), still one path with no jumps: the turn slows from the arena's 12 degrees a second to the rink's 8 and never stops or reverses; the lens, the roof, the zoom and the tip change in one direction only on the way in. `flightPlan` takes `seat` and `rinkTurn`; `PACE` has the seconds of each part; the path now ends on the rink turning.
16
+ - Tests for the way in (nothing reverses, the seat is reached where the rink stage starts, the stage stills show the roof lifted and the seat's view) over fourteen lists of stages, and in a real browser the four corners of the rink are followed on the screen at 30 frames a second, from outside and from the seat: each moves a little a frame and none falls behind the eye.
17
+
10
18
  ## [0.27.0] - 2026-10-20
11
19
 
12
20
  ### Added
@@ -112,7 +112,7 @@ export declare const DISTRICT_MARGIN: Readonly<Record<DistrictMode, number>>;
112
112
  * import { planDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
113
113
  * const plan = planDistrict({ venues: ["bc-place", "rogers-arena"] });
114
114
  * console.log(plan.mode, plan.members.length, Math.round(plan.between[0].metres));
115
- * // neighbours 2 264
115
+ * // neighbours 2 380
116
116
  * ```
117
117
  */
118
118
  export declare function planDistrict(options: DistrictOptions): DistrictPlan;
package/dist/district.js CHANGED
@@ -97,7 +97,7 @@ const haversine = (a, b) => {
97
97
  * import { planDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
98
98
  * const plan = planDistrict({ venues: ["bc-place", "rogers-arena"] });
99
99
  * console.log(plan.mode, plan.members.length, Math.round(plan.between[0].metres));
100
- * // neighbours 2 264
100
+ * // neighbours 2 380
101
101
  * ```
102
102
  */
103
103
  export function planDistrict(options) {
@@ -6,6 +6,7 @@ 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
8
  import { FLYTHROUGH_WORDS, say } from "./flythrough.words.js";
9
+ import { LENS } from "./turn.camera.js";
9
10
  import { mountKyogijoTurn } from "./turn.dom.js";
10
11
  import { arenaOfVenue, planVenue, venueById, venueOrientation, venueRink } from "./venues.js";
11
12
  /**
@@ -43,7 +44,7 @@ export function mountFlythrough(host, options, settings = {}) {
43
44
  }
44
45
  })();
45
46
  const place = `${arena.city}, ${region}`;
46
- const values = { arena: name, place };
47
+ const values = { arena: name, place, seats: venue.seats.hockey.toLocaleString(language), tiers: venue.tiers.count };
47
48
  const wanted = (options.stages ?? STAGE_IDS).filter((stage, i, all) => STAGE_IDS.includes(stage) && all.indexOf(stage) === i);
48
49
  const stageList = STAGE_IDS.filter((stage) => wanted.includes(stage));
49
50
  if (stageList.length === 0)
@@ -68,7 +69,7 @@ export function mountFlythrough(host, options, settings = {}) {
68
69
  frame = { at: [0, 0], metresX: 1, metresY: 1 };
69
70
  }
70
71
  const mapFrameNow = frame;
71
- host.innerHTML = flythroughMarkup({ id, language, theme: options.theme, arena: name, place, stages: stageList, map: mapSvg, mapCredit: chizu, controls: settings.controls });
72
+ host.innerHTML = flythroughMarkup({ id, language, theme: options.theme, arena: name, place, stages: stageList, map: mapSvg, mapCredit: chizu, controls: settings.controls, seats: values.seats, tiers: values.tiers });
72
73
  const root = host.querySelector(".kf");
73
74
  const stage = root.querySelector(".kf-stage");
74
75
  const mapLayer = root.querySelector(".kf-map");
@@ -89,8 +90,10 @@ export function mountFlythrough(host, options, settings = {}) {
89
90
  // ---- The 3D arena, from above to begin with: the view the path hands over at. ----
90
91
  const drawing = { sport: "nhl", arena: venue.id, crowd: { real: true }, language, ...(options.theme === undefined ? {} : { theme: options.theme }) };
91
92
  let turn;
92
- if (stageList.includes("arena"))
93
- turn = mountKyogijoTurn(turnHost, drawing, { preset: "overhead", controls: false, venue: { roof: true }, motion: "none", id: `${id}-turn` });
93
+ // 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
+ 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"))
96
+ turn = mountKyogijoTurn(turnHost, drawing, turnSettings);
94
97
  // ---- The site plan: the building's outline and the rink, in the drawing's own feet; the sheet under it is the floor's. ----
95
98
  const plan = planVenue(venue);
96
99
  const rink = venueRink(venue);
@@ -126,6 +129,9 @@ export function mountFlythrough(host, options, settings = {}) {
126
129
  if (turn !== undefined && floor !== null) {
127
130
  // Measure the arena seen from straight above, as the 3D view lays it out in this container: where the rink stands and how big a foot is.
128
131
  const was = turn.view();
132
+ const lens = turnSettings.lens;
133
+ // The hand-over is seen from straight above through the usual long lens, whatever the lens is now.
134
+ turnSettings.lens = LENS;
129
135
  turn.set({ yaw: 0, pitch: 0, zoom: 1 }, false);
130
136
  const box = floor.getBoundingClientRect();
131
137
  // The stage's own area, inside its border: the layers are laid out in it.
@@ -141,6 +147,7 @@ export function mountFlythrough(host, options, settings = {}) {
141
147
  sheet = { x: vb.x, y: vb.y, w: vb.width, h: vb.height };
142
148
  drawPlan();
143
149
  }
150
+ turnSettings.lens = lens;
144
151
  turn.set(was, false);
145
152
  }
146
153
  const startSpan = start?.span ?? 9000000;
@@ -201,9 +208,12 @@ export function mountFlythrough(host, options, settings = {}) {
201
208
  put("3d", turnHost, "visibility", live3d ? "visible" : "hidden");
202
209
  if (live3d && turn !== undefined) {
203
210
  put("3d", turnHost, "opacity", num(cam.arena, 3));
204
- const view = `${num(cam.yaw, 3)}|${num(cam.pitch, 3)}|${num(cam.logZoom, 4)}`;
211
+ put("3d", turnHost, "--kf-roof", num(cam.roof, 3));
212
+ put("3d", turnHost, "@data-roof", cam.roof > 0.004 ? "1" : "0");
213
+ const view = `${num(cam.yaw, 3)}|${num(cam.pitch, 3)}|${num(cam.logZoom, 4)}|${num(cam.logLens, 4)}`;
205
214
  if (written["3d:view"] !== view) {
206
215
  written["3d:view"] = view;
216
+ turnSettings.lens = Math.exp(cam.logLens);
207
217
  turn.set({ yaw: cam.yaw, pitch: cam.pitch, zoom: Math.exp(cam.logZoom) }, false);
208
218
  }
209
219
  }
@@ -25,6 +25,9 @@ export interface MarkupInput {
25
25
  /** The arena's name, and where it is (`Anaheim, United States`), in the page's language. */
26
26
  arena: string;
27
27
  place: string;
28
+ /** The venue's seats for ice hockey and its seating tiers, in the words of the bowl's stage (`{seats}`, `{tiers}`). */
29
+ seats?: number | string;
30
+ tiers?: number | string;
28
31
  /** The stages flown, in order. */
29
32
  stages: readonly StageId[];
30
33
  /** The drawn map (already through `mapMarkup`), and whether it is chizu's (so that the credit names it). */
@@ -38,6 +41,8 @@ export interface MarkupInput {
38
41
  export declare const stageWords: (language: Language, stage: StageId, values: {
39
42
  arena: string;
40
43
  place: string;
44
+ seats?: number | string;
45
+ tiers?: number | string;
41
46
  }) => {
42
47
  name: string;
43
48
  text: string;
@@ -63,7 +63,7 @@ export function controlsMarkup(words, stages, current, playing) {
63
63
  /** The whole fly-through as markup. */
64
64
  export function flythroughMarkup(input) {
65
65
  const words = FLYTHROUGH_WORDS[input.language];
66
- const values = { arena: input.arena, place: input.place };
66
+ const values = { arena: input.arena, place: input.place, seats: input.seats, tiers: input.tiers };
67
67
  const first = input.stages[0];
68
68
  const intro = stageWords(input.language, first, values);
69
69
  const label = say(words.title, values);
@@ -1,14 +1,15 @@
1
1
  /** The stages of the fly-through, in order. */
2
- export type StageId = "map" | "city" | "arena";
2
+ export type StageId = "map" | "city" | "arena" | "bowl" | "rink";
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"];
6
+ export declare const CHANNELS: readonly ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom", "logLens", "roof"];
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
- * 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.
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
13
  */
13
14
  export type Camera = Record<Channel, number>;
14
15
  /** What the path needs to know of the page and the venue. */
@@ -33,6 +34,14 @@ export interface FlightInput {
33
34
  };
34
35
  /** Degrees a second the arena turns at, once it is turning: 12 by default. */
35
36
  turn?: number;
37
+ /** The seat's viewpoint the camera settles at in the bowl: the tip of the 3D view in degrees, its zoom and the eye's lens. 77, 2.8 and 1 by default. */
38
+ seat?: {
39
+ pitch: number;
40
+ zoom: number;
41
+ lens: number;
42
+ };
43
+ /** Degrees a second the rink turns at, from the seat: 8 by default. */
44
+ rinkTurn?: number;
36
45
  }
37
46
  /** A keyframe: the camera at a moment, and where it must be going where that is not what the neighbours say. */
38
47
  export interface Keyframe {
@@ -51,11 +60,11 @@ export interface Flight {
51
60
  }[];
52
61
  /** The time of the last keyframe. */
53
62
  end: number;
54
- /** After `end`, the arena goes on turning at this many degrees a second (negative: the near side goes to the right), and nothing else moves. */
63
+ /** After `end`, the view goes on turning at this many degrees a second (negative: the near side goes to the right), and nothing else moves: the arena's turn, or the rink's from the seat. */
55
64
  tail: number;
56
65
  /** The camera of each stage at rest: what a reader who prefers reduced motion is shown (one layer, nothing half-faded). */
57
66
  rest: Readonly<Record<StageId, Camera>>;
58
- input: Required<Pick<FlightInput, "north" | "siteSpan" | "startSpan" | "citySpan" | "turn">> & {
67
+ input: Required<Pick<FlightInput, "north" | "siteSpan" | "startSpan" | "citySpan" | "turn" | "rinkTurn" | "seat">> & {
59
68
  anchorStart: readonly [number, number];
60
69
  anchorEnd: readonly [number, number];
61
70
  view: {
@@ -83,7 +92,7 @@ export declare const stagesOf: (flight: Flight) => StageId[];
83
92
  export declare const turnPeriod: (flight: Flight) => number;
84
93
  /** 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. */
85
94
  export declare function foldTime(flight: Flight, time: number): number;
86
- /** How a path is paced: the seconds of each part. */
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. */
87
96
  export declare const PACE: {
88
97
  readonly dwell: 1.6;
89
98
  readonly step: 1.25;
@@ -92,11 +101,15 @@ export declare const PACE: {
92
101
  readonly rise: 0.8;
93
102
  readonly hand: 0.7;
94
103
  readonly tilt: 3.2;
104
+ readonly hold: 3.5;
105
+ readonly over: 2.2;
106
+ readonly settle: 2.2;
107
+ readonly stay: 2;
95
108
  };
96
109
  /**
97
- * The path from the map to the turning arena, 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
110
+ * 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
98
111
  * 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,
99
- * tips to the sport's main camera and turns.
112
+ * tips to the sport's main camera and turns; then the roof lifts away and the camera comes in over the stands, the eye's lens shortening as it goes, and settles at a seat's viewpoint, from where the rink turns.
100
113
  */
101
114
  export declare function flightPlan(input: FlightInput): Flight;
102
115
  /** The 3D view the arena settles in, as the sport's main camera: its yaw and pitch. */
@@ -3,11 +3,11 @@
3
3
  // keyframes in time, and every channel runs through them as a monotone cubic (Fritsch and Carlson), so that it is smooth where it passes a keyframe (the speed does not jump),
4
4
  // never overshoots between two (a layer's opacity stays between 0 and 1), and is still at its first keyframe. Nothing here touches a page: the same path can be worked out in
5
5
  // Node, tested frame by frame, and handed to the mount, which draws it.
6
- import { LONG_PRESETS, presetView } from "./turn.camera.js";
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"];
8
+ export const STAGE_IDS = ["map", "city", "arena", "bowl", "rink"];
9
9
  /** The channels of the camera. */
10
- export const CHANNELS = ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom"];
10
+ export const CHANNELS = ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom", "logLens", "roof"];
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. */
@@ -107,13 +107,13 @@ export function foldTime(flight, time) {
107
107
  return flight.end + ((time - flight.end) % period);
108
108
  }
109
109
  /** 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 };
111
- /** How a path is paced: the seconds of each part. */
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 };
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 };
113
113
  /**
114
- * The path from the map to the turning arena, 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
114
+ * 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
115
  * 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,
116
- * tips to the sport's main camera and turns.
116
+ * tips to the sport's main camera and turns; then the roof lifts away and the camera comes in over the stands, the eye's lens shortening as it goes, and settles at a seat's viewpoint, from where the rink turns.
117
117
  */
118
118
  export function flightPlan(input) {
119
119
  const startSpan = input.startSpan ?? 9000000;
@@ -122,6 +122,8 @@ export function flightPlan(input) {
122
122
  const [ax1, ay1] = input.anchorEnd ?? [0, 0];
123
123
  const north = wrap180(input.north);
124
124
  const turn = input.turn ?? 12;
125
+ const rinkTurn = input.rinkTurn ?? 8;
126
+ const seat = input.seat ?? { pitch: 77, zoom: 2.8, lens: 1 };
125
127
  const view = input.view ?? { yaw: LONG_PRESETS.broadcast.yaw, pitch: LONG_PRESETS.broadcast.pitch };
126
128
  const ln = Math.log;
127
129
  const L0 = ln(startSpan);
@@ -143,6 +145,13 @@ export function flightPlan(input) {
143
145
  const start = { logSpan: L0, ax: ax0, ay: ay0, north: 0, map: 1, site: 0, arena: 0, yaw: 0, pitch: 0, logZoom: 0 };
144
146
  const arrive = { ...fall(4), logSpan: L1, ax: ax1, ay: ay1, north, map: 0, site: 1 };
145
147
  const near = { ...arrive, logSpan: L2 };
148
+ // 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
+ const yaws = [];
150
+ 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]]) {
152
+ yaw -= ((from + to) / 2) * seconds;
153
+ yaws.push(yaw);
154
+ }
146
155
  const blocks = [
147
156
  { id: "map", keys: [{ dt: 0, camera: start }, { dt: PACE.dwell, camera: start }, { dt: PACE.step, camera: fall(1) }, { dt: PACE.step, camera: fall(2) }, { dt: PACE.step, camera: fall(3) }] },
148
157
  { id: "city", keys: [{ dt: PACE.step, camera: arrive }, { dt: PACE.cityDwell, camera: arrive }, { dt: PACE.approach, camera: near }, { dt: PACE.rise, camera: { ...near, arena: 1 } }] },
@@ -150,7 +159,21 @@ export function flightPlan(input) {
150
159
  id: "arena",
151
160
  keys: [
152
161
  { dt: PACE.hand, camera: { ...near, arena: 1, site: 0, yaw: 0, pitch: 0 }, slope: { yaw: 0 } },
153
- { dt: PACE.tilt, camera: { ...near, arena: 1, site: 0, yaw: -(turn * PACE.tilt) / 2, pitch: view.pitch }, slope: { yaw: -turn } },
162
+ { dt: PACE.tilt, camera: { ...near, arena: 1, site: 0, yaw: yaws[0], pitch: view.pitch }, slope: { yaw: -turn } },
163
+ ],
164
+ },
165
+ {
166
+ id: "bowl",
167
+ keys: [
168
+ { dt: PACE.hold, camera: { ...near, arena: 1, site: 0, yaw: yaws[1], pitch: view.pitch }, slope: { yaw: -turn } },
169
+ { dt: PACE.over, camera: { ...near, arena: 1, site: 0, yaw: yaws[2], 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.3 }, slope: { yaw: -(turn + rinkTurn) / 2 } },
170
+ ],
171
+ },
172
+ {
173
+ id: "rink",
174
+ 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 } },
154
177
  ],
155
178
  },
156
179
  ];
@@ -184,14 +207,16 @@ export function flightPlan(input) {
184
207
  map: { ...BASE, ...start },
185
208
  city: { ...BASE, ...arrive },
186
209
  arena: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: view.pitch, logZoom: 0 },
210
+ 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 },
187
212
  };
188
213
  return {
189
214
  keys,
190
215
  stages,
191
216
  end: time,
192
- tail: flown.some((block) => block.id === "arena") ? -turn : 0,
217
+ tail: flown[flown.length - 1].keys[flown[flown.length - 1].keys.length - 1].slope?.yaw ?? 0,
193
218
  rest,
194
- input: { north, siteSpan: input.siteSpan, startSpan, citySpan, turn, anchorStart: [ax0, ay0], anchorEnd: [ax1, ay1], view },
219
+ input: { north, siteSpan: input.siteSpan, startSpan, citySpan, turn, rinkTurn, seat, anchorStart: [ax0, ay0], anchorEnd: [ax1, ay1], view },
195
220
  };
196
221
  }
197
222
  /** The 3D view the arena settles in, as the sport's main camera: its yaw and pitch. */
@@ -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-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-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}";
@@ -24,6 +24,8 @@ export const FLYTHROUGH_STYLE = `.kf{${LIGHT};color:var(--kf-ink);font:16px/1.45
24
24
  .kf-3d .ky-turn{height:100%}
25
25
  .kf-3d .ky-turn-view{aspect-ratio:auto;height:100%;min-height:0;border-radius:0;cursor:default;touch-action:auto}
26
26
  .kf-3d .ky-turn-help,.kf-3d .ky-turn-live{display:none}
27
+ .kf-3d .ky-up-roof{filter:opacity(var(--kf-roof,1))}
28
+ .kf-3d[data-roof="0"] .ky-up-roof{visibility:hidden}
27
29
  .kf-pin{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none}
28
30
  .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)}
29
31
  .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}
@@ -8,7 +8,7 @@ export interface FlythroughWords {
8
8
  region: string;
9
9
  /** What a screen reader is told about the whole fly-through: `{arena}`, `{place}`. */
10
10
  about: string;
11
- /** The stages: their names and what is said of each (`{arena}`, `{place}`). */
11
+ /** The stages: their names and what is said of each (`{arena}`, `{place}`, `{seats}`, `{tiers}`). */
12
12
  stage: Record<StageId, {
13
13
  name: string;
14
14
  text: string;
@@ -30,4 +30,4 @@ export interface FlythroughWords {
30
30
  }
31
31
  export declare const FLYTHROUGH_WORDS: Record<Language, FlythroughWords>;
32
32
  /** A sentence with its `{name}` slots filled in. */
33
- export declare const say: (text: string, values: Record<string, string | number>) => string;
33
+ export declare const say: (text: string, values: Record<string, string | number | undefined>) => string;
@@ -2,11 +2,13 @@ export const FLYTHROUGH_WORDS = {
2
2
  en: {
3
3
  title: "Fly-through: {arena}",
4
4
  region: "fly-through",
5
- about: "A fly-through from a map to {arena} in {place}: the map, the city and the arena in 3D. Play, pause, step back or forward and choose a stage with the buttons or the keys.",
5
+ about: "A fly-through from a map to {arena} in {place}: the map, the city, the arena in 3D, the way in over the stands and the rink seen from a seat. Play, pause, step back or forward and choose a stage with the buttons or the keys.",
6
6
  stage: {
7
7
  map: { name: "Map", text: "{arena} stands in {place}." },
8
8
  city: { name: "City", text: "The building of {arena} from above, as OpenStreetMap maps it." },
9
9
  arena: { name: "Arena", text: "{arena} in 3D, turning." },
10
+ bowl: { name: "Bowl", text: "In over the stands, with the roof lifted away: a plausible bowl of {seats} seats in {tiers} tiers, not a seat map." },
11
+ rink: { name: "Rink", text: "A view across the ice from the stands, turning; the place and the height of the seat are ours, not a real seat's." },
10
12
  },
11
13
  announce: "Stage {n} of {total}: {name}. {text}",
12
14
  play: "Play",
@@ -22,11 +24,13 @@ export const FLYTHROUGH_WORDS = {
22
24
  ja: {
23
25
  title: "フライスルー:{arena}",
24
26
  region: "フライスルー",
25
- about: "地図から{place}の{arena}まで進むフライスルーです。地図、都市、3Dのアリーナの順に見られます。ボタンまたはキーで、再生・一時停止、前後の場面への移動、場面の選択ができます。",
27
+ about: "地図から{place}の{arena}まで進むフライスルーです。地図、都市、3Dのアリーナ、スタンドの上空から中へ入る場面、客席から見たリンクの順に見られます。ボタンまたはキーで、再生・一時停止、前後の場面への移動、場面の選択ができます。",
26
28
  stage: {
27
29
  map: { name: "地図", text: "{arena}は{place}にあります。" },
28
30
  city: { name: "都市", text: "{arena}の建物を真上から見たところです(OpenStreetMapの記録による)。" },
29
31
  arena: { name: "アリーナ", text: "{arena}を3Dで回転させて表示しています。" },
32
+ bowl: { name: "客席", text: "屋根を取り払い、スタンドの上空から中へ入ります。{seats}席・{tiers}層の推定のスタンドで、実際の座席表ではありません。" },
33
+ rink: { name: "リンク", text: "スタンドからリンクを見渡した眺めで、ゆっくり回転します。視点の位置と高さは仮のもので、実在の座席ではありません。" },
30
34
  },
31
35
  announce: "場面{n}/{total}:{name}。{text}",
32
36
  play: "再生",
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package's version: the same as package.json's, and a test holds them to each other. */
2
- export declare const VERSION = "0.27.0";
2
+ export declare const VERSION = "0.28.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package's version: the same as package.json's, and a test holds them to each other. */
2
- export const VERSION = "0.27.0";
2
+ export const VERSION = "0.28.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kyogijo-stadiums",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Kyogijo Stadiums 競技場, 40 sports' surfaces as SVG, drawn to scale from the rulebooks: NFL, CFL and IFAF football fields, an NHL rink, an NBA court, MLB ballparks, soccer pitches, curling sheets, athletics stadiums, speed skating, short track and figure skating rinks, swimming pools, water polo fields, diving wells, artistic swimming areas, and the courts and fields of tennis, volleyball (indoor, beach and sitting), badminton, pickleball, table tennis, padel, squash, racquetball, sepak takraw, teqball, roundnet, netball, lacrosse, rugby, Australian football, Gaelic games, cricket, field hockey and handball, and the budo of judo, karate, kendo, sumo and kyudo with a dojo of tatami laid as real units, the MMA cage, with each team's name and logo on its side and the score in the middle, stands and a crowd, a pageant of flags, and a 3D view. Light and dark, accessible, a compact badge for lists, a helper for React and a custom element. Zero dependencies.",
5
5
  "keywords": [
6
6
  "sports",