@heroiclands/package-build 22.4.3 → 22.4.5

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