@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.
- package/dist/advanced.cjs +372 -103
- package/dist/advanced.d.cts +52 -19
- package/dist/advanced.d.ts +52 -19
- package/dist/advanced.js +372 -103
- package/dist/auto.cjs +370 -97
- package/dist/auto.js +117 -117
- package/dist/auto.mjs +370 -97
- package/dist/cli.cjs +151 -151
- package/dist/editor.cjs +3 -0
- package/dist/editor.js +3 -0
- package/dist/highlight.cjs +3 -0
- package/dist/highlight.js +3 -0
- package/dist/index.cjs +498 -96
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +496 -96
- package/dist/internal.cjs +372 -103
- package/dist/internal.d.cts +52 -19
- package/dist/internal.d.ts +52 -19
- package/dist/internal.js +372 -103
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/gazetteer.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -1
- package/dist/map-data/water-bodies.json +1 -1
- package/dist/map-data/world-coarse.json +1 -1
- package/dist/map-data/world-detail.json +1 -1
- package/docs/language-reference.md +38 -2
- package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
- package/package.json +1 -1
- package/src/boxes-and-lines/parser.ts +39 -0
- package/src/boxes-and-lines/renderer.ts +219 -14
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/completion.ts +4 -5
- package/src/d3.ts +26 -6
- package/src/editor/keywords.ts +3 -0
- package/src/index.ts +8 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/README.md +6 -0
- package/src/map/data/gazetteer.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -1
- package/src/map/data/water-bodies.json +1 -1
- package/src/map/data/world-coarse.json +1 -1
- package/src/map/data/world-detail.json +1 -1
- package/src/map/dimensions.ts +21 -5
- package/src/map/layout.ts +167 -63
- package/src/map/legend-band.ts +99 -0
- package/src/map/renderer.ts +105 -32
- package/src/map/resolver.ts +43 -1
- package/src/map/types.ts +20 -0
- package/src/utils/reserved-key-registry.ts +5 -3
- package/src/utils/svg-embed.ts +193 -0
package/src/map/renderer.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
//
|
|
825
|
-
//
|
|
826
|
-
const
|
|
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
|
-
|
|
847
|
-
|
|
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
|
}
|
package/src/map/resolver.ts
CHANGED
|
@@ -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)
|
|
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
|
-
'
|
|
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
|
+
}
|