@johnmorrisdotca/kyogijo-stadiums 0.7.0 → 0.8.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.
package/dist/strings.js CHANGED
@@ -425,13 +425,14 @@ export const KYOGIJO_STRINGS = {
425
425
  },
426
426
  turn: {
427
427
  region: "3D view", group: "Viewpoint",
428
- keys: "A 3D view you can turn. Drag, or use the arrow keys, to turn and tilt it; plus and minus zoom; 1 to 4 pick a viewpoint; 0 or Home puts it back. Pinch, or hold Ctrl and scroll, to zoom.",
428
+ keys: "A 3D view you can turn. Drag, or use the arrow keys, to turn and tilt it; plus and minus zoom; 1 to 4 pick a viewpoint; 0 or Home puts it back; Space starts and stops the auto-turn. Pinch, or hold Ctrl and scroll, to zoom.",
429
429
  overhead: "Overhead", broadcast: "Broadcast", end: "End-on", corner: "Low corner",
430
430
  reset: "Reset the view", turnLeft: "Turn left", turnRight: "Turn right", raise: "Tip the surface away", lower: "Tip the surface toward you", zoomIn: "Zoom in", zoomOut: "Zoom out",
431
+ play: "Auto-turn", playOn: "Start the auto-turn", playOff: "Pause the auto-turn", announcePlay: "Auto-turn on", announcePause: "Auto-turn paused",
431
432
  announce: "{name} view", announceAngle: "Turned to {yaw} degrees, seen from {pitch} degrees above the floor.",
432
433
  flat: "This browser cannot draw the 3D view, so the flat drawing is shown.",
433
434
  standing: "Standing on it, in the 3D view: {list}.", item: "{name} {h}", itemSag: "{name} {h} at the posts, {low} at the middle",
434
- 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" },
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
436
  },
436
437
  rules: {
437
438
  "defensive-zone": "Defensive zone",
@@ -960,13 +961,14 @@ export const KYOGIJO_STRINGS = {
960
961
  },
961
962
  turn: {
962
963
  region: "3D表示", group: "視点",
963
- keys: "回して見られる3D表示です。ドラッグするか矢印キーで回転と傾きを変え、プラスとマイナスのキーで拡大と縮小、1から4のキーで視点を選び、0キーまたはHomeキーで元に戻します。ピンチ操作、またはCtrlキーを押しながらのスクロールでも拡大・縮小できます。",
964
+ keys: "回して見られる3D表示です。ドラッグするか矢印キーで回転と傾きを変え、プラスとマイナスのキーで拡大と縮小、1から4のキーで視点を選び、0キーまたはHomeキーで元に戻し、スペースキーで自動回転を始めたり止めたりします。ピンチ操作、またはCtrlキーを押しながらのスクロールでも拡大・縮小できます。",
964
965
  overhead: "真上", broadcast: "中継カメラ", end: "エンド側から", corner: "低い角度から",
965
966
  reset: "元に戻す", turnLeft: "左へ回す", turnRight: "右へ回す", raise: "床面を奥へ傾ける", lower: "床面を手前へ傾ける", zoomIn: "拡大", zoomOut: "縮小",
967
+ play: "自動回転", playOn: "自動回転を始める", playOff: "自動回転を一時停止する", announcePlay: "自動回転を始めました", announcePause: "自動回転を一時停止しました",
966
968
  announce: "{name}の視点", announceAngle: "向きは{yaw}度、見下ろす角度は{pitch}度です。",
967
969
  flat: "このブラウザーでは3D表示ができないため、平面の図を表示しています。",
968
970
  standing: "3D表示で立って見えるもの:{list}。", item: "{name} {h}", itemSag: "{name} 支柱の位置で{h}(中央は{low})",
969
- 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: "的" },
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": "畳の厚み" },
970
972
  },
971
973
  rules: {
972
974
  "defensive-zone": "ディフェンディングゾーン",
@@ -1,10 +1,13 @@
1
1
  // A budō surface lying wide, from its plan (budo/plan.ts): the floor and the free zone, the mats in their tones with their heri and their seams, the walls, the clay and the straw of a dohyō, the tape,
2
2
  // the targets. The plan is in metres; the drawing is in feet, as every surface lies, so the whole is drawn inside a group that scales metres to feet and moves the surface's box to the origin.
3
- import { el } from "../format.js";
3
+ import { el, num } from "../format.js";
4
+ import { floorTiles } from "../hardwood.js";
4
5
  import { areaD, marks, strokes } from "./field.js";
5
6
  import { atEnds, edges, ft, quarterTurn } from "./surface.js";
6
7
  /** Feet in a metre. */
7
8
  export const K = 1 / 0.3048;
9
+ /** The hardwood of a dojo's surround, laid as the basketball floor is (`hardwood.ts`): strip boards of 0.3 ft, each with a tone of its own and a little grain, seams that are only just there. */
10
+ const SURROUND_WOOD = { seed: 11, fine: true, board: 0.3, stain: 0, sheen: 0, hoops: false, bench: false };
8
11
  const feetBox = (b, ox, oy) => edges((b.x0 - ox) * K, (b.y0 - oy) * K, (b.x1 - ox) * K, (b.y1 - oy) * K);
9
12
  /** A plan's named range in feet, as a surface lists them. */
10
13
  function rangeOf(r, ox, oy) {
@@ -28,7 +31,7 @@ export function budoSurface(o, plan, margin) {
28
31
  const weave = full && plan.texture && pieces.length > 0;
29
32
  const stripe = (at, colour, opacity, width) => el("stop", { offset: at, "stop-color": colour, "stop-opacity": 0 }) + el("stop", { offset: at, "stop-color": colour, "stop-opacity": opacity }) + el("stop", { offset: at + width, "stop-color": colour, "stop-opacity": opacity }) + el("stop", { offset: at + width, "stop-color": colour, "stop-opacity": 0 });
30
33
  const woven = () => {
31
- defs += el("linearGradient", { id: `${o.id}-weave`, x1: 0, y1: 0, x2: 0, y2: 0.1, gradientUnits: "userSpaceOnUse", spreadMethod: "repeat" }, stripe(0.1, "#000000", 0.16, 0.22) + stripe(0.52, "#ffffff", 0.14, 0.16));
34
+ defs += el("linearGradient", { id: `${o.id}-weave`, x1: 0, y1: 0, x2: 0, y2: 0.1, gradientUnits: "userSpaceOnUse", spreadMethod: "repeat" }, stripe(0.1, "#000000", 0.03, 0.22) + stripe(0.52, "#000000", 0.015, 0.16));
32
35
  const rect = (p) => `M${p.x0} ${p.y0}H${p.x1}V${p.y1}H${p.x0}Z`;
33
36
  // A mat that lies across is drawn in a group turned a quarter, with its corners turned back, so that its ridges run along it.
34
37
  const turned = (p) => `M${p.y0} ${-p.x0}H${p.y1}V${-p.x1}H${p.y0}Z`;
@@ -43,6 +46,19 @@ export function budoSurface(o, plan, margin) {
43
46
  out += el("path", { class: "ky-bud-edge", d: pieces.map(rect).join(""), fill: "none" });
44
47
  return out;
45
48
  };
49
+ // The surround is polished hardwood: the boards (under) and their seams (over) as two tiles of the basketball floor's code, in feet and turned to metres, and a warm satin sheen in a few
50
+ // broad diagonal glints over them. Drawn only at full detail.
51
+ let polished = false;
52
+ const polish = (d) => {
53
+ const tiles = floorTiles(SURROUND_WOOD);
54
+ const tile = (suffix, body) => el("pattern", { id: `${o.id}-wood-${suffix}`, width: 96, height: num(tiles.height), patternUnits: "userSpaceOnUse", patternTransform: `scale(${(1 / K).toFixed(6)})` }, body);
55
+ const b = plan.box;
56
+ const glint = (at, w, a) => el("stop", { offset: at - w, "stop-color": "#fff1cf", "stop-opacity": 0 }) + el("stop", { offset: at, "stop-color": "#fff1cf", "stop-opacity": a }) + el("stop", { offset: at + w, "stop-color": "#fff1cf", "stop-opacity": 0 });
57
+ defs += tile("a", tiles.under) + tile("b", tiles.over) + el("linearGradient", { id: `${o.id}-wood-s`, gradientUnits: "userSpaceOnUse", x1: b.x0, y1: b.y0, x2: b.x1, y2: b.y1 }, glint(0.22, 0.06, 0.1) + glint(0.5, 0.08, 0.13) + glint(0.78, 0.05, 0.08));
58
+ const layer = (suffix, opacity) => el("path", { class: "ky-bud-wood", d, "fill-rule": "evenodd", fill: `url(#${o.id}-wood-${suffix})`, opacity });
59
+ // The boards are meant to be seen only when looked for: the tones and the seams are laid at half strength.
60
+ return layer("a", 0.5) + layer("b", 0.45) + layer("s");
61
+ };
46
62
  let pending = false;
47
63
  let wove = false;
48
64
  for (const a of plan.areas) {
@@ -54,6 +70,10 @@ export function budoSurface(o, plan, margin) {
54
70
  if (tone)
55
71
  pending = true;
56
72
  ground += el("path", { class: ft(o, a.feature, a.cls), d: areaD(a), "fill-rule": a.evenodd === true ? "evenodd" : undefined });
73
+ if (full && a.id === "surround" && !polished) {
74
+ polished = true;
75
+ ground += polish(areaD(a));
76
+ }
57
77
  }
58
78
  if (weave && !wove)
59
79
  ground += woven();
@@ -9,4 +9,6 @@ export { CURLING_PRESETS, LONG_PRESETS, NET_PRESETS, PITCH_MAX, STEP, TURN_DEFAU
9
9
  export type { Box3, Mat4, Placement, TurnPreset, TurnToward, TurnView } from "./turn.camera.ts";
10
10
  export { describeUprights, heightUnit, planesOf, uprightsFor } from "./uprights.ts";
11
11
  export type { UprightOptions, Uprights } from "./uprights.ts";
12
+ export { ROOF } from "./venue.ts";
13
+ export type { Venue, VenueBuild, VenueSettings } from "./venue.ts";
12
14
  export type { Plane, PartKind, Upright, UprightKind, V3 } from "./turn.kit.ts";
@@ -5,3 +5,4 @@ export { layoutOf, planeMarkup, planeMatrix, turnMarkup, turnMarkupOf } from "./
5
5
  export { KYOGIJO_TURN_STYLE } from "./turn.style.js";
6
6
  export { CURLING_PRESETS, LONG_PRESETS, NET_PRESETS, PITCH_MAX, STEP, TURN_DEFAULT, TURN_PRESETS, TURN_PRESET_NAMES, ZOOM_MAX, ZOOM_MIN, cameraMatrix, clampView, depthOf, drag, eyeDistance, fit, matrix3d, multiply, nudge, presetOf, presetView, presetsFor, project, step } from "./turn.camera.js";
7
7
  export { describeUprights, heightUnit, planesOf, uprightsFor } from "./uprights.js";
8
+ export { ROOF } from "./venue.js";
@@ -15,6 +15,14 @@ export interface KyogijoTurn {
15
15
  reset(): void;
16
16
  /** Draw again with new options (a score that moved, a look that changed), keeping the view. */
17
17
  update(options: SurfaceOptions | ScoreOptions): void;
18
+ /** Whether the auto-turn is running. */
19
+ readonly playing: boolean;
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. */
21
+ play(): boolean;
22
+ /** Stop the auto-turn. */
23
+ pause(): void;
24
+ /** Start the auto-turn if it is stopped and stop it if it is running; it gives whether it is running now. */
25
+ togglePlay(): boolean;
18
26
  /** The flat drawing that is the floor, or `null` after `destroy`. */
19
27
  svg(): SVGSVGElement | null;
20
28
  /** Stop listening and empty the container. */
package/dist/turn.dom.js CHANGED
@@ -15,7 +15,7 @@ export function turnSupported(win = typeof window === "undefined" ? undefined :
15
15
  return false;
16
16
  return css.supports("transform", "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)");
17
17
  }
18
- const KEYS = { ArrowLeft: true, ArrowRight: true, ArrowUp: true, ArrowDown: true, "+": true, "=": true, "-": true, _: true, "0": true, Home: true, "1": true, "2": true, "3": true, "4": true };
18
+ const KEYS = { " ": true, ArrowLeft: true, ArrowRight: true, ArrowUp: true, ArrowDown: true, "+": true, "=": true, "-": true, _: true, "0": true, Home: true, "1": true, "2": true, "3": true, "4": true };
19
19
  /** The view part of the way from one to another, `t` from 0 to 1, the turn taken the short way round. */
20
20
  const between = (from, to, t) => ({ yaw: from.yaw + (nearest(from.yaw, to.yaw) - from.yaw) * t, pitch: from.pitch + (to.pitch - from.pitch) * t, zoom: from.zoom * Math.pow(to.zoom / from.zoom, t) });
21
21
  /**
@@ -48,6 +48,10 @@ export function mountKyogijoTurn(host, options, settings = {}) {
48
48
  set: () => undefined,
49
49
  preset: () => undefined,
50
50
  reset: () => undefined,
51
+ playing: false,
52
+ play: () => false,
53
+ pause: () => undefined,
54
+ togglePlay: () => false,
51
55
  update: (next) => {
52
56
  model = buildTurn(next, settings);
53
57
  flat(next);
@@ -70,6 +74,12 @@ export function mountKyogijoTurn(host, options, settings = {}) {
70
74
  let opening = model.view;
71
75
  let flight;
72
76
  let spoken;
77
+ // The auto-turn: whether it is on, the frame that will advance it, the time of the last one, and whether it has moved the view since the page was last told.
78
+ let playing = false;
79
+ let tickFrame;
80
+ let lastTick;
81
+ let advanced = false;
82
+ const speed = typeof settings.speed === "number" && Number.isFinite(settings.speed) ? settings.speed : 12;
73
83
  const calm = () => settings.motion === "none" || (settings.motion !== "smooth" && media?.matches === true);
74
84
  // The elements, floor first, and what was written to each, so that only a change is written.
75
85
  let elements = [];
@@ -84,6 +94,18 @@ export function mountKyogijoTurn(host, options, settings = {}) {
84
94
  };
85
95
  gather();
86
96
  const buttons = () => root.querySelectorAll("button[data-preset]");
97
+ /** The Play button says what it is and what it does: pressed while the view turns. */
98
+ const syncPlay = () => {
99
+ const button = root.querySelector("button[data-act=play]");
100
+ if (button === null)
101
+ return;
102
+ const words = turnWords(model);
103
+ button.setAttribute("aria-pressed", String(playing));
104
+ button.title = playing ? words.playOff : words.playOn;
105
+ const glyph = button.querySelector("[data-glyph]");
106
+ if (glyph !== null)
107
+ glyph.textContent = playing ? "❚❚" : "▶";
108
+ };
87
109
  /** Put a view on the screen. */
88
110
  const paint = (now) => {
89
111
  const width = stage.clientWidth || 960;
@@ -155,7 +177,10 @@ export function mountKyogijoTurn(host, options, settings = {}) {
155
177
  const angles = () => turnWords(model).announceAngle.replace("{yaw}", String(Math.round(((view.yaw % 360) + 360) % 360))).replace("{pitch}", String(Math.round(90 - view.pitch)));
156
178
  const changed = () => settings.onChange?.(view, presetOf(view, sport()));
157
179
  /** Move to a view the controls asked for: glide, say where it is, tell the page. */
158
- const go = (next, ms, speak = "angle") => {
180
+ const go = (next, ms, speak = "angle", keepPlaying = false) => {
181
+ // Anything the reader (or the page) asks of the view takes it over from the auto-turn, but a zoom, which does not turn it.
182
+ if (!keepPlaying)
183
+ setPlaying(false, true);
159
184
  // A turn takes the short way round, even from 170 degrees to -170.
160
185
  show(clampView({ ...next, yaw: nearest(view.yaw, next.yaw) }), ms);
161
186
  if (speak === "preset") {
@@ -172,6 +197,82 @@ export function mountKyogijoTurn(host, options, settings = {}) {
172
197
  go(opening, 560, "none");
173
198
  say(turnWords(model).reset);
174
199
  };
200
+ // ---- The auto-turn: a trophy case's slow turn. It runs only when asked, holds while the pointer holds the view, and does nothing while the page is hidden. ----
201
+ const hidden = () => doc.hidden === true;
202
+ const stopTick = () => {
203
+ if (tickFrame !== undefined)
204
+ win?.cancelAnimationFrame(tickFrame);
205
+ tickFrame = undefined;
206
+ lastTick = undefined;
207
+ };
208
+ const tick = (stamp) => {
209
+ tickFrame = undefined;
210
+ if (!playing || !alive)
211
+ return;
212
+ // Hidden, off the screen or held: the loop ends here, and the visibility change, the scroll back or the pointer's release starts it again.
213
+ if (hidden() || !onScreen || down.size > 0) {
214
+ lastTick = undefined;
215
+ return;
216
+ }
217
+ // About thirty frames a second are plenty for a slow turn, and every plane is written at each.
218
+ if (lastTick !== undefined && stamp - lastTick < 30) {
219
+ loop();
220
+ return;
221
+ }
222
+ const dt = lastTick === undefined ? 0 : Math.min(0.1, (stamp - lastTick) / 1000);
223
+ lastTick = stamp;
224
+ if (dt > 0) {
225
+ stop();
226
+ view = clampView({ ...view, yaw: view.yaw - speed * dt });
227
+ shown = view;
228
+ advanced = true;
229
+ paint(shown);
230
+ label();
231
+ }
232
+ loop();
233
+ };
234
+ const loop = () => {
235
+ if (tickFrame === undefined && playing && alive && win !== null)
236
+ tickFrame = win.requestAnimationFrame(tick);
237
+ };
238
+ /** Start or stop the auto-turn. `quiet` leaves the live region alone (a key or a drag stopped it, and says its own thing). */
239
+ const setPlaying = (next, quiet = false) => {
240
+ if (playing === next)
241
+ return;
242
+ playing = next;
243
+ stopTick();
244
+ syncPlay();
245
+ if (next)
246
+ loop();
247
+ else if (advanced) {
248
+ // The page is told where the view ended up, once, not at every frame.
249
+ advanced = false;
250
+ changed();
251
+ }
252
+ if (!quiet)
253
+ say(next ? turnWords(model).announcePlay : turnWords(model).announcePause);
254
+ settings.onPlayChange?.(next);
255
+ };
256
+ // A view scrolled out of sight does not turn.
257
+ let onScreen = true;
258
+ let seen;
259
+ if (typeof IntersectionObserver === "function") {
260
+ seen = new IntersectionObserver((entries) => {
261
+ onScreen = entries[entries.length - 1]?.isIntersecting !== false;
262
+ if (onScreen && playing) {
263
+ lastTick = undefined;
264
+ loop();
265
+ }
266
+ });
267
+ seen.observe(stage);
268
+ }
269
+ const onVisible = () => {
270
+ if (playing && !hidden()) {
271
+ lastTick = undefined;
272
+ loop();
273
+ }
274
+ };
275
+ doc.addEventListener("visibilitychange", onVisible);
175
276
  // ---- Buttons. ----
176
277
  const onClick = (event) => {
177
278
  const target = event.target?.closest("button");
@@ -187,8 +288,9 @@ export function mountKyogijoTurn(host, options, settings = {}) {
187
288
  case "turn-right": return go(nudge(view, "right", big ? STEP.yawBig : STEP.yaw * 2), 180);
188
289
  case "raise": return go(nudge(view, "up", big ? STEP.pitchBig : STEP.pitch * 2), 180);
189
290
  case "lower": return go(nudge(view, "down", big ? STEP.pitchBig : STEP.pitch * 2), 180);
190
- case "zoom-in": return go(step(view, { zoom: STEP.zoom }), 180, "none");
191
- case "zoom-out": return go(step(view, { zoom: 1 / STEP.zoom }), 180, "none");
291
+ case "zoom-in": return go(step(view, { zoom: STEP.zoom }), 180, "none", true);
292
+ case "zoom-out": return go(step(view, { zoom: 1 / STEP.zoom }), 180, "none", true);
293
+ case "play": return void setPlaying(!playing);
192
294
  case "reset": return toOpening();
193
295
  default:
194
296
  }
@@ -209,9 +311,10 @@ export function mountKyogijoTurn(host, options, settings = {}) {
209
311
  case "ArrowUp": return go(nudge(view, "up", pitch), 120);
210
312
  case "ArrowDown": return go(nudge(view, "down", pitch), 120);
211
313
  case "+":
212
- case "=": return go(step(view, { zoom: STEP.zoom }), 120, "none");
314
+ case "=": return go(step(view, { zoom: STEP.zoom }), 120, "none", true);
213
315
  case "-":
214
- case "_": return go(step(view, { zoom: 1 / STEP.zoom }), 120, "none");
316
+ case "_": return go(step(view, { zoom: 1 / STEP.zoom }), 120, "none", true);
317
+ case " ": return void setPlaying(!playing);
215
318
  case "0":
216
319
  case "Home": return toOpening();
217
320
  default: toPreset(TURN_PRESET_NAMES[Number(event.key) - 1]);
@@ -240,6 +343,8 @@ export function mountKyogijoTurn(host, options, settings = {}) {
240
343
  }
241
344
  moved = false;
242
345
  grab();
346
+ // The auto-turn holds while the pointer does.
347
+ stopTick();
243
348
  if (down.size === 1)
244
349
  root.setAttribute("data-dragging", "");
245
350
  if (down.size === 2) {
@@ -273,6 +378,8 @@ export function mountKyogijoTurn(host, options, settings = {}) {
273
378
  if (down.size === 0) {
274
379
  root.removeAttribute("data-dragging");
275
380
  label();
381
+ if (playing)
382
+ loop();
276
383
  if (moved) {
277
384
  say(angles(), 450);
278
385
  changed();
@@ -309,6 +416,9 @@ export function mountKyogijoTurn(host, options, settings = {}) {
309
416
  observer = new ResizeObserver(() => alive && paint(shown));
310
417
  observer.observe(stage);
311
418
  }
419
+ // Nothing moves until the reader asks, unless the page did: `autoPlay` starts it, but not for a reader who prefers reduced motion (`"force"` does).
420
+ if (settings.autoPlay === "force" || (settings.autoPlay === true && !calm()))
421
+ setPlaying(true, true);
312
422
  return {
313
423
  mode: "3d",
314
424
  view: () => ({ ...view }),
@@ -324,7 +434,19 @@ export function mountKyogijoTurn(host, options, settings = {}) {
324
434
  view = clampView(view);
325
435
  opening = openingView(settings, sport());
326
436
  const layout = layoutOf(model, shown, stage.clientWidth || 960, stage.clientHeight || 600, settings.lens);
327
- root.querySelector(".ky-turn-world").innerHTML = worldMarkup(model, layout);
437
+ const world = root.querySelector(".ky-turn-world");
438
+ world.innerHTML = worldMarkup(model, layout);
439
+ // The look the floor is painted in (and the theme) are attributes of the world.
440
+ for (const name of ["data-sport", "data-look", "data-theme"])
441
+ world.removeAttribute(name);
442
+ world.className = model.look === undefined ? "ky-turn-world" : "ky-turn-world kyogijo";
443
+ if (model.look !== undefined) {
444
+ world.setAttribute("data-sport", model.look.sport);
445
+ world.setAttribute("data-look", model.look.look);
446
+ const forced = model.options.theme;
447
+ if (forced === "light" || forced === "dark")
448
+ world.setAttribute("data-theme", forced);
449
+ }
328
450
  gather();
329
451
  stage.setAttribute("aria-label", model.title);
330
452
  root.querySelector(".ky-turn-help").textContent = `${turnWords(model).keys}${model.standing === "" ? "" : ` ${model.standing}`}`;
@@ -335,16 +457,33 @@ export function mountKyogijoTurn(host, options, settings = {}) {
335
457
  root.removeAttribute("data-theme");
336
458
  const controls = root.querySelector(".ky-turn-controls");
337
459
  if (controls !== null)
338
- controls.outerHTML = controlsMarkup(model, view);
460
+ controls.outerHTML = controlsMarkup(model, view, playing);
339
461
  paint(shown);
340
462
  label();
341
463
  },
464
+ get playing() {
465
+ return playing;
466
+ },
467
+ play() {
468
+ setPlaying(true);
469
+ return playing;
470
+ },
471
+ pause() {
472
+ setPlaying(false);
473
+ },
474
+ togglePlay() {
475
+ setPlaying(!playing);
476
+ return playing;
477
+ },
342
478
  svg: () => (alive ? root.querySelector(".ky-turn-floor svg.kyogijo") : null),
343
479
  destroy() {
344
480
  if (!alive)
345
481
  return;
346
482
  alive = false;
347
483
  stop();
484
+ stopTick();
485
+ seen?.disconnect();
486
+ doc.removeEventListener("visibilitychange", onVisible);
348
487
  clearTimeout(spoken);
349
488
  observer?.disconnect();
350
489
  host.replaceChildren();
@@ -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";
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";
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,9 +21,11 @@ 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
+ /** 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
+ fadeTo?: number;
24
26
  }
25
27
  /** What stands on the surface as a thing a reader would name: a net, a basket, a goal, a pair of posts. */
26
- 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";
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";
27
29
  /** A thing that stands on the surface: where, how high, and the planes it is made of. */
28
30
  export interface Upright {
29
31
  id: string;
@@ -83,4 +85,4 @@ export declare function shy(p: Plane, centre: readonly [number, number]): Plane;
83
85
  * 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
84
86
  * from straight above hides nothing. `yaw` and `pitch` are the view's, in degrees.
85
87
  */
86
- export declare function fadeOf(p: Pick<Plane, "shy">, yaw: number, pitch: number): number;
88
+ export declare function fadeOf(p: Pick<Plane, "shy" | "fadeTo">, yaw: number, pitch: number): number;
package/dist/turn.kit.js CHANGED
@@ -106,5 +106,5 @@ export function fadeOf(p, yaw, pitch) {
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
108
  const t = Math.min(1, Math.max(0, (hides - 0.1) / 0.25));
109
- return Math.round((1 - 0.92 * t) * 1000) / 1000;
109
+ return Math.round((1 - (1 - (p.fadeTo ?? 0.08)) * t) * 1000) / 1000;
110
110
  }
@@ -23,10 +23,17 @@ export declare function layoutOf(model: TurnModel, view: TurnView, width: number
23
23
  export declare function planeMarkup(p: Plane, scale: number, transform: string, stack: number, fade?: number): string;
24
24
  /** The floor as an element: the flat drawing, in the pixels of the scene. */
25
25
  export declare function floorMarkup(model: TurnModel, transform: string, stack: number): string;
26
+ /** 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. */
27
+ export declare function defsMarkup(model: TurnModel): string;
28
+ /**
29
+ * The attributes of the element the planes stand in. A floor painted in a look (a dojo's mats) gets the drawing's own class, sport and look, so that the stylesheet's rules for the look set
30
+ * its custom properties here too, and the walls and the wood of the room are the colours of the floor. A theme the options force goes with them.
31
+ */
32
+ export declare function worldAttributes(model: TurnModel): string;
26
33
  /** The floor and the planes of a model, laid out. */
27
34
  export declare function worldMarkup(model: TurnModel, layout: Layout): string;
28
35
  /** The buttons under the view. */
29
- export declare function controlsMarkup(model: TurnModel, view: TurnView): string;
36
+ export declare function controlsMarkup(model: TurnModel, view: TurnView, playing?: boolean): string;
30
37
  /** The size, in pixels, the first view is worked out for. */
31
38
  export declare const sizeOf: (settings: TurnSettings) => {
32
39
  width: number;
@@ -52,12 +52,24 @@ export function floorMarkup(model, transform, stack) {
52
52
  const vb = model.viewBox;
53
53
  return `<div class="ky-turn-floor" style="width:${px(vb.w * model.scale)}px;height:${px(vb.h * model.scale)}px;transform:${transform};z-index:${stack}">${model.floor}</div>`;
54
54
  }
55
+ /** 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. */
56
+ export function defsMarkup(model) {
57
+ return model.defs === "" ? "" : `<svg class="ky-turn-defs" xmlns="http://www.w3.org/2000/svg" width="0" height="0"${model.crowdStyle === "" ? "" : ` style="${esc(model.crowdStyle)}"`} aria-hidden="true" focusable="false"><defs>${model.defs}</defs></svg>`;
58
+ }
59
+ /**
60
+ * The attributes of the element the planes stand in. A floor painted in a look (a dojo's mats) gets the drawing's own class, sport and look, so that the stylesheet's rules for the look set
61
+ * its custom properties here too, and the walls and the wood of the room are the colours of the floor. A theme the options force goes with them.
62
+ */
63
+ export function worldAttributes(model) {
64
+ const theme = model.options.theme;
65
+ return model.look === undefined ? 'class="ky-turn-world"' : `class="ky-turn-world kyogijo" data-sport="${esc(model.look.sport)}" data-look="${esc(model.look.look)}"${theme === "light" || theme === "dark" ? ` data-theme="${theme}"` : ""}`;
66
+ }
55
67
  /** The floor and the planes of a model, laid out. */
56
68
  export function worldMarkup(model, layout) {
57
- return 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("");
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("");
58
70
  }
59
71
  /** The buttons under the view. */
60
- export function controlsMarkup(model, view) {
72
+ export function controlsMarkup(model, view, playing = false) {
61
73
  const words = turnWords(model);
62
74
  const here = presetOf(view, model.options.sport);
63
75
  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("");
@@ -70,7 +82,9 @@ export function controlsMarkup(model, view) {
70
82
  ["zoom-out", "−", words.zoomOut],
71
83
  ];
72
84
  const buttons = tools.map(([act, glyph, label]) => `<button type="button" data-act="${act}" aria-label="${esc(label)}" title="${esc(label)}">${glyph}</button>`).join("");
73
- return `<div class="ky-turn-controls"><div class="ky-turn-group" role="group" aria-label="${esc(words.group)}">${presets}</div><div class="ky-turn-group">${buttons}<button type="button" data-act="reset" aria-keyshortcuts="0 Home">${esc(words.reset)}</button></div></div>`;
85
+ // The auto-turn: a toggle named for what it is, pressed while the view turns; its glyph and tooltip say what pressing it does.
86
+ const play = `<button type="button" data-act="play" data-wide aria-pressed="${playing}" aria-keyshortcuts="Space" title="${esc(playing ? words.playOff : words.playOn)}"><span data-glyph aria-hidden="true">${playing ? "❚❚" : "▶"}</span>${esc(words.play)}</button>`;
87
+ return `<div class="ky-turn-controls"><div class="ky-turn-group" role="group" aria-label="${esc(words.group)}">${presets}</div><div class="ky-turn-group">${buttons}<button type="button" data-act="reset" aria-keyshortcuts="0 Home">${esc(words.reset)}</button>${play}</div></div>`;
74
88
  }
75
89
  /** The size, in pixels, the first view is worked out for. */
76
90
  export const sizeOf = (settings) => settings.size ?? { width: 960, height: 600 };
@@ -86,7 +100,7 @@ export function turnMarkupOf(model, settings = {}) {
86
100
  const sport = model.options.sport;
87
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}">` +
88
102
  sheet +
89
- `<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"><div class="ky-turn-world">${worldMarkup(model, layout)}</div></div>` +
103
+ `<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>` +
90
104
  `<p class="ky-turn-help" id="${esc(model.id)}-help">${esc(keys)}</p>` +
91
105
  (settings.controls === false ? "" : controlsMarkup(model, view)) +
92
106
  `<div class="ky-turn-live" role="status" aria-live="polite"></div>` +
@@ -2,6 +2,7 @@ import { KYOGIJO_STRINGS } from "./strings.ts";
2
2
  import { type TurnPreset, type TurnView } from "./turn.camera.ts";
3
3
  import type { Plane } from "./turn.kit.ts";
4
4
  import { type UprightOptions, type Uprights } from "./uprights.ts";
5
+ import type { VenueSettings } from "./venue.ts";
5
6
  import type { Box3 } from "./turn.camera.ts";
6
7
  import type { Language, ScoreOptions, SurfaceOptions } from "./types.ts";
7
8
  /** What can be asked of a 3D view. */
@@ -14,6 +15,12 @@ export interface TurnSettings {
14
15
  controls?: boolean;
15
16
  /** What stands on the surface: `false` for the floor alone, or which kinds, whether the boards and the glass are drawn, the height of a volleyball net. True when left out. */
16
17
  uprights?: boolean | UprightOptions;
18
+ /**
19
+ * The venue round the surface, raised in 3D from the data the package already has: the stands of a drawing's `crowd` (every tier a strip of raked planes with the crowd on it, the walks between the tiers, a
20
+ * ballpark's grandstands and the seats on top of its wall) and a dojo's room (its walls with their doors and windows, the dais, the banner, the balconies and the edge of the mats). `true` or `false`, or
21
+ * `{ roof, detail }`. On when the drawing has a crowd or is a dojo, and off with `uprights: false`. The stands and the walls give way to the eye as the boards do, so that the surface is never hidden.
22
+ */
23
+ venue?: boolean | VenueSettings;
17
24
  /** Where the score of a score drawing goes: `board` (the default) hangs it up as a scoreboard that faces the eye (over the middle of a court or a rink, behind an end of a field, behind centre field in a ballpark) and takes the panel off the floor; `floor` leaves the panel on the floor. */
18
25
  score?: "board" | "floor";
19
26
  /** What a finger does on the view: `scroll` (the default) lets a vertical swipe scroll the page and a sideways one turn the view; `turn` keeps every touch for the view, which then also tips it. */
@@ -31,6 +38,15 @@ export interface TurnSettings {
31
38
  };
32
39
  /** The prefix of the ids in the view, for a page with more than one. A hash of the options when missing. */
33
40
  id?: string;
41
+ /**
42
+ * The auto-turn, a trophy case's slow turn of the whole view: `true` starts it when the view is mounted (paused, if the reader prefers reduced motion), `"force"` starts it even then; left out, nothing moves until
43
+ * the reader presses Play (or Space). It stops while the pointer holds the view and while the page is hidden, and the keys, the viewpoints and the reset stop it.
44
+ */
45
+ autoPlay?: boolean | "force";
46
+ /** Degrees of yaw a second the auto-turn goes by: 12 by default, positive turning the near side to the right as a drag to the right does, negative the other way. */
47
+ speed?: number;
48
+ /** Called with `true` when the auto-turn starts and `false` when it stops, whatever stopped it. */
49
+ onPlayChange?: (playing: boolean) => void;
34
50
  /** Called after the view has changed by a key, a button or the pointer, with the view and the viewpoint it is (`undefined` when it has been turned by hand). */
35
51
  onChange?: (view: TurnView, preset: TurnPreset | undefined) => void;
36
52
  }
@@ -59,6 +75,16 @@ export interface TurnModel {
59
75
  /** Pixels in a foot. */
60
76
  scale: number;
61
77
  uprights: Uprights;
78
+ /** The patterns the venue's planes are drawn with, and the crowd's colours (custom properties) they read: both empty when the view has no venue. */
79
+ defs: string;
80
+ crowdStyle: string;
81
+ /** Whether the venue is raised. */
82
+ venue: boolean;
83
+ /** The sport and look the floor is painted in, when it has one (a budō surface's mats): the planes of its room are painted in the same look. */
84
+ look: {
85
+ sport: string;
86
+ look: string;
87
+ } | undefined;
62
88
  planes: Plane[];
63
89
  /** The whole scene in pixels, from its middle: the floor's rectangle, and the height of the highest thing. */
64
90
  box: Box3;