@johnmorrisdotca/kyogijo-stadiums 0.25.0 → 0.27.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 (47) hide show
  1. package/CHANGELOG.md +27 -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-entry.d.ts +8 -0
  14. package/dist/flythrough-entry.js +6 -0
  15. package/dist/flythrough.dom.d.ts +103 -0
  16. package/dist/flythrough.dom.js +517 -0
  17. package/dist/flythrough.markup.d.ts +48 -0
  18. package/dist/flythrough.markup.js +86 -0
  19. package/dist/flythrough.path.d.ts +145 -0
  20. package/dist/flythrough.path.js +253 -0
  21. package/dist/flythrough.style.d.ts +2 -0
  22. package/dist/flythrough.style.js +40 -0
  23. package/dist/flythrough.words.d.ts +33 -0
  24. package/dist/flythrough.words.js +44 -0
  25. package/dist/stadiums-entry.d.ts +2 -0
  26. package/dist/stadiums-entry.js +2 -0
  27. package/dist/stadiums.d.ts +158 -0
  28. package/dist/stadiums.data.d.ts +2 -0
  29. package/dist/stadiums.data.js +11 -0
  30. package/dist/stadiums.js +210 -0
  31. package/dist/stadiums.osm.data.d.ts +2 -0
  32. package/dist/stadiums.osm.data.js +58 -0
  33. package/dist/strings.d.ts +11 -0
  34. package/dist/strings.js +2 -2
  35. package/dist/turn.kit.d.ts +5 -0
  36. package/dist/turn.markup.d.ts +7 -0
  37. package/dist/turn.markup.js +38 -2
  38. package/dist/turn.model.d.ts +4 -0
  39. package/dist/turn.style.js +5 -0
  40. package/dist/types.d.ts +4 -1
  41. package/dist/venue.d.ts +2 -0
  42. package/dist/venue.js +1 -1
  43. package/dist/venue.real.d.ts +6 -1
  44. package/dist/venue.real.js +56 -9
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +18 -1
@@ -0,0 +1,517 @@
1
+ // The fly-through in a page: `mountFlythrough` writes the stage into a container and moves one camera along the path of `flythrough.path.ts`. Three layers are stacked under one set of numbers: the map (chizu's, when
2
+ // the page hands it over, else a plain graticule) turned and zoomed in a viewBox that is true to the ground round the arena, the site plan (the building's outline, from OpenStreetMap) in pixels at the same
3
+ // scale, and the 3D arena of `mountKyogijoTurn`, driven by its own view. Nothing runs on a timer: a frame is drawn only while the clock moves (playing, or gliding to a stage), and not while the page is hidden
4
+ // or the stage is off the screen. A reader who prefers reduced motion gets each stage as a still, and the controls step from one to the next.
5
+ import { arenaById, arenaNameIn } from "./arenas.js";
6
+ import { STAGE_IDS, cameraAt, coverSide, flightPlan, foldTime, glide, glideSeconds, lengthWords, mainView, mapBox, mapFrame, nextRate, niceLength, stageAt, startOf, stagesOf, } from "./flythrough.path.js";
7
+ import { flythroughMarkup, graticuleMarkup, gridPath, mapMarkup, stageWords } from "./flythrough.markup.js";
8
+ import { FLYTHROUGH_WORDS, say } from "./flythrough.words.js";
9
+ import { mountKyogijoTurn } from "./turn.dom.js";
10
+ import { arenaOfVenue, planVenue, venueById, venueOrientation, venueRink } from "./venues.js";
11
+ /**
12
+ * Mount a fly-through of an arena in a container: a map that falls to the city and the building, which rises into the arena in 3D, turning.
13
+ * @example
14
+ * ```ts no-run
15
+ * import { mountFlythrough } from "@johnmorrisdotca/kyogijo-stadiums/flythrough";
16
+ * import WORLD from "@johnmorrisdotca/chizu/world";
17
+ * import { drawChizu } from "@johnmorrisdotca/chizu/draw";
18
+ * import { projectPoint } from "@johnmorrisdotca/chizu";
19
+ *
20
+ * const trip = mountFlythrough(document.querySelector("#trip")!, { arena: "honda-center", start: { map: WORLD, draw: drawChizu, project: projectPoint } });
21
+ * trip.goTo("city");
22
+ * ```
23
+ */
24
+ export function mountFlythrough(host, options, settings = {}) {
25
+ const venue = venueById(options.arena);
26
+ if (venue === undefined)
27
+ throw new Error(`${options.arena} has no real venue: choose one of the keys of VENUES`);
28
+ const arena = arenaById(venue.id) ?? arenaOfVenue(venue);
29
+ const doc = host.ownerDocument;
30
+ const win = doc.defaultView;
31
+ const language = options.language === "ja" ? "ja" : "en";
32
+ const words = FLYTHROUGH_WORDS[language];
33
+ const id = settings.id ?? `kf-${venue.id}`;
34
+ const speed = typeof settings.speed === "number" && Number.isFinite(settings.speed) && settings.speed > 0 ? settings.speed : 1;
35
+ const orientation = venueOrientation(venue);
36
+ const name = arenaNameIn(arena, undefined, language);
37
+ const region = (() => {
38
+ try {
39
+ return new Intl.DisplayNames([language], { type: "region" }).of(arena.country) ?? arena.country;
40
+ }
41
+ catch {
42
+ return arena.country;
43
+ }
44
+ })();
45
+ const place = `${arena.city}, ${region}`;
46
+ const values = { arena: name, place };
47
+ const wanted = (options.stages ?? STAGE_IDS).filter((stage, i, all) => STAGE_IDS.includes(stage) && all.indexOf(stage) === i);
48
+ const stageList = STAGE_IDS.filter((stage) => wanted.includes(stage));
49
+ if (stageList.length === 0)
50
+ throw new Error("a fly-through needs at least one stage: choose from map, city and arena");
51
+ // ---- The map: chizu's where it is handed over and has a place for the arena, else a plain graticule. ----
52
+ const [lon, lat] = venue.osm.axis.centre;
53
+ const start = options.start;
54
+ let frame;
55
+ let mapSvg;
56
+ let chizu = false;
57
+ if (start?.map !== undefined && start.draw !== undefined && start.project !== undefined) {
58
+ const { map, project } = start;
59
+ frame = mapFrame((a, b) => project(map, a, b), lon, lat);
60
+ if (frame !== undefined) {
61
+ mapSvg = mapMarkup(start.draw(map, { style: true, language }));
62
+ chizu = true;
63
+ }
64
+ }
65
+ if (frame === undefined || mapSvg === undefined) {
66
+ const plain = graticuleMarkup(lat);
67
+ mapSvg = plain.svg;
68
+ frame = { at: [0, 0], metresX: 1, metresY: 1 };
69
+ }
70
+ 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
+ const root = host.querySelector(".kf");
73
+ const stage = root.querySelector(".kf-stage");
74
+ const mapLayer = root.querySelector(".kf-map");
75
+ const mapBoxEl = root.querySelector(".kf-map-box");
76
+ const mapSvgEl = mapBoxEl.querySelector("svg");
77
+ const siteEl = root.querySelector(".kf-site");
78
+ const gridEl = siteEl.querySelector(".kf-grid");
79
+ const planEl = siteEl.querySelector(".kf-plan");
80
+ const turnHost = root.querySelector(".kf-3d");
81
+ const pin = root.querySelector(".kf-pin");
82
+ const scale = root.querySelector(".kf-scale");
83
+ const scaleBar = root.querySelector(".kf-scale-bar");
84
+ const scaleText = root.querySelector(".kf-scale-text");
85
+ const caption = root.querySelector(".kf-caption");
86
+ const live = root.querySelector(".kf-live");
87
+ const media = win?.matchMedia?.("(prefers-reduced-motion: reduce)");
88
+ const calm = () => settings.motion === "none" || (settings.motion !== "smooth" && media?.matches === true);
89
+ // ---- The 3D arena, from above to begin with: the view the path hands over at. ----
90
+ const drawing = { sport: "nhl", arena: venue.id, crowd: { real: true }, language, ...(options.theme === undefined ? {} : { theme: options.theme }) };
91
+ let turn;
92
+ if (stageList.includes("arena"))
93
+ turn = mountKyogijoTurn(turnHost, drawing, { preset: "overhead", controls: false, venue: { roof: true }, motion: "none", id: `${id}-turn` });
94
+ // ---- 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
+ const plan = planVenue(venue);
96
+ const rink = venueRink(venue);
97
+ const rinkMiddle = [rink.x + rink.w / 2, rink.y + rink.h / 2];
98
+ const footprint = `M${plan.footprint.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join("L")}Z`;
99
+ let sheet = { x: -200, y: -200, w: 600, h: 490 };
100
+ const drawPlan = () => {
101
+ planEl.innerHTML = `<rect class="ky-apron" x="${sheet.x}" y="${sheet.y}" width="${sheet.w}" height="${sheet.h}"/><path class="ky-cr-shell" d="${footprint}"/><rect class="kf-rink" x="${rink.x}" y="${rink.y}" width="${rink.w}" height="${rink.h}" rx="${Math.min(28, rink.h / 3)}"/>`;
102
+ };
103
+ drawPlan();
104
+ // ---- The path, worked out for this container: the plan hands over to the 3D arena where the two are the same size. ----
105
+ let width = 0;
106
+ let height = 0;
107
+ const startCentre = start?.centre;
108
+ const anchorStart = (w, h, span) => {
109
+ if (startCentre === undefined || start?.project === undefined || start.map === undefined)
110
+ return [0, 0];
111
+ const here = start.project(start.map, lon, lat);
112
+ const there = start.project(start.map, startCentre[0], startCentre[1]);
113
+ if (here === null || there === null)
114
+ return [0, 0];
115
+ // The arena's offset from the middle, in metres on the ground, as a share of what the stage shows.
116
+ const dx = ((here[0] - there[0]) * mapFrameNow.metresX) / span;
117
+ const dy = ((here[1] - there[1]) * mapFrameNow.metresY) / (span * (h / w));
118
+ return [Math.max(-0.4, Math.min(0.4, dx)), Math.max(-0.4, Math.min(0.4, dy))];
119
+ };
120
+ const makeFlight = () => {
121
+ width = stage.clientWidth || 960;
122
+ height = stage.clientHeight || 600;
123
+ let siteSpan = 400;
124
+ let anchorEnd = [0, 0];
125
+ const floor = turn?.svg() ?? null;
126
+ if (turn !== undefined && floor !== null) {
127
+ // 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
+ const was = turn.view();
129
+ turn.set({ yaw: 0, pitch: 0, zoom: 1 }, false);
130
+ const box = floor.getBoundingClientRect();
131
+ // The stage's own area, inside its border: the layers are laid out in it.
132
+ const outer = stage.getBoundingClientRect();
133
+ const [left, top] = [outer.left + stage.clientLeft, outer.top + stage.clientTop];
134
+ const vb = floor.viewBox.baseVal;
135
+ if (box.width > 0 && vb.width > 0 && width > 0) {
136
+ const pxPerFoot = box.width / vb.width;
137
+ siteSpan = (0.3048 * width) / pxPerFoot;
138
+ const x = box.left - left + (rinkMiddle[0] - vb.x) * pxPerFoot;
139
+ const y = box.top - top + (rinkMiddle[1] - vb.y) * pxPerFoot;
140
+ anchorEnd = [(x - width / 2) / width, (y - height / 2) / height];
141
+ sheet = { x: vb.x, y: vb.y, w: vb.width, h: vb.height };
142
+ drawPlan();
143
+ }
144
+ turn.set(was, false);
145
+ }
146
+ const startSpan = start?.span ?? 9000000;
147
+ return flightPlan({ north: orientation.north, siteSpan, startSpan, anchorStart: anchorStart(width, height, startSpan), anchorEnd, stages: stageList, view: mainView("nhl") });
148
+ };
149
+ let flight = makeFlight();
150
+ // ---- Drawing a camera. ----
151
+ let current = cameraAt(flight, 0);
152
+ let shownStage;
153
+ let written = {};
154
+ const put = (key, el, prop, value) => {
155
+ const k = `${key}:${prop}`;
156
+ if (written[k] === value)
157
+ return;
158
+ written[k] = value;
159
+ if (prop.startsWith("@"))
160
+ el.setAttribute(prop.slice(1), value);
161
+ else
162
+ el.style.setProperty(prop, value);
163
+ };
164
+ const num = (value, places = 2) => String(Math.round(value * 10 ** places) / 10 ** places);
165
+ /** Put a camera on the screen. */
166
+ const render = (cam) => {
167
+ current = cam;
168
+ const W = width;
169
+ const H = height;
170
+ const ax = W / 2 + cam.ax * W;
171
+ const ay = H / 2 + cam.ay * H;
172
+ const metresPerPixel = Math.exp(cam.logSpan) / W;
173
+ const live3d = cam.arena > 0.003 && turn !== undefined;
174
+ // The map, turned about the arena, in a square that covers the stage.
175
+ const mapOn = cam.map > 0.003;
176
+ put("map", mapLayer, "visibility", mapOn ? "visible" : "hidden");
177
+ if (mapOn) {
178
+ const side = coverSide(W, H, ax, ay);
179
+ const box = mapBox(mapFrameNow, side * metresPerPixel);
180
+ put("map", mapLayer, "opacity", num(cam.map, 3));
181
+ put("map", mapBoxEl, "width", `${num(side, 1)}px`);
182
+ put("map", mapBoxEl, "height", `${num(side, 1)}px`);
183
+ put("map", mapBoxEl, "left", `${num(ax - side / 2, 1)}px`);
184
+ put("map", mapBoxEl, "top", `${num(ay - side / 2, 1)}px`);
185
+ put("map", mapBoxEl, "transform", `rotate(${num(cam.north, 3)}deg)`);
186
+ put("map", mapSvgEl, "@viewBox", `${box.x} ${box.y} ${box.w} ${box.h}`);
187
+ }
188
+ // The site plan, in pixels: the drawing's feet at this scale, turned to the venue's own orientation as the map is, about the arena.
189
+ const siteOn = cam.site > 0.003;
190
+ put("site", siteEl, "visibility", siteOn ? "visible" : "hidden");
191
+ if (siteOn) {
192
+ const pxPerFoot = 0.3048 / metresPerPixel;
193
+ put("site", siteEl, "opacity", num(cam.site, 3));
194
+ put("site", siteEl, "@viewBox", `0 0 ${W} ${H}`);
195
+ put("site", planEl, "@transform", `translate(${num(ax, 2)} ${num(ay, 2)}) rotate(${num(cam.north - flight.input.north, 3)}) scale(${pxPerFoot.toPrecision(7)}) translate(${-rinkMiddle[0]} ${-rinkMiddle[1]})`);
196
+ const spacing = niceLength(metresPerPixel, 170) / metresPerPixel;
197
+ put("site", gridEl, "@d", gridPath(ax, ay, spacing, Math.hypot(W, H)));
198
+ put("site", gridEl, "@transform", `rotate(${num(cam.north, 3)} ${num(ax, 2)} ${num(ay, 2)})`);
199
+ }
200
+ // The 3D arena.
201
+ put("3d", turnHost, "visibility", live3d ? "visible" : "hidden");
202
+ if (live3d && turn !== undefined) {
203
+ put("3d", turnHost, "opacity", num(cam.arena, 3));
204
+ const view = `${num(cam.yaw, 3)}|${num(cam.pitch, 3)}|${num(cam.logZoom, 4)}`;
205
+ if (written["3d:view"] !== view) {
206
+ written["3d:view"] = view;
207
+ turn.set({ yaw: cam.yaw, pitch: cam.pitch, zoom: Math.exp(cam.logZoom) }, false);
208
+ }
209
+ }
210
+ // The pin, the scale bar (true on the map and the plan, not in perspective) and the stage's words.
211
+ put("pin", pin, "visibility", mapOn ? "visible" : "hidden");
212
+ put("pin", pin, "opacity", num(cam.map, 3));
213
+ put("pin", pin, "transform", `translate(${num(ax, 1)}px, ${num(ay, 1)}px)`);
214
+ const barOpacity = 1 - cam.arena;
215
+ put("scale", scale, "opacity", num(barOpacity, 3));
216
+ put("scale", scale, "visibility", barOpacity > 0.01 ? "visible" : "hidden");
217
+ if (barOpacity > 0.01) {
218
+ const length = niceLength(metresPerPixel, 140);
219
+ put("scale", scaleBar, "width", `${num(length / metresPerPixel, 1)}px`);
220
+ const text = lengthWords(length);
221
+ if (scaleText.textContent !== text)
222
+ scaleText.textContent = text;
223
+ }
224
+ };
225
+ /** Say where the camera is in words: the caption, the buttons, the live region, the page's callback. */
226
+ const enter = (id2, announce) => {
227
+ if (shownStage === id2)
228
+ return;
229
+ shownStage = id2;
230
+ const said = stageWords(language, id2, values);
231
+ caption.querySelector(".kf-title").textContent = said.name;
232
+ caption.querySelector(".kf-text").textContent = said.text;
233
+ root.dataset.stage = id2;
234
+ for (const chip of root.querySelectorAll("button[data-stage]")) {
235
+ if (chip.dataset.stage === id2)
236
+ chip.setAttribute("aria-current", "step");
237
+ else
238
+ chip.removeAttribute("aria-current");
239
+ }
240
+ if (announce)
241
+ live.textContent = say(words.announce, { n: stageList.indexOf(id2) + 1, total: stageList.length, name: said.name, text: said.text });
242
+ settings.onStage?.(id2);
243
+ };
244
+ // ---- The clock. ----
245
+ let time = 0;
246
+ let rate = 0;
247
+ let playing = false;
248
+ let journey;
249
+ let frameId;
250
+ let last;
251
+ let alive = true;
252
+ let onScreen = true;
253
+ const hidden = () => doc.hidden === true;
254
+ const syncPlay = () => {
255
+ const button = root.querySelector("button[data-act=play]");
256
+ if (button === null)
257
+ return;
258
+ button.setAttribute("aria-pressed", String(playing));
259
+ const glyph = button.querySelector("[data-glyph]");
260
+ const word = button.querySelector("[data-word]");
261
+ if (glyph !== null)
262
+ glyph.textContent = playing ? "❚❚" : "▶";
263
+ if (word !== null)
264
+ word.textContent = playing ? words.pause : words.play;
265
+ };
266
+ const show = (at, announce = true) => {
267
+ time = foldTime(flight, at);
268
+ const cam = cameraAt(flight, time);
269
+ render(cam);
270
+ enter(stageAt(flight, time), announce);
271
+ };
272
+ const stopLoop = () => {
273
+ if (frameId !== undefined)
274
+ win?.cancelAnimationFrame(frameId);
275
+ frameId = undefined;
276
+ last = undefined;
277
+ };
278
+ const busy = () => journey !== undefined || playing || rate > 0;
279
+ const loop = () => {
280
+ if (frameId === undefined && alive && win !== null && busy() && !hidden() && onScreen && !calm())
281
+ frameId = win.requestAnimationFrame(tick);
282
+ };
283
+ const tick = (stamp) => {
284
+ frameId = undefined;
285
+ if (!alive)
286
+ return;
287
+ const dt = last === undefined ? 0 : Math.min(0.1, (stamp - last) / 1000);
288
+ last = stamp;
289
+ if (journey !== undefined) {
290
+ journey.elapsed += dt;
291
+ const done = journey.elapsed >= journey.seconds;
292
+ time = done ? journey.to : glide(journey.from, journey.to, journey.elapsed, journey.seconds);
293
+ if (done)
294
+ journey = undefined;
295
+ }
296
+ else {
297
+ rate = nextRate(rate, playing, dt);
298
+ time += rate * dt * speed;
299
+ }
300
+ show(time, true);
301
+ if (busy())
302
+ loop();
303
+ else
304
+ last = undefined;
305
+ };
306
+ const setPlaying = (next) => {
307
+ if (playing === next)
308
+ return;
309
+ playing = next;
310
+ syncPlay();
311
+ settings.onPlayChange?.(next);
312
+ if (calm())
313
+ scheduleStill();
314
+ else if (next)
315
+ loop();
316
+ };
317
+ // Reduced motion: a stage is a still, and playing steps to the next one every few seconds, with no motion between.
318
+ let stillTimer;
319
+ const STILL_SECONDS = 5;
320
+ const clearStill = () => {
321
+ if (stillTimer !== undefined)
322
+ clearTimeout(stillTimer);
323
+ stillTimer = undefined;
324
+ };
325
+ const stillStage = (to, announce = true) => {
326
+ time = startOf(flight, to) ?? 0;
327
+ render(flight.rest[to]);
328
+ enter(to, announce);
329
+ };
330
+ const scheduleStill = () => {
331
+ clearStill();
332
+ if (!playing || !alive)
333
+ return;
334
+ stillTimer = setTimeout(() => {
335
+ const index = stageList.indexOf(stageAt(flight, time));
336
+ if (index >= stageList.length - 1)
337
+ return setPlaying(false);
338
+ stillStage(stageList[index + 1]);
339
+ scheduleStill();
340
+ }, STILL_SECONDS * 1000);
341
+ };
342
+ const here = () => stageAt(flight, time);
343
+ const goTo = (target) => {
344
+ const to = typeof target === "number" ? stageList[target] : target;
345
+ if (to === undefined || !stageList.includes(to))
346
+ return;
347
+ if (calm()) {
348
+ clearStill();
349
+ stillStage(to);
350
+ if (playing)
351
+ scheduleStill();
352
+ return;
353
+ }
354
+ const from = foldTime(flight, time);
355
+ const goal = startOf(flight, to) ?? 0;
356
+ rate = 0;
357
+ journey = { from, to: goal, elapsed: 0, seconds: glideSeconds(goal - from) };
358
+ time = from;
359
+ last = undefined;
360
+ loop();
361
+ };
362
+ const step = (by) => {
363
+ const index = stageList.indexOf(here());
364
+ // Back from a stage that has run a while goes to its start; from its start, to the stage before; from the first stage's start, it starts again.
365
+ if (by < 0) {
366
+ const into = time - (startOf(flight, stageList[index]) ?? 0);
367
+ return goTo(into > 1.5 && !calm() ? index : Math.max(0, index - 1));
368
+ }
369
+ goTo(Math.min(stageList.length - 1, index + 1));
370
+ };
371
+ // ---- Buttons and keys. ----
372
+ const onClick = (event) => {
373
+ const target = event.target?.closest("button");
374
+ if (target === null || target === undefined || !root.contains(target))
375
+ return;
376
+ const wanted2 = target.dataset.stage;
377
+ if (wanted2 !== undefined)
378
+ return goTo(wanted2);
379
+ if (target.dataset.act === "play")
380
+ return void toggle();
381
+ if (target.dataset.act === "next")
382
+ return step(1);
383
+ if (target.dataset.act === "back")
384
+ return step(-1);
385
+ };
386
+ root.addEventListener("click", onClick);
387
+ const toggle = () => {
388
+ setPlaying(!playing);
389
+ return playing;
390
+ };
391
+ const onKey = (event) => {
392
+ if (event.ctrlKey || event.metaKey || event.altKey)
393
+ return;
394
+ if (event.key === " ") {
395
+ event.preventDefault();
396
+ return void toggle();
397
+ }
398
+ if (event.key === "ArrowRight") {
399
+ event.preventDefault();
400
+ return step(1);
401
+ }
402
+ if (event.key === "ArrowLeft") {
403
+ event.preventDefault();
404
+ return step(-1);
405
+ }
406
+ if (event.key === "Home") {
407
+ event.preventDefault();
408
+ return goTo(0);
409
+ }
410
+ const number = Number(event.key);
411
+ if (Number.isInteger(number) && number >= 1 && number <= stageList.length) {
412
+ event.preventDefault();
413
+ goTo(number - 1);
414
+ }
415
+ };
416
+ stage.addEventListener("keydown", onKey);
417
+ // ---- The page: a stage off the screen or a hidden page does not fly, and a change of size works the path out again. ----
418
+ let seen;
419
+ if (typeof IntersectionObserver === "function") {
420
+ seen = new IntersectionObserver((entries) => {
421
+ onScreen = entries[entries.length - 1]?.isIntersecting !== false;
422
+ if (onScreen)
423
+ loop();
424
+ });
425
+ seen.observe(stage);
426
+ }
427
+ const onVisible = () => {
428
+ last = undefined;
429
+ loop();
430
+ };
431
+ doc.addEventListener("visibilitychange", onVisible);
432
+ let observer;
433
+ if (typeof ResizeObserver === "function") {
434
+ observer = new ResizeObserver(() => {
435
+ if (!alive || (stage.clientWidth === width && stage.clientHeight === height))
436
+ return;
437
+ flight = makeFlight();
438
+ written = {};
439
+ if (calm())
440
+ stillStage(here(), false);
441
+ else
442
+ show(time, false);
443
+ });
444
+ observer.observe(stage);
445
+ }
446
+ const onMotion = () => {
447
+ if (!alive)
448
+ return;
449
+ stopLoop();
450
+ journey = undefined;
451
+ rate = 0;
452
+ if (calm())
453
+ stillStage(here(), false);
454
+ else
455
+ show(time, false);
456
+ };
457
+ media?.addEventListener?.("change", onMotion);
458
+ // ---- Start. ----
459
+ if (calm()) {
460
+ stillStage(stageList[0], false);
461
+ root.dataset.mode = "stages";
462
+ const still = doc.createElement("p");
463
+ still.className = "kf-still";
464
+ still.textContent = words.still;
465
+ (root.querySelector(".kf-controls") ?? root.querySelector(".kf-help"))?.after(still);
466
+ }
467
+ else {
468
+ root.dataset.mode = "motion";
469
+ show(0, false);
470
+ }
471
+ if (settings.autoPlay !== false && !calm())
472
+ setPlaying(true);
473
+ return {
474
+ get mode() {
475
+ return calm() ? "stages" : "motion";
476
+ },
477
+ get playing() {
478
+ return playing;
479
+ },
480
+ get stage() {
481
+ return here();
482
+ },
483
+ stages: stagesOf(flight),
484
+ get time() {
485
+ return time;
486
+ },
487
+ get duration() {
488
+ return flight.end;
489
+ },
490
+ camera: () => ({ ...current }),
491
+ play: () => setPlaying(true),
492
+ pause: () => setPlaying(false),
493
+ toggle,
494
+ next: () => step(1),
495
+ back: () => step(-1),
496
+ goTo,
497
+ seek(seconds) {
498
+ stopLoop();
499
+ journey = undefined;
500
+ rate = 0;
501
+ show(Math.max(0, seconds), false);
502
+ },
503
+ destroy() {
504
+ if (!alive)
505
+ return;
506
+ alive = false;
507
+ stopLoop();
508
+ clearStill();
509
+ seen?.disconnect();
510
+ observer?.disconnect();
511
+ doc.removeEventListener("visibilitychange", onVisible);
512
+ media?.removeEventListener?.("change", onMotion);
513
+ turn?.destroy();
514
+ host.replaceChildren();
515
+ },
516
+ };
517
+ }
@@ -0,0 +1,48 @@
1
+ import type { StageId } from "./flythrough.path.ts";
2
+ import { type FlythroughWords } from "./flythrough.words.ts";
3
+ import type { Language } from "./types.ts";
4
+ /**
5
+ * A map drawn by chizu as a layer: its opening tag is rewritten so that it fills its box whatever the box's shape (`preserveAspectRatio="none"`: the layer is given a viewBox that is true to the ground, and the
6
+ * box a square), is hidden from a screen reader (the stage says what it shows) and keeps its own `viewBox` to start from. The rest, the style and the shapes, is left as drawn. A map with text in it would be
7
+ * stretched with it, so the text is taken out: the stage's words are HTML.
8
+ */
9
+ export declare function mapMarkup(svg: string): string;
10
+ /**
11
+ * The map to start from when none is given: the sea and a graticule (a line for every five degrees of longitude and of latitude) round the arena, in metres from it, on the plane that is true to the ground
12
+ * there. It shows where on the earth the camera is and how far it has come, and nothing more.
13
+ */
14
+ export declare function graticuleMarkup(lat: number, reach?: number): {
15
+ svg: string;
16
+ width: number;
17
+ };
18
+ /** The lines of the site's grid as a path, in pixels: vertical and horizontal lines `spacing` apart through `(x, y)`, reaching `reach` pixels from it. Drawn in a group turned about `(x, y)`. */
19
+ export declare function gridPath(x: number, y: number, spacing: number, reach: number): string;
20
+ /** What the markup is made from. */
21
+ export interface MarkupInput {
22
+ id: string;
23
+ language: Language;
24
+ theme?: "light" | "dark";
25
+ /** The arena's name, and where it is (`Anaheim, United States`), in the page's language. */
26
+ arena: string;
27
+ place: string;
28
+ /** The stages flown, in order. */
29
+ stages: readonly StageId[];
30
+ /** The drawn map (already through `mapMarkup`), and whether it is chizu's (so that the credit names it). */
31
+ map: string;
32
+ mapCredit: boolean;
33
+ /** The inside of the site plan's svg, drawn by the mount once it knows the sizes. */
34
+ plan?: string;
35
+ controls?: boolean;
36
+ }
37
+ /** The words of a language for the stage names: a stage's name and what is said of it. */
38
+ export declare const stageWords: (language: Language, stage: StageId, values: {
39
+ arena: string;
40
+ place: string;
41
+ }) => {
42
+ name: string;
43
+ text: string;
44
+ };
45
+ /** The controls under the stage: back, play, next and a button for each stage. */
46
+ export declare function controlsMarkup(words: FlythroughWords, stages: readonly StageId[], current: StageId, playing: boolean): string;
47
+ /** The whole fly-through as markup. */
48
+ export declare function flythroughMarkup(input: MarkupInput): string;
@@ -0,0 +1,86 @@
1
+ import { FLYTHROUGH_STYLE } from "./flythrough.style.js";
2
+ import { FLYTHROUGH_WORDS, say } from "./flythrough.words.js";
3
+ import { esc } from "./format.js";
4
+ /**
5
+ * A map drawn by chizu as a layer: its opening tag is rewritten so that it fills its box whatever the box's shape (`preserveAspectRatio="none"`: the layer is given a viewBox that is true to the ground, and the
6
+ * box a square), is hidden from a screen reader (the stage says what it shows) and keeps its own `viewBox` to start from. The rest, the style and the shapes, is left as drawn. A map with text in it would be
7
+ * stretched with it, so the text is taken out: the stage's words are HTML.
8
+ */
9
+ export function mapMarkup(svg) {
10
+ const open = /^<svg\b[^>]*>/.exec(svg.trim());
11
+ if (open === null)
12
+ throw new Error("a map must be an SVG");
13
+ const viewBox = /\sviewBox="([^"]*)"/.exec(open[0])?.[1];
14
+ if (viewBox === undefined)
15
+ throw new Error("a map must have a viewBox");
16
+ const rest = svg.trim().slice(open[0].length).replace(/<text\b[\s\S]*?<\/text>/g, "");
17
+ return `<svg xmlns="http://www.w3.org/2000/svg" class="chizu" viewBox="${esc(viewBox)}" preserveAspectRatio="none" aria-hidden="true" focusable="false">${rest}`;
18
+ }
19
+ /**
20
+ * The map to start from when none is given: the sea and a graticule (a line for every five degrees of longitude and of latitude) round the arena, in metres from it, on the plane that is true to the ground
21
+ * there. It shows where on the earth the camera is and how far it has come, and nothing more.
22
+ */
23
+ export function graticuleMarkup(lat, reach = 70) {
24
+ const mx = (111195 * Math.cos((lat * Math.PI) / 180)) * 1;
25
+ const my = 111195;
26
+ const half = reach * mx;
27
+ const tall = reach * my;
28
+ const lines = [];
29
+ for (let d = -reach; d <= reach; d += 5) {
30
+ lines.push(`M${(d * mx).toFixed(0)} ${(-tall).toFixed(0)}V${tall.toFixed(0)}`);
31
+ lines.push(`M${(-half).toFixed(0)} ${(d * my).toFixed(0)}H${half.toFixed(0)}`);
32
+ }
33
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" class="chizu" viewBox="${(-half).toFixed(0)} ${(-tall).toFixed(0)} ${(2 * half).toFixed(0)} ${(2 * tall).toFixed(0)}" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect x="${(-half).toFixed(0)}" y="${(-tall).toFixed(0)}" width="${(2 * half).toFixed(0)}" height="${(2 * tall).toFixed(0)}" fill="var(--kf-sea)"/><path d="${lines.join("")}" fill="none" stroke="var(--kf-grid)" stroke-width="1" vector-effect="non-scaling-stroke"/></svg>`;
34
+ return { svg, width: 2 * half };
35
+ }
36
+ /** The lines of the site's grid as a path, in pixels: vertical and horizontal lines `spacing` apart through `(x, y)`, reaching `reach` pixels from it. Drawn in a group turned about `(x, y)`. */
37
+ export function gridPath(x, y, spacing, reach) {
38
+ if (!(spacing >= 4))
39
+ return "";
40
+ const k = Math.ceil(reach / spacing);
41
+ const out = [];
42
+ for (let i = -k; i <= k; i += 1) {
43
+ out.push(`M${(x + i * spacing).toFixed(1)} ${(y - reach).toFixed(1)}V${(y + reach).toFixed(1)}`);
44
+ out.push(`M${(x - reach).toFixed(1)} ${(y + i * spacing).toFixed(1)}H${(x + reach).toFixed(1)}`);
45
+ }
46
+ return out.join("");
47
+ }
48
+ /** The words of a language for the stage names: a stage's name and what is said of it. */
49
+ export const stageWords = (language, stage, values) => {
50
+ const words = FLYTHROUGH_WORDS[language].stage[stage];
51
+ return { name: words.name, text: say(words.text, values) };
52
+ };
53
+ /** The controls under the stage: back, play, next and a button for each stage. */
54
+ export function controlsMarkup(words, stages, current, playing) {
55
+ const chips = stages.map((id, i) => `<li><button type="button" data-stage="${id}" aria-keyshortcuts="${i + 1}"${id === current ? ' aria-current="step"' : ""}>${i + 1}. ${esc(words.stage[id].name)}</button></li>`).join("");
56
+ return (`<div class="kf-controls">` +
57
+ `<button type="button" data-act="back" aria-keyshortcuts="ArrowLeft">◀ ${esc(words.back)}</button>` +
58
+ `<button type="button" data-act="play" data-wide aria-pressed="${playing}" aria-keyshortcuts="Space"><span data-glyph aria-hidden="true">${playing ? "❚❚" : "▶"}</span> <span data-word>${esc(playing ? words.pause : words.play)}</span></button>` +
59
+ `<button type="button" data-act="next" aria-keyshortcuts="ArrowRight">${esc(words.next)} ▶</button>` +
60
+ `<ol class="kf-group" role="group" aria-label="${esc(words.stages)}">${chips}</ol>` +
61
+ `</div>`);
62
+ }
63
+ /** The whole fly-through as markup. */
64
+ export function flythroughMarkup(input) {
65
+ const words = FLYTHROUGH_WORDS[input.language];
66
+ const values = { arena: input.arena, place: input.place };
67
+ const first = input.stages[0];
68
+ const intro = stageWords(input.language, first, values);
69
+ const label = say(words.title, values);
70
+ return (`<div class="kf" id="${esc(input.id)}" lang="${input.language}" data-stage="${first}"${input.theme === undefined ? "" : ` data-theme="${input.theme}"`}>` +
71
+ `<style>${FLYTHROUGH_STYLE}</style>` +
72
+ `<div class="kf-stage" tabindex="0" role="group" aria-roledescription="${esc(words.region)}" aria-label="${esc(label)}" aria-describedby="${esc(input.id)}-about ${esc(input.id)}-help">` +
73
+ `<div class="kf-layer kf-map" aria-hidden="true"><div class="kf-map-box">${input.map}</div></div>` +
74
+ `<svg class="kf-layer kf-site kyogijo ky-nhl" data-sport="nhl" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"${input.theme === undefined ? "" : ` data-theme="${input.theme}"`}><path class="kf-grid" d=""/><g class="kf-plan">${input.plan ?? ""}</g></svg>` +
75
+ `<div class="kf-layer kf-3d" inert></div>` +
76
+ `<div class="kf-pin" aria-hidden="true"></div>` +
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-caption" aria-hidden="true"><span class="kf-title">${esc(intro.name)}</span><span class="kf-text">${esc(intro.text)}</span></div>` +
79
+ `</div>` +
80
+ `<p class="kf-help" id="${esc(input.id)}-help">${esc(words.keys)}</p>` +
81
+ `<p class="kf-help" id="${esc(input.id)}-about" hidden>${esc(say(words.about, values))}</p>` +
82
+ (input.controls === false ? "" : controlsMarkup(words, input.stages, first, false)) +
83
+ `<p class="kf-credit">${esc(input.mapCredit ? words.creditMap : words.creditPlain)}</p>` +
84
+ `<div class="kf-live" role="status" aria-live="polite"></div>` +
85
+ `</div>`);
86
+ }