@heroiclands/package-build 22.4.4 → 22.4.6
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 +24 -0
- package/CONTENT.md +30 -1
- package/README.md +9 -0
- package/bin/content-build.mjs +163 -0
- package/bin/package-build.mjs +18 -4
- package/content-config.mjs +7 -0
- package/docs/api.md +88 -0
- package/docs/commands.md +222 -2
- package/docs/configuration.md +19 -1
- package/docs/content-format.md +105 -6
- package/docs/diagnostics.md +1 -0
- package/docs/project-setup.md +7 -6
- package/engine/frontmatter-lint.mjs +11 -1
- package/engine/index.mjs +18 -0
- package/engine/map-build.mjs +270 -0
- package/engine/map-dot.mjs +745 -0
- package/engine/map-graphviz.mjs +138 -0
- package/engine/map-layout.mjs +894 -0
- package/engine/map-places.mjs +561 -0
- package/engine/metadata-index.mjs +7 -0
- package/engine/note-vocabulary.mjs +28 -0
- package/engine/place-relations.mjs +637 -0
- package/engine/site-config.mjs +1 -1
- package/engine/site-root.mjs +147 -6
- package/package.json +3 -2
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-build.d.mts +84 -0
- package/types/engine/map-dot.d.mts +143 -0
- package/types/engine/map-graphviz.d.mts +61 -0
- package/types/engine/map-layout.d.mts +427 -0
- package/types/engine/map-places.d.mts +259 -0
- package/types/engine/note-vocabulary.d.mts +12 -0
- package/types/engine/place-relations.d.mts +95 -0
- package/types/engine/site-config.d.mts +1 -1
- package/types/engine/site-root.d.mts +59 -7
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The geometry of the map from a place, and of the chart from a place.
|
|
16
|
+
*
|
|
17
|
+
* A pre-modern map was an itinerary: centred on its maker, exact about the
|
|
18
|
+
* next stage and vague about the tenth. A place note's `borders` and `routes`
|
|
19
|
+
* are exactly that, so the map from a place puts the place at the centre and
|
|
20
|
+
* every neighbour where its bearing and its days say — **north up, east
|
|
21
|
+
* right**, the angle from the bearing and the radius from the days marker.
|
|
22
|
+
* One ring per marker, spaced logarithmically so a day's ride and a month's
|
|
23
|
+
* voyage both fit on one page; a border with no route on the innermost ring; a
|
|
24
|
+
* place whose days nobody stated on the rim, marked unknown. The chart is the
|
|
25
|
+
* same drawing from the same place at a larger horizon: the hops chain on as
|
|
26
|
+
* long as none turns back, and a year's travel fits on the page.
|
|
27
|
+
*
|
|
28
|
+
* **Positions are computed here, not solved by a layout engine.** GraphViz
|
|
29
|
+
* receives every node pinned and only draws, so the picture states what the
|
|
30
|
+
* notes state and nothing a solver decided. Everything in this module is
|
|
31
|
+
* arithmetic over the place records — no file, no process, no GraphViz — so
|
|
32
|
+
* it is tested by asserting coordinates.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { BEARINGS, TRAVEL_DAYS } from "./place-relations.mjs";
|
|
38
|
+
|
|
39
|
+
/* --------------------------------------------------------------------- */
|
|
40
|
+
/* Bearings */
|
|
41
|
+
/* --------------------------------------------------------------------- */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The angle of a bearing in degrees, counter-clockwise from east, so north
|
|
45
|
+
* is up and east is right on a page whose `y` grows upward — GraphViz's
|
|
46
|
+
* coordinate system for a pinned position.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} bearing - A bearing.
|
|
49
|
+
* @returns {number|undefined} `N` is 90, `E` 0, `S` -90, `W` 180; `undefined`
|
|
50
|
+
* for a value that is not a bearing.
|
|
51
|
+
*/
|
|
52
|
+
export function bearingAngle(bearing) {
|
|
53
|
+
const i = BEARINGS.indexOf(/** @type {string} */ (bearing));
|
|
54
|
+
if (i === -1) return undefined;
|
|
55
|
+
const angle = 90 - 45 * i;
|
|
56
|
+
return angle <= -180 ? angle + 360 : angle;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The unit vector of a bearing.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} bearing - A bearing.
|
|
63
|
+
* @returns {{x: number, y: number}|undefined} The vector, or `undefined` for a
|
|
64
|
+
* value that is not a bearing.
|
|
65
|
+
*/
|
|
66
|
+
export function bearingVector(bearing) {
|
|
67
|
+
const angle = bearingAngle(bearing);
|
|
68
|
+
if (angle === undefined) return undefined;
|
|
69
|
+
const rad = (angle * Math.PI) / 180;
|
|
70
|
+
return { x: Math.cos(rad), y: Math.sin(rad) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How many steps around the compass separate two bearings, the shorter way:
|
|
75
|
+
* 0 for the same bearing, 1 for adjacent ones, 4 for opposites.
|
|
76
|
+
*
|
|
77
|
+
* @param {unknown} a - A bearing.
|
|
78
|
+
* @param {unknown} b - A bearing.
|
|
79
|
+
* @returns {number|undefined} The steps, or `undefined` when either is not a
|
|
80
|
+
* bearing.
|
|
81
|
+
*/
|
|
82
|
+
export function bearingSteps(a, b) {
|
|
83
|
+
const i = BEARINGS.indexOf(/** @type {string} */ (a));
|
|
84
|
+
const j = BEARINGS.indexOf(/** @type {string} */ (b));
|
|
85
|
+
if (i === -1 || j === -1) return undefined;
|
|
86
|
+
const d = Math.abs(i - j) % BEARINGS.length;
|
|
87
|
+
return Math.min(d, BEARINGS.length - d);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* --------------------------------------------------------------------- */
|
|
91
|
+
/* Rings */
|
|
92
|
+
/* --------------------------------------------------------------------- */
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The horizon of the map from a place, in days. A place farther than this
|
|
96
|
+
* is a name at the rim rather than a node on a ring.
|
|
97
|
+
*
|
|
98
|
+
* @type {number}
|
|
99
|
+
*/
|
|
100
|
+
export const HORIZON_DAYS = 90;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The days markers that are rings — every marker of the scale up to the
|
|
104
|
+
* horizon.
|
|
105
|
+
*
|
|
106
|
+
* @type {readonly number[]}
|
|
107
|
+
*/
|
|
108
|
+
export const RING_DAYS = Object.freeze(TRAVEL_DAYS.filter((d) => d <= HORIZON_DAYS));
|
|
109
|
+
|
|
110
|
+
/** The radius of the one-day ring, in points. */
|
|
111
|
+
const INNER_RADIUS = 160;
|
|
112
|
+
|
|
113
|
+
/** The radius of the horizon ring, in points. */
|
|
114
|
+
const HORIZON_RADIUS = 560;
|
|
115
|
+
|
|
116
|
+
/** Points of radius per unit of `ln(days)`: the log spacing of the rings. */
|
|
117
|
+
const RADIUS_PER_LOG_DAY = (HORIZON_RADIUS - INNER_RADIUS) / Math.log(HORIZON_DAYS);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The radius of the rim — where a place beyond the horizon, or one whose
|
|
121
|
+
* days nobody stated, is named. One log step beyond the horizon, so the rim
|
|
122
|
+
* reads as the next ring rather than a margin.
|
|
123
|
+
*
|
|
124
|
+
* @param {number} [horizon] - The horizon in days; the map from a place's
|
|
125
|
+
* by default.
|
|
126
|
+
* @returns {number} Points.
|
|
127
|
+
*/
|
|
128
|
+
export function rimRadius(horizon = HORIZON_DAYS) {
|
|
129
|
+
return INNER_RADIUS + RADIUS_PER_LOG_DAY * Math.log(horizon * 2);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The radius of the ring a days value sits on.
|
|
134
|
+
*
|
|
135
|
+
* Logarithmic in days, so equal ratios of days are equal steps of radius: a
|
|
136
|
+
* day to two days is the same step as five to ten, whatever the horizon. Any
|
|
137
|
+
* value beyond the horizon, and no value at all, is the rim.
|
|
138
|
+
*
|
|
139
|
+
* @param {number|undefined} days - A days value, on the scale or not.
|
|
140
|
+
* @param {number} [horizon] - The horizon in days; the map from a place's
|
|
141
|
+
* by default.
|
|
142
|
+
* @returns {number} Points.
|
|
143
|
+
*/
|
|
144
|
+
export function ringRadius(days, horizon = HORIZON_DAYS) {
|
|
145
|
+
if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) {
|
|
146
|
+
return rimRadius(horizon);
|
|
147
|
+
}
|
|
148
|
+
if (days > horizon) return rimRadius(horizon);
|
|
149
|
+
return INNER_RADIUS + RADIUS_PER_LOG_DAY * Math.log(days);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The rings, inside out — one per marker up to the horizon.
|
|
154
|
+
*
|
|
155
|
+
* @param {number} [horizon] - The horizon in days; the map from a place's
|
|
156
|
+
* by default.
|
|
157
|
+
* @returns {Array<{days: number, radius: number}>} Each marker and its radius.
|
|
158
|
+
*/
|
|
159
|
+
export function rings(horizon = HORIZON_DAYS) {
|
|
160
|
+
return TRAVEL_DAYS.filter((d) => d <= horizon).map((days) => ({
|
|
161
|
+
days,
|
|
162
|
+
radius: ringRadius(days, horizon),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The smallest marker of the scale that covers a total of days, or `undefined`
|
|
168
|
+
* beyond the scale's end or for no total at all.
|
|
169
|
+
*
|
|
170
|
+
* A two-hop journey adds two markers, and the sum is rarely a marker itself;
|
|
171
|
+
* the ring it belongs on is the first one no shorter than it — "about this,
|
|
172
|
+
* under normal conditions", rounded the pessimistic way.
|
|
173
|
+
*
|
|
174
|
+
* @param {number|undefined} total - Days, on the scale or not.
|
|
175
|
+
* @returns {number|undefined} The marker.
|
|
176
|
+
*/
|
|
177
|
+
export function daysMarker(total) {
|
|
178
|
+
if (typeof total !== "number" || !Number.isFinite(total)) return undefined;
|
|
179
|
+
return TRAVEL_DAYS.find((d) => d >= total);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The length a route's edge asks of `neato` in the whole route graph, in
|
|
184
|
+
* inches — the same log scale as the rings, so the god's-eye view and the
|
|
185
|
+
* itinerary agree about what a day and a month look like.
|
|
186
|
+
*
|
|
187
|
+
* @param {number|undefined} days - The route's days, or none for a border.
|
|
188
|
+
* @returns {number} Inches.
|
|
189
|
+
*/
|
|
190
|
+
export function edgeLength(days) {
|
|
191
|
+
const d = typeof days === "number" && Number.isFinite(days) && days > 0 ? days : 1;
|
|
192
|
+
return (INNER_RADIUS + RADIUS_PER_LOG_DAY * Math.log(d)) / 72;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* --------------------------------------------------------------------- */
|
|
196
|
+
/* Composing hops */
|
|
197
|
+
/* --------------------------------------------------------------------- */
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One hop of a journey: a bearing and, for a route, its days.
|
|
201
|
+
*
|
|
202
|
+
* @typedef {object} Hop
|
|
203
|
+
* @property {string} bearing - Where the far end lies from the near end.
|
|
204
|
+
* @property {number} [days] - The route's marker; absent for a border.
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* How far, in degrees, a hop's bearing may lie from the running direction of
|
|
209
|
+
* the journey so far and still compose: one octant either way. A path may
|
|
210
|
+
* bend, hop by hop, but never turn back.
|
|
211
|
+
*/
|
|
212
|
+
const TURN_DEGREES = 45;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A chain of hops composed into one bearing and one total, or nothing where
|
|
216
|
+
* a hop turns back.
|
|
217
|
+
*
|
|
218
|
+
* The composed direction is that of the hops added as vectors weighted by
|
|
219
|
+
* their days, and the total is their sum. Each hop after the first is
|
|
220
|
+
* checked against the running direction of the chain before it — not
|
|
221
|
+
* against the previous hop — and composes only where it lies within one
|
|
222
|
+
* octant of that direction, so a path may bend a little at every hop and
|
|
223
|
+
* drift past an octant of where it began, but a hop that turns back ends
|
|
224
|
+
* the chain: a place east and then north of here is not in any one
|
|
225
|
+
* direction, and the drawing omits it rather than guess. A hop with no days
|
|
226
|
+
* — a border — makes the total unknown, because a frontier states no
|
|
227
|
+
* distance, and then every hop weighs the same, because nothing says which
|
|
228
|
+
* is the longer.
|
|
229
|
+
*
|
|
230
|
+
* @param {Hop[]} hops - The hops, from the centre outward.
|
|
231
|
+
* @returns {{angle: number, days: number|undefined}|undefined} The composed
|
|
232
|
+
* bearing in degrees and the total days, or `undefined` where a hop turns
|
|
233
|
+
* back, a bearing is not one, or there are no hops.
|
|
234
|
+
*/
|
|
235
|
+
export function composeChain(hops) {
|
|
236
|
+
if (!Array.isArray(hops) || hops.length === 0) return undefined;
|
|
237
|
+
const known = (d) => typeof d === "number" && Number.isFinite(d) && d > 0;
|
|
238
|
+
const allKnown = hops.every((hop) => known(hop?.days));
|
|
239
|
+
let x = 0;
|
|
240
|
+
let y = 0;
|
|
241
|
+
for (const hop of hops) {
|
|
242
|
+
const u = bearingVector(hop?.bearing);
|
|
243
|
+
if (!u) return undefined;
|
|
244
|
+
if (x !== 0 || y !== 0) {
|
|
245
|
+
const running = (Math.atan2(y, x) * 180) / Math.PI;
|
|
246
|
+
const turn =
|
|
247
|
+
Math.abs(/** @type {number} */ (bearingAngle(hop.bearing)) - running) % 360;
|
|
248
|
+
if (Math.min(turn, 360 - turn) > TURN_DEGREES + 1e-9) return undefined;
|
|
249
|
+
}
|
|
250
|
+
const w = allKnown ? /** @type {number} */ (hop.days) : 1;
|
|
251
|
+
x += w * u.x;
|
|
252
|
+
y += w * u.y;
|
|
253
|
+
}
|
|
254
|
+
const angle = (Math.atan2(y, x) * 180) / Math.PI;
|
|
255
|
+
const days =
|
|
256
|
+
allKnown ? hops.reduce((sum, hop) => sum + /** @type {number} */ (hop.days), 0) : undefined;
|
|
257
|
+
// -0 from atan2 on an exactly-east result reads as 0.
|
|
258
|
+
return { angle: angle === 0 ? 0 : angle, days };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Two hops composed into one bearing and one total, or nothing where they
|
|
263
|
+
* disagree.
|
|
264
|
+
*
|
|
265
|
+
* Two hops agree when their bearings are the same or adjacent — the
|
|
266
|
+
* two-hop case of {@link composeChain}: the second hop is checked against
|
|
267
|
+
* the first, which is the whole running direction at that point.
|
|
268
|
+
*
|
|
269
|
+
* @param {Hop} first - The hop from the centre.
|
|
270
|
+
* @param {Hop} second - The hop onward.
|
|
271
|
+
* @returns {{angle: number, days: number|undefined}|undefined} The composed
|
|
272
|
+
* bearing in degrees and the total days, or `undefined` where the hops
|
|
273
|
+
* disagree or either bearing is not one.
|
|
274
|
+
*/
|
|
275
|
+
export function composeHops(first, second) {
|
|
276
|
+
return composeChain([first, second]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* --------------------------------------------------------------------- */
|
|
280
|
+
/* Reading a place's relations from both ends */
|
|
281
|
+
/* --------------------------------------------------------------------- */
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A place as the map reads it. The shape {@link module:engine/map-places}
|
|
285
|
+
* builds from the content index; only the relation fields matter here.
|
|
286
|
+
*
|
|
287
|
+
* @typedef {object} MapPlace
|
|
288
|
+
* @property {string} shortcode
|
|
289
|
+
* @property {string} name
|
|
290
|
+
* @property {string} [subType]
|
|
291
|
+
* @property {string[]} parents
|
|
292
|
+
* @property {Array<{to: string, bearing: string}>} borders
|
|
293
|
+
* @property {Array<{to: string, bearing: string, mode?: string, days?: number}>} routes
|
|
294
|
+
* @property {string} [url]
|
|
295
|
+
* @property {string} [address]
|
|
296
|
+
*/
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A relation between two places, read from whichever end states it.
|
|
300
|
+
*
|
|
301
|
+
* @typedef {object} Relation
|
|
302
|
+
* @property {string} from
|
|
303
|
+
* @property {string} to
|
|
304
|
+
* @property {string} bearing - Where `to` lies from `from`.
|
|
305
|
+
* @property {"border"|"route"} kind
|
|
306
|
+
* @property {number} [days]
|
|
307
|
+
* @property {string} [mode]
|
|
308
|
+
* @property {boolean} stated - Whether `from` states it, as opposed to `to`.
|
|
309
|
+
*/
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The lower-case shortcode a relation entry names.
|
|
313
|
+
*
|
|
314
|
+
* @param {unknown} entry - A `borders` or `routes` entry.
|
|
315
|
+
* @returns {string} The shortcode, or `""` for an entry naming none.
|
|
316
|
+
*/
|
|
317
|
+
function targetOf(entry) {
|
|
318
|
+
return String(entry?.to ?? "").toLowerCase();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A days value as a number on the scale, or `undefined`.
|
|
323
|
+
*
|
|
324
|
+
* @param {unknown} value - The authored value.
|
|
325
|
+
* @returns {number|undefined} The days.
|
|
326
|
+
*/
|
|
327
|
+
function daysOf(value) {
|
|
328
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
329
|
+
return typeof n === "number" && TRAVEL_DAYS.includes(n) ? n : undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The bearing the other end of a relation states.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} bearing - A bearing.
|
|
336
|
+
* @returns {string} The opposite one.
|
|
337
|
+
*/
|
|
338
|
+
function opposite(bearing) {
|
|
339
|
+
const i = BEARINGS.indexOf(bearing);
|
|
340
|
+
return BEARINGS[(i + 4) % BEARINGS.length];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Every relation touching one place, read from both ends.
|
|
345
|
+
*
|
|
346
|
+
* A border or a route is one fact stated from each end, and a note that
|
|
347
|
+
* states one side of it does not make the fact untrue — so the map reads what
|
|
348
|
+
* the place states *and* what every other place states about it, inverting
|
|
349
|
+
* the bearing on the way back. The place's own statement wins where both
|
|
350
|
+
* exist, and a target no place declares is returned separately so the caller
|
|
351
|
+
* can name it. The lint is where a missing reciprocal is reported; here it is
|
|
352
|
+
* simply drawn.
|
|
353
|
+
*
|
|
354
|
+
* @param {Map<string, MapPlace>} places - Every place, by shortcode.
|
|
355
|
+
* @param {string} shortcode - The place asked about.
|
|
356
|
+
* @returns {{relations: Relation[], unresolved: Array<{from: string, to: string}>}}
|
|
357
|
+
* The relations, own statements first, and the targets that resolved to
|
|
358
|
+
* nothing.
|
|
359
|
+
*/
|
|
360
|
+
export function relationsOf(places, shortcode) {
|
|
361
|
+
const self = places.get(shortcode);
|
|
362
|
+
/** @type {Relation[]} */
|
|
363
|
+
const relations = [];
|
|
364
|
+
/** @type {Array<{from: string, to: string}>} */
|
|
365
|
+
const unresolved = [];
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
const key = (r) => `${r.to}|${r.kind}|${r.mode ?? ""}`;
|
|
368
|
+
|
|
369
|
+
const own = (list, kind) => {
|
|
370
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
371
|
+
const to = targetOf(entry);
|
|
372
|
+
const bearing = String(entry?.bearing ?? "");
|
|
373
|
+
if (!to || !BEARINGS.includes(bearing)) continue;
|
|
374
|
+
if (!places.has(to)) {
|
|
375
|
+
unresolved.push({ from: shortcode, to });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const relation = {
|
|
379
|
+
from: shortcode,
|
|
380
|
+
to,
|
|
381
|
+
bearing,
|
|
382
|
+
kind,
|
|
383
|
+
stated: true,
|
|
384
|
+
...(kind === "route" ?
|
|
385
|
+
{ days: daysOf(entry.days), mode: String(entry.mode ?? "") }
|
|
386
|
+
: {}),
|
|
387
|
+
};
|
|
388
|
+
if (seen.has(key(relation))) continue;
|
|
389
|
+
seen.add(key(relation));
|
|
390
|
+
relations.push(relation);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
own(self?.borders, "border");
|
|
394
|
+
own(self?.routes, "route");
|
|
395
|
+
|
|
396
|
+
for (const [other, place] of places) {
|
|
397
|
+
if (other === shortcode) continue;
|
|
398
|
+
const inbound = (list, kind) => {
|
|
399
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
400
|
+
if (targetOf(entry) !== shortcode) continue;
|
|
401
|
+
const theirs = String(entry?.bearing ?? "");
|
|
402
|
+
if (!BEARINGS.includes(theirs)) continue;
|
|
403
|
+
const relation = {
|
|
404
|
+
from: shortcode,
|
|
405
|
+
to: other,
|
|
406
|
+
bearing: opposite(theirs),
|
|
407
|
+
kind,
|
|
408
|
+
stated: false,
|
|
409
|
+
...(kind === "route" ?
|
|
410
|
+
{ days: daysOf(entry.days), mode: String(entry.mode ?? "") }
|
|
411
|
+
: {}),
|
|
412
|
+
};
|
|
413
|
+
if (seen.has(key(relation))) continue;
|
|
414
|
+
seen.add(key(relation));
|
|
415
|
+
relations.push(relation);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
inbound(place.borders, "border");
|
|
419
|
+
inbound(place.routes, "route");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { relations, unresolved };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* --------------------------------------------------------------------- */
|
|
426
|
+
/* The map from a place */
|
|
427
|
+
/* --------------------------------------------------------------------- */
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* One place on the map from a place.
|
|
431
|
+
*
|
|
432
|
+
* @typedef {object} FromNode
|
|
433
|
+
* @property {string} shortcode
|
|
434
|
+
* @property {number} hop - 0 for the centre, 1 for a neighbour, 2 and up
|
|
435
|
+
* beyond — the map from a place stops at 2, the chart goes on.
|
|
436
|
+
* @property {number} x - Points, east positive.
|
|
437
|
+
* @property {number} y - Points, north positive.
|
|
438
|
+
* @property {number} angle - Degrees, counter-clockwise from east.
|
|
439
|
+
* @property {number} radius - Points from the centre.
|
|
440
|
+
* @property {number} [days] - The days the ring was chosen by, or none.
|
|
441
|
+
* @property {boolean} border - Reached by a border alone.
|
|
442
|
+
* @property {boolean} unknown - On the rim because nobody stated its days.
|
|
443
|
+
* @property {string} [via] - The place a hop beyond the neighbours is reached
|
|
444
|
+
* through.
|
|
445
|
+
*/
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* One edge on the map from a place.
|
|
449
|
+
*
|
|
450
|
+
* @typedef {object} FromEdge
|
|
451
|
+
* @property {string} from
|
|
452
|
+
* @property {string} to
|
|
453
|
+
* @property {"border"|"route"} kind - A route where any route reaches `to`.
|
|
454
|
+
* @property {string} bearing
|
|
455
|
+
* @property {number} [days] - The shortest route's.
|
|
456
|
+
* @property {string} [mode] - The shortest route's.
|
|
457
|
+
* @property {Array<{mode: string, days: number|undefined}>} routes - Every
|
|
458
|
+
* route that reaches `to`, for the label.
|
|
459
|
+
* @property {number} hop - 1 from the centre, 2 and up onward.
|
|
460
|
+
*/
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Half the spread, in degrees, between neighbours that share a ring and a
|
|
464
|
+
* bearing, so they fan out rather than stack.
|
|
465
|
+
*/
|
|
466
|
+
const FAN_DEGREES = 14;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Fan apart the nodes that would otherwise sit on one point.
|
|
470
|
+
*
|
|
471
|
+
* Nodes on one ring at one angle are spread evenly about that angle, in name
|
|
472
|
+
* order so the picture is stable between runs, and their mean stays where the
|
|
473
|
+
* bearing put it.
|
|
474
|
+
*
|
|
475
|
+
* @param {FromNode[]} nodes - The nodes, angles set.
|
|
476
|
+
* @param {Map<string, MapPlace>} places - For the names.
|
|
477
|
+
* @returns {void}
|
|
478
|
+
*/
|
|
479
|
+
function fanApart(nodes, places) {
|
|
480
|
+
/** @type {Map<string, FromNode[]>} */
|
|
481
|
+
const groups = new Map();
|
|
482
|
+
for (const node of nodes) {
|
|
483
|
+
if (node.hop === 0) continue;
|
|
484
|
+
const key = `${node.radius.toFixed(3)}@${node.angle.toFixed(3)}`;
|
|
485
|
+
const group = groups.get(key) ?? [];
|
|
486
|
+
group.push(node);
|
|
487
|
+
groups.set(key, group);
|
|
488
|
+
}
|
|
489
|
+
for (const group of groups.values()) {
|
|
490
|
+
if (group.length < 2) continue;
|
|
491
|
+
group.sort((a, b) =>
|
|
492
|
+
String(places.get(a.shortcode)?.name ?? a.shortcode).localeCompare(
|
|
493
|
+
String(places.get(b.shortcode)?.name ?? b.shortcode),
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
const step = Math.min(FAN_DEGREES, (2 * FAN_DEGREES) / (group.length - 1));
|
|
497
|
+
group.forEach((node, i) => {
|
|
498
|
+
node.angle += (i - (group.length - 1) / 2) * step;
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The layout of the map from a place or of the chart from one.
|
|
505
|
+
*
|
|
506
|
+
* @typedef {object} FromLayout
|
|
507
|
+
* @property {string} centre
|
|
508
|
+
* @property {FromNode[]} nodes
|
|
509
|
+
* @property {FromEdge[]} edges
|
|
510
|
+
* @property {Array<{days: number, radius: number}>} rings
|
|
511
|
+
* @property {number} rim - The rim's radius, in points.
|
|
512
|
+
* @property {number} horizon - The horizon, in days.
|
|
513
|
+
* @property {boolean} beyond - Whether a place past the horizon is named at
|
|
514
|
+
* the rim; false, it is omitted and the rim holds only the unknown.
|
|
515
|
+
* @property {Array<{from: string, to: string}>} unresolved
|
|
516
|
+
*/
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Lay out the itinerary from a place: the map from it, or the chart.
|
|
520
|
+
*
|
|
521
|
+
* The centre at the origin. Every neighbour it states, or that states it, at
|
|
522
|
+
* the angle of its bearing and on the ring of its days — the innermost ring
|
|
523
|
+
* for a border with no route, the shortest route where several modes reach
|
|
524
|
+
* one neighbour. Beyond the neighbours, every place a chain of hops reaches
|
|
525
|
+
* within `maxHops`, at the composed bearing where no hop turns back and on
|
|
526
|
+
* the ring of the summed days; on the rim, marked unknown, where a hop
|
|
527
|
+
* beyond the first states no days; omitted where a hop turns back. A place
|
|
528
|
+
* reachable by several chains takes the one with known days, then the
|
|
529
|
+
* fewest, then the fewest hops, then the first `via` by name. The chain goes
|
|
530
|
+
* on from a neighbour and from any place whose total is known and within
|
|
531
|
+
* the horizon; it does not go on from a place at the rim, because the
|
|
532
|
+
* drawing states nothing beyond a distance it does not know. A place whose
|
|
533
|
+
* total lies past the horizon is named at the rim with its days where
|
|
534
|
+
* `beyond` is set, and left out otherwise. Nodes sharing a ring and a
|
|
535
|
+
* bearing are fanned apart.
|
|
536
|
+
*
|
|
537
|
+
* @param {Map<string, MapPlace>} places - Every place, by shortcode.
|
|
538
|
+
* @param {string} centre - The place the drawing is from.
|
|
539
|
+
* @param {object} opts
|
|
540
|
+
* @param {number} opts.maxHops - How many hops out the chain may reach.
|
|
541
|
+
* @param {number} opts.horizon - The horizon, in days, a marker of the scale.
|
|
542
|
+
* @param {boolean} opts.beyond - Whether a place past the horizon is named at
|
|
543
|
+
* the rim rather than omitted.
|
|
544
|
+
* @returns {FromLayout} The layout, in points.
|
|
545
|
+
* @throws {Error} When `centre` is not a place.
|
|
546
|
+
*/
|
|
547
|
+
function layoutItinerary(places, centre, { maxHops, horizon, beyond }) {
|
|
548
|
+
if (!places.has(centre)) {
|
|
549
|
+
throw new Error(`"${centre}" is not a place in this package or a fetched index`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** @type {FromNode[]} */
|
|
553
|
+
const nodes = [
|
|
554
|
+
{
|
|
555
|
+
shortcode: centre,
|
|
556
|
+
hop: 0,
|
|
557
|
+
x: 0,
|
|
558
|
+
y: 0,
|
|
559
|
+
angle: 0,
|
|
560
|
+
radius: 0,
|
|
561
|
+
border: false,
|
|
562
|
+
unknown: false,
|
|
563
|
+
},
|
|
564
|
+
];
|
|
565
|
+
/** @type {FromEdge[]} */
|
|
566
|
+
const edges = [];
|
|
567
|
+
/** @type {Map<string, FromNode>} */
|
|
568
|
+
const byShortcode = new Map([[centre, nodes[0]]]);
|
|
569
|
+
/** @type {Map<string, Hop[]>} The hops each drawn place is reached by. */
|
|
570
|
+
const paths = new Map([[centre, []]]);
|
|
571
|
+
|
|
572
|
+
const { relations, unresolved } = relationsOf(places, centre);
|
|
573
|
+
|
|
574
|
+
// Hop 1: one node and one edge per neighbour, the ring from its shortest
|
|
575
|
+
// route, the edge carrying every mode that reaches it.
|
|
576
|
+
/** @type {Map<string, FromEdge>} */
|
|
577
|
+
const firstEdges = new Map();
|
|
578
|
+
for (const relation of relations) {
|
|
579
|
+
const days = relation.kind === "route" ? relation.days : undefined;
|
|
580
|
+
let node = byShortcode.get(relation.to);
|
|
581
|
+
if (!node) {
|
|
582
|
+
node = {
|
|
583
|
+
shortcode: relation.to,
|
|
584
|
+
hop: 1,
|
|
585
|
+
x: 0,
|
|
586
|
+
y: 0,
|
|
587
|
+
angle: /** @type {number} */ (bearingAngle(relation.bearing)),
|
|
588
|
+
radius: days === undefined ? ringRadius(1, horizon) : ringRadius(days, horizon),
|
|
589
|
+
days,
|
|
590
|
+
border: relation.kind === "border",
|
|
591
|
+
// A route whose days are off the scale sits on the rim, and
|
|
592
|
+
// says so; a border sits on the innermost ring by rule.
|
|
593
|
+
unknown: relation.kind === "route" && days === undefined,
|
|
594
|
+
};
|
|
595
|
+
nodes.push(node);
|
|
596
|
+
byShortcode.set(relation.to, node);
|
|
597
|
+
paths.set(relation.to, [{ bearing: relation.bearing, days }]);
|
|
598
|
+
firstEdges.set(relation.to, {
|
|
599
|
+
from: centre,
|
|
600
|
+
to: relation.to,
|
|
601
|
+
kind: relation.kind,
|
|
602
|
+
bearing: relation.bearing,
|
|
603
|
+
hop: 1,
|
|
604
|
+
routes: [],
|
|
605
|
+
});
|
|
606
|
+
edges.push(/** @type {FromEdge} */ (firstEdges.get(relation.to)));
|
|
607
|
+
}
|
|
608
|
+
const edge = /** @type {FromEdge} */ (firstEdges.get(relation.to));
|
|
609
|
+
if (relation.kind !== "route") continue;
|
|
610
|
+
edge.routes.push({ mode: String(relation.mode ?? ""), days });
|
|
611
|
+
// A route outranks a border for the node's ring and the edge's style,
|
|
612
|
+
// and the shortest route sets both.
|
|
613
|
+
if (
|
|
614
|
+
edge.kind === "border" ||
|
|
615
|
+
(days !== undefined && (edge.days === undefined || days < edge.days))
|
|
616
|
+
) {
|
|
617
|
+
edge.kind = "route";
|
|
618
|
+
edge.days = days;
|
|
619
|
+
edge.mode = String(relation.mode ?? "");
|
|
620
|
+
node.days = days;
|
|
621
|
+
node.border = false;
|
|
622
|
+
node.radius = ringRadius(days, horizon);
|
|
623
|
+
node.unknown = days === undefined;
|
|
624
|
+
paths.set(relation.to, [{ bearing: relation.bearing, days }]);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Without a rim for the beyond, a neighbour past the horizon is out like
|
|
629
|
+
// any other place.
|
|
630
|
+
const within = (days) => days === undefined || days <= horizon;
|
|
631
|
+
if (!beyond) {
|
|
632
|
+
for (const node of [...nodes]) {
|
|
633
|
+
if (node.hop !== 1 || within(node.days)) continue;
|
|
634
|
+
nodes.splice(nodes.indexOf(node), 1);
|
|
635
|
+
edges.splice(
|
|
636
|
+
edges.indexOf(/** @type {FromEdge} */ (firstEdges.get(node.shortcode))),
|
|
637
|
+
1,
|
|
638
|
+
);
|
|
639
|
+
byShortcode.delete(node.shortcode);
|
|
640
|
+
paths.delete(node.shortcode);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Beyond the neighbours: the best chain per place, settled in order of
|
|
645
|
+
// total days — known before unknown, fewer before more, then fewer hops,
|
|
646
|
+
// then by the name of the place it is reached through — and each place
|
|
647
|
+
// extended once, from the chain it is drawn by.
|
|
648
|
+
/** @type {Array<{node: FromNode, edge: FromEdge, hops: Hop[]}>} */
|
|
649
|
+
const queue = [];
|
|
650
|
+
const rank = (a, b) => {
|
|
651
|
+
const aKnown = a.node.days !== undefined;
|
|
652
|
+
const bKnown = b.node.days !== undefined;
|
|
653
|
+
if (aKnown !== bKnown) return aKnown ? -1 : 1;
|
|
654
|
+
if (aKnown && a.node.days !== b.node.days) {
|
|
655
|
+
return /** @type {number} */ (a.node.days) - /** @type {number} */ (b.node.days);
|
|
656
|
+
}
|
|
657
|
+
if (a.node.hop !== b.node.hop) return a.node.hop - b.node.hop;
|
|
658
|
+
return (
|
|
659
|
+
String(a.node.via).localeCompare(String(b.node.via)) ||
|
|
660
|
+
a.node.shortcode.localeCompare(b.node.shortcode)
|
|
661
|
+
);
|
|
662
|
+
};
|
|
663
|
+
const extend = (via) => {
|
|
664
|
+
const sofar = /** @type {Hop[]} */ (paths.get(via));
|
|
665
|
+
if (sofar.length >= maxHops) return;
|
|
666
|
+
for (const relation of relationsOf(places, via).relations) {
|
|
667
|
+
if (byShortcode.has(relation.to)) continue;
|
|
668
|
+
const hops = [...sofar, { bearing: relation.bearing, days: relation.days }];
|
|
669
|
+
const composed = composeChain(hops);
|
|
670
|
+
if (!composed) continue;
|
|
671
|
+
if (!beyond && !within(composed.days)) continue;
|
|
672
|
+
const marker = daysMarker(composed.days);
|
|
673
|
+
queue.push({
|
|
674
|
+
node: {
|
|
675
|
+
shortcode: relation.to,
|
|
676
|
+
hop: hops.length,
|
|
677
|
+
x: 0,
|
|
678
|
+
y: 0,
|
|
679
|
+
angle: composed.angle,
|
|
680
|
+
radius: ringRadius(marker, horizon),
|
|
681
|
+
days: composed.days,
|
|
682
|
+
border: relation.kind === "border" && hops.every((h) => h.days === undefined),
|
|
683
|
+
unknown: composed.days === undefined,
|
|
684
|
+
via,
|
|
685
|
+
},
|
|
686
|
+
edge: {
|
|
687
|
+
from: via,
|
|
688
|
+
to: relation.to,
|
|
689
|
+
kind: relation.kind,
|
|
690
|
+
bearing: relation.bearing,
|
|
691
|
+
hop: hops.length,
|
|
692
|
+
routes:
|
|
693
|
+
relation.kind === "route" ?
|
|
694
|
+
[{ mode: String(relation.mode ?? ""), days: relation.days }]
|
|
695
|
+
: [],
|
|
696
|
+
...(relation.kind === "route" ?
|
|
697
|
+
{ days: relation.days, mode: relation.mode }
|
|
698
|
+
: {}),
|
|
699
|
+
},
|
|
700
|
+
hops,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
for (const node of nodes) if (node.hop === 1) extend(node.shortcode);
|
|
705
|
+
/** @type {FromNode[]} */
|
|
706
|
+
const beyondNeighbours = [];
|
|
707
|
+
while (queue.length) {
|
|
708
|
+
queue.sort(rank);
|
|
709
|
+
const { node, edge, hops } = /** @type {NonNullable<typeof queue[0]>} */ (queue.shift());
|
|
710
|
+
if (byShortcode.has(node.shortcode)) continue;
|
|
711
|
+
byShortcode.set(node.shortcode, node);
|
|
712
|
+
paths.set(node.shortcode, hops);
|
|
713
|
+
beyondNeighbours.push(node);
|
|
714
|
+
edges.push(edge);
|
|
715
|
+
if (node.days !== undefined && within(node.days)) extend(node.shortcode);
|
|
716
|
+
}
|
|
717
|
+
beyondNeighbours.sort((a, b) => a.hop - b.hop || a.shortcode.localeCompare(b.shortcode));
|
|
718
|
+
nodes.push(...beyondNeighbours);
|
|
719
|
+
edges.sort((a, b) => a.hop - b.hop || (a.hop === 1 ? 0 : a.to.localeCompare(b.to)));
|
|
720
|
+
|
|
721
|
+
fanApart(nodes, places);
|
|
722
|
+
for (const node of nodes) {
|
|
723
|
+
if (node.hop === 0) continue;
|
|
724
|
+
const rad = (node.angle * Math.PI) / 180;
|
|
725
|
+
node.x = exact(node.radius * Math.cos(rad));
|
|
726
|
+
node.y = exact(node.radius * Math.sin(rad));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
centre,
|
|
731
|
+
nodes,
|
|
732
|
+
edges,
|
|
733
|
+
rings: rings(horizon),
|
|
734
|
+
rim: rimRadius(horizon),
|
|
735
|
+
horizon,
|
|
736
|
+
beyond,
|
|
737
|
+
unresolved,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Lay out the map from a place: two hops, a horizon of 90 days, and a rim
|
|
743
|
+
* naming what lies beyond it.
|
|
744
|
+
*
|
|
745
|
+
* The centre at the origin. Every neighbour it states, or that states it, at
|
|
746
|
+
* the angle of its bearing and on the ring of its days — the innermost ring
|
|
747
|
+
* for a border with no route, the shortest route where several modes reach
|
|
748
|
+
* one neighbour. Beyond each neighbour, every place a second hop reaches, at
|
|
749
|
+
* the composed bearing where the two hops agree and on the ring of their
|
|
750
|
+
* total; on the rim, marked unknown, where either hop states no days; omitted
|
|
751
|
+
* where the hops disagree. A place reachable by several second hops takes
|
|
752
|
+
* the one with known days, then the shortest. Nodes sharing a ring and a
|
|
753
|
+
* bearing are fanned apart.
|
|
754
|
+
*
|
|
755
|
+
* @param {Map<string, MapPlace>} places - Every place, by shortcode.
|
|
756
|
+
* @param {string} centre - The place the map is from.
|
|
757
|
+
* @returns {FromLayout} The layout, in points.
|
|
758
|
+
* @throws {Error} When `centre` is not a place.
|
|
759
|
+
*/
|
|
760
|
+
export function layoutFrom(places, centre) {
|
|
761
|
+
return layoutItinerary(places, centre, { maxHops: 2, horizon: HORIZON_DAYS, beyond: true });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The horizon of the chart from a place when none is asked for, in days: the
|
|
766
|
+
* end of the scale, so the whole of what the notes state fits on the page.
|
|
767
|
+
*
|
|
768
|
+
* @type {number}
|
|
769
|
+
*/
|
|
770
|
+
export const CHART_HORIZON_DAYS = 360;
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Lay out the chart from a place: the map from it at a larger horizon.
|
|
774
|
+
*
|
|
775
|
+
* The neighbours as on the map from a place. Beyond them the hops chain on,
|
|
776
|
+
* each checked against the running direction of the chain so far and
|
|
777
|
+
* composing only within one octant of it, so a path bends but never turns
|
|
778
|
+
* back; a place is drawn at the composed bearing on the ring of the summed
|
|
779
|
+
* days, and where several chains reach it, by the one with the fewest. A
|
|
780
|
+
* border beyond the first hop makes the total unknown and sends the place to
|
|
781
|
+
* the rim, and nothing is drawn beyond it. A place whose total exceeds the
|
|
782
|
+
* horizon is left out — the chart is already the larger view — so the rim
|
|
783
|
+
* holds the unknown alone.
|
|
784
|
+
*
|
|
785
|
+
* @param {Map<string, MapPlace>} places - Every place, by shortcode.
|
|
786
|
+
* @param {string} centre - The place the chart is from.
|
|
787
|
+
* @param {object} [opts]
|
|
788
|
+
* @param {number} [opts.horizon] - The horizon in days, a marker of the
|
|
789
|
+
* scale; {@link CHART_HORIZON_DAYS} by default.
|
|
790
|
+
* @returns {FromLayout} The layout, in points.
|
|
791
|
+
* @throws {Error} When `centre` is not a place, or `horizon` is not a marker
|
|
792
|
+
* of the scale.
|
|
793
|
+
*/
|
|
794
|
+
export function layoutChart(places, centre, { horizon = CHART_HORIZON_DAYS } = {}) {
|
|
795
|
+
if (!TRAVEL_DAYS.includes(horizon)) {
|
|
796
|
+
throw new Error(
|
|
797
|
+
`a chart's horizon must be a marker of the days scale — one of ${TRAVEL_DAYS.join(", ")} — and ${horizon} is not`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
return layoutItinerary(places, centre, { maxHops: Infinity, horizon, beyond: false });
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* A coordinate with the floating-point dust of a right angle swept off, so a
|
|
805
|
+
* place due north sits at `x = 0` rather than at `1e-14`.
|
|
806
|
+
*
|
|
807
|
+
* @param {number} value - Points.
|
|
808
|
+
* @returns {number} The value, or `0` where it is within a millionth of it.
|
|
809
|
+
*/
|
|
810
|
+
function exact(value) {
|
|
811
|
+
return Math.abs(value) < 1e-6 ? 0 : value;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/* --------------------------------------------------------------------- */
|
|
815
|
+
/* The whole route graph */
|
|
816
|
+
/* --------------------------------------------------------------------- */
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* One edge of the whole route graph.
|
|
820
|
+
*
|
|
821
|
+
* @typedef {object} TravelEdge
|
|
822
|
+
* @property {string} from
|
|
823
|
+
* @property {string} to
|
|
824
|
+
* @property {"border"|"route"} kind
|
|
825
|
+
* @property {string} bearing - Where `to` lies from `from`.
|
|
826
|
+
* @property {number} [days]
|
|
827
|
+
* @property {string} [mode]
|
|
828
|
+
* @property {number} len - The length asked of `neato`, in inches.
|
|
829
|
+
*/
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The whole route graph: one edge per pair of places, however many ends and
|
|
833
|
+
* modes state it, with a length from its days.
|
|
834
|
+
*
|
|
835
|
+
* A pair reached by several modes takes the shortest; a pair that both
|
|
836
|
+
* borders and travels takes the route, since the days are what the length
|
|
837
|
+
* is from. The end that states the relation is `from`, so a one-sided entry
|
|
838
|
+
* is drawn from the note that carries it.
|
|
839
|
+
*
|
|
840
|
+
* @param {Map<string, MapPlace>} places - Every place, by shortcode.
|
|
841
|
+
* @returns {{edges: TravelEdge[], unresolved: Array<{from: string, to: string}>}}
|
|
842
|
+
* The edges, in a stable order.
|
|
843
|
+
*/
|
|
844
|
+
export function travelGraph(places) {
|
|
845
|
+
/** @type {Map<string, TravelEdge>} */
|
|
846
|
+
const edges = new Map();
|
|
847
|
+
/** @type {Array<{from: string, to: string}>} */
|
|
848
|
+
const unresolved = [];
|
|
849
|
+
const pairKey = (a, b) => [a, b].sort().join("|");
|
|
850
|
+
|
|
851
|
+
for (const shortcode of [...places.keys()].sort()) {
|
|
852
|
+
const place = places.get(shortcode);
|
|
853
|
+
const consider = (list, kind) => {
|
|
854
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
855
|
+
const to = targetOf(entry);
|
|
856
|
+
const bearing = String(entry?.bearing ?? "");
|
|
857
|
+
if (!to || !BEARINGS.includes(bearing)) continue;
|
|
858
|
+
if (!places.has(to)) {
|
|
859
|
+
unresolved.push({ from: shortcode, to });
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const days = kind === "route" ? daysOf(entry.days) : undefined;
|
|
863
|
+
const edge = {
|
|
864
|
+
from: shortcode,
|
|
865
|
+
to,
|
|
866
|
+
kind,
|
|
867
|
+
bearing,
|
|
868
|
+
len: edgeLength(days),
|
|
869
|
+
...(kind === "route" ? { days, mode: String(entry.mode ?? "") } : {}),
|
|
870
|
+
};
|
|
871
|
+
const key = pairKey(shortcode, to);
|
|
872
|
+
const existing = edges.get(key);
|
|
873
|
+
if (!existing) {
|
|
874
|
+
edges.set(key, edge);
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
// A route outranks a border, and a shorter route a longer.
|
|
878
|
+
if (existing.kind === "border" && kind === "route") edges.set(key, edge);
|
|
879
|
+
else if (
|
|
880
|
+
existing.kind === "route" &&
|
|
881
|
+
kind === "route" &&
|
|
882
|
+
days !== undefined &&
|
|
883
|
+
(existing.days === undefined || days < existing.days)
|
|
884
|
+
) {
|
|
885
|
+
edges.set(key, edge);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
consider(place?.borders, "border");
|
|
890
|
+
consider(place?.routes, "route");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return { edges: [...edges.values()], unresolved };
|
|
894
|
+
}
|