@diagrammo/dgmo 0.22.0 → 0.24.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 +372 -103
- package/dist/advanced.d.cts +52 -19
- package/dist/advanced.d.ts +52 -19
- package/dist/advanced.js +372 -103
- package/dist/auto.cjs +370 -97
- package/dist/auto.js +117 -117
- package/dist/auto.mjs +370 -97
- package/dist/cli.cjs +151 -151
- 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 +498 -96
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +496 -96
- package/dist/internal.cjs +372 -103
- package/dist/internal.d.cts +52 -19
- package/dist/internal.d.ts +52 -19
- package/dist/internal.js +372 -103
- 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 +38 -2
- 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 +219 -14
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/completion.ts +4 -5
- package/src/d3.ts +26 -6
- package/src/editor/keywords.ts +3 -0
- package/src/index.ts +8 -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/dimensions.ts +21 -5
- package/src/map/layout.ts +167 -63
- package/src/map/legend-band.ts +99 -0
- package/src/map/renderer.ts +105 -32
- package/src/map/resolver.ts +43 -1
- package/src/map/types.ts +20 -0
- package/src/utils/reserved-key-registry.ts +5 -3
- package/src/utils/svg-embed.ts +193 -0
|
@@ -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
|
|
@@ -2751,7 +2786,8 @@ route Miami style: arc
|
|
|
2751
2786
|
- Title is the declaration line; `caption` (data-source attribution, travels with the exported PNG) is the only chrome directive. There is no `subtitle`.
|
|
2752
2787
|
- Legend auto-composes below the title: the value ramp + `region-metric` and each tag group are **selectable colouring groups** (collapse/activate to flip the fill); POI size (`poi-metric`) and edge thickness (`flow-metric`) are self-evident from scale and carry no legend key in v1. `no-legend` suppresses all of it.
|
|
2753
2788
|
- **Region and POI labels are on by default.** Region labels auto-fit **full → abbrev → hide** (a US-state 2-letter abbreviation is tried when the full name doesn't fit; other regions degrade full → hide); POI labels are collision-managed. Labels render **on the map** (export-safe), escalating inline → leader line → numbered pin in dense clusters; markers never move. A wide map in a narrow column (< ~480px) prefers abbreviations and drops reference relief, as if zoomed out.
|
|
2754
|
-
- **Cosmetic features are on by default**; the only switches are bare `no-*` opt-outs (no positive opt-in flag): `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`, `no-legend`. A plain look = the four basemap flags together.
|
|
2789
|
+
- **Cosmetic features are on by default**; the only switches are bare `no-*` opt-outs (no positive opt-in flag): `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`, `no-legend`, `no-colorize`. A plain look = the four basemap flags together (`no-colorize` is **not** one of the four — it toggles region *fill style*, not a basemap backdrop layer).
|
|
2790
|
+
- **Colorize (distinct political fills) is the default for any map without region data.** Unless a region carries data (a `value:` or a tag), every region drawn at the resolved extent is filled a **distinct light pastel** such that no two bordering regions share a hue — the conventional "colour the countries/states so neighbours separate" look, with zero config. It applies to named-region maps, POI/route-only maps, and even a bare `map` (the whole world colours as the backdrop). The fills are **non-semantic** (no legend entry) and **extent-independent** (a region's colour is the same at any width and in an inset). A direct trailing colour (`Texas red`) paints on top as a highlight and does not suppress colorize; adding any `value:`/tag flips the map to the data dress (colorize auto-suppressed, no error). `no-colorize` forces the plain green-land + blue-water dress — useful when many POIs/routes should pop against a calm map.
|
|
2755
2791
|
|
|
2756
2792
|
### Name resolution
|
|
2757
2793
|
|
|
@@ -2767,7 +2803,7 @@ route Miami style: arc
|
|
|
2767
2803
|
|
|
2768
2804
|
### Directives & reserved keys
|
|
2769
2805
|
|
|
2770
|
-
The directive set is **
|
|
2806
|
+
The directive set is **13, all colon-free**: six naming intent the renderer can't infer — `region-metric`, `poi-metric`, `flow-metric`, `locale`, `active-tag`, `caption` — and seven `no-*` cosmetic opt-outs — `no-legend`, `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`, `no-colorize`. There is **no** `projection`, `scale`, `subtitle`, `surface`, `region`, or label-enum directive, and cosmetics have no positive opt-in form. Reserved metadata keys (need colons): `value`, `label`, `style` (`value` = the one numeric channel: region shade / POI size / edge thickness); `surface:` is no longer recognized. A bare US state postal code resolves to that state (`poi Portland OR` → Oregon; `CA` = California). Coordinates are positional (no `at:` key). Projection is inferred from extent + whether the map carries data (US → albers-usa; world data → Equal Earth; world reference → natural-earth; regional → mercator) and cannot be overridden.
|
|
2771
2807
|
|
|
2772
2808
|
---
|
|
2773
2809
|
|
|
@@ -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';
|
|
@@ -33,7 +35,9 @@ import { ScaleContext } from '../utils/scaling';
|
|
|
33
35
|
|
|
34
36
|
// ── Constants (aligned with infra pattern) ─────────────────
|
|
35
37
|
const DIAGRAM_PADDING = 20;
|
|
36
|
-
|
|
38
|
+
// Box labels run smaller than the 13px org/infra use — boxes-and-lines nodes are
|
|
39
|
+
// narrower (~97px), so a smaller label fits more text per line before wrapping.
|
|
40
|
+
const NODE_FONT_SIZE = 11;
|
|
37
41
|
const MIN_NODE_FONT_SIZE = 9;
|
|
38
42
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
39
43
|
const EDGE_STROKE_WIDTH = 1.5;
|
|
@@ -50,6 +54,9 @@ const NODE_TEXT_PADDING = 12;
|
|
|
50
54
|
const GROUP_RX = 8;
|
|
51
55
|
const GROUP_LABEL_FONT_SIZE = 14;
|
|
52
56
|
const GROUP_LABEL_ZONE = 32;
|
|
57
|
+
// % tint floor so the ramp minimum still reads as "low, present" (mirror map).
|
|
58
|
+
const RAMP_FLOOR = 15;
|
|
59
|
+
const VALUE_FONT_SIZE = 11;
|
|
53
60
|
|
|
54
61
|
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
55
62
|
type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
|
|
@@ -184,8 +191,30 @@ function nodeColors(
|
|
|
184
191
|
activeGroupName: string | null,
|
|
185
192
|
palette: PaletteColors,
|
|
186
193
|
isDark: boolean,
|
|
194
|
+
value: {
|
|
195
|
+
active: boolean;
|
|
196
|
+
hue: string;
|
|
197
|
+
fillForValue: (v: number) => string;
|
|
198
|
+
},
|
|
187
199
|
solid?: boolean
|
|
188
200
|
): { fill: string; stroke: string; text: string } {
|
|
201
|
+
// Untagged-neutral fill, reused by the value path for no-value boxes.
|
|
202
|
+
const neutralFill = mix(palette.bg, palette.text, isDark ? 90 : 95);
|
|
203
|
+
// Value dimension active: choropleth tint by the node's value, neutral when a
|
|
204
|
+
// box has no value (mirror map: `value !== undefined ? fillForValue : neutral`).
|
|
205
|
+
if (value.active) {
|
|
206
|
+
const fill =
|
|
207
|
+
node.value !== undefined ? value.fillForValue(node.value) : neutralFill;
|
|
208
|
+
// Stroke = the ramp hue (NOT a tag color — there may be none); a present
|
|
209
|
+
// stroke is required for the app's --bl-node-stroke hover-dim to work.
|
|
210
|
+
const stroke = value.hue;
|
|
211
|
+
const text = contrastText(
|
|
212
|
+
fill,
|
|
213
|
+
palette.textOnFillLight,
|
|
214
|
+
palette.textOnFillDark
|
|
215
|
+
);
|
|
216
|
+
return { fill, stroke, text };
|
|
217
|
+
}
|
|
189
218
|
const tagColor = resolveTagColor(
|
|
190
219
|
node.metadata,
|
|
191
220
|
[...tagGroups],
|
|
@@ -368,6 +397,83 @@ export function renderBoxesAndLines(
|
|
|
368
397
|
const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
|
|
369
398
|
const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
|
|
370
399
|
const sTitleY = sctx.structural(TITLE_Y);
|
|
400
|
+
|
|
401
|
+
// ── Value ramp + active-dimension resolution (mirror of map's value model) ──
|
|
402
|
+
// The ramp is computed in the renderer (architectural divergence from the
|
|
403
|
+
// map, which precomputes in layout) — node sizes are value-independent, and
|
|
404
|
+
// this file already owns all colouring + the legend build. Hoisted ONCE
|
|
405
|
+
// before the node loop so `fillForValue` is not recomputed per node.
|
|
406
|
+
const nodeValues = parsed.nodes
|
|
407
|
+
.filter((n) => n.value !== undefined)
|
|
408
|
+
.map((n) => n.value!);
|
|
409
|
+
const hasRamp = nodeValues.length > 0;
|
|
410
|
+
const allNonNegative = hasRamp && nodeValues.every((v) => v >= 0);
|
|
411
|
+
const rampMin = allNonNegative ? 0 : Math.min(...nodeValues);
|
|
412
|
+
const rampMax = Math.max(...nodeValues);
|
|
413
|
+
// Default hue = palette.primary (NOT red like the map — boxes have no water to
|
|
414
|
+
// stand out against, and red reads as alarm on a neutral metric). A trailing
|
|
415
|
+
// color on `box-metric` overrides.
|
|
416
|
+
const rampHue =
|
|
417
|
+
resolveColor(parsed.boxMetricColor ?? '', palette) ?? palette.primary;
|
|
418
|
+
// Lift the ramp anchor off the near-black surface on dark themes so the
|
|
419
|
+
// lowest values read as a clear muted tint rather than sinking to the surface.
|
|
420
|
+
const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
|
|
421
|
+
const fillForValue = (v: number): string => {
|
|
422
|
+
const t = rampMax > rampMin ? (v - rampMin) / (rampMax - rampMin) : 1;
|
|
423
|
+
const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
|
|
424
|
+
return mix(rampHue, rampBase, pct);
|
|
425
|
+
};
|
|
426
|
+
const VALUE_NAME = hasRamp ? parsed.boxMetric?.trim() || 'Value' : null;
|
|
427
|
+
|
|
428
|
+
// Local active-dimension resolver — mirror map's inline matchColorGroup /
|
|
429
|
+
// activeIsScore. Do NOT extend the shared resolveActiveTagGroup (it has a
|
|
430
|
+
// fixed 3-arg signature consumed by 7 chart types). On a name collision
|
|
431
|
+
// between a tag group and the metric label, the tag group wins (AC9).
|
|
432
|
+
const matchColorGroup = (v: string): string | null => {
|
|
433
|
+
const lv = v.trim().toLowerCase();
|
|
434
|
+
if (lv === '' || lv === 'none') return null;
|
|
435
|
+
const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
|
|
436
|
+
if (tg) return tg.name;
|
|
437
|
+
if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
|
|
438
|
+
return v; // unknown name passes through → renders neutral
|
|
439
|
+
};
|
|
440
|
+
const override = activeTagGroup; // string | null | undefined
|
|
441
|
+
let activeGroup: string | null;
|
|
442
|
+
if (override !== undefined) {
|
|
443
|
+
activeGroup = override === null ? null : matchColorGroup(override);
|
|
444
|
+
} else if (parsed.options['active-tag'] !== undefined) {
|
|
445
|
+
activeGroup = matchColorGroup(parsed.options['active-tag']);
|
|
446
|
+
} else {
|
|
447
|
+
// Default-active dimension: value ramp when any box has a value, else the
|
|
448
|
+
// first declared tag group, else none.
|
|
449
|
+
activeGroup =
|
|
450
|
+
VALUE_NAME ??
|
|
451
|
+
(parsed.tagGroups.length > 0 ? parsed.tagGroups[0]!.name : null);
|
|
452
|
+
}
|
|
453
|
+
const activeIsValue = VALUE_NAME !== null && activeGroup === VALUE_NAME;
|
|
454
|
+
|
|
455
|
+
// Synthetic legend group for the value ramp (empty entries + gradient),
|
|
456
|
+
// prepended to the tag groups handed to renderLegendD3 — exactly like the
|
|
457
|
+
// map's VALUE_NAME group. The shared legend infra renders the gradient capsule
|
|
458
|
+
// ONLY when it is the active group (legendState.activeGroup === its name).
|
|
459
|
+
const valueGroup: LegendGroupData | null =
|
|
460
|
+
VALUE_NAME !== null
|
|
461
|
+
? {
|
|
462
|
+
name: VALUE_NAME,
|
|
463
|
+
entries: [],
|
|
464
|
+
gradient: {
|
|
465
|
+
min: rampMin,
|
|
466
|
+
max: rampMax,
|
|
467
|
+
hue: rampHue,
|
|
468
|
+
base: rampBase,
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
: null;
|
|
472
|
+
const legendGroups: readonly LegendGroupData[] = [
|
|
473
|
+
...(valueGroup ? [valueGroup] : []),
|
|
474
|
+
...parsed.tagGroups,
|
|
475
|
+
];
|
|
476
|
+
|
|
371
477
|
// Reserve legend height only when a legend will actually render. App-hosted
|
|
372
478
|
// controls move the Descriptions toggle to the app overlay, so a
|
|
373
479
|
// descriptions-only chart (no tag groups) reserves nothing.
|
|
@@ -375,13 +481,13 @@ export function renderBoxesAndLines(
|
|
|
375
481
|
(n) => n.description && n.description.length > 0
|
|
376
482
|
);
|
|
377
483
|
const willRenderLegend =
|
|
378
|
-
|
|
484
|
+
legendGroups.length > 0 ||
|
|
379
485
|
(reserveHasDescriptions && controlsHost !== 'app');
|
|
380
486
|
const sLegendHeight = willRenderLegend
|
|
381
487
|
? sctx.structural(
|
|
382
488
|
getMaxLegendReservedHeight(
|
|
383
489
|
{
|
|
384
|
-
groups:
|
|
490
|
+
groups: legendGroups,
|
|
385
491
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
386
492
|
mode: exportMode ? 'export' : 'preview',
|
|
387
493
|
},
|
|
@@ -390,12 +496,6 @@ export function renderBoxesAndLines(
|
|
|
390
496
|
)
|
|
391
497
|
: 0;
|
|
392
498
|
|
|
393
|
-
const activeGroup = resolveActiveTagGroup(
|
|
394
|
-
parsed.tagGroups,
|
|
395
|
-
parsed.options['active-tag'],
|
|
396
|
-
activeTagGroup
|
|
397
|
-
);
|
|
398
|
-
|
|
399
499
|
// Build hidden set
|
|
400
500
|
const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
|
|
401
501
|
|
|
@@ -414,7 +514,7 @@ export function renderBoxesAndLines(
|
|
|
414
514
|
(n) => n.description && n.description.length > 0
|
|
415
515
|
);
|
|
416
516
|
const needsLegend =
|
|
417
|
-
|
|
517
|
+
legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
|
|
418
518
|
const legendH = needsLegend ? sLegendHeight + 8 : 0;
|
|
419
519
|
|
|
420
520
|
const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
|
|
@@ -809,6 +909,7 @@ export function renderBoxesAndLines(
|
|
|
809
909
|
activeGroup,
|
|
810
910
|
palette,
|
|
811
911
|
isDark,
|
|
912
|
+
{ active: activeIsValue, hue: rampHue, fillForValue },
|
|
812
913
|
parsed.options['solid-fill'] === 'on'
|
|
813
914
|
);
|
|
814
915
|
|
|
@@ -826,6 +927,12 @@ export function renderBoxesAndLines(
|
|
|
826
927
|
nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
|
|
827
928
|
}
|
|
828
929
|
|
|
930
|
+
// Numeric value drives the gradient scrub; guard on !== undefined so a
|
|
931
|
+
// legitimate `value: 0` still emits data-value="0" (0 is falsy).
|
|
932
|
+
if (node.value !== undefined) {
|
|
933
|
+
nodeG.attr('data-value', node.value);
|
|
934
|
+
}
|
|
935
|
+
|
|
829
936
|
if (onClickItem) {
|
|
830
937
|
nodeG.on('click', (event: Event) => {
|
|
831
938
|
// Don't intercept clicks on links in description text
|
|
@@ -982,6 +1089,61 @@ export function renderBoxesAndLines(
|
|
|
982
1089
|
fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
|
|
983
1090
|
nodeG.append('title').text(tooltipText);
|
|
984
1091
|
}
|
|
1092
|
+
} else if (parsed.showValues && node.value !== undefined) {
|
|
1093
|
+
// Plain node with show-values: label header + thin divider + a
|
|
1094
|
+
// "Metric: value" line below (org/infra card style), instead of a
|
|
1095
|
+
// vertically-centered label with a floating number.
|
|
1096
|
+
const valueLabel = parsed.boxMetric
|
|
1097
|
+
? `${parsed.boxMetric}: ${node.value}`
|
|
1098
|
+
: String(node.value);
|
|
1099
|
+
// Fixed header zone (not label-height-driven) so the divider sits at a
|
|
1100
|
+
// UNIFORM Y across every box, regardless of label line count (infra/org
|
|
1101
|
+
// both anchor the separator to a constant header height).
|
|
1102
|
+
const headerH = ln.height / 2;
|
|
1103
|
+
const sepY = -ln.height / 2 + headerH;
|
|
1104
|
+
const fitted = fitLabelToHeader(node.label, ln.width, 2);
|
|
1105
|
+
const labelLineH = fitted.fontSize * 1.3;
|
|
1106
|
+
const labelTotalH = fitted.lines.length * labelLineH;
|
|
1107
|
+
const headerCenterY = -ln.height / 2 + headerH / 2;
|
|
1108
|
+
for (let li = 0; li < fitted.lines.length; li++) {
|
|
1109
|
+
nodeG
|
|
1110
|
+
.append('text')
|
|
1111
|
+
.attr('x', 0)
|
|
1112
|
+
.attr(
|
|
1113
|
+
'y',
|
|
1114
|
+
headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
|
|
1115
|
+
)
|
|
1116
|
+
.attr('text-anchor', 'middle')
|
|
1117
|
+
.attr('dominant-baseline', 'central')
|
|
1118
|
+
.attr('font-size', fitted.fontSize)
|
|
1119
|
+
.attr('font-weight', '600')
|
|
1120
|
+
.attr('fill', colors.text)
|
|
1121
|
+
// In-bounds by loop guard.
|
|
1122
|
+
.text(fitted.lines[li]!);
|
|
1123
|
+
}
|
|
1124
|
+
// Thin divider under the title — a tint of the box's own stroke colour
|
|
1125
|
+
// (matches org / infra card separators), not a neutral text line.
|
|
1126
|
+
nodeG
|
|
1127
|
+
.append('line')
|
|
1128
|
+
.attr('x1', -ln.width / 2)
|
|
1129
|
+
.attr('y1', sepY)
|
|
1130
|
+
.attr('x2', ln.width / 2)
|
|
1131
|
+
.attr('y2', sepY)
|
|
1132
|
+
.attr('stroke', colors.stroke)
|
|
1133
|
+
.attr('stroke-opacity', 0.3)
|
|
1134
|
+
.attr('stroke-width', 1);
|
|
1135
|
+
// "Metric: value" centered in the space below the divider.
|
|
1136
|
+
nodeG
|
|
1137
|
+
.append('text')
|
|
1138
|
+
.attr('class', 'bl-node-value')
|
|
1139
|
+
.attr('x', 0)
|
|
1140
|
+
.attr('y', (sepY + ln.height / 2) / 2)
|
|
1141
|
+
.attr('text-anchor', 'middle')
|
|
1142
|
+
.attr('dominant-baseline', 'central')
|
|
1143
|
+
.attr('font-size', VALUE_FONT_SIZE)
|
|
1144
|
+
.attr('fill', colors.text)
|
|
1145
|
+
.attr('opacity', 0.85)
|
|
1146
|
+
.text(valueLabel);
|
|
985
1147
|
} else {
|
|
986
1148
|
const maxLabelLines = Math.max(
|
|
987
1149
|
2,
|
|
@@ -1004,6 +1166,48 @@ export function renderBoxesAndLines(
|
|
|
1004
1166
|
.text(fitted.lines[li]!);
|
|
1005
1167
|
}
|
|
1006
1168
|
}
|
|
1169
|
+
|
|
1170
|
+
// ── show-values on a DESCRIBED node ── the body is already full, so the
|
|
1171
|
+
// value rides in a top-right corner badge (plain nodes are handled in the
|
|
1172
|
+
// header/divider branch above; a described node with descriptions hidden
|
|
1173
|
+
// also falls through to that plain branch).
|
|
1174
|
+
if (
|
|
1175
|
+
parsed.showValues &&
|
|
1176
|
+
node.value !== undefined &&
|
|
1177
|
+
desc &&
|
|
1178
|
+
desc.length > 0 &&
|
|
1179
|
+
!hideDescriptions
|
|
1180
|
+
) {
|
|
1181
|
+
const valueText = String(node.value);
|
|
1182
|
+
const padX = 6;
|
|
1183
|
+
const padY = 5;
|
|
1184
|
+
const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO + 8;
|
|
1185
|
+
const bh = VALUE_FONT_SIZE + 4;
|
|
1186
|
+
// Clamp to the left padding so a long value on a narrow node never
|
|
1187
|
+
// slides past the box edge / over the label (R2-6 / AC23).
|
|
1188
|
+
const bx = Math.max(-ln.width / 2 + 4, ln.width / 2 - bw - 4);
|
|
1189
|
+
const by = -ln.height / 2 + 4;
|
|
1190
|
+
nodeG
|
|
1191
|
+
.append('rect')
|
|
1192
|
+
.attr('x', bx)
|
|
1193
|
+
.attr('y', by)
|
|
1194
|
+
.attr('width', bw)
|
|
1195
|
+
.attr('height', bh)
|
|
1196
|
+
.attr('rx', 3)
|
|
1197
|
+
.attr('fill', palette.bg)
|
|
1198
|
+
.attr('opacity', 0.85);
|
|
1199
|
+
nodeG
|
|
1200
|
+
.append('text')
|
|
1201
|
+
.attr('class', 'bl-node-value')
|
|
1202
|
+
.attr('x', bx + bw - padX)
|
|
1203
|
+
.attr('y', by + padY)
|
|
1204
|
+
.attr('text-anchor', 'end')
|
|
1205
|
+
.attr('dominant-baseline', 'central')
|
|
1206
|
+
.attr('font-size', VALUE_FONT_SIZE)
|
|
1207
|
+
.attr('font-weight', '600')
|
|
1208
|
+
.attr('fill', palette.textMuted)
|
|
1209
|
+
.text(valueText);
|
|
1210
|
+
}
|
|
1007
1211
|
}
|
|
1008
1212
|
|
|
1009
1213
|
// ── Render legend ──────────────────────────────────────
|
|
@@ -1011,9 +1215,10 @@ export function renderBoxesAndLines(
|
|
|
1011
1215
|
(n) => n.description && n.description.length > 0
|
|
1012
1216
|
);
|
|
1013
1217
|
// App-hosted: the Descriptions control moves to the app overlay, so a
|
|
1014
|
-
// descriptions-only legend (no tag groups) has nothing left to render.
|
|
1218
|
+
// descriptions-only legend (no tag groups) has nothing left to render. The
|
|
1219
|
+
// value ramp (a synthetic group in legendGroups) also forces a legend.
|
|
1015
1220
|
const hasLegend =
|
|
1016
|
-
|
|
1221
|
+
legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
|
|
1017
1222
|
|
|
1018
1223
|
if (hasLegend) {
|
|
1019
1224
|
// Build controls group for description toggle. App-hosted controls own the
|
|
@@ -1035,7 +1240,7 @@ export function renderBoxesAndLines(
|
|
|
1035
1240
|
}
|
|
1036
1241
|
|
|
1037
1242
|
const legendConfig: LegendConfig = {
|
|
1038
|
-
groups:
|
|
1243
|
+
groups: legendGroups,
|
|
1039
1244
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
1040
1245
|
mode: exportMode ? 'export' : 'preview',
|
|
1041
1246
|
// 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
|
@@ -4238,7 +4238,6 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4238
4238
|
): void {
|
|
4239
4239
|
const {
|
|
4240
4240
|
width,
|
|
4241
|
-
height,
|
|
4242
4241
|
tooltip,
|
|
4243
4242
|
solid,
|
|
4244
4243
|
textColor,
|
|
@@ -4301,8 +4300,20 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4301
4300
|
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4302
4301
|
: 0;
|
|
4303
4302
|
const innerWidth = width - margin.left - margin.right;
|
|
4304
|
-
|
|
4305
|
-
|
|
4303
|
+
// Each event gets a fixed comfortable row. The old behaviour compressed rowH
|
|
4304
|
+
// to fit the container height (`min(28, avail / n)`), but that only ever
|
|
4305
|
+
// shrank rows BELOW the 22px bar height — cramming events into overlap when
|
|
4306
|
+
// the host surface was shorter than the content required (e.g. the app's
|
|
4307
|
+
// fixed-height embedded-diagram surface). A constant rowH never overlaps:
|
|
4308
|
+
// when the container is taller than needed the SVG shrinks to the content
|
|
4309
|
+
// (top-aligned via preserveAspectRatio); when shorter, the SVG grows past it
|
|
4310
|
+
// and the host collapses/expands to the rendered height. This also makes the
|
|
4311
|
+
// interactive preview match the exported image, which already used rowH=28.
|
|
4312
|
+
const rowH = ctx.structural(28);
|
|
4313
|
+
// Draw the era bands and time axis to the content height (not the full
|
|
4314
|
+
// container) so the axis sits just below the last event.
|
|
4315
|
+
const innerHeight = rowH * sorted.length;
|
|
4316
|
+
const usedHeight = margin.top + innerHeight + margin.bottom;
|
|
4306
4317
|
|
|
4307
4318
|
const xScale = d3Scale
|
|
4308
4319
|
.scaleLinear()
|
|
@@ -4313,8 +4324,8 @@ function renderTimelineHorizontalTimeSort(
|
|
|
4313
4324
|
.select(container)
|
|
4314
4325
|
.append('svg')
|
|
4315
4326
|
.attr('width', width)
|
|
4316
|
-
.attr('height',
|
|
4317
|
-
.attr('viewBox', `0 0 ${width} ${
|
|
4327
|
+
.attr('height', usedHeight)
|
|
4328
|
+
.attr('viewBox', `0 0 ${width} ${usedHeight}`)
|
|
4318
4329
|
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
4319
4330
|
.style('background', bgColor);
|
|
4320
4331
|
|
|
@@ -7743,6 +7754,10 @@ export async function renderForExport(
|
|
|
7743
7754
|
// here — the Node fs `loadMapData()` seam can't run in a browser. CLI/SSR
|
|
7744
7755
|
// omit this and fall back to the fs loader.
|
|
7745
7756
|
mapData?: import('./map/resolved-types').MapData;
|
|
7757
|
+
// WYSIWYG map export: the live preview pane's displayed aspect (w/h). When
|
|
7758
|
+
// set, the map canvas adopts it + stretch-fills so the PNG matches the
|
|
7759
|
+
// on-screen map. The app passes this; headless consumers omit it.
|
|
7760
|
+
mapAspect?: number;
|
|
7746
7761
|
}
|
|
7747
7762
|
): Promise<string> {
|
|
7748
7763
|
const exportMode = options?.exportMode ?? false;
|
|
@@ -8483,7 +8498,12 @@ export async function renderForExport(
|
|
|
8483
8498
|
// aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
|
|
8484
8499
|
// export matches the content's natural shape — no vertical stretch, no
|
|
8485
8500
|
// letterbox bands. `preferContain` rides along to the renderer.
|
|
8486
|
-
const dims = mapExportDimensions(
|
|
8501
|
+
const dims = mapExportDimensions(
|
|
8502
|
+
mapResolved,
|
|
8503
|
+
mapData,
|
|
8504
|
+
EXPORT_WIDTH,
|
|
8505
|
+
options?.mapAspect
|
|
8506
|
+
);
|
|
8487
8507
|
const container = createExportContainer(dims.width, dims.height);
|
|
8488
8508
|
renderMapForExport(
|
|
8489
8509
|
container,
|
package/src/editor/keywords.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -213,6 +213,14 @@ export { themes, type Theme } from './themes';
|
|
|
213
213
|
|
|
214
214
|
export { getMinDimensions } from './dimensions';
|
|
215
215
|
|
|
216
|
+
// ============================================================
|
|
217
|
+
// SVG embed normalization (responsive inline embedding)
|
|
218
|
+
// ============================================================
|
|
219
|
+
// Tightens a static render() SVG's viewBox to its content + strips fixed
|
|
220
|
+
// width/height so hosts (Obsidian, remark/markdown, web) can size it to its
|
|
221
|
+
// natural aspect ratio with no dead space. Pure string transform.
|
|
222
|
+
export { normalizeSvgForEmbed, getEmbedSvgViewBox } from './utils/svg-embed';
|
|
223
|
+
|
|
216
224
|
// ============================================================
|
|
217
225
|
// Map chart-type completion (gazetteer-fed; §24B.5/.8)
|
|
218
226
|
// ============================================================
|