@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.
- package/dist/advanced.cjs +919 -298
- package/dist/advanced.d.cts +148 -54
- package/dist/advanced.d.ts +148 -54
- package/dist/advanced.js +922 -300
- package/dist/auto.cjs +904 -297
- package/dist/auto.js +117 -117
- package/dist/auto.mjs +909 -299
- package/dist/cli.cjs +159 -159
- package/dist/index.cjs +903 -296
- package/dist/index.js +908 -298
- package/dist/internal.cjs +919 -298
- package/dist/internal.d.cts +148 -54
- package/dist/internal.d.ts +148 -54
- package/dist/internal.js +922 -300
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/lakes.json +1 -0
- package/dist/map-data/na-lakes.json +1 -0
- package/dist/map-data/na-land.json +1 -0
- package/dist/map-data/rivers.json +1 -0
- package/docs/language-reference.md +12 -7
- package/gallery/fixtures/map-region-scope.dgmo +15 -0
- package/package.json +4 -4
- package/src/advanced.ts +6 -2
- package/src/c4/parser.ts +6 -6
- package/src/completion.ts +6 -2
- package/src/echarts.ts +1 -1
- package/src/infra/parser.ts +10 -10
- package/src/journey-map/parser.ts +1 -1
- package/src/label-layout.ts +36 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/README.md +2 -0
- package/src/map/data/lakes.json +1 -0
- package/src/map/data/na-lakes.json +1 -0
- package/src/map/data/na-land.json +1 -0
- package/src/map/data/rivers.json +1 -0
- package/src/map/layout.ts +1022 -205
- package/src/map/load-data.ts +29 -2
- package/src/map/parser.ts +22 -13
- package/src/map/renderer.ts +200 -219
- package/src/map/resolved-types.ts +18 -1
- package/src/map/resolver.ts +79 -7
- package/src/map/types.ts +4 -0
- package/src/mindmap/parser.ts +1 -1
- package/src/sitemap/parser.ts +1 -1
- package/src/utils/legend-d3.ts +42 -0
- package/src/utils/legend-layout.ts +83 -3
- package/src/utils/legend-svg.ts +1 -8
- 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 =
|
|
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 {
|
package/src/map/resolver.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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>>;
|
package/src/mindmap/parser.ts
CHANGED
|
@@ -243,7 +243,7 @@ export function parseMindmap(
|
|
|
243
243
|
if (descResult.needsColon) {
|
|
244
244
|
pushWarning(
|
|
245
245
|
lineNumber,
|
|
246
|
-
`Use "description: ${descResult.text}" —
|
|
246
|
+
`Use "description: ${descResult.text}" — bare "description" is deprecated.`
|
|
247
247
|
);
|
|
248
248
|
}
|
|
249
249
|
// Find parent node from indent stack
|
package/src/sitemap/parser.ts
CHANGED
|
@@ -486,7 +486,7 @@ export function parseSitemap(
|
|
|
486
486
|
if (descResult.needsColon) {
|
|
487
487
|
pushWarning(
|
|
488
488
|
lineNumber,
|
|
489
|
-
`Use "description: ${descResult.text}" —
|
|
489
|
+
`Use "description: ${descResult.text}" — bare "description" is deprecated.`
|
|
490
490
|
);
|
|
491
491
|
}
|
|
492
492
|
const parent = findParentNode(indent, indentStack);
|
package/src/utils/legend-d3.ts
CHANGED
|
@@ -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-
|
|
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,
|
package/src/utils/legend-svg.ts
CHANGED
|
@@ -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
|
|
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 {
|