@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
package/src/map/load-data.ts
CHANGED
|
@@ -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 [
|
|
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({
|
|
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
|
-
![
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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:
|
|
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
|
}
|
package/src/map/renderer.ts
CHANGED
|
@@ -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
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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',
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
216
|
-
.attr(
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
):
|
|
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
|
}
|