@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/dist/advanced.cjs +152 -73
- package/dist/advanced.d.cts +35 -19
- package/dist/advanced.d.ts +35 -19
- package/dist/advanced.js +152 -73
- package/dist/auto.cjs +153 -74
- package/dist/auto.js +110 -110
- package/dist/auto.mjs +153 -74
- package/dist/cli.cjs +150 -150
- package/dist/index.cjs +284 -73
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +282 -73
- package/dist/internal.cjs +152 -73
- package/dist/internal.d.cts +35 -19
- package/dist/internal.d.ts +35 -19
- package/dist/internal.js +152 -73
- package/docs/language-reference.md +3 -2
- package/package.json +1 -1
- package/src/boxes-and-lines/renderer.ts +98 -51
- package/src/d3.ts +22 -10
- package/src/index.ts +8 -0
- package/src/map/dimensions.ts +21 -5
- package/src/map/layout.ts +57 -46
- package/src/map/legend-band.ts +99 -0
- package/src/map/renderer.ts +10 -28
- package/src/map/resolver.ts +43 -1
- package/src/map/types.ts +20 -0
- package/src/utils/svg-embed.ts +193 -0
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
|
// ============================================================
|
package/src/map/dimensions.ts
CHANGED
|
@@ -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
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 =
|
|
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
|
+
}
|
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';
|
|
@@ -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
|
-
//
|
|
916
|
-
//
|
|
917
|
-
const
|
|
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
|
-
|
|
938
|
-
|
|
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
|
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|