@diagrammo/dgmo 0.37.0 → 0.38.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.
@@ -143,6 +143,7 @@ Valid markup is the floor, not the goal. A good diagram reads at a glance. Apply
143
143
  | `quadrant` | 2×2 positioning matrix |
144
144
  | `pyramid` | stacked hierarchy of layers (Maslow, DIKW) |
145
145
  | `ring` | concentric rings of nested categories |
146
+ | `treemap` | nested rectangles sized by value (budgets, disk usage, portfolios) |
146
147
  | `map` | geographic concept map: regions, points, routes |
147
148
  | `wireframe` | low-fidelity UI layout with panels and controls |
148
149
  | `bar` | categorical comparisons |
@@ -129,6 +129,7 @@ Valid markup is the floor, not the goal. A good diagram reads at a glance. Apply
129
129
  | `quadrant` | 2×2 positioning matrix |
130
130
  | `pyramid` | stacked hierarchy of layers (Maslow, DIKW) |
131
131
  | `ring` | concentric rings of nested categories |
132
+ | `treemap` | nested rectangles sized by value (budgets, disk usage, portfolios) |
132
133
  | `map` | geographic concept map: regions, points, routes |
133
134
  | `wireframe` | low-fidelity UI layout with panels and controls |
134
135
  | `bar` | categorical comparisons |
@@ -3089,6 +3090,100 @@ When ring band thickness would force the in-band label below the readable floor
3089
3090
 
3090
3091
  ---
3091
3092
 
3093
+ ## 24C. Treemaps
3094
+
3095
+ <!-- TYPE:treemap -->
3096
+
3097
+ <!-- TIPS start -->
3098
+ **Styling tips:** Put the unit in the title (`Cloud Spend ($)`) — there is no currency/format directive. Reach for `depth N` once a tree goes past ~3 levels (deeper subtrees collapse to a drillable block) and `other-below N` to fold a long tail of tiny categories into one **Other** box. Pick the color mode to match intent: tags for categories, `heat` for a gain/loss heatmap, branch for zero-config structure.
3099
+ <!-- TIPS end -->
3100
+
3101
+ Nested rectangles sized by value — the canonical way to show a hierarchy's proportions at a glance (budgets, disk usage, portfolios, taxonomies). Indentation is the hierarchy; a bare trailing number on a leaf is its size; parents auto-sum their children. Built on a squarified layout so cells keep good aspect ratios.
3102
+
3103
+ ### Declaration
3104
+
3105
+ ```
3106
+ treemap [Title with units, e.g. "Cloud Spend ($)"]
3107
+
3108
+ Branch
3109
+ Leaf 320
3110
+ Leaf 180
3111
+ ```
3112
+
3113
+ ### Example
3114
+
3115
+ ```
3116
+ treemap Q3 Budget
3117
+
3118
+ tag Team as t
3119
+ Eng blue
3120
+ Sales green
3121
+ Ops orange
3122
+
3123
+ Engineering t: Eng
3124
+ Platform 320
3125
+ Mobile 180
3126
+ Data 140
3127
+ Go-to-Market t: Sales
3128
+ Ads 90
3129
+ Field Sales 130
3130
+ Operations t: Ops
3131
+ Cloud 110
3132
+ Support 70
3133
+ ```
3134
+
3135
+ ### Value & hierarchy
3136
+
3137
+ - **Indentation** sets the hierarchy (same model as mindmap / org).
3138
+ - A **bare trailing number** on a leaf is its size (`Platform 320`) — the funnel/sankey idiom (§1.5). No thousands commas (`1_000` is fine).
3139
+ - **Parents auto-sum** their descendants; a trailing number on a branch line is ignored with a warning (auto-sum always wins).
3140
+ - To keep a label that genuinely ends in a digit, **quote it** (`"Region 5"`); unquoted `Region 5` parses as label `Region`, value `5`.
3141
+
3142
+ ### Color modes
3143
+
3144
+ Three modes, with the source-declared default selected as **tag → heat → branch** (the desktop app adds a runtime switcher that previews the others without editing source):
3145
+
3146
+ | Mode | When it's the default | Coloring |
3147
+ | ---------- | --------------------------- | ------------------------------------------------------------ |
3148
+ | **tag** | a `tag` group is declared | categorical color per tag value (legend hover-dims) |
3149
+ | **heat** | any `heat:` value / `heat` directive | value ramp over a second per-node `heat:` metric |
3150
+ | **branch** | neither tags nor heat | each top-level branch a distinct hue, tinted with depth |
3151
+
3152
+ ### Tags
3153
+
3154
+ Declare a `tag` group before content and apply it inline (`Engineering t: Eng`); children inherit the tag unless they override it. See §1.3.
3155
+
3156
+ ### Heat metric & ramp
3157
+
3158
+ Add a per-node `heat:` number (a second metric, distinct from size; negatives/floats ok) and name the ramp with a `heat <Label> [low] [high]` directive. Colors are optional and **data-aware**: data that crosses zero defaults to a diverging `red·neutral·green` ramp with the midpoint pinned at 0; one-sign data defaults to a sequential `neutral·accent` ramp. One explicit color = `neutral→hue`; two = `low → high` (with a neutral midpoint). **Named palette colors only — no hex.** `heat:` is used instead of `score:` (which journey-map reserves for 1–5 emotion, §22).
3159
+
3160
+ ### Node Metadata
3161
+
3162
+ | Key | Type | Default | Description |
3163
+ | ------- | --------------- | ------- | -------------------------------------------- |
3164
+ | `heat` | number | — | Color-by-value metric (signed / float ok) |
3165
+ | `<tag>` | declared value | — | Tag value via the group's alias or name |
3166
+
3167
+ ### Directives
3168
+
3169
+ | Directive | Effect |
3170
+ | --------------- | --------------------------------------------------------------------------------------- |
3171
+ | `heat <Label>` | Name (and optionally color) the value ramp; pairs with the `heat:` key. |
3172
+ | `depth N` | Render N levels; deeper subtrees collapse to a drillable solid block (a render budget). |
3173
+ | `other-below N` | Roll children under N% of their parent into a single hatched **Other** bucket (opt-in). |
3174
+ | `no-values` | Hide value labels. |
3175
+ | `no-percent` | Hide percentage labels. |
3176
+ | `no-headers` | Hide parent header bars. |
3177
+ | `no-legend` | Hide the legend. |
3178
+
3179
+ Numbers auto-compact (1.2M, 940k). Units live in the title — there is no format/currency directive.
3180
+
3181
+ ### Interactivity vs export
3182
+
3183
+ In the desktop app a treemap is interactive: click a parent to drill in (with a breadcrumb), hover for a tooltip (path / value / % / heat), and flip the color mode. Static SVG / PNG export is the clean, full tree with none of that chrome — the same interactive-vs-export split the map chart uses.
3184
+
3185
+ ---
3186
+
3092
3187
  ## 24A. RACI Matrices (RACI / RASCI / DACI)
3093
3188
 
3094
3189
  <!-- TYPE:raci -->
@@ -0,0 +1,15 @@
1
+ treemap Cloud Spend ($)
2
+
3
+ Compute
4
+ EC2 1240000
5
+ Lambda 180000
6
+ GPU 640000
7
+ Storage
8
+ S3 420000
9
+ EBS 210000
10
+ Network
11
+ Egress 380000
12
+ CDN 95000
13
+ Data
14
+ Warehouse 540000
15
+ Streaming 160000
@@ -0,0 +1,12 @@
1
+ treemap Portfolio — today
2
+ heat Day change %
3
+
4
+ AAPL 180 heat: -2.4
5
+ NVDA 220 heat: 4.1
6
+ MSFT 160 heat: 0.8
7
+ Energy
8
+ XOM 60 heat: -1.2
9
+ CVX 45 heat: -0.6
10
+ Financials
11
+ JPM 70 heat: 1.9
12
+ BAC 40 heat: -3.1
@@ -0,0 +1,21 @@
1
+ treemap Engineering Portfolio (effort, pts)
2
+
3
+ tag Squad as sq
4
+ Platform blue
5
+ Product green
6
+ Infra orange
7
+
8
+ heat Δ vs plan %
9
+
10
+ Platform sq: Platform
11
+ API Gateway 120 heat: 4.0
12
+ Auth 80 heat: -6.5
13
+ Service Mesh 60 heat: 1.2
14
+ Product sq: Product
15
+ Onboarding 140 heat: 8.1
16
+ Checkout 110 heat: -3.4
17
+ Search 70 heat: 0.5
18
+ Infra sq: Infra
19
+ CI Pipeline 90 heat: -1.8
20
+ Observability 75 heat: 5.6
21
+ Cost Controls 50 heat: 2.3
@@ -0,0 +1,18 @@
1
+ treemap Q3 Budget
2
+
3
+ tag Team as t
4
+ Eng blue
5
+ Sales green
6
+ Ops orange
7
+
8
+ Engineering t: Eng
9
+ Platform 320
10
+ Mobile 180
11
+ Data 140
12
+ Go-to-Market t: Sales
13
+ Ads 90
14
+ Field Sales 130
15
+ Events 40
16
+ Operations t: Ops
17
+ Cloud 110
18
+ Support 70
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/advanced.ts CHANGED
@@ -507,6 +507,17 @@ export { parseRing } from './ring/parser';
507
507
  export { renderRing, renderRingForExport } from './ring/renderer';
508
508
  export type { ParsedRing, RingLayer } from './ring/types';
509
509
 
510
+ export { parseTreemap } from './treemap/parser';
511
+ export { renderTreemap, renderTreemapForExport } from './treemap/renderer';
512
+ export { layoutTreemap } from './treemap/layout';
513
+ export type { TreemapCell, TreemapLayoutResult } from './treemap/layout';
514
+ export type {
515
+ ParsedTreemap,
516
+ TreemapNode,
517
+ TreemapColorMode,
518
+ TreemapOptions,
519
+ } from './treemap/types';
520
+
510
521
  // Map (§24B) — the interactive render surface for app/editor consumers. The
511
522
  // Node fs `loadMapData` is exported for completeness, but the browser supplies
512
523
  // `MapData` by DI (resolveMap/renderMap take it as an argument).
@@ -933,6 +933,17 @@ export async function layoutBoxesAndLinesSearch(
933
933
  const tick = onProgress
934
934
  ? (): Promise<void> => new Promise<void>((r) => setTimeout(r))
935
935
  : (): undefined => undefined;
936
+ // Yielding is only worth its cost once a search is genuinely slow. A typical
937
+ // diagram finishes its whole candidate pool in tens of ms; yielding (a
938
+ // setTimeout, clamped to ~4ms/turn in browsers) after every one of ~90
939
+ // candidates would turn a 30ms layout into ~1s — all to flash a throbber that
940
+ // never shows (the app only reveals it after 150ms). So we suppress yields
941
+ // until the search has burned through a budget, then throttle them: fast
942
+ // diagrams run flat-out, only truly heavy ones cede the main thread to paint.
943
+ const YIELD_AFTER_MS = 150;
944
+ const YIELD_EVERY_MS = 30;
945
+ const searchStart = performance.now();
946
+ let lastYield = searchStart;
936
947
 
937
948
  // collapsed group labels (shown as plain boxes) — mirrors the ELK path
938
949
  const collapsedGroupLabels = new Set<string>();
@@ -1514,7 +1525,14 @@ export async function layoutBoxesAndLinesSearch(
1514
1525
  const step = async (phase: string): Promise<void> => {
1515
1526
  if (!onProgress) return;
1516
1527
  onProgress(++progressDone, progressTotal, phase);
1517
- await tick();
1528
+ const now = performance.now();
1529
+ if (
1530
+ now - searchStart > YIELD_AFTER_MS &&
1531
+ now - lastYield > YIELD_EVERY_MS
1532
+ ) {
1533
+ lastYield = now;
1534
+ await tick();
1535
+ }
1518
1536
  };
1519
1537
 
1520
1538
  // Build the candidate pool.
@@ -66,6 +66,7 @@ import { parseCycle } from './cycle/parser';
66
66
  import { parseJourneyMap } from './journey-map/parser';
67
67
  import { parsePyramid } from './pyramid/parser';
68
68
  import { parseRing } from './ring/parser';
69
+ import { parseTreemap } from './treemap/parser';
69
70
  import { parseRaci, allTasks } from './raci/parser';
70
71
  import type { DgmoError } from './diagnostics';
71
72
 
@@ -451,6 +452,9 @@ export const CHART_TYPE_REGISTRY: readonly ChartTypeDescriptor[] = [
451
452
  { id: 'cycle', category: 'visualization', parse: parseCycle },
452
453
  { id: 'pyramid', category: 'visualization', parse: parsePyramid },
453
454
  { id: 'ring', category: 'visualization', parse: parseRing },
455
+ // Treemap: squarified hierarchy. No measure/minDims — a treemap fills whatever
456
+ // rectangle it's given and has no intrinsic aspect/size (F11).
457
+ { id: 'treemap', category: 'visualization', parse: parseTreemap },
454
458
 
455
459
  // ── Geographic map (own parser → resolver → layout → renderer) ──
456
460
  { id: 'map', category: 'visualization', parse: parseMap },
@@ -122,6 +122,11 @@ export const chartTypes: readonly ChartTypeMeta[] = [
122
122
  description:
123
123
  'Concentric rings showing nested or hierarchical categories (read core-out)',
124
124
  },
125
+ {
126
+ id: 'treemap',
127
+ description:
128
+ 'Nested rectangles sized by value — show a hierarchy’s proportions (budgets, disk usage, portfolios) with color by category, value heatmap, or branch',
129
+ },
125
130
  {
126
131
  id: 'map',
127
132
  description:
package/src/completion.ts CHANGED
@@ -521,6 +521,24 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
521
521
  // layer pipe-metadata, not directives — they live in PIPE_METADATA.
522
522
  withGlobals({}),
523
523
  ],
524
+ [
525
+ 'treemap',
526
+ // Hierarchy sized by a bare trailing number; color modes + opt-outs.
527
+ withGlobals({
528
+ heat: {
529
+ description:
530
+ 'Name the color-by-value ramp (`heat <Label> [low] [high]`); pairs with the per-node `heat:` key',
531
+ },
532
+ depth: {
533
+ description:
534
+ 'Render N levels; deeper subtrees collapse to a drillable block',
535
+ },
536
+ 'no-values': { description: 'Hide value labels' },
537
+ 'no-percent': { description: 'Hide percentage labels' },
538
+ 'no-headers': { description: 'Hide parent header bars' },
539
+ 'no-legend': { description: 'Hide the legend' },
540
+ }),
541
+ ],
524
542
  [
525
543
  'map',
526
544
  // Geographic map directives (§24B.2/.7). Cosmetics are ON by default — the
@@ -695,6 +713,7 @@ export const STRUCTURAL_KEYWORDS = new Map<string, string[]>([
695
713
  ['infra', ['tag']],
696
714
  ['pert', ['tag']],
697
715
  ['mindmap', ['tag']],
716
+ ['treemap', ['tag']],
698
717
  ['boxes-and-lines', ['tag']],
699
718
  ['er', ['tag']],
700
719
  ['cycle', ['direction-counterclockwise', 'circle-nodes']],
@@ -1828,6 +1847,50 @@ function extractMindmapSymbols(docText: string): DiagramSymbols {
1828
1847
  return { kind: 'mindmap', entities };
1829
1848
  }
1830
1849
 
1850
+ // ============================================================
1851
+ // Treemap extractor
1852
+ // ============================================================
1853
+
1854
+ function extractTreemapSymbols(docText: string): DiagramSymbols {
1855
+ const lines = docText.split('\n');
1856
+ const entities: string[] = [];
1857
+ let pastFirstLine = false;
1858
+ let inTagBlock = false;
1859
+
1860
+ for (const line of lines) {
1861
+ const trimmed = line.trim();
1862
+ if (!trimmed || trimmed.startsWith('//')) continue;
1863
+
1864
+ if (!pastFirstLine) {
1865
+ pastFirstLine = true;
1866
+ continue;
1867
+ }
1868
+
1869
+ // Directives and tag blocks are not node entities.
1870
+ if (
1871
+ /^(depth|heat|no-[a-z]+)\s/i.test(trimmed) ||
1872
+ /^no-[a-z]+$/i.test(trimmed)
1873
+ )
1874
+ continue;
1875
+ if (/^tag\s+/i.test(trimmed)) {
1876
+ inTagBlock = true;
1877
+ continue;
1878
+ }
1879
+ const indent = line.search(/\S/);
1880
+ if (inTagBlock) {
1881
+ if (indent > 0) continue;
1882
+ inTagBlock = false;
1883
+ }
1884
+
1885
+ // Node name: strip same-line metadata + the bare trailing value number.
1886
+ let label = trimmed.split(/\s+\w+:/)[0]!.trim();
1887
+ label = label.replace(/\s+-?\d[\d_,.]*$/, '').trim();
1888
+ if (label && !entities.includes(label)) entities.push(label);
1889
+ }
1890
+
1891
+ return { kind: 'treemap', entities };
1892
+ }
1893
+
1831
1894
  // ============================================================
1832
1895
  // Pyramid extractor
1833
1896
  // ============================================================
@@ -2311,6 +2374,7 @@ registerExtractor('daci', extractRaciSymbols);
2311
2374
  registerExtractor('org', extractOrgSymbols);
2312
2375
  registerExtractor('kanban', extractKanbanSymbols);
2313
2376
  registerExtractor('mindmap', extractMindmapSymbols);
2377
+ registerExtractor('treemap', extractTreemapSymbols);
2314
2378
  registerExtractor('pyramid', extractPyramidSymbols);
2315
2379
  registerExtractor('ring', extractRingSymbols);
2316
2380
  registerExtractor('arc', extractArcSymbols);
package/src/d3.ts CHANGED
@@ -117,6 +117,7 @@ export const DIAGRAM_EXPORT_HANDLERS: Record<string, DiagramExportHandler> = {
117
117
  map: exportMap,
118
118
  pyramid: exportPyramid,
119
119
  ring: exportRing,
120
+ treemap: exportTreemap,
120
121
  raci: exportRaci,
121
122
  rasci: exportRaci,
122
123
  daci: exportRaci,
@@ -962,12 +963,33 @@ async function exportRing(ctx: ExportContext): Promise<string> {
962
963
  if (ringParsed.error || ringParsed.layers.length === 0) return '';
963
964
 
964
965
  const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
965
- renderRingForExport(
966
+ renderRingForExport(container, ringParsed, effectivePalette, ctx.isDark, {
967
+ width: EXPORT_WIDTH,
968
+ height: EXPORT_HEIGHT,
969
+ });
970
+ return finalizeSvgExport(container, theme, effectivePalette);
971
+ }
972
+
973
+ async function exportTreemap(ctx: ExportContext): Promise<string> {
974
+ const { content, theme, palette } = ctx;
975
+ const { parseTreemap } = await import('./treemap/parser');
976
+ const { renderTreemapForExport } = await import('./treemap/renderer');
977
+
978
+ const effectivePalette = await resolveExportPalette(theme, palette);
979
+ const treemapParsed = parseTreemap(content, effectivePalette);
980
+ if (treemapParsed.error || treemapParsed.roots.length === 0) return '';
981
+
982
+ // Headless export: full tree (no depth window), source-declared color mode.
983
+ const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
984
+ renderTreemapForExport(
966
985
  container,
967
- ringParsed,
986
+ treemapParsed,
968
987
  effectivePalette,
969
988
  ctx.isDark,
970
- { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }
989
+ {
990
+ width: EXPORT_WIDTH,
991
+ height: EXPORT_HEIGHT,
992
+ }
971
993
  );
972
994
  return finalizeSvgExport(container, theme, effectivePalette);
973
995
  }
@@ -982,13 +1004,10 @@ async function exportRaci(ctx: ExportContext): Promise<string> {
982
1004
  if (raciParsed.error) return '';
983
1005
 
984
1006
  const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
985
- renderRaciForExport(
986
- container,
987
- raciParsed,
988
- effectivePalette,
989
- ctx.isDark,
990
- { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }
991
- );
1007
+ renderRaciForExport(container, raciParsed, effectivePalette, ctx.isDark, {
1008
+ width: EXPORT_WIDTH,
1009
+ height: EXPORT_HEIGHT,
1010
+ });
992
1011
  return finalizeSvgExport(container, theme, effectivePalette);
993
1012
  }
994
1013
 
@@ -1091,14 +1110,7 @@ async function exportVenn(ctx: ExportContext): Promise<string> {
1091
1110
  if (parsed.error || parsed.vennSets.length < 2) return '';
1092
1111
  const effectivePalette = await resolveExportPalette(theme, palette);
1093
1112
  const { container, dims } = beginVizExport();
1094
- renderVenn(
1095
- container,
1096
- parsed,
1097
- effectivePalette,
1098
- ctx.isDark,
1099
- undefined,
1100
- dims
1101
- );
1113
+ renderVenn(container, parsed, effectivePalette, ctx.isDark, undefined, dims);
1102
1114
  return finalizeSvgExport(container, theme, effectivePalette);
1103
1115
  }
1104
1116
 
@@ -213,6 +213,8 @@ export const ATTRIBUTE_KEYS = new Set([
213
213
  'value',
214
214
  'label',
215
215
  'style',
216
+ // Treemap color-by-value metric key
217
+ 'heat',
216
218
  // Infra node behavior + edge colon-keys (§4) come from the single-source
217
219
  // directives registry — the parser accepts them only as `key: value` node
218
220
  // properties, so they render as `propertyName`. The dual-use top-level SLO
@@ -27,6 +27,7 @@ export const CHART_TYPES = new Set([
27
27
  'journey-map',
28
28
  'pyramid',
29
29
  'ring',
30
+ 'treemap',
30
31
  'raci',
31
32
  'rasci',
32
33
  'daci',
@@ -91,6 +92,8 @@ export const METADATA_KEYS = new Set([
91
92
  'value',
92
93
  'label',
93
94
  'style',
95
+ // Treemap color-by-value metric key
96
+ 'heat',
94
97
  ]);
95
98
 
96
99
  /** Tag declaration keyword. */
@@ -32,6 +32,13 @@ const TITLE_GAP = 16;
32
32
  // width:100% in a narrow note column stays sane.
33
33
  const ASPECT_MAX = 3.0;
34
34
  const ASPECT_MIN = 0.9;
35
+ // Deliberate vertical boost for GLOBAL (whole-world) extents only. An honest
36
+ // whole-world projection is ~2.4:1, which reads as a flat letterbox band. We emit
37
+ // a canvas this fraction taller and let the global stretch-fill (layout.ts) fill
38
+ // the extra height — the projected land stretches ~12% taller while POI radii and
39
+ // label fonts (applied in the renderer, not here) stay un-squashed. US, regional,
40
+ // and non-global extents are untouched (they keep their true projected aspect).
41
+ const GLOBAL_HEIGHT_BOOST = 1.12;
35
42
  // Minimum px of actual map area (below the chrome band) — keeps a short canvas
36
43
  // (very wide extent) from being crowded out by the title/caption.
37
44
  const MIN_MAP_BAND = 200;
@@ -96,11 +103,21 @@ export function mapExportDimensions(
96
103
  Number.isFinite(aspectOverride) &&
97
104
  aspectOverride > 0;
98
105
  const raw = useOverride ? aspectOverride : mapContentAspect(resolved, data);
106
+ // Whole-world extents get the vertical boost (a taller canvas the global
107
+ // stretch-fill then fills). `fitIsGlobal` is the SAME classification the layout
108
+ // uses to decide stretch-fill vs contain, so the two always agree. The override
109
+ // path (app WYSIWYG) and non-global extents pass through at their true aspect.
110
+ const isGlobal =
111
+ !useOverride && buildMapProjection(resolved, data).fitIsGlobal;
112
+ // The target the renderer should hit BEFORE the pathological clamp: the boosted
113
+ // aspect for global views, the raw aspect otherwise. preferContain compares the
114
+ // clamp against THIS (not raw) so the deliberate boost never trips the letterbox.
115
+ const target = isGlobal ? raw / GLOBAL_HEIGHT_BOOST : raw;
99
116
  // The override is the user's on-screen aspect — honour it as-is (no clamp);
100
117
  // only the intrinsic path guards against pathological extents.
101
118
  const clamped = useOverride
102
119
  ? raw
103
- : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
120
+ : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, target));
104
121
  const width = baseWidth;
105
122
  let height = Math.round(width / clamped);
106
123
 
@@ -128,6 +145,6 @@ export function mapExportDimensions(
128
145
  // contain-fit (letterbox) rather than stretch-distort. The WYSIWYG override is
129
146
  // exempt: it stretch-fills (mirroring the preview pane) unless the MIN_MAP_BAND
130
147
  // floor had to grow the canvas off-aspect.
131
- const preferContain = useOverride ? floored : clamped !== raw || floored;
148
+ const preferContain = useOverride ? floored : clamped !== target || floored;
132
149
  return { width, height, preferContain };
133
150
  }