@johnmorrisdotca/kyogijo-stadiums 0.24.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 +26 -0
- package/NOTICE.md +17 -0
- package/README.md +58 -40
- package/dist/crowd.d.ts +51 -2
- package/dist/crowd.draw.d.ts +3 -1
- package/dist/crowd.draw.js +27 -3
- package/dist/crowd.js +17 -9
- package/dist/draw.js +13 -2
- 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/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/strings.d.ts +6 -0
- package/dist/strings.js +2 -2
- package/dist/turn.model.js +3 -1
- package/dist/turn.style.js +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/venue.js +7 -4
- package/dist/venue.real.d.ts +16 -0
- package/dist/venue.real.js +103 -0
- package/dist/venues-entry.d.ts +2 -0
- package/dist/venues-entry.js +2 -0
- package/dist/venues.d.ts +153 -0
- package/dist/venues.data.d.ts +2 -0
- package/dist/venues.data.js +74 -0
- package/dist/venues.geo.d.ts +60 -0
- package/dist/venues.geo.js +211 -0
- package/dist/venues.js +213 -0
- package/dist/venues.osm.data.d.ts +4 -0
- package/dist/venues.osm.data.js +363 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -1
|
@@ -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
|
+
}
|
|
@@ -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;
|