@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
@@ -11,10 +11,27 @@ export interface MapData {
11
11
  worldCoarse: BoundaryTopology;
12
12
  worldDetail: BoundaryTopology;
13
13
  usStates: BoundaryTopology;
14
+ /** Major lakes (Natural Earth 110m) drawn as water over land — e.g. the Great
15
+ * Lakes. Optional so hand-built test fixtures need not supply it. */
16
+ lakes?: BoundaryTopology;
17
+ /** Major river centerlines (Natural Earth 110m) drawn as thin water lines over
18
+ * land — e.g. the Amazon, Nile, Mississippi. Optional, like `lakes`. */
19
+ rivers?: BoundaryTopology;
20
+ /** North-America-clipped 10m country land, used as crisp neighbour context
21
+ * under the albers-usa US view so Canada/Mexico match the 10m states instead
22
+ * of the coarser world tiers. Optional, like `lakes`. */
23
+ naLand?: BoundaryTopology;
24
+ /** North-America-clipped 10m major lakes (Great Lakes etc.), used in place of
25
+ * the coarse `lakes` under the albers-usa US view. Optional. */
26
+ naLakes?: BoundaryTopology;
14
27
  gazetteer: Gazetteer;
15
28
  }
16
29
 
17
- export type ProjectionFamily = 'natural-earth' | 'albers-usa' | 'mercator';
30
+ export type ProjectionFamily =
31
+ | 'equirectangular'
32
+ | 'natural-earth'
33
+ | 'albers-usa'
34
+ | 'mercator';
18
35
 
19
36
  /** Which geometry layers the renderer draws. */
20
37
  export interface Basemaps {
@@ -31,6 +31,11 @@ type LookupResult =
31
31
  const WORLD_SPAN = 90;
32
32
  const MERCATOR_MAX_SPAN = 25;
33
33
  const PAD_FRACTION = 0.05;
34
+ // Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
35
+ // Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
36
+ // populated continents fill the frame rather than waste it on ice.
37
+ const WORLD_LAT_SOUTH = -58;
38
+ const WORLD_LAT_NORTH = 78;
34
39
 
35
40
  // Long-form (or common-alias) country name → the folded Natural-Earth display
36
41
  // name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
@@ -122,6 +127,9 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
122
127
  const f = fold(r.name);
123
128
  return usStateIndex.has(f) && !countryIndex.has(f);
124
129
  }) ||
130
+ parsed.regions.some(
131
+ (r) => r.scope === 'US' || r.scope?.startsWith('US-')
132
+ ) ||
125
133
  parsed.pois.some(
126
134
  (p) => p.pos.kind === 'name' && p.pos.scope?.startsWith('US-')
127
135
  );
@@ -144,15 +152,42 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
144
152
  name: string;
145
153
  layer: 'country' | 'us-state';
146
154
  } | null = null;
147
- if (inCountry && inState) {
155
+ // Explicit ISO scope (§24B.8): force the country-vs-state pick and skip the
156
+ // ambiguity warning. `US`/`US-XX` → state; any other 2-letter code → country.
157
+ const scope = r.scope;
158
+ if (scope) {
159
+ const wantsState = scope === 'US' || scope.startsWith('US-');
160
+ if (wantsState && inState) {
161
+ if (scope.startsWith('US-') && inState.id !== scope) {
162
+ err(
163
+ r.lineNumber,
164
+ `No subdivision "${r.name}" in scope ${scope} (it is ${inState.id}).`,
165
+ 'E_MAP_SCOPE_MISS'
166
+ );
167
+ continue;
168
+ }
169
+ chosen = { ...inState, layer: 'us-state' };
170
+ } else if (!wantsState && inCountry) {
171
+ chosen = { ...inCountry, layer: 'country' };
172
+ } else {
173
+ err(
174
+ r.lineNumber,
175
+ `No region "${r.name}" found in scope ${scope}.`,
176
+ 'E_MAP_SCOPE_MISS'
177
+ );
178
+ continue;
179
+ }
180
+ } else if (inCountry && inState) {
148
181
  if (usScoped) {
149
182
  chosen = { ...inState, layer: 'us-state' };
150
183
  } else {
151
184
  chosen = { ...inCountry, layer: 'country' };
152
185
  }
186
+ // Teach the disambiguation syntax so the author can pin it explicitly.
187
+ // Suggest the non-redundant forms: a bare ISO code, or name + scope.
153
188
  warn(
154
189
  r.lineNumber,
155
- `"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}).`,
190
+ `"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
156
191
  'W_MAP_REGION_AMBIGUOUS'
157
192
  );
158
193
  } else if (inState) {
@@ -370,9 +405,23 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
370
405
  }
371
406
 
372
407
  // ── Edges + routes: bind endpoints, create implicit POIs (R7/R8) ──
408
+ // A POI declared with an alias (`poi Chicago as central`) registers under its
409
+ // id (`central`), so an endpoint that references it by NAME (`… -> Chicago`)
410
+ // misses the id registry. Map declared names → id so such a reference binds to
411
+ // the existing POI instead of spawning a duplicate implicit one on the same
412
+ // spot (only aliased POIs need this — a name-only POI's id already IS its
413
+ // folded name).
414
+ const declaredByName = new Map<string, string>();
415
+ for (const p of pois) {
416
+ const fn = p.name ? fold(p.name) : undefined;
417
+ if (fn && fn !== p.id && !declaredByName.has(fn))
418
+ declaredByName.set(fn, p.id);
419
+ }
373
420
  const resolveEndpoint = (ref: string, line: number): string | null => {
374
421
  const f = fold(ref);
375
422
  if (registry.has(f)) return f;
423
+ const aliased = declaredByName.get(f);
424
+ if (aliased) return aliased;
376
425
  const got = lookupName(ref, undefined, line, inferredCountry, true);
377
426
  if (got.kind !== 'ok') return null;
378
427
  noteCountry(got.iso);
@@ -462,9 +511,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
462
511
  [-180, -85],
463
512
  [180, 85],
464
513
  ];
465
- const extent: GeoExtent = unioned
466
- ? pad(unioned, PAD_FRACTION)
467
- : DEFAULT_EXTENT; // empty → default
514
+ let extent: GeoExtent = unioned ? pad(unioned, PAD_FRACTION) : DEFAULT_EXTENT; // empty → default
468
515
 
469
516
  const lonSpan = extent[1][0] - extent[0][0];
470
517
  const latSpan = extent[1][1] - extent[0][1];
@@ -480,6 +527,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
480
527
  let projection: ProjectionFamily;
481
528
  const override = parsed.directives.projection;
482
529
  if (
530
+ override === 'equirectangular' ||
483
531
  override === 'natural-earth' ||
484
532
  override === 'albers-usa' ||
485
533
  override === 'mercator'
@@ -488,11 +536,35 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
488
536
  } else if (usDominant) {
489
537
  projection = 'albers-usa';
490
538
  } else if (span > WORLD_SPAN) {
491
- projection = 'natural-earth';
539
+ // World/continental scale: equirectangular fills the frame edge-to-edge and
540
+ // never clips the continents at the boundary (naturalEarth's curved sides
541
+ // overrun a corner-based fit). `projection natural-earth` opts back into the
542
+ // curved look explicitly.
543
+ projection = 'equirectangular';
492
544
  } else if (span < MERCATOR_MAX_SPAN) {
493
545
  projection = 'mercator';
494
546
  } else {
495
- projection = 'natural-earth';
547
+ projection = 'equirectangular';
548
+ }
549
+
550
+ // World-scale framing (R10): a multi-continent spread frames most cleanly as
551
+ // the conventional Greenwich-centred world rectangle. The tight-arc longitude
552
+ // union is unstable for sparse global points — and an antimeridian-crossing
553
+ // country box (the US, via its Aleutians) wraps the union to an Asia-centred
554
+ // window that splits the Americas at the seam. When the data occupies at
555
+ // least half the globe in longitude, snap to full longitude AND widen the
556
+ // latitude band to the populated world (≈ Tierra del Fuego → northern
557
+ // Russia/Canada) so the map reads as a standard world view with every
558
+ // continent shown — not a thin band cropped to the data's latitudes (which
559
+ // would slice off South Africa, southern Argentina, northern Russia, …). The
560
+ // ≥180° gate leaves regional spreads tight — `region` continents (Europe
561
+ // ≈70°, Asia ≈155°) and antimeridian clusters (mercator anyway) untouched.
562
+ // Applies to both world projections (equirectangular default + natural-earth).
563
+ if (lonSpan >= 180) {
564
+ extent = [
565
+ [-180, Math.min(extent[0][1], WORLD_LAT_SOUTH)],
566
+ [180, Math.max(extent[1][1], WORLD_LAT_NORTH)],
567
+ ];
496
568
  }
497
569
 
498
570
  result.regions = regions;
package/src/map/types.ts CHANGED
@@ -38,6 +38,10 @@ export interface MapDirectives {
38
38
  * (§24B.3/.4 — BOTH may be present; bivariate seam). */
39
39
  export interface MapRegion {
40
40
  readonly name: string;
41
+ /** Optional trailing ISO scope qualifier (§24B.8) — a 3166-1 country code
42
+ * (`Georgia US` → US context) or 3166-2 subdivision (`Georgia US-GA`).
43
+ * Forces the country-vs-state interpretation and silences the ambiguity warning. */
44
+ readonly scope?: string;
41
45
  readonly score?: number;
42
46
  /** Tag values keyed by lowercased tag GROUP name (alias is resolved away). */
43
47
  readonly tags: Readonly<Record<string, string>>;
@@ -243,7 +243,7 @@ export function parseMindmap(
243
243
  if (descResult.needsColon) {
244
244
  pushWarning(
245
245
  lineNumber,
246
- `Use "description: ${descResult.text}" — colon is required.`
246
+ `Use "description: ${descResult.text}" — bare "description" is deprecated.`
247
247
  );
248
248
  }
249
249
  // Find parent node from indent stack
@@ -486,7 +486,7 @@ export function parseSitemap(
486
486
  if (descResult.needsColon) {
487
487
  pushWarning(
488
488
  lineNumber,
489
- `Use "description: ${descResult.text}" — colon is required.`
489
+ `Use "description: ${descResult.text}" — bare "description" is deprecated.`
490
490
  );
491
491
  }
492
492
  const parent = findParentNode(indent, indentStack);
@@ -198,6 +198,48 @@ function renderCapsule(
198
198
  .attr('font-family', FONT_FAMILY)
199
199
  .text(capsule.groupName);
200
200
 
201
+ // Continuous ramp (choropleth group): min | gradient | max, in place of dots.
202
+ if (capsule.gradient) {
203
+ const gr = capsule.gradient;
204
+ const gradId = `dgmo-legend-ramp-${capsule.groupName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
205
+ const def = g.append('defs').append('linearGradient').attr('id', gradId);
206
+ def
207
+ .append('stop')
208
+ .attr('offset', '0%')
209
+ .attr('stop-color', mix(gr.hue, gr.base, 15));
210
+ def.append('stop').attr('offset', '100%').attr('stop-color', gr.hue);
211
+ g.append('text')
212
+ .attr('x', gr.minX)
213
+ .attr('y', gr.textY)
214
+ .attr('dominant-baseline', 'central')
215
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
216
+ .attr('fill', palette.textMuted)
217
+ .attr('pointer-events', 'none')
218
+ .attr('font-family', FONT_FAMILY)
219
+ .text(gr.minText);
220
+ // Class + raw min/max so the app can scrub the ramp (x → value) and
221
+ // highlight the regions whose score lands near the cursor.
222
+ g.append('rect')
223
+ .attr('class', 'dgmo-legend-gradient-ramp')
224
+ .attr('data-ramp-min', gr.min)
225
+ .attr('data-ramp-max', gr.max)
226
+ .attr('x', gr.rampX)
227
+ .attr('y', gr.rampY)
228
+ .attr('width', gr.rampW)
229
+ .attr('height', gr.rampH)
230
+ .attr('rx', 2)
231
+ .attr('fill', `url(#${gradId})`);
232
+ g.append('text')
233
+ .attr('x', gr.maxX)
234
+ .attr('y', gr.textY)
235
+ .attr('dominant-baseline', 'central')
236
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
237
+ .attr('fill', palette.textMuted)
238
+ .attr('pointer-events', 'none')
239
+ .attr('font-family', FONT_FAMILY)
240
+ .text(gr.maxText);
241
+ }
242
+
201
243
  // Entry dots + labels
202
244
  for (const entry of capsule.entries) {
203
245
  const entryG = g
@@ -20,7 +20,7 @@ import {
20
20
  truncateLegendText,
21
21
  } from './legend-constants';
22
22
 
23
- import type { LegendGroupData } from './legend-svg';
23
+ import type { LegendGroupData } from './legend-types';
24
24
  import type {
25
25
  LegendConfig,
26
26
  LegendState,
@@ -41,6 +41,85 @@ const CONTROL_FONT_SIZE = 11;
41
41
  const CONTROL_ICON_GAP = 4;
42
42
  const CONTROL_GAP = 8;
43
43
 
44
+ // Continuous-ramp swatch dims (choropleth groups) — match the legacy map ramp
45
+ // block so the gradient reads the same now that it lives in the top legend.
46
+ const RAMP_LEGEND_W = 80;
47
+ const RAMP_LEGEND_H = 8;
48
+ const RAMP_LABEL_GAP = 6;
49
+
50
+ /** Compact numeric label for a ramp end (integers bare; else 1 decimal). */
51
+ function fmtRamp(n: number): string {
52
+ return Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10);
53
+ }
54
+
55
+ /** Width of a gradient group's capsule: pill + min label + ramp + max label. */
56
+ function gradientCapsuleWidth(
57
+ name: string,
58
+ gradient: NonNullable<LegendGroupData['gradient']>
59
+ ): number {
60
+ const pw = pillWidth(name);
61
+ const minW = measureLegendText(fmtRamp(gradient.min), LEGEND_ENTRY_FONT_SIZE);
62
+ const maxW = measureLegendText(fmtRamp(gradient.max), LEGEND_ENTRY_FONT_SIZE);
63
+ return (
64
+ LEGEND_CAPSULE_PAD +
65
+ pw +
66
+ 4 +
67
+ minW +
68
+ RAMP_LABEL_GAP +
69
+ RAMP_LEGEND_W +
70
+ RAMP_LABEL_GAP +
71
+ maxW +
72
+ LEGEND_CAPSULE_PAD
73
+ );
74
+ }
75
+
76
+ /** Active capsule for a continuous-ramp (choropleth) group. */
77
+ function buildGradientCapsuleLayout(
78
+ group: LegendGroupData,
79
+ gradient: NonNullable<LegendGroupData['gradient']>
80
+ ): LegendCapsuleLayout {
81
+ const pw = pillWidth(group.name);
82
+ const minText = fmtRamp(gradient.min);
83
+ const maxText = fmtRamp(gradient.max);
84
+ const minW = measureLegendText(minText, LEGEND_ENTRY_FONT_SIZE);
85
+ const gx = LEGEND_CAPSULE_PAD + pw + 4;
86
+ const minX = gx;
87
+ const rampX = gx + minW + RAMP_LABEL_GAP;
88
+ const maxX = rampX + RAMP_LEGEND_W + RAMP_LABEL_GAP;
89
+ const width = gradientCapsuleWidth(group.name, gradient);
90
+ return {
91
+ groupName: group.name,
92
+ x: 0,
93
+ y: 0,
94
+ width,
95
+ height: LEGEND_HEIGHT,
96
+ pill: {
97
+ groupName: group.name,
98
+ x: LEGEND_CAPSULE_PAD,
99
+ y: LEGEND_CAPSULE_PAD,
100
+ width: pw,
101
+ height: LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2,
102
+ isActive: true,
103
+ },
104
+ entries: [],
105
+ gradient: {
106
+ rampX,
107
+ rampY: (LEGEND_HEIGHT - RAMP_LEGEND_H) / 2,
108
+ rampW: RAMP_LEGEND_W,
109
+ rampH: RAMP_LEGEND_H,
110
+ min: gradient.min,
111
+ max: gradient.max,
112
+ minText,
113
+ minX,
114
+ maxText,
115
+ maxX,
116
+ textY: LEGEND_HEIGHT / 2,
117
+ hue: gradient.hue,
118
+ base: gradient.base,
119
+ },
120
+ };
121
+ }
122
+
44
123
  // ── Measurement helpers ─────────────────────────────────────
45
124
 
46
125
  export function pillWidth(name: string): number {
@@ -267,7 +346,7 @@ export function computeLegendLayout(
267
346
 
268
347
  const visibleGroups = config.showEmptyGroups
269
348
  ? groups
270
- : groups.filter((g) => g.entries.length > 0);
349
+ : groups.filter((g) => g.entries.length > 0 || !!g.gradient);
271
350
  if (
272
351
  visibleGroups.length === 0 &&
273
352
  (!configControls || configControls.length === 0) &&
@@ -364,7 +443,7 @@ export function computeLegendLayout(
364
443
  groupAvailW,
365
444
  config.capsulePillAddonWidth ?? 0
366
445
  );
367
- } else if (!activeGroupName) {
446
+ } else if (!activeGroupName || config.showInactivePills) {
368
447
  const pw = pillWidth(g.name);
369
448
  pills.push({
370
449
  groupName: g.name,
@@ -413,6 +492,7 @@ function buildCapsuleLayout(
413
492
  containerWidth: number,
414
493
  addonWidth = 0
415
494
  ): LegendCapsuleLayout {
495
+ if (group.gradient) return buildGradientCapsuleLayout(group, group.gradient);
416
496
  const pw = pillWidth(group.name);
417
497
  const info = capsuleWidth(
418
498
  group.name,
@@ -18,6 +18,7 @@ import type {
18
18
  LegendPalette,
19
19
  LegendCapsuleLayout,
20
20
  LegendPillLayout,
21
+ LegendGroupData,
21
22
  } from './legend-types';
22
23
  import {
23
24
  LEGEND_HEIGHT,
@@ -31,14 +32,6 @@ import { FONT_FAMILY } from '../fonts';
31
32
 
32
33
  // ── Types ────────────────────────────────────────────────────
33
34
 
34
- export interface LegendGroupData {
35
- readonly name: string;
36
- readonly entries: ReadonlyArray<{
37
- readonly value: string;
38
- readonly color: string;
39
- }>;
40
- }
41
-
42
35
  interface LegendRenderOptions {
43
36
  palette: { bg: string; surface: string; text: string; textMuted: string };
44
37
  isDark: boolean;
@@ -73,10 +73,30 @@ export interface ControlsGroupConfig {
73
73
  toggles: ControlsGroupToggle[];
74
74
  }
75
75
 
76
+ // ── Group Data ──────────────────────────────────────────────
77
+
78
+ export interface LegendGroupData {
79
+ readonly name: string;
80
+ readonly entries: ReadonlyArray<{
81
+ readonly value: string;
82
+ readonly color: string;
83
+ }>;
84
+ /** Continuous (choropleth) groups carry a gradient ramp instead of discrete
85
+ * entries — its active capsule renders `min ▭gradient▭ max` rather than dots.
86
+ * Additive: only the map sets it; every other caller omits it and renders
87
+ * unchanged. When set, `entries` is empty. */
88
+ readonly gradient?: {
89
+ readonly min: number;
90
+ readonly max: number;
91
+ readonly hue: string;
92
+ readonly base: string;
93
+ };
94
+ }
95
+
76
96
  // ── Config ──────────────────────────────────────────────────
77
97
 
78
98
  export interface LegendConfig {
79
- groups: readonly import('./legend-svg').LegendGroupData[];
99
+ groups: readonly LegendGroupData[];
80
100
  position: LegendPosition;
81
101
  controls?: LegendControl[];
82
102
  controlsGroup?: ControlsGroupConfig;
@@ -87,6 +107,11 @@ export interface LegendConfig {
87
107
  capsulePillAddonWidth?: number;
88
108
  /** When true, groups with no entries are still rendered as collapsed pills. Default: false (empty groups hidden). */
89
109
  showEmptyGroups?: boolean;
110
+ /** When true, INACTIVE sibling groups still render as collapsed pills next to
111
+ * the active capsule (preview only — export still shows just the active
112
+ * group). Lets the user click a sibling to switch the active group. Default
113
+ * false (legacy: when one group is active the others are hidden). */
114
+ showInactivePills?: boolean;
90
115
  }
91
116
 
92
117
  export interface LegendPalette {
@@ -132,6 +157,24 @@ export interface LegendCapsuleLayout {
132
157
  moreCount?: number;
133
158
  /** X offset where addon content (e.g. eye icon) can be placed — after pill, before entries */
134
159
  addonX?: number;
160
+ /** Continuous-ramp swatch (choropleth groups) drawn in place of entry dots:
161
+ * `minText` | gradient rect | `maxText`, all vertically centred. */
162
+ gradient?: {
163
+ rampX: number;
164
+ rampY: number;
165
+ rampW: number;
166
+ rampH: number;
167
+ /** Raw numeric ends (for the app's gradient-scrub: x → value). */
168
+ min: number;
169
+ max: number;
170
+ minText: string;
171
+ minX: number;
172
+ maxText: string;
173
+ maxX: number;
174
+ textY: number;
175
+ hue: string;
176
+ base: string;
177
+ };
135
178
  }
136
179
 
137
180
  export interface LegendControlLayout {