@diagrammo/dgmo 0.23.0 → 0.25.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/LICENSE +1 -1
- package/dist/advanced.cjs +166 -91
- package/dist/advanced.d.cts +35 -19
- package/dist/advanced.d.ts +35 -19
- package/dist/advanced.js +166 -91
- package/dist/auto.cjs +167 -92
- package/dist/auto.js +110 -110
- package/dist/auto.mjs +167 -92
- package/dist/cli.cjs +150 -150
- package/dist/index.cjs +298 -91
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +296 -91
- package/dist/internal.cjs +166 -91
- package/dist/internal.d.cts +35 -19
- package/dist/internal.d.ts +35 -19
- package/dist/internal.js +166 -91
- 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/diagnostics.ts +0 -19
- package/src/index.ts +8 -0
- package/src/map/dimensions.ts +21 -5
- package/src/map/geo.ts +0 -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/sequence/parser.ts +0 -4
- package/src/utils/brand.ts +0 -17
- package/src/utils/legend-d3.ts +18 -8
- package/src/utils/parsing.ts +0 -16
- package/src/utils/reserved-key-registry.ts +0 -12
- package/src/utils/svg-embed.ts +193 -0
|
@@ -35,7 +35,9 @@ import { ScaleContext } from '../utils/scaling';
|
|
|
35
35
|
|
|
36
36
|
// ── Constants (aligned with infra pattern) ─────────────────
|
|
37
37
|
const DIAGRAM_PADDING = 20;
|
|
38
|
-
|
|
38
|
+
// Box labels run smaller than the 13px org/infra use — boxes-and-lines nodes are
|
|
39
|
+
// narrower (~97px), so a smaller label fits more text per line before wrapping.
|
|
40
|
+
const NODE_FONT_SIZE = 11;
|
|
39
41
|
const MIN_NODE_FONT_SIZE = 9;
|
|
40
42
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
41
43
|
const EDGE_STROKE_WIDTH = 1.5;
|
|
@@ -429,7 +431,7 @@ export function renderBoxesAndLines(
|
|
|
429
431
|
// between a tag group and the metric label, the tag group wins (AC9).
|
|
430
432
|
const matchColorGroup = (v: string): string | null => {
|
|
431
433
|
const lv = v.trim().toLowerCase();
|
|
432
|
-
if (lv === 'none') return null;
|
|
434
|
+
if (lv === '' || lv === 'none') return null;
|
|
433
435
|
const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
|
|
434
436
|
if (tg) return tg.name;
|
|
435
437
|
if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
|
|
@@ -1087,6 +1089,61 @@ export function renderBoxesAndLines(
|
|
|
1087
1089
|
fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
|
|
1088
1090
|
nodeG.append('title').text(tooltipText);
|
|
1089
1091
|
}
|
|
1092
|
+
} else if (parsed.showValues && node.value !== undefined) {
|
|
1093
|
+
// Plain node with show-values: label header + thin divider + a
|
|
1094
|
+
// "Metric: value" line below (org/infra card style), instead of a
|
|
1095
|
+
// vertically-centered label with a floating number.
|
|
1096
|
+
const valueLabel = parsed.boxMetric
|
|
1097
|
+
? `${parsed.boxMetric}: ${node.value}`
|
|
1098
|
+
: String(node.value);
|
|
1099
|
+
// Fixed header zone (not label-height-driven) so the divider sits at a
|
|
1100
|
+
// UNIFORM Y across every box, regardless of label line count (infra/org
|
|
1101
|
+
// both anchor the separator to a constant header height).
|
|
1102
|
+
const headerH = ln.height / 2;
|
|
1103
|
+
const sepY = -ln.height / 2 + headerH;
|
|
1104
|
+
const fitted = fitLabelToHeader(node.label, ln.width, 2);
|
|
1105
|
+
const labelLineH = fitted.fontSize * 1.3;
|
|
1106
|
+
const labelTotalH = fitted.lines.length * labelLineH;
|
|
1107
|
+
const headerCenterY = -ln.height / 2 + headerH / 2;
|
|
1108
|
+
for (let li = 0; li < fitted.lines.length; li++) {
|
|
1109
|
+
nodeG
|
|
1110
|
+
.append('text')
|
|
1111
|
+
.attr('x', 0)
|
|
1112
|
+
.attr(
|
|
1113
|
+
'y',
|
|
1114
|
+
headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
|
|
1115
|
+
)
|
|
1116
|
+
.attr('text-anchor', 'middle')
|
|
1117
|
+
.attr('dominant-baseline', 'central')
|
|
1118
|
+
.attr('font-size', fitted.fontSize)
|
|
1119
|
+
.attr('font-weight', '600')
|
|
1120
|
+
.attr('fill', colors.text)
|
|
1121
|
+
// In-bounds by loop guard.
|
|
1122
|
+
.text(fitted.lines[li]!);
|
|
1123
|
+
}
|
|
1124
|
+
// Thin divider under the title — a tint of the box's own stroke colour
|
|
1125
|
+
// (matches org / infra card separators), not a neutral text line.
|
|
1126
|
+
nodeG
|
|
1127
|
+
.append('line')
|
|
1128
|
+
.attr('x1', -ln.width / 2)
|
|
1129
|
+
.attr('y1', sepY)
|
|
1130
|
+
.attr('x2', ln.width / 2)
|
|
1131
|
+
.attr('y2', sepY)
|
|
1132
|
+
.attr('stroke', colors.stroke)
|
|
1133
|
+
.attr('stroke-opacity', 0.3)
|
|
1134
|
+
.attr('stroke-width', 1);
|
|
1135
|
+
// "Metric: value" centered in the space below the divider.
|
|
1136
|
+
nodeG
|
|
1137
|
+
.append('text')
|
|
1138
|
+
.attr('class', 'bl-node-value')
|
|
1139
|
+
.attr('x', 0)
|
|
1140
|
+
.attr('y', (sepY + ln.height / 2) / 2)
|
|
1141
|
+
.attr('text-anchor', 'middle')
|
|
1142
|
+
.attr('dominant-baseline', 'central')
|
|
1143
|
+
.attr('font-size', VALUE_FONT_SIZE)
|
|
1144
|
+
.attr('fill', colors.text)
|
|
1145
|
+
.attr('opacity', 0.85)
|
|
1146
|
+
.text(valueLabel);
|
|
1090
1147
|
} else {
|
|
1091
1148
|
const maxLabelLines = Math.max(
|
|
1092
1149
|
2,
|
|
@@ -1110,56 +1167,46 @@ export function renderBoxesAndLines(
|
|
|
1110
1167
|
}
|
|
1111
1168
|
}
|
|
1112
1169
|
|
|
1113
|
-
// ── show-values
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
if (
|
|
1170
|
+
// ── show-values on a DESCRIBED node ── the body is already full, so the
|
|
1171
|
+
// value rides in a top-right corner badge (plain nodes are handled in the
|
|
1172
|
+
// header/divider branch above; a described node with descriptions hidden
|
|
1173
|
+
// also falls through to that plain branch).
|
|
1174
|
+
if (
|
|
1175
|
+
parsed.showValues &&
|
|
1176
|
+
node.value !== undefined &&
|
|
1177
|
+
desc &&
|
|
1178
|
+
desc.length > 0 &&
|
|
1179
|
+
!hideDescriptions
|
|
1180
|
+
) {
|
|
1118
1181
|
const valueText = String(node.value);
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
.text(valueText);
|
|
1148
|
-
} else {
|
|
1149
|
-
// Plain node: value centered just above the bottom edge.
|
|
1150
|
-
nodeG
|
|
1151
|
-
.append('text')
|
|
1152
|
-
.attr('class', 'bl-node-value')
|
|
1153
|
-
.attr('x', 0)
|
|
1154
|
-
.attr('y', ln.height / 2 - VALUE_FONT_SIZE)
|
|
1155
|
-
.attr('text-anchor', 'middle')
|
|
1156
|
-
.attr('dominant-baseline', 'central')
|
|
1157
|
-
.attr('font-size', VALUE_FONT_SIZE)
|
|
1158
|
-
.attr('font-weight', '600')
|
|
1159
|
-
.attr('fill', colors.text)
|
|
1160
|
-
.attr('opacity', 0.8)
|
|
1161
|
-
.text(valueText);
|
|
1162
|
-
}
|
|
1182
|
+
const padX = 6;
|
|
1183
|
+
const padY = 5;
|
|
1184
|
+
const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO + 8;
|
|
1185
|
+
const bh = VALUE_FONT_SIZE + 4;
|
|
1186
|
+
// Clamp to the left padding so a long value on a narrow node never
|
|
1187
|
+
// slides past the box edge / over the label (R2-6 / AC23).
|
|
1188
|
+
const bx = Math.max(-ln.width / 2 + 4, ln.width / 2 - bw - 4);
|
|
1189
|
+
const by = -ln.height / 2 + 4;
|
|
1190
|
+
nodeG
|
|
1191
|
+
.append('rect')
|
|
1192
|
+
.attr('x', bx)
|
|
1193
|
+
.attr('y', by)
|
|
1194
|
+
.attr('width', bw)
|
|
1195
|
+
.attr('height', bh)
|
|
1196
|
+
.attr('rx', 3)
|
|
1197
|
+
.attr('fill', palette.bg)
|
|
1198
|
+
.attr('opacity', 0.85);
|
|
1199
|
+
nodeG
|
|
1200
|
+
.append('text')
|
|
1201
|
+
.attr('class', 'bl-node-value')
|
|
1202
|
+
.attr('x', bx + bw - padX)
|
|
1203
|
+
.attr('y', by + padY)
|
|
1204
|
+
.attr('text-anchor', 'end')
|
|
1205
|
+
.attr('dominant-baseline', 'central')
|
|
1206
|
+
.attr('font-size', VALUE_FONT_SIZE)
|
|
1207
|
+
.attr('font-weight', '600')
|
|
1208
|
+
.attr('fill', palette.textMuted)
|
|
1209
|
+
.text(valueText);
|
|
1163
1210
|
}
|
|
1164
1211
|
}
|
|
1165
1212
|
|
package/src/d3.ts
CHANGED
|
@@ -4238,7 +4238,6 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4238
4238
|
): void {
|
|
4239
4239
|
const {
|
|
4240
4240
|
width,
|
|
4241
|
-
height,
|
|
4242
4241
|
tooltip,
|
|
4243
4242
|
solid,
|
|
4244
4243
|
textColor,
|
|
@@ -4301,14 +4300,18 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4301
4300
|
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4302
4301
|
: 0;
|
|
4303
4302
|
const innerWidth = width - margin.left - margin.right;
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
//
|
|
4307
|
-
//
|
|
4308
|
-
//
|
|
4309
|
-
//
|
|
4310
|
-
//
|
|
4311
|
-
//
|
|
4303
|
+
// Each event gets a fixed comfortable row. The old behaviour compressed rowH
|
|
4304
|
+
// to fit the container height (`min(28, avail / n)`), but that only ever
|
|
4305
|
+
// shrank rows BELOW the 22px bar height — cramming events into overlap when
|
|
4306
|
+
// the host surface was shorter than the content required (e.g. the app's
|
|
4307
|
+
// fixed-height embedded-diagram surface). A constant rowH never overlaps:
|
|
4308
|
+
// when the container is taller than needed the SVG shrinks to the content
|
|
4309
|
+
// (top-aligned via preserveAspectRatio); when shorter, the SVG grows past it
|
|
4310
|
+
// and the host collapses/expands to the rendered height. This also makes the
|
|
4311
|
+
// interactive preview match the exported image, which already used rowH=28.
|
|
4312
|
+
const rowH = ctx.structural(28);
|
|
4313
|
+
// Draw the era bands and time axis to the content height (not the full
|
|
4314
|
+
// container) so the axis sits just below the last event.
|
|
4312
4315
|
const innerHeight = rowH * sorted.length;
|
|
4313
4316
|
const usedHeight = margin.top + innerHeight + margin.bottom;
|
|
4314
4317
|
|
|
@@ -7751,6 +7754,10 @@ export async function renderForExport(
|
|
|
7751
7754
|
// here — the Node fs `loadMapData()` seam can't run in a browser. CLI/SSR
|
|
7752
7755
|
// omit this and fall back to the fs loader.
|
|
7753
7756
|
mapData?: import('./map/resolved-types').MapData;
|
|
7757
|
+
// WYSIWYG map export: the live preview pane's displayed aspect (w/h). When
|
|
7758
|
+
// set, the map canvas adopts it + stretch-fills so the PNG matches the
|
|
7759
|
+
// on-screen map. The app passes this; headless consumers omit it.
|
|
7760
|
+
mapAspect?: number;
|
|
7754
7761
|
}
|
|
7755
7762
|
): Promise<string> {
|
|
7756
7763
|
const exportMode = options?.exportMode ?? false;
|
|
@@ -8491,7 +8498,12 @@ export async function renderForExport(
|
|
|
8491
8498
|
// aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
|
|
8492
8499
|
// export matches the content's natural shape — no vertical stretch, no
|
|
8493
8500
|
// letterbox bands. `preferContain` rides along to the renderer.
|
|
8494
|
-
const dims = mapExportDimensions(
|
|
8501
|
+
const dims = mapExportDimensions(
|
|
8502
|
+
mapResolved,
|
|
8503
|
+
mapData,
|
|
8504
|
+
EXPORT_WIDTH,
|
|
8505
|
+
options?.mapAspect
|
|
8506
|
+
);
|
|
8495
8507
|
const container = createExportContainer(dims.width, dims.height);
|
|
8496
8508
|
renderMapForExport(
|
|
8497
8509
|
container,
|
package/src/diagnostics.ts
CHANGED
|
@@ -347,13 +347,6 @@ export function bareDescriptionRemovedMessage(args: {
|
|
|
347
347
|
return `'|' description shorthand removed in ${args.chartType} — use 'description: ${quoted}'`;
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
-
/**
|
|
351
|
-
* Canonical message for `E_TAG_DECLARED_AFTER_CONTENT`.
|
|
352
|
-
*/
|
|
353
|
-
export function tagDeclaredAfterContentMessage(tagName: string): string {
|
|
354
|
-
return `'tag ${tagName}' must appear before content — move it above diagram lines`;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
350
|
/**
|
|
358
351
|
* Canonical message for `W_EMPTY_METADATA_VALUE`. Emitted when a
|
|
359
352
|
* `key:` token has no value following the colon.
|
|
@@ -364,15 +357,3 @@ export function emptyMetadataValueMessage(key: string): string {
|
|
|
364
357
|
`Provide a value or remove the key.`
|
|
365
358
|
);
|
|
366
359
|
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Canonical message for `W_ATTRIBUTE_AT_PARENT_INDENT`. Emitted
|
|
370
|
-
* when an indented reserved-key attribute appears at the same indent
|
|
371
|
-
* level as preceding structural children.
|
|
372
|
-
*/
|
|
373
|
-
export function attributeAtParentIndentMessage(key: string): string {
|
|
374
|
-
return (
|
|
375
|
-
`Attribute '${key}:' attaches to the parent above — ` +
|
|
376
|
-
`indent further if you meant it on the preceding structural child.`
|
|
377
|
-
);
|
|
378
|
-
}
|
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/geo.ts
CHANGED
|
@@ -42,11 +42,6 @@ export function featureIndex(
|
|
|
42
42
|
return idx;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** Set of geometry ids (ISO codes) present in a topology. */
|
|
46
|
-
export function idSet(topo: BoundaryTopology): Set<string> {
|
|
47
|
-
return new Set(geomObject(topo).geometries.map((g) => g.id));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
45
|
// Memoize adjacency on the RAW asset object (never the per-render-mutated
|
|
51
46
|
// `worldLayer`). Keyed by topology identity — the assets are stable singletons
|
|
52
47
|
// from load-data.ts, so one build per topology lasts the process (G13).
|
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
|
}
|