@diagrammo/dgmo 0.22.0 → 0.23.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 +238 -48
- package/dist/advanced.d.cts +17 -0
- package/dist/advanced.d.ts +17 -0
- package/dist/advanced.js +238 -48
- package/dist/auto.cjs +236 -42
- package/dist/auto.js +115 -115
- package/dist/auto.mjs +236 -42
- package/dist/cli.cjs +153 -153
- package/dist/editor.cjs +3 -0
- package/dist/editor.js +3 -0
- package/dist/highlight.cjs +3 -0
- package/dist/highlight.js +3 -0
- package/dist/index.cjs +232 -41
- package/dist/index.js +232 -41
- package/dist/internal.cjs +238 -48
- package/dist/internal.d.cts +17 -0
- package/dist/internal.d.ts +17 -0
- package/dist/internal.js +238 -48
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/gazetteer.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -1
- package/dist/map-data/water-bodies.json +1 -1
- package/dist/map-data/world-coarse.json +1 -1
- package/dist/map-data/world-detail.json +1 -1
- package/docs/language-reference.md +35 -0
- package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
- package/package.json +1 -1
- package/src/boxes-and-lines/parser.ts +39 -0
- package/src/boxes-and-lines/renderer.ts +171 -13
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/completion.ts +4 -5
- package/src/d3.ts +12 -4
- package/src/editor/keywords.ts +3 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/README.md +6 -0
- package/src/map/data/gazetteer.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -1
- package/src/map/data/water-bodies.json +1 -1
- package/src/map/data/world-coarse.json +1 -1
- package/src/map/data/world-detail.json +1 -1
- package/src/map/layout.ts +111 -18
- package/src/map/renderer.ts +95 -4
- package/src/utils/reserved-key-registry.ts +5 -3
|
@@ -1469,12 +1469,47 @@ Indented shorthand also supports groups (place arrow directly after group header
|
|
|
1469
1469
|
### 13.6 Directives
|
|
1470
1470
|
|
|
1471
1471
|
- `direction TB` — top-to-bottom layout (default: `LR`)
|
|
1472
|
+
- `box-metric <Label> [color]` — name a numeric value dimension (see §13.8); optional trailing color sets the ramp hue
|
|
1473
|
+
- `show-values` — print each box's numeric value as text (off by default)
|
|
1472
1474
|
|
|
1473
1475
|
### 13.7 Options
|
|
1474
1476
|
|
|
1475
1477
|
- `active-tag GroupName` — set active tag group for coloring
|
|
1478
|
+
- `active-tag none` — suppress tag coloring
|
|
1479
|
+
- `active-tag <metric>` — make the value ramp the active dimension (see §13.8)
|
|
1476
1480
|
- `hide team:Backend, team:Frontend` — hide nodes with matching tag values (colon syntax for tag:value)
|
|
1477
1481
|
|
|
1482
|
+
### 13.8 Value metric (numeric ramp)
|
|
1483
|
+
|
|
1484
|
+
Boxes can carry a numeric measure that drives a continuous color ramp — a
|
|
1485
|
+
choropleth-style "value dimension" alongside the categorical tag groups.
|
|
1486
|
+
|
|
1487
|
+
```
|
|
1488
|
+
boxes-and-lines Fleet Crews
|
|
1489
|
+
box-metric Crew blue
|
|
1490
|
+
show-values
|
|
1491
|
+
|
|
1492
|
+
Flagship value: 120
|
|
1493
|
+
Frigate value: 40
|
|
1494
|
+
Sloop value: 12
|
|
1495
|
+
Flagship -> Frigate
|
|
1496
|
+
Flagship -> Sloop
|
|
1497
|
+
```
|
|
1498
|
+
|
|
1499
|
+
- `value: <number>` on any box records its measure (a reserved metadata key —
|
|
1500
|
+
lifted out, never rendered as a tag). Non-numeric values are an error.
|
|
1501
|
+
- `box-metric <Label> [color]` names the dimension and optionally sets the ramp
|
|
1502
|
+
hue (default: the palette's primary color).
|
|
1503
|
+
- The ramp anchors at `0` for all-non-negative data, else at the data minimum.
|
|
1504
|
+
- The value ramp is the resting-active dimension whenever any box has a
|
|
1505
|
+
`value:` (so value shading works in static export with no interaction).
|
|
1506
|
+
`active-tag <tag-group>` switches to a tag group; `active-tag none` suppresses
|
|
1507
|
+
tinting; `active-tag <metric>` forces the value ramp. On a name collision
|
|
1508
|
+
between a tag group and the metric label, the tag group wins.
|
|
1509
|
+
- When the value ramp is active, every box tints along the min→max ramp and the
|
|
1510
|
+
legend shows a gradient capsule; boxes without a `value:` get a neutral fill.
|
|
1511
|
+
- `show-values` additionally prints each box's number as text.
|
|
1512
|
+
|
|
1478
1513
|
---
|
|
1479
1514
|
|
|
1480
1515
|
## 15. Timeline Diagrams
|
|
@@ -6,28 +6,30 @@ tag Priority p High red, Medium orange, Low gray
|
|
|
6
6
|
active-tag Team
|
|
7
7
|
hide priority:Low
|
|
8
8
|
|
|
9
|
+
box-metric Load orange
|
|
10
|
+
|
|
9
11
|
direction LR
|
|
10
12
|
|
|
11
13
|
// --- Services ---
|
|
12
|
-
API Gateway t: Backend
|
|
14
|
+
API Gateway t: Backend, value: 850
|
|
13
15
|
Main entry point for all requests
|
|
14
16
|
Routes to **backend services**
|
|
15
17
|
-routes-> UserService
|
|
16
18
|
-routes-> ProductService
|
|
17
19
|
-routes-> OrderService
|
|
18
20
|
|
|
19
|
-
UserService t: Backend
|
|
21
|
+
UserService t: Backend, value: 430
|
|
20
22
|
Handles auth and profiles
|
|
21
23
|
Uses `JWT` tokens for sessions
|
|
22
24
|
-reads-> UserDB
|
|
23
25
|
-checks-> SessionCache
|
|
24
26
|
|
|
25
|
-
ProductService t: Frontend, description: Product catalog and search
|
|
27
|
+
ProductService t: Frontend, value: 620, description: Product catalog and search
|
|
26
28
|
Supports *full-text* search
|
|
27
29
|
-queries-> ProductDB
|
|
28
30
|
-invalidates-> ProductCache
|
|
29
31
|
|
|
30
|
-
OrderService t: Backend
|
|
32
|
+
OrderService t: Backend, value: 290
|
|
31
33
|
Order processing pipeline
|
|
32
34
|
Validates inventory before commit
|
|
33
35
|
-writes-> OrderDB
|
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
extractColor,
|
|
27
27
|
parseFirstLine,
|
|
28
28
|
OPTION_NOCOLON_RE,
|
|
29
|
+
peelTrailingColorName,
|
|
29
30
|
splitNameAndMeta,
|
|
30
31
|
tryParseSharedOption,
|
|
31
32
|
warnUnknownMetaKeys,
|
|
@@ -297,6 +298,26 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
297
298
|
continue;
|
|
298
299
|
}
|
|
299
300
|
|
|
301
|
+
// box-metric / show-values directives — pre-content only (like
|
|
302
|
+
// active-tag). Explicit regex branches: a bare flag and a
|
|
303
|
+
// `key value` form won't both match the active-tag OPTION codepath.
|
|
304
|
+
if (!contentStarted) {
|
|
305
|
+
const metricMatch = trimmed.match(/^box-metric\s+(.+)$/i);
|
|
306
|
+
if (metricMatch) {
|
|
307
|
+
// Regex capture group present after successful match.
|
|
308
|
+
const { label, colorName } = peelTrailingColorName(
|
|
309
|
+
metricMatch[1]!.trim()
|
|
310
|
+
);
|
|
311
|
+
result.boxMetric = label;
|
|
312
|
+
if (colorName !== undefined) result.boxMetricColor = colorName;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (/^show-values$/i.test(trimmed)) {
|
|
316
|
+
result.showValues = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
300
321
|
// active-tag directive
|
|
301
322
|
if (!contentStarted) {
|
|
302
323
|
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
@@ -784,6 +805,23 @@ function parseNodeLine(
|
|
|
784
805
|
delete metadata['description'];
|
|
785
806
|
}
|
|
786
807
|
|
|
808
|
+
// Lift `value: X` out of metadata into a typed numeric field (mirror of the
|
|
809
|
+
// map parser). Validate finite-numeric; delete from metadata so it never
|
|
810
|
+
// becomes a `data-tag-value` attribute.
|
|
811
|
+
let value: number | undefined;
|
|
812
|
+
if (metadata['value'] !== undefined) {
|
|
813
|
+
const raw = metadata['value'];
|
|
814
|
+
const num = Number(raw);
|
|
815
|
+
if (Number.isFinite(num)) {
|
|
816
|
+
value = num;
|
|
817
|
+
} else {
|
|
818
|
+
diagnostics.push(
|
|
819
|
+
makeDgmoError(lineNum, `value must be a number (got "${raw}")`, 'error')
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
delete metadata['value'];
|
|
823
|
+
}
|
|
824
|
+
|
|
787
825
|
// TD-18 alias is now peeled by splitNameAndMeta — re-register if set.
|
|
788
826
|
if (split.alias) {
|
|
789
827
|
nameAliasMap?.set(normalizeName(split.alias), label);
|
|
@@ -796,6 +834,7 @@ function parseNodeLine(
|
|
|
796
834
|
lineNumber: lineNum,
|
|
797
835
|
metadata,
|
|
798
836
|
...(description !== undefined && { description }),
|
|
837
|
+
...(value !== undefined && { value }),
|
|
799
838
|
};
|
|
800
839
|
}
|
|
801
840
|
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
LegendConfig,
|
|
12
12
|
LegendState,
|
|
13
13
|
LegendCallbacks,
|
|
14
|
+
LegendGroupData,
|
|
14
15
|
ControlsGroupToggle,
|
|
15
16
|
} from '../utils/legend-types';
|
|
16
17
|
import {
|
|
@@ -19,7 +20,8 @@ import {
|
|
|
19
20
|
TITLE_Y,
|
|
20
21
|
} from '../utils/title-constants';
|
|
21
22
|
import { contrastText, mix, shapeFill } from '../palettes/color-utils';
|
|
22
|
-
import {
|
|
23
|
+
import { resolveColor } from '../colors';
|
|
24
|
+
import { resolveTagColor } from '../utils/tag-groups';
|
|
23
25
|
import type { TagGroup } from '../utils/tag-groups';
|
|
24
26
|
import type { PaletteColors } from '../palettes';
|
|
25
27
|
import { renderInlineText } from '../utils/inline-markdown';
|
|
@@ -50,6 +52,9 @@ const NODE_TEXT_PADDING = 12;
|
|
|
50
52
|
const GROUP_RX = 8;
|
|
51
53
|
const GROUP_LABEL_FONT_SIZE = 14;
|
|
52
54
|
const GROUP_LABEL_ZONE = 32;
|
|
55
|
+
// % tint floor so the ramp minimum still reads as "low, present" (mirror map).
|
|
56
|
+
const RAMP_FLOOR = 15;
|
|
57
|
+
const VALUE_FONT_SIZE = 11;
|
|
53
58
|
|
|
54
59
|
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
55
60
|
type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
|
|
@@ -184,8 +189,30 @@ function nodeColors(
|
|
|
184
189
|
activeGroupName: string | null,
|
|
185
190
|
palette: PaletteColors,
|
|
186
191
|
isDark: boolean,
|
|
192
|
+
value: {
|
|
193
|
+
active: boolean;
|
|
194
|
+
hue: string;
|
|
195
|
+
fillForValue: (v: number) => string;
|
|
196
|
+
},
|
|
187
197
|
solid?: boolean
|
|
188
198
|
): { fill: string; stroke: string; text: string } {
|
|
199
|
+
// Untagged-neutral fill, reused by the value path for no-value boxes.
|
|
200
|
+
const neutralFill = mix(palette.bg, palette.text, isDark ? 90 : 95);
|
|
201
|
+
// Value dimension active: choropleth tint by the node's value, neutral when a
|
|
202
|
+
// box has no value (mirror map: `value !== undefined ? fillForValue : neutral`).
|
|
203
|
+
if (value.active) {
|
|
204
|
+
const fill =
|
|
205
|
+
node.value !== undefined ? value.fillForValue(node.value) : neutralFill;
|
|
206
|
+
// Stroke = the ramp hue (NOT a tag color — there may be none); a present
|
|
207
|
+
// stroke is required for the app's --bl-node-stroke hover-dim to work.
|
|
208
|
+
const stroke = value.hue;
|
|
209
|
+
const text = contrastText(
|
|
210
|
+
fill,
|
|
211
|
+
palette.textOnFillLight,
|
|
212
|
+
palette.textOnFillDark
|
|
213
|
+
);
|
|
214
|
+
return { fill, stroke, text };
|
|
215
|
+
}
|
|
189
216
|
const tagColor = resolveTagColor(
|
|
190
217
|
node.metadata,
|
|
191
218
|
[...tagGroups],
|
|
@@ -368,6 +395,83 @@ export function renderBoxesAndLines(
|
|
|
368
395
|
const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
|
|
369
396
|
const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
|
|
370
397
|
const sTitleY = sctx.structural(TITLE_Y);
|
|
398
|
+
|
|
399
|
+
// ── Value ramp + active-dimension resolution (mirror of map's value model) ──
|
|
400
|
+
// The ramp is computed in the renderer (architectural divergence from the
|
|
401
|
+
// map, which precomputes in layout) — node sizes are value-independent, and
|
|
402
|
+
// this file already owns all colouring + the legend build. Hoisted ONCE
|
|
403
|
+
// before the node loop so `fillForValue` is not recomputed per node.
|
|
404
|
+
const nodeValues = parsed.nodes
|
|
405
|
+
.filter((n) => n.value !== undefined)
|
|
406
|
+
.map((n) => n.value!);
|
|
407
|
+
const hasRamp = nodeValues.length > 0;
|
|
408
|
+
const allNonNegative = hasRamp && nodeValues.every((v) => v >= 0);
|
|
409
|
+
const rampMin = allNonNegative ? 0 : Math.min(...nodeValues);
|
|
410
|
+
const rampMax = Math.max(...nodeValues);
|
|
411
|
+
// Default hue = palette.primary (NOT red like the map — boxes have no water to
|
|
412
|
+
// stand out against, and red reads as alarm on a neutral metric). A trailing
|
|
413
|
+
// color on `box-metric` overrides.
|
|
414
|
+
const rampHue =
|
|
415
|
+
resolveColor(parsed.boxMetricColor ?? '', palette) ?? palette.primary;
|
|
416
|
+
// Lift the ramp anchor off the near-black surface on dark themes so the
|
|
417
|
+
// lowest values read as a clear muted tint rather than sinking to the surface.
|
|
418
|
+
const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
|
|
419
|
+
const fillForValue = (v: number): string => {
|
|
420
|
+
const t = rampMax > rampMin ? (v - rampMin) / (rampMax - rampMin) : 1;
|
|
421
|
+
const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
|
|
422
|
+
return mix(rampHue, rampBase, pct);
|
|
423
|
+
};
|
|
424
|
+
const VALUE_NAME = hasRamp ? parsed.boxMetric?.trim() || 'Value' : null;
|
|
425
|
+
|
|
426
|
+
// Local active-dimension resolver — mirror map's inline matchColorGroup /
|
|
427
|
+
// activeIsScore. Do NOT extend the shared resolveActiveTagGroup (it has a
|
|
428
|
+
// fixed 3-arg signature consumed by 7 chart types). On a name collision
|
|
429
|
+
// between a tag group and the metric label, the tag group wins (AC9).
|
|
430
|
+
const matchColorGroup = (v: string): string | null => {
|
|
431
|
+
const lv = v.trim().toLowerCase();
|
|
432
|
+
if (lv === 'none') return null;
|
|
433
|
+
const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
|
|
434
|
+
if (tg) return tg.name;
|
|
435
|
+
if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
|
|
436
|
+
return v; // unknown name passes through → renders neutral
|
|
437
|
+
};
|
|
438
|
+
const override = activeTagGroup; // string | null | undefined
|
|
439
|
+
let activeGroup: string | null;
|
|
440
|
+
if (override !== undefined) {
|
|
441
|
+
activeGroup = override === null ? null : matchColorGroup(override);
|
|
442
|
+
} else if (parsed.options['active-tag'] !== undefined) {
|
|
443
|
+
activeGroup = matchColorGroup(parsed.options['active-tag']);
|
|
444
|
+
} else {
|
|
445
|
+
// Default-active dimension: value ramp when any box has a value, else the
|
|
446
|
+
// first declared tag group, else none.
|
|
447
|
+
activeGroup =
|
|
448
|
+
VALUE_NAME ??
|
|
449
|
+
(parsed.tagGroups.length > 0 ? parsed.tagGroups[0]!.name : null);
|
|
450
|
+
}
|
|
451
|
+
const activeIsValue = VALUE_NAME !== null && activeGroup === VALUE_NAME;
|
|
452
|
+
|
|
453
|
+
// Synthetic legend group for the value ramp (empty entries + gradient),
|
|
454
|
+
// prepended to the tag groups handed to renderLegendD3 — exactly like the
|
|
455
|
+
// map's VALUE_NAME group. The shared legend infra renders the gradient capsule
|
|
456
|
+
// ONLY when it is the active group (legendState.activeGroup === its name).
|
|
457
|
+
const valueGroup: LegendGroupData | null =
|
|
458
|
+
VALUE_NAME !== null
|
|
459
|
+
? {
|
|
460
|
+
name: VALUE_NAME,
|
|
461
|
+
entries: [],
|
|
462
|
+
gradient: {
|
|
463
|
+
min: rampMin,
|
|
464
|
+
max: rampMax,
|
|
465
|
+
hue: rampHue,
|
|
466
|
+
base: rampBase,
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
: null;
|
|
470
|
+
const legendGroups: readonly LegendGroupData[] = [
|
|
471
|
+
...(valueGroup ? [valueGroup] : []),
|
|
472
|
+
...parsed.tagGroups,
|
|
473
|
+
];
|
|
474
|
+
|
|
371
475
|
// Reserve legend height only when a legend will actually render. App-hosted
|
|
372
476
|
// controls move the Descriptions toggle to the app overlay, so a
|
|
373
477
|
// descriptions-only chart (no tag groups) reserves nothing.
|
|
@@ -375,13 +479,13 @@ export function renderBoxesAndLines(
|
|
|
375
479
|
(n) => n.description && n.description.length > 0
|
|
376
480
|
);
|
|
377
481
|
const willRenderLegend =
|
|
378
|
-
|
|
482
|
+
legendGroups.length > 0 ||
|
|
379
483
|
(reserveHasDescriptions && controlsHost !== 'app');
|
|
380
484
|
const sLegendHeight = willRenderLegend
|
|
381
485
|
? sctx.structural(
|
|
382
486
|
getMaxLegendReservedHeight(
|
|
383
487
|
{
|
|
384
|
-
groups:
|
|
488
|
+
groups: legendGroups,
|
|
385
489
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
386
490
|
mode: exportMode ? 'export' : 'preview',
|
|
387
491
|
},
|
|
@@ -390,12 +494,6 @@ export function renderBoxesAndLines(
|
|
|
390
494
|
)
|
|
391
495
|
: 0;
|
|
392
496
|
|
|
393
|
-
const activeGroup = resolveActiveTagGroup(
|
|
394
|
-
parsed.tagGroups,
|
|
395
|
-
parsed.options['active-tag'],
|
|
396
|
-
activeTagGroup
|
|
397
|
-
);
|
|
398
|
-
|
|
399
497
|
// Build hidden set
|
|
400
498
|
const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
|
|
401
499
|
|
|
@@ -414,7 +512,7 @@ export function renderBoxesAndLines(
|
|
|
414
512
|
(n) => n.description && n.description.length > 0
|
|
415
513
|
);
|
|
416
514
|
const needsLegend =
|
|
417
|
-
|
|
515
|
+
legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
|
|
418
516
|
const legendH = needsLegend ? sLegendHeight + 8 : 0;
|
|
419
517
|
|
|
420
518
|
const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
|
|
@@ -809,6 +907,7 @@ export function renderBoxesAndLines(
|
|
|
809
907
|
activeGroup,
|
|
810
908
|
palette,
|
|
811
909
|
isDark,
|
|
910
|
+
{ active: activeIsValue, hue: rampHue, fillForValue },
|
|
812
911
|
parsed.options['solid-fill'] === 'on'
|
|
813
912
|
);
|
|
814
913
|
|
|
@@ -826,6 +925,12 @@ export function renderBoxesAndLines(
|
|
|
826
925
|
nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
|
|
827
926
|
}
|
|
828
927
|
|
|
928
|
+
// Numeric value drives the gradient scrub; guard on !== undefined so a
|
|
929
|
+
// legitimate `value: 0` still emits data-value="0" (0 is falsy).
|
|
930
|
+
if (node.value !== undefined) {
|
|
931
|
+
nodeG.attr('data-value', node.value);
|
|
932
|
+
}
|
|
933
|
+
|
|
829
934
|
if (onClickItem) {
|
|
830
935
|
nodeG.on('click', (event: Event) => {
|
|
831
936
|
// Don't intercept clicks on links in description text
|
|
@@ -1004,6 +1109,58 @@ export function renderBoxesAndLines(
|
|
|
1004
1109
|
.text(fitted.lines[li]!);
|
|
1005
1110
|
}
|
|
1006
1111
|
}
|
|
1112
|
+
|
|
1113
|
+
// ── show-values: print the numeric value as text (opt-in) ──
|
|
1114
|
+
// Independent of the active dimension (a user may want the numbers printed
|
|
1115
|
+
// while a tag group tints). Plain nodes: centered below the label. Described
|
|
1116
|
+
// nodes: a top-right corner badge so it never overflows the full body (R2-6).
|
|
1117
|
+
if (parsed.showValues && node.value !== undefined) {
|
|
1118
|
+
const valueText = String(node.value);
|
|
1119
|
+
const descShown = !!(desc && desc.length > 0 && !hideDescriptions);
|
|
1120
|
+
if (descShown) {
|
|
1121
|
+
// Corner badge — pill behind the number so it reads over the header.
|
|
1122
|
+
const padX = 6;
|
|
1123
|
+
const padY = 5;
|
|
1124
|
+
const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO + 8;
|
|
1125
|
+
const bh = VALUE_FONT_SIZE + 4;
|
|
1126
|
+
const bx = ln.width / 2 - bw - 4;
|
|
1127
|
+
const by = -ln.height / 2 + 4;
|
|
1128
|
+
nodeG
|
|
1129
|
+
.append('rect')
|
|
1130
|
+
.attr('x', bx)
|
|
1131
|
+
.attr('y', by)
|
|
1132
|
+
.attr('width', bw)
|
|
1133
|
+
.attr('height', bh)
|
|
1134
|
+
.attr('rx', 3)
|
|
1135
|
+
.attr('fill', palette.bg)
|
|
1136
|
+
.attr('opacity', 0.85);
|
|
1137
|
+
nodeG
|
|
1138
|
+
.append('text')
|
|
1139
|
+
.attr('class', 'bl-node-value')
|
|
1140
|
+
.attr('x', bx + bw - padX)
|
|
1141
|
+
.attr('y', by + padY)
|
|
1142
|
+
.attr('text-anchor', 'end')
|
|
1143
|
+
.attr('dominant-baseline', 'central')
|
|
1144
|
+
.attr('font-size', VALUE_FONT_SIZE)
|
|
1145
|
+
.attr('font-weight', '600')
|
|
1146
|
+
.attr('fill', palette.textMuted)
|
|
1147
|
+
.text(valueText);
|
|
1148
|
+
} else {
|
|
1149
|
+
// Plain node: value centered just above the bottom edge.
|
|
1150
|
+
nodeG
|
|
1151
|
+
.append('text')
|
|
1152
|
+
.attr('class', 'bl-node-value')
|
|
1153
|
+
.attr('x', 0)
|
|
1154
|
+
.attr('y', ln.height / 2 - VALUE_FONT_SIZE)
|
|
1155
|
+
.attr('text-anchor', 'middle')
|
|
1156
|
+
.attr('dominant-baseline', 'central')
|
|
1157
|
+
.attr('font-size', VALUE_FONT_SIZE)
|
|
1158
|
+
.attr('font-weight', '600')
|
|
1159
|
+
.attr('fill', colors.text)
|
|
1160
|
+
.attr('opacity', 0.8)
|
|
1161
|
+
.text(valueText);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1007
1164
|
}
|
|
1008
1165
|
|
|
1009
1166
|
// ── Render legend ──────────────────────────────────────
|
|
@@ -1011,9 +1168,10 @@ export function renderBoxesAndLines(
|
|
|
1011
1168
|
(n) => n.description && n.description.length > 0
|
|
1012
1169
|
);
|
|
1013
1170
|
// App-hosted: the Descriptions control moves to the app overlay, so a
|
|
1014
|
-
// descriptions-only legend (no tag groups) has nothing left to render.
|
|
1171
|
+
// descriptions-only legend (no tag groups) has nothing left to render. The
|
|
1172
|
+
// value ramp (a synthetic group in legendGroups) also forces a legend.
|
|
1015
1173
|
const hasLegend =
|
|
1016
|
-
|
|
1174
|
+
legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
|
|
1017
1175
|
|
|
1018
1176
|
if (hasLegend) {
|
|
1019
1177
|
// Build controls group for description toggle. App-hosted controls own the
|
|
@@ -1035,7 +1193,7 @@ export function renderBoxesAndLines(
|
|
|
1035
1193
|
}
|
|
1036
1194
|
|
|
1037
1195
|
const legendConfig: LegendConfig = {
|
|
1038
|
-
groups:
|
|
1196
|
+
groups: legendGroups,
|
|
1039
1197
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
1040
1198
|
mode: exportMode ? 'export' : 'preview',
|
|
1041
1199
|
// Keep inactive sibling tag groups visible as collapsed pills so the user
|
|
@@ -6,6 +6,9 @@ export interface BLNode {
|
|
|
6
6
|
readonly lineNumber: number;
|
|
7
7
|
readonly metadata: Readonly<Record<string, string>>;
|
|
8
8
|
readonly description?: readonly string[];
|
|
9
|
+
/** Numeric measure lifted from `value: X` metadata (mirror of map's
|
|
10
|
+
* `region.value`). Drives the value ramp / choropleth tinting. */
|
|
11
|
+
readonly value?: number;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
export interface BLEdge {
|
|
@@ -36,6 +39,12 @@ export interface ParsedBoxesAndLines {
|
|
|
36
39
|
readonly options: Readonly<Record<string, string>>;
|
|
37
40
|
readonly initialHiddenTagValues: ReadonlyMap<string, ReadonlySet<string>>;
|
|
38
41
|
readonly direction: 'LR' | 'TB';
|
|
42
|
+
/** `box-metric <label> [color]` — names the value-ramp dimension and
|
|
43
|
+
* optionally sets its hue. Mirror of map's `region-metric`. */
|
|
44
|
+
readonly boxMetric?: string;
|
|
45
|
+
readonly boxMetricColor?: string;
|
|
46
|
+
/** `show-values` — print each box's numeric value as text (opt-in). */
|
|
47
|
+
readonly showValues?: boolean;
|
|
39
48
|
readonly diagnostics: readonly DgmoError[];
|
|
40
49
|
readonly error: string | null;
|
|
41
50
|
}
|
package/src/completion.ts
CHANGED
|
@@ -447,6 +447,8 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
|
|
|
447
447
|
direction: { description: 'Layout direction', values: ['LR', 'TB'] },
|
|
448
448
|
'active-tag': { description: 'Active tag group name' },
|
|
449
449
|
hide: { description: 'Hide tag:value pairs' },
|
|
450
|
+
'box-metric': { description: 'Metric label for the value ramp' },
|
|
451
|
+
'show-values': { description: 'Print box values as text' },
|
|
450
452
|
}),
|
|
451
453
|
],
|
|
452
454
|
[
|
|
@@ -740,12 +742,9 @@ export const PIPE_METADATA = new Map<string, PipeContextMap>([
|
|
|
740
742
|
{
|
|
741
743
|
node: {
|
|
742
744
|
description: { description: 'Node description text' },
|
|
745
|
+
value: { description: 'Numeric value for the metric ramp' },
|
|
743
746
|
},
|
|
744
|
-
edge: {
|
|
745
|
-
width: { description: 'Edge stroke width in pixels' },
|
|
746
|
-
split: { description: 'Traffic split percentage' },
|
|
747
|
-
fanout: { description: 'Fanout multiplier (integer >= 1)' },
|
|
748
|
-
},
|
|
747
|
+
edge: {},
|
|
749
748
|
},
|
|
750
749
|
],
|
|
751
750
|
[
|
package/src/d3.ts
CHANGED
|
@@ -4301,8 +4301,16 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4301
4301
|
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4302
4302
|
: 0;
|
|
4303
4303
|
const innerWidth = width - margin.left - margin.right;
|
|
4304
|
-
const
|
|
4305
|
-
const rowH = Math.min(ctx.structural(28),
|
|
4304
|
+
const availInnerHeight = height - margin.top - margin.bottom;
|
|
4305
|
+
const rowH = Math.min(ctx.structural(28), availInnerHeight / sorted.length);
|
|
4306
|
+
// Each event needs only `rowH` of vertical space. When the container is
|
|
4307
|
+
// taller than the rows require (rowH hits its 28px cap), draw the era
|
|
4308
|
+
// bands and time axis to the content height instead of the full container
|
|
4309
|
+
// so the axis sits just below the last event rather than leaving a large
|
|
4310
|
+
// vertical gap. The SVG itself shrinks to match (top-aligned via
|
|
4311
|
+
// preserveAspectRatio) so callers don't reserve dead space below the chart.
|
|
4312
|
+
const innerHeight = rowH * sorted.length;
|
|
4313
|
+
const usedHeight = margin.top + innerHeight + margin.bottom;
|
|
4306
4314
|
|
|
4307
4315
|
const xScale = d3Scale
|
|
4308
4316
|
.scaleLinear()
|
|
@@ -4313,8 +4321,8 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4313
4321
|
.select(container)
|
|
4314
4322
|
.append('svg')
|
|
4315
4323
|
.attr('width', width)
|
|
4316
|
-
.attr('height',
|
|
4317
|
-
.attr('viewBox', `0 0 ${width} ${
|
|
4324
|
+
.attr('height', usedHeight)
|
|
4325
|
+
.attr('viewBox', `0 0 ${width} ${usedHeight}`)
|
|
4318
4326
|
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
4319
4327
|
.style('background', bgColor);
|
|
4320
4328
|
|
package/src/editor/keywords.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"assets":{"gazetteer.json":{"bytes":
|
|
1
|
+
{"assets":{"gazetteer.json":{"bytes":130767,"gzBytes":56261,"sha256":"5ad56e5ba0b3a4f9a6dc8bd3bf8b0fda0e7b86cbe4d85d231114f5dd967d65f7"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":90845,"gzBytes":26493,"sha256":"a698b3f296e61712fb39b3d8d42ec7c4699f8aadecb549367feb7d09f7785580"},"na-lakes.json":{"bytes":39387,"gzBytes":11281,"sha256":"2a41c04969209380d544a09efe354277e12d704458af95955201eb4f698d16c6"},"na-land.json":{"bytes":114082,"gzBytes":32375,"sha256":"7b94c9bb4e809c22813da5ae939e1ff6a781fd77a04d9c1585a9a82d2a195388"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"water-bodies.json":{"bytes":4854,"gzBytes":2123,"sha256":"6d1a407a376c63518329c52189e2887053c4b61062af0597e060050ae8469635"},"world-coarse.json":{"bytes":55436,"gzBytes":18397,"sha256":"5cb42e3c8975dde56504ca5c68ece0a1e71d0929680b5fc8cdab758c8666dbf8"},"world-detail.json":{"bytes":163562,"gzBytes":46767,"sha256":"39f1736eaabe9e21190972be3157822be22ee84fdc41751237f2b516f09a7586"}},"counts":{"countries":175,"gazetteerAliases":8,"gazetteerCities":2119,"mountainRanges":205,"usStates":56,"waterBodies":113},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json":{"bytes":6648697,"sha256":"93c8fdf0e591e113f449d0d466e15c7a9841b9b6571c7afe41f95ba51b322452"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson":{"bytes":13287234,"sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson":{"bytes":5583870,"sha256":"b7b26e50ea917d3696aec87f932def2bf5f890f5770e441d59c162c6f4c92a77"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson":{"bytes":534055,"sha256":"b9c3f7f557d0ff5217906adc82b66ecdac14aa7438df7e518cf6675d037bceb8"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson":{"bytes":1163418,"sha256":"6fe58083e0cc5c7fad9e396970e28a8580bbd8770cfa4d1d7b5a34423e912f97"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5549002,"sha256":"d20e28b2f610da34c21fd82ff6a8e4d24ebe67eba2dccf65bd2c4332ff0f380a"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-06-02 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"marineCoarse":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson","version":"natural-earth 110m (nvkelso vector snapshot)"},"marineDetail":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"mountainRanges":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"naLakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json","version":"natural-earth 10m (martynafford snapshot)"},"naLand":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
|
package/src/map/data/README.md
CHANGED
|
@@ -10,6 +10,10 @@ hand-edit — regenerate from source.
|
|
|
10
10
|
- `us-states.json` — US states + DC + territories (TopoJSON), keyed by ISO 3166-2.
|
|
11
11
|
- `lakes.json` — major lakes (Natural Earth 110m, TopoJSON), drawn as water over land.
|
|
12
12
|
- `rivers.json` — major river centerlines (Natural Earth 110m, TopoJSON), drawn as thin water lines.
|
|
13
|
+
- `na-land.json` — NA-clipped 10m country land (TopoJSON, ISO-keyed): crisp neighbour context under the albers-usa US view.
|
|
14
|
+
- `na-lakes.json` — NA-clipped 10m major lakes (TopoJSON): the lakes counterpart to `na-land.json` for the US view.
|
|
15
|
+
- `mountain-ranges.json` — notable mountain ranges (Natural Earth 50m geography regions, FEATURECLA "Range/mtn", TopoJSON), drawn as a subtle gradient relief cue when the `relief` directive is on. Optional; single tier (no elevation).
|
|
16
|
+
- `water-bodies.json` — water-body orientation labels (`{ entries: [lat, lon, name, tier, kind] }`) from Natural Earth 110m+50m geography marine polys (oceans/seas/gulfs/bays/straits/channels/sounds; rivers + reefs excluded). Anchors are mapshaper inner points; `tier` is the NE scalerank. Drawn only when the `context-labels` directive is on. Optional.
|
|
13
17
|
- `gazetteer.json` — `{ cities, byName, alt }` city index (see `types.ts`).
|
|
14
18
|
`byName`/`alt` reference `cities` by array index (normalized).
|
|
15
19
|
- `PROVENANCE.json` — source versions + per-asset sha256/sizes + GeoNames date range.
|
|
@@ -18,6 +22,8 @@ hand-edit — regenerate from source.
|
|
|
18
22
|
## Sources & attribution
|
|
19
23
|
- **Country boundaries:** Natural Earth via `world-atlas@2.0.2` (public domain).
|
|
20
24
|
- **US states:** US Census via `us-atlas@3.0.1` (public domain).
|
|
25
|
+
- **Mountain ranges:** Natural Earth 50m `geography_regions_polys` via `nvkelso/natural-earth-vector` (public domain).
|
|
26
|
+
- **Water bodies:** Natural Earth 110m+50m `geography_marine_polys` via `nvkelso/natural-earth-vector` (public domain). One editorial override applied (`Gulf of Mexico` → `Gulf of America`).
|
|
21
27
|
- **Cities:** Data © **GeoNames**, licensed under **CC BY 4.0**
|
|
22
28
|
(https://creativecommons.org/licenses/by/4.0/) — https://www.geonames.org/.
|
|
23
29
|
|