@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/map/renderer.ts
CHANGED
|
@@ -17,10 +17,131 @@ 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 {
|
|
20
|
+
import {
|
|
21
|
+
layoutMap,
|
|
22
|
+
parsePathRings,
|
|
23
|
+
type MapLayoutRegion,
|
|
24
|
+
type MapLayoutCoastlineStyle,
|
|
25
|
+
type PlacedLabel,
|
|
26
|
+
} from './layout';
|
|
21
27
|
|
|
22
28
|
const LABEL_FONT = 11;
|
|
23
29
|
|
|
30
|
+
// ── Coastline water-lines helpers (opt-in `coastline`, §24B.2) ──
|
|
31
|
+
// Geometry is derived from the already-drawn region paths: each outer ring is
|
|
32
|
+
// buffered as a symmetric SVG stroke band then eroded (flat-water overdraw) to a
|
|
33
|
+
// thin offshore ring; a luminance <mask> reveals only the water side. See the
|
|
34
|
+
// render block + ADR-1/6 in the tech-spec.
|
|
35
|
+
|
|
36
|
+
/** Even-odd point-in-ring test (screen space). */
|
|
37
|
+
function pointInRing(
|
|
38
|
+
px: number,
|
|
39
|
+
py: number,
|
|
40
|
+
ring: ReadonlyArray<[number, number]>
|
|
41
|
+
): boolean {
|
|
42
|
+
let inside = false;
|
|
43
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
44
|
+
const [xi, yi] = ring[i]!;
|
|
45
|
+
const [xj, yj] = ring[j]!;
|
|
46
|
+
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi)
|
|
47
|
+
inside = !inside;
|
|
48
|
+
}
|
|
49
|
+
return inside;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build an SVG subpath `d` (`M…L…Z`) from a ring's points. */
|
|
53
|
+
function ringToPath(ring: ReadonlyArray<[number, number]>): string {
|
|
54
|
+
let d = '';
|
|
55
|
+
for (let i = 0; i < ring.length; i++)
|
|
56
|
+
d += (i ? 'L' : 'M') + ring[i]![0] + ',' + ring[i]![1];
|
|
57
|
+
return d + 'Z';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Coast outlines to buffer: every region's OUTER rings whose bbox extent clears
|
|
61
|
+
* `minExtent`. Holes/enclaves are skipped via containment depth (even depth =
|
|
62
|
+
* outer landmass boundary, odd = a hole) so an enclave (Lesotho) or a lake-hole
|
|
63
|
+
* is never ringed as a fake coast on land (R11). `minExtent` is a bare
|
|
64
|
+
* degenerate-ring floor now — every island, however small, grows coast rings. */
|
|
65
|
+
function coastlineOuterRings(
|
|
66
|
+
regions: readonly MapLayoutRegion[],
|
|
67
|
+
minExtent: number
|
|
68
|
+
): string[] {
|
|
69
|
+
const paths: string[] = [];
|
|
70
|
+
for (const r of regions) {
|
|
71
|
+
const rings = parsePathRings(r.d);
|
|
72
|
+
for (let i = 0; i < rings.length; i++) {
|
|
73
|
+
const ring = rings[i]!;
|
|
74
|
+
if (ring.length < 3) continue;
|
|
75
|
+
let minX = Infinity;
|
|
76
|
+
let minY = Infinity;
|
|
77
|
+
let maxX = -Infinity;
|
|
78
|
+
let maxY = -Infinity;
|
|
79
|
+
for (const [x, y] of ring) {
|
|
80
|
+
if (x < minX) minX = x;
|
|
81
|
+
if (x > maxX) maxX = x;
|
|
82
|
+
if (y < minY) minY = y;
|
|
83
|
+
if (y > maxY) maxY = y;
|
|
84
|
+
}
|
|
85
|
+
if (Math.max(maxX - minX, maxY - minY) < minExtent) continue;
|
|
86
|
+
const [fx, fy] = ring[0]!;
|
|
87
|
+
let depth = 0;
|
|
88
|
+
for (let j = 0; j < rings.length; j++)
|
|
89
|
+
if (j !== i && pointInRing(fx, fy, rings[j]!)) depth++;
|
|
90
|
+
if (depth % 2 === 1) continue; // hole/enclave — skip
|
|
91
|
+
paths.push(ringToPath(ring));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return paths;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Stroke the coast-parallel water-lines into a masked group. Per line, outer→
|
|
98
|
+
* inner so the inner ring draws on top: a colour pass (the symmetric buffer
|
|
99
|
+
* band) then a flat-water overdraw that erodes it to a thin offshore ring. The
|
|
100
|
+
* group's `<mask>` keeps only the water-side half of each band.
|
|
101
|
+
*
|
|
102
|
+
* The outer→inner ordering protects a single ring (the inner band never reaches
|
|
103
|
+
* the outer ring because `d1+thickness < d2`, the layout invariant). It does NOT
|
|
104
|
+
* protect across regions: where two coasts sit closer than ~2·d1 (a tripoint, a
|
|
105
|
+
* narrow strait, an inset box edge), one region's flat-water overdraw can paint
|
|
106
|
+
* over a neighbour's inner ring — the same accepted "tripoint stub / narrow
|
|
107
|
+
* inlet fills solid" artifact the tech-spec calls out, bounded by small d.
|
|
108
|
+
*
|
|
109
|
+
* Perf: every outer ring in a given (level, pass) shares identical stroke
|
|
110
|
+
* attributes, so they collapse into ONE multi-subpath `<path>` (each ring's
|
|
111
|
+
* `d` already starts with `M`, so joining is a valid compound path). A world
|
|
112
|
+
* map drops from ~6k coastline paths to 2 per ring-level (~10 total), which is
|
|
113
|
+
* ~87% of the whole map's path count — the dominant cost in any repaint. The
|
|
114
|
+
* only visible consequence: the colour-band pass strokes at `stroke-opacity`,
|
|
115
|
+
* and a single path's stroke rasterises as ONE coverage region, so adjacent
|
|
116
|
+
* bands that overlap (straits/tripoints) no longer double-darken — they read
|
|
117
|
+
* uniform, which is if anything cleaner. Draw order (all colour bands, then all
|
|
118
|
+
* flat-water, outer level first) is unchanged, so the single-ring invariant and
|
|
119
|
+
* the accepted cross-region overdraw artifact behave exactly as before. */
|
|
120
|
+
function appendWaterLines(
|
|
121
|
+
g: Sel,
|
|
122
|
+
outerRings: readonly string[],
|
|
123
|
+
style: MapLayoutCoastlineStyle,
|
|
124
|
+
flatWater: string
|
|
125
|
+
): void {
|
|
126
|
+
const d = outerRings.join(' ');
|
|
127
|
+
const linesOuterFirst = [...style.lines].sort((a, b) => b.d - a.d);
|
|
128
|
+
for (const line of linesOuterFirst) {
|
|
129
|
+
g.append('path')
|
|
130
|
+
.attr('d', d)
|
|
131
|
+
.attr('stroke', style.color)
|
|
132
|
+
.attr('stroke-width', 2 * (line.d + line.thickness))
|
|
133
|
+
.attr('stroke-opacity', line.opacity)
|
|
134
|
+
.attr('stroke-linejoin', 'round')
|
|
135
|
+
.attr('stroke-linecap', 'round');
|
|
136
|
+
g.append('path')
|
|
137
|
+
.attr('d', d)
|
|
138
|
+
.attr('stroke', flatWater)
|
|
139
|
+
.attr('stroke-width', 2 * line.d)
|
|
140
|
+
.attr('stroke-linejoin', 'round')
|
|
141
|
+
.attr('stroke-linecap', 'round');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
24
145
|
/** Render a resolved map into `container` (d3-selection appends an `<svg>`). */
|
|
25
146
|
export function renderMap(
|
|
26
147
|
container: HTMLDivElement,
|
|
@@ -45,6 +166,11 @@ export function renderMap(
|
|
|
45
166
|
{
|
|
46
167
|
palette,
|
|
47
168
|
isDark,
|
|
169
|
+
// Export-only: forward the contain-fit request from mapExportDimensions so a
|
|
170
|
+
// clamped/floored (off-aspect) export canvas letterboxes instead of
|
|
171
|
+
// stretch-distorting. The in-app preview pane passes no exportDims → unset →
|
|
172
|
+
// keeps the global stretch-fill.
|
|
173
|
+
preferContain: exportDims?.preferContain ?? false,
|
|
48
174
|
...(activeGroupOverride !== undefined && {
|
|
49
175
|
activeGroup: activeGroupOverride,
|
|
50
176
|
}),
|
|
@@ -100,6 +226,9 @@ export function renderMap(
|
|
|
100
226
|
.attr('fill', r.fill)
|
|
101
227
|
.attr('stroke', r.stroke)
|
|
102
228
|
.attr('stroke-width', strokeWidth);
|
|
229
|
+
// Display name on EVERY region (authored + base/context) so the app can
|
|
230
|
+
// surface it on hover — decorative metadata, no visible text drawn here.
|
|
231
|
+
if (r.label) p.attr('data-region-name', r.label);
|
|
103
232
|
// Data layer? Tag it so the app can highlight on legend hover / gradient
|
|
104
233
|
// scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
|
|
105
234
|
// (both lowercased to match the lowercased legend-entry attributes).
|
|
@@ -123,6 +252,156 @@ export function renderMap(
|
|
|
123
252
|
};
|
|
124
253
|
for (const r of layout.regions) drawRegion(gRegions, r, 0.5);
|
|
125
254
|
|
|
255
|
+
// ── Relief (mountain-range hachure over ALL land, under rivers/POIs/labels) ──
|
|
256
|
+
// Rule horizontal lines across the whole canvas, clipped to the INTERSECTION
|
|
257
|
+
// of (a) the union of range polygons and (b) the land — nested clipPaths, so
|
|
258
|
+
// the hachure never bleeds onto water (coarse range polygons overrun the
|
|
259
|
+
// coast, and horizontal lines on the sea read as the water convention). The
|
|
260
|
+
// land clip is every drawn region except lakes — INCLUDING value-/tag-coloured
|
|
261
|
+
// regions, so the relief texture sits ATOP the choropleth/tag fills (a range
|
|
262
|
+
// crossing a valued state still reads as mountains there). It stays below
|
|
263
|
+
// rivers, POIs, and labels. Explicit <line>s in a <clipPath> (not a tiled
|
|
264
|
+
// <pattern>) dodge WKWebView/resvg pattern quirks. A non-scaling stroke keeps
|
|
265
|
+
// the width constant in device px at any zoom/DPR (uniform, no moire); kept
|
|
266
|
+
// sub-pixel + low-contrast so the texture stays faint. Decorative — no data attrs.
|
|
267
|
+
if (layout.relief.length && layout.reliefHatch) {
|
|
268
|
+
const h = layout.reliefHatch;
|
|
269
|
+
const rangeClipId = 'dgmo-relief-clip';
|
|
270
|
+
const landClipId = 'dgmo-relief-land';
|
|
271
|
+
const rangeClip = defs.append('clipPath').attr('id', rangeClipId);
|
|
272
|
+
for (const s of layout.relief) rangeClip.append('path').attr('d', s.d);
|
|
273
|
+
const landClip = defs.append('clipPath').attr('id', landClipId);
|
|
274
|
+
for (const r of layout.regions)
|
|
275
|
+
if (r.id !== 'lake') landClip.append('path').attr('d', r.d);
|
|
276
|
+
const gRelief = svg
|
|
277
|
+
.append('g')
|
|
278
|
+
.attr('clip-path', `url(#${landClipId})`) // outer: land only
|
|
279
|
+
.append('g')
|
|
280
|
+
.attr('class', 'dgmo-map-relief')
|
|
281
|
+
.attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
|
|
282
|
+
.attr('stroke', h.color)
|
|
283
|
+
.attr('stroke-width', h.width)
|
|
284
|
+
// Non-scaling stroke = constant device width at any zoom/DPR (uniform,
|
|
285
|
+
// no moire). NOT crispEdges — that snaps to a solid ~1px in WebKit and
|
|
286
|
+
// reads far too heavy; plain AA keeps the sub-pixel lines whisper-thin.
|
|
287
|
+
.attr('vector-effect', 'non-scaling-stroke');
|
|
288
|
+
for (let y = h.spacing; y < height; y += h.spacing) {
|
|
289
|
+
gRelief
|
|
290
|
+
.append('line')
|
|
291
|
+
.attr('x1', 0)
|
|
292
|
+
.attr('y1', y)
|
|
293
|
+
.attr('x2', width)
|
|
294
|
+
.attr('y2', y);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Coastline water-lines (faint nautical-chart lines on the WATER side) ──
|
|
299
|
+
// 2 discrete coast-parallel lines hugging the ocean shore + lake shores, fading
|
|
300
|
+
// seaward. Each region's outer ring is buffered as a symmetric SVG stroke band
|
|
301
|
+
// then eroded with a flat-water overdraw to a thin offshore ring; a luminance
|
|
302
|
+
// <mask> (white canvas − black land + white lakes) reveals only the water side,
|
|
303
|
+
// so land/land borders self-remove (their band falls on
|
|
304
|
+
// the neighbour's land, which the mask hides — no topojson.mesh needed). NOT a
|
|
305
|
+
// clipPath: sibling clip paths UNION (can't subtract land). Decorative — no data
|
|
306
|
+
// attrs, plain strokes. Below rivers/POIs/legs/labels, above region/relief fills
|
|
307
|
+
// (so with `relief` on, water-lines sit on water and relief on land — disjoint).
|
|
308
|
+
// §24B.2, ADR-1/3/6.
|
|
309
|
+
if (layout.coastlineStyle) {
|
|
310
|
+
const cs = layout.coastlineStyle;
|
|
311
|
+
const maskId = 'dgmo-map-water-mask';
|
|
312
|
+
const mask = defs
|
|
313
|
+
.append('mask')
|
|
314
|
+
.attr('id', maskId)
|
|
315
|
+
// userSpaceOnUse: the default objectBoundingBox clamps the mask region to
|
|
316
|
+
// the group's own bbox and drops the canvas-edge reveal (round-2 #2).
|
|
317
|
+
.attr('maskUnits', 'userSpaceOnUse')
|
|
318
|
+
.attr('x', 0)
|
|
319
|
+
.attr('y', 0)
|
|
320
|
+
.attr('width', width)
|
|
321
|
+
.attr('height', height);
|
|
322
|
+
mask
|
|
323
|
+
.append('rect')
|
|
324
|
+
.attr('x', 0)
|
|
325
|
+
.attr('y', 0)
|
|
326
|
+
.attr('width', width)
|
|
327
|
+
.attr('height', height)
|
|
328
|
+
.attr('fill', 'white');
|
|
329
|
+
// All land fills are opaque black and all lake fills opaque white, so each
|
|
330
|
+
// group collapses into ONE compound path (land first, lakes over it to
|
|
331
|
+
// re-reveal their interiors). Identical pixels, ~hundreds of paths → 2.
|
|
332
|
+
const landD = layout.regions
|
|
333
|
+
.filter((r) => r.id !== 'lake')
|
|
334
|
+
.map((r) => r.d)
|
|
335
|
+
.join(' ');
|
|
336
|
+
const lakeD = layout.regions
|
|
337
|
+
.filter((r) => r.id === 'lake')
|
|
338
|
+
.map((r) => r.d)
|
|
339
|
+
.join(' ');
|
|
340
|
+
if (landD) mask.append('path').attr('d', landD).attr('fill', 'black');
|
|
341
|
+
if (lakeD) mask.append('path').attr('d', lakeD).attr('fill', 'white');
|
|
342
|
+
// Moat around each AK/HI inset box: the opaque box is drawn in the FOREGROUND
|
|
343
|
+
// over open ocean, so the main-map offshore rings get chopped by its border
|
|
344
|
+
// and butt into it — sloppy. Paint each box quad black (interior, under the
|
|
345
|
+
// box) with a black stroke whose half-width = the band's outer reach, so the
|
|
346
|
+
// main rings also clear a margin OUTSIDE the border. The ring-ends then fall
|
|
347
|
+
// in open water away from the frame instead of crashing into it.
|
|
348
|
+
if (layout.insets.length) {
|
|
349
|
+
const reach = Math.max(0, ...cs.lines.map((l) => l.d + l.thickness));
|
|
350
|
+
for (const box of layout.insets) {
|
|
351
|
+
const d =
|
|
352
|
+
box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
|
|
353
|
+
'Z';
|
|
354
|
+
mask
|
|
355
|
+
.append('path')
|
|
356
|
+
.attr('d', d)
|
|
357
|
+
.attr('fill', 'black')
|
|
358
|
+
.attr('stroke', 'black')
|
|
359
|
+
.attr('stroke-width', 2 * reach)
|
|
360
|
+
.attr('stroke-linejoin', 'round');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// NO frame band: a synthetic frame-cut edge (clipExtent/cullFeatureToView
|
|
364
|
+
// trims region `d` to the view rect) has the region INTERIOR — land — on the
|
|
365
|
+
// canvas-interior side, which the mask already paints black, so its band is
|
|
366
|
+
// hidden anyway. A border band here only suppressed REAL coastal rings near
|
|
367
|
+
// the canvas edge, leaving an empty top/bottom strip — so the water-lines now
|
|
368
|
+
// carry through to every edge of the visible map.
|
|
369
|
+
const gWater = svg
|
|
370
|
+
.append('g')
|
|
371
|
+
.attr('class', 'dgmo-map-water-lines')
|
|
372
|
+
.attr('fill', 'none')
|
|
373
|
+
.attr('mask', `url(#${maskId})`);
|
|
374
|
+
appendWaterLines(
|
|
375
|
+
gWater,
|
|
376
|
+
coastlineOuterRings(layout.regions, cs.minExtent),
|
|
377
|
+
cs,
|
|
378
|
+
layout.background
|
|
379
|
+
);
|
|
380
|
+
// Restore the seaward half of the coast stroke: the rings' flat-water erosion
|
|
381
|
+
// overdraws repaint the water out to d_max, which paints over the water-side
|
|
382
|
+
// half of each region's coast outline and makes coastlines read faded. Re-
|
|
383
|
+
// stroke every region inside the SAME masked group (so it only repaints the
|
|
384
|
+
// water side — the land side was never touched, and interior land/land borders
|
|
385
|
+
// stay hidden), on top of the rings. The strokes sit at the coast (offset 0),
|
|
386
|
+
// well inside d0, so they never cover the offshore rings.
|
|
387
|
+
// Group the restroke by stroke colour (base borders share one colour; the
|
|
388
|
+
// occasional data region may differ) so same-coloured coasts collapse into a
|
|
389
|
+
// single compound path instead of one path per region.
|
|
390
|
+
const byStroke = new Map<string, string[]>();
|
|
391
|
+
for (const r of layout.regions) {
|
|
392
|
+
const arr = byStroke.get(r.stroke);
|
|
393
|
+
if (arr) arr.push(r.d);
|
|
394
|
+
else byStroke.set(r.stroke, [r.d]);
|
|
395
|
+
}
|
|
396
|
+
for (const [stroke, ds] of byStroke)
|
|
397
|
+
gWater
|
|
398
|
+
.append('path')
|
|
399
|
+
.attr('d', ds.join(' '))
|
|
400
|
+
.attr('stroke', stroke)
|
|
401
|
+
.attr('stroke-width', 0.5)
|
|
402
|
+
.attr('stroke-linejoin', 'round');
|
|
403
|
+
}
|
|
404
|
+
|
|
126
405
|
// ── Rivers (thin water centerlines over the land, under POIs/edges) ──
|
|
127
406
|
if (layout.rivers.length) {
|
|
128
407
|
const gRivers = svg
|
|
@@ -145,7 +424,7 @@ export function renderMap(
|
|
|
145
424
|
// then draws on top, framed by the box border. ──
|
|
146
425
|
if (layout.insets.length) {
|
|
147
426
|
const insetG = svg.append('g').attr('class', 'dgmo-map-insets');
|
|
148
|
-
|
|
427
|
+
layout.insets.forEach((box, bi) => {
|
|
149
428
|
// Angled-top quad frame — rides under the conus coast so it never covers
|
|
150
429
|
// neighbouring states. Closed path from the four corners.
|
|
151
430
|
const d =
|
|
@@ -158,10 +437,110 @@ export function renderMap(
|
|
|
158
437
|
.attr('stroke', mix(palette.text, palette.bg, 55))
|
|
159
438
|
.attr('stroke-width', 1)
|
|
160
439
|
.attr('stroke-linejoin', 'round');
|
|
161
|
-
|
|
440
|
+
// Neighbour land (Canada beside Alaska) clipped to this box, behind the
|
|
441
|
+
// state — so a land border reads as land rather than sprouting coast rings.
|
|
442
|
+
if (box.contextLand) {
|
|
443
|
+
const clipId = `dgmo-map-inset-clip-${bi}`;
|
|
444
|
+
defs.append('clipPath').attr('id', clipId).append('path').attr('d', d);
|
|
445
|
+
insetG
|
|
446
|
+
.append('path')
|
|
447
|
+
.attr('d', box.contextLand.d)
|
|
448
|
+
.attr('fill', box.contextLand.fill)
|
|
449
|
+
.attr('clip-path', `url(#${clipId})`);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
162
452
|
for (const r of layout.insetRegions) drawRegion(insetG, r, 0.5);
|
|
453
|
+
|
|
454
|
+
// Inset coastline water-lines (AK/HI box interiors) for visual parity with
|
|
455
|
+
// the main map. Mask = the inset box quads (white reveal) − inset regions
|
|
456
|
+
// (black land / white lake); buffer+erode the inset region outer rings the
|
|
457
|
+
// same way. Inside the inset group so it composites over the box fills.
|
|
458
|
+
if (layout.coastlineStyle) {
|
|
459
|
+
const cs = layout.coastlineStyle;
|
|
460
|
+
const maskId = 'dgmo-map-inset-water-mask';
|
|
461
|
+
const mask = defs
|
|
462
|
+
.append('mask')
|
|
463
|
+
.attr('id', maskId)
|
|
464
|
+
.attr('maskUnits', 'userSpaceOnUse')
|
|
465
|
+
.attr('x', 0)
|
|
466
|
+
.attr('y', 0)
|
|
467
|
+
.attr('width', width)
|
|
468
|
+
.attr('height', height);
|
|
469
|
+
for (const box of layout.insets) {
|
|
470
|
+
const d =
|
|
471
|
+
box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
|
|
472
|
+
'Z';
|
|
473
|
+
mask.append('path').attr('d', d).attr('fill', 'white');
|
|
474
|
+
}
|
|
475
|
+
// Neighbour land masks as land too — clipped to its box so it can't darken
|
|
476
|
+
// an adjacent inset — keeping the AK/Canada land border free of rings.
|
|
477
|
+
layout.insets.forEach((box, bi) => {
|
|
478
|
+
if (box.contextLand)
|
|
479
|
+
mask
|
|
480
|
+
.append('path')
|
|
481
|
+
.attr('d', box.contextLand.d)
|
|
482
|
+
.attr('fill', 'black')
|
|
483
|
+
.attr('clip-path', `url(#dgmo-map-inset-clip-${bi})`);
|
|
484
|
+
});
|
|
485
|
+
for (const r of layout.insetRegions)
|
|
486
|
+
if (r.id !== 'lake')
|
|
487
|
+
mask.append('path').attr('d', r.d).attr('fill', 'black');
|
|
488
|
+
for (const r of layout.insetRegions)
|
|
489
|
+
if (r.id === 'lake')
|
|
490
|
+
mask.append('path').attr('d', r.d).attr('fill', 'white');
|
|
491
|
+
// Clip the water-line strokes to the inset box quads — the mask controls
|
|
492
|
+
// which side reads as water, but SVG strokes still extend stroke-width/2
|
|
493
|
+
// past their path, so without this the seaward rings bleed over the box
|
|
494
|
+
// border. Union of all inset quads = one clipPath shared by the group.
|
|
495
|
+
const clipId = 'dgmo-map-inset-water-clip';
|
|
496
|
+
const clip = defs.append('clipPath').attr('id', clipId);
|
|
497
|
+
for (const box of layout.insets) {
|
|
498
|
+
const d =
|
|
499
|
+
box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
|
|
500
|
+
'Z';
|
|
501
|
+
clip.append('path').attr('d', d);
|
|
502
|
+
}
|
|
503
|
+
// Nest clip (outer) + mask (inner) rather than both on one element —
|
|
504
|
+
// WebKit (Tauri) renders the combined attributes inconsistently; the
|
|
505
|
+
// nested form is honored by every engine.
|
|
506
|
+
const gInsetWater = insetG
|
|
507
|
+
.append('g')
|
|
508
|
+
.attr('clip-path', `url(#${clipId})`)
|
|
509
|
+
.append('g')
|
|
510
|
+
.attr('class', 'dgmo-map-inset-water-lines')
|
|
511
|
+
.attr('fill', 'none')
|
|
512
|
+
.attr('mask', `url(#${maskId})`);
|
|
513
|
+
appendWaterLines(
|
|
514
|
+
gInsetWater,
|
|
515
|
+
coastlineOuterRings(layout.insetRegions, cs.minExtent),
|
|
516
|
+
cs,
|
|
517
|
+
layout.background
|
|
518
|
+
);
|
|
519
|
+
// Restore the seaward half of the inset coast strokes (see main pass).
|
|
520
|
+
for (const r of layout.insetRegions)
|
|
521
|
+
gInsetWater
|
|
522
|
+
.append('path')
|
|
523
|
+
.attr('d', r.d)
|
|
524
|
+
.attr('stroke', r.stroke)
|
|
525
|
+
.attr('stroke-width', 0.5)
|
|
526
|
+
.attr('stroke-linejoin', 'round');
|
|
527
|
+
}
|
|
163
528
|
}
|
|
164
529
|
|
|
530
|
+
// Code↔diagram sync: tag a synced element with its 1-based source line and,
|
|
531
|
+
// in preview, make it jump the editor cursor on click. Source-derived elements
|
|
532
|
+
// (regions, POIs, legs, labels) all carry `lineNumber`; generated context
|
|
533
|
+
// labels use 0 as a "no source line" sentinel, so guard on `>= 1`.
|
|
534
|
+
const wireSync = <E extends Element>(
|
|
535
|
+
sel: d3Selection.Selection<E, unknown, null, undefined>,
|
|
536
|
+
lineNumber: number
|
|
537
|
+
): void => {
|
|
538
|
+
if (lineNumber < 1) return;
|
|
539
|
+
sel.attr('data-line-number', lineNumber);
|
|
540
|
+
if (onClickItem)
|
|
541
|
+
sel.style('cursor', 'pointer').on('click', () => onClickItem(lineNumber));
|
|
542
|
+
};
|
|
543
|
+
|
|
165
544
|
// ── Legs (edges + route legs) ──
|
|
166
545
|
const gLegs = svg
|
|
167
546
|
.append('g')
|
|
@@ -174,6 +553,9 @@ export function renderMap(
|
|
|
174
553
|
.attr('stroke', leg.color)
|
|
175
554
|
.attr('stroke-width', leg.width)
|
|
176
555
|
.attr('stroke-linecap', 'round');
|
|
556
|
+
// A 0-width invisible leg path is hard to hit; pointer-events on the visible
|
|
557
|
+
// stroke is enough for the line widths legs use.
|
|
558
|
+
wireSync(p, leg.lineNumber);
|
|
177
559
|
if (leg.arrow) {
|
|
178
560
|
const id = `dgmo-map-arrow-${i}`;
|
|
179
561
|
const s = arrowSize(leg.width);
|
|
@@ -193,20 +575,67 @@ export function renderMap(
|
|
|
193
575
|
p.attr('marker-end', `url(#${id})`);
|
|
194
576
|
}
|
|
195
577
|
if (leg.label !== undefined && leg.labelX !== undefined) {
|
|
196
|
-
|
|
578
|
+
// Text shade is contrast-picked in layout against the fill under the label
|
|
579
|
+
// (dark scored country ⇒ light text, pale land ⇒ dark), with the ghost halo
|
|
580
|
+
// only when that contrast is marginal. Fall back to the muted default for
|
|
581
|
+
// legs that predate the computed style.
|
|
582
|
+
const lt = emitText(
|
|
197
583
|
gLegs,
|
|
198
584
|
leg.labelX,
|
|
199
585
|
leg.labelY ?? 0,
|
|
200
586
|
leg.label,
|
|
201
587
|
'middle',
|
|
202
|
-
palette.textMuted,
|
|
203
|
-
haloColor,
|
|
204
|
-
true,
|
|
588
|
+
leg.labelColor ?? palette.textMuted,
|
|
589
|
+
leg.labelHaloColor ?? haloColor,
|
|
590
|
+
leg.labelHalo ?? true,
|
|
205
591
|
LABEL_FONT - 1
|
|
206
592
|
);
|
|
593
|
+
wireSync(lt, leg.lineNumber);
|
|
207
594
|
}
|
|
208
595
|
});
|
|
209
596
|
|
|
597
|
+
// ── Coincident-stack spider legs + hub (under the dots) ──
|
|
598
|
+
// Legs + hub are DECORATIVE (`data-cluster-deco`, pointer-events none) — the app
|
|
599
|
+
// toggles only their opacity. Drawn VISIBLE so export + the no-JS default show
|
|
600
|
+
// the expanded fan. An invisible hit-area circle (interactive only) owns all
|
|
601
|
+
// pointer interaction so hover/click drives the spiderfy controller robustly.
|
|
602
|
+
const gSpider = svg.append('g').attr('class', 'dgmo-map-spider');
|
|
603
|
+
for (const cl of layout.clusters) {
|
|
604
|
+
// Pointer hit-area — bottom of the stack so member dots still take their own
|
|
605
|
+
// clicks (line-jump); clicks on the empty centre fall through to here.
|
|
606
|
+
if (!exportDims) {
|
|
607
|
+
gSpider
|
|
608
|
+
.append('circle')
|
|
609
|
+
.attr('cx', cl.cx)
|
|
610
|
+
.attr('cy', cl.cy)
|
|
611
|
+
.attr('r', cl.hitR)
|
|
612
|
+
.attr('fill', 'transparent')
|
|
613
|
+
.attr('data-cluster-hit', cl.id)
|
|
614
|
+
.style('cursor', 'pointer');
|
|
615
|
+
}
|
|
616
|
+
for (const leg of cl.legs) {
|
|
617
|
+
gSpider
|
|
618
|
+
.append('line')
|
|
619
|
+
.attr('x1', cl.cx)
|
|
620
|
+
.attr('y1', cl.cy)
|
|
621
|
+
.attr('x2', leg.x2)
|
|
622
|
+
.attr('y2', leg.y2)
|
|
623
|
+
.attr('stroke', leg.color)
|
|
624
|
+
.attr('stroke-width', 1)
|
|
625
|
+
.attr('data-cluster-deco', cl.id)
|
|
626
|
+
.style('pointer-events', 'none');
|
|
627
|
+
}
|
|
628
|
+
// Faint neutral hub anchoring the legs (RQ3).
|
|
629
|
+
gSpider
|
|
630
|
+
.append('circle')
|
|
631
|
+
.attr('cx', cl.cx)
|
|
632
|
+
.attr('cy', cl.cy)
|
|
633
|
+
.attr('r', 2)
|
|
634
|
+
.attr('fill', mix(palette.textMuted, palette.bg, 40))
|
|
635
|
+
.attr('data-cluster-deco', cl.id)
|
|
636
|
+
.style('pointer-events', 'none');
|
|
637
|
+
}
|
|
638
|
+
|
|
210
639
|
// ── POIs ──
|
|
211
640
|
const gPois = svg.append('g').attr('class', 'dgmo-map-pois');
|
|
212
641
|
for (const poi of layout.pois) {
|
|
@@ -230,6 +659,9 @@ export function renderMap(
|
|
|
230
659
|
.attr('stroke-width', 1)
|
|
231
660
|
.attr('data-line-number', poi.lineNumber)
|
|
232
661
|
.attr('data-poi', poi.id);
|
|
662
|
+
// Coincident-stack member: tag so the app hides it when collapsing the stack.
|
|
663
|
+
if (poi.clusterId !== undefined)
|
|
664
|
+
c.attr('data-cluster-member', poi.clusterId);
|
|
233
665
|
// Tag the marker per tag value (lowercased, matching the lowercased
|
|
234
666
|
// legend-entry attributes) so the app can spotlight it on legend hover.
|
|
235
667
|
if (poi.tags) {
|
|
@@ -260,6 +692,31 @@ export function renderMap(
|
|
|
260
692
|
// ── Labels (leaders + halo text) ──
|
|
261
693
|
const gLabels = svg.append('g').attr('class', 'dgmo-map-labels');
|
|
262
694
|
for (const lab of layout.labels) {
|
|
695
|
+
// Hover-only labels: OMIT entirely from static export (export = the
|
|
696
|
+
// hover-less default view); in preview emit invisible + flagged so the app
|
|
697
|
+
// can reveal them on hover. They carry no leader, so the leader block below
|
|
698
|
+
// is skipped naturally.
|
|
699
|
+
if (lab.hidden) {
|
|
700
|
+
if (exportDims) continue;
|
|
701
|
+
emitText(
|
|
702
|
+
gLabels,
|
|
703
|
+
lab.x,
|
|
704
|
+
lab.y,
|
|
705
|
+
lab.text,
|
|
706
|
+
lab.anchor,
|
|
707
|
+
lab.color,
|
|
708
|
+
lab.haloColor,
|
|
709
|
+
lab.halo,
|
|
710
|
+
LABEL_FONT,
|
|
711
|
+
lab.italic,
|
|
712
|
+
lab.letterSpacing
|
|
713
|
+
)
|
|
714
|
+
.attr('data-poi', lab.poiId ?? null)
|
|
715
|
+
.attr('data-poi-hidden', '')
|
|
716
|
+
.style('opacity', 0)
|
|
717
|
+
.style('pointer-events', 'none');
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
263
720
|
if (lab.leader) {
|
|
264
721
|
const line = gLabels
|
|
265
722
|
.append('line')
|
|
@@ -274,6 +731,10 @@ export function renderMap(
|
|
|
274
731
|
)
|
|
275
732
|
.attr('stroke-width', lab.leaderColor ? 1 : 0.75);
|
|
276
733
|
if (lab.poiId !== undefined) line.attr('data-poi', lab.poiId);
|
|
734
|
+
// Spiderfy member leader: toggle it with the badge (same as its text).
|
|
735
|
+
if (lab.clusterMember !== undefined)
|
|
736
|
+
line.attr('data-cluster-member', lab.clusterMember);
|
|
737
|
+
wireSync(line, lab.lineNumber);
|
|
277
738
|
}
|
|
278
739
|
const t = emitText(
|
|
279
740
|
gLabels,
|
|
@@ -284,13 +745,68 @@ export function renderMap(
|
|
|
284
745
|
lab.color,
|
|
285
746
|
lab.haloColor,
|
|
286
747
|
lab.halo,
|
|
287
|
-
LABEL_FONT
|
|
748
|
+
LABEL_FONT,
|
|
749
|
+
lab.italic,
|
|
750
|
+
lab.letterSpacing,
|
|
751
|
+
lab.lines
|
|
288
752
|
);
|
|
289
753
|
// POI labels are spotlightable: tag with the POI id and make the text the
|
|
290
754
|
// hover target (the app dims the other dots/labels on enter).
|
|
291
755
|
if (lab.poiId !== undefined) {
|
|
292
756
|
t.attr('data-poi', lab.poiId).style('cursor', 'default');
|
|
293
757
|
}
|
|
758
|
+
// Coincident-stack member label: hidden by the app when its stack collapses.
|
|
759
|
+
if (lab.clusterMember !== undefined) {
|
|
760
|
+
t.attr('data-cluster-member', lab.clusterMember);
|
|
761
|
+
}
|
|
762
|
+
// Click a region/POI label to jump to its source line (sets cursor:pointer,
|
|
763
|
+
// overriding the default above for POI labels).
|
|
764
|
+
wireSync(t, lab.lineNumber);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── Coincident-stack badges (collapsed view). Interactive ONLY — a static
|
|
768
|
+
// export keeps the expanded fan (every label visible), so no badge there. A
|
|
769
|
+
// neutral dot ringed with the bare member count, emitted hidden; the app shows
|
|
770
|
+
// it at rest and hides it (revealing the spider) on click. ──
|
|
771
|
+
if (!exportDims && layout.clusters.length) {
|
|
772
|
+
const gBadge = svg.append('g').attr('class', 'dgmo-map-cluster-badges');
|
|
773
|
+
for (const cl of layout.clusters) {
|
|
774
|
+
// Decorative: the hit-area (drawn under the dots) owns hover + click; the
|
|
775
|
+
// badge just shows the count, so it never intercepts pointer events.
|
|
776
|
+
const g = gBadge
|
|
777
|
+
.append('g')
|
|
778
|
+
.attr('data-cluster', cl.id)
|
|
779
|
+
.style('opacity', 0)
|
|
780
|
+
.style('pointer-events', 'none');
|
|
781
|
+
const R = 9;
|
|
782
|
+
// Inner neutral disc + outer ring → reads as "more than one here" (RQ2).
|
|
783
|
+
g.append('circle')
|
|
784
|
+
.attr('cx', cl.cx)
|
|
785
|
+
.attr('cy', cl.cy)
|
|
786
|
+
.attr('r', R)
|
|
787
|
+
.attr('fill', mix(palette.textMuted, palette.bg, 35))
|
|
788
|
+
.attr('stroke', palette.textMuted)
|
|
789
|
+
.attr('stroke-width', 1);
|
|
790
|
+
g.append('circle')
|
|
791
|
+
.attr('cx', cl.cx)
|
|
792
|
+
.attr('cy', cl.cy)
|
|
793
|
+
.attr('r', R + 2.5)
|
|
794
|
+
.attr('fill', 'none')
|
|
795
|
+
.attr('stroke', palette.textMuted)
|
|
796
|
+
.attr('stroke-width', 1);
|
|
797
|
+
// Bare count (RQ1).
|
|
798
|
+
emitText(
|
|
799
|
+
g,
|
|
800
|
+
cl.cx,
|
|
801
|
+
cl.cy + 3,
|
|
802
|
+
String(cl.count),
|
|
803
|
+
'middle',
|
|
804
|
+
palette.text,
|
|
805
|
+
palette.bg,
|
|
806
|
+
false,
|
|
807
|
+
LABEL_FONT
|
|
808
|
+
);
|
|
809
|
+
}
|
|
294
810
|
}
|
|
295
811
|
|
|
296
812
|
// ── Legend (categorical via renderLegendD3 + ramp/size/weight blocks; AR1) ──
|
|
@@ -347,6 +863,7 @@ export function renderMap(
|
|
|
347
863
|
if (layout.title) {
|
|
348
864
|
svg
|
|
349
865
|
.append('text')
|
|
866
|
+
.attr('class', 'dgmo-map-title')
|
|
350
867
|
.attr('x', width / 2)
|
|
351
868
|
.attr('y', TITLE_Y)
|
|
352
869
|
.attr('text-anchor', 'middle')
|
|
@@ -363,6 +880,7 @@ export function renderMap(
|
|
|
363
880
|
if (layout.subtitle) {
|
|
364
881
|
svg
|
|
365
882
|
.append('text')
|
|
883
|
+
.attr('class', 'dgmo-map-subtitle')
|
|
366
884
|
.attr('x', width / 2)
|
|
367
885
|
.attr('y', TITLE_Y + TITLE_FONT_SIZE)
|
|
368
886
|
.attr('text-anchor', 'middle')
|
|
@@ -415,7 +933,10 @@ function emitText(
|
|
|
415
933
|
color: string,
|
|
416
934
|
halo: string,
|
|
417
935
|
withHalo: boolean,
|
|
418
|
-
fontSize: number
|
|
936
|
+
fontSize: number,
|
|
937
|
+
italic?: boolean,
|
|
938
|
+
letterSpacing?: number,
|
|
939
|
+
lines?: readonly string[]
|
|
419
940
|
): d3Selection.Selection<SVGTextElement, unknown, null, undefined> {
|
|
420
941
|
const t = g
|
|
421
942
|
.append('text')
|
|
@@ -423,14 +944,36 @@ function emitText(
|
|
|
423
944
|
.attr('y', y)
|
|
424
945
|
.attr('text-anchor', anchor)
|
|
425
946
|
.attr('font-size', fontSize)
|
|
426
|
-
.attr('fill', color)
|
|
427
|
-
|
|
947
|
+
.attr('fill', color);
|
|
948
|
+
// Multi-line context labels (water names): stack centred tspans around `y` so
|
|
949
|
+
// the block's vertical centre matches the placement rect. Single-line keeps the
|
|
950
|
+
// bare `.text()` form so every other call site stays byte-identical.
|
|
951
|
+
if (lines && lines.length > 1) {
|
|
952
|
+
const lineHeight = fontSize + 2; // MUST match context-labels LINE_HEIGHT
|
|
953
|
+
const startDy = -((lines.length - 1) / 2) * lineHeight;
|
|
954
|
+
lines.forEach((ln, i) => {
|
|
955
|
+
t.append('tspan')
|
|
956
|
+
.attr('x', x)
|
|
957
|
+
.attr('dy', i === 0 ? startDy : lineHeight)
|
|
958
|
+
.text(ln);
|
|
959
|
+
});
|
|
960
|
+
} else {
|
|
961
|
+
t.text(text);
|
|
962
|
+
}
|
|
963
|
+
// Cartographic styling for context labels; absent on every other call site so
|
|
964
|
+
// existing output stays byte-identical (only emitted when explicitly set).
|
|
965
|
+
if (italic) t.attr('font-style', 'italic');
|
|
966
|
+
if (letterSpacing) t.attr('letter-spacing', letterSpacing);
|
|
428
967
|
if (withHalo) {
|
|
968
|
+
// Thin, even outline (2px / 1px-per-side at the 11px label font — 3px read
|
|
969
|
+
// top-heavy as adjacent glyph tops merged their strokes). Round join + cap
|
|
970
|
+
// keep the edge uniform around every glyph.
|
|
429
971
|
t.attr('paint-order', 'stroke fill')
|
|
430
972
|
.attr('stroke', halo)
|
|
431
|
-
.attr('stroke-width',
|
|
973
|
+
.attr('stroke-width', 2)
|
|
432
974
|
.attr('stroke-linejoin', 'round')
|
|
433
|
-
.attr('stroke-
|
|
975
|
+
.attr('stroke-linecap', 'round')
|
|
976
|
+
.attr('stroke-opacity', 0.55);
|
|
434
977
|
}
|
|
435
978
|
return t;
|
|
436
979
|
}
|