@johnmorrisdotca/kyogijo-stadiums 0.24.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 (62) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/NOTICE.md +27 -0
  3. package/README.md +95 -59
  4. package/dist/crowd.d.ts +56 -2
  5. package/dist/crowd.draw.d.ts +3 -1
  6. package/dist/crowd.draw.js +27 -3
  7. package/dist/crowd.js +18 -10
  8. package/dist/district-entry.d.ts +5 -0
  9. package/dist/district-entry.js +4 -0
  10. package/dist/district.d.ts +167 -0
  11. package/dist/district.js +291 -0
  12. package/dist/district.turn.d.ts +27 -0
  13. package/dist/district.turn.js +157 -0
  14. package/dist/draw.js +22 -2
  15. package/dist/flythrough-entry.d.ts +8 -0
  16. package/dist/flythrough-entry.js +6 -0
  17. package/dist/flythrough.dom.d.ts +103 -0
  18. package/dist/flythrough.dom.js +517 -0
  19. package/dist/flythrough.markup.d.ts +48 -0
  20. package/dist/flythrough.markup.js +86 -0
  21. package/dist/flythrough.path.d.ts +145 -0
  22. package/dist/flythrough.path.js +253 -0
  23. package/dist/flythrough.style.d.ts +2 -0
  24. package/dist/flythrough.style.js +40 -0
  25. package/dist/flythrough.words.d.ts +33 -0
  26. package/dist/flythrough.words.js +44 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/stadiums-entry.d.ts +2 -0
  30. package/dist/stadiums-entry.js +2 -0
  31. package/dist/stadiums.d.ts +158 -0
  32. package/dist/stadiums.data.d.ts +2 -0
  33. package/dist/stadiums.data.js +11 -0
  34. package/dist/stadiums.js +210 -0
  35. package/dist/stadiums.osm.data.d.ts +2 -0
  36. package/dist/stadiums.osm.data.js +58 -0
  37. package/dist/strings.d.ts +17 -0
  38. package/dist/strings.js +2 -2
  39. package/dist/turn.kit.d.ts +5 -0
  40. package/dist/turn.markup.d.ts +7 -0
  41. package/dist/turn.markup.js +38 -2
  42. package/dist/turn.model.d.ts +4 -0
  43. package/dist/turn.model.js +3 -1
  44. package/dist/turn.style.js +6 -0
  45. package/dist/types.d.ts +11 -0
  46. package/dist/venue.d.ts +2 -0
  47. package/dist/venue.js +7 -4
  48. package/dist/venue.real.d.ts +21 -0
  49. package/dist/venue.real.js +150 -0
  50. package/dist/venues-entry.d.ts +2 -0
  51. package/dist/venues-entry.js +2 -0
  52. package/dist/venues.d.ts +153 -0
  53. package/dist/venues.data.d.ts +2 -0
  54. package/dist/venues.data.js +74 -0
  55. package/dist/venues.geo.d.ts +60 -0
  56. package/dist/venues.geo.js +211 -0
  57. package/dist/venues.js +213 -0
  58. package/dist/venues.osm.data.d.ts +4 -0
  59. package/dist/venues.osm.data.js +363 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +25 -1
@@ -0,0 +1,145 @@
1
+ /** The stages of the fly-through, in order. */
2
+ export type StageId = "map" | "city" | "arena";
3
+ /** The stages that exist, in the order of the path. */
4
+ export declare const STAGE_IDS: readonly StageId[];
5
+ /** The channels of the camera. */
6
+ export declare const CHANNELS: readonly ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom"];
7
+ export type Channel = (typeof CHANNELS)[number];
8
+ /**
9
+ * Where the camera is. `logSpan` is the natural logarithm of the metres the container's width shows (so that a zoom is a straight line); `ax` and `ay` are where the arena stands on the screen, as a
10
+ * share of the width and of the height from the middle; `north` is how far the map and the site plan are turned, in degrees clockwise (the rink's long side lies along the page when it reaches the
11
+ * venue's); `map`, `site` and `arena` are how much of the map, of the site plan and of the 3D arena shows (0 to 1); `yaw`, `pitch` and `logZoom` are the 3D view's turn and tip in degrees and the natural logarithm of its zoom.
12
+ */
13
+ export type Camera = Record<Channel, number>;
14
+ /** What the path needs to know of the page and the venue. */
15
+ export interface FlightInput {
16
+ /** How far the venue's plan is turned from north-up, in degrees clockwise (`venueOrientation(venue).north`). */
17
+ north: number;
18
+ /** The metres the container's width shows when the 3D arena seen from straight above is as large as the site plan is: where the plan hands over to the 3D arena. */
19
+ siteSpan: number;
20
+ /** The metres the width shows at the start, on the map. 9,000 km by default. */
21
+ startSpan?: number;
22
+ /** The metres the width shows when the camera reaches the city. Eight times the site span, and at least 1.5 km, by default. */
23
+ citySpan?: number;
24
+ /** Where the arena stands on the screen at the start and at the 3D arena, as shares of the width and the height from the middle: `[0, 0]` for both by default. */
25
+ anchorStart?: readonly [number, number];
26
+ anchorEnd?: readonly [number, number];
27
+ /** The stages to fly, in the order of the path (the others are left out, and the camera goes from where the stage before them ended): all by default. */
28
+ stages?: readonly StageId[];
29
+ /** The 3D view the arena settles in: the sport's main camera by default. */
30
+ view?: {
31
+ yaw: number;
32
+ pitch: number;
33
+ };
34
+ /** Degrees a second the arena turns at, once it is turning: 12 by default. */
35
+ turn?: number;
36
+ }
37
+ /** A keyframe: the camera at a moment, and where it must be going where that is not what the neighbours say. */
38
+ export interface Keyframe {
39
+ t: number;
40
+ camera: Camera;
41
+ /** Channels' speeds (units a second) to pass through this keyframe at, in place of the monotone cubic's own. */
42
+ slope?: Partial<Camera>;
43
+ }
44
+ /** A path: the keyframes, where each stage starts, and what happens after the last keyframe. */
45
+ export interface Flight {
46
+ keys: readonly Keyframe[];
47
+ /** The stages flown and the time each starts at, in order. */
48
+ stages: readonly {
49
+ id: StageId;
50
+ start: number;
51
+ }[];
52
+ /** The time of the last keyframe. */
53
+ end: number;
54
+ /** After `end`, the arena goes on turning at this many degrees a second (negative: the near side goes to the right), and nothing else moves. */
55
+ tail: number;
56
+ /** The camera of each stage at rest: what a reader who prefers reduced motion is shown (one layer, nothing half-faded). */
57
+ rest: Readonly<Record<StageId, Camera>>;
58
+ input: Required<Pick<FlightInput, "north" | "siteSpan" | "startSpan" | "citySpan" | "turn">> & {
59
+ anchorStart: readonly [number, number];
60
+ anchorEnd: readonly [number, number];
61
+ view: {
62
+ yaw: number;
63
+ pitch: number;
64
+ };
65
+ };
66
+ }
67
+ /** Zero to one with a zero slope at both ends. */
68
+ export declare const smooth: (t: number) => number;
69
+ /**
70
+ * The speeds a monotone cubic passes the keyframes of one channel at: zero at both ends and where the neighbours' slopes change sign (a plateau or a turning point), else the weighted harmonic mean of the
71
+ * two slopes (Fritsch and Butland), which keeps every piece between its two values.
72
+ */
73
+ export declare function monotoneSlopes(t: readonly number[], y: readonly number[]): number[];
74
+ /** The camera at a moment of a path. After the last keyframe the arena goes on turning and nothing else moves. Before the first, the first. */
75
+ export declare function cameraAt(flight: Flight, time: number): Camera;
76
+ /** The stage a moment is in: the last whose start is not after it. */
77
+ export declare function stageAt(flight: Flight, time: number): StageId;
78
+ /** When a stage starts, or `undefined` for a stage the path does not fly. */
79
+ export declare const startOf: (flight: Flight, id: StageId) => number | undefined;
80
+ /** The stages a path flies, in order. */
81
+ export declare const stagesOf: (flight: Flight) => StageId[];
82
+ /** The seconds one turn of the arena takes once it is turning, and so how far a moment after the end can be brought back to without a change of view. */
83
+ export declare const turnPeriod: (flight: Flight) => number;
84
+ /** A moment after the end brought back by whole turns of the arena to one near the end: the same view, and a time that does not grow without end. */
85
+ export declare function foldTime(flight: Flight, time: number): number;
86
+ /** How a path is paced: the seconds of each part. */
87
+ export declare const PACE: {
88
+ readonly dwell: 1.6;
89
+ readonly step: 1.25;
90
+ readonly cityDwell: 0.9;
91
+ readonly approach: 1.6;
92
+ readonly rise: 0.8;
93
+ readonly hand: 0.7;
94
+ readonly tilt: 3.2;
95
+ };
96
+ /**
97
+ * The path from the map to the turning arena, for the stages asked (all by default): the map holds a moment, then the camera falls to the city in four even steps of the logarithm of the distance (the
98
+ * map turning as it comes to the venue's own orientation and giving way to the site plan), holds there, comes in until the plan is as large as the 3D arena seen from above, the arena rises out of it,
99
+ * tips to the sport's main camera and turns.
100
+ */
101
+ export declare function flightPlan(input: FlightInput): Flight;
102
+ /** The 3D view the arena settles in, as the sport's main camera: its yaw and pitch. */
103
+ export declare const mainView: (sport?: string) => {
104
+ yaw: number;
105
+ pitch: number;
106
+ };
107
+ /** A glide of the clock from one moment of the path to another: zero to one with a zero slope at both ends, so that a skip starts and ends at rest. */
108
+ export declare const glide: (from: number, to: number, elapsed: number, seconds: number) => number;
109
+ /** How long a skip over `distance` seconds of the path takes: quick, and never so long that a long skip is slow or so short that it is a cut. */
110
+ export declare const glideSeconds: (distance: number) => number;
111
+ /** The seconds the clock takes to go from still to the path's pace and back. */
112
+ export declare const RAMP = 0.45;
113
+ /** The speed the clock runs at after `dt` seconds: it goes to 1 while playing and to 0 when not, at one steady rate of change, reaching it in `RAMP` seconds and never at once. */
114
+ export declare function nextRate(rate: number, playing: boolean, dt: number): number;
115
+ /** The metres in a degree of latitude on the sphere the venues' geometry uses, and its radius. */
116
+ export declare const METRES_PER_DEGREE: number;
117
+ /** How a map's canvas relates to the ground round the arena: the canvas point it stands at and the metres one unit of the canvas is, across and down. */
118
+ export interface MapFrame {
119
+ /** The arena on the map's canvas. */
120
+ at: readonly [number, number];
121
+ /** Metres in one canvas unit, along x and along y, at the arena. */
122
+ metresX: number;
123
+ metresY: number;
124
+ }
125
+ /**
126
+ * The map's frame at an arena, from the map's own projection: the canvas point of the arena and of a point a hundredth of a degree east of it and north of it give the metres a unit is, so that the
127
+ * map can be drawn true to the ground round the arena (the world's projection is not, and a map stretched to be square on the ground there is the same map, stretched). `project` answers `null` where the map has no
128
+ * place for a point, and then there is no frame.
129
+ */
130
+ export declare function mapFrame(project: (lon: number, lat: number) => readonly [number, number] | null, lon: number, lat: number): MapFrame | undefined;
131
+ /**
132
+ * The viewBox of the map layer: a square of `metres` on the ground, centred on the arena, as canvas units (`metres` along x over the metres of a unit, and along y). Drawn into a square box with `preserveAspectRatio="none"`.
133
+ */
134
+ export declare function mapBox(frame: MapFrame, metres: number): {
135
+ x: number;
136
+ y: number;
137
+ w: number;
138
+ h: number;
139
+ };
140
+ /** The side, in pixels, of the square the map is drawn in so that it covers a `width` by `height` container whichever way it is turned about the arena, which stands at `(x, y)` in it. */
141
+ export declare const coverSide: (width: number, height: number, x: number, y: number) => number;
142
+ /** A distance a scale bar can show: 1, 2 or 5 times a power of ten metres, the longest that fits in `maxPixels` at `metresPerPixel`. */
143
+ export declare function niceLength(metresPerPixel: number, maxPixels: number): number;
144
+ /** A length in metres as a scale bar says it: `500 m`, `2 km`. */
145
+ export declare const lengthWords: (metres: number) => string;
@@ -0,0 +1,253 @@
1
+ // The path of the fly-through, as plain numbers: where the camera is at any moment of one smooth run from a map to an arena. The camera is a handful of channels (how far
2
+ // in it is, where the arena stands on the screen, how the map is turned, how much of each layer shows, and the 3D view's turn, tip and zoom); the path is a short list of
3
+ // keyframes in time, and every channel runs through them as a monotone cubic (Fritsch and Carlson), so that it is smooth where it passes a keyframe (the speed does not jump),
4
+ // never overshoots between two (a layer's opacity stays between 0 and 1), and is still at its first keyframe. Nothing here touches a page: the same path can be worked out in
5
+ // Node, tested frame by frame, and handed to the mount, which draws it.
6
+ import { LONG_PRESETS, presetView } from "./turn.camera.js";
7
+ /** The stages that exist, in the order of the path. */
8
+ export const STAGE_IDS = ["map", "city", "arena"];
9
+ /** The channels of the camera. */
10
+ export const CHANNELS = ["logSpan", "ax", "ay", "north", "map", "site", "arena", "yaw", "pitch", "logZoom"];
11
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
12
+ const lerp = (a, b, t) => a + (b - a) * t;
13
+ /** Zero to one with a zero slope at both ends. */
14
+ export const smooth = (t) => {
15
+ const u = clamp(t, 0, 1);
16
+ return u * u * (3 - 2 * u);
17
+ };
18
+ /** An angle in degrees brought to the range -180 to 180. */
19
+ const wrap180 = (angle) => ((((angle + 180) % 360) + 360) % 360) - 180;
20
+ /**
21
+ * The speeds a monotone cubic passes the keyframes of one channel at: zero at both ends and where the neighbours' slopes change sign (a plateau or a turning point), else the weighted harmonic mean of the
22
+ * two slopes (Fritsch and Butland), which keeps every piece between its two values.
23
+ */
24
+ export function monotoneSlopes(t, y) {
25
+ const n = t.length;
26
+ const d = new Array(n).fill(0);
27
+ if (n < 3)
28
+ return d;
29
+ const h = t.slice(1).map((time, i) => time - t[i]);
30
+ const delta = h.map((step, i) => (y[i + 1] - y[i]) / step);
31
+ for (let k = 1; k < n - 1; k += 1) {
32
+ if (delta[k - 1] * delta[k] <= 0)
33
+ continue;
34
+ const w1 = 2 * h[k] + h[k - 1];
35
+ const w2 = h[k] + 2 * h[k - 1];
36
+ d[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
37
+ }
38
+ return d;
39
+ }
40
+ /** The cubic Hermite of one channel at `time`, through its keyframes with these slopes. Outside the keyframes it is the first or the last value. */
41
+ function hermite(times, values, slopes, time) {
42
+ const last = times.length - 1;
43
+ if (time <= times[0])
44
+ return values[0];
45
+ if (time >= times[last])
46
+ return values[last];
47
+ let i = 0;
48
+ while (i < last - 1 && time >= times[i + 1])
49
+ i += 1;
50
+ const h = times[i + 1] - times[i];
51
+ const s = (time - times[i]) / h;
52
+ const s2 = s * s;
53
+ const s3 = s2 * s;
54
+ return (2 * s3 - 3 * s2 + 1) * values[i] + (s3 - 2 * s2 + s) * h * slopes[i] + (-2 * s3 + 3 * s2) * values[i + 1] + (s3 - s2) * h * slopes[i + 1];
55
+ }
56
+ /** The speeds of every channel at every keyframe, worked out once for a path. */
57
+ const SLOPES = new WeakMap();
58
+ function slopesOf(keys) {
59
+ const known = SLOPES.get(keys);
60
+ if (known !== undefined)
61
+ return known;
62
+ const times = keys.map((key) => key.t);
63
+ const made = {};
64
+ for (const channel of CHANNELS) {
65
+ const slopes = monotoneSlopes(times, keys.map((key) => key.camera[channel]));
66
+ keys.forEach((key, i) => {
67
+ const own = key.slope?.[channel];
68
+ if (own !== undefined)
69
+ slopes[i] = own;
70
+ });
71
+ made[channel] = slopes;
72
+ }
73
+ SLOPES.set(keys, made);
74
+ return made;
75
+ }
76
+ /** The camera at a moment of a path. After the last keyframe the arena goes on turning and nothing else moves. Before the first, the first. */
77
+ export function cameraAt(flight, time) {
78
+ const { keys } = flight;
79
+ const slopes = slopesOf(keys);
80
+ const times = keys.map((key) => key.t);
81
+ const out = {};
82
+ for (const channel of CHANNELS)
83
+ out[channel] = hermite(times, keys.map((key) => key.camera[channel]), slopes[channel], time);
84
+ if (time > flight.end)
85
+ out.yaw += flight.tail * (time - flight.end);
86
+ return out;
87
+ }
88
+ /** The stage a moment is in: the last whose start is not after it. */
89
+ export function stageAt(flight, time) {
90
+ let found = flight.stages[0].id;
91
+ for (const stage of flight.stages)
92
+ if (stage.start <= time + 1e-9)
93
+ found = stage.id;
94
+ return found;
95
+ }
96
+ /** When a stage starts, or `undefined` for a stage the path does not fly. */
97
+ export const startOf = (flight, id) => flight.stages.find((stage) => stage.id === id)?.start;
98
+ /** The stages a path flies, in order. */
99
+ export const stagesOf = (flight) => flight.stages.map((stage) => stage.id);
100
+ /** The seconds one turn of the arena takes once it is turning, and so how far a moment after the end can be brought back to without a change of view. */
101
+ export const turnPeriod = (flight) => (flight.tail === 0 ? Infinity : 360 / Math.abs(flight.tail));
102
+ /** A moment after the end brought back by whole turns of the arena to one near the end: the same view, and a time that does not grow without end. */
103
+ export function foldTime(flight, time) {
104
+ if (time <= flight.end + turnPeriod(flight))
105
+ return time;
106
+ const period = turnPeriod(flight);
107
+ return flight.end + ((time - flight.end) % period);
108
+ }
109
+ /** The camera's defaults: the map, from far above, with nothing of the arena showing. */
110
+ const BASE = { logSpan: 0, ax: 0, ay: 0, north: 0, map: 1, site: 0, arena: 0, yaw: 0, pitch: 0, logZoom: 0 };
111
+ /** How a path is paced: the seconds of each part. */
112
+ export const PACE = { dwell: 1.6, step: 1.25, cityDwell: 0.9, approach: 1.6, rise: 0.8, hand: 0.7, tilt: 3.2 };
113
+ /**
114
+ * The path from the map to the turning arena, for the stages asked (all by default): the map holds a moment, then the camera falls to the city in four even steps of the logarithm of the distance (the
115
+ * map turning as it comes to the venue's own orientation and giving way to the site plan), holds there, comes in until the plan is as large as the 3D arena seen from above, the arena rises out of it,
116
+ * tips to the sport's main camera and turns.
117
+ */
118
+ export function flightPlan(input) {
119
+ const startSpan = input.startSpan ?? 9000000;
120
+ const citySpan = input.citySpan ?? Math.max(1500, input.siteSpan * 8);
121
+ const [ax0, ay0] = input.anchorStart ?? [0, 0];
122
+ const [ax1, ay1] = input.anchorEnd ?? [0, 0];
123
+ const north = wrap180(input.north);
124
+ const turn = input.turn ?? 12;
125
+ const view = input.view ?? { yaw: LONG_PRESETS.broadcast.yaw, pitch: LONG_PRESETS.broadcast.pitch };
126
+ const ln = Math.log;
127
+ const L0 = ln(startSpan);
128
+ const L1 = ln(citySpan);
129
+ const L2 = ln(input.siteSpan);
130
+ // The descent, in four equal steps of the logarithm of the distance: where each is, how far the map is turned, how much of the map and of the plan shows.
131
+ const fall = (k) => {
132
+ const f = k / 4;
133
+ const g = smooth((f - 0.35) / 0.65);
134
+ return {
135
+ logSpan: lerp(L0, L1, f),
136
+ ax: lerp(ax0, ax1, smooth(f)),
137
+ ay: lerp(ay0, ay1, smooth(f)),
138
+ north: north * g,
139
+ map: [1, 1, 1, 0.5, 0][k],
140
+ site: [0, 0, 0, 0.5, 1][k],
141
+ };
142
+ };
143
+ const start = { logSpan: L0, ax: ax0, ay: ay0, north: 0, map: 1, site: 0, arena: 0, yaw: 0, pitch: 0, logZoom: 0 };
144
+ const arrive = { ...fall(4), logSpan: L1, ax: ax1, ay: ay1, north, map: 0, site: 1 };
145
+ const near = { ...arrive, logSpan: L2 };
146
+ const blocks = [
147
+ { id: "map", keys: [{ dt: 0, camera: start }, { dt: PACE.dwell, camera: start }, { dt: PACE.step, camera: fall(1) }, { dt: PACE.step, camera: fall(2) }, { dt: PACE.step, camera: fall(3) }] },
148
+ { id: "city", keys: [{ dt: PACE.step, camera: arrive }, { dt: PACE.cityDwell, camera: arrive }, { dt: PACE.approach, camera: near }, { dt: PACE.rise, camera: { ...near, arena: 1 } }] },
149
+ {
150
+ id: "arena",
151
+ keys: [
152
+ { dt: PACE.hand, camera: { ...near, arena: 1, site: 0, yaw: 0, pitch: 0 }, slope: { yaw: 0 } },
153
+ { dt: PACE.tilt, camera: { ...near, arena: 1, site: 0, yaw: -(turn * PACE.tilt) / 2, pitch: view.pitch }, slope: { yaw: -turn } },
154
+ ],
155
+ },
156
+ ];
157
+ const wanted = (input.stages ?? STAGE_IDS).filter((id, i, all) => all.indexOf(id) === i);
158
+ const flown = blocks.filter((block) => wanted.includes(block.id));
159
+ if (flown.length === 0)
160
+ throw new Error("a fly-through needs at least one stage");
161
+ const keys = [];
162
+ const stages = [];
163
+ let time = 0;
164
+ let camera = { ...BASE };
165
+ // A stage left out keeps its seconds: the camera crosses them in one smooth run from where the stage before ended to where the stage after starts, so that it never jumps.
166
+ let carry = 0;
167
+ for (const block of blocks) {
168
+ if (!wanted.includes(block.id)) {
169
+ carry += block.keys.reduce((sum, key) => sum + key.dt, 0);
170
+ continue;
171
+ }
172
+ block.keys.forEach((key, i) => {
173
+ if (keys.length > 0)
174
+ time += key.dt + (i === 0 ? carry : 0);
175
+ camera = { ...camera, ...key.camera };
176
+ // A stage starts at its first key; the key that arrives from the stage before is the one that stage ended on.
177
+ if (i === 0)
178
+ stages.push({ id: block.id, start: time });
179
+ keys.push({ t: time, camera, ...(key.slope === undefined ? {} : { slope: key.slope }) });
180
+ });
181
+ carry = 0;
182
+ }
183
+ const rest = {
184
+ map: { ...BASE, ...start },
185
+ city: { ...BASE, ...arrive },
186
+ arena: { ...BASE, ...near, map: 0, site: 0, arena: 1, yaw: view.yaw, pitch: view.pitch, logZoom: 0 },
187
+ };
188
+ return {
189
+ keys,
190
+ stages,
191
+ end: time,
192
+ tail: flown.some((block) => block.id === "arena") ? -turn : 0,
193
+ rest,
194
+ input: { north, siteSpan: input.siteSpan, startSpan, citySpan, turn, anchorStart: [ax0, ay0], anchorEnd: [ax1, ay1], view },
195
+ };
196
+ }
197
+ /** The 3D view the arena settles in, as the sport's main camera: its yaw and pitch. */
198
+ export const mainView = (sport = "nhl") => {
199
+ const view = presetView("broadcast", sport) ?? LONG_PRESETS.broadcast;
200
+ return { yaw: view.yaw, pitch: view.pitch };
201
+ };
202
+ // ---- Time: how the clock of the fly-through moves, so that nothing starts or stops with a jump. ----
203
+ /** A glide of the clock from one moment of the path to another: zero to one with a zero slope at both ends, so that a skip starts and ends at rest. */
204
+ export const glide = (from, to, elapsed, seconds) => lerp(from, to, smooth(seconds <= 0 ? 1 : elapsed / seconds));
205
+ /** How long a skip over `distance` seconds of the path takes: quick, and never so long that a long skip is slow or so short that it is a cut. */
206
+ export const glideSeconds = (distance) => clamp(0.55 + 0.14 * Math.abs(distance), 0.6, 1.8);
207
+ /** The seconds the clock takes to go from still to the path's pace and back. */
208
+ export const RAMP = 0.45;
209
+ /** The speed the clock runs at after `dt` seconds: it goes to 1 while playing and to 0 when not, at one steady rate of change, reaching it in `RAMP` seconds and never at once. */
210
+ export function nextRate(rate, playing, dt) {
211
+ const target = playing ? 1 : 0;
212
+ return rate + clamp(target - rate, -dt / RAMP, dt / RAMP);
213
+ }
214
+ // ---- The map's frame: how the viewBox follows the camera. ----
215
+ /** The metres in a degree of latitude on the sphere the venues' geometry uses, and its radius. */
216
+ export const METRES_PER_DEGREE = (6371008.8 * Math.PI) / 180;
217
+ /**
218
+ * The map's frame at an arena, from the map's own projection: the canvas point of the arena and of a point a hundredth of a degree east of it and north of it give the metres a unit is, so that the
219
+ * map can be drawn true to the ground round the arena (the world's projection is not, and a map stretched to be square on the ground there is the same map, stretched). `project` answers `null` where the map has no
220
+ * place for a point, and then there is no frame.
221
+ */
222
+ export function mapFrame(project, lon, lat) {
223
+ const step = 0.01;
224
+ const at = project(lon, lat);
225
+ const east = project(lon + step, lat);
226
+ const north = project(lon, lat + step);
227
+ if (at === null || east === null || north === null)
228
+ return undefined;
229
+ const unitsX = Math.abs(east[0] - at[0]) / step;
230
+ const unitsY = Math.abs(north[1] - at[1]) / step;
231
+ if (!(unitsX > 0) || !(unitsY > 0))
232
+ return undefined;
233
+ return { at, metresX: (METRES_PER_DEGREE * Math.cos((lat * Math.PI) / 180)) / unitsX, metresY: METRES_PER_DEGREE / unitsY };
234
+ }
235
+ /**
236
+ * The viewBox of the map layer: a square of `metres` on the ground, centred on the arena, as canvas units (`metres` along x over the metres of a unit, and along y). Drawn into a square box with `preserveAspectRatio="none"`.
237
+ */
238
+ export function mapBox(frame, metres) {
239
+ const w = metres / frame.metresX;
240
+ const h = metres / frame.metresY;
241
+ return { x: frame.at[0] - w / 2, y: frame.at[1] - h / 2, w, h };
242
+ }
243
+ /** The side, in pixels, of the square the map is drawn in so that it covers a `width` by `height` container whichever way it is turned about the arena, which stands at `(x, y)` in it. */
244
+ export const coverSide = (width, height, x, y) => 2 * Math.hypot(Math.max(x, width - x), Math.max(y, height - y));
245
+ /** A distance a scale bar can show: 1, 2 or 5 times a power of ten metres, the longest that fits in `maxPixels` at `metresPerPixel`. */
246
+ export function niceLength(metresPerPixel, maxPixels) {
247
+ const wanted = metresPerPixel * maxPixels;
248
+ const power = 10 ** Math.floor(Math.log10(wanted));
249
+ const steps = [5, 2, 1].map((n) => n * power);
250
+ return steps.find((step) => step <= wanted) ?? power / 2;
251
+ }
252
+ /** A length in metres as a scale bar says it: `500 m`, `2 km`. */
253
+ export const lengthWords = (metres) => (metres >= 1000 ? `${metres / 1000} km` : `${metres} m`);
@@ -0,0 +1,2 @@
1
+ /** The fly-through's stylesheet. */
2
+ export declare const FLYTHROUGH_STYLE = ".kf{--kf-bg:#ffffff;--kf-ink:#10151b;--kf-muted:#434b54;--kf-panel:rgba(255,255,255,.9);--kf-line:#b9b3a3;--kf-accent:#1f5fa8;--kf-pin:#c0392b;--kf-grid:rgba(60,70,80,.2);--kf-sea:#cfe3ee;--kf-button:#ffffff;--kf-button-on:#10151b;--kf-button-on-ink:#ffffff;--kf-land:#e9e1c8;--kf-coast:#5d5a50;color:var(--kf-ink);font:16px/1.45 system-ui,-apple-system,\"Segoe UI\",\"Hiragino Sans\",\"Noto Sans JP\",sans-serif;color-scheme:light dark}\n@media (prefers-color-scheme:dark){.kf:not([data-theme=light]){--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3}}\n.kf[data-theme=dark]{--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3;color-scheme:dark}\n.kf[data-theme=light]{color-scheme:light}\n.kf *{box-sizing:border-box}\n.kf [hidden]{display:none!important}\n.kf-stage{position:relative;isolation:isolate;width:100%;aspect-ratio:16/10;min-height:260px;overflow:hidden;background:var(--kf-bg);border:1px solid var(--kf-line);border-radius:12px;outline-offset:2px;user-select:none;-webkit-user-select:none}\n.kf-stage:focus-visible{outline:3px solid var(--kf-accent)}\n@media (max-width:560px){.kf-stage{aspect-ratio:4/5}}\n.kf-layer{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n.kf-map{overflow:hidden}\n.kf-map-box{position:absolute;transform-origin:50% 50%;background:var(--kf-sea)}\n.kf-map-box svg{display:block;width:100%;height:100%}\n.kf-map-box .chizu{--cz-sea:var(--kf-sea);--cz-land:var(--kf-land);--cz-line:var(--kf-coast)}\n.kf-site{overflow:hidden}\n.kf-grid{fill:none;stroke:var(--kf-grid);stroke-width:1}\n.kf-rink{fill:var(--kf-bg);fill-opacity:.9;stroke:var(--kf-muted);stroke-width:.6;vector-effect:non-scaling-stroke}\n.kf-3d{overflow:hidden}\n.kf-3d .ky-turn{height:100%}\n.kf-3d .ky-turn-view{aspect-ratio:auto;height:100%;min-height:0;border-radius:0;cursor:default;touch-action:auto}\n.kf-3d .ky-turn-help,.kf-3d .ky-turn-live{display:none}\n.kf-pin{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none}\n.kf-pin::before{content:\"\";position:absolute;left:-7px;top:-7px;width:14px;height:14px;border-radius:50%;background:var(--kf-pin);border:2px solid var(--kf-bg);box-shadow:0 0 0 2px var(--kf-pin)}\n.kf-scale{position:absolute;left:12px;bottom:12px;display:flex;flex-direction:column;gap:2px;align-items:flex-start;font-size:.8rem;color:var(--kf-ink);background:var(--kf-panel);padding:4px 8px;border-radius:8px;pointer-events:none}\n.kf-scale-bar{display:block;height:6px;border:2px solid var(--kf-ink);border-top:0}\n.kf-caption{position:absolute;right:12px;top:12px;max-width:min(26rem,calc(100% - 24px));padding:8px 12px;border-radius:10px;background:var(--kf-panel);color:var(--kf-ink);pointer-events:none}\n.kf-title{display:block;font-weight:800}\n.kf-text{display:block;font-size:.92rem;color:var(--kf-muted)}\n.kf-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0 0}\n.kf-group{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}\n.kf button{font:inherit;font-size:1rem;font-weight:700;min-height:44px;min-width:44px;padding:0 14px;border-radius:12px;border:2px solid var(--kf-ink);background:var(--kf-button);color:var(--kf-ink);cursor:pointer}\n.kf button[aria-pressed=true],.kf button[aria-current=step]{background:var(--kf-button-on);color:var(--kf-button-on-ink)}\n.kf button:focus-visible{outline:3px solid var(--kf-accent);outline-offset:2px}\n.kf-help,.kf-credit,.kf-still{margin:8px 0 0;font-size:.85rem;color:var(--kf-muted)}\n.kf-live{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}";
@@ -0,0 +1,40 @@
1
+ // The fly-through's stylesheet, written into the mount (as the 3D view writes its own): the stage with its layers, the controls under it, light and dark. The colours are custom properties of
2
+ // `.kf`, so that a page can set them; `data-theme` on `.kf` forces a theme, and without it the device's setting decides.
3
+ const LIGHT = "--kf-bg:#ffffff;--kf-ink:#10151b;--kf-muted:#434b54;--kf-panel:rgba(255,255,255,.9);--kf-line:#b9b3a3;--kf-accent:#1f5fa8;--kf-pin:#c0392b;--kf-grid:rgba(60,70,80,.2);--kf-sea:#cfe3ee;--kf-button:#ffffff;--kf-button-on:#10151b;--kf-button-on-ink:#ffffff;--kf-land:#e9e1c8;--kf-coast:#5d5a50";
4
+ const DARK = "--kf-bg:#18212a;--kf-ink:#f6f3ea;--kf-muted:#bcc3cb;--kf-panel:rgba(24,33,42,.9);--kf-line:#475463;--kf-accent:#7fb2ee;--kf-pin:#ff7a6b;--kf-grid:rgba(200,210,220,.22);--kf-sea:#14232f;--kf-button:#18212a;--kf-button-on:#f6f3ea;--kf-button-on-ink:#0d1218;--kf-land:#2f3b44;--kf-coast:#8a97a3";
5
+ /** The fly-through's stylesheet. */
6
+ export const FLYTHROUGH_STYLE = `.kf{${LIGHT};color:var(--kf-ink);font:16px/1.45 system-ui,-apple-system,"Segoe UI","Hiragino Sans","Noto Sans JP",sans-serif;color-scheme:light dark}
7
+ @media (prefers-color-scheme:dark){.kf:not([data-theme=light]){${DARK}}}
8
+ .kf[data-theme=dark]{${DARK};color-scheme:dark}
9
+ .kf[data-theme=light]{color-scheme:light}
10
+ .kf *{box-sizing:border-box}
11
+ .kf [hidden]{display:none!important}
12
+ .kf-stage{position:relative;isolation:isolate;width:100%;aspect-ratio:16/10;min-height:260px;overflow:hidden;background:var(--kf-bg);border:1px solid var(--kf-line);border-radius:12px;outline-offset:2px;user-select:none;-webkit-user-select:none}
13
+ .kf-stage:focus-visible{outline:3px solid var(--kf-accent)}
14
+ @media (max-width:560px){.kf-stage{aspect-ratio:4/5}}
15
+ .kf-layer{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}
16
+ .kf-map{overflow:hidden}
17
+ .kf-map-box{position:absolute;transform-origin:50% 50%;background:var(--kf-sea)}
18
+ .kf-map-box svg{display:block;width:100%;height:100%}
19
+ .kf-map-box .chizu{--cz-sea:var(--kf-sea);--cz-land:var(--kf-land);--cz-line:var(--kf-coast)}
20
+ .kf-site{overflow:hidden}
21
+ .kf-grid{fill:none;stroke:var(--kf-grid);stroke-width:1}
22
+ .kf-rink{fill:var(--kf-bg);fill-opacity:.9;stroke:var(--kf-muted);stroke-width:.6;vector-effect:non-scaling-stroke}
23
+ .kf-3d{overflow:hidden}
24
+ .kf-3d .ky-turn{height:100%}
25
+ .kf-3d .ky-turn-view{aspect-ratio:auto;height:100%;min-height:0;border-radius:0;cursor:default;touch-action:auto}
26
+ .kf-3d .ky-turn-help,.kf-3d .ky-turn-live{display:none}
27
+ .kf-pin{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none}
28
+ .kf-pin::before{content:"";position:absolute;left:-7px;top:-7px;width:14px;height:14px;border-radius:50%;background:var(--kf-pin);border:2px solid var(--kf-bg);box-shadow:0 0 0 2px var(--kf-pin)}
29
+ .kf-scale{position:absolute;left:12px;bottom:12px;display:flex;flex-direction:column;gap:2px;align-items:flex-start;font-size:.8rem;color:var(--kf-ink);background:var(--kf-panel);padding:4px 8px;border-radius:8px;pointer-events:none}
30
+ .kf-scale-bar{display:block;height:6px;border:2px solid var(--kf-ink);border-top:0}
31
+ .kf-caption{position:absolute;right:12px;top:12px;max-width:min(26rem,calc(100% - 24px));padding:8px 12px;border-radius:10px;background:var(--kf-panel);color:var(--kf-ink);pointer-events:none}
32
+ .kf-title{display:block;font-weight:800}
33
+ .kf-text{display:block;font-size:.92rem;color:var(--kf-muted)}
34
+ .kf-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0 0}
35
+ .kf-group{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}
36
+ .kf button{font:inherit;font-size:1rem;font-weight:700;min-height:44px;min-width:44px;padding:0 14px;border-radius:12px;border:2px solid var(--kf-ink);background:var(--kf-button);color:var(--kf-ink);cursor:pointer}
37
+ .kf button[aria-pressed=true],.kf button[aria-current=step]{background:var(--kf-button-on);color:var(--kf-button-on-ink)}
38
+ .kf button:focus-visible{outline:3px solid var(--kf-accent);outline-offset:2px}
39
+ .kf-help,.kf-credit,.kf-still{margin:8px 0 0;font-size:.85rem;color:var(--kf-muted)}
40
+ .kf-live{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}`;
@@ -0,0 +1,33 @@
1
+ import type { StageId } from "./flythrough.path.ts";
2
+ import type { Language } from "./types.ts";
3
+ /** The words of one language. */
4
+ export interface FlythroughWords {
5
+ /** The name of the whole thing, for the region's label: `{arena}`. */
6
+ title: string;
7
+ /** What the region is, for a screen reader. */
8
+ region: string;
9
+ /** What a screen reader is told about the whole fly-through: `{arena}`, `{place}`. */
10
+ about: string;
11
+ /** The stages: their names and what is said of each (`{arena}`, `{place}`). */
12
+ stage: Record<StageId, {
13
+ name: string;
14
+ text: string;
15
+ }>;
16
+ /** Said when the stage changes: `{n}`, `{total}`, `{name}`, `{text}`. */
17
+ announce: string;
18
+ play: string;
19
+ pause: string;
20
+ back: string;
21
+ next: string;
22
+ stages: string;
23
+ /** The keys, under the view. */
24
+ keys: string;
25
+ /** The credit of the map and of the building's outline, drawn with the picture. */
26
+ creditMap: string;
27
+ creditPlain: string;
28
+ /** The sentence on a still stage, for a reader who prefers reduced motion. */
29
+ still: string;
30
+ }
31
+ export declare const FLYTHROUGH_WORDS: Record<Language, FlythroughWords>;
32
+ /** A sentence with its `{name}` slots filled in. */
33
+ export declare const say: (text: string, values: Record<string, string | number>) => string;
@@ -0,0 +1,44 @@
1
+ export const FLYTHROUGH_WORDS = {
2
+ en: {
3
+ title: "Fly-through: {arena}",
4
+ region: "fly-through",
5
+ about: "A fly-through from a map to {arena} in {place}: the map, the city and the arena in 3D. Play, pause, step back or forward and choose a stage with the buttons or the keys.",
6
+ stage: {
7
+ map: { name: "Map", text: "{arena} stands in {place}." },
8
+ city: { name: "City", text: "The building of {arena} from above, as OpenStreetMap maps it." },
9
+ arena: { name: "Arena", text: "{arena} in 3D, turning." },
10
+ },
11
+ announce: "Stage {n} of {total}: {name}. {text}",
12
+ play: "Play",
13
+ pause: "Pause",
14
+ back: "Back",
15
+ next: "Next",
16
+ stages: "Stages",
17
+ keys: "Space plays or pauses. Left and right arrows go back and forward a stage; the number keys choose a stage; Home starts again.",
18
+ creditMap: "Map: Natural Earth (public domain), drawn with chizu. Building outline: (c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright.",
19
+ creditPlain: "Building outline: (c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright.",
20
+ still: "Each stage is shown as a still picture, because this device is set to reduce motion.",
21
+ },
22
+ ja: {
23
+ title: "フライスルー:{arena}",
24
+ region: "フライスルー",
25
+ about: "地図から{place}の{arena}まで進むフライスルーです。地図、都市、3Dのアリーナの順に見られます。ボタンまたはキーで、再生・一時停止、前後の場面への移動、場面の選択ができます。",
26
+ stage: {
27
+ map: { name: "地図", text: "{arena}は{place}にあります。" },
28
+ city: { name: "都市", text: "{arena}の建物を真上から見たところです(OpenStreetMapの記録による)。" },
29
+ arena: { name: "アリーナ", text: "{arena}を3Dで回転させて表示しています。" },
30
+ },
31
+ announce: "場面{n}/{total}:{name}。{text}",
32
+ play: "再生",
33
+ pause: "一時停止",
34
+ back: "戻る",
35
+ next: "次へ",
36
+ stages: "場面",
37
+ keys: "スペースキーで再生・一時停止。左右の矢印キーで前後の場面へ、数字キーで場面を選び、Homeキーで最初に戻ります。",
38
+ creditMap: "地図:Natural Earth(パブリックドメイン)、chizuで描画。建物の輪郭:(c) OpenStreetMap contributors、ODbL 1.0、openstreetmap.org/copyright。",
39
+ creditPlain: "建物の輪郭:(c) OpenStreetMap contributors、ODbL 1.0、openstreetmap.org/copyright。",
40
+ still: "この端末は動きを減らす設定のため、各場面を静止画で表示しています。",
41
+ },
42
+ };
43
+ /** A sentence with its `{name}` slots filled in. */
44
+ export const say = (text, values) => text.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
package/dist/index.d.ts CHANGED
@@ -63,6 +63,8 @@ export { ERA_CURRENT, ERA_SOURCES, ERA_SPORTS, eraChanges, eraRange, eraStates,
63
63
  export type { EraChange, EraRules, EraRulesOf, EraSource, EraSpan, EraSport, FootballRules, MlbRules, NbaRules, NhlRules } from "./eras.ts";
64
64
  export { ARENA_IDS, ARENAS, arenaById, arenaByName, arenaByWikidata, arenaCourtIn, arenaNameIn, arenaOfficialName, arenaRinkIn, arenaSourceUrl, arenasByName } from "./arenas.ts";
65
65
  export type { Arena, ArenaCapacity, ArenaData, ArenaName, ArenaSize, ArenaSource } from "./arenas.ts";
66
+ export { VENUE_ATTRIBUTION, VENUE_IDS, VENUES, arenaOfVenue, bowlInside, planVenue, venueBowl, venueById, venueByWikidata, venueOrientation, venueRink, venuesIn } from "./venues.ts";
67
+ export type { RealVenue, RealVenueData, VenueBasis, VenueDivision, VenueOrientation, VenueOsm, VenuePlan, VenueRoof } from "./venues.ts";
66
68
  export { GROUND_IDS, GROUNDS, groundById, groundByName, groundByWikidata, groundNameIn, groundOfficialName, groundSourceUrl, groundsByName, groundsHosting } from "./grounds.ts";
67
69
  export type { Ground, GroundCompetition, GroundData, GroundName, GroundSource, GroundTournament } from "./grounds.ts";
68
70
  export { AFL_GROUNDS, FAMILY_OF, LEAGUES, LEAGUE_SOURCES, VARIANTS, chooseLeague, hasHistory, leagueAt, leagueById, leagueChanges, leagueFigures, leagueIds, leagueSeason, leagueSize } from "./leagues.ts";
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ export { PARKS, PARK_IDS, fenceFor, parkById, parkByName, parkByWikidata, parkNa
39
39
  export { officialName, plainName } from "./names.js";
40
40
  export { ERA_CURRENT, ERA_SOURCES, ERA_SPORTS, eraChanges, eraRange, eraStates, footballIn, rulesFor, seasonLabel } from "./eras.js";
41
41
  export { ARENA_IDS, ARENAS, arenaById, arenaByName, arenaByWikidata, arenaCourtIn, arenaNameIn, arenaOfficialName, arenaRinkIn, arenaSourceUrl, arenasByName } from "./arenas.js";
42
+ export { VENUE_ATTRIBUTION, VENUE_IDS, VENUES, arenaOfVenue, bowlInside, planVenue, venueBowl, venueById, venueByWikidata, venueOrientation, venueRink, venuesIn } from "./venues.js";
42
43
  export { GROUND_IDS, GROUNDS, groundById, groundByName, groundByWikidata, groundNameIn, groundOfficialName, groundSourceUrl, groundsByName, groundsHosting } from "./grounds.js";
43
44
  export { AFL_GROUNDS, FAMILY_OF, LEAGUES, LEAGUE_SOURCES, VARIANTS, chooseLeague, hasHistory, leagueAt, leagueById, leagueChanges, leagueFigures, leagueIds, leagueSeason, leagueSize } from "./leagues.js";
44
45
  export { SURFACE_UNITS, UNITS, convertLength, isUnit } from "./units.js";
@@ -0,0 +1,2 @@
1
+ export { VENUE_ATTRIBUTION, STADIUMS, STADIUM_IDS, groundOfStadium, planStadium, stadiumBowl, stadiumBowlInside, stadiumById, stadiumByWikidata, stadiumConfigOf, stadiumOrientation, stadiumSeats } from "./stadiums.ts";
2
+ export type { RealStadium, RealStadiumData, StadiumBasis, StadiumConfigData, StadiumConfigId, StadiumOrientation, StadiumOsm, StadiumPlan, StadiumRoof } from "./stadiums.ts";
@@ -0,0 +1,2 @@
1
+ // The real stadiums: the outline of each stadium's building and the way its field lies (OpenStreetMap, ODbL 1.0) and a plausible bowl from the seats of each configuration, the bowls and the roof the sources give (Wikidata, Wikipedia).
2
+ export { VENUE_ATTRIBUTION, STADIUMS, STADIUM_IDS, groundOfStadium, planStadium, stadiumBowl, stadiumBowlInside, stadiumById, stadiumByWikidata, stadiumConfigOf, stadiumOrientation, stadiumSeats } from "./stadiums.js";