@johnmorrisdotca/kyogijo-stadiums 0.4.0 → 0.6.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/CHANGELOG.md +133 -0
- package/NOTICE.md +182 -2
- package/README.md +106 -19
- package/dist/annotate.js +1 -1
- package/dist/arenas-entry.d.ts +4 -0
- package/dist/arenas-entry.js +3 -0
- package/dist/arenas.d.ts +125 -0
- package/dist/arenas.data.d.ts +2 -0
- package/dist/arenas.data.js +3763 -0
- package/dist/arenas.js +72 -0
- package/dist/athletics/describe.d.ts +8 -0
- package/dist/athletics/describe.js +41 -0
- package/dist/athletics/events.d.ts +17 -0
- package/dist/athletics/events.js +48 -0
- package/dist/athletics/facilities.d.ts +80 -0
- package/dist/athletics/facilities.js +176 -0
- package/dist/athletics/infield.d.ts +79 -0
- package/dist/athletics/infield.js +166 -0
- package/dist/athletics/layout.d.ts +13 -0
- package/dist/athletics/layout.js +98 -0
- package/dist/athletics/oval.d.ts +57 -0
- package/dist/athletics/oval.js +116 -0
- package/dist/athletics/plan.d.ts +97 -0
- package/dist/athletics/plan.js +355 -0
- package/dist/athletics/races.d.ts +102 -0
- package/dist/athletics/races.js +355 -0
- package/dist/athletics/steeplechase.d.ts +72 -0
- package/dist/athletics/steeplechase.js +196 -0
- package/dist/athletics/stencil.d.ts +13 -0
- package/dist/athletics/stencil.js +85 -0
- package/dist/athletics/track.d.ts +54 -0
- package/dist/athletics/track.js +105 -0
- package/dist/athletics/types.d.ts +77 -0
- package/dist/athletics/types.js +1 -0
- package/dist/athletics-entry.d.ts +17 -0
- package/dist/athletics-entry.js +11 -0
- package/dist/athletics.style.d.ts +16 -0
- package/dist/athletics.style.js +51 -0
- package/dist/atlas-entry.d.ts +6 -2
- package/dist/atlas-entry.js +4 -2
- package/dist/atlas.d.ts +117 -23
- package/dist/atlas.js +197 -12
- package/dist/background.js +2 -1
- package/dist/budo/describe.d.ts +13 -0
- package/dist/budo/describe.js +120 -0
- package/dist/budo/dojo.api.d.ts +64 -0
- package/dist/budo/dojo.api.js +46 -0
- package/dist/budo/dojo.d.ts +72 -0
- package/dist/budo/dojo.js +300 -0
- package/dist/budo/judo.d.ts +61 -0
- package/dist/budo/judo.js +109 -0
- package/dist/budo/karate.d.ts +29 -0
- package/dist/budo/karate.js +80 -0
- package/dist/budo/kendo.d.ts +18 -0
- package/dist/budo/kendo.js +44 -0
- package/dist/budo/kimarite.d.ts +19 -0
- package/dist/budo/kimarite.js +33 -0
- package/dist/budo/kit.d.ts +16 -0
- package/dist/budo/kit.js +24 -0
- package/dist/budo/kyudo.d.ts +28 -0
- package/dist/budo/kyudo.js +66 -0
- package/dist/budo/looks.d.ts +28 -0
- package/dist/budo/looks.js +58 -0
- package/dist/budo/parts.d.ts +32 -0
- package/dist/budo/parts.js +10 -0
- package/dist/budo/plan.d.ts +64 -0
- package/dist/budo/plan.js +139 -0
- package/dist/budo/room.d.ts +133 -0
- package/dist/budo/room.js +111 -0
- package/dist/budo/sumo.d.ts +31 -0
- package/dist/budo/sumo.js +71 -0
- package/dist/budo/tatami.d.ts +55 -0
- package/dist/budo/tatami.js +156 -0
- package/dist/budo/types.d.ts +192 -0
- package/dist/budo/types.js +2 -0
- package/dist/budo-entry.d.ts +22 -0
- package/dist/budo-entry.js +13 -0
- package/dist/budo.board.d.ts +28 -0
- package/dist/budo.board.js +81 -0
- package/dist/budo.score.d.ts +18 -0
- package/dist/budo.score.js +52 -0
- package/dist/budo.style.d.ts +6 -0
- package/dist/budo.style.js +67 -0
- package/dist/charts-entry.d.ts +6 -1
- package/dist/charts-entry.js +3 -0
- package/dist/charts.athletics.d.ts +183 -0
- package/dist/charts.athletics.js +455 -0
- package/dist/charts.baseball.js +1 -1
- package/dist/charts.curling.heat.d.ts +24 -0
- package/dist/charts.curling.heat.js +116 -0
- package/dist/charts.curling.js +14 -3
- package/dist/charts.js +4 -4
- package/dist/charts.pool.d.ts +67 -0
- package/dist/charts.pool.js +76 -0
- package/dist/charts.skating.d.ts +204 -0
- package/dist/charts.skating.js +426 -0
- package/dist/charts.soccer.js +3 -3
- package/dist/charts.types.d.ts +63 -1
- package/dist/colour.d.ts +4 -4
- package/dist/colour.js +4 -4
- package/dist/court.d.ts +85 -2
- package/dist/court.js +102 -7
- package/dist/court.more.d.ts +46 -0
- package/dist/court.more.js +219 -0
- package/dist/courts.style.d.ts +14 -2
- package/dist/courts.style.js +51 -4
- package/dist/crowd.d.ts +164 -0
- package/dist/crowd.draw.d.ts +17 -0
- package/dist/crowd.draw.js +204 -0
- package/dist/crowd.js +480 -0
- package/dist/crowd.park.d.ts +19 -0
- package/dist/crowd.park.js +153 -0
- package/dist/curling.stats.d.ts +271 -0
- package/dist/curling.stats.js +301 -0
- package/dist/draw.d.ts +16 -0
- package/dist/draw.js +462 -54
- package/dist/element-entry.d.ts +3 -1
- package/dist/element-entry.js +2 -1
- package/dist/element.d.ts +13 -2
- package/dist/element.js +79 -4
- package/dist/emblems-source.d.ts +50 -0
- package/dist/emblems-source.js +56 -0
- package/dist/eras.d.ts +3 -1
- package/dist/eras.js +8 -3
- package/dist/features.js +204 -0
- package/dist/field.data.d.ts +16 -0
- package/dist/field.data.js +50 -0
- package/dist/fields.kit.d.ts +2 -2
- package/dist/fields.types.d.ts +24 -4
- package/dist/formations.js +18 -7
- package/dist/formations.sports.d.ts +8 -0
- package/dist/formations.sports.js +251 -0
- package/dist/frames.providers.js +71 -8
- package/dist/geojson.js +3 -0
- package/dist/geometry-entry.d.ts +2 -1
- package/dist/geometry-entry.js +2 -1
- package/dist/geometry.d.ts +20 -0
- package/dist/geometry.js +78 -1
- package/dist/grounds.d.ts +4 -0
- package/dist/grounds.data.js +12 -0
- package/dist/halls.d.ts +65 -0
- package/dist/halls.js +122 -0
- package/dist/hardwood.d.ts +31 -0
- package/dist/hardwood.js +157 -0
- package/dist/heat.js +21 -13
- package/dist/heat.types.d.ts +5 -0
- package/dist/heat.zones.d.ts +4 -2
- package/dist/heat.zones.js +207 -7
- package/dist/homeside.js +24 -0
- package/dist/index.d.ts +30 -4
- package/dist/index.js +18 -2
- package/dist/layers-entry.d.ts +2 -1
- package/dist/layers-entry.js +1 -0
- package/dist/layers.d.ts +2 -1
- package/dist/layers.env.d.ts +12 -1
- package/dist/layers.env.js +36 -4
- package/dist/layers.js +32 -13
- package/dist/layers.types.d.ts +9 -2
- package/dist/leagues/athletics.d.ts +63 -0
- package/dist/leagues/athletics.js +87 -0
- package/dist/leagues/budo.d.ts +196 -0
- package/dist/leagues/budo.js +105 -0
- package/dist/leagues/courts.d.ts +269 -4
- package/dist/leagues/courts.js +155 -1
- package/dist/leagues/hockey.js +41 -19
- package/dist/leagues/skating.d.ts +145 -0
- package/dist/leagues/skating.js +73 -0
- package/dist/leagues/sources.js +41 -0
- package/dist/leagues/types.d.ts +1 -1
- package/dist/leagues-entry.d.ts +4 -2
- package/dist/leagues-entry.js +2 -1
- package/dist/leagues.d.ts +17 -4
- package/dist/leagues.js +246 -5
- package/dist/lineup.codes.d.ts +23 -0
- package/dist/lineup.codes.js +122 -0
- package/dist/logo.d.ts +25 -0
- package/dist/logo.js +47 -0
- package/dist/look.d.ts +3 -3
- package/dist/look.js +39 -6
- package/dist/outline.d.ts +1 -1
- package/dist/outline.js +46 -4
- package/dist/pageant.d.ts +120 -0
- package/dist/pageant.draw.d.ts +21 -0
- package/dist/pageant.draw.js +171 -0
- package/dist/pageant.js +211 -0
- package/dist/pageant.style.d.ts +8 -0
- package/dist/pageant.style.js +42 -0
- package/dist/png.d.ts +8 -1
- package/dist/png.js +21 -1
- package/dist/pointer-entry.d.ts +2 -0
- package/dist/pointer-entry.js +2 -0
- package/dist/pointer.d.ts +113 -0
- package/dist/pointer.js +151 -0
- package/dist/pool.d.ts +176 -0
- package/dist/pool.figures.d.ts +306 -0
- package/dist/pool.figures.js +148 -0
- package/dist/pool.js +304 -0
- package/dist/pool.kit.d.ts +48 -0
- package/dist/pool.kit.js +141 -0
- package/dist/pool.labels.d.ts +20 -0
- package/dist/pool.labels.js +258 -0
- package/dist/pool.race.d.ts +65 -0
- package/dist/pool.race.js +140 -0
- package/dist/pool.replay.d.ts +46 -0
- package/dist/pool.replay.js +127 -0
- package/dist/pool.section.d.ts +13 -0
- package/dist/pool.section.js +83 -0
- package/dist/pool.style.d.ts +20 -0
- package/dist/pool.style.js +169 -0
- package/dist/pool.types.d.ts +145 -0
- package/dist/pool.types.js +3 -0
- package/dist/pool.words.d.ts +13 -0
- package/dist/pool.words.js +101 -0
- package/dist/ranges.names.js +53 -3
- package/dist/replay-entry.d.ts +3 -0
- package/dist/replay-entry.js +2 -0
- package/dist/restyle.js +1 -1
- package/dist/road.draw.js +3 -2
- package/dist/road.js +2 -1
- package/dist/road.types.d.ts +3 -0
- package/dist/rules.d.ts +138 -0
- package/dist/rules.js +511 -0
- package/dist/rules.types.d.ts +4 -0
- package/dist/rules.types.js +64 -0
- package/dist/score.d.ts +3 -0
- package/dist/score.js +93 -6
- package/dist/sheet.d.ts +3 -0
- package/dist/sheet.js +7 -3
- package/dist/situation.d.ts +135 -0
- package/dist/situation.draw.d.ts +34 -0
- package/dist/situation.draw.js +202 -0
- package/dist/situation.js +337 -0
- package/dist/situation.style.d.ts +2 -0
- package/dist/situation.style.js +45 -0
- package/dist/situation.types.d.ts +77 -0
- package/dist/situation.types.js +3 -0
- package/dist/situation.widgets.d.ts +39 -0
- package/dist/situation.widgets.js +129 -0
- package/dist/skating/describe.d.ts +7 -0
- package/dist/skating/describe.js +44 -0
- package/dist/skating/events.d.ts +24 -0
- package/dist/skating/events.js +47 -0
- package/dist/skating/figure.d.ts +24 -0
- package/dist/skating/figure.js +37 -0
- package/dist/skating/long.d.ts +54 -0
- package/dist/skating/long.js +111 -0
- package/dist/skating/parts.d.ts +43 -0
- package/dist/skating/parts.js +40 -0
- package/dist/skating/plan.d.ts +55 -0
- package/dist/skating/plan.js +197 -0
- package/dist/skating/short.d.ts +47 -0
- package/dist/skating/short.js +89 -0
- package/dist/skating/track.d.ts +66 -0
- package/dist/skating/track.js +94 -0
- package/dist/skating/types.d.ts +57 -0
- package/dist/skating/types.js +3 -0
- package/dist/skating-entry.d.ts +17 -0
- package/dist/skating-entry.js +11 -0
- package/dist/skating.style.d.ts +4 -0
- package/dist/skating.style.js +24 -0
- package/dist/strings.d.ts +470 -0
- package/dist/strings.js +674 -13
- package/dist/style.d.ts +2 -0
- package/dist/style.js +19 -2
- package/dist/surfaces/artistic.d.ts +9 -0
- package/dist/surfaces/artistic.js +94 -0
- package/dist/surfaces/athletics.d.ts +16 -0
- package/dist/surfaces/athletics.js +132 -0
- package/dist/surfaces/baseball.js +17 -0
- package/dist/surfaces/basketball.floor.d.ts +27 -0
- package/dist/surfaces/basketball.floor.js +83 -0
- package/dist/surfaces/basketball.gear.d.ts +27 -0
- package/dist/surfaces/basketball.gear.js +90 -0
- package/dist/surfaces/basketball.js +44 -22
- package/dist/surfaces/budo.d.ts +6 -0
- package/dist/surfaces/budo.js +104 -0
- package/dist/surfaces/curling.d.ts +16 -0
- package/dist/surfaces/curling.fold.d.ts +67 -0
- package/dist/surfaces/curling.fold.js +107 -0
- package/dist/surfaces/curling.js +37 -3
- package/dist/surfaces/dive.d.ts +9 -0
- package/dist/surfaces/dive.js +128 -0
- package/dist/surfaces/field.d.ts +7 -2
- package/dist/surfaces/field.js +15 -9
- package/dist/surfaces/football.js +7 -0
- package/dist/surfaces/hockey.js +4 -2
- package/dist/surfaces/net.d.ts +2 -1
- package/dist/surfaces/net.js +160 -15
- package/dist/surfaces/polo.d.ts +12 -0
- package/dist/surfaces/polo.js +159 -0
- package/dist/surfaces/pool.d.ts +27 -0
- package/dist/surfaces/pool.js +198 -0
- package/dist/surfaces/skating.d.ts +6 -0
- package/dist/surfaces/skating.js +62 -0
- package/dist/surfaces/soccer.js +2 -1
- package/dist/surfaces/surface.d.ts +76 -0
- package/dist/surfaces/venue.d.ts +15 -0
- package/dist/surfaces/venue.js +95 -0
- package/dist/target-entry.d.ts +2 -0
- package/dist/target-entry.js +2 -0
- package/dist/target.d.ts +200 -0
- package/dist/target.js +525 -0
- package/dist/team.common.d.ts +1 -1
- package/dist/team.common.js +3 -3
- package/dist/team.formation.d.ts +3 -1
- package/dist/team.formation.js +3 -3
- package/dist/team.layers.d.ts +2 -2
- package/dist/team.layers.js +4 -1
- package/dist/team.lineup.d.ts +27 -0
- package/dist/team.lineup.js +207 -0
- package/dist/team.network.js +6 -5
- package/dist/team.shape.js +5 -5
- package/dist/team.types.d.ts +40 -1
- package/dist/tenants.d.ts +77 -0
- package/dist/tenants.data.d.ts +8 -0
- package/dist/tenants.data.js +1793 -0
- package/dist/tenants.js +46 -0
- package/dist/turn-entry.d.ts +12 -0
- package/dist/turn-entry.js +7 -0
- package/dist/turn.camera.d.ts +116 -0
- package/dist/turn.camera.js +184 -0
- package/dist/turn.dom.d.ts +40 -0
- package/dist/turn.dom.js +353 -0
- package/dist/turn.kit.d.ts +86 -0
- package/dist/turn.kit.js +110 -0
- package/dist/turn.markup.d.ts +48 -0
- package/dist/turn.markup.js +108 -0
- package/dist/turn.model.d.ts +78 -0
- package/dist/turn.model.js +91 -0
- package/dist/turn.style.d.ts +5 -0
- package/dist/turn.style.js +56 -0
- package/dist/types.d.ts +221 -20
- package/dist/units.d.ts +1 -1
- package/dist/units.js +22 -1
- package/dist/uprights.board.d.ts +17 -0
- package/dist/uprights.board.js +59 -0
- package/dist/uprights.budo.d.ts +17 -0
- package/dist/uprights.budo.js +87 -0
- package/dist/uprights.d.ts +37 -0
- package/dist/uprights.fields.d.ts +32 -0
- package/dist/uprights.fields.js +172 -0
- package/dist/uprights.goal.d.ts +49 -0
- package/dist/uprights.goal.js +69 -0
- package/dist/uprights.js +169 -0
- package/dist/uprights.more.d.ts +7 -0
- package/dist/uprights.more.js +183 -0
- package/dist/uprights.net.d.ts +12 -0
- package/dist/uprights.net.js +109 -0
- package/dist/uprights.netkit.d.ts +28 -0
- package/dist/uprights.netkit.js +67 -0
- package/dist/uprights.pools.d.ts +8 -0
- package/dist/uprights.pools.js +190 -0
- package/dist/uprights.rinks.d.ts +27 -0
- package/dist/uprights.rinks.js +106 -0
- package/dist/uprights.track.d.ts +50 -0
- package/dist/uprights.track.js +246 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/zones-entry.d.ts +2 -0
- package/dist/zones-entry.js +2 -0
- package/package.json +56 -3
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// The athletics data: the lane draw of a race (names and flags on the lanes of its start), its results (places, medals, times, reaction times and splits at the finish and along the lanes), a replay of the
|
|
2
|
+
// race (every runner on the curve of the lane, the stagger as the rules put it), and the attempts of a field event as marks in the sector or the pit with the best mark picked out. Written for this
|
|
3
|
+
// package from the Track and Field Facilities Manual (2019, 2.2.1.6) and Book C2.1's Technical Rules (14, 17, 25 to 38); nothing of any other package is used. The flags are the caller's: an SVG
|
|
4
|
+
// document as text, or an address, as a team's logo is.
|
|
5
|
+
import { assemble, clean, drawingOptions, languageOf, round, wordsOf } from "./charts.js";
|
|
6
|
+
import { athleticsPlan } from "./athletics/plan.js";
|
|
7
|
+
import { raceById, fieldEventById } from "./athletics/events.js";
|
|
8
|
+
import { fieldEventName } from "./athletics/describe.js";
|
|
9
|
+
import { curveAcross } from "./athletics/races.js";
|
|
10
|
+
import { distanceOn, laneRadius, lap, pose } from "./athletics/track.js";
|
|
11
|
+
import { esc } from "./format.js";
|
|
12
|
+
import { fill } from "./strings.js";
|
|
13
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
14
|
+
const K = 1 / 0.3048;
|
|
15
|
+
const WORDS = (language) => wordsOf(language).athletics;
|
|
16
|
+
/** A time in seconds as it is written: `9.58`, `1:40.91`, `2:03:59.40`. */
|
|
17
|
+
export function formatTime(seconds, places = 2) {
|
|
18
|
+
if (!isNum(seconds) || seconds < 0)
|
|
19
|
+
return "";
|
|
20
|
+
const r = Math.round(seconds * 10 ** places) / 10 ** places;
|
|
21
|
+
const h = Math.floor(r / 3600);
|
|
22
|
+
const m = Math.floor((r - h * 3600) / 60);
|
|
23
|
+
const s = r - h * 3600 - m * 60;
|
|
24
|
+
const sec = s.toFixed(places).padStart(places > 0 ? places + 3 : 2, "0");
|
|
25
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : m > 0 ? `${m}:${sec}` : s.toFixed(places);
|
|
26
|
+
}
|
|
27
|
+
function raceOf(options) {
|
|
28
|
+
const id = options.race ?? "400m";
|
|
29
|
+
if (raceById(id) === undefined)
|
|
30
|
+
throw new Error(`Unknown race "${String(id)}"`);
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
/** The plan of the race's drawing, with its marks, and the marks of the race. */
|
|
34
|
+
function planOf(options, race, surface) {
|
|
35
|
+
const style = { ...(surface?.athletics ?? {}), races: [race], start: race };
|
|
36
|
+
const plan = athleticsPlan({ sport: "athletics", league: surface?.league, athletics: style, units: surface?.units, pitch: surface?.pitch, detail: surface?.detail, range: surface?.range });
|
|
37
|
+
void options;
|
|
38
|
+
const marks = plan.races.find((r) => r.race.id === race);
|
|
39
|
+
return { plan, marks };
|
|
40
|
+
}
|
|
41
|
+
const isPortraitSurface = (surface) => surface?.orientation === "portrait";
|
|
42
|
+
function checkEntries(entries, label) {
|
|
43
|
+
if (!Array.isArray(entries))
|
|
44
|
+
throw new TypeError(`${label} is a list`);
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
if (typeof e !== "object" || e === null || !Number.isInteger(e.lane) || e.lane < 1)
|
|
48
|
+
throw new TypeError(`${label}: each athlete has a lane, a whole number from 1`);
|
|
49
|
+
if (seen.has(e.lane))
|
|
50
|
+
throw new RangeError(`${label}: lane ${e.lane} is given twice`);
|
|
51
|
+
seen.add(e.lane);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The drawing's options for an athletics chart: the stadium close up on the start (or the finish), the race marked, whatever the caller laid over it. */
|
|
55
|
+
function surfaceFor(options, title, race, range) {
|
|
56
|
+
const surface = options.surface ?? {};
|
|
57
|
+
const style = { races: [race], start: race, ...(surface.athletics ?? {}) };
|
|
58
|
+
return drawingOptions("athletics", { ...options, surface: { ...(range === undefined ? {} : { range }), ...surface, athletics: style } }, title, {});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The lane draw of a race: each athlete's name and flag on the lane of the start, behind the lane's number, for the close-up of the start (`range: "start"`, the drawing's default). The positions are the
|
|
62
|
+
* start lines' as the Manual marks them: the stagger of the 200, 400 and 800 m and the 4 x 400 m, the straight line of the sprints, the curved line of a race run without lanes.
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { laneDraw } from "@johnmorrisdotca/kyogijo-stadiums/athletics";
|
|
66
|
+
* const chart = laneDraw([{ lane: 4, name: "M. Jacobs", country: "ITA" }, { lane: 5, name: "F. Kerley", country: "USA" }], { race: "100m" });
|
|
67
|
+
* console.log(chart.totals.athletes, chart.title);
|
|
68
|
+
* // 2 Lane draw, 100 m
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function laneDraw(entries, options = {}) {
|
|
72
|
+
checkEntries(entries, "entries");
|
|
73
|
+
const race = raceOf(options);
|
|
74
|
+
const language = languageOf(options);
|
|
75
|
+
const w = WORDS(language);
|
|
76
|
+
const info = raceById(race);
|
|
77
|
+
const raceName = (info?.name[language] ?? race);
|
|
78
|
+
const title = fill(w.data.draw, { race: raceName });
|
|
79
|
+
const o = surfaceFor(options, title, race, "start");
|
|
80
|
+
const { plan, marks } = planOf(options, race, o);
|
|
81
|
+
const portrait = isPortraitSurface(o);
|
|
82
|
+
const names = [];
|
|
83
|
+
const flags = [];
|
|
84
|
+
const sorted = [...entries].sort((a, b) => a.lane - b.lane);
|
|
85
|
+
// The number behind each line is a tag, sized to the view, on a bend; on a straight it is painted, 0.85 m tall. The flag and the name follow it back along the lane.
|
|
86
|
+
const number = plan.digits.find((d) => d.id === "start-number");
|
|
87
|
+
const tag = number?.tag === true;
|
|
88
|
+
const unit = number === undefined ? 0.85 : number.h;
|
|
89
|
+
const numberBack = tag ? unit * 0.78 + 0.35 : unit * 1.2;
|
|
90
|
+
const numberHalf = tag ? unit * 0.78 : unit * 0.45;
|
|
91
|
+
const flagSide = 0.9 * (unit / 0.85);
|
|
92
|
+
const flagAt = numberBack + numberHalf + flagSide / 2 + 0.2;
|
|
93
|
+
const nameAt = flagAt + flagSide / 2 + 0.3;
|
|
94
|
+
const nameSize = Math.max(0.45, unit * (tag ? 0.42 : 0.6));
|
|
95
|
+
for (const e of sorted) {
|
|
96
|
+
const start = marks.starts.find((s) => s.lane === e.lane);
|
|
97
|
+
if (start === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
const fx = Math.cos(start.heading);
|
|
100
|
+
const fy = Math.sin(start.heading);
|
|
101
|
+
const back = (d) => [start.x - fx * d, start.y - fy * d];
|
|
102
|
+
const [nx, ny] = back(nameAt);
|
|
103
|
+
names.push(clean({ x: nx, y: ny, text: e.name, anchor: "end", size: nameSize, rotate: portrait ? -90 : 0, title: e.country === undefined ? undefined : `${e.name}, ${e.country}` }));
|
|
104
|
+
if (e.flag !== undefined) {
|
|
105
|
+
const [px, py] = back(flagAt);
|
|
106
|
+
flags.push(clean({ x: px, y: py, shape: "logo", logo: e.flag, size: flagSide / 1.8, title: e.country }));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const at = { from: "athletics", units: "m" };
|
|
110
|
+
const layers = [];
|
|
111
|
+
if (flags.length > 0)
|
|
112
|
+
layers.push({ kind: "points", id: "flags", name: w.data.layers.flags, ...at, clip: "none", data: flags, style: { shape: "logo", size: flagSide / 1.8 } });
|
|
113
|
+
layers.push({ kind: "text", id: "names", name: w.data.layers.names, ...at, clip: "none", data: names, style: { size: nameSize, anchor: "end", weight: 800 } });
|
|
114
|
+
const lanes = sorted.map((e) => e.lane);
|
|
115
|
+
const totals = { athletes: sorted.length, firstLane: lanes[0] ?? 0, lastLane: lanes[lanes.length - 1] ?? 0 };
|
|
116
|
+
const summary = sorted.length === 0 ? "" : fill(w.data.summaryDraw, { n: totals.athletes, race: raceName, first: totals.firstLane, last: totals.lastLane });
|
|
117
|
+
const chart = assemble(o, layers, { title, summary, totals, tableLayers: ["names"] });
|
|
118
|
+
chart.table = () => entriesTable(sorted, language);
|
|
119
|
+
return chart;
|
|
120
|
+
}
|
|
121
|
+
/** The lane draw as an HTML table: lane, athlete, country. Every cell is escaped. */
|
|
122
|
+
function entriesTable(entries, language) {
|
|
123
|
+
const c = WORDS(language).data.columns;
|
|
124
|
+
const rows = entries.map((e) => `<tr><td>${e.lane}</td><td>${esc(e.name)}</td><td>${esc(e.country ?? "")}</td></tr>`).join("");
|
|
125
|
+
return `<table class="ky-table"><thead><tr><th>${c.lane}</th><th>${c.athlete}</th><th>${c.country}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
126
|
+
}
|
|
127
|
+
const MEDAL_COLOURS = { gold: "#d4a017", silver: "#b8bec6", bronze: "#b87333" };
|
|
128
|
+
/**
|
|
129
|
+
* The results of a race on the finish line's close-up: after the line, in each lane, a disc with the place (gold, silver and bronze for the first three), the athlete's name and time; the reaction time
|
|
130
|
+
* behind the start line and the splits along the lane as small marks with their times; and a table of all of it. Times are in seconds from the gun.
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* import { raceResults } from "@johnmorrisdotca/kyogijo-stadiums/athletics";
|
|
134
|
+
* const chart = raceResults([{ lane: 4, name: "M. Jacobs", place: 1, time: 9.8 }, { lane: 5, name: "F. Kerley", place: 2, time: 9.84 }], { race: "100m" });
|
|
135
|
+
* console.log(chart.totals.winner?.name, chart.summary);
|
|
136
|
+
* // M. Jacobs M. Jacobs won the 100 m in 9.80.
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function raceResults(results, options = {}) {
|
|
140
|
+
checkEntries(results, "results");
|
|
141
|
+
const race = raceOf(options);
|
|
142
|
+
const language = languageOf(options);
|
|
143
|
+
const w = WORDS(language);
|
|
144
|
+
const info = raceById(race);
|
|
145
|
+
const raceName = (info?.name[language] ?? race);
|
|
146
|
+
const title = fill(w.data.results, { race: raceName });
|
|
147
|
+
const o = surfaceFor(options, title, race, "finish");
|
|
148
|
+
const { plan, marks } = planOf(options, race, o);
|
|
149
|
+
const oval = plan.oval;
|
|
150
|
+
const S = oval.straight;
|
|
151
|
+
const finishX = S / 2;
|
|
152
|
+
const sorted = [...results].sort((a, b) => (a.place ?? 99) - (b.place ?? 99) || a.lane - b.lane);
|
|
153
|
+
const lanes = plan.straight?.lane ?? oval.lane;
|
|
154
|
+
const top = plan.straight?.y0 ?? oval.radius;
|
|
155
|
+
const at = { from: "athletics", units: "m" };
|
|
156
|
+
const discs = [];
|
|
157
|
+
const places = [];
|
|
158
|
+
const words = [];
|
|
159
|
+
const reactions = [];
|
|
160
|
+
const splits = [];
|
|
161
|
+
for (const r of sorted) {
|
|
162
|
+
const y = top + (r.lane - 0.5) * lanes;
|
|
163
|
+
const medal = r.place === 1 ? "gold" : r.place === 2 ? "silver" : r.place === 3 ? "bronze" : undefined;
|
|
164
|
+
const done = isNum(r.time) && r.place !== undefined;
|
|
165
|
+
if (done) {
|
|
166
|
+
discs.push(clean({ x: finishX + 1.15, y, shape: "circle", size: 0.95, color: medal === undefined ? "#2f3a44" : MEDAL_COLOURS[medal], title: `${r.name}, ${medal === undefined ? "" : `${w.data.medals[medal]}, `}${formatTime(r.time)}` }));
|
|
167
|
+
places.push({ x: finishX + 1.15, y, text: r.place, size: 0.55, color: medal === undefined ? "#ffffff" : "#1c1f1d" });
|
|
168
|
+
words.push({ x: finishX + 2.05, y, text: `${r.name} ${formatTime(r.time)}`, anchor: "start", size: 0.5 });
|
|
169
|
+
}
|
|
170
|
+
else if (r.note !== undefined) {
|
|
171
|
+
words.push({ x: finishX + 2.05, y, text: `${r.name} ${r.note}`, anchor: "start", size: 0.5 });
|
|
172
|
+
}
|
|
173
|
+
if (options.reactions !== false && isNum(r.reaction)) {
|
|
174
|
+
const start = marks.starts.find((s) => s.lane === r.lane);
|
|
175
|
+
if (start !== undefined)
|
|
176
|
+
reactions.push({ x: start.x + Math.cos(start.heading) * 2.1 - Math.cos(start.heading + Math.PI / 2) * 0, y: start.y + Math.sin(start.heading) * 2.1, text: `${r.reaction.toFixed(3)}`, size: 0.4, anchor: "middle", title: fill(w.data.reaction, { t: r.reaction.toFixed(3) }) });
|
|
177
|
+
}
|
|
178
|
+
if (options.splits !== false && r.splits !== undefined) {
|
|
179
|
+
const start = marks.starts.find((s) => s.lane === r.lane);
|
|
180
|
+
if (start !== undefined) {
|
|
181
|
+
for (const sp of r.splits) {
|
|
182
|
+
if (!isNum(sp.distance) || !isNum(sp.time))
|
|
183
|
+
continue;
|
|
184
|
+
const p = alongLane(plan, marks, start.lane, sp.distance);
|
|
185
|
+
splits.push({ x: p[0], y: p[1], shape: "circle", size: 0.5, title: fill(w.data.split, { d: round(sp.distance, 1), t: formatTime(sp.time) }), value: sp.time });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const layers = [];
|
|
191
|
+
if (splits.length > 0)
|
|
192
|
+
layers.push({ kind: "points", id: "splits", name: w.data.layers.splits, ...at, clip: "none", data: splits, style: { size: 0.5, fill: "#f2c230" } });
|
|
193
|
+
layers.push({ kind: "points", id: "places", name: w.data.layers.medals, ...at, clip: "none", data: discs, style: { size: 0.95 } });
|
|
194
|
+
layers.push({ kind: "text", id: "place-numbers", name: w.data.layers.medals, ...at, clip: "none", data: places, style: { size: 0.55, weight: 800, halo: false } });
|
|
195
|
+
layers.push({ kind: "text", id: "athletes", name: w.data.layers.names, ...at, clip: "none", data: words, style: { size: 0.5, anchor: "start", weight: 800 } });
|
|
196
|
+
if (reactions.length > 0)
|
|
197
|
+
layers.push({ kind: "text", id: "reactions", name: w.data.layers.reactions, ...at, clip: "none", data: reactions, style: { size: 0.4 } });
|
|
198
|
+
const winner = sorted.find((r) => r.place === 1 && isNum(r.time));
|
|
199
|
+
const react = results.filter((r) => isNum(r.reaction)).map((r) => r.reaction);
|
|
200
|
+
const totals = {
|
|
201
|
+
athletes: results.length,
|
|
202
|
+
finished: results.filter((r) => isNum(r.time) && r.place !== undefined).length,
|
|
203
|
+
winner: winner === undefined ? null : { name: winner.name, time: winner.time },
|
|
204
|
+
quickestReaction: react.length === 0 ? null : Math.min(...react),
|
|
205
|
+
};
|
|
206
|
+
const summary = winner === undefined ? "" : fill(w.data.summaryResults, { winner: winner.name, race: raceName, time: formatTime(totals.winner?.time ?? 0) });
|
|
207
|
+
const chart = assemble(o, layers, { title, summary, totals, tableLayers: ["places"] });
|
|
208
|
+
chart.table = () => resultsTable(sorted, language);
|
|
209
|
+
const entries = [];
|
|
210
|
+
chart.key = [];
|
|
211
|
+
void entries;
|
|
212
|
+
return chart;
|
|
213
|
+
}
|
|
214
|
+
/** The point `distance` metres along the lane of a race from its start line, for a splits mark: on the lane's own line, and in a race that leaves its lanes, drawn in towards the inside. */
|
|
215
|
+
function alongLane(plan, marks, lane, distance) {
|
|
216
|
+
const start = marks.starts.find((s) => s.lane === lane);
|
|
217
|
+
if (start === undefined)
|
|
218
|
+
return [0, 0];
|
|
219
|
+
return runnerAt(plan, marks, lane, distance);
|
|
220
|
+
}
|
|
221
|
+
/** The results as an HTML table: place, lane, athlete, country, time, reaction, the splits. Every cell is escaped. */
|
|
222
|
+
function resultsTable(results, language) {
|
|
223
|
+
const w = WORDS(language).data;
|
|
224
|
+
const c = w.columns;
|
|
225
|
+
const rows = results
|
|
226
|
+
.map((r) => {
|
|
227
|
+
const splits = (r.splits ?? []).map((s) => `${round(s.distance, 1)} ${w.metres}: ${formatTime(s.time)}`).join("; ");
|
|
228
|
+
return `<tr><td>${r.place ?? ""}</td><td>${r.lane}</td><td>${esc(r.name)}</td><td>${esc(r.country ?? "")}</td><td>${isNum(r.time) ? formatTime(r.time) : esc(r.note ?? "")}</td><td>${isNum(r.reaction) ? r.reaction.toFixed(3) : ""}</td><td>${esc(splits)}</td></tr>`;
|
|
229
|
+
})
|
|
230
|
+
.join("");
|
|
231
|
+
return `<table class="ky-table"><thead><tr><th>${c.place}</th><th>${c.lane}</th><th>${c.athlete}</th><th>${c.country}</th><th>${c.time}</th><th>${c.reaction}</th><th>${c.splits}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
232
|
+
}
|
|
233
|
+
const smooth = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t));
|
|
234
|
+
/** The distance a runner has run at a time, by the reaction time, the splits and the finish: straight lines between them, standing until the reaction. */
|
|
235
|
+
export function distanceRun(runner, total, t) {
|
|
236
|
+
const rt = isNum(runner.reaction) ? runner.reaction : 0.15;
|
|
237
|
+
if (t <= rt)
|
|
238
|
+
return 0;
|
|
239
|
+
const pts = [[rt, 0]];
|
|
240
|
+
for (const s of [...(runner.splits ?? [])].sort((a, b) => a.time - b.time))
|
|
241
|
+
if (s.time > rt && s.time < runner.time && s.distance > 0 && s.distance < total)
|
|
242
|
+
pts.push([s.time, s.distance]);
|
|
243
|
+
pts.push([runner.time, total]);
|
|
244
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
245
|
+
if (t <= pts[i][0]) {
|
|
246
|
+
const [t0, d0] = pts[i - 1];
|
|
247
|
+
const [t1, d1] = pts[i];
|
|
248
|
+
return d0 + ((t - t0) / (t1 - t0)) * (d1 - d0);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// After the finish: carry on at the pace of the last stretch, slowing to a stop over three seconds.
|
|
252
|
+
const [t0, d0] = pts[pts.length - 2];
|
|
253
|
+
const [t1, d1] = pts[pts.length - 1];
|
|
254
|
+
const v = (d1 - d0) / (t1 - t0);
|
|
255
|
+
const u = Math.min(t - t1, 3);
|
|
256
|
+
return d1 + v * (u - (u * u) / 6);
|
|
257
|
+
}
|
|
258
|
+
/** The running line's distance mapped from one radius to another by the part of the track it is on (its bend's angle, its straight's length): where a runner who has cut in to another lane is. */
|
|
259
|
+
function mapDistance(plan, from, to, s) {
|
|
260
|
+
const S = plan.oval.straight;
|
|
261
|
+
const L = 2 * S + 2 * Math.PI * from;
|
|
262
|
+
const d = ((s % L) + L) % L;
|
|
263
|
+
const turns = Math.floor(s / L);
|
|
264
|
+
const bend = Math.PI * from;
|
|
265
|
+
const Lto = 2 * S + 2 * Math.PI * to;
|
|
266
|
+
let out;
|
|
267
|
+
if (d <= bend)
|
|
268
|
+
out = (d / from) * to;
|
|
269
|
+
else if (d <= bend + S)
|
|
270
|
+
out = Math.PI * to + (d - bend);
|
|
271
|
+
else if (d <= 2 * bend + S)
|
|
272
|
+
out = Math.PI * to + S + ((d - bend - S) / from) * to;
|
|
273
|
+
else
|
|
274
|
+
out = 2 * Math.PI * to + S + (d - 2 * bend - S);
|
|
275
|
+
return out + turns * Lto;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Where a runner is when `d` metres have been run from the start line of the lane: in the lane, on the lane's own curve, for a race run in lanes; and, for a race that leaves them, in the lane as far as
|
|
279
|
+
* the breakline (or, with none, the start) and then moving in to lane 1's line over the next 40 m, as a runner does once free.
|
|
280
|
+
*/
|
|
281
|
+
function runnerAt(plan, marks, lane, d) {
|
|
282
|
+
const o = plan.oval;
|
|
283
|
+
const start = marks.starts.find((s) => s.lane === lane);
|
|
284
|
+
if (start === undefined)
|
|
285
|
+
return [0, 0];
|
|
286
|
+
const race = marks.race;
|
|
287
|
+
const total = race.distance;
|
|
288
|
+
if (race.kind === "straight") {
|
|
289
|
+
const x = Math.min(start.x + d, start.x + total + 100);
|
|
290
|
+
return [x, start.y];
|
|
291
|
+
}
|
|
292
|
+
const r = laneRadius(o, lane);
|
|
293
|
+
const r1 = laneRadius(o, 1);
|
|
294
|
+
const s = start.s + d;
|
|
295
|
+
// Where the runner leaves the lane: the breakline's crossing of the lane, or the start for a race run without lanes.
|
|
296
|
+
let free = Infinity;
|
|
297
|
+
if (race.kind === "curved" || race.kind === "steeplechase")
|
|
298
|
+
free = 0;
|
|
299
|
+
else if (race.breakline === true && marks.breakline !== undefined) {
|
|
300
|
+
const a = r1;
|
|
301
|
+
const [px, py] = curveAcross(o, { side: plan.indoor ? "right" : "left", f0: 0, wire: o.straight }, a, r, r, 1)[0];
|
|
302
|
+
const sBreak = distanceOn(o, r, px, py);
|
|
303
|
+
const L = lap(o, lane);
|
|
304
|
+
free = (((sBreak - start.s) % L) + L) % L;
|
|
305
|
+
}
|
|
306
|
+
const w = free === Infinity ? 0 : smooth((d - free) / 40);
|
|
307
|
+
const p = pose(o, r, s);
|
|
308
|
+
if (w === 0)
|
|
309
|
+
return [p.x, p.y];
|
|
310
|
+
const q = pose(o, r1, mapDistance(plan, r, r1, s));
|
|
311
|
+
return [p.x + (q.x - p.x) * w, p.y + (q.y - p.y) * w];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* A replay of a race: every runner, from the gun, on the curve of the lane (the stagger of the start as the Manual's table has it), in the pace of the reaction time, the splits and the finish time,
|
|
315
|
+
* to the line and on to a stop. A race that leaves its lanes (the 800 m after its breakline, the long races from the start) is drawn with the runners moving in to the inside. The positions are in
|
|
316
|
+
* the athletics frame (metres from the middle of the track), so the timeline is for the stadium drawn whole or close up. The relays are not replayed.
|
|
317
|
+
* @example
|
|
318
|
+
* ```ts
|
|
319
|
+
* import { raceReplay } from "@johnmorrisdotca/kyogijo-stadiums/athletics";
|
|
320
|
+
* const replay = raceReplay([{ lane: 1, name: "A", time: 10.1 }, { lane: 2, name: "B", time: 10.3 }], { race: "100m" });
|
|
321
|
+
* console.log(replay.timeline.frames.length > 40, replay.duration);
|
|
322
|
+
* // true 10.3
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
export function raceReplay(runners, options = {}) {
|
|
326
|
+
checkEntries(runners, "runners");
|
|
327
|
+
const race = raceOf(options);
|
|
328
|
+
const info = raceById(race);
|
|
329
|
+
if (info?.legs !== undefined)
|
|
330
|
+
throw new RangeError(`The ${race} is a relay: a replay of a relay is not made`);
|
|
331
|
+
const language = languageOf(options);
|
|
332
|
+
const w = WORDS(language);
|
|
333
|
+
const raceName = (info?.name[language] ?? race);
|
|
334
|
+
const title = fill(w.data.replay, { race: raceName });
|
|
335
|
+
const o = surfaceFor(options, title, race, undefined);
|
|
336
|
+
const { plan, marks } = planOf(options, race, o);
|
|
337
|
+
const step = isNum(options.step) && options.step > 0 ? options.step : 0.2;
|
|
338
|
+
const runOut = isNum(options.runOut) && options.runOut >= 0 ? options.runOut : 8;
|
|
339
|
+
for (const r of runners)
|
|
340
|
+
if (!isNum(r.time) || r.time <= 0)
|
|
341
|
+
throw new RangeError(`runner in lane ${r.lane}: the time is a number of seconds above zero`);
|
|
342
|
+
const total = info.distance;
|
|
343
|
+
const duration = Math.max(...runners.map((r) => r.time), 0);
|
|
344
|
+
const end = duration + 3.2;
|
|
345
|
+
const frames = [];
|
|
346
|
+
const finishX = plan.oval.straight / 2;
|
|
347
|
+
for (let k = 0; k * step <= end + 1e-9; k += 1) {
|
|
348
|
+
const t = Math.round(k * step * 1000) / 1000;
|
|
349
|
+
const objects = [];
|
|
350
|
+
for (const r of [...runners].sort((a, b) => a.lane - b.lane)) {
|
|
351
|
+
const d = distanceRun(r, total, t);
|
|
352
|
+
const run = Math.min(d, total + runOut);
|
|
353
|
+
// The runner reaches the line at `total`; beyond it the run-out is straight along the finish straight.
|
|
354
|
+
const [x, y] = d <= total ? runnerAt(plan, marks, r.lane, d) : (() => { const [fx, fy] = runnerAt(plan, marks, r.lane, total); return [Math.max(fx, finishX) + (run - total), fy]; })();
|
|
355
|
+
objects.push(clean({ id: `lane-${r.lane}`, team: "home", x: round(x, 3), y: round(y, 3), label: r.name, number: r.lane }));
|
|
356
|
+
}
|
|
357
|
+
frames.push({ t, objects });
|
|
358
|
+
}
|
|
359
|
+
const events = [{ t: 0, kind: "gun", label: w.data.gun }];
|
|
360
|
+
for (const r of runners) {
|
|
361
|
+
const [x, y] = runnerAt(plan, marks, r.lane, total);
|
|
362
|
+
events.push({ t: r.time, kind: "finish", team: "home", x: round(x, 3), y: round(y, 3), by: `lane-${r.lane}`, label: fill(w.data.finish, { name: r.name, time: formatTime(r.time) }) });
|
|
363
|
+
}
|
|
364
|
+
events.sort((a, b) => a.t - b.t);
|
|
365
|
+
const timeline = { frames, events, from: "athletics", units: "m" };
|
|
366
|
+
const summary = fill(w.data.summaryReplay, { n: runners.length, race: raceName });
|
|
367
|
+
return { timeline, options: { ...o, title }, look: { trail: 0, scale: 0.5, numbers: true, hold: 2 }, title, summary, duration };
|
|
368
|
+
}
|
|
369
|
+
// ----- field events
|
|
370
|
+
/** The distance from the facility's centre to where a mark is measured from: the circle's inside edge (Rule 32), or the javelin's arc (8 m, Rule 32, javelin throw runway). */
|
|
371
|
+
const MEASURED_FROM = { "shot-put": 1.0675, "discus-throw": 1.25, "hammer-throw": 1.0675, "javelin-throw": 8 };
|
|
372
|
+
/**
|
|
373
|
+
* The attempts of a field event as marks: each valid throw in its sector at its distance and direction, each jump in its pit at its distance from the take-off line, the best of them picked out with
|
|
374
|
+
* a ring and its distance written beside it. A foul has no mark and is in the table. Put them on the stadium (`athletics: { events: [event] }`) or on the event alone (`athletics: { layout: event }`).
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* import { fieldMarks } from "@johnmorrisdotca/kyogijo-stadiums/athletics";
|
|
378
|
+
* const chart = fieldMarks("javelin-throw", [{ athlete: "A", distance: 84.2 }, { athlete: "A", distance: 88.1, across: -3 }, { athlete: "A", valid: false }]);
|
|
379
|
+
* console.log(chart.totals.best?.distance, chart.totals.fouls);
|
|
380
|
+
* // 88.1 1
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
export function fieldMarks(event, attempts, options = {}) {
|
|
384
|
+
if (fieldEventById(event) === undefined)
|
|
385
|
+
throw new Error(`Unknown field event "${String(event)}"`);
|
|
386
|
+
if (!Array.isArray(attempts))
|
|
387
|
+
throw new TypeError("attempts is a list of attempts");
|
|
388
|
+
const language = languageOf(options);
|
|
389
|
+
const w = WORDS(language);
|
|
390
|
+
const surface = options.surface ?? {};
|
|
391
|
+
const layout = surface.athletics?.layout === event;
|
|
392
|
+
const title = fill(w.data.marks, { event: fieldEventName(event, language) });
|
|
393
|
+
const style = { events: [event], ...(surface.athletics ?? {}) };
|
|
394
|
+
const o = drawingOptions("athletics", { ...options, surface: { ...surface, athletics: style } }, title, {});
|
|
395
|
+
const plan = athleticsPlan({ sport: "athletics", league: surface.league, athletics: style, pitch: surface.pitch });
|
|
396
|
+
const facility = plan.facilities.find((f) => f.event === event && (options.facility === undefined || f.id === options.facility));
|
|
397
|
+
if (facility === undefined)
|
|
398
|
+
throw new Error(options.facility === undefined ? `The stadium has no ${event} facility: put the event in athletics.events` : `The ${event} has no facility "${options.facility}": use one of ${plan.facilities.filter((f) => f.event === event).map((f) => f.id).join(", ")}`);
|
|
399
|
+
const jump = event === "long-jump" || event === "triple-jump";
|
|
400
|
+
const heading = facility.heading;
|
|
401
|
+
const from = (x, y) => (layout ? [(x - plan.box.x0) * K, (y - plan.box.y0) * K] : [x, y]);
|
|
402
|
+
const at = layout ? { units: "ft" } : { from: "athletics", units: "m" };
|
|
403
|
+
// A layer's sizes are in its own units: feet for the event alone, metres in the athletics frame.
|
|
404
|
+
const u = layout ? K : 1;
|
|
405
|
+
const takeoff = event === "long-jump" ? 3 : options.women === true ? 11 : 13;
|
|
406
|
+
const place = (a) => {
|
|
407
|
+
const d = a.distance;
|
|
408
|
+
const across = isNum(a.across) ? a.across : 0;
|
|
409
|
+
if (jump) {
|
|
410
|
+
// The pit's near end is the facility; the take-off line is `takeoff` m before it, and the mark is `d` m from the line.
|
|
411
|
+
const along = d - takeoff;
|
|
412
|
+
return [facility.x + Math.cos(heading) * along - Math.sin(heading) * across, facility.y + Math.sin(heading) * along + Math.cos(heading) * across];
|
|
413
|
+
}
|
|
414
|
+
const th = heading + (across * Math.PI) / 180;
|
|
415
|
+
const r = d + (MEASURED_FROM[event] ?? 0);
|
|
416
|
+
return [facility.x + r * Math.cos(th), facility.y + r * Math.sin(th)];
|
|
417
|
+
};
|
|
418
|
+
const valid = attempts.filter((a) => isNum(a.distance) && a.valid !== false);
|
|
419
|
+
const fouls = attempts.length - valid.length;
|
|
420
|
+
const bestOne = valid.reduce((b, a) => (b === undefined || a.distance > b.distance ? a : b), undefined);
|
|
421
|
+
const marks = [];
|
|
422
|
+
const labels = [];
|
|
423
|
+
valid.forEach((a, i) => {
|
|
424
|
+
const [x, y] = from(...place(a));
|
|
425
|
+
const isBest = a === bestOne;
|
|
426
|
+
const text = `${a.distance.toFixed(2)}`;
|
|
427
|
+
marks.push(clean({ x, y, team: a.team ?? "home", outcome: isBest ? "best" : "attempt", value: a.distance, title: a.title ?? `${a.athlete === undefined ? "" : `${a.athlete}, `}${text} ${w.data.metres}${isBest ? `, ${w.data.best}` : ""}` }));
|
|
428
|
+
if (isBest || options.labels === true)
|
|
429
|
+
labels.push({ x: x + 1.4 * u, y: y - 1.4 * u, text, size: (isBest ? 0.9 : 0.6) * u, anchor: "start" });
|
|
430
|
+
void i;
|
|
431
|
+
});
|
|
432
|
+
const layers = [];
|
|
433
|
+
const size = 1.1 * u;
|
|
434
|
+
layers.push({ kind: "points", id: "attempts", name: w.data.layers.attempts, ...at, clip: "none", data: marks, style: { size, outcomes: { best: { shape: "star", scale: 1.7 }, attempt: {} } } });
|
|
435
|
+
if (labels.length > 0)
|
|
436
|
+
layers.push({ kind: "text", id: "marks", name: w.data.layers.best, ...at, clip: "none", data: labels, style: { size: 0.8 * u, weight: 800 } });
|
|
437
|
+
const totals = { attempts: attempts.length, valid: valid.length, fouls, best: bestOne === undefined ? null : { distance: bestOne.distance, athlete: bestOne.athlete } };
|
|
438
|
+
const summary = bestOne === undefined ? "" : fill(w.data.summaryMarks, { n: attempts.length, best: `${bestOne.distance.toFixed(2)} ${w.data.metres}`, who: bestOne.athlete ?? "" });
|
|
439
|
+
const chart = assemble(o, layers, { title, summary, totals, tableLayers: ["attempts"] });
|
|
440
|
+
chart.table = () => attemptsTable(attempts, bestOne, language);
|
|
441
|
+
return chart;
|
|
442
|
+
}
|
|
443
|
+
/** The attempts as an HTML table: the round, the athlete, the mark, whether it was valid. The best is in bold. Every cell is escaped. */
|
|
444
|
+
function attemptsTable(attempts, best, language) {
|
|
445
|
+
const w = WORDS(language).data;
|
|
446
|
+
const c = w.columns;
|
|
447
|
+
const rows = attempts
|
|
448
|
+
.map((a, i) => {
|
|
449
|
+
const mark = isNum(a.distance) && a.valid !== false ? `${(a.distance).toFixed(2)}` : w.foul;
|
|
450
|
+
const cell = a === best ? `<strong>${mark}</strong>` : mark;
|
|
451
|
+
return `<tr><td>${a.round ?? i + 1}</td><td>${esc(a.athlete ?? "")}</td><td>${cell}</td><td>${isNum(a.distance) && a.valid !== false ? w.yes : w.no}</td></tr>`;
|
|
452
|
+
})
|
|
453
|
+
.join("");
|
|
454
|
+
return `<table class="ky-table"><thead><tr><th>${c.round}</th><th>${c.athlete}</th><th>${c.distance}</th><th>${c.valid}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
455
|
+
}
|
package/dist/charts.baseball.js
CHANGED
|
@@ -76,7 +76,7 @@ export function mlbSprayChart(balls, options = {}) {
|
|
|
76
76
|
if (at !== undefined)
|
|
77
77
|
[x, y] = toFrame === undefined ? at : toFrame([at])[0];
|
|
78
78
|
}
|
|
79
|
-
return clean({ x: x ?? null, y: y ?? null, outcome: ballType(b.type), value: b.value, team: b.team, title: b.title });
|
|
79
|
+
return clean({ id: b.id, x: x ?? null, y: y ?? null, outcome: ballType(b.type), value: b.value, team: b.team, title: b.title });
|
|
80
80
|
});
|
|
81
81
|
const { counts, placed: count, skipped } = countOutcomes(rows);
|
|
82
82
|
// The fence of the park in that season, and the balls that came down beyond it: each ball's distance and its angle from dead centre, from the surface's own feet.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Chart, CurlingHeatOptions, CurlingHeatTotals, CurlingStone, KeyItem } from "./charts.types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A heat chart of the stones of many ends on a house: where they came to rest, counted into cells (`heatmap`, `hexbin`), a density (`contour`), the zones of the house (`zones`) or
|
|
4
|
+
* drawn as translucent discs in their team's colours (`discs`). Only stones in play are drawn, as `curlingScore` judges them. `team` keeps one team's stones, and `only` the free
|
|
5
|
+
* guards or the stones in the house, so that "where do the guards of the team with the hammer end up" is one call. Places are feet from the button, as for `curlingStones`; give
|
|
6
|
+
* the stones of every end together. The totals say how many stones were drawn, the free guards and the stones in the house for each team, and the share in each zone.
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { curlingHeat } from "@johnmorrisdotca/kyogijo-stadiums/charts";
|
|
10
|
+
* const stones = [
|
|
11
|
+
* { x: 0.3, y: 0.2, team: "home" },
|
|
12
|
+
* { x: -1.1, y: 0.9, team: "away" },
|
|
13
|
+
* { x: 0.1, y: -14, team: "away" },
|
|
14
|
+
* { x: 2.4, y: -17, team: "home" },
|
|
15
|
+
* { x: 0, y: -10, team: "home" },
|
|
16
|
+
* ];
|
|
17
|
+
* const chart = curlingHeat(stones, { only: "guards", view: "discs" });
|
|
18
|
+
* console.log(chart.totals.drawn, chart.totals.guards, chart.totals.house);
|
|
19
|
+
* // 3 { home: 2, away: 1 } { home: 1, away: 1 }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function curlingHeat(stones: readonly CurlingStone[], options?: CurlingHeatOptions): Chart<CurlingHeatTotals> & {
|
|
23
|
+
key: KeyItem[];
|
|
24
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The curling heat chart: where the stones of many ends came to rest, counted into square cells, hexagons, a density or the zones of the house (the button, the rings and the
|
|
2
|
+
// free guard zone), or drawn as translucent discs in each team's colour so that stones that fall on each other build up colour. The idea of the chart is the heat map of the
|
|
3
|
+
// stones' resting places and the map of the free guards that the analyses of curlovision (BSD) and Jordan Myslik's curling analytics show; the code is written for this package on
|
|
4
|
+
// the heat layers, with the rules of `curlingScore` for which stones are in play and in the house and `curlingFreeGuards` for the guards.
|
|
5
|
+
import { curlingFreeGuards } from "./curling.stats.js";
|
|
6
|
+
import { curlingScore, CURLING_COLORS } from "./charts.curling.js";
|
|
7
|
+
import { assemble, clean, drawingOptions, languageOf, percentOf, wordsOf } from "./charts.js";
|
|
8
|
+
import { STONE_DIAMETER } from "./geometry.js";
|
|
9
|
+
import { sheetFor } from "./sheet.js";
|
|
10
|
+
import { shownIce, teesOf } from "./surfaces/curling.js";
|
|
11
|
+
import { foldCrop } from "./surfaces/curling.fold.js";
|
|
12
|
+
import { zoneCounts } from "./heat.js";
|
|
13
|
+
import { envFor } from "./layers.env.js";
|
|
14
|
+
import { fill } from "./strings.js";
|
|
15
|
+
import { convertLength, isUnit } from "./units.js";
|
|
16
|
+
const isNum = (value) => typeof value === "number" && Number.isFinite(value);
|
|
17
|
+
/**
|
|
18
|
+
* A heat chart of the stones of many ends on a house: where they came to rest, counted into cells (`heatmap`, `hexbin`), a density (`contour`), the zones of the house (`zones`) or
|
|
19
|
+
* drawn as translucent discs in their team's colours (`discs`). Only stones in play are drawn, as `curlingScore` judges them. `team` keeps one team's stones, and `only` the free
|
|
20
|
+
* guards or the stones in the house, so that "where do the guards of the team with the hammer end up" is one call. Places are feet from the button, as for `curlingStones`; give
|
|
21
|
+
* the stones of every end together. The totals say how many stones were drawn, the free guards and the stones in the house for each team, and the share in each zone.
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { curlingHeat } from "@johnmorrisdotca/kyogijo-stadiums/charts";
|
|
25
|
+
* const stones = [
|
|
26
|
+
* { x: 0.3, y: 0.2, team: "home" },
|
|
27
|
+
* { x: -1.1, y: 0.9, team: "away" },
|
|
28
|
+
* { x: 0.1, y: -14, team: "away" },
|
|
29
|
+
* { x: 2.4, y: -17, team: "home" },
|
|
30
|
+
* { x: 0, y: -10, team: "home" },
|
|
31
|
+
* ];
|
|
32
|
+
* const chart = curlingHeat(stones, { only: "guards", view: "discs" });
|
|
33
|
+
* console.log(chart.totals.drawn, chart.totals.guards, chart.totals.house);
|
|
34
|
+
* // 3 { home: 2, away: 1 } { home: 1, away: 1 }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function curlingHeat(stones, options = {}) {
|
|
38
|
+
if (!Array.isArray(stones))
|
|
39
|
+
throw new TypeError("stones is a list of stones");
|
|
40
|
+
const view = options.view ?? "heatmap";
|
|
41
|
+
if (!["heatmap", "hexbin", "contour", "zones", "discs"].includes(view))
|
|
42
|
+
throw new RangeError(`Unknown view "${String(view)}": use heatmap, hexbin, contour, zones or discs`);
|
|
43
|
+
const only = options.only ?? "all";
|
|
44
|
+
if (!["all", "guards", "house"].includes(only))
|
|
45
|
+
throw new RangeError(`Unknown only "${String(only)}": use all, guards or house`);
|
|
46
|
+
const unit = options.units ?? "ft";
|
|
47
|
+
if (!isUnit(unit))
|
|
48
|
+
throw new RangeError(`Unknown units "${String(unit)}": use one of ft, in, yd, m, cm, mm`);
|
|
49
|
+
const language = languageOf(options);
|
|
50
|
+
const all = wordsOf(language);
|
|
51
|
+
const w = all.charts;
|
|
52
|
+
const house = options.house === "right" ? "right" : "left";
|
|
53
|
+
const surface = options.surface ?? {};
|
|
54
|
+
const sheet = { show: options.show ?? "half", end: house, guardZone: true, ...(surface.sheet ?? {}) };
|
|
55
|
+
const o = drawingOptions("curling", { ...options, surface: { ...surface, sheet, colors: { layerHome: CURLING_COLORS.home, layerAway: CURLING_COLORS.away, ...(surface.colors ?? {}) } } }, w.titleCurlingHeat, { margin: 4.5 });
|
|
56
|
+
const feet = (value) => convertLength(value, unit, "ft");
|
|
57
|
+
const judged = curlingScore(stones, { units: unit });
|
|
58
|
+
const guards = curlingFreeGuards(stones, { units: unit });
|
|
59
|
+
const guardSet = new Set(guards.indices);
|
|
60
|
+
const teamOf = (s) => (typeof s.team === "string" && s.team !== "" ? s.team : "home");
|
|
61
|
+
const count = { guards: { home: 0, away: 0 }, house: { home: 0, away: 0 } };
|
|
62
|
+
const kept = [];
|
|
63
|
+
let inPlay = 0;
|
|
64
|
+
for (const r of judged.results) {
|
|
65
|
+
if (!r.inPlay)
|
|
66
|
+
continue;
|
|
67
|
+
inPlay += 1;
|
|
68
|
+
const s = stones[r.index];
|
|
69
|
+
const team = teamOf(s);
|
|
70
|
+
if (guardSet.has(r.index) && (team === "home" || team === "away"))
|
|
71
|
+
count.guards[team] += 1;
|
|
72
|
+
if (r.inHouse && (team === "home" || team === "away"))
|
|
73
|
+
count.house[team] += 1;
|
|
74
|
+
if (options.team !== undefined && team !== options.team)
|
|
75
|
+
continue;
|
|
76
|
+
if (only === "guards" && !guardSet.has(r.index))
|
|
77
|
+
continue;
|
|
78
|
+
if (only === "house" && !r.inHouse)
|
|
79
|
+
continue;
|
|
80
|
+
kept.push(clean({ x: feet(s.x), y: feet(s.y), team, title: s.title }));
|
|
81
|
+
}
|
|
82
|
+
const at = { from: "curling", units: "ft", ...(house === "left" ? { attacking: "left" } : {}) };
|
|
83
|
+
// The cells are a foot square by default: the bins are counted across the sheet's length, and a cell that holds no stone is not drawn.
|
|
84
|
+
const cell = isNum(options.cell) && options.cell > 0 ? options.cell : 1;
|
|
85
|
+
const plan = sheetFor(o);
|
|
86
|
+
const bins = Math.min(400, Math.max(1, Math.round(plan.spec.length / cell)));
|
|
87
|
+
// The colour bar is sized and placed against the part of the sheet that is drawn: the half or the house, or on a folded sheet the house end the stones are at, where its panel shows it large.
|
|
88
|
+
const region = plan.show === "folded" ? ((crop) => ({ x: crop.x, y: 0, w: crop.w, h: plan.spec.width }))(foldCrop(plan.spec, teesOf(plan.spec)[house === "left" ? 0 : 1], house, plan.panels)) : shownIce(plan.spec, plan.show, plan.end);
|
|
89
|
+
const heat = { name: w.curlingHeatStat, statistic: "count", statisticName: w.curlingHeatStat, ramp: options.ramp, legend: options.legend === false ? undefined : { position: "bottom", region }, labels: options.labels === true ? true : undefined, ground: "none", minCount: 1 };
|
|
90
|
+
const layers = [];
|
|
91
|
+
if (view === "heatmap")
|
|
92
|
+
layers.push(clean({ kind: "heatmap", id: "heat", ...at, data: kept, bins, ...heat }));
|
|
93
|
+
else if (view === "hexbin")
|
|
94
|
+
layers.push(clean({ kind: "hexbin", id: "heat", ...at, data: kept, size: cell, ...heat }));
|
|
95
|
+
else if (view === "contour")
|
|
96
|
+
layers.push(clean({ kind: "contour", id: "heat", ...at, data: kept, ...heat }));
|
|
97
|
+
else if (view === "zones")
|
|
98
|
+
layers.push(clean({ kind: "zones", id: "heat", ...at, zones: "curling-house", end: house, data: kept, ...heat, labels: options.labels === false ? undefined : true }));
|
|
99
|
+
else
|
|
100
|
+
layers.push({ kind: "points", id: "discs", name: w.curlingStone, ...at, data: kept, style: { size: STONE_DIAMETER, opacity: 0.35 } });
|
|
101
|
+
// The zones of the house, counted for the totals whichever way the stones are drawn.
|
|
102
|
+
const counter = { kind: "zones", id: "zones", ...at, zones: "curling-house", end: house, data: kept };
|
|
103
|
+
const zones = zoneCounts(counter, envFor(o, false)).map((z) => ({ id: z.id, name: z.name, stones: z.n, share: percentOf(z.n, kept.length) }));
|
|
104
|
+
const kinds = [];
|
|
105
|
+
if (options.team !== undefined)
|
|
106
|
+
kinds.push(fill(w.curlingHeatTeam, { name: options.team === "away" ? options.names?.away ?? all.said.away : options.names?.home ?? all.said.home }));
|
|
107
|
+
if (only === "guards")
|
|
108
|
+
kinds.push(w.curlingHeatGuards);
|
|
109
|
+
if (only === "house")
|
|
110
|
+
kinds.push(w.curlingHeatHouse);
|
|
111
|
+
const parts = [fill(w.sumCurlingHeat, { n: inPlay, house: count.house.home + count.house.away, guards: count.guards.home + count.guards.away }), ...kinds];
|
|
112
|
+
const totals = { stones: judged.stones, inPlay, drawn: kept.length, guards: count.guards, house: count.house, zones, skipped: judged.skipped };
|
|
113
|
+
const chart = assemble(o, layers, { title: w.titleCurlingHeat, summary: parts.join(all.said.gap), totals });
|
|
114
|
+
chart.key = [];
|
|
115
|
+
return chart;
|
|
116
|
+
}
|
package/dist/charts.curling.js
CHANGED
|
@@ -9,6 +9,9 @@ import { assemble, clean, drawingOptions, keyItems, keyLayers, languageOf, round
|
|
|
9
9
|
import { fill } from "./strings.js";
|
|
10
10
|
import { outlineOf } from "./outline.js";
|
|
11
11
|
import { isUnit } from "./units.js";
|
|
12
|
+
import { isPortrait } from "./common.js";
|
|
13
|
+
import { foldPlan } from "./surfaces/curling.fold.js";
|
|
14
|
+
import { teesOf } from "./surfaces/curling.js";
|
|
12
15
|
const R = STONE_DIAMETER / 2;
|
|
13
16
|
const EPS = 1e-6;
|
|
14
17
|
/** The colours of the two teams' stones, the handles' colours of a game: red and yellow. */
|
|
@@ -122,7 +125,7 @@ export function curlingStones(stones, options = {}) {
|
|
|
122
125
|
const title = options.end === undefined ? w.titleCurling : `${w.titleCurling}${all.said.sep}${fill(w.curlingEnd, { n: options.end })}`;
|
|
123
126
|
const surface = options.surface ?? {};
|
|
124
127
|
// The two stone colours and the half of the sheet with the free guard zone, under whatever the caller laid over them.
|
|
125
|
-
const sheet = { show: "half", end: house, guardZone: true, ...(surface.sheet ?? {}) };
|
|
128
|
+
const sheet = { show: options.show ?? "half", end: house, guardZone: true, ...(surface.sheet ?? {}) };
|
|
126
129
|
const o = drawingOptions("curling", { ...options, surface: { ...surface, sheet, colors: { layerHome: CURLING_COLORS.home, layerAway: CURLING_COLORS.away, ...(surface.colors ?? {}) } } }, title, { margin: 4.5 });
|
|
127
130
|
const plan = sheetFor(o);
|
|
128
131
|
const totals = curlingScore(stones, { units: options.units, width: plan.spec.width });
|
|
@@ -181,8 +184,16 @@ export function curlingStones(stones, options = {}) {
|
|
|
181
184
|
if (options.key !== false) {
|
|
182
185
|
const { box } = outlineOf(o);
|
|
183
186
|
const tee = house === "left" ? box.w / 2 - CURLING.teeFromMiddle : box.w / 2 + CURLING.teeFromMiddle;
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
if (plan.show === "folded") {
|
|
188
|
+
// A folded sheet's key runs along the top of the whole drawing, above the enlarged house that is there.
|
|
189
|
+
const margin = typeof o.margin === "number" ? convertLength(o.margin, o.units ?? "ft", "ft") : 3;
|
|
190
|
+
const fold = foldPlan(plan.spec, teesOf(plan.spec), { panels: plan.panels, fit: plan.fit, margin, turned: isPortrait(o), id: "k" });
|
|
191
|
+
layers.push(...keyLayers(o, entries, LOOKS, all, [], { x: box.x, w: box.w, y: fold.view.y + margin * 0.5 }));
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const shown = { x: house === "left" ? tee - CURLING.backLine : tee - CURLING.hogLine - 1, w: CURLING.backLine + CURLING.hogLine + 1 };
|
|
195
|
+
layers.push(...keyLayers(o, entries, LOOKS, all, [], shown));
|
|
196
|
+
}
|
|
186
197
|
}
|
|
187
198
|
const teamName = totals.scoring === null ? "" : name(totals.scoring);
|
|
188
199
|
const parts = [
|