@johnmorrisdotca/kyogijo-stadiums 0.40.0 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ All notable changes to this project are written here. The format follows
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.40.2] - 2026-10-21
11
+
12
+ ### Fixed
13
+
14
+ - **BC Place and Lumen Field still differed between football and soccer, by a few pixels**: 0.40.1 made the frame the building's, but the view's origin still moved with the field, so the picture shifted and its margin changed, and the crowd's tiles started at the field's corner, so the people were a different pattern in each. A stadium of more than one configuration is now laid out in **the venue's own frame**, the middle of its field at the origin: the `viewBox` is the same text for every configuration, the building, the bowl and the crowd are the same numbers and the same tiles (`crowdStands(...).shift` is where the field's middle is in the surface's own frame, and the surface group is shifted by it), and the 3D view's floor, stands and what stands on the field are in the same frame (`uprightsFor` moves the field's things into it). Rendered at one size in a browser, the two configurations differ in no pixel outside the field's surround and the closed seats, in 2D and from overhead in 3D (`e2e/venue.demo.mjs`, Lumen Field too, light and dark, in Chromium and WebKit on macOS and Linux). In the dark the crowd's dimming (`opacity` on `.ky-crowd`) is kept on a layer of its own for a stadium of more than one configuration (`will-change: opacity`): without it Chromium on Linux rounded the dimmed crowd a level or two differently depending on what was drawn over it.
15
+ - **The seats a configuration does not open are closed in the same place every time**: BC Place's soccer pitch opens 52,497 of the bowl's 54,500 and Lumen Field's 37,722 of 68,740. A whole bowl that does not open is drawn as empty seats under a pale veil (`ring.closed`), and the seats left over are a block of the next bowl at the middle of the left side, as wide as comes nearest (`bowl.closedSectors`, a `closed` sector in the stands, painted in 2D and on the raked planes in 3D). It replaces the thinner crowd that the upper bowl had in 0.40.1; no random number is used.
16
+
17
+ ## [0.40.1] - 2026-10-21
18
+
19
+ ### Fixed
20
+
21
+ - **Switching a stadium between football and soccer reshaped the whole drawing** (BC Place, Lumen Field): the view was fitted to the field and the bowl was rebuilt from the configuration's seats, so the building's outline, the scale, the frame and the tint of the ground all changed. Now only the field changes. The view is the building's with a little ground round it, about the middle of the field (the same outline and scale for every configuration, in 2D and in 3D), the bowl is the building's and holds the most seats any configuration has (BC Place's soccer 52,497 opens from the lowest bowl up and draws the other 2,003 seats empty, as Lumen Field's does), and the ground round the building is the football field's beige whichever field lies in it (`data-stadium` on the drawing's root; the dark ground is one for every sport). `planStadium(...).total` for BC Place's soccer is now 54,500, the bowl's, with `open` the configuration's 52,497.
22
+ - **The stadiums page's buttons were the browser's own** (the venue buttons) and its field choice differed from the family's: the venues are chips and the field a segmented choice, as on the venues and home pages, 48 pixels high, with a pressed state, in light and dark, and on a phone the chips scroll sideways. A new demo test (`e2e/controls.demo.mjs`) fails on any visible button or select of any demo page that is left in the browser's own style, in light and dark.
23
+
10
24
  ## [0.40.0] - 2026-10-21
11
25
 
12
26
  ### Added
package/dist/crowd.d.ts CHANGED
@@ -48,11 +48,13 @@ export interface StandRing {
48
48
  seats: number;
49
49
  /** The aisles and vomitories across the band, each from a point on its inner edge to its outer: across the sides, fanning round the corners. */
50
50
  aisles: [Pt, Pt][];
51
+ /** The band is closed in this configuration (a real stadium's seats a configuration does not open): drawn as empty seats, with no people, in the same place every time. */
52
+ closed?: boolean;
51
53
  }
52
54
  /** A block of the stands that is not the home fans': the visitors', or a card display. A polygon in the wide frame. */
53
55
  export interface StandSector {
54
56
  ring: string;
55
- kind: "away" | "tifo-home" | "tifo-away";
57
+ kind: "away" | "tifo-home" | "tifo-away" | "closed";
56
58
  points: Pt[];
57
59
  }
58
60
  /** A place left open or built on: a tunnel through the lower stands, a bench, the camera platform of the media side. A box in the wide frame. */
@@ -96,6 +98,11 @@ export interface CrowdStands {
96
98
  gallery?: boolean;
97
99
  /** The stands are a real venue's (see `src/venues.ts`): the outline of its building, the way north lies on the page and the roof. */
98
100
  real?: RealShell;
101
+ /**
102
+ * A real stadium of more than one configuration is laid out in its own frame, the venue's, with the middle of its field at the origin whichever field it is: `shift` is where that middle is in the drawing's own
103
+ * frame (the surface's), so that what belongs to the field is drawn there and the building, the bowl and the crowd are the same numbers for every configuration.
104
+ */
105
+ shift?: [number, number];
99
106
  }
100
107
  /** The room above a real venue's building that the arrow saying where north is takes, in feet, and how far above the building's box the middle of the arrow stands. */
101
108
  export declare const NORTH_ROOM = 60;
@@ -150,6 +157,8 @@ export interface RealShell {
150
157
  /** The stands of a real venue, worked out from its building (`venueBowl`): what `layoutOf` takes in place of a sport's usual tiers. */
151
158
  export interface RealBowl {
152
159
  rings: StandRing[];
160
+ /** Blocks of a band a configuration closes (its seats beyond the configuration's own): drawn over the band as empty seats, in the same place every time. */
161
+ closedSectors?: StandSector[];
153
162
  /** How far the first row stands from the surface's edge at each side, in feet (the bench side has more). */
154
163
  off: {
155
164
  left: number;
@@ -220,8 +229,22 @@ export declare function rrectAt(o: Extract<Outline, {
220
229
  p: Pt;
221
230
  n: Pt;
222
231
  };
232
+ /** The aisles a band has, as parameters of its inner outline: one at each end of every straight side (about every 30 feet along it) and one in the middle of every corner. */
233
+ export declare function aisleParameters(hole: Extract<Outline, {
234
+ kind: "rrect";
235
+ }>): {
236
+ us: number[];
237
+ nh: number;
238
+ nv: number;
239
+ };
223
240
  /** The aisles of a band as lines from its inner edge to its outer. */
224
241
  export declare function aislesOf(region: Outline, hole: Outline | null): [Pt, Pt][];
242
+ /** The polygon of a band between two parameters, cut finely (a thirty-second of a side at a time) so that a block at a corner follows its curve as the band does. */
243
+ export declare function sectorPolygon(region: Extract<Outline, {
244
+ kind: "rrect";
245
+ }>, hole: Extract<Outline, {
246
+ kind: "rrect";
247
+ }>, u0: number, u1: number): Pt[];
225
248
  /** What the layout needs of the options and the surface. */
226
249
  export interface LayoutInput {
227
250
  sport: Sport;
@@ -3,7 +3,7 @@ import { type Crowd, type CrowdStands, type RealShell } from "./crowd.ts";
3
3
  export declare function patterns(id: string, crowd: Crowd, density: number, tifo: {
4
4
  home: boolean;
5
5
  away: boolean;
6
- } | undefined, neutral?: boolean, rows?: boolean): string;
6
+ } | undefined, neutral?: boolean, rows?: boolean, shut?: boolean): string;
7
7
  /** How the crowd is drawn: the prefix of ids, the options read, the share of seats taken, the detail, and the class of the ground the stands stand on. */
8
8
  export interface DrawInput {
9
9
  id: string;
@@ -52,11 +52,14 @@ function tile(seed, density, share, team, neutral = false) {
52
52
  return el("g", { fill: "none", "stroke-linecap": "round", "stroke-width": 2.3 }, out + headPaths);
53
53
  }
54
54
  /** The tiles a crowd needs: the home tile, the home tile of the upper tiers (emptier) and the visitors', and the display tiles. */
55
- export function patterns(id, crowd, density, tifo, neutral = false, rows = false) {
55
+ export function patterns(id, crowd, density, tifo, neutral = false, rows = false, shut = false) {
56
56
  // A 3D view's tiles carry the lines of the rows too (a row every third of a tile, along the tile's x), so that a raked plane is textured and not drawn seat by seat.
57
57
  const rowLines = rows ? el("path", { class: "ky-cr-row", d: `M0 .3H${TILE}M0 ${TILE / 3 + 0.3}H${TILE}M0 ${(2 * TILE) / 3 + 0.3}H${TILE}`, fill: "none" }) : "";
58
58
  const make = (suffix, body, size = TILE) => el("pattern", { id: `${id}-cr-${suffix}`, width: size, height: size, patternUnits: "userSpaceOnUse" }, size === TILE ? body + rowLines : body);
59
59
  let out = make("h", tile(crowd.seed * 7 + 1, density, neutral ? 0 : 0.88, "ky-cr-h", neutral)) + make("a", tile(crowd.seed * 7 + 3, Math.min(1, density * 1.04), 0.94, "ky-cr-a"));
60
+ // The seats of a closed band: the same seats, every one empty.
61
+ if (shut)
62
+ out += make("e", tile(crowd.seed * 7 + 5, 0, 0, "ky-cr-h", true));
60
63
  if (tifo !== undefined) {
61
64
  const stripes = (cls) => el("rect", { class: cls, width: 3, height: 6 }) + el("rect", { class: "ky-cr-tw", x: 3, width: 3, height: 6 });
62
65
  const checks = (cls) => el("rect", { class: cls, width: 3, height: 3 }) + el("rect", { class: cls, x: 3, y: 3, width: 3, height: 3 }) + el("rect", { class: "ky-cr-tw", x: 3, width: 3, height: 3 }) + el("rect", { class: "ky-cr-tw", y: 3, width: 3, height: 3 });
@@ -185,6 +188,11 @@ export function drawCrowd(stands, o) {
185
188
  // The bed of seats under the tile, so that no seam of the pattern shows light.
186
189
  const bed = stands.gallery === true ? "ky-cr-bed ky-bud-gbed" : "ky-cr-bed";
187
190
  // A hall's gallery, seen from above, is neat rows of seats along its band (a few people are not drawn: it is subtle).
191
+ if (ring.closed === true) {
192
+ // Closed seats: the bed and the same seats, every one empty, so that the closed part is the same drawing in every configuration that closes it.
193
+ body += el("path", { class: bed, d: path(ring) }) + (fine ? el("path", { class: "ky-cr-shut", d: path(ring), fill: `url(#${id}-cr-e)` }) + el("path", { class: "ky-cr-shut-veil", d: path(ring) }) : el("path", { class: "ky-cr-blk", d: path(ring), "fill-opacity": num(0.28) }));
194
+ continue;
195
+ }
188
196
  if (stands.gallery === true && fine)
189
197
  body += el("path", { class: bed, d: path(ring) }) + galleryRows(ring) + (density > 0 ? el("path", { class: "ky-cr-people", d: path(ring), fill: `url(#${id}-cr-h)` }) : "");
190
198
  else if (fine)
@@ -254,10 +262,15 @@ export function drawCrowd(stands, o) {
254
262
  const [x0, y0, x1, y1] = g.box;
255
263
  body += el("rect", { class: `ky-cr-${g.kind}`, x: n1(x0), y: n1(y0), width: n1(x1 - x0), height: n1(y1 - y0), rx: g.kind === "bench" ? 1 : undefined });
256
264
  }
257
- return { defs: (fine && (stands.gallery !== true || density > 0) ? patterns(id, crowd, density, tifo.home || tifo.away ? tifo : undefined, stands.gallery === true || stands.real !== undefined) : "") + pool, markup: el("g", { class: light === undefined ? "ky-crowd" : light.house === "dark" ? "ky-crowd ky-night ky-down" : light.house === "half" ? "ky-crowd ky-night ky-half" : "ky-crowd ky-night" }, body) + lit, over };
265
+ return { defs: (fine && (stands.gallery !== true || density > 0) ? patterns(id, crowd, density, tifo.home || tifo.away ? tifo : undefined, stands.gallery === true || stands.real !== undefined, false, stands.rings.some((r) => r.closed === true) || stands.sectors.some((s) => s.kind === "closed")) : "") + pool, markup: el("g", { class: light === undefined ? "ky-crowd" : light.house === "dark" ? "ky-crowd ky-night ky-down" : light.house === "half" ? "ky-crowd ky-night ky-half" : "ky-crowd ky-night" }, body) + lit, over };
258
266
  }
259
267
  /** A block of visitors or a card display, a polygon over the band it is in. */
260
268
  function sector(s, id, fine, density) {
269
+ // A block of closed seats: the bed and the same seats, every one empty.
270
+ if (s.kind === "closed") {
271
+ const d = `M${s.points.map(([x, y]) => `${n1(x)} ${n1(y)}`).join("L")}Z`;
272
+ return el("path", { class: "ky-cr-bed", d }) + (fine ? el("path", { class: "ky-cr-shut", d, fill: `url(#${id}-cr-e)` }) + el("path", { class: "ky-cr-shut-veil", d }) : el("path", { class: "ky-cr-blk", d, "fill-opacity": num(0.28) }));
273
+ }
261
274
  if (s.kind === "away")
262
275
  return fine ? el("path", { class: "ky-cr-people", d: poly(s.points), fill: `url(#${id}-cr-a)` }) : el("path", { class: "ky-cr-blk-a", d: poly(s.points), "fill-opacity": num(0.3 + 0.6 * density) });
263
276
  const team = s.kind === "tifo-home" ? "th" : "ta";
@@ -270,6 +283,7 @@ export const KYOGIJO_CROWD_STYLE = `
270
283
  :where(html[data-theme=dark] .kyogijo,.kyogijo[data-theme=dark]){--ky-cr-bed:#242a32;--ky-cr-empty:#4a535f;--ky-cr-gap:#050709;--ky-cr-conc:#2a3038;--ky-cr-rim:#07090c;--ky-cr-dim:.82}
271
284
  .kyogijo .ky-crowd{opacity:var(--ky-cr-dim)}
272
285
  .kyogijo .ky-cr-bed{fill:var(--ky-cr-bed);stroke:var(--ky-cr-rim);stroke-width:1.8;stroke-opacity:.55;stroke-linejoin:round}
286
+ .kyogijo .ky-cr-shut-veil{fill:var(--ky-cr-empty);fill-opacity:.42}
273
287
  .kyogijo .ky-cr-seat{fill:none;stroke:var(--ky-cr-empty);stroke-width:1.3;stroke-opacity:.65}
274
288
  .kyogijo .ky-cr-h{stroke:var(--ky-cr-home,var(--ky-right))}
275
289
  .kyogijo .ky-cr-a{stroke:var(--ky-cr-away,var(--ky-left))}
package/dist/crowd.js CHANGED
@@ -274,7 +274,7 @@ export function rrectAt(o, u) {
274
274
  }
275
275
  }
276
276
  /** The aisles a band has, as parameters of its inner outline: one at each end of every straight side (about every 30 feet along it) and one in the middle of every corner. */
277
- function aisleParameters(hole) {
277
+ export function aisleParameters(hole) {
278
278
  const r = Math.min(hole.r, (hole.x1 - hole.x0) / 2, (hole.y1 - hole.y0) / 2);
279
279
  const nh = Math.max(2, Math.round((hole.x1 - hole.x0 - 2 * r) / 30));
280
280
  const nv = Math.max(2, Math.round((hole.y1 - hole.y0 - 2 * r) / 30));
@@ -304,6 +304,12 @@ export function aislesOf(region, hole) {
304
304
  function galleryAisles(region, hole, shares) {
305
305
  return [0, 2, 4, 6].flatMap((side) => shares.map((f) => [rrectAt(hole, side + f).p, rrectAt(region, side + f).p]));
306
306
  }
307
+ /** The polygon of a band between two parameters, cut finely (a thirty-second of a side at a time) so that a block at a corner follows its curve as the band does. */
308
+ export function sectorPolygon(region, hole, u0, u1) {
309
+ const steps = Math.max(2, Math.ceil((u1 - u0) * 32));
310
+ const us = Array.from({ length: steps + 1 }, (_, i) => u0 + ((u1 - u0) * i) / steps);
311
+ return [...us.map((u) => rrectAt(region, u).p), ...us.map((u) => rrectAt(hole, u).p).reverse()];
312
+ }
307
313
  /** The polygon of a band between two aisle parameters: along the outer edge, then back along the inner. */
308
314
  function sectionBetween(region, hole, u0, u1) {
309
315
  const us = [u0];
@@ -394,6 +400,8 @@ export function layoutOf(input) {
394
400
  const stands = { sport, shape, box, rings, covers, concourses, sectors: [], gaps: [], reach: reachOf, seats: rings.reduce((n, r) => n + r.seats, 0) };
395
401
  if (input.real !== undefined)
396
402
  stands.real = input.real.shell;
403
+ if (input.real?.closedSectors !== undefined)
404
+ stands.sectors.push(...input.real.closedSectors);
397
405
  if (stadium !== undefined)
398
406
  stands.stadium = stadium;
399
407
  if (gal !== undefined)
package/dist/draw.js CHANGED
@@ -11,7 +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 { planPark, stadiumBowl, stadiumById, stadiumConfigOf, stadiumKind, stadiumNames, stadiumSeats } from "./stadiums.js";
14
+ import { KYOGIJO_STADIUM_STYLE, planPark, stadiumBowl, stadiumById, stadiumConfigOf, stadiumConfigs, stadiumKind, stadiumNames, stadiumSeats } from "./stadiums.js";
15
15
  import { groundById, groundNameIn, groundOfficialName } from "./grounds.js";
16
16
  import { BOARD, courtFor, hasSetsBoard, isNetLeague, isNetSport } from "./court.js";
17
17
  import { cricketBoardMarkup } from "./cricket.board.js";
@@ -457,8 +457,13 @@ function standsFor(o, surface, crowd) {
457
457
  return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: venueBowl(venue, box) });
458
458
  // A football field or a soccer pitch in a real stadium: its building and a bowl of the configuration's seats.
459
459
  const real = realStadiumOf(o, sport, crowd.real);
460
- if (real !== undefined)
461
- return crowdLayout({ sport, box, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: stadiumBowl(real.stadium, real.config, box) });
460
+ if (real !== undefined) {
461
+ const stands = (at) => crowdLayout({ sport, box: at, crowd: crowd.awayEnd === "auto" ? { ...crowd, awayEnd: "none" } : crowd, awaySide, real: stadiumBowl(real.stadium, real.config, at) });
462
+ if (!venueFrame(o, crowd, surface))
463
+ return stands(box);
464
+ // In the venue's frame the middle of the field is the origin, so the same building, bowl and crowd are laid out whichever field is in it.
465
+ return { ...stands({ x: -box.w / 2, y: -box.h / 2, w: box.w, h: box.h }), shift: [box.x + box.w / 2, box.y + box.h / 2] };
466
+ }
462
467
  // 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.
463
468
  const hall = sport === "dojo" ? dojoGallery(o) : undefined;
464
469
  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 };
@@ -469,6 +474,20 @@ const smallDrawing = (o) => {
469
474
  const w = typeof o.width === "number" ? o.width : typeof o.width === "string" && /^\s*\d+(?:\.\d+)?(?:px)?\s*$/.test(o.width) ? parseFloat(o.width) : undefined;
470
475
  return o.detail === "simple" || (w !== undefined && w < 480);
471
476
  };
477
+ /** The ground, in feet, a stadium's frame holds beyond its building. */
478
+ const VENUE_PAD = 6;
479
+ /** Whether the options draw a real stadium of more than one configuration (a football field and a soccer pitch in one building): its frame is the building's, not the field's. */
480
+ function stadiumHasConfigs(o, crowd) {
481
+ const real = realStadiumOf(o, chooseLeague(o).sport, crowd.real);
482
+ return real !== undefined && stadiumKind(real.stadium) === "stadium" && stadiumConfigs(real.stadium).length > 1;
483
+ }
484
+ /**
485
+ * Whether a stadium of more than one configuration is laid out in the venue's own frame: its field is placed in it, and the building, the bowl, the crowd and the view are the same numbers whichever field it is. Not for a portrait drawing,
486
+ * a cropped one, or one that asks for annotations, a situation, a pageant or the pointer, which are worked out in the surface's own frame.
487
+ */
488
+ function venueFrame(o, crowd, surface) {
489
+ return surface.turn === undefined && o.range === undefined && o.situation === undefined && o.pageant === undefined && o.pointer !== true && !annotated(o) && stadiumHasConfigs(o, crowd);
490
+ }
472
491
  /** A surface with the stands and the crowd round it: the ground is made as wide as the stands need, then the stands are laid on it under the surface. */
473
492
  function buildCrowded(o, portrait, id, score, scored, extra, crowd) {
474
493
  let margin = extra.margin;
@@ -491,6 +510,22 @@ function buildCrowded(o, portrait, id, score, scored, extra, crowd) {
491
510
  // A gallery is empty unless the options say how full: its seats are the detail.
492
511
  const taken = stands.gallery === true && crowd.density === undefined && crowd.attendance === undefined ? 0 : densityOf(crowd, seats, ground);
493
512
  const drawn = drawCrowd(stands, { id, crowd, density: taken, fine, ground: bg, team: teamColors(o) });
513
+ if (stands.shift !== undefined) {
514
+ // A stadium of more than one configuration: the ground, the building, the stands and the crowd are laid in the venue's own frame (the middle of the field at the origin), under a surface that is shifted to lie in it, and the view is the
515
+ // building's with a little ground round it: the same frame, the same numbers, the same crowd whichever field is drawn. What stands over the field comes back to the surface's frame.
516
+ const [sx, sy] = stands.shift;
517
+ const { reach, box } = stands;
518
+ const [left, right, top, bottom] = [box.w / 2 + reach.left, box.w / 2 + reach.right, box.h / 2 + reach.top, box.h / 2 + reach.bottom].map((d) => Math.round((d + VENUE_PAD) * 100) / 100);
519
+ const view = { x: -left, y: -top, w: left + right, h: top + bottom };
520
+ const apron = first.replace(/\sx="[^"]*"/, ` x="${view.x - 1}"`).replace(/\sy="[^"]*"/, ` y="${view.y - 1}"`).replace(/\swidth="[^"]*"/, ` width="${view.w + 2}"`).replace(/\sheight="[^"]*"/, ` height="${view.h + 2}"`);
521
+ surface.venue = { shift: [sx, sy], under: apron + drawn.markup };
522
+ surface.base = surface.base.slice(first.length);
523
+ surface.view = view;
524
+ surface.defs += drawn.defs;
525
+ surface.lines += drawn.over === "" ? "" : el("g", { transform: `translate(${sx.toFixed(4)} ${sy.toFixed(4)})` }, drawn.over);
526
+ surface.crowdMargin = margin;
527
+ return surface;
528
+ }
494
529
  surface.base = first + drawn.markup + surface.base.slice(first.length);
495
530
  surface.defs += drawn.defs;
496
531
  surface.lines += drawn.over;
@@ -930,6 +965,9 @@ function drawnKyogijo(o) {
930
965
  const walled = sport === "mlb" && ballparkDrawn(ballparkFor(o));
931
966
  const hardwood = sport === "nba" && o.court?.hardwood !== undefined && o.court.hardwood !== false;
932
967
  const crowded = crowdOf(o) !== undefined;
968
+ // The stadium a drawing is in, where it has more than one configuration: its ground is its own, whichever field lies in it (the stylesheet reads it).
969
+ const crowdHere = crowdOf(o);
970
+ const stadiumOfDrawing = crowdHere !== undefined && stadiumHasConfigs(o, crowdHere) ? realStadiumOf(o, sport, crowdHere.real)?.stadium.id : undefined;
933
971
  const style = colourStyle(o, { left: leftTeam, right: rightTeam, home: isScore(o) ? o.home : undefined, away: isScore(o) ? o.away : undefined }, featured);
934
972
  const palette = isLook(o.look) ? o.look : undefined;
935
973
  const budoLook = isMatSport(sport) ? budoLookOf(sport, o.budo?.look ?? (sport === "dojo" ? dojoSpecOf(o.dojo).look : undefined)) : undefined;
@@ -994,8 +1032,10 @@ function drawnKyogijo(o) {
994
1032
  if (lines !== undefined)
995
1033
  surface.lines = restyleLines(surface.lines, lines);
996
1034
  // A ground (a pattern, a colour or a photograph) is laid on the apron, under the surface.
997
- const ground = laidOn(surface.base, o.background, id);
998
- grounded = ground.base !== surface.base;
1035
+ // A stadium laid out in the venue's frame has its ground, building and stands under the surface, which is shifted to lie in it.
1036
+ const venue = surface.venue;
1037
+ const ground = laidOn(venue === undefined ? surface.base : venue.under, o.background, id);
1038
+ grounded = ground.base !== (venue === undefined ? surface.base : venue.under);
999
1039
  defs = surface.defs + ground.defs;
1000
1040
  // The situation: a tint under the lines, and the rest over them and the data (cut with the surface when it is cropped).
1001
1041
  const sit = situation === undefined
@@ -1009,7 +1049,7 @@ function drawnKyogijo(o) {
1009
1049
  strip: stripped && sized === "compact",
1010
1050
  });
1011
1051
  const cutSit = (markup) => (markup !== "" && surface.defs.includes(`id="${id}-rg"`) ? el("g", { "clip-path": `url(#${id}-rg)` }, markup) : markup);
1012
- let inner = ground.base + (sit === undefined ? "" : cutSit(sit.under));
1052
+ let inner = (venue === undefined ? ground.base : surface.base) + (sit === undefined ? "" : cutSit(sit.under));
1013
1053
  if (sit !== undefined)
1014
1054
  defs += sit.defs;
1015
1055
  let teams = "";
@@ -1093,7 +1133,8 @@ function drawnKyogijo(o) {
1093
1133
  else
1094
1134
  inner += plateMarkup(surface.plate, left, right, { competition: o.competition, foot: foot === "" ? undefined : foot }, { turned: surface.turned, parts, surfaceSport: sport });
1095
1135
  }
1096
- const group = el("g", { class: "ky-surface-group", transform: surface.turn, part: parts ? "surface" : undefined, ...(o.pointer === true ? fieldAttributes({ ...o, sport }) : {}) }, inner);
1136
+ const field = el("g", { class: "ky-surface-group", transform: venue === undefined ? surface.turn : `translate(${(-venue.shift[0]).toFixed(4)} ${(-venue.shift[1]).toFixed(4)})`, part: parts ? "surface" : undefined, ...(o.pointer === true ? fieldAttributes({ ...o, sport }) : {}) }, inner);
1137
+ const group = venue === undefined ? field : ground.base + field;
1097
1138
  const v = surface.view;
1098
1139
  if (isScore(o) && left !== undefined && right !== undefined && sized === "compact") {
1099
1140
  const badge = badgeMarkup(left, right, { sport, status, parts, id, situation: situation === undefined ? undefined : badgeSituation(situation, language) });
@@ -1122,6 +1163,7 @@ function drawnKyogijo(o) {
1122
1163
  role: "img",
1123
1164
  "aria-labelledby": `${titleId} ${descId}`,
1124
1165
  "data-sport": sport,
1166
+ "data-stadium": stadiumOfDrawing,
1125
1167
  "data-league": o.league === undefined || o.league === "" ? (chooseLeague(o).league?.id ?? undefined) : o.league,
1126
1168
  "data-range": isScore(o) && sport === "curling" ? undefined : rangeLabel(o),
1127
1169
  "data-event": o.host !== undefined && typeof o.event === "string" ? safeClass(o.event) : undefined,
@@ -1140,7 +1182,7 @@ function drawnKyogijo(o) {
1140
1182
  part: parts ? "svg" : undefined,
1141
1183
  style: style === "" ? undefined : style,
1142
1184
  }, `<title id="${titleId}">${esc(title)}</title><desc id="${descId}">${esc(description)}</desc>` +
1143
- (o.style === "none" ? "" : `<style>${KYOGIJO_BASE_STYLE}${left?.round === true || right?.round === true || (pg !== undefined && pageant?.style === "stage") || (pg !== undefined && pageant?.emblem?.shape !== "plain" && pageant?.emblem !== undefined) ? `\n${KYOGIJO_MARK_STYLE}` : ""}${pg === undefined ? "" : `\n${KYOGIJO_PAGEANT_STYLE}${pageant?.animate === undefined || pageant.animate === "none" ? "" : `\n${KYOGIJO_PAGEANT_MOTION}`}`}${sport === "curling" ? `\n${KYOGIJO_CURLING_STYLE}` : ""}${foldedSheet ? `\n${KYOGIJO_FOLD_STYLE}` : ""}${isPoolSport(sport) ? `\n${o.pool?.venue !== undefined ? KYOGIJO_POOL_STYLE : poolStyle({ sport, ...commonOf(o.pool ?? {}), race: o.pool?.race !== undefined, section: o.pool?.section !== undefined && o.pool.section !== false })}` : ""}${plan === undefined ? "" : `\n${courtStyle(plan.sport, plan.look)}`}${fieldsStyle(sport) === "" ? "" : `\n${fieldsStyle(sport)}`}${athleticsStyle(sport) === "" ? "" : `\n${athleticsStyle(sport)}`}${skatingStyle(sport) === "" ? "" : `\n${skatingStyle(sport)}`}${budoLook === undefined ? "" : `\n${budoStyle(sport, budoLook)}`}${palette === undefined ? "" : `\n${lookStyle(palette)}`}${athleticsTrackRules(sport, o.athletics?.track) === "" ? "" : `\n${athleticsTrackRules(sport, o.athletics?.track)}`}${walled ? `\n${KYOGIJO_WALLS_STYLE}` : ""}${hardwood ? `\n${KYOGIJO_HARDWOOD_STYLE}` : ""}${features === undefined ? "" : `\n${featureStyle(sport, features)}`}${notes === undefined || notes.svg === "" ? "" : `\n${KYOGIJO_NOTE_STYLE}`}${grounded ? `\n${KYOGIJO_BACKGROUND_STYLE}` : ""}${crowded ? `\n${KYOGIJO_CROWD_STYLE}` : ""}${crowded && sport === "dojo" ? KYOGIJO_BUDO_GALLERY_STYLE : ""}${situation === undefined ? "" : `\n${KYOGIJO_SITUATION_STYLE}`}${o.host === undefined ? "" : `\n${specialStyle(hostSport(o.host))}`}</style>`) +
1185
+ (o.style === "none" ? "" : `<style>${KYOGIJO_BASE_STYLE}${left?.round === true || right?.round === true || (pg !== undefined && pageant?.style === "stage") || (pg !== undefined && pageant?.emblem?.shape !== "plain" && pageant?.emblem !== undefined) ? `\n${KYOGIJO_MARK_STYLE}` : ""}${pg === undefined ? "" : `\n${KYOGIJO_PAGEANT_STYLE}${pageant?.animate === undefined || pageant.animate === "none" ? "" : `\n${KYOGIJO_PAGEANT_MOTION}`}`}${sport === "curling" ? `\n${KYOGIJO_CURLING_STYLE}` : ""}${foldedSheet ? `\n${KYOGIJO_FOLD_STYLE}` : ""}${isPoolSport(sport) ? `\n${o.pool?.venue !== undefined ? KYOGIJO_POOL_STYLE : poolStyle({ sport, ...commonOf(o.pool ?? {}), race: o.pool?.race !== undefined, section: o.pool?.section !== undefined && o.pool.section !== false })}` : ""}${plan === undefined ? "" : `\n${courtStyle(plan.sport, plan.look)}`}${fieldsStyle(sport) === "" ? "" : `\n${fieldsStyle(sport)}`}${athleticsStyle(sport) === "" ? "" : `\n${athleticsStyle(sport)}`}${skatingStyle(sport) === "" ? "" : `\n${skatingStyle(sport)}`}${budoLook === undefined ? "" : `\n${budoStyle(sport, budoLook)}`}${palette === undefined ? "" : `\n${lookStyle(palette)}`}${athleticsTrackRules(sport, o.athletics?.track) === "" ? "" : `\n${athleticsTrackRules(sport, o.athletics?.track)}`}${walled ? `\n${KYOGIJO_WALLS_STYLE}` : ""}${hardwood ? `\n${KYOGIJO_HARDWOOD_STYLE}` : ""}${features === undefined ? "" : `\n${featureStyle(sport, features)}`}${notes === undefined || notes.svg === "" ? "" : `\n${KYOGIJO_NOTE_STYLE}`}${grounded ? `\n${KYOGIJO_BACKGROUND_STYLE}` : ""}${stadiumOfDrawing === undefined ? "" : `\n${KYOGIJO_STADIUM_STYLE}`}${crowded ? `\n${KYOGIJO_CROWD_STYLE}` : ""}${crowded && sport === "dojo" ? KYOGIJO_BUDO_GALLERY_STYLE : ""}${situation === undefined ? "" : `\n${KYOGIJO_SITUATION_STYLE}`}${o.host === undefined ? "" : `\n${specialStyle(hostSport(o.host))}`}</style>`) +
1144
1186
  (defs === "" ? "" : `<defs>${defs}</defs>`) +
1145
1187
  body);
1146
1188
  return root;
@@ -181,6 +181,11 @@ export declare const stadiumOrientation: (stadium: RealStadium) => StadiumOrient
181
181
  export declare const stadiumConfigOf: (sport: string | undefined) => StadiumConfigId | undefined;
182
182
  /** The seats of a stadium for a configuration (0 for one it has none of). */
183
183
  export declare const stadiumSeats: (stadium: RealStadium, config: StadiumConfigId) => number;
184
+ /**
185
+ * The stylesheet of a stadium that stages more than one field (`data-stadium` on the drawing's root): the ground round the building is its own, the football field's, whichever field lies in it, so that a soccer pitch does not tint it
186
+ * green (light only: the dark ground is one for every sport), and the crowd's dimming in the dark kept on a layer of its own.
187
+ */
188
+ export declare const KYOGIJO_STADIUM_STYLE: string;
184
189
  /** How far a hall's first row is from the box of the ring, on every side, in feet (ours: the seats at the ringside are a few feet from the ropes). */
185
190
  export declare const HALL_FIRST_ROW = 8;
186
191
  /** 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. */
@@ -225,7 +230,7 @@ export interface StadiumPlan {
225
230
  * import { planStadium, stadiumById } from "@johnmorrisdotca/kyogijo-stadiums/stadiums";
226
231
  * const plan = planStadium(stadiumById("bc-place")!, "soccer", { x: 0, y: 0, w: 344.5, h: 223.1 });
227
232
  * console.log(plan.orientation.basis, plan.seats.map((t) => `${t.tier} ${t.seats}`).join(", "), plan.total);
228
- * // opening lower 27695, upper 24802 52497
233
+ * // opening lower 27695, upper 26805 54500
229
234
  * ```
230
235
  */
231
236
  export declare function planStadium(stadium: RealStadium, config: StadiumConfigId, box: Box): StadiumPlan;
package/dist/stadiums.js CHANGED
@@ -3,7 +3,7 @@
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
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
5
  // in `buildTurn`.
6
- import { NORTH_ROOM, aislesOf, areaOfOutline, outlinePoints } from "./crowd.js";
6
+ import { NORTH_ROOM, aisleParameters, aislesOf, areaOfOutline, outlinePoints, sectorPolygon } from "./crowd.js";
7
7
  import { parkLayout } from "./crowd.park.js";
8
8
  import { FEET_PER_METRE, FOOTBALL } from "./geometry.js";
9
9
  import { groundById } from "./grounds.js";
@@ -59,6 +59,15 @@ export const stadiumOrientation = (stadium) => ({ heading: stadium.osm.axis.head
59
59
  export const stadiumConfigOf = (sport) => (sport === "cfl" || sport === "nfl" || sport === "soccer" || sport === "mlb" || sport === "boxing" ? sport : undefined);
60
60
  /** The seats of a stadium for a configuration (0 for one it has none of). */
61
61
  export const stadiumSeats = (stadium, config) => stadium.configs[config]?.seats ?? 0;
62
+ /**
63
+ * The stylesheet of a stadium that stages more than one field (`data-stadium` on the drawing's root): the ground round the building is its own, the football field's, whichever field lies in it, so that a soccer pitch does not tint it
64
+ * green (light only: the dark ground is one for every sport), and the crowd's dimming in the dark kept on a layer of its own.
65
+ */
66
+ export const KYOGIJO_STADIUM_STYLE =
67
+ // The crowd dims in the dark as a group (`opacity`): on a layer of its own it is drawn the same whatever is drawn over it (the field), so that two configurations of a stadium are the same crowd to the last level of a pixel.
68
+ ".kyogijo[data-stadium] .ky-crowd{will-change:opacity}\n" +
69
+ "@media (prefers-color-scheme:light){:where(html:not([data-theme=dark]) .kyogijo[data-sport=soccer][data-stadium]:not([data-theme=dark])){--ky-apron:#dcd8c9}}\n" +
70
+ ":where(html[data-theme=light] .kyogijo[data-sport=soccer][data-stadium],.kyogijo[data-theme=light][data-sport=soccer][data-stadium]){--ky-apron:#dcd8c9}";
62
71
  // ---- The bowl. ----
63
72
  /** 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. */
64
73
  const FIRST_ROW = { cfl: { side: 42, end: 28, corner: 60 }, nfl: { side: 40, end: 26, corner: 60 } };
@@ -94,6 +103,42 @@ function solveFor(f, lo, hi, target) {
94
103
  }
95
104
  return (a + b) / 2;
96
105
  }
106
+ /**
107
+ * The bowl with `closedSeats` of its seats closed, the ones a configuration does not open: whole bands from the last (outermost) inward, marked `closed` and drawn as empty seats, and where the seats left to close are part of a band, a block of
108
+ * that band at the middle of its left side (an end of the field, where the long axis ends), as many seats as comes nearest, drawn as empty seats over it. The same place every time: no random number. `area` is
109
+ * the floor a seat takes in the plan.
110
+ */
111
+ function shut(rings, closedSeats, area) {
112
+ const out = rings.map((ring) => ({ ...ring }));
113
+ const sectors = [];
114
+ let left = closedSeats;
115
+ for (let k = out.length - 1; k >= 0 && left > 0; k -= 1) {
116
+ const ring = out[k];
117
+ if (left >= ring.seats - 2) {
118
+ ring.fill = 0;
119
+ ring.closed = true;
120
+ left -= ring.seats;
121
+ continue;
122
+ }
123
+ if (ring.region.kind === "rrect" && ring.hole?.kind === "rrect") {
124
+ const [region, hole] = [ring.region, ring.hole];
125
+ // A block centred on the middle of the left side (6.5), widened by a tenth of a side's length (about 30 feet) at a time, as wide as comes nearest to the seats to close.
126
+ const step = 1 / Math.max(aisleParameters(hole).nv, 2);
127
+ const seatsOf = (w) => Math.round(areaOfOutline({ kind: "poly", points: sectorPolygon(region, hole, 6.5 - w / 2, 6.5 + w / 2) }) / area);
128
+ let width = step;
129
+ let made = seatsOf(width);
130
+ for (let i = 0; made < left && width + step < 8; i += 1) {
131
+ const next = seatsOf(width + step);
132
+ if (Math.abs(next - left) > Math.abs(made - left))
133
+ break;
134
+ [width, made] = [width + step, next];
135
+ }
136
+ sectors.push({ ring: ring.id, kind: "closed", points: sectorPolygon(region, hole, 6.5 - width / 2, 6.5 + width / 2) });
137
+ }
138
+ left = 0;
139
+ }
140
+ return { rings: out, sectors };
141
+ }
97
142
  /** Plans already made, by stadium, configuration and field: a plan is a pure function of them, and a drawing asks for it more than once. */
98
143
  const PLANS = new WeakMap();
99
144
  /**
@@ -105,7 +150,7 @@ const PLANS = new WeakMap();
105
150
  * import { planStadium, stadiumById } from "@johnmorrisdotca/kyogijo-stadiums/stadiums";
106
151
  * const plan = planStadium(stadiumById("bc-place")!, "soccer", { x: 0, y: 0, w: 344.5, h: 223.1 });
107
152
  * console.log(plan.orientation.basis, plan.seats.map((t) => `${t.tier} ${t.seats}`).join(", "), plan.total);
108
- * // opening lower 27695, upper 24802 52497
153
+ * // opening lower 27695, upper 26805 54500
109
154
  * ```
110
155
  */
111
156
  export function planStadium(stadium, config, box) {
@@ -133,6 +178,8 @@ function plan(stadium, config, rink) {
133
178
  const place = (ring) => ring.map(([x, y]) => [round((x - middle[0]) * FEET_PER_METRE + at[0], 2), round((y - middle[1]) * FEET_PER_METRE + at[1], 2)]);
134
179
  const footprint = place(outline);
135
180
  const fb = boxOf(footprint);
181
+ // How far the building reaches from the middle of the field on each side, worked out from the outline as it is (not as placed and rounded): one answer for every configuration, which is the stadium's frame.
182
+ const around = boxOf(outline.map(([x, y]) => [(x - middle[0]) * FEET_PER_METRE, (y - middle[1]) * FEET_PER_METRE]));
136
183
  const openingRing = stadium.osm.roof?.opening?.ring;
137
184
  const opening = openingRing === undefined ? undefined : place(turned(openingRing));
138
185
  // 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.
@@ -163,7 +210,8 @@ function plan(stadium, config, rink) {
163
210
  const depth = (first.x0 - outer.x0) + (first.y0 - outer.y0) + (outer.x1 - first.x1) + (outer.y1 - first.y1);
164
211
  const gap = GAP / Math.max(20, depth / 4);
165
212
  const open = stadium.configs[config]?.seats ?? 0;
166
- const seatsTotal = stadium.bowlSeats ?? open;
213
+ // The bowl is the building's, the same for every configuration: it holds the most seats any of them opens (or the seats the data gives the bowl), and a configuration that opens fewer closes the rest.
214
+ const seatsTotal = stadium.bowlSeats ?? Math.max(...Object.values(stadium.configs).map((c) => c?.seats ?? 0));
167
215
  const count = Math.min(3, stadium.tiers.count);
168
216
  const lower = stadium.tiers.lowerSeats === undefined ? (count === 1 ? 1 : count === 2 ? LOWER_SHARE : 0.36) : stadium.tiers.lowerSeats / seatsTotal;
169
217
  const shares = count === 1 ? [1] : count === 2 ? [lower, 1 - lower] : [lower, (1 - lower) * 0.36, (1 - lower) * 0.64];
@@ -235,14 +283,8 @@ function plan(stadium, config, rink) {
235
283
  }
236
284
  }
237
285
  const total = rings.reduce((sum, ring) => sum + ring.seats, 0);
238
- // 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.
239
- if (open < seatsTotal) {
240
- let before = 0;
241
- for (const ring of rings) {
242
- ring.fill = round(ring.fill * Math.min(1, Math.max(0, (open - before) / Math.max(1, ring.seats))), 3);
243
- before += ring.seats;
244
- }
245
- }
286
+ // A configuration that opens fewer seats than the bowl holds opens them from the lowest bowl up: the seats it does not open are the top tier's, closed, drawn empty and in the same place every time.
287
+ const closing = open < seatsTotal ? shut(rings, seatsTotal - open, seatArea) : { rings, sectors: [] };
246
288
  const top = rings[rings.length - 1].height[1];
247
289
  const roof = stadium.osm.roof;
248
290
  const rise = roof?.rise === undefined ? 0 : round(roof.rise * FEET_PER_METRE);
@@ -254,9 +296,10 @@ function plan(stadium, config, rink) {
254
296
  const north = orientation.basis === "none" ? undefined : orientation.north;
255
297
  const room = north === undefined ? 0 : NORTH_ROOM;
256
298
  const bowl = {
257
- rings,
299
+ rings: closing.rings,
300
+ ...(closing.sectors.length === 0 ? {} : { closedSectors: closing.sectors }),
258
301
  off: { left: rink.x - first.x0, right: first.x1 - rink.x - rink.w, top: rink.y - first.y0, bottom: first.y1 - rink.y - rink.h },
259
- reach: { left: Math.max(0, rink.x - fb.x0), right: Math.max(0, fb.x1 - rink.x - rink.w), top: Math.max(0, rink.y - fb.y0) + room, bottom: Math.max(0, fb.y1 - rink.y - rink.h) },
302
+ reach: { left: Math.max(0, -rink.w / 2 - around.x0), right: Math.max(0, around.x1 - rink.w / 2), top: Math.max(0, -rink.h / 2 - around.y0) + room, bottom: Math.max(0, around.y1 - rink.h / 2) },
260
303
  shell: {
261
304
  footprint,
262
305
  hall: { ...outer, x0: outer.x0 - HALL, y0: outer.y0 - HALL, x1: outer.x1 + HALL, y1: outer.y1 + HALL, r: outer.r + HALL },
@@ -155,6 +155,11 @@ export interface Surface {
155
155
  };
156
156
  /** For a surface with a crowd: the margin it was drawn with, which holds the stands. */
157
157
  crowdMargin?: number;
158
+ /** A real stadium of more than one configuration laid out in the venue's own frame: where the middle of its field is in the surface's frame (the surface is shifted to lie there), and the ground, building, stands and crowd under it, in the venue's frame. */
159
+ venue?: {
160
+ shift: [number, number];
161
+ under: string;
162
+ };
158
163
  /** Where the parts a situation is drawn against are: a rink's lines and nets, a diamond's bases, a gridiron's goal lines. */
159
164
  anchors?: SituationAnchors;
160
165
  }
@@ -120,6 +120,7 @@ export const KYOGIJO_TURN_STYLE = `
120
120
  .ky-turn .ky-cr-ta{fill:var(--ky-cr-away)}
121
121
  .ky-turn .ky-cr-tw{fill:#f2f0ea}
122
122
  .ky-turn .ky-cr-aisle{fill:none;stroke:var(--ky-cr-gap);stroke-opacity:.8}
123
+ .ky-turn .ky-cr-shut-veil{fill:var(--ky-cr-empty);fill-opacity:.42}
123
124
  .ky-turn .ky-cr-row{fill:none;stroke:var(--ky-cr-gap);stroke-opacity:.5;stroke-width:.5}
124
125
  .ky-turn .ky-bud-gseat{stroke:var(--ky-up-rail);stroke-opacity:.3}
125
126
  .ky-turn .u-door{fill:var(--ky-up-door)}
package/dist/uprights.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // stylesheet. `describeUprights` says the same in words, for a reader who cannot see it.
4
4
  import { courtFor, courtGeometry, isNetSport } from "./court.js";
5
5
  import { drawingId, feetFence, lengthUnit } from "./common.js";
6
- import { drawKyogijo, pitchOf } from "./draw.js";
6
+ import { crowdStands, drawKyogijo, pitchOf } from "./draw.js";
7
7
  import { rulesFor, footballIn } from "./eras.js";
8
8
  import { fieldPlan, isFieldSport } from "./fields.js";
9
9
  import { footballLength } from "./geometry.js";
@@ -126,6 +126,14 @@ export function uprightsFor(o, settings = {}) {
126
126
  thin = thinnest(sheet.length, sheet.width);
127
127
  area = { x0: 0, y0: 0, x1: sheet.length, y1: sheet.width };
128
128
  }
129
+ // A real stadium of more than one configuration is drawn in the venue's own frame (the middle of its field at the origin): what stands on the field is moved there with it, and the stands are already in it.
130
+ const shift = crowdStands(o)?.shift;
131
+ if (shift !== undefined) {
132
+ const [dx, dy] = shift;
133
+ items = items.map((item) => ({ ...item, at: [item.at[0] - dx, item.at[1] - dy], planes: item.planes.map((p) => ({ ...p, o: [p.o[0] - dx, p.o[1] - dy, p.o[2]] })) }));
134
+ if (area !== undefined)
135
+ area = { x0: area.x0 - dx, y0: area.y0 - dy, x1: area.x1 - dx, y1: area.y1 - dy };
136
+ }
129
137
  // The venue: the stands and the room, raised. A dojo's own walls are the room's, with their doors and windows.
130
138
  let defs = "";
131
139
  let reach = 0;
package/dist/venue.js CHANGED
@@ -50,7 +50,7 @@ export function standsVenueOf(stands, crowd, o, v, lift) {
50
50
  const density = densityOf(crowd, stands.seats, groundById(o.ground)?.capacity);
51
51
  const tifo = { home: stands.sectors.some((s) => s.kind === "tifo-home"), away: stands.sectors.some((s) => s.kind === "tifo-away") };
52
52
  const pid = `${v.id}-v`;
53
- const defs = fine ? patterns(pid, crowd, density, tifo.home || tifo.away ? tifo : undefined, stands.real !== undefined, true) : "";
53
+ const defs = fine ? patterns(pid, crowd, density, tifo.home || tifo.away ? tifo : undefined, stands.real !== undefined, true, stands.rings.some((r) => r.closed === true) || stands.sectors.some((s) => s.kind === "closed")) : "";
54
54
  const centre = [stands.box.x + stands.box.w / 2, stands.box.y + stands.box.h / 2];
55
55
  const covers = stands.covers;
56
56
  const inCover = (p) => covers.some(([x0, y0, x1, y1]) => p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1);
@@ -62,7 +62,11 @@ export function standsVenueOf(stands, crowd, o, v, lift) {
62
62
  const paint = (ring, piece) => (corners, local) => {
63
63
  let svg = `<polygon class="u-bed" points="${pointList(corners)}"/>`;
64
64
  const people = ring.fill < 0.95 ? ` fill-opacity="${round(1 - Math.min(0.5, (1 - ring.fill) * 1.6))}"` : "";
65
- svg += fine ? `<polygon fill="url(#${pid}-cr-h)"${people} points="${pointList(corners)}"/>` : `<polygon class="u-blk" fill-opacity="${round(0.28 + 0.6 * density * ring.fill)}" points="${pointList(corners)}"/>`;
65
+ // Closed seats are the same seats, every one empty.
66
+ if (ring.closed === true)
67
+ svg += fine ? `<polygon fill="url(#${pid}-cr-e)" points="${pointList(corners)}"/><polygon class="ky-cr-shut-veil" points="${pointList(corners)}"/>` : `<polygon class="u-blk" fill-opacity="0.28" points="${pointList(corners)}"/>`;
68
+ else
69
+ svg += fine ? `<polygon fill="url(#${pid}-cr-h)"${people} points="${pointList(corners)}"/>` : `<polygon class="u-blk" fill-opacity="${round(0.28 + 0.6 * density * ring.fill)}" points="${pointList(corners)}"/>`;
66
70
  for (const s of stands.sectors) {
67
71
  if (s.ring !== ring.id)
68
72
  continue;
@@ -70,7 +74,9 @@ export function standsVenueOf(stands, crowd, o, v, lift) {
70
74
  if (part.length < 3 || Math.abs(areaOf(part)) < 0.05)
71
75
  continue;
72
76
  const points = pointList(part.map(local));
73
- if (s.kind === "away")
77
+ if (s.kind === "closed")
78
+ svg += fine ? `<polygon class="u-bed" points="${points}"/><polygon fill="url(#${pid}-cr-e)" points="${points}"/><polygon class="ky-cr-shut-veil" points="${points}"/>` : `<polygon class="u-blk" fill-opacity="0.28" points="${points}"/>`;
79
+ else if (s.kind === "away")
74
80
  svg += fine ? `<polygon fill="url(#${pid}-cr-a)" points="${points}"/>` : `<polygon class="u-blk-a" fill-opacity="${round(0.3 + 0.6 * density)}" points="${points}"/>`;
75
81
  else
76
82
  svg += fine ? `<polygon fill="url(#${pid}-cr-${s.kind === "tifo-home" ? "th" : "ta"})" points="${points}"/>` : `<polygon class="${s.kind === "tifo-home" ? "u-th" : "u-ta"}" points="${points}"/>`;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package's version: the same as package.json's, and a test holds them to each other. */
2
- export declare const VERSION = "0.40.0";
2
+ export declare const VERSION = "0.40.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package's version: the same as package.json's, and a test holds them to each other. */
2
- export const VERSION = "0.40.0";
2
+ export const VERSION = "0.40.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kyogijo-stadiums",
3
- "version": "0.40.0",
3
+ "version": "0.40.2",
4
4
  "description": "Kyogijo Stadiums 競技場, 40 sports' surfaces as SVG, drawn to scale from the rulebooks: NFL, CFL and IFAF football fields, an NHL rink, an NBA court, MLB ballparks, soccer pitches, curling sheets, athletics stadiums, speed skating, short track and figure skating rinks, swimming pools, water polo fields, diving wells, artistic swimming areas, and the courts and fields of tennis, volleyball (indoor, beach and sitting), badminton, pickleball, table tennis, padel, squash, racquetball, sepak takraw, teqball, roundnet, netball, lacrosse, rugby, Australian football, Gaelic games, cricket, field hockey and handball, and the budo of judo, karate, kendo, sumo and kyudo with a dojo of tatami laid as real units, the MMA cage, with each team's name and logo on its side and the score in the middle, stands and a crowd, a pageant of flags, and a 3D view. Light and dark, accessible, a compact badge for lists, a helper for React and a custom element. Zero dependencies.",
5
5
  "keywords": [
6
6
  "sports",