@diagrammo/dgmo 0.19.0 → 0.20.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.
Files changed (48) hide show
  1. package/dist/advanced.cjs +919 -298
  2. package/dist/advanced.d.cts +148 -54
  3. package/dist/advanced.d.ts +148 -54
  4. package/dist/advanced.js +922 -300
  5. package/dist/auto.cjs +904 -297
  6. package/dist/auto.js +117 -117
  7. package/dist/auto.mjs +909 -299
  8. package/dist/cli.cjs +159 -159
  9. package/dist/index.cjs +903 -296
  10. package/dist/index.js +908 -298
  11. package/dist/internal.cjs +919 -298
  12. package/dist/internal.d.cts +148 -54
  13. package/dist/internal.d.ts +148 -54
  14. package/dist/internal.js +922 -300
  15. package/dist/map-data/PROVENANCE.json +1 -1
  16. package/dist/map-data/lakes.json +1 -0
  17. package/dist/map-data/na-lakes.json +1 -0
  18. package/dist/map-data/na-land.json +1 -0
  19. package/dist/map-data/rivers.json +1 -0
  20. package/docs/language-reference.md +12 -7
  21. package/gallery/fixtures/map-region-scope.dgmo +15 -0
  22. package/package.json +4 -4
  23. package/src/advanced.ts +6 -2
  24. package/src/c4/parser.ts +6 -6
  25. package/src/completion.ts +6 -2
  26. package/src/echarts.ts +1 -1
  27. package/src/infra/parser.ts +10 -10
  28. package/src/journey-map/parser.ts +1 -1
  29. package/src/label-layout.ts +36 -0
  30. package/src/map/data/PROVENANCE.json +1 -1
  31. package/src/map/data/README.md +2 -0
  32. package/src/map/data/lakes.json +1 -0
  33. package/src/map/data/na-lakes.json +1 -0
  34. package/src/map/data/na-land.json +1 -0
  35. package/src/map/data/rivers.json +1 -0
  36. package/src/map/layout.ts +1022 -205
  37. package/src/map/load-data.ts +29 -2
  38. package/src/map/parser.ts +22 -13
  39. package/src/map/renderer.ts +200 -219
  40. package/src/map/resolved-types.ts +18 -1
  41. package/src/map/resolver.ts +79 -7
  42. package/src/map/types.ts +4 -0
  43. package/src/mindmap/parser.ts +1 -1
  44. package/src/sitemap/parser.ts +1 -1
  45. package/src/utils/legend-d3.ts +42 -0
  46. package/src/utils/legend-layout.ts +83 -3
  47. package/src/utils/legend-svg.ts +1 -8
  48. package/src/utils/legend-types.ts +44 -1
@@ -20,6 +20,10 @@ const FILES = {
20
20
  worldCoarse: 'world-coarse.json',
21
21
  worldDetail: 'world-detail.json',
22
22
  usStates: 'us-states.json',
23
+ lakes: 'lakes.json',
24
+ rivers: 'rivers.json',
25
+ naLand: 'na-land.json',
26
+ naLakes: 'na-lakes.json',
23
27
  gazetteer: 'gazetteer.json',
24
28
  } as const;
25
29
 
@@ -92,13 +96,36 @@ function moduleBaseDir(): string {
92
96
  export function loadMapData(): Promise<MapData> {
93
97
  cache ??= (async (): Promise<MapData> => {
94
98
  const dir = await firstExistingDir(moduleBaseDir());
95
- const [worldCoarse, worldDetail, usStates, gazetteer] = await Promise.all([
99
+ const [
100
+ worldCoarse,
101
+ worldDetail,
102
+ usStates,
103
+ lakes,
104
+ rivers,
105
+ naLand,
106
+ naLakes,
107
+ gazetteer,
108
+ ] = await Promise.all([
96
109
  readJson<BoundaryTopology>(dir, FILES.worldCoarse),
97
110
  readJson<BoundaryTopology>(dir, FILES.worldDetail),
98
111
  readJson<BoundaryTopology>(dir, FILES.usStates),
112
+ // Lakes/rivers/NA assets are optional — older bundles may predate them.
113
+ readJson<BoundaryTopology>(dir, FILES.lakes).catch(() => undefined),
114
+ readJson<BoundaryTopology>(dir, FILES.rivers).catch(() => undefined),
115
+ readJson<BoundaryTopology>(dir, FILES.naLand).catch(() => undefined),
116
+ readJson<BoundaryTopology>(dir, FILES.naLakes).catch(() => undefined),
99
117
  readJson<Gazetteer>(dir, FILES.gazetteer),
100
118
  ]);
101
- return validate({ worldCoarse, worldDetail, usStates, gazetteer });
119
+ return validate({
120
+ worldCoarse,
121
+ worldDetail,
122
+ usStates,
123
+ gazetteer,
124
+ ...(lakes && { lakes }),
125
+ ...(rivers && { rivers }),
126
+ ...(naLand && { naLand }),
127
+ ...(naLakes && { naLakes }),
128
+ });
102
129
  })().catch((e: unknown) => {
103
130
  cache = undefined; // don't poison future calls with a rejected promise
104
131
  throw e;
package/src/map/parser.ts CHANGED
@@ -105,11 +105,6 @@ export function parseMap(content: string): ParsedMap {
105
105
  const pushWarning = (line: number, message: string): void => {
106
106
  diagnostics.push(makeDgmoError(line, message, 'warning'));
107
107
  };
108
- // §24B calls the score+tag-coexistence note "info", but DgmoSeverity is only
109
- // error|warning — emit at warning (lowest available informational severity).
110
- const pushInfo = (line: number, message: string): void => {
111
- diagnostics.push(makeDgmoError(line, message, 'warning'));
112
- };
113
108
 
114
109
  const lines = content.split('\n');
115
110
 
@@ -289,11 +284,16 @@ export function parseMap(content: string): ParsedMap {
289
284
  dup(d.projection);
290
285
  if (
291
286
  value &&
292
- !['natural-earth', 'albers-usa', 'mercator'].includes(value)
287
+ ![
288
+ 'equirectangular',
289
+ 'natural-earth',
290
+ 'albers-usa',
291
+ 'mercator',
292
+ ].includes(value)
293
293
  )
294
294
  pushWarning(
295
295
  line,
296
- `Unknown projection "${value}" (expected natural-earth | albers-usa | mercator).`
296
+ `Unknown projection "${value}" (expected equirectangular | natural-earth | albers-usa | mercator).`
297
297
  );
298
298
  d.projection = value;
299
299
  break;
@@ -441,17 +441,26 @@ export function parseMap(content: string): ParsedMap {
441
441
  scoreNum = undefined;
442
442
  }
443
443
  }
444
- if (scoreNum !== undefined && Object.keys(tags).length)
445
- pushInfo(
446
- line,
447
- 'A region has both `score:` and a tag value v1 renders only the score (bivariate is a future seam).'
448
- );
444
+ // A region may carry BOTH a `score:` and a tag value — they are two
445
+ // selectable colouring dimensions (the legend flips between the score ramp
446
+ // and the tag group), so this is no longer warned (bivariate is handled).
447
+ // Peel a trailing ISO scope token (§24B.8)same qualifier POIs accept,
448
+ // so `Georgia US-GA` / `Georgia US` can force the country-vs-state pick.
449
+ let regionName = split.name;
450
+ let regionScope: string | undefined;
451
+ const rToks = regionName.split(/\s+/);
452
+ const rLast = rToks[rToks.length - 1]!;
453
+ if (rToks.length > 1 && SCOPE_RE.test(rLast)) {
454
+ regionName = rToks.slice(0, -1).join(' ');
455
+ regionScope = rLast;
456
+ }
449
457
  const region: Writable<MapRegion> = {
450
- name: split.name,
458
+ name: regionName,
451
459
  tags,
452
460
  meta,
453
461
  lineNumber: line,
454
462
  };
463
+ if (regionScope !== undefined) region.scope = regionScope;
455
464
  if (scoreNum !== undefined) region.score = scoreNum;
456
465
  regions.push(region);
457
466
  }
@@ -17,7 +17,7 @@ import type { LegendConfig, LegendState } from '../utils/legend-types';
17
17
  import type { PaletteColors } from '../palettes/types';
18
18
  import type { D3ExportDimensions } from '../utils/d3-types';
19
19
  import type { MapData, ResolvedMap } from './resolved-types';
20
- import { layoutMap, type MapLayout, type PlacedLabel } from './layout';
20
+ import { layoutMap, type MapLayoutRegion, type PlacedLabel } from './layout';
21
21
 
22
22
  const LABEL_FONT = 11;
23
23
 
@@ -29,7 +29,9 @@ export function renderMap(
29
29
  palette: PaletteColors,
30
30
  isDark: boolean,
31
31
  onClickItem?: (lineNumber: number) => void,
32
- exportDims?: D3ExportDimensions
32
+ exportDims?: D3ExportDimensions,
33
+ /** Live override of the active colouring group (interactive legend flip). */
34
+ activeGroupOverride?: string | null
33
35
  ): void {
34
36
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
35
37
  const width = exportDims?.width ?? container.clientWidth;
@@ -43,6 +45,9 @@ export function renderMap(
43
45
  {
44
46
  palette,
45
47
  isDark,
48
+ ...(activeGroupOverride !== undefined && {
49
+ activeGroup: activeGroupOverride,
50
+ }),
46
51
  }
47
52
  );
48
53
 
@@ -54,7 +59,12 @@ export function renderMap(
54
59
  .attr('viewBox', `0 0 ${width} ${height}`)
55
60
  .attr('preserveAspectRatio', 'xMidYMin meet')
56
61
  .attr('xmlns', 'http://www.w3.org/2000/svg')
57
- .style('font-family', FONT_FAMILY);
62
+ .style('font-family', FONT_FAMILY)
63
+ // Match the SVG element background to the water rect so any letterboxing
64
+ // (when the host container's aspect differs from the viewBox) shows water,
65
+ // not the gray palette bg that finalizeSvgExport would otherwise apply —
66
+ // i.e. no stray band above/below the map.
67
+ .style('background', layout.background);
58
68
 
59
69
  svg
60
70
  .append('rect')
@@ -62,66 +72,46 @@ export function renderMap(
62
72
  .attr('height', height)
63
73
  .attr('fill', layout.background);
64
74
 
65
- // Arrowhead marker for directed legs.
66
- const arrowColor = mix(palette.text, palette.bg, 50);
75
+ // Arrowhead markers for directed legs. Sized in user-space (NOT the SVG
76
+ // default of stroke-width units) so a heavy weighted line doesn't blow the
77
+ // arrowhead up to a giant wedge. The size grows gently with the line width —
78
+ // enough to stay distinct from the stroke — but is firmly capped.
67
79
  const defs = svg.append('defs');
68
- defs
69
- .append('marker')
70
- .attr('id', 'dgmo-map-arrow')
71
- .attr('viewBox', '0 0 10 10')
72
- .attr('refX', 9)
73
- .attr('refY', 5)
74
- .attr('markerWidth', 7)
75
- .attr('markerHeight', 7)
76
- .attr('orient', 'auto-start-reverse')
77
- .append('path')
78
- .attr('d', 'M0,0L10,5L0,10z')
79
- .attr('fill', arrowColor);
80
+ // Dampened: ~8px at the thinnest leg, easing toward a 15px cap as legs widen.
81
+ const arrowSize = (w: number): number => Math.min(15, 7 + w * 0.95);
80
82
 
81
- const haloColor = layout.background;
83
+ // Neutral bg (not the water-tinted backdrop) so label halos read over both
84
+ // land and ocean.
85
+ const haloColor = palette.bg;
82
86
 
83
- // ── Title / subtitle ──
84
- if (layout.title) {
85
- svg
86
- .append('text')
87
- .attr('x', width / 2)
88
- .attr('y', TITLE_Y)
89
- .attr('text-anchor', 'middle')
90
- .attr('font-size', TITLE_FONT_SIZE)
91
- .attr('font-weight', TITLE_FONT_WEIGHT)
92
- .attr('fill', palette.text)
93
- .text(layout.title);
94
- }
95
- if (layout.subtitle) {
96
- svg
97
- .append('text')
98
- .attr('x', width / 2)
99
- .attr('y', TITLE_Y + TITLE_FONT_SIZE)
100
- .attr('text-anchor', 'middle')
101
- .attr('font-size', LABEL_FONT + 1)
102
- .attr('fill', palette.textMuted)
103
- .text(layout.subtitle);
104
- }
105
- if (layout.caption) {
106
- svg
107
- .append('text')
108
- .attr('x', width / 2)
109
- .attr('y', height - 8)
110
- .attr('text-anchor', 'middle')
111
- .attr('font-size', LABEL_FONT)
112
- .attr('fill', palette.textMuted)
113
- .text(layout.caption);
114
- }
87
+ // Title / subtitle / caption are rendered LAST (see end of function) so they
88
+ // sit in the foreground above the basemap, POIs, and labels.
115
89
 
116
90
  // ── Regions ──
117
91
  const gRegions = svg.append('g').attr('class', 'dgmo-map-regions');
118
- for (const r of layout.regions) {
119
- const p = gRegions
92
+ const drawRegion = (
93
+ g: Sel,
94
+ r: MapLayoutRegion,
95
+ strokeWidth: number
96
+ ): void => {
97
+ const p = g
120
98
  .append('path')
121
99
  .attr('d', r.d)
122
100
  .attr('fill', r.fill)
123
101
  .attr('stroke', r.stroke)
124
- .attr('stroke-width', 0.5);
102
+ .attr('stroke-width', strokeWidth);
103
+ // Data layer? Tag it so the app can highlight on legend hover / gradient
104
+ // scrub. `data-score` for ramp-proximity, `data-tag-<group>` per tag value
105
+ // (both lowercased to match the lowercased legend-entry attributes).
106
+ if (r.layer !== 'base') {
107
+ p.classed('dgmo-map-region', true).attr('data-region', r.id);
108
+ if (r.score !== undefined) p.attr('data-score', r.score);
109
+ if (r.tags) {
110
+ for (const [group, value] of Object.entries(r.tags)) {
111
+ p.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
112
+ }
113
+ }
114
+ }
125
115
  if (r.lineNumber >= 0) {
126
116
  p.attr('data-line-number', r.lineNumber);
127
117
  if (onClickItem) {
@@ -130,6 +120,46 @@ export function renderMap(
130
120
  );
131
121
  }
132
122
  }
123
+ };
124
+ for (const r of layout.regions) drawRegion(gRegions, r, 0.5);
125
+
126
+ // ── Rivers (thin water centerlines over the land, under POIs/edges) ──
127
+ if (layout.rivers.length) {
128
+ const gRivers = svg
129
+ .append('g')
130
+ .attr('class', 'dgmo-map-rivers')
131
+ .attr('fill', 'none');
132
+ for (const r of layout.rivers) {
133
+ gRivers
134
+ .append('path')
135
+ .attr('d', r.d)
136
+ .attr('stroke', r.color)
137
+ .attr('stroke-width', r.width)
138
+ .attr('stroke-linecap', 'round')
139
+ .attr('stroke-linejoin', 'round');
140
+ }
141
+ }
142
+
143
+ // ── AK / HI insets (albers-usa) — drawn in the FOREGROUND so the opaque ocean
144
+ // box hides the main-map neighbour land (Mexico's Baja) behind it; the state
145
+ // then draws on top, framed by the box border. ──
146
+ if (layout.insets.length) {
147
+ const insetG = svg.append('g').attr('class', 'dgmo-map-insets');
148
+ for (const box of layout.insets) {
149
+ // Angled-top quad frame — rides under the conus coast so it never covers
150
+ // neighbouring states. Closed path from the four corners.
151
+ const d =
152
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
153
+ 'Z';
154
+ insetG
155
+ .append('path')
156
+ .attr('d', d)
157
+ .attr('fill', layout.background)
158
+ .attr('stroke', mix(palette.text, palette.bg, 55))
159
+ .attr('stroke-width', 1)
160
+ .attr('stroke-linejoin', 'round');
161
+ }
162
+ for (const r of layout.insetRegions) drawRegion(insetG, r, 0.5);
133
163
  }
134
164
 
135
165
  // ── Legs (edges + route legs) ──
@@ -137,14 +167,31 @@ export function renderMap(
137
167
  .append('g')
138
168
  .attr('class', 'dgmo-map-legs')
139
169
  .attr('fill', 'none');
140
- for (const leg of layout.legs) {
170
+ layout.legs.forEach((leg, i) => {
141
171
  const p = gLegs
142
172
  .append('path')
143
173
  .attr('d', leg.d)
144
174
  .attr('stroke', leg.color)
145
175
  .attr('stroke-width', leg.width)
146
176
  .attr('stroke-linecap', 'round');
147
- if (leg.arrow) p.attr('marker-end', 'url(#dgmo-map-arrow)');
177
+ if (leg.arrow) {
178
+ const id = `dgmo-map-arrow-${i}`;
179
+ const s = arrowSize(leg.width);
180
+ defs
181
+ .append('marker')
182
+ .attr('id', id)
183
+ .attr('viewBox', '0 0 10 10')
184
+ .attr('refX', 10)
185
+ .attr('refY', 5)
186
+ .attr('markerUnits', 'userSpaceOnUse')
187
+ .attr('markerWidth', s)
188
+ .attr('markerHeight', s)
189
+ .attr('orient', 'auto-start-reverse')
190
+ .append('path')
191
+ .attr('d', 'M0,0L10,5L0,10z')
192
+ .attr('fill', leg.color);
193
+ p.attr('marker-end', `url(#${id})`);
194
+ }
148
195
  if (leg.label !== undefined && leg.labelX !== undefined) {
149
196
  emitText(
150
197
  gLegs,
@@ -158,7 +205,7 @@ export function renderMap(
158
205
  LABEL_FONT - 1
159
206
  );
160
207
  }
161
- }
208
+ });
162
209
 
163
210
  // ── POIs ──
164
211
  const gPois = svg.append('g').attr('class', 'dgmo-map-pois');
@@ -181,7 +228,8 @@ export function renderMap(
181
228
  .attr('fill', poi.fill)
182
229
  .attr('stroke', poi.stroke)
183
230
  .attr('stroke-width', 1)
184
- .attr('data-line-number', poi.lineNumber);
231
+ .attr('data-line-number', poi.lineNumber)
232
+ .attr('data-poi', poi.id);
185
233
  if (onClickItem) {
186
234
  c.style('cursor', 'pointer').on('click', () =>
187
235
  onClickItem(poi.lineNumber)
@@ -202,61 +250,40 @@ export function renderMap(
202
250
  }
203
251
  }
204
252
 
205
- // ── Labels (leaders, halo text, numbered pins) ──
253
+ // ── Labels (leaders + halo text) ──
206
254
  const gLabels = svg.append('g').attr('class', 'dgmo-map-labels');
207
255
  for (const lab of layout.labels) {
208
256
  if (lab.leader) {
209
- gLabels
257
+ const line = gLabels
210
258
  .append('line')
211
259
  .attr('x1', lab.leader.x1)
212
260
  .attr('y1', lab.leader.y1)
213
261
  .attr('x2', lab.leader.x2)
214
262
  .attr('y2', lab.leader.y2)
215
- .attr('stroke', mix(palette.textMuted, palette.bg, 60))
216
- .attr('stroke-width', 0.75);
263
+ // Tie the leader to its dot by colour; neutral grey when it has none.
264
+ .attr(
265
+ 'stroke',
266
+ lab.leaderColor ?? mix(palette.textMuted, palette.bg, 60)
267
+ )
268
+ .attr('stroke-width', lab.leaderColor ? 1 : 0.75);
269
+ if (lab.poiId !== undefined) line.attr('data-poi', lab.poiId);
217
270
  }
218
- if (lab.pin !== undefined) {
219
- gLabels
220
- .append('rect')
221
- .attr('x', lab.x - 1)
222
- .attr('y', lab.y - LABEL_FONT)
223
- .attr('width', LABEL_FONT * 1.3)
224
- .attr('height', LABEL_FONT * 1.3)
225
- .attr('rx', 2)
226
- .attr('fill', palette.surface)
227
- .attr('stroke', palette.border);
228
- }
229
- emitText(
271
+ const t = emitText(
230
272
  gLabels,
231
273
  lab.x,
232
274
  lab.y,
233
275
  lab.text,
234
276
  lab.anchor,
235
277
  lab.color,
236
- haloColor,
278
+ lab.haloColor,
237
279
  lab.halo,
238
280
  LABEL_FONT
239
281
  );
240
- }
241
-
242
- // ── Pin legend list ──
243
- if (layout.pinList.length > 0) {
244
- const gPins = svg
245
- .append('g')
246
- .attr('class', 'dgmo-map-pin-list')
247
- .attr(
248
- 'transform',
249
- `translate(12, ${height - layout.pinList.length * 14 - 8})`
250
- );
251
- layout.pinList.forEach((entry, i) => {
252
- gPins
253
- .append('text')
254
- .attr('x', 0)
255
- .attr('y', i * 14)
256
- .attr('font-size', LABEL_FONT - 1)
257
- .attr('fill', palette.textMuted)
258
- .text(`${entry.pin} — ${entry.label}`);
259
- });
282
+ // POI labels are spotlightable: tag with the POI id and make the text the
283
+ // hover target (the app dims the other dots/labels on enter).
284
+ if (lab.poiId !== undefined) {
285
+ t.attr('data-poi', lab.poiId).style('cursor', 'default');
286
+ }
260
287
  }
261
288
 
262
289
  // ── Legend (categorical via renderLegendD3 + ramp/size/weight blocks; AR1) ──
@@ -269,21 +296,92 @@ export function renderMap(
269
296
  .append('g')
270
297
  .attr('class', 'dgmo-map-legend')
271
298
  .attr('transform', `translate(0, ${legendY})`);
272
- const groups = layout.legend.tagGroups.filter((g) => g.entries.length > 0);
299
+ // The score ramp is a selectable colouring group alongside the tag groups
300
+ // (the user flips between them); its capsule renders the gradient inline.
301
+ // Reserved name "Score" when no metric label is set — must match SCORE_NAME
302
+ // in layout.ts so the resolved activeGroup selects it.
303
+ const ramp = layout.legend.ramp;
304
+ const scoreGroup = ramp
305
+ ? {
306
+ name: ramp.metric?.trim() || 'Score',
307
+ entries: [],
308
+ gradient: {
309
+ min: ramp.min,
310
+ max: ramp.max,
311
+ hue: ramp.hue,
312
+ base: ramp.base,
313
+ },
314
+ }
315
+ : null;
316
+ const tagGroups = layout.legend.tagGroups
317
+ .filter((g) => g.entries.length > 0)
318
+ .map((g) => ({ name: g.name, entries: [...g.entries] }));
319
+ const groups = [...(scoreGroup ? [scoreGroup] : []), ...tagGroups];
273
320
  if (groups.length > 0) {
274
321
  const config: LegendConfig = {
275
- groups: groups.map((g) => ({ name: g.name, entries: [...g.entries] })),
322
+ groups,
276
323
  position: { placement: 'top-center', titleRelation: 'below-title' },
277
324
  mode: exportDims ? 'export' : 'preview',
278
325
  showEmptyGroups: false,
326
+ // Keep inactive siblings visible as pills so the user can click to flip
327
+ // the active colouring dimension (preview only — export shows just the
328
+ // active group).
329
+ showInactivePills: true,
279
330
  };
280
331
  const state: LegendState = { activeGroup: layout.legend.activeGroup };
281
332
  renderLegendD3(legendG, config, state, palette, isDark, undefined, width);
282
333
  }
283
- // Custom keys (ramp / size / weight), stacked above the pin list so they
284
- // never overlap it (#3).
285
- const pinGap = layout.pinList.length ? layout.pinList.length * 14 + 14 : 0;
286
- emitExtraLegend(svg, layout, palette, height, pinGap);
334
+ }
335
+
336
+ // ── Title / subtitle / caption (foreground drawn last so they sit above the
337
+ // basemap, POIs, and labels; layout reserves top padding so POIs clear them) ──
338
+ // Soft bg halo so the banner stays legible over busy land/water (the muted
339
+ // subtitle/caption otherwise wash out on mid-toned palettes like gruvbox).
340
+ if (layout.title) {
341
+ svg
342
+ .append('text')
343
+ .attr('x', width / 2)
344
+ .attr('y', TITLE_Y)
345
+ .attr('text-anchor', 'middle')
346
+ .attr('font-size', TITLE_FONT_SIZE)
347
+ .attr('font-weight', TITLE_FONT_WEIGHT)
348
+ .attr('fill', palette.text)
349
+ .attr('paint-order', 'stroke fill')
350
+ .attr('stroke', palette.bg)
351
+ .attr('stroke-width', 4)
352
+ .attr('stroke-linejoin', 'round')
353
+ .attr('stroke-opacity', 0.7)
354
+ .text(layout.title);
355
+ }
356
+ if (layout.subtitle) {
357
+ svg
358
+ .append('text')
359
+ .attr('x', width / 2)
360
+ .attr('y', TITLE_Y + TITLE_FONT_SIZE)
361
+ .attr('text-anchor', 'middle')
362
+ .attr('font-size', LABEL_FONT + 1)
363
+ .attr('fill', palette.textMuted)
364
+ .attr('paint-order', 'stroke fill')
365
+ .attr('stroke', palette.bg)
366
+ .attr('stroke-width', 3)
367
+ .attr('stroke-linejoin', 'round')
368
+ .attr('stroke-opacity', 0.7)
369
+ .text(layout.subtitle);
370
+ }
371
+ if (layout.caption) {
372
+ svg
373
+ .append('text')
374
+ .attr('x', width / 2)
375
+ .attr('y', height - 8)
376
+ .attr('text-anchor', 'middle')
377
+ .attr('font-size', LABEL_FONT)
378
+ .attr('fill', palette.textMuted)
379
+ .attr('paint-order', 'stroke fill')
380
+ .attr('stroke', palette.bg)
381
+ .attr('stroke-width', 3)
382
+ .attr('stroke-linejoin', 'round')
383
+ .attr('stroke-opacity', 0.7)
384
+ .text(layout.caption);
287
385
  }
288
386
  }
289
387
 
@@ -300,7 +398,6 @@ export function renderMapForExport(
300
398
  }
301
399
 
302
400
  type Sel = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
303
- type SvgSel = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
304
401
 
305
402
  function emitText(
306
403
  g: Sel,
@@ -312,7 +409,7 @@ function emitText(
312
409
  halo: string,
313
410
  withHalo: boolean,
314
411
  fontSize: number
315
- ): void {
412
+ ): d3Selection.Selection<SVGTextElement, unknown, null, undefined> {
316
413
  const t = g
317
414
  .append('text')
318
415
  .attr('x', x)
@@ -328,121 +425,5 @@ function emitText(
328
425
  .attr('stroke-linejoin', 'round')
329
426
  .attr('stroke-opacity', 0.7);
330
427
  }
331
- }
332
-
333
- /** Ramp gradient bar + graduated size/weight keys (not legend swatch groups). */
334
- function emitExtraLegend(
335
- svg: SvgSel,
336
- layout: MapLayout,
337
- palette: PaletteColors,
338
- height: number,
339
- bottomGap: number
340
- ): void {
341
- const { legend } = layout;
342
- if (!legend) return;
343
- // Nothing to draw if there are only categorical swatches (#4).
344
- if (!legend.ramp && !legend.size && !legend.weight) return;
345
- const blocks: Array<() => void> = [];
346
- const g = svg
347
- .append('g')
348
- .attr('class', 'dgmo-map-legend-keys')
349
- .attr('transform', `translate(12, ${height - 56 - bottomGap})`);
350
- let xCursor = 0;
351
-
352
- if (legend.ramp) {
353
- const ramp = legend.ramp;
354
- blocks.push(() => {
355
- const block = g.append('g').attr('transform', `translate(${xCursor},0)`);
356
- const gradId = 'dgmo-map-ramp';
357
- const grad = block
358
- .append('defs')
359
- .append('linearGradient')
360
- .attr('id', gradId)
361
- .attr('x1', '0%')
362
- .attr('x2', '100%');
363
- grad
364
- .append('stop')
365
- .attr('offset', '0%')
366
- .attr('stop-color', mix(ramp.hue, palette.bg, 15));
367
- grad.append('stop').attr('offset', '100%').attr('stop-color', ramp.hue);
368
- block
369
- .append('rect')
370
- .attr('width', 80)
371
- .attr('height', 8)
372
- .attr('fill', `url(#${gradId})`);
373
- block
374
- .append('text')
375
- .attr('x', 0)
376
- .attr('y', 22)
377
- .attr('font-size', 9)
378
- .attr('fill', palette.textMuted)
379
- .text(String(ramp.min));
380
- block
381
- .append('text')
382
- .attr('x', 80)
383
- .attr('y', 22)
384
- .attr('text-anchor', 'end')
385
- .attr('font-size', 9)
386
- .attr('fill', palette.textMuted)
387
- .text(String(ramp.max));
388
- if (ramp.metric) {
389
- block
390
- .append('text')
391
- .attr('x', 0)
392
- .attr('y', -4)
393
- .attr('font-size', 9)
394
- .attr('fill', palette.textMuted)
395
- .text(ramp.metric);
396
- }
397
- xCursor += 110;
398
- });
399
- }
400
- if (legend.size) {
401
- const sz = legend.size;
402
- blocks.push(() => {
403
- const block = g.append('g').attr('transform', `translate(${xCursor},0)`);
404
- [3, 6, 10].forEach((r, i) => {
405
- block
406
- .append('circle')
407
- .attr('cx', i * 26 + r)
408
- .attr('cy', 8)
409
- .attr('r', r)
410
- .attr('fill', 'none')
411
- .attr('stroke', palette.textMuted);
412
- });
413
- block
414
- .append('text')
415
- .attr('x', 0)
416
- .attr('y', -4)
417
- .attr('font-size', 9)
418
- .attr('fill', palette.textMuted)
419
- .text(sz.metric ?? 'size');
420
- xCursor += 110;
421
- });
422
- }
423
- if (legend.weight) {
424
- const wt = legend.weight;
425
- blocks.push(() => {
426
- const block = g.append('g').attr('transform', `translate(${xCursor},0)`);
427
- [1, 3, 6].forEach((w, i) => {
428
- block
429
- .append('line')
430
- .attr('x1', i * 26)
431
- .attr('y1', 8)
432
- .attr('x2', i * 26 + 20)
433
- .attr('y2', 8)
434
- .attr('stroke', palette.textMuted)
435
- .attr('stroke-width', w);
436
- });
437
- block
438
- .append('text')
439
- .attr('x', 0)
440
- .attr('y', -4)
441
- .attr('font-size', 9)
442
- .attr('fill', palette.textMuted)
443
- .text(wt.metric ?? 'weight');
444
- xCursor += 110;
445
- });
446
- }
447
- for (const draw of blocks) draw();
428
+ return t;
448
429
  }