@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.
@@ -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
- const NODE_FONT_SIZE = 13;
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: print the numeric value as text (opt-in) ──
1114
- // Independent of the active dimension (a user may want the numbers printed
1115
- // while a tag group tints). Plain nodes: centered below the label. Described
1116
- // nodes: a top-right corner badge so it never overflows the full body (R2-6).
1117
- if (parsed.showValues && node.value !== undefined) {
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 descShown = !!(desc && desc.length > 0 && !hideDescriptions);
1120
- if (descShown) {
1121
- // Corner badge pill behind the number so it reads over the header.
1122
- const padX = 6;
1123
- const padY = 5;
1124
- const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO + 8;
1125
- const bh = VALUE_FONT_SIZE + 4;
1126
- const bx = ln.width / 2 - bw - 4;
1127
- const by = -ln.height / 2 + 4;
1128
- nodeG
1129
- .append('rect')
1130
- .attr('x', bx)
1131
- .attr('y', by)
1132
- .attr('width', bw)
1133
- .attr('height', bh)
1134
- .attr('rx', 3)
1135
- .attr('fill', palette.bg)
1136
- .attr('opacity', 0.85);
1137
- nodeG
1138
- .append('text')
1139
- .attr('class', 'bl-node-value')
1140
- .attr('x', bx + bw - padX)
1141
- .attr('y', by + padY)
1142
- .attr('text-anchor', 'end')
1143
- .attr('dominant-baseline', 'central')
1144
- .attr('font-size', VALUE_FONT_SIZE)
1145
- .attr('font-weight', '600')
1146
- .attr('fill', palette.textMuted)
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
- const availInnerHeight = height - margin.top - margin.bottom;
4305
- const rowH = Math.min(ctx.structural(28), availInnerHeight / sorted.length);
4306
- // Each event needs only `rowH` of vertical space. When the container is
4307
- // taller than the rows require (rowH hits its 28px cap), draw the era
4308
- // bands and time axis to the content height instead of the full container
4309
- // so the axis sits just below the last event rather than leaving a large
4310
- // vertical gap. The SVG itself shrinks to match (top-aligned via
4311
- // preserveAspectRatio) so callers don't reserve dead space below the chart.
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(mapResolved, mapData, EXPORT_WIDTH);
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,
@@ -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
  // ============================================================
@@ -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/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
- 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
  }