@diagrammo/dgmo 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.cursorrules +3 -0
  2. package/.github/copilot-instructions.md +3 -0
  3. package/.windsurfrules +3 -0
  4. package/SKILL.md +3 -0
  5. package/dist/advanced.cjs +1058 -135
  6. package/dist/advanced.d.cts +122 -8
  7. package/dist/advanced.d.ts +122 -8
  8. package/dist/advanced.js +1055 -133
  9. package/dist/auto.cjs +1047 -132
  10. package/dist/auto.js +96 -95
  11. package/dist/auto.mjs +1048 -130
  12. package/dist/cli.cjs +117 -116
  13. package/dist/editor.cjs +4 -1
  14. package/dist/editor.js +4 -1
  15. package/dist/highlight.cjs +3 -0
  16. package/dist/highlight.js +3 -0
  17. package/dist/index.cjs +1043 -131
  18. package/dist/index.js +1044 -129
  19. package/docs/ai-integration.md +3 -0
  20. package/docs/language-reference.md +106 -7
  21. package/gallery/fixtures/map-coastline.dgmo +2 -2
  22. package/gallery/fixtures/map-route.dgmo +5 -5
  23. package/gallery/fixtures/treemap-branch.dgmo +15 -0
  24. package/gallery/fixtures/treemap-heat.dgmo +12 -0
  25. package/gallery/fixtures/treemap-tag-heat.dgmo +21 -0
  26. package/gallery/fixtures/treemap-tags.dgmo +18 -0
  27. package/package.json +1 -1
  28. package/src/advanced.ts +11 -0
  29. package/src/boxes-and-lines/layout-search.ts +19 -1
  30. package/src/chart-type-registry.ts +4 -0
  31. package/src/chart-types.ts +5 -0
  32. package/src/completion.ts +64 -0
  33. package/src/d3.ts +30 -18
  34. package/src/editor/highlight-api.ts +2 -0
  35. package/src/editor/keywords.ts +3 -0
  36. package/src/infra/parser.ts +95 -38
  37. package/src/map/dimensions.ts +19 -2
  38. package/src/map/layout.ts +91 -7
  39. package/src/map/parser.ts +51 -29
  40. package/src/map/renderer.ts +10 -0
  41. package/src/map/types.ts +9 -8
  42. package/src/treemap/layout.ts +189 -0
  43. package/src/treemap/parser.ts +475 -0
  44. package/src/treemap/renderer.ts +698 -0
  45. package/src/treemap/types.ts +57 -0
  46. package/src/utils/parsing.ts +1 -0
  47. package/src/utils/reserved-key-registry.ts +5 -0
@@ -78,16 +78,20 @@ const TAG_VALUE_RE = /^(\w[\w\s]+?)\s*$/;
78
78
 
79
79
  // Component line. Accepts either a quoted name ("name with | : reserved chars")
80
80
  // or a bare name (multi-word allowed; must start with letter/underscore so digit-
81
- // only or sigil-led lines fall through to other branches). Pipe metadata follows
82
- // after a `|` separator.
81
+ // only or sigil-led lines fall through to other branches). Metadata follows
82
+ // after a `|` separator (legacy) or as same-line `key: value` (§1.4).
83
83
  //
84
- // Captures:
85
- // 1: quoted-name content (without the surrounding quotes), or undefined
86
- // 2: bare-name (trimmed at the call site), or undefined
87
- // 3: metadata block — legacy `| ...` form OR new same-line
88
- // `<word>: <value>...` form (§1.4), or undefined
84
+ // Named captures: qname (quoted content), uname (bare name), alias (`as <x>`
85
+ // postfix), meta (the metadata tail). qname/uname are mutually exclusive.
86
+ //
87
+ // A component declaration: a quoted display name OR a bare name, an OPTIONAL
88
+ // `as <alias>` postfix (alias mirrors peelAlias: letter-led, ≤12 chars), then
89
+ // optional same-line metadata. The `as` capture is what makes a quoted name +
90
+ // alias parse — `"Web App" as web` — which the bare-name path got for free by
91
+ // sweeping the alias into the lazy name group and peeling it later, but the
92
+ // quoted path could not reach past the closing quote.
89
93
  const COMPONENT_RE =
90
- /^(?:"([^"]+)"|([a-zA-Z_][\w -]*?))\s*((?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
94
+ /^(?:"(?<qname>[^"]+)"|(?<uname>[a-zA-Z_][\w -]*?))(?:\s+as\s+(?<alias>[A-Za-z][A-Za-z0-9_]{0,11}))?\s*(?<meta>(?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
91
95
 
92
96
  // Legacy pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated).
93
97
  // Kept for transitional back-compat extraction; new same-line metadata
@@ -302,6 +306,11 @@ export function parseInfra(content: string): ParsedInfra {
302
306
  let currentGroup: Writable<InfraGroup> | null = null;
303
307
  let currentTagGroup: Writable<InfraTagGroup> | null = null;
304
308
  let baseIndent = 0; // indent of the current component line
309
+ // The `infra [Title]` declaration is only legal on the first content line.
310
+ // parseFirstLine() must run ONCE, there — otherwise a later bare node line
311
+ // whose name is a chart-type keyword (e.g. an edge-block header `timeline`)
312
+ // is mis-read as a nested chart declaration and the parse aborts.
313
+ let firstLineConsumed = false;
305
314
 
306
315
  function finishCurrentNode() {
307
316
  if (!currentNode) return;
@@ -310,7 +319,12 @@ export function parseInfra(content: string): ParsedInfra {
310
319
  if (existing) {
311
320
  const incomingDisplay = displayName(currentNode.label);
312
321
  const existingDisplay = displayName(existing.label);
313
- if (incomingDisplay !== existingDisplay) {
322
+ // A bare reference line that is a declared alias of `existing` (e.g. an
323
+ // edge-block header `web` for a node `"Web App" as web`) is an intentional
324
+ // pointer, not a near-duplicate name — don't flag it as a case/whitespace
325
+ // typo. Genuine collisions (no matching alias) still warn.
326
+ const isAliasRef = nameAliasMap.get(currentNode.label) === key;
327
+ if (incomingDisplay !== existingDisplay && !isAliasRef) {
314
328
  result.diagnostics.push(
315
329
  makeDgmoError(
316
330
  currentNode.lineNumber,
@@ -400,20 +414,25 @@ export function parseInfra(content: string): ParsedInfra {
400
414
  finishCurrentNode();
401
415
  }
402
416
 
403
- // First line: `infra [Title]` or legacy `chart: infra`
404
- const firstLineResult = parseFirstLine(trimmed);
405
- if (firstLineResult) {
406
- if (firstLineResult.chartType !== 'infra') {
407
- setError(
408
- lineNumber,
409
- `Expected chart type 'infra', got '${firstLineResult.chartType}'`
410
- );
411
- }
412
- if (firstLineResult.title) {
413
- result.title = firstLineResult.title;
414
- result.titleLineNumber = lineNumber;
417
+ // First line: `infra [Title]` or legacy `chart: infra` — only ever
418
+ // attempted on the first content line (see firstLineConsumed). A node or
419
+ // alias named like a chart type elsewhere is a normal node, not a redecl.
420
+ if (!firstLineConsumed) {
421
+ firstLineConsumed = true;
422
+ const firstLineResult = parseFirstLine(trimmed);
423
+ if (firstLineResult) {
424
+ if (firstLineResult.chartType !== 'infra') {
425
+ setError(
426
+ lineNumber,
427
+ `Expected chart type 'infra', got '${firstLineResult.chartType}'`
428
+ );
429
+ }
430
+ if (firstLineResult.title) {
431
+ result.title = firstLineResult.title;
432
+ result.titleLineNumber = lineNumber;
433
+ }
434
+ continue;
415
435
  }
416
- continue;
417
436
  }
418
437
 
419
438
  // direction-tb — bare boolean to switch to top-to-bottom (default is LR)
@@ -496,18 +515,26 @@ export function parseInfra(content: string): ParsedInfra {
496
515
  finishCurrentNode();
497
516
  finishCurrentTagGroup();
498
517
 
499
- const rawName = (compMatch[1] ?? compMatch[2] ?? '').trim();
518
+ const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
519
+ const rawName = (qname ?? uname ?? '').trim();
500
520
  const peeled = peelInfraDecorations(rawName, lineNumber);
501
521
  const name = peeled.label;
502
- const rest = compMatch[3] || '';
522
+ // Explicit `as` capture wins; fall back to an alias the peeler split out
523
+ // of a bare name (and the quoted-literal `"Foo as bar"` legacy case).
524
+ const alias = asAlias ?? peeled.alias;
525
+ const rest = meta ?? '';
503
526
  const { tags } = extractPipeMetadata(rest);
504
527
  warnUnknownMetaKeys(
505
528
  tags,
506
529
  withTagAliases(INFRA_REGISTRY, tagAliasSet),
507
530
  (msg) => warn(lineNumber, msg)
508
531
  );
509
- const id = nodeId(name);
510
- if (peeled.alias) nameAliasMap.set(peeled.alias, id);
532
+ // A bare reference line may be an alias for an already-declared node
533
+ // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
534
+ // through the alias map first so an edge-source/reference merges into
535
+ // that node instead of spawning a duplicate stub.
536
+ const id = nameAliasMap.get(name) ?? nodeId(name);
537
+ if (alias) nameAliasMap.set(alias, id);
511
538
  const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
512
539
 
513
540
  currentNode = {
@@ -609,18 +636,24 @@ export function parseInfra(content: string): ParsedInfra {
609
636
  const compMatch = trimmed.match(COMPONENT_RE);
610
637
  if (compMatch) {
611
638
  finishCurrentTagGroup();
612
- const rawName = (compMatch[1] ?? compMatch[2] ?? '').trim();
639
+ const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
640
+ const rawName = (qname ?? uname ?? '').trim();
613
641
  const peeled = peelInfraDecorations(rawName, lineNumber);
614
642
  const name = peeled.label;
615
- const rest = compMatch[3] || '';
643
+ const alias = asAlias ?? peeled.alias;
644
+ const rest = meta ?? '';
616
645
  const { tags: nodeTags } = extractPipeMetadata(rest);
617
646
  warnUnknownMetaKeys(
618
647
  nodeTags,
619
648
  withTagAliases(INFRA_REGISTRY, tagAliasSet),
620
649
  (msg) => warn(lineNumber, msg)
621
650
  );
622
- const id = nodeId(name);
623
- if (peeled.alias) nameAliasMap.set(peeled.alias, id);
651
+ // A bare reference line may be an alias for an already-declared node
652
+ // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
653
+ // through the alias map first so an edge-source/reference merges into
654
+ // that node instead of spawning a duplicate stub.
655
+ const id = nameAliasMap.get(name) ?? nodeId(name);
656
+ if (alias) nameAliasMap.set(alias, id);
624
657
  // Cascade group metadata into node tags; node-level metadata overrides
625
658
  const tags: Record<string, string> = currentGroup.metadata
626
659
  ? { ...currentGroup.metadata, ...nodeTags }
@@ -859,6 +892,18 @@ export function parseInfra(content: string): ParsedInfra {
859
892
  continue;
860
893
  }
861
894
 
895
+ // Indented tag assignment — `t: Edge` on its own line, the indented twin
896
+ // of same-line `Name t: Edge`. A tag alias is neither a behavior property
897
+ // nor 'description', so without this it falls through to the unknown-key
898
+ // path and is mis-collected as description text, silently dropping the
899
+ // node's tier. currentNode.tags is the same object held in
900
+ // nodeMutableTags, so a direct assign is enough.
901
+ if (currentNode && tagAliasSet.has(normalizeName(key))) {
902
+ const mut = nodeMutableTags.get(currentNode.id);
903
+ if (mut) mut[tagAttrKey(key)] = rawVal;
904
+ continue;
905
+ }
906
+
862
907
  // Unknown keys: decide between property typo warning vs description collection.
863
908
  // Heuristic: if the key looks like a plausible property (alphanumeric-hyphen, close
864
909
  // match to a known key, or the value looks numeric/percentage), warn as typo.
@@ -947,18 +992,24 @@ export function parseInfra(content: string): ParsedInfra {
947
992
 
948
993
  const compMatch = trimmed.match(COMPONENT_RE);
949
994
  if (compMatch) {
950
- const rawName = (compMatch[1] ?? compMatch[2] ?? '').trim();
995
+ const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
996
+ const rawName = (qname ?? uname ?? '').trim();
951
997
  const peeled = peelInfraDecorations(rawName, lineNumber);
952
998
  const name = peeled.label;
953
- const rest = compMatch[3] || '';
999
+ const alias = asAlias ?? peeled.alias;
1000
+ const rest = meta ?? '';
954
1001
  const { tags: nodeTags } = extractPipeMetadata(rest);
955
1002
  warnUnknownMetaKeys(
956
1003
  nodeTags,
957
1004
  withTagAliases(INFRA_REGISTRY, tagAliasSet),
958
1005
  (msg) => warn(lineNumber, msg)
959
1006
  );
960
- const id = nodeId(name);
961
- if (peeled.alias) nameAliasMap.set(peeled.alias, id);
1007
+ // A bare reference line may be an alias for an already-declared node
1008
+ // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
1009
+ // through the alias map first so an edge-source/reference merges into
1010
+ // that node instead of spawning a duplicate stub.
1011
+ const id = nameAliasMap.get(name) ?? nodeId(name);
1012
+ if (alias) nameAliasMap.set(alias, id);
962
1013
  const tags: Record<string, string> = currentGroup.metadata
963
1014
  ? { ...currentGroup.metadata, ...nodeTags }
964
1015
  : nodeTags;
@@ -986,18 +1037,24 @@ export function parseInfra(content: string): ParsedInfra {
986
1037
  finishCurrentTagGroup();
987
1038
  currentGroup = null;
988
1039
 
989
- const rawName = (compMatch[1] ?? compMatch[2] ?? '').trim();
1040
+ const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
1041
+ const rawName = (qname ?? uname ?? '').trim();
990
1042
  const peeled = peelInfraDecorations(rawName, lineNumber);
991
1043
  const name = peeled.label;
992
- const rest = compMatch[3] || '';
1044
+ const alias = asAlias ?? peeled.alias;
1045
+ const rest = meta ?? '';
993
1046
  const { tags } = extractPipeMetadata(rest);
994
1047
  warnUnknownMetaKeys(
995
1048
  tags,
996
1049
  withTagAliases(INFRA_REGISTRY, tagAliasSet),
997
1050
  (msg) => warn(lineNumber, msg)
998
1051
  );
999
- const id = nodeId(name);
1000
- if (peeled.alias) nameAliasMap.set(peeled.alias, id);
1052
+ // A bare reference line may be an alias for an already-declared node
1053
+ // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
1054
+ // through the alias map first so an edge-source/reference merges into
1055
+ // that node instead of spawning a duplicate stub.
1056
+ const id = nameAliasMap.get(name) ?? nodeId(name);
1057
+ if (alias) nameAliasMap.set(alias, id);
1001
1058
 
1002
1059
  currentNode = {
1003
1060
  id,
@@ -32,6 +32,13 @@ const TITLE_GAP = 16;
32
32
  // width:100% in a narrow note column stays sane.
33
33
  const ASPECT_MAX = 3.0;
34
34
  const ASPECT_MIN = 0.9;
35
+ // Deliberate vertical boost for GLOBAL (whole-world) extents only. An honest
36
+ // whole-world projection is ~2.4:1, which reads as a flat letterbox band. We emit
37
+ // a canvas this fraction taller and let the global stretch-fill (layout.ts) fill
38
+ // the extra height — the projected land stretches ~12% taller while POI radii and
39
+ // label fonts (applied in the renderer, not here) stay un-squashed. US, regional,
40
+ // and non-global extents are untouched (they keep their true projected aspect).
41
+ const GLOBAL_HEIGHT_BOOST = 1.12;
35
42
  // Minimum px of actual map area (below the chrome band) — keeps a short canvas
36
43
  // (very wide extent) from being crowded out by the title/caption.
37
44
  const MIN_MAP_BAND = 200;
@@ -96,11 +103,21 @@ export function mapExportDimensions(
96
103
  Number.isFinite(aspectOverride) &&
97
104
  aspectOverride > 0;
98
105
  const raw = useOverride ? aspectOverride : mapContentAspect(resolved, data);
106
+ // Whole-world extents get the vertical boost (a taller canvas the global
107
+ // stretch-fill then fills). `fitIsGlobal` is the SAME classification the layout
108
+ // uses to decide stretch-fill vs contain, so the two always agree. The override
109
+ // path (app WYSIWYG) and non-global extents pass through at their true aspect.
110
+ const isGlobal =
111
+ !useOverride && buildMapProjection(resolved, data).fitIsGlobal;
112
+ // The target the renderer should hit BEFORE the pathological clamp: the boosted
113
+ // aspect for global views, the raw aspect otherwise. preferContain compares the
114
+ // clamp against THIS (not raw) so the deliberate boost never trips the letterbox.
115
+ const target = isGlobal ? raw / GLOBAL_HEIGHT_BOOST : raw;
99
116
  // The override is the user's on-screen aspect — honour it as-is (no clamp);
100
117
  // only the intrinsic path guards against pathological extents.
101
118
  const clamped = useOverride
102
119
  ? raw
103
- : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
120
+ : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, target));
104
121
  const width = baseWidth;
105
122
  let height = Math.round(width / clamped);
106
123
 
@@ -128,6 +145,6 @@ export function mapExportDimensions(
128
145
  // contain-fit (letterbox) rather than stretch-distort. The WYSIWYG override is
129
146
  // exempt: it stretch-fills (mirroring the preview pane) unless the MIN_MAP_BAND
130
147
  // floor had to grow the canvas off-aspect.
131
- const preferContain = useOverride ? floored : clamped !== raw || floored;
148
+ const preferContain = useOverride ? floored : clamped !== target || floored;
132
149
  return { width, height, preferContain };
133
150
  }
package/src/map/layout.ts CHANGED
@@ -442,6 +442,10 @@ export interface MapLayoutLeg {
442
442
  * (§24B.6). Omitted when the leg carries no tag. */
443
443
  readonly tags?: Readonly<Record<string, string>>;
444
444
  readonly label?: string;
445
+ /** The leg's numeric weight (the `value:` metadata) when present and positive.
446
+ * Drives {@link width}, but kept here verbatim so the renderer can surface it
447
+ * on hover (a `<title>` tooltip) — the width alone is lossy. */
448
+ readonly value?: number;
445
449
  readonly labelX?: number;
446
450
  readonly labelY?: number;
447
451
  /** Text colour for the label — contrast-picked against the background fill the
@@ -3027,13 +3031,15 @@ export function layoutMap(
3027
3031
  leg.label !== undefined
3028
3032
  ? labelOnFill(fillAt(bow.labelX, bow.labelY))
3029
3033
  : undefined;
3034
+ const routeVal = Number(leg.value);
3030
3035
  legs.push({
3031
3036
  d: legPath(a, b, bow.curved, bow.offset),
3032
- width: routeWidthFor(Number(leg.value)),
3037
+ width: routeWidthFor(routeVal),
3033
3038
  color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
3034
3039
  arrow: true,
3035
3040
  fromId: leg.fromId,
3036
3041
  toId: leg.toId,
3042
+ ...(Number.isFinite(routeVal) && routeVal > 0 && { value: routeVal }),
3037
3043
  ...(Object.keys(leg.tags).length > 0 && { tags: leg.tags }),
3038
3044
  lineNumber: leg.lineNumber,
3039
3045
  ...(leg.label !== undefined && {
@@ -3087,6 +3093,7 @@ export function layoutMap(
3087
3093
  e.label !== undefined
3088
3094
  ? labelOnFill(fillAt(bow.labelX, bow.labelY))
3089
3095
  : undefined;
3096
+ const edgeVal = Number(e.meta['value']);
3090
3097
  legs.push({
3091
3098
  d: legPath(a, b, bow.curved, bow.offset),
3092
3099
  width: widthFor(e),
@@ -3094,6 +3101,7 @@ export function layoutMap(
3094
3101
  arrow: e.directed,
3095
3102
  fromId: e.fromId,
3096
3103
  toId: e.toId,
3104
+ ...(Number.isFinite(edgeVal) && edgeVal > 0 && { value: edgeVal }),
3097
3105
  ...(Object.keys(e.tags).length > 0 && { tags: e.tags }),
3098
3106
  lineNumber: e.lineNumber,
3099
3107
  ...(e.label !== undefined && {
@@ -3436,6 +3444,49 @@ export function layoutMap(
3436
3444
  const SHORT_HOP_MAX = Math.max(46, Math.min(width, height) * 0.11);
3437
3445
  const STEP = 4; // px probe granularity walking out of the region
3438
3446
  const HOP_GAP = 9; // px clearance between region edge and chip
3447
+ // How OPEN is the space a chip projects into? Measured on the chip's OUTWARD
3448
+ // hemisphere (the side facing away from its region): cast a fan of rays and
3449
+ // return the distance to the nearest land or canvas edge. A chip wedged in a
3450
+ // crowded inlet (the Gulf of Mexico, hemmed by Florida + the Caribbean)
3451
+ // scores low; one sitting in open water (the empty Pacific off Mexico's west
3452
+ // coast) saturates the cap. Callout scoring MAXIMISES this, using leader
3453
+ // length only as a tie-break — so a region leaders toward the open sea, not
3454
+ // into whichever coast happens to be the shortest hop away. We only probe the
3455
+ // outward hemisphere because the inward side always hits the region's own
3456
+ // fill immediately (the chip hugs its border), which would flatten every spot.
3457
+ const CLEAR_STEP = 6; // px granularity of the openness probe
3458
+ const CLEAR_MAX = SHORT_HOP_MAX * 3; // saturation — beyond this it's "open"
3459
+ const CLEAR_TOL = CLEAR_STEP; // openness within a probe step counts as a tie
3460
+ const clearanceAt = (
3461
+ px: number,
3462
+ py: number,
3463
+ ox: number, // outward unit dir (away from the region)
3464
+ oy: number
3465
+ ): number => {
3466
+ const baseAng = Math.atan2(oy, ox);
3467
+ let minClear = CLEAR_MAX;
3468
+ // 5 rays spanning the outward hemisphere (baseAng ± 90° in 45° steps).
3469
+ for (let k = -2; k <= 2; k++) {
3470
+ const a = baseAng + (k * Math.PI) / 4;
3471
+ const rx = Math.cos(a);
3472
+ const ry = Math.sin(a);
3473
+ let d = CLEAR_STEP;
3474
+ for (; d <= CLEAR_MAX; d += CLEAR_STEP) {
3475
+ const qx = px + rx * d;
3476
+ const qy = py + ry * d;
3477
+ if (
3478
+ qx < FIT_PAD ||
3479
+ qy < topPad ||
3480
+ qx > width - FIT_PAD ||
3481
+ qy > height - FIT_PAD
3482
+ )
3483
+ break; // ran into the frame — that edge bounds the openness
3484
+ if (fillAt(qx, qy) !== water) break; // hit land of any kind
3485
+ }
3486
+ if (d < minClear) minClear = d;
3487
+ }
3488
+ return minClear;
3489
+ };
3439
3490
  // Try to seat `name`(+`value`) as a short-hop chip for a region centred at
3440
3491
  // (cx,cy) on fill `fill`. Returns the placement (chip rect + leader) or null.
3441
3492
  const tryShortHopCallout = (
@@ -3455,11 +3506,19 @@ export function layoutMap(
3455
3506
  } | null => {
3456
3507
  const chipW = stackW(name, value, font);
3457
3508
  const chipH = stackH(value !== undefined, font);
3509
+ // Unit vectors — 4 cardinals + 4 diagonals. Diagonals matter: open water
3510
+ // often sits off-axis (Mexico's empty sea is WSW), and a pure-cardinal
3511
+ // scheme can only aim due-N/S/E/W. Magnitude 1 keeps each walk step = STEP px.
3512
+ const D = Math.SQRT1_2; // 0.7071…
3458
3513
  const dirs: Array<[number, number]> = [
3459
- [0, -1], // north (over Canada)
3460
- [0, 1], // south (over Mexico / Gulf)
3461
- [1, 0], // east (Atlantic)
3462
- [-1, 0], // west (Pacific)
3514
+ [0, -1], // north
3515
+ [0, 1], // south
3516
+ [1, 0], // east
3517
+ [-1, 0], // west
3518
+ [D, -D], // NE
3519
+ [D, D], // SE
3520
+ [-D, -D], // NW
3521
+ [-D, D], // SW
3463
3522
  ];
3464
3523
  let best: {
3465
3524
  x: number;
@@ -3467,6 +3526,7 @@ export function layoutMap(
3467
3526
  rect: LabelRect;
3468
3527
  leader: [number, number, number, number];
3469
3528
  len: number;
3529
+ clear: number;
3470
3530
  } | null = null;
3471
3531
  for (const [dx, dy] of dirs) {
3472
3532
  // Walk from the centroid outward until we leave the region's own fill —
@@ -3533,13 +3593,23 @@ export function layoutMap(
3533
3593
  )
3534
3594
  )
3535
3595
  continue;
3536
- if (best === null || len < best.len)
3596
+ // Prefer the MOST OPEN spot; fall back to the shortest leader only when
3597
+ // two candidates are about equally open (e.g. several open-water spots
3598
+ // that all saturate the clearance cap). This is what redirects a label
3599
+ // away from a cramped-but-close coast toward the empty sea.
3600
+ const clear = clearanceAt(ccx, ccy, dx, dy);
3601
+ const better =
3602
+ best === null ||
3603
+ clear > best.clear + CLEAR_TOL ||
3604
+ (clear >= best.clear - CLEAR_TOL && len < best.len);
3605
+ if (better)
3537
3606
  best = {
3538
3607
  x: ccx,
3539
3608
  y: ccy,
3540
3609
  rect,
3541
3610
  leader: [cx, cy, innerX, innerY],
3542
3611
  len,
3612
+ clear,
3543
3613
  };
3544
3614
  }
3545
3615
  return best
@@ -4599,6 +4669,20 @@ export function layoutMap(
4599
4669
  const nx = -dy / len;
4600
4670
  const ny = dx / len; // unit normal to the chord
4601
4671
  const perp = labelH + 2;
4672
+ // A label rides ON its line by default (off=0 first) — a hairline under
4673
+ // the text reads fine and looks intentional. But a HEAVY weighted leg
4674
+ // (value→width up to W_MAX) draws a thick dark stroke, and text centred on
4675
+ // it is unreadable (dark-on-dark, no contrast). For those, lead with a
4676
+ // perpendicular hop that clears the stroke's half-width + the label's own
4677
+ // half-height + a gap, so the text sits just BESIDE the line on open
4678
+ // ground (both sides tried; the first clean one — typically the open-water
4679
+ // side — wins). Thin legs keep the ride-on default untouched.
4680
+ const stroke = lg.width ?? W_MIN;
4681
+ const clearW = stroke / 2 + labelH / 2 + 3;
4682
+ const offList =
4683
+ stroke >= 4
4684
+ ? [clearW, -clearW, clearW + perp, -(clearW + perp), 0]
4685
+ : [0, perp, -perp, 2 * perp, -2 * perp];
4602
4686
  // Walk the ACTUAL DRAWN PATH, not the chord: parse the leg's `d` (the
4603
4687
  // trimmed `M…Q…` arc or `M…L…` line) and sample the curve at each t, so a
4604
4688
  // label rides its own bowed line instead of floating at the chord midpoint
@@ -4627,7 +4711,7 @@ export function layoutMap(
4627
4711
  // hop — growing — so a label on a SHORT leg between two close dots (where
4628
4712
  // every on-line slot straddles an endpoint) can still escape above or below.
4629
4713
  const candidates: Array<[number, number]> = [];
4630
- for (const off of [0, perp, -perp, 2 * perp, -2 * perp])
4714
+ for (const off of offList)
4631
4715
  for (const t of T_LIST) {
4632
4716
  const [bx, by] = pointAt(t);
4633
4717
  candidates.push([bx + nx * off, by + ny * off - 4]);
package/src/map/parser.ts CHANGED
@@ -186,8 +186,8 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
186
186
  }
187
187
  // (1a) Indented child of an open route → a leg (an edge from the prev stop).
188
188
  if (open.route && indent > open.route.indent) {
189
- const leg = parseLeg(trimmed, lineNumber, open.route.route.style);
190
- (open.route.route.legs as MapRouteLeg[]).push(leg);
189
+ const leg = parseLeg(trimmed, lineNumber);
190
+ if (leg) (open.route.route.legs as MapRouteLeg[]).push(leg);
191
191
  continue;
192
192
  }
193
193
  // (1b) Indented child of an open POI → hub edge or extra metadata.
@@ -217,6 +217,18 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
217
217
  Object.assign(open.poi.poi.meta as Record<string, string>, split.meta);
218
218
  continue;
219
219
  }
220
+ // A named child line with no arrow is a hub edge missing its glyph (the
221
+ // POI-hub mirror of a malformed route leg). Error here rather than letting
222
+ // it fall through and be silently reinterpreted as a top-level region.
223
+ if (split.name) {
224
+ const src =
225
+ open.poi.poi.alias ?? poiName(open.poi.poi.pos) ?? 'the poi';
226
+ pushError(
227
+ lineNumber,
228
+ `Malformed hub edge: "${trimmed}" — an indented line under a poi is an edge from "${src}" and needs an arrow glyph (\`-> dest\`, \`~> dest\`, or labeled \`-label-> dest\`). For a standalone place, declare it at the top level. (§24B.5)`
229
+ );
230
+ continue;
231
+ }
220
232
  // not a recognized child → fall through after closing the POI
221
233
  }
222
234
 
@@ -510,17 +522,20 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
510
522
  const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
511
523
  const originLabel = meta['label'];
512
524
  const originValue = meta['value'];
513
- // Leg shape comes only from `style: arc` / arrow style (surface parsing
514
- // removed — a leg no longer bows just because it crosses water).
515
- const style: 'straight' | 'arc' =
516
- meta['style'] === 'arc' ? 'arc' : 'straight';
525
+ // `style: arc` on the route header was removed (§24B.6): leg shape comes
526
+ // solely from each leg's own arrow glyph (`-…->` straight, `~…~>` arc).
527
+ if (meta['style'] !== undefined) {
528
+ pushError(
529
+ line,
530
+ 'route header no longer takes `style:` — set leg shape with the arrow glyph instead (`~…~>` for an arc leg, `-…->` for a straight one). (§24B.6)'
531
+ );
532
+ }
517
533
  const route: Writable<MapRoute> = {
518
534
  origin: pos,
519
535
  ...(split.alias !== undefined && { originAlias: split.alias }),
520
536
  ...(originLabel !== undefined && { originLabel }),
521
537
  ...(originValue !== undefined && { originValue }),
522
538
  originTags: tags,
523
- style,
524
539
  legs: [],
525
540
  lineNumber: line,
526
541
  };
@@ -528,25 +543,34 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
528
543
  open.route = { route, indent };
529
544
  }
530
545
 
531
- /** Parse one route body line into a leg: `[-label->] <destination> [keys]`.
532
- * The arrow (if any) gives the leg label + shape; `value:` is leg thickness;
533
- * a tag colours the LINE (§24B.6); `label:`/`as` name the destination stop.
534
- * Bare `<dest>` = plain leg. */
535
- function parseLeg(
536
- trimmed: string,
537
- line: number,
538
- headerStyle: 'straight' | 'arc'
539
- ): MapRouteLeg {
540
- let arrowStyle: 'straight' | 'arc' = 'straight';
541
- let label: string | undefined;
542
- let rest = trimmed;
546
+ /** Parse one route body line into a leg: `<arrow> <destination> [keys]`. The
547
+ * arrow is REQUIRED and gives the leg label + shape (`-…->` straight, `~…~>`
548
+ * arc); `value:` is leg thickness; a tag colours the LINE (§24B.6);
549
+ * `label:`/`as` name the destination stop. A bare destination with no arrow,
550
+ * or an undirected (`--`/`~~`) glyph, is a parse error — a voyage always flows
551
+ * from the previous stop to the next (§24B.6). */
552
+ function parseLeg(trimmed: string, line: number): MapRouteLeg | null {
543
553
  const m = trimmed.match(LEG_ARROW_RE);
544
- if (m) {
545
- const arr = classifyArrow(m[1]!, line);
546
- arrowStyle = arr.style;
547
- label = arr.label;
548
- rest = m[2]!;
554
+ if (!m) {
555
+ pushError(
556
+ line,
557
+ `Malformed route leg: "${trimmed}" — a leg needs an arrow glyph (\`-> dest\`, \`~> dest\`, or labeled \`-label-> dest\` / \`~label~> dest\`). (§24B.6)`
558
+ );
559
+ return null;
560
+ }
561
+ const arr = classifyArrow(m[1]!, line);
562
+ // A route leg is always directional (prev stop → next), so the undirected
563
+ // `--`/`~~` glyphs are meaningless here (they stay valid on free-form edges).
564
+ if (!arr.directed) {
565
+ pushError(
566
+ line,
567
+ `A route leg is directional — "${trimmed}" uses an undirected glyph; use \`-> \` (straight) or \`~> \` (arc). (§24B.6)`
568
+ );
569
+ return null;
549
570
  }
571
+ const arrowStyle: 'straight' | 'arc' = arr.style;
572
+ const label: string | undefined = arr.label;
573
+ const rest = m[2]!;
550
574
  const split = splitNameAndMeta(
551
575
  rest,
552
576
  registry(),
@@ -562,13 +586,11 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
562
586
  const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
563
587
  const value = meta['value'];
564
588
  const destLabel = meta['label'];
565
- // Leg shape comes only from the arrow style or the route header `style: arc`
566
- // (surface parsing removed — no implied bow).
567
- const style: 'straight' | 'arc' =
568
- arrowStyle === 'arc' || headerStyle === 'arc' ? 'arc' : 'straight';
589
+ // Leg shape comes solely from the leg's own arrow glyph (§24B.6) — the route
590
+ // header `style:` was removed.
569
591
  return {
570
592
  ...(label !== undefined && { label }),
571
- style,
593
+ style: arrowStyle,
572
594
  ...(value !== undefined && { value }),
573
595
  dest: pos,
574
596
  ...(split.alias !== undefined && { destAlias: split.alias }),
@@ -973,6 +973,16 @@ export function renderMap(
973
973
  // A 0-width invisible leg path is hard to hit; pointer-events on the visible
974
974
  // stroke is enough for the line widths legs use.
975
975
  wireSync(p, leg.lineNumber);
976
+ // Surface the leg's weight on hover. A native <title> gives a tooltip on the
977
+ // visible stroke in every surface (app, exported SVG, Obsidian, web) with no
978
+ // viewer code; `data-value` is also emitted so an app overlay can style it
979
+ // later. The label (when any) prefixes the number for context ("fiber: 28").
980
+ if (leg.value !== undefined) {
981
+ p.attr('data-value', leg.value);
982
+ p.append('title').text(
983
+ leg.label !== undefined ? `${leg.label}: ${leg.value}` : `${leg.value}`
984
+ );
985
+ }
976
986
  if (arrowTri) {
977
987
  // Filled arrowhead at the true endpoint — a flat triangle whose base sits
978
988
  // where the trimmed stroke ends, so the line meets it cleanly with no
package/src/map/types.ts CHANGED
@@ -110,12 +110,13 @@ export interface MapPoi {
110
110
  }
111
111
 
112
112
  /** One leg of a route (§24B.6): an edge from the previous stop to `dest`. Reuses
113
- * the edge arrow idiom — in-arrow text = leg label, `value:` = leg thickness,
114
- * `->`/`~>` (or the header `style: arc`) = shape. A tag on the leg line colours
115
- * the LINE (§24B.6); `label:`/`as` still name the DESTINATION stop. */
113
+ * the indented edge arrow idiom (same as sitemap) — in-arrow text = leg label,
114
+ * `value:` = leg thickness, the arrow glyph = shape (`-…->` straight, `~…~>`
115
+ * arc). A tag on the leg line colours the LINE (§24B.6); `label:`/`as` still
116
+ * name the DESTINATION stop. The arrow is required — a bare destination errors. */
116
117
  export interface MapRouteLeg {
117
118
  readonly label?: string; // in-arrow leg label
118
- readonly style: 'straight' | 'arc';
119
+ readonly style: 'straight' | 'arc'; // from the leg's own arrow glyph
119
120
  readonly value?: string; // leg thickness (numeric string, like an edge)
120
121
  readonly dest: PoiPos;
121
122
  readonly destAlias?: string;
@@ -126,16 +127,16 @@ export interface MapRouteLeg {
126
127
  readonly lineNumber: number;
127
128
  }
128
129
 
129
- /** An ordered, auto-numbered route (§24B.6): `route <origin> [style: arc]` + a
130
- * sequence of indented arrow legs, each continuing from the previous stop.
131
- * Repeat the origin as a leg's destination to close a loop. */
130
+ /** An ordered, auto-numbered route (§24B.6): `route <origin>` + a sequence of
131
+ * indented arrow legs, each continuing from the previous stop. Leg shape is
132
+ * per-leg (the arrow glyph) — there is no header-level shape option. Repeat the
133
+ * origin as a leg's destination to close a loop. */
132
134
  export interface MapRoute {
133
135
  readonly origin: PoiPos;
134
136
  readonly originAlias?: string;
135
137
  readonly originLabel?: string;
136
138
  readonly originValue?: string; // header value → origin marker size
137
139
  readonly originTags: Readonly<Record<string, string>>;
138
- readonly style: 'straight' | 'arc'; // header default leg shape
139
140
  readonly legs: readonly MapRouteLeg[];
140
141
  readonly lineNumber: number;
141
142
  }