@johnmorrisdotca/kyogijo-stadiums 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/NOTICE.md +4 -0
  3. package/README.md +71 -16
  4. package/dist/annotate.js +1 -1
  5. package/dist/athletics/describe.d.ts +8 -0
  6. package/dist/athletics/describe.js +41 -0
  7. package/dist/athletics/events.d.ts +17 -0
  8. package/dist/athletics/events.js +48 -0
  9. package/dist/athletics/facilities.d.ts +80 -0
  10. package/dist/athletics/facilities.js +176 -0
  11. package/dist/athletics/infield.d.ts +79 -0
  12. package/dist/athletics/infield.js +166 -0
  13. package/dist/athletics/layout.d.ts +13 -0
  14. package/dist/athletics/layout.js +98 -0
  15. package/dist/athletics/oval.d.ts +57 -0
  16. package/dist/athletics/oval.js +116 -0
  17. package/dist/athletics/plan.d.ts +97 -0
  18. package/dist/athletics/plan.js +355 -0
  19. package/dist/athletics/races.d.ts +102 -0
  20. package/dist/athletics/races.js +355 -0
  21. package/dist/athletics/steeplechase.d.ts +72 -0
  22. package/dist/athletics/steeplechase.js +196 -0
  23. package/dist/athletics/stencil.d.ts +13 -0
  24. package/dist/athletics/stencil.js +85 -0
  25. package/dist/athletics/track.d.ts +54 -0
  26. package/dist/athletics/track.js +105 -0
  27. package/dist/athletics/types.d.ts +77 -0
  28. package/dist/athletics/types.js +1 -0
  29. package/dist/athletics-entry.d.ts +17 -0
  30. package/dist/athletics-entry.js +11 -0
  31. package/dist/athletics.style.d.ts +16 -0
  32. package/dist/athletics.style.js +51 -0
  33. package/dist/atlas-entry.d.ts +4 -2
  34. package/dist/atlas-entry.js +3 -2
  35. package/dist/atlas.d.ts +83 -7
  36. package/dist/atlas.js +108 -8
  37. package/dist/charts-entry.d.ts +6 -1
  38. package/dist/charts-entry.js +3 -0
  39. package/dist/charts.athletics.d.ts +183 -0
  40. package/dist/charts.athletics.js +455 -0
  41. package/dist/charts.curling.heat.d.ts +24 -0
  42. package/dist/charts.curling.heat.js +116 -0
  43. package/dist/charts.curling.js +14 -3
  44. package/dist/charts.pool.d.ts +67 -0
  45. package/dist/charts.pool.js +76 -0
  46. package/dist/charts.skating.d.ts +204 -0
  47. package/dist/charts.skating.js +426 -0
  48. package/dist/charts.types.d.ts +53 -1
  49. package/dist/court.d.ts +85 -2
  50. package/dist/court.js +102 -7
  51. package/dist/court.more.d.ts +46 -0
  52. package/dist/court.more.js +219 -0
  53. package/dist/courts.style.d.ts +14 -2
  54. package/dist/courts.style.js +51 -4
  55. package/dist/crowd.d.ts +154 -0
  56. package/dist/crowd.draw.d.ts +17 -0
  57. package/dist/crowd.draw.js +198 -0
  58. package/dist/crowd.js +465 -0
  59. package/dist/crowd.park.d.ts +19 -0
  60. package/dist/crowd.park.js +153 -0
  61. package/dist/curling.stats.d.ts +271 -0
  62. package/dist/curling.stats.js +301 -0
  63. package/dist/draw.d.ts +16 -0
  64. package/dist/draw.js +318 -35
  65. package/dist/element-entry.d.ts +1 -1
  66. package/dist/element-entry.js +1 -1
  67. package/dist/element.d.ts +13 -2
  68. package/dist/element.js +77 -3
  69. package/dist/features.js +131 -0
  70. package/dist/fields.kit.d.ts +2 -2
  71. package/dist/fields.types.d.ts +18 -4
  72. package/dist/frames.providers.js +63 -8
  73. package/dist/geojson.js +3 -0
  74. package/dist/geometry-entry.d.ts +2 -1
  75. package/dist/geometry-entry.js +2 -1
  76. package/dist/geometry.d.ts +20 -0
  77. package/dist/geometry.js +78 -1
  78. package/dist/grounds.d.ts +4 -0
  79. package/dist/grounds.data.js +12 -0
  80. package/dist/hardwood.d.ts +31 -0
  81. package/dist/hardwood.js +157 -0
  82. package/dist/heat.js +4 -3
  83. package/dist/heat.types.d.ts +5 -0
  84. package/dist/heat.zones.d.ts +2 -2
  85. package/dist/heat.zones.js +154 -6
  86. package/dist/homeside.js +17 -0
  87. package/dist/index.d.ts +16 -2
  88. package/dist/index.js +10 -1
  89. package/dist/layers.d.ts +1 -1
  90. package/dist/layers.env.d.ts +3 -1
  91. package/dist/layers.env.js +4 -2
  92. package/dist/layers.js +4 -4
  93. package/dist/leagues/athletics.d.ts +63 -0
  94. package/dist/leagues/athletics.js +87 -0
  95. package/dist/leagues/courts.d.ts +269 -4
  96. package/dist/leagues/courts.js +155 -1
  97. package/dist/leagues/skating.d.ts +145 -0
  98. package/dist/leagues/skating.js +73 -0
  99. package/dist/leagues/sources.js +20 -0
  100. package/dist/leagues/types.d.ts +1 -1
  101. package/dist/leagues-entry.d.ts +4 -2
  102. package/dist/leagues-entry.js +2 -1
  103. package/dist/leagues.d.ts +14 -4
  104. package/dist/leagues.js +146 -5
  105. package/dist/look.d.ts +3 -3
  106. package/dist/look.js +39 -6
  107. package/dist/outline.d.ts +1 -1
  108. package/dist/outline.js +30 -2
  109. package/dist/pageant.d.ts +120 -0
  110. package/dist/pageant.draw.d.ts +21 -0
  111. package/dist/pageant.draw.js +171 -0
  112. package/dist/pageant.js +207 -0
  113. package/dist/pageant.style.d.ts +8 -0
  114. package/dist/pageant.style.js +42 -0
  115. package/dist/png.d.ts +8 -1
  116. package/dist/png.js +21 -1
  117. package/dist/pool.d.ts +176 -0
  118. package/dist/pool.figures.d.ts +304 -0
  119. package/dist/pool.figures.js +144 -0
  120. package/dist/pool.js +304 -0
  121. package/dist/pool.kit.d.ts +48 -0
  122. package/dist/pool.kit.js +141 -0
  123. package/dist/pool.labels.d.ts +20 -0
  124. package/dist/pool.labels.js +257 -0
  125. package/dist/pool.race.d.ts +65 -0
  126. package/dist/pool.race.js +140 -0
  127. package/dist/pool.replay.d.ts +46 -0
  128. package/dist/pool.replay.js +127 -0
  129. package/dist/pool.section.d.ts +13 -0
  130. package/dist/pool.section.js +83 -0
  131. package/dist/pool.style.d.ts +20 -0
  132. package/dist/pool.style.js +169 -0
  133. package/dist/pool.types.d.ts +145 -0
  134. package/dist/pool.types.js +3 -0
  135. package/dist/pool.words.d.ts +13 -0
  136. package/dist/pool.words.js +101 -0
  137. package/dist/ranges.names.js +47 -3
  138. package/dist/replay-entry.d.ts +3 -0
  139. package/dist/replay-entry.js +2 -0
  140. package/dist/restyle.js +1 -1
  141. package/dist/score.js +70 -5
  142. package/dist/sheet.d.ts +3 -0
  143. package/dist/sheet.js +7 -3
  144. package/dist/skating/describe.d.ts +7 -0
  145. package/dist/skating/describe.js +44 -0
  146. package/dist/skating/events.d.ts +24 -0
  147. package/dist/skating/events.js +47 -0
  148. package/dist/skating/figure.d.ts +24 -0
  149. package/dist/skating/figure.js +37 -0
  150. package/dist/skating/long.d.ts +54 -0
  151. package/dist/skating/long.js +111 -0
  152. package/dist/skating/parts.d.ts +43 -0
  153. package/dist/skating/parts.js +40 -0
  154. package/dist/skating/plan.d.ts +55 -0
  155. package/dist/skating/plan.js +197 -0
  156. package/dist/skating/short.d.ts +47 -0
  157. package/dist/skating/short.js +89 -0
  158. package/dist/skating/track.d.ts +66 -0
  159. package/dist/skating/track.js +94 -0
  160. package/dist/skating/types.d.ts +57 -0
  161. package/dist/skating/types.js +3 -0
  162. package/dist/skating-entry.d.ts +17 -0
  163. package/dist/skating-entry.js +11 -0
  164. package/dist/skating.style.d.ts +4 -0
  165. package/dist/skating.style.js +24 -0
  166. package/dist/strings.d.ts +296 -0
  167. package/dist/strings.js +328 -11
  168. package/dist/style.d.ts +2 -0
  169. package/dist/style.js +11 -1
  170. package/dist/surfaces/artistic.d.ts +9 -0
  171. package/dist/surfaces/artistic.js +94 -0
  172. package/dist/surfaces/athletics.d.ts +16 -0
  173. package/dist/surfaces/athletics.js +132 -0
  174. package/dist/surfaces/baseball.js +15 -0
  175. package/dist/surfaces/basketball.floor.d.ts +27 -0
  176. package/dist/surfaces/basketball.floor.js +83 -0
  177. package/dist/surfaces/basketball.gear.d.ts +27 -0
  178. package/dist/surfaces/basketball.gear.js +90 -0
  179. package/dist/surfaces/basketball.js +43 -21
  180. package/dist/surfaces/curling.d.ts +16 -0
  181. package/dist/surfaces/curling.fold.d.ts +67 -0
  182. package/dist/surfaces/curling.fold.js +107 -0
  183. package/dist/surfaces/curling.js +37 -3
  184. package/dist/surfaces/dive.d.ts +9 -0
  185. package/dist/surfaces/dive.js +128 -0
  186. package/dist/surfaces/field.d.ts +7 -2
  187. package/dist/surfaces/field.js +15 -9
  188. package/dist/surfaces/hockey.js +1 -0
  189. package/dist/surfaces/net.d.ts +2 -1
  190. package/dist/surfaces/net.js +160 -15
  191. package/dist/surfaces/polo.d.ts +12 -0
  192. package/dist/surfaces/polo.js +159 -0
  193. package/dist/surfaces/pool.d.ts +27 -0
  194. package/dist/surfaces/pool.js +198 -0
  195. package/dist/surfaces/skating.d.ts +6 -0
  196. package/dist/surfaces/skating.js +62 -0
  197. package/dist/surfaces/soccer.js +2 -1
  198. package/dist/surfaces/surface.d.ts +44 -0
  199. package/dist/surfaces/venue.d.ts +15 -0
  200. package/dist/surfaces/venue.js +95 -0
  201. package/dist/tenants.d.ts +77 -0
  202. package/dist/tenants.data.d.ts +8 -0
  203. package/dist/tenants.data.js +945 -0
  204. package/dist/tenants.js +46 -0
  205. package/dist/turn-entry.d.ts +12 -0
  206. package/dist/turn-entry.js +7 -0
  207. package/dist/turn.camera.d.ts +105 -0
  208. package/dist/turn.camera.js +168 -0
  209. package/dist/turn.dom.d.ts +40 -0
  210. package/dist/turn.dom.js +351 -0
  211. package/dist/turn.kit.d.ts +86 -0
  212. package/dist/turn.kit.js +110 -0
  213. package/dist/turn.markup.d.ts +48 -0
  214. package/dist/turn.markup.js +108 -0
  215. package/dist/turn.model.d.ts +78 -0
  216. package/dist/turn.model.js +91 -0
  217. package/dist/turn.style.d.ts +5 -0
  218. package/dist/turn.style.js +55 -0
  219. package/dist/types.d.ts +183 -18
  220. package/dist/units.d.ts +1 -1
  221. package/dist/units.js +15 -1
  222. package/dist/uprights.board.d.ts +17 -0
  223. package/dist/uprights.board.js +59 -0
  224. package/dist/uprights.d.ts +37 -0
  225. package/dist/uprights.fields.d.ts +32 -0
  226. package/dist/uprights.fields.js +172 -0
  227. package/dist/uprights.goal.d.ts +49 -0
  228. package/dist/uprights.goal.js +69 -0
  229. package/dist/uprights.js +160 -0
  230. package/dist/uprights.more.d.ts +7 -0
  231. package/dist/uprights.more.js +183 -0
  232. package/dist/uprights.net.d.ts +12 -0
  233. package/dist/uprights.net.js +109 -0
  234. package/dist/uprights.netkit.d.ts +28 -0
  235. package/dist/uprights.netkit.js +67 -0
  236. package/dist/uprights.pools.d.ts +8 -0
  237. package/dist/uprights.pools.js +190 -0
  238. package/dist/uprights.rinks.d.ts +27 -0
  239. package/dist/uprights.rinks.js +106 -0
  240. package/dist/uprights.track.d.ts +50 -0
  241. package/dist/uprights.track.js +246 -0
  242. package/dist/version.d.ts +1 -1
  243. package/dist/version.js +1 -1
  244. package/package.json +19 -2
@@ -0,0 +1,257 @@
1
+ // The data of a race on a pool: who is in each lane (the lane number, the flag, the name, the country's letters), what each swam (the place as a medal, the time, the reaction time, the splits) and the
2
+ // split pace bars in the lanes. Drawn in feet in the frame of a pool lying wide; a pool lying tall turns it with the pool, so that the labels lie along the lanes, and only the numbers in the discs are
3
+ // turned back upright. Nothing here is a rule of the sport: the sizes are ours.
4
+ import { safeColour, safeLogo } from "./colour.js";
5
+ import { el, esc, num } from "./format.js";
6
+ import { feet } from "./pool.js";
7
+ import { STATUS_TEXT, formatTime, pacePer100, splitMetres } from "./pool.race.js";
8
+ import { textOn, ft } from "./surfaces/surface.js";
9
+ import { textWidth } from "./text.js";
10
+ const ROW = feet(2.5);
11
+ const NAME = 2.7;
12
+ const SMALL = 2.0;
13
+ const DISC = 1.75;
14
+ const GAP = 1.1;
15
+ /** The medal each of the first three places wins: gold, silver and bronze, in these colours whatever the look. */
16
+ export const MEDALS = Object.freeze({ 1: "#d9a620", 2: "#aeb6c0", 3: "#b8733a" });
17
+ /** The width of text in feet, with a margin of a sixth: the estimate is for a bold face, and the pages' fonts differ. */
18
+ const tw = (text, size) => textWidth(text) * size * 1.18;
19
+ /** The most splits a plate writes; a longer race has its splits in the pace bars. */
20
+ const MOST_SPLITS = 8;
21
+ /** The pace bar colours, slowest to fastest: a dark to light scale that stays legible on the water and in print (the scale of viridis, reversed in meaning so that bright is fast). */
22
+ const PACE_STOPS = [
23
+ [68, 1, 84],
24
+ [59, 82, 139],
25
+ [33, 145, 140],
26
+ [94, 201, 98],
27
+ [253, 231, 37],
28
+ ];
29
+ /** The colour of a pace between the field's slowest (0) and fastest (1). */
30
+ export function paceColour(t) {
31
+ const x = Math.min(1, Math.max(0, t)) * (PACE_STOPS.length - 1);
32
+ const k = Math.min(PACE_STOPS.length - 2, Math.floor(x));
33
+ const f = x - k;
34
+ const c = PACE_STOPS[k].map((v, i) => Math.round(v + (PACE_STOPS[k + 1][i] - v) * f));
35
+ return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
36
+ }
37
+ /** The ink that reads on a colour. */
38
+ const inkFor = (hex) => {
39
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
40
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#10161c" : "#ffffff";
41
+ };
42
+ function disc(o, cx, cy, text, cls, fill) {
43
+ const r = DISC;
44
+ const size = text.length > 2 ? r * 0.95 : r * 1.25;
45
+ return el("circle", { class: cls, cx, cy, r, fill }) + textOn(o.portrait, cx, cy + size * 0.36, text, { size, anchor: "middle", cls: "ky-pl-dn", weight: 800 });
46
+ }
47
+ function flagMarkup(row, x, cy, w, h) {
48
+ const logo = safeLogo(row.flag);
49
+ if (logo === undefined)
50
+ return "";
51
+ return el("image", { class: "ky-logo", href: logo, x, y: cy - h / 2, width: w, height: h, preserveAspectRatio: "xMidYMid slice" }) + el("rect", { class: "ky-pl-fb", x, y: cy - h / 2, width: w, height: h, rx: 0.25, fill: "none" });
52
+ }
53
+ /** The plate of a swimmer at the starting end, and its width in feet. */
54
+ function namePlate(plan, o, row, cy, widths, size) {
55
+ const race = plan.race;
56
+ if (race === undefined)
57
+ return "";
58
+ const show = race.show;
59
+ const h = ROW * 0.84;
60
+ const plateW = plateWidth(plan, widths);
61
+ const x1 = -labelEdge();
62
+ const x0 = x1 - plateW;
63
+ let out = el("rect", { class: "ky-pl-pl", x: x0, y: cy - h / 2, width: plateW, height: h, rx: h * 0.18 });
64
+ const colour = safeColour(row.color);
65
+ if (colour !== undefined)
66
+ out += el("rect", { x: x0, y: cy - h / 2, width: 0.7, height: h, fill: colour, rx: 0.3 });
67
+ let x = x0 + GAP + (colour === undefined ? 0 : 0.7);
68
+ if (show.flags && widths.flag > 0) {
69
+ out += flagMarkup(row, x, cy, widths.flag, widths.flag * 0.667);
70
+ x += widths.flag + GAP;
71
+ }
72
+ if (show.names) {
73
+ out += el("text", { class: "ky-text ky-pl-nm", x, y: cy + size * 0.36, "font-size": size }, esc(row.name));
74
+ x += widths.name + GAP;
75
+ }
76
+ if (widths.abbr > 0) {
77
+ out += el("text", { class: "ky-text ky-pl-ab", x, y: cy + SMALL * 0.36, "font-size": SMALL }, esc(row.abbr));
78
+ x += widths.abbr + GAP;
79
+ }
80
+ if (widths.seed > 0) {
81
+ out += el("text", { class: "ky-text ky-pl-ab", x, y: cy + SMALL * 0.36, "font-size": SMALL * 0.9 }, esc(row.seed ?? ""));
82
+ x += widths.seed + GAP;
83
+ }
84
+ out += disc(o, x1 - GAP - DISC, cy, String(row.lane), "ky-pl-ld");
85
+ return el("g", { class: "ky-pl-row", "data-lane": row.lane }, out);
86
+ }
87
+ /** How far from the pool's end wall the plates stand, in feet: past the blocks. */
88
+ function labelEdge() {
89
+ return feet(1.6) + 1.2;
90
+ }
91
+ function plateWidth(plan, widths) {
92
+ const show = plan.race?.show;
93
+ let w = GAP + DISC * 2 + GAP;
94
+ if (show?.flags && widths.flag > 0)
95
+ w += widths.flag + GAP;
96
+ if (show?.names)
97
+ w += widths.name + GAP;
98
+ if (widths.abbr > 0)
99
+ w += widths.abbr + GAP;
100
+ if (widths.seed > 0)
101
+ w += widths.seed + GAP;
102
+ return w;
103
+ }
104
+ /** The text of a row's splits: the cumulative times at 50, 100... as the scoreboard writes them. */
105
+ const splitsText = (row) => row.splits.filter((s) => Number.isFinite(s)).map(formatTime).join(" ");
106
+ /** The plate of a result at the turning end. */
107
+ function resultPlate(plan, o, row, cy, measure, length) {
108
+ const race = plan.race;
109
+ if (race === undefined)
110
+ return "";
111
+ const show = race.show;
112
+ const h = ROW * 0.84;
113
+ const x0 = length + labelEdge();
114
+ const plateW = measure.time + measure.extra + 2 * GAP + (show.medals ? DISC * 2 + GAP : 0);
115
+ let out = el("rect", { class: "ky-pl-pl", x: x0, y: cy - h / 2, width: plateW, height: h, rx: h * 0.18 });
116
+ let x = x0 + GAP;
117
+ const place = row.place;
118
+ if (show.medals && row.status === undefined) {
119
+ if (place !== undefined) {
120
+ const colour = MEDALS[place];
121
+ out += disc(o, x + DISC, cy, String(place), colour === undefined ? "ky-pl-ld" : "ky-pl-mm", colour);
122
+ if (colour !== undefined)
123
+ out += el("circle", { class: "ky-pl-mr", cx: x + DISC, cy, r: DISC - 0.35 });
124
+ }
125
+ x += DISC * 2 + GAP;
126
+ }
127
+ else if (show.medals)
128
+ x += DISC * 2 + GAP;
129
+ const time = row.status !== undefined ? STATUS_TEXT[row.status] : (row.timeText ?? "");
130
+ const two = measure.lines === 2;
131
+ const topY = two ? cy - ROW * 0.12 : cy;
132
+ out += el("text", { class: "ky-text ky-pl-tm", x, y: topY + NAME * 0.36, "font-size": NAME }, esc(time));
133
+ let tail = x + tw(time, NAME) + GAP * 1.6;
134
+ if (row.record !== undefined) {
135
+ out += el("text", { class: "ky-text ky-pl-rc", x: tail, y: topY + SMALL * 0.36, "font-size": SMALL * 0.9 }, esc(row.record));
136
+ tail += tw(row.record, SMALL * 0.9) + GAP * 1.2;
137
+ }
138
+ if (show.reaction && row.reaction !== undefined)
139
+ out += el("text", { class: "ky-text ky-pl-ab", x: tail, y: topY + SMALL * 0.36, "font-size": SMALL * 0.9 }, esc(`RT ${row.reaction.toFixed(2)}`));
140
+ if (two)
141
+ out += el("text", { class: "ky-text ky-pl-sp", x, y: cy + ROW * 0.22 + SMALL * 0.3, "font-size": SMALL * 0.9 }, esc(splitsText(row)));
142
+ return el("g", { class: "ky-pl-row", "data-lane": row.lane }, out);
143
+ }
144
+ /** The pace bars of the race: each split a bar along its share of the lane, in the colour of its pace. */
145
+ function paceBars(plan, o) {
146
+ const race = plan.race;
147
+ if (race === undefined || race.pace === null || race.fastest === undefined || race.slowest === undefined)
148
+ return "";
149
+ const L = feet(plan.length);
150
+ const lanes = new Map(plan.lanes.map((lane) => [lane.number, lane]));
151
+ const span = race.slowest - race.fastest;
152
+ let out = "";
153
+ for (const row of race.rows) {
154
+ const lane = lanes.get(row.lane);
155
+ if (lane === undefined)
156
+ continue;
157
+ const cy = feet(lane.y);
158
+ const h = ROW * 0.5;
159
+ row.splits.forEach((cum, i) => {
160
+ const from = i === 0 ? 0 : row.splits[i - 1];
161
+ if (!Number.isFinite(cum) || !Number.isFinite(from))
162
+ return;
163
+ const metres = splitMetres(i, race.splitEvery, race.distance);
164
+ const pace = pacePer100(from, cum, metres);
165
+ const colour = paceColour(span <= 1e-9 ? 0.5 : 1 - (pace - race.fastest) / span);
166
+ const x0 = ((i * race.splitEvery) / race.distance) * L;
167
+ const x1 = Math.min(L, x0 + (metres / race.distance) * L);
168
+ const gap = 0.35;
169
+ out += el("rect", { class: "ky-pl-pb", x: x0 + gap, y: cy - h / 2, width: Math.max(0.1, x1 - x0 - 2 * gap), height: h, rx: 0.7, fill: colour, "data-pace": num(pace) });
170
+ if (race.pace?.labels === true) {
171
+ const text = formatTime(cum - from);
172
+ const size = Math.min(2.1, ((x1 - x0 - 2 * gap) * 0.9) / Math.max(1, tw(text, 1)), h * 0.5);
173
+ if (size >= 0.9)
174
+ out += el("text", { class: `ky-text ky-pl-pt ky-pl-pt-${inkFor(colour) === "#ffffff" ? "l" : "d"}`, x: (x0 + x1) / 2, y: cy + size * 0.36, "font-size": size, "text-anchor": "middle" }, esc(text));
175
+ }
176
+ });
177
+ }
178
+ return el("g", { class: ft(o, "pace-bars", "ky-pl-pace") }, out);
179
+ }
180
+ /** The distances above the pool for the pace bars: where each split ends, in metres of the race. */
181
+ function paceAxis(plan) {
182
+ const race = plan.race;
183
+ if (race === undefined || race.pace === null)
184
+ return "";
185
+ const L = feet(plan.length);
186
+ let out = "";
187
+ const count = Math.max(1, Math.round(race.distance / race.splitEvery));
188
+ for (let i = 0; i <= count; i += 1) {
189
+ const d = Math.min(race.distance, i * race.splitEvery);
190
+ const x = (d / race.distance) * L;
191
+ out += el("text", { class: "ky-text ky-pl-ax", x, y: -feet(0.85), "font-size": 2, "text-anchor": i === 0 ? "start" : i === count ? "end" : "middle" }, `${num(d)} m`);
192
+ out += el("path", { class: "ky-line ky-pl-tick", d: `M${num(x)} ${num(-feet(0.5))}V${num(-feet(0.1))}`, "stroke-width": 0.25 });
193
+ }
194
+ return out;
195
+ }
196
+ /** Everything the data of the race draws. */
197
+ export function raceArt(plan, o) {
198
+ const race = plan.race;
199
+ const none = { left: "", right: "", top: "", pace: "", reachLeft: 0, reachRight: 0, reachTop: 0 };
200
+ if (race === undefined)
201
+ return none;
202
+ const show = race.show;
203
+ const lanes = new Map(plan.lanes.map((lane) => [lane.number, lane]));
204
+ const length = feet(plan.length);
205
+ // The widths of the columns: the longest name fitted into a plate at most this wide (the size shrinks for it).
206
+ const longest = Math.max(0, ...race.rows.map((r) => tw(r.name, NAME)));
207
+ const nameMax = 44;
208
+ const size = longest > nameMax ? NAME * (nameMax / longest) : NAME;
209
+ const widths = {
210
+ name: show.names ? Math.min(nameMax, Math.max(14, longest)) : 0,
211
+ abbr: show.names ? Math.max(0, ...race.rows.map((r) => tw(r.abbr, SMALL))) : 0,
212
+ seed: show.seeds && race.rows.some((r) => r.seed !== undefined) ? Math.max(...race.rows.map((r) => tw(r.seed ?? "", SMALL * 0.9))) : 0,
213
+ flag: show.flags && race.rows.some((r) => safeLogo(r.flag) !== undefined) ? 4.5 : 0,
214
+ };
215
+ let left = "";
216
+ let reachLeft = 0;
217
+ if (race.rows.length > 0 && (show.names || show.flags)) {
218
+ for (const row of race.rows) {
219
+ const lane = lanes.get(row.lane);
220
+ if (lane !== undefined)
221
+ left += namePlate(plan, o, row, feet(lane.y), widths, size);
222
+ }
223
+ reachLeft = labelEdge() + plateWidth(plan, widths);
224
+ }
225
+ let right = "";
226
+ let reachRight = 0;
227
+ if (race.rows.length > 0 && show.results && race.hasResults) {
228
+ const splits = show.splits && race.rows.some((r) => r.splits.length > 0 && r.splits.length <= MOST_SPLITS);
229
+ const lines = splits ? 2 : 1;
230
+ const times = Math.max(...race.rows.map((r) => tw(r.status !== undefined ? STATUS_TEXT[r.status] : (r.timeText ?? ""), NAME)));
231
+ const extras = Math.max(...race.rows.map((r) => {
232
+ const head = GAP * 0.6 + (r.record === undefined ? 0 : tw(r.record, SMALL * 0.9) + GAP * 1.2) + (show.reaction && r.reaction !== undefined ? tw(`RT ${r.reaction.toFixed(2)}`, SMALL * 0.9) + GAP : 0);
233
+ const splitW = splits && r.splits.length <= MOST_SPLITS ? tw(splitsText(r), SMALL * 0.9) - times : 0;
234
+ return Math.max(head + GAP, splitW);
235
+ }));
236
+ const measure = { time: times, extra: Math.max(0, extras), lines };
237
+ for (const row of race.rows) {
238
+ const lane = lanes.get(row.lane);
239
+ if (lane === undefined)
240
+ continue;
241
+ const rowSplits = splits && row.splits.length > 0 && row.splits.length <= MOST_SPLITS;
242
+ right += resultPlate(plan, o, row, feet(lane.y), { ...measure, lines: rowSplits ? 2 : 1 }, length);
243
+ }
244
+ reachRight = labelEdge() + measure.time + measure.extra + 2 * GAP + (show.medals ? DISC * 2 + GAP : 0);
245
+ }
246
+ let top = "";
247
+ let reachTop = 0;
248
+ if (race.event !== undefined) {
249
+ top += el("text", { class: "ky-text ky-pl-ev", x: -reachLeft, y: race.pace === null ? -feet(1.2) : -feet(2.5), "font-size": 3.1 }, esc(race.event));
250
+ }
251
+ const pace = race.pace === null ? "" : paceBars(plan, o);
252
+ if (race.pace !== null) {
253
+ top += paceAxis(plan);
254
+ reachTop = feet(race.event === undefined ? 1.6 : 3.1);
255
+ }
256
+ return { left, right, top, pace, reachLeft, reachRight, reachTop };
257
+ }
@@ -0,0 +1,65 @@
1
+ import type { PaceOptions, PoolRace, RaceShow, Swimmer, SwimmerStatus } from "./pool.types.ts";
2
+ /** A time in seconds from a number or from text: `26.12`, `"26.12"`, `"1:46.91"`. `undefined` when it is neither. */
3
+ export declare function parseTime(value: unknown): number | undefined;
4
+ /**
5
+ * A time in seconds as swimming writes it, to the hundredth: `26.12`, `1:46.91`, `14:31.02`. A time that rounds to the next minute carries into it.
6
+ * @example
7
+ * ```ts
8
+ * import { formatTime } from "@johnmorrisdotca/kyogijo-stadiums/replay";
9
+ * console.log(formatTime(26.121), formatTime(106.9), formatTime(59.996));
10
+ * // 26.12 1:46.90 1:00.00
11
+ * ```
12
+ */
13
+ export declare function formatTime(seconds: number): string;
14
+ /** One lane of a race as it is drawn. */
15
+ export interface RaceRow {
16
+ swimmer: Swimmer;
17
+ lane: number;
18
+ name: string;
19
+ abbr: string;
20
+ flag: string | undefined;
21
+ color: string | undefined;
22
+ seed: string | undefined;
23
+ /** The final time in seconds and as it is written (a time of text that cannot be read is written as it is). */
24
+ time: number | undefined;
25
+ timeText: string | undefined;
26
+ place: number | undefined;
27
+ reaction: number | undefined;
28
+ /** The cumulative times at each split, in seconds; `NaN` for one the data lacks. */
29
+ splits: number[];
30
+ status: SwimmerStatus | undefined;
31
+ record: string | undefined;
32
+ }
33
+ /** A race, checked, with what is shown and the pace of the field. */
34
+ export interface RacePlan {
35
+ event: string | undefined;
36
+ /** The metres of the race. */
37
+ distance: number;
38
+ /** The metres between two splits. */
39
+ splitEvery: number;
40
+ rows: RaceRow[];
41
+ show: Required<RaceShow>;
42
+ pace: Required<PaceOptions> | null;
43
+ /** The seconds per 100 m of the fastest and the slowest split of the field, for the colour of the pace bars. */
44
+ fastest: number | undefined;
45
+ slowest: number | undefined;
46
+ /** True when any swimmer has a time, a status or a place. */
47
+ hasResults: boolean;
48
+ hasSplits: boolean;
49
+ hasReaction: boolean;
50
+ }
51
+ /** The seconds per 100 m of the split from `from` to `to` seconds over `metres`. */
52
+ export declare const pacePer100: (from: number, to: number, metres: number) => number;
53
+ /**
54
+ * Read and check a race for a pool whose lanes are numbered `lanes`, `splitEvery` metres apart by default and `length` metres long.
55
+ * `lanes` are the pool's lane numbers in the order they lie down the drawing, which is the order the rows are given in. A swimmer with no `time` whose splits reach the whole race has the last split for it.
56
+ * @throws RangeError for a lane the pool does not have, or two swimmers in one lane. @throws TypeError when `swimmers` is not a list.
57
+ */
58
+ export declare function racePlan(race: PoolRace, lanes: readonly number[], defaults: {
59
+ splitEvery: number;
60
+ length: number;
61
+ }): RacePlan;
62
+ /** The metres of the split with this index (from 0): `every`, except the last, which is whatever is left of the race. */
63
+ export declare function splitMetres(index: number, every: number, distance: number): number;
64
+ /** The status written in place of a time. */
65
+ export declare const STATUS_TEXT: Readonly<Record<SwimmerStatus, string>>;
@@ -0,0 +1,140 @@
1
+ /** A time in seconds from a number or from text: `26.12`, `"26.12"`, `"1:46.91"`. `undefined` when it is neither. */
2
+ export function parseTime(value) {
3
+ if (typeof value === "number")
4
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
5
+ if (typeof value !== "string")
6
+ return undefined;
7
+ const text = value.trim();
8
+ const clock = /^(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/.exec(text);
9
+ if (clock !== null)
10
+ return Number(clock[1]) * 60 + Number(clock[2]) + (clock[3] === undefined ? 0 : Number(`0.${clock[3]}`));
11
+ const plain = /^(\d{1,4})(?:[.,](\d{1,3}))?$/.exec(text);
12
+ return plain === null ? undefined : Number(plain[1]) + (plain[2] === undefined ? 0 : Number(`0.${plain[2]}`));
13
+ }
14
+ /**
15
+ * A time in seconds as swimming writes it, to the hundredth: `26.12`, `1:46.91`, `14:31.02`. A time that rounds to the next minute carries into it.
16
+ * @example
17
+ * ```ts
18
+ * import { formatTime } from "@johnmorrisdotca/kyogijo-stadiums/replay";
19
+ * console.log(formatTime(26.121), formatTime(106.9), formatTime(59.996));
20
+ * // 26.12 1:46.90 1:00.00
21
+ * ```
22
+ */
23
+ export function formatTime(seconds) {
24
+ const hundredths = Math.round(seconds * 100);
25
+ const minutes = Math.floor(hundredths / 6000);
26
+ const rest = hundredths - minutes * 6000;
27
+ const whole = Math.floor(rest / 100);
28
+ const part = String(rest - whole * 100).padStart(2, "0");
29
+ return minutes === 0 ? `${whole}.${part}` : `${minutes}:${String(whole).padStart(2, "0")}.${part}`;
30
+ }
31
+ const STATUSES = ["dq", "dns", "dnf"];
32
+ const clean = (text, max) => {
33
+ if (typeof text !== "string")
34
+ return undefined;
35
+ const one = text.replace(/\s+/g, " ").trim();
36
+ return one === "" ? undefined : one.slice(0, max);
37
+ };
38
+ const SHOW = { names: true, flags: true, results: true, reaction: true, splits: true, medals: true, seeds: true };
39
+ /** The seconds per 100 m of the split from `from` to `to` seconds over `metres`. */
40
+ export const pacePer100 = (from, to, metres) => ((to - from) * 100) / metres;
41
+ /**
42
+ * Read and check a race for a pool whose lanes are numbered `lanes`, `splitEvery` metres apart by default and `length` metres long.
43
+ * `lanes` are the pool's lane numbers in the order they lie down the drawing, which is the order the rows are given in. A swimmer with no `time` whose splits reach the whole race has the last split for it.
44
+ * @throws RangeError for a lane the pool does not have, or two swimmers in one lane. @throws TypeError when `swimmers` is not a list.
45
+ */
46
+ export function racePlan(race, lanes, defaults) {
47
+ if (typeof race !== "object" || race === null || !Array.isArray(race.swimmers))
48
+ throw new TypeError("a race needs swimmers: a list of { lane, name, ... }");
49
+ const seen = new Set();
50
+ const rows = [];
51
+ for (const swimmer of race.swimmers) {
52
+ if (typeof swimmer !== "object" || swimmer === null)
53
+ throw new TypeError("a swimmer is { lane, name, ... }");
54
+ const lane = swimmer.lane;
55
+ if (!Number.isInteger(lane) || !lanes.includes(lane))
56
+ throw new RangeError(`This pool has no lane ${String(lane)}: its lanes are ${Math.min(...lanes)} to ${Math.max(...lanes)}`);
57
+ if (seen.has(lane))
58
+ throw new RangeError(`Lane ${lane} has two swimmers`);
59
+ seen.add(lane);
60
+ const timeText = typeof swimmer.time === "number" ? (Number.isFinite(swimmer.time) ? formatTime(swimmer.time) : undefined) : clean(swimmer.time, 12);
61
+ const time = parseTime(swimmer.time);
62
+ const splits = Array.isArray(swimmer.splits) ? swimmer.splits.map((value) => parseTime(value) ?? Number.NaN) : [];
63
+ rows.push({
64
+ swimmer,
65
+ lane,
66
+ name: clean(swimmer.name, 40) ?? "",
67
+ abbr: (clean(swimmer.abbr, 5) ?? "").toLocaleUpperCase("en"),
68
+ flag: typeof swimmer.flag === "string" ? swimmer.flag : undefined,
69
+ color: typeof swimmer.color === "string" ? swimmer.color : undefined,
70
+ seed: typeof swimmer.seed === "number" ? (Number.isFinite(swimmer.seed) ? formatTime(swimmer.seed) : undefined) : clean(swimmer.seed, 12),
71
+ time,
72
+ timeText: time !== undefined ? formatTime(time) : timeText,
73
+ place: Number.isInteger(swimmer.place) && swimmer.place > 0 ? swimmer.place : undefined,
74
+ reaction: typeof swimmer.reaction === "number" && Number.isFinite(swimmer.reaction) && swimmer.reaction >= 0 ? swimmer.reaction : undefined,
75
+ splits,
76
+ status: STATUSES.includes(swimmer.status) ? swimmer.status : undefined,
77
+ record: clean(swimmer.record, 6)?.toLocaleUpperCase("en"),
78
+ });
79
+ }
80
+ // The rows run down the page in the order of the lanes as the pool is drawn.
81
+ rows.sort((a, b) => lanes.indexOf(a.lane) - lanes.indexOf(b.lane));
82
+ const every = Number.isFinite(race.splitEvery) && race.splitEvery > 0 ? race.splitEvery : defaults.splitEvery;
83
+ const most = Math.max(0, ...rows.map((r) => r.splits.length));
84
+ const distance = Number.isFinite(race.distance) && race.distance > 0 ? race.distance : most > 0 ? most * every : defaults.length;
85
+ // The last split is the time of a swimmer whose splits reach the end of the race.
86
+ for (const r of rows) {
87
+ if (r.time !== undefined || r.status !== undefined || r.splits.length === 0)
88
+ continue;
89
+ const last = r.splits[r.splits.length - 1];
90
+ if (Number.isFinite(last) && r.splits.length * every >= distance - 1e-6) {
91
+ r.time = last;
92
+ r.timeText = formatTime(last);
93
+ }
94
+ }
95
+ // Places from the times, when no one has been given one.
96
+ if (rows.every((r) => r.place === undefined)) {
97
+ const timed = rows.filter((r) => r.time !== undefined && r.status === undefined).sort((a, b) => a.time - b.time);
98
+ let place = 0;
99
+ let last;
100
+ timed.forEach((r, i) => {
101
+ if (r.time !== last)
102
+ place = i + 1;
103
+ last = r.time;
104
+ r.place = place;
105
+ });
106
+ }
107
+ const hasResults = rows.some((r) => r.timeText !== undefined || r.status !== undefined || r.place !== undefined);
108
+ // The entry times are for a start list: once there are results they are left out, unless the race asks for them.
109
+ const show = { ...SHOW, seeds: !hasResults, ...Object.fromEntries(Object.entries(race.show ?? {}).filter(([, value]) => typeof value === "boolean")) };
110
+ const pace = race.pace === undefined || race.pace === false ? null : { labels: race.pace === true || race.pace.labels !== false };
111
+ // The pace of each split: from the one before it (0 at the start) over the metres between them, the last split over what is left of the race.
112
+ const paces = [];
113
+ for (const r of rows) {
114
+ r.splits.forEach((cum, i) => {
115
+ const from = i === 0 ? 0 : r.splits[i - 1];
116
+ if (Number.isFinite(cum) && Number.isFinite(from))
117
+ paces.push(pacePer100(from, cum, splitMetres(i, every, distance)));
118
+ });
119
+ }
120
+ return {
121
+ event: clean(race.event, 80),
122
+ distance,
123
+ splitEvery: every,
124
+ rows,
125
+ show,
126
+ pace,
127
+ fastest: paces.length === 0 ? undefined : Math.min(...paces),
128
+ slowest: paces.length === 0 ? undefined : Math.max(...paces),
129
+ hasResults,
130
+ hasSplits: most > 0,
131
+ hasReaction: rows.some((r) => r.reaction !== undefined),
132
+ };
133
+ }
134
+ /** The metres of the split with this index (from 0): `every`, except the last, which is whatever is left of the race. */
135
+ export function splitMetres(index, every, distance) {
136
+ const start = index * every;
137
+ return Math.max(1e-6, Math.min(every, distance - start));
138
+ }
139
+ /** The status written in place of a time. */
140
+ export const STATUS_TEXT = Object.freeze({ dq: "DQ", dns: "DNS", dnf: "DNF" });
@@ -0,0 +1,46 @@
1
+ import { type RaceRow } from "./pool.race.ts";
2
+ import type { ReplayEvent, ReplayTimeline } from "./replay.types.ts";
3
+ import type { Language, SurfaceOptions } from "./types.ts";
4
+ /** What a race replay is made with. */
5
+ export interface RaceReplayOptions {
6
+ /** Frames a second. 10 by default. */
7
+ fps?: number;
8
+ /** Seconds of waiting before the start signal and of staying on after the last touch. 1.5 and 3 by default. */
9
+ lead?: number;
10
+ tail?: number;
11
+ /** The language of the events' words. English by default. */
12
+ language?: Language;
13
+ /** The reaction time of a swimmer that has none, in seconds. 0.7 by default. */
14
+ reaction?: number;
15
+ /** How far behind the wall a swimmer on the block stands, in metres. 0.4 by default. */
16
+ block?: number;
17
+ }
18
+ /** The marks of a swimmer's progress: the seconds after the start signal at which the swimmer has swum each distance, from the splits (and the final time). */
19
+ export interface Progress {
20
+ /** Distance in metres against the seconds after the signal; the first point is the reaction time at 0 m. */
21
+ points: [number, number][];
22
+ /** Whether the swimmer is a swimmer in the water at all (a swimmer who did not start stays on the block). */
23
+ swims: boolean;
24
+ }
25
+ /** The progress of one row over a race of `distance` metres with a split every `every` metres. */
26
+ export declare function progressOf(row: RaceRow, distance: number, every: number, reaction: number): Progress;
27
+ /** The metres a swimmer has swum `t` seconds after the signal, by straight lines between the points of progress; before the reaction it is 0, after the last it stays. */
28
+ export declare function distanceAt(progress: Progress, t: number): number;
29
+ /** The place along the pool, in metres from the starting wall, of a swimmer who has swum `d` metres in a pool of `length`: down the first length, back the next. */
30
+ export declare function alongPool(d: number, length: number): number;
31
+ /**
32
+ * The replay of a race by lane. Give the options of a swimming drawing (`{ pool: { race } }`, with a `course` and `lanes` if they are not the 50 m pool's of eight): each swimmer with splits is played back from them.
33
+ * The timeline is in the pool's frame (`from: "swimming"`) in metres; its events are the turns at the walls and the finish, each with the swimmer's name and, at the finish, the time.
34
+ * @throws TypeError when the options have no race, RangeError for a race with no swimmer that has splits or a time.
35
+ * @example
36
+ * ```ts
37
+ * import { raceTimeline, drawFrame } from "@johnmorrisdotca/kyogijo-stadiums/replay";
38
+ * const pool = { race: { distance: 100, swimmers: [{ lane: 4, name: "A. Swimmer", splits: [26, 54.2] }, { lane: 5, name: "B. Swimmer", splits: [26.4, 55] }] } };
39
+ * const timeline = raceTimeline({ pool });
40
+ * console.log(timeline.frames.length > 100, timeline.events.map((e) => e.kind).join(), drawFrame({ sport: "swimming", pool }, timeline, 20).startsWith("<svg"));
41
+ * // true start,turn,turn,finish,finish true
42
+ * ```
43
+ */
44
+ export declare function raceTimeline(options: Pick<SurfaceOptions, "pool">, replay?: RaceReplayOptions): ReplayTimeline & {
45
+ events: ReplayEvent[];
46
+ };
@@ -0,0 +1,127 @@
1
+ // A race replay by lane: the swimmers of a race move down their lanes and back as their splits say, as a timeline of the package's replay (`drawFrame`, `mountKyogijo(...).replay`, `<kyogijo-field>`). Each
2
+ // swimmer is a mark with the lane's number on it, in the frame of the pool (`swimming`: metres from the middle of the water, x towards the turning end, y towards the top of the drawing, so that lane 1 is below
3
+ // lane 8). Between two splits a swimmer goes at the speed of the split, in a straight line; before the reaction time the swimmer is on the block. The wall is touched at the turns, and the field's order is the order
4
+ // of the marks. Nothing here is a model of swimming: it is the splits the caller has, played back.
5
+ import { swimPlan } from "./pool.js";
6
+ import { formatTime } from "./pool.race.js";
7
+ import { KYOGIJO_STRINGS } from "./strings.js";
8
+ const finite = (value) => Number.isFinite(value);
9
+ /** The progress of one row over a race of `distance` metres with a split every `every` metres. */
10
+ export function progressOf(row, distance, every, reaction) {
11
+ const start = row.reaction ?? reaction;
12
+ if (row.status === "dns")
13
+ return { points: [[0, 0]], swims: false };
14
+ const points = [[start, 0]];
15
+ let last = start;
16
+ row.splits.forEach((cum, i) => {
17
+ if (!finite(cum))
18
+ return;
19
+ const at = Math.min(distance, (i + 1) * every);
20
+ if (cum <= last)
21
+ return;
22
+ // A split is the distance the swimmer has swum when the clock reads it: the last one is the whole race.
23
+ points.push([cum, i === row.splits.length - 1 && row.time !== undefined && Math.abs(cum - row.time) < 1e-6 ? distance : at]);
24
+ last = cum;
25
+ });
26
+ const end = points[points.length - 1];
27
+ // A time without all its splits: the rest is swum at one speed up to it.
28
+ if (row.time !== undefined && row.time > end[0] && row.status !== "dnf" && end[1] < distance)
29
+ points.push([row.time, distance]);
30
+ return { points, swims: points.length > 1 };
31
+ }
32
+ /** The metres a swimmer has swum `t` seconds after the signal, by straight lines between the points of progress; before the reaction it is 0, after the last it stays. */
33
+ export function distanceAt(progress, t) {
34
+ const p = progress.points;
35
+ if (t <= p[0][0])
36
+ return 0;
37
+ for (let i = 1; i < p.length; i += 1) {
38
+ if (t <= p[i][0]) {
39
+ const [t0, d0] = p[i - 1];
40
+ const [t1, d1] = p[i];
41
+ return d0 + ((t - t0) / (t1 - t0)) * (d1 - d0);
42
+ }
43
+ }
44
+ return p[p.length - 1][1];
45
+ }
46
+ /** The place along the pool, in metres from the starting wall, of a swimmer who has swum `d` metres in a pool of `length`: down the first length, back the next. */
47
+ export function alongPool(d, length) {
48
+ const lap = Math.floor(d / length + 1e-9);
49
+ const rest = d - lap * length;
50
+ return lap % 2 === 0 ? rest : length - rest;
51
+ }
52
+ /**
53
+ * The seconds after the signal at which a swimmer has swum `d` metres: the inverse of `distanceAt` over the progress, for the turns.
54
+ */
55
+ function timeAt(progress, d) {
56
+ const p = progress.points;
57
+ for (let i = 1; i < p.length; i += 1) {
58
+ if (d <= p[i][1] + 1e-9 && p[i][1] > p[i - 1][1]) {
59
+ const [t0, d0] = p[i - 1];
60
+ const [t1, d1] = p[i];
61
+ return t0 + ((d - d0) / (d1 - d0)) * (t1 - t0);
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * The replay of a race by lane. Give the options of a swimming drawing (`{ pool: { race } }`, with a `course` and `lanes` if they are not the 50 m pool's of eight): each swimmer with splits is played back from them.
68
+ * The timeline is in the pool's frame (`from: "swimming"`) in metres; its events are the turns at the walls and the finish, each with the swimmer's name and, at the finish, the time.
69
+ * @throws TypeError when the options have no race, RangeError for a race with no swimmer that has splits or a time.
70
+ * @example
71
+ * ```ts
72
+ * import { raceTimeline, drawFrame } from "@johnmorrisdotca/kyogijo-stadiums/replay";
73
+ * const pool = { race: { distance: 100, swimmers: [{ lane: 4, name: "A. Swimmer", splits: [26, 54.2] }, { lane: 5, name: "B. Swimmer", splits: [26.4, 55] }] } };
74
+ * const timeline = raceTimeline({ pool });
75
+ * console.log(timeline.frames.length > 100, timeline.events.map((e) => e.kind).join(), drawFrame({ sport: "swimming", pool }, timeline, 20).startsWith("<svg"));
76
+ * // true start,turn,turn,finish,finish true
77
+ * ```
78
+ */
79
+ export function raceTimeline(options, replay = {}) {
80
+ const plan = swimPlan({ sport: "swimming", pool: options.pool });
81
+ const race = plan.race;
82
+ if (race === undefined)
83
+ throw new TypeError("a race replay needs a race: { pool: { race: { swimmers: [...] } } }");
84
+ const fps = typeof replay.fps === "number" && replay.fps >= 1 && replay.fps <= 60 ? replay.fps : 10;
85
+ const lead = typeof replay.lead === "number" && replay.lead >= 0 ? replay.lead : 1.5;
86
+ const tail = typeof replay.tail === "number" && replay.tail >= 0 ? replay.tail : 3;
87
+ const reaction = typeof replay.reaction === "number" && replay.reaction >= 0 ? replay.reaction : 0.7;
88
+ const block = typeof replay.block === "number" && replay.block >= 0 ? replay.block : 0.4;
89
+ const words = KYOGIJO_STRINGS[replay.language === "ja" ? "ja" : "en"].pool.chart;
90
+ const lanes = new Map(plan.lanes.map((lane) => [lane.number, lane]));
91
+ const L = plan.length;
92
+ const rows = race.rows.map((row) => ({ row, progress: progressOf(row, race.distance, race.splitEvery, reaction) }));
93
+ if (!rows.some((r) => r.progress.swims))
94
+ throw new RangeError("a race replay needs a swimmer with splits or a time");
95
+ const end = Math.max(...rows.map((r) => r.progress.points[r.progress.points.length - 1][0]));
96
+ const total = lead + end + tail;
97
+ const count = Math.ceil(total * fps);
98
+ const objectAt = (r, t) => {
99
+ const lane = lanes.get(r.row.lane);
100
+ const d = distanceAt(r.progress, t - lead);
101
+ const onBlock = t - lead < r.progress.points[0][0];
102
+ const x = onBlock ? -block : alongPool(d, L);
103
+ return { id: `lane-${r.row.lane}`, team: "home", x: Math.round((x - L / 2) * 1000) / 1000, y: Math.round((plan.width / 2 - lane.y) * 1000) / 1000, label: r.row.name, number: r.row.lane };
104
+ };
105
+ const frames = [];
106
+ for (let i = 0; i <= count; i += 1) {
107
+ const t = Math.round((i / fps) * 1000) / 1000;
108
+ frames.push({ t, objects: rows.map((r) => objectAt(r, t)) });
109
+ }
110
+ const events = [{ t: lead, kind: "start", label: words.start }];
111
+ for (const r of rows) {
112
+ if (!r.progress.swims)
113
+ continue;
114
+ const id = `lane-${r.row.lane}`;
115
+ // A turn at each wall the swimmer touches before the finish.
116
+ for (let wall = 1; wall * L < race.distance - 1e-6; wall += 1) {
117
+ const at = timeAt(r.progress, wall * L);
118
+ if (at !== undefined)
119
+ events.push({ t: Math.round((lead + at) * 1000) / 1000, kind: "turn", by: id, label: `${r.row.name}, ${words.turn.toLowerCase()}` });
120
+ }
121
+ const last = r.progress.points[r.progress.points.length - 1];
122
+ if (last[1] >= race.distance - 1e-6)
123
+ events.push({ t: Math.round((lead + last[0]) * 1000) / 1000, kind: "finish", by: id, label: `${r.row.name}, ${words.finish.toLowerCase()} ${r.row.timeText ?? formatTime(last[0])}` });
124
+ }
125
+ events.sort((a, b) => a.t - b.t);
126
+ return { frames, events, from: "swimming", units: "m" };
127
+ }
@@ -0,0 +1,13 @@
1
+ import type { SwimPlan } from "./pool.ts";
2
+ import { type BuildOptions } from "./surfaces/surface.ts";
3
+ /** The section: its markup and how far it reaches below `y0` (feet). */
4
+ export interface Section {
5
+ markup: string;
6
+ height: number;
7
+ /** The id of the gradient its water uses, to define in the drawing's defs. */
8
+ defs: string;
9
+ }
10
+ /**
11
+ * The section of a pool whose water's surface is at `top` feet (in the drawing's frame, below the plan), for a pool that is `plan.length` long, aligned with the plan along x.
12
+ */
13
+ export declare function sectionOf(plan: SwimPlan, o: Pick<BuildOptions, "portrait" | "features" | "exact" | "detail" | "id">, top: number): Section;