@diagrammo/dgmo 0.21.1 → 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/README.md +16 -6
- package/dist/advanced.cjs +2230 -503
- package/dist/advanced.d.cts +5731 -0
- package/dist/advanced.d.ts +5731 -0
- package/dist/advanced.js +2226 -503
- package/dist/auto.cjs +2272 -479
- package/dist/auto.d.cts +39 -0
- package/dist/auto.d.ts +39 -0
- package/dist/auto.js +124 -124
- package/dist/auto.mjs +2274 -480
- package/dist/cli.cjs +170 -170
- package/dist/editor.cjs +16 -16
- package/dist/editor.js +16 -16
- package/dist/highlight.cjs +18 -13
- package/dist/highlight.js +18 -13
- package/dist/index.cjs +2253 -465
- package/dist/index.d.cts +339 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +2255 -466
- package/dist/internal.cjs +2230 -503
- package/dist/internal.d.cts +5731 -0
- package/dist/internal.d.ts +5731 -0
- package/dist/internal.js +2226 -503
- 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 -0
- package/dist/map-data/world-coarse.json +1 -1
- package/dist/map-data/world-detail.json +1 -1
- package/docs/language-reference.md +55 -9
- package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
- package/gallery/fixtures/map-categorical-world.dgmo +16 -0
- package/gallery/fixtures/map-categorical.dgmo +0 -1
- package/gallery/fixtures/map-choropleth.dgmo +0 -1
- package/gallery/fixtures/map-coastline.dgmo +7 -0
- package/gallery/fixtures/map-colorize.dgmo +11 -0
- package/gallery/fixtures/map-direct-color.dgmo +0 -1
- package/gallery/fixtures/map-reference-world.dgmo +11 -0
- package/gallery/fixtures/map-region-scope.dgmo +0 -3
- package/gallery/fixtures/map-route.dgmo +0 -1
- package/package.json +1 -1
- package/src/advanced.ts +12 -1
- package/src/boxes-and-lines/parser.ts +39 -0
- package/src/boxes-and-lines/renderer.ts +205 -20
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/cli.ts +1 -1
- package/src/completion.ts +36 -30
- package/src/cycle/renderer.ts +14 -1
- package/src/d3.ts +20 -6
- package/src/editor/highlight-api.ts +4 -0
- package/src/editor/keywords.ts +16 -16
- package/src/infra/renderer.ts +35 -7
- package/src/map/colorize.ts +54 -0
- package/src/map/context-labels.ts +429 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/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/types.ts +34 -0
- package/src/map/data/water-bodies.json +1 -0
- package/src/map/data/world-coarse.json +1 -1
- package/src/map/data/world-detail.json +1 -1
- package/src/map/dimensions.ts +117 -0
- package/src/map/geo-query.ts +21 -3
- package/src/map/geo.ts +47 -1
- package/src/map/layout.ts +1408 -266
- package/src/map/load-data.ts +10 -2
- package/src/map/parser.ts +42 -116
- package/src/map/renderer.ts +604 -14
- package/src/map/resolved-types.ts +16 -2
- package/src/map/resolver.ts +208 -59
- package/src/map/types.ts +30 -32
- package/src/mindmap/renderer.ts +10 -1
- package/src/palettes/atlas.ts +77 -0
- package/src/palettes/blueprint.ts +73 -0
- package/src/palettes/color-utils.ts +58 -1
- package/src/palettes/index.ts +12 -3
- package/src/palettes/slate.ts +73 -0
- package/src/palettes/tidewater.ts +73 -0
- package/src/render.ts +8 -1
- package/src/tech-radar/renderer.ts +3 -0
- package/src/tech-radar/types.ts +3 -0
- package/src/utils/d3-types.ts +5 -0
- package/src/utils/legend-layout.ts +21 -4
- package/src/utils/legend-types.ts +7 -0
- package/src/utils/reserved-key-registry.ts +8 -3
- package/src/palettes/bold.ts +0 -67
|
@@ -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],
|
|
@@ -324,6 +351,9 @@ interface BLRenderOptions {
|
|
|
324
351
|
onToggleDescriptions?: (active: boolean) => void;
|
|
325
352
|
onToggleControlsExpand?: () => void;
|
|
326
353
|
exportMode?: boolean;
|
|
354
|
+
/** When 'app', the description toggle is hosted by the app overlay strip
|
|
355
|
+
* (inline gear suppressed, controls row + anchor reserved). */
|
|
356
|
+
controlsHost?: 'app' | 'inline';
|
|
327
357
|
}
|
|
328
358
|
|
|
329
359
|
export function renderBoxesAndLines(
|
|
@@ -344,6 +374,7 @@ export function renderBoxesAndLines(
|
|
|
344
374
|
onToggleDescriptions,
|
|
345
375
|
onToggleControlsExpand,
|
|
346
376
|
exportMode = false,
|
|
377
|
+
controlsHost,
|
|
347
378
|
} = options ?? {};
|
|
348
379
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
349
380
|
|
|
@@ -364,22 +395,104 @@ export function renderBoxesAndLines(
|
|
|
364
395
|
const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
|
|
365
396
|
const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
|
|
366
397
|
const sTitleY = sctx.structural(TITLE_Y);
|
|
367
|
-
const sLegendHeight = sctx.structural(
|
|
368
|
-
getMaxLegendReservedHeight(
|
|
369
|
-
{
|
|
370
|
-
groups: parsed.tagGroups,
|
|
371
|
-
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
372
|
-
mode: exportMode ? 'export' : 'preview',
|
|
373
|
-
},
|
|
374
|
-
width
|
|
375
|
-
)
|
|
376
|
-
);
|
|
377
398
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
|
|
475
|
+
// Reserve legend height only when a legend will actually render. App-hosted
|
|
476
|
+
// controls move the Descriptions toggle to the app overlay, so a
|
|
477
|
+
// descriptions-only chart (no tag groups) reserves nothing.
|
|
478
|
+
const reserveHasDescriptions = parsed.nodes.some(
|
|
479
|
+
(n) => n.description && n.description.length > 0
|
|
382
480
|
);
|
|
481
|
+
const willRenderLegend =
|
|
482
|
+
legendGroups.length > 0 ||
|
|
483
|
+
(reserveHasDescriptions && controlsHost !== 'app');
|
|
484
|
+
const sLegendHeight = willRenderLegend
|
|
485
|
+
? sctx.structural(
|
|
486
|
+
getMaxLegendReservedHeight(
|
|
487
|
+
{
|
|
488
|
+
groups: legendGroups,
|
|
489
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
490
|
+
mode: exportMode ? 'export' : 'preview',
|
|
491
|
+
},
|
|
492
|
+
width
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
: 0;
|
|
383
496
|
|
|
384
497
|
// Build hidden set
|
|
385
498
|
const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
|
|
@@ -399,7 +512,7 @@ export function renderBoxesAndLines(
|
|
|
399
512
|
(n) => n.description && n.description.length > 0
|
|
400
513
|
);
|
|
401
514
|
const needsLegend =
|
|
402
|
-
|
|
515
|
+
legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
|
|
403
516
|
const legendH = needsLegend ? sLegendHeight + 8 : 0;
|
|
404
517
|
|
|
405
518
|
const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
|
|
@@ -794,6 +907,7 @@ export function renderBoxesAndLines(
|
|
|
794
907
|
activeGroup,
|
|
795
908
|
palette,
|
|
796
909
|
isDark,
|
|
910
|
+
{ active: activeIsValue, hue: rampHue, fillForValue },
|
|
797
911
|
parsed.options['solid-fill'] === 'on'
|
|
798
912
|
);
|
|
799
913
|
|
|
@@ -811,6 +925,12 @@ export function renderBoxesAndLines(
|
|
|
811
925
|
nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
|
|
812
926
|
}
|
|
813
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
|
+
|
|
814
934
|
if (onClickItem) {
|
|
815
935
|
nodeG.on('click', (event: Event) => {
|
|
816
936
|
// Don't intercept clicks on links in description text
|
|
@@ -989,18 +1109,76 @@ export function renderBoxesAndLines(
|
|
|
989
1109
|
.text(fitted.lines[li]!);
|
|
990
1110
|
}
|
|
991
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
|
+
}
|
|
992
1164
|
}
|
|
993
1165
|
|
|
994
1166
|
// ── Render legend ──────────────────────────────────────
|
|
995
1167
|
const hasDescriptions = parsed.nodes.some(
|
|
996
1168
|
(n) => n.description && n.description.length > 0
|
|
997
1169
|
);
|
|
998
|
-
|
|
1170
|
+
// App-hosted: the Descriptions control moves to the app overlay, so a
|
|
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.
|
|
1173
|
+
const hasLegend =
|
|
1174
|
+
legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
|
|
999
1175
|
|
|
1000
1176
|
if (hasLegend) {
|
|
1001
|
-
// Build controls group for description toggle
|
|
1177
|
+
// Build controls group for description toggle. App-hosted controls own the
|
|
1178
|
+
// toggling, so the group is built (to gate + size the row) even without the
|
|
1179
|
+
// inline-gear callback.
|
|
1002
1180
|
let controlsGroup: { toggles: ControlsGroupToggle[] } | undefined;
|
|
1003
|
-
if (hasDescriptions && onToggleDescriptions) {
|
|
1181
|
+
if (hasDescriptions && (onToggleDescriptions || controlsHost === 'app')) {
|
|
1004
1182
|
controlsGroup = {
|
|
1005
1183
|
toggles: [
|
|
1006
1184
|
{
|
|
@@ -1015,10 +1193,17 @@ export function renderBoxesAndLines(
|
|
|
1015
1193
|
}
|
|
1016
1194
|
|
|
1017
1195
|
const legendConfig: LegendConfig = {
|
|
1018
|
-
groups:
|
|
1196
|
+
groups: legendGroups,
|
|
1019
1197
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
1020
1198
|
mode: exportMode ? 'export' : 'preview',
|
|
1199
|
+
// Keep inactive sibling tag groups visible as collapsed pills so the user
|
|
1200
|
+
// can click one to flip the active colouring dimension (preview only —
|
|
1201
|
+
// export shows just the active group). Without this, declaring a second
|
|
1202
|
+
// tag group (e.g. Team) leaves it invisible whenever another group is
|
|
1203
|
+
// active. The app's BoxesAndLinesPreview already wires pill clicks.
|
|
1204
|
+
showInactivePills: true,
|
|
1021
1205
|
...(controlsGroup !== undefined && { controlsGroup }),
|
|
1206
|
+
...(controlsHost !== undefined && { controlsHost }),
|
|
1022
1207
|
};
|
|
1023
1208
|
const legendState: LegendState = {
|
|
1024
1209
|
activeGroup,
|
|
@@ -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/cli.ts
CHANGED
|
@@ -136,7 +136,7 @@ Key options:
|
|
|
136
136
|
- \`-o <file>\` — output file; format inferred from extension (\`.svg\` → SVG, else PNG)
|
|
137
137
|
- \`-o url\` — output a shareable diagrammo.app URL
|
|
138
138
|
- \`--theme <theme>\` — \`light\` (default), \`dark\`, \`transparent\`
|
|
139
|
-
- \`--palette <name>\` — \`nord\` (default), \`solarized\`, \`catppuccin\`, \`rose-pine\`, \`gruvbox\`, \`tokyo-night\`, \`one-dark\`, \`
|
|
139
|
+
- \`--palette <name>\` — \`nord\` (default), \`atlas\`, \`blueprint\`, \`slate\`, \`tidewater\`, \`solarized\`, \`catppuccin\`, \`rose-pine\`, \`gruvbox\`, \`tokyo-night\`, \`one-dark\`, \`dracula\`, \`monokai\`
|
|
140
140
|
- \`--copy\` — copy the URL to clipboard (use with \`-o url\`)
|
|
141
141
|
- \`--chart-types\` — list all supported chart types
|
|
142
142
|
|
package/src/completion.ts
CHANGED
|
@@ -98,9 +98,12 @@ const GLOBAL_DIRECTIVES: Record<string, DirectiveValueSpec> = {
|
|
|
98
98
|
'gruvbox',
|
|
99
99
|
'tokyo-night',
|
|
100
100
|
'one-dark',
|
|
101
|
-
'bold',
|
|
102
101
|
'dracula',
|
|
103
102
|
'monokai',
|
|
103
|
+
'atlas',
|
|
104
|
+
'blueprint',
|
|
105
|
+
'slate',
|
|
106
|
+
'tidewater',
|
|
104
107
|
],
|
|
105
108
|
},
|
|
106
109
|
theme: {
|
|
@@ -444,6 +447,8 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
|
|
|
444
447
|
direction: { description: 'Layout direction', values: ['LR', 'TB'] },
|
|
445
448
|
'active-tag': { description: 'Active tag group name' },
|
|
446
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' },
|
|
447
452
|
}),
|
|
448
453
|
],
|
|
449
454
|
[
|
|
@@ -508,19 +513,12 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
|
|
|
508
513
|
],
|
|
509
514
|
[
|
|
510
515
|
'map',
|
|
511
|
-
// Geographic map directives (§24B.2/.7).
|
|
512
|
-
//
|
|
513
|
-
//
|
|
516
|
+
// Geographic map directives (§24B.2/.7). Cosmetics are ON by default — the
|
|
517
|
+
// only switches are bare `no-*` opt-outs, surfaced proactively so a
|
|
518
|
+
// zero-config map still hints at what can be turned off. `poi`/`route` are
|
|
519
|
+
// content keywords, not directives; metadata keys (value/label/style) live
|
|
520
|
+
// in the reserved-key registry.
|
|
514
521
|
withGlobals({
|
|
515
|
-
region: {
|
|
516
|
-
description:
|
|
517
|
-
'Basemap: us-states (force US state mesh + scoping) | world (inert — already the default)',
|
|
518
|
-
values: ['us-states', 'world'],
|
|
519
|
-
},
|
|
520
|
-
projection: {
|
|
521
|
-
description: 'Override the auto projection',
|
|
522
|
-
values: ['equirectangular', 'natural-earth', 'albers-usa', 'mercator'],
|
|
523
|
-
},
|
|
524
522
|
'region-metric': { description: 'Label for the region value ramp' },
|
|
525
523
|
'poi-metric': {
|
|
526
524
|
description: 'Label for the POI value (marker size) channel',
|
|
@@ -528,21 +526,32 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
|
|
|
528
526
|
'flow-metric': {
|
|
529
527
|
description: 'Label for the edge/leg value (thickness) channel',
|
|
530
528
|
},
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
values: ['full', 'abbrev', 'off'],
|
|
529
|
+
locale: {
|
|
530
|
+
description:
|
|
531
|
+
'Default country/state for bare place names, e.g. locale US-GA',
|
|
535
532
|
},
|
|
536
|
-
'
|
|
537
|
-
description: '
|
|
538
|
-
values: ['off', 'auto', 'all'],
|
|
533
|
+
'active-tag': {
|
|
534
|
+
description: 'Which tag group leads when several are present',
|
|
539
535
|
},
|
|
540
|
-
|
|
541
|
-
'default-state': { description: 'ISO subdivision scope' },
|
|
536
|
+
caption: { description: 'Caption line (data-source attribution)' },
|
|
542
537
|
'no-legend': { description: 'Suppress the legend' },
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
538
|
+
'no-coastline': {
|
|
539
|
+
description: 'Turn off coastal water-lines (on by default)',
|
|
540
|
+
},
|
|
541
|
+
'no-relief': {
|
|
542
|
+
description: 'Turn off mountain-range relief shading (on by default)',
|
|
543
|
+
},
|
|
544
|
+
'no-context-labels': {
|
|
545
|
+
description: 'Turn off orientation labels for water + nearby countries',
|
|
546
|
+
},
|
|
547
|
+
'no-region-labels': {
|
|
548
|
+
description: 'Turn off subdivision name labels (on by default)',
|
|
549
|
+
},
|
|
550
|
+
'no-poi-labels': { description: 'Turn off POI labels (on by default)' },
|
|
551
|
+
'no-colorize': {
|
|
552
|
+
description:
|
|
553
|
+
'Force plain green-land reference dress (regions are auto-coloured by default)',
|
|
554
|
+
},
|
|
546
555
|
}),
|
|
547
556
|
],
|
|
548
557
|
]);
|
|
@@ -733,12 +742,9 @@ export const PIPE_METADATA = new Map<string, PipeContextMap>([
|
|
|
733
742
|
{
|
|
734
743
|
node: {
|
|
735
744
|
description: { description: 'Node description text' },
|
|
745
|
+
value: { description: 'Numeric value for the metric ramp' },
|
|
736
746
|
},
|
|
737
|
-
edge: {
|
|
738
|
-
width: { description: 'Edge stroke width in pixels' },
|
|
739
|
-
split: { description: 'Traffic split percentage' },
|
|
740
|
-
fanout: { description: 'Fanout multiplier (integer >= 1)' },
|
|
741
|
-
},
|
|
747
|
+
edge: {},
|
|
742
748
|
},
|
|
743
749
|
],
|
|
744
750
|
[
|
package/src/cycle/renderer.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface CycleRenderOptions {
|
|
|
48
48
|
onToggleDescriptions?: (active: boolean) => void;
|
|
49
49
|
onToggleControlsExpand?: () => void;
|
|
50
50
|
exportMode?: boolean;
|
|
51
|
+
/** When 'app', the description toggle is hosted by the app overlay strip:
|
|
52
|
+
* the inline gear is suppressed and a controls row + anchor are reserved.
|
|
53
|
+
* Default (inline) renders the gear as before. */
|
|
54
|
+
controlsHost?: 'app' | 'inline';
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
@@ -92,7 +96,13 @@ export function renderCycle(
|
|
|
92
96
|
const hasDescriptions =
|
|
93
97
|
parsed.nodes.some((n) => n.description.length > 0) ||
|
|
94
98
|
parsed.edges.some((e) => e.description.length > 0);
|
|
95
|
-
|
|
99
|
+
// App-hosted: controls live in the app overlay strip. Cycle has no tag groups,
|
|
100
|
+
// so there's no in-SVG legend left to render — don't reserve a legend band.
|
|
101
|
+
const appHostedControls = renderOptions?.controlsHost === 'app';
|
|
102
|
+
const hasLegend =
|
|
103
|
+
!appHostedControls &&
|
|
104
|
+
hasDescriptions &&
|
|
105
|
+
!!renderOptions?.onToggleDescriptions;
|
|
96
106
|
|
|
97
107
|
const showTitle = !!parsed.title && parsed.options['no-title'] !== 'on';
|
|
98
108
|
const legendOffset = hasLegend ? sLegendHeight : 0;
|
|
@@ -160,6 +170,9 @@ export function renderCycle(
|
|
|
160
170
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
161
171
|
mode: renderOptions?.exportMode ? 'export' : 'preview',
|
|
162
172
|
controlsGroup,
|
|
173
|
+
...(renderOptions?.controlsHost !== undefined && {
|
|
174
|
+
controlsHost: renderOptions.controlsHost,
|
|
175
|
+
}),
|
|
163
176
|
};
|
|
164
177
|
const legendState: LegendState = {
|
|
165
178
|
activeGroup: null,
|
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
|
|
|
@@ -8459,6 +8467,7 @@ export async function renderForExport(
|
|
|
8459
8467
|
const { parseMap } = await import('./map/parser');
|
|
8460
8468
|
const { resolveMap } = await import('./map/resolver');
|
|
8461
8469
|
const { renderMapForExport } = await import('./map/renderer');
|
|
8470
|
+
const { mapExportDimensions } = await import('./map/dimensions');
|
|
8462
8471
|
|
|
8463
8472
|
const effectivePalette = await resolveExportPalette(theme, palette);
|
|
8464
8473
|
const mapParsed = parseMap(content);
|
|
@@ -8478,14 +8487,19 @@ export async function renderForExport(
|
|
|
8478
8487
|
}
|
|
8479
8488
|
const mapResolved = resolveMap(mapParsed, mapData);
|
|
8480
8489
|
|
|
8481
|
-
|
|
8490
|
+
// Content-aware canvas: derive the height from the map's intrinsic projected
|
|
8491
|
+
// aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
|
|
8492
|
+
// export matches the content's natural shape — no vertical stretch, no
|
|
8493
|
+
// letterbox bands. `preferContain` rides along to the renderer.
|
|
8494
|
+
const dims = mapExportDimensions(mapResolved, mapData, EXPORT_WIDTH);
|
|
8495
|
+
const container = createExportContainer(dims.width, dims.height);
|
|
8482
8496
|
renderMapForExport(
|
|
8483
8497
|
container,
|
|
8484
8498
|
mapResolved,
|
|
8485
8499
|
mapData,
|
|
8486
8500
|
effectivePalette,
|
|
8487
8501
|
theme === 'dark',
|
|
8488
|
-
|
|
8502
|
+
dims
|
|
8489
8503
|
);
|
|
8490
8504
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
8491
8505
|
}
|
package/src/editor/keywords.ts
CHANGED
|
@@ -80,11 +80,10 @@ export const METADATA_KEYS = new Set([
|
|
|
80
80
|
'quadrant',
|
|
81
81
|
'ring',
|
|
82
82
|
'trend',
|
|
83
|
-
// Map (§24B) metadata keys
|
|
84
|
-
'
|
|
83
|
+
// Map (§24B) reserved metadata keys
|
|
84
|
+
'value',
|
|
85
85
|
'label',
|
|
86
|
-
'
|
|
87
|
-
'weight',
|
|
86
|
+
'style',
|
|
88
87
|
]);
|
|
89
88
|
|
|
90
89
|
/** Tag declaration keyword. */
|
|
@@ -111,6 +110,9 @@ export const DIRECTIVE_KEYWORDS = new Set([
|
|
|
111
110
|
'hide',
|
|
112
111
|
'mode',
|
|
113
112
|
'direction',
|
|
113
|
+
// Boxes-and-lines
|
|
114
|
+
'box-metric',
|
|
115
|
+
'show-values',
|
|
114
116
|
// ER
|
|
115
117
|
'notation',
|
|
116
118
|
// Class
|
|
@@ -150,22 +152,20 @@ export const DIRECTIVE_KEYWORDS = new Set([
|
|
|
150
152
|
// Sequence
|
|
151
153
|
'activations',
|
|
152
154
|
'no-activations',
|
|
153
|
-
// Map (§24B) directives
|
|
154
|
-
'region',
|
|
155
|
-
'projection',
|
|
155
|
+
// Map (§24B) directives — cosmetics on by default, bare `no-*` opt-outs
|
|
156
156
|
'region-metric',
|
|
157
157
|
'poi-metric',
|
|
158
158
|
'flow-metric',
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'default-country',
|
|
162
|
-
'default-state',
|
|
163
|
-
'no-legend',
|
|
164
|
-
'no-insets',
|
|
165
|
-
'muted',
|
|
166
|
-
'natural',
|
|
167
|
-
'subtitle',
|
|
159
|
+
'locale',
|
|
160
|
+
'active-tag',
|
|
168
161
|
'caption',
|
|
162
|
+
'no-legend',
|
|
163
|
+
'no-coastline',
|
|
164
|
+
'no-relief',
|
|
165
|
+
'no-context-labels',
|
|
166
|
+
'no-region-labels',
|
|
167
|
+
'no-poi-labels',
|
|
168
|
+
'no-colorize',
|
|
169
169
|
'poi',
|
|
170
170
|
'route',
|
|
171
171
|
// Data charts
|