@diagrammo/dgmo 0.31.0 → 0.32.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/.cursorrules +4 -1
- package/.github/copilot-instructions.md +4 -1
- package/.windsurfrules +4 -1
- package/SKILL.md +4 -1
- package/dist/advanced.cjs +1297 -358
- package/dist/advanced.d.cts +117 -15
- package/dist/advanced.d.ts +117 -15
- package/dist/advanced.js +1291 -358
- package/dist/auto.cjs +1087 -316
- package/dist/auto.js +98 -98
- package/dist/auto.mjs +1087 -316
- package/dist/cli.cjs +140 -140
- package/dist/index.cjs +1090 -397
- package/dist/index.js +1090 -397
- package/docs/ai-integration.md +4 -1
- package/docs/language-reference.md +282 -27
- package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
- package/gallery/fixtures/c4-full.dgmo +4 -5
- package/gallery/fixtures/c4.dgmo +2 -3
- package/package.json +7 -1
- package/src/advanced.ts +7 -0
- package/src/boxes-and-lines/focus.ts +257 -0
- package/src/boxes-and-lines/layout-search.ts +131 -65
- package/src/boxes-and-lines/layout.ts +7 -1
- package/src/boxes-and-lines/parser.ts +19 -4
- package/src/boxes-and-lines/renderer.ts +54 -3
- package/src/c4/parser.ts +8 -7
- package/src/chart-type-registry.ts +129 -4
- package/src/chart-types.ts +4 -4
- package/src/chart.ts +18 -1
- package/src/colors.ts +225 -2
- package/src/cycle/parser.ts +2 -7
- package/src/d3.ts +67 -54
- package/src/diagnostics.ts +17 -0
- package/src/dimensions.ts +9 -13
- package/src/echarts.ts +42 -14
- package/src/er/parser.ts +6 -1
- package/src/gantt/parser.ts +44 -7
- package/src/graph/flowchart-parser.ts +77 -3
- package/src/graph/state-renderer.ts +2 -2
- package/src/infra/parser.ts +80 -0
- package/src/journey-map/parser.ts +8 -7
- package/src/kanban/parser.ts +8 -7
- package/src/map/context-labels.ts +134 -27
- package/src/map/geo.ts +10 -2
- package/src/map/layout.ts +259 -4
- package/src/map/parser.ts +2 -0
- package/src/map/renderer.ts +22 -11
- package/src/map/resolver.ts +68 -19
- package/src/mindmap/parser.ts +15 -7
- package/src/mindmap/renderer.ts +50 -12
- package/src/org/parser.ts +8 -7
- package/src/org/renderer.ts +22 -7
- package/src/palettes/color-utils.ts +12 -2
- package/src/palettes/index.ts +1 -0
- package/src/pert/renderer.ts +2 -2
- package/src/pyramid/parser.ts +2 -7
- package/src/quadrant/renderer.ts +2 -2
- package/src/raci/parser.ts +2 -7
- package/src/raci/renderer.ts +4 -4
- package/src/ring/parser.ts +2 -7
- package/src/sequence/parser.ts +18 -7
- package/src/sequence/renderer.ts +4 -4
- package/src/sitemap/parser.ts +8 -7
- package/src/sitemap/renderer.ts +2 -2
- package/src/tech-radar/parser.ts +2 -7
- package/src/timeline/renderer.ts +15 -5
- package/src/utils/parsing.ts +13 -1
- package/src/utils/scaling.ts +38 -81
- package/src/utils/tag-groups.ts +38 -0
- package/src/visualizations/parse.ts +6 -1
- package/src/wireframe/parser.ts +6 -1
|
@@ -34,6 +34,13 @@ export interface CountryCandidate {
|
|
|
34
34
|
* bypasses the both-axes smear gate (its full-canvas bbox is an antimeridian
|
|
35
35
|
* artifact, not a real footprint, so the unreliable-centroid concern is moot). */
|
|
36
36
|
readonly curatedAnchor?: boolean;
|
|
37
|
+
/** Ordered interior placement positions (screen coords), best-first: the commit
|
|
38
|
+
* loop tries each in turn and places at the first that clears all collisions, so
|
|
39
|
+
* a country can dodge a data cluster onto open ground on its own land
|
|
40
|
+
* (map-context-neighbor-labels, D7/D12). INVARIANT: either ABSENT or NON-EMPTY,
|
|
41
|
+
* and `anchor === positions[0]` always (an empty array is illegal — F7).
|
|
42
|
+
* Absent ⇒ single-anchor behaviour (`positions ?? [anchor]`). */
|
|
43
|
+
readonly positions?: readonly (readonly [number, number])[];
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface ContextLabelArgs {
|
|
@@ -48,6 +55,13 @@ export interface ContextLabelArgs {
|
|
|
48
55
|
readonly project: (lon: number, lat: number) => [number, number] | null;
|
|
49
56
|
/** Collision test against every committed data/region/POI/route obstacle. */
|
|
50
57
|
readonly collides: (rect: LabelRect) => boolean;
|
|
58
|
+
/** Screen positions of the diagram's CONTENT (POI markers / data points). When
|
|
59
|
+
* non-empty, country candidates rank by proximity to the NEAREST point (not a
|
|
60
|
+
* centroid — a centroid is dragged off by outlying POIs and pulls in irrelevant
|
|
61
|
+
* giants) so a thin budget labels the countries adjacent to the story (Belarus,
|
|
62
|
+
* Poland) rather than far-corner giants (Kazakhstan). Empty/absent ⇒ the legacy
|
|
63
|
+
* area-descending rank (map-context-neighbor-labels, proximity knob). */
|
|
64
|
+
readonly contentPoints?: readonly (readonly [number, number])[] | undefined;
|
|
51
65
|
/** True when the screen point sits over LAND (a country/state fill) rather than
|
|
52
66
|
* open water. WATER labels are rejected when their footprint touches land — an
|
|
53
67
|
* ocean name belongs over the ocean (they're optional orientation aids, so drop
|
|
@@ -76,6 +90,15 @@ const COUNTRY_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
|
|
|
76
90
|
const COUNTRY_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
|
|
77
91
|
const COUNTRY_FADE_MAX = 45; // % blend toward bg at max font (subdue big names)
|
|
78
92
|
|
|
93
|
+
// Multi-position country dodging (map-context-neighbor-labels). A country gets
|
|
94
|
+
// several interior on-its-own-land positions so its label can dodge a colliding
|
|
95
|
+
// data cluster into open space instead of being dropped. Layout generates the
|
|
96
|
+
// positions (geo work stays in layout); the commit loop below walks them
|
|
97
|
+
// best-first and places at the first that clears all collisions.
|
|
98
|
+
export const MAX_COUNTRY_POSITIONS = 4; // ordered positions per country (cap; D7/D15)
|
|
99
|
+
export const COUNTRY_POS_GRID = 5; // lon/lat sampling grid resolution N (N×N cells; D9)
|
|
100
|
+
export const COUNTRY_POS_TOPN_MARGIN = 3; // generate positions for top budget+margin (D15)
|
|
101
|
+
|
|
79
102
|
// Water-kind priority within a tier (oceans first, then seas, then the rest) so
|
|
80
103
|
// a thin budget always spends on the highest-orientation-value names.
|
|
81
104
|
const KIND_ORDER: Record<WaterKind, number> = {
|
|
@@ -107,12 +130,18 @@ export function labelBudget(
|
|
|
107
130
|
band: TierBand
|
|
108
131
|
): number {
|
|
109
132
|
const bandCap: Record<TierBand, number> = {
|
|
110
|
-
world:
|
|
111
|
-
continental:
|
|
112
|
-
regional:
|
|
113
|
-
local:
|
|
133
|
+
world: 10,
|
|
134
|
+
continental: 9,
|
|
135
|
+
regional: 7,
|
|
136
|
+
local: 6,
|
|
114
137
|
};
|
|
115
|
-
|
|
138
|
+
// Area divisor lowered 150→105 (map-context-neighbor-labels, budget knob): the
|
|
139
|
+
// old budget was so thin a regional view spent every slot on a couple of giant
|
|
140
|
+
// landmasses, leaving the countries that ring the story unlabeled. The lower
|
|
141
|
+
// divisor + raised band caps roughly +50% the slots so the proximity rank has
|
|
142
|
+
// room to surface the neighbourhood. Still floors to ~1 on a thumbnail / 0 on a
|
|
143
|
+
// tiny canvas (AC9).
|
|
144
|
+
const area = Math.floor(Math.sqrt(Math.max(0, width * height)) / 105);
|
|
116
145
|
return Math.max(0, Math.min(area, bandCap[band]));
|
|
117
146
|
}
|
|
118
147
|
|
|
@@ -217,6 +246,22 @@ function rectAround(
|
|
|
217
246
|
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
218
247
|
}
|
|
219
248
|
|
|
249
|
+
/** Squared distance from point (px,py) to an axis-aligned bbox [x0,y0,x1,y1]; 0
|
|
250
|
+
* when the point is inside. Used to rank country candidates by how close their
|
|
251
|
+
* footprint reaches to the diagram's content centre (proximity knob). */
|
|
252
|
+
function rectDist2(
|
|
253
|
+
px: number,
|
|
254
|
+
py: number,
|
|
255
|
+
x0: number,
|
|
256
|
+
y0: number,
|
|
257
|
+
x1: number,
|
|
258
|
+
y1: number
|
|
259
|
+
): number {
|
|
260
|
+
const dx = Math.max(x0 - px, 0, px - x1);
|
|
261
|
+
const dy = Math.max(y0 - py, 0, py - y1);
|
|
262
|
+
return dx * dx + dy * dy;
|
|
263
|
+
}
|
|
264
|
+
|
|
220
265
|
function rectFits(r: LabelRect, width: number, height: number): boolean {
|
|
221
266
|
return r.x >= 0 && r.y >= 0 && r.x + r.w <= width && r.y + r.h <= height;
|
|
222
267
|
}
|
|
@@ -244,6 +289,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
244
289
|
palette,
|
|
245
290
|
project,
|
|
246
291
|
collides,
|
|
292
|
+
contentPoints,
|
|
247
293
|
overLand,
|
|
248
294
|
} = args;
|
|
249
295
|
|
|
@@ -276,6 +322,9 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
276
322
|
color: string;
|
|
277
323
|
fontSize: number;
|
|
278
324
|
sort: number; // priority key (lower first)
|
|
325
|
+
// Ordered dodge positions (screen coords), best-first. Absent on water (single
|
|
326
|
+
// anchor); on a country, mirrors CountryCandidate.positions (anchor === [0]).
|
|
327
|
+
positions?: readonly (readonly [number, number])[];
|
|
279
328
|
};
|
|
280
329
|
const candidates: Candidate[] = [];
|
|
281
330
|
|
|
@@ -360,18 +409,39 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
360
409
|
});
|
|
361
410
|
}
|
|
362
411
|
|
|
363
|
-
// -- Country candidates (unreferenced
|
|
364
|
-
// Rank
|
|
365
|
-
//
|
|
412
|
+
// -- Country candidates (unreferenced) --
|
|
413
|
+
// Rank PROXIMITY-FIRST when a content centre is known: the countries that ring
|
|
414
|
+
// the diagram's story should win the thin budget, not whichever giants happen to
|
|
415
|
+
// be biggest in frame (Kazakhstan/Sweden on a Ukraine map). The rank's anchor is
|
|
416
|
+
// the candidate's primary position (`positions[0]` === `anchor`). Without a
|
|
417
|
+
// content centre, fall back to the legacy biggest-area-first rank. Area is still
|
|
418
|
+
// computed (it drives the font/fade ramp and the smear gate below).
|
|
366
419
|
const ranked = countries
|
|
367
420
|
.map((c) => {
|
|
368
421
|
const [x0, y0, x1, y1] = c.bbox;
|
|
369
422
|
const w = x1 - x0;
|
|
370
423
|
const h = y1 - y0;
|
|
371
|
-
|
|
424
|
+
// Distance from the NEAREST content point to the country's FOOTPRINT (0 if a
|
|
425
|
+
// point is inside the bbox, else squared distance to the nearest edge). Edge
|
|
426
|
+
// distance — not centroid distance — so a large neighbour that REACHES toward
|
|
427
|
+
// the action (Poland) outranks a tiny country merely near a point (the
|
|
428
|
+
// Baltics), with no size-weighting constant; nearest-point — not a single
|
|
429
|
+
// centroid — so an outlying POI doesn't drag the rank toward distant giants.
|
|
430
|
+
let dist = Infinity;
|
|
431
|
+
if (contentPoints?.length) {
|
|
432
|
+
for (const p of contentPoints) {
|
|
433
|
+
const d = rectDist2(p[0], p[1], x0, y0, x1, y1);
|
|
434
|
+
if (d < dist) dist = d;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { c, w, h, area: w * h, dist };
|
|
372
438
|
})
|
|
373
439
|
.filter((r) => Number.isFinite(r.area) && r.area > 0)
|
|
374
|
-
.sort((a, b) =>
|
|
440
|
+
.sort((a, b) =>
|
|
441
|
+
contentPoints?.length
|
|
442
|
+
? a.dist - b.dist || b.area - a.area // nearest the story first; area breaks ties
|
|
443
|
+
: b.area - a.area
|
|
444
|
+
);
|
|
375
445
|
// Canvas linear extent — the denominator for the footprint size ramp below.
|
|
376
446
|
const canvasLinear = Math.sqrt(Math.max(1, width * height));
|
|
377
447
|
let ci = 0;
|
|
@@ -406,7 +476,14 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
406
476
|
);
|
|
407
477
|
const fontSize = Math.round(FONT + t * (COUNTRY_FONT_MAX - FONT));
|
|
408
478
|
const fade = Math.round(t * COUNTRY_FADE_MAX);
|
|
409
|
-
|
|
479
|
+
// Blend `fade`% TOWARD the bg (subdue big names). `mix(a,b,pct)` weights `a` by
|
|
480
|
+
// `pct`, so the bg fraction must be `100 - fade` — i.e. a small country (fade≈0)
|
|
481
|
+
// stays fully muted/dark and only a big landmass fades to a soft backdrop. (The
|
|
482
|
+
// earlier `mix(countryColor, bg, fade)` was inverted: it lightened the SMALL
|
|
483
|
+
// names toward white instead, which read as illegible once proximity started
|
|
484
|
+
// surfacing small neighbours like Belarus/Georgia.)
|
|
485
|
+
const color =
|
|
486
|
+
fade > 0 ? mix(countryColor, palette.bg, 100 - fade) : countryColor;
|
|
410
487
|
// Always the full country name — never an ISO abbreviation. If the name
|
|
411
488
|
// doesn't fit the footprint, drop the label rather than abbreviate.
|
|
412
489
|
const text = c.name;
|
|
@@ -424,6 +501,9 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
424
501
|
letterSpacing: 0,
|
|
425
502
|
color,
|
|
426
503
|
fontSize,
|
|
504
|
+
// Multi-position dodging: carry the ordered interior positions through to the
|
|
505
|
+
// commit loop. Invariant anchor === positions[0], so `cx/cy` is positions[0].
|
|
506
|
+
...(c.positions ? { positions: c.positions } : {}),
|
|
427
507
|
// Band 1 (orientation-value ranking): above MINOR water (band 2, 2000+) but
|
|
428
508
|
// below MAJOR water — oceans + major seas (band 0, ≤~16). So a big country
|
|
429
509
|
// (US, Canada, Russia) outranks a minor sea/bay (Sargasso, Bahía de
|
|
@@ -445,17 +525,25 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
445
525
|
const countryCount = candidates.reduce((n, c) => n + (c.italic ? 0 : 1), 0);
|
|
446
526
|
const waterCap = budget - Math.min(2, countryCount);
|
|
447
527
|
let waterPlaced = 0;
|
|
448
|
-
for
|
|
449
|
-
|
|
450
|
-
|
|
528
|
+
// Test one trial position for a candidate against every gate (fit, water-on-land,
|
|
529
|
+
// committed-obstacle collision, context-overlap). Returns the placed rect when the
|
|
530
|
+
// position clears, else null. Country candidates carry several ordered positions
|
|
531
|
+
// (map-context-neighbor-labels); we walk them best-first and commit the first that
|
|
532
|
+
// clears, so a label dodges a colliding cluster instead of being dropped. Water
|
|
533
|
+
// candidates have a single position, so behaviour is unchanged for them.
|
|
534
|
+
const gateAt = (
|
|
535
|
+
cx: number,
|
|
536
|
+
cy: number,
|
|
537
|
+
cand: Candidate
|
|
538
|
+
): LabelRect | null => {
|
|
451
539
|
const rect = rectAround(
|
|
452
|
-
|
|
453
|
-
|
|
540
|
+
cx,
|
|
541
|
+
cy,
|
|
454
542
|
cand.lines,
|
|
455
543
|
cand.letterSpacing,
|
|
456
544
|
cand.fontSize
|
|
457
545
|
);
|
|
458
|
-
if (!rectFits(rect, width, height))
|
|
546
|
+
if (!rectFits(rect, width, height)) return null;
|
|
459
547
|
// Water labels must sit over OPEN WATER and NEVER touch land — sample a grid
|
|
460
548
|
// over every wrapped line (each line's own horizontal extent at five points);
|
|
461
549
|
// drop the whole label if ANY sample hits land (Decision: optional orientation
|
|
@@ -463,12 +551,12 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
463
551
|
// exempt — they belong on their country.
|
|
464
552
|
if (cand.italic && overLand) {
|
|
465
553
|
const inset = 2;
|
|
466
|
-
const top =
|
|
554
|
+
const top = cy - ((cand.lines.length - 1) / 2) * LINE_HEIGHT;
|
|
467
555
|
const touchesLand = cand.lines.some((line, li) => {
|
|
468
556
|
const lw = labelWidth(line, cand.letterSpacing);
|
|
469
|
-
const x0 =
|
|
470
|
-
const x1 =
|
|
471
|
-
const xs = [x0, (x0 +
|
|
557
|
+
const x0 = cx - lw / 2 + inset;
|
|
558
|
+
const x1 = cx + lw / 2 - inset;
|
|
559
|
+
const xs = [x0, (x0 + cx) / 2, cx, (cx + x1) / 2, x1];
|
|
472
560
|
const base = top + li * LINE_HEIGHT;
|
|
473
561
|
// Sample the glyph body top→baseline (text rises above the baseline) so a
|
|
474
562
|
// label whose ascenders clip a coastline is rejected, not just one whose
|
|
@@ -477,15 +565,34 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
|
|
|
477
565
|
xs.some((x) => overLand(x, y))
|
|
478
566
|
);
|
|
479
567
|
});
|
|
480
|
-
if (touchesLand)
|
|
568
|
+
if (touchesLand) return null;
|
|
569
|
+
}
|
|
570
|
+
if (collides(rect)) return null;
|
|
571
|
+
if (placedRects.some((r) => overlapsPadded(rect, r, CONTEXT_PAD)))
|
|
572
|
+
return null;
|
|
573
|
+
return rect;
|
|
574
|
+
};
|
|
575
|
+
for (const cand of candidates) {
|
|
576
|
+
if (placed.length >= budget) break;
|
|
577
|
+
if (cand.italic && waterPlaced >= waterCap) continue;
|
|
578
|
+
// Walk positions best-first; commit at the first that clears every gate. F8:
|
|
579
|
+
// `positions ?? [[cx,cy]]` keeps single-anchor (water + non-top-N country)
|
|
580
|
+
// behaviour identical. If none clears → drop (no halo, no overlap — D16).
|
|
581
|
+
const positions = cand.positions ?? [[cand.cx, cand.cy]];
|
|
582
|
+
let chosen: { x: number; y: number; rect: LabelRect } | null = null;
|
|
583
|
+
for (const [px, py] of positions) {
|
|
584
|
+
const rect = gateAt(px!, py!, cand);
|
|
585
|
+
if (rect) {
|
|
586
|
+
chosen = { x: px!, y: py!, rect };
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
481
589
|
}
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
placedRects.push(rect);
|
|
590
|
+
if (!chosen) continue;
|
|
591
|
+
placedRects.push(chosen.rect);
|
|
485
592
|
if (cand.italic) waterPlaced++;
|
|
486
593
|
placed.push({
|
|
487
|
-
x:
|
|
488
|
-
y:
|
|
594
|
+
x: chosen.x,
|
|
595
|
+
y: chosen.y,
|
|
489
596
|
text: cand.text,
|
|
490
597
|
anchor: 'middle',
|
|
491
598
|
color: cand.color,
|
package/src/map/geo.ts
CHANGED
|
@@ -171,8 +171,16 @@ function pointOnRingEdge(
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/** Point-in-polygon for a Polygon/MultiPolygon geometry (outer ring minus holes).
|
|
174
|
-
* A point on the outer boundary counts as inside (deterministic border handling).
|
|
175
|
-
|
|
174
|
+
* A point on the outer boundary counts as inside (deterministic border handling).
|
|
175
|
+
* Exported so layout's context-label dodge-position generation can validate that
|
|
176
|
+
* a candidate interior point sits on the country's OWN rendered geometry —
|
|
177
|
+
* holes-aware, so enclaves and neighbours are both rejected (map-context-neighbor
|
|
178
|
+
* -labels D8). */
|
|
179
|
+
export function pointInGeometry(
|
|
180
|
+
geometry: unknown,
|
|
181
|
+
lon: number,
|
|
182
|
+
lat: number
|
|
183
|
+
): boolean {
|
|
176
184
|
const g = geometry as {
|
|
177
185
|
type: string;
|
|
178
186
|
coordinates: number[][][] | number[][][][];
|
package/src/map/layout.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
politicalTints,
|
|
26
26
|
valueRampColor,
|
|
27
27
|
} from '../palettes/color-utils';
|
|
28
|
-
import { buildAdjacency, featureBboxPrimary } from './geo';
|
|
28
|
+
import { buildAdjacency, featureBboxPrimary, pointInGeometry } from './geo';
|
|
29
29
|
import { assignColors } from './colorize';
|
|
30
30
|
import { resolveColor } from '../colors';
|
|
31
31
|
import type { PaletteColors } from '../palettes/types';
|
|
@@ -52,7 +52,14 @@ import type {
|
|
|
52
52
|
ProjectionFamily,
|
|
53
53
|
GeoExtent,
|
|
54
54
|
} from './resolved-types';
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
placeContextLabels,
|
|
57
|
+
tierBand,
|
|
58
|
+
labelBudget,
|
|
59
|
+
MAX_COUNTRY_POSITIONS,
|
|
60
|
+
COUNTRY_POS_GRID,
|
|
61
|
+
COUNTRY_POS_TOPN_MARGIN,
|
|
62
|
+
} from './context-labels';
|
|
56
63
|
import type { CountryCandidate } from './context-labels';
|
|
57
64
|
|
|
58
65
|
// Minimal GeoJSON shapes (avoid a hard @types/geojson dep; cast at d3 calls).
|
|
@@ -316,6 +323,17 @@ export interface MapLayoutRegion {
|
|
|
316
323
|
* area-weighted centroid stays on the body. Honours WORLD_LABEL_ANCHORS. */
|
|
317
324
|
readonly labelX?: number;
|
|
318
325
|
readonly labelY?: number;
|
|
326
|
+
/** Screen-space bounding box `[minX, minY, maxX, maxY]` of the drawn path,
|
|
327
|
+
* computed once in `layoutMap` (reusing the `fillAt` hit-target parse) so the
|
|
328
|
+
* renderer's per-POI-label region cull doesn't re-parse every path string per
|
|
329
|
+
* label blob. Absent only if the layout was built before this field existed —
|
|
330
|
+
* the renderer falls back to parsing `d`. */
|
|
331
|
+
bbox?: readonly [number, number, number, number];
|
|
332
|
+
/** Parsed screen-space rings of `d`, computed once in `layoutMap` (the same
|
|
333
|
+
* `fillAt` hit-target parse as `bbox`) so the renderer's coastline buffering
|
|
334
|
+
* doesn't re-parse every region path on every render. Absent only for layouts
|
|
335
|
+
* predating this field — callers fall back to `parsePathRings(d)`. */
|
|
336
|
+
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
|
319
337
|
}
|
|
320
338
|
|
|
321
339
|
/** A framed inset "cutout" (albers-usa AK/HI), in screen px. The frame is a
|
|
@@ -708,6 +726,139 @@ function decodeLayer(topo: BoundaryTopology): Map<string, GeoFeature> {
|
|
|
708
726
|
return out;
|
|
709
727
|
}
|
|
710
728
|
|
|
729
|
+
/** Generate ordered interior label positions for a country, screen-projected and
|
|
730
|
+
* best-first, so its context label can DODGE a colliding data cluster onto open
|
|
731
|
+
* ground on its own land (map-context-neighbor-labels, Opt F). PURE +
|
|
732
|
+
* DETERMINISTIC — the geo work that the pure context-labels module must not do.
|
|
733
|
+
*
|
|
734
|
+
* Algorithm (D8/D9): lay a `COUNTRY_POS_GRID²` lon/lat grid over the feature's
|
|
735
|
+
* geographic bbox; keep cells that are (a) on the country's OWN rendered geometry
|
|
736
|
+
* (`pointInGeometry` — holes-aware, so neighbours AND enclaves are rejected) and
|
|
737
|
+
* (b) project to a finite point inside the viewport (its visible lobe). Order the
|
|
738
|
+
* kept cells: the most-interior cell (most on-land 8-grid-neighbours, tie-broken
|
|
739
|
+
* by proximity to the visible centroid) leads, then a greedy max-min spread of the
|
|
740
|
+
* rest so fallbacks actually dodge. A `curated` lon/lat (WORLD_LABEL_ANCHORS) is
|
|
741
|
+
* forced to slot 0 with grid cells filling the rest (D13).
|
|
742
|
+
*
|
|
743
|
+
* Returns `{ lonLat, screen }[]` (≤ MAX_COUNTRY_POSITIONS), or `[]` when the
|
|
744
|
+
* geometry yields no valid in-frame position (caller falls back to the single
|
|
745
|
+
* centroid anchor, D11). The `lonLat` is exposed so tests can verify on-own-land
|
|
746
|
+
* containment with an INDEPENDENT oracle (not a re-call of the acceptance test). */
|
|
747
|
+
export function countryLabelPositions(args: {
|
|
748
|
+
geometry: unknown;
|
|
749
|
+
bounds: readonly [readonly [number, number], readonly [number, number]];
|
|
750
|
+
project: (lon: number, lat: number) => [number, number] | null;
|
|
751
|
+
width: number;
|
|
752
|
+
height: number;
|
|
753
|
+
curated?: readonly [number, number] | null;
|
|
754
|
+
}): { lonLat: [number, number]; screen: [number, number] }[] {
|
|
755
|
+
const { geometry, bounds, project, width, height, curated } = args;
|
|
756
|
+
const w0 = bounds[0][0];
|
|
757
|
+
const s0 = bounds[0][1];
|
|
758
|
+
const e0 = bounds[1][0];
|
|
759
|
+
const n0 = bounds[1][1];
|
|
760
|
+
// Bail on non-finite, antimeridian-wrapping (e0 < w0 — NE crossers ship seam-split,
|
|
761
|
+
// but a feature whose own bbox still wraps falls back to the single anchor), or
|
|
762
|
+
// degenerate (zero-span) bboxes — the grid math needs a positive lon/lat span. `<=`
|
|
763
|
+
// makes the zero-span case explicit rather than relying on downstream emptiness
|
|
764
|
+
// (D9/D11).
|
|
765
|
+
if (![w0, s0, e0, n0].every(Number.isFinite) || e0 <= w0 || n0 <= s0) {
|
|
766
|
+
return mkCurated(curated, project);
|
|
767
|
+
}
|
|
768
|
+
const N = COUNTRY_POS_GRID;
|
|
769
|
+
// onLand[i][j]: cell centre is on the country's own geometry (for interiorness).
|
|
770
|
+
const onLand: boolean[][] = [];
|
|
771
|
+
type Cell = {
|
|
772
|
+
i: number;
|
|
773
|
+
j: number;
|
|
774
|
+
lon: number;
|
|
775
|
+
lat: number;
|
|
776
|
+
sx: number;
|
|
777
|
+
sy: number;
|
|
778
|
+
};
|
|
779
|
+
const kept: Cell[] = [];
|
|
780
|
+
for (let i = 0; i < N; i++) {
|
|
781
|
+
onLand[i] = [];
|
|
782
|
+
const lon = w0 + ((i + 0.5) / N) * (e0 - w0);
|
|
783
|
+
for (let j = 0; j < N; j++) {
|
|
784
|
+
const lat = s0 + ((j + 0.5) / N) * (n0 - s0);
|
|
785
|
+
const land = pointInGeometry(geometry, lon, lat);
|
|
786
|
+
onLand[i]![j] = land;
|
|
787
|
+
if (!land) continue;
|
|
788
|
+
const p = project(lon, lat);
|
|
789
|
+
if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
|
|
790
|
+
// Only the visible lobe: an off-frame cell can never host a fitting label.
|
|
791
|
+
if (p[0] < 0 || p[0] > width || p[1] < 0 || p[1] > height) continue;
|
|
792
|
+
kept.push({ i, j, lon, lat, sx: p[0], sy: p[1] });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (!kept.length) return mkCurated(curated, project);
|
|
796
|
+
// Visible centroid (mean of kept screen points) — tie-breaks interiorness toward
|
|
797
|
+
// the country's in-frame mass.
|
|
798
|
+
const cx = kept.reduce((s, c) => s + c.sx, 0) / kept.length;
|
|
799
|
+
const cy = kept.reduce((s, c) => s + c.sy, 0) / kept.length;
|
|
800
|
+
const interiorness = (c: Cell): number => {
|
|
801
|
+
let n = 0;
|
|
802
|
+
for (let di = -1; di <= 1; di++)
|
|
803
|
+
for (let dj = -1; dj <= 1; dj++) {
|
|
804
|
+
if (di === 0 && dj === 0) continue;
|
|
805
|
+
const ni = c.i + di;
|
|
806
|
+
const nj = c.j + dj;
|
|
807
|
+
if (ni >= 0 && ni < N && nj >= 0 && nj < N && onLand[ni]![nj]) n++;
|
|
808
|
+
}
|
|
809
|
+
return n;
|
|
810
|
+
};
|
|
811
|
+
const dist2ToCentre = (c: Cell): number =>
|
|
812
|
+
(c.sx - cx) ** 2 + (c.sy - cy) ** 2;
|
|
813
|
+
// Most-interior cell leads (tie → nearest the visible centroid).
|
|
814
|
+
const pool = [...kept];
|
|
815
|
+
pool.sort((a, b) => {
|
|
816
|
+
const d = interiorness(b) - interiorness(a);
|
|
817
|
+
return d !== 0 ? d : dist2ToCentre(a) - dist2ToCentre(b);
|
|
818
|
+
});
|
|
819
|
+
// grid ordering: best cell, then greedy max-min spread of the rest.
|
|
820
|
+
const ordered: Cell[] = [pool.shift()!];
|
|
821
|
+
while (ordered.length < MAX_COUNTRY_POSITIONS && pool.length) {
|
|
822
|
+
let bestIdx = 0;
|
|
823
|
+
let bestMin = -1;
|
|
824
|
+
for (let k = 0; k < pool.length; k++) {
|
|
825
|
+
const c = pool[k]!;
|
|
826
|
+
let minD = Infinity;
|
|
827
|
+
for (const o of ordered) {
|
|
828
|
+
const d = (c.sx - o.sx) ** 2 + (c.sy - o.sy) ** 2;
|
|
829
|
+
if (d < minD) minD = d;
|
|
830
|
+
}
|
|
831
|
+
if (minD > bestMin) {
|
|
832
|
+
bestMin = minD;
|
|
833
|
+
bestIdx = k;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
ordered.push(pool.splice(bestIdx, 1)[0]!);
|
|
837
|
+
}
|
|
838
|
+
const grid = ordered.map((c) => ({
|
|
839
|
+
lonLat: [c.lon, c.lat] as [number, number],
|
|
840
|
+
screen: [c.sx, c.sy] as [number, number],
|
|
841
|
+
}));
|
|
842
|
+
// Curated anchor (D13): forced to slot 0; grid cells fill the rest.
|
|
843
|
+
const curatedPos = curated
|
|
844
|
+
? mkCurated(curated, project)
|
|
845
|
+
: ([] as { lonLat: [number, number]; screen: [number, number] }[]);
|
|
846
|
+
const out = [...curatedPos, ...grid].slice(0, MAX_COUNTRY_POSITIONS);
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Project a single curated lon/lat into the position-list shape (or [] when it
|
|
851
|
+
* doesn't project finitely). Helper for `countryLabelPositions`. */
|
|
852
|
+
function mkCurated(
|
|
853
|
+
curated: readonly [number, number] | null | undefined,
|
|
854
|
+
project: (lon: number, lat: number) => [number, number] | null
|
|
855
|
+
): { lonLat: [number, number]; screen: [number, number] }[] {
|
|
856
|
+
if (!curated) return [];
|
|
857
|
+
const p = project(curated[0], curated[1]);
|
|
858
|
+
if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) return [];
|
|
859
|
+
return [{ lonLat: [curated[0], curated[1]], screen: [p[0], p[1]] }];
|
|
860
|
+
}
|
|
861
|
+
|
|
711
862
|
// Our own US map (replaces d3 geoAlbersUsa, whose fixed composite clips
|
|
712
863
|
// Canada/Mexico to hard lines and bakes in inset boxes we can't control). A
|
|
713
864
|
// plain Albers conic for the contiguous 48 — it does NOT clip, so neighbour land
|
|
@@ -2312,6 +2463,20 @@ export function layoutMap(
|
|
|
2312
2463
|
if (p[1] < minY) minY = p[1];
|
|
2313
2464
|
if (p[1] > maxY) maxY = p[1];
|
|
2314
2465
|
}
|
|
2466
|
+
// Stash the bbox + parsed rings on the region so the renderer's per-POI-label
|
|
2467
|
+
// cull (bbox) and coastline buffering (rings) reuse this parse instead of
|
|
2468
|
+
// re-parsing `d` (roadmap #2/#4).
|
|
2469
|
+
(
|
|
2470
|
+
r as {
|
|
2471
|
+
bbox?: readonly [number, number, number, number];
|
|
2472
|
+
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
|
2473
|
+
}
|
|
2474
|
+
).bbox = [minX, minY, maxX, maxY];
|
|
2475
|
+
(
|
|
2476
|
+
r as {
|
|
2477
|
+
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
|
2478
|
+
}
|
|
2479
|
+
).rings = rings;
|
|
2315
2480
|
return { fill: r.fill, rings, minX, minY, maxX, maxY };
|
|
2316
2481
|
});
|
|
2317
2482
|
const fillAt = (x: number, y: number): string => {
|
|
@@ -4238,6 +4403,19 @@ export function layoutMap(
|
|
|
4238
4403
|
// work (bbox/anchor) stays here; area-rank + fit + collision live in the
|
|
4239
4404
|
// pure module so the strict density invariants (AC7) are unit-testable.
|
|
4240
4405
|
const countryCandidates: CountryCandidate[] = [];
|
|
4406
|
+
// Pass 1: collect the raw country records (feature + screen bbox/anchor),
|
|
4407
|
+
// carrying `f` so pass 2 can generate dodge positions from the SAME rendered
|
|
4408
|
+
// geometry (D14 — no re-decode).
|
|
4409
|
+
type RawCountry = {
|
|
4410
|
+
f: GeoFeature;
|
|
4411
|
+
iso: string;
|
|
4412
|
+
name: string;
|
|
4413
|
+
bbox: [number, number, number, number];
|
|
4414
|
+
anchor: [number, number] | null;
|
|
4415
|
+
curatedLngLat: readonly [number, number] | null;
|
|
4416
|
+
area: number;
|
|
4417
|
+
};
|
|
4418
|
+
const rawCountries: RawCountry[] = [];
|
|
4241
4419
|
for (const f of worldLayer.values()) {
|
|
4242
4420
|
const iso = typeof f.id === 'string' ? f.id : String(f.id ?? '');
|
|
4243
4421
|
if (!iso || regionById.has(iso)) continue;
|
|
@@ -4259,11 +4437,87 @@ export function layoutMap(
|
|
|
4259
4437
|
const a = anchorLngLat
|
|
4260
4438
|
? project(anchorLngLat[0], anchorLngLat[1])
|
|
4261
4439
|
: (path.centroid(f as never) as [number, number]);
|
|
4262
|
-
|
|
4440
|
+
rawCountries.push({
|
|
4441
|
+
f,
|
|
4442
|
+
iso,
|
|
4263
4443
|
name: (f.properties as { name?: string } | undefined)?.name ?? iso,
|
|
4264
4444
|
bbox: [x0, y0, x1, y1],
|
|
4265
4445
|
anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
|
|
4266
|
-
|
|
4446
|
+
curatedLngLat: anchorLngLat ?? null,
|
|
4447
|
+
area: (x1 - x0) * (y1 - y0),
|
|
4448
|
+
});
|
|
4449
|
+
}
|
|
4450
|
+
// Pass 2: generate multi-position dodge candidates EAGERLY for the top
|
|
4451
|
+
// `budget + margin` area-ranked countries only — only that many can win a slot,
|
|
4452
|
+
// so generating for all ~45 in-view countries is wasted PiP work (D15). The
|
|
4453
|
+
// band/budget helpers live in the pure module; compute them here since layout
|
|
4454
|
+
// doesn't otherwise hold them. Positions[0] becomes the anchor so the
|
|
4455
|
+
// single-anchor `anchor === positions[0]` invariant (D12) holds.
|
|
4456
|
+
const cBand = tierBand(Math.max(dLonSpan, dLatSpan));
|
|
4457
|
+
const cBudget = labelBudget(width, height, cBand);
|
|
4458
|
+
// Content points = the POI markers (the diagram's story). Drive BOTH the
|
|
4459
|
+
// proximity rank in placeContextLabels AND which countries get dodge positions
|
|
4460
|
+
// generated here, so the near-action winners are equipped to dodge
|
|
4461
|
+
// (map-context-neighbor-labels, proximity knob). Empty ⇒ legacy area rank.
|
|
4462
|
+
const contentPoints: [number, number][] = markers.map((m) => [m.cx, m.cy]);
|
|
4463
|
+
// Generate dodge positions for the top `budget + margin` candidates ranked the
|
|
4464
|
+
// SAME way placeContextLabels will pick winners — by proximity to the nearest
|
|
4465
|
+
// content point when known, else by area. (Generating for all ~45 in-view
|
|
4466
|
+
// countries is wasted PiP work; D15.) The +MARGIN slack covers the rank skew
|
|
4467
|
+
// from the module's extra fit/viewport filtering, so the eventual winner still
|
|
4468
|
+
// carries dodge positions.
|
|
4469
|
+
const topN = cBudget + COUNTRY_POS_TOPN_MARGIN;
|
|
4470
|
+
const rankOrder = rawCountries
|
|
4471
|
+
.map((r, idx) => {
|
|
4472
|
+
// Match placeContextLabels' rank: distance from the NEAREST content point to
|
|
4473
|
+
// the country's footprint bbox (0 if inside, else nearest-edge), so the same
|
|
4474
|
+
// near-the-action winners get dodge positions generated.
|
|
4475
|
+
let dist = Infinity;
|
|
4476
|
+
const [x0, y0, x1, y1] = r.bbox;
|
|
4477
|
+
for (const [px, py] of contentPoints) {
|
|
4478
|
+
const dx = Math.max(x0 - px, 0, px - x1);
|
|
4479
|
+
const dy = Math.max(y0 - py, 0, py - y1);
|
|
4480
|
+
const d = dx * dx + dy * dy;
|
|
4481
|
+
if (d < dist) dist = d;
|
|
4482
|
+
}
|
|
4483
|
+
return { idx, area: r.area, dist };
|
|
4484
|
+
})
|
|
4485
|
+
.filter((r) => Number.isFinite(r.area) && r.area > 0)
|
|
4486
|
+
.sort((a, b) =>
|
|
4487
|
+
contentPoints.length
|
|
4488
|
+
? a.dist - b.dist || b.area - a.area
|
|
4489
|
+
: b.area - a.area
|
|
4490
|
+
)
|
|
4491
|
+
.slice(0, topN);
|
|
4492
|
+
const genIdx = new Set(rankOrder.map((r) => r.idx));
|
|
4493
|
+
for (let i = 0; i < rawCountries.length; i++) {
|
|
4494
|
+
const r = rawCountries[i]!;
|
|
4495
|
+
let anchor = r.anchor;
|
|
4496
|
+
let positions: readonly (readonly [number, number])[] | undefined;
|
|
4497
|
+
if (genIdx.has(i) && anchor) {
|
|
4498
|
+
const gb = geoBounds(r.f as never) as [
|
|
4499
|
+
[number, number],
|
|
4500
|
+
[number, number],
|
|
4501
|
+
];
|
|
4502
|
+
const gen = countryLabelPositions({
|
|
4503
|
+
geometry: r.f.geometry,
|
|
4504
|
+
bounds: gb,
|
|
4505
|
+
project,
|
|
4506
|
+
width,
|
|
4507
|
+
height,
|
|
4508
|
+
curated: r.curatedLngLat,
|
|
4509
|
+
});
|
|
4510
|
+
if (gen.length) {
|
|
4511
|
+
positions = gen.map((p) => p.screen);
|
|
4512
|
+
anchor = positions[0] as [number, number]; // D12: anchor === positions[0]
|
|
4513
|
+
}
|
|
4514
|
+
}
|
|
4515
|
+
countryCandidates.push({
|
|
4516
|
+
name: r.name,
|
|
4517
|
+
bbox: r.bbox,
|
|
4518
|
+
anchor,
|
|
4519
|
+
curatedAnchor: !!r.curatedLngLat,
|
|
4520
|
+
...(positions ? { positions } : {}),
|
|
4267
4521
|
});
|
|
4268
4522
|
}
|
|
4269
4523
|
// Framed US states (POI-only region framing): when the frame is snapped to a
|
|
@@ -4310,6 +4564,7 @@ export function layoutMap(
|
|
|
4310
4564
|
palette,
|
|
4311
4565
|
project,
|
|
4312
4566
|
collides,
|
|
4567
|
+
contentPoints,
|
|
4313
4568
|
// Water labels must stay over open water — `fillAt` returns the ocean
|
|
4314
4569
|
// backdrop colour off-land and a region fill on-land (lakes/states count
|
|
4315
4570
|
// as land here, which is the safe side for an ocean name).
|
package/src/map/parser.ts
CHANGED
|
@@ -102,6 +102,8 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
|
|
|
102
102
|
diagnostics.push(makeDgmoError(line, message, 'error', code));
|
|
103
103
|
result.error ??= formatDgmoError(diagnostics[diagnostics.length - 1]!);
|
|
104
104
|
};
|
|
105
|
+
// Bespoke (not the shared makeFail, Story 111.4): map delegates to pushError,
|
|
106
|
+
// which is first-error-wins (`??=`) and carries an optional diagnostic code.
|
|
105
107
|
const fail = (line: number, message: string): ParsedMap => {
|
|
106
108
|
pushError(line, message);
|
|
107
109
|
return result;
|
package/src/map/renderer.ts
CHANGED
|
@@ -135,7 +135,8 @@ function coastlineOuterRings(
|
|
|
135
135
|
): string[] {
|
|
136
136
|
const paths: string[] = [];
|
|
137
137
|
for (const r of regions) {
|
|
138
|
-
|
|
138
|
+
// Reuse the rings parsed once in layoutMap; fall back for older layouts.
|
|
139
|
+
const rings = (r.rings as Array<Array<[number, number]>>) ?? parsePathRings(r.d);
|
|
139
140
|
for (let i = 0; i < rings.length; i++) {
|
|
140
141
|
const ring = rings[i]!;
|
|
141
142
|
if (ring.length < 3) continue;
|
|
@@ -681,17 +682,27 @@ export function renderMap(
|
|
|
681
682
|
// label, so the patch is seamless and the only visible change is the
|
|
682
683
|
// vanished line work.
|
|
683
684
|
for (const r of layout.regions) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
685
|
+
// bbox is precomputed once in layoutMap (roadmap #2); fall back to
|
|
686
|
+
// parsing only for layouts predating that field.
|
|
687
|
+
let minX: number,
|
|
688
|
+
minY: number,
|
|
689
|
+
maxX: number,
|
|
690
|
+
maxY: number;
|
|
691
|
+
if (r.bbox) {
|
|
692
|
+
[minX, minY, maxX, maxY] = r.bbox;
|
|
693
|
+
} else {
|
|
694
|
+
minX = Infinity;
|
|
695
|
+
minY = Infinity;
|
|
696
|
+
maxX = -Infinity;
|
|
687
697
|
maxY = -Infinity;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
698
|
+
for (const ring of parsePathRings(r.d))
|
|
699
|
+
for (const [px, py] of ring) {
|
|
700
|
+
if (px < minX) minX = px;
|
|
701
|
+
if (px > maxX) maxX = px;
|
|
702
|
+
if (py < minY) minY = py;
|
|
703
|
+
if (py > maxY) maxY = py;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
695
706
|
const hit = blobRects.some(
|
|
696
707
|
(b) => minX <= b.x1 && maxX >= b.x0 && minY <= b.y1 && maxY >= b.y0
|
|
697
708
|
);
|