@johnmorrisdotca/kyogijo-stadiums 0.49.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ import { pointAlong } from "./circuits.geo.js";
2
+ import { circuitOf, circuitPlan, CIRCUIT_ATTRIBUTION } from "./circuits.js";
3
+ import { KYOGIJO_CIRCUIT_STYLE } from "./circuits.style.js";
4
+ import { CIRCUIT_WORDS } from "./circuits.words.js";
5
+ import { metaMarkup, safeClass } from "./common.js";
6
+ import { el, esc, hash, num } from "./format.js";
7
+ import { FEET_PER_METRE as FT } from "./geometry.js";
8
+ import { fill } from "./strings.core.js";
9
+ import { VERSION } from "./version.js";
10
+ const path = (points, closed) => `M${points.map(([x, y]) => `${num(x)} ${num(y)}`).join("L")}${closed ? "Z" : ""}`;
11
+ const right = ([dx, dy]) => [-dy, dx];
12
+ const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k];
13
+ /** The metres of a length in the words' own way: one decimal of a kilometre. */
14
+ const km = (metres, language) => (metres / 1000).toLocaleString(language === "ja" ? "ja-JP" : "en-US", { minimumFractionDigits: 3, maximumFractionDigits: 3 });
15
+ /** The words of a circuit's drawing: its title and its description, sentence by sentence. */
16
+ export function circuitText(plan, language, standing) {
17
+ const c = plan.circuit;
18
+ const w = CIRCUIT_WORDS[language];
19
+ const name = c.names[language];
20
+ const gap = language === "ja" ? "" : " ";
21
+ const parts = [
22
+ fill(w.lap, { name, km: km(c.length.value, language), measured: km(plan.measured, language) }),
23
+ c.direction.value === "counterclockwise" ? w.counterclockwise : w.clockwise,
24
+ fill(w.turns, { turns: c.corners.value }),
25
+ w.pit,
26
+ ...(plan.building === undefined ? [] : [w.building]),
27
+ ...(c.width.basis === "plausible" ? [fill(w.width, { width: c.width.value })] : []),
28
+ ...(standing === undefined || standing === "" ? [] : [standing]),
29
+ fill(w.credit, { credit: CIRCUIT_ATTRIBUTION }),
30
+ ];
31
+ return { title: fill(w.title, { name }), description: parts.join(gap) };
32
+ }
33
+ /** The badges of the turns' numbers: each beside the outside of its bend, then pushed apart where two would touch, in the same few steps every time. */
34
+ function badgesOf(plan, r, k) {
35
+ const middle = [(plan.box.x0 + plan.box.x1) / 2, (plan.box.y0 + plan.box.y1) / 2];
36
+ const reach = plan.width / 2 + r + 4 * k;
37
+ const placed = plan.turns.map((t) => {
38
+ let normal = right(t.dir);
39
+ if (t.side === 1)
40
+ normal = right(t.dir);
41
+ else if (t.side === -1)
42
+ normal = [-normal[0], -normal[1]];
43
+ else if ((t.at[0] - middle[0]) * normal[0] + (t.at[1] - middle[1]) * normal[1] < 0)
44
+ normal = [-normal[0], -normal[1]];
45
+ return { n: t.n, at: add(t.at, normal, reach), r, from: t.at };
46
+ });
47
+ // Two badges that would touch are pushed apart along the line between them; a badge that would lie on the track is pushed off it.
48
+ for (let round = 0; round < 40; round += 1) {
49
+ let moved = false;
50
+ for (let i = 0; i < placed.length; i += 1) {
51
+ for (let j = i + 1; j < placed.length; j += 1) {
52
+ const [a, b] = [placed[i], placed[j]];
53
+ const dx = b.at[0] - a.at[0];
54
+ const dy = b.at[1] - a.at[1];
55
+ const d = Math.hypot(dx, dy);
56
+ const want = 2 * r + 2 * k;
57
+ if (d >= want)
58
+ continue;
59
+ const [ux, uy] = d < 1e-6 ? [1, 0] : [dx / d, dy / d];
60
+ const push = (want - d) / 2 + 0.01;
61
+ a.at = [a.at[0] - ux * push, a.at[1] - uy * push];
62
+ b.at = [b.at[0] + ux * push, b.at[1] + uy * push];
63
+ moved = true;
64
+ }
65
+ }
66
+ for (const b of placed) {
67
+ for (let i = 0; i < plan.lap.length; i += 1) {
68
+ const p = plan.lap[i];
69
+ const q = plan.lap[(i + 1) % plan.lap.length];
70
+ const ex = q[0] - p[0];
71
+ const ey = q[1] - p[1];
72
+ const len2 = ex * ex + ey * ey || 1;
73
+ const t = Math.min(1, Math.max(0, ((b.at[0] - p[0]) * ex + (b.at[1] - p[1]) * ey) / len2));
74
+ const near = [p[0] + t * ex, p[1] + t * ey];
75
+ const d = Math.hypot(b.at[0] - near[0], b.at[1] - near[1]);
76
+ const want = plan.width / 2 + r + k;
77
+ if (d >= want || d < 1e-6)
78
+ continue;
79
+ b.at = add(near, [(b.at[0] - near[0]) / d, (b.at[1] - near[1]) / d], want + 0.01);
80
+ moved = true;
81
+ }
82
+ }
83
+ if (!moved)
84
+ break;
85
+ }
86
+ return placed;
87
+ }
88
+ /** The finish line: two rows of squares across the track, black and white in turn, along the way the lap runs. */
89
+ function chequer(at, dir, width) {
90
+ const cells = 6;
91
+ const s = width / cells;
92
+ const across = right(dir);
93
+ let out = "";
94
+ for (let row = 0; row < 2; row += 1) {
95
+ for (let i = 0; i < cells; i += 1) {
96
+ const o = add(add(at, across, -width / 2 + i * s), dir, (row - 1) * s);
97
+ const corners = [o, add(o, across, s), add(add(o, across, s), dir, s), add(o, dir, s)];
98
+ out += el("path", { class: (i + row) % 2 === 0 ? "ky-ci-check-a" : "ky-ci-check-b", d: path(corners, true) });
99
+ }
100
+ }
101
+ return el("g", { class: "ky-ci-finish" }, out);
102
+ }
103
+ /** The north arrow at `at`, pointing `north` degrees clockwise from the top of the page, `size` long, with an N drawn as strokes (no font). */
104
+ function northArrow(at, north, size, label) {
105
+ const a = (north * Math.PI) / 180;
106
+ const u = [Math.sin(a), -Math.cos(a)];
107
+ const v = right(u);
108
+ const p = (along, side = 0) => {
109
+ const q = add(add(at, u, along * size), v, side * size);
110
+ return `${num(q[0])} ${num(q[1])}`;
111
+ };
112
+ const arrow = el("path", { class: "ky-ci-north", d: `M${p(-0.5)}L${p(0.5)}M${p(0.5)}L${p(0.18, 0.16)}L${p(0.18, -0.16)}Z`, "stroke-width": num(size * 0.06) });
113
+ // The N stands upright beside the arrow's tail, whichever way the arrow points.
114
+ const n = add(at, u, -0.5 * size - 0.32 * size);
115
+ const h = size * 0.16;
116
+ const letter = el("path", { class: "ky-ci-north-n", d: `M${num(n[0] - h * 0.75)} ${num(n[1] + h)}V${num(n[1] - h)}L${num(n[0] + h * 0.75)} ${num(n[1] + h)}V${num(n[1] - h)}`, "stroke-width": num(size * 0.055) });
117
+ return el("g", { class: "ky-ci-compass", "aria-label": label, role: "img" }, arrow + letter);
118
+ }
119
+ /** A scale bar of a round number of metres that is about a fifth of the drawing's width, at `at` (its left end), with its numbers. */
120
+ function scaleBar(at, width, k, unit) {
121
+ const steps = [100, 200, 250, 500, 1000, 2000];
122
+ const metres = steps.reduce((best, s) => (Math.abs(s * FT - width / 5) < Math.abs(best * FT - width / 5) ? s : best), steps[0]);
123
+ const long = metres * FT;
124
+ const h = 5 * k;
125
+ const text = 15 * k;
126
+ let out = el("rect", { class: "ky-ci-scale-fill", x: at[0], y: at[1] - h, width: long / 2, height: h });
127
+ out += el("rect", { class: "ky-ci-scale", x: at[0], y: at[1] - h, width: long, height: h, "stroke-width": num(1.4 * k) });
128
+ for (const [x, label] of [[0, "0"], [long / 2, String(metres / 2)], [long, `${metres} ${unit}`]]) {
129
+ out += el("text", { class: "ky-ci-scale-text", x: at[0] + x, y: at[1] - h - 5 * k, "font-size": num(text), "text-anchor": x === 0 ? "start" : x === long ? "end" : "middle" }, esc(label));
130
+ }
131
+ return el("g", { class: "ky-ci-scalebar", "data-metres": metres }, out);
132
+ }
133
+ /** The arrows along the lap: a few, evenly round it, each pointing the way the lap runs. */
134
+ function arrows(plan, k) {
135
+ const count = 8;
136
+ const total = plan.lap.reduce((sum, p, i) => sum + Math.hypot(plan.lap[(i + 1) % plan.lap.length][0] - p[0], plan.lap[(i + 1) % plan.lap.length][1] - p[1]), 0);
137
+ const size = Math.max(plan.width * 1.2, 15 * k);
138
+ let out = "";
139
+ for (let i = 0; i < count; i += 1) {
140
+ // Halfway between two of the evenly spaced places, so that none sits on the start.
141
+ const { at, dir } = pointAlong(plan.lap, plan.start.along + ((i + 0.5) * total) / count);
142
+ const side = right(dir);
143
+ const tip = add(at, dir, size * 0.6);
144
+ const back = add(at, dir, -size * 0.5);
145
+ out += el("path", { class: "ky-ci-arrow", d: path([tip, add(back, side, size * 0.45), add(at, dir, -size * 0.15), add(back, side, -size * 0.45)], true), "stroke-width": num(k * 0.8) });
146
+ }
147
+ return el("g", { class: "ky-ci-arrows" }, out);
148
+ }
149
+ /** A circuit's drawing and what was worked out to make it. */
150
+ export function circuitDrawing(options) {
151
+ const circuit = circuitOf(options.circuit);
152
+ const language = options.language === "ja" ? "ja" : "en";
153
+ const words = CIRCUIT_WORDS[language];
154
+ const plan = circuitPlan(circuit);
155
+ const label = options.label !== false;
156
+ const showTurns = options.turns !== false;
157
+ const base = Math.max(plan.box.x1 - plan.box.x0, plan.box.y1 - plan.box.y0);
158
+ const k = base / 1000;
159
+ const pad = 46 * k;
160
+ const head = label ? 70 * k : 0;
161
+ const foot = 44 * k;
162
+ const viewBox = { x: plan.box.x0 - pad, y: plan.box.y0 - pad - head, w: plan.box.x1 - plan.box.x0 + 2 * pad, h: plan.box.y1 - plan.box.y0 + 2 * pad + head + foot };
163
+ const gid = safeClass(options.id).replace(/ /g, "-") || `ky-ci-${hash(JSON.stringify([options, VERSION]))}`;
164
+ const { title, description } = circuitText(plan, language);
165
+ const r = 13 * k;
166
+ const placed = badgesOf(plan, r, k);
167
+ let body = el("rect", { class: "ky-ci-ground", x: viewBox.x, y: viewBox.y, width: viewBox.w, height: viewBox.h });
168
+ body += el("path", { class: "ky-ci-area", d: path(plan.area, true), "stroke-width": num(1.2 * k) });
169
+ if (plan.building !== undefined)
170
+ body += el("path", { class: "ky-ci-building", d: path(plan.building.ring, true), "stroke-width": num(1.2 * k), "data-height": num(plan.building.height / FT) });
171
+ const lapD = path(plan.lap, true);
172
+ const pitD = path(plan.pitLane, false);
173
+ const edge = Math.max(plan.width * 0.14, 1.1 * k);
174
+ body += el("path", { class: "ky-ci-halo", d: lapD, "stroke-width": num(plan.width + 9 * k) });
175
+ body += el("path", { class: "ky-ci-edge ky-ci-pit-edge", d: pitD, "stroke-width": num(plan.pitWidth + 2 * edge) });
176
+ body += el("path", { class: "ky-ci-pit", d: pitD, "stroke-width": num(plan.pitWidth), "data-part": "pit-lane" });
177
+ body += el("path", { class: "ky-ci-edge", d: lapD, "stroke-width": num(plan.width + 2 * edge) });
178
+ body += el("path", { class: "ky-ci-track", d: lapD, "stroke-width": num(plan.width), "data-part": "lap" });
179
+ if (options.arrows !== false)
180
+ body += arrows(plan, k);
181
+ // The start line: one white line across the track; the finish: a chequered band across it.
182
+ const across = right(plan.start.dir);
183
+ const s0 = add(plan.start.at, across, -plan.width / 2);
184
+ const s1 = add(plan.start.at, across, plan.width / 2);
185
+ body += el("path", { class: "ky-ci-start", d: path([s0, s1], false), "stroke-width": num(Math.max(1.6 * FT, 1.2 * k)), "data-part": "start" });
186
+ body += chequer(plan.finish.at, plan.finish.dir, plan.width);
187
+ // The turns' numbers, each with a thin line to its bend.
188
+ let turns = "";
189
+ for (const b of showTurns ? placed : []) {
190
+ const toward = Math.hypot(b.at[0] - b.from[0], b.at[1] - b.from[1]);
191
+ const lead = toward < 1e-6 ? b.from : add(b.from, [(b.at[0] - b.from[0]) / toward, (b.at[1] - b.from[1]) / toward], plan.width / 2);
192
+ let inner = el("path", { class: "ky-ci-leader", d: path([lead, b.at], false), "stroke-width": num(1.1 * k) });
193
+ inner += el("circle", { class: "ky-ci-badge", cx: b.at[0], cy: b.at[1], r: b.r, "stroke-width": num(1.4 * k) });
194
+ inner += el("text", { class: "ky-ci-badge-n", x: b.at[0], y: b.at[1] + b.r * 0.4, "font-size": num(b.r * (b.n >= 10 ? 1.05 : 1.25)) }, String(b.n));
195
+ turns += el("g", { class: "ky-ci-turn", "data-turn": b.n, "aria-label": fill(words.turn, { n: b.n }) }, inner);
196
+ }
197
+ body += el("g", { class: "ky-ci-turns" }, turns);
198
+ // The name and the facts in the top left; the north arrow in the top right; the scale bar and the credit along the foot.
199
+ if (label) {
200
+ body += el("text", { class: "ky-ci-name", x: viewBox.x + pad * 0.6, y: viewBox.y + 36 * k, "font-size": num(26 * k) }, esc(circuit.names[language]));
201
+ body += el("text", { class: "ky-ci-facts", x: viewBox.x + pad * 0.6, y: viewBox.y + 60 * k, "font-size": num(16 * k) }, esc(fill(words.facts, { km: km(circuit.length.value, language), turns: circuit.corners.value })));
202
+ }
203
+ body += northArrow([viewBox.x + viewBox.w - pad * 0.6 - 20 * k, viewBox.y + (label ? 44 * k : pad)], plan.north, 40 * k, words.northArrow);
204
+ body += scaleBar([viewBox.x + pad * 0.6, viewBox.y + viewBox.h - 14 * k], viewBox.w, k, words.metres);
205
+ body += el("text", { class: "ky-ci-credit", x: viewBox.x + viewBox.w - pad * 0.6, y: viewBox.y + viewBox.h - 14 * k, "font-size": num(11 * k), "text-anchor": "end" }, esc(CIRCUIT_ATTRIBUTION));
206
+ const titleId = `${gid}-t`;
207
+ const descId = `${gid}-d`;
208
+ const css = options.css === "none" ? "" : `<style>${KYOGIJO_CIRCUIT_STYLE}</style>`;
209
+ const svg = el("svg", {
210
+ xmlns: "http://www.w3.org/2000/svg",
211
+ class: `kyogijo ky-circuit${safeClass(options.className) === "" ? "" : ` ${safeClass(options.className)}`}`,
212
+ viewBox: `${num(viewBox.x)} ${num(viewBox.y)} ${num(viewBox.w)} ${num(viewBox.h)}`,
213
+ role: "img",
214
+ "aria-labelledby": `${titleId} ${descId}`,
215
+ "data-circuit": circuit.id,
216
+ "data-direction": circuit.direction.value,
217
+ "data-theme": options.theme === "light" || options.theme === "dark" ? options.theme : undefined,
218
+ "data-version": VERSION,
219
+ }, metaMarkup(titleId, title, descId, description) + css + body);
220
+ return { svg, plan, viewBox, k, badges: placed.map(({ n, at, r: radius }) => ({ n, at, r: radius })), title, description };
221
+ }
222
+ /**
223
+ * A circuit as one SVG string: the lap as a ribbon of the track's width on the ways OpenStreetMap maps for it, the pit lane and the circuit's ground with its pit building, the start and the chequered finish across the
224
+ * track, arrows the way the lap runs, each turn's number beside its bend where OpenStreetMap numbers it, a north arrow, a scale bar and the credit of the map's data. Light and dark follow the page.
225
+ * @example
226
+ * ```ts
227
+ * import { drawCircuit } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
228
+ * const svg = drawCircuit({ circuit: "las-vegas" });
229
+ * console.log(svg.startsWith("<svg"), svg.includes('data-turn="17"'), svg.includes("OpenStreetMap"));
230
+ * // true true true
231
+ * ```
232
+ */
233
+ export function drawCircuit(options) {
234
+ return circuitDrawing(options).svg;
235
+ }
236
+ /**
237
+ * A circuit's facts as a table, each with its basis (a source's, or ours) and the length the drawn line measures: for a reader who cannot see the drawing, and for a page that wants the data.
238
+ * @example
239
+ * ```ts
240
+ * import { circuitTable } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
241
+ * const table = circuitTable({ circuit: "las-vegas" });
242
+ * console.log(table.startsWith("<table"), table.includes("6.201 km"));
243
+ * // true true
244
+ * ```
245
+ */
246
+ export function circuitTable(options) {
247
+ const language = options.language === "ja" ? "ja" : "en";
248
+ const w = CIRCUIT_WORDS[language].table;
249
+ const plan = circuitPlan(circuitOf(options.circuit));
250
+ const c = plan.circuit;
251
+ const basis = (b) => (b === "documented" ? w.documented : w.plausible);
252
+ const rows = [
253
+ [w.length, `${km(c.length.value, language)} km`, basis(c.length.basis)],
254
+ [w.measured, `${km(plan.measured, language)} km`, "OpenStreetMap"],
255
+ [w.corners, String(c.corners.value), basis(c.corners.basis)],
256
+ [w.direction, c.direction.value === "counterclockwise" ? w.counterclockwise : w.clockwise, basis(c.direction.basis)],
257
+ [w.opened, c.opened.value, basis(c.opened.basis)],
258
+ [w.width, `${c.width.value} m`, basis(c.width.basis)],
259
+ [w.pitLaneWidth, `${c.pitLaneWidth.value} m`, basis(c.pitLaneWidth.basis)],
260
+ [w.wikidata, c.wikidata, "Wikidata"],
261
+ ];
262
+ const head = `<thead><tr><th scope="col">${esc(w.fact)}</th><th scope="col">${esc(w.value)}</th><th scope="col">${esc(w.basis)}</th></tr></thead>`;
263
+ const body = rows.map(([a, b, d]) => `<tr><th scope="row">${esc(a)}</th><td>${esc(b)}</td><td>${esc(d)}</td></tr>`).join("");
264
+ return `<table class="ky-table" data-kind="circuit"><caption>${esc(fill(w.caption, { name: c.names[language] }))}</caption>${head}<tbody>${body}</tbody></table>`;
265
+ }
@@ -0,0 +1,29 @@
1
+ import type { Pt } from "./crowd.ts";
2
+ /** The length of a line, closed into a ring when `closed`. */
3
+ export declare function lineLength(points: readonly Pt[], closed: boolean): number;
4
+ /** An open line with the points that lie within `tolerance` of the line between their neighbours taken out (Douglas and Peucker); the two ends are kept. */
5
+ export declare function simplifyLine(points: readonly Pt[], tolerance: number): Pt[];
6
+ /** A ring (the first point not repeated) made simpler as a line from its first point round to it again, so that the first point, the start of a lap, stays where it is. */
7
+ export declare function simplifyLoop(points: readonly Pt[], tolerance: number): Pt[];
8
+ /** Where on a line (closed when `closed`) the point nearest `p` is: the segment it is on (from point `i` to the next), how far round from the first point that is, the point itself and the distance to it. */
9
+ export declare function nearestOn(points: readonly Pt[], p: Pt, closed: boolean): {
10
+ i: number;
11
+ along: number;
12
+ at: Pt;
13
+ d: number;
14
+ };
15
+ /** The point and the unit direction of a ring `along` units round from its first point (wrapped round the lap). */
16
+ export declare function pointAlong(points: readonly Pt[], along: number): {
17
+ at: Pt;
18
+ dir: Pt;
19
+ };
20
+ /**
21
+ * Which way a ring bends at `along`, worked out over `reach` either side: the unit direction of travel there and the side the bend turns to (`1` to the left of travel in a frame whose y grows down the page, as an SVG's does,
22
+ * `-1` to the right, `0` straight).
23
+ */
24
+ export declare function bendAt(points: readonly Pt[], along: number, reach: number): {
25
+ dir: Pt;
26
+ side: -1 | 0 | 1;
27
+ };
28
+ /** The signed area of a ring (the shoelace sum, halved): positive when it runs clockwise on a page whose y grows down. */
29
+ export declare function signedAreaOf(points: readonly Pt[]): number;
@@ -0,0 +1,105 @@
1
+ /** The length of a line, closed into a ring when `closed`. */
2
+ export function lineLength(points, closed) {
3
+ let sum = 0;
4
+ const last = closed ? points.length : points.length - 1;
5
+ for (let i = 0; i < last; i += 1) {
6
+ const p = points[i];
7
+ const q = points[(i + 1) % points.length];
8
+ sum += Math.hypot(q[0] - p[0], q[1] - p[1]);
9
+ }
10
+ return sum;
11
+ }
12
+ /** The distance from `p` to the segment from `a` to `b`, and how far along it (0 to 1) the nearest point is. */
13
+ function toSegment(p, a, b) {
14
+ const dx = b[0] - a[0];
15
+ const dy = b[1] - a[1];
16
+ const len2 = dx * dx + dy * dy;
17
+ const t = len2 === 0 ? 0 : Math.min(1, Math.max(0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2));
18
+ return { d: Math.hypot(a[0] + t * dx - p[0], a[1] + t * dy - p[1]), t };
19
+ }
20
+ /** An open line with the points that lie within `tolerance` of the line between their neighbours taken out (Douglas and Peucker); the two ends are kept. */
21
+ export function simplifyLine(points, tolerance) {
22
+ if (points.length < 3)
23
+ return [...points];
24
+ const keep = new Array(points.length).fill(false);
25
+ keep[0] = true;
26
+ keep[points.length - 1] = true;
27
+ const stack = [[0, points.length - 1]];
28
+ while (stack.length > 0) {
29
+ const [from, to] = stack.pop();
30
+ let at = -1;
31
+ let most = tolerance;
32
+ for (let i = from + 1; i < to; i += 1) {
33
+ const { d } = toSegment(points[i], points[from], points[to]);
34
+ if (d > most)
35
+ [at, most] = [i, d];
36
+ }
37
+ if (at >= 0) {
38
+ keep[at] = true;
39
+ stack.push([from, at], [at, to]);
40
+ }
41
+ }
42
+ return points.filter((_, i) => keep[i]);
43
+ }
44
+ /** A ring (the first point not repeated) made simpler as a line from its first point round to it again, so that the first point, the start of a lap, stays where it is. */
45
+ export function simplifyLoop(points, tolerance) {
46
+ const line = simplifyLine([...points, points[0]], tolerance);
47
+ return line.slice(0, -1);
48
+ }
49
+ /** Where on a line (closed when `closed`) the point nearest `p` is: the segment it is on (from point `i` to the next), how far round from the first point that is, the point itself and the distance to it. */
50
+ export function nearestOn(points, p, closed) {
51
+ let best = { i: 0, along: 0, at: points[0], d: Infinity };
52
+ let walked = 0;
53
+ const last = closed ? points.length : points.length - 1;
54
+ for (let i = 0; i < last; i += 1) {
55
+ const a = points[i];
56
+ const b = points[(i + 1) % points.length];
57
+ const long = Math.hypot(b[0] - a[0], b[1] - a[1]);
58
+ const { d, t } = toSegment(p, a, b);
59
+ if (d < best.d)
60
+ best = { i, along: walked + t * long, at: [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])], d };
61
+ walked += long;
62
+ }
63
+ return best;
64
+ }
65
+ /** The point and the unit direction of a ring `along` units round from its first point (wrapped round the lap). */
66
+ export function pointAlong(points, along) {
67
+ const total = lineLength(points, true);
68
+ let left = ((along % total) + total) % total;
69
+ for (let i = 0; i < points.length; i += 1) {
70
+ const a = points[i];
71
+ const b = points[(i + 1) % points.length];
72
+ const long = Math.hypot(b[0] - a[0], b[1] - a[1]);
73
+ if (left <= long || i === points.length - 1) {
74
+ const t = long === 0 ? 0 : Math.min(1, left / long);
75
+ return { at: [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])], dir: long === 0 ? [1, 0] : [(b[0] - a[0]) / long, (b[1] - a[1]) / long] };
76
+ }
77
+ left -= long;
78
+ }
79
+ return { at: points[0], dir: [1, 0] };
80
+ }
81
+ /**
82
+ * Which way a ring bends at `along`, worked out over `reach` either side: the unit direction of travel there and the side the bend turns to (`1` to the left of travel in a frame whose y grows down the page, as an SVG's does,
83
+ * `-1` to the right, `0` straight).
84
+ */
85
+ export function bendAt(points, along, reach) {
86
+ const before = pointAlong(points, along - reach).at;
87
+ const here = pointAlong(points, along);
88
+ const after = pointAlong(points, along + reach).at;
89
+ const a = [here.at[0] - before[0], here.at[1] - before[1]];
90
+ const b = [after[0] - here.at[0], after[1] - here.at[1]];
91
+ const cross = a[0] * b[1] - a[1] * b[0];
92
+ const scale = Math.hypot(...a) * Math.hypot(...b);
93
+ const side = scale === 0 || Math.abs(cross / scale) < 0.02 ? 0 : cross < 0 ? 1 : -1;
94
+ return { dir: here.dir, side };
95
+ }
96
+ /** The signed area of a ring (the shoelace sum, halved): positive when it runs clockwise on a page whose y grows down. */
97
+ export function signedAreaOf(points) {
98
+ let sum = 0;
99
+ for (let i = 0; i < points.length; i += 1) {
100
+ const p = points[i];
101
+ const q = points[(i + 1) % points.length];
102
+ sum += p[0] * q[1] - q[0] * p[1];
103
+ }
104
+ return sum / 2;
105
+ }
@@ -0,0 +1,73 @@
1
+ import { CIRCUIT_DATA } from "./circuits.data.js";
2
+ import { CIRCUIT_OSM } from "./circuits.osm.data.js";
3
+ import { bendAt, lineLength, nearestOn } from "./circuits.geo.js";
4
+ import { FEET_PER_METRE as FT } from "./geometry.js";
5
+ import { metresFrom, northOn, smallestBox, turnedTo } from "./venues.geo.js";
6
+ /** The credit a drawing made from OpenStreetMap's data carries (the same words as the venues'). */
7
+ export const CIRCUIT_ATTRIBUTION = "(c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright";
8
+ /** Every circuit, in the order of the data. */
9
+ export const CIRCUITS = Object.freeze(CIRCUIT_DATA.map((c) => {
10
+ const osm = CIRCUIT_OSM[c.id];
11
+ if (osm === undefined)
12
+ throw new Error(`the circuit ${c.id} has no OpenStreetMap record`);
13
+ return Object.freeze({ ...c, osm });
14
+ }));
15
+ /** The keys of the circuits. */
16
+ export const CIRCUIT_IDS = Object.freeze(CIRCUITS.map((c) => c.id));
17
+ /** A circuit by its key, or `undefined`. */
18
+ export const circuitById = (id) => CIRCUITS.find((c) => c.id === id);
19
+ const points = (osm) => [...osm.lap, ...osm.pitLane.line, ...osm.area.ring, ...(osm.pitBuilding?.ring ?? [])];
20
+ /** A circuit's plan: its lap, pit lane, ground, pit building, turns, start and finish in the drawing's frame. */
21
+ export function circuitPlan(circuit) {
22
+ const osm = circuit.osm;
23
+ const all = points(osm);
24
+ const lons = all.map((p) => p[0]);
25
+ const lats = all.map((p) => p[1]);
26
+ const origin = [(Math.min(...lons) + Math.max(...lons)) / 2, (Math.min(...lats) + Math.max(...lats)) / 2];
27
+ const lapMetres = metresFrom(origin, osm.lap);
28
+ const heading = smallestBox(lapMetres).heading;
29
+ const frame = (ring) => metresFrom(origin, ring).map((p) => {
30
+ const [x, y] = turnedTo(heading, p);
31
+ return [x * FT, y * FT];
32
+ });
33
+ const lap = frame(osm.lap);
34
+ const place = (at) => {
35
+ const near = nearestOn(lap, frame([at])[0], true);
36
+ const bend = bendAt(lap, near.along, 30 * FT);
37
+ return { at: near.at, dir: bend.dir, along: near.along };
38
+ };
39
+ const turns = osm.turns.map((t) => {
40
+ const near = nearestOn(lap, frame([t.at])[0], true);
41
+ const bend = bendAt(lap, near.along, 40 * FT);
42
+ return { n: t.n, at: near.at, along: near.along, dir: bend.dir, side: bend.side };
43
+ });
44
+ const pitLane = frame(osm.pitLane.line);
45
+ const area = frame(osm.area.ring);
46
+ const building = osm.pitBuilding === undefined ? undefined : { ring: frame(osm.pitBuilding.ring), height: osm.pitBuilding.height * FT };
47
+ const xs = [...lap, ...pitLane, ...area, ...(building?.ring ?? [])].map((p) => p[0]);
48
+ const ys = [...lap, ...pitLane, ...area, ...(building?.ring ?? [])].map((p) => p[1]);
49
+ return {
50
+ circuit,
51
+ origin,
52
+ heading,
53
+ north: northOn(heading),
54
+ lap,
55
+ measured: lineLength(lapMetres, true),
56
+ pitLane,
57
+ area,
58
+ ...(building === undefined ? {} : { building }),
59
+ turns,
60
+ start: place(osm.start.at),
61
+ finish: place(osm.finish.at),
62
+ width: circuit.width.value * FT,
63
+ pitWidth: circuit.pitLaneWidth.value * FT,
64
+ box: { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) },
65
+ };
66
+ }
67
+ /** The circuit a set of options names, or an error that lists the keys. */
68
+ export function circuitOf(id) {
69
+ const found = circuitById(id);
70
+ if (found === undefined)
71
+ throw new Error(`no circuit "${String(id)}": the circuits are ${CIRCUIT_IDS.join(", ")}`);
72
+ return found;
73
+ }
@@ -0,0 +1,2 @@
1
+ import type { CircuitOsm } from "./circuits.types.ts";
2
+ export declare const CIRCUIT_OSM: Record<string, CircuitOsm>;
@@ -0,0 +1,188 @@
1
+ export const CIRCUIT_OSM = {
2
+ "las-vegas": {
3
+ "source": "relation/16696508",
4
+ "read": "2026-11-03",
5
+ "base": "2026-11-03T08:11:25Z",
6
+ "licence": "ODbL-1.0",
7
+ "attribution": "(c) OpenStreetMap contributors, ODbL 1.0, openstreetmap.org/copyright",
8
+ "points": 228,
9
+ "tolerance": 0.5,
10
+ "start": { "source": "node/13016217986", "at": [-115.161882, 36.109284] },
11
+ "finish": { "source": "node/13016217985", "at": [-115.16256, 36.108665] },
12
+ "turns": [
13
+ { "n": 1, "source": "node/11348753612", "at": [-115.160364, 36.1107] },
14
+ { "n": 2, "source": "node/11348753615", "at": [-115.160527, 36.111068] },
15
+ { "n": 3, "source": "node/11348753622", "at": [-115.162922, 36.110443] },
16
+ { "n": 4, "source": "node/5717585567", "at": [-115.163928, 36.111154] },
17
+ { "n": 5, "source": "node/137743393", "at": [-115.163995, 36.119208] },
18
+ { "n": 6, "source": "node/12626696512", "at": [-115.161185, 36.119173] },
19
+ { "n": 7, "source": "node/12638889512", "at": [-115.160113, 36.120741] },
20
+ { "n": 8, "source": "node/12638889528", "at": [-115.160671, 36.120939] },
21
+ { "n": 9, "source": "node/12059212382", "at": [-115.160406, 36.122236] },
22
+ { "n": 10, "source": "node/137402143", "at": [-115.165999, 36.123073] },
23
+ { "n": 11, "source": "node/13250780586", "at": [-115.166888, 36.124717] },
24
+ { "n": 12, "source": "node/1490596713", "at": [-115.169378, 36.125559] },
25
+ { "n": 13, "source": "node/1598856720", "at": [-115.172521, 36.120102] },
26
+ { "n": 14, "source": "node/2852160857", "at": [-115.173152, 36.108811] },
27
+ { "n": 15, "source": "node/2852160859", "at": [-115.172805, 36.108527] },
28
+ { "n": 16, "source": "node/2852160864", "at": [-115.171797, 36.10798] },
29
+ { "n": 17, "source": "node/13341696778", "at": [-115.163901, 36.10797] },
30
+ ],
31
+ "pitLane": { "source": "way/1223479152", "line": [
32
+ [-115.163901, 36.10797],
33
+ [-115.163719, 36.108128],
34
+ [-115.163534, 36.108211],
35
+ [-115.163399, 36.108284],
36
+ [-115.163327, 36.108348],
37
+ [-115.163159, 36.108544],
38
+ [-115.163088, 36.108588],
39
+ [-115.163006, 36.108606],
40
+ [-115.162864, 36.108617],
41
+ [-115.162772, 36.108643],
42
+ [-115.160585, 36.110632],
43
+ [-115.160527, 36.110733],
44
+ [-115.160525, 36.110799],
45
+ [-115.160546, 36.110885],
46
+ [-115.160615, 36.110975],
47
+ [-115.160786, 36.111152],
48
+ ] },
49
+ "area": { "source": "way/233786564", "ring": [
50
+ [-115.159474, 36.108123],
51
+ [-115.159465, 36.109009],
52
+ [-115.159463, 36.111419],
53
+ [-115.163831, 36.111463],
54
+ [-115.16387, 36.111421],
55
+ [-115.163831, 36.108924],
56
+ [-115.16384, 36.108175],
57
+ [-115.163275, 36.108185],
58
+ [-115.163113, 36.108111],
59
+ ] },
60
+ "pitBuilding": { "source": "way/1198427833", "height": 19.5, "levels": 4, "ring": [
61
+ [-115.160882, 36.110929],
62
+ [-115.160837, 36.110969],
63
+ [-115.160746, 36.110905],
64
+ [-115.160791, 36.110864],
65
+ [-115.160627, 36.110748],
66
+ [-115.162844, 36.108712],
67
+ [-115.16312, 36.108908],
68
+ [-115.162957, 36.109058],
69
+ [-115.163191, 36.108998],
70
+ [-115.163351, 36.109117],
71
+ [-115.162718, 36.109277],
72
+ [-115.16103, 36.110828],
73
+ [-115.161186, 36.11098],
74
+ [-115.161052, 36.111078],
75
+ [-115.160911, 36.111007],
76
+ [-115.160944, 36.110973],
77
+ ] },
78
+ "lap": [
79
+ [-115.161882, 36.109284],
80
+ [-115.160457, 36.110584],
81
+ [-115.160364, 36.1107],
82
+ [-115.160339, 36.110795],
83
+ [-115.160346, 36.110874],
84
+ [-115.160416, 36.110984],
85
+ [-115.160527, 36.111068],
86
+ [-115.160644, 36.111116],
87
+ [-115.160786, 36.111152],
88
+ [-115.160999, 36.111176],
89
+ [-115.161206, 36.111167],
90
+ [-115.161388, 36.111125],
91
+ [-115.161573, 36.111048],
92
+ [-115.16184, 36.110903],
93
+ [-115.162253, 36.110584],
94
+ [-115.162388, 36.110509],
95
+ [-115.162551, 36.110465],
96
+ [-115.162753, 36.110441],
97
+ [-115.162922, 36.110443],
98
+ [-115.163104, 36.110489],
99
+ [-115.163265, 36.11054],
100
+ [-115.163431, 36.110615],
101
+ [-115.163611, 36.11074],
102
+ [-115.163758, 36.110892],
103
+ [-115.163928, 36.111154],
104
+ [-115.163941, 36.111772],
105
+ [-115.163941, 36.11265],
106
+ [-115.163969, 36.114338],
107
+ [-115.163963, 36.115729],
108
+ [-115.163995, 36.119208],
109
+ [-115.162977, 36.119205],
110
+ [-115.161185, 36.119173],
111
+ [-115.160922, 36.119319],
112
+ [-115.160561, 36.119603],
113
+ [-115.1604, 36.119784],
114
+ [-115.160199, 36.120147],
115
+ [-115.160142, 36.120366],
116
+ [-115.160112, 36.120615],
117
+ [-115.160113, 36.120741],
118
+ [-115.160393, 36.120792],
119
+ [-115.160477, 36.120812],
120
+ [-115.160518, 36.12083],
121
+ [-115.160612, 36.120889],
122
+ [-115.160671, 36.120939],
123
+ [-115.160701, 36.120982],
124
+ [-115.160739, 36.121078],
125
+ [-115.160746, 36.121199],
126
+ [-115.160715, 36.121326],
127
+ [-115.160619, 36.121508],
128
+ [-115.160428, 36.121821],
129
+ [-115.160418, 36.121862],
130
+ [-115.160406, 36.122236],
131
+ [-115.161621, 36.122255],
132
+ [-115.163038, 36.122292],
133
+ [-115.16404, 36.12229],
134
+ [-115.164161, 36.122469],
135
+ [-115.164789, 36.122495],
136
+ [-115.165233, 36.122578],
137
+ [-115.165486, 36.122681],
138
+ [-115.165771, 36.122882],
139
+ [-115.165999, 36.123073],
140
+ [-115.166099, 36.123197],
141
+ [-115.166263, 36.123475],
142
+ [-115.166719, 36.124462],
143
+ [-115.1668, 36.124594],
144
+ [-115.166888, 36.124717],
145
+ [-115.166993, 36.124807],
146
+ [-115.167403, 36.125109],
147
+ [-115.167607, 36.125224],
148
+ [-115.167978, 36.125335],
149
+ [-115.168348, 36.125409],
150
+ [-115.169152, 36.125532],
151
+ [-115.169378, 36.125559],
152
+ [-115.170175, 36.124296],
153
+ [-115.170827, 36.123304],
154
+ [-115.1716, 36.122216],
155
+ [-115.172014, 36.121456],
156
+ [-115.17229, 36.120739],
157
+ [-115.172521, 36.120102],
158
+ [-115.172634, 36.119746],
159
+ [-115.172763, 36.119258],
160
+ [-115.172829, 36.118921],
161
+ [-115.172938, 36.118154],
162
+ [-115.172984, 36.117475],
163
+ [-115.172992, 36.115944],
164
+ [-115.172983, 36.11477],
165
+ [-115.17303, 36.114029],
166
+ [-115.173048, 36.113017],
167
+ [-115.173134, 36.110958],
168
+ [-115.173145, 36.109883],
169
+ [-115.173142, 36.109147],
170
+ [-115.173152, 36.108811],
171
+ [-115.172561, 36.108329],
172
+ [-115.172342, 36.108166],
173
+ [-115.172192, 36.108065],
174
+ [-115.172108, 36.108025],
175
+ [-115.172026, 36.108002],
176
+ [-115.171915, 36.107986],
177
+ [-115.171283, 36.107967],
178
+ [-115.170195, 36.107965],
179
+ [-115.168707, 36.10795],
180
+ [-115.164027, 36.107952],
181
+ [-115.163901, 36.10797],
182
+ [-115.163509, 36.10809],
183
+ [-115.163145, 36.108223],
184
+ [-115.162981, 36.108306],
185
+ [-115.162837, 36.108412],
186
+ ],
187
+ },
188
+ };