@johnmorrisdotca/kyogijo-stadiums 0.26.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/NOTICE.md +10 -0
  3. package/README.md +50 -32
  4. package/dist/crowd.d.ts +7 -2
  5. package/dist/crowd.js +1 -1
  6. package/dist/district-entry.d.ts +5 -0
  7. package/dist/district-entry.js +4 -0
  8. package/dist/district.d.ts +167 -0
  9. package/dist/district.js +291 -0
  10. package/dist/district.turn.d.ts +27 -0
  11. package/dist/district.turn.js +157 -0
  12. package/dist/draw.js +10 -1
  13. package/dist/flythrough.dom.js +15 -5
  14. package/dist/flythrough.markup.d.ts +5 -0
  15. package/dist/flythrough.markup.js +1 -1
  16. package/dist/flythrough.path.d.ts +21 -8
  17. package/dist/flythrough.path.js +36 -11
  18. package/dist/flythrough.style.d.ts +1 -1
  19. package/dist/flythrough.style.js +2 -0
  20. package/dist/flythrough.words.d.ts +2 -2
  21. package/dist/flythrough.words.js +6 -2
  22. package/dist/stadiums-entry.d.ts +2 -0
  23. package/dist/stadiums-entry.js +2 -0
  24. package/dist/stadiums.d.ts +158 -0
  25. package/dist/stadiums.data.d.ts +2 -0
  26. package/dist/stadiums.data.js +11 -0
  27. package/dist/stadiums.js +210 -0
  28. package/dist/stadiums.osm.data.d.ts +2 -0
  29. package/dist/stadiums.osm.data.js +58 -0
  30. package/dist/strings.d.ts +11 -0
  31. package/dist/strings.js +2 -2
  32. package/dist/turn.kit.d.ts +5 -0
  33. package/dist/turn.markup.d.ts +7 -0
  34. package/dist/turn.markup.js +38 -2
  35. package/dist/turn.model.d.ts +4 -0
  36. package/dist/turn.style.js +5 -0
  37. package/dist/types.d.ts +4 -1
  38. package/dist/venue.d.ts +2 -0
  39. package/dist/venue.js +1 -1
  40. package/dist/venue.real.d.ts +6 -1
  41. package/dist/venue.real.js +56 -9
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +13 -1
@@ -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: "再生",
@@ -0,0 +1,2 @@
1
+ export { VENUE_ATTRIBUTION, STADIUMS, STADIUM_IDS, groundOfStadium, planStadium, stadiumBowl, stadiumBowlInside, stadiumById, stadiumByWikidata, stadiumConfigOf, stadiumOrientation, stadiumSeats } from "./stadiums.ts";
2
+ export type { RealStadium, RealStadiumData, StadiumBasis, StadiumConfigData, StadiumConfigId, StadiumOrientation, StadiumOsm, StadiumPlan, StadiumRoof } from "./stadiums.ts";
@@ -0,0 +1,2 @@
1
+ // The real stadiums: the outline of each stadium's building and the way its field lies (OpenStreetMap, ODbL 1.0) and a plausible bowl from the seats of each configuration, the bowls and the roof the sources give (Wikidata, Wikipedia).
2
+ export { VENUE_ATTRIBUTION, STADIUMS, STADIUM_IDS, groundOfStadium, planStadium, stadiumBowl, stadiumBowlInside, stadiumById, stadiumByWikidata, stadiumConfigOf, stadiumOrientation, stadiumSeats } from "./stadiums.js";
@@ -0,0 +1,158 @@
1
+ import { type Box, type Pt, type RealBowl } from "./crowd.ts";
2
+ import { type Ground } from "./grounds.ts";
3
+ import { VENUE_ATTRIBUTION } from "./venues.ts";
4
+ export { VENUE_ATTRIBUTION };
5
+ /** How a fact is known: `documented` is what a source cited with it says; `plausible` is ours, for a bowl no source describes. */
6
+ export type StadiumBasis = "documented" | "plausible";
7
+ /** The configurations of a stadium: the field the seats are counted for. `cfl` is the football field of the Canadian league, `soccer` a soccer pitch. */
8
+ export type StadiumConfigId = "cfl" | "soccer";
9
+ /** The kinds of roof the stadiums have: `cable` is a cable-supported roof with an opening over the field that a retractable fabric closes; `fixed` a lid over the bowl. */
10
+ export type StadiumRoof = "cable" | "fixed";
11
+ /** What one configuration of a stadium is: its sport and its seats, and where the figure was read. */
12
+ export interface StadiumConfigData {
13
+ sport: "cfl" | "soccer";
14
+ /** The seats for this configuration: the infobox's figure for it, or Wikidata's. */
15
+ seats: number;
16
+ source: "wikidata" | "wikipedia";
17
+ /** What was read, in a few words of ours. */
18
+ read: string;
19
+ }
20
+ /** The facts of a stadium: the seats of each configuration, the number of bowls and the kind of roof, each with what a source says. No text of an article is kept. */
21
+ export interface RealStadiumData {
22
+ /** The key: `bc-place`, the key of the stadium's ground in `GROUNDS`. */
23
+ id: string;
24
+ /** The Wikidata item of the stadium. */
25
+ wikidata: string;
26
+ /** The key of the stadium's ground in `GROUNDS` (its name, its place, its pitch). */
27
+ ground: string;
28
+ /** The English Wikipedia article the facts that are not Wikidata's were read from, at a revision. */
29
+ en: {
30
+ title: string;
31
+ revision: number;
32
+ };
33
+ configs: Record<StadiumConfigId, StadiumConfigData>;
34
+ tiers: {
35
+ /** The seating levels (bowls). */
36
+ count: 1 | 2 | 3;
37
+ basis: StadiumBasis;
38
+ /** The seats of the lowest bowl, where a source gives them: the lower tier's band holds this many whatever the configuration (the upper bowl is what is opened or closed for the others). */
39
+ lowerSeats?: number;
40
+ read: string;
41
+ };
42
+ roof: {
43
+ kind: StadiumRoof;
44
+ basis: StadiumBasis;
45
+ read: string; /** A cable roof's opening over the field, as the article gives it, in metres. */
46
+ opening?: {
47
+ length: number;
48
+ width: number;
49
+ unit: "m";
50
+ };
51
+ };
52
+ }
53
+ /** What OpenStreetMap gave of a stadium: ODbL 1.0 data, "(c) OpenStreetMap contributors". */
54
+ export interface StadiumOsm {
55
+ /** The element the outline is: `way/24705904`. */
56
+ source: string;
57
+ read: string;
58
+ base: string;
59
+ licence: "ODbL-1.0";
60
+ attribution: string;
61
+ /** The roof OpenStreetMap maps over the building: its heights in metres (`height` to the top of the roof, `rise` of that the roof's own) and the opening in it as `[longitude, latitude]`. */
62
+ roof?: {
63
+ source: string;
64
+ height?: number;
65
+ rise?: number;
66
+ outer?: string;
67
+ opening?: {
68
+ source: string;
69
+ ring: [number, number][];
70
+ };
71
+ };
72
+ /** The building's outline as `[longitude, latitude]`, not repeating its first point. */
73
+ ring: [number, number][];
74
+ /**
75
+ * How the field lies, worked out when the data was made: the heading of its long side (degrees clockwise from north, 0 to under 180), how that is known (`opening`: from the roof's opening, which the article says is
76
+ * the field's size; `building`: from the long axis of the building's main hall, an inference; `none`: no direction, 90 is stood in), how much of a direction the building has (`strength`) and how much longer than
77
+ * wide the opening or the hall is (`elongation`), and the middle of the field as `[longitude, latitude]`.
78
+ */
79
+ axis: {
80
+ heading: number;
81
+ basis: "opening" | "building" | "none";
82
+ strength: number;
83
+ elongation: number;
84
+ centre: [number, number];
85
+ };
86
+ }
87
+ /** A stadium: its facts and what OpenStreetMap gave. */
88
+ export interface RealStadium extends RealStadiumData {
89
+ osm: StadiumOsm;
90
+ }
91
+ /** How the field lies in the building: its long side's heading, how that is known, and the way north points on the page (degrees clockwise from the top). */
92
+ export interface StadiumOrientation {
93
+ heading: number;
94
+ basis: "opening" | "building" | "none";
95
+ north: number;
96
+ }
97
+ /** The stadiums by key. */
98
+ export declare const STADIUMS: Readonly<Record<string, RealStadium>>;
99
+ /** The keys of `STADIUMS`, in the order of the data. */
100
+ export declare const STADIUM_IDS: readonly string[];
101
+ /** The stadium with a key, or `undefined`. */
102
+ export declare const stadiumById: (id: string | undefined) => RealStadium | undefined;
103
+ /** The stadium whose Wikidata item is `Q612227`, or `undefined`. */
104
+ export declare const stadiumByWikidata: (wikidata: string | undefined) => RealStadium | undefined;
105
+ /** The ground a stadium is (its name, place and pitch). */
106
+ export declare const groundOfStadium: (stadium: RealStadium) => Ground;
107
+ /** How the field lies in a stadium's building (see `StadiumOsm.axis`). */
108
+ export declare const stadiumOrientation: (stadium: RealStadium) => StadiumOrientation;
109
+ /** The configuration a sport is: `cfl` or `soccer`, or `undefined` for a sport a stadium has no configuration for. */
110
+ export declare const stadiumConfigOf: (sport: string | undefined) => StadiumConfigId | undefined;
111
+ /** The seats of a stadium for a configuration. */
112
+ export declare const stadiumSeats: (stadium: RealStadium, config: StadiumConfigId) => number;
113
+ /** The height of the first row above the field, in feet, the rise of a bowl's rows by the number of bowls (what the lowest, a middle and the upper rise, at most), and the rise of the walk between two bowls (a suite level): ours, no source gives them. */
114
+ export declare const STADIUM_FIRST_HEIGHT = 5;
115
+ export declare const STADIUM_RISES: Readonly<Record<1 | 2 | 3, readonly number[]>>;
116
+ export declare const STADIUM_WALK_RISE = 24;
117
+ /** The height a roof's rim stands over the top row, at the least, in feet. */
118
+ export declare const STADIUM_CLEARANCE = 22;
119
+ /** The plan of a stadium for a configuration: its building in the drawing's frame, the bowl fitted inside, and how it lies. */
120
+ export interface StadiumPlan {
121
+ stadium: RealStadium;
122
+ config: StadiumConfigId;
123
+ orientation: StadiumOrientation;
124
+ /** The field, in the drawing's frame. */
125
+ box: Box;
126
+ /** The building's outline in feet, in the drawing's frame (the field's long side along x, in the middle of the building's hall). */
127
+ footprint: Pt[];
128
+ /** The roof's opening in feet, in the drawing's frame, where OpenStreetMap maps it. */
129
+ opening: Pt[] | undefined;
130
+ /** The stands for `layoutOf`: bowls, first row, ground, and the building and roof. */
131
+ bowl: RealBowl;
132
+ /** The seats the bowls hold, by bowl, and in all: equal to the configuration's seats where the building has room (`fit` is `fits`). */
133
+ seats: {
134
+ tier: string;
135
+ seats: number;
136
+ }[];
137
+ total: number;
138
+ /** The floor a seat takes in the plan, in square feet (one of `SEAT_AREAS`). */
139
+ seatArea: number;
140
+ fit: "fits" | "tight";
141
+ }
142
+ /**
143
+ * The plan of a stadium's bowl for a configuration: the building's outline turned so that the field lies along x, a first row round a football field's footprint as a gridiron's stands have it, the largest bowl-shaped
144
+ * outline that fits the building round the middle of the field, and between the two the bowls, the outer edge of each placed so that its band holds the share of the configuration's seats the sources give it, with a walk
145
+ * between them. The bowl is the same building for football and soccer (the seats of the configuration differ, and the field).
146
+ * @example
147
+ * ```ts
148
+ * import { planStadium, stadiumById } from "@johnmorrisdotca/kyogijo-stadiums/stadiums";
149
+ * const plan = planStadium(stadiumById("bc-place")!, "soccer", { x: 0, y: 0, w: 344.5, h: 223.1 });
150
+ * console.log(plan.orientation.basis, plan.seats.map((t) => t.tier).join(" "), plan.total);
151
+ * // opening lower upper 52497
152
+ * ```
153
+ */
154
+ export declare function planStadium(stadium: RealStadium, config: StadiumConfigId, box: Box): StadiumPlan;
155
+ /** The stands of a stadium for `layoutOf` and the 3D view, round a field: `planStadium`'s bowl. */
156
+ export declare const stadiumBowl: (stadium: RealStadium, config: StadiumConfigId, box: Box) => RealBowl;
157
+ /** Whether the bowl lies inside the building: every corner of every bowl's outer edge is inside the footprint. */
158
+ export declare function stadiumBowlInside(plan: StadiumPlan): boolean;
@@ -0,0 +1,2 @@
1
+ import type { RealStadiumData } from "./stadiums.ts";
2
+ export declare const STADIUM_DATA: RealStadiumData[];
@@ -0,0 +1,11 @@
1
+ export const STADIUM_DATA = [
2
+ {
3
+ "id": "bc-place",
4
+ "wikidata": "Q612227",
5
+ "ground": "bc-place",
6
+ "en": { "title": "BC Place", "revision": 1372065649 },
7
+ "configs": { "cfl": { "sport": "cfl", "seats": 54500, "source": "wikipedia", "read": "the infobox's first capacity, 54,500, which Wikidata's capacity (P1083) also gives; the article's stand for football of the CFL's club" }, "soccer": { "sport": "soccer", "seats": 52497, "source": "wikipedia", "read": "the infobox's capacity for the 2026 world cup of soccer, 52,497, 2,003 fewer than its first" } },
8
+ "tiers": { "count": 2, "basis": "documented", "lowerSeats": 27695, "read": "the infobox's lower bowl of 27,695 seats, and the article's lower and upper bowls (the club that plays soccer there sold only the lower bowl)" },
9
+ "roof": { "kind": "cable", "basis": "documented", "read": "the article: a retractable roof supported by cables, the largest of its kind, its opening 100 by 85 metres (the size of the field), the fabric retracting into a pod in the middle of the opening above the suspended video board", "opening": { "length": 100, "width": 85, "unit": "m" } },
10
+ },
11
+ ];