@diagrammo/dgmo 0.25.5 → 0.27.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 (139) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4255 -2756
  3. package/dist/advanced.d.cts +285 -59
  4. package/dist/advanced.d.ts +285 -59
  5. package/dist/advanced.js +4253 -2750
  6. package/dist/auto.cjs +4051 -2589
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4051 -2589
  9. package/dist/cli.cjs +172 -170
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +4076 -2591
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4076 -2591
  18. package/dist/internal.cjs +4255 -2756
  19. package/dist/internal.d.cts +285 -59
  20. package/dist/internal.d.ts +285 -59
  21. package/dist/internal.js +4253 -2750
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +1 -1
  33. package/src/advanced.ts +3 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -1
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion-types.ts +0 -1
  48. package/src/completion.ts +106 -51
  49. package/src/cycle/layout.ts +55 -72
  50. package/src/cycle/renderer.ts +11 -6
  51. package/src/d3.ts +78 -117
  52. package/src/diagnostics.ts +16 -0
  53. package/src/echarts.ts +46 -33
  54. package/src/editor/keywords.ts +4 -0
  55. package/src/er/layout.ts +114 -22
  56. package/src/er/parser.ts +28 -1
  57. package/src/er/renderer.ts +55 -2
  58. package/src/er/types.ts +3 -0
  59. package/src/gantt/renderer.ts +46 -38
  60. package/src/gantt/resolver.ts +9 -2
  61. package/src/graph/edge-spline.ts +29 -0
  62. package/src/graph/flowchart-parser.ts +35 -2
  63. package/src/graph/flowchart-renderer.ts +80 -52
  64. package/src/graph/layout.ts +206 -23
  65. package/src/graph/notes.ts +21 -0
  66. package/src/graph/state-parser.ts +26 -1
  67. package/src/graph/state-renderer.ts +80 -52
  68. package/src/graph/types.ts +13 -0
  69. package/src/index.ts +1 -1
  70. package/src/infra/layout.ts +46 -26
  71. package/src/infra/parser.ts +1 -1
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +57 -12
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1196 -90
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/parser.ts +0 -14
  101. package/src/pert/renderer.ts +30 -43
  102. package/src/pyramid/renderer.ts +4 -5
  103. package/src/raci/renderer.ts +42 -70
  104. package/src/render.ts +1 -1
  105. package/src/ring/renderer.ts +1 -2
  106. package/src/sequence/parser.ts +100 -22
  107. package/src/sequence/renderer.ts +75 -50
  108. package/src/sitemap/layout.ts +27 -19
  109. package/src/sitemap/renderer.ts +12 -5
  110. package/src/tech-radar/renderer.ts +11 -35
  111. package/src/utils/arrow-markers.ts +51 -0
  112. package/src/utils/fit-canvas.ts +64 -0
  113. package/src/utils/legend-constants.ts +8 -54
  114. package/src/utils/legend-d3.ts +10 -7
  115. package/src/utils/legend-layout.ts +7 -4
  116. package/src/utils/legend-types.ts +10 -4
  117. package/src/utils/note-box/constants.ts +25 -0
  118. package/src/utils/note-box/index.ts +11 -0
  119. package/src/utils/note-box/metrics.ts +90 -0
  120. package/src/utils/note-box/svg.ts +331 -0
  121. package/src/utils/notes/bounds.ts +30 -0
  122. package/src/utils/notes/build.ts +131 -0
  123. package/src/utils/notes/index.ts +18 -0
  124. package/src/utils/notes/model.ts +19 -0
  125. package/src/utils/notes/parse.ts +131 -0
  126. package/src/utils/notes/place.ts +177 -0
  127. package/src/utils/notes/resolve.ts +88 -0
  128. package/src/utils/number-format.ts +36 -0
  129. package/src/utils/parsing.ts +41 -0
  130. package/src/utils/reserved-key-registry.ts +4 -0
  131. package/src/utils/text-measure.ts +122 -0
  132. package/src/wireframe/layout.ts +4 -2
  133. package/src/wireframe/renderer.ts +8 -6
  134. package/src/palettes/dracula.ts +0 -68
  135. package/src/palettes/gruvbox.ts +0 -85
  136. package/src/palettes/monokai.ts +0 -68
  137. package/src/palettes/one-dark.ts +0 -70
  138. package/src/palettes/rose-pine.ts +0 -84
  139. package/src/palettes/solarized.ts +0 -77
@@ -78,6 +78,36 @@ const CONTAINER_OVERSHOOT_DEG = 8;
78
78
  // albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
79
79
  // CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
80
80
  const US_NATIONAL_LON_SPAN = 48;
81
+ // Sub-national US auto-zoom (map-us-subnational-zoom). A US region/choropleth map
82
+ // whose authored data occupies a geographically COMPACT slice of the country
83
+ // zooms to that slice (conic-equal-area, like every non-US regional view) instead
84
+ // of always framing the whole nation on albers-usa. "Compact" = the raw data bbox
85
+ // covers less than US_SUBNATIONAL_AREA_FRACTION of the CONUS bbox AREA. Area (not
86
+ // lon-span) is used so a near-national diagonal (e.g. WA+FL) stays national while
87
+ // a tight cluster (the Northeast) or a tall corridor zooms. CONUS_BBOX ≈ the
88
+ // contiguous 48 (≈1416 deg²); the fraction bisects the empty gap between a
89
+ // regional cluster (≲0.15) and a national map (≳0.9). Tunable.
90
+ const CONUS_BBOX: GeoExtent = [
91
+ [-125, 25],
92
+ [-66, 49],
93
+ ];
94
+ const US_SUBNATIONAL_AREA_FRACTION = 0.4;
95
+
96
+ /** Rectangular (lon×lat) area of a bbox in deg² — NOT d3 `geoArea` (spherical).
97
+ * The gate frames with a lon/lat bbox anyway, so a planar ratio is the honest
98
+ * measure of "how much of the country does this occupy". */
99
+ function bboxArea(b: GeoExtent): number {
100
+ return (b[1][0] - b[0][0]) * (b[1][1] - b[0][1]);
101
+ }
102
+
103
+ /** True when a US data extent is compact enough to zoom to (sub-national) rather
104
+ * than frame the whole nation. Pure + side-effect-free (unit-tested in isolation
105
+ * with no fixtures/render). Pass the RAW unpadded data-union bbox, not the padded
106
+ * framing extent — the fraction is calibrated on raw data bboxes. */
107
+ export function isSubNationalUsExtent(bbox: GeoExtent | null): boolean {
108
+ if (!bbox) return false;
109
+ return bboxArea(bbox) / bboxArea(CONUS_BBOX) < US_SUBNATIONAL_AREA_FRACTION;
110
+ }
81
111
 
82
112
  // Long-form (or common-alias) country name → the folded Natural-Earth display
83
113
  // name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
@@ -216,6 +246,9 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
216
246
  const warn = (line: number, message: string, code?: string): void => {
217
247
  diagnostics.push(makeDgmoError(line, message, 'warning', code));
218
248
  };
249
+ // Folded tokens already flagged as "city shadows an airport code" — emit the
250
+ // W_MAP_AIRPORT_SHADOWED_BY_CITY hint at most once per code (ADR-2, F9).
251
+ const shadowedAirports = new Set<string>();
219
252
 
220
253
  const result: Writable<ResolvedMap> = {
221
254
  title: parsed.title,
@@ -286,6 +319,12 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
286
319
  const regions: ResolvedRegion[] = [];
287
320
  const seenRegion = new Map<string, number>(); // iso → index in regions
288
321
  let usSubdivisionReferenced = false;
322
+ // AK/HI national guard (map-us-subnational-zoom): a choropleth that colors
323
+ // Alaska or Hawaii is inherently a national composite map (the insets only make
324
+ // sense on albers-usa), so it never sub-national auto-zooms. Tracked ISO-based
325
+ // here because `referencedRegionIds` is not in scope at the projection block.
326
+ let hasAlaskaRef = false;
327
+ let hasHawaiiRef = false;
289
328
  const referencedRegionIds: { topo: 'us'; id: string }[] = [];
290
329
  for (const r of parsed.regions) {
291
330
  const f = fold(r.name);
@@ -356,6 +395,8 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
356
395
  }
357
396
  if (chosen.layer === 'us-state') {
358
397
  usSubdivisionReferenced = true;
398
+ if (chosen.id === 'US-AK') hasAlaskaRef = true;
399
+ if (chosen.id === 'US-HI') hasHawaiiRef = true;
359
400
  referencedRegionIds.push({ topo: 'us', id: chosen.id });
360
401
  }
361
402
  const resolved: ResolvedRegion = {
@@ -428,6 +469,28 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
428
469
  if (aliasIdx !== undefined) idxs = [aliasIdx];
429
470
  }
430
471
  if (!idxs?.length) {
472
+ // Airports are the LOWEST-precedence identifier tier (ADR-2): consulted
473
+ // only after city `byName` + `alt` miss, so a real city always wins a
474
+ // shared token. Exact folded-code match — never defers. Returns just
475
+ // {lat,lon,iso} like a city; the POI renders the user's typed token ("JFK")
476
+ // as its label, so there is no airport-specific label work.
477
+ const airIdx = data.airports?.airportIata?.[f];
478
+ if (airIdx !== undefined) {
479
+ const a = data.airports!.airports[airIdx];
480
+ if (a) return { kind: 'ok', lat: a[0], lon: a[1], iso: a[2] };
481
+ }
482
+ // A 3-letter token that resolved to neither a city nor a bundled airport is
483
+ // almost always an airport code the set doesn't cover — give the targeted
484
+ // hint (with the `as XXX` coords escape hatch) instead of the generic miss.
485
+ if (/^[A-Za-z]{3}$/.test(name)) {
486
+ const code = name.toUpperCase();
487
+ err(
488
+ line,
489
+ `Unknown airport code "${code}" — not in the bundled airport set (large hubs + US commercial). Use coordinates with \`as ${code}\` if you need it.`,
490
+ 'E_MAP_UNKNOWN_AIRPORT_CODE'
491
+ );
492
+ return { kind: 'miss' };
493
+ }
431
494
  const cityNames = data.gazetteer.cities.map((c) => c[4]);
432
495
  const hint = suggest(name, cityNames);
433
496
  err(
@@ -469,6 +532,22 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
469
532
  );
470
533
  }
471
534
  const c = cands[0]!;
535
+ // Shadow hint (ADR-2/F9): the token resolved to a city but is ALSO a bundled
536
+ // IATA code (e.g. `Ufa` city vs UFA airport). Non-blocking, once per code, and
537
+ // only for the `byName` overlap set (an `alt`-alias hit whose key differs from
538
+ // the IATA fold is not flagged — a documented limit).
539
+ if (
540
+ data.airports?.airportIata?.[f] !== undefined &&
541
+ !shadowedAirports.has(f)
542
+ ) {
543
+ shadowedAirports.add(f);
544
+ const code = name.toUpperCase();
545
+ warn(
546
+ line,
547
+ `"${name}" resolved to the city; "${code}" is also an airport code. Use coordinates with \`as ${code}\` for the airport.`,
548
+ 'W_MAP_AIRPORT_SHADOWED_BY_CITY'
549
+ );
550
+ }
472
551
  return { kind: 'ok', lat: c[0], lon: c[1], iso: c[2] };
473
552
  };
474
553
 
@@ -614,6 +693,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
614
693
  directed: e.directed,
615
694
  style: e.style,
616
695
  meta: e.meta,
696
+ tags: e.tags,
617
697
  lineNumber: e.lineNumber,
618
698
  });
619
699
  }
@@ -698,7 +778,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
698
778
  leg.dest,
699
779
  leg.destAlias,
700
780
  leg.destLabel,
701
- leg.destTags,
781
+ {}, // a leg tag colours the LINE (§24B.6), not the destination stop
702
782
  undefined, // a leg's `value:` is leg thickness, not the dest's size
703
783
  leg.lineNumber
704
784
  );
@@ -709,6 +789,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
709
789
  ...(leg.label !== undefined && { label: leg.label }),
710
790
  style: leg.style,
711
791
  ...(leg.value !== undefined && { value: leg.value }),
792
+ tags: leg.tags,
712
793
  lineNumber: leg.lineNumber,
713
794
  });
714
795
  if (!stopIds.includes(destId)) stopIds.push(destId); // unique markers (loop-close dedupe)
@@ -767,6 +848,14 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
767
848
  }
768
849
  const points: Array<[number, number]> = pois.map((p) => [p.lon, p.lat]);
769
850
  const unioned = unionExtent(regionBoxes, points);
851
+ // Sub-national US auto-zoom gate (map-us-subnational-zoom). Computed from the
852
+ // RAW unpadded union (NOT the padded `extent` below, which inflates area ~1.5×).
853
+ // `usSubNational` decides WHETHER to zoom; the projection-family picker below
854
+ // still keys off span as before. `localeUsForced` = `locale US` with no
855
+ // subdivision → the author asked for the whole-country frame, so stay national
856
+ // even on compact data.
857
+ const usSubNational = isSubNationalUsExtent(unioned);
858
+ const localeUsForced = localeCountry === 'US' && !localeSubdivision;
770
859
  const DEFAULT_EXTENT: GeoExtent = [
771
860
  [-180, -85],
772
861
  [180, 85],
@@ -821,7 +910,14 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
821
910
  // symmetrically about its centroid until the longer axis reaches
822
911
  // POI_ZOOM_FLOOR_DEG so recognizable land always frames the dots. Uniform scale
823
912
  // preserves the aspect; the layout's fitExtent letterboxes to canvas.
824
- if (isPoiOnly) {
913
+ // Also applies to a sub-national US region map (map-us-subnational-zoom) so a
914
+ // single tiny state (e.g. Rhode Island) keeps neighbour context instead of
915
+ // over-zooming into near-blank land. `usSubNational` is derived from the raw
916
+ // union above, so flooring the framing `extent` here doesn't feed back into it.
917
+ // Gated on `usOriented` — `usSubNational` is a pure area-vs-CONUS ratio, true
918
+ // for ANY small bbox, so without this guard a compact non-US region (e.g. a
919
+ // single small European country) would be floored too, regressing AC10.
920
+ if (isPoiOnly || (usSubNational && usOriented)) {
825
921
  const cx = (extent[0][0] + extent[1][0]) / 2;
826
922
  const cy = (extent[0][1] + extent[1][1]) / 2;
827
923
  const lon = extent[1][0] - extent[0][0];
@@ -850,15 +946,34 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
850
946
  // intentionally snaps a POI-only US city map to the national frame ("show all
851
947
  // states") rather than fit-zooming to the cluster on a geographic projection.
852
948
  // (§24B.2 — projection is inferred, never configured.)
949
+ // A compact US region/choropleth that should zoom rather than frame the nation
950
+ // (map-us-subnational-zoom): US-oriented, area-compact, not a POI map, and not
951
+ // forced national by an AK/HI inset or `locale US`.
952
+ const usSubNationalZoom =
953
+ usOriented &&
954
+ usSubNational &&
955
+ !isPoiOnly &&
956
+ !hasAlaskaRef &&
957
+ !hasHawaiiRef &&
958
+ !localeUsForced;
853
959
  let projection: ProjectionFamily;
854
- if (isPoiOnly && usOriented && lonSpan < US_NATIONAL_LON_SPAN) {
855
- // Sub-national US POI cluster: regional Mercator (familiar shapes), fit to
856
- // the floored extent above. The us-states mesh is still drawn (subdivisions
857
- // pushed via usOriented), so the home state + neighbours frame the dots.
858
- // albers-usa is reserved for genuinely national-span content below — a local
859
- // cluster no longer snaps to the whole-nation composite (#13, §24B.2).
960
+ if (
961
+ usOriented &&
962
+ lonSpan < US_NATIONAL_LON_SPAN &&
963
+ (isPoiOnly || usSubNationalZoom)
964
+ ) {
965
+ // Sub-national US, zoomed: regional Mercator. North is straight up everywhere
966
+ // (vertical meridians) — a conic equal-area fans its meridians toward the pole
967
+ // so an off-centre region visibly tilts, which reads wrong for a "zoom into the
968
+ // US" view. Area distortion across a sub-national frame is negligible, and the
969
+ // POI-cluster path already uses Mercator here. Covers both a POI cluster (fit
970
+ // to the floored extent) and a compact region/choropleth. The us-states mesh
971
+ // is still drawn (subdivisions via usOriented), so neighbours frame the data.
860
972
  projection = 'mercator';
861
973
  } else if (usOriented) {
974
+ // National US frame: spread-out region data, a wide corridor (lonSpan ≥ the
975
+ // national threshold), a POI map that spans the country, an AK/HI composite,
976
+ // or an explicit `locale US` whole-country request. (§24B.2 — albers-usa.)
862
977
  projection = 'albers-usa';
863
978
  } else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
864
979
  // World/multi-continent scale (or a polar-reaching frame). Every world map —
@@ -868,10 +983,15 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
868
983
  // consistent rectangular look over Equal Earth's area honesty.)
869
984
  projection = 'equirectangular';
870
985
  } else {
871
- // Tight clusters AND single-continent regional views: Mercator gives every
872
- // mid-latitude landmass its familiar conventional shape (a world projection
873
- // squashes a continent like Europe horizontally).
874
- projection = 'mercator';
986
+ // Tight clusters AND single-continent regional views: a conic equal-area
987
+ // (Albers) projection, parameterized per-extent in the layout. This is the
988
+ // conventional atlas projection for a single mid-latitude continent it
989
+ // keeps familiar country shapes (no world-projection horizontal squash) and,
990
+ // unlike Mercator, does NOT vertically inflate high-latitude land. That
991
+ // inflation is what pushed a Europe choropleth's colored mass into the bottom
992
+ // ~30% of the frame (Scandinavia's stretch wasted the upper half on empty
993
+ // ocean); equal-area framing re-centers the data and scales the land larger.
994
+ projection = 'conic-equal-area';
875
995
  }
876
996
 
877
997
  // World-scale framing (R10): a multi-continent spread frames most cleanly as
package/src/map/types.ts CHANGED
@@ -19,9 +19,13 @@ export type PoiPos =
19
19
  export interface MapDirectives {
20
20
  /** Legend label for the region value ramp (`region-metric <label>`). */
21
21
  regionMetric?: string;
22
- /** Recognized color NAME for the choropleth ramp hue, peeled off the
22
+ /** Recognized color NAME for the choropleth ramp HIGH endpoint, peeled off the
23
23
  * `region-metric` trailing token (§24B.3). Defaults to red when absent. */
24
24
  regionMetricColor?: string;
25
+ /** Recognized color NAME for the choropleth ramp LOW endpoint (the second,
26
+ * left-of-two trailing colors on `region-metric`, §24B.3). Absent ⇒ the low
27
+ * end is the implied floored neutral (today's single-colour behaviour). */
28
+ regionMetricLowColor?: string;
25
29
  /** Legend label for the POI value (marker size) channel (`poi-metric`). */
26
30
  poiMetric?: string;
27
31
  /** Legend label for the edge/leg value (thickness) channel (`flow-metric`). */
@@ -49,12 +53,20 @@ export interface MapDirectives {
49
53
  noContextLabels?: boolean;
50
54
  /** `no-region-labels` — suppress region labels (default-on, full→abbrev→hide). */
51
55
  noRegionLabels?: boolean;
56
+ /** `no-region-value` — suppress the metric VALUE shown under each data region's
57
+ * name on a `region-metric` choropleth (default-on). The region NAME still
58
+ * renders (governed by `no-region-labels`); only the numeric value line goes. */
59
+ noRegionValue?: boolean;
52
60
  /** `no-poi-labels` — suppress POI labels (default-on, collision-managed auto). */
53
61
  noPoiLabels?: boolean;
54
62
  /** `no-colorize` — force the plain green-land reference dress even when regions
55
63
  * are referenced (regions are auto-coloured by default; §24B colorize). A
56
64
  * no-op under data — the basemap is already gray there. */
57
65
  noColorize?: boolean;
66
+ /** `no-cities` — suppress the subtle gazetteer city dots scattered across the
67
+ * basemap for geographic orientation (default-on; population-ranked, spacing-
68
+ * thinned so density adapts to zoom). Explicit POIs always draw regardless. */
69
+ noCities?: boolean;
58
70
  /** `no-cluster-pois` — never collapse coincident POI markers into a count badge
59
71
  * (clustering/spiderfy is default-on in the interactive preview). With this set
60
72
  * the markers always render fanned out with their legs — the same as a static
@@ -99,8 +111,8 @@ export interface MapPoi {
99
111
 
100
112
  /** One leg of a route (§24B.6): an edge from the previous stop to `dest`. Reuses
101
113
  * the edge arrow idiom — in-arrow text = leg label, `value:` = leg thickness,
102
- * `->`/`~>` (or the header `style: arc`) = shape. Stop-targeted keys on the leg
103
- * line (`tag`, `label:`) decorate the DESTINATION point. */
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. */
104
116
  export interface MapRouteLeg {
105
117
  readonly label?: string; // in-arrow leg label
106
118
  readonly style: 'straight' | 'arc';
@@ -108,7 +120,9 @@ export interface MapRouteLeg {
108
120
  readonly dest: PoiPos;
109
121
  readonly destAlias?: string;
110
122
  readonly destLabel?: string;
111
- readonly destTags: Readonly<Record<string, string>>;
123
+ /** Tag(s) on the leg line → colour the LINE itself. To categorise a STOP,
124
+ * tag its own `poi` line. */
125
+ readonly tags: Readonly<Record<string, string>>;
112
126
  readonly lineNumber: number;
113
127
  }
114
128
 
@@ -127,7 +141,9 @@ export interface MapRoute {
127
141
  }
128
142
 
129
143
  /** A connector (§24B.6). Endpoints are RAW identifier strings (name or alias);
130
- * binding to POIs/regions is the resolver's job. `~>`→arc; `--`→directed:false. */
144
+ * binding to POIs/regions is the resolver's job. Token = arrowhead iff it ends
145
+ * in `>`, arc iff it starts with `~`: `->` straight, `~>` arc, `--`/`-label-`
146
+ * undirected straight, `~~`/`~label~` undirected arc. */
131
147
  export interface MapEdge {
132
148
  readonly from: string;
133
149
  readonly to: string;
@@ -135,6 +151,8 @@ export interface MapEdge {
135
151
  readonly directed: boolean;
136
152
  readonly style: 'straight' | 'arc';
137
153
  readonly meta: Readonly<Record<string, string>>;
154
+ /** Tag(s) on the edge line → colour the LINE itself (§24B.6). */
155
+ readonly tags: Readonly<Record<string, string>>;
138
156
  readonly lineNumber: number;
139
157
  }
140
158
 
@@ -166,8 +184,10 @@ export interface MapLayoutLegend {
166
184
  metric?: string;
167
185
  min: number;
168
186
  max: number;
169
- hue: string;
170
- /** Low end of the ramp gradient (the land colour the fills blend from). */
171
- base: string;
187
+ /** Resolved hex of the LOW (t=0) endpoint — the explicit low colour, or the
188
+ * floored neutral the single-colour fills blend up from. */
189
+ low: string;
190
+ /** Resolved hex of the HIGH (t=1) endpoint (the named ramp hue). */
191
+ high: string;
172
192
  };
173
193
  }
@@ -12,17 +12,19 @@
12
12
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
13
13
 
14
14
  import { parseDgmo } from '../dgmo-router';
15
+ import { METADATA_DIAGNOSTIC_CODES } from '../diagnostics';
15
16
  import { migrateContent } from './index';
16
17
 
17
18
  // Diagnostic codes the migration tool itself is repairing — these
18
19
  // MUST NOT count as "block fails to parse" reasons, otherwise every
19
- // legacy block would be skipped.
20
- const MIGRATION_TARGET_CODES = new Set([
21
- 'E_PIPE_OPERATOR_REMOVED',
22
- 'E_GANTT_BARE_PERCENT_REMOVED',
23
- 'E_JOURNEY_BARE_SCORE_REMOVED',
24
- 'E_PYRAMID_BARE_DESCRIPTION_REMOVED',
25
- 'E_RING_BARE_DESCRIPTION_REMOVED',
20
+ // legacy block would be skipped. Derived from the canonical registry
21
+ // so a code rename can't silently desync the migrator.
22
+ const MIGRATION_TARGET_CODES = new Set<string>([
23
+ METADATA_DIAGNOSTIC_CODES.PIPE_OPERATOR_REMOVED,
24
+ METADATA_DIAGNOSTIC_CODES.GANTT_BARE_PERCENT_REMOVED,
25
+ METADATA_DIAGNOSTIC_CODES.JOURNEY_BARE_SCORE_REMOVED,
26
+ METADATA_DIAGNOSTIC_CODES.PYRAMID_BARE_DESCRIPTION_REMOVED,
27
+ METADATA_DIAGNOSTIC_CODES.RING_BARE_DESCRIPTION_REMOVED,
26
28
  ]);
27
29
 
28
30
  const FENCE_RE = /^(\s*)(```+|~~~+)\s*(dgmo|diagrammo)\b([^\n]*)$/i;
@@ -7,8 +7,8 @@
7
7
  // (for drawing). Ensures both agree on line breaks and font size.
8
8
 
9
9
  import { preprocessDescriptionLine } from '../utils/description-helpers';
10
+ import { measureText, truncateText } from '../utils/text-measure';
10
11
 
11
- const CHAR_WIDTH_RATIO = 0.58; // avg char width / fontSize for Helvetica
12
12
  const H_PAD = 16; // 8px padding each side
13
13
  const MAX_LABEL_LINES = 3;
14
14
  const MAX_DESC_LINES = 2;
@@ -38,8 +38,6 @@ function tryWrap(
38
38
  maxLines: number
39
39
  ): string[] | null {
40
40
  const availWidth = maxWidth - H_PAD;
41
- const charWidth = fontSize * CHAR_WIDTH_RATIO;
42
- const maxChars = Math.max(1, Math.floor(availWidth / charWidth));
43
41
 
44
42
  const lines: string[] = [];
45
43
  let currentLine = '';
@@ -49,7 +47,7 @@ function tryWrap(
49
47
  const sep = currentLine && !currentLine.endsWith('-') ? ' ' : '';
50
48
  const candidate = currentLine + sep + token;
51
49
 
52
- if (candidate.length <= maxChars) {
50
+ if (measureText(candidate, fontSize) <= availWidth) {
53
51
  currentLine = candidate;
54
52
  } else if (!currentLine) {
55
53
  // Single token exceeds line — force it onto this line (will be truncated later if needed)
@@ -72,21 +70,19 @@ function tryWrap(
72
70
  return lines;
73
71
  }
74
72
 
75
- /** Truncate the last line of a lines array with ellipsis to fit maxChars. */
73
+ /** Truncate the last line of a lines array with ellipsis to fit maxWidth. */
76
74
  function truncateLastLine(
77
75
  lines: string[],
78
76
  maxWidth: number,
79
77
  fontSize: number
80
78
  ): string[] {
81
79
  const availWidth = maxWidth - H_PAD;
82
- const charWidth = fontSize * CHAR_WIDTH_RATIO;
83
- const maxChars = Math.max(1, Math.floor(availWidth / charWidth));
84
80
 
85
81
  const result = [...lines];
86
82
  // In-bounds: caller passes non-empty lines; tryWrap returns at least one line.
87
83
  const last = result[result.length - 1]!;
88
- if (last.length > maxChars) {
89
- result[result.length - 1] = last.substring(0, maxChars - 1) + '\u2026';
84
+ if (measureText(last, fontSize) > availWidth) {
85
+ result[result.length - 1] = truncateText(last, fontSize, availWidth);
90
86
  }
91
87
  return result;
92
88
  }
@@ -132,11 +128,14 @@ export function wrapText(
132
128
  const last = truncated[truncated.length - 1]!;
133
129
  if (!last.endsWith('\u2026')) {
134
130
  const availWidth = maxWidth - H_PAD;
135
- const charWidth = minFontSize * CHAR_WIDTH_RATIO;
136
- const maxChars = Math.max(1, Math.floor(availWidth / charWidth));
137
- if (last.length >= maxChars - 1) {
138
- truncated[truncated.length - 1] =
139
- last.substring(0, maxChars - 1) + '\u2026';
131
+ // If appending an ellipsis would overflow, truncate to fit; otherwise
132
+ // just append it to signal that lines were dropped.
133
+ if (measureText(last + '\u2026', minFontSize) > availWidth) {
134
+ truncated[truncated.length - 1] = truncateText(
135
+ last,
136
+ minFontSize,
137
+ availWidth
138
+ );
140
139
  } else {
141
140
  truncated[truncated.length - 1] = last + '\u2026';
142
141
  }
package/src/org/layout.ts CHANGED
@@ -10,8 +10,18 @@ import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
10
10
  import {
11
11
  LEGEND_PILL_FONT_SIZE,
12
12
  LEGEND_ENTRY_FONT_SIZE,
13
+ LEGEND_HEIGHT,
14
+ LEGEND_PILL_PAD,
15
+ LEGEND_CAPSULE_PAD,
16
+ LEGEND_DOT_R,
17
+ LEGEND_ENTRY_DOT_GAP,
18
+ LEGEND_ENTRY_TRAIL,
19
+ LEGEND_GROUP_GAP,
20
+ LEGEND_EYE_SIZE,
21
+ LEGEND_EYE_GAP,
13
22
  measureLegendText,
14
23
  } from '../utils/legend-constants';
24
+ import { measureText } from '../utils/text-measure';
15
25
 
16
26
  // ============================================================
17
27
  // Types
@@ -91,7 +101,10 @@ export interface OrgLayoutResult {
91
101
  // Constants
92
102
  // ============================================================
93
103
 
94
- const CHAR_WIDTH = 7.5;
104
+ // Card text font sizes — MUST match the renderer (LABEL_FONT_SIZE / META_FONT_SIZE)
105
+ // so node sizing measures text at the exact size it is drawn.
106
+ const LABEL_FONT_SIZE = 13;
107
+ const META_FONT_SIZE = 11;
95
108
  const META_LINE_HEIGHT = 16;
96
109
  const HEADER_HEIGHT = 28;
97
110
  const SEPARATOR_GAP = 6;
@@ -108,15 +121,6 @@ const CONTAINER_META_LINE_HEIGHT = 16;
108
121
  const STACK_V_GAP = 20;
109
122
 
110
123
  // Legend (kanban-style pills)
111
- const LEGEND_HEIGHT = 28;
112
- const LEGEND_PILL_PAD = 16;
113
- const LEGEND_CAPSULE_PAD = 4;
114
- const LEGEND_DOT_R = 4;
115
- const LEGEND_ENTRY_DOT_GAP = 4;
116
- const LEGEND_ENTRY_TRAIL = 8;
117
- const LEGEND_GROUP_GAP = 12;
118
- const LEGEND_EYE_SIZE = 14;
119
- const LEGEND_EYE_GAP = 6;
120
124
 
121
125
  // ============================================================
122
126
  // Helpers
@@ -156,17 +160,15 @@ function filterMetadata(
156
160
  }
157
161
 
158
162
  function computeCardWidth(label: string, meta: Record<string, string>): number {
159
- let maxChars = label.length;
163
+ // Label is drawn bold at LABEL_FONT_SIZE; meta rows at META_FONT_SIZE.
164
+ let maxTextWidth = measureText(label, LABEL_FONT_SIZE);
160
165
 
161
166
  for (const [key, value] of Object.entries(meta)) {
162
- const lineChars = key.length + 2 + value.length; // "key: value"
163
- if (lineChars > maxChars) maxChars = lineChars;
167
+ const lineWidth = measureText(`${key}: ${value}`, META_FONT_SIZE);
168
+ if (lineWidth > maxTextWidth) maxTextWidth = lineWidth;
164
169
  }
165
170
 
166
- return Math.max(
167
- MIN_CARD_WIDTH,
168
- Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2
169
- );
171
+ return Math.max(MIN_CARD_WIDTH, Math.ceil(maxTextWidth) + CARD_H_PAD * 2);
170
172
  }
171
173
 
172
174
  function computeCardHeight(meta: Record<string, string>): number {
@@ -26,6 +26,7 @@ import {
26
26
  EYE_CLOSED_PATH,
27
27
  } from '../utils/legend-constants';
28
28
  import { renderLegendD3 } from '../utils/legend-d3';
29
+ import { measureText } from '../utils/text-measure';
29
30
  import { getMaxLegendReservedHeight } from '../utils/legend-layout';
30
31
  import type { LegendConfig, LegendState } from '../utils/legend-types';
31
32
 
@@ -329,8 +330,12 @@ export function renderOrg(
329
330
  const metaDisplayKeys = metaEntries.map(
330
331
  ([k]) => displayNames.get(k) ?? k
331
332
  );
332
- const maxKeyLen = Math.max(...metaDisplayKeys.map((k) => k.length));
333
- const valueX = 10 + (maxKeyLen + 2) * (sContainerMetaFontSize * 0.6);
333
+ const maxKeyWidth = Math.max(
334
+ ...metaDisplayKeys.map((k) =>
335
+ measureText(`${k}: `, sContainerMetaFontSize)
336
+ )
337
+ );
338
+ const valueX = 10 + maxKeyWidth;
334
339
 
335
340
  const metaStartY = sContainerHeaderHeight + sContainerMetaFontSize - 2;
336
341
  for (let i = 0; i < metaEntries.length; i++) {
@@ -529,8 +534,10 @@ export function renderOrg(
529
534
  const metaDisplayKeys = metaEntries.map(
530
535
  ([k]) => displayNames.get(k) ?? k
531
536
  );
532
- const maxKeyLen = Math.max(...metaDisplayKeys.map((k) => k.length));
533
- const valueX = 10 + (maxKeyLen + 2) * (sMetaFontSize * 0.6);
537
+ const maxKeyWidth = Math.max(
538
+ ...metaDisplayKeys.map((k) => measureText(`${k}: `, sMetaFontSize))
539
+ );
540
+ const valueX = 10 + maxKeyWidth;
534
541
 
535
542
  const metaStartY = sHeaderHeight + sSeparatorGap + sMetaFontSize;
536
543
  for (let i = 0; i < metaEntries.length; i++) {