@diagrammo/dgmo 0.22.0 → 0.24.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 (51) hide show
  1. package/dist/advanced.cjs +372 -103
  2. package/dist/advanced.d.cts +52 -19
  3. package/dist/advanced.d.ts +52 -19
  4. package/dist/advanced.js +372 -103
  5. package/dist/auto.cjs +370 -97
  6. package/dist/auto.js +117 -117
  7. package/dist/auto.mjs +370 -97
  8. package/dist/cli.cjs +151 -151
  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 +498 -96
  14. package/dist/index.d.cts +37 -1
  15. package/dist/index.d.ts +37 -1
  16. package/dist/index.js +496 -96
  17. package/dist/internal.cjs +372 -103
  18. package/dist/internal.d.cts +52 -19
  19. package/dist/internal.d.ts +52 -19
  20. package/dist/internal.js +372 -103
  21. package/dist/map-data/PROVENANCE.json +1 -1
  22. package/dist/map-data/gazetteer.json +1 -1
  23. package/dist/map-data/mountain-ranges.json +1 -1
  24. package/dist/map-data/water-bodies.json +1 -1
  25. package/dist/map-data/world-coarse.json +1 -1
  26. package/dist/map-data/world-detail.json +1 -1
  27. package/docs/language-reference.md +38 -2
  28. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  29. package/package.json +1 -1
  30. package/src/boxes-and-lines/parser.ts +39 -0
  31. package/src/boxes-and-lines/renderer.ts +219 -14
  32. package/src/boxes-and-lines/types.ts +9 -0
  33. package/src/completion.ts +4 -5
  34. package/src/d3.ts +26 -6
  35. package/src/editor/keywords.ts +3 -0
  36. package/src/index.ts +8 -0
  37. package/src/map/data/PROVENANCE.json +1 -1
  38. package/src/map/data/README.md +6 -0
  39. package/src/map/data/gazetteer.json +1 -1
  40. package/src/map/data/mountain-ranges.json +1 -1
  41. package/src/map/data/water-bodies.json +1 -1
  42. package/src/map/data/world-coarse.json +1 -1
  43. package/src/map/data/world-detail.json +1 -1
  44. package/src/map/dimensions.ts +21 -5
  45. package/src/map/layout.ts +167 -63
  46. package/src/map/legend-band.ts +99 -0
  47. package/src/map/renderer.ts +105 -32
  48. package/src/map/resolver.ts +43 -1
  49. package/src/map/types.ts +20 -0
  50. package/src/utils/reserved-key-registry.ts +5 -3
  51. package/src/utils/svg-embed.ts +193 -0
@@ -14,6 +14,7 @@ import {
14
14
  import { mix } from '../palettes/color-utils';
15
15
  import { renderLegendD3 } from '../utils/legend-d3';
16
16
  import type { LegendConfig, LegendState } from '../utils/legend-types';
17
+ import { mapLegendConfig, mapLegendGroups } from './legend-band';
17
18
  import type { PaletteColors } from '../palettes/types';
18
19
  import type { D3ExportDimensions } from '../utils/d3-types';
19
20
  import type { MapData, ResolvedMap } from './resolved-types';
@@ -57,6 +58,70 @@ function ringToPath(ring: ReadonlyArray<[number, number]>): string {
57
58
  return d + 'Z';
58
59
  }
59
60
 
61
+ /** Open SVG polyline (`M…L…`, no `Z`) from a run of points. */
62
+ function polylineToPath(pts: ReadonlyArray<[number, number]>): string {
63
+ let d = '';
64
+ for (let i = 0; i < pts.length; i++)
65
+ d += (i ? 'L' : 'M') + pts[i]![0] + ',' + pts[i]![1];
66
+ return d;
67
+ }
68
+
69
+ /** Coast subpaths for one ring, dropping any edge that runs ALONG a canvas edge.
70
+ * A region clipped to the viewport (the antimeridian on a world map, or a
71
+ * regional clipExtent cut) gains a synthetic straight edge collinear with the
72
+ * frame — that edge is NOT a real coast and must not be buffered into a coast
73
+ * band (which would ring the cut with water-lines short of the edge). Without a
74
+ * `frame` the ring is returned closed (`M…Z`) as before. With one, the ring is
75
+ * split at every frame-collinear edge into open coast arcs (`M…L…`), so the land
76
+ * runs cleanly to the edge and only true coastline gets a water-line. */
77
+ function ringToCoastPaths(
78
+ ring: ReadonlyArray<[number, number]>,
79
+ frame?: { w: number; h: number }
80
+ ): string[] {
81
+ if (!frame) return [ringToPath(ring)];
82
+ const n = ring.length;
83
+ const eps = 0.75;
84
+ const onL = (x: number): boolean => Math.abs(x) <= eps;
85
+ const onR = (x: number): boolean => Math.abs(x - frame.w) <= eps;
86
+ const onT = (y: number): boolean => Math.abs(y) <= eps;
87
+ const onB = (y: number): boolean => Math.abs(y - frame.h) <= eps;
88
+ const isFrameEdge = (
89
+ a: readonly [number, number],
90
+ b: readonly [number, number]
91
+ ): boolean =>
92
+ (onL(a[0]) && onL(b[0])) ||
93
+ (onR(a[0]) && onR(b[0])) ||
94
+ (onT(a[1]) && onT(b[1])) ||
95
+ (onB(a[1]) && onB(b[1]));
96
+ // No frame-collinear edge anywhere → ordinary interior coastline (closed).
97
+ let firstBreak = -1;
98
+ for (let i = 0; i < n; i++)
99
+ if (isFrameEdge(ring[i]!, ring[(i + 1) % n]!)) {
100
+ firstBreak = i;
101
+ break;
102
+ }
103
+ if (firstBreak === -1) return [ringToPath(ring)];
104
+ // Walk the loop from just after the first cut, accumulating runs of real-coast
105
+ // edges into open polylines and breaking at each frame-collinear edge.
106
+ const paths: string[] = [];
107
+ let cur: Array<[number, number]> = [];
108
+ const start = (firstBreak + 1) % n;
109
+ for (let k = 0; k < n; k++) {
110
+ const i = (start + k) % n;
111
+ const a = ring[i]!;
112
+ const b = ring[(i + 1) % n]!;
113
+ if (isFrameEdge(a, b)) {
114
+ if (cur.length >= 2) paths.push(polylineToPath(cur));
115
+ cur = [];
116
+ continue;
117
+ }
118
+ if (cur.length === 0) cur.push(a);
119
+ cur.push(b);
120
+ }
121
+ if (cur.length >= 2) paths.push(polylineToPath(cur));
122
+ return paths;
123
+ }
124
+
60
125
  /** Coast outlines to buffer: every region's OUTER rings whose bbox extent clears
61
126
  * `minExtent`. Holes/enclaves are skipped via containment depth (even depth =
62
127
  * outer landmass boundary, odd = a hole) so an enclave (Lesotho) or a lake-hole
@@ -64,7 +129,8 @@ function ringToPath(ring: ReadonlyArray<[number, number]>): string {
64
129
  * degenerate-ring floor now — every island, however small, grows coast rings. */
65
130
  function coastlineOuterRings(
66
131
  regions: readonly MapLayoutRegion[],
67
- minExtent: number
132
+ minExtent: number,
133
+ frame?: { w: number; h: number }
68
134
  ): string[] {
69
135
  const paths: string[] = [];
70
136
  for (const r of regions) {
@@ -88,7 +154,7 @@ function coastlineOuterRings(
88
154
  for (let j = 0; j < rings.length; j++)
89
155
  if (j !== i && pointInRing(fx, fy, rings[j]!)) depth++;
90
156
  if (depth % 2 === 1) continue; // hole/enclave — skip
91
- paths.push(ringToPath(ring));
157
+ paths.push(...ringToCoastPaths(ring, frame));
92
158
  }
93
159
  }
94
160
  return paths;
@@ -171,6 +237,9 @@ export function renderMap(
171
237
  // stretch-distorting. The in-app preview pane passes no exportDims → unset →
172
238
  // keeps the global stretch-fill.
173
239
  preferContain: exportDims?.preferContain ?? false,
240
+ // Reserve the legend band for the mode actually drawn below (export shows
241
+ // only the active group; preview keeps the inactive pills).
242
+ legendMode: exportDims ? 'export' : 'preview',
174
243
  ...(activeGroupOverride !== undefined && {
175
244
  activeGroup: activeGroupOverride,
176
245
  }),
@@ -229,6 +298,16 @@ export function renderMap(
229
298
  // Display name on EVERY region (authored + base/context) so the app can
230
299
  // surface it on hover — decorative metadata, no visible text drawn here.
231
300
  if (r.label) p.attr('data-region-name', r.label);
301
+ // ISO id on EVERY political region (authored + base/context + inset states),
302
+ // so the Inspect tool can resolve a reverse-geocoded `{iso}` to its drawn path
303
+ // and outline it. Distinct from `data-region` (data-layer-only, legend hover):
304
+ // this lands on base/context land too. Lakes carry no iso (not a place).
305
+ if (r.id && r.id !== 'lake') p.attr('data-iso', r.id);
306
+ // Area-weighted centroid (px) the app anchors the hover label to — robust to
307
+ // antimeridian crossers where a bounding-box centre lands in open ocean.
308
+ if (r.labelX !== undefined && r.labelY !== undefined) {
309
+ p.attr('data-label-x', r.labelX).attr('data-label-y', r.labelY);
310
+ }
232
311
  // Data layer? Tag it so the app can highlight on legend hover / gradient
233
312
  // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
234
313
  // (both lowercased to match the lowercased legend-entry attributes).
@@ -276,6 +355,10 @@ export function renderMap(
276
355
  const gRelief = svg
277
356
  .append('g')
278
357
  .attr('clip-path', `url(#${landClipId})`) // outer: land only
358
+ // Decorative texture — never a pointer target, so region hover (the app's
359
+ // name-on-hover) always reaches the region path beneath. WebKit hit-tests
360
+ // masked/clipped overlays unlike Chromium, so this must be explicit.
361
+ .style('pointer-events', 'none')
279
362
  .append('g')
280
363
  .attr('class', 'dgmo-map-relief')
281
364
  .attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
@@ -370,10 +453,20 @@ export function renderMap(
370
453
  .append('g')
371
454
  .attr('class', 'dgmo-map-water-lines')
372
455
  .attr('fill', 'none')
456
+ // Decorative nautical lines — never a pointer target. Without this the wide
457
+ // pre-mask coastal ring bands swallow region hover over coastal countries in
458
+ // WebKit (which hit-tests masked content unlike Chromium); e.g. Portugal.
459
+ .style('pointer-events', 'none')
373
460
  .attr('mask', `url(#${maskId})`);
374
461
  appendWaterLines(
375
462
  gWater,
376
- coastlineOuterRings(layout.regions, cs.minExtent),
463
+ // Pass the canvas frame so edges collinear with it (the antimeridian on a
464
+ // world map, regional clipExtent cuts) don't get ringed as fake coast —
465
+ // land runs cleanly to the render-area edge.
466
+ coastlineOuterRings(layout.regions, cs.minExtent, {
467
+ w: width,
468
+ h: height,
469
+ }),
377
470
  cs,
378
471
  layout.background
379
472
  );
@@ -407,7 +500,8 @@ export function renderMap(
407
500
  const gRivers = svg
408
501
  .append('g')
409
502
  .attr('class', 'dgmo-map-rivers')
410
- .attr('fill', 'none');
503
+ .attr('fill', 'none')
504
+ .style('pointer-events', 'none'); // decorative — pass hover to regions below
411
505
  for (const r of layout.rivers) {
412
506
  gRivers
413
507
  .append('path')
@@ -509,6 +603,7 @@ export function renderMap(
509
603
  .append('g')
510
604
  .attr('class', 'dgmo-map-inset-water-lines')
511
605
  .attr('fill', 'none')
606
+ .style('pointer-events', 'none') // decorative — pass hover to inset regions
512
607
  .attr('mask', `url(#${maskId})`);
513
608
  appendWaterLines(
514
609
  gInsetWater,
@@ -821,36 +916,14 @@ export function renderMap(
821
916
  .attr('transform', `translate(0, ${legendY})`);
822
917
  // The value ramp is a selectable colouring group alongside the tag groups
823
918
  // (the user flips between them); its capsule renders the gradient inline.
824
- // Reserved name "Value" when no region-metric label is set must match
825
- // VALUE_NAME in layout.ts so the resolved activeGroup selects it.
826
- const ramp = layout.legend.ramp;
827
- const scoreGroup = ramp
828
- ? {
829
- name: ramp.metric?.trim() || 'Value',
830
- entries: [],
831
- gradient: {
832
- min: ramp.min,
833
- max: ramp.max,
834
- hue: ramp.hue,
835
- base: ramp.base,
836
- },
837
- }
838
- : null;
839
- const tagGroups = layout.legend.tagGroups
840
- .filter((g) => g.entries.length > 0)
841
- .map((g) => ({ name: g.name, entries: [...g.entries] }));
842
- const groups = [...(scoreGroup ? [scoreGroup] : []), ...tagGroups];
919
+ // Built from the shared helper so the drawn legend matches the band the
920
+ // layout reserved for it (see legend-band.ts).
921
+ const groups = mapLegendGroups(layout.legend);
843
922
  if (groups.length > 0) {
844
- const config: LegendConfig = {
923
+ const config: LegendConfig = mapLegendConfig(
845
924
  groups,
846
- position: { placement: 'top-center', titleRelation: 'below-title' },
847
- mode: exportDims ? 'export' : 'preview',
848
- showEmptyGroups: false,
849
- // Keep inactive siblings visible as pills so the user can click to flip
850
- // the active colouring dimension (preview only — export shows just the
851
- // active group).
852
- showInactivePills: true,
853
- };
925
+ exportDims ? 'export' : 'preview'
926
+ );
854
927
  const state: LegendState = { activeGroup: layout.legend.activeGroup };
855
928
  renderLegendD3(legendG, config, state, palette, isDark, undefined, width);
856
929
  }
@@ -64,6 +64,16 @@ const WORLD_LAT_NORTH = 78;
64
64
  // the dots. A tight cluster (e.g. Bay Area cities) therefore frames as ≈ its
65
65
  // home state + neighbours rather than the whole nation. Tunable.
66
66
  const POI_ZOOM_FLOOR_DEG = 7;
67
+ // POI-only container framing reveals the region(s) that CONTAIN the dots, but a
68
+ // single POI near the edge of a tall/wide country (e.g. Cartagena at the north
69
+ // tip of Colombia) would otherwise drag the frame to that country's far edge —
70
+ // all the way to the Amazon, ~15° below the southernmost dot. Clamp the container
71
+ // union so it reveals at most this many degrees of container BEYOND the POI
72
+ // cluster on each side: northern Colombia stays for orientation, the empty
73
+ // interior is cropped. Sized so an edge cluster still reaches across a US
74
+ // state-scale container (a Bay-Area cluster sits on the coast, ~8° from the
75
+ // Nevada border, and must still show the whole of California). Tunable.
76
+ const CONTAINER_OVERSHOOT_DEG = 8;
67
77
  // Above this longitudinal span a US POI-only extent is "national" — use the
68
78
  // albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
69
79
  // CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
@@ -798,7 +808,11 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
798
808
  if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
799
809
  }
800
810
  const containerUnion = unionExtent(containerBoxes, points);
801
- if (containerUnion) extent = pad(containerUnion, PAD_FRACTION);
811
+ if (containerUnion)
812
+ extent = pad(
813
+ clampContainerToCluster(containerUnion, points),
814
+ PAD_FRACTION
815
+ );
802
816
  }
803
817
 
804
818
  // POI-only fit-to-cluster zoom floor. With region framing above, the extent is
@@ -934,6 +948,34 @@ function mostCommonCountry(
934
948
  return best;
935
949
  }
936
950
 
951
+ /** Asymmetric container clamp (R-poi-region overshoot guard). Container framing
952
+ * reveals the region(s) holding the POIs, but one POI at the edge of a tall/wide
953
+ * country drags the frame to that country's far edge. Cap how far the frame
954
+ * extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG. Latitude
955
+ * always clamps; longitude clamps only when neither extent crosses the
956
+ * antimeridian seam (a wrapped extent carries east > 180), where naive min/max
957
+ * would be wrong. Never tightens past the cluster itself, so the dots stay
958
+ * framed, and never widens it — the container edge is still the outer bound. */
959
+ function clampContainerToCluster(
960
+ container: GeoExtent,
961
+ points: Array<[number, number]>
962
+ ): GeoExtent {
963
+ const poi = unionExtent([], points);
964
+ if (!poi) return container;
965
+ let [[west, south], [east, north]] = container;
966
+ const [[pWest, pSouth], [pEast, pNorth]] = poi;
967
+ south = Math.max(south, pSouth - CONTAINER_OVERSHOOT_DEG);
968
+ north = Math.min(north, pNorth + CONTAINER_OVERSHOOT_DEG);
969
+ if (east <= 180 && pEast <= 180) {
970
+ west = Math.max(west, pWest - CONTAINER_OVERSHOOT_DEG);
971
+ east = Math.min(east, pEast + CONTAINER_OVERSHOOT_DEG);
972
+ }
973
+ return [
974
+ [west, south],
975
+ [east, north],
976
+ ];
977
+ }
978
+
937
979
  function pad(e: GeoExtent, frac: number): GeoExtent {
938
980
  const dLon = (e[1][0] - e[0][0]) * frac || 1;
939
981
  const dLat = (e[1][1] - e[0][1]) * frac || 1;
package/src/map/types.ts CHANGED
@@ -142,3 +142,23 @@ export interface ParsedMap {
142
142
  readonly diagnostics: readonly DgmoError[];
143
143
  readonly error: string | null;
144
144
  }
145
+
146
+ /** Legend descriptor for a rendered map (a layout-stage output, re-exported from
147
+ * `layout.ts`). It lives here so the `legend-band` helper can consume it without
148
+ * importing `layout` — `layout` already value-imports `mapLegendBand`, so the
149
+ * reverse type import would form a layout↔legend-band cycle. */
150
+ export interface MapLayoutLegend {
151
+ readonly tagGroups: ReadonlyArray<{
152
+ name: string;
153
+ entries: ReadonlyArray<{ value: string; color: string }>;
154
+ }>;
155
+ readonly activeGroup: string | null;
156
+ readonly ramp?: {
157
+ metric?: string;
158
+ min: number;
159
+ max: number;
160
+ hue: string;
161
+ /** Low end of the ramp gradient (the land colour the fills blend from). */
162
+ base: string;
163
+ };
164
+ }
@@ -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([
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Make an SVG produced by `@diagrammo/dgmo`'s static `render()` suitable for
3
+ * responsive inline embedding in any host (Obsidian, remark/markdown, web
4
+ * pages):
5
+ *
6
+ * - dgmo renders diagrams inside a fixed export canvas (e.g.
7
+ * `viewBox="0 0 1200 800"`), with content often occupying only a fraction
8
+ * of it. We compute a tight content bounding box from element coordinates
9
+ * and set the root `viewBox` to bbox+padding, so the diagram's intrinsic
10
+ * aspect ratio matches its CONTENT — no dead space above/below or beside it.
11
+ * - Ensure the root `<svg>` has a `viewBox` so it scales responsively.
12
+ * - Strip fixed `width="N"` / `height="N"` so CSS (e.g. `width:100%;
13
+ * height:auto`, or an aspect-ratio derived from the tight viewBox) controls
14
+ * sizing.
15
+ * - Remove any inline `background:` from the root style so the page
16
+ * background shows through.
17
+ *
18
+ * This is intentionally a string transform, not a DOM `getBBox()` step: dgmo
19
+ * can dual-render light/dark SVGs where one is hidden by color-mode CSS, and
20
+ * `getBBox()` returns 0 for the hidden copy. Parsing coordinates from the
21
+ * markup measures both copies reliably and works server-side (Node).
22
+ */
23
+ export function normalizeSvgForEmbed(input: string): string {
24
+ let svg = input;
25
+ const rootMatch = svg.match(/<svg[^>]*>/);
26
+ const rootTag = rootMatch?.[0] ?? '';
27
+ if (rootTag && !rootTag.includes('viewBox')) {
28
+ const wh = rootTag.match(/width="(\d+)"[^>]*height="(\d+)"/);
29
+ if (wh) {
30
+ svg = svg.replace(/<svg/, `<svg viewBox="0 0 ${wh[1]} ${wh[2]}"`);
31
+ }
32
+ }
33
+
34
+ const tight = computeBBox(svg);
35
+ if (tight && tight.width > 0 && tight.height > 0) {
36
+ const pad = 16;
37
+ const vb = `${tight.x - pad} ${tight.y - pad} ${tight.width + pad * 2} ${tight.height + pad * 2}`;
38
+ svg = svg.replace(/(<svg[^>]*?)viewBox="[^"]*"/, `$1viewBox="${vb}"`);
39
+ }
40
+
41
+ svg = svg.replace(/(<svg[^>]*?) width="[^"]*"/g, '$1');
42
+ svg = svg.replace(/(<svg[^>]*?) height="[^"]*"/g, '$1');
43
+ svg = svg.replace(/(<svg[^>]*?style="[^"]*?)background:[^;"]*;?\s*/g, '$1');
44
+ svg = svg.replace(/<svg\s{2,}/g, '<svg ');
45
+ return svg;
46
+ }
47
+
48
+ /**
49
+ * Parse the content bounding box of a normalized embed SVG, if one can be
50
+ * derived. Returns `null` when no usable coordinates are found (e.g. an empty
51
+ * diagram). Useful for hosts that want to set an explicit `aspect-ratio` from
52
+ * the tight viewBox.
53
+ */
54
+ export function getEmbedSvgViewBox(
55
+ svg: string
56
+ ): { x: number; y: number; width: number; height: number } | null {
57
+ const tight = computeBBox(svg);
58
+ if (!tight || tight.width <= 0 || tight.height <= 0) return null;
59
+ const pad = 16;
60
+ return {
61
+ x: tight.x - pad,
62
+ y: tight.y - pad,
63
+ width: tight.width + pad * 2,
64
+ height: tight.height + pad * 2,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Compute an approximate content bounding box from raw element coordinates.
70
+ *
71
+ * This is a regex walk, not a real SVG layout — it ignores `transform`
72
+ * attributes and uses a heuristic for text widths. dgmo's renderers mostly use
73
+ * absolute coordinates within their viewBox, so the approximation is close
74
+ * enough that the rendered output reliably fills the visible area.
75
+ */
76
+ function computeBBox(
77
+ svg: string
78
+ ): { x: number; y: number; width: number; height: number } | null {
79
+ const xs: number[] = [];
80
+ const ys: number[] = [];
81
+
82
+ function push(x: number, y: number): void {
83
+ if (Number.isFinite(x) && Number.isFinite(y)) {
84
+ xs.push(x);
85
+ ys.push(y);
86
+ }
87
+ }
88
+
89
+ function attr(tag: string, name: string): number | null {
90
+ const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
91
+ if (!m) return null;
92
+ const n = parseFloat(m[1]!);
93
+ return Number.isFinite(n) ? n : null;
94
+ }
95
+
96
+ // <rect x y width height>
97
+ for (const m of svg.matchAll(/<rect\b[^>]*?\/?>/g)) {
98
+ const tag = m[0];
99
+ const x = attr(tag, 'x');
100
+ const y = attr(tag, 'y');
101
+ const w = attr(tag, 'width');
102
+ const h = attr(tag, 'height');
103
+ if (x !== null && y !== null && w !== null && h !== null) {
104
+ push(x, y);
105
+ push(x + w, y + h);
106
+ }
107
+ }
108
+
109
+ // <line x1 y1 x2 y2>
110
+ for (const m of svg.matchAll(/<line\b[^>]*?\/?>/g)) {
111
+ const tag = m[0];
112
+ const x1 = attr(tag, 'x1');
113
+ const y1 = attr(tag, 'y1');
114
+ const x2 = attr(tag, 'x2');
115
+ const y2 = attr(tag, 'y2');
116
+ if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
117
+ push(x1, y1);
118
+ push(x2, y2);
119
+ }
120
+ }
121
+
122
+ // <circle cx cy r>
123
+ for (const m of svg.matchAll(/<circle\b[^>]*?\/?>/g)) {
124
+ const tag = m[0];
125
+ const cx = attr(tag, 'cx');
126
+ const cy = attr(tag, 'cy');
127
+ const r = attr(tag, 'r');
128
+ if (cx !== null && cy !== null && r !== null) {
129
+ push(cx - r, cy - r);
130
+ push(cx + r, cy + r);
131
+ }
132
+ }
133
+
134
+ // <ellipse cx cy rx ry>
135
+ for (const m of svg.matchAll(/<ellipse\b[^>]*?\/?>/g)) {
136
+ const tag = m[0];
137
+ const cx = attr(tag, 'cx');
138
+ const cy = attr(tag, 'cy');
139
+ const rx = attr(tag, 'rx');
140
+ const ry = attr(tag, 'ry');
141
+ if (cx !== null && cy !== null && rx !== null && ry !== null) {
142
+ push(cx - rx, cy - ry);
143
+ push(cx + rx, cy + ry);
144
+ }
145
+ }
146
+
147
+ // <text x y>some content</text>
148
+ // Approximate width: text content length × an empirical font width factor.
149
+ // dgmo uses Inter ~14px by default; ~7px per character is a usable rough
150
+ // estimate that won't drastically under- or over-count.
151
+ for (const m of svg.matchAll(/<text\b([^>]*?)>([\s\S]*?)<\/text>/g)) {
152
+ const tag = `<text${m[1]}>`;
153
+ const text = m[2]!.replace(/<[^>]+>/g, ''); // strip inner tags (tspan, etc.)
154
+ const x = attr(tag, 'x');
155
+ const y = attr(tag, 'y');
156
+ if (x !== null && y !== null) {
157
+ const w = text.length * 7;
158
+ // text-anchor may be start/middle/end; assume worst case (middle).
159
+ push(x - w / 2, y - 14);
160
+ push(x + w / 2, y + 4);
161
+ }
162
+ }
163
+
164
+ // <path d="..."> — pull every coordinate pair out of the d attribute.
165
+ for (const m of svg.matchAll(/<path\b[^>]*?\bd="([^"]+)"/g)) {
166
+ const d = m[1]!;
167
+ const nums = d.match(/-?\d+(?:\.\d+)?/g);
168
+ if (!nums) continue;
169
+ for (let i = 0; i + 1 < nums.length; i += 2) {
170
+ push(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
171
+ }
172
+ }
173
+
174
+ // <polygon points="x,y x,y ..."> and <polyline>
175
+ for (const m of svg.matchAll(
176
+ /<(?:polygon|polyline)\b[^>]*?\bpoints="([^"]+)"/g
177
+ )) {
178
+ const nums = m[1]!.match(/-?\d+(?:\.\d+)?/g);
179
+ if (!nums) continue;
180
+ for (let i = 0; i + 1 < nums.length; i += 2) {
181
+ push(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
182
+ }
183
+ }
184
+
185
+ if (xs.length === 0 || ys.length === 0) return null;
186
+
187
+ const minX = Math.min(...xs);
188
+ const maxX = Math.max(...xs);
189
+ const minY = Math.min(...ys);
190
+ const maxY = Math.max(...ys);
191
+
192
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
193
+ }