@johnmorrisdotca/kyogijo-stadiums 0.24.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/NOTICE.md +27 -0
  3. package/README.md +95 -59
  4. package/dist/crowd.d.ts +56 -2
  5. package/dist/crowd.draw.d.ts +3 -1
  6. package/dist/crowd.draw.js +27 -3
  7. package/dist/crowd.js +18 -10
  8. package/dist/district-entry.d.ts +5 -0
  9. package/dist/district-entry.js +4 -0
  10. package/dist/district.d.ts +167 -0
  11. package/dist/district.js +291 -0
  12. package/dist/district.turn.d.ts +27 -0
  13. package/dist/district.turn.js +157 -0
  14. package/dist/draw.js +22 -2
  15. package/dist/flythrough-entry.d.ts +8 -0
  16. package/dist/flythrough-entry.js +6 -0
  17. package/dist/flythrough.dom.d.ts +103 -0
  18. package/dist/flythrough.dom.js +517 -0
  19. package/dist/flythrough.markup.d.ts +48 -0
  20. package/dist/flythrough.markup.js +86 -0
  21. package/dist/flythrough.path.d.ts +145 -0
  22. package/dist/flythrough.path.js +253 -0
  23. package/dist/flythrough.style.d.ts +2 -0
  24. package/dist/flythrough.style.js +40 -0
  25. package/dist/flythrough.words.d.ts +33 -0
  26. package/dist/flythrough.words.js +44 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/stadiums-entry.d.ts +2 -0
  30. package/dist/stadiums-entry.js +2 -0
  31. package/dist/stadiums.d.ts +158 -0
  32. package/dist/stadiums.data.d.ts +2 -0
  33. package/dist/stadiums.data.js +11 -0
  34. package/dist/stadiums.js +210 -0
  35. package/dist/stadiums.osm.data.d.ts +2 -0
  36. package/dist/stadiums.osm.data.js +58 -0
  37. package/dist/strings.d.ts +17 -0
  38. package/dist/strings.js +2 -2
  39. package/dist/turn.kit.d.ts +5 -0
  40. package/dist/turn.markup.d.ts +7 -0
  41. package/dist/turn.markup.js +38 -2
  42. package/dist/turn.model.d.ts +4 -0
  43. package/dist/turn.model.js +3 -1
  44. package/dist/turn.style.js +6 -0
  45. package/dist/types.d.ts +11 -0
  46. package/dist/venue.d.ts +2 -0
  47. package/dist/venue.js +7 -4
  48. package/dist/venue.real.d.ts +21 -0
  49. package/dist/venue.real.js +150 -0
  50. package/dist/venues-entry.d.ts +2 -0
  51. package/dist/venues-entry.js +2 -0
  52. package/dist/venues.d.ts +153 -0
  53. package/dist/venues.data.d.ts +2 -0
  54. package/dist/venues.data.js +74 -0
  55. package/dist/venues.geo.d.ts +60 -0
  56. package/dist/venues.geo.js +211 -0
  57. package/dist/venues.js +213 -0
  58. package/dist/venues.osm.data.d.ts +4 -0
  59. package/dist/venues.osm.data.js +363 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +25 -1
@@ -3,7 +3,7 @@
3
3
  // All of it is patterns, paths and rectangles, which every renderer of the package reads. The tiles are made from a seed with a generator of our own, so the same options give
4
4
  // the same crowd. Written for this package; no photograph or other drawing was copied.
5
5
  import { el, num } from "./format.js";
6
- import { generator, outlinePath } from "./crowd.js";
6
+ import { NORTH_AT, generator, outlinePath } from "./crowd.js";
7
7
  /** The width and the height of a tile of the crowd, in feet, and the seats' pitch. */
8
8
  const TILE = 14.4;
9
9
  const PITCH = 2.4;
@@ -132,11 +132,32 @@ function galleryRows(ring) {
132
132
  (gaps === "" ? "" : el("path", { class: "ky-bud-gaisle", d: gaps, fill: "none", "stroke-width": 3.4 })));
133
133
  }
134
134
  const poly = (points) => `M${points.map(([x, y]) => `${Math.round(x)} ${Math.round(y)}`).join("L")}Z`;
135
+ /** The building of a real venue and, where its rink's orientation is known, a small arrow with an N that says where north lies. The N is a path of three strokes, so that no font is needed. */
136
+ export function realMarkup(real) {
137
+ const fp = real.footprint;
138
+ let svg = el("path", { class: "ky-cr-shell", d: `M${fp.map(([x, y]) => `${n1(x)} ${n1(y)}`).join("L")}Z` });
139
+ if (real.north !== undefined) {
140
+ const top = Math.min(...fp.map((p) => p[1]));
141
+ const right = Math.max(...fp.map((p) => p[0]));
142
+ const [cx, cy] = [right - 34, top - NORTH_AT];
143
+ const a = (real.north * Math.PI) / 180;
144
+ const [dx, dy] = [Math.sin(a), -Math.cos(a)];
145
+ const at = (along, across = 0) => `${n1(cx + dx * along - dy * across)} ${n1(cy + dy * along + dx * across)}`;
146
+ svg += el("path", { class: "ky-cr-north", d: `M${at(-13)}L${at(14)}M${at(14)}L${at(6, 4.5)}L${at(6, -4.5)}Z` });
147
+ const [gx, gy] = [cx + dx * 24, cy + dy * 24];
148
+ svg += el("path", { class: "ky-cr-north-n", d: `M${n1(gx - 3.5)} ${n1(gy + 4.5)}V${n1(gy - 4.5)}L${n1(gx + 3.5)} ${n1(gy + 4.5)}V${n1(gy - 4.5)}` });
149
+ }
150
+ return el("g", { class: "ky-cr-real" }, svg);
151
+ }
135
152
  /** The stands and the crowd as markup and the defs it uses. The markup is laid over the ground and under the surface. */
136
153
  export function drawCrowd(stands, o) {
137
154
  const { id, crowd, density, fine } = o;
138
155
  const tifo = { home: stands.sectors.some((s) => s.kind === "tifo-home"), away: stands.sectors.some((s) => s.kind === "tifo-away") };
139
- let body = "";
156
+ // A real venue's building lies under its stands; with `stands: false` it is all there is.
157
+ const building = stands.real === undefined ? "" : realMarkup(stands.real);
158
+ if (!crowd.stands && stands.real !== undefined)
159
+ return { defs: "", markup: el("g", { class: "ky-crowd" }, building), over: "" };
160
+ let body = building;
140
161
  // A ballpark's stands stand on a plaza of their own, not on the grass of the foul ground.
141
162
  if (stands.sport === "mlb")
142
163
  for (const ring of stands.rings)
@@ -195,7 +216,7 @@ export function drawCrowd(stands, o) {
195
216
  const [x0, y0, x1, y1] = g.box;
196
217
  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 });
197
218
  }
198
- return { defs: (fine && (stands.gallery !== true || density > 0) ? patterns(id, crowd, density, tifo.home || tifo.away ? tifo : undefined, stands.gallery === true) : ""), markup: el("g", { class: "ky-crowd" }, body), over };
219
+ return { defs: (fine && (stands.gallery !== true || density > 0) ? patterns(id, crowd, density, tifo.home || tifo.away ? tifo : undefined, stands.gallery === true || stands.real !== undefined) : ""), markup: el("g", { class: "ky-crowd" }, body), over };
199
220
  }
200
221
  /** A block of visitors or a card display, a polygon over the band it is in. */
201
222
  function sector(s, id, fine, density) {
@@ -228,6 +249,9 @@ export const KYOGIJO_CROWD_STYLE = `
228
249
  .kyogijo .ky-cr-bench{fill:#1c2127;fill-opacity:.55}
229
250
  .kyogijo .ky-cr-media{fill:#1c2127;fill-opacity:.4}
230
251
  .kyogijo .ky-cr-camera{fill:#1c2127;fill-opacity:.75}
252
+ .kyogijo .ky-cr-shell{fill:var(--ky-cr-conc);fill-opacity:.55;stroke:var(--ky-cr-rim);stroke-width:1.6;stroke-linejoin:round}
253
+ .kyogijo .ky-cr-north{fill:var(--ky-cr-rim);stroke:var(--ky-cr-rim);stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
254
+ .kyogijo .ky-cr-north-n{fill:none;stroke:var(--ky-cr-rim);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
231
255
  .kyogijo .ky-cr-plaza{fill:var(--ky-apron);stroke:var(--ky-apron);stroke-width:8;stroke-linejoin:round}
232
256
  .kyogijo .ky-cr-ez{fill:var(--ky-line);fill-opacity:.16}
233
257
  .kyogijo .ky-cr-yard{stroke:var(--ky-line);stroke-opacity:.5;fill:none}`.trim();
package/dist/crowd.js CHANGED
@@ -3,6 +3,9 @@
3
3
  // profile of the distances a venue of its kind keeps between the surface and its first row and how deep its tiers are; they are conventions for a drawing, not a rulebook's
4
4
  // figures (no rulebook gives them), except the footprint of the gridiron a soccer pitch can stand inside, which is the field's own.
5
5
  import { FOOTBALL } from "./geometry.js";
6
+ /** 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. */
7
+ export const NORTH_ROOM = 60;
8
+ export const NORTH_AT = 32;
6
9
  const finite = (v) => typeof v === "number" && Number.isFinite(v);
7
10
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
8
11
  /** The crowd the options ask for, or `undefined` for none. */
@@ -28,6 +31,8 @@ export function resolveCrowd(value) {
28
31
  gaps: o.gaps !== false,
29
32
  roof: o.roof !== false,
30
33
  wallSeats: typeof o.wallSeats === "boolean" ? o.wallSeats : undefined,
34
+ real: o.real === true,
35
+ stands: o.stands !== false,
31
36
  detail: o.detail === "fine" || o.detail === "plain" ? o.detail : "auto",
32
37
  };
33
38
  }
@@ -216,7 +221,8 @@ export function outlinePoints(o) {
216
221
  }
217
222
  return out;
218
223
  }
219
- const area = (o) => {
224
+ /** The area of an outline, in square feet. */
225
+ export const areaOfOutline = (o) => {
220
226
  if (o.kind === "ellipse")
221
227
  return Math.PI * o.rx * o.ry;
222
228
  if (o.kind === "rrect") {
@@ -231,7 +237,7 @@ const area = (o) => {
231
237
  return Math.abs(s) / 2;
232
238
  };
233
239
  /** The seats of a band of the stands: its area at a seat's 2 by 2.3 feet. */
234
- export const seatsIn = (region, hole) => Math.round((area(region) - (hole === null ? 0 : area(hole))) / 4.6);
240
+ export const seatsIn = (region, hole) => Math.round((areaOfOutline(region) - (hole === null ? 0 : areaOfOutline(hole))) / 4.6);
235
241
  /** A point of a rounded rectangle and the outward normal there, by its parameter `u` (any real number, taken modulo 8): the four straight sides are the even whole numbers (the top from left to right, the right from top to bottom and so on round) and the four corners the odd ones. Concentric outlines (the same corners' centres) have the same parameter at the points an aisle joins. */
236
242
  export function rrectAt(o, u) {
237
243
  const m = ((u % 8) + 8) % 8;
@@ -281,7 +287,7 @@ function aisleParameters(hole) {
281
287
  return { us, nh, nv };
282
288
  }
283
289
  /** The aisles of a band as lines from its inner edge to its outer. */
284
- function aislesOf(region, hole) {
290
+ export function aislesOf(region, hole) {
285
291
  if (region.kind === "rrect" && hole !== null && hole.kind === "rrect")
286
292
  return aisleParameters(hole).us.map((u) => [rrectAt(hole, u).p, rrectAt(region, u).p]);
287
293
  if (region.kind === "ellipse" && hole !== null && hole.kind === "ellipse") {
@@ -325,15 +331,15 @@ export function layoutOf(input) {
325
331
  const base0 = gal === undefined ? PROFILES[sport] : { shape: "bowl", side: gal.offset, end: gal.offset, bench: 0, corner: 0, tiers: [{ id: "gallery", from: 0, to: gal.depth, height: gal.height, fill: 1 }], away: "corner" };
326
332
  const base = input.arena === true && base0.arena !== undefined ? { ...base0, ...base0.arena, tiers: base0.arena.tiers ?? base0.tiers } : base0;
327
333
  const host = sport === "soccer" ? (crowd.stadium === "nfl" || crowd.stadium === "cfl" ? crowd.stadium : crowd.stadium === "auto" && input.ground !== undefined ? GRIDIRON_GROUNDS[input.ground] : undefined) : undefined;
328
- const stadium = sport === "soccer" ? host ?? "purpose" : undefined;
334
+ const stadium = sport === "soccer" && input.real === undefined ? host ?? "purpose" : undefined;
329
335
  const p = stadium === "nfl" || stadium === "cfl" ? { ...PROFILES[stadium], shape: base.shape, away: base.away } : base;
330
- const shape = crowd.shape ?? p.shape;
331
- const off = offsets(base, box, stadium ?? "purpose");
336
+ const shape = input.real !== undefined ? "bowl" : crowd.shape ?? p.shape;
337
+ const off = input.real?.off ?? offsets(base, box, stadium ?? "purpose");
332
338
  const cx = box.x + box.w / 2;
333
339
  const cy = box.y + box.h / 2;
334
- const rings = [];
335
- const reachOf = { left: 0, right: 0, top: 0, bottom: 0 };
336
- for (const t of p.tiers) {
340
+ const rings = input.real === undefined ? [] : input.real.rings.map((ring) => ({ ...ring }));
341
+ const reachOf = input.real === undefined ? { left: 0, right: 0, top: 0, bottom: 0 } : { ...input.real.reach };
342
+ for (const t of input.real === undefined ? p.tiers : []) {
337
343
  const at = (k) => {
338
344
  if (base.oval === true)
339
345
  return { kind: "ellipse", cx, cy, rx: box.w / 2 + (off.left + off.right) / 2 + k, ry: box.h / 2 + (off.top + off.bottom) / 2 + k };
@@ -384,6 +390,8 @@ export function layoutOf(input) {
384
390
  concourses.push({ region: ring.hole, hole: before.region });
385
391
  });
386
392
  const stands = { sport, shape, box, rings, covers, concourses, sectors: [], gaps: [], reach: reachOf, seats: rings.reduce((n, r) => n + r.seats, 0) };
393
+ if (input.real !== undefined)
394
+ stands.real = input.real.shell;
387
395
  if (stadium !== undefined)
388
396
  stands.stadium = stadium;
389
397
  if (gal !== undefined)
@@ -404,7 +412,7 @@ export function layoutOf(input) {
404
412
  const side = crowd.awayEnd === "left" || crowd.awayEnd === "right" ? crowd.awayEnd : input.awaySide;
405
413
  const sign = side === "right" ? 1 : -1;
406
414
  const mode = crowd.awayEnd === "auto" ? (p.away === "end" ? "end" : "corner") : crowd.awayEnd === "corner" ? "corner" : "end";
407
- const tiers = rings.filter((r) => r.tier === "lower" || r.tier === "upper");
415
+ const tiers = rings.filter((r) => r.tier === "lower" || r.tier === "middle" || r.tier === "upper");
408
416
  const hole = lowerRing.hole;
409
417
  if (lowerRing.region.kind === "rrect" && hole !== null && hole.kind === "rrect") {
410
418
  // Round a rectangle a block is whole sections: it starts and ends on aisles, so that it is a section of the stands and not a wedge.
@@ -0,0 +1,5 @@
1
+ export { DISTRICT_IDS, DISTRICT_MARGIN, DISTRICT_MODES, KYOGIJO_DISTRICT_STYLE, describeDistrict, districtId, districtMember, drawDistrict, isDistrictId, metresText, planDistrict, scaleOf, unknownVenues } from "./district.ts";
2
+ export type { DistrictMember, DistrictMode, DistrictOptions, DistrictPlaced, DistrictPlan, DistrictVenueInput } from "./district.ts";
3
+ export { buildDistrictTurn, districtPresets, districtTurnMarkup, mountDistrictTurn } from "./district.turn.ts";
4
+ export type { DistrictTurnSettings } from "./district.turn.ts";
5
+ export { VENUE_ATTRIBUTION } from "./venues.ts";
@@ -0,0 +1,4 @@
1
+ // A district: several real venues of one city on one plan at their real relative positions and orientations (OpenStreetMap, ODbL 1.0), in 2D and in 3D, tight round neighbours or at the real distances of a city.
2
+ export { DISTRICT_IDS, DISTRICT_MARGIN, DISTRICT_MODES, KYOGIJO_DISTRICT_STYLE, describeDistrict, districtId, districtMember, drawDistrict, isDistrictId, metresText, planDistrict, scaleOf, unknownVenues } from "./district.js";
3
+ export { buildDistrictTurn, districtPresets, districtTurnMarkup, mountDistrictTurn } from "./district.turn.js";
4
+ export { VENUE_ATTRIBUTION } from "./venues.js";
@@ -0,0 +1,167 @@
1
+ import { type StadiumConfigId } from "./stadiums.ts";
2
+ import type { Pt } from "./crowd.ts";
3
+ import type { Language, SurfaceOptions } from "./types.ts";
4
+ /** How a district is fitted: `neighbours` tight round venues that stand close together, `city` at the real distances between venues that are kilometres apart. */
5
+ export type DistrictMode = "neighbours" | "city";
6
+ export declare const DISTRICT_MODES: readonly DistrictMode[];
7
+ /** A venue of a district: its key, and for a stadium the configuration it is drawn in (`cfl` or `soccer`; the first it has where none is given). `"bc-place:soccer"` is the same as `{ id: "bc-place", config: "soccer" }`. */
8
+ export type DistrictVenueInput = string | {
9
+ id: string;
10
+ config?: StadiumConfigId;
11
+ };
12
+ /** What can be asked of a district. */
13
+ export interface DistrictOptions {
14
+ /** The venues, by key: any key of `VENUES` (the arenas) and `STADIUMS` (the stadiums); at least one. */
15
+ venues: readonly DistrictVenueInput[];
16
+ /** `neighbours` (the default) or `city`. */
17
+ mode?: DistrictMode;
18
+ /** How far the plan is turned, in degrees clockwise: 0 (the default) has north at the top; `"auto"` turns it so that the district's long way lies across the page. */
19
+ turn?: number | "auto";
20
+ /** Write each venue's name beside it. On for `city`, off for `neighbours`. */
21
+ labels?: boolean;
22
+ language?: Language;
23
+ theme?: "light" | "dark";
24
+ /** The prefix of the ids in the drawing, for a page with more than one. A hash of the options when missing. */
25
+ id?: string;
26
+ /** `"none"` leaves the stylesheet out, as for every drawing. */
27
+ style?: "none";
28
+ /** Draw every venue's stands in 2D (true, the default) or its building only. */
29
+ stands?: boolean;
30
+ }
31
+ /** A venue of a district as the district needs it: what it is called, where it stands, how it lies, and how it is drawn. */
32
+ export interface DistrictMember {
33
+ /** The key of the venue and the configuration, unique in a district: `rogers-arena`, `bc-place:soccer`. */
34
+ key: string;
35
+ id: string;
36
+ kind: "arena" | "stadium";
37
+ config?: StadiumConfigId;
38
+ /** The name in both languages. */
39
+ name: {
40
+ en: string;
41
+ ja: string;
42
+ };
43
+ /** The building's outline, `[longitude, latitude]`. */
44
+ ring: readonly (readonly [number, number])[];
45
+ /** The middle of the surface (the rink, the field): the point the venue's drawing is turned about. */
46
+ centre: readonly [number, number];
47
+ /** The heading of the surface's long side, degrees clockwise from north, 0 to under 180, and how it is known (`none` stands in 90). */
48
+ heading: number;
49
+ basis: string;
50
+ /** The seats of the drawing. */
51
+ seats: number;
52
+ /** The options the venue is drawn from. */
53
+ options: SurfaceOptions;
54
+ }
55
+ /** Every key a district can be asked for: the arenas of `VENUES` and the stadiums of `STADIUMS`. */
56
+ export declare const DISTRICT_IDS: readonly string[];
57
+ /** Whether a key is a venue a district can place. */
58
+ export declare const isDistrictId: (id: string) => boolean;
59
+ /**
60
+ * A venue of a district, from its key: `rogers-arena` (an arena), `bc-place` or `bc-place:soccer` (a stadium in its first configuration or the one named). Throws for a key no venue has, and says which keys there are.
61
+ * @example
62
+ * ```ts
63
+ * import { districtMember } from "@johnmorrisdotca/kyogijo-stadiums/district";
64
+ * const member = districtMember("bc-place:soccer");
65
+ * console.log(member.key, member.kind, member.seats);
66
+ * // bc-place:soccer stadium 52497
67
+ * ```
68
+ */
69
+ export declare function districtMember(input: DistrictVenueInput, language?: Language): DistrictMember;
70
+ /** A venue placed in the district: where its surface's middle is, how far its drawing is turned, and where its building stands, all in feet in the district's frame (x to the right, y down the page). */
71
+ export interface DistrictPlaced {
72
+ member: DistrictMember;
73
+ /** The middle of the surface. */
74
+ at: Pt;
75
+ /** The degrees the venue's own drawing (its field along x) is turned clockwise to lie as it does in the world. */
76
+ angle: number;
77
+ /** The building's outline. */
78
+ footprint: Pt[];
79
+ /** Its convex hull, which the depth order of the 3D view separates buildings by. */
80
+ hull: Pt[];
81
+ }
82
+ /** What a district is: its venues placed, the box that fits them, and how the map lies. */
83
+ export interface DistrictPlan {
84
+ options: DistrictOptions;
85
+ mode: DistrictMode;
86
+ /** The degrees the map is turned clockwise (0 has north at the top). */
87
+ turn: number;
88
+ /** The way north points on the page, degrees clockwise from the top. */
89
+ north: number;
90
+ members: DistrictPlaced[];
91
+ /** The frame the plan is drawn in, in feet: the venues' buildings with the margin the mode gives them. */
92
+ view: {
93
+ x: number;
94
+ y: number;
95
+ w: number;
96
+ h: number;
97
+ };
98
+ /** The distance between each pair of surfaces' middles, in metres, as the ground gives it. */
99
+ between: {
100
+ a: string;
101
+ b: string;
102
+ metres: number;
103
+ }[];
104
+ }
105
+ /** The margin round the buildings, as a share of the longer side of the district: tight for neighbours, wide for a city's grid and names. */
106
+ export declare const DISTRICT_MARGIN: Readonly<Record<DistrictMode, number>>;
107
+ /**
108
+ * The plan of a district: each venue's surface placed where it is on the ground (the distance and the direction between middles come from the coordinates of OpenStreetMap's outlines, to a metre or so) and turned so
109
+ * that its long side points where it does in the world, and the frame that fits them. A venue whose orientation is not known (an arena the building gives no axis for) is placed with its rink east-west, as it is drawn alone.
110
+ * @example
111
+ * ```ts
112
+ * import { planDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
113
+ * const plan = planDistrict({ venues: ["bc-place", "rogers-arena"] });
114
+ * console.log(plan.mode, plan.members.length, Math.round(plan.between[0].metres));
115
+ * // neighbours 2 264
116
+ * ```
117
+ */
118
+ export declare function planDistrict(options: DistrictOptions): DistrictPlan;
119
+ /** The colours and the type of a district's drawing, light and dark. Defaults sit in `:where()`, so a page's own rule wins. */
120
+ export declare const KYOGIJO_DISTRICT_STYLE = "\n:where(.ky-district){--ky-dist-ground:#e1e4da;--ky-dist-grid:rgba(40,46,42,.16);--ky-dist-ink:#1f2320;--ky-dist-halo:rgba(255,255,255,.9)}\n@media (prefers-color-scheme:dark){:where(html:not([data-theme=light]) .ky-district:not([data-theme=light])){--ky-dist-ground:#1b2025;--ky-dist-grid:rgba(220,226,220,.16);--ky-dist-ink:#e8ece8;--ky-dist-halo:rgba(14,18,16,.85)}}\n:where(html[data-theme=dark] .ky-district,.ky-district[data-theme=dark]){--ky-dist-ground:#1b2025;--ky-dist-grid:rgba(220,226,220,.16);--ky-dist-ink:#e8ece8;--ky-dist-halo:rgba(14,18,16,.85)}\n.ky-district .ky-dist-ground{fill:var(--ky-dist-ground)}\n.ky-district .ky-dist-grid{fill:none;stroke:var(--ky-dist-grid);stroke-width:1}\n.ky-district .ky-dist-ink{fill:none;stroke:var(--ky-dist-ink);stroke-linecap:round;stroke-linejoin:round}\n.ky-district .ky-dist-text{fill:var(--ky-dist-ink);font-family:var(--ky-font,system-ui,-apple-system,\"Segoe UI\",\"Hiragino Sans\",\"Noto Sans JP\",sans-serif);font-weight:600;paint-order:stroke;stroke:var(--ky-dist-halo);stroke-linejoin:round}";
121
+ /**
122
+ * A venue's drawing as a part of the district's: its root made a nested `svg` that keeps the class, the sport and the colours the drawing's stylesheet reads, its box laid in its own frame (so that the group's turn
123
+ * and shift put it in place), and its title, description, apron, arrow and stylesheet taken out (the stylesheet is given back in `styles`).
124
+ */
125
+ export declare function nestedDrawing(svg: string): {
126
+ markup: string;
127
+ box: {
128
+ x: number;
129
+ y: number;
130
+ w: number;
131
+ h: number;
132
+ };
133
+ styles: string[];
134
+ };
135
+ /** The middle of a venue's surface in its own drawing's frame (the box the stands are laid round), which its drawing is turned about. */
136
+ export declare function surfaceCentre(options: SurfaceOptions): Pt;
137
+ /** A length in metres worth drawing as a scale: the largest of 50, 100, 200, 500, 1000, 2000, 5000 that is not more than a fifth of the plan's width. */
138
+ export declare function scaleOf(view: {
139
+ w: number;
140
+ }): number;
141
+ /** The distance a scale says, as it is read: metres under a kilometre, else kilometres. */
142
+ export declare const metresText: (metres: number) => string;
143
+ /** The id of a district's drawing: the option's, or a hash of the options. */
144
+ export declare const districtId: (options: DistrictOptions) => string;
145
+ /** The title and the description of a district, in its language: what is drawn, where each venue stands from the first, how far apart they are, and the credit. */
146
+ export declare function describeDistrict(options: DistrictOptions, plan?: DistrictPlan): {
147
+ title: string;
148
+ description: string;
149
+ };
150
+ /** How a venue is drawn for a district: its own options with the district's language, theme and ids, in 2D with its stands or as the floor of the 3D view (its building only). */
151
+ export declare function drawMember(member: DistrictMember, options: DistrictOptions, index: number, id: string, how?: {
152
+ floor?: boolean;
153
+ }): string;
154
+ /**
155
+ * The plan of a district as one SVG: the ground, a grid and a scale, a north arrow, and each venue's own drawing (its stands, or its building only for `stands: false`) put where it stands and turned as it lies, with a name beside each
156
+ * where `labels` says. `drawing` is how each venue is drawn; the 3D view gives it the building-only floor.
157
+ * @example
158
+ * ```ts
159
+ * import { drawDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
160
+ * const svg = drawDistrict({ venues: ["bc-place", "rogers-arena"] });
161
+ * console.log(svg.includes("ky-district"), svg.includes("ky-cr-shell"));
162
+ * // true true
163
+ * ```
164
+ */
165
+ export declare function drawDistrict(options: DistrictOptions, plan?: DistrictPlan, drawing?: (member: DistrictMember, index: number) => string): string;
166
+ /** The venues a district was asked for that the package has no key for: none when it can be drawn. */
167
+ export declare const unknownVenues: (venues: readonly DistrictVenueInput[]) => string[];
@@ -0,0 +1,291 @@
1
+ // A district: several real venues of one city placed on one plan at their real relative positions and orientations, read from the same OpenStreetMap outlines each venue is drawn from (`venues.ts`, `stadiums.ts`).
2
+ // It is generic: a district is a list of venue keys, whichever kinds they are (an NHL arena, a football or soccer stadium, a ballpark later), and the plan fits itself to them. Two modes say how far apart the venues are:
3
+ // `neighbours` fits the plan tight round buildings that stand within a few hundred metres of each other, with no more than a margin between them; `city` keeps the real distances between venues that are kilometres
4
+ // apart, with a grid, a scale and a name beside each. Nothing is drawn but what the venues are drawn from: no street, no park and no mark of a team, a league or a tournament; the credit
5
+ // `VENUE_ATTRIBUTION` goes with it. Pure: options in, a plan, and markup, out; `district.turn.ts` raises it in 3D.
6
+ import { arenaById, arenaNameIn } from "./arenas.js";
7
+ import { crowdStands, drawKyogijo } from "./draw.js";
8
+ import { FEET_PER_METRE } from "./geometry.js";
9
+ import { KYOGIJO_STRINGS } from "./strings.js";
10
+ import { esc, hash } from "./format.js";
11
+ import { groundNameIn } from "./grounds.js";
12
+ import { STADIUM_IDS, groundOfStadium, stadiumById, stadiumSeats } from "./stadiums.js";
13
+ import { VENUE_ATTRIBUTION, VENUE_IDS, venueById } from "./venues.js";
14
+ import { hullOf, metresFrom, middleOf, smallestBox } from "./venues.geo.js";
15
+ export const DISTRICT_MODES = ["neighbours", "city"];
16
+ const venueMember = (venue, language) => {
17
+ const arena = arenaById(venue.id);
18
+ if (arena === undefined)
19
+ throw new Error(`${venue.id} is not an arena`);
20
+ return {
21
+ key: venue.id,
22
+ id: venue.id,
23
+ kind: "arena",
24
+ name: { en: arenaNameIn(arena, undefined, "en"), ja: arenaNameIn(arena, undefined, "ja") },
25
+ ring: venue.osm.ring,
26
+ centre: venue.osm.axis.centre,
27
+ heading: venue.osm.axis.heading,
28
+ basis: venue.osm.axis.basis,
29
+ seats: venue.seats.hockey,
30
+ options: { sport: "nhl", arena: venue.id, crowd: { real: true }, language },
31
+ };
32
+ };
33
+ const stadiumMember = (stadium, config, language) => {
34
+ const ground = groundOfStadium(stadium);
35
+ return {
36
+ key: `${stadium.id}:${config}`,
37
+ id: stadium.id,
38
+ kind: "stadium",
39
+ config,
40
+ name: { en: groundNameIn(ground, undefined, "en"), ja: groundNameIn(ground, undefined, "ja") },
41
+ ring: stadium.osm.ring,
42
+ centre: stadium.osm.axis.centre,
43
+ heading: stadium.osm.axis.heading,
44
+ basis: stadium.osm.axis.basis,
45
+ seats: stadiumSeats(stadium, config),
46
+ options: { sport: config, stadium: stadium.id, crowd: { real: true }, language },
47
+ };
48
+ };
49
+ /** Every key a district can be asked for: the arenas of `VENUES` and the stadiums of `STADIUMS`. */
50
+ export const DISTRICT_IDS = Object.freeze([...VENUE_IDS, ...STADIUM_IDS]);
51
+ /** Whether a key is a venue a district can place. */
52
+ export const isDistrictId = (id) => DISTRICT_IDS.includes(id);
53
+ /**
54
+ * A venue of a district, from its key: `rogers-arena` (an arena), `bc-place` or `bc-place:soccer` (a stadium in its first configuration or the one named). Throws for a key no venue has, and says which keys there are.
55
+ * @example
56
+ * ```ts
57
+ * import { districtMember } from "@johnmorrisdotca/kyogijo-stadiums/district";
58
+ * const member = districtMember("bc-place:soccer");
59
+ * console.log(member.key, member.kind, member.seats);
60
+ * // bc-place:soccer stadium 52497
61
+ * ```
62
+ */
63
+ export function districtMember(input, language = "en") {
64
+ const [id, named] = typeof input === "string" ? input.split(":") : [input.id, input.config];
65
+ const venue = venueById(id);
66
+ if (venue !== undefined) {
67
+ if (named !== undefined && named !== "nhl")
68
+ throw new Error(`${id} is an arena with one configuration, not ${named}`);
69
+ return venueMember(venue, language);
70
+ }
71
+ const stadium = stadiumById(id);
72
+ if (stadium !== undefined) {
73
+ const config = (named ?? Object.keys(stadium.configs)[0]);
74
+ if (stadium.configs[config] === undefined)
75
+ throw new Error(`${id} has no configuration ${String(named)}: ${Object.keys(stadium.configs).join(", ")}`);
76
+ return stadiumMember(stadium, config, language);
77
+ }
78
+ throw new Error(`no venue ${String(id)}: the venues are ${DISTRICT_IDS.join(", ")}`);
79
+ }
80
+ // ---- The plan. ----
81
+ const RAD = Math.PI / 180;
82
+ /** Turn a point of the map (east, down the page) by `degrees` clockwise on the page. */
83
+ const turnBy = ([x, y], degrees) => [x * Math.cos(degrees * RAD) - y * Math.sin(degrees * RAD), x * Math.sin(degrees * RAD) + y * Math.cos(degrees * RAD)];
84
+ /** The margin round the buildings, as a share of the longer side of the district: tight for neighbours, wide for a city's grid and names. */
85
+ export const DISTRICT_MARGIN = { neighbours: 0.09, city: 0.12 };
86
+ const EARTH = 6371008.8;
87
+ const haversine = (a, b) => {
88
+ const [p, q] = [a[1] * RAD, b[1] * RAD];
89
+ const h = Math.sin((q - p) / 2) ** 2 + Math.cos(p) * Math.cos(q) * Math.sin(((b[0] - a[0]) * RAD) / 2) ** 2;
90
+ return 2 * EARTH * Math.asin(Math.sqrt(h));
91
+ };
92
+ /**
93
+ * The plan of a district: each venue's surface placed where it is on the ground (the distance and the direction between middles come from the coordinates of OpenStreetMap's outlines, to a metre or so) and turned so
94
+ * that its long side points where it does in the world, and the frame that fits them. A venue whose orientation is not known (an arena the building gives no axis for) is placed with its rink east-west, as it is drawn alone.
95
+ * @example
96
+ * ```ts
97
+ * import { planDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
98
+ * const plan = planDistrict({ venues: ["bc-place", "rogers-arena"] });
99
+ * console.log(plan.mode, plan.members.length, Math.round(plan.between[0].metres));
100
+ * // neighbours 2 264
101
+ * ```
102
+ */
103
+ export function planDistrict(options) {
104
+ if (options.venues.length === 0)
105
+ throw new Error("a district needs a venue");
106
+ const language = options.language === "ja" ? "ja" : "en";
107
+ const mode = options.mode === "city" ? "city" : "neighbours";
108
+ const members = options.venues.map((v) => districtMember(v, language));
109
+ const keys = new Set();
110
+ for (const m of members) {
111
+ if (keys.has(m.key))
112
+ throw new Error(`${m.key} is in the district twice`);
113
+ keys.add(m.key);
114
+ }
115
+ const origin = middleOf(members.map((m) => m.centre));
116
+ const ground = (ring) => metresFrom(origin, ring).map(([east, north]) => [east * FEET_PER_METRE, -north * FEET_PER_METRE]);
117
+ const raw = members.map((m) => ({ at: ground([m.centre])[0], footprint: ground(m.ring) }));
118
+ // The map's turn: as asked, or the one that lays the district's long way across the page (the smallest box round every building).
119
+ const all = raw.flatMap((r) => r.footprint);
120
+ let turn = 0;
121
+ if (typeof options.turn === "number" && Number.isFinite(options.turn))
122
+ turn = ((options.turn % 360) + 360) % 360;
123
+ else if (options.turn === "auto" && members.length > 1) {
124
+ // `smallestBox` reads its points as east and north; these are east and down the page, so its heading is 90 degrees less the long side's angle on the page, and turning the map by (heading - 90) lays that side along x.
125
+ const box = smallestBox(all);
126
+ turn = (((box.heading - 90) % 360) + 360) % 360;
127
+ }
128
+ const placed = members.map((member, i) => {
129
+ const footprint = raw[i].footprint.map((p) => turnBy(p, turn));
130
+ return { member, at: turnBy(raw[i].at, turn), angle: member.heading - 90 + turn, footprint, hull: hullOf(footprint) };
131
+ });
132
+ const xs = placed.flatMap((p) => p.footprint.map((q) => q[0]));
133
+ const ys = placed.flatMap((p) => p.footprint.map((q) => q[1]));
134
+ const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
135
+ const margin = Math.max(x1 - x0, y1 - y0) * DISTRICT_MARGIN[mode];
136
+ const view = { x: x0 - margin, y: y0 - margin, w: x1 - x0 + 2 * margin, h: y1 - y0 + 2 * margin };
137
+ const between = [];
138
+ members.forEach((a, i) => members.slice(i + 1).forEach((b) => between.push({ a: a.key, b: b.key, metres: Math.round(haversine(a.centre, b.centre) * 10) / 10 })));
139
+ return { options, mode, turn, north: turn, members: placed, view, between };
140
+ }
141
+ // ---- The drawing. ----
142
+ /** The colours and the type of a district's drawing, light and dark. Defaults sit in `:where()`, so a page's own rule wins. */
143
+ export const KYOGIJO_DISTRICT_STYLE = `
144
+ :where(.ky-district){--ky-dist-ground:#e1e4da;--ky-dist-grid:rgba(40,46,42,.16);--ky-dist-ink:#1f2320;--ky-dist-halo:rgba(255,255,255,.9)}
145
+ @media (prefers-color-scheme:dark){:where(html:not([data-theme=light]) .ky-district:not([data-theme=light])){--ky-dist-ground:#1b2025;--ky-dist-grid:rgba(220,226,220,.16);--ky-dist-ink:#e8ece8;--ky-dist-halo:rgba(14,18,16,.85)}}
146
+ :where(html[data-theme=dark] .ky-district,.ky-district[data-theme=dark]){--ky-dist-ground:#1b2025;--ky-dist-grid:rgba(220,226,220,.16);--ky-dist-ink:#e8ece8;--ky-dist-halo:rgba(14,18,16,.85)}
147
+ .ky-district .ky-dist-ground{fill:var(--ky-dist-ground)}
148
+ .ky-district .ky-dist-grid{fill:none;stroke:var(--ky-dist-grid);stroke-width:1}
149
+ .ky-district .ky-dist-ink{fill:none;stroke:var(--ky-dist-ink);stroke-linecap:round;stroke-linejoin:round}
150
+ .ky-district .ky-dist-text{fill:var(--ky-dist-ink);font-family:var(--ky-font,system-ui,-apple-system,"Segoe UI","Hiragino Sans","Noto Sans JP",sans-serif);font-weight:600;paint-order:stroke;stroke:var(--ky-dist-halo);stroke-linejoin:round}`;
151
+ const ROOT = /^<svg([^>]*)>/;
152
+ const VIEWBOX = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/;
153
+ /** The north arrow of a venue's drawing and the apron under it: a district has one of each of its own. */
154
+ const withoutOwn = (svg) => svg.replace(/<rect class="ky-apron"[^>]*\/>/, "").replace(/<path class="ky-cr-north"[^>]*\/>/, "").replace(/<path class="ky-cr-north-n"[^>]*\/>/, "");
155
+ /**
156
+ * A venue's drawing as a part of the district's: its root made a nested `svg` that keeps the class, the sport and the colours the drawing's stylesheet reads, its box laid in its own frame (so that the group's turn
157
+ * and shift put it in place), and its title, description, apron, arrow and stylesheet taken out (the stylesheet is given back in `styles`).
158
+ */
159
+ export function nestedDrawing(svg) {
160
+ const root = ROOT.exec(svg);
161
+ const found = root === null ? null : VIEWBOX.exec(root[1]);
162
+ if (root === null || found === null)
163
+ throw new Error("a venue's drawing is an SVG with a viewBox");
164
+ const [x, y, w, h] = found.slice(1).map(Number);
165
+ const keep = [...root[1].matchAll(/\s(class|data-[\w-]+|style)="([^"]*)"/g)].map((m) => ` ${m[1]}="${m[2]}"`).join("");
166
+ // The drawing's stylesheet is taken out and given back apart: the district writes each distinct one once (venues of one family share the base style, which is most of it).
167
+ const styles = [];
168
+ const body = withoutOwn(svg.slice(root[0].length))
169
+ .replace(/<style>[\s\S]*?<\/style>/g, (block) => {
170
+ styles.push(block);
171
+ return "";
172
+ })
173
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>|<desc\b[^>]*>[\s\S]*?<\/desc>/g, "")
174
+ .replace(/<\/svg>\s*$/, "");
175
+ return { markup: `<svg${keep} x="${x}" y="${y}" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}" aria-hidden="true" focusable="false">${body}</svg>`, box: { x, y, w, h }, styles };
176
+ }
177
+ const num = (value) => String(Math.round(value * 100) / 100);
178
+ /** The middle of a venue's surface in its own drawing's frame (the box the stands are laid round), which its drawing is turned about. */
179
+ export function surfaceCentre(options) {
180
+ const stands = crowdStands(options);
181
+ if (stands === undefined)
182
+ throw new Error("a district's venue has a crowd");
183
+ return [stands.box.x + stands.box.w / 2, stands.box.y + stands.box.h / 2];
184
+ }
185
+ /** A length in metres worth drawing as a scale: the largest of 50, 100, 200, 500, 1000, 2000, 5000 that is not more than a fifth of the plan's width. */
186
+ export function scaleOf(view) {
187
+ const fifth = (view.w / FEET_PER_METRE) / 5;
188
+ return [5000, 2000, 1000, 500, 200, 100, 50].find((m) => m <= fifth) ?? 50;
189
+ }
190
+ /** The distance a scale says, as it is read: metres under a kilometre, else kilometres. */
191
+ export const metresText = (metres) => (metres >= 1000 ? `${metres / 1000} km` : `${metres} m`);
192
+ /** The id of a district's drawing: the option's, or a hash of the options. */
193
+ export const districtId = (options) => (typeof options.id === "string" && options.id !== "" ? options.id : `ky-district-${hash(JSON.stringify([options.venues, options.mode, options.turn, options.language, options.theme, options.stands]))}`);
194
+ /** The title and the description of a district, in its language: what is drawn, where each venue stands from the first, how far apart they are, and the credit. */
195
+ export function describeDistrict(options, plan = planDistrict(options)) {
196
+ const language = options.language === "ja" ? "ja" : "en";
197
+ const names = plan.members.map((p) => p.member.name[language]);
198
+ const words = KYOGIJO_STRINGS[language].said;
199
+ const list = language === "ja" ? (names.length > 1 ? `${names.slice(0, -1).join("、")}と${names[names.length - 1]}` : names[0]) : names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0];
200
+ const title = language === "ja" ? `${list}の位置関係` : `${list}: where they stand`;
201
+ const gap = language === "ja" ? "" : " ";
202
+ const first = plan.between[0];
203
+ const nameOf = (key) => plan.members.find((p) => p.member.key === key).member.name[language];
204
+ const apart = first === undefined ? "" : language === "ja" ? `${gap}${nameOf(first.a)}と${nameOf(first.b)}の中心は約${Math.round(first.metres)}m離れています。` : `${gap}The middles of ${nameOf(first.a)} and ${nameOf(first.b)} are about ${Math.round(first.metres)} metres apart.`;
205
+ const text = fillWords(words.district, { count: String(plan.members.length), mode: words[plan.mode === "city" ? "districtCity" : "districtNeighbours"] });
206
+ return { title, description: `${text}${apart}${gap}${words.districtCredit}${gap}${VENUE_ATTRIBUTION}${language === "ja" ? "。" : "."}` };
207
+ }
208
+ const fillWords = (text, values) => text.replace(/\{(\w+)\}/g, (_, key) => values[key] ?? "");
209
+ /** How a venue is drawn for a district: its own options with the district's language, theme and ids, in 2D with its stands or as the floor of the 3D view (its building only). */
210
+ export function drawMember(member, options, index, id, how = {}) {
211
+ const stands = options.stands !== false && how.floor !== true;
212
+ return drawKyogijo({
213
+ ...member.options,
214
+ crowd: { real: true, ...(stands ? {} : { stands: false }) },
215
+ language: options.language === "ja" ? "ja" : "en",
216
+ ...(options.theme === undefined ? {} : { theme: options.theme }),
217
+ id: `${id}-m${index}`,
218
+ });
219
+ }
220
+ /**
221
+ * The plan of a district as one SVG: the ground, a grid and a scale, a north arrow, and each venue's own drawing (its stands, or its building only for `stands: false`) put where it stands and turned as it lies, with a name beside each
222
+ * where `labels` says. `drawing` is how each venue is drawn; the 3D view gives it the building-only floor.
223
+ * @example
224
+ * ```ts
225
+ * import { drawDistrict } from "@johnmorrisdotca/kyogijo-stadiums/district";
226
+ * const svg = drawDistrict({ venues: ["bc-place", "rogers-arena"] });
227
+ * console.log(svg.includes("ky-district"), svg.includes("ky-cr-shell"));
228
+ * // true true
229
+ * ```
230
+ */
231
+ export function drawDistrict(options, plan = planDistrict(options), drawing) {
232
+ const language = options.language === "ja" ? "ja" : "en";
233
+ const id = districtId(options);
234
+ const { view } = plan;
235
+ const labels = options.labels ?? plan.mode === "city";
236
+ const { title, description } = describeDistrict(options, plan);
237
+ const diag = Math.max(view.w, view.h);
238
+ const parts = [];
239
+ // A grid every `step` feet of the ground: the kilometre in a city, the hundred metres among neighbours.
240
+ const step = (plan.mode === "city" ? 1000 : 100) * FEET_PER_METRE;
241
+ const grid = [];
242
+ const reach = Math.hypot(view.w, view.h);
243
+ // The grid lines run along the map's east and north, turned with it: lines of constant east and of constant north through the district's origin (the plan's centre of the venues), drawn long enough to cross the frame.
244
+ const origin = [0, 0];
245
+ for (let k = -Math.ceil(reach / step); k <= Math.ceil(reach / step); k += 1) {
246
+ const [ax, ay] = turnBy([k * step, -reach], plan.turn);
247
+ const [bx, by] = turnBy([k * step, reach], plan.turn);
248
+ const [cx, cy] = turnBy([-reach, k * step], plan.turn);
249
+ const [dx, dy] = turnBy([reach, k * step], plan.turn);
250
+ grid.push(`M${num(origin[0] + ax)} ${num(origin[1] + ay)}L${num(origin[0] + bx)} ${num(origin[1] + by)}M${num(origin[0] + cx)} ${num(origin[1] + cy)}L${num(origin[0] + dx)} ${num(origin[1] + dy)}`);
251
+ }
252
+ parts.push(`<rect class="ky-dist-ground" x="${num(view.x)}" y="${num(view.y)}" width="${num(view.w)}" height="${num(view.h)}"/>`);
253
+ parts.push(`<clipPath id="${id}-clip"><rect x="${num(view.x)}" y="${num(view.y)}" width="${num(view.w)}" height="${num(view.h)}"/></clipPath>`);
254
+ parts.push(`<path class="ky-dist-grid" clip-path="url(#${id}-clip)" d="${grid.join("")}"/>`);
255
+ const styles = [];
256
+ plan.members.forEach((placed, i) => {
257
+ const svg = drawing === undefined ? drawMember(placed.member, options, i, id) : drawing(placed.member, i);
258
+ const { markup, styles: own } = nestedDrawing(svg);
259
+ for (const block of own)
260
+ if (!styles.includes(block))
261
+ styles.push(block);
262
+ const centre = surfaceCentre(placed.member.options);
263
+ parts.push(`<g class="ky-dist-venue" data-venue="${esc(placed.member.key)}" transform="translate(${num(placed.at[0])} ${num(placed.at[1])}) rotate(${num(placed.angle)}) translate(${num(-centre[0])} ${num(-centre[1])})">${markup}</g>`);
264
+ });
265
+ if (labels) {
266
+ const size = diag * (plan.mode === "city" ? 0.018 : 0.026);
267
+ plan.members.forEach((placed) => {
268
+ const bottom = Math.max(...placed.footprint.map((p) => p[1]));
269
+ const x = placed.footprint.reduce((s, p) => s + p[0], 0) / placed.footprint.length;
270
+ parts.push(`<text class="ky-dist-text" x="${num(x)}" y="${num(bottom + size * 1.3)}" text-anchor="middle" font-size="${num(size)}" stroke-width="${num(size * 0.22)}">${esc(placed.member.name[language])}</text>`);
271
+ });
272
+ }
273
+ // The north arrow, in the top right corner of the frame, and the scale bar at the bottom left.
274
+ const arrow = diag * 0.05;
275
+ const [ax, ay] = [view.x + view.w - arrow * 1.2, view.y + arrow * 1.6];
276
+ const tip = turnBy([0, -arrow], plan.turn);
277
+ const tail = turnBy([0, arrow * 0.6], plan.turn);
278
+ const left = turnBy([-arrow * 0.28, 0], plan.turn);
279
+ const right = turnBy([arrow * 0.28, 0], plan.turn);
280
+ const nPos = turnBy([0, -arrow * 1.35], plan.turn);
281
+ parts.push(`<g class="ky-dist-north" aria-hidden="true"><path class="ky-dist-ink" stroke-width="${num(arrow * 0.06)}" d="M${num(ax + tail[0])} ${num(ay + tail[1])}L${num(ax + tip[0])} ${num(ay + tip[1])}M${num(ax + left[0])} ${num(ay + left[1])}L${num(ax + tip[0])} ${num(ay + tip[1])}L${num(ax + right[0])} ${num(ay + right[1])}"/><text class="ky-dist-text" x="${num(ax + nPos[0])}" y="${num(ay + nPos[1] + arrow * 0.18)}" text-anchor="middle" font-size="${num(arrow * 0.5)}" stroke-width="${num(arrow * 0.1)}">N</text></g>`);
282
+ const metres = scaleOf(view);
283
+ const bar = metres * FEET_PER_METRE;
284
+ const [sx, sy] = [view.x + diag * 0.03, view.y + view.h - diag * 0.04];
285
+ parts.push(`<g class="ky-dist-scale" aria-hidden="true"><path class="ky-dist-ink" stroke-width="${num(diag * 0.004)}" d="M${num(sx)} ${num(sy - diag * 0.01)}V${num(sy)}H${num(sx + bar)}V${num(sy - diag * 0.01)}"/><text class="ky-dist-text" x="${num(sx + bar / 2)}" y="${num(sy - diag * 0.018)}" text-anchor="middle" font-size="${num(diag * 0.022)}" stroke-width="${num(diag * 0.005)}">${metresText(metres)}</text></g>`);
286
+ const sheet = options.style === "none" ? "" : `<style>${KYOGIJO_DISTRICT_STYLE}</style>${styles.join("")}`;
287
+ const theme = options.theme === "light" || options.theme === "dark" ? ` data-theme="${options.theme}"` : "";
288
+ return `<svg xmlns="http://www.w3.org/2000/svg" class="kyogijo ky-district" viewBox="${num(view.x)} ${num(view.y)} ${num(view.w)} ${num(view.h)}" role="img" aria-labelledby="${id}-t ${id}-d" data-sport="district" data-mode="${plan.mode}"${theme} lang="${language}"><title id="${id}-t">${esc(title)}</title><desc id="${id}-d">${esc(description)}</desc>${sheet}${parts.join("")}</svg>`;
289
+ }
290
+ /** The venues a district was asked for that the package has no key for: none when it can be drawn. */
291
+ export const unknownVenues = (venues) => venues.map((v) => (typeof v === "string" ? v.split(":")[0] : v.id)).filter((id) => !isDistrictId(id));