@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.
@@ -0,0 +1,745 @@
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 drawings of `content-build map`, as DOT.
16
+ *
17
+ * Every drawing is emitted as DOT text and kept beside its rendering, so a
18
+ * picture can be re-rendered, diffed or read without the content index.
19
+ * The drawings share one style table: **shape, colour and size are by
20
+ * `subType`**, in one place, so the containment tree, the map from a place,
21
+ * the chart from one, the route graph and the legend all follow an edit to a
22
+ * row. An anomaly
23
+ * overrides fill and border colour only, so what kind of place a node is and
24
+ * whether something is wrong with it read as two separate questions.
25
+ *
26
+ * Every node's label is the place's `name.full`, its tooltip the canonical
27
+ * address, and — where the place carries a URL — its `URL`, which GraphViz
28
+ * renders as an `<a href>` around the name so a page can inline the SVG and
29
+ * every name is a link.
30
+ *
31
+ * @module
32
+ */
33
+
34
+ import { subtreeOf } from "./map-places.mjs";
35
+
36
+ /* --------------------------------------------------------------------- */
37
+ /* Style */
38
+ /* --------------------------------------------------------------------- */
39
+
40
+ /** The fill of a place with no parent, or of a placeholder for a parent that resolves to nothing. */
41
+ const RED_FILL = "#EF5350";
42
+ /** The border of a place on a cycle. */
43
+ const RED_BORDER = "#B71C1C";
44
+ const DEFAULT_BORDER = "#37474F";
45
+ const POLITY_BORDER = "#616161";
46
+ /** The colour of a ring, and of anything two hops out. */
47
+ const FAINT = "#B0BEC5";
48
+ const DIM = "#78909C";
49
+ /** The fill of a place two or more hops out. */
50
+ const DIM_FILL = "#ECEFF1";
51
+
52
+ /**
53
+ * How much of the second hop's dimness a further hop keeps: each hop beyond
54
+ * the second steps down by this much.
55
+ */
56
+ const HOP_FADE_STEP = 0.25;
57
+
58
+ /** The least a place may fade to, so the farthest hop still reads. */
59
+ const HOP_FADE_FLOOR = 0.5;
60
+
61
+ /**
62
+ * The opacity of a place by its hop count: the neighbours and the second hop
63
+ * are drawn full — the second in the dim palette — and each hop beyond
64
+ * steps down, never below the floor.
65
+ *
66
+ * @param {number} hop - 1 for a neighbour, 2 and up beyond.
67
+ * @returns {number} 0 to 1.
68
+ */
69
+ export function hopOpacity(hop) {
70
+ return Math.max(HOP_FADE_FLOOR, 1 - HOP_FADE_STEP * Math.max(0, hop - 2));
71
+ }
72
+
73
+ /**
74
+ * A colour at an opacity, as DOT reads it: `#RRGGBB` at full, `#RRGGBBAA`
75
+ * otherwise.
76
+ *
77
+ * @param {string} colour - `#RRGGBB`.
78
+ * @param {number} opacity - 0 to 1.
79
+ * @returns {string} The colour.
80
+ */
81
+ function faded(colour, opacity) {
82
+ if (opacity >= 1) return colour;
83
+ const alpha = Math.round(Math.max(0, opacity) * 255)
84
+ .toString(16)
85
+ .padStart(2, "0")
86
+ .toUpperCase();
87
+ return `${colour}${alpha}`;
88
+ }
89
+
90
+ /**
91
+ * One row of the style table.
92
+ *
93
+ * @typedef {object} NodeStyle
94
+ * @property {string} shape
95
+ * @property {string} fill
96
+ * @property {string} border
97
+ * @property {number} fontsize
98
+ * @property {number} width - Inches, a minimum: a long label still grows the node.
99
+ * @property {number} height - Inches.
100
+ * @property {number} penwidth
101
+ * @property {string} extraStyle - `rounded`, `dashed`, or none.
102
+ */
103
+
104
+ /**
105
+ * Shape, colour and size by `subType`, plus the synthetic `polity` row for
106
+ * the affiliation notes drawn beside their regions. The world is largest;
107
+ * regions large with a bold label; settlements small with a thin border;
108
+ * site, feature and structure between.
109
+ *
110
+ * @type {Readonly<Record<string, NodeStyle>>}
111
+ */
112
+ export const STYLE = Object.freeze({
113
+ world: {
114
+ shape: "doublecircle",
115
+ fill: "#FFE082",
116
+ border: DEFAULT_BORDER,
117
+ fontsize: 16,
118
+ width: 1.3,
119
+ height: 1.3,
120
+ penwidth: 2.5,
121
+ extraStyle: "",
122
+ },
123
+ region: {
124
+ shape: "box",
125
+ fill: "#90CAF9",
126
+ border: DEFAULT_BORDER,
127
+ fontsize: 14,
128
+ width: 1.3,
129
+ height: 0.55,
130
+ penwidth: 2.0,
131
+ extraStyle: "rounded",
132
+ },
133
+ settlement: {
134
+ shape: "ellipse",
135
+ fill: "#A5D6A7",
136
+ border: DEFAULT_BORDER,
137
+ fontsize: 8,
138
+ width: 0.55,
139
+ height: 0.3,
140
+ penwidth: 1.0,
141
+ extraStyle: "",
142
+ },
143
+ site: {
144
+ shape: "diamond",
145
+ fill: "#CE93D8",
146
+ border: DEFAULT_BORDER,
147
+ fontsize: 10,
148
+ width: 0.9,
149
+ height: 0.55,
150
+ penwidth: 1.2,
151
+ extraStyle: "",
152
+ },
153
+ feature: {
154
+ shape: "hexagon",
155
+ fill: "#80CBC4",
156
+ border: DEFAULT_BORDER,
157
+ fontsize: 10,
158
+ width: 0.9,
159
+ height: 0.45,
160
+ penwidth: 1.2,
161
+ extraStyle: "",
162
+ },
163
+ structure: {
164
+ shape: "house",
165
+ fill: "#FFAB91",
166
+ border: DEFAULT_BORDER,
167
+ fontsize: 10,
168
+ width: 0.9,
169
+ height: 0.55,
170
+ penwidth: 1.2,
171
+ extraStyle: "",
172
+ },
173
+ polity: {
174
+ shape: "cylinder",
175
+ fill: "#E0E0E0",
176
+ border: POLITY_BORDER,
177
+ fontsize: 9,
178
+ width: 0.8,
179
+ height: 0.45,
180
+ penwidth: 1.0,
181
+ extraStyle: "dashed",
182
+ },
183
+ });
184
+
185
+ /** What each style row means, for the legend. */
186
+ const LEGEND = Object.freeze([
187
+ ["world", "the root note"],
188
+ ["region", "continent, region, sub-region"],
189
+ ["settlement", "city, town, village"],
190
+ ["site", "ruin, landmark, dungeon, wonder"],
191
+ ["feature", "river, mountain range, sea"],
192
+ ["structure", "a single building"],
193
+ ["polity", "affiliation, dashed edge to its region"],
194
+ ]);
195
+
196
+ /**
197
+ * A DOT double-quoted string.
198
+ *
199
+ * @param {unknown} value - The text.
200
+ * @returns {string} Quoted and escaped, newlines folded to spaces.
201
+ */
202
+ export function dotString(value) {
203
+ return (
204
+ '"' +
205
+ String(value ?? "")
206
+ .replace(/\\/g, "\\\\")
207
+ .replace(/"/g, '\\"')
208
+ .replace(/\n/g, " ") +
209
+ '"'
210
+ );
211
+ }
212
+
213
+ /**
214
+ * A DOT string whose `\n` line breaks are kept as GraphViz line breaks.
215
+ *
216
+ * @param {string[]} lines - The label's lines.
217
+ * @returns {string} Quoted.
218
+ */
219
+ function dotLines(lines) {
220
+ return '"' + lines.map((l) => dotString(l).slice(1, -1)).join("\\n") + '"';
221
+ }
222
+
223
+ /**
224
+ * A node id safe for DOT: a prefix and the shortcode, which is an address
225
+ * segment and so already a safe identifier.
226
+ *
227
+ * @param {string} prefix - `p` for a place, `a` for a polity.
228
+ * @param {string} shortcode - The note's shortcode.
229
+ * @returns {string} The id.
230
+ */
231
+ function nodeId(prefix, shortcode) {
232
+ return `${prefix}_${shortcode}`;
233
+ }
234
+
235
+ /**
236
+ * A place's or a polity's `[...]` attribute block from its style row.
237
+ *
238
+ * @param {string} key - A row of {@link STYLE}.
239
+ * @param {object} opts
240
+ * @param {number} opts.scale - Multiplies every size.
241
+ * @param {string} opts.label
242
+ * @param {string} opts.tooltip
243
+ * @param {string} [opts.url]
244
+ * @param {string} [opts.fill] - Overrides the row's fill.
245
+ * @param {string} [opts.border] - Overrides the row's border.
246
+ * @param {number} [opts.extraPenwidth]
247
+ * @param {string} [opts.fontcolor]
248
+ * @param {string} [opts.extra] - Further attributes, verbatim.
249
+ * @returns {string} The block.
250
+ */
251
+ function styleAttrs(
252
+ key,
253
+ { scale, label, tooltip, url, fill, border, extraPenwidth = 0, fontcolor, extra },
254
+ ) {
255
+ const row = STYLE[key] ?? STYLE.settlement;
256
+ const fontsize = Math.max(6, Math.round(row.fontsize * scale));
257
+ const width = Math.max(0.1, row.width * scale);
258
+ const height = Math.max(0.1, row.height * scale);
259
+ const penwidth = Math.max(0.5, row.penwidth * scale + extraPenwidth);
260
+ const style = [row.extraStyle, "filled"].filter(Boolean).join(",");
261
+ return (
262
+ `[label=${dotString(label)}, shape=${row.shape}, style=${dotString(style)}, ` +
263
+ `fillcolor=${dotString(fill ?? row.fill)}, color=${dotString(border ?? row.border)}, ` +
264
+ `fontsize=${fontsize}, width=${width.toFixed(2)}, height=${height.toFixed(2)}, ` +
265
+ `penwidth=${penwidth.toFixed(2)}, tooltip=${dotString(tooltip)}` +
266
+ (fontcolor ? `, fontcolor=${dotString(fontcolor)}` : "") +
267
+ (url ? `, URL=${dotString(url)}` : "") +
268
+ (extra ? `, ${extra}` : "") +
269
+ "]"
270
+ );
271
+ }
272
+
273
+ /**
274
+ * The legend, drawn from real styled nodes — same table, same scale — so
275
+ * shape and relative size are shown rather than described.
276
+ *
277
+ * @param {string[]} lines - The DOT being assembled.
278
+ * @param {number} scale - The drawing's scale.
279
+ * @returns {void}
280
+ */
281
+ function emitLegend(lines, scale) {
282
+ lines.push(" subgraph cluster_legend {");
283
+ lines.push(
284
+ ' label="Legend"; style="rounded"; color="#B0BEC5"; fontsize=12; fontname="Helvetica-Bold";',
285
+ );
286
+ const ids = [];
287
+ for (const [key, meaning] of LEGEND) {
288
+ const id = `legend_${key}`;
289
+ ids.push(id);
290
+ lines.push(` ${id} ${styleAttrs(key, { scale, label: key, tooltip: meaning })};`);
291
+ }
292
+ ids.push("legend_noparent");
293
+ lines.push(
294
+ ` legend_noparent ${styleAttrs("site", {
295
+ scale,
296
+ label: "no parent / unresolved",
297
+ tooltip:
298
+ "a place with no parent, or a placeholder for a parent shortcode that does not resolve",
299
+ fill: RED_FILL,
300
+ border: RED_BORDER,
301
+ })};`,
302
+ );
303
+ ids.push("legend_cycle");
304
+ lines.push(
305
+ ` legend_cycle ${styleAttrs("site", {
306
+ scale,
307
+ label: "cycle",
308
+ tooltip: "this place sits on a cycle in the parents graph",
309
+ border: RED_BORDER,
310
+ extraPenwidth: 2.0 * scale,
311
+ })};`,
312
+ );
313
+ lines.push(` ${ids.join(" -> ")} [style=invis];`);
314
+ lines.push(" }");
315
+ }
316
+
317
+ /* --------------------------------------------------------------------- */
318
+ /* The containment tree */
319
+ /* --------------------------------------------------------------------- */
320
+
321
+ /**
322
+ * A place node of the tree, styled by `subType` and highlighted by anomaly.
323
+ *
324
+ * @param {import("./map-places.mjs").MapPlace} place - The place.
325
+ * @param {import("./map-places.mjs").Containment} analysis - For the cycles.
326
+ * @param {number} scale - The drawing's scale.
327
+ * @returns {string} The attribute block.
328
+ */
329
+ function placeAttrs(place, analysis, scale) {
330
+ const onCycle = analysis.cycleNodes.has(place.shortcode);
331
+ const noParent = place.parents.length === 0 && place.shortcode !== analysis.world;
332
+ return styleAttrs(place.subType ?? "settlement", {
333
+ scale,
334
+ label: place.name,
335
+ tooltip: place.address ?? place.shortcode,
336
+ url: place.url,
337
+ fill: noParent ? RED_FILL : undefined,
338
+ border: onCycle ? RED_BORDER : undefined,
339
+ extraPenwidth: onCycle ? 2.0 * scale : 0,
340
+ });
341
+ }
342
+
343
+ /**
344
+ * The placeholder standing in for a parent shortcode no place declares.
345
+ *
346
+ * @param {string} shortcode - The unresolved shortcode.
347
+ * @param {Set<string>} referrers - The places naming it.
348
+ * @param {number} scale - The drawing's scale.
349
+ * @returns {string} The attribute block.
350
+ */
351
+ function placeholderAttrs(shortcode, referrers, scale) {
352
+ const tooltip = `unresolved shortcode "${shortcode}", named by: ${[...referrers].sort().join(", ")}`;
353
+ return (
354
+ `[label=${dotString(shortcode)}, shape=octagon, style=filled, fillcolor=${dotString(RED_FILL)}, ` +
355
+ `color=${dotString(RED_BORDER)}, fontsize=${Math.max(6, Math.round(9 * scale))}, ` +
356
+ `width=${Math.max(0.1, 0.8 * scale).toFixed(2)}, height=${Math.max(0.1, 0.4 * scale).toFixed(2)}, ` +
357
+ `penwidth=${Math.max(0.5, 1.2 * scale).toFixed(2)}, tooltip=${dotString(tooltip)}]`
358
+ );
359
+ }
360
+
361
+ /**
362
+ * The containment tree as DOT.
363
+ *
364
+ * With no `root`, every place, clustered by continent — a region parented
365
+ * directly on the world — and the places reaching no continent floating
366
+ * outside every cluster. With a `root`, the subtree beneath that place, in
367
+ * one cluster, the world drawn beside it only when the root is the world or
368
+ * a continent. Edges run child → parent, as `parents` states them. A polity
369
+ * is drawn dashed beside every region note in its directory, unless
370
+ * `polities` is false.
371
+ *
372
+ * @param {object} opts
373
+ * @param {Map<string, import("./map-places.mjs").MapPlace>} opts.places
374
+ * @param {import("./map-places.mjs").MapPolity[]} [opts.polities]
375
+ * @param {import("./map-places.mjs").Containment} opts.analysis
376
+ * @param {string} [opts.root] - Draw the subtree beneath this place.
377
+ * @param {string} [opts.title] - The drawing's title, defaulting to the
378
+ * world's or the root's name.
379
+ * @param {string} [opts.engine="dot"] - `dot`, `twopi` or `neato`.
380
+ * @param {string} [opts.rankdir="TB"] - `dot` only.
381
+ * @param {number} [opts.nodesep] - Inches; defaults by scale.
382
+ * @param {number} [opts.ranksep] - Inches; defaults by engine and scale.
383
+ * @param {number} [opts.scale=1] - Multiplies every size.
384
+ * @param {boolean} [opts.drawPolities=true] - Whether to draw the polities.
385
+ * @returns {string} The DOT text.
386
+ */
387
+ export function treeDot({
388
+ places,
389
+ polities = [],
390
+ analysis,
391
+ root,
392
+ title,
393
+ engine = "dot",
394
+ rankdir = "TB",
395
+ nodesep,
396
+ ranksep,
397
+ scale = 1,
398
+ drawPolities = true,
399
+ }) {
400
+ const world = analysis.world;
401
+ let included;
402
+ let scopeLabel;
403
+ let worldIsContext = false;
404
+ if (root !== undefined) {
405
+ if (!places.has(root)) {
406
+ throw new Error(`"${root}" is not a place in this package or a fetched index`);
407
+ }
408
+ included = subtreeOf(analysis, root);
409
+ worldIsContext = Boolean(
410
+ world && (root === world || places.get(root)?.parents.includes(world)),
411
+ );
412
+ if (worldIsContext && world) included.add(world);
413
+ scopeLabel = places.get(root)?.name ?? root;
414
+ } else {
415
+ included = new Set(places.keys());
416
+ }
417
+ const byName = (a, b) =>
418
+ String(places.get(a)?.name ?? a).localeCompare(String(places.get(b)?.name ?? b));
419
+
420
+ const lines = [];
421
+ lines.push("digraph tree {");
422
+ if (engine === "dot") {
423
+ lines.push(` rankdir=${rankdir};`);
424
+ lines.push(" splines=ortho;");
425
+ } else {
426
+ lines.push(" overlap=false;");
427
+ }
428
+ const defaultRanksep = engine === "twopi" ? 1.0 : 0.5;
429
+ lines.push(` nodesep=${(nodesep ?? 0.25 * scale).toFixed(2)};`);
430
+ lines.push(` ranksep=${(ranksep ?? defaultRanksep * scale).toFixed(2)};`);
431
+ if (engine === "twopi") {
432
+ const centre = root ?? world;
433
+ if (centre && places.has(centre)) lines.push(` root=${dotString(nodeId("p", centre))};`);
434
+ }
435
+ lines.push(' graph [fontname="Helvetica"];');
436
+ lines.push(' node [fontname="Helvetica"];');
437
+ lines.push(' edge [fontname="Helvetica", fontsize=8];');
438
+ const heading =
439
+ title ??
440
+ `${scopeLabel ?? (world ? places.get(world)?.name : "The world")} — containment tree`;
441
+ lines.push(` label=${dotString(heading)};`);
442
+ lines.push(' labelloc=t; fontsize=18; fontname="Helvetica-Bold";');
443
+ lines.push("");
444
+ emitLegend(lines, scale);
445
+ lines.push("");
446
+
447
+ const cluster = (id, label) => {
448
+ lines.push(` subgraph ${dotString(`cluster_${id}`)} {`);
449
+ lines.push(` label=${dotString(label)};`);
450
+ lines.push(' style="rounded"; color="#B0BEC5"; fontsize=12; fontname="Helvetica-Bold";');
451
+ };
452
+
453
+ if (root === undefined) {
454
+ /** @type {Map<string, string[]>} */
455
+ const byContinent = new Map();
456
+ const unclustered = [];
457
+ for (const sc of [...included].sort()) {
458
+ const c = analysis.continentOf.get(sc);
459
+ if (c === undefined) unclustered.push(sc);
460
+ else byContinent.set(c, [...(byContinent.get(c) ?? []), sc]);
461
+ }
462
+ for (const c of [...byContinent.keys()].sort(byName)) {
463
+ cluster(c, places.get(c)?.name ?? c);
464
+ for (const sc of /** @type {string[]} */ (byContinent.get(c)).sort(byName)) {
465
+ lines.push(
466
+ ` ${nodeId("p", sc)} ${placeAttrs(/** @type {any} */ (places.get(sc)), analysis, scale)};`,
467
+ );
468
+ }
469
+ lines.push(" }");
470
+ }
471
+ lines.push("");
472
+ for (const sc of unclustered.sort(byName)) {
473
+ lines.push(
474
+ ` ${nodeId("p", sc)} ${placeAttrs(/** @type {any} */ (places.get(sc)), analysis, scale)};`,
475
+ );
476
+ }
477
+ } else {
478
+ cluster(root, /** @type {string} */ (scopeLabel));
479
+ for (const sc of [...included].sort(byName)) {
480
+ if (sc === world && world !== root) continue;
481
+ lines.push(
482
+ ` ${nodeId("p", sc)} ${placeAttrs(/** @type {any} */ (places.get(sc)), analysis, scale)};`,
483
+ );
484
+ }
485
+ lines.push(" }");
486
+ if (worldIsContext && world && world !== root) {
487
+ lines.push(
488
+ ` ${nodeId("p", world)} ${placeAttrs(/** @type {any} */ (places.get(world)), analysis, scale)};`,
489
+ );
490
+ }
491
+ }
492
+ lines.push("");
493
+
494
+ /** @type {Map<string, Set<string>>} */
495
+ const placeholders = new Map();
496
+ const edges = [];
497
+ for (const sc of [...included].sort()) {
498
+ const place = places.get(sc);
499
+ if (!place) continue;
500
+ for (const parent of place.parents) {
501
+ if (places.has(parent)) {
502
+ if (root !== undefined && !included.has(parent)) continue;
503
+ edges.push(`${nodeId("p", sc)} -> ${nodeId("p", parent)}`);
504
+ } else {
505
+ const set = placeholders.get(parent) ?? new Set();
506
+ set.add(sc);
507
+ placeholders.set(parent, set);
508
+ edges.push(`${nodeId("p", sc)} -> ${nodeId("missing", parent)}`);
509
+ }
510
+ }
511
+ }
512
+ for (const [parent, referrers] of [...placeholders].sort()) {
513
+ lines.push(` ${nodeId("missing", parent)} ${placeholderAttrs(parent, referrers, scale)};`);
514
+ }
515
+ lines.push("");
516
+ for (const edge of edges) lines.push(` ${edge};`);
517
+ lines.push("");
518
+
519
+ if (drawPolities) {
520
+ /** @type {Map<string, string[]>} */
521
+ const includedRegionsByFolder = new Map();
522
+ for (const sc of included) {
523
+ const place = places.get(sc);
524
+ if (!place?.local || place.subType !== "region") continue;
525
+ const folder = place.folder ?? "";
526
+ includedRegionsByFolder.set(folder, [
527
+ ...(includedRegionsByFolder.get(folder) ?? []),
528
+ sc,
529
+ ]);
530
+ }
531
+ for (const polity of polities) {
532
+ const regions = includedRegionsByFolder.get(polity.folder) ?? [];
533
+ if (regions.length === 0) continue;
534
+ const id = nodeId("a", polity.shortcode);
535
+ lines.push(
536
+ ` ${id} ${styleAttrs("polity", {
537
+ scale,
538
+ label: polity.name,
539
+ tooltip: polity.address ?? polity.shortcode,
540
+ url: polity.url,
541
+ })};`,
542
+ );
543
+ for (const region of regions) {
544
+ lines.push(
545
+ ` ${id} -> ${nodeId("p", region)} [style=dashed, arrowhead=none, color=${dotString(POLITY_BORDER)}];`,
546
+ );
547
+ }
548
+ }
549
+ }
550
+ lines.push("}");
551
+ return lines.join("\n") + "\n";
552
+ }
553
+
554
+ /* --------------------------------------------------------------------- */
555
+ /* The map from a place, and the chart */
556
+ /* --------------------------------------------------------------------- */
557
+
558
+ /**
559
+ * An edge's label: the bearing, and the days of every route that reaches
560
+ * the far end.
561
+ *
562
+ * @param {import("./map-layout.mjs").FromEdge|import("./map-layout.mjs").TravelEdge} edge
563
+ * @returns {string} `E · 5 d`, `E · 5 d land, 3 d ship`, or `E` for a border.
564
+ */
565
+ function edgeLabel(edge) {
566
+ const routes = "routes" in edge ? edge.routes : [];
567
+ const days = (d) => (d === undefined ? "?" : `${d} d`);
568
+ if (routes.length > 1) {
569
+ return `${edge.bearing} · ${routes.map((r) => `${days(r.days)} ${r.mode}`).join(", ")}`;
570
+ }
571
+ if (edge.kind === "route") return `${edge.bearing} · ${days(edge.days)}`;
572
+ return edge.bearing;
573
+ }
574
+
575
+ /**
576
+ * The map from a place, or the chart from one, as DOT for `neato -n2`.
577
+ *
578
+ * Every node is pinned at the position the layout computed, so the engine
579
+ * only draws. The rings are circles pinned at the origin, drawn faintly,
580
+ * each labelled with its days marker at the top; the rim is one more,
581
+ * labelled `beyond` where the layout names what lies past the horizon there
582
+ * and `unknown` where it holds only the places whose days nobody stated.
583
+ * The centre is drawn in its `subType` style at full size, a neighbour at
584
+ * the layout's scale, a second hop dimmer, and each hop beyond fainter
585
+ * still, down to a floor. A place at the rim is a name, its label carrying
586
+ * its days or `?`. A border edge is dashed; every edge is labelled with its
587
+ * bearing and days, and dimmed with its far end.
588
+ *
589
+ * @param {import("./map-layout.mjs").FromLayout} layout - The layout.
590
+ * @param {Map<string, import("./map-places.mjs").MapPlace>} places - Every place.
591
+ * @param {object} [opts]
592
+ * @param {number} [opts.scale=1] - Multiplies every size.
593
+ * @param {string} [opts.title] - Defaults to `From <name>`, or to
594
+ * `Chart from <name>` for a layout with no rim beyond the horizon.
595
+ * @returns {string} The DOT text.
596
+ */
597
+ export function fromDot(layout, places, { scale = 1, title } = {}) {
598
+ const centre = places.get(layout.centre);
599
+ const name = centre?.name ?? layout.centre;
600
+ const lines = [];
601
+ lines.push("digraph from {");
602
+ lines.push(" splines=false; overlap=true; outputorder=nodesfirst;");
603
+ lines.push(' graph [fontname="Helvetica"];');
604
+ lines.push(' node [fontname="Helvetica"];');
605
+ lines.push(` edge [fontname="Helvetica", fontsize=${Math.max(6, Math.round(8 * scale))}];`);
606
+ lines.push(
607
+ ` label=${dotString(title ?? (layout.beyond ? `From ${name}` : `Chart from ${name}`))};`,
608
+ );
609
+ lines.push(' labelloc=t; fontsize=18; fontname="Helvetica-Bold";');
610
+ lines.push("");
611
+
612
+ // The rings, innermost first so a larger one never hides a smaller.
613
+ const ringNode = (id, radius, label) => {
614
+ const inches = ((2 * radius) / 72).toFixed(3);
615
+ lines.push(
616
+ ` ${id} [label="", shape=circle, fixedsize=true, width=${inches}, height=${inches}, ` +
617
+ `pos="0,0!", color=${dotString(FAINT)}, penwidth=0.6, style=dashed, ` +
618
+ `tooltip=${dotString(label)}];`,
619
+ );
620
+ };
621
+ for (const ring of layout.rings) {
622
+ ringNode(`ring_${ring.days}`, ring.radius, `${ring.days} days`);
623
+ lines.push(
624
+ ` ringlabel_${ring.days} [label=${dotString(`${ring.days} d`)}, shape=plaintext, ` +
625
+ `fontsize=${Math.max(6, Math.round(7 * scale))}, fontcolor=${dotString(DIM)}, ` +
626
+ `pos="0,${(ring.radius + 6).toFixed(1)}!", width=0, height=0, margin=0];`,
627
+ );
628
+ }
629
+ ringNode(
630
+ "rim",
631
+ layout.rim,
632
+ layout.beyond ? "beyond the horizon, or days unknown" : "days unknown",
633
+ );
634
+ lines.push(
635
+ ` rimlabel [label=${dotString(layout.beyond ? "beyond" : "unknown")}, shape=plaintext, ` +
636
+ `fontsize=${Math.max(6, Math.round(7 * scale))}, fontcolor=${dotString(DIM)}, ` +
637
+ `pos="0,${(layout.rim + 6).toFixed(1)}!", width=0, height=0, margin=0];`,
638
+ );
639
+ lines.push("");
640
+
641
+ for (const node of layout.nodes) {
642
+ const place = places.get(node.shortcode);
643
+ if (!place) continue;
644
+ const pos = `pos="${node.x.toFixed(1)},${node.y.toFixed(1)}!"`;
645
+ const id = nodeId("p", node.shortcode);
646
+ const opacity = hopOpacity(node.hop);
647
+ const atRim = node.unknown || node.radius >= layout.rim;
648
+ if (atRim) {
649
+ const label = dotLines([place.name, node.days === undefined ? "?" : `${node.days} d`]);
650
+ lines.push(
651
+ ` ${id} [label=${label}, shape=plaintext, fontsize=${Math.max(6, Math.round(9 * scale))}, ` +
652
+ `fontcolor=${dotString(faded(DIM, opacity))}, tooltip=${dotString(place.address ?? place.shortcode)}` +
653
+ (place.url ? `, URL=${dotString(place.url)}` : "") +
654
+ `, ${pos}];`,
655
+ );
656
+ continue;
657
+ }
658
+ const nodeScale = node.hop === 0 ? scale : scale * 0.8;
659
+ const dim = node.hop >= 2;
660
+ lines.push(
661
+ ` ${id} ${styleAttrs(place.subType ?? "settlement", {
662
+ scale: nodeScale,
663
+ label: place.name,
664
+ tooltip: place.address ?? place.shortcode,
665
+ url: place.url,
666
+ border: dim ? faded(DIM, opacity) : undefined,
667
+ fontcolor: dim ? faded(DIM, opacity) : undefined,
668
+ extra: pos + (dim ? `, fillcolor=${dotString(faded(DIM_FILL, opacity))}` : ""),
669
+ })};`,
670
+ );
671
+ }
672
+ lines.push("");
673
+
674
+ for (const edge of layout.edges) {
675
+ const attrs = [`label=${dotString(edgeLabel(edge))}`, "arrowhead=none"];
676
+ if (edge.kind === "border") attrs.push("style=dashed");
677
+ if (edge.hop >= 2) {
678
+ const opacity = hopOpacity(edge.hop);
679
+ attrs.push(
680
+ `color=${dotString(faded(FAINT, opacity))}`,
681
+ `fontcolor=${dotString(faded(DIM, opacity))}`,
682
+ );
683
+ } else attrs.push(`color=${dotString(DEFAULT_BORDER)}`);
684
+ lines.push(` ${nodeId("p", edge.from)} -> ${nodeId("p", edge.to)} [${attrs.join(", ")}];`);
685
+ }
686
+ lines.push("}");
687
+ return lines.join("\n") + "\n";
688
+ }
689
+
690
+ /* --------------------------------------------------------------------- */
691
+ /* The whole route graph */
692
+ /* --------------------------------------------------------------------- */
693
+
694
+ /**
695
+ * The whole route graph as DOT, for `neato`.
696
+ *
697
+ * Undirected, one edge per pair, each asking the engine for a length from
698
+ * its days; the engine solves the positions. A border is dashed. Every node
699
+ * that takes part in a relation is drawn in its `subType` style.
700
+ *
701
+ * @param {ReturnType<typeof import("./map-layout.mjs").travelGraph>} graph - The graph.
702
+ * @param {Map<string, import("./map-places.mjs").MapPlace>} places - Every place.
703
+ * @param {object} [opts]
704
+ * @param {number} [opts.scale=1] - Multiplies every size.
705
+ * @param {string} [opts.title] - Defaults to `Routes`.
706
+ * @returns {string} The DOT text.
707
+ */
708
+ export function travelDot(graph, places, { scale = 1, title } = {}) {
709
+ const lines = [];
710
+ lines.push("graph travel {");
711
+ lines.push(" overlap=false; splines=true;");
712
+ lines.push(' graph [fontname="Helvetica"];');
713
+ lines.push(' node [fontname="Helvetica"];');
714
+ lines.push(` edge [fontname="Helvetica", fontsize=${Math.max(6, Math.round(8 * scale))}];`);
715
+ lines.push(` label=${dotString(title ?? "Routes")};`);
716
+ lines.push(' labelloc=t; fontsize=18; fontname="Helvetica-Bold";');
717
+ lines.push("");
718
+
719
+ const involved = new Set();
720
+ for (const edge of graph.edges) {
721
+ involved.add(edge.from);
722
+ involved.add(edge.to);
723
+ }
724
+ for (const sc of [...involved].sort()) {
725
+ const place = places.get(sc);
726
+ if (!place) continue;
727
+ lines.push(
728
+ ` ${nodeId("p", sc)} ${styleAttrs(place.subType ?? "settlement", {
729
+ scale: scale * 0.8,
730
+ label: place.name,
731
+ tooltip: place.address ?? place.shortcode,
732
+ url: place.url,
733
+ })};`,
734
+ );
735
+ }
736
+ lines.push("");
737
+ for (const edge of graph.edges) {
738
+ const attrs = [`len=${edge.len.toFixed(3)}`, `label=${dotString(edgeLabel(edge))}`];
739
+ if (edge.kind === "border") attrs.push("style=dashed");
740
+ attrs.push(`color=${dotString(DEFAULT_BORDER)}`);
741
+ lines.push(` ${nodeId("p", edge.from)} -- ${nodeId("p", edge.to)} [${attrs.join(", ")}];`);
742
+ }
743
+ lines.push("}");
744
+ return lines.join("\n") + "\n";
745
+ }