@johnmorrisdotca/kyogijo-stadiums 0.10.2 → 0.12.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.
@@ -0,0 +1,324 @@
1
+ // A tournament layout drawn: the package's own surfaces, each drawn by `drawKyogijo` and set where the layout puts it, on one floor, to scale, with the barriers, the officials' chairs, the scoreboard, the
2
+ // boards and the empty places for the event's own words and marks; and, when asked, the hall's floor and a real stadium round it at the same scale. A surface is not drawn a second way here: its markup is
3
+ // the drawing's own, moved. The same options always give the same string. The text alternative is `multiTable`.
4
+ import { octagonD } from "./budo/plan.js";
5
+ import { drawKyogijo } from "./draw.js";
6
+ import { safeClass } from "./common.js";
7
+ import { densityOf, layoutOf, resolveCrowd } from "./crowd.js";
8
+ import { drawCrowd, KYOGIJO_CROWD_STYLE } from "./crowd.draw.js";
9
+ import { el, esc, hash, num } from "./format.js";
10
+ import { FEET_PER_METRE } from "./geometry.js";
11
+ import { fitSentence, multiFit, multiLayout, multiNumber, needsSentence, venueOf } from "./multi.js";
12
+ import { KYOGIJO_MULTI_STYLE } from "./multi.style.js";
13
+ import { MULTI_WORDS } from "./multi.words.js";
14
+ import { outlineOf } from "./outline.js";
15
+ import { fill } from "./strings.js";
16
+ import { textWidth } from "./text.js";
17
+ import { VERSION } from "./version.js";
18
+ const metres = (ft) => ft / FEET_PER_METRE;
19
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
20
+ /** One of the layout's surfaces as markup: the drawing's own, without its title, its stylesheet, its apron and its free zone (the layout draws the floor). */
21
+ function inner(svg) {
22
+ const look = /^<svg[^>]* data-look="([^"]*)"/.exec(svg)?.[1];
23
+ const style = /<style>([\s\S]*?)<\/style>/.exec(svg)?.[1] ?? "";
24
+ let body = svg.replace(/<style>[\s\S]*?<\/style>/, "");
25
+ body = body.slice(body.indexOf("</desc>") + "</desc>".length, body.lastIndexOf("</svg>"));
26
+ body = body.replace(/<rect class="ky-(?:apron|free)(?: [^"]*)?"[^>]*\/>/g, "");
27
+ return { body, style, look };
28
+ }
29
+ /** The text of an empty place, sized to fit it. */
30
+ function slotText(slot, words) {
31
+ return slot.kind === "floor-text" ? words.slots.floorText : slot.kind === "floor-logo" ? words.slots.floorLogo : words.slots.board;
32
+ }
33
+ /** The tag of a layout's floor in the drawing: the colour looks of `data-floor`, a budō floor wearing the mats'. */
34
+ const floorClass = (layout) => (layout.spec.kind === "budo" ? "ky-bud-t2" : "ky-mu-floor");
35
+ /**
36
+ * A tournament layout as one SVG string: several of the package's surfaces laid out together for one large event, to scale, on one floor. `preset` is one of `MULTI_PRESETS` (`tabletennis-4`, `pickleball-2x4`,
37
+ * `curling-1x4`, `judo-1x3`, `karate-2x3`, `badminton-1x4`, ...), or `layout` is your own; `rows`, `columns` and `stagger` change the grid. The empty places for the event's words and marks are drawn dashed and hold nothing.
38
+ * `venue` sets the layout in a hall's floor (a key of `MULTI_VENUES` or a size of your own) and the description says whether it fits; `stadium` draws a real stadium round the floor at the same scale, with the
39
+ * walls, the stands and the crowd the package draws for the sport. Light and dark follow the page; `multiTable` is the same thing as a table.
40
+ * @example
41
+ * ```ts
42
+ * import { drawMulti } from "@johnmorrisdotca/kyogijo-stadiums/multi";
43
+ * const svg = drawMulti({ preset: "tabletennis-4", stadium: true, venue: "ovo-arena-wembley" });
44
+ * console.log(svg.startsWith("<svg"), svg.includes("Table 4"));
45
+ * // true true
46
+ * ```
47
+ */
48
+ export function drawMulti(options = {}) {
49
+ const o = options;
50
+ const language = o.language === "ja" ? "ja" : "en";
51
+ const words = MULTI_WORDS[language];
52
+ const layout = multiLayout(o);
53
+ const spec = layout.spec;
54
+ const venue = venueOf(o.venue);
55
+ const fit = venue === undefined ? undefined : multiFit(layout, venue);
56
+ const stadium = o.stadium === undefined || o.stadium === false ? undefined : o.stadium === true ? {} : o.stadium;
57
+ const gid = safeClass(o.id).replace(/ /g, "-") || `ky-mu-${hash(JSON.stringify([spec, o, VERSION]))}`;
58
+ const withSlots = o.slots !== false;
59
+ const withLabels = o.labels !== false;
60
+ // ---- The hall: the venue's floor, or the layout's own, and where the layout stands in it. ----
61
+ const turned = fit?.turned === true && fit.fits === true;
62
+ const lw = turned ? layout.floor.h : layout.floor.w;
63
+ const lh = turned ? layout.floor.w : layout.floor.h;
64
+ let hall = { w: lw, h: lh };
65
+ if (venue !== undefined && venue.shape === "rectangle") {
66
+ const L = venue.unit === "m" ? venue.length * FEET_PER_METRE : venue.length;
67
+ const W = venue.unit === "m" ? venue.width * FEET_PER_METRE : venue.width;
68
+ hall = { w: L, h: W };
69
+ }
70
+ else if (venue !== undefined && venue.shape === "octagon") {
71
+ const across = venue.across * FEET_PER_METRE;
72
+ hall = { w: across, h: across };
73
+ }
74
+ // The layout is set in the middle of the hall; one that does not fit still is, and hangs over.
75
+ const ox = (hall.w - lw) / 2;
76
+ const oy = (hall.h - lh) / 2;
77
+ const move = turned ? `translate(${num(ox + lw)} ${num(oy)}) rotate(90)` : `translate(${num(ox)} ${num(oy)})`;
78
+ const to = (x, y) => (turned ? [ox + lw - y, oy + x] : [ox + x, oy + y]);
79
+ // ---- The surfaces, each drawn by the package. ----
80
+ let style = "";
81
+ let look;
82
+ let cellsMarkup = "";
83
+ const surfaceBox = outlineOf(spec.surface).box;
84
+ const common = { ...spec.surface, margin: 0, language };
85
+ if (spec.kind === "budo") {
86
+ const drawn = inner(drawKyogijo({ ...common, id: `${gid}-s`, ...(o.style === "none" ? { style: "none" } : {}) }));
87
+ style = drawn.style;
88
+ look = drawn.look;
89
+ // The mats stand where the layout puts them: the drawing's own origin is the tatami's top left.
90
+ const t = layout.tatami;
91
+ cellsMarkup = el("g", { class: "ky-mu-cell", transform: `translate(${num(t.x - surfaceBox.x)} ${num(t.y - surfaceBox.y)})` }, drawn.body);
92
+ }
93
+ else {
94
+ layout.cells.forEach((cell, i) => {
95
+ const drawn = inner(drawKyogijo({ ...common, id: `${gid}-s${i + 1}`, style: i === 0 && o.style !== "none" ? "embed" : "none" }));
96
+ if (i === 0) {
97
+ style = drawn.style;
98
+ look = drawn.look;
99
+ }
100
+ // A surface turned a quarter stands across the layout: the drawing's own top left goes to the cell's top right, and the drawing is turned.
101
+ const place = cell.turn ? `translate(${num(cell.x + cell.w)} ${num(cell.y)}) rotate(90) translate(${num(-surfaceBox.x)} ${num(-surfaceBox.y)})` : `translate(${num(cell.x - surfaceBox.x)} ${num(cell.y - surfaceBox.y)})`;
102
+ cellsMarkup += el("g", { class: "ky-mu-cell", "data-cell": cell.n, transform: place }, drawn.body);
103
+ });
104
+ }
105
+ // ---- What stands on and round the floor. ----
106
+ let floorMarkup = "";
107
+ floorMarkup += el("rect", { class: floorClass(layout), x: 0, y: 0, width: num(layout.floor.w), height: num(layout.floor.h) });
108
+ let over = "";
109
+ if (spec.parts.barriers === true)
110
+ for (const c of layout.cells)
111
+ over += el("rect", { class: "ky-mu-barrier", x: num(c.bay.x + 0.5), y: num(c.bay.y + 0.5), width: num(c.bay.w - 1), height: num(c.bay.h - 1), rx: 0.4 });
112
+ if (spec.parts.officials === true) {
113
+ for (const c of layout.cells) {
114
+ if (c.turn) {
115
+ // Beside the net, on the right of a surface that stands across the layout.
116
+ const cy = c.y + c.h / 2;
117
+ over += el("rect", { class: "ky-mu-chair", x: num(c.x + c.w + 1.8), y: num(cy - 3.2), width: 1.6, height: 1.6, rx: 0.3 });
118
+ over += el("rect", { class: "ky-mu-desk", x: num(c.x + c.w + 1.5), y: num(cy - 0.8), width: 1.1, height: 3.6, rx: 0.2 });
119
+ continue;
120
+ }
121
+ const cx = c.x + c.w / 2;
122
+ over += el("rect", { class: "ky-mu-chair", x: num(cx + 1.6), y: num(c.y - 3.4), width: 1.6, height: 1.6, rx: 0.3 });
123
+ over += el("rect", { class: "ky-mu-desk", x: num(cx - 5), y: num(c.y - 3.1), width: 3.6, height: 1.1, rx: 0.2 });
124
+ }
125
+ }
126
+ if (spec.parts.scoreboard === "each")
127
+ for (const c of layout.cells)
128
+ over += el("rect", { class: "ky-mu-sb", x: num(c.x + c.w / 2 - 2), y: num(c.y + c.h + 1.2), width: 4, height: 1.4, rx: 0.2 });
129
+ const sb = layout.shared;
130
+ if (sb !== undefined) {
131
+ over += el("rect", { class: "ky-mu-sb", x: num(sb.x), y: num(sb.y), width: num(sb.w), height: num(sb.h), rx: 0.5 });
132
+ // One scoreboard for all: a quarter of it for each of four areas, or one panel for each when they are fewer or more.
133
+ const n = layout.cells.length;
134
+ const cols = n <= 4 ? Math.min(2, n) : n;
135
+ const rows = n <= 4 && n > 2 ? 2 : 1;
136
+ let cuts = "";
137
+ for (let i = 1; i < cols; i += 1)
138
+ cuts += `M${num(sb.x + (sb.w * i) / cols)} ${num(sb.y)}v${num(sb.h)}`;
139
+ for (let j = 1; j < rows; j += 1)
140
+ cuts += `M${num(sb.x)} ${num(sb.y + (sb.h * j) / rows)}h${num(sb.w)}`;
141
+ if (cuts !== "")
142
+ over += el("path", { class: "ky-mu-sb-cut", d: cuts, fill: "none" });
143
+ }
144
+ let slotsMarkup = "";
145
+ let slotTexts = "";
146
+ if (withSlots) {
147
+ for (const slot of layout.slots) {
148
+ const board = slot.kind === "board";
149
+ slotsMarkup += el("rect", { class: board ? "ky-mu-board" : "ky-mu-slot", x: num(slot.x), y: num(slot.y), width: num(slot.w), height: num(slot.h), "data-slot": slot.id });
150
+ if (board)
151
+ slotsMarkup += el("rect", { class: "ky-mu-slot ky-mu-slot-board", x: num(slot.x + 0.3), y: num(slot.y + 0.3), width: num(slot.w - 0.6), height: num(slot.h - 0.6) });
152
+ const text = slotText(slot, words);
153
+ const horizontal = slot.w >= slot.h;
154
+ const long = horizontal ? slot.w : slot.h;
155
+ const short = horizontal ? slot.h : slot.w;
156
+ const size = clamp(Math.min(short * 0.5, (long * 0.9) / Math.max(1, textWidth(text))), 0.5, 4);
157
+ const [tx, ty] = to(slot.x + slot.w / 2, slot.y + slot.h / 2);
158
+ // The words of a slot on a long side are laid along it; in a turned layout they turn back to read from the left.
159
+ const rot = (horizontal ? 0 : -90) + 0;
160
+ slotTexts += el("text", { class: `ky-mu-slot-text${board ? " ky-mu-slot-text-board" : ""}`, x: num(tx), y: num(ty + size * 0.34), "font-size": num(size), transform: rot === 0 ? undefined : `rotate(${rot} ${num(tx)} ${num(ty)})` }, esc(text));
161
+ }
162
+ }
163
+ let labelsMarkup = "";
164
+ if (withLabels) {
165
+ for (const c of layout.cells) {
166
+ const size = clamp(Math.min(c.bay.w, c.bay.h) * 0.075, 1.1, 3.4);
167
+ let lx;
168
+ let ly;
169
+ let anchor = "start";
170
+ if (spec.kind === "budo") {
171
+ [lx, ly] = to(c.x + c.w / 2, c.y + c.h * 0.16);
172
+ anchor = "middle";
173
+ }
174
+ else {
175
+ // At the outer corner of its bay, so that the labels stay clear of what hangs over the middle of the floor.
176
+ const right = spec.columns > 1 && c.column >= spec.columns / 2;
177
+ const below = spec.rows > 1 && spec.columns > 1 && c.row >= spec.rows / 2;
178
+ [lx, ly] = to(right ? c.bay.x + c.bay.w - 1.4 : c.bay.x + 1.4, below ? c.bay.y + c.bay.h - size * 0.5 : c.bay.y + size * 1.3);
179
+ anchor = right !== turned ? "end" : "start";
180
+ }
181
+ labelsMarkup += el("text", { class: "ky-mu-label", x: num(lx), y: num(ly), "font-size": num(size), "text-anchor": anchor }, esc(c.label));
182
+ }
183
+ }
184
+ // ---- The hall and the stadium round it. ----
185
+ let hallMarkup = "";
186
+ let reach = { left: 0, right: 0, top: 0, bottom: 0 };
187
+ let defs = "";
188
+ let crowdStyle = "";
189
+ const wide = { x: 0, y: 0, w: hall.w, h: hall.h };
190
+ const boards = layout.slots.filter((s) => s.kind === "board");
191
+ const boardsMargin = withSlots && boards.length > 0 ? 5 : 0;
192
+ if (venue !== undefined || stadium !== undefined) {
193
+ const look = venue?.floor !== undefined && venue.floor !== "neutral" ? ` ky-mu-hf-${venue.floor}` : "";
194
+ if (venue?.shape === "octagon") {
195
+ const a = hall.w / 2;
196
+ hallMarkup += el("path", { class: `ky-mu-hallfloor${look}`, d: octagonD(a, a, a) });
197
+ }
198
+ else
199
+ hallMarkup += el("rect", { class: `ky-mu-hallfloor${look}`, x: 0, y: 0, width: num(hall.w), height: num(hall.h) });
200
+ }
201
+ if (stadium !== undefined) {
202
+ const standsLike = venue?.standsLike ?? spec.sport;
203
+ const crowd = resolveCrowd({ gaps: false, awayEnd: "none", roof: stadium.roof, shape: stadium.shape, density: stadium.density, detail: stadium.detail === "plain" ? "plain" : "fine" });
204
+ const stands = layoutOf({ sport: standsLike, box: wide, crowd, awaySide: "right", arena: venue?.shape === "octagon" });
205
+ const density = stadium.crowd === false ? 0 : densityOf(crowd, stands.seats, undefined);
206
+ const drawn = drawCrowd(stands, { id: `${gid}-cr`, crowd, density, fine: crowd.detail !== "plain", ground: "ky-mu-apron" });
207
+ defs += drawn.defs;
208
+ const wall = venue?.shape === "octagon" ? el("path", { class: "ky-mu-wall", d: octagonD(hall.w / 2, hall.w / 2, hall.w / 2) }) : el("rect", { class: "ky-mu-wall", x: 0, y: 0, width: num(hall.w), height: num(hall.h), rx: 0.5 });
209
+ hallMarkup = drawn.markup + hallMarkup + wall;
210
+ reach = stands.reach;
211
+ crowdStyle = KYOGIJO_CROWD_STYLE;
212
+ }
213
+ else if (venue !== undefined) {
214
+ hallMarkup += el("rect", { class: "ky-mu-hall", x: 0, y: 0, width: num(hall.w), height: num(hall.h) });
215
+ }
216
+ const bad = fit !== undefined && fit.fits === false;
217
+ const overhang = bad ? el("rect", { class: "ky-mu-over", x: num(ox), y: num(oy), width: num(lw), height: num(lh) }) : "";
218
+ // ---- The picture. ----
219
+ const room = Math.max(4, Math.min(hall.w, hall.h) * 0.03);
220
+ const view = {
221
+ x: Math.min(wide.x - reach.left, ox) - room - boardsMargin,
222
+ y: Math.min(wide.y - reach.top, oy) - room - boardsMargin,
223
+ x1: Math.max(wide.x + wide.w + reach.right, ox + lw) + room + boardsMargin,
224
+ y1: Math.max(wide.y + wide.h + reach.bottom, oy + lh) + room + boardsMargin,
225
+ };
226
+ const vw = view.x1 - view.x;
227
+ const vh = view.y1 - view.y;
228
+ const sentences = [
229
+ fill(words.say.drawn, { name: spec.name[language], surfaces: layout.needs.surfaces, length: multiNumber(layout.needs.length, language), width: multiNumber(layout.needs.width, language), area: multiNumber(layout.needs.area, language) }),
230
+ fill(words.say.grid, { rows: spec.rows, columns: spec.columns }),
231
+ ];
232
+ if (spec.stagger !== undefined && spec.stagger > 0)
233
+ sentences.push(fill(words.say.stagger, { share: `${Math.round(spec.stagger * 100)}%` }));
234
+ if (layout.shared !== undefined)
235
+ sentences.push(words.say.shared);
236
+ if (withSlots && layout.slots.some((s) => s.kind !== "board"))
237
+ sentences.push(fill(words.say.slots, { n: layout.slots.filter((s) => s.kind !== "board").length }));
238
+ if (spec.event !== undefined) {
239
+ const named = language === "ja" ? spec.event.ja : undefined;
240
+ sentences.push(fill(words.say.event, { event: named?.name ?? spec.event.name, year: spec.event.year, venue: named?.venue ?? spec.event.venue, city: named?.city ?? spec.event.city }));
241
+ }
242
+ sentences.push(spec.basis === "event" ? words.say.basisEvent : words.say.basisRulebook);
243
+ if (venue !== undefined && fit !== undefined)
244
+ sentences.push(fill(words.say.venue, { venue: venue.name[language], fit: fitSentence(fit, language) }));
245
+ if (stadium !== undefined)
246
+ sentences.push(words.say.stadium);
247
+ const title = o.title ?? fill(words.say.title, { name: spec.name[language] });
248
+ const description = o.description ?? sentences.join(language === "ja" ? "" : " ");
249
+ const titleId = `${gid}-t`;
250
+ const descId = `${gid}-d`;
251
+ const css = o.style === "none" ? "" : `<style>${style}\n${KYOGIJO_MULTI_STYLE}${crowdStyle === "" ? "" : `\n${crowdStyle}`}</style>`;
252
+ const hallLayer = hallMarkup === "" ? "" : hallMarkup;
253
+ const group = el("g", { class: "ky-mu-layout", transform: move }, floorMarkup + cellsMarkup + over + slotsMarkup);
254
+ const root = el("svg", {
255
+ xmlns: "http://www.w3.org/2000/svg",
256
+ class: `kyogijo ky-multi ky-${spec.sport}${safeClass(o.className) === "" ? "" : ` ${safeClass(o.className)}`}`,
257
+ viewBox: `${num(view.x)} ${num(view.y)} ${num(vw)} ${num(vh)}`,
258
+ role: "img",
259
+ "aria-labelledby": `${titleId} ${descId}`,
260
+ "data-sport": spec.sport,
261
+ "data-layout": spec.id,
262
+ "data-grid": `${spec.rows}x${spec.columns}`,
263
+ "data-look": look,
264
+ "data-floor": spec.floor,
265
+ "data-fit": fit === undefined ? undefined : fit.fits === null ? "maybe" : String(fit.fits),
266
+ "data-stadium": stadium === undefined ? undefined : "on",
267
+ "data-theme": o.theme === "light" || o.theme === "dark" ? o.theme : undefined,
268
+ "data-version": VERSION,
269
+ }, `<title id="${titleId}">${esc(title)}</title><desc id="${descId}">${esc(description)}</desc>` + css + (defs === "" ? "" : `<defs>${defs}</defs>`) + el("rect", { class: "ky-mu-apron", x: num(view.x), y: num(view.y), width: num(vw), height: num(vh) }) + hallLayer + group + overhang + slotTexts + labelsMarkup);
270
+ return root;
271
+ }
272
+ /** Where an empty place is, in words: the surface it belongs to, the side or end it is at, and whether it is on the mats. */
273
+ function slotWhere(slot, layout, words) {
274
+ const where = words.table.where;
275
+ const parts = [];
276
+ const cell = /^(?:text|logo)-(\d+)(?:-(top|bottom|start|end))?$/.exec(slot.id);
277
+ const common = /^logo-common-(\d+)$/.exec(slot.id);
278
+ const side = /^(?:board|text)-(top|bottom|left|right)$/.exec(slot.id) ?? /^text-(\d+)-(top|bottom)$/.exec(slot.id);
279
+ if (common !== null)
280
+ parts.push(layout.cells[Number(common[1]) - 1].label, where.common);
281
+ else if (cell !== null) {
282
+ parts.push(layout.cells[Number(cell[1]) - 1].label);
283
+ if (cell[2] !== undefined)
284
+ parts.push(where[cell[2]]);
285
+ }
286
+ else if (side !== null)
287
+ parts.push(where[side[side.length - 1]]);
288
+ else if (slot.id === "logo-start")
289
+ parts.push(where.start);
290
+ else if (slot.id === "logo-end")
291
+ parts.push(where.end);
292
+ if (slot.onMat)
293
+ parts.push(words.table.onMat);
294
+ return parts.join(words === MULTI_WORDS.ja ? "、" : ", ");
295
+ }
296
+ /**
297
+ * The same thing as the picture, as a table: each playing surface with its row, column, place and size in metres, and each empty place for the event's words and marks, under a caption that gives
298
+ * what the layout needs. For a reader who cannot see the drawing, and for a page that wants the figures.
299
+ * @example
300
+ * ```ts
301
+ * import { multiTable } from "@johnmorrisdotca/kyogijo-stadiums/multi";
302
+ * const table = multiTable({ preset: "tabletennis-4" });
303
+ * console.log(table.startsWith("<table"), table.includes("Table 4"));
304
+ * // true true
305
+ * ```
306
+ */
307
+ export function multiTable(options = {}) {
308
+ const language = options.language === "ja" ? "ja" : "en";
309
+ const words = MULTI_WORDS[language];
310
+ const layout = multiLayout(options);
311
+ const t = words.table;
312
+ const m = (ft) => multiNumber(metres(ft), language);
313
+ const head = `<thead><tr><th scope="col">${esc(t.surface)}</th><th scope="col">${esc(t.row)}</th><th scope="col">${esc(t.column)}</th><th scope="col">${esc(t.x)}</th><th scope="col">${esc(t.y)}</th><th scope="col">${esc(t.size)}</th></tr></thead>`;
314
+ const rows = layout.cells.map((c) => `<tr><th scope="row">${esc(c.label)}</th><td>${c.row + 1}</td><td>${c.column + 1}</td><td>${m(c.x)}</td><td>${m(c.y)}</td><td>${m(c.w)} × ${m(c.h)}</td></tr>`).join("");
315
+ const slotRows = options.slots === false ? "" : layout.slots.map((s) => `<tr><th scope="row">${esc(t.slot)}: ${esc(slotText(s, words))}</th><td colspan="2">${esc(slotWhere(s, layout, words))}</td><td>${m(s.x)}</td><td>${m(s.y)}</td><td>${m(s.w)} × ${m(s.h)}</td></tr>`).join("");
316
+ const caption = language === "ja" ? `${fill(t.caption, { name: layout.spec.name[language] })}。必要な床:${needsSentence(layout.needs, language)}` : `${fill(t.caption, { name: layout.spec.name[language] })} ${needsSentence(layout.needs, language)}`;
317
+ return `<table class="ky-table" data-kind="multi"><caption>${esc(caption)}</caption>${head}<tbody>${rows}${slotRows}</tbody></table>`;
318
+ }
319
+ /** The hall of a layout as the drawing sets it, for a page that wants to say so: the venue, its fit, and the floor the layout needs. */
320
+ export function multiSetting(options) {
321
+ const layout = multiLayout(options);
322
+ const venue = venueOf(options.venue);
323
+ return { venue, fit: venue === undefined ? undefined : multiFit(layout, venue), layout };
324
+ }
package/dist/multi.js ADDED
@@ -0,0 +1,286 @@
1
+ // A tournament layout laid out: several of the package's own surfaces on one floor, to scale, where each stands and what it leaves free; the floor the layout needs; and whether it fits a hall's floor. The
2
+ // figures are in multi.data.ts (a rulebook's, with its source); this file only adds them up. Nothing here draws SVG (multi.draw.ts does), and every function returns new values.
3
+ import { inOctagon } from "./budo/plan.js";
4
+ import { fill } from "./strings.js";
5
+ import { FEET_PER_METRE } from "./geometry.js";
6
+ import { IJF_CONTEST, WKF_KUMITE } from "./leagues/budo.js";
7
+ import { MULTI_PRESETS, MULTI_VENUES, multiPreset, multiVenueById } from "./multi.data.js";
8
+ import { MULTI_WORDS } from "./multi.words.js";
9
+ import { outlineOf } from "./outline.js";
10
+ /** The most rows or columns a layout takes. */
11
+ export const MULTI_MAX = 12;
12
+ const feet = (v, unit) => (unit === "m" ? v * FEET_PER_METRE : v);
13
+ const metres = (ft) => ft / FEET_PER_METRE;
14
+ const round = (v, places = 2) => {
15
+ const k = 10 ** places;
16
+ return Math.round(v * k) / k;
17
+ };
18
+ /** A number as a language writes it: thousands separated, two decimals at most (`3,234.5`). */
19
+ export const multiNumber = (value, language = "en") => new Intl.NumberFormat(language === "ja" ? "ja-JP" : "en-US", { maximumFractionDigits: 2 }).format(value);
20
+ const finite = (v) => typeof v === "number" && Number.isFinite(v);
21
+ /** A whole number of 1 to `max`, or an error that says which option it is. */
22
+ function count(value, name, max) {
23
+ if (!Number.isInteger(value) || value < 1 || value > max)
24
+ throw new RangeError(`${name} is a whole number of 1 to ${max}`);
25
+ return value;
26
+ }
27
+ /**
28
+ * The layout the options ask for: a preset or a layout of the caller's own, with the grid and the stagger it is given in place of its own.
29
+ * @throws Error for a preset that is not one, an option that is not a whole number or a share, or neither a preset nor a layout.
30
+ */
31
+ export function specOf(o) {
32
+ const base = o.layout ?? (o.preset === undefined ? undefined : multiPreset(o.preset));
33
+ if (base === undefined) {
34
+ if (o.preset !== undefined && o.layout === undefined)
35
+ throw new Error(`Unknown preset "${o.preset}": use one of ${MULTI_PRESETS.map((p) => p.id).join(", ")}`);
36
+ throw new TypeError("A tournament layout needs a preset or a layout");
37
+ }
38
+ const budo = base.kind === "budo";
39
+ const rows = o.rows === undefined ? base.rows : count(o.rows, "rows", budo ? 6 : MULTI_MAX);
40
+ const columns = o.columns === undefined ? base.columns : count(o.columns, "columns", budo ? 6 : MULTI_MAX);
41
+ if (o.stagger !== undefined && (!finite(o.stagger) || o.stagger < 0 || o.stagger > 1))
42
+ throw new RangeError("stagger is a share from 0 to 1");
43
+ const stagger = o.stagger ?? base.stagger;
44
+ const surface = budo ? { ...base.surface, budo: { ...base.surface.budo, areas: { across: columns, down: rows } } } : base.surface;
45
+ return { ...base, rows, columns, ...(stagger === undefined ? {} : { stagger }), surface };
46
+ }
47
+ const size = (s) => ({ length: feet(s.length, s.unit), width: feet(s.width, s.unit) });
48
+ /** The slots on and round a floor, in feet. */
49
+ function slotsFor(spec, cells, floor, tatami) {
50
+ const out = [];
51
+ const brand = spec.parts.brand ?? "none";
52
+ const boardDepth = 3; // three feet: a board of about 0.9 m
53
+ if (brand === "mat" && tatami !== undefined) {
54
+ // A budō floor is printed on the mats themselves: the event's words in the safety area above and below each contest area, and a mark in the end safety areas and in the common safe zones.
55
+ const { safety: s, gap } = tatami;
56
+ for (const cell of cells) {
57
+ const textW = cell.w * 0.9;
58
+ const textH = s * 0.45;
59
+ // The band between two rows serves both: the words are set under the upper area only.
60
+ if (cell.row === 0)
61
+ out.push({ id: `text-${cell.n}-top`, kind: "floor-text", onMat: true, x: cell.x + (cell.w - textW) / 2, y: cell.y - s / 2 - textH / 2, w: textW, h: textH });
62
+ out.push({ id: `text-${cell.n}-bottom`, kind: "floor-text", onMat: true, x: cell.x + (cell.w - textW) / 2, y: cell.y + cell.h + s / 2 - textH / 2, w: textW, h: textH });
63
+ }
64
+ const mark = Math.min(s, gap) * 0.7;
65
+ const cy = tatami.y + tatami.h / 2;
66
+ out.push({ id: "logo-start", kind: "floor-logo", onMat: true, x: tatami.x + s / 2 - mark / 2, y: cy - mark / 2, w: mark, h: mark });
67
+ out.push({ id: "logo-end", kind: "floor-logo", onMat: true, x: tatami.x + tatami.w - s / 2 - mark / 2, y: cy - mark / 2, w: mark, h: mark });
68
+ cells.forEach((cell, k) => {
69
+ if (k % Math.max(1, spec.columns) === spec.columns - 1)
70
+ return;
71
+ const gx = cell.x + cell.w + gap / 2;
72
+ out.push({ id: `logo-common-${cell.n}`, kind: "floor-logo", onMat: true, x: gx - mark / 2, y: cell.y + cell.h / 2 - mark / 2, w: mark, h: mark });
73
+ });
74
+ }
75
+ else if (brand === "floor") {
76
+ // Each surface has two places for a mark at its ends (the ITTF's rule allows up to 6 advertisements on the floor of an area) and one for words beside it. A surface turned a quarter has its ends above and below it.
77
+ for (const cell of cells) {
78
+ const mx = (cell.bay.w - cell.w) / 2;
79
+ const my = (cell.bay.h - cell.h) / 2;
80
+ if (cell.turn) {
81
+ const room = Math.min(my, cell.bay.w * 0.5);
82
+ if (room >= 3) {
83
+ const m = room * 0.6;
84
+ out.push({ id: `logo-${cell.n}-start`, kind: "floor-logo", onMat: false, x: cell.x + cell.w / 2 - m / 2, y: cell.y - room / 2 - m / 2, w: m, h: m });
85
+ out.push({ id: `logo-${cell.n}-end`, kind: "floor-logo", onMat: false, x: cell.x + cell.w / 2 - m / 2, y: cell.y + cell.h + room / 2 - m / 2, w: m, h: m });
86
+ }
87
+ // The words stand along the left side, the officials' chair being on the right.
88
+ if (mx >= 4) {
89
+ const w = Math.min(mx * 0.4, 3.5);
90
+ const h = cell.bay.h * 0.55;
91
+ out.push({ id: `text-${cell.n}`, kind: "floor-text", onMat: false, x: cell.x - mx / 2 - w / 2, y: cell.y + cell.h / 2 - h / 2, w, h });
92
+ }
93
+ continue;
94
+ }
95
+ const room = Math.min(mx, cell.bay.h * 0.5);
96
+ if (room >= 3) {
97
+ const m = room * 0.6;
98
+ out.push({ id: `logo-${cell.n}-start`, kind: "floor-logo", onMat: false, x: cell.x - room / 2 - m / 2, y: cell.y + cell.h / 2 - m / 2, w: m, h: m });
99
+ out.push({ id: `logo-${cell.n}-end`, kind: "floor-logo", onMat: false, x: cell.x + cell.w + room / 2 - m / 2, y: cell.y + cell.h / 2 - m / 2, w: m, h: m });
100
+ }
101
+ if (my >= 4) {
102
+ const h = Math.min(my * 0.4, 3.5);
103
+ const w = cell.bay.w * 0.55;
104
+ // The words go on the side of the surface that the officials' chair is not on.
105
+ const y = spec.parts.officials === true ? cell.y + cell.h + my / 2 - h / 2 : cell.y - my / 2 - h / 2;
106
+ out.push({ id: `text-${cell.n}`, kind: "floor-text", onMat: false, x: cell.x + cell.w / 2 - w / 2, y, w, h });
107
+ }
108
+ }
109
+ }
110
+ if (spec.parts.boards === true) {
111
+ const gap = 1;
112
+ out.push({ id: "board-top", kind: "board", onMat: false, x: 0, y: -gap - boardDepth, w: floor.w, h: boardDepth });
113
+ out.push({ id: "board-bottom", kind: "board", onMat: false, x: 0, y: floor.h + gap, w: floor.w, h: boardDepth });
114
+ out.push({ id: "board-left", kind: "board", onMat: false, x: -gap - boardDepth, y: 0, w: boardDepth, h: floor.h });
115
+ out.push({ id: "board-right", kind: "board", onMat: false, x: floor.w + gap, y: 0, w: boardDepth, h: floor.h });
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * A layout laid out: where each surface stands, the floor it needs and the empty places for the event's own words and marks, all in feet from the top left corner of the floor (and the floor's size
121
+ * and what the layout needs in metres).
122
+ * @example
123
+ * ```ts
124
+ * import { multiLayout } from "@johnmorrisdotca/kyogijo-stadiums/multi";
125
+ * const layout = multiLayout({ preset: "tabletennis-4" });
126
+ * console.log(layout.cells.length, layout.needs.length, layout.needs.width);
127
+ * // 4 28 14
128
+ * ```
129
+ */
130
+ export function multiLayout(options = {}) {
131
+ const spec = specOf(options);
132
+ const language = options.language === "ja" ? "ja" : "en";
133
+ const words = MULTI_WORDS[language];
134
+ const name = (n) => fill(words.names[spec.word], { n });
135
+ const cells = [];
136
+ let floor;
137
+ let tatami;
138
+ if (spec.kind === "budo") {
139
+ // The contest area, the safety area, the common safe zone between areas and the free zone, in metres: judo's from the IJF's figures (or the options'), karate's from the WKF's kumite area.
140
+ const karate = spec.sport === "karate";
141
+ const opts = spec.surface.budo ?? {};
142
+ const c = karate ? WKF_KUMITE.area : opts.size ?? IJF_CONTEST.contest.drawn;
143
+ const s = karate ? WKF_KUMITE.safety : opts.safety ?? IJF_CONTEST.safety.drawn;
144
+ const gap = karate ? WKF_KUMITE.between : Math.max(IJF_CONTEST.common, s);
145
+ const free = karate ? 0 : IJF_CONTEST.freeZone;
146
+ const across = spec.columns;
147
+ const down = spec.rows;
148
+ const tw = across * c + (across - 1) * gap + 2 * s;
149
+ const th = down * c + (down - 1) * gap + 2 * s;
150
+ floor = { w: feet(tw + 2 * free, "m"), h: feet(th + 2 * free, "m") };
151
+ tatami = { x: feet(free, "m"), y: feet(free, "m"), w: feet(tw, "m"), h: feet(th, "m"), safety: feet(s, "m"), gap: feet(gap, "m") };
152
+ for (let j = 0; j < down; j += 1) {
153
+ for (let i = 0; i < across; i += 1) {
154
+ const x = feet(free + s + i * (c + gap), "m");
155
+ const y = feet(free + s + j * (c + gap), "m");
156
+ const w = feet(c, "m");
157
+ const b = feet(gap / 2, "m");
158
+ cells.push({ n: cells.length + 1, label: name(cells.length + 1), row: j, column: i, x, y, w, h: w, turn: false, bay: { x: x - b, y: y - b, w: w + 2 * b, h: w + 2 * b } });
159
+ }
160
+ }
161
+ }
162
+ else {
163
+ const box = outlineOf(spec.surface).box;
164
+ const turn = spec.turn === 90;
165
+ const sw = turn ? box.h : box.w;
166
+ const sh = turn ? box.w : box.h;
167
+ const bay = spec.bay === undefined ? { length: sw, width: sh } : size(spec.bay);
168
+ const edge = spec.edge === undefined ? { length: (bay.length - sw) / 2, width: (bay.width - sh) / 2 } : size(spec.edge);
169
+ const shift = spec.stagger === undefined ? 0 : spec.stagger * bay.length;
170
+ for (let j = 0; j < spec.rows; j += 1) {
171
+ for (let i = 0; i < spec.columns; i += 1) {
172
+ const x = edge.length + i * bay.length + (j % 2 === 1 ? shift : 0);
173
+ const y = edge.width + j * bay.width;
174
+ const mx = (bay.length - sw) / 2;
175
+ const my = (bay.width - sh) / 2;
176
+ cells.push({ n: cells.length + 1, label: name(cells.length + 1), row: j, column: i, x, y, w: sw, h: sh, turn, bay: { x: x - mx, y: y - my, w: bay.length, h: bay.width } });
177
+ }
178
+ }
179
+ floor = { w: 2 * edge.length + (spec.columns - 1) * bay.length + sw + (spec.rows > 1 ? shift : 0), h: 2 * edge.width + (spec.rows - 1) * bay.width + sh };
180
+ }
181
+ const slots = spec.parts.brand === "none" && spec.parts.boards !== true ? [] : slotsFor(spec, cells, floor, tatami);
182
+ const needs = { length: round(metres(floor.w)), width: round(metres(floor.h)), area: round(metres(floor.w) * metres(floor.h), 1), surfaces: cells.length };
183
+ const layout = { spec, cells, slots, floor, needs };
184
+ if (tatami !== undefined)
185
+ layout.tatami = { x: tatami.x, y: tatami.y, w: tatami.w, h: tatami.h };
186
+ if (spec.parts.scoreboard === "shared") {
187
+ // 12 by 6 feet (ours), wider when there are more than four areas to give a panel each.
188
+ const w = Math.min(Math.max(12, 3 * cells.length), floor.w * 0.5);
189
+ layout.shared = { x: floor.w / 2 - w / 2, y: floor.h / 2 - 3, w, h: 6 };
190
+ }
191
+ return layout;
192
+ }
193
+ /** A hall as the caller gives it, or a key of `MULTI_VENUES`. @throws Error for a key that is not one, a size that is not positive. */
194
+ export function venueOf(venue) {
195
+ if (venue === undefined)
196
+ return undefined;
197
+ if (typeof venue === "string") {
198
+ const found = multiVenueById(venue);
199
+ if (found === undefined)
200
+ throw new Error(`Unknown venue "${venue}": use one of ${MULTI_VENUES.map((v) => v.id).join(", ")}`);
201
+ return found;
202
+ }
203
+ if ("shape" in venue)
204
+ return venue;
205
+ if (!finite(venue.length) || !finite(venue.width) || venue.length <= 0 || venue.width <= 0)
206
+ throw new RangeError("A venue of your own needs a length and a width above 0");
207
+ const unit = venue.unit === "ft" ? "ft" : "m";
208
+ return {
209
+ id: "custom",
210
+ name: { en: "Your floor", ja: "指定した床" },
211
+ shape: "rectangle",
212
+ length: venue.length,
213
+ width: venue.width,
214
+ unit,
215
+ what: { en: `a floor of ${venue.length} by ${venue.width} ${unit === "m" ? "metres" : "feet"}`, ja: `${venue.length}×${venue.width}${unit === "m" ? "m" : "フィート"}の床` },
216
+ standsLike: "badminton",
217
+ sources: [],
218
+ };
219
+ }
220
+ /**
221
+ * Whether a layout fits a hall's floor. A floor that is a rectangle takes the layout as it is or turned a quarter; an octagon takes it if its corners are inside; a floor of which only the area is published
222
+ * can only be compared by area, and says so (`fits` is `null` when the area is enough, because the shape may still not take it).
223
+ * @example
224
+ * ```ts
225
+ * import { multiFit, multiLayout } from "@johnmorrisdotca/kyogijo-stadiums/multi";
226
+ * const four = multiFit(multiLayout({ preset: "curling-1x4" }), "nhl-ice-pad");
227
+ * const six = multiFit(multiLayout({ preset: "curling-1x4", columns: 6 }), "nhl-ice-pad");
228
+ * console.log(four.fits, four.turned, six.fits);
229
+ * // true true false
230
+ * ```
231
+ */
232
+ export function multiFit(layout, venue) {
233
+ const v = venueOf(venue);
234
+ if (v === undefined)
235
+ throw new TypeError("multiFit needs a venue");
236
+ const needs = layout.needs;
237
+ if (v.shape === "area") {
238
+ const have = v.area ?? 0;
239
+ return { fits: needs.area <= have ? null : false, turned: false, by: "area", needs, spare: { area: round(have - needs.area, 1) }, floor: { area: have } };
240
+ }
241
+ if (v.shape === "octagon") {
242
+ const a = v.across / 2;
243
+ const L = needs.length;
244
+ const W = needs.width;
245
+ const asIs = inOctagon(0, 0, a, L / 2, W / 2);
246
+ const turned = inOctagon(0, 0, a, W / 2, L / 2);
247
+ const fits = asIs || turned;
248
+ // The spare is the octagon's width across the flats less the layout's, on each axis (the octagon is widest across the flats: its corners are cut).
249
+ return { fits, turned: !asIs && turned, by: "size", needs, spare: { length: round(2 * a - L), width: round(2 * a - W) }, floor: { across: v.across, area: v.area ?? round(8 * a * a * (Math.SQRT2 - 1), 1) } };
250
+ }
251
+ const length = v.unit === "m" ? v.length : metres(v.length);
252
+ const width = v.unit === "m" ? v.width : metres(v.width);
253
+ const asIs = needs.length <= length + 1e-9 && needs.width <= width + 1e-9;
254
+ const turned = needs.width <= length + 1e-9 && needs.length <= width + 1e-9;
255
+ const floorArea = round(v.area ?? length * width, 1);
256
+ if (asIs || turned) {
257
+ const useTurn = !asIs;
258
+ return { fits: true, turned: useTurn, by: "size", needs, spare: useTurn ? { length: round(length - needs.width), width: round(width - needs.length) } : { length: round(length - needs.length), width: round(width - needs.width) }, floor: { length: round(length), width: round(width), area: floorArea } };
259
+ }
260
+ const as = { length: round(length - needs.length), width: round(width - needs.width) };
261
+ const tu = { length: round(length - needs.width), width: round(width - needs.length) };
262
+ const shortAs = Math.min(0, as.length) + Math.min(0, as.width);
263
+ const shortTu = Math.min(0, tu.length) + Math.min(0, tu.width);
264
+ const useTurn = shortTu > shortAs;
265
+ return { fits: false, turned: useTurn, by: "size", needs, spare: useTurn ? tu : as, floor: { length: round(length), width: round(width), area: floorArea } };
266
+ }
267
+ /** The sentence that says how a layout sits in a hall, in a language. */
268
+ export function fitSentence(fit, language = "en") {
269
+ const w = MULTI_WORDS[language].fit;
270
+ const turned = fit.turned ? w.turned : "";
271
+ if (fit.by === "area") {
272
+ const spare = fit.spare;
273
+ const n = (v) => multiNumber(v, language);
274
+ return spare.area >= 0 ? fill(w.maybe, { need: n(fit.needs.area), have: n(fit.floor.area) }) : fill(w.tooSmall, { need: n(fit.needs.area), have: n(fit.floor.area) });
275
+ }
276
+ const spare = fit.spare;
277
+ const n = (v) => multiNumber(v, language);
278
+ if (fit.fits === true)
279
+ return fill(w.fits, { length: n(spare.length), width: n(spare.width), turned });
280
+ return fill(w.short, { length: n(round(Math.max(0, -spare.length))), width: n(round(Math.max(0, -spare.width))), turned });
281
+ }
282
+ /** The size of a layout as words, in a language: `28 by 14 metres (392 square metres)`; along the layout first, then across it. */
283
+ export const needsSentence = (needs, language = "en") => {
284
+ const n = (v) => multiNumber(v, language);
285
+ return language === "ja" ? `横${n(needs.length)}m×縦${n(needs.width)}m(${n(needs.area)}平方メートル)` : `${n(needs.length)} by ${n(needs.width)} metres (${n(needs.area)} square metres)`;
286
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The rules of a tournament layout. `:where` keeps the specificity at nothing so that a page's own custom properties win; the layout's group follows the device and the page like a drawing does, and a
3
+ * `data-theme` of light or dark forces it.
4
+ */
5
+ export declare const KYOGIJO_MULTI_STYLE: string;