@johnmorrisdotca/kyogijo-stadiums 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/NOTICE.md +10 -0
  3. package/README.md +50 -32
  4. package/dist/crowd.d.ts +7 -2
  5. package/dist/crowd.js +1 -1
  6. package/dist/district-entry.d.ts +5 -0
  7. package/dist/district-entry.js +4 -0
  8. package/dist/district.d.ts +167 -0
  9. package/dist/district.js +291 -0
  10. package/dist/district.turn.d.ts +27 -0
  11. package/dist/district.turn.js +157 -0
  12. package/dist/draw.js +10 -1
  13. package/dist/flythrough.dom.js +15 -5
  14. package/dist/flythrough.markup.d.ts +5 -0
  15. package/dist/flythrough.markup.js +1 -1
  16. package/dist/flythrough.path.d.ts +21 -8
  17. package/dist/flythrough.path.js +36 -11
  18. package/dist/flythrough.style.d.ts +1 -1
  19. package/dist/flythrough.style.js +2 -0
  20. package/dist/flythrough.words.d.ts +2 -2
  21. package/dist/flythrough.words.js +6 -2
  22. package/dist/stadiums-entry.d.ts +2 -0
  23. package/dist/stadiums-entry.js +2 -0
  24. package/dist/stadiums.d.ts +158 -0
  25. package/dist/stadiums.data.d.ts +2 -0
  26. package/dist/stadiums.data.js +11 -0
  27. package/dist/stadiums.js +210 -0
  28. package/dist/stadiums.osm.data.d.ts +2 -0
  29. package/dist/stadiums.osm.data.js +58 -0
  30. package/dist/strings.d.ts +11 -0
  31. package/dist/strings.js +2 -2
  32. package/dist/turn.kit.d.ts +5 -0
  33. package/dist/turn.markup.d.ts +7 -0
  34. package/dist/turn.markup.js +38 -2
  35. package/dist/turn.model.d.ts +4 -0
  36. package/dist/turn.style.js +5 -0
  37. package/dist/types.d.ts +4 -1
  38. package/dist/venue.d.ts +2 -0
  39. package/dist/venue.js +1 -1
  40. package/dist/venue.real.d.ts +6 -1
  41. package/dist/venue.real.js +56 -9
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +13 -1
@@ -0,0 +1,291 @@
1
+ // A district: several real venues of one city placed on one plan at their real relative positions and orientations, read from the same OpenStreetMap outlines each venue is drawn from (`venues.ts`, `stadiums.ts`).
2
+ // It is generic: a district is a list of venue keys, whichever kinds they are (an NHL arena, a football or soccer stadium, a ballpark later), and the plan fits itself to them. Two modes say how far apart the venues are:
3
+ // `neighbours` fits the plan tight round buildings that stand within a few hundred metres of each other, with no more than a margin between them; `city` keeps the real distances between venues that are kilometres
4
+ // apart, with a grid, a scale and a name beside each. Nothing is drawn but what the venues are drawn from: no street, no park and no mark of a team, a league or a tournament; the credit
5
+ // `VENUE_ATTRIBUTION` goes with it. Pure: options in, a plan, and markup, out; `district.turn.ts` raises it in 3D.
6
+ import { arenaById, arenaNameIn } from "./arenas.js";
7
+ import { crowdStands, drawKyogijo } from "./draw.js";
8
+ import { FEET_PER_METRE } from "./geometry.js";
9
+ import { KYOGIJO_STRINGS } from "./strings.js";
10
+ import { esc, hash } from "./format.js";
11
+ import { groundNameIn } from "./grounds.js";
12
+ import { STADIUM_IDS, groundOfStadium, stadiumById, stadiumSeats } from "./stadiums.js";
13
+ import { VENUE_ATTRIBUTION, VENUE_IDS, venueById } from "./venues.js";
14
+ import { hullOf, metresFrom, middleOf, smallestBox } from "./venues.geo.js";
15
+ export const DISTRICT_MODES = ["neighbours", "city"];
16
+ const venueMember = (venue, language) => {
17
+ const arena = arenaById(venue.id);
18
+ if (arena === undefined)
19
+ throw new Error(`${venue.id} is not an arena`);
20
+ return {
21
+ key: venue.id,
22
+ id: venue.id,
23
+ kind: "arena",
24
+ name: { en: arenaNameIn(arena, undefined, "en"), ja: arenaNameIn(arena, undefined, "ja") },
25
+ ring: venue.osm.ring,
26
+ centre: venue.osm.axis.centre,
27
+ heading: venue.osm.axis.heading,
28
+ basis: venue.osm.axis.basis,
29
+ seats: venue.seats.hockey,
30
+ options: { sport: "nhl", arena: venue.id, crowd: { real: true }, language },
31
+ };
32
+ };
33
+ const stadiumMember = (stadium, config, language) => {
34
+ const ground = groundOfStadium(stadium);
35
+ return {
36
+ key: `${stadium.id}:${config}`,
37
+ id: stadium.id,
38
+ kind: "stadium",
39
+ config,
40
+ name: { en: groundNameIn(ground, undefined, "en"), ja: groundNameIn(ground, undefined, "ja") },
41
+ ring: stadium.osm.ring,
42
+ centre: stadium.osm.axis.centre,
43
+ heading: stadium.osm.axis.heading,
44
+ basis: stadium.osm.axis.basis,
45
+ seats: stadiumSeats(stadium, config),
46
+ options: { sport: config, stadium: stadium.id, crowd: { real: true }, language },
47
+ };
48
+ };
49
+ /** Every key a district can be asked for: the arenas of `VENUES` and the stadiums of `STADIUMS`. */
50
+ export const DISTRICT_IDS = Object.freeze([...VENUE_IDS, ...STADIUM_IDS]);
51
+ /** Whether a key is a venue a district can place. */
52
+ export const isDistrictId = (id) => DISTRICT_IDS.includes(id);
53
+ /**
54
+ * A venue of a district, from its key: `rogers-arena` (an arena), `bc-place` or `bc-place:soccer` (a stadium in its first configuration or the one named). Throws for a key no venue has, and says which keys there are.
55
+ * @example
56
+ * ```ts
57
+ * import { districtMember } from "@johnmorrisdotca/kyogijo-stadiums/district";
58
+ * const member = districtMember("bc-place:soccer");
59
+ * console.log(member.key, member.kind, member.seats);
60
+ * // bc-place:soccer stadium 52497
61
+ * ```
62
+ */
63
+ export function districtMember(input, language = "en") {
64
+ const [id, named] = typeof input === "string" ? input.split(":") : [input.id, input.config];
65
+ const venue = venueById(id);
66
+ if (venue !== undefined) {
67
+ if (named !== undefined && named !== "nhl")
68
+ throw new Error(`${id} is an arena with one configuration, not ${named}`);
69
+ return venueMember(venue, language);
70
+ }
71
+ const stadium = stadiumById(id);
72
+ if (stadium !== undefined) {
73
+ const config = (named ?? Object.keys(stadium.configs)[0]);
74
+ if (stadium.configs[config] === undefined)
75
+ throw new Error(`${id} has no configuration ${String(named)}: ${Object.keys(stadium.configs).join(", ")}`);
76
+ return stadiumMember(stadium, config, language);
77
+ }
78
+ throw new Error(`no venue ${String(id)}: the venues are ${DISTRICT_IDS.join(", ")}`);
79
+ }
80
+ // ---- The plan. ----
81
+ const RAD = Math.PI / 180;
82
+ /** Turn a point of the map (east, down the page) by `degrees` clockwise on the page. */
83
+ const turnBy = ([x, y], degrees) => [x * Math.cos(degrees * RAD) - y * Math.sin(degrees * RAD), x * Math.sin(degrees * RAD) + y * Math.cos(degrees * RAD)];
84
+ /** The margin round the buildings, as a share of the longer side of the district: tight for neighbours, wide for a city's grid and names. */
85
+ export const DISTRICT_MARGIN = { neighbours: 0.09, city: 0.12 };
86
+ const EARTH = 6371008.8;
87
+ const haversine = (a, b) => {
88
+ const [p, q] = [a[1] * RAD, b[1] * RAD];
89
+ const h = Math.sin((q - p) / 2) ** 2 + Math.cos(p) * Math.cos(q) * Math.sin(((b[0] - a[0]) * RAD) / 2) ** 2;
90
+ return 2 * EARTH * Math.asin(Math.sqrt(h));
91
+ };
92
+ /**
93
+ * The plan of a district: each venue's surface placed where it is on the ground (the distance and the direction between middles come from the coordinates of OpenStreetMap's outlines, to a metre or so) and turned so
94
+ * that its long side points where it does in the world, and the frame that fits them. A venue whose orientation is not known (an arena the building gives no axis for) is placed with its rink east-west, as it is drawn alone.
95
+ * @example
96
+ * ```ts
97
+ * import { planDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
98
+ * const plan = planDistrict({ venues: ["bc-place", "rogers-arena"] });
99
+ * console.log(plan.mode, plan.members.length, Math.round(plan.between[0].metres));
100
+ * // neighbours 2 380
101
+ * ```
102
+ */
103
+ export function planDistrict(options) {
104
+ if (options.venues.length === 0)
105
+ throw new Error("a district needs a venue");
106
+ const language = options.language === "ja" ? "ja" : "en";
107
+ const mode = options.mode === "city" ? "city" : "neighbours";
108
+ const members = options.venues.map((v) => districtMember(v, language));
109
+ const keys = new Set();
110
+ for (const m of members) {
111
+ if (keys.has(m.key))
112
+ throw new Error(`${m.key} is in the district twice`);
113
+ keys.add(m.key);
114
+ }
115
+ const origin = middleOf(members.map((m) => m.centre));
116
+ const ground = (ring) => metresFrom(origin, ring).map(([east, north]) => [east * FEET_PER_METRE, -north * FEET_PER_METRE]);
117
+ const raw = members.map((m) => ({ at: ground([m.centre])[0], footprint: ground(m.ring) }));
118
+ // The map's turn: as asked, or the one that lays the district's long way across the page (the smallest box round every building).
119
+ const all = raw.flatMap((r) => r.footprint);
120
+ let turn = 0;
121
+ if (typeof options.turn === "number" && Number.isFinite(options.turn))
122
+ turn = ((options.turn % 360) + 360) % 360;
123
+ else if (options.turn === "auto" && members.length > 1) {
124
+ // `smallestBox` reads its points as east and north; these are east and down the page, so its heading is 90 degrees less the long side's angle on the page, and turning the map by (heading - 90) lays that side along x.
125
+ const box = smallestBox(all);
126
+ turn = (((box.heading - 90) % 360) + 360) % 360;
127
+ }
128
+ const placed = members.map((member, i) => {
129
+ const footprint = raw[i].footprint.map((p) => turnBy(p, turn));
130
+ return { member, at: turnBy(raw[i].at, turn), angle: member.heading - 90 + turn, footprint, hull: hullOf(footprint) };
131
+ });
132
+ const xs = placed.flatMap((p) => p.footprint.map((q) => q[0]));
133
+ const ys = placed.flatMap((p) => p.footprint.map((q) => q[1]));
134
+ const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
135
+ const margin = Math.max(x1 - x0, y1 - y0) * DISTRICT_MARGIN[mode];
136
+ const view = { x: x0 - margin, y: y0 - margin, w: x1 - x0 + 2 * margin, h: y1 - y0 + 2 * margin };
137
+ const between = [];
138
+ members.forEach((a, i) => members.slice(i + 1).forEach((b) => between.push({ a: a.key, b: b.key, metres: Math.round(haversine(a.centre, b.centre) * 10) / 10 })));
139
+ return { options, mode, turn, north: turn, members: placed, view, between };
140
+ }
141
+ // ---- The drawing. ----
142
+ /** The colours and the type of a district's drawing, light and dark. Defaults sit in `:where()`, so a page's own rule wins. */
143
+ export const KYOGIJO_DISTRICT_STYLE = `
144
+ :where(.ky-district){--ky-dist-ground:#e1e4da;--ky-dist-grid:rgba(40,46,42,.16);--ky-dist-ink:#1f2320;--ky-dist-halo:rgba(255,255,255,.9)}
145
+ @media (prefers-color-scheme:dark){:where(html:not([data-theme=light]) .ky-district:not([data-theme=light])){--ky-dist-ground:#1b2025;--ky-dist-grid:rgba(220,226,220,.16);--ky-dist-ink:#e8ece8;--ky-dist-halo:rgba(14,18,16,.85)}}
146
+ :where(html[data-theme=dark] .ky-district,.ky-district[data-theme=dark]){--ky-dist-ground:#1b2025;--ky-dist-grid:rgba(220,226,220,.16);--ky-dist-ink:#e8ece8;--ky-dist-halo:rgba(14,18,16,.85)}
147
+ .ky-district .ky-dist-ground{fill:var(--ky-dist-ground)}
148
+ .ky-district .ky-dist-grid{fill:none;stroke:var(--ky-dist-grid);stroke-width:1}
149
+ .ky-district .ky-dist-ink{fill:none;stroke:var(--ky-dist-ink);stroke-linecap:round;stroke-linejoin:round}
150
+ .ky-district .ky-dist-text{fill:var(--ky-dist-ink);font-family:var(--ky-font,system-ui,-apple-system,"Segoe UI","Hiragino Sans","Noto Sans JP",sans-serif);font-weight:600;paint-order:stroke;stroke:var(--ky-dist-halo);stroke-linejoin:round}`;
151
+ const ROOT = /^<svg([^>]*)>/;
152
+ const VIEWBOX = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/;
153
+ /** The north arrow of a venue's drawing and the apron under it: a district has one of each of its own. */
154
+ const withoutOwn = (svg) => svg.replace(/<rect class="ky-apron"[^>]*\/>/, "").replace(/<path class="ky-cr-north"[^>]*\/>/, "").replace(/<path class="ky-cr-north-n"[^>]*\/>/, "");
155
+ /**
156
+ * A venue's drawing as a part of the district's: its root made a nested `svg` that keeps the class, the sport and the colours the drawing's stylesheet reads, its box laid in its own frame (so that the group's turn
157
+ * and shift put it in place), and its title, description, apron, arrow and stylesheet taken out (the stylesheet is given back in `styles`).
158
+ */
159
+ export function nestedDrawing(svg) {
160
+ const root = ROOT.exec(svg);
161
+ const found = root === null ? null : VIEWBOX.exec(root[1]);
162
+ if (root === null || found === null)
163
+ throw new Error("a venue's drawing is an SVG with a viewBox");
164
+ const [x, y, w, h] = found.slice(1).map(Number);
165
+ const keep = [...root[1].matchAll(/\s(class|data-[\w-]+|style)="([^"]*)"/g)].map((m) => ` ${m[1]}="${m[2]}"`).join("");
166
+ // The drawing's stylesheet is taken out and given back apart: the district writes each distinct one once (venues of one family share the base style, which is most of it).
167
+ const styles = [];
168
+ const body = withoutOwn(svg.slice(root[0].length))
169
+ .replace(/<style>[\s\S]*?<\/style>/g, (block) => {
170
+ styles.push(block);
171
+ return "";
172
+ })
173
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>|<desc\b[^>]*>[\s\S]*?<\/desc>/g, "")
174
+ .replace(/<\/svg>\s*$/, "");
175
+ return { markup: `<svg${keep} x="${x}" y="${y}" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}" aria-hidden="true" focusable="false">${body}</svg>`, box: { x, y, w, h }, styles };
176
+ }
177
+ const num = (value) => String(Math.round(value * 100) / 100);
178
+ /** The middle of a venue's surface in its own drawing's frame (the box the stands are laid round), which its drawing is turned about. */
179
+ export function surfaceCentre(options) {
180
+ const stands = crowdStands(options);
181
+ if (stands === undefined)
182
+ throw new Error("a district's venue has a crowd");
183
+ return [stands.box.x + stands.box.w / 2, stands.box.y + stands.box.h / 2];
184
+ }
185
+ /** A length in metres worth drawing as a scale: the largest of 50, 100, 200, 500, 1000, 2000, 5000 that is not more than a fifth of the plan's width. */
186
+ export function scaleOf(view) {
187
+ const fifth = (view.w / FEET_PER_METRE) / 5;
188
+ return [5000, 2000, 1000, 500, 200, 100, 50].find((m) => m <= fifth) ?? 50;
189
+ }
190
+ /** The distance a scale says, as it is read: metres under a kilometre, else kilometres. */
191
+ export const metresText = (metres) => (metres >= 1000 ? `${metres / 1000} km` : `${metres} m`);
192
+ /** The id of a district's drawing: the option's, or a hash of the options. */
193
+ export const districtId = (options) => (typeof options.id === "string" && options.id !== "" ? options.id : `ky-district-${hash(JSON.stringify([options.venues, options.mode, options.turn, options.language, options.theme, options.stands]))}`);
194
+ /** The title and the description of a district, in its language: what is drawn, where each venue stands from the first, how far apart they are, and the credit. */
195
+ export function describeDistrict(options, plan = planDistrict(options)) {
196
+ const language = options.language === "ja" ? "ja" : "en";
197
+ const names = plan.members.map((p) => p.member.name[language]);
198
+ const words = KYOGIJO_STRINGS[language].said;
199
+ const list = language === "ja" ? (names.length > 1 ? `${names.slice(0, -1).join("、")}と${names[names.length - 1]}` : names[0]) : names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0];
200
+ const title = language === "ja" ? `${list}の位置関係` : `${list}: where they stand`;
201
+ const gap = language === "ja" ? "" : " ";
202
+ const first = plan.between[0];
203
+ const nameOf = (key) => plan.members.find((p) => p.member.key === key).member.name[language];
204
+ const apart = first === undefined ? "" : language === "ja" ? `${gap}${nameOf(first.a)}と${nameOf(first.b)}の中心は約${Math.round(first.metres)}m離れています。` : `${gap}The middles of ${nameOf(first.a)} and ${nameOf(first.b)} are about ${Math.round(first.metres)} metres apart.`;
205
+ const text = fillWords(words.district, { count: String(plan.members.length), mode: words[plan.mode === "city" ? "districtCity" : "districtNeighbours"] });
206
+ return { title, description: `${text}${apart}${gap}${words.districtCredit}${gap}${VENUE_ATTRIBUTION}${language === "ja" ? "。" : "."}` };
207
+ }
208
+ const fillWords = (text, values) => text.replace(/\{(\w+)\}/g, (_, key) => values[key] ?? "");
209
+ /** How a venue is drawn for a district: its own options with the district's language, theme and ids, in 2D with its stands or as the floor of the 3D view (its building only). */
210
+ export function drawMember(member, options, index, id, how = {}) {
211
+ const stands = options.stands !== false && how.floor !== true;
212
+ return drawKyogijo({
213
+ ...member.options,
214
+ crowd: { real: true, ...(stands ? {} : { stands: false }) },
215
+ language: options.language === "ja" ? "ja" : "en",
216
+ ...(options.theme === undefined ? {} : { theme: options.theme }),
217
+ id: `${id}-m${index}`,
218
+ });
219
+ }
220
+ /**
221
+ * The plan of a district as one SVG: the ground, a grid and a scale, a north arrow, and each venue's own drawing (its stands, or its building only for `stands: false`) put where it stands and turned as it lies, with a name beside each
222
+ * where `labels` says. `drawing` is how each venue is drawn; the 3D view gives it the building-only floor.
223
+ * @example
224
+ * ```ts
225
+ * import { drawDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
226
+ * const svg = drawDistrict({ venues: ["bc-place", "rogers-arena"] });
227
+ * console.log(svg.includes("ky-district"), svg.includes("ky-cr-shell"));
228
+ * // true true
229
+ * ```
230
+ */
231
+ export function drawDistrict(options, plan = planDistrict(options), drawing) {
232
+ const language = options.language === "ja" ? "ja" : "en";
233
+ const id = districtId(options);
234
+ const { view } = plan;
235
+ const labels = options.labels ?? plan.mode === "city";
236
+ const { title, description } = describeDistrict(options, plan);
237
+ const diag = Math.max(view.w, view.h);
238
+ const parts = [];
239
+ // A grid every `step` feet of the ground: the kilometre in a city, the hundred metres among neighbours.
240
+ const step = (plan.mode === "city" ? 1000 : 100) * FEET_PER_METRE;
241
+ const grid = [];
242
+ const reach = Math.hypot(view.w, view.h);
243
+ // The grid lines run along the map's east and north, turned with it: lines of constant east and of constant north through the district's origin (the plan's centre of the venues), drawn long enough to cross the frame.
244
+ const origin = [0, 0];
245
+ for (let k = -Math.ceil(reach / step); k <= Math.ceil(reach / step); k += 1) {
246
+ const [ax, ay] = turnBy([k * step, -reach], plan.turn);
247
+ const [bx, by] = turnBy([k * step, reach], plan.turn);
248
+ const [cx, cy] = turnBy([-reach, k * step], plan.turn);
249
+ const [dx, dy] = turnBy([reach, k * step], plan.turn);
250
+ grid.push(`M${num(origin[0] + ax)} ${num(origin[1] + ay)}L${num(origin[0] + bx)} ${num(origin[1] + by)}M${num(origin[0] + cx)} ${num(origin[1] + cy)}L${num(origin[0] + dx)} ${num(origin[1] + dy)}`);
251
+ }
252
+ parts.push(`<rect class="ky-dist-ground" x="${num(view.x)}" y="${num(view.y)}" width="${num(view.w)}" height="${num(view.h)}"/>`);
253
+ parts.push(`<clipPath id="${id}-clip"><rect x="${num(view.x)}" y="${num(view.y)}" width="${num(view.w)}" height="${num(view.h)}"/></clipPath>`);
254
+ parts.push(`<path class="ky-dist-grid" clip-path="url(#${id}-clip)" d="${grid.join("")}"/>`);
255
+ const styles = [];
256
+ plan.members.forEach((placed, i) => {
257
+ const svg = drawing === undefined ? drawMember(placed.member, options, i, id) : drawing(placed.member, i);
258
+ const { markup, styles: own } = nestedDrawing(svg);
259
+ for (const block of own)
260
+ if (!styles.includes(block))
261
+ styles.push(block);
262
+ const centre = surfaceCentre(placed.member.options);
263
+ parts.push(`<g class="ky-dist-venue" data-venue="${esc(placed.member.key)}" transform="translate(${num(placed.at[0])} ${num(placed.at[1])}) rotate(${num(placed.angle)}) translate(${num(-centre[0])} ${num(-centre[1])})">${markup}</g>`);
264
+ });
265
+ if (labels) {
266
+ const size = diag * (plan.mode === "city" ? 0.018 : 0.026);
267
+ plan.members.forEach((placed) => {
268
+ const bottom = Math.max(...placed.footprint.map((p) => p[1]));
269
+ const x = placed.footprint.reduce((s, p) => s + p[0], 0) / placed.footprint.length;
270
+ parts.push(`<text class="ky-dist-text" x="${num(x)}" y="${num(bottom + size * 1.3)}" text-anchor="middle" font-size="${num(size)}" stroke-width="${num(size * 0.22)}">${esc(placed.member.name[language])}</text>`);
271
+ });
272
+ }
273
+ // The north arrow, in the top right corner of the frame, and the scale bar at the bottom left.
274
+ const arrow = diag * 0.05;
275
+ const [ax, ay] = [view.x + view.w - arrow * 1.2, view.y + arrow * 1.6];
276
+ const tip = turnBy([0, -arrow], plan.turn);
277
+ const tail = turnBy([0, arrow * 0.6], plan.turn);
278
+ const left = turnBy([-arrow * 0.28, 0], plan.turn);
279
+ const right = turnBy([arrow * 0.28, 0], plan.turn);
280
+ const nPos = turnBy([0, -arrow * 1.35], plan.turn);
281
+ parts.push(`<g class="ky-dist-north" aria-hidden="true"><path class="ky-dist-ink" stroke-width="${num(arrow * 0.06)}" d="M${num(ax + tail[0])} ${num(ay + tail[1])}L${num(ax + tip[0])} ${num(ay + tip[1])}M${num(ax + left[0])} ${num(ay + left[1])}L${num(ax + tip[0])} ${num(ay + tip[1])}L${num(ax + right[0])} ${num(ay + right[1])}"/><text class="ky-dist-text" x="${num(ax + nPos[0])}" y="${num(ay + nPos[1] + arrow * 0.18)}" text-anchor="middle" font-size="${num(arrow * 0.5)}" stroke-width="${num(arrow * 0.1)}">N</text></g>`);
282
+ const metres = scaleOf(view);
283
+ const bar = metres * FEET_PER_METRE;
284
+ const [sx, sy] = [view.x + diag * 0.03, view.y + view.h - diag * 0.04];
285
+ parts.push(`<g class="ky-dist-scale" aria-hidden="true"><path class="ky-dist-ink" stroke-width="${num(diag * 0.004)}" d="M${num(sx)} ${num(sy - diag * 0.01)}V${num(sy)}H${num(sx + bar)}V${num(sy - diag * 0.01)}"/><text class="ky-dist-text" x="${num(sx + bar / 2)}" y="${num(sy - diag * 0.018)}" text-anchor="middle" font-size="${num(diag * 0.022)}" stroke-width="${num(diag * 0.005)}">${metresText(metres)}</text></g>`);
286
+ const sheet = options.style === "none" ? "" : `<style>${KYOGIJO_DISTRICT_STYLE}</style>${styles.join("")}`;
287
+ const theme = options.theme === "light" || options.theme === "dark" ? ` data-theme="${options.theme}"` : "";
288
+ return `<svg xmlns="http://www.w3.org/2000/svg" class="kyogijo ky-district" viewBox="${num(view.x)} ${num(view.y)} ${num(view.w)} ${num(view.h)}" role="img" aria-labelledby="${id}-t ${id}-d" data-sport="district" data-mode="${plan.mode}"${theme} lang="${language}"><title id="${id}-t">${esc(title)}</title><desc id="${id}-d">${esc(description)}</desc>${sheet}${parts.join("")}</svg>`;
289
+ }
290
+ /** The venues a district was asked for that the package has no key for: none when it can be drawn. */
291
+ export const unknownVenues = (venues) => venues.map((v) => (typeof v === "string" ? v.split(":")[0] : v.id)).filter((id) => !isDistrictId(id));
@@ -0,0 +1,27 @@
1
+ import { type DistrictOptions, type DistrictPlan } from "./district.ts";
2
+ import { type PresetTable } from "./turn.camera.ts";
3
+ import { type KyogijoTurn } from "./turn.dom.ts";
4
+ import { type TurnModel, type TurnSettings } from "./turn.model.ts";
5
+ /** What can be asked of a district's 3D view beyond a surface's: `roof` and `roofOpen` for every venue that has one (as `venue: { roof, roofOpen }` of a single venue's view). */
6
+ export type DistrictTurnSettings = TurnSettings;
7
+ /** The four viewpoints of a district, turned with the map: the main camera looks from the south over the district (north at the top of the page), the end-on view from the west and the low corner from the south-west. */
8
+ export declare function districtPresets(plan: Pick<DistrictPlan, "turn">): PresetTable;
9
+ /**
10
+ * A district as a 3D model: the floor is the district's flat plan with each venue's building only, and on it stands each venue as it stands on its own (turned about the middle of its surface and moved to its place in the
11
+ * district), with a name over each where the district has labels. A `TurnModel` like any other: `turnMarkupOf` and `mountDistrictTurn` make a view of it.
12
+ */
13
+ export declare function buildDistrictTurn(options: DistrictOptions, settings?: DistrictTurnSettings): TurnModel;
14
+ /**
15
+ * Mount a district as a 3D view in a container: the same view as `mountKyogijoTurn`'s, with the controls, the pointer, the keys and the auto-turn of every 3D view, over the district's plan with each venue standing on it.
16
+ * @example
17
+ * ```ts no-run
18
+ * import { mountDistrictTurn } from "@johnmorrisdotca/kyogijo-stadiums/district";
19
+ * const view = mountDistrictTurn(document.querySelector("#city")!, { venues: ["bc-place", "rogers-arena"] }, { preset: "broadcast" });
20
+ * view.set({ yaw: 40 });
21
+ * ```
22
+ */
23
+ export declare function mountDistrictTurn(host: HTMLElement, options: DistrictOptions, settings?: DistrictTurnSettings): KyogijoTurn<DistrictOptions>;
24
+ /** A district as the markup of a 3D view, still until a page's script makes it turn: pure, so a server can make it. */
25
+ export declare function districtTurnMarkup(options: DistrictOptions, settings?: DistrictTurnSettings): string;
26
+ /** The distance in feet that a number of metres is: what a test or a caller needs to compare the plan with the ground. */
27
+ export declare const feetOf: (metres: number) => number;
@@ -0,0 +1,157 @@
1
+ // A district in 3D: the 3D view of `turn.ts` with several real venues standing on one ground at their real relative positions and orientations. Nothing here is drawn a second way. Each venue is built as it is on its own
2
+ // (`buildTurn`: the stands, the walls and the roof, the goals and posts of its field), its planes are turned about the middle of its surface and moved to where the plan puts it, and the floor is the district's flat
3
+ // plan drawn with each venue's building only. The model it makes is a `TurnModel`, so the camera, the controls, the pointer, the auto-turn and the steady fit of every other 3D view are this one's; its
4
+ // buildings are stacked building by building (`Plane.group`), the far one first, so that a roof never shows through a nearer building.
5
+ import { describeDistrict, districtId, drawDistrict, drawMember, planDistrict, surfaceCentre } from "./district.js";
6
+ import { n, plane } from "./turn.kit.js";
7
+ import { TURN_PRESETS, wrap } from "./turn.camera.js";
8
+ import { mountTurn } from "./turn.dom.js";
9
+ import { turnMarkupOf } from "./turn.markup.js";
10
+ import { buildTurn, openingView, sceneBox, WORLD_PX } from "./turn.model.js";
11
+ import { planesOf } from "./uprights.js";
12
+ import { FEET_PER_METRE } from "./geometry.js";
13
+ import { hullOf } from "./venues.geo.js";
14
+ /** The four viewpoints of a district, turned with the map: the main camera looks from the south over the district (north at the top of the page), the end-on view from the west and the low corner from the south-west. */
15
+ export function districtPresets(plan) {
16
+ const turn = plan.turn;
17
+ return {
18
+ overhead: { ...TURN_PRESETS.overhead, yaw: wrap(turn) },
19
+ broadcast: { yaw: wrap(turn - 20), pitch: 58, zoom: 1 },
20
+ end: { yaw: wrap(turn - 90), pitch: 64, zoom: 1 },
21
+ corner: { yaw: wrap(turn - 38), pitch: 66, zoom: 1 },
22
+ };
23
+ }
24
+ /** Turn a point about the origin by `degrees` clockwise on the page. */
25
+ const rot = ([x, y], degrees) => {
26
+ const a = (degrees * Math.PI) / 180;
27
+ return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
28
+ };
29
+ /** A plane of a venue's own frame (its surface's middle at `centre`) put in the district's: turned by the venue's angle about that middle and moved to where the plan puts it. */
30
+ function placeInDistrict(p, placed, centre, prefix, group) {
31
+ const at = (x, y) => {
32
+ const [rx, ry] = rot([x - centre[0], y - centre[1]], placed.angle);
33
+ return [rx + placed.at[0], ry + placed.at[1]];
34
+ };
35
+ const dir = (x, y) => rot([x, y], placed.angle);
36
+ const [ox, oy] = at(p.o[0], p.o[1]);
37
+ const [ux, uy] = dir(p.u[0], p.u[1]);
38
+ const [vx, vy] = dir(p.v[0], p.v[1]);
39
+ const moved = { ...p, id: `${prefix}${p.id}`, o: [ox, oy, p.o[2]], u: [ux, uy, p.u[2]], v: [vx, vy, p.v[2]], group };
40
+ if (p.shy !== undefined)
41
+ moved.shy = dir(p.shy[0], p.shy[1]);
42
+ if (p.crop === true) {
43
+ // A picture of the floor over a rectangle keeps its sides along x and y.
44
+ const corners = [at(p.o[0], p.o[1]), at(p.o[0] + p.u[0], p.o[1] + p.u[1]), at(p.o[0] + p.v[0], p.o[1] + p.v[1]), at(p.o[0] + p.u[0] + p.v[0], p.o[1] + p.u[1] + p.v[1])];
45
+ const [x0, x1] = [Math.min(...corners.map((c) => c[0])), Math.max(...corners.map((c) => c[0]))];
46
+ const [y0, y1] = [Math.min(...corners.map((c) => c[1])), Math.max(...corners.map((c) => c[1]))];
47
+ moved.o = [x0, y0, p.o[2]];
48
+ moved.u = [x1 - x0, 0, 0];
49
+ moved.v = [0, y1 - y0, 0];
50
+ }
51
+ return moved;
52
+ }
53
+ /** A name that faces the eye, standing over a building: its text on a plate. */
54
+ function sign(id, text, at, bottom, size, group) {
55
+ const wide = Math.max(size * 2.2, size * 0.62 * text.length + size * 1.2);
56
+ const tall = size * 1.7;
57
+ const svg = `<rect width="${n(wide)}" height="${n(tall)}" rx="${n(size * 0.3)}" class="u-signplate"/><text class="u-sign" x="${n(wide / 2)}" y="${n(tall * 0.68)}" text-anchor="middle" font-size="${n(size)}">${text.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</text>`;
58
+ return { ...plane(id, "sign", [at[0] - wide / 2, at[1], bottom + tall], [wide, 0, 0], [0, 0, -tall], svg), billboard: true, lean: true, group };
59
+ }
60
+ /**
61
+ * A district as a 3D model: the floor is the district's flat plan with each venue's building only, and on it stands each venue as it stands on its own (turned about the middle of its surface and moved to its place in the
62
+ * district), with a name over each where the district has labels. A `TurnModel` like any other: `turnMarkupOf` and `mountDistrictTurn` make a view of it.
63
+ */
64
+ export function buildDistrictTurn(options, settings = {}) {
65
+ const language = options.language === "ja" ? "ja" : "en";
66
+ const plan = planDistrict(options);
67
+ const base = districtId(options);
68
+ const id = settings.id ?? `${base}-turn`;
69
+ const labels = options.labels ?? plan.mode === "city";
70
+ const items = [];
71
+ const defs = [];
72
+ let crowdStyle = "";
73
+ plan.members.forEach((placed, i) => {
74
+ const member = placed.member;
75
+ const memberId = `${id}-m${i}`;
76
+ // Each venue is built as its own 3D view is, with the crowd drawn without its seats on its floor and the stands raised.
77
+ const model = buildTurn({ ...member.options, language, ...(options.theme === undefined ? {} : { theme: options.theme }), id: `${base}-m${i}` }, { ...settings, id: memberId, venue: { roof: true, ...(typeof settings.venue === "object" ? settings.venue : {}) }, preset: undefined, view: undefined });
78
+ const centre = surfaceCentre(member.options);
79
+ const prefix = `m${i}-`;
80
+ for (const item of model.uprights.items) {
81
+ const planes = item.planes.filter((p) => p.kind !== "ground").map((p) => placeInDistrict(p, placed, centre, prefix, i));
82
+ if (planes.length > 0) {
83
+ const [ax, ay] = item.at;
84
+ const [rx, ry] = rot([ax - centre[0], ay - centre[1]], placed.angle);
85
+ items.push({ ...item, id: `${prefix}${item.id}`, at: [rx + placed.at[0], ry + placed.at[1]], planes });
86
+ }
87
+ }
88
+ if (model.defs !== "")
89
+ defs.push(model.defs);
90
+ if (crowdStyle === "" && model.crowdStyle !== "")
91
+ crowdStyle = model.crowdStyle;
92
+ });
93
+ if (labels) {
94
+ const size = Math.max(plan.view.w, plan.view.h) * (plan.mode === "city" ? 0.016 : 0.022);
95
+ plan.members.forEach((placed, i) => {
96
+ const top = Math.max(...items.filter((it) => it.id.startsWith(`m${i}-`)).map((it) => it.height), 60);
97
+ const middle = [placed.footprint.reduce((s, p) => s + p[0], 0) / placed.footprint.length, placed.footprint.reduce((s, p) => s + p[1], 0) / placed.footprint.length];
98
+ items.push({ id: `m${i}-name`, kind: "scoreboard", at: middle, height: top + size * 1.7, planes: [sign(`m${i}-name`, placed.member.name[language], middle, top + size * 0.6, size, i)] });
99
+ });
100
+ }
101
+ const view = plan.view;
102
+ const scale = WORLD_PX / Math.max(view.w, view.h);
103
+ const centre = [view.x + view.w / 2, view.y + view.h / 2];
104
+ const area = { x0: view.x, y0: view.y, x1: view.x + view.w, y1: view.y + view.h };
105
+ const uprights = { items, lift: 0, thin: Math.hypot(view.w, view.h) * 0.0012, area, whole: true, ...(defs.length === 0 ? {} : { defs: defs.join("") }) };
106
+ const planes = planesOf(uprights);
107
+ const box = sceneBox(planes, area, 0, centre, scale);
108
+ // (The floor has no names: the 3D view's own signs, which face the eye, carry them.)
109
+ const floor = drawDistrict({ ...options, id: base, labels: false }, plan, (member, i) => drawMember(member, { ...options, id: base }, i, base, { floor: true }));
110
+ const { title, description } = describeDistrict(options, plan);
111
+ const presets = districtPresets(plan);
112
+ const surfaceLike = { sport: "soccer", language, ...(options.theme === undefined ? {} : { theme: options.theme }), ...(options.style === undefined ? {} : { style: options.style }) };
113
+ return {
114
+ id,
115
+ language,
116
+ options: surfaceLike,
117
+ title,
118
+ description,
119
+ standing: "",
120
+ floor,
121
+ viewBox: { x: view.x, y: view.y, w: view.w, h: view.h },
122
+ scale,
123
+ uprights,
124
+ defs: defs.join(""),
125
+ crowdStyle,
126
+ venue: true,
127
+ look: undefined,
128
+ planes,
129
+ box,
130
+ centre,
131
+ view: openingView(settings, presets),
132
+ turnable: true,
133
+ presets,
134
+ // A scene that turns about its middle with buildings far from it wants a longer lens, so that what swings toward the eye does not swell.
135
+ lens: 12,
136
+ flat: drawDistrict(options, plan),
137
+ groups: plan.members.map((p) => ({ hull: hullOf(p.footprint) })),
138
+ };
139
+ }
140
+ /**
141
+ * Mount a district as a 3D view in a container: the same view as `mountKyogijoTurn`'s, with the controls, the pointer, the keys and the auto-turn of every 3D view, over the district's plan with each venue standing on it.
142
+ * @example
143
+ * ```ts no-run
144
+ * import { mountDistrictTurn } from "@johnmorrisdotca/kyogijo-stadiums/district";
145
+ * const view = mountDistrictTurn(document.querySelector("#city")!, { venues: ["bc-place", "rogers-arena"] }, { preset: "broadcast" });
146
+ * view.set({ yaw: 40 });
147
+ * ```
148
+ */
149
+ export function mountDistrictTurn(host, options, settings = {}) {
150
+ return mountTurn(host, options, settings, buildDistrictTurn);
151
+ }
152
+ /** A district as the markup of a 3D view, still until a page's script makes it turn: pure, so a server can make it. */
153
+ export function districtTurnMarkup(options, settings = {}) {
154
+ return turnMarkupOf(buildDistrictTurn(options, settings), settings);
155
+ }
156
+ /** The distance in feet that a number of metres is: what a test or a caller needs to compare the plan with the ground. */
157
+ export const feetOf = (metres) => metres * FEET_PER_METRE;
package/dist/draw.js CHANGED
@@ -11,6 +11,7 @@ import { FOOTBALL } from "./geometry.js";
11
11
  import { renderLayers } from "./layers.js";
12
12
  import { arenaById, arenaCourtIn, arenaNameIn, arenaOfficialName, arenaRinkIn } from "./arenas.js";
13
13
  import { venueById, venueBowl } from "./venues.js";
14
+ import { groundOfStadium, stadiumBowl, stadiumById, stadiumConfigOf, stadiumSeats } from "./stadiums.js";
14
15
  import { groundById, groundNameIn, groundOfficialName } from "./grounds.js";
15
16
  import { BOARD, courtFor, hasSetsBoard, isNetLeague, isNetSport } from "./court.js";
16
17
  import { cricketBoardMarkup } from "./cricket.board.js";
@@ -435,6 +436,11 @@ function standsFor(o, surface, crowd) {
435
436
  // (No visitors' block: no source says where a real arena puts them.)
436
437
  if (venue !== undefined)
437
438
  return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: venueBowl(venue, box) });
439
+ // A football field or a soccer pitch in a real stadium: its building and a bowl of the configuration's seats.
440
+ const config = crowd.real ? stadiumConfigOf(sport) : undefined;
441
+ const stadium = config !== undefined && chooseLeague(o).league === undefined ? stadiumById(o.stadium ?? o.ground) : undefined;
442
+ if (stadium !== undefined && config !== undefined)
443
+ return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: stadiumBowl(stadium, config, box) });
438
444
  // A dojo's crowd sits in its gallery, from the room's balcony data (in feet here); the arenas (the Budokan, the Kokugikan) keep their bowls.
439
445
  const hall = sport === "dojo" ? dojoGallery(o) : undefined;
440
446
  const gallery = hall === undefined ? undefined : { offset: hall.offset / 0.3048, depth: hall.depth / 0.3048, height: [hall.height[0] / 0.3048, hall.height[1] / 0.3048], rows: hall.rows, aisles: hall.aisles };
@@ -727,7 +733,10 @@ export function describe(o) {
727
733
  const realCrowd = o.crowd === undefined || o.crowd === false ? undefined : resolveCrowd(o.crowd);
728
734
  const venue = sport === "nhl" && league === undefined && realCrowd?.real === true ? venueById(o.arena) : undefined;
729
735
  const realLine = venue === undefined || arenaName === undefined ? "" : `${gap}${fill(words.said.real, { arena: arenaName, seats: String(venue.seats.hockey), tiers: String(venue.tiers.count) })}${gap}${words.said[venue.osm.axis.basis === "mapped" ? "realMapped" : venue.osm.axis.basis === "building" ? "realBuilding" : "realNone"]}`;
730
- const parkLine = (parkName === undefined ? `${groundLine}${halfLine}${arenaLine}${realLine}` : `${gap}${fill(words.said.park, { park: parkName })}${officially(parkOfficial)}${ballpark === undefined ? "" : ballparkSentences(ballpark).map((line) => `${gap}${line}`).join("")}`) + (special === undefined ? "" : `${gap}${special.text}`);
736
+ const config = realCrowd?.real === true ? stadiumConfigOf(sport) : undefined;
737
+ const realStadium = config !== undefined && league === undefined ? stadiumById(o.stadium ?? o.ground) : undefined;
738
+ const stadiumLine = realStadium === undefined || config === undefined ? "" : `${gap}${fill(words.said.stadium, { stadium: groundNameIn(groundOfStadium(realStadium), o.year, language), seats: String(stadiumSeats(realStadium, config)), tiers: String(realStadium.tiers.count) })}${gap}${words.said[realStadium.osm.axis.basis === "opening" ? "stadiumOpening" : realStadium.osm.axis.basis === "building" ? "stadiumBuilding" : "stadiumNone"]}`;
739
+ const parkLine = (parkName === undefined ? `${groundLine}${halfLine}${arenaLine}${realLine}${stadiumLine}` : `${gap}${fill(words.said.park, { park: parkName })}${officially(parkOfficial)}${ballpark === undefined ? "" : ballparkSentences(ballpark).map((line) => `${gap}${line}`).join("")}`) + (special === undefined ? "" : `${gap}${special.text}`);
731
740
  // A drawing of a past season says which: the markings are those of that season.
732
741
  const dated = typeof o.year === "number" && Number.isFinite(o.year);
733
742
  const eraLine = dated && league !== undefined
@@ -6,6 +6,7 @@ import { arenaById, arenaNameIn } from "./arenas.js";
6
6
  import { STAGE_IDS, cameraAt, coverSide, flightPlan, foldTime, glide, glideSeconds, lengthWords, mainView, mapBox, mapFrame, nextRate, niceLength, stageAt, startOf, stagesOf, } from "./flythrough.path.js";
7
7
  import { flythroughMarkup, graticuleMarkup, gridPath, mapMarkup, stageWords } from "./flythrough.markup.js";
8
8
  import { FLYTHROUGH_WORDS, say } from "./flythrough.words.js";
9
+ import { LENS } from "./turn.camera.js";
9
10
  import { mountKyogijoTurn } from "./turn.dom.js";
10
11
  import { arenaOfVenue, planVenue, venueById, venueOrientation, venueRink } from "./venues.js";
11
12
  /**
@@ -43,7 +44,7 @@ export function mountFlythrough(host, options, settings = {}) {
43
44
  }
44
45
  })();
45
46
  const place = `${arena.city}, ${region}`;
46
- const values = { arena: name, place };
47
+ const values = { arena: name, place, seats: venue.seats.hockey.toLocaleString(language), tiers: venue.tiers.count };
47
48
  const wanted = (options.stages ?? STAGE_IDS).filter((stage, i, all) => STAGE_IDS.includes(stage) && all.indexOf(stage) === i);
48
49
  const stageList = STAGE_IDS.filter((stage) => wanted.includes(stage));
49
50
  if (stageList.length === 0)
@@ -68,7 +69,7 @@ export function mountFlythrough(host, options, settings = {}) {
68
69
  frame = { at: [0, 0], metresX: 1, metresY: 1 };
69
70
  }
70
71
  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
+ host.innerHTML = flythroughMarkup({ id, language, theme: options.theme, arena: name, place, stages: stageList, map: mapSvg, mapCredit: chizu, controls: settings.controls, seats: values.seats, tiers: values.tiers });
72
73
  const root = host.querySelector(".kf");
73
74
  const stage = root.querySelector(".kf-stage");
74
75
  const mapLayer = root.querySelector(".kf-map");
@@ -89,8 +90,10 @@ export function mountFlythrough(host, options, settings = {}) {
89
90
  // ---- The 3D arena, from above to begin with: the view the path hands over at. ----
90
91
  const drawing = { sport: "nhl", arena: venue.id, crowd: { real: true }, language, ...(options.theme === undefined ? {} : { theme: options.theme }) };
91
92
  let turn;
92
- if (stageList.includes("arena"))
93
- turn = mountKyogijoTurn(turnHost, drawing, { preset: "overhead", controls: false, venue: { roof: true }, motion: "none", id: `${id}-turn` });
93
+ // The settings object is kept: the view reads its `lens` at every paint, and the path shortens the lens as the camera comes into the bowl.
94
+ const turnSettings = { preset: "overhead", controls: false, venue: { roof: true }, motion: "none", id: `${id}-turn`, lens: LENS };
95
+ if (stageList.some((one) => one === "arena" || one === "bowl" || one === "rink"))
96
+ turn = mountKyogijoTurn(turnHost, drawing, turnSettings);
94
97
  // ---- 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
98
  const plan = planVenue(venue);
96
99
  const rink = venueRink(venue);
@@ -126,6 +129,9 @@ export function mountFlythrough(host, options, settings = {}) {
126
129
  if (turn !== undefined && floor !== null) {
127
130
  // 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
131
  const was = turn.view();
132
+ const lens = turnSettings.lens;
133
+ // The hand-over is seen from straight above through the usual long lens, whatever the lens is now.
134
+ turnSettings.lens = LENS;
129
135
  turn.set({ yaw: 0, pitch: 0, zoom: 1 }, false);
130
136
  const box = floor.getBoundingClientRect();
131
137
  // The stage's own area, inside its border: the layers are laid out in it.
@@ -141,6 +147,7 @@ export function mountFlythrough(host, options, settings = {}) {
141
147
  sheet = { x: vb.x, y: vb.y, w: vb.width, h: vb.height };
142
148
  drawPlan();
143
149
  }
150
+ turnSettings.lens = lens;
144
151
  turn.set(was, false);
145
152
  }
146
153
  const startSpan = start?.span ?? 9000000;
@@ -201,9 +208,12 @@ export function mountFlythrough(host, options, settings = {}) {
201
208
  put("3d", turnHost, "visibility", live3d ? "visible" : "hidden");
202
209
  if (live3d && turn !== undefined) {
203
210
  put("3d", turnHost, "opacity", num(cam.arena, 3));
204
- const view = `${num(cam.yaw, 3)}|${num(cam.pitch, 3)}|${num(cam.logZoom, 4)}`;
211
+ put("3d", turnHost, "--kf-roof", num(cam.roof, 3));
212
+ put("3d", turnHost, "@data-roof", cam.roof > 0.004 ? "1" : "0");
213
+ const view = `${num(cam.yaw, 3)}|${num(cam.pitch, 3)}|${num(cam.logZoom, 4)}|${num(cam.logLens, 4)}`;
205
214
  if (written["3d:view"] !== view) {
206
215
  written["3d:view"] = view;
216
+ turnSettings.lens = Math.exp(cam.logLens);
207
217
  turn.set({ yaw: cam.yaw, pitch: cam.pitch, zoom: Math.exp(cam.logZoom) }, false);
208
218
  }
209
219
  }
@@ -25,6 +25,9 @@ export interface MarkupInput {
25
25
  /** The arena's name, and where it is (`Anaheim, United States`), in the page's language. */
26
26
  arena: string;
27
27
  place: string;
28
+ /** The venue's seats for ice hockey and its seating tiers, in the words of the bowl's stage (`{seats}`, `{tiers}`). */
29
+ seats?: number | string;
30
+ tiers?: number | string;
28
31
  /** The stages flown, in order. */
29
32
  stages: readonly StageId[];
30
33
  /** The drawn map (already through `mapMarkup`), and whether it is chizu's (so that the credit names it). */
@@ -38,6 +41,8 @@ export interface MarkupInput {
38
41
  export declare const stageWords: (language: Language, stage: StageId, values: {
39
42
  arena: string;
40
43
  place: string;
44
+ seats?: number | string;
45
+ tiers?: number | string;
41
46
  }) => {
42
47
  name: string;
43
48
  text: string;
@@ -63,7 +63,7 @@ export function controlsMarkup(words, stages, current, playing) {
63
63
  /** The whole fly-through as markup. */
64
64
  export function flythroughMarkup(input) {
65
65
  const words = FLYTHROUGH_WORDS[input.language];
66
- const values = { arena: input.arena, place: input.place };
66
+ const values = { arena: input.arena, place: input.place, seats: input.seats, tiers: input.tiers };
67
67
  const first = input.stages[0];
68
68
  const intro = stageWords(input.language, first, values);
69
69
  const label = say(words.title, values);