@diagrammo/dgmo 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursorrules +4 -1
- package/.github/copilot-instructions.md +4 -1
- package/.windsurfrules +4 -1
- package/README.md +21 -3
- package/SKILL.md +4 -1
- package/dist/advanced.cjs +1853 -623
- package/dist/advanced.d.cts +143 -16
- package/dist/advanced.d.ts +143 -16
- package/dist/advanced.js +1846 -623
- package/dist/auto.cjs +1640 -581
- package/dist/auto.js +99 -99
- package/dist/auto.mjs +1640 -581
- package/dist/cli.cjs +148 -147
- package/dist/index.cjs +1643 -662
- package/dist/index.js +1643 -662
- package/docs/ai-integration.md +4 -1
- package/docs/language-reference.md +282 -27
- package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
- package/gallery/fixtures/c4-full.dgmo +4 -5
- package/gallery/fixtures/c4.dgmo +2 -3
- package/package.json +7 -1
- package/src/advanced.ts +10 -0
- package/src/boxes-and-lines/focus.ts +257 -0
- package/src/boxes-and-lines/layout-search.ts +345 -65
- package/src/boxes-and-lines/layout.ts +11 -1
- package/src/boxes-and-lines/parser.ts +97 -4
- package/src/boxes-and-lines/renderer.ts +111 -8
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/c4/parser.ts +8 -7
- package/src/c4/renderer.ts +7 -5
- package/src/chart-type-registry.ts +129 -4
- package/src/chart-types.ts +3 -3
- package/src/chart.ts +18 -1
- package/src/class/renderer.ts +4 -2
- package/src/cli-banner.ts +107 -0
- package/src/cli.ts +13 -0
- package/src/colors.ts +247 -2
- package/src/cycle/parser.ts +2 -7
- package/src/d3.ts +67 -54
- package/src/diagnostics.ts +17 -0
- package/src/dimensions.ts +9 -13
- package/src/echarts.ts +42 -14
- package/src/er/parser.ts +6 -1
- package/src/er/renderer.ts +4 -2
- package/src/gantt/parser.ts +44 -7
- package/src/graph/flowchart-parser.ts +77 -3
- package/src/graph/flowchart-renderer.ts +4 -2
- package/src/graph/state-renderer.ts +6 -4
- package/src/infra/parser.ts +80 -0
- package/src/infra/renderer.ts +8 -4
- package/src/journey-map/parser.ts +23 -8
- package/src/journey-map/renderer.ts +1 -1
- package/src/kanban/parser.ts +8 -7
- package/src/kanban/renderer.ts +1 -1
- package/src/map/context-labels.ts +134 -27
- package/src/map/geo.ts +10 -2
- package/src/map/layout.ts +259 -4
- package/src/map/parser.ts +2 -0
- package/src/map/renderer.ts +49 -25
- package/src/map/resolver.ts +68 -19
- package/src/mindmap/parser.ts +15 -7
- package/src/mindmap/renderer.ts +55 -15
- package/src/org/parser.ts +8 -7
- package/src/org/renderer.ts +89 -127
- package/src/palettes/color-utils.ts +19 -4
- package/src/palettes/index.ts +1 -0
- package/src/pert/renderer.ts +15 -10
- package/src/pyramid/parser.ts +2 -7
- package/src/quadrant/renderer.ts +2 -2
- package/src/raci/parser.ts +2 -7
- package/src/raci/renderer.ts +5 -5
- package/src/ring/parser.ts +2 -7
- package/src/sequence/parser.ts +18 -7
- package/src/sequence/renderer.ts +4 -4
- package/src/sitemap/parser.ts +8 -7
- package/src/sitemap/renderer.ts +37 -39
- package/src/tech-radar/parser.ts +2 -7
- package/src/timeline/renderer.ts +15 -5
- package/src/utils/card.ts +183 -0
- package/src/utils/parsing.ts +13 -1
- package/src/utils/scaling.ts +38 -81
- package/src/utils/tag-groups.ts +48 -10
- package/src/utils/visual-conventions.ts +61 -0
- package/src/visualizations/parse.ts +6 -1
- package/src/wireframe/parser.ts +6 -1
package/src/map/renderer.ts
CHANGED
|
@@ -135,7 +135,8 @@ function coastlineOuterRings(
|
|
|
135
135
|
): string[] {
|
|
136
136
|
const paths: string[] = [];
|
|
137
137
|
for (const r of regions) {
|
|
138
|
-
|
|
138
|
+
// Reuse the rings parsed once in layoutMap; fall back for older layouts.
|
|
139
|
+
const rings = (r.rings as Array<Array<[number, number]>>) ?? parsePathRings(r.d);
|
|
139
140
|
for (let i = 0; i < rings.length; i++) {
|
|
140
141
|
const ring = rings[i]!;
|
|
141
142
|
if (ring.length < 3) continue;
|
|
@@ -219,6 +220,15 @@ function appendWaterLines(
|
|
|
219
220
|
}
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
// Per-render namespace for SVG def ids (clipPaths, masks, filters, markers, the
|
|
224
|
+
// `<use>`-shared coast path). SVG `url(#id)` / `href="#id"` resolve document-
|
|
225
|
+
// globally to the FIRST matching id, so when several maps are inlined on one
|
|
226
|
+
// page (the docs gallery, an MDX page) shared constant ids made every later map
|
|
227
|
+
// reference the first map's defs — its coast `<use>` ghosted through. A
|
|
228
|
+
// monotonic per-render suffix makes every render's ids unique. NOT reset between
|
|
229
|
+
// renders, so re-renders (legend flips) and same-page siblings never collide.
|
|
230
|
+
let mapInstanceCounter = 0;
|
|
231
|
+
|
|
222
232
|
/** Render a resolved map into `container` (d3-selection appends an `<svg>`). */
|
|
223
233
|
export function renderMap(
|
|
224
234
|
container: HTMLDivElement,
|
|
@@ -283,6 +293,10 @@ export function renderMap(
|
|
|
283
293
|
// arrowhead up to a giant wedge. The size grows gently with the line width —
|
|
284
294
|
// enough to stay distinct from the stroke — but is firmly capped.
|
|
285
295
|
const defs = svg.append('defs');
|
|
296
|
+
// Namespace every def id minted below so multiple maps on one page don't share
|
|
297
|
+
// `url(#…)` targets (see mapInstanceCounter).
|
|
298
|
+
const uid = mapInstanceCounter++;
|
|
299
|
+
const nid = (base: string): string => `${base}__m${uid}`;
|
|
286
300
|
// Dampened: ~8px at the thinnest leg, easing toward a 15px cap as legs widen.
|
|
287
301
|
const arrowSize = (w: number): number => Math.min(15, 7 + w * 0.95);
|
|
288
302
|
|
|
@@ -356,8 +370,8 @@ export function renderMap(
|
|
|
356
370
|
// sub-pixel + low-contrast so the texture stays faint. Decorative — no data attrs.
|
|
357
371
|
if (layout.relief.length && layout.reliefHatch) {
|
|
358
372
|
const h = layout.reliefHatch;
|
|
359
|
-
const rangeClipId = 'dgmo-relief-clip';
|
|
360
|
-
const landClipId = 'dgmo-relief-land';
|
|
373
|
+
const rangeClipId = nid('dgmo-relief-clip');
|
|
374
|
+
const landClipId = nid('dgmo-relief-land');
|
|
361
375
|
const rangeClip = defs.append('clipPath').attr('id', rangeClipId);
|
|
362
376
|
for (const s of layout.relief) rangeClip.append('path').attr('d', s.d);
|
|
363
377
|
const landClip = defs.append('clipPath').attr('id', landClipId);
|
|
@@ -402,7 +416,7 @@ export function renderMap(
|
|
|
402
416
|
// §24B.2, ADR-1/3/6.
|
|
403
417
|
if (layout.coastlineStyle) {
|
|
404
418
|
const cs = layout.coastlineStyle;
|
|
405
|
-
const maskId = 'dgmo-map-water-mask';
|
|
419
|
+
const maskId = nid('dgmo-map-water-mask');
|
|
406
420
|
const mask = defs
|
|
407
421
|
.append('mask')
|
|
408
422
|
.attr('id', maskId)
|
|
@@ -472,7 +486,7 @@ export function renderMap(
|
|
|
472
486
|
appendWaterLines(
|
|
473
487
|
gWater,
|
|
474
488
|
defs,
|
|
475
|
-
'dgmo-map-coast',
|
|
489
|
+
nid('dgmo-map-coast'),
|
|
476
490
|
// Pass the canvas frame so edges collinear with it (the antimeridian on a
|
|
477
491
|
// world map, regional clipExtent cuts) don't get ringed as fake coast —
|
|
478
492
|
// land runs cleanly to the render-area edge.
|
|
@@ -565,7 +579,7 @@ export function renderMap(
|
|
|
565
579
|
(l) => l.poiId !== undefined && !l.hidden
|
|
566
580
|
);
|
|
567
581
|
if (poiLabels.length) {
|
|
568
|
-
const patchBlurId = 'dgmo-map-label-patch-blur';
|
|
582
|
+
const patchBlurId = nid('dgmo-map-label-patch-blur');
|
|
569
583
|
// Soft falloff so the patch dissolves into the surrounding basemap instead of
|
|
570
584
|
// ending on a hard edge. Tuned at the 11px label font. One shared filter for
|
|
571
585
|
// every patch group.
|
|
@@ -668,17 +682,27 @@ export function renderMap(
|
|
|
668
682
|
// label, so the patch is seamless and the only visible change is the
|
|
669
683
|
// vanished line work.
|
|
670
684
|
for (const r of layout.regions) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
685
|
+
// bbox is precomputed once in layoutMap (roadmap #2); fall back to
|
|
686
|
+
// parsing only for layouts predating that field.
|
|
687
|
+
let minX: number,
|
|
688
|
+
minY: number,
|
|
689
|
+
maxX: number,
|
|
690
|
+
maxY: number;
|
|
691
|
+
if (r.bbox) {
|
|
692
|
+
[minX, minY, maxX, maxY] = r.bbox;
|
|
693
|
+
} else {
|
|
694
|
+
minX = Infinity;
|
|
695
|
+
minY = Infinity;
|
|
696
|
+
maxX = -Infinity;
|
|
674
697
|
maxY = -Infinity;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
698
|
+
for (const ring of parsePathRings(r.d))
|
|
699
|
+
for (const [px, py] of ring) {
|
|
700
|
+
if (px < minX) minX = px;
|
|
701
|
+
if (px > maxX) maxX = px;
|
|
702
|
+
if (py < minY) minY = py;
|
|
703
|
+
if (py > maxY) maxY = py;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
682
706
|
const hit = blobRects.some(
|
|
683
707
|
(b) => minX <= b.x1 && maxX >= b.x0 && minY <= b.y1 && maxY >= b.y0
|
|
684
708
|
);
|
|
@@ -690,7 +714,7 @@ export function renderMap(
|
|
|
690
714
|
// Always-on patch for labels that are visible at rest (non-cluster members).
|
|
691
715
|
buildPatch(
|
|
692
716
|
poiLabels.filter((l) => l.clusterMember === undefined),
|
|
693
|
-
'dgmo-map-label-patch'
|
|
717
|
+
nid('dgmo-map-label-patch')
|
|
694
718
|
);
|
|
695
719
|
// Per-cluster patches, hidden until the fan expands.
|
|
696
720
|
const byCluster = new Map<string, typeof poiLabels>();
|
|
@@ -705,11 +729,11 @@ export function renderMap(
|
|
|
705
729
|
// data-cluster-deco attribute instead.
|
|
706
730
|
let ci = 0;
|
|
707
731
|
for (const [cid, labs] of byCluster)
|
|
708
|
-
buildPatch(labs, `dgmo-map-label-patch-c${ci++}
|
|
732
|
+
buildPatch(labs, nid(`dgmo-map-label-patch-c${ci++}`), cid);
|
|
709
733
|
} else {
|
|
710
734
|
// Export / `no-cluster-pois`: fan is permanently open → one always-on patch
|
|
711
735
|
// over every POI label.
|
|
712
|
-
buildPatch(poiLabels, 'dgmo-map-label-patch');
|
|
736
|
+
buildPatch(poiLabels, nid('dgmo-map-label-patch'));
|
|
713
737
|
}
|
|
714
738
|
}
|
|
715
739
|
|
|
@@ -734,7 +758,7 @@ export function renderMap(
|
|
|
734
758
|
// Neighbour land (Canada beside Alaska) clipped to this box, behind the
|
|
735
759
|
// state — so a land border reads as land rather than sprouting coast rings.
|
|
736
760
|
if (box.contextLand) {
|
|
737
|
-
const clipId = `dgmo-map-inset-clip-${bi}
|
|
761
|
+
const clipId = nid(`dgmo-map-inset-clip-${bi}`);
|
|
738
762
|
defs.append('clipPath').attr('id', clipId).append('path').attr('d', d);
|
|
739
763
|
insetG
|
|
740
764
|
.append('path')
|
|
@@ -751,7 +775,7 @@ export function renderMap(
|
|
|
751
775
|
// same way. Inside the inset group so it composites over the box fills.
|
|
752
776
|
if (layout.coastlineStyle) {
|
|
753
777
|
const cs = layout.coastlineStyle;
|
|
754
|
-
const maskId = 'dgmo-map-inset-water-mask';
|
|
778
|
+
const maskId = nid('dgmo-map-inset-water-mask');
|
|
755
779
|
const mask = defs
|
|
756
780
|
.append('mask')
|
|
757
781
|
.attr('id', maskId)
|
|
@@ -786,7 +810,7 @@ export function renderMap(
|
|
|
786
810
|
.append('path')
|
|
787
811
|
.attr('d', box.contextLand.d)
|
|
788
812
|
.attr('fill', 'black')
|
|
789
|
-
.attr('clip-path', `url(
|
|
813
|
+
.attr('clip-path', `url(#${nid(`dgmo-map-inset-clip-${bi}`)})`);
|
|
790
814
|
});
|
|
791
815
|
for (const r of layout.insetRegions)
|
|
792
816
|
if (r.id !== 'lake')
|
|
@@ -798,7 +822,7 @@ export function renderMap(
|
|
|
798
822
|
// which side reads as water, but SVG strokes still extend stroke-width/2
|
|
799
823
|
// past their path, so without this the seaward rings bleed over the box
|
|
800
824
|
// border. Union of all inset quads = one clipPath shared by the group.
|
|
801
|
-
const clipId = 'dgmo-map-inset-water-clip';
|
|
825
|
+
const clipId = nid('dgmo-map-inset-water-clip');
|
|
802
826
|
const clip = defs.append('clipPath').attr('id', clipId);
|
|
803
827
|
for (const box of layout.insets) {
|
|
804
828
|
const d =
|
|
@@ -820,7 +844,7 @@ export function renderMap(
|
|
|
820
844
|
appendWaterLines(
|
|
821
845
|
gInsetWater,
|
|
822
846
|
defs,
|
|
823
|
-
'dgmo-map-inset-coast',
|
|
847
|
+
nid('dgmo-map-inset-coast'),
|
|
824
848
|
coastlineOuterRings(layout.insetRegions, cs.minExtent),
|
|
825
849
|
cs,
|
|
826
850
|
layout.background
|
|
@@ -895,7 +919,7 @@ export function renderMap(
|
|
|
895
919
|
// stroke is enough for the line widths legs use.
|
|
896
920
|
wireSync(p, leg.lineNumber);
|
|
897
921
|
if (leg.arrow) {
|
|
898
|
-
const id = `dgmo-map-arrow-${i}
|
|
922
|
+
const id = nid(`dgmo-map-arrow-${i}`);
|
|
899
923
|
const s = arrowSize(leg.width);
|
|
900
924
|
defs
|
|
901
925
|
.append('marker')
|
package/src/map/resolver.ts
CHANGED
|
@@ -68,12 +68,44 @@ const POI_ZOOM_FLOOR_DEG = 7;
|
|
|
68
68
|
// single POI near the edge of a tall/wide country (e.g. Cartagena at the north
|
|
69
69
|
// tip of Colombia) would otherwise drag the frame to that country's far edge —
|
|
70
70
|
// all the way to the Amazon, ~15° below the southernmost dot. Clamp the container
|
|
71
|
-
// union so it reveals at most
|
|
72
|
-
// cluster on each side: northern Colombia stays for orientation,
|
|
73
|
-
// interior is cropped.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
71
|
+
// union so it reveals at most `containerOvershoot(cluster)` degrees of container
|
|
72
|
+
// BEYOND the POI cluster on each side: northern Colombia stays for orientation,
|
|
73
|
+
// the empty interior is cropped.
|
|
74
|
+
//
|
|
75
|
+
// For NON-US clusters the overshoot SHRINKS as the cluster grows (tuned
|
|
76
|
+
// 2026-06-19). A tiny cluster has no context of its own, so it keeps the full MAX
|
|
77
|
+
// (8°) — enough to reveal its whole modest container (e.g. a small European
|
|
78
|
+
// country). A LARGE cluster already supplies its own context, so a fixed 8° just
|
|
79
|
+
// padded a huge container with empty land (a Ukraine/Russia strike map framed
|
|
80
|
+
// ~2.4× the cluster, a wide dead band above the dots). Linearly decaying the
|
|
81
|
+
// overshoot to MIN (3°) tightens big clusters (~1.7× cluster) without cropping
|
|
82
|
+
// small ones. The POI_ZOOM_FLOOR_DEG floor still guards the lower bound.
|
|
83
|
+
//
|
|
84
|
+
// US-ORIENTED maps are EXEMPT (keep the flat MAX): the national-vs-regional
|
|
85
|
+
// projection gate (US_NATIONAL_LON_SPAN, albers-usa vs regional Mercator) is
|
|
86
|
+
// calibrated against the 8°-overshoot frame span — a coast-to-Caribbean US cruise
|
|
87
|
+
// route clears the national threshold only because the west overshoot reaches ~8°
|
|
88
|
+
// past Denver. Shrinking it there would silently flip such maps off albers-usa.
|
|
89
|
+
// US containers (a state, CONUS) aren't the huge-empty-container problem anyway.
|
|
90
|
+
const CONTAINER_OVERSHOOT_MAX = 8;
|
|
91
|
+
const CONTAINER_OVERSHOOT_MIN = 3;
|
|
92
|
+
// Degrees of overshoot shed per degree of cluster span (larger span ⇒ less slack).
|
|
93
|
+
const CONTAINER_OVERSHOOT_DECAY = 0.3;
|
|
94
|
+
|
|
95
|
+
/** Per-cluster container overshoot (deg). US-oriented maps keep the flat MAX (the
|
|
96
|
+
* albers-usa national gate is calibrated to it); other maps get full MAX for a
|
|
97
|
+
* tight cluster, decaying to MIN for a large one. `span` = the cluster's larger
|
|
98
|
+
* lon/lat extent. */
|
|
99
|
+
function containerOvershoot(span: number, usOriented: boolean): number {
|
|
100
|
+
if (usOriented) return CONTAINER_OVERSHOOT_MAX;
|
|
101
|
+
return Math.max(
|
|
102
|
+
CONTAINER_OVERSHOOT_MIN,
|
|
103
|
+
Math.min(
|
|
104
|
+
CONTAINER_OVERSHOOT_MAX,
|
|
105
|
+
CONTAINER_OVERSHOOT_MAX - CONTAINER_OVERSHOOT_DECAY * span
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
77
109
|
// Above this longitudinal span a US POI-only extent is "national" — use the
|
|
78
110
|
// albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
|
|
79
111
|
// CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
|
|
@@ -907,7 +939,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
907
939
|
const containerUnion = unionExtent(containerBoxes, points);
|
|
908
940
|
if (containerUnion)
|
|
909
941
|
extent = pad(
|
|
910
|
-
clampContainerToCluster(containerUnion, points),
|
|
942
|
+
clampContainerToCluster(containerUnion, points, usOriented),
|
|
911
943
|
PAD_FRACTION
|
|
912
944
|
);
|
|
913
945
|
}
|
|
@@ -1079,25 +1111,42 @@ function mostCommonCountry(
|
|
|
1079
1111
|
/** Asymmetric container clamp (R-poi-region overshoot guard). Container framing
|
|
1080
1112
|
* reveals the region(s) holding the POIs, but one POI at the edge of a tall/wide
|
|
1081
1113
|
* country drags the frame to that country's far edge. Cap how far the frame
|
|
1082
|
-
* extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1114
|
+
* extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG, while
|
|
1115
|
+
* letting a genuinely tighter container edge still bound the frame (so a small
|
|
1116
|
+
* country shows whole, but a cluster inside a giant one stays on the cluster).
|
|
1117
|
+
*
|
|
1118
|
+
* Each longitude side clamps independently. A container edge is a usable outer
|
|
1119
|
+
* bound only when it sits within the normal [-180, 180] range AND on the correct
|
|
1120
|
+
* side of the cluster; an antimeridian-crossing container (Russia, Fiji, NZ, the
|
|
1121
|
+
* US via the Aleutians) reports a degenerate east (> 180, or numerically < its
|
|
1122
|
+
* own west), so that side falls back to cluster ± overshoot instead of skipping
|
|
1123
|
+
* the clamp entirely (which previously blew a western-Russia cluster out to a
|
|
1124
|
+
* world frame). Latitude never wraps, so it always clamps. Assumes the POI
|
|
1125
|
+
* cluster itself does not straddle the seam — true for any regional cluster. */
|
|
1087
1126
|
function clampContainerToCluster(
|
|
1088
1127
|
container: GeoExtent,
|
|
1089
|
-
points: Array<[number, number]
|
|
1128
|
+
points: Array<[number, number]>,
|
|
1129
|
+
usOriented: boolean
|
|
1090
1130
|
): GeoExtent {
|
|
1091
1131
|
const poi = unionExtent([], points);
|
|
1092
1132
|
if (!poi) return container;
|
|
1093
1133
|
let [[west, south], [east, north]] = container;
|
|
1094
1134
|
const [[pWest, pSouth], [pEast, pNorth]] = poi;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1135
|
+
// Overshoot shrinks with cluster size for non-US maps (see containerOvershoot):
|
|
1136
|
+
// a big cluster already orients itself, so it gets less surrounding slack than a
|
|
1137
|
+
// tiny one. US-oriented maps keep the flat MAX (the albers-usa gate needs it).
|
|
1138
|
+
const over = containerOvershoot(
|
|
1139
|
+
Math.max(pEast - pWest, pNorth - pSouth),
|
|
1140
|
+
usOriented
|
|
1141
|
+
);
|
|
1142
|
+
south = Math.max(south, pSouth - over);
|
|
1143
|
+
north = Math.min(north, pNorth + over);
|
|
1144
|
+
const wOver = pWest - over;
|
|
1145
|
+
const eOver = pEast + over;
|
|
1146
|
+
// West edge usable iff in range and not east of the cluster's west.
|
|
1147
|
+
west = west >= -180 && west <= pWest ? Math.max(west, wOver) : wOver;
|
|
1148
|
+
// East edge usable iff in range and not west of the cluster's east.
|
|
1149
|
+
east = east <= 180 && east >= pEast ? Math.min(east, eOver) : eOver;
|
|
1101
1150
|
return [
|
|
1102
1151
|
[west, south],
|
|
1103
1152
|
[east, north],
|
package/src/mindmap/parser.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
descriptionBareRemovedMessage,
|
|
4
4
|
formatDgmoError,
|
|
5
5
|
makeDgmoError,
|
|
6
|
+
makeFail,
|
|
6
7
|
METADATA_DIAGNOSTIC_CODES,
|
|
7
8
|
pipeOperatorRemovedMessage,
|
|
8
9
|
suggest,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
validateTagGroupNames,
|
|
17
18
|
stripDefaultModifier,
|
|
18
19
|
finalizeAutoTagColors,
|
|
20
|
+
cascadeTagMetadata,
|
|
19
21
|
AUTO_TAG_COLOR_SENTINEL,
|
|
20
22
|
} from '../utils/tag-groups';
|
|
21
23
|
import {
|
|
@@ -60,12 +62,7 @@ export function parseMindmap(
|
|
|
60
62
|
error: null,
|
|
61
63
|
};
|
|
62
64
|
|
|
63
|
-
const fail = (
|
|
64
|
-
const diag = makeDgmoError(line, message);
|
|
65
|
-
result.diagnostics.push(diag);
|
|
66
|
-
result.error = formatDgmoError(diag);
|
|
67
|
-
return result;
|
|
68
|
-
};
|
|
65
|
+
const fail = makeFail(result);
|
|
69
66
|
|
|
70
67
|
const pushError = (line: number, message: string): void => {
|
|
71
68
|
const diag = makeDgmoError(line, message);
|
|
@@ -210,7 +207,12 @@ export function parseMindmap(
|
|
|
210
207
|
const indent = measureIndent(line);
|
|
211
208
|
if (indent > 0) {
|
|
212
209
|
const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
|
|
213
|
-
const { label, color } = extractColor(
|
|
210
|
+
const { label, color } = extractColor(
|
|
211
|
+
cleanEntry,
|
|
212
|
+
palette,
|
|
213
|
+
result.diagnostics,
|
|
214
|
+
lineNumber
|
|
215
|
+
);
|
|
214
216
|
// Bare value (no explicit color) → keep it; finalized below.
|
|
215
217
|
if (isDefault) {
|
|
216
218
|
currentTagGroup.defaultValue = label;
|
|
@@ -307,6 +309,12 @@ export function parseMindmap(
|
|
|
307
309
|
collectAll(result.roots);
|
|
308
310
|
validateTagValues(allNodes, result.tagGroups, pushWarning, suggest);
|
|
309
311
|
validateTagGroupNames(result.tagGroups, pushWarning);
|
|
312
|
+
|
|
313
|
+
// Cascade explicit tag values down the tree so sub-nodes inherit a tagged
|
|
314
|
+
// ancestor's value (overridable per-node). Runs after validation (so we
|
|
315
|
+
// don't double-warn on inherited values) and before the layout's
|
|
316
|
+
// global-default injection (so an inherited value wins over the default).
|
|
317
|
+
cascadeTagMetadata(result.roots, result.tagGroups);
|
|
310
318
|
}
|
|
311
319
|
|
|
312
320
|
// Check for empty mindmap
|
package/src/mindmap/renderer.ts
CHANGED
|
@@ -35,9 +35,11 @@ const LABEL_LINE_HEIGHT = 18;
|
|
|
35
35
|
const DESC_LINE_HEIGHT = 14;
|
|
36
36
|
const NODE_RADIUS = 6;
|
|
37
37
|
const ROOT_STROKE_WIDTH = 2.5;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
import {
|
|
39
|
+
NODE_STROKE_WIDTH,
|
|
40
|
+
EDGE_STROKE_WIDTH,
|
|
41
|
+
COLLAPSE_BAR_HEIGHT,
|
|
42
|
+
} from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
41
43
|
|
|
42
44
|
function nodeFill(
|
|
43
45
|
palette: PaletteColors,
|
|
@@ -147,9 +149,16 @@ export function renderMindmap(
|
|
|
147
149
|
const availHeight =
|
|
148
150
|
containerHeight - DIAGRAM_PADDING * 2 - legendReserve - titleReserve;
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
// Fit to BOTH axes so a tall tree shrinks to fit a short canvas instead of
|
|
153
|
+
// overflowing vertically (export sizes its own canvas, so it stays identity).
|
|
154
|
+
let ctx = isExport
|
|
151
155
|
? ScaleContext.identity()
|
|
152
|
-
: ScaleContext.
|
|
156
|
+
: ScaleContext.fromBox(
|
|
157
|
+
availWidth,
|
|
158
|
+
layout.width,
|
|
159
|
+
availHeight,
|
|
160
|
+
layout.height
|
|
161
|
+
);
|
|
153
162
|
|
|
154
163
|
let renderLayout = layout;
|
|
155
164
|
if (ctx.factor < 1) {
|
|
@@ -159,25 +168,56 @@ export function renderMindmap(
|
|
|
159
168
|
hiddenCounts.set(n.id, n.hiddenCount);
|
|
160
169
|
}
|
|
161
170
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
const relayout = (c: ScaleContext): MindmapLayoutResult =>
|
|
172
|
+
layoutMindmap(parsed, palette, {
|
|
173
|
+
interactive: !isExport,
|
|
174
|
+
...(hiddenCounts.size > 0 && { hiddenCounts }),
|
|
175
|
+
activeTagGroup: activeTagGroup ?? null,
|
|
176
|
+
...(hideDescriptions !== undefined && { hideDescriptions }),
|
|
177
|
+
ctx: c,
|
|
178
|
+
});
|
|
179
|
+
renderLayout = relayout(ctx);
|
|
180
|
+
// Scaling is non-linear, so one pass can still overflow. Re-measure the
|
|
181
|
+
// laid-out result and tighten until it fits both axes or hits the floor.
|
|
182
|
+
for (let i = 0; i < 3 && !ctx.isBelowFloor; i++) {
|
|
183
|
+
const refit = Math.min(
|
|
184
|
+
availWidth / renderLayout.width,
|
|
185
|
+
availHeight / renderLayout.height
|
|
186
|
+
);
|
|
187
|
+
if (refit >= 0.999) break; // already fits
|
|
188
|
+
ctx = ScaleContext.fromFactor(ctx.factor * refit);
|
|
189
|
+
renderLayout = relayout(ctx);
|
|
190
|
+
}
|
|
169
191
|
}
|
|
170
192
|
|
|
171
|
-
|
|
193
|
+
// Re-layout keeps text readable but is floor-limited, so a dense tree can
|
|
194
|
+
// still exceed the canvas. Apply a final uniform scale as a hard guarantee
|
|
195
|
+
// that the diagram always fits within the canvas (no overflow), regardless
|
|
196
|
+
// of how small the canvas is. Export sizes its own canvas, so this is a no-op
|
|
197
|
+
// there (fitScale === 1).
|
|
198
|
+
const fitScale = isExport
|
|
199
|
+
? 1
|
|
200
|
+
: Math.min(
|
|
201
|
+
1,
|
|
202
|
+
renderLayout.width > 0 ? availWidth / renderLayout.width : 1,
|
|
203
|
+
renderLayout.height > 0 ? availHeight / renderLayout.height : 1
|
|
204
|
+
);
|
|
205
|
+
const scaledWidth = renderLayout.width * fitScale;
|
|
206
|
+
const scaledHeight = renderLayout.height * fitScale;
|
|
207
|
+
|
|
208
|
+
const offsetX = Math.max(0, (availWidth - scaledWidth) / 2);
|
|
172
209
|
const offsetY =
|
|
173
210
|
DIAGRAM_PADDING +
|
|
174
211
|
legendReserve +
|
|
175
212
|
titleReserve +
|
|
176
|
-
Math.max(0, (availHeight -
|
|
213
|
+
Math.max(0, (availHeight - scaledHeight) / 2);
|
|
177
214
|
|
|
178
215
|
const mainG = svg
|
|
179
216
|
.append('g')
|
|
180
|
-
.attr(
|
|
217
|
+
.attr(
|
|
218
|
+
'transform',
|
|
219
|
+
`translate(${offsetX}, ${offsetY})${fitScale < 1 ? ` scale(${fitScale})` : ''}`
|
|
220
|
+
);
|
|
181
221
|
|
|
182
222
|
if (ctx.isBelowFloor) {
|
|
183
223
|
svg.attr('width', '100%');
|
package/src/org/parser.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { DgmoError } from '../diagnostics';
|
|
|
3
3
|
import {
|
|
4
4
|
formatDgmoError,
|
|
5
5
|
makeDgmoError,
|
|
6
|
+
makeFail,
|
|
6
7
|
METADATA_DIAGNOSTIC_CODES,
|
|
7
8
|
pipeOperatorRemovedMessage,
|
|
8
9
|
suggest,
|
|
@@ -107,12 +108,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
|
|
|
107
108
|
error: null,
|
|
108
109
|
};
|
|
109
110
|
|
|
110
|
-
const fail = (
|
|
111
|
-
const diag = makeDgmoError(line, message);
|
|
112
|
-
result.diagnostics.push(diag);
|
|
113
|
-
result.error = formatDgmoError(diag);
|
|
114
|
-
return result;
|
|
115
|
-
};
|
|
111
|
+
const fail = makeFail(result);
|
|
116
112
|
|
|
117
113
|
/** Push a recoverable error and continue parsing. */
|
|
118
114
|
const pushError = (line: number, message: string): void => {
|
|
@@ -261,7 +257,12 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
|
|
|
261
257
|
const indent = measureIndent(line);
|
|
262
258
|
if (indent > 0) {
|
|
263
259
|
const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
|
|
264
|
-
const { label, color } = extractColor(
|
|
260
|
+
const { label, color } = extractColor(
|
|
261
|
+
cleanEntry,
|
|
262
|
+
palette,
|
|
263
|
+
result.diagnostics,
|
|
264
|
+
lineNumber
|
|
265
|
+
);
|
|
265
266
|
// Bare value (no explicit color) → keep it; the post-parse
|
|
266
267
|
// finalize pass assigns a deterministic palette color.
|
|
267
268
|
if (isDefault) {
|