@johnmorrisdotca/kyogijo-stadiums 0.25.0 → 0.26.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 +14 -0
- package/dist/flythrough-entry.d.ts +8 -0
- package/dist/flythrough-entry.js +6 -0
- package/dist/flythrough.dom.d.ts +103 -0
- package/dist/flythrough.dom.js +517 -0
- package/dist/flythrough.markup.d.ts +48 -0
- package/dist/flythrough.markup.js +86 -0
- package/dist/flythrough.path.d.ts +145 -0
- package/dist/flythrough.path.js +253 -0
- package/dist/flythrough.style.d.ts +2 -0
- package/dist/flythrough.style.js +40 -0
- package/dist/flythrough.words.d.ts +33 -0
- package/dist/flythrough.words.js +44 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ All notable changes to this project are written here. The format follows
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.26.0] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The fly-through** (`mountFlythrough`, [the fly-through](./docs/flythrough.md), entry `@johnmorrisdotca/kyogijo-stadiums/flythrough`): one smooth camera from a map to a real arena. It starts on a map drawn with chizu (handed over as `start: { map, draw, project }`, so the package keeps no dependency; a plain graticule without one), falls to the city and to the building of the arena as OpenStreetMap maps it, and the building rises into the 3D arena, which tips to the sport's main camera and turns. `mountFlythrough(host, { arena, start?, stages?, language?, theme? }, settings?)` gives back `play`, `pause`, `toggle`, `next`, `back`, `goTo(stage)`, `seek(seconds)`, `camera()` and `destroy`. This first release ends on the turning arena; into the bowl, down to the rink and a game on it are the next ones.
|
|
15
|
+
- **One path, no jumps.** The camera is ten numbers (how far in, where the arena stands on the screen, how far the map is turned, how much of the map, the plan and the arena shows, the 3D view's turn, tip and zoom), each running through the path's keyframes as a monotone cubic: smooth at every keyframe, still at the start, never overshooting a layer's share. The map's viewBox is true to the ground round the arena and turned to the venue's own orientation as it comes in; the plan hands over to the 3D arena at the same size and place, measured in the stage as it is. `flightPlan`, `cameraAt` and `stageAt` are the path as plain numbers, pure and testable in Node.
|
|
16
|
+
- **Controls and reduced motion.** Back, Play or Pause (the clock eases to a stop), Next and a button for each stage; Space, the arrows, the number keys and Home; a skip glides along the path and does not cut. A reader who prefers reduced motion gets each stage as a still (the map, the plan, the arena at the main camera), Next, Back and the stage buttons show the next still at once, and Play steps through them every five seconds.
|
|
17
|
+
- **The demo's fly-through page** (`flythrough.html?arena=honda-center`): each of the eight venues, English and Japanese, light and dark, desk and phone, with the OpenStreetMap credit under the stage. No word is stretched (the words are HTML over the layers) and no team's or league's mark is drawn.
|
|
18
|
+
- Tests that walk the path at 60 frames a second for every list of stages (no step, no jump of speed), check that every stage is reachable, that the stills show one layer whole, and, in a real browser, that the pin, the plan and the 3D floor never jump on the screen at 30 frames a second and are the same size where they hand over.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- The shared background module in `scripts/` (tooling for the demo and the README pictures; the package ships none of it) is now the family's version 1.0.1: the `id` it is given is made safe (letters, digits, `_` and `-`; `bg-` in front of one that does not start with a letter) before it is written into element ids and `url(#...)`. `test/family-backgrounds.test.ts` draws every preset with hostile ids.
|
|
23
|
+
|
|
10
24
|
## [0.25.0] - 2026-10-20
|
|
11
25
|
|
|
12
26
|
### Added
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { mountFlythrough } from "./flythrough.dom.ts";
|
|
2
|
+
export type { Flythrough, FlythroughOptions, FlythroughSettings, FlythroughStart } from "./flythrough.dom.ts";
|
|
3
|
+
export { CHANNELS, PACE, RAMP, STAGE_IDS, cameraAt, flightPlan, foldTime, glide, glideSeconds, mainView, mapBox, mapFrame, monotoneSlopes, nextRate, stageAt, startOf, stagesOf } from "./flythrough.path.ts";
|
|
4
|
+
export type { Camera, Channel, Flight, FlightInput, Keyframe, MapFrame, StageId } from "./flythrough.path.ts";
|
|
5
|
+
export { flythroughMarkup, graticuleMarkup, mapMarkup } from "./flythrough.markup.ts";
|
|
6
|
+
export { FLYTHROUGH_STYLE } from "./flythrough.style.ts";
|
|
7
|
+
export { FLYTHROUGH_WORDS } from "./flythrough.words.ts";
|
|
8
|
+
export type { FlythroughWords } from "./flythrough.words.ts";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The fly-through: one smooth camera from a map to the real arena, over the map (chizu's, handed over), the building's site plan and the 3D arena.
|
|
2
|
+
export { mountFlythrough } from "./flythrough.dom.js";
|
|
3
|
+
export { CHANNELS, PACE, RAMP, STAGE_IDS, cameraAt, flightPlan, foldTime, glide, glideSeconds, mainView, mapBox, mapFrame, monotoneSlopes, nextRate, stageAt, startOf, stagesOf } from "./flythrough.path.js";
|
|
4
|
+
export { flythroughMarkup, graticuleMarkup, mapMarkup } from "./flythrough.markup.js";
|
|
5
|
+
export { FLYTHROUGH_STYLE } from "./flythrough.style.js";
|
|
6
|
+
export { FLYTHROUGH_WORDS } from "./flythrough.words.js";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type Camera, type StageId } from "./flythrough.path.ts";
|
|
2
|
+
import type { Language } from "./types.ts";
|
|
3
|
+
/** A map to start on: chizu's world (or any map of the same shape) with chizu's `drawChizu` and `projectPoint`, which are handed over, not imported, so that the package has no dependency. */
|
|
4
|
+
export interface FlythroughStart<M extends {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
} = {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}> {
|
|
11
|
+
/** The map: chizu's `WORLD`, for instance. */
|
|
12
|
+
map?: M;
|
|
13
|
+
/** Draws the map as an SVG string: chizu's `drawChizu`. It is called with `{ style: true, language }`. */
|
|
14
|
+
draw?: (map: M, options: {
|
|
15
|
+
style: boolean;
|
|
16
|
+
language: Language;
|
|
17
|
+
}) => string;
|
|
18
|
+
/** Puts a longitude and latitude on the map's canvas: chizu's `projectPoint`; `null` where the map has no place for it. */
|
|
19
|
+
project?: (map: M, lon: number, lat: number) => readonly [number, number] | null;
|
|
20
|
+
/** The metres the container's width shows at the start. 9,000 km by default. */
|
|
21
|
+
span?: number;
|
|
22
|
+
/** Where the camera starts looking, `[longitude, latitude]`: the arena stands off the middle of the screen and comes to the middle as the camera falls. The arena itself by default. */
|
|
23
|
+
centre?: readonly [number, number];
|
|
24
|
+
}
|
|
25
|
+
/** What a fly-through is of. */
|
|
26
|
+
export interface FlythroughOptions<M extends {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
} = {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}> {
|
|
33
|
+
/** The arena's key in `ARENAS`: an arena that has a real venue (`VENUE_IDS`). */
|
|
34
|
+
arena: string;
|
|
35
|
+
start?: FlythroughStart<M>;
|
|
36
|
+
/** The stages to fly, in the order of the path; all of them by default. */
|
|
37
|
+
stages?: readonly StageId[];
|
|
38
|
+
language?: Language;
|
|
39
|
+
/** `light` or `dark`; the device's setting decides when it is left out. */
|
|
40
|
+
theme?: "light" | "dark";
|
|
41
|
+
}
|
|
42
|
+
/** How it behaves. */
|
|
43
|
+
export interface FlythroughSettings {
|
|
44
|
+
/** `auto` (the default) flies unless the reader prefers reduced motion, and then shows each stage as a still; `smooth` flies anyway and `none` shows stills. */
|
|
45
|
+
motion?: "auto" | "smooth" | "none";
|
|
46
|
+
/** Whether it starts flying when it is mounted (it does by default, unless the reader prefers reduced motion). */
|
|
47
|
+
autoPlay?: boolean;
|
|
48
|
+
/** How many times faster than the path's own pace the clock runs. 1 by default. */
|
|
49
|
+
speed?: number;
|
|
50
|
+
/** Whether the buttons under the stage are drawn. True when left out; the keys work either way. */
|
|
51
|
+
controls?: boolean;
|
|
52
|
+
/** The prefix of the ids in the page, for a page with more than one. */
|
|
53
|
+
id?: string;
|
|
54
|
+
/** Called with the stage when the camera enters one. */
|
|
55
|
+
onStage?: (stage: StageId) => void;
|
|
56
|
+
/** Called with `true` when it starts flying and `false` when it stops. */
|
|
57
|
+
onPlayChange?: (playing: boolean) => void;
|
|
58
|
+
}
|
|
59
|
+
/** What `mountFlythrough` gives back. */
|
|
60
|
+
export interface Flythrough {
|
|
61
|
+
/** `motion` flies; `stages` shows each stage as a still. */
|
|
62
|
+
readonly mode: "motion" | "stages";
|
|
63
|
+
readonly playing: boolean;
|
|
64
|
+
/** The stage the camera is in. */
|
|
65
|
+
readonly stage: StageId;
|
|
66
|
+
/** The stages flown, in order. */
|
|
67
|
+
readonly stages: readonly StageId[];
|
|
68
|
+
/** Seconds along the path now. */
|
|
69
|
+
readonly time: number;
|
|
70
|
+
/** The seconds the path takes from the first stage to the last one's start (and then the arena turns for ever). */
|
|
71
|
+
readonly duration: number;
|
|
72
|
+
/** The camera now. */
|
|
73
|
+
camera(): Camera;
|
|
74
|
+
play(): void;
|
|
75
|
+
pause(): void;
|
|
76
|
+
/** Play when paused, pause when playing; gives whether it plays now. */
|
|
77
|
+
toggle(): boolean;
|
|
78
|
+
/** Go to the stage after this one (the last one stays), and to the one before (the first starts again). */
|
|
79
|
+
next(): void;
|
|
80
|
+
back(): void;
|
|
81
|
+
/** Go to a stage (its name or its place in `stages`): the camera glides there along the path, or shows its still. */
|
|
82
|
+
goTo(stage: StageId | number): void;
|
|
83
|
+
/** Put the camera at a moment of the path at once, whatever the mode (a scrubber, a test). */
|
|
84
|
+
seek(seconds: number): void;
|
|
85
|
+
destroy(): void;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 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.
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts no-run
|
|
91
|
+
* import { mountFlythrough } from "@johnmorrisdotca/kyogijo-stadiums/flythrough";
|
|
92
|
+
* import WORLD from "@johnmorrisdotca/chizu/world";
|
|
93
|
+
* import { drawChizu } from "@johnmorrisdotca/chizu/draw";
|
|
94
|
+
* import { projectPoint } from "@johnmorrisdotca/chizu";
|
|
95
|
+
*
|
|
96
|
+
* const trip = mountFlythrough(document.querySelector("#trip")!, { arena: "honda-center", start: { map: WORLD, draw: drawChizu, project: projectPoint } });
|
|
97
|
+
* trip.goTo("city");
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export declare function mountFlythrough<M extends {
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
}>(host: HTMLElement, options: FlythroughOptions<M>, settings?: FlythroughSettings): Flythrough;
|
|
@@ -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
|
+
}
|