@diagrammo/dgmo 0.20.3 → 0.21.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.
@@ -1,10 +1,10 @@
1
1
  map US Sales by State
2
2
  region us-states
3
- metric Sales ($M)
3
+ region-metric Sales ($M)
4
4
 
5
- California score: 92
6
- Texas score: 78
7
- New York score: 64
8
- Florida score: 51
9
- Washington score: 40
10
- Colorado score: 30
5
+ California value: 92
6
+ Texas value: 78
7
+ New York value: 64
8
+ Florida value: 51
9
+ Washington value: 40
10
+ Colorado value: 30
@@ -1,9 +1,9 @@
1
1
  map Data Center Footprint
2
- size-metric Requests/s
2
+ poi-metric Requests/s
3
3
 
4
- poi Denver as hub size: 90
5
- poi Dallas size: 320
6
- poi Seattle size: 180
4
+ poi Denver as hub value: 90
5
+ poi Dallas value: 320
6
+ poi Seattle value: 180
7
7
 
8
8
  hub -> Dallas
9
9
  hub -> Seattle
@@ -1,15 +1,15 @@
1
1
  map Region Scope Disambiguation
2
2
  region us-states
3
- metric Sales ($M)
3
+ region-metric Sales ($M)
4
4
  region-labels abbrev
5
5
  subtitle Pin a country/state name clash by ISO code (US-GA) or name + scope (Georgia US)
6
6
 
7
- California score: 92
8
- Texas score: 78
7
+ California value: 92
8
+ Texas value: 78
9
9
  // "Georgia" clashes with the country GE — pin the state. Both lines are
10
10
  // equivalent; use whichever reads best:
11
- // terse ISO code: US-GA score: 64
12
- // name + scope: Georgia US score: 64
13
- US-GA score: 64
14
- Florida score: 51
15
- Washington score: 40
11
+ // terse ISO code: US-GA value: 64
12
+ // name + scope: Georgia US value: 64
13
+ US-GA value: 64
14
+ Florida value: 51
15
+ Washington value: 40
@@ -1,9 +1,8 @@
1
1
  map Caribbean Cruise
2
2
  projection mercator
3
3
 
4
- route style: arc
5
- Miami label: Embark
6
- Havana
7
- Kingston
8
- Santo Domingo
9
- Miami
4
+ route Miami style: arc
5
+ -weigh anchor-> Havana
6
+ -> Kingston
7
+ -> Santo Domingo
8
+ -> Miami
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.20.3",
3
+ "version": "0.21.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/completion.ts CHANGED
@@ -509,7 +509,7 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
509
509
  [
510
510
  'map',
511
511
  // Geographic map directives (§24B.2/.7). `poi`/`route` are content
512
- // keywords, not directives; metadata keys (score/size/label) live in the
512
+ // keywords, not directives; metadata keys (value/label/style) live in the
513
513
  // reserved-key registry.
514
514
  withGlobals({
515
515
  region: {
@@ -521,9 +521,14 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
521
521
  description: 'Override the auto projection',
522
522
  values: ['equirectangular', 'natural-earth', 'albers-usa', 'mercator'],
523
523
  },
524
- metric: { description: 'Label for the region score ramp' },
525
- 'size-metric': { description: 'Label for the POI size channel' },
526
- scale: { description: 'Override score ramp anchors: scale <min> <max>' },
524
+ 'region-metric': { description: 'Label for the region value ramp' },
525
+ 'poi-metric': {
526
+ description: 'Label for the POI value (marker size) channel',
527
+ },
528
+ 'flow-metric': {
529
+ description: 'Label for the edge/leg value (thickness) channel',
530
+ },
531
+ scale: { description: 'Override value ramp anchors: scale <min> <max>' },
527
532
  'region-labels': {
528
533
  description: 'Subdivision name labels',
529
534
  values: ['full', 'abbrev', 'off'],
@@ -153,13 +153,16 @@ export const DIRECTIVE_KEYWORDS = new Set([
153
153
  // Map (§24B) directives
154
154
  'region',
155
155
  'projection',
156
- 'metric',
157
- 'size-metric',
156
+ 'region-metric',
157
+ 'poi-metric',
158
+ 'flow-metric',
158
159
  'region-labels',
159
160
  'poi-labels',
160
161
  'default-country',
161
162
  'default-state',
162
163
  'no-legend',
164
+ 'muted',
165
+ 'natural',
163
166
  'subtitle',
164
167
  'caption',
165
168
  'poi',
package/src/map/layout.ts CHANGED
@@ -73,6 +73,21 @@ const RIVER_WIDTH = 1.3; // px stroke width for river lines
73
73
  // a clear gray rather than sinking into the dark background.
74
74
  const FOREIGN_TINT_LIGHT = 30;
75
75
  const FOREIGN_TINT_DARK = 62;
76
+ // MUTED basemap — used when a colouring dimension (score ramp or a tag group) is
77
+ // active. The data hues may themselves be blue or green (e.g. `Core blue`,
78
+ // `Growth teal`), which collide with the decorative blue-water / green-land
79
+ // dress: a blue region vanishes into the ocean, a green one into the land. So
80
+ // when regions carry the data signal the basemap RECEDES to neutral grays —
81
+ // water and unscored/neighbour land become low-saturation gray, leaving the data
82
+ // fills as the only saturated thing on the map (the cartographic norm for a
83
+ // choropleth). Plain reference maps with no data keep the blue/green dress.
84
+ // Light land is left at the page bg (cleanest white ground for the data hues);
85
+ // dark land lifts off the near-black surface so dark-mixed tints stay legible.
86
+ const MUTED_WATER_LIGHT = 14; // % gray of bg — pale sea
87
+ const MUTED_WATER_DARK = 10;
88
+ const MUTED_FOREIGN_LIGHT = 28; // neighbour land — grayer than the sea
89
+ const MUTED_FOREIGN_DARK = 16;
90
+ const MUTED_LAND_DARK = 24; // subject land on dark (light land = palette.bg)
76
91
  const COLO_R = 9; // spiderfy radius
77
92
  const GOLDEN_ANGLE = 2.399963229728653; // rad (137.5deg) -- even spiral, no random
78
93
  const FAN_STEP = 16; // px perpendicular offset between parallel edges
@@ -86,9 +101,9 @@ export interface MapLayoutRegion {
86
101
  readonly label?: string;
87
102
  readonly lineNumber: number;
88
103
  readonly layer: 'base' | 'country' | 'us-state';
89
- /** The region's score (if any) — emitted as `data-score` so the app can
104
+ /** The region's value (if any) — emitted as `data-value` so the app can
90
105
  * highlight by gradient-scrub proximity. */
91
- readonly score?: number;
106
+ readonly value?: number;
92
107
  /** The region's tag values keyed by group (lowercased) — emitted as
93
108
  * `data-tag-<group>` so the app can highlight on legend-entry hover. */
94
109
  readonly tags?: Readonly<Record<string, string>>;
@@ -119,6 +134,9 @@ export interface MapLayoutPoi {
119
134
  readonly implicit: boolean;
120
135
  readonly isOrigin: boolean; // route origin -> distinct marker
121
136
  readonly routeNumber?: number; // route stop badge
137
+ /** Tag values keyed by lowercased group name — emitted as `data-tag-<group>`
138
+ * so the app can spotlight markers on legend-entry hover (mirrors regions). */
139
+ readonly tags?: Readonly<Record<string, string>>;
122
140
  }
123
141
 
124
142
  /** A drawn connector -- an edge or a route leg (same geometry contract). */
@@ -276,19 +294,37 @@ const US_NON_CONUS = new Set([
276
294
 
277
295
  /** The map's water / backdrop colour for a palette — the single source of truth
278
296
  * shared by the renderer's `<rect>` fill and any host wrapper that needs to
279
- * match it (so letterbox gaps around the SVG don't show a stray band). */
280
- export function mapBackgroundColor(palette: PaletteColors): string {
297
+ * match it (so letterbox gaps around the SVG don't show a stray band). When
298
+ * `dataActive` (a score ramp or tag group is colouring regions) the sea recedes
299
+ * to a pale neutral so blue/green data hues don't blend into it. */
300
+ export function mapBackgroundColor(
301
+ palette: PaletteColors,
302
+ isDark = false,
303
+ dataActive = false
304
+ ): string {
305
+ if (dataActive)
306
+ return mix(
307
+ palette.colors.gray,
308
+ palette.bg,
309
+ isDark ? MUTED_WATER_DARK : MUTED_WATER_LIGHT
310
+ );
281
311
  return mix(palette.colors.blue, palette.bg, WATER_TINT);
282
312
  }
283
313
 
284
- /** The map's neutral (unscored/untagged) LAND colour — the green base every
285
- * region blends from. Exported so a host can DIM a region to plain land
286
- * (rather than lowering opacity, which would let the blue water show through
287
- * and make the shape read as ocean). Matches the layout's `neutralFill`. */
314
+ /** The map's neutral (unscored/untagged) LAND colour — the base every region
315
+ * blends from. Exported so a host can DIM a region to plain land (rather than
316
+ * lowering opacity, which would let the water show through and make the shape
317
+ * read as ocean). Matches the layout's `neutralFill`. Green reference dress by
318
+ * default; neutral (page bg on light, lifted gray on dark) when `dataActive`. */
288
319
  export function mapNeutralLandColor(
289
320
  palette: PaletteColors,
290
- isDark: boolean
321
+ isDark: boolean,
322
+ dataActive = false
291
323
  ): string {
324
+ if (dataActive)
325
+ return isDark
326
+ ? mix(palette.colors.gray, palette.bg, MUTED_LAND_DARK)
327
+ : palette.bg;
292
328
  return mix(
293
329
  palette.colors.green,
294
330
  palette.bg,
@@ -344,21 +380,9 @@ export function layoutMap(
344
380
  }
345
381
  const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
346
382
 
347
- // Land is a muted green; the ocean/backdrop is blue. Scored/tagged regions
348
- // paint over the land base, and the score ramp blends FROM the land colour so
349
- // low scores stay land-toned rather than fading out. In a US view the world
350
- // layer is just neighbour context (Mexico/Canada at the frame edge) — fill it
351
- // gray so the green US reads as the subject; world maps (no us-states layer)
352
- // keep green land for every country.
353
- const landTint = isDark ? LAND_TINT_DARK : LAND_TINT_LIGHT;
354
- const neutralFill = mix(palette.colors.green, palette.bg, landTint);
355
- const water = mapBackgroundColor(palette);
356
383
  const usContext = usLayer !== null;
357
- const foreignFill = mix(
358
- palette.colors.gray,
359
- palette.bg,
360
- isDark ? FOREIGN_TINT_DARK : FOREIGN_TINT_LIGHT
361
- );
384
+ // Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
385
+ // colouring dimension is active — defined below, once `activeGroup` is known.
362
386
  // Region borders: a clearly dark outline in BOTH themes. palette.text flips
363
387
  // (dark on light, light on dark), so mix toward whichever of text/bg is the
364
388
  // dark one — never a light hairline over the land fills.
@@ -367,29 +391,30 @@ export function layoutMap(
367
391
  : mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
368
392
 
369
393
  // -- Region fill model (choropleth + categorical; AR4/AR6) --
370
- const scores = resolved.regions
371
- .filter((r) => r.score !== undefined)
372
- .map((r) => r.score!);
394
+ const values = resolved.regions
395
+ .filter((r) => r.value !== undefined)
396
+ .map((r) => r.value!);
373
397
  const scaleOverride = resolved.directives.scale;
374
- const rampMin = scaleOverride ? scaleOverride.min : Math.min(...scores);
375
- const rampMax = scaleOverride ? scaleOverride.max : Math.max(...scores);
376
- // Score ramp is red so scored regions stand out against the blue water
398
+ const rampMin = scaleOverride ? scaleOverride.min : Math.min(...values);
399
+ const rampMax = scaleOverride ? scaleOverride.max : Math.max(...values);
400
+ // Value ramp is red so valued regions stand out against the blue water
377
401
  // (palette.primary is a blue in most palettes and would blend in).
378
402
  const rampHue = palette.colors.red;
379
- const hasRamp = scores.length > 0;
403
+ const hasRamp = values.length > 0;
380
404
 
381
- // Colouring dimension (AR4, bivariate): the score ramp and each tag group are
382
- // mutually-exclusive selectable groups. `SCORE_NAME` is the ramp's group name
383
- // (the metric label, or "Score"); the reserved token `score` also selects it.
384
- // Exactly one dimension is active and drives every region's fill.
385
- const SCORE_NAME = hasRamp
386
- ? resolved.directives.metric?.trim() || 'Score'
405
+ // Colouring dimension (AR4, bivariate): the value ramp and each tag group are
406
+ // mutually-exclusive selectable groups. `VALUE_NAME` is the ramp's group name
407
+ // (the region-metric label, or "Value"). Exactly one dimension is active and
408
+ // drives every region's fill. The value ramp is the default-active dimension
409
+ // whenever any region has a value (the old `active-tag score` token is gone —
410
+ // there is nothing to force; selecting a tag group is what `active-tag` does).
411
+ const VALUE_NAME = hasRamp
412
+ ? resolved.directives.regionMetric?.trim() || 'Value'
387
413
  : null;
388
414
  const matchColorGroup = (v: string): string | null => {
389
415
  const lv = v.trim().toLowerCase();
390
416
  if (lv === 'none') return null;
391
- if (SCORE_NAME && (lv === 'score' || lv === SCORE_NAME.toLowerCase()))
392
- return SCORE_NAME;
417
+ if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
393
418
  const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
394
419
  return tg ? tg.name : v; // unknown name passes through → renders neutral
395
420
  };
@@ -400,13 +425,41 @@ export function layoutMap(
400
425
  } else if (resolved.directives.activeTag !== undefined) {
401
426
  activeGroup = matchColorGroup(resolved.directives.activeTag);
402
427
  } else {
403
- // Default: colour by score when scores exist (preserves the historical
404
- // "score wins" default), else the first declared tag group.
428
+ // Default: colour by the value ramp when values exist, else the first
429
+ // declared tag group.
405
430
  activeGroup =
406
- SCORE_NAME ??
431
+ VALUE_NAME ??
407
432
  (resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
408
433
  }
409
- const activeIsScore = SCORE_NAME !== null && activeGroup === SCORE_NAME;
434
+ const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
435
+
436
+ // Basemap dress. When a colouring dimension is active the regions carry the
437
+ // signal, so the sea/land recede to neutral grays (the data hues — which may be
438
+ // blue or green — would otherwise blend into a blue ocean / green land). A
439
+ // plain reference map (no score, no tag → activeGroup null) keeps the blue
440
+ // water + green land. The bare `muted` / `natural` flags force either dress
441
+ // regardless (so two maps in a deck can match); absent → this auto rule. In a
442
+ // US view the surrounding world layer is always recessive gray so the US reads
443
+ // as the subject.
444
+ const mutedBasemap =
445
+ resolved.directives.basemapStyle === 'muted'
446
+ ? true
447
+ : resolved.directives.basemapStyle === 'natural'
448
+ ? false
449
+ : activeGroup !== null;
450
+ const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
451
+ const water = mapBackgroundColor(palette, isDark, mutedBasemap);
452
+ const foreignFill = mix(
453
+ palette.colors.gray,
454
+ palette.bg,
455
+ mutedBasemap
456
+ ? isDark
457
+ ? MUTED_FOREIGN_DARK
458
+ : MUTED_FOREIGN_LIGHT
459
+ : isDark
460
+ ? FOREIGN_TINT_DARK
461
+ : FOREIGN_TINT_LIGHT
462
+ );
410
463
 
411
464
  // Score ramp base: a NEUTRAL tint of the page, NOT the (green) land colour —
412
465
  // blending red toward green produced muddy brown mid-tones that blurred into
@@ -415,7 +468,7 @@ export function layoutMap(
415
468
  // off the near-black surface so the lowest scores read as a clear muted red
416
469
  // rather than sinking to maroon-black.
417
470
  const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
418
- const fillForScore = (s: number): string => {
471
+ const fillForValue = (s: number): string => {
419
472
  const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
420
473
  const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
421
474
  return mix(rampHue, rampBase, pct);
@@ -451,14 +504,14 @@ export function layoutMap(
451
504
  };
452
505
 
453
506
  /** A region's fill under the ACTIVE colouring dimension (AR4, bivariate):
454
- * score-active → ramp for scored regions, neutral otherwise; a tag group
455
- * active → that group's tag colour, neutral otherwise (score ignored). */
507
+ * value-active → ramp for valued regions, neutral otherwise; a tag group
508
+ * active → that group's tag colour, neutral otherwise (value ignored). */
456
509
  const regionFill = (r: {
457
- score?: number;
510
+ value?: number;
458
511
  tags: Readonly<Record<string, string>>;
459
512
  }): string => {
460
513
  if (activeIsScore) {
461
- return r.score !== undefined ? fillForScore(r.score) : neutralFill;
514
+ return r.value !== undefined ? fillForValue(r.value) : neutralFill;
462
515
  }
463
516
  return tagFill(r.tags, activeGroup) ?? neutralFill;
464
517
  };
@@ -778,7 +831,7 @@ export function layoutMap(
778
831
  stroke: regionStroke,
779
832
  lineNumber,
780
833
  layer: 'us-state',
781
- ...(r?.score !== undefined && { score: r.score }),
834
+ ...(r?.value !== undefined && { value: r.value }),
782
835
  ...(r && Object.keys(r.tags).length > 0 && { tags: r.tags }),
783
836
  });
784
837
  const ctr = geoPath(proj).centroid(f as never);
@@ -1012,7 +1065,7 @@ export function layoutMap(
1012
1065
  lineNumber,
1013
1066
  layer,
1014
1067
  ...(label !== undefined && { label }),
1015
- ...(isThisLayer && r.score !== undefined && { score: r.score }),
1068
+ ...(isThisLayer && r.value !== undefined && { value: r.value }),
1016
1069
  ...(isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }),
1017
1070
  });
1018
1071
  }
@@ -1067,14 +1120,14 @@ export function layoutMap(
1067
1120
  }
1068
1121
  }
1069
1122
 
1070
- // -- POIs: project, size-scale, co-located spiderfy --
1123
+ // -- POIs: project, value→size-scale, co-located spiderfy --
1071
1124
  const sizeVals = resolved.pois
1072
- .map((p) => Number(p.meta['size']))
1125
+ .map((p) => Number(p.meta['value']))
1073
1126
  .filter((n) => Number.isFinite(n) && n > 0);
1074
1127
  const sizeMin = sizeVals.length ? Math.min(...sizeVals) : 0;
1075
1128
  const sizeMax = sizeVals.length ? Math.max(...sizeVals) : 0;
1076
1129
  const radiusFor = (p: ResolvedPoi): number => {
1077
- const v = Number(p.meta['size']);
1130
+ const v = Number(p.meta['value']);
1078
1131
  if (!Number.isFinite(v) || v <= 0 || sizeMax <= 0) return R_DEFAULT;
1079
1132
  // sqrt so AREA encodes the value
1080
1133
  const t =
@@ -1160,6 +1213,7 @@ export function layoutMap(
1160
1213
  implicit: !!e.p.implicit,
1161
1214
  isOrigin: originIds.has(e.p.id),
1162
1215
  ...(num !== undefined && { routeNumber: num }),
1216
+ ...(Object.keys(e.p.tags).length > 0 && { tags: e.p.tags }),
1163
1217
  });
1164
1218
  });
1165
1219
  }
@@ -1208,31 +1262,50 @@ export function layoutMap(
1208
1262
  return `M${ax},${ay}Q${px},${py} ${bx},${by}`;
1209
1263
  };
1210
1264
 
1211
- // Routes: legs between consecutive stops (loop closing leg included).
1265
+ // Routes: each leg is an edge (fromId toId) carrying its own label,
1266
+ // value→thickness, and arc shape. Loop-closing legs are explicit in `rt.legs`;
1267
+ // the origin is never double-marked because `stopIds` is unique.
1268
+ const routeLegVals = resolved.routes
1269
+ .flatMap((rt) => rt.legs)
1270
+ .map((l) => Number(l.value))
1271
+ .filter((n) => Number.isFinite(n) && n > 0);
1272
+ const rlMin = routeLegVals.length ? Math.min(...routeLegVals) : 0;
1273
+ const rlMax = routeLegVals.length ? Math.max(...routeLegVals) : 0;
1274
+ const routeWidthFor = (v: number): number => {
1275
+ if (!Number.isFinite(v) || v <= 0 || rlMax <= 0) return W_MIN;
1276
+ const t = rlMax > rlMin ? (v - rlMin) / (rlMax - rlMin) : 1;
1277
+ return W_MIN + t * (W_MAX - W_MIN);
1278
+ };
1212
1279
  for (const rt of resolved.routes) {
1213
- const curved = rt.meta['style'] === 'arc';
1214
- for (let i = 1; i < rt.stopIds.length; i++) {
1215
- const a = poiScreen.get(rt.stopIds[i - 1]!);
1216
- const b = poiScreen.get(rt.stopIds[i]!);
1280
+ for (const leg of rt.legs) {
1281
+ const a = poiScreen.get(leg.fromId);
1282
+ const b = poiScreen.get(leg.toId);
1217
1283
  if (!a || !b) continue;
1284
+ const mx = (a.cx + b.cx) / 2;
1285
+ const my = (a.cy + b.cy) / 2;
1218
1286
  legs.push({
1219
- d: legPath(a, b, curved, 0),
1220
- width: W_MIN,
1287
+ d: legPath(a, b, leg.style === 'arc', 0),
1288
+ width: routeWidthFor(Number(leg.value)),
1221
1289
  color: mix(palette.text, palette.bg, 72),
1222
1290
  arrow: true,
1223
- lineNumber: rt.lineNumber,
1291
+ lineNumber: leg.lineNumber,
1292
+ ...(leg.label !== undefined && {
1293
+ label: leg.label,
1294
+ labelX: mx,
1295
+ labelY: my - 4,
1296
+ }),
1224
1297
  });
1225
1298
  }
1226
1299
  }
1227
1300
 
1228
1301
  // Edges: group by unordered endpoint pair for deterministic fan-out (AR9).
1229
1302
  const weightVals = resolved.edges
1230
- .map((e) => Number(e.meta['weight']))
1303
+ .map((e) => Number(e.meta['value']))
1231
1304
  .filter((n) => Number.isFinite(n) && n > 0);
1232
1305
  const wMin = weightVals.length ? Math.min(...weightVals) : 0;
1233
1306
  const wMax = weightVals.length ? Math.max(...weightVals) : 0;
1234
1307
  const widthFor = (e: ResolvedEdge): number => {
1235
- const v = Number(e.meta['weight']);
1308
+ const v = Number(e.meta['value']);
1236
1309
  if (!Number.isFinite(v) || v <= 0 || wMax <= 0) return W_MIN;
1237
1310
  const t = wMax > wMin ? (v - wMin) / (wMax - wMin) : 1;
1238
1311
  return W_MIN + t * (W_MAX - W_MIN);
@@ -1566,17 +1639,18 @@ export function layoutMap(
1566
1639
  name: g.name,
1567
1640
  entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
1568
1641
  }));
1569
- // Only the colouring dimensions (score ramp + tag groups) get a legend.
1570
- // POI size and edge weight are self-evident from the marker/line scale and
1571
- // intentionally carry no key.
1642
+ // Only the colouring dimensions (value ramp + tag groups) get a legend.
1643
+ // POI size and edge thickness are self-evident from the marker/line scale and
1644
+ // intentionally carry no key (the poi-metric/flow-metric labels are captured
1645
+ // for future use but not rendered as legend keys in v1).
1572
1646
  if (tagGroups.length > 0 || hasRamp) {
1573
1647
  legend = {
1574
1648
  tagGroups,
1575
1649
  activeGroup,
1576
1650
  ...(hasRamp && {
1577
1651
  ramp: {
1578
- ...(resolved.directives.metric !== undefined && {
1579
- metric: resolved.directives.metric,
1652
+ ...(resolved.directives.regionMetric !== undefined && {
1653
+ metric: resolved.directives.regionMetric,
1580
1654
  }),
1581
1655
  min: rampMin,
1582
1656
  max: rampMax,