@johnmorrisdotca/kyogijo-stadiums 0.12.1 → 0.13.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,253 @@
1
+ // A tournament layout in 3D: the 3D view of `turn.ts`, with a layout's several surfaces on its floor. Nothing here is drawn a second way. Each surface stands as it does on its own page (`uprightsFor`: the nets and posts, a table
2
+ // tennis table's legs and skirt), moved to where the layout puts it, and the stands of the stadium round the hall are the 3D view's own (`standsVenueOf`); what only a layout has (the surrounds between the areas, the umpires'
3
+ // chairs and score tables, the scoreboards) is stood up from the same figures the flat drawing draws, and the floor is the flat drawing itself, `multiDrawing` made for raising. The model it makes is a `TurnModel`, so the
4
+ // camera, the controls, the pointer, the auto-turn and the tests of every other 3D view are this one's.
5
+ import { FEET_PER_METRE } from "./geometry.js";
6
+ import { multiDrawing } from "./multi.draw.js";
7
+ import { outlineOf } from "./outline.js";
8
+ import { hash } from "./format.js";
9
+ import { TURN_PRESETS, NET_PRESETS, wrap } from "./turn.camera.js";
10
+ import { flat, n, plane, shy, thinnest, wall } from "./turn.kit.js";
11
+ import { mountTurn } from "./turn.dom.js";
12
+ import { turnMarkupOf } from "./turn.markup.js";
13
+ import { openingView, sceneBox } from "./turn.model.js";
14
+ import { describeUprights, planesOf, uprightsFor } from "./uprights.js";
15
+ import { standsVenueOf } from "./venue.js";
16
+ import { isNetSport } from "./court.js";
17
+ /** How high a surround stands: the ITTF's "about 75 cm" (Handbook 3.2.3.3). A layout of another sport with surrounds has the same (ours: no other rulebook is read for it). */
18
+ export const SURROUND_HEIGHT = 0.75 * FEET_PER_METRE;
19
+ /** The longest piece a surround is cut into, in feet: a long wall is several planes, so that each is drawn in its place among the surfaces and the chairs and not as one slab sorted by its middle. */
20
+ const SURROUND_PIECE = 10;
21
+ /** An umpire's chair's seat and a score table's top, in feet over the floor (ours: the rule gives no height for either). */
22
+ const CHAIR = 1.5;
23
+ const DESK = 2.5;
24
+ /** Where the scoreboards hang (ours): the one over the middle of the floor has its lower edge 13 feet up and its cables run 8 feet above its top; the one at each surface stands 2.6 feet off the floor. */
25
+ const HUNG = { bottom: 13, cables: 8 };
26
+ const STANDING = { legs: 2.6, tall: 1.4 };
27
+ const mapOf = (to) => {
28
+ const [e, f] = to(0, 0);
29
+ const [px, py] = to(1, 0);
30
+ const [qx, qy] = to(0, 1);
31
+ return { a: px - e, b: qx - e, c: py - f, d: qy - f, e, f };
32
+ };
33
+ const at = (m, x, y) => [m.a * x + m.b * y + m.e, m.c * x + m.d * y + m.f];
34
+ const dir = (m, x, y) => [m.a * x + m.b * y, m.c * x + m.d * y];
35
+ /** A plane moved by a map, under a new id. A plane that stands in a frame turned a quarter turns with it: its corner, its two sides and the way it faces out. */
36
+ function place(p, m, id) {
37
+ const [ox, oy] = at(m, p.o[0], p.o[1]);
38
+ const [ux, uy] = dir(m, p.u[0], p.u[1]);
39
+ const [vx, vy] = dir(m, p.v[0], p.v[1]);
40
+ const moved = { ...p, id, o: [ox, oy, p.o[2]], u: [ux, uy, p.u[2]], v: [vx, vy, p.v[2]] };
41
+ if (p.shy !== undefined)
42
+ moved.shy = dir(m, p.shy[0], p.shy[1]);
43
+ if (p.crop === true) {
44
+ // A picture of the floor over a rectangle keeps its sides along x and y.
45
+ const corners = [at(m, p.o[0], p.o[1]), at(m, p.o[0] + p.u[0], p.o[1] + p.u[1]), at(m, p.o[0] + p.v[0], p.o[1] + p.v[1]), at(m, p.o[0] + p.u[0] + p.v[0], p.o[1] + p.u[1] + p.v[1])];
46
+ const [x0, x1] = [Math.min(...corners.map((c) => c[0])), Math.max(...corners.map((c) => c[0]))];
47
+ const [y0, y1] = [Math.min(...corners.map((c) => c[1])), Math.max(...corners.map((c) => c[1]))];
48
+ moved.o = [x0, y0, p.o[2]];
49
+ moved.u = [x1 - x0, 0, 0];
50
+ moved.v = [0, y1 - y0, 0];
51
+ }
52
+ return moved;
53
+ }
54
+ const rect = (x, y, w, h) => ({ x0: x, y0: y, x1: x + w, y1: y + h });
55
+ /** A solid box on the floor: four sides up to `z1` and a flat top. `cls` paints the top; the sides are one tone darker (`cls-side`). */
56
+ function box(id, kind, r, z1, cls) {
57
+ const sides = [[r.x0, r.y0, r.x1, r.y0], [r.x1, r.y0, r.x1, r.y1], [r.x1, r.y1, r.x0, r.y1], [r.x0, r.y1, r.x0, r.y0]];
58
+ const planes = sides.map(([a, b, c, d], i) => wall(`${id}-${i + 1}`, kind, a, b, c, d, 0, z1, `<rect width="${n(Math.hypot(c - a, d - b))}" height="${n(z1)}" class="${cls}-side"/>`));
59
+ planes.push(flat(`${id}-top`, kind, r.x0, r.y0, r.x1, r.y1, z1, `<rect width="${n(r.x1 - r.x0)}" height="${n(r.y1 - r.y0)}" class="${cls}"/>`));
60
+ return planes;
61
+ }
62
+ /** The surround of one area: four sides over the rectangle, each in pieces of at most `SURROUND_PIECE`, facing out from the area's middle (it gives way to a nearly level eye, as the glass of a rink does, but only then). */
63
+ function surround(id, r) {
64
+ const centre = [(r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2];
65
+ const sides = [[r.x0, r.y0, r.x1, r.y0], [r.x1, r.y0, r.x1, r.y1], [r.x1, r.y1, r.x0, r.y1], [r.x0, r.y1, r.x0, r.y0]];
66
+ const planes = [];
67
+ sides.forEach(([xa, ya, xb, yb], side) => {
68
+ const long = Math.hypot(xb - xa, yb - ya);
69
+ const pieces = Math.max(1, Math.ceil(long / SURROUND_PIECE));
70
+ const rim = Math.min(0.3, SURROUND_HEIGHT * 0.12);
71
+ for (let i = 0; i < pieces; i += 1) {
72
+ const [t0, t1] = [i / pieces, (i + 1) / pieces];
73
+ const run = long / pieces;
74
+ const svg = `<rect width="${n(run)}" height="${n(SURROUND_HEIGHT)}" class="u-surround"/><rect width="${n(run)}" height="${n(rim)}" class="u-surround-rim"/>`;
75
+ const p = wall(`${id}-${side + 1}-${i + 1}`, "surround", xa + (xb - xa) * t0, ya + (yb - ya) * t0, xa + (xb - xa) * t1, ya + (yb - ya) * t1, 0, SURROUND_HEIGHT, svg);
76
+ planes.push({ ...shy(p, centre), fadeTo: 0.35, fadeAt: 0.85 });
77
+ }
78
+ });
79
+ return planes;
80
+ }
81
+ /** A scoreboard that faces the eye: its panel (and where it is divided into the panels of the areas), hung on cables from over its top, or standing on legs. */
82
+ function board(id, cx, cy, wide, tall, bottom, extra, hung, cuts) {
83
+ const total = tall + extra;
84
+ const top = hung ? extra : 0;
85
+ const line = Math.max(0.12, wide * 0.008);
86
+ const holds = hung
87
+ ? `<path d="M${n(wide * 0.12)} 0V${n(extra)}M${n(wide * 0.88)} 0V${n(extra)}" class="u-sbline" fill="none" stroke-width="${n(line)}"/>`
88
+ : `<path d="M${n(wide * 0.14)} ${n(tall)}V${n(total)}M${n(wide * 0.86)} ${n(tall)}V${n(total)}" class="u-sbline" fill="none" stroke-width="${n(line * 1.6)}"/>`;
89
+ const panel = `<rect x="0" y="${n(top)}" width="${n(wide)}" height="${n(tall)}" rx="${n(wide * 0.02)}" class="u-sbframe"/>${cuts === "" ? "" : `<path d="${cuts}" transform="translate(0 ${n(top)})" class="u-sbline" fill="none" stroke-width="${n(line * 0.7)}"/>`}`;
90
+ return { ...plane(id, "screen", [cx - wide / 2, cy, bottom + total], [wide, 0, 0], [0, 0, -total], `${holds}${panel}`), billboard: true };
91
+ }
92
+ /**
93
+ * The viewpoints of a layout. Overhead, broadcast and the low corner are a single surface's (`TURN_PRESETS`); the end-on view of a net sport looks along its surfaces' length, where the nets are seen whole, as `NET_PRESETS` does. All are turned a quarter where
94
+ * the floor or the surfaces stand across the picture, so that the long way of the floor runs across the screen. `floor` is the picture's frame (the hall's, turned if the hall asks) and `lengthAlongX` says whether the surfaces' length runs along its x.
95
+ */
96
+ export function multiPresets(sport, floor, lengthAlongX) {
97
+ const portrait = floor.h > floor.w ? 90 : 0;
98
+ const nets = isNetSport(sport);
99
+ const end = nets ? NET_PRESETS.end : TURN_PRESETS.end;
100
+ const along = nets ? (lengthAlongX ? 0 : 90) : portrait;
101
+ return {
102
+ overhead: { ...TURN_PRESETS.overhead, yaw: portrait },
103
+ broadcast: { ...TURN_PRESETS.broadcast, yaw: wrap(TURN_PRESETS.broadcast.yaw + portrait) },
104
+ end: { ...end, yaw: wrap(end.yaw + along) },
105
+ corner: { ...TURN_PRESETS.corner, yaw: wrap(TURN_PRESETS.corner.yaw + portrait) },
106
+ };
107
+ }
108
+ /**
109
+ * A tournament layout as a 3D model: the floor is the layout's flat drawing, and on it stand each surface as it stands on its own page, the surrounds, the umpires' chairs and score tables, the scoreboards and, with `stadium`, the stands
110
+ * of the hall. A `TurnModel` like any other: `turnMarkupOf` and `mountMultiTurn` make a view of it.
111
+ */
112
+ export function buildMultiTurn(options, settings = {}) {
113
+ const wanted = settings.uprights;
114
+ const upOptions = wanted === false || wanted === true || wanted === undefined ? {} : wanted;
115
+ const on = wanted !== false;
116
+ const stadiumOn = settings.venue !== false && options.stadium !== undefined && options.stadium !== false;
117
+ const drawn = multiDrawing(stadiumOn ? options : { ...options, stadium: undefined }, true, on);
118
+ const { frame, title, description } = drawn;
119
+ const layout = frame.layout;
120
+ const spec = layout.spec;
121
+ const language = options.language === "ja" ? "ja" : "en";
122
+ const id = settings.id ?? (typeof options.id === "string" && options.id !== "" ? `${options.id}-turn` : `ky-turn-${hash(JSON.stringify([options, settings.uprights, settings.preset, settings.venue]))}`);
123
+ const only = upOptions.only;
124
+ const keep = (kind) => only === undefined || only.includes(kind);
125
+ const items = [];
126
+ const toPicture = mapOf(frame.to);
127
+ if (on) {
128
+ // ---- Each surface, as it stands on its own page, moved to its cell. ----
129
+ const common = { ...spec.surface, margin: 0, language };
130
+ if (spec.kind === "cells") {
131
+ const own = uprightsFor(common, { ...upOptions, venue: undefined, score: "floor" });
132
+ const surfaceBox = outlineOf(spec.surface).box;
133
+ for (const cell of layout.cells) {
134
+ const into = mapOf((x, y) => frame.to(...(cell.turn ? [cell.x + cell.w - y, cell.y + x] : [cell.x + x, cell.y + y])));
135
+ for (const item of own.items) {
136
+ // The hall's floor under a table is the layout's own, so the planes that stood in for it are left behind.
137
+ const planes = item.planes.filter((p) => p.kind !== "ground").map((p) => place(p, into, `c${cell.n}-${p.id}`));
138
+ if (planes.length > 0)
139
+ items.push({ ...item, id: `c${cell.n}-${item.id}`, at: at(into, item.at[0], item.at[1]), planes });
140
+ }
141
+ }
142
+ // A surface that stands over the floor (a table's top) is a plane of its own, with the surface's own drawing on it.
143
+ for (const lifted of drawn.lifted) {
144
+ const local = flat("table-top", "table", 0, 0, surfaceBox.w, surfaceBox.h, lifted.lift, `<g transform="translate(${n(-lifted.box.x)} ${n(-lifted.box.y)})">${lifted.body}</g>`);
145
+ const into = mapOf((x, y) => frame.to(...(lifted.cell.turn ? [lifted.cell.x + lifted.cell.w - y, lifted.cell.y + x] : [lifted.cell.x + x, lifted.cell.y + y])));
146
+ items.push({ id: `c${lifted.cell.n}-table-top`, kind: "table-legs", at: at(into, surfaceBox.w / 2, surfaceBox.h / 2), height: lifted.lift, planes: [place(local, into, `c${lifted.cell.n}-table-top`)] });
147
+ }
148
+ }
149
+ // ---- What only a layout has. ----
150
+ const parts = spec.parts;
151
+ const layoutPlanes = (ps) => ps.map((p) => place(p, toPicture, p.id));
152
+ if (parts.barriers === true && upOptions.boards !== false && keep("surrounds")) {
153
+ const planes = layout.cells.flatMap((c) => surround(`c${c.n}-surround`, { x0: c.bay.x + 0.5, y0: c.bay.y + 0.5, x1: c.bay.x + c.bay.w - 0.5, y1: c.bay.y + c.bay.h - 0.5 }));
154
+ items.push({ id: "surrounds", kind: "surrounds", at: at(toPicture, layout.floor.w / 2, layout.floor.h / 2), height: SURROUND_HEIGHT, planes: layoutPlanes(planes) });
155
+ }
156
+ if (parts.officials === true && keep("officials")) {
157
+ const planes = [];
158
+ for (const c of layout.cells)
159
+ planes.push(...officialsOf(c));
160
+ items.push({ id: "officials", kind: "officials", at: at(toPicture, layout.floor.w / 2, layout.floor.h / 2), height: DESK, planes: layoutPlanes(planes) });
161
+ }
162
+ if (keep("scoreboard")) {
163
+ const boards = [];
164
+ if (parts.scoreboard === "each")
165
+ for (const c of layout.cells)
166
+ boards.push(board(`c${c.n}-scoreboard`, c.x + c.w / 2, c.y + c.h + 1.9, 4, STANDING.tall, 0, STANDING.legs, false, ""));
167
+ const sb = layout.shared;
168
+ if (sb !== undefined) {
169
+ const count = layout.cells.length;
170
+ const cols = count <= 4 ? Math.min(2, count) : count;
171
+ const rows = count <= 4 && count > 2 ? 2 : 1;
172
+ let cuts = "";
173
+ for (let i = 1; i < cols; i += 1)
174
+ cuts += `M${n((sb.w * i) / cols)} 0v${n(sb.h)}`;
175
+ for (let j = 1; j < rows; j += 1)
176
+ cuts += `M0 ${n((sb.h * j) / rows)}h${n(sb.w)}`;
177
+ boards.push(board("scoreboard", sb.x + sb.w / 2, sb.y + sb.h / 2, sb.w, sb.h, HUNG.bottom, HUNG.cables, true, cuts));
178
+ }
179
+ if (boards.length > 0)
180
+ items.push({ id: "scoreboard", kind: "scoreboard", at: at(toPicture, layout.floor.w / 2, layout.floor.h / 2), height: parts.scoreboard === "shared" ? HUNG.bottom : STANDING.legs, planes: layoutPlanes(boards) });
181
+ }
182
+ }
183
+ // ---- The stands of the stadium round the hall, in the picture's own frame. ----
184
+ let defs = "";
185
+ let crowdStyle = "";
186
+ if (drawn.stands !== undefined && keep("stands")) {
187
+ const roof = typeof settings.venue === "object" && settings.venue.roof !== undefined ? settings.venue.roof : typeof options.stadium === "object" && options.stadium.roof === true;
188
+ const venue = standsVenueOf(drawn.stands.stands, drawn.stands.crowd, { sport: drawn.stands.standsLike }, { ...(typeof settings.venue === "object" ? settings.venue : {}), roof, id }, 0);
189
+ items.push(...venue.items.filter((item) => item.kind !== "roof" || roof));
190
+ defs = venue.defs;
191
+ crowdStyle = "--ky-cr-home:#8b94a3;--ky-cr-away:#8b94a3";
192
+ }
193
+ // ---- The model. ----
194
+ const uprights = { items, lift: 0, thin: thinnest(drawn.view.w, drawn.view.h), area: { x0: drawn.view.x, y0: drawn.view.y, x1: drawn.view.x + drawn.view.w, y1: drawn.view.y + drawn.view.h }, whole: true, ...(defs === "" ? {} : { defs }) };
195
+ const planes = planesOf(uprights);
196
+ const scale = 1400 / Math.max(drawn.view.w, drawn.view.h);
197
+ const centre = [drawn.view.x + drawn.view.w / 2, drawn.view.y + drawn.view.h / 2];
198
+ const box3 = sceneBox(planes, uprights.area, 0, centre, scale);
199
+ const look = /^<svg[^>]* data-look="([^"]*)"/.exec(drawn.svg)?.[1];
200
+ const surfaceLike = { sport: spec.sport, language, ...(options.theme === undefined ? {} : { theme: options.theme }), ...(options.style === undefined ? {} : { style: options.style }) };
201
+ const standing = describeUprights(surfaceLike, uprights, language);
202
+ const first = layout.cells[0];
203
+ const presets = multiPresets(spec.sport, { w: frame.lw, h: frame.lh }, first === undefined || first.turn === frame.turned);
204
+ return {
205
+ id,
206
+ language,
207
+ options: surfaceLike,
208
+ title,
209
+ description: standing === "" ? description : `${description}${language === "ja" ? "" : " "}${standing}`,
210
+ standing,
211
+ floor: drawn.svg,
212
+ viewBox: { x: drawn.view.x, y: drawn.view.y, w: drawn.view.w, h: drawn.view.h },
213
+ scale,
214
+ uprights,
215
+ defs,
216
+ crowdStyle,
217
+ venue: drawn.stands !== undefined,
218
+ look: { sport: spec.sport, look: look ?? "plain" },
219
+ planes,
220
+ box: box3,
221
+ centre,
222
+ view: openingView(settings, presets),
223
+ turnable: true,
224
+ presets,
225
+ flat: multiDrawing(options, false).svg,
226
+ };
227
+ }
228
+ /** The umpire's chair and the score table beside a surface, where the flat drawing has them (feet, in the layout's frame). */
229
+ function officialsOf(c) {
230
+ if (c.turn) {
231
+ // Beside the net, on the right of a surface that stands across the layout.
232
+ const cy = c.y + c.h / 2;
233
+ return [...box(`c${c.n}-chair`, "chair", rect(c.x + c.w + 1.8, cy - 3.2, 1.6, 1.6), CHAIR, "u-chair"), ...box(`c${c.n}-desk`, "desk", rect(c.x + c.w + 1.5, cy - 0.8, 1.1, 3.6), DESK, "u-chair")];
234
+ }
235
+ const cx = c.x + c.w / 2;
236
+ return [...box(`c${c.n}-chair`, "chair", rect(cx + 1.6, c.y - 3.4, 1.6, 1.6), CHAIR, "u-chair"), ...box(`c${c.n}-desk`, "desk", rect(cx - 5, c.y - 3.1, 3.6, 1.1), DESK, "u-chair")];
237
+ }
238
+ /**
239
+ * Mount a tournament layout as a 3D view in a container: the same view as `mountKyogijoTurn`'s, with the controls, the pointer, the keys and the auto-turn of every 3D view, over the layout's floor with each surface standing on it.
240
+ * @example
241
+ * ```ts no-run
242
+ * import { mountMultiTurn } from "@johnmorrisdotca/kyogijo-stadiums/multi";
243
+ * const view = mountMultiTurn(document.querySelector("#hall")!, { preset: "tabletennis-4", stadium: true }, { preset: "broadcast" });
244
+ * view.set({ yaw: 40 });
245
+ * ```
246
+ */
247
+ export function mountMultiTurn(host, options, settings = {}) {
248
+ return mountTurn(host, options, settings, buildMultiTurn);
249
+ }
250
+ /** A tournament layout as the markup of a 3D view, still until a page's script makes it turn: pure, so a server can make it. */
251
+ export function multiTurnMarkup(options, settings = {}) {
252
+ return turnMarkupOf(buildMultiTurn(options, settings), settings);
253
+ }
package/dist/strings.d.ts CHANGED
@@ -993,7 +993,7 @@ export interface KyogijoWords {
993
993
  item: string;
994
994
  itemSag: string;
995
995
  /** The names of what stands on the surface, by kind. */
996
- parts: Record<"scoreboard" | "net" | "antennae" | "goalposts" | "netposts" | "hoop" | "goal" | "boards" | "stumps" | "pylons" | "flags" | "fence" | "backstop" | "foul-poles" | "table-legs" | "walls" | "starting-blocks" | "backstroke-flags" | "false-start-rope" | "springboards" | "platforms" | "judges-stand" | "hurdles" | "steeplechase" | "high-jump" | "pole-vault" | "cage" | "padding" | "rink-boards" | "mound" | "azuchi" | "targets" | "stands" | "wall-seats" | "roof" | "gallery" | "dais" | "banner" | "shrine" | "mat-edge", string>;
996
+ parts: Record<"scoreboard" | "net" | "antennae" | "goalposts" | "netposts" | "hoop" | "goal" | "boards" | "stumps" | "pylons" | "flags" | "fence" | "backstop" | "foul-poles" | "table-legs" | "walls" | "starting-blocks" | "backstroke-flags" | "false-start-rope" | "springboards" | "platforms" | "judges-stand" | "hurdles" | "steeplechase" | "high-jump" | "pole-vault" | "cage" | "padding" | "rink-boards" | "mound" | "azuchi" | "targets" | "stands" | "wall-seats" | "roof" | "gallery" | "dais" | "banner" | "shrine" | "mat-edge" | "surrounds" | "officials", string>;
997
997
  };
998
998
  /** The names of the zones that `zoneAt` answers with (rules.ts), by id. */
999
999
  rules: Record<RuleId, string>;
package/dist/strings.js CHANGED
@@ -432,7 +432,7 @@ export const KYOGIJO_STRINGS = {
432
432
  announce: "{name} view", announceAngle: "Turned to {yaw} degrees, seen from {pitch} degrees above the floor.",
433
433
  flat: "This browser cannot draw the 3D view, so the flat drawing is shown.",
434
434
  standing: "Standing on it, in the 3D view: {list}.", item: "{name} {h}", itemSag: "{name} {h} at the posts, {low} at the middle",
435
- parts: { scoreboard: "scoreboard (lower edge)", net: "net", antennae: "antennae", goalposts: "goal posts", netposts: "net posts", hoop: "baskets", goal: "goals", boards: "boards and glass", stumps: "stumps", pylons: "pylons", flags: "corner flags", fence: "fence", backstop: "backstop", "foul-poles": "foul poles", "table-legs": "table top", walls: "walls", "starting-blocks": "starting blocks", "backstroke-flags": "backstroke flags", "false-start-rope": "false start rope", springboards: "springboards", platforms: "platforms", "judges-stand": "judges' stand", hurdles: "hurdles", steeplechase: "steeplechase barriers", "high-jump": "high jump uprights and bar", "pole-vault": "pole vault uprights and bar", cage: "discus and hammer cage", padding: "padding", "rink-boards": "boards", mound: "clay mound", azuchi: "azuchi", targets: "targets", stands: "stands", "wall-seats": "seats on top of the wall", roof: "roof", gallery: "gallery", dais: "dais", banner: "banner", shrine: "shrine", "mat-edge": "mat thickness" },
435
+ parts: { scoreboard: "scoreboard (lower edge)", net: "net", antennae: "antennae", goalposts: "goal posts", netposts: "net posts", hoop: "baskets", goal: "goals", boards: "boards and glass", stumps: "stumps", pylons: "pylons", flags: "corner flags", fence: "fence", backstop: "backstop", "foul-poles": "foul poles", "table-legs": "table top", walls: "walls", "starting-blocks": "starting blocks", "backstroke-flags": "backstroke flags", "false-start-rope": "false start rope", springboards: "springboards", platforms: "platforms", "judges-stand": "judges' stand", hurdles: "hurdles", steeplechase: "steeplechase barriers", "high-jump": "high jump uprights and bar", "pole-vault": "pole vault uprights and bar", cage: "discus and hammer cage", padding: "padding", "rink-boards": "boards", mound: "clay mound", azuchi: "azuchi", targets: "targets", stands: "stands", "wall-seats": "seats on top of the wall", roof: "roof", gallery: "gallery", dais: "dais", banner: "banner", shrine: "shrine", "mat-edge": "mat thickness", surrounds: "surrounds", officials: "umpires' chairs and score tables" },
436
436
  },
437
437
  rules: {
438
438
  "defensive-zone": "Defensive zone",
@@ -968,7 +968,7 @@ export const KYOGIJO_STRINGS = {
968
968
  announce: "{name}の視点", announceAngle: "向きは{yaw}度、見下ろす角度は{pitch}度です。",
969
969
  flat: "このブラウザーでは3D表示ができないため、平面の図を表示しています。",
970
970
  standing: "3D表示で立って見えるもの:{list}。", item: "{name} {h}", itemSag: "{name} 支柱の位置で{h}(中央は{low})",
971
- parts: { scoreboard: "スコアボードの下端", net: "ネット", antennae: "アンテナ", goalposts: "ゴールポスト", netposts: "支柱", hoop: "バスケットゴール", goal: "ゴール", boards: "ボードとガラス", stumps: "ウィケットのスタンプ", pylons: "パイロン", flags: "コーナーフラッグ", fence: "外野フェンス", backstop: "バックネット", "foul-poles": "ファウルポール", "table-legs": "天板", walls: "壁", "starting-blocks": "スタート台", "backstroke-flags": "背泳ぎ用フラッグ", "false-start-rope": "フォルススタートロープ", springboards: "飛板", platforms: "固定台", "judges-stand": "ジャッジ台", hurdles: "ハードル", steeplechase: "障害物", "high-jump": "走高跳の支柱とバー", "pole-vault": "棒高跳の支柱とバー", cage: "円盤投・ハンマー投の囲い", padding: "防護マット", "rink-boards": "フェンス", mound: "土の台", azuchi: "安土", targets: "的", stands: "スタンド", "wall-seats": "壁の上の観客席", roof: "屋根", gallery: "見学席", dais: "上座の台", banner: "垂れ幕", shrine: "神棚", "mat-edge": "畳の厚み" },
971
+ parts: { scoreboard: "スコアボードの下端", net: "ネット", antennae: "アンテナ", goalposts: "ゴールポスト", netposts: "支柱", hoop: "バスケットゴール", goal: "ゴール", boards: "ボードとガラス", stumps: "ウィケットのスタンプ", pylons: "パイロン", flags: "コーナーフラッグ", fence: "外野フェンス", backstop: "バックネット", "foul-poles": "ファウルポール", "table-legs": "天板", walls: "壁", "starting-blocks": "スタート台", "backstroke-flags": "背泳ぎ用フラッグ", "false-start-rope": "フォルススタートロープ", springboards: "飛板", platforms: "固定台", "judges-stand": "ジャッジ台", hurdles: "ハードル", steeplechase: "障害物", "high-jump": "走高跳の支柱とバー", "pole-vault": "棒高跳の支柱とバー", cage: "円盤投・ハンマー投の囲い", padding: "防護マット", "rink-boards": "フェンス", mound: "土の台", azuchi: "安土", targets: "的", stands: "スタンド", "wall-seats": "壁の上の観客席", roof: "屋根", gallery: "見学席", dais: "上座の台", banner: "垂れ幕", shrine: "神棚", "mat-edge": "畳の厚み", surrounds: "競技エリアのフェンス", officials: "審判席と記録台" },
972
972
  },
973
973
  rules: {
974
974
  "defensive-zone": "ディフェンディングゾーン",
@@ -1,4 +1,4 @@
1
- export { mountKyogijoTurn, turnSupported } from "./turn.dom.ts";
1
+ export { mountKyogijoTurn, mountTurn, turnSupported } from "./turn.dom.ts";
2
2
  export type { KyogijoTurn } from "./turn.dom.ts";
3
3
  export { buildTurn, floorOptions, openingView } from "./turn.model.ts";
4
4
  export type { TurnModel, TurnSettings } from "./turn.model.ts";
@@ -1,5 +1,5 @@
1
1
  // The 3D view: a surface as a floor that turns, with a net, a basket, a goal or a pair of posts standing on it.
2
- export { mountKyogijoTurn, turnSupported } from "./turn.dom.js";
2
+ export { mountKyogijoTurn, mountTurn, turnSupported } from "./turn.dom.js";
3
3
  export { buildTurn, floorOptions, openingView } from "./turn.model.js";
4
4
  export { layoutOf, planeMarkup, planeMatrix, turnMarkup, turnMarkupOf } from "./turn.markup.js";
5
5
  export { KYOGIJO_TURN_STYLE } from "./turn.style.js";
@@ -25,8 +25,10 @@ export declare const NET_PRESETS: Readonly<Record<TurnPreset, TurnView>>;
25
25
  * stay nearly square on to it, and the end view looks along it.
26
26
  */
27
27
  export declare const CURLING_PRESETS: Readonly<Record<TurnPreset, TurnView>>;
28
- /** The four viewpoints of a sport. */
29
- export declare const presetsFor: (sport?: string) => Readonly<Record<TurnPreset, TurnView>>;
28
+ /** The four viewpoints as a table. */
29
+ export type PresetTable = Readonly<Record<TurnPreset, TurnView>>;
30
+ /** The four viewpoints of a sport, or the table itself when it is given one (a tournament layout has its own). */
31
+ export declare const presetsFor: (sport?: string | PresetTable) => PresetTable;
30
32
  /** The view a mount opens on. */
31
33
  export declare const TURN_DEFAULT: TurnPreset;
32
34
  export declare const PITCH_MAX = 84;
@@ -39,9 +41,9 @@ export declare function clampView(view: TurnView, fallback?: TurnView): TurnView
39
41
  /** The angle equal to `to` that is nearest to `from`, so that a change of view turns the short way round. */
40
42
  export declare const nearest: (from: number, to: number) => number;
41
43
  /** The view a viewpoint name stands for in a sport, or `undefined` for a name that is not one. */
42
- export declare const presetView: (name: string | undefined, sport?: string) => TurnView | undefined;
44
+ export declare const presetView: (name: string | undefined, sport?: string | PresetTable) => TurnView | undefined;
43
45
  /** The name of the viewpoint a view is in a sport, or `undefined` when it is none of them (it has been turned by hand). */
44
- export declare function presetOf(view: TurnView, sport?: string): TurnPreset | undefined;
46
+ export declare function presetOf(view: TurnView, sport?: string | PresetTable): TurnPreset | undefined;
45
47
  /** The steps of the keys and the buttons, in degrees, and the factor of a zoom step. A held Shift takes the larger. */
46
48
  export declare const STEP: {
47
49
  readonly yaw: 6;
@@ -114,5 +116,10 @@ export declare function cameraMatrix(view: TurnView, at: Placement, distance: nu
114
116
  export declare const matrix3d: (m: Mat4) => string;
115
117
  /** How far a point is from the eye's plane after the scene is turned and tipped, in pixels towards the eye: bigger is nearer. Planes are drawn far first. */
116
118
  export declare function depthOf(point: readonly [number, number, number], view: TurnView, at: Placement): number;
117
- /** How far the eye is from the screen, in pixels, for a container: a long lens, so that the floor keeps its shape and the heights lean a little. */
119
+ /** The lens of the eye, as a multiple of the container's longer side. */
120
+ export declare const LENS = 8;
121
+ /**
122
+ * How far the eye is from the screen, in pixels, for a container: a long lens, so that the floor keeps its shape and the heights lean a little. The lens is long enough that the
123
+ * nearer end of a floor is hardly larger than the farther one: with a short lens (it was 1.7) the floor swelled by up to a quarter as its near end swung toward the eye in a turn.
124
+ */
118
125
  export declare const eyeDistance: (w: number, h: number, lens?: number) => number;
@@ -41,8 +41,8 @@ export const CURLING_PRESETS = {
41
41
  };
42
42
  /** The sports whose net runs across the court and whose viewpoints are `NET_PRESETS`. */
43
43
  const NET_SPORTS = ["tennis", "volleyball", "badminton", "pickleball", "tabletennis", "padel", "sepaktakraw", "teqball"];
44
- /** The four viewpoints of a sport. */
45
- export const presetsFor = (sport) => (sport === "curling" ? CURLING_PRESETS : sport !== undefined && NET_SPORTS.includes(sport) ? NET_PRESETS : sport !== undefined && LONG_SPORTS.includes(sport) ? LONG_PRESETS : TURN_PRESETS);
44
+ /** The four viewpoints of a sport, or the table itself when it is given one (a tournament layout has its own). */
45
+ export const presetsFor = (sport) => typeof sport === "object" ? sport : (sport === "curling" ? CURLING_PRESETS : sport !== undefined && NET_SPORTS.includes(sport) ? NET_PRESETS : sport !== undefined && LONG_SPORTS.includes(sport) ? LONG_PRESETS : TURN_PRESETS);
46
46
  /** The view a mount opens on. */
47
47
  export const TURN_DEFAULT = "corner";
48
48
  export const PITCH_MAX = 84;
@@ -199,7 +199,15 @@ export function cameraMatrix(view, at, distance) {
199
199
  m = multiply(m, rotateZ);
200
200
  return multiply(m, scale);
201
201
  }
202
- const num = (value) => String(Math.round(value * 10000) / 10000);
202
+ /**
203
+ * A number of a matrix as CSS writes it: to ten decimals, with no exponent. The perspective terms of a camera matrix are tiny (a few ten-thousandths) and are multiplied by
204
+ * the pixels of a whole floor, so rounding them to four decimals, as every other term could be, bent the perspective by tens of percent and by jumps as the turn went on: the
205
+ * surface swelled and shrank in steps.
206
+ */
207
+ const num = (value) => {
208
+ const text = value.toFixed(10).replace(/\.?0+$/, "");
209
+ return text === "-0" || text === "" ? "0" : text;
210
+ };
203
211
  /** A matrix as the CSS `transform` that applies it. */
204
212
  export const matrix3d = (m) => `matrix3d(${m.map(num).join(",")})`;
205
213
  /** How far a point is from the eye's plane after the scene is turned and tipped, in pixels towards the eye: bigger is nearer. Planes are drawn far first. */
@@ -210,5 +218,10 @@ export function depthOf(point, view, at) {
210
218
  const y2 = x * Math.sin(yaw) + y * Math.cos(yaw);
211
219
  return y2 * Math.sin(view.pitch * RAD) + z * Math.cos(view.pitch * RAD);
212
220
  }
213
- /** How far the eye is from the screen, in pixels, for a container: a long lens, so that the floor keeps its shape and the heights lean a little. */
214
- export const eyeDistance = (w, h, lens = 1.7) => lens * Math.max(w, h);
221
+ /** The lens of the eye, as a multiple of the container's longer side. */
222
+ export const LENS = 8;
223
+ /**
224
+ * How far the eye is from the screen, in pixels, for a container: a long lens, so that the floor keeps its shape and the heights lean a little. The lens is long enough that the
225
+ * nearer end of a floor is hardly larger than the farther one: with a short lens (it was 1.7) the floor swelled by up to a quarter as its near end swung toward the eye in a turn.
226
+ */
227
+ export const eyeDistance = (w, h, lens = LENS) => lens * Math.max(w, h);
@@ -1,8 +1,8 @@
1
1
  import { type TurnPreset, type TurnView } from "./turn.camera.ts";
2
- import { type TurnSettings } from "./turn.model.ts";
2
+ import { type TurnModel, type TurnSettings } from "./turn.model.ts";
3
3
  import type { ScoreOptions, SurfaceOptions } from "./types.ts";
4
- /** What `mountKyogijoTurn` gives back. */
5
- export interface KyogijoTurn {
4
+ /** What `mountKyogijoTurn` gives back. `O` is what `update` takes: the options of a drawing, or (for `mountMultiTurn`) of a tournament layout. */
5
+ export interface KyogijoTurn<O = SurfaceOptions | ScoreOptions> {
6
6
  /** `3d`, or `flat` where the browser cannot draw the 3D view (or there is nothing to turn): the flat drawing is shown and the rest of this does nothing. */
7
7
  readonly mode: "3d" | "flat";
8
8
  /** The view now: where it is going if it is gliding. */
@@ -14,7 +14,7 @@ export interface KyogijoTurn {
14
14
  /** Back to the opening view. */
15
15
  reset(): void;
16
16
  /** Draw again with new options (a score that moved, a look that changed), keeping the view. */
17
- update(options: SurfaceOptions | ScoreOptions): void;
17
+ update(options: O): void;
18
18
  /** Whether the auto-turn is running. */
19
19
  readonly playing: boolean;
20
20
  /** Start the auto-turn, a slow turn of the whole view. It gives whether it is running (a surface that cannot turn gives `false`). Nothing turns by itself until this, the Play button or Space starts it, unless `autoPlay` asked. */
@@ -46,3 +46,8 @@ export declare function turnSupported(win?: {
46
46
  * ```
47
47
  */
48
48
  export declare function mountKyogijoTurn(host: HTMLElement, options: SurfaceOptions | ScoreOptions, settings?: TurnSettings): KyogijoTurn;
49
+ /**
50
+ * Mount any 3D model (`mountKyogijoTurn` is this with the model of one surface; `mountMultiTurn` with a tournament layout's): `build` makes the model from what `update` is given, and the rest, the controls, the pointer, the keys
51
+ * and the auto-turn, is the same.
52
+ */
53
+ export declare function mountTurn<O>(host: HTMLElement, options: O, settings: TurnSettings, build: (options: O, settings: TurnSettings) => TurnModel): KyogijoTurn<O>;
package/dist/turn.dom.js CHANGED
@@ -30,16 +30,24 @@ const between = (from, to, t) => ({ yaw: from.yaw + (nearest(from.yaw, to.yaw) -
30
30
  * ```
31
31
  */
32
32
  export function mountKyogijoTurn(host, options, settings = {}) {
33
+ return mountTurn(host, options, settings, buildTurn);
34
+ }
35
+ /**
36
+ * Mount any 3D model (`mountKyogijoTurn` is this with the model of one surface; `mountMultiTurn` with a tournament layout's): `build` makes the model from what `update` is given, and the rest, the controls, the pointer, the keys
37
+ * and the auto-turn, is the same.
38
+ */
39
+ export function mountTurn(host, options, settings, build) {
33
40
  const doc = host.ownerDocument;
34
41
  const win = doc.defaultView;
35
- let model = buildTurn(options, settings);
36
- const sport = () => model.options.sport;
42
+ let model = build(options, settings);
43
+ /** What names the four viewpoints: the model's own table (a tournament layout has one), else the sport's. */
44
+ const sport = () => model.presets ?? model.options.sport;
37
45
  const supported = turnSupported(win ?? undefined) && model.turnable;
38
46
  let alive = true;
39
47
  if (!supported) {
40
48
  // The flat drawing, with a sentence that says why it does not turn when the browser is the reason.
41
49
  const flat = (next) => {
42
- host.innerHTML = `<div class="ky-turn" data-mode="flat"><style>${KYOGIJO_TURN_STYLE}</style>${drawKyogijo(floorOptions(next))}${model.turnable ? `<p class="ky-turn-flat">${esc(turnWords(model).flat)}</p>` : ""}</div>`;
50
+ host.innerHTML = `<div class="ky-turn" data-mode="flat"><style>${KYOGIJO_TURN_STYLE}</style>${model.flat ?? drawKyogijo(floorOptions(next))}${model.turnable ? `<p class="ky-turn-flat">${esc(turnWords(model).flat)}</p>` : ""}</div>`;
43
51
  };
44
52
  flat(options);
45
53
  return {
@@ -53,7 +61,7 @@ export function mountKyogijoTurn(host, options, settings = {}) {
53
61
  pause: () => undefined,
54
62
  togglePlay: () => false,
55
63
  update: (next) => {
56
- model = buildTurn(next, settings);
64
+ model = build(next, settings);
57
65
  flat(next);
58
66
  },
59
67
  svg: () => (alive ? host.querySelector("svg.kyogijo") : null),
@@ -430,7 +438,7 @@ export function mountKyogijoTurn(host, options, settings = {}) {
430
438
  update(next) {
431
439
  if (!alive)
432
440
  return;
433
- model = buildTurn(next, { ...settings, preset: undefined, view });
441
+ model = build(next, { ...settings, preset: undefined, view });
434
442
  view = clampView(view);
435
443
  opening = openingView(settings, sport());
436
444
  const layout = layoutOf(model, shown, stage.clientWidth || 960, stage.clientHeight || 600, settings.lens);
@@ -1,7 +1,7 @@
1
1
  /** A point or a direction: x, y, z in feet. */
2
2
  export type V3 = readonly [number, number, number];
3
3
  /** What a plane is a part of: it is the class of its element (`ky-up-<kind>`) and what a test finds it by. */
4
- export type PartKind = "post" | "net" | "tape" | "antenna" | "board" | "glass" | "rim" | "stanchion" | "frame" | "goal" | "flag" | "stump" | "leg" | "table" | "pylon" | "wall" | "screen" | "pole" | "ground" | "target" | "seats" | "concourse" | "fascia" | "roof" | "banner" | "platform" | "rail" | "matedge" | "shrine";
4
+ export type PartKind = "post" | "net" | "tape" | "antenna" | "board" | "glass" | "rim" | "stanchion" | "frame" | "goal" | "flag" | "stump" | "leg" | "table" | "pylon" | "wall" | "screen" | "pole" | "ground" | "target" | "seats" | "concourse" | "fascia" | "roof" | "banner" | "platform" | "rail" | "matedge" | "shrine" | "surround" | "chair" | "desk";
5
5
  /** One flat rectangle. Its own x runs along `u` (its whole length) and its own y along `v`; `o` is the corner both start from. `svg` is what is drawn on it, in feet along `u` and `v`. */
6
6
  export interface Plane {
7
7
  /** A name that says what it is and where: `net`, `post-left`, `goal-right-back`. */
@@ -21,11 +21,18 @@ export interface Plane {
21
21
  * wall. The nearer the eye is to that side, and the lower it is, the more the wall is cut away (see `fadeOf`), so that the court is never hidden behind its own glass.
22
22
  */
23
23
  shy?: readonly [number, number];
24
+ /**
25
+ * A plane that is the floor's own drawing seen through its rectangle, not a drawing of its own: the top of a raised platform (a dohyō's clay with its bales). The floor lies on the ground, so
26
+ * that the ground round the platform stays low, and this plane lifts the part of it that is the platform's top to the platform's height. Its `u` and `v` must be flat and along x and y.
27
+ */
28
+ crop?: boolean;
24
29
  /** How much of a `shy` plane is left when it is cut away altogether: 0.08 when missing. A plane that others lie over on the screen (the steps of a gallery) takes less, so that they do not add up to a veil. */
25
30
  fadeTo?: number;
31
+ /** How low the eye must be for a `shy` plane to begin to give way: the share (of the plane's side being towards the eye, times the sine of the pitch) at which the fade starts; 0.1 when missing. A low barrier takes a high one, so that it stays whole until the eye is nearly level with it. */
32
+ fadeAt?: number;
26
33
  }
27
34
  /** What stands on the surface as a thing a reader would name: a net, a basket, a goal, a pair of posts. */
28
- export type UprightKind = "scoreboard" | "net" | "antennae" | "goalposts" | "netposts" | "hoop" | "goal" | "boards" | "stumps" | "pylons" | "flags" | "fence" | "backstop" | "foul-poles" | "table-legs" | "walls" | "starting-blocks" | "backstroke-flags" | "false-start-rope" | "springboards" | "platforms" | "judges-stand" | "hurdles" | "steeplechase" | "high-jump" | "pole-vault" | "cage" | "padding" | "rink-boards" | "mound" | "azuchi" | "targets" | "stands" | "wall-seats" | "roof" | "gallery" | "dais" | "banner" | "shrine" | "mat-edge";
35
+ export type UprightKind = "scoreboard" | "net" | "antennae" | "goalposts" | "netposts" | "hoop" | "goal" | "boards" | "stumps" | "pylons" | "flags" | "fence" | "backstop" | "foul-poles" | "table-legs" | "walls" | "starting-blocks" | "backstroke-flags" | "false-start-rope" | "springboards" | "platforms" | "judges-stand" | "hurdles" | "steeplechase" | "high-jump" | "pole-vault" | "cage" | "padding" | "rink-boards" | "mound" | "azuchi" | "targets" | "stands" | "wall-seats" | "roof" | "gallery" | "dais" | "banner" | "shrine" | "mat-edge" | "surrounds" | "officials";
29
36
  /** A thing that stands on the surface: where, how high, and the planes it is made of. */
30
37
  export interface Upright {
31
38
  id: string;
@@ -85,4 +92,4 @@ export declare function shy(p: Plane, centre: readonly [number, number]): Plane;
85
92
  * How much of a wall is left (1 whole, down to 0.08) at a view: none is cut away unless the wall is on the eye's side of the surface, and then more the lower the eye is, because a wall seen
86
93
  * from straight above hides nothing. `yaw` and `pitch` are the view's, in degrees.
87
94
  */
88
- export declare function fadeOf(p: Pick<Plane, "shy" | "fadeTo">, yaw: number, pitch: number): number;
95
+ export declare function fadeOf(p: Pick<Plane, "shy" | "fadeTo" | "fadeAt">, yaw: number, pitch: number): number;
package/dist/turn.kit.js CHANGED
@@ -105,6 +105,7 @@ export function fadeOf(p, yaw, pitch) {
105
105
  // The horizontal direction from the surface to the eye (the same sum as the depth of a point).
106
106
  const toward = p.shy[0] * Math.sin(a) + p.shy[1] * Math.cos(a);
107
107
  const hides = toward * Math.sin((pitch * Math.PI) / 180);
108
- const t = Math.min(1, Math.max(0, (hides - 0.1) / 0.25));
108
+ const from = p.fadeAt ?? 0.1;
109
+ const t = Math.min(1, Math.max(0, (hides - from) / 0.25));
109
110
  return Math.round((1 - (1 - (p.fadeTo ?? 0.08)) * t) * 1000) / 1000;
110
111
  }
@@ -21,6 +21,11 @@ export interface Layout {
21
21
  export declare function layoutOf(model: TurnModel, view: TurnView, width: number, height: number, lens?: number): Layout;
22
22
  /** One plane as an element: its box in pixels and the SVG drawn on it in feet. */
23
23
  export declare function planeMarkup(p: Plane, scale: number, transform: string, stack: number, fade?: number): string;
24
+ /**
25
+ * A plane that is the floor's own drawing seen through its rectangle (`Plane.crop`): the floor's SVG again, its viewBox cut down to the plane's rectangle, in the plane's own box. It is a picture, not a second
26
+ * description: it carries no role, title or description of its own.
27
+ */
28
+ export declare function cropMarkup(model: TurnModel, p: Plane, transform: string, stack: number, fade?: number): string;
24
29
  /** The floor as an element: the flat drawing, in the pixels of the scene. */
25
30
  export declare function floorMarkup(model: TurnModel, transform: string, stack: number): string;
26
31
  /** The patterns the venue's planes are drawn with, once, in a box of no size: every plane's SVG reads them by id, and the crowd's colours are on this element. */
@@ -29,15 +29,16 @@ export function layoutOf(model, view, width, height, lens) {
29
29
  // A billboard faces the eye at this view.
30
30
  const planes = model.planes.map((p) => facing(p, view.yaw));
31
31
  const transforms = [floor, ...planes].map((p) => matrix3d(multiply(camera, planeMatrix(p, s, centre))));
32
- // Far to near: a plane's depth is that of its middle. The ground of a hall is under everything, the floor next.
33
- const order = planes
34
- .map((p, i) => ({ i, ground: p.kind === "ground", depth: depthOf([(p.o[0] + (p.u[0] + p.v[0]) / 2 - centre[0]) * s, (p.o[1] + (p.u[1] + p.v[1]) / 2 - centre[1]) * s, (p.o[2] + (p.u[2] + p.v[2]) / 2) * s], view, at) }))
35
- .filter((one) => !one.ground);
32
+ // Far to near: a plane's depth is that of its middle. The ground of a hall is under everything. The floor is next, unless it stands raised on something (a table tennis table's top, on its legs): then it is
33
+ // sorted with the rest, by its middle, so that the legs and the skirt on the far side are drawn under it and the ones on the near side over it, and nothing under the top shows through it.
34
+ const depth = (p) => depthOf([(p.o[0] + (p.u[0] + p.v[0]) / 2 - centre[0]) * s, (p.o[1] + (p.u[1] + p.v[1]) / 2 - centre[1]) * s, (p.o[2] + (p.u[2] + p.v[2]) / 2) * s], view, at);
35
+ const raised = model.uprights.lift > 0;
36
+ const order = [...planes.map((p, i) => ({ i: i + 1, ground: p.kind === "ground", depth: depth(p) })), ...(raised ? [{ i: 0, ground: false, depth: depth(floor) }] : [])].filter((one) => !one.ground);
36
37
  order.sort((a, b) => a.depth - b.depth);
37
38
  const stack = new Array(model.planes.length + 1).fill(0);
38
39
  stack[0] = 1;
39
40
  order.forEach((one, rank) => {
40
- stack[one.i + 1] = rank + 2;
41
+ stack[one.i] = rank + 2;
41
42
  });
42
43
  const fade = [1, ...model.planes.map((p) => fadeOf(p, view.yaw, view.pitch))];
43
44
  return { at, transforms, stack, fade };
@@ -47,6 +48,22 @@ export function planeMarkup(p, scale, transform, stack, fade = 1) {
47
48
  const [w, h] = size(p);
48
49
  return `<div class="ky-up ky-up-${p.kind}" data-part="${esc(p.id)}" style="width:${px(w * scale)}px;height:${px(h * scale)}px;transform:${transform};z-index:${stack}${fade < 1 ? `;opacity:${n(fade)}` : ""}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(w)} ${n(h)}" width="${px(w * scale)}" height="${px(h * scale)}" aria-hidden="true" focusable="false">${p.svg}</svg></div>`;
49
50
  }
51
+ /**
52
+ * A plane that is the floor's own drawing seen through its rectangle (`Plane.crop`): the floor's SVG again, its viewBox cut down to the plane's rectangle, in the plane's own box. It is a picture, not a second
53
+ * description: it carries no role, title or description of its own.
54
+ */
55
+ export function cropMarkup(model, p, transform, stack, fade = 1) {
56
+ const [w, h] = size(p);
57
+ const box = `${px(p.o[0])} ${px(p.o[1])} ${px(w)} ${px(h)}`;
58
+ const picture = model.floor
59
+ .replace(/^<svg([^>]*?)\sviewBox="[^"]*"/, `<svg$1 viewBox="${box}"`)
60
+ .replace(/^<svg([^>]*?)\swidth="[^"]*"/, "<svg$1")
61
+ .replace(/^<svg([^>]*?)\sheight="[^"]*"/, "<svg$1")
62
+ .replace(/^<svg([^>]*?)\srole="img"/, '<svg$1 aria-hidden="true"')
63
+ .replace(/^<svg([^>]*?)\saria-labelledby="[^"]*"/, "<svg$1")
64
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>|<desc\b[^>]*>[\s\S]*?<\/desc>/g, "");
65
+ return `<div class="ky-up ky-up-${p.kind}" data-part="${esc(p.id)}" data-crop="floor" style="width:${px(w * model.scale)}px;height:${px(h * model.scale)}px;transform:${transform};z-index:${stack}${fade < 1 ? `;opacity:${n(fade)}` : ""}">${picture}</div>`;
66
+ }
50
67
  /** The floor as an element: the flat drawing, in the pixels of the scene. */
51
68
  export function floorMarkup(model, transform, stack) {
52
69
  const vb = model.viewBox;
@@ -66,12 +83,12 @@ export function worldAttributes(model) {
66
83
  }
67
84
  /** The floor and the planes of a model, laid out. */
68
85
  export function worldMarkup(model, layout) {
69
- return defsMarkup(model) + floorMarkup(model, layout.transforms[0], layout.stack[0]) + model.planes.map((p, i) => planeMarkup(p, model.scale, layout.transforms[i + 1], layout.stack[i + 1], layout.fade[i + 1])).join("");
86
+ return defsMarkup(model) + floorMarkup(model, layout.transforms[0], layout.stack[0]) + model.planes.map((p, i) => (p.crop === true ? cropMarkup(model, p, layout.transforms[i + 1], layout.stack[i + 1], layout.fade[i + 1]) : planeMarkup(p, model.scale, layout.transforms[i + 1], layout.stack[i + 1], layout.fade[i + 1]))).join("");
70
87
  }
71
88
  /** The buttons under the view. */
72
89
  export function controlsMarkup(model, view, playing = false) {
73
90
  const words = turnWords(model);
74
- const here = presetOf(view, model.options.sport);
91
+ const here = presetOf(view, model.presets ?? model.options.sport);
75
92
  const presets = TURN_PRESET_NAMES.map((name, i) => `<button type="button" data-preset="${name}" aria-pressed="${here === name}" aria-keyshortcuts="${i + 1}">${esc(words[name])}</button>`).join("");
76
93
  const tools = [
77
94
  ["turn-left", "↺", words.turnLeft],
@@ -98,7 +115,7 @@ export function turnMarkupOf(model, settings = {}) {
98
115
  const sheet = model.options.style === "none" ? "" : `<style>${KYOGIJO_TURN_STYLE}</style>`;
99
116
  const theme = model.options.theme;
100
117
  const sport = model.options.sport;
101
- return (`<div class="ky-turn" id="${esc(model.id)}" data-sport="${esc(String(sport))}" data-mode="3d" data-touch="${settings.touch === "turn" ? "turn" : "scroll"}" data-preset="${presetOf(view, sport) ?? "custom"}"${theme === "light" || theme === "dark" ? ` data-theme="${theme}"` : ""} lang="${model.language}">` +
118
+ return (`<div class="ky-turn" id="${esc(model.id)}" data-sport="${esc(String(sport))}" data-mode="3d" data-touch="${settings.touch === "turn" ? "turn" : "scroll"}" data-preset="${presetOf(view, model.presets ?? sport) ?? "custom"}"${theme === "light" || theme === "dark" ? ` data-theme="${theme}"` : ""} lang="${model.language}">` +
102
119
  sheet +
103
120
  `<div class="ky-turn-view" tabindex="0" role="group" aria-roledescription="${esc(words.region)}" aria-label="${esc(model.title)}" aria-describedby="${esc(model.id)}-help" aria-keyshortcuts="ArrowLeft ArrowRight ArrowUp ArrowDown + - 0 1 2 3 4 Home Space"><div ${worldAttributes(model)}>${worldMarkup(model, layout)}</div></div>` +
104
121
  `<p class="ky-turn-help" id="${esc(model.id)}-help">${esc(keys)}</p>` +