@diagrammo/dgmo 0.23.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.
package/src/index.ts CHANGED
@@ -213,6 +213,14 @@ export { themes, type Theme } from './themes';
213
213
 
214
214
  export { getMinDimensions } from './dimensions';
215
215
 
216
+ // ============================================================
217
+ // SVG embed normalization (responsive inline embedding)
218
+ // ============================================================
219
+ // Tightens a static render() SVG's viewBox to its content + strips fixed
220
+ // width/height so hosts (Obsidian, remark/markdown, web) can size it to its
221
+ // natural aspect ratio with no dead space. Pure string transform.
222
+ export { normalizeSvgForEmbed, getEmbedSvgViewBox } from './utils/svg-embed';
223
+
216
224
  // ============================================================
217
225
  // Map chart-type completion (gazetteer-fed; §24B.5/.8)
218
226
  // ============================================================
@@ -83,10 +83,24 @@ export interface MapExportDimensions {
83
83
  export function mapExportDimensions(
84
84
  resolved: ResolvedMap,
85
85
  data: MapData,
86
- baseWidth = 1200
86
+ baseWidth = 1200,
87
+ /** WYSIWYG override (app export): the live preview pane's displayed aspect
88
+ * (width / height). When provided, the canvas adopts it verbatim and
89
+ * stretch-fills (no clamp, no contain) so the PNG matches exactly what's on
90
+ * screen. Omitted by every headless consumer (CLI / MCP / SSG / Obsidian),
91
+ * which keep the intrinsic-aspect sizing below. */
92
+ aspectOverride?: number
87
93
  ): MapExportDimensions {
88
- const raw = mapContentAspect(resolved, data);
89
- const clamped = Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
94
+ const useOverride =
95
+ aspectOverride !== undefined &&
96
+ Number.isFinite(aspectOverride) &&
97
+ aspectOverride > 0;
98
+ const raw = useOverride ? aspectOverride : mapContentAspect(resolved, data);
99
+ // The override is the user's on-screen aspect — honour it as-is (no clamp);
100
+ // only the intrinsic path guards against pathological extents.
101
+ const clamped = useOverride
102
+ ? raw
103
+ : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
90
104
  const width = baseWidth;
91
105
  let height = Math.round(width / clamped);
92
106
 
@@ -111,7 +125,9 @@ export function mapExportDimensions(
111
125
  }
112
126
 
113
127
  // The canvas was forced off the content aspect ⇒ tell the renderer to
114
- // contain-fit (letterbox) rather than stretch-distort.
115
- const preferContain = clamped !== raw || floored;
128
+ // contain-fit (letterbox) rather than stretch-distort. The WYSIWYG override is
129
+ // exempt: it stretch-fills (mirroring the preview pane) unless the MIN_MAP_BAND
130
+ // floor had to grow the canvas off-aspect.
131
+ const preferContain = useOverride ? floored : clamped !== raw || floored;
116
132
  return { width, height, preferContain };
117
133
  }
package/src/map/layout.ts CHANGED
@@ -36,6 +36,9 @@ import {
36
36
  import type { LabelRect, PointCircle } from '../label-layout';
37
37
  import { measureLegendText } from '../utils/legend-constants';
38
38
  import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
39
+ import type { LegendMode } from '../utils/legend-types';
40
+ import { mapLegendBand } from './legend-band';
41
+ import type { MapLayoutLegend } from './types';
39
42
  import type { DgmoError } from '../diagnostics';
40
43
  import type { BoundaryTopology } from './data/types';
41
44
  import type {
@@ -363,21 +366,11 @@ export interface PlacedLabel {
363
366
  readonly lineNumber: number;
364
367
  }
365
368
 
366
- export interface MapLayoutLegend {
367
- readonly tagGroups: ReadonlyArray<{
368
- name: string;
369
- entries: ReadonlyArray<{ value: string; color: string }>;
370
- }>;
371
- readonly activeGroup: string | null;
372
- readonly ramp?: {
373
- metric?: string;
374
- min: number;
375
- max: number;
376
- hue: string;
377
- /** Low end of the ramp gradient (the land colour the fills blend from). */
378
- base: string;
379
- };
380
- }
369
+ // MapLayoutLegend now lives in ./types (imported for local use + re-exported
370
+ // below) so that ./legend-band can consume the type without importing this
371
+ // module, which would re-introduce the layout↔legend-band cycle (this module
372
+ // value-imports mapLegendBand from ./legend-band).
373
+ export type { MapLayoutLegend };
381
374
 
382
375
  /** A drawn river centerline — an open stroked path (no fill). */
383
376
  export interface MapLayoutRiver {
@@ -482,6 +475,10 @@ export interface LayoutOptions {
482
475
  * canvas away from the content aspect, so the off-aspect canvas doesn't
483
476
  * re-distort. The in-app preview pane leaves this unset (keeps stretch-fill). */
484
477
  readonly preferContain?: boolean;
478
+ /** Which legend variant gets drawn — `'export'` shows only the active group,
479
+ * `'preview'` keeps inactive pills. Used to size the reserved legend band so
480
+ * the projected land starts below the legend. Defaults to `'preview'`. */
481
+ readonly legendMode?: LegendMode;
485
482
  }
486
483
 
487
484
  interface Size {
@@ -1168,6 +1165,35 @@ export function layoutMap(
1168
1165
 
1169
1166
  const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
1170
1167
 
1168
+ // -- Legend model (AR1: categorical via renderer's renderLegendD3). Built here
1169
+ // (before the fit) so the fit can reserve a band for it. Only the colouring
1170
+ // dimensions (value ramp + tag groups) get a legend; POI size and edge
1171
+ // thickness are self-evident from the marker/line scale and carry no key. --
1172
+ let legend: MapLayoutLegend | null = null;
1173
+ if (!resolved.directives.noLegend) {
1174
+ const legendTagGroups = resolved.tagGroups.map((g) => ({
1175
+ name: g.name,
1176
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
1177
+ }));
1178
+ if (legendTagGroups.length > 0 || hasRamp) {
1179
+ legend = {
1180
+ tagGroups: legendTagGroups,
1181
+ activeGroup,
1182
+ ...(hasRamp && {
1183
+ ramp: {
1184
+ ...(resolved.directives.regionMetric !== undefined && {
1185
+ metric: resolved.directives.regionMetric,
1186
+ }),
1187
+ min: rampMin,
1188
+ max: rampMax,
1189
+ hue: rampHue,
1190
+ base: rampBase,
1191
+ },
1192
+ }),
1193
+ };
1194
+ }
1195
+ }
1196
+
1171
1197
  // -- Fit the projection to the canvas (size-dependent; the projection + fit
1172
1198
  // target themselves came from buildMapProjection above). --
1173
1199
  // Reserve top padding for the title/subtitle banner ONLY when there are POIs,
@@ -1183,6 +1209,18 @@ export function layoutMap(
1183
1209
  TITLE_FONT_SIZE / 2;
1184
1210
  topPad = Math.max(FIT_PAD, bannerBottom + TITLE_GAP);
1185
1211
  }
1212
+ // Reserve a band for the top-center legend so the projected land starts BELOW
1213
+ // it (the legend is a foreground overlay — without this it covers land, e.g.
1214
+ // Europe on a world map). The band is measured from the SAME groups/config the
1215
+ // renderer draws (mode-aware: export shows only the active group), so the
1216
+ // reserve matches the rendered legend exactly.
1217
+ const legendBand = mapLegendBand(legend, {
1218
+ width,
1219
+ mode: opts.legendMode ?? 'preview',
1220
+ hasTitle: Boolean(resolved.title),
1221
+ hasSubtitle: Boolean(resolved.subtitle),
1222
+ });
1223
+ if (legendBand > topPad) topPad = legendBand;
1186
1224
  const fitBox: [[number, number], [number, number]] = [
1187
1225
  [FIT_PAD, topPad],
1188
1226
  [
@@ -1223,7 +1261,10 @@ export function layoutMap(
1223
1261
  // edge, not 24px short of it with a coastline ringing the gap). The title
1224
1262
  // overlays the top; we reserve a top band only when POIs are present (so
1225
1263
  // their markers don't project up under the foreground title banner).
1226
- const topReserve = resolved.title && resolved.pois.length > 0 ? topPad : 0;
1264
+ const topReserve =
1265
+ (resolved.title && resolved.pois.length > 0) || legendBand > 0
1266
+ ? topPad
1267
+ : 0;
1227
1268
  const ox = 0;
1228
1269
  const oy = topReserve;
1229
1270
  const sx = cw > 0 ? width / cw : 1;
@@ -2887,36 +2928,6 @@ export function layoutMap(
2887
2928
  labels.push(...contextLabels);
2888
2929
  }
2889
2930
 
2890
- // -- Legend model (AR1: categorical via renderer's renderLegendD3) --
2891
- let legend: MapLayoutLegend | null = null;
2892
- if (!resolved.directives.noLegend) {
2893
- const tagGroups = resolved.tagGroups.map((g) => ({
2894
- name: g.name,
2895
- entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
2896
- }));
2897
- // Only the colouring dimensions (value ramp + tag groups) get a legend.
2898
- // POI size and edge thickness are self-evident from the marker/line scale and
2899
- // intentionally carry no key (the poi-metric/flow-metric labels are captured
2900
- // for future use but not rendered as legend keys in v1).
2901
- if (tagGroups.length > 0 || hasRamp) {
2902
- legend = {
2903
- tagGroups,
2904
- activeGroup,
2905
- ...(hasRamp && {
2906
- ramp: {
2907
- ...(resolved.directives.regionMetric !== undefined && {
2908
- metric: resolved.directives.regionMetric,
2909
- }),
2910
- min: rampMin,
2911
- max: rampMax,
2912
- hue: rampHue,
2913
- base: rampBase,
2914
- },
2915
- }),
2916
- };
2917
- }
2918
- }
2919
-
2920
2931
  return {
2921
2932
  width,
2922
2933
  height,
@@ -0,0 +1,99 @@
1
+ // Vertical band reserved at the top of a map canvas for the choropleth / tag
2
+ // legend, so the projected land starts BELOW the legend instead of being covered
3
+ // by it. The legend (§24B.11) is drawn as a top-center foreground overlay; on a
4
+ // world map that placement sits over land (e.g. Europe), hiding it. Reserving a
5
+ // band in the fit box pushes the map down so the legend clears the land.
6
+ //
7
+ // Shared by `layoutMap` (grows `topPad`) and `mapExportDimensions` is intentionally
8
+ // NOT a consumer — the canvas height is independent; the band only repositions the
9
+ // map WITHIN the canvas. The group builder + config are reused by the renderer so
10
+ // the measured band matches exactly what gets drawn.
11
+ import { computeLegendLayout } from '../utils/legend-layout';
12
+ import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
13
+ import type {
14
+ LegendConfig,
15
+ LegendGroupData,
16
+ LegendMode,
17
+ LegendState,
18
+ } from '../utils/legend-types';
19
+ import type { MapLayoutLegend } from './types';
20
+
21
+ // Gap between the title/subtitle banner and the legend top — mirrors the `+ 8`
22
+ // in renderer.ts `legendY`.
23
+ const LEGEND_TOP_GAP = 8;
24
+ // Gap between the legend bottom and the start of the map content.
25
+ const LEGEND_BOTTOM_GAP = 10;
26
+
27
+ /** The legend's colouring groups (score ramp first, then non-empty tag groups) —
28
+ * the SAME array the renderer draws, so a measured layout matches the rendered
29
+ * one. Empty when the legend has neither a ramp nor any populated tag group. */
30
+ export function mapLegendGroups(legend: MapLayoutLegend): LegendGroupData[] {
31
+ const ramp = legend.ramp;
32
+ // Reserved name "Value" when no region-metric label is set — must match
33
+ // VALUE_NAME in layout.ts so the resolved activeGroup selects it.
34
+ const scoreGroup: LegendGroupData | null = ramp
35
+ ? {
36
+ name: ramp.metric?.trim() || 'Value',
37
+ entries: [],
38
+ gradient: {
39
+ min: ramp.min,
40
+ max: ramp.max,
41
+ hue: ramp.hue,
42
+ base: ramp.base,
43
+ },
44
+ }
45
+ : null;
46
+ const tagGroups: LegendGroupData[] = legend.tagGroups
47
+ .filter((g) => g.entries.length > 0)
48
+ .map((g) => ({ name: g.name, entries: [...g.entries] }));
49
+ return [...(scoreGroup ? [scoreGroup] : []), ...tagGroups];
50
+ }
51
+
52
+ /** The shared map-legend config (top-center, below the title, inactive pills kept
53
+ * so the preview can flip the active colouring dimension). `mode` gates the
54
+ * export-only filtering (active group only). */
55
+ export function mapLegendConfig(
56
+ groups: readonly LegendGroupData[],
57
+ mode: LegendMode
58
+ ): LegendConfig {
59
+ return {
60
+ groups,
61
+ position: { placement: 'top-center', titleRelation: 'below-title' },
62
+ mode,
63
+ showEmptyGroups: false,
64
+ showInactivePills: true,
65
+ };
66
+ }
67
+
68
+ /** Y of the legend's top edge — mirrors `legendY` in renderer.ts. */
69
+ export function mapLegendTop(hasTitle: boolean, hasSubtitle: boolean): number {
70
+ return (
71
+ (hasTitle ? TITLE_Y + TITLE_FONT_SIZE : 0) +
72
+ (hasSubtitle ? TITLE_FONT_SIZE : 0) +
73
+ LEGEND_TOP_GAP
74
+ );
75
+ }
76
+
77
+ /** Total vertical band (px from the canvas top) the legend occupies = its top Y +
78
+ * measured height + a bottom gap. Returns 0 when no legend is drawn, so callers
79
+ * can `Math.max` it into their existing top reserve without special-casing. */
80
+ export function mapLegendBand(
81
+ legend: MapLayoutLegend | null,
82
+ opts: {
83
+ width: number;
84
+ mode: LegendMode;
85
+ hasTitle: boolean;
86
+ hasSubtitle: boolean;
87
+ }
88
+ ): number {
89
+ if (!legend) return 0;
90
+ const groups = mapLegendGroups(legend);
91
+ if (groups.length === 0) return 0;
92
+ const config = mapLegendConfig(groups, opts.mode);
93
+ const state: LegendState = { activeGroup: legend.activeGroup };
94
+ const { height } = computeLegendLayout(config, state, opts.width);
95
+ if (height <= 0) return 0;
96
+ return (
97
+ mapLegendTop(opts.hasTitle, opts.hasSubtitle) + height + LEGEND_BOTTOM_GAP
98
+ );
99
+ }
@@ -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';
@@ -236,6 +237,9 @@ export function renderMap(
236
237
  // stretch-distorting. The in-app preview pane passes no exportDims → unset →
237
238
  // keeps the global stretch-fill.
238
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',
239
243
  ...(activeGroupOverride !== undefined && {
240
244
  activeGroup: activeGroupOverride,
241
245
  }),
@@ -912,36 +916,14 @@ export function renderMap(
912
916
  .attr('transform', `translate(0, ${legendY})`);
913
917
  // The value ramp is a selectable colouring group alongside the tag groups
914
918
  // (the user flips between them); its capsule renders the gradient inline.
915
- // Reserved name "Value" when no region-metric label is set must match
916
- // VALUE_NAME in layout.ts so the resolved activeGroup selects it.
917
- const ramp = layout.legend.ramp;
918
- const scoreGroup = ramp
919
- ? {
920
- name: ramp.metric?.trim() || 'Value',
921
- entries: [],
922
- gradient: {
923
- min: ramp.min,
924
- max: ramp.max,
925
- hue: ramp.hue,
926
- base: ramp.base,
927
- },
928
- }
929
- : null;
930
- const tagGroups = layout.legend.tagGroups
931
- .filter((g) => g.entries.length > 0)
932
- .map((g) => ({ name: g.name, entries: [...g.entries] }));
933
- 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);
934
922
  if (groups.length > 0) {
935
- const config: LegendConfig = {
923
+ const config: LegendConfig = mapLegendConfig(
936
924
  groups,
937
- position: { placement: 'top-center', titleRelation: 'below-title' },
938
- mode: exportDims ? 'export' : 'preview',
939
- showEmptyGroups: false,
940
- // Keep inactive siblings visible as pills so the user can click to flip
941
- // the active colouring dimension (preview only — export shows just the
942
- // active group).
943
- showInactivePills: true,
944
- };
925
+ exportDims ? 'export' : 'preview'
926
+ );
945
927
  const state: LegendState = { activeGroup: layout.legend.activeGroup };
946
928
  renderLegendD3(legendG, config, state, palette, isDark, undefined, width);
947
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
+ }
@@ -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
+ }