@johnmorrisdotca/kyogijo-stadiums 0.24.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/NOTICE.md +27 -0
  3. package/README.md +95 -59
  4. package/dist/crowd.d.ts +56 -2
  5. package/dist/crowd.draw.d.ts +3 -1
  6. package/dist/crowd.draw.js +27 -3
  7. package/dist/crowd.js +18 -10
  8. package/dist/district-entry.d.ts +5 -0
  9. package/dist/district-entry.js +4 -0
  10. package/dist/district.d.ts +167 -0
  11. package/dist/district.js +291 -0
  12. package/dist/district.turn.d.ts +27 -0
  13. package/dist/district.turn.js +157 -0
  14. package/dist/draw.js +22 -2
  15. package/dist/flythrough-entry.d.ts +8 -0
  16. package/dist/flythrough-entry.js +6 -0
  17. package/dist/flythrough.dom.d.ts +103 -0
  18. package/dist/flythrough.dom.js +517 -0
  19. package/dist/flythrough.markup.d.ts +48 -0
  20. package/dist/flythrough.markup.js +86 -0
  21. package/dist/flythrough.path.d.ts +145 -0
  22. package/dist/flythrough.path.js +253 -0
  23. package/dist/flythrough.style.d.ts +2 -0
  24. package/dist/flythrough.style.js +40 -0
  25. package/dist/flythrough.words.d.ts +33 -0
  26. package/dist/flythrough.words.js +44 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/stadiums-entry.d.ts +2 -0
  30. package/dist/stadiums-entry.js +2 -0
  31. package/dist/stadiums.d.ts +158 -0
  32. package/dist/stadiums.data.d.ts +2 -0
  33. package/dist/stadiums.data.js +11 -0
  34. package/dist/stadiums.js +210 -0
  35. package/dist/stadiums.osm.data.d.ts +2 -0
  36. package/dist/stadiums.osm.data.js +58 -0
  37. package/dist/strings.d.ts +17 -0
  38. package/dist/strings.js +2 -2
  39. package/dist/turn.kit.d.ts +5 -0
  40. package/dist/turn.markup.d.ts +7 -0
  41. package/dist/turn.markup.js +38 -2
  42. package/dist/turn.model.d.ts +4 -0
  43. package/dist/turn.model.js +3 -1
  44. package/dist/turn.style.js +6 -0
  45. package/dist/types.d.ts +11 -0
  46. package/dist/venue.d.ts +2 -0
  47. package/dist/venue.js +7 -4
  48. package/dist/venue.real.d.ts +21 -0
  49. package/dist/venue.real.js +150 -0
  50. package/dist/venues-entry.d.ts +2 -0
  51. package/dist/venues-entry.js +2 -0
  52. package/dist/venues.d.ts +153 -0
  53. package/dist/venues.data.d.ts +2 -0
  54. package/dist/venues.data.js +74 -0
  55. package/dist/venues.geo.d.ts +60 -0
  56. package/dist/venues.geo.js +211 -0
  57. package/dist/venues.js +213 -0
  58. package/dist/venues.osm.data.d.ts +4 -0
  59. package/dist/venues.osm.data.js +363 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +25 -1
@@ -0,0 +1,211 @@
1
+ const EARTH = 6371008.8;
2
+ const RAD = Math.PI / 180;
3
+ /** Points in degrees as `[east, north]` metres from `origin`, on a plane tangent to the earth there (good to a few centimetres over a building's size). */
4
+ export function metresFrom(origin, ring) {
5
+ const k = Math.cos(origin[1] * RAD);
6
+ return ring.map(([lon, lat]) => [(lon - origin[0]) * RAD * EARTH * k, (lat - origin[1]) * RAD * EARTH]);
7
+ }
8
+ /** The inverse of `metresFrom`: a point given as east and north metres from `origin`, in degrees. */
9
+ export function lonLatFrom(origin, [east, north]) {
10
+ return [origin[0] + east / (RAD * EARTH * Math.cos(origin[1] * RAD)), origin[1] + north / (RAD * EARTH)];
11
+ }
12
+ /** The mean of a ring's longitudes and latitudes: a point to measure from. */
13
+ export const middleOf = (ring) => [ring.reduce((s, p) => s + p[0], 0) / ring.length, ring.reduce((s, p) => s + p[1], 0) / ring.length];
14
+ /** The area of a polygon, unsigned. */
15
+ export function areaOf(points) {
16
+ let s = 0;
17
+ points.forEach(([x, y], i) => {
18
+ const [x2, y2] = points[(i + 1) % points.length];
19
+ s += x * y2 - x2 * y;
20
+ });
21
+ return Math.abs(s) / 2;
22
+ }
23
+ /** The centre of mass of a polygon's area. */
24
+ export function centroidOf(points) {
25
+ let a = 0;
26
+ let cx = 0;
27
+ let cy = 0;
28
+ points.forEach(([x, y], i) => {
29
+ const [x2, y2] = points[(i + 1) % points.length];
30
+ const f = x * y2 - x2 * y;
31
+ a += f;
32
+ cx += (x + x2) * f;
33
+ cy += (y + y2) * f;
34
+ });
35
+ return a === 0 ? [points[0][0], points[0][1]] : [cx / (3 * a), cy / (3 * a)];
36
+ }
37
+ /** The convex hull of some points (Andrew's monotone chain), counter-clockwise. */
38
+ export function hullOf(points) {
39
+ const sorted = [...points].sort((p, q) => p[0] - q[0] || p[1] - q[1]);
40
+ if (sorted.length < 3)
41
+ return sorted;
42
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
43
+ const half = (list) => {
44
+ const out = [];
45
+ for (const p of list) {
46
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0)
47
+ out.pop();
48
+ out.push(p);
49
+ }
50
+ out.pop();
51
+ return out;
52
+ };
53
+ return [...half(sorted), ...half([...sorted].reverse())];
54
+ }
55
+ /** The heading, in degrees clockwise from north, of a direction given as east and north components, taken modulo 180 (an axis has no head): 0 to under 180. */
56
+ export const axisHeading = (east, north) => (((Math.atan2(east, north) / RAD) % 180) + 180) % 180;
57
+ /** The smallest rectangle (by area) that encloses some points: its long side's heading, its length and its width, in the units of the points. */
58
+ export function smallestBox(points) {
59
+ const hull = hullOf(points);
60
+ let best;
61
+ hull.forEach((p, i) => {
62
+ const q = hull[(i + 1) % hull.length];
63
+ const d = Math.hypot(q[0] - p[0], q[1] - p[1]);
64
+ if (d < 1e-9)
65
+ return;
66
+ const ux = (q[0] - p[0]) / d;
67
+ const uy = (q[1] - p[1]) / d;
68
+ let lo = Infinity;
69
+ let hi = -Infinity;
70
+ let left = Infinity;
71
+ let right = -Infinity;
72
+ for (const [x, y] of hull) {
73
+ const along = x * ux + y * uy;
74
+ const across = -x * uy + y * ux;
75
+ lo = Math.min(lo, along);
76
+ hi = Math.max(hi, along);
77
+ left = Math.min(left, across);
78
+ right = Math.max(right, across);
79
+ }
80
+ const [a, b] = [hi - lo, right - left];
81
+ if (best === undefined || a * b < best.area - 1e-9)
82
+ best = a >= b ? { area: a * b, heading: axisHeading(ux, uy), length: a, width: b } : { area: a * b, heading: axisHeading(-uy, ux), length: b, width: a };
83
+ });
84
+ if (best === undefined)
85
+ throw new Error("a rectangle needs points that are not all the same");
86
+ return { heading: best.heading, length: best.length, width: best.width };
87
+ }
88
+ /**
89
+ * A point given as east and north metres, in the drawing's frame, for a rink whose long side runs along `heading` (degrees clockwise from north, 0 to under 180): x along the rink's long side, y across it and down the page,
90
+ * so that the picture is the map turned and not mirrored. Metres stay metres.
91
+ */
92
+ export function turnedTo(heading, [east, north]) {
93
+ const a = heading * RAD;
94
+ return [east * Math.sin(a) + north * Math.cos(a), east * Math.cos(a) - north * Math.sin(a)];
95
+ }
96
+ /** The way north points in the drawing, in degrees clockwise from the top of the page, for a rink along `heading`. 90 less the heading: a rink that runs east-west has north at the top. */
97
+ export const northOn = (heading) => 90 - heading;
98
+ /** A ring with points that lie within `tolerance` of the line between their neighbours taken out (Douglas and Peucker), the ends kept: a ring is a list of points not repeating its first. */
99
+ export function simplified(ring, tolerance) {
100
+ const keep = new Array(ring.length).fill(false);
101
+ // The two farthest-apart points anchor the ring; each half is then simplified as a line.
102
+ let [a, b, far] = [0, 0, -1];
103
+ ring.forEach((p, i) => ring.forEach((q, j) => {
104
+ const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
105
+ if (j > i && d > far)
106
+ [a, b, far] = [i, j, d];
107
+ }));
108
+ keep[a] = true;
109
+ keep[b] = true;
110
+ const run = (from, to, step) => {
111
+ const [p, q] = [ring[from], ring[to]];
112
+ const len = Math.hypot(q[0] - p[0], q[1] - p[1]);
113
+ let at = -1;
114
+ let most = tolerance;
115
+ for (let i = step(from); i !== to; i = step(i)) {
116
+ const r = ring[i];
117
+ const d = len < 1e-9 ? Math.hypot(r[0] - p[0], r[1] - p[1]) : Math.abs((q[0] - p[0]) * (p[1] - r[1]) - (p[0] - r[0]) * (q[1] - p[1])) / len;
118
+ if (d > most)
119
+ [at, most] = [i, d];
120
+ }
121
+ if (at >= 0) {
122
+ keep[at] = true;
123
+ run(from, at, step);
124
+ run(at, to, step);
125
+ }
126
+ };
127
+ const next = (i) => (i + 1) % ring.length;
128
+ run(a, b, next);
129
+ run(b, a, next);
130
+ return ring.filter((_, i) => keep[i]);
131
+ }
132
+ /** Whether a point is in a polygon (even-odd). */
133
+ export function inside(points, [x, y]) {
134
+ let hit = false;
135
+ for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
136
+ const [xi, yi] = points[i];
137
+ const [xj, yj] = points[j];
138
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
139
+ hit = !hit;
140
+ }
141
+ return hit;
142
+ }
143
+ /** The smallest box round some points. */
144
+ export function boxOf(points) {
145
+ return { x0: Math.min(...points.map((p) => p[0])), y0: Math.min(...points.map((p) => p[1])), x1: Math.max(...points.map((p) => p[0])), y1: Math.max(...points.map((p) => p[1])) };
146
+ }
147
+ /** The distance from a point to the nearest edge of a polygon. */
148
+ export function edgeDistance(points, [x, y]) {
149
+ let best = Infinity;
150
+ points.forEach(([ax, ay], i) => {
151
+ const [bx, by] = points[(i + 1) % points.length];
152
+ const [dx, dy] = [bx - ax, by - ay];
153
+ const len = dx * dx + dy * dy;
154
+ const t = len === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len));
155
+ best = Math.min(best, Math.hypot(x - (ax + t * dx), y - (ay + t * dy)));
156
+ });
157
+ return best;
158
+ }
159
+ /** How coarse the raster `coreOf` lays on the outline is, in the units of the points, and how many degrees apart the directions it tries are. */
160
+ const CELL = 2;
161
+ const STEP = 3;
162
+ /** The largest rectangle with its sides along x and y inside a polygon: width, height and the middle, found on a raster of `CELL` (the largest rectangle in each row's histogram). */
163
+ function largestRectangle(poly) {
164
+ const b = boxOf(poly);
165
+ const nx = Math.max(1, Math.ceil((b.x1 - b.x0) / CELL));
166
+ const ny = Math.max(1, Math.ceil((b.y1 - b.y0) / CELL));
167
+ const column = new Array(nx).fill(0);
168
+ let best = { area: 0, w: 0, h: 0, at: [0, 0] };
169
+ for (let j = 0; j < ny; j += 1) {
170
+ for (let i = 0; i < nx; i += 1)
171
+ column[i] = inside(poly, [b.x0 + (i + 0.5) * CELL, b.y0 + (j + 0.5) * CELL]) ? column[i] + 1 : 0;
172
+ const stack = [];
173
+ for (let i = 0; i <= nx; i += 1) {
174
+ const h = i === nx ? 0 : column[i];
175
+ let start = i;
176
+ while (stack.length > 0 && stack[stack.length - 1][1] >= h) {
177
+ const [from, height] = stack.pop();
178
+ const area = height * (i - from);
179
+ if (area > best.area)
180
+ best = { area, w: (i - from) * CELL, h: height * CELL, at: [b.x0 + (from + (i - from) / 2) * CELL, b.y0 + (j + 1 - height / 2) * CELL] };
181
+ start = from;
182
+ }
183
+ stack.push([start, h]);
184
+ }
185
+ }
186
+ return { w: best.w, h: best.h, at: best.at };
187
+ }
188
+ /**
189
+ * The main hall of a building: the largest rectangle inside its outline over every direction (`STEP` degrees apart), which does not see a wing, a plaza or a ramp that a rectangle cannot fill. Its long side is the
190
+ * building's axis, its middle the middle of the hall, and `strength` how much of a direction the building has (a round one has none). Ties between a direction and the one at a right angle go to the longer rectangle.
191
+ */
192
+ export function coreOf(points) {
193
+ const areas = [];
194
+ let best;
195
+ for (let angle = 0; angle < 180; angle += STEP) {
196
+ const a = angle * RAD;
197
+ // The outline turned so that the direction `angle` (counter-clockwise from east) lies along x.
198
+ const turned = points.map(([x, y]) => [x * Math.cos(a) + y * Math.sin(a), -x * Math.sin(a) + y * Math.cos(a)]);
199
+ const { w, h, at } = largestRectangle(turned);
200
+ areas.push(w * h);
201
+ if (best === undefined || w * h > best.area * 1.005 || (w * h >= best.area * 0.995 && w / h > best.w / best.h))
202
+ best = { area: w * h, angle, w, h, at };
203
+ }
204
+ if (best === undefined || best.area === 0)
205
+ throw new Error("a building needs room for a rectangle");
206
+ const a = best.angle * RAD;
207
+ const centre = [best.at[0] * Math.cos(a) - best.at[1] * Math.sin(a), best.at[0] * Math.sin(a) + best.at[1] * Math.cos(a)];
208
+ // The long side runs along `angle` when it is the width, and at a right angle to it when it is the height; a heading is clockwise from north.
209
+ const along = best.w >= best.h ? best.angle : best.angle + 90;
210
+ return { heading: (((90 - along) % 180) + 180) % 180, length: Math.max(best.w, best.h), width: Math.min(best.w, best.h), centre, strength: Math.max(...areas) / Math.min(...areas) };
211
+ }
package/dist/venues.js ADDED
@@ -0,0 +1,213 @@
1
+ // The real venues: for an arena of the NHL, the outline of its building and the way its rink lies in it (from OpenStreetMap, ODbL 1.0), and a bowl of the seats, the number of seating levels and the kind of roof
2
+ // the sources give (Wikidata, CC0, and the English Wikipedia article each fact cites), fitted inside the outline. Level one is the building: its outline and orientation, as OpenStreetMap maps them. Level two is a plausible
3
+ // bowl: a number of seats in the number of tiers the sources say, between the rink's boards and the building's wall, under the roof's kind. It stops there: no team's or ticket seller's seat map is read or copied, no
4
+ // section or row is named, and no mark of a team or a league is drawn. The drawing is `drawKyogijo({ sport: "nhl", arena, crowd: { real: true } })` and, in 3D, the same options in `buildTurn` or `mountTurn`.
5
+ import { arenaById, arenaRinkIn } from "./arenas.js";
6
+ import { NORTH_ROOM, aislesOf, areaOfOutline, outlinePoints } from "./crowd.js";
7
+ import { NHL, FEET_PER_METRE } from "./geometry.js";
8
+ import { boxOf, edgeDistance, inside, metresFrom, middleOf, northOn, turnedTo } from "./venues.geo.js";
9
+ import { VENUE_DATA } from "./venues.data.js";
10
+ import { VENUE_ATTRIBUTION, VENUE_OSM } from "./venues.osm.data.js";
11
+ export { VENUE_ATTRIBUTION };
12
+ /** The venues by key: every arena of the Pacific division of the NHL, with its facts and its outline. */
13
+ export const VENUES = Object.freeze(Object.fromEntries(VENUE_DATA.map((data) => [data.id, Object.freeze({ ...data, osm: VENUE_OSM[data.id] })])));
14
+ /** The keys of `VENUES`, in the order of the data. */
15
+ export const VENUE_IDS = Object.freeze(VENUE_DATA.map((data) => data.id));
16
+ /** The venue of an arena (by its key in `ARENAS`), or `undefined`. */
17
+ export const venueById = (id) => (id !== undefined && Object.prototype.hasOwnProperty.call(VENUES, id) ? VENUES[id] : undefined);
18
+ /** The venue whose arena is the Wikidata item `Q774138`, or `undefined`. */
19
+ export const venueByWikidata = (wikidata) => Object.values(VENUES).find((venue) => venue.wikidata === wikidata);
20
+ /** The venues of a division of the NHL, in the order of the data. */
21
+ export const venuesIn = (division) => Object.values(VENUES).filter((venue) => venue.division === division);
22
+ /** The arena a venue is of. */
23
+ export const arenaOfVenue = (venue) => {
24
+ const arena = arenaById(venue.id);
25
+ if (arena === undefined)
26
+ throw new Error(`${venue.id} is not an arena`);
27
+ return arena;
28
+ };
29
+ const round = (value, places = 1) => Math.round(value * 10 ** places) / 10 ** places;
30
+ const lonLat = (ring) => ring.map(([lon, lat]) => [lon, lat]);
31
+ /** How the rink lies in a venue's building (see `VenueOsm.axis`). */
32
+ export const venueOrientation = (venue) => ({ heading: venue.osm.axis.heading, basis: venue.osm.axis.basis, north: round(northOn(venue.osm.axis.heading)) });
33
+ // ---- The bowl. ----
34
+ /** The rink's box as the drawing has it: the arena's own where its sources publish a size, else the NHL's. */
35
+ export function venueRink(venue) {
36
+ const sized = arenaRinkIn(arenaById(venue.id));
37
+ return { x: 0, y: 0, w: sized?.length ?? NHL.length, h: sized?.width ?? NHL.width };
38
+ }
39
+ /** How far the first row is from the boards at the sides and at the ends, and the bench side's extra, and the corner's radius: the NHL profile of `crowd.ts`, so that a real venue's first row is the usual one. */
40
+ const FIRST_ROW = { side: 8, end: 8, bench: 6, corner: 40 };
41
+ /** The floor a seat takes in plan, in square feet (ours): a bowl of today's arenas is steeper than the usual drawing's, whose seat is 2 by 2.3 feet (4.6), so that a seat takes less of the floor's area. Tried in turn: a building
42
+ * with room for the seats at the first has the first; one with less has a steeper bowl, to the last, which is the least a seat takes. */
43
+ export const SEAT_AREAS = [3.8, 3.4, 3];
44
+ /** How far the bowl's outer edge keeps from the building's wall, in feet (ours: the wall and the walk round the bowl). */
45
+ export const WALL = 10;
46
+ /** How far the hall's wall stands beyond the bowl's outer edge, in feet, where the building's outline allows: the concourse and the wall, ours. */
47
+ export const HALL = 30;
48
+ /** The walk between two tiers, in feet (ours: a suite level or a club between two bowls). */
49
+ export const GAP = 10;
50
+ /** The share of the seats in the lowest tier where no source says: a lower bowl is a little more than half. */
51
+ const LOWER_SHARE = 0.56;
52
+ /** A bowl's outer corners, as a share of its half-depth (ours). */
53
+ const CORNER = 0.75;
54
+ /** The height of the first row above the ice, in feet, the rise of a tier's rows by the number of tiers (what the lowest, a middle and the upper bowl rise, at most), the rise of the walk between two tiers (a suite level), and the steepest a bowl is raked, in degrees (ours: no source gives them). */
55
+ export const FIRST_HEIGHT = 4;
56
+ export const RISES = { 1: [40], 2: [26, 40], 3: [24, 20, 36] };
57
+ export const WALK_RISE = 16;
58
+ export const MAX_RAKE = 36;
59
+ const TIER_IDS = { 1: ["lower"], 2: ["lower", "upper"], 3: ["lower", "middle", "upper"] };
60
+ /** How many of a tier's seats are taken, relative to the stands' (the upper tiers are emptier), as `crowd.ts` has them. */
61
+ const FILL = { lower: 1, middle: 0.94, upper: 0.88 };
62
+ /** The height a roof's edge stands over the top row, at the least, in feet. */
63
+ export const CLEARANCE = 22;
64
+ const lerp = (a, b, t) => a + (b - a) * t;
65
+ const between = (a, b, t) => ({ kind: "rrect", x0: lerp(a.x0, b.x0, t), y0: lerp(a.y0, b.y0, t), x1: lerp(a.x1, b.x1, t), y1: lerp(a.y1, b.y1, t), r: lerp(a.r, b.r, t) });
66
+ /** The parameter in `[lo, hi]` at which an increasing function reaches `target` (bisection), or `hi` where it does not. */
67
+ function solveFor(f, lo, hi, target) {
68
+ if (f(hi) <= target)
69
+ return hi;
70
+ let [a, b] = [lo, hi];
71
+ for (let i = 0; i < 48; i += 1) {
72
+ const m = (a + b) / 2;
73
+ if (f(m) < target)
74
+ a = m;
75
+ else
76
+ b = m;
77
+ }
78
+ return (a + b) / 2;
79
+ }
80
+ /**
81
+ * The plan of a venue's bowl: the building's outline turned so that the rink lies along x, a first row round the rink as an NHL arena has it, the largest bowl-shaped outline that fits the building round the middle of its main
82
+ * hall, and between the two the tiers, the outer edge of each placed so that its band holds the share of the venue's seats the sources give it (at `SEAT_AREAS` square feet a seat), with a walk between the tiers. The
83
+ * bowl is as long and wide as the building lets it be and as round at the corners as a bowl is; the rest is the building's.
84
+ * @example
85
+ * ```ts
86
+ * import { planVenue, venueById } from "@johnmorrisdotca/kyogijo-stadiums/venues";
87
+ * const plan = planVenue(venueById("honda-center")!);
88
+ * console.log(plan.orientation.basis, plan.seats.map((t) => t.tier).join(" "), plan.total);
89
+ * // building lower upper 17174
90
+ * ```
91
+ */
92
+ export function planVenue(venue, rink = venueRink(venue)) {
93
+ const key = `${rink.x}|${rink.y}|${rink.w}|${rink.h}`;
94
+ const mine = PLANS.get(venue) ?? new Map();
95
+ PLANS.set(venue, mine);
96
+ const known = mine.get(key);
97
+ if (known !== undefined)
98
+ return known;
99
+ const made = plan(venue, rink);
100
+ mine.set(key, made);
101
+ return made;
102
+ }
103
+ /** Plans already made, by venue and rink: a plan is a pure function of a venue and a rink, and a drawing asks for it more than once. */
104
+ const PLANS = new WeakMap();
105
+ function plan(venue, rink) {
106
+ const orientation = venueOrientation(venue);
107
+ const origin = middleOf(lonLat(venue.osm.ring));
108
+ const turned = (ring) => metresFrom(origin, lonLat(ring)).map((p) => turnedTo(orientation.heading, p));
109
+ const outline = turned(venue.osm.ring);
110
+ // The rink stands in the middle of the ice where OpenStreetMap maps it, else in the middle of the building's main hall.
111
+ const [middle] = turned([venue.osm.axis.centre]);
112
+ const at = [rink.x + rink.w / 2, rink.y + rink.h / 2];
113
+ const footprint = outline.map(([x, y]) => [round((x - middle[0]) * FEET_PER_METRE + at[0], 2), round((y - middle[1]) * FEET_PER_METRE + at[1], 2)]);
114
+ const fb = boxOf(footprint);
115
+ // The first row round the rink, and the largest bowl-shaped outline that fits inside the building round the middle of the hall: as much longer than wide as the hall is, and as round at the corners as a bowl is.
116
+ const first = { kind: "rrect", x0: rink.x - FIRST_ROW.end, y0: rink.y - FIRST_ROW.side, x1: rink.x + rink.w + FIRST_ROW.end, y1: rink.y + rink.h + FIRST_ROW.side + FIRST_ROW.bench, r: FIRST_ROW.corner };
117
+ // The aspect that holds the most: a bowl is between 1.15 and 1.6 times as long as it is wide, as much as the hall allows.
118
+ const shapedAs = (long, s) => ({ kind: "rrect", x0: at[0] - long * s, y0: at[1] - s, x1: at[0] + long * s, y1: at[1] + s, r: CORNER * s });
119
+ const largest = (long) => {
120
+ let [lo, hi] = [60, Math.max(fb.x1 - fb.x0, fb.y1 - fb.y0)];
121
+ for (let i = 0; i < 24; i += 1) {
122
+ const m = (lo + hi) / 2;
123
+ if (outlinePoints(shapedAs(long, m)).every((p) => inside(footprint, p) && edgeDistance(footprint, p) >= WALL))
124
+ lo = m;
125
+ else
126
+ hi = m;
127
+ }
128
+ return lo;
129
+ };
130
+ const tries = [1.15, 1.25, 1.35, 1.45, 1.6].map((long) => ({ long, s: largest(long) }));
131
+ const best = tries.reduce((a, b) => (a.long * a.s * a.s >= b.long * b.s * b.s ? a : b));
132
+ const lo = best.s;
133
+ const shaped = (s) => shapedAs(best.long, s);
134
+ const wall = shaped(lo);
135
+ // A building too small for the first row and a tier's depth is given the room: the bowl then lies beyond its walls (`fit` says so).
136
+ const outer = { ...wall, x0: Math.min(wall.x0, first.x0 - 40), y0: Math.min(wall.y0, first.y0 - 40), x1: Math.max(wall.x1, first.x1 + 40), y1: Math.max(wall.y1, first.y1 + 40) };
137
+ const depth = (first.x0 - outer.x0) + (first.y0 - outer.y0) + (outer.x1 - first.x1) + (outer.y1 - first.y1);
138
+ const gap = GAP / Math.max(20, depth / 4);
139
+ // The tiers, from the rink out: each takes its share of the seats, then the walk. A building with less room than the seats need at the first of `SEAT_AREAS` a seat gets a bowl that is steeper (a seat takes less of the floor), down
140
+ // to the least a seat can take; where even that is too little the bowl holds fewer seats than the venue and `fit` says `tight`.
141
+ const count = venue.tiers.count;
142
+ const lower = venue.tiers.lowerShare ?? (count === 1 ? 1 : count === 2 ? LOWER_SHARE : 0.4);
143
+ const shares = count === 1 ? [1] : count === 2 ? [lower, 1 - lower] : [lower, (1 - lower) * 0.36, (1 - lower) * 0.64];
144
+ const outlineAt = (t) => between(first, outer, t);
145
+ const build = (area) => {
146
+ const seatsOf = (region, hole) => Math.round((areaOfOutline(region) - areaOfOutline(hole)) / area);
147
+ const rings = [];
148
+ let t0 = 0;
149
+ let tight = false;
150
+ let up = FIRST_HEIGHT;
151
+ shares.forEach((share, k) => {
152
+ const wanted = venue.seats.hockey * share;
153
+ const t1 = solveFor((t) => seatsOf(outlineAt(t), outlineAt(t0)), t0, 1, wanted);
154
+ if (seatsOf(outlineAt(t1), outlineAt(t0)) < wanted - 1)
155
+ tight = true;
156
+ const region = outlineAt(t1);
157
+ const hole = outlineAt(t0);
158
+ const tier = TIER_IDS[count][k];
159
+ // The band's depth along the two long sides on average, where the bowl is the shallowest and the rake the steepest: the rise is what a rake of at most `MAX_RAKE` degrees gives over it, up to the usual rise of a tier of its place.
160
+ const run = (region.y1 - hole.y1 + (hole.y0 - region.y0)) / 2;
161
+ const rise = Math.min(RISES[count][k], Math.max(6, run * Math.tan((MAX_RAKE * Math.PI) / 180)));
162
+ const [h0, h1] = [k === 0 ? FIRST_HEIGHT : up + WALK_RISE, (k === 0 ? FIRST_HEIGHT : up + WALK_RISE) + rise];
163
+ up = h1;
164
+ rings.push({
165
+ id: tier,
166
+ tier,
167
+ region,
168
+ hole,
169
+ // Its depth at the bench side, from the first row, as the tunnel and the camera are placed.
170
+ from: round(hole.y1 - first.y1, 2),
171
+ to: round(region.y1 - first.y1, 2),
172
+ height: [round(h0), round(h1)],
173
+ rake: round((Math.atan2(h1 - h0, Math.max(1, run)) * 180) / Math.PI),
174
+ fill: FILL[tier],
175
+ seats: seatsOf(region, hole),
176
+ aisles: aislesOf(region, hole),
177
+ });
178
+ t0 = Math.min(1, t1 + gap);
179
+ });
180
+ return { rings, tight };
181
+ };
182
+ let seatArea = SEAT_AREAS[0];
183
+ let made = build(seatArea);
184
+ for (const area of SEAT_AREAS.slice(1)) {
185
+ if (!made.tight)
186
+ break;
187
+ seatArea = area;
188
+ made = build(area);
189
+ }
190
+ const { rings, tight } = made;
191
+ // The rounding of each tier's seats leaves the total a seat or two off the venue's: the last tier takes the difference, so that a bowl that fits holds exactly the seats the sources give.
192
+ const missing = venue.seats.hockey - rings.reduce((sum, ring) => sum + ring.seats, 0);
193
+ if (!tight && Math.abs(missing) <= 3)
194
+ rings[rings.length - 1].seats += missing;
195
+ const total = rings.reduce((sum, ring) => sum + ring.seats, 0);
196
+ const top = rings[rings.length - 1].height[1];
197
+ const edge = Math.max(round((venue.osm.height ?? 0) * FEET_PER_METRE), top + CLEARANCE);
198
+ const north = orientation.basis === "none" ? undefined : orientation.north;
199
+ const room = north === undefined ? 0 : NORTH_ROOM;
200
+ const bowl = {
201
+ rings,
202
+ off: { left: FIRST_ROW.end, right: FIRST_ROW.end, top: FIRST_ROW.side, bottom: FIRST_ROW.side + FIRST_ROW.bench },
203
+ 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) },
204
+ shell: { footprint, hall: { ...outer, x0: outer.x0 - HALL, y0: outer.y0 - HALL, x1: outer.x1 + HALL, y1: outer.y1 + HALL, r: outer.r + HALL }, ...(north === undefined ? {} : { north }), roof: venue.roof.kind, edge },
205
+ };
206
+ return { venue, orientation, box: rink, footprint, bowl, seats: rings.map((ring) => ({ tier: ring.tier, seats: ring.seats })), total, seatArea, fit: tight ? "tight" : "fits" };
207
+ }
208
+ /** The stands of a venue for `layoutOf` and the 3D view, round a rink: `planVenue`'s bowl. */
209
+ export const venueBowl = (venue, rink) => planVenue(venue, rink).bowl;
210
+ /** Whether the bowl lies inside the building: every corner of every tier's outer edge is inside the footprint. */
211
+ export function bowlInside(plan) {
212
+ return outlinePoints(plan.bowl.rings[plan.bowl.rings.length - 1].region).every((p) => inside(plan.footprint, p));
213
+ }
@@ -0,0 +1,4 @@
1
+ import type { VenueOsm } from "./venues.ts";
2
+ /** The credit the ODbL asks for, wherever a drawing made from this data is shown. */
3
+ export declare const VENUE_ATTRIBUTION = "(c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright";
4
+ export declare const VENUE_OSM: Record<string, VenueOsm>;