@diagrammo/dgmo 0.22.0 → 0.23.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 (43) hide show
  1. package/dist/advanced.cjs +238 -48
  2. package/dist/advanced.d.cts +17 -0
  3. package/dist/advanced.d.ts +17 -0
  4. package/dist/advanced.js +238 -48
  5. package/dist/auto.cjs +236 -42
  6. package/dist/auto.js +115 -115
  7. package/dist/auto.mjs +236 -42
  8. package/dist/cli.cjs +153 -153
  9. package/dist/editor.cjs +3 -0
  10. package/dist/editor.js +3 -0
  11. package/dist/highlight.cjs +3 -0
  12. package/dist/highlight.js +3 -0
  13. package/dist/index.cjs +232 -41
  14. package/dist/index.js +232 -41
  15. package/dist/internal.cjs +238 -48
  16. package/dist/internal.d.cts +17 -0
  17. package/dist/internal.d.ts +17 -0
  18. package/dist/internal.js +238 -48
  19. package/dist/map-data/PROVENANCE.json +1 -1
  20. package/dist/map-data/gazetteer.json +1 -1
  21. package/dist/map-data/mountain-ranges.json +1 -1
  22. package/dist/map-data/water-bodies.json +1 -1
  23. package/dist/map-data/world-coarse.json +1 -1
  24. package/dist/map-data/world-detail.json +1 -1
  25. package/docs/language-reference.md +35 -0
  26. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  27. package/package.json +1 -1
  28. package/src/boxes-and-lines/parser.ts +39 -0
  29. package/src/boxes-and-lines/renderer.ts +171 -13
  30. package/src/boxes-and-lines/types.ts +9 -0
  31. package/src/completion.ts +4 -5
  32. package/src/d3.ts +12 -4
  33. package/src/editor/keywords.ts +3 -0
  34. package/src/map/data/PROVENANCE.json +1 -1
  35. package/src/map/data/README.md +6 -0
  36. package/src/map/data/gazetteer.json +1 -1
  37. package/src/map/data/mountain-ranges.json +1 -1
  38. package/src/map/data/water-bodies.json +1 -1
  39. package/src/map/data/world-coarse.json +1 -1
  40. package/src/map/data/world-detail.json +1 -1
  41. package/src/map/layout.ts +111 -18
  42. package/src/map/renderer.ts +95 -4
  43. package/src/utils/reserved-key-registry.ts +5 -3
package/src/map/layout.ts CHANGED
@@ -69,6 +69,16 @@ const R_MAX = 22;
69
69
  const W_MIN = 1.25; // edge stroke width
70
70
  const W_MAX = 8;
71
71
  const FONT = 11; // on-map label font px
72
+
73
+ // A few countries have far-flung territory that drags the area-weighted centroid
74
+ // off the mainland (US → Alaska pulls it up into Canada). Anchor their world-layer
75
+ // label/hover point to a mainland [lon, lat] instead. Antimeridian crossers whose
76
+ // body dominates by area (Russia) are NOT listed — their area-weighted centroid
77
+ // already lands on the mainland; only the naive bounding-box centre (which the app
78
+ // previously used for hover) mistook the wrapped sliver for half the shape.
79
+ const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
80
+ US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
81
+ };
72
82
  // POI-cluster hover-only gate (Decision #1). A ≥2-member cluster's callout
73
83
  // column falls back to hover-only labels when it would sprawl or overflow:
74
84
  // - MAX_CLUSTER_EXTENT_FACTOR × min(width,height) = the px diagonal beyond which
@@ -112,16 +122,21 @@ const RELIEF_MIN_DIM = 2; // px
112
122
  // Relief = horizontal hachure lines clipped to each range: a subtle
113
123
  // dark-on-light / light-on-dark texture that reads as "mountains here". Spacing
114
124
  // is SCREEN-space so density is constant regardless of zoom (geo-space spacing
115
- // would collapse a small range to 1–2 lines and read as a glitch). Kept FAINT:
116
- // thin sub-pixel lines drawn with a non-scaling stroke (constant device width at
117
- // any zoom/DPR) and low-contrast colour. NOT crispEdgesthat snaps the stroke
118
- // to a solid ~1px in WebKit and reads far too heavy; plain AA keeps them whisper-thin.
119
- const RELIEF_HATCH_SPACING = 2; // px between lines
120
- const RELIEF_HATCH_WIDTH = 0.15; // px stroke
125
+ // would collapse a small range to 1–2 lines and read as a glitch). Drawn with a
126
+ // non-scaling stroke (constant device width at any zoom/DPR) and a low-contrast
127
+ // colour so it reads as faint, fine terrain hachure dense thin lines that are
128
+ // almost indistinguishable as individual strokes (a whisper of texture, not
129
+ // stripes). NOT crispEdges that snaps the stroke to a solid ~1px in WebKit and
130
+ // reads too heavy; plain AA keeps the lines soft. The width is kept just ABOVE
131
+ // sub-pixel: at ~0.15px the AA fuzz spreads each line to ~1px and tight spacing
132
+ // merges them into a flat grey wash (a "blob"). 0.25px every 1.5px stays a fine,
133
+ // faint hatch on both zoomed-out world maps and zoomed-in regional views.
134
+ const RELIEF_HATCH_SPACING = 1.5; // px between lines
135
+ const RELIEF_HATCH_WIDTH = 0.2; // px stroke
121
136
  // % of the DARK reference (palette.bg on dark themes, palette.text on light)
122
137
  // blended into the land colour — so the lines read DARKER than the land in both
123
138
  // themes (palette.text alone flips to light on dark themes).
124
- const RELIEF_HATCH_STRENGTH = 32;
139
+ const RELIEF_HATCH_STRENGTH = 26;
125
140
  // Coastline water-lines (opt-in `coastline`, §24B.2). N equal-width coast-parallel
126
141
  // rings on the water side, evenly spaced and FADING seaward — the antique
127
142
  // nautical-chart depth-contour look. Offshore distances + thickness are
@@ -191,6 +206,14 @@ export interface MapLayoutRegion {
191
206
  /** The region's tag values keyed by group (lowercased) — emitted as
192
207
  * `data-tag-<group>` so the app can highlight on legend-entry hover. */
193
208
  readonly tags?: Readonly<Record<string, string>>;
209
+ /** Area-weighted screen centroid (px) of the DRAWN geometry — emitted as
210
+ * `data-label-x`/`data-label-y` so the app can anchor the hover label here
211
+ * instead of the path's bounding-box centre. The bbox centre breaks for
212
+ * antimeridian crossers (Russia's wrapped Chukotka sliver pins the box's left
213
+ * edge to the far side of the map, dropping the centre into the Atlantic); the
214
+ * area-weighted centroid stays on the body. Honours WORLD_LABEL_ANCHORS. */
215
+ readonly labelX?: number;
216
+ readonly labelY?: number;
194
217
  }
195
218
 
196
219
  /** A framed inset "cutout" (albers-usa AK/HI), in screen px. The frame is a
@@ -836,6 +859,60 @@ export function parsePathRings(d: string): Array<Array<[number, number]>> {
836
859
  return rings;
837
860
  }
838
861
 
862
+ /** Drop antimeridian wrap-slivers from a GLOBAL-view region path. A landmass that
863
+ * crosses ±180° (Russia's Chukotka, the western Aleutians, Fiji…) is clipped into
864
+ * fragments; the far one is a small sliver pinned to the OPPOSITE vertical frame
865
+ * edge — it reads as a stray island floating beside its true continent (e.g. the
866
+ * "island left of Alaska"). We drop any ring that (a) has an edge collinear with
867
+ * the LEFT or RIGHT canvas edge AND (b) is small AND (c) isn't the region's
868
+ * largest ring. The mainland (large, on its own edge) and interior islands (not
869
+ * frame-cut) are kept. Vertical edges only — a ring cut by the top/bottom lat
870
+ * crop is real content, not a wrap. Global-only: regional clipExtent cuts ARE
871
+ * real land at the viewport edge and must survive. */
872
+ function dropAntimeridianWrapSlivers(
873
+ d: string,
874
+ width: number,
875
+ height: number
876
+ ): string {
877
+ const rings = parsePathRings(d);
878
+ if (rings.length <= 1) return d;
879
+ const eps = 0.75;
880
+ const minArea = 0.003 * width * height; // 0.3% of canvas
881
+ const ringArea = (r: ReadonlyArray<[number, number]>): number => {
882
+ let s = 0;
883
+ for (let i = 0; i < r.length; i++) {
884
+ const a = r[i]!;
885
+ const b = r[(i + 1) % r.length]!;
886
+ s += a[0] * b[1] - b[0] * a[1];
887
+ }
888
+ return Math.abs(s) / 2;
889
+ };
890
+ const areas = rings.map(ringArea);
891
+ const maxArea = Math.max(...areas);
892
+ const onVEdge = (
893
+ a: readonly [number, number],
894
+ b: readonly [number, number]
895
+ ): boolean =>
896
+ (Math.abs(a[0]) <= eps && Math.abs(b[0]) <= eps) ||
897
+ (Math.abs(a[0] - width) <= eps && Math.abs(b[0] - width) <= eps);
898
+ let dropped = false;
899
+ const kept = rings.filter((r, idx) => {
900
+ if (areas[idx]! >= maxArea || areas[idx]! >= minArea) return true;
901
+ const touches = r.some((p, i) => onVEdge(p, r[(i + 1) % r.length]!));
902
+ if (touches) {
903
+ dropped = true;
904
+ return false;
905
+ }
906
+ return true;
907
+ });
908
+ if (!dropped) return d;
909
+ return kept
910
+ .map(
911
+ (r) => r.map((p, i) => (i ? 'L' : 'M') + p[0] + ',' + p[1]).join('') + 'Z'
912
+ )
913
+ .join('');
914
+ }
915
+
839
916
  export function layoutMap(
840
917
  resolved: ResolvedMap,
841
918
  data: MapData,
@@ -1140,10 +1217,17 @@ export function layoutMap(
1140
1217
  const by0 = cb[0][1];
1141
1218
  const cw = cb[1][0] - bx0;
1142
1219
  const ch = cb[1][1] - by0;
1143
- const ox = fitBox[0][0];
1144
- const oy = fitBox[0][1];
1145
- const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
1146
- const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
1220
+ // A global stretch-fill runs the world to EVERY edge of the canvas — no
1221
+ // FIT_PAD inset. The equirectangular rectangle is the map, so its edges ARE
1222
+ // the render-area edges (the antimeridian sits exactly on the left/right
1223
+ // edge, not 24px short of it with a coastline ringing the gap). The title
1224
+ // overlays the top; we reserve a top band only when POIs are present (so
1225
+ // their markers don't project up under the foreground title banner).
1226
+ const topReserve = resolved.title && resolved.pois.length > 0 ? topPad : 0;
1227
+ const ox = 0;
1228
+ const oy = topReserve;
1229
+ const sx = cw > 0 ? width / cw : 1;
1230
+ const sy = ch > 0 ? (height - topReserve) / ch : 1;
1147
1231
  stretchParams = { sx, sy, ox, oy, bx0, by0 };
1148
1232
  const stretch = (x: number, y: number): [number, number] => [
1149
1233
  ox + (x - bx0) * sx,
@@ -1588,7 +1672,12 @@ export function layoutMap(
1588
1672
  // but still drop antimeridian frame-fillers (Fiji et al.).
1589
1673
  const viewF = shouldCull ? cullFeatureToView(f) : dropFrameFillers(f);
1590
1674
  if (!viewF) continue;
1591
- const d = path(viewF as never) ?? '';
1675
+ const raw = path(viewF as never) ?? '';
1676
+ // Global views: strip the wrap-sliver a crossing landmass leaves pinned to
1677
+ // the far edge (Russia's Chukotka beside Alaska). Regional cuts are real.
1678
+ const d = fitIsGlobal
1679
+ ? dropAntimeridianWrapSlivers(raw, width, height)
1680
+ : raw;
1592
1681
  if (!d) continue;
1593
1682
  const isThisLayer = r?.layer === layerKind;
1594
1683
  // Non-US neighbour land in a US view is gray context, not yellow land.
@@ -1614,6 +1703,15 @@ export function layoutMap(
1614
1703
  // (the same source the resolver/inset/context-label layers read).
1615
1704
  label = (f.properties as { name?: string } | null)?.name;
1616
1705
  }
1706
+ // Label/hover anchor: a hardcoded mainland anchor when far-flung territory
1707
+ // would skew it, else the area-weighted screen centroid of the drawn shape.
1708
+ // The latter (unlike a bounding-box centre) survives antimeridian crossers.
1709
+ const labelAnchor = WORLD_LABEL_ANCHORS[iso];
1710
+ const c = labelAnchor
1711
+ ? project(labelAnchor[0], labelAnchor[1])
1712
+ : path.centroid(viewF as never);
1713
+ const hasCentroid =
1714
+ c != null && Number.isFinite(c[0]) && Number.isFinite(c[1]);
1617
1715
  regions.push({
1618
1716
  id: iso,
1619
1717
  d,
@@ -1622,6 +1720,7 @@ export function layoutMap(
1622
1720
  lineNumber,
1623
1721
  layer,
1624
1722
  ...(label !== undefined && { label }),
1723
+ ...(hasCentroid && { labelX: c[0], labelY: c[1] }),
1625
1724
  ...(isThisLayer && r.value !== undefined && { value: r.value }),
1626
1725
  ...(isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }),
1627
1726
  });
@@ -2284,12 +2383,6 @@ export function layoutMap(
2284
2383
  lineNumber,
2285
2384
  });
2286
2385
  };
2287
- // A few countries have far-flung territory that drags the area-weighted
2288
- // centroid off the mainland (US → Alaska pulls it up into Canada). Anchor
2289
- // their world-layer label to a mainland [lon, lat] instead.
2290
- const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
2291
- US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
2292
- };
2293
2386
  // A region label's screen footprint, middle-anchored on its centroid, used to
2294
2387
  // keep two region labels from overlapping (a small gap adds breathing room).
2295
2388
  const REGION_LABEL_GAP = 2;
@@ -57,6 +57,70 @@ function ringToPath(ring: ReadonlyArray<[number, number]>): string {
57
57
  return d + 'Z';
58
58
  }
59
59
 
60
+ /** Open SVG polyline (`M…L…`, no `Z`) from a run of points. */
61
+ function polylineToPath(pts: ReadonlyArray<[number, number]>): string {
62
+ let d = '';
63
+ for (let i = 0; i < pts.length; i++)
64
+ d += (i ? 'L' : 'M') + pts[i]![0] + ',' + pts[i]![1];
65
+ return d;
66
+ }
67
+
68
+ /** Coast subpaths for one ring, dropping any edge that runs ALONG a canvas edge.
69
+ * A region clipped to the viewport (the antimeridian on a world map, or a
70
+ * regional clipExtent cut) gains a synthetic straight edge collinear with the
71
+ * frame — that edge is NOT a real coast and must not be buffered into a coast
72
+ * band (which would ring the cut with water-lines short of the edge). Without a
73
+ * `frame` the ring is returned closed (`M…Z`) as before. With one, the ring is
74
+ * split at every frame-collinear edge into open coast arcs (`M…L…`), so the land
75
+ * runs cleanly to the edge and only true coastline gets a water-line. */
76
+ function ringToCoastPaths(
77
+ ring: ReadonlyArray<[number, number]>,
78
+ frame?: { w: number; h: number }
79
+ ): string[] {
80
+ if (!frame) return [ringToPath(ring)];
81
+ const n = ring.length;
82
+ const eps = 0.75;
83
+ const onL = (x: number): boolean => Math.abs(x) <= eps;
84
+ const onR = (x: number): boolean => Math.abs(x - frame.w) <= eps;
85
+ const onT = (y: number): boolean => Math.abs(y) <= eps;
86
+ const onB = (y: number): boolean => Math.abs(y - frame.h) <= eps;
87
+ const isFrameEdge = (
88
+ a: readonly [number, number],
89
+ b: readonly [number, number]
90
+ ): boolean =>
91
+ (onL(a[0]) && onL(b[0])) ||
92
+ (onR(a[0]) && onR(b[0])) ||
93
+ (onT(a[1]) && onT(b[1])) ||
94
+ (onB(a[1]) && onB(b[1]));
95
+ // No frame-collinear edge anywhere → ordinary interior coastline (closed).
96
+ let firstBreak = -1;
97
+ for (let i = 0; i < n; i++)
98
+ if (isFrameEdge(ring[i]!, ring[(i + 1) % n]!)) {
99
+ firstBreak = i;
100
+ break;
101
+ }
102
+ if (firstBreak === -1) return [ringToPath(ring)];
103
+ // Walk the loop from just after the first cut, accumulating runs of real-coast
104
+ // edges into open polylines and breaking at each frame-collinear edge.
105
+ const paths: string[] = [];
106
+ let cur: Array<[number, number]> = [];
107
+ const start = (firstBreak + 1) % n;
108
+ for (let k = 0; k < n; k++) {
109
+ const i = (start + k) % n;
110
+ const a = ring[i]!;
111
+ const b = ring[(i + 1) % n]!;
112
+ if (isFrameEdge(a, b)) {
113
+ if (cur.length >= 2) paths.push(polylineToPath(cur));
114
+ cur = [];
115
+ continue;
116
+ }
117
+ if (cur.length === 0) cur.push(a);
118
+ cur.push(b);
119
+ }
120
+ if (cur.length >= 2) paths.push(polylineToPath(cur));
121
+ return paths;
122
+ }
123
+
60
124
  /** Coast outlines to buffer: every region's OUTER rings whose bbox extent clears
61
125
  * `minExtent`. Holes/enclaves are skipped via containment depth (even depth =
62
126
  * outer landmass boundary, odd = a hole) so an enclave (Lesotho) or a lake-hole
@@ -64,7 +128,8 @@ function ringToPath(ring: ReadonlyArray<[number, number]>): string {
64
128
  * degenerate-ring floor now — every island, however small, grows coast rings. */
65
129
  function coastlineOuterRings(
66
130
  regions: readonly MapLayoutRegion[],
67
- minExtent: number
131
+ minExtent: number,
132
+ frame?: { w: number; h: number }
68
133
  ): string[] {
69
134
  const paths: string[] = [];
70
135
  for (const r of regions) {
@@ -88,7 +153,7 @@ function coastlineOuterRings(
88
153
  for (let j = 0; j < rings.length; j++)
89
154
  if (j !== i && pointInRing(fx, fy, rings[j]!)) depth++;
90
155
  if (depth % 2 === 1) continue; // hole/enclave — skip
91
- paths.push(ringToPath(ring));
156
+ paths.push(...ringToCoastPaths(ring, frame));
92
157
  }
93
158
  }
94
159
  return paths;
@@ -229,6 +294,16 @@ export function renderMap(
229
294
  // Display name on EVERY region (authored + base/context) so the app can
230
295
  // surface it on hover — decorative metadata, no visible text drawn here.
231
296
  if (r.label) p.attr('data-region-name', r.label);
297
+ // ISO id on EVERY political region (authored + base/context + inset states),
298
+ // so the Inspect tool can resolve a reverse-geocoded `{iso}` to its drawn path
299
+ // and outline it. Distinct from `data-region` (data-layer-only, legend hover):
300
+ // this lands on base/context land too. Lakes carry no iso (not a place).
301
+ if (r.id && r.id !== 'lake') p.attr('data-iso', r.id);
302
+ // Area-weighted centroid (px) the app anchors the hover label to — robust to
303
+ // antimeridian crossers where a bounding-box centre lands in open ocean.
304
+ if (r.labelX !== undefined && r.labelY !== undefined) {
305
+ p.attr('data-label-x', r.labelX).attr('data-label-y', r.labelY);
306
+ }
232
307
  // Data layer? Tag it so the app can highlight on legend hover / gradient
233
308
  // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
234
309
  // (both lowercased to match the lowercased legend-entry attributes).
@@ -276,6 +351,10 @@ export function renderMap(
276
351
  const gRelief = svg
277
352
  .append('g')
278
353
  .attr('clip-path', `url(#${landClipId})`) // outer: land only
354
+ // Decorative texture — never a pointer target, so region hover (the app's
355
+ // name-on-hover) always reaches the region path beneath. WebKit hit-tests
356
+ // masked/clipped overlays unlike Chromium, so this must be explicit.
357
+ .style('pointer-events', 'none')
279
358
  .append('g')
280
359
  .attr('class', 'dgmo-map-relief')
281
360
  .attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
@@ -370,10 +449,20 @@ export function renderMap(
370
449
  .append('g')
371
450
  .attr('class', 'dgmo-map-water-lines')
372
451
  .attr('fill', 'none')
452
+ // Decorative nautical lines — never a pointer target. Without this the wide
453
+ // pre-mask coastal ring bands swallow region hover over coastal countries in
454
+ // WebKit (which hit-tests masked content unlike Chromium); e.g. Portugal.
455
+ .style('pointer-events', 'none')
373
456
  .attr('mask', `url(#${maskId})`);
374
457
  appendWaterLines(
375
458
  gWater,
376
- coastlineOuterRings(layout.regions, cs.minExtent),
459
+ // Pass the canvas frame so edges collinear with it (the antimeridian on a
460
+ // world map, regional clipExtent cuts) don't get ringed as fake coast —
461
+ // land runs cleanly to the render-area edge.
462
+ coastlineOuterRings(layout.regions, cs.minExtent, {
463
+ w: width,
464
+ h: height,
465
+ }),
377
466
  cs,
378
467
  layout.background
379
468
  );
@@ -407,7 +496,8 @@ export function renderMap(
407
496
  const gRivers = svg
408
497
  .append('g')
409
498
  .attr('class', 'dgmo-map-rivers')
410
- .attr('fill', 'none');
499
+ .attr('fill', 'none')
500
+ .style('pointer-events', 'none'); // decorative — pass hover to regions below
411
501
  for (const r of layout.rivers) {
412
502
  gRivers
413
503
  .append('path')
@@ -509,6 +599,7 @@ export function renderMap(
509
599
  .append('g')
510
600
  .attr('class', 'dgmo-map-inset-water-lines')
511
601
  .attr('fill', 'none')
602
+ .style('pointer-events', 'none') // decorative — pass hover to inset regions
512
603
  .attr('mask', `url(#${maskId})`);
513
604
  appendWaterLines(
514
605
  gInsetWater,
@@ -148,12 +148,14 @@ export const PERT_REGISTRY: ReservedKeyRegistry = staticRegistry([
148
148
  'collapsed',
149
149
  ]);
150
150
 
151
+ // `width`/`split`/`fanout` were copy-pasted from an infra-flavored template
152
+ // during the §1.4 metadata migration but boxes-and-lines never read them
153
+ // (split/fanout are infra-only edge-flow keys, consumed in src/infra/*). Removed
154
+ // 2026-06-03 — only `value` (the numeric ramp) is a real BL data channel.
151
155
  export const BOXES_AND_LINES_REGISTRY: ReservedKeyRegistry = staticRegistry([
152
156
  'color',
153
157
  'description',
154
- 'width',
155
- 'split',
156
- 'fanout',
158
+ 'value',
157
159
  ]);
158
160
 
159
161
  export const TIMELINE_REGISTRY: ReservedKeyRegistry = staticRegistry([