@johnmorrisdotca/kyogijo-stadiums 0.28.0 → 0.31.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 (48) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE.md +17 -5
  3. package/README.md +80 -107
  4. package/dist/crowd.d.ts +20 -2
  5. package/dist/crowd.park.d.ts +6 -0
  6. package/dist/crowd.park.js +3 -3
  7. package/dist/district.d.ts +3 -3
  8. package/dist/district.js +14 -13
  9. package/dist/draw.js +25 -10
  10. package/dist/flythrough-entry.d.ts +1 -0
  11. package/dist/flythrough-entry.js +1 -0
  12. package/dist/flythrough.dom.js +52 -6
  13. package/dist/flythrough.game.d.ts +36 -0
  14. package/dist/flythrough.game.js +145 -0
  15. package/dist/flythrough.markup.js +1 -0
  16. package/dist/flythrough.path.d.ts +6 -4
  17. package/dist/flythrough.path.js +26 -22
  18. package/dist/flythrough.style.d.ts +1 -1
  19. package/dist/flythrough.style.js +3 -0
  20. package/dist/flythrough.words.d.ts +2 -0
  21. package/dist/flythrough.words.js +6 -2
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/stadiums-entry.d.ts +2 -2
  25. package/dist/stadiums-entry.js +2 -2
  26. package/dist/stadiums.d.ts +109 -28
  27. package/dist/stadiums.data.js +25 -0
  28. package/dist/stadiums.js +159 -30
  29. package/dist/stadiums.osm.data.js +114 -0
  30. package/dist/strings.d.ts +4 -0
  31. package/dist/strings.js +2 -2
  32. package/dist/turn.dom.d.ts +8 -2
  33. package/dist/turn.dom.js +10 -4
  34. package/dist/venue.d.ts +2 -0
  35. package/dist/venue.js +6 -5
  36. package/dist/venue.real.d.ts +7 -2
  37. package/dist/venue.real.js +124 -10
  38. package/dist/venues-entry.d.ts +2 -2
  39. package/dist/venues-entry.js +1 -1
  40. package/dist/venues.d.ts +14 -3
  41. package/dist/venues.data.js +72 -0
  42. package/dist/venues.geo.d.ts +22 -0
  43. package/dist/venues.geo.js +28 -0
  44. package/dist/venues.js +9 -1
  45. package/dist/venues.osm.data.js +366 -0
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +4 -2
package/dist/stadiums.js CHANGED
@@ -1,12 +1,16 @@
1
- // The real stadiums: for a stadium that stages football and soccer, the outline of its building and the way its field lies in it (from OpenStreetMap, ODbL 1.0), and a bowl of the seats of each configuration, the
2
- // number of bowls and the kind of roof the sources give (Wikidata, CC0, and the English Wikipedia article each fact cites), fitted inside the outline. It is the stadium's counterpart of `venues.ts`, for a surface that is a
1
+ // The real stadiums: for a stadium that stages football and soccer, or a ballpark, the outline of its building and the way its field lies in it (from OpenStreetMap, ODbL 1.0), and a bowl of the seats of each configuration,
2
+ // the number of bowls and the kind of roof the sources give (Wikidata, CC0, and the English Wikipedia article each fact cites), fitted inside the outline. It is the stadium's counterpart of `venues.ts`, for a surface that is a
3
3
  // field and not a rink: level one is the building, level two a plausible bowl, and it stops there (no team's, ticket seller's or federation's seat map is read or copied, no section or row is named, and no mark of a club, a
4
- // league or a tournament is drawn). The drawing is `drawKyogijo({ sport: "cfl", stadium, crowd: { real: true } })` (or `sport: "soccer"` with a `ground` or a `stadium`) and, in 3D, the same options in `buildTurn`.
4
+ // league or a tournament is drawn). The drawing is `drawKyogijo({ sport: "cfl", stadium, crowd: { real: true } })` (or `sport: "soccer"` with a `ground` or a `stadium`, `sport: "mlb"` with a `park`) and, in 3D, the same options
5
+ // in `buildTurn`.
5
6
  import { NORTH_ROOM, aislesOf, areaOfOutline, outlinePoints } from "./crowd.js";
7
+ import { parkLayout } from "./crowd.park.js";
6
8
  import { FEET_PER_METRE, FOOTBALL } from "./geometry.js";
7
9
  import { groundById } from "./grounds.js";
10
+ import { PARKS } from "./parks.js";
8
11
  import { GAP, HALL, MAX_RAKE, SEAT_AREAS, WALL, VENUE_ATTRIBUTION } from "./venues.js";
9
12
  import { boxOf, edgeDistance, inside, metresFrom, middleOf, northOn, turnedTo } from "./venues.geo.js";
13
+ import { clipConvex } from "./venue.geom.js";
10
14
  import { STADIUM_DATA } from "./stadiums.data.js";
11
15
  import { STADIUM_OSM } from "./stadiums.osm.data.js";
12
16
  export { VENUE_ATTRIBUTION };
@@ -18,30 +22,49 @@ export const STADIUM_IDS = Object.freeze(STADIUM_DATA.map((data) => data.id));
18
22
  export const stadiumById = (id) => (id !== undefined && Object.prototype.hasOwnProperty.call(STADIUMS, id) ? STADIUMS[id] : undefined);
19
23
  /** The stadium whose Wikidata item is `Q612227`, or `undefined`. */
20
24
  export const stadiumByWikidata = (wikidata) => Object.values(STADIUMS).find((s) => s.wikidata === wikidata);
21
- /** The ground a stadium is (its name, place and pitch). */
25
+ /** The kind of a stadium: a `stadium` unless it is a ballpark. */
26
+ export const stadiumKind = (stadium) => stadium.kind ?? "stadium";
27
+ /** The configurations a stadium has, in the order of its data. */
28
+ export const stadiumConfigs = (stadium) => Object.keys(stadium.configs);
29
+ /** The ground a stadium is (its name, place and pitch). Throws for a ballpark, which has a park. */
22
30
  export const groundOfStadium = (stadium) => {
23
31
  const ground = groundById(stadium.ground);
24
32
  if (ground === undefined)
25
- throw new Error(`${stadium.id} has no ground ${stadium.ground}`);
33
+ throw new Error(`${stadium.id} has no ground ${String(stadium.ground)}`);
26
34
  return ground;
27
35
  };
36
+ /** The names a stadium goes by in both languages, of its ground or its park. */
37
+ export function stadiumNames(stadium) {
38
+ if (stadiumKind(stadium) === "park") {
39
+ const park = PARKS[stadium.park ?? ""];
40
+ if (park === undefined)
41
+ throw new Error(`${stadium.id} has no park ${String(stadium.park)}`);
42
+ return { en: park.name, ja: park.nameJa };
43
+ }
44
+ const ground = groundOfStadium(stadium);
45
+ return { en: ground.name, ja: ground.nameJa };
46
+ }
28
47
  const round = (value, places = 1) => Math.round(value * 10 ** places) / 10 ** places;
29
48
  const lonLat = (ring) => ring.map(([lon, lat]) => [lon, lat]);
49
+ /** The heading the field's frame runs along, degrees clockwise from north: the long side's, and for a ballpark (whose frame has home plate at its origin, x to the right of the way out and y minus the way out) a quarter turn from the way out to centre field. */
50
+ export const stadiumFrameHeading = (stadium) => (stadiumKind(stadium) === "park" ? (stadium.osm.axis.heading + 90) % 360 : stadium.osm.axis.heading);
30
51
  /** How the field lies in a stadium's building (see `StadiumOsm.axis`). */
31
- export const stadiumOrientation = (stadium) => ({ heading: stadium.osm.axis.heading, basis: stadium.osm.axis.basis, north: round(northOn(stadium.osm.axis.heading)) });
32
- /** The configuration a sport is: `cfl` or `soccer`, or `undefined` for a sport a stadium has no configuration for. */
33
- export const stadiumConfigOf = (sport) => (sport === "cfl" || sport === "soccer" ? sport : undefined);
34
- /** The seats of a stadium for a configuration. */
35
- export const stadiumSeats = (stadium, config) => stadium.configs[config].seats;
52
+ export const stadiumOrientation = (stadium) => ({ heading: stadium.osm.axis.heading, basis: stadium.osm.axis.basis, north: round(northOn(stadiumFrameHeading(stadium))) });
53
+ /** The configuration a sport is: `cfl`, `nfl`, `soccer` or `mlb`, or `undefined` for a sport a stadium has no configuration for. */
54
+ export const stadiumConfigOf = (sport) => (sport === "cfl" || sport === "nfl" || sport === "soccer" || sport === "mlb" ? sport : undefined);
55
+ /** The seats of a stadium for a configuration (0 for one it has none of). */
56
+ export const stadiumSeats = (stadium, config) => stadium.configs[config]?.seats ?? 0;
36
57
  // ---- The bowl. ----
37
- /** How far the first row is from a football field's footprint (the CFL's, the field and its end zones) along a side and at an end, and the corner's radius: the package's own gridiron profile (`crowd.ts`), so that a real stadium's first row is the usual one. */
38
- const FIRST_ROW = { side: 42, end: 28, corner: 60 };
58
+ /** How far the first row is from a football field's footprint (the league's field and its end zones) along a side and at an end, and the corner's radius: the package's own gridiron profile (`crowd.ts`), so that a real stadium's first row is the usual one. */
59
+ const FIRST_ROW = { cfl: { side: 42, end: 28, corner: 60 }, nfl: { side: 40, end: 26, corner: 60 } };
39
60
  /** The height of the first row above the field, in feet, the rise of a bowl's rows by the number of bowls (what the lowest, a middle and the upper rise, at most), and the rise of the walk between two bowls (a suite level): ours, no source gives them. */
40
61
  export const STADIUM_FIRST_HEIGHT = 5;
41
62
  export const STADIUM_RISES = { 1: [60], 2: [38, 62], 3: [32, 26, 56] };
42
63
  export const STADIUM_WALK_RISE = 24;
43
64
  /** The height a roof's rim stands over the top row, at the least, in feet. */
44
65
  export const STADIUM_CLEARANCE = 22;
66
+ /** How far a canopy's low edge stands over the top row at the least, in feet: a bowl under a canopy is lowered to keep it. */
67
+ export const CANOPY_CLEARANCE = 6;
45
68
  /** The share of the seats in the lowest bowl where no source says. */
46
69
  const LOWER_SHARE = 0.5;
47
70
  const CORNER = 0.7;
@@ -68,17 +91,21 @@ function solveFor(f, lo, hi, target) {
68
91
  const PLANS = new WeakMap();
69
92
  /**
70
93
  * The plan of a stadium's bowl for a configuration: the building's outline turned so that the field lies along x, a first row round a football field's footprint as a gridiron's stands have it, the largest bowl-shaped
71
- * outline that fits the building round the middle of the field, and between the two the bowls, the outer edge of each placed so that its band holds the share of the configuration's seats the sources give it, with a walk
72
- * between them. The bowl is the same building for football and soccer (the seats of the configuration differ, and the field).
94
+ * outline that fits the building round the middle of the field, and between the two the bowls, the outer edge of each placed so that its band holds the share of the seats the sources give it, with a walk
95
+ * between them. The bowl is the same building for every configuration (the seats of the configuration differ, and the field); where a bowl holds more seats than a configuration opens, the rest are closed.
73
96
  * @example
74
97
  * ```ts
75
98
  * import { planStadium, stadiumById } from "@johnmorrisdotca/kyogijo-stadiums/stadiums";
76
99
  * const plan = planStadium(stadiumById("bc-place")!, "soccer", { x: 0, y: 0, w: 344.5, h: 223.1 });
77
- * console.log(plan.orientation.basis, plan.seats.map((t) => t.tier).join(" "), plan.total);
78
- * // opening lower upper 52497
100
+ * console.log(plan.orientation.basis, plan.seats.map((t) => `${t.tier} ${t.seats}`).join(", "), plan.total);
101
+ * // opening lower 27695, upper 24802 52497
79
102
  * ```
80
103
  */
81
104
  export function planStadium(stadium, config, box) {
105
+ if (stadiumKind(stadium) !== "stadium")
106
+ throw new Error(`${stadium.id} is a ballpark: planPark plans it`);
107
+ if (stadium.configs[config] === undefined)
108
+ throw new Error(`${stadium.id} has no configuration ${config}`);
82
109
  const key = `${config}|${box.x}|${box.y}|${box.w}|${box.h}`;
83
110
  const mine = PLANS.get(stadium) ?? new Map();
84
111
  PLANS.set(stadium, mine);
@@ -102,9 +129,11 @@ function plan(stadium, config, rink) {
102
129
  const openingRing = stadium.osm.roof?.opening?.ring;
103
130
  const opening = openingRing === undefined ? undefined : place(turned(openingRing));
104
131
  // The first row round a football field's footprint, and the largest bowl-shaped outline that fits inside the building round the middle of the field.
105
- const gridiron = FOOTBALL.cfl;
132
+ const kind = stadium.gridiron ?? "cfl";
133
+ const gridiron = FOOTBALL[kind];
134
+ const row = FIRST_ROW[kind];
106
135
  const length = gridiron.length + 2 * gridiron.endZone;
107
- const first = { kind: "rrect", x0: at[0] - length / 2 - FIRST_ROW.end, y0: at[1] - gridiron.width / 2 - FIRST_ROW.side, x1: at[0] + length / 2 + FIRST_ROW.end, y1: at[1] + gridiron.width / 2 + FIRST_ROW.side, r: FIRST_ROW.corner };
136
+ const first = { kind: "rrect", x0: at[0] - length / 2 - row.end, y0: at[1] - gridiron.width / 2 - row.side, x1: at[0] + length / 2 + row.end, y1: at[1] + gridiron.width / 2 + row.side, r: row.corner };
108
137
  const shapedAs = (long, s) => ({ kind: "rrect", x0: at[0] - long * s, y0: at[1] - s, x1: at[0] + long * s, y1: at[1] + s, r: CORNER * s });
109
138
  const largest = (long) => {
110
139
  let [lo, hi] = [60, Math.max(fb.x1 - fb.x0, fb.y1 - fb.y0)];
@@ -123,12 +152,15 @@ function plan(stadium, config, rink) {
123
152
  const outer = { ...wall, x0: Math.min(wall.x0, first.x0 - 40), y0: Math.min(wall.y0, first.y0 - 40), x1: Math.max(wall.x1, first.x1 + 40), y1: Math.max(wall.y1, first.y1 + 40) };
124
153
  const depth = (first.x0 - outer.x0) + (first.y0 - outer.y0) + (outer.x1 - first.x1) + (outer.y1 - first.y1);
125
154
  const gap = GAP / Math.max(20, depth / 4);
126
- const seatsTotal = stadium.configs[config].seats;
127
- const count = stadium.tiers.count;
155
+ const open = stadium.configs[config]?.seats ?? 0;
156
+ const seatsTotal = stadium.bowlSeats ?? open;
157
+ const count = Math.min(3, stadium.tiers.count);
128
158
  const lower = stadium.tiers.lowerSeats === undefined ? (count === 1 ? 1 : count === 2 ? LOWER_SHARE : 0.36) : stadium.tiers.lowerSeats / seatsTotal;
129
159
  const shares = count === 1 ? [1] : count === 2 ? [lower, 1 - lower] : [lower, (1 - lower) * 0.36, (1 - lower) * 0.64];
130
160
  const outlineAt = (t) => between(first, outer, t);
131
- const build = (area) => {
161
+ // A canopy's low edge is the least height the top row may reach (less the clearance): a bowl under a canopy is lowered, by the factor `vscale`, if it would not clear.
162
+ const low = stadium.osm.canopies === undefined ? Infinity : Math.min(...stadium.osm.canopies.map((c) => (c.height - c.rise) * FEET_PER_METRE));
163
+ const build = (area, vscale) => {
132
164
  const seatsOf = (region, hole) => Math.round((areaOfOutline(region) - areaOfOutline(hole)) / area);
133
165
  const rings = [];
134
166
  let t0 = 0;
@@ -143,8 +175,8 @@ function plan(stadium, config, rink) {
143
175
  const hole = outlineAt(t0);
144
176
  const tier = TIER_IDS[count][k];
145
177
  const run = (region.y1 - hole.y1 + (hole.y0 - region.y0)) / 2;
146
- const rise = Math.min(STADIUM_RISES[count][k], Math.max(6, run * Math.tan((MAX_RAKE * Math.PI) / 180)));
147
- const h0 = k === 0 ? STADIUM_FIRST_HEIGHT : up + STADIUM_WALK_RISE;
178
+ const rise = Math.min(STADIUM_RISES[count][k] * vscale, Math.max(6, run * Math.tan((MAX_RAKE * Math.PI) / 180)));
179
+ const h0 = k === 0 ? STADIUM_FIRST_HEIGHT : up + STADIUM_WALK_RISE * vscale;
148
180
  const h1 = h0 + rise;
149
181
  up = h1;
150
182
  rings.push({
@@ -164,27 +196,43 @@ function plan(stadium, config, rink) {
164
196
  });
165
197
  return { rings, tight };
166
198
  };
199
+ const heightOf = (made) => made.rings[made.rings.length - 1].height[1];
167
200
  let seatArea = SEAT_AREAS[0];
168
- let made = build(seatArea);
201
+ let made = build(seatArea, 1);
169
202
  for (const area of SEAT_AREAS.slice(1)) {
170
203
  if (!made.tight)
171
204
  break;
172
205
  seatArea = area;
173
- made = build(area);
206
+ made = build(area, 1);
207
+ }
208
+ // (A bowl's rise is capped by its rake, so lowering it is not in proportion: a few rounds bring the top row under the canopy.)
209
+ let vscale = 1;
210
+ for (let pass = 0; pass < 10 && heightOf(made) > low - CANOPY_CLEARANCE + 0.5; pass += 1) {
211
+ vscale = Math.max(0.1, vscale * ((low - CANOPY_CLEARANCE - STADIUM_FIRST_HEIGHT) / (heightOf(made) - STADIUM_FIRST_HEIGHT)));
212
+ made = build(seatArea, vscale);
174
213
  }
175
214
  const { rings, tight } = made;
176
- // The rounding of each bowl's seats leaves the total a seat or two off the configuration's: the last bowl takes the difference.
215
+ // The rounding of each bowl's seats leaves the total a seat or two off the bowl's: the last bowl takes the difference.
177
216
  const missing = seatsTotal - rings.reduce((sum, ring) => sum + ring.seats, 0);
178
217
  if (!tight && Math.abs(missing) <= 3)
179
218
  rings[rings.length - 1].seats += missing;
180
219
  const total = rings.reduce((sum, ring) => sum + ring.seats, 0);
220
+ // A configuration that opens fewer seats than the bowl holds opens them from the lowest bowl up: a bowl's people are its open share of them, and a closed one is empty.
221
+ if (open < seatsTotal) {
222
+ let before = 0;
223
+ for (const ring of rings) {
224
+ ring.fill = round(ring.fill * Math.min(1, Math.max(0, (open - before) / Math.max(1, ring.seats))), 3);
225
+ before += ring.seats;
226
+ }
227
+ }
181
228
  const top = rings[rings.length - 1].height[1];
182
229
  const roof = stadium.osm.roof;
183
230
  const rise = roof?.rise === undefined ? 0 : round(roof.rise * FEET_PER_METRE);
184
- // The rim of the roof stands where OpenStreetMap maps it (its height less the roof's own rise), and at the least over the top row by a roof's clearance.
231
+ // The rim of a roof stands where OpenStreetMap maps it (its height less the roof's own rise), and at the least over the top row by a roof's clearance; a canopy's building has walls to the top row and a little over it.
185
232
  const mapped = roof?.height === undefined ? 0 : round((roof.height - (roof.rise ?? 0)) * FEET_PER_METRE);
186
- const edge = Math.max(mapped, top + STADIUM_CLEARANCE);
233
+ const edge = stadium.roof.kind === "canopy" ? round(top + CANOPY_CLEARANCE) : Math.max(mapped, top + STADIUM_CLEARANCE);
187
234
  const cable = stadium.roof.kind === "cable" && opening !== undefined && rise > 0;
235
+ const canopies = stadium.roof.kind === "canopy" && stadium.osm.canopies !== undefined ? stadium.osm.canopies.map((c) => ({ ring: place(turned(c.ring)), low: round((c.height - c.rise) * FEET_PER_METRE), rise: round(c.rise * FEET_PER_METRE) })) : undefined;
188
236
  const north = orientation.basis === "none" ? undefined : orientation.north;
189
237
  const room = north === undefined ? 0 : NORTH_ROOM;
190
238
  const bowl = {
@@ -195,12 +243,13 @@ function plan(stadium, config, rink) {
195
243
  footprint,
196
244
  hall: { ...outer, x0: outer.x0 - HALL, y0: outer.y0 - HALL, x1: outer.x1 + HALL, y1: outer.y1 + HALL, r: outer.r + HALL },
197
245
  ...(north === undefined ? {} : { north }),
198
- roof: cable ? "cable" : "fixed",
246
+ roof: cable ? "cable" : canopies === undefined ? "fixed" : "canopy",
199
247
  edge,
200
248
  ...(cable ? { crown: { rise, opening } } : {}),
249
+ ...(canopies === undefined ? {} : { canopies }),
201
250
  },
202
251
  };
203
- return { stadium, config, orientation, box: rink, footprint, opening, bowl, seats: rings.map((ring) => ({ tier: ring.tier, seats: ring.seats })), total, seatArea, fit: tight ? "tight" : "fits" };
252
+ return { stadium, config, orientation, box: rink, footprint, opening, bowl, seats: rings.map((ring) => ({ tier: ring.tier, seats: ring.seats })), total, open, seatArea, fit: tight ? "tight" : "fits" };
204
253
  }
205
254
  /** The stands of a stadium for `layoutOf` and the 3D view, round a field: `planStadium`'s bowl. */
206
255
  export const stadiumBowl = (stadium, config, box) => planStadium(stadium, config, box).bowl;
@@ -208,3 +257,83 @@ export const stadiumBowl = (stadium, config, box) => planStadium(stadium, config
208
257
  export function stadiumBowlInside(plan) {
209
258
  return outlinePoints(plan.bowl.rings[plan.bowl.rings.length - 1].region).every((p) => inside(plan.footprint, p));
210
259
  }
260
+ const PARK_PLANS = new WeakMap();
261
+ /** The share of a ballpark's building the closed roof covers, and the share of its width along the east side the open roof's sections rest over (ours: the article says where they rest, not how much). */
262
+ const PARK_COVER = 0.92;
263
+ const PARK_PARKED = 0.3;
264
+ /**
265
+ * The plan of a ballpark from what its stands are laid from (`input`: the park's fence, the backstop and the crowd): the usual stands with each band as deep as its seats need, the building's outline turned so that home
266
+ * plate faces where OpenStreetMap's infield and the outline say, and the roof: sections far over the field, closed over the stands and the field or resting over the east side where the article says they rest.
267
+ */
268
+ export function planPark(stadium, input) {
269
+ if (stadiumKind(stadium) !== "park")
270
+ throw new Error(`${stadium.id} is not a ballpark`);
271
+ const key = JSON.stringify([input.fence, input.backstop, input.crowd.shape, input.crowd.awayEnd, input.crowd.tifo, input.crowd.gaps, input.seatsOnWalls ?? null]);
272
+ const mine = PARK_PLANS.get(stadium) ?? new Map();
273
+ PARK_PLANS.set(stadium, mine);
274
+ const known = mine.get(key);
275
+ if (known !== undefined)
276
+ return known;
277
+ const made = park(stadium, input);
278
+ mine.set(key, made);
279
+ return made;
280
+ }
281
+ function park(stadium, input) {
282
+ const config = "mlb";
283
+ const seats = stadium.configs.mlb?.seats ?? 0;
284
+ const bands = stadium.tiers.tierSeats ?? { lower: seats * 0.45, upper: seats * 0.47, bleachers: seats * 0.08 };
285
+ const sum = bands.lower + bands.upper + bands.bleachers;
286
+ const wanted = { lower: (bands.lower / sum) * seats, upper: (bands.upper / sum) * seats, bleachers: (bands.bleachers / sum) * seats };
287
+ // Each band is as deep as holds its seats: the usual depths scaled by what the seats of the band are to what the band holds, a few rounds over (a band's seats grow with its depth).
288
+ const depths = { lower: 60, upper: 56, bleachers: 54 };
289
+ let stands = parkLayout({ ...input, depths });
290
+ for (let i = 0; i < 12; i += 1) {
291
+ const held = (id) => stands.rings.find((r) => r.id === id)?.seats ?? 0;
292
+ const next = { lower: depths.lower * (wanted.lower / Math.max(1, held("lower"))), upper: depths.upper * (wanted.upper / Math.max(1, held("upper"))), bleachers: depths.bleachers * (wanted.bleachers / Math.max(1, held("bleachers"))) };
293
+ for (const k of ["lower", "upper", "bleachers"])
294
+ depths[k] = Math.min(160, Math.max(20, next[k]));
295
+ stands = parkLayout({ ...input, depths });
296
+ }
297
+ const total = stands.rings.reduce((n, r) => n + r.seats, 0);
298
+ const orientation = stadiumOrientation(stadium);
299
+ const frameHeading = stadiumFrameHeading(stadium);
300
+ // The building in the park's frame, in feet: home plate at the origin.
301
+ const origin = middleOf(lonLat(stadium.osm.ring));
302
+ const world = metresFrom(origin, lonLat(stadium.osm.ring));
303
+ const [home] = metresFrom(origin, [stadium.osm.axis.centre]);
304
+ const frameOf = (p) => {
305
+ const [x, y] = turnedTo(frameHeading, [p[0] - home[0], p[1] - home[1]]);
306
+ return [round(x * FEET_PER_METRE, 2), round(y * FEET_PER_METRE, 2)];
307
+ };
308
+ const footprint = world.map(frameOf);
309
+ const fb = boxOf(footprint);
310
+ const box = stands.box;
311
+ const c = [box.x + box.w / 2, box.y + box.h / 2];
312
+ const cover = footprint.map((p) => [c[0] + (p[0] - c[0]) * PARK_COVER, c[1] + (p[1] - c[1]) * PARK_COVER]);
313
+ // Where the sections rest: the east side of the building, over the railway there (the article says so): the strip of the outline east of a share of its width.
314
+ const [wx0, wx1] = [Math.min(...world.map((p) => p[0])), Math.max(...world.map((p) => p[0]))];
315
+ const [wy0, wy1] = [Math.min(...world.map((p) => p[1])), Math.max(...world.map((p) => p[1]))];
316
+ const strip = clipConvex(world, [[wx1 - (wx1 - wx0) * PARK_PARKED, wy0 - 1], [wx1 + 1, wy0 - 1], [wx1 + 1, wy1 + 1], [wx1 - (wx1 - wx0) * PARK_PARKED, wy1 + 1]]);
317
+ const parked = strip.map(frameOf);
318
+ const north = orientation.basis === "none" ? undefined : orientation.north;
319
+ const room = north === undefined ? 0 : NORTH_ROOM;
320
+ const wallFeet = round((stadium.osm.height ?? 20) * FEET_PER_METRE);
321
+ const hall = { kind: "rrect", x0: fb.x0 - 30, y0: fb.y0 - 30, x1: fb.x1 + 30, y1: fb.y1 + 30, r: 0 };
322
+ const shell = {
323
+ footprint,
324
+ hall,
325
+ ...(north === undefined ? {} : { north }),
326
+ roof: "panels",
327
+ edge: wallFeet,
328
+ wall: wallFeet,
329
+ panels: { cover, bottom: stadium.roof.bottom ?? 217, top: stadium.roof.top ?? 269, parked },
330
+ };
331
+ const reach = {
332
+ left: Math.max(stands.reach.left, box.x - fb.x0),
333
+ right: Math.max(stands.reach.right, fb.x1 - box.x - box.w),
334
+ top: Math.max(stands.reach.top, box.y - fb.y0) + room,
335
+ bottom: Math.max(stands.reach.bottom, fb.y1 - box.y - box.h),
336
+ };
337
+ const real = { ...stands, real: shell, reach };
338
+ return { stadium, config, orientation, footprint, stands: real, depths: { lower: round(depths.lower), upper: round(depths.upper), bleachers: round(depths.bleachers) }, seats: stands.rings.map((r) => ({ tier: r.id, seats: r.seats })), total, fit: Math.abs(total - seats) / seats <= 0.02 ? "fits" : "tight" };
339
+ }
@@ -5,6 +5,7 @@ export const STADIUM_OSM = {
5
5
  "base": "2026-10-20T21:46:16Z",
6
6
  "licence": "ODbL-1.0",
7
7
  "attribution": "(c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright",
8
+ "height": 61,
8
9
  "axis": { "heading": 52.8, "basis": "opening", "strength": 1.47, "elongation": 1.19, "centre": [-123.112002, 49.276698] },
9
10
  "roof": {
10
11
  "source": "relation/5836463",
@@ -55,4 +56,117 @@ export const STADIUM_OSM = {
55
56
  [-123.113623, 49.276478],
56
57
  ],
57
58
  },
59
+ "lumen-field": {
60
+ "source": "relation/2305831",
61
+ "read": "2026-10-20",
62
+ "base": "2026-10-20T23:05:49Z",
63
+ "licence": "ODbL-1.0",
64
+ "attribution": "(c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright",
65
+ "axis": { "heading": 0, "basis": "pitch", "strength": 1.32, "elongation": 2.16, "centre": [-122.33163, 47.595154] },
66
+ "canopies": [
67
+ { "source": "way/458208106", "height": 36, "rise": 10, "ring": [
68
+ [-122.332395, 47.596154],
69
+ [-122.33234, 47.595145],
70
+ [-122.332404, 47.594143],
71
+ [-122.332683, 47.594276],
72
+ [-122.332869, 47.594407],
73
+ [-122.333005, 47.594605],
74
+ [-122.333075, 47.594764],
75
+ [-122.333159, 47.595095],
76
+ [-122.33313, 47.59539],
77
+ [-122.332993, 47.595744],
78
+ [-122.332861, 47.59592],
79
+ [-122.332747, 47.596004],
80
+ [-122.332452, 47.596154],
81
+ ] },
82
+ { "source": "way/458208107", "height": 36, "rise": 10, "ring": [
83
+ [-122.330897, 47.596155],
84
+ [-122.330837, 47.596159],
85
+ [-122.33062, 47.596055],
86
+ [-122.330448, 47.595948],
87
+ [-122.330298, 47.595762],
88
+ [-122.330117, 47.5953],
89
+ [-122.330116, 47.59501],
90
+ [-122.330212, 47.594711],
91
+ [-122.33041, 47.594391],
92
+ [-122.330582, 47.594279],
93
+ [-122.330872, 47.59415],
94
+ [-122.330942, 47.595174],
95
+ [-122.330936, 47.595579],
96
+ ] },
97
+ ],
98
+ "ring": [
99
+ [-122.332249, 47.59627],
100
+ [-122.332463, 47.596203],
101
+ [-122.332661, 47.596118],
102
+ [-122.33284, 47.596015],
103
+ [-122.332996, 47.595897],
104
+ [-122.333126, 47.595765],
105
+ [-122.333162, 47.595677],
106
+ [-122.333156, 47.594577],
107
+ [-122.333041, 47.594455],
108
+ [-122.332904, 47.594343],
109
+ [-122.332747, 47.594244],
110
+ [-122.332574, 47.594159],
111
+ [-122.332187, 47.594034],
112
+ [-122.331979, 47.593997],
113
+ [-122.331766, 47.593978],
114
+ [-122.331338, 47.593994],
115
+ [-122.331311, 47.594047],
116
+ [-122.331129, 47.594028],
117
+ [-122.330739, 47.594148],
118
+ [-122.330405, 47.594329],
119
+ [-122.330265, 47.594439],
120
+ [-122.330166, 47.594722],
121
+ [-122.330125, 47.59496],
122
+ [-122.330117, 47.5953],
123
+ [-122.330168, 47.595589],
124
+ [-122.330269, 47.59587],
125
+ [-122.330506, 47.596048],
126
+ [-122.330646, 47.596124],
127
+ [-122.331021, 47.596263],
128
+ [-122.331021, 47.596093],
129
+ [-122.331134, 47.596093],
130
+ [-122.331227, 47.596281],
131
+ [-122.332031, 47.596283],
132
+ [-122.332131, 47.596089],
133
+ [-122.33225, 47.596089],
134
+ ],
135
+ },
136
+ "t-mobile-park": {
137
+ "source": "relation/3008332",
138
+ "read": "2026-10-20",
139
+ "base": "2026-10-20T23:06:50Z",
140
+ "licence": "ODbL-1.0",
141
+ "attribution": "(c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright",
142
+ "height": 21,
143
+ "axis": { "heading": 49.4, "basis": "infield", "strength": 1.43, "elongation": 1, "centre": [-122.332905, 47.591117] },
144
+ "ring": [
145
+ [-122.330843, 47.59228],
146
+ [-122.331382, 47.590448],
147
+ [-122.333677, 47.590452],
148
+ [-122.333678, 47.59047],
149
+ [-122.333819, 47.590456],
150
+ [-122.333945, 47.5905],
151
+ [-122.333997, 47.590554],
152
+ [-122.334013, 47.590618],
153
+ [-122.333989, 47.590681],
154
+ [-122.33393, 47.59073],
155
+ [-122.333978, 47.59073],
156
+ [-122.333975, 47.59216],
157
+ [-122.333739, 47.59216],
158
+ [-122.33374, 47.591759],
159
+ [-122.333776, 47.591759],
160
+ [-122.333776, 47.591748],
161
+ [-122.333683, 47.591723],
162
+ [-122.333451, 47.592099],
163
+ [-122.333388, 47.592119],
164
+ [-122.333409, 47.592138],
165
+ [-122.333293, 47.5922],
166
+ [-122.333231, 47.592154],
167
+ [-122.333131, 47.592213],
168
+ [-122.333256, 47.592215],
169
+ [-122.333251, 47.592271],
170
+ ],
171
+ },
58
172
  };
package/dist/strings.d.ts CHANGED
@@ -516,6 +516,10 @@ export interface KyogijoWords {
516
516
  stadiumOpening: string;
517
517
  stadiumBuilding: string;
518
518
  stadiumNone: string;
519
+ /** A ballpark's stands: {seats} seats fitted inside the outline of {stadium}'s building; and how the pitch or the infield says the field lies. */
520
+ stadiumPark: string;
521
+ stadiumPitch: string;
522
+ stadiumInfield: string;
519
523
  /** A district: a plan of {count} real venues at their real positions, {mode}; and what `mode` says for the two kinds of plan, and the word before the credit. */
520
524
  district: string;
521
525
  districtNeighbours: string;
package/dist/strings.js CHANGED
@@ -199,7 +199,7 @@ export const KYOGIJO_STRINGS = {
199
199
  },
200
200
  },
201
201
  scoreTitle: "{away} {awayScore}, {home} {homeScore}",
202
- said: { home: "home", away: "away", won: "{name} won.", tied: "The game is level.", park: "The outfield fence follows the figures published for {park}.", officially: "Its official name is {name}.", ground: "The pitch follows the size published for {ground}.", groundDefault: "No pitch size is published for {ground}, so the pitch has the international size of 105 by 68 metres.", arenaRink: "{arena}, hockey rink", arenaCourt: "{arena}, basketball court", arenaRinkAbout: "an ice hockey rink, {length} by {width} feet ({lengthM} by {widthM} metres) with rounded corners, with its red and blue lines, face-off circles, creases and goal nets", arenaCourtAbout: "a basketball court, {length} by {width} feet ({lengthM} by {widthM} metres), with its keys, three-point lines, free-throw circles and the centre circle", arenaSize: "The size is the one published for {arena}.", arenaUnknown: "The article says the rink of {arena} was shorter than the NHL's but gives no size, so the NHL's is drawn.", real: "The stands are a plausible bowl of {seats} seats in {tiers} tiers, fitted inside the outline of the building of {arena} from OpenStreetMap ((c) OpenStreetMap contributors); it is not the arena's plan.", realBuilding: "The rink lies along the building's long axis, which is an inference, and the arrow points north.", realMapped: "The rink lies as OpenStreetMap maps the ice, and the arrow points north.", realNone: "The rink's orientation is not known, so it is drawn east-west with north at the top.", stadium: "The stands are a plausible bowl of {seats} seats in {tiers} bowls, fitted inside the outline of the building of {stadium} from OpenStreetMap ((c) OpenStreetMap contributors); it is not the stadium's plan.", stadiumOpening: "The field lies along the opening in the roof, which is the size of the field, and the arrow points north.", stadiumBuilding: "The field lies along the building's long axis, which is an inference, and the arrow points north.", stadiumNone: "The field's orientation is not known, so it is drawn east-west with north at the top.", district: "A plan of {count} real venues at their real positions and orientations, from the outlines OpenStreetMap maps; {mode}.", districtNeighbours: "it is fitted close round the buildings", districtCity: "it keeps the real distances, with a grid of kilometres and a scale", districtCredit: "Credit:", era: "The markings are those of the {season} season.", hammer: "{name} has the hammer.", ends: "By end, {name}: {list}.", total: "Total", wonPens: "{name} won {a}-{b} on penalties.", pens: "{a}-{b} pens", on: "Drawn as", sep: ", ", stop: ".", colon: ": ", gap: " " },
202
+ said: { home: "home", away: "away", won: "{name} won.", tied: "The game is level.", park: "The outfield fence follows the figures published for {park}.", officially: "Its official name is {name}.", ground: "The pitch follows the size published for {ground}.", groundDefault: "No pitch size is published for {ground}, so the pitch has the international size of 105 by 68 metres.", arenaRink: "{arena}, hockey rink", arenaCourt: "{arena}, basketball court", arenaRinkAbout: "an ice hockey rink, {length} by {width} feet ({lengthM} by {widthM} metres) with rounded corners, with its red and blue lines, face-off circles, creases and goal nets", arenaCourtAbout: "a basketball court, {length} by {width} feet ({lengthM} by {widthM} metres), with its keys, three-point lines, free-throw circles and the centre circle", arenaSize: "The size is the one published for {arena}.", arenaUnknown: "The article says the rink of {arena} was shorter than the NHL's but gives no size, so the NHL's is drawn.", real: "The stands are a plausible bowl of {seats} seats in {tiers} tiers, fitted inside the outline of the building of {arena} from OpenStreetMap ((c) OpenStreetMap contributors); it is not the arena's plan.", realBuilding: "The rink lies along the building's long axis, which is an inference, and the arrow points north.", realMapped: "The rink lies as OpenStreetMap maps the ice, and the arrow points north.", realNone: "The rink's orientation is not known, so it is drawn east-west with north at the top.", stadium: "The stands are a plausible bowl of {seats} seats in {tiers} bowls, fitted inside the outline of the building of {stadium} from OpenStreetMap ((c) OpenStreetMap contributors); it is not the stadium's plan.", stadiumOpening: "The field lies along the opening in the roof, which is the size of the field, and the arrow points north.", stadiumBuilding: "The field lies along the building's long axis, which is an inference, and the arrow points north.", stadiumNone: "The field's orientation is not known, so it is drawn east-west with north at the top.", stadiumPark: "The stands are the usual stands of a ballpark, each band as deep as its seats need, for {seats} seats, fitted inside the outline of the building of {stadium} from OpenStreetMap ((c) OpenStreetMap contributors); it is not the park's plan.", stadiumPitch: "The field lies as OpenStreetMap maps the pitch, and the arrow points north.", stadiumInfield: "The way home plate faces is worked out from the infield OpenStreetMap maps and the building's outline, and the arrow points north.", district: "A plan of {count} real venues at their real positions and orientations, from the outlines OpenStreetMap maps; {mode}.", districtNeighbours: "it is fitted close round the buildings", districtCity: "it keeps the real distances, with a grid of kilometres and a scale", districtCredit: "Credit:", era: "The markings are those of the {season} season.", hammer: "{name} has the hammer.", ends: "By end, {name}: {list}.", total: "Total", wonPens: "{name} won {a}-{b} on penalties.", pens: "{a}-{b} pens", on: "Drawn as", sep: ", ", stop: ".", colon: ": ", gap: " " },
203
203
  budo: {
204
204
  sides: { north: "north", south: "south", east: "east", west: "west" },
205
205
  judo: "a judo contest area of {contest} by {contest} metres in a safety area {safety} metres wide, laid with tatami of {tatami}",
@@ -758,7 +758,7 @@ export const KYOGIJO_STRINGS = {
758
758
  },
759
759
  },
760
760
  scoreTitle: "{away} {awayScore}、{home} {homeScore}",
761
- said: { home: "ホーム", away: "アウェイ", won: "{name}の勝ちです。", tied: "同点です。", park: "外野フェンスは、{park}の公表されている距離をもとに描かれています。", officially: "正式名称は{name}です。", ground: "ピッチは、{ground}の公表されている広さをもとに描かれています。", groundDefault: "{ground}のピッチの広さは公表されていないため、国際標準の105×68mで描かれています。", arenaRink: "{arena}のアイスホッケーリンク", arenaCourt: "{arena}のバスケットボールコート", arenaRinkAbout: "アイスホッケーのリンク。{length}フィート(約{lengthM}m)×{width}フィート(約{widthM}m)で角が丸く、レッドライン、ブルーライン、フェイスオフサークル、クリース、ゴールがあります", arenaCourtAbout: "バスケットボールのコート。{length}フィート(約{lengthM}m)×{width}フィート(約{widthM}m)で、ペイントエリア、スリーポイントライン、フリースローサークル、センターサークルがあります", arenaSize: "広さは、{arena}の公表されている数値をもとに描かれています。", arenaUnknown: "{arena}のリンクはNHLの規格より短かったとされていますが、広さが示されていないため、NHLの規格で描かれています。", real: "スタンドは、OpenStreetMapの建物の輪郭((c) OpenStreetMap contributors)の内側に収まるように、{arena}の{seats}席・{tiers}層を推定して描いたもので、アリーナの設計図ではありません。", realBuilding: "リンクは建物の長い軸に沿って置いており(推定)、矢印は北を指します。", realMapped: "リンクの向きはOpenStreetMapに記録された氷面のとおりで、矢印は北を指します。", realNone: "リンクの向きは分かっていないため、東西に置き、北を上にしています。", stadium: "{stadium}の建物の輪郭(OpenStreetMap、(c) OpenStreetMap contributors)の内側に、{seats}席・{tiers}層のスタンドを推定して描いています。スタジアムの設計図ではありません。", stadiumOpening: "フィールドは屋根の開口部(フィールドと同じ大きさ)に沿って置いており、矢印は北を指します。", stadiumBuilding: "フィールドは建物の長い軸に沿って置いており(推定)、矢印は北を指します。", stadiumNone: "フィールドの向きは分かっていないため、東西に置き、北を上にしています。", district: "OpenStreetMapの建物の輪郭をもとに、{count}か所の実在の会場を実際の位置と向きのとおりに並べた配置図です。{mode}。", districtNeighbours: "建物のすぐ近くまで寄せて描いています", districtCity: "実際の距離を保ち、1km方眼と縮尺を添えています", districtCredit: "出典:", era: "ラインやマークは{season}シーズンのものです。", hammer: "{name}がハンマー(最後の1投)を持っています。", ends: "{name}のエンドごとの得点:{list}。", total: "合計", wonPens: "{name}がPK戦を{a}-{b}で制しました。", pens: "PK戦 {a}-{b}", on: "図の内容", sep: "、", stop: "。", colon: ":", gap: "" },
761
+ said: { home: "ホーム", away: "アウェイ", won: "{name}の勝ちです。", tied: "同点です。", park: "外野フェンスは、{park}の公表されている距離をもとに描かれています。", officially: "正式名称は{name}です。", ground: "ピッチは、{ground}の公表されている広さをもとに描かれています。", groundDefault: "{ground}のピッチの広さは公表されていないため、国際標準の105×68mで描かれています。", arenaRink: "{arena}のアイスホッケーリンク", arenaCourt: "{arena}のバスケットボールコート", arenaRinkAbout: "アイスホッケーのリンク。{length}フィート(約{lengthM}m)×{width}フィート(約{widthM}m)で角が丸く、レッドライン、ブルーライン、フェイスオフサークル、クリース、ゴールがあります", arenaCourtAbout: "バスケットボールのコート。{length}フィート(約{lengthM}m)×{width}フィート(約{widthM}m)で、ペイントエリア、スリーポイントライン、フリースローサークル、センターサークルがあります", arenaSize: "広さは、{arena}の公表されている数値をもとに描かれています。", arenaUnknown: "{arena}のリンクはNHLの規格より短かったとされていますが、広さが示されていないため、NHLの規格で描かれています。", real: "スタンドは、OpenStreetMapの建物の輪郭((c) OpenStreetMap contributors)の内側に収まるように、{arena}の{seats}席・{tiers}層を推定して描いたもので、アリーナの設計図ではありません。", realBuilding: "リンクは建物の長い軸に沿って置いており(推定)、矢印は北を指します。", realMapped: "リンクの向きはOpenStreetMapに記録された氷面のとおりで、矢印は北を指します。", realNone: "リンクの向きは分かっていないため、東西に置き、北を上にしています。", stadium: "{stadium}の建物の輪郭(OpenStreetMap、(c) OpenStreetMap contributors)の内側に、{seats}席・{tiers}層のスタンドを推定して描いています。スタジアムの設計図ではありません。", stadiumOpening: "フィールドは屋根の開口部(フィールドと同じ大きさ)に沿って置いており、矢印は北を指します。", stadiumBuilding: "フィールドは建物の長い軸に沿って置いており(推定)、矢印は北を指します。", stadiumNone: "フィールドの向きは分かっていないため、東西に置き、北を上にしています。", stadiumPark: "{stadium}の建物の輪郭(OpenStreetMap、(c) OpenStreetMap contributors)の内側に、{seats}席のスタンドを推定して描いています。球場の設計図ではありません。", stadiumPitch: "フィールドの向きはOpenStreetMapに記録されたピッチのとおりで、矢印は北を指します。", stadiumInfield: "ホームプレートの向きは、OpenStreetMapに記録された内野と建物の輪郭から推定しており、矢印は北を指します。", district: "OpenStreetMapの建物の輪郭をもとに、{count}か所の実在の会場を実際の位置と向きのとおりに並べた配置図です。{mode}。", districtNeighbours: "建物のすぐ近くまで寄せて描いています", districtCity: "実際の距離を保ち、1km方眼と縮尺を添えています", districtCredit: "出典:", era: "ラインやマークは{season}シーズンのものです。", hammer: "{name}がハンマー(最後の1投)を持っています。", ends: "{name}のエンドごとの得点:{list}。", total: "合計", wonPens: "{name}がPK戦を{a}-{b}で制しました。", pens: "PK戦 {a}-{b}", on: "図の内容", sep: "、", stop: "。", colon: ":", gap: "" },
762
762
  budo: {
763
763
  sides: { north: "北", south: "南", east: "東", west: "西" },
764
764
  judo: "柔道の試合場。試合場内は{contest}m四方、幅{safety}mの安全地帯があり、{tatami}の畳を敷いています",
@@ -1,6 +1,7 @@
1
1
  import { type TurnPreset, type TurnView } from "./turn.camera.ts";
2
2
  import { type TurnModel, type TurnSettings } from "./turn.model.ts";
3
3
  import type { ScoreOptions, SurfaceOptions } from "./types.ts";
4
+ import type { VenueSettings } from "./venue.ts";
4
5
  /** What `mountKyogijoTurn` gives back. `O` is what `update` takes: the options of a drawing, or (for `mountMultiTurn`) of a tournament layout. */
5
6
  export interface KyogijoTurn<O = SurfaceOptions | ScoreOptions> {
6
7
  /** `3d`, or `flat` where the browser cannot draw the 3D view (or there is nothing to turn): the flat drawing is shown and the rest of this does nothing. */
@@ -13,8 +14,13 @@ export interface KyogijoTurn<O = SurfaceOptions | ScoreOptions> {
13
14
  preset(name: TurnPreset): void;
14
15
  /** Back to the opening view. */
15
16
  reset(): void;
16
- /** Draw again with new options (a score that moved, a look that changed), keeping the view. */
17
- update(options: O): void;
17
+ /**
18
+ * Draw again with new options (a score that moved, a look that changed), keeping the view and the auto-turn: the view goes on turning. `change` alters what the venue's walls and roof stand on (the settings `venue` was
19
+ * mounted with, the parts given), so that a page can switch them on and off without mounting the view again.
20
+ */
21
+ update(options: O, change?: {
22
+ venue?: VenueSettings;
23
+ }): void;
18
24
  /** Whether the auto-turn is running. */
19
25
  readonly playing: boolean;
20
26
  /** Start the auto-turn, a slow turn of the whole view. It gives whether it is running (a surface that cannot turn gives `false`). Nothing turns by itself until this, the Play button or Space starts it, unless `autoPlay` asked. */
package/dist/turn.dom.js CHANGED
@@ -40,6 +40,8 @@ export function mountKyogijoTurn(host, options, settings = {}) {
40
40
  export function mountTurn(host, options, settings, build) {
41
41
  const doc = host.ownerDocument;
42
42
  const win = doc.defaultView;
43
+ /** The venue's settings as they are now: the mounted ones, altered by `update`. */
44
+ let venue = settings.venue;
43
45
  let model = build(options, settings);
44
46
  /** What names the four viewpoints: the model's own table (a tournament layout has one), else the sport's. */
45
47
  const sport = () => model.presets ?? model.options.sport;
@@ -61,8 +63,10 @@ export function mountTurn(host, options, settings, build) {
61
63
  play: () => false,
62
64
  pause: () => undefined,
63
65
  togglePlay: () => false,
64
- update: (next) => {
65
- model = build(next, settings);
66
+ update: (next, change) => {
67
+ if (change?.venue !== undefined)
68
+ venue = { ...(typeof venue === "object" ? venue : {}), ...change.venue };
69
+ model = build(next, { ...settings, id: settings.id ?? model.id, venue });
66
70
  flat(next);
67
71
  },
68
72
  svg: () => (alive ? host.querySelector("svg.kyogijo") : null),
@@ -436,10 +440,12 @@ export function mountTurn(host, options, settings, build) {
436
440
  },
437
441
  preset: toPreset,
438
442
  reset: toOpening,
439
- update(next) {
443
+ update(next, change) {
440
444
  if (!alive)
441
445
  return;
442
- model = build(next, { ...settings, preset: undefined, view });
446
+ if (change?.venue !== undefined)
447
+ venue = { ...(typeof venue === "object" ? venue : {}), ...change.venue };
448
+ model = build(next, { ...settings, id: settings.id ?? model.id, venue, preset: undefined, view });
443
449
  view = clampView(view);
444
450
  opening = openingView(settings, sport());
445
451
  const layout = layoutOf(model, shown, stage.clientWidth || 960, stage.clientHeight || 600, settings.lens);
package/dist/venue.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface VenueSettings {
8
8
  roof?: boolean;
9
9
  /** A retractable roof (a real venue's: BC Place's) drawn open, the fabric gathered into its pod, instead of closed over the opening. Closed by default. */
10
10
  roofOpen?: boolean;
11
+ /** The walls of a real venue's building (`crowd: { real: true }`), raised on its outline: they follow `roof` where left out (a roof and walls together, as before), and `walls: true` with `roof` off raises the walls alone, `roof: true` with `walls: false` the roof alone. */
12
+ walls?: boolean;
11
13
  /** How finely the curves are cut: `low` a facet to each corner, `auto` (the default) three, `high` six; an oval is 12, 28 or 56 facets round. */
12
14
  detail?: Cut;
13
15
  }
package/dist/venue.js CHANGED
@@ -116,16 +116,16 @@ export function standsVenueOf(stands, crowd, o, v, lift) {
116
116
  }
117
117
  // The low wall under the first row of a tier the walks do not lead to (a ballpark's wall seats stand on the fence, which the fence's own planes make).
118
118
  if (ring.tier !== "wall" && !chained.has(ring) && ring.height[0] > 0.5) {
119
- for (const strip of strips) {
119
+ strips.forEach((strip, si) => {
120
120
  for (let i = 0; i + 1 < strip.inner.length; i += 1) {
121
121
  const [a, b] = [strip.inner[i], strip.inner[i + 1]];
122
122
  if (Math.hypot(b[0] - a[0], b[1] - a[1]) < 0.5 || inCover([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]))
123
123
  continue;
124
124
  const h = lift + ring.height[0];
125
- const w = wall(`${ring.id}-front-${i}`, "fascia", a[0], a[1], b[0], b[1], 0, h, `<rect class="u-fascia" width="${n(Math.hypot(b[0] - a[0], b[1] - a[1]))}" height="${n(h)}"/>`);
125
+ const w = wall(`${ring.id}-front-${si === 0 ? "" : `s${si}-`}${i}`, "fascia", a[0], a[1], b[0], b[1], 0, h, `<rect class="u-fascia" width="${n(Math.hypot(b[0] - a[0], b[1] - a[1]))}" height="${n(h)}"/>`);
126
126
  seatPlanes.push(shy(w, centre));
127
127
  }
128
- }
128
+ });
129
129
  }
130
130
  }
131
131
  // The walks between the tiers: a plane from the outer edge of one tier up to the first row of the next.
@@ -150,8 +150,9 @@ export function standsVenueOf(stands, crowd, o, v, lift) {
150
150
  if (wallSeats.length > 0)
151
151
  items.push({ id: "wall-seats", kind: "wall-seats", at: centre, height: topOf(wallSeats), planes: wallSeats });
152
152
  // The roof: a canopy over the back of the top tier, or a lid over it in a hall. A real venue has its own: the walls of its building and a roof of its kind over them.
153
- if (stands.real !== undefined && v.roof === true)
154
- items.push(...realBuilding(stands.real, topRing(stands), centre, lift, ARC_STEPS[cut].oval * 2, v.roofOpen === true));
153
+ const [walls, roof] = [v.walls ?? v.roof === true, v.roof === true];
154
+ if (stands.real !== undefined && (walls || roof))
155
+ items.push(...realBuilding(stands.real, topRing(stands), centre, lift, ARC_STEPS[cut].oval * 2, v.roofOpen === true, { walls, roof }));
155
156
  const top = v.roof === true && crowd.roof && stands.real === undefined ? topRing(stands) : undefined;
156
157
  if (top !== undefined) {
157
158
  const lid = INDOOR.includes(String(o.sport));
@@ -8,10 +8,15 @@ export declare function saddleAt(real: Pick<RealShell, "footprint">, [x, y]: Pt)
8
8
  /** The height of the roof over a point of the building's edge, above the surface, in feet. */
9
9
  export declare const roofAt: (real: RealShell, p: Pt) => number;
10
10
  /**
11
- * The walls and the roof of a real venue. The hall is the building's outline cut to the bowl's wall and a margin (`RealShell.hall`): its rim is where a ray from the middle meets the one or the other first, in `steps`
11
+ * The walls and the roof of a real venue (`parts` says which are made). The hall is the building's outline cut to the bowl's wall and a margin (`RealShell.hall`): its rim is where a ray from the middle meets the one or the other first, in `steps`
12
12
  * rays. A wall stands on each stretch of the rim, up to the roof's height over it; the roof is a band from the rim in to the inner edge of the top tier (the middle stays open, as the 3D view's roofs are).
13
13
  */
14
- export declare function realBuilding(real: RealShell, top: StandRing | undefined, centre: readonly [number, number], lift: number, steps: number, open?: boolean): Items;
14
+ export declare function realBuilding(real: RealShell, top: StandRing | undefined, centre: readonly [number, number], lift: number, steps: number, open?: boolean, parts?: {
15
+ walls: boolean;
16
+ roof: boolean;
17
+ }): Items;
18
+ /** How far a canopy's low edge, the field's side of it, stands under its high edge: the OpenStreetMap roof's `roof:height` is the rise, and the canopy rises to the back of the stands (ours: the sources do not say which way it slopes). */
19
+ export declare const CANOPY_NOTE = "low over the field, high over the back of the stands";
15
20
  /** The pod in the middle of a cable roof's opening, as a share of the opening's size, and how thick it is, in feet (ours: the article says the fabric gathers into a pod above the video board, not how large it is). */
16
21
  export declare const POD: {
17
22
  readonly share: 0.24;