@diagrammo/dgmo 0.21.0 → 0.22.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/README.md +16 -6
- package/dist/advanced.cjs +2521 -623
- package/dist/advanced.d.cts +917 -534
- package/dist/advanced.d.ts +917 -534
- package/dist/advanced.js +2516 -623
- package/dist/auto.cjs +2333 -608
- package/dist/auto.js +119 -119
- package/dist/auto.mjs +2335 -609
- package/dist/cli.cjs +168 -168
- package/dist/editor.cjs +13 -15
- package/dist/editor.js +13 -15
- package/dist/highlight.cjs +15 -12
- package/dist/highlight.js +15 -12
- package/dist/index.cjs +2317 -595
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2319 -596
- package/dist/internal.cjs +2521 -623
- package/dist/internal.d.cts +917 -534
- package/dist/internal.d.ts +917 -534
- package/dist/internal.js +2516 -623
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -0
- package/dist/map-data/water-bodies.json +1 -0
- package/docs/language-reference.md +44 -31
- package/gallery/fixtures/map-categorical-world.dgmo +16 -0
- package/gallery/fixtures/map-categorical.dgmo +0 -1
- package/gallery/fixtures/map-choropleth.dgmo +0 -1
- package/gallery/fixtures/map-coastline.dgmo +7 -0
- package/gallery/fixtures/map-colorize.dgmo +11 -0
- package/gallery/fixtures/map-direct-color.dgmo +9 -0
- package/gallery/fixtures/map-reference-world.dgmo +11 -0
- package/gallery/fixtures/map-region-scope.dgmo +0 -3
- package/gallery/fixtures/map-route.dgmo +0 -1
- package/package.json +1 -1
- package/src/advanced.ts +26 -1
- package/src/boxes-and-lines/renderer.ts +39 -12
- package/src/cli.ts +1 -1
- package/src/completion.ts +32 -24
- package/src/cycle/renderer.ts +14 -1
- package/src/d3.ts +23 -11
- package/src/editor/highlight-api.ts +4 -0
- package/src/editor/keywords.ts +13 -15
- package/src/infra/renderer.ts +35 -7
- package/src/map/colorize.ts +54 -0
- package/src/map/context-labels.ts +429 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -0
- package/src/map/data/types.ts +34 -0
- package/src/map/data/water-bodies.json +1 -0
- package/src/map/dimensions.ts +117 -0
- package/src/map/geo-query.ts +295 -0
- package/src/map/geo.ts +305 -2
- package/src/map/invert.ts +111 -0
- package/src/map/layout.ts +1504 -335
- package/src/map/load-data.ts +16 -2
- package/src/map/parser.ts +57 -111
- package/src/map/renderer.ts +556 -13
- package/src/map/resolved-types.ts +24 -2
- package/src/map/resolver.ts +237 -67
- package/src/map/types.ts +39 -23
- package/src/mindmap/renderer.ts +10 -1
- package/src/palettes/atlas.ts +77 -0
- package/src/palettes/blueprint.ts +73 -0
- package/src/palettes/color-utils.ts +58 -1
- package/src/palettes/index.ts +12 -3
- package/src/palettes/slate.ts +73 -0
- package/src/palettes/tidewater.ts +73 -0
- package/src/render.ts +8 -1
- package/src/tech-radar/renderer.ts +3 -0
- package/src/tech-radar/types.ts +3 -0
- package/src/utils/d3-types.ts +5 -0
- package/src/utils/legend-layout.ts +21 -4
- package/src/utils/legend-types.ts +7 -0
- package/src/utils/reserved-key-registry.ts +3 -0
- package/src/palettes/bold.ts +0 -67
package/src/completion.ts
CHANGED
|
@@ -98,9 +98,12 @@ const GLOBAL_DIRECTIVES: Record<string, DirectiveValueSpec> = {
|
|
|
98
98
|
'gruvbox',
|
|
99
99
|
'tokyo-night',
|
|
100
100
|
'one-dark',
|
|
101
|
-
'bold',
|
|
102
101
|
'dracula',
|
|
103
102
|
'monokai',
|
|
103
|
+
'atlas',
|
|
104
|
+
'blueprint',
|
|
105
|
+
'slate',
|
|
106
|
+
'tidewater',
|
|
104
107
|
],
|
|
105
108
|
},
|
|
106
109
|
theme: {
|
|
@@ -508,19 +511,12 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
|
|
|
508
511
|
],
|
|
509
512
|
[
|
|
510
513
|
'map',
|
|
511
|
-
// Geographic map directives (§24B.2/.7).
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
+
// Geographic map directives (§24B.2/.7). Cosmetics are ON by default — the
|
|
515
|
+
// only switches are bare `no-*` opt-outs, surfaced proactively so a
|
|
516
|
+
// zero-config map still hints at what can be turned off. `poi`/`route` are
|
|
517
|
+
// content keywords, not directives; metadata keys (value/label/style) live
|
|
518
|
+
// in the reserved-key registry.
|
|
514
519
|
withGlobals({
|
|
515
|
-
region: {
|
|
516
|
-
description:
|
|
517
|
-
'Basemap: us-states (force US state mesh + scoping) | world (inert — already the default)',
|
|
518
|
-
values: ['us-states', 'world'],
|
|
519
|
-
},
|
|
520
|
-
projection: {
|
|
521
|
-
description: 'Override the auto projection',
|
|
522
|
-
values: ['equirectangular', 'natural-earth', 'albers-usa', 'mercator'],
|
|
523
|
-
},
|
|
524
520
|
'region-metric': { description: 'Label for the region value ramp' },
|
|
525
521
|
'poi-metric': {
|
|
526
522
|
description: 'Label for the POI value (marker size) channel',
|
|
@@ -528,20 +524,32 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
|
|
|
528
524
|
'flow-metric': {
|
|
529
525
|
description: 'Label for the edge/leg value (thickness) channel',
|
|
530
526
|
},
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
values: ['full', 'abbrev', 'off'],
|
|
527
|
+
locale: {
|
|
528
|
+
description:
|
|
529
|
+
'Default country/state for bare place names, e.g. locale US-GA',
|
|
535
530
|
},
|
|
536
|
-
'
|
|
537
|
-
description: '
|
|
538
|
-
values: ['off', 'auto', 'all'],
|
|
531
|
+
'active-tag': {
|
|
532
|
+
description: 'Which tag group leads when several are present',
|
|
539
533
|
},
|
|
540
|
-
|
|
541
|
-
'default-state': { description: 'ISO subdivision scope' },
|
|
534
|
+
caption: { description: 'Caption line (data-source attribution)' },
|
|
542
535
|
'no-legend': { description: 'Suppress the legend' },
|
|
543
|
-
|
|
544
|
-
|
|
536
|
+
'no-coastline': {
|
|
537
|
+
description: 'Turn off coastal water-lines (on by default)',
|
|
538
|
+
},
|
|
539
|
+
'no-relief': {
|
|
540
|
+
description: 'Turn off mountain-range relief shading (on by default)',
|
|
541
|
+
},
|
|
542
|
+
'no-context-labels': {
|
|
543
|
+
description: 'Turn off orientation labels for water + nearby countries',
|
|
544
|
+
},
|
|
545
|
+
'no-region-labels': {
|
|
546
|
+
description: 'Turn off subdivision name labels (on by default)',
|
|
547
|
+
},
|
|
548
|
+
'no-poi-labels': { description: 'Turn off POI labels (on by default)' },
|
|
549
|
+
'no-colorize': {
|
|
550
|
+
description:
|
|
551
|
+
'Force plain green-land reference dress (regions are auto-coloured by default)',
|
|
552
|
+
},
|
|
545
553
|
}),
|
|
546
554
|
],
|
|
547
555
|
]);
|
package/src/cycle/renderer.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface CycleRenderOptions {
|
|
|
48
48
|
onToggleDescriptions?: (active: boolean) => void;
|
|
49
49
|
onToggleControlsExpand?: () => void;
|
|
50
50
|
exportMode?: boolean;
|
|
51
|
+
/** When 'app', the description toggle is hosted by the app overlay strip:
|
|
52
|
+
* the inline gear is suppressed and a controls row + anchor are reserved.
|
|
53
|
+
* Default (inline) renders the gear as before. */
|
|
54
|
+
controlsHost?: 'app' | 'inline';
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
@@ -92,7 +96,13 @@ export function renderCycle(
|
|
|
92
96
|
const hasDescriptions =
|
|
93
97
|
parsed.nodes.some((n) => n.description.length > 0) ||
|
|
94
98
|
parsed.edges.some((e) => e.description.length > 0);
|
|
95
|
-
|
|
99
|
+
// App-hosted: controls live in the app overlay strip. Cycle has no tag groups,
|
|
100
|
+
// so there's no in-SVG legend left to render — don't reserve a legend band.
|
|
101
|
+
const appHostedControls = renderOptions?.controlsHost === 'app';
|
|
102
|
+
const hasLegend =
|
|
103
|
+
!appHostedControls &&
|
|
104
|
+
hasDescriptions &&
|
|
105
|
+
!!renderOptions?.onToggleDescriptions;
|
|
96
106
|
|
|
97
107
|
const showTitle = !!parsed.title && parsed.options['no-title'] !== 'on';
|
|
98
108
|
const legendOffset = hasLegend ? sLegendHeight : 0;
|
|
@@ -160,6 +170,9 @@ export function renderCycle(
|
|
|
160
170
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
161
171
|
mode: renderOptions?.exportMode ? 'export' : 'preview',
|
|
162
172
|
controlsGroup,
|
|
173
|
+
...(renderOptions?.controlsHost !== undefined && {
|
|
174
|
+
controlsHost: renderOptions.controlsHost,
|
|
175
|
+
}),
|
|
163
176
|
};
|
|
164
177
|
const legendState: LegendState = {
|
|
165
178
|
activeGroup: null,
|
package/src/d3.ts
CHANGED
|
@@ -7739,6 +7739,10 @@ export async function renderForExport(
|
|
|
7739
7739
|
c4Container?: string;
|
|
7740
7740
|
tagGroup?: string;
|
|
7741
7741
|
exportMode?: boolean;
|
|
7742
|
+
// Browser callers (the app / Obsidian) bundle the map JSON and inject it
|
|
7743
|
+
// here — the Node fs `loadMapData()` seam can't run in a browser. CLI/SSR
|
|
7744
|
+
// omit this and fall back to the fs loader.
|
|
7745
|
+
mapData?: import('./map/resolved-types').MapData;
|
|
7742
7746
|
}
|
|
7743
7747
|
): Promise<string> {
|
|
7744
7748
|
const exportMode = options?.exportMode ?? false;
|
|
@@ -8454,32 +8458,40 @@ export async function renderForExport(
|
|
|
8454
8458
|
if (detectedType === 'map') {
|
|
8455
8459
|
const { parseMap } = await import('./map/parser');
|
|
8456
8460
|
const { resolveMap } = await import('./map/resolver');
|
|
8457
|
-
const { loadMapData } = await import('./map/load-data');
|
|
8458
8461
|
const { renderMapForExport } = await import('./map/renderer');
|
|
8462
|
+
const { mapExportDimensions } = await import('./map/dimensions');
|
|
8459
8463
|
|
|
8460
8464
|
const effectivePalette = await resolveExportPalette(theme, palette);
|
|
8461
8465
|
const mapParsed = parseMap(content);
|
|
8462
8466
|
// Always render — an empty or partially-resolved map still draws the
|
|
8463
8467
|
// inferred base map (§24B.10 / layout AC23); diagnostics surface separately.
|
|
8464
|
-
//
|
|
8465
|
-
//
|
|
8466
|
-
//
|
|
8467
|
-
let mapData;
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8468
|
+
// Prefer injected `mapData` (browser bundles it; the fs loader can't run
|
|
8469
|
+
// there); fall back to the Node fs loader for CLI/SSR. Degrade like every
|
|
8470
|
+
// other branch (return '') if neither yields data.
|
|
8471
|
+
let mapData = options?.mapData;
|
|
8472
|
+
if (!mapData) {
|
|
8473
|
+
const { loadMapData } = await import('./map/load-data');
|
|
8474
|
+
try {
|
|
8475
|
+
mapData = await loadMapData();
|
|
8476
|
+
} catch {
|
|
8477
|
+
return '';
|
|
8478
|
+
}
|
|
8472
8479
|
}
|
|
8473
8480
|
const mapResolved = resolveMap(mapParsed, mapData);
|
|
8474
8481
|
|
|
8475
|
-
|
|
8482
|
+
// Content-aware canvas: derive the height from the map's intrinsic projected
|
|
8483
|
+
// aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
|
|
8484
|
+
// export matches the content's natural shape — no vertical stretch, no
|
|
8485
|
+
// letterbox bands. `preferContain` rides along to the renderer.
|
|
8486
|
+
const dims = mapExportDimensions(mapResolved, mapData, EXPORT_WIDTH);
|
|
8487
|
+
const container = createExportContainer(dims.width, dims.height);
|
|
8476
8488
|
renderMapForExport(
|
|
8477
8489
|
container,
|
|
8478
8490
|
mapResolved,
|
|
8479
8491
|
mapData,
|
|
8480
8492
|
effectivePalette,
|
|
8481
8493
|
theme === 'dark',
|
|
8482
|
-
|
|
8494
|
+
dims
|
|
8483
8495
|
);
|
|
8484
8496
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
8485
8497
|
}
|
package/src/editor/keywords.ts
CHANGED
|
@@ -80,11 +80,10 @@ export const METADATA_KEYS = new Set([
|
|
|
80
80
|
'quadrant',
|
|
81
81
|
'ring',
|
|
82
82
|
'trend',
|
|
83
|
-
// Map (§24B) metadata keys
|
|
84
|
-
'
|
|
83
|
+
// Map (§24B) reserved metadata keys
|
|
84
|
+
'value',
|
|
85
85
|
'label',
|
|
86
|
-
'
|
|
87
|
-
'weight',
|
|
86
|
+
'style',
|
|
88
87
|
]);
|
|
89
88
|
|
|
90
89
|
/** Tag declaration keyword. */
|
|
@@ -150,21 +149,20 @@ export const DIRECTIVE_KEYWORDS = new Set([
|
|
|
150
149
|
// Sequence
|
|
151
150
|
'activations',
|
|
152
151
|
'no-activations',
|
|
153
|
-
// Map (§24B) directives
|
|
154
|
-
'region',
|
|
155
|
-
'projection',
|
|
152
|
+
// Map (§24B) directives — cosmetics on by default, bare `no-*` opt-outs
|
|
156
153
|
'region-metric',
|
|
157
154
|
'poi-metric',
|
|
158
155
|
'flow-metric',
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'default-country',
|
|
162
|
-
'default-state',
|
|
163
|
-
'no-legend',
|
|
164
|
-
'muted',
|
|
165
|
-
'natural',
|
|
166
|
-
'subtitle',
|
|
156
|
+
'locale',
|
|
157
|
+
'active-tag',
|
|
167
158
|
'caption',
|
|
159
|
+
'no-legend',
|
|
160
|
+
'no-coastline',
|
|
161
|
+
'no-relief',
|
|
162
|
+
'no-context-labels',
|
|
163
|
+
'no-region-labels',
|
|
164
|
+
'no-poi-labels',
|
|
165
|
+
'no-colorize',
|
|
168
166
|
'poi',
|
|
169
167
|
'route',
|
|
170
168
|
// Data charts
|
package/src/infra/renderer.ts
CHANGED
|
@@ -2080,9 +2080,13 @@ function renderLegend(
|
|
|
2080
2080
|
isDark: boolean,
|
|
2081
2081
|
activeGroup: string | null,
|
|
2082
2082
|
playback?: InfraPlaybackState,
|
|
2083
|
-
exportMode = false
|
|
2083
|
+
exportMode = false,
|
|
2084
|
+
controlsHost?: 'app' | 'inline'
|
|
2084
2085
|
) {
|
|
2085
2086
|
if (legendGroups.length === 0 && !playback) return;
|
|
2087
|
+
// App-hosted playback: the play/pause + speed UI lives in the app overlay
|
|
2088
|
+
// strip, so suppress the in-SVG Playback pill and emit the controls anchor.
|
|
2089
|
+
const appHostedPlayback = controlsHost === 'app' && !!playback;
|
|
2086
2090
|
|
|
2087
2091
|
const legendG = rootSvg
|
|
2088
2092
|
.append('g')
|
|
@@ -2097,8 +2101,9 @@ function renderLegend(
|
|
|
2097
2101
|
name: g.name,
|
|
2098
2102
|
entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
|
|
2099
2103
|
}));
|
|
2100
|
-
// Add Playback as a group with empty entries (collapsed pill) or dummy entries
|
|
2101
|
-
|
|
2104
|
+
// Add Playback as a group with empty entries (collapsed pill) or dummy entries
|
|
2105
|
+
// (expanded) — unless the app hosts it, in which case it's suppressed.
|
|
2106
|
+
if (playback && !appHostedPlayback) {
|
|
2102
2107
|
allGroups.push({ name: 'Playback', entries: [] });
|
|
2103
2108
|
}
|
|
2104
2109
|
|
|
@@ -2107,6 +2112,20 @@ function renderLegend(
|
|
|
2107
2112
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
2108
2113
|
mode: exportMode ? 'export' : 'preview',
|
|
2109
2114
|
showEmptyGroups: true,
|
|
2115
|
+
...(appHostedPlayback && {
|
|
2116
|
+
controlsHost: 'app' as const,
|
|
2117
|
+
controlsGroup: {
|
|
2118
|
+
toggles: [
|
|
2119
|
+
{
|
|
2120
|
+
id: 'playback',
|
|
2121
|
+
type: 'toggle' as const,
|
|
2122
|
+
label: 'Playback',
|
|
2123
|
+
active: true,
|
|
2124
|
+
onToggle: () => {},
|
|
2125
|
+
},
|
|
2126
|
+
],
|
|
2127
|
+
},
|
|
2128
|
+
}),
|
|
2110
2129
|
};
|
|
2111
2130
|
const legendState: LegendState = { activeGroup };
|
|
2112
2131
|
renderLegendD3(
|
|
@@ -2233,9 +2252,13 @@ export function renderInfra(
|
|
|
2233
2252
|
playback?: InfraPlaybackState | null,
|
|
2234
2253
|
expandedNodeIds?: Set<string> | null,
|
|
2235
2254
|
exportMode?: boolean,
|
|
2236
|
-
collapsedNodes?: Set<string> | null
|
|
2255
|
+
collapsedNodes?: Set<string> | null,
|
|
2256
|
+
/** When 'app', the playback pill is suppressed and a controls row + anchor are
|
|
2257
|
+
* reserved for the app overlay strip (play/pause + speed live there). */
|
|
2258
|
+
controlsHost?: 'app' | 'inline'
|
|
2237
2259
|
) {
|
|
2238
2260
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
2261
|
+
const appHostedPlayback = controlsHost === 'app' && !!playback;
|
|
2239
2262
|
|
|
2240
2263
|
const ctx = ScaleContext.identity();
|
|
2241
2264
|
const sc = buildScaledConstants(ctx);
|
|
@@ -2246,7 +2269,10 @@ export function renderInfra(
|
|
|
2246
2269
|
palette,
|
|
2247
2270
|
layout.edges
|
|
2248
2271
|
);
|
|
2249
|
-
|
|
2272
|
+
// App-hosted: the playback pill moves to the app overlay, so a playback-only
|
|
2273
|
+
// legend (no tag groups) has nothing left to render.
|
|
2274
|
+
const hasLegend =
|
|
2275
|
+
legendGroups.length > 0 || (!!playback && !appHostedPlayback);
|
|
2250
2276
|
const fixedLegend = !exportMode && hasLegend;
|
|
2251
2277
|
const legendDynamicH = hasLegend
|
|
2252
2278
|
? getMaxLegendReservedHeight(
|
|
@@ -2461,7 +2487,8 @@ export function renderInfra(
|
|
|
2461
2487
|
isDark,
|
|
2462
2488
|
activeGroup ?? null,
|
|
2463
2489
|
playback ?? undefined,
|
|
2464
|
-
exportMode
|
|
2490
|
+
exportMode,
|
|
2491
|
+
controlsHost
|
|
2465
2492
|
);
|
|
2466
2493
|
// Re-enable pointer events on interactive legend elements
|
|
2467
2494
|
legendSvg
|
|
@@ -2478,7 +2505,8 @@ export function renderInfra(
|
|
|
2478
2505
|
isDark,
|
|
2479
2506
|
activeGroup ?? null,
|
|
2480
2507
|
playback ?? undefined,
|
|
2481
|
-
exportMode
|
|
2508
|
+
exportMode,
|
|
2509
|
+
controlsHost
|
|
2482
2510
|
);
|
|
2483
2511
|
}
|
|
2484
2512
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Political fill-assignment pass (§24B colorize). PURE + DETERMINISTIC — no
|
|
2
|
+
// projection, no palette, no DOM. Given the per-topology arc-adjacency graph
|
|
3
|
+
// (from geo.ts buildAdjacency), assign every drawn region a colour INDEX such
|
|
4
|
+
// that no two arc-neighbours share one. The only job of a political fill is
|
|
5
|
+
// boundary disambiguation; "no two neighbours share a hue" is the minimal
|
|
6
|
+
// property — and a planar political map famously needs only a handful of colours.
|
|
7
|
+
//
|
|
8
|
+
// FIRST-FIT greedy: each region takes the LOWEST index not used by an
|
|
9
|
+
// already-coloured neighbour. This is collision-free by construction (a node with
|
|
10
|
+
// k coloured neighbours has at most k forbidden indices, so index ≤ k is always
|
|
11
|
+
// free) AND clusters regions into the FEWEST colours — on the shipped graphs the
|
|
12
|
+
// max index used is 5 (world) / 4 (us-states), i.e. 5–6 colours total. The caller
|
|
13
|
+
// generates exactly that many palette tints, so the fills stay on-palette (no
|
|
14
|
+
// need for the old Δ+1 ≈ 17 wheel hues).
|
|
15
|
+
|
|
16
|
+
/** Result of {@link assignColors}: a colour INDEX per ISO + the number of
|
|
17
|
+
* distinct colours actually used (`= maxIndex + 1`). The index is a stable
|
|
18
|
+
* function of (ISO, global arc-adjacency) — extent-independent (AC10). */
|
|
19
|
+
export interface ColorAssignment {
|
|
20
|
+
readonly byIso: Map<string, number>;
|
|
21
|
+
readonly huesNeeded: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** First-fit greedy graph-coloring over `isos` using `adjacency` (ISO → neighbour
|
|
25
|
+
* ISOs). Visits ISOs in stable ascending order; each takes the lowest index not
|
|
26
|
+
* taken by an already-coloured neighbour — collision-free, and minimises the
|
|
27
|
+
* total colour count.
|
|
28
|
+
*
|
|
29
|
+
* EVERY iso in `isos` is assigned an index — including zero-degree nodes
|
|
30
|
+
* (islands, DC, territories with no neighbour entry) — so the caller never needs
|
|
31
|
+
* a fallback fill (F14). */
|
|
32
|
+
export function assignColors(
|
|
33
|
+
isos: readonly string[],
|
|
34
|
+
adjacency: ReadonlyMap<string, readonly string[]>
|
|
35
|
+
): ColorAssignment {
|
|
36
|
+
const sorted = [...isos].sort();
|
|
37
|
+
const byIso = new Map<string, number>();
|
|
38
|
+
let maxIndex = -1;
|
|
39
|
+
|
|
40
|
+
for (const iso of sorted) {
|
|
41
|
+
const taken = new Set<number>();
|
|
42
|
+
for (const n of adjacency.get(iso) ?? []) {
|
|
43
|
+
const c = byIso.get(n);
|
|
44
|
+
if (c !== undefined) taken.add(c);
|
|
45
|
+
}
|
|
46
|
+
// Lowest index not taken by a neighbour (always exists: ≤ neighbour count).
|
|
47
|
+
let h = 0;
|
|
48
|
+
while (taken.has(h)) h++;
|
|
49
|
+
byIso.set(iso, h);
|
|
50
|
+
if (h > maxIndex) maxIndex = h;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { byIso, huesNeeded: maxIndex + 1 };
|
|
54
|
+
}
|