@diagrammo/dgmo 0.23.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/internal.js CHANGED
@@ -26649,7 +26649,7 @@ function renderBoxesAndLines(container, parsed, layout, palette, isDark, options
26649
26649
  const VALUE_NAME = hasRamp ? parsed.boxMetric?.trim() || "Value" : null;
26650
26650
  const matchColorGroup = (v) => {
26651
26651
  const lv = v.trim().toLowerCase();
26652
- if (lv === "none") return null;
26652
+ if (lv === "" || lv === "none") return null;
26653
26653
  const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
26654
26654
  if (tg) return tg.name;
26655
26655
  if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
@@ -26990,6 +26990,22 @@ function renderBoxesAndLines(container, parsed, layout, palette, isDark, options
26990
26990
  const tooltipText = fullText.length > 200 ? fullText.slice(0, 199) + "\u2026" : fullText;
26991
26991
  nodeG.append("title").text(tooltipText);
26992
26992
  }
26993
+ } else if (parsed.showValues && node.value !== void 0) {
26994
+ const valueLabel = parsed.boxMetric ? `${parsed.boxMetric}: ${node.value}` : String(node.value);
26995
+ const headerH = ln.height / 2;
26996
+ const sepY = -ln.height / 2 + headerH;
26997
+ const fitted = fitLabelToHeader(node.label, ln.width, 2);
26998
+ const labelLineH = fitted.fontSize * 1.3;
26999
+ const labelTotalH = fitted.lines.length * labelLineH;
27000
+ const headerCenterY = -ln.height / 2 + headerH / 2;
27001
+ for (let li = 0; li < fitted.lines.length; li++) {
27002
+ nodeG.append("text").attr("x", 0).attr(
27003
+ "y",
27004
+ headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
27005
+ ).attr("text-anchor", "middle").attr("dominant-baseline", "central").attr("font-size", fitted.fontSize).attr("font-weight", "600").attr("fill", colors.text).text(fitted.lines[li]);
27006
+ }
27007
+ nodeG.append("line").attr("x1", -ln.width / 2).attr("y1", sepY).attr("x2", ln.width / 2).attr("y2", sepY).attr("stroke", colors.stroke).attr("stroke-opacity", 0.3).attr("stroke-width", 1);
27008
+ nodeG.append("text").attr("class", "bl-node-value").attr("x", 0).attr("y", (sepY + ln.height / 2) / 2).attr("text-anchor", "middle").attr("dominant-baseline", "central").attr("font-size", VALUE_FONT_SIZE).attr("fill", colors.text).attr("opacity", 0.85).text(valueLabel);
26993
27009
  } else {
26994
27010
  const maxLabelLines = Math.max(
26995
27011
  2,
@@ -27002,21 +27018,16 @@ function renderBoxesAndLines(container, parsed, layout, palette, isDark, options
27002
27018
  nodeG.append("text").attr("x", 0).attr("y", -totalH / 2 + lineH / 2 + li * lineH).attr("text-anchor", "middle").attr("dominant-baseline", "central").attr("font-size", fitted.fontSize).attr("font-weight", "600").attr("fill", colors.text).text(fitted.lines[li]);
27003
27019
  }
27004
27020
  }
27005
- if (parsed.showValues && node.value !== void 0) {
27021
+ if (parsed.showValues && node.value !== void 0 && desc && desc.length > 0 && !hideDescriptions) {
27006
27022
  const valueText = String(node.value);
27007
- const descShown = !!(desc && desc.length > 0 && !hideDescriptions);
27008
- if (descShown) {
27009
- const padX = 6;
27010
- const padY = 5;
27011
- const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO2 + 8;
27012
- const bh = VALUE_FONT_SIZE + 4;
27013
- const bx = ln.width / 2 - bw - 4;
27014
- const by = -ln.height / 2 + 4;
27015
- nodeG.append("rect").attr("x", bx).attr("y", by).attr("width", bw).attr("height", bh).attr("rx", 3).attr("fill", palette.bg).attr("opacity", 0.85);
27016
- nodeG.append("text").attr("class", "bl-node-value").attr("x", bx + bw - padX).attr("y", by + padY).attr("text-anchor", "end").attr("dominant-baseline", "central").attr("font-size", VALUE_FONT_SIZE).attr("font-weight", "600").attr("fill", palette.textMuted).text(valueText);
27017
- } else {
27018
- nodeG.append("text").attr("class", "bl-node-value").attr("x", 0).attr("y", ln.height / 2 - VALUE_FONT_SIZE).attr("text-anchor", "middle").attr("dominant-baseline", "central").attr("font-size", VALUE_FONT_SIZE).attr("font-weight", "600").attr("fill", colors.text).attr("opacity", 0.8).text(valueText);
27019
- }
27023
+ const padX = 6;
27024
+ const padY = 5;
27025
+ const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO2 + 8;
27026
+ const bh = VALUE_FONT_SIZE + 4;
27027
+ const bx = Math.max(-ln.width / 2 + 4, ln.width / 2 - bw - 4);
27028
+ const by = -ln.height / 2 + 4;
27029
+ nodeG.append("rect").attr("x", bx).attr("y", by).attr("width", bw).attr("height", bh).attr("rx", 3).attr("fill", palette.bg).attr("opacity", 0.85);
27030
+ nodeG.append("text").attr("class", "bl-node-value").attr("x", bx + bw - padX).attr("y", by + padY).attr("text-anchor", "end").attr("dominant-baseline", "central").attr("font-size", VALUE_FONT_SIZE).attr("font-weight", "600").attr("fill", palette.textMuted).text(valueText);
27020
27031
  }
27021
27032
  }
27022
27033
  const hasDescriptions = parsed.nodes.some(
@@ -27110,7 +27121,7 @@ var init_renderer6 = __esm({
27110
27121
  init_wrapped_desc();
27111
27122
  init_scaling();
27112
27123
  DIAGRAM_PADDING6 = 20;
27113
- NODE_FONT_SIZE = 13;
27124
+ NODE_FONT_SIZE = 11;
27114
27125
  MIN_NODE_FONT_SIZE = 9;
27115
27126
  EDGE_LABEL_FONT_SIZE4 = 11;
27116
27127
  EDGE_STROKE_WIDTH5 = 1.5;
@@ -47070,7 +47081,11 @@ function resolveMap(parsed, data) {
47070
47081
  if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
47071
47082
  }
47072
47083
  const containerUnion = unionExtent(containerBoxes, points);
47073
- if (containerUnion) extent2 = pad(containerUnion, PAD_FRACTION);
47084
+ if (containerUnion)
47085
+ extent2 = pad(
47086
+ clampContainerToCluster(containerUnion, points),
47087
+ PAD_FRACTION
47088
+ );
47074
47089
  }
47075
47090
  if (isPoiOnly) {
47076
47091
  const cx = (extent2[0][0] + extent2[1][0]) / 2;
@@ -47151,6 +47166,22 @@ function mostCommonCountry(regions, poiCountries) {
47151
47166
  }
47152
47167
  return best;
47153
47168
  }
47169
+ function clampContainerToCluster(container, points) {
47170
+ const poi = unionExtent([], points);
47171
+ if (!poi) return container;
47172
+ let [[west, south], [east, north]] = container;
47173
+ const [[pWest, pSouth], [pEast, pNorth]] = poi;
47174
+ south = Math.max(south, pSouth - CONTAINER_OVERSHOOT_DEG);
47175
+ north = Math.min(north, pNorth + CONTAINER_OVERSHOOT_DEG);
47176
+ if (east <= 180 && pEast <= 180) {
47177
+ west = Math.max(west, pWest - CONTAINER_OVERSHOOT_DEG);
47178
+ east = Math.min(east, pEast + CONTAINER_OVERSHOOT_DEG);
47179
+ }
47180
+ return [
47181
+ [west, south],
47182
+ [east, north]
47183
+ ];
47184
+ }
47154
47185
  function pad(e, frac) {
47155
47186
  const dLon = (e[1][0] - e[0][0]) * frac || 1;
47156
47187
  const dLat = (e[1][1] - e[0][1]) * frac || 1;
@@ -47163,7 +47194,7 @@ function firstError(diags) {
47163
47194
  const e = diags.find((d) => d.severity === "error");
47164
47195
  return e ? formatDgmoError(e) : null;
47165
47196
  }
47166
- var WORLD_SPAN, MERCATOR_MAX_LAT, PAD_FRACTION, REGION_PAD_FRACTION, WORLD_LAT_SOUTH, WORLD_LAT_NORTH, POI_ZOOM_FLOOR_DEG, US_NATIONAL_LON_SPAN, REGION_ALIASES, US_STATE_POSTAL;
47197
+ var WORLD_SPAN, MERCATOR_MAX_LAT, PAD_FRACTION, REGION_PAD_FRACTION, WORLD_LAT_SOUTH, WORLD_LAT_NORTH, POI_ZOOM_FLOOR_DEG, CONTAINER_OVERSHOOT_DEG, US_NATIONAL_LON_SPAN, REGION_ALIASES, US_STATE_POSTAL;
47167
47198
  var init_resolver2 = __esm({
47168
47199
  "src/map/resolver.ts"() {
47169
47200
  "use strict";
@@ -47176,6 +47207,7 @@ var init_resolver2 = __esm({
47176
47207
  WORLD_LAT_SOUTH = -58;
47177
47208
  WORLD_LAT_NORTH = 78;
47178
47209
  POI_ZOOM_FLOOR_DEG = 7;
47210
+ CONTAINER_OVERSHOOT_DEG = 8;
47179
47211
  US_NATIONAL_LON_SPAN = 48;
47180
47212
  REGION_ALIASES = {
47181
47213
  // Common everyday names → the Natural-Earth display name actually shipped.
@@ -47254,6 +47286,55 @@ var init_resolver2 = __esm({
47254
47286
  }
47255
47287
  });
47256
47288
 
47289
+ // src/map/legend-band.ts
47290
+ function mapLegendGroups(legend) {
47291
+ const ramp = legend.ramp;
47292
+ const scoreGroup = ramp ? {
47293
+ name: ramp.metric?.trim() || "Value",
47294
+ entries: [],
47295
+ gradient: {
47296
+ min: ramp.min,
47297
+ max: ramp.max,
47298
+ hue: ramp.hue,
47299
+ base: ramp.base
47300
+ }
47301
+ } : null;
47302
+ const tagGroups = legend.tagGroups.filter((g) => g.entries.length > 0).map((g) => ({ name: g.name, entries: [...g.entries] }));
47303
+ return [...scoreGroup ? [scoreGroup] : [], ...tagGroups];
47304
+ }
47305
+ function mapLegendConfig(groups, mode) {
47306
+ return {
47307
+ groups,
47308
+ position: { placement: "top-center", titleRelation: "below-title" },
47309
+ mode,
47310
+ showEmptyGroups: false,
47311
+ showInactivePills: true
47312
+ };
47313
+ }
47314
+ function mapLegendTop(hasTitle, hasSubtitle) {
47315
+ return (hasTitle ? TITLE_Y + TITLE_FONT_SIZE : 0) + (hasSubtitle ? TITLE_FONT_SIZE : 0) + LEGEND_TOP_GAP2;
47316
+ }
47317
+ function mapLegendBand(legend, opts) {
47318
+ if (!legend) return 0;
47319
+ const groups = mapLegendGroups(legend);
47320
+ if (groups.length === 0) return 0;
47321
+ const config = mapLegendConfig(groups, opts.mode);
47322
+ const state = { activeGroup: legend.activeGroup };
47323
+ const { height } = computeLegendLayout(config, state, opts.width);
47324
+ if (height <= 0) return 0;
47325
+ return mapLegendTop(opts.hasTitle, opts.hasSubtitle) + height + LEGEND_BOTTOM_GAP2;
47326
+ }
47327
+ var LEGEND_TOP_GAP2, LEGEND_BOTTOM_GAP2;
47328
+ var init_legend_band = __esm({
47329
+ "src/map/legend-band.ts"() {
47330
+ "use strict";
47331
+ init_legend_layout();
47332
+ init_title_constants();
47333
+ LEGEND_TOP_GAP2 = 8;
47334
+ LEGEND_BOTTOM_GAP2 = 10;
47335
+ }
47336
+ });
47337
+
47257
47338
  // src/map/colorize.ts
47258
47339
  function assignColors(isos, adjacency) {
47259
47340
  const sorted = [...isos].sort();
@@ -47845,12 +47926,43 @@ function layoutMap(resolved, data, size, opts) {
47845
47926
  return tagFill(r.tags, activeGroup) ?? neutralFill;
47846
47927
  };
47847
47928
  const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
47929
+ let legend = null;
47930
+ if (!resolved.directives.noLegend) {
47931
+ const legendTagGroups = resolved.tagGroups.map((g) => ({
47932
+ name: g.name,
47933
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color }))
47934
+ }));
47935
+ if (legendTagGroups.length > 0 || hasRamp) {
47936
+ legend = {
47937
+ tagGroups: legendTagGroups,
47938
+ activeGroup,
47939
+ ...hasRamp && {
47940
+ ramp: {
47941
+ ...resolved.directives.regionMetric !== void 0 && {
47942
+ metric: resolved.directives.regionMetric
47943
+ },
47944
+ min: rampMin,
47945
+ max: rampMax,
47946
+ hue: rampHue,
47947
+ base: rampBase
47948
+ }
47949
+ }
47950
+ };
47951
+ }
47952
+ }
47848
47953
  const TITLE_GAP2 = 16;
47849
47954
  let topPad = FIT_PAD;
47850
47955
  if (resolved.title && resolved.pois.length > 0) {
47851
47956
  const bannerBottom = (resolved.subtitle ? TITLE_Y + TITLE_FONT_SIZE : TITLE_Y) + TITLE_FONT_SIZE / 2;
47852
47957
  topPad = Math.max(FIT_PAD, bannerBottom + TITLE_GAP2);
47853
47958
  }
47959
+ const legendBand = mapLegendBand(legend, {
47960
+ width,
47961
+ mode: opts.legendMode ?? "preview",
47962
+ hasTitle: Boolean(resolved.title),
47963
+ hasSubtitle: Boolean(resolved.subtitle)
47964
+ });
47965
+ if (legendBand > topPad) topPad = legendBand;
47854
47966
  const fitBox = [
47855
47967
  [FIT_PAD, topPad],
47856
47968
  [
@@ -47868,7 +47980,7 @@ function layoutMap(resolved, data, size, opts) {
47868
47980
  const by0 = cb[0][1];
47869
47981
  const cw = cb[1][0] - bx0;
47870
47982
  const ch = cb[1][1] - by0;
47871
- const topReserve = resolved.title && resolved.pois.length > 0 ? topPad : 0;
47983
+ const topReserve = resolved.title && resolved.pois.length > 0 || legendBand > 0 ? topPad : 0;
47872
47984
  const ox = 0;
47873
47985
  const oy = topReserve;
47874
47986
  const sx = cw > 0 ? width / cw : 1;
@@ -48932,30 +49044,6 @@ function layoutMap(resolved, data, size, opts) {
48932
49044
  });
48933
49045
  labels.push(...contextLabels);
48934
49046
  }
48935
- let legend = null;
48936
- if (!resolved.directives.noLegend) {
48937
- const tagGroups = resolved.tagGroups.map((g) => ({
48938
- name: g.name,
48939
- entries: g.entries.map((e) => ({ value: e.value, color: e.color }))
48940
- }));
48941
- if (tagGroups.length > 0 || hasRamp) {
48942
- legend = {
48943
- tagGroups,
48944
- activeGroup,
48945
- ...hasRamp && {
48946
- ramp: {
48947
- ...resolved.directives.regionMetric !== void 0 && {
48948
- metric: resolved.directives.regionMetric
48949
- },
48950
- min: rampMin,
48951
- max: rampMax,
48952
- hue: rampHue,
48953
- base: rampBase
48954
- }
48955
- }
48956
- };
48957
- }
48958
- }
48959
49047
  return {
48960
49048
  width,
48961
49049
  height,
@@ -48991,6 +49079,7 @@ var init_layout15 = __esm({
48991
49079
  init_label_layout();
48992
49080
  init_legend_constants();
48993
49081
  init_title_constants();
49082
+ init_legend_band();
48994
49083
  init_context_labels();
48995
49084
  FIT_PAD = 24;
48996
49085
  RAMP_FLOOR2 = 15;
@@ -49191,6 +49280,9 @@ function renderMap(container, resolved, data, palette, isDark, onClickItem, expo
49191
49280
  // stretch-distorting. The in-app preview pane passes no exportDims → unset →
49192
49281
  // keeps the global stretch-fill.
49193
49282
  preferContain: exportDims?.preferContain ?? false,
49283
+ // Reserve the legend band for the mode actually drawn below (export shows
49284
+ // only the active group; preview keeps the inactive pills).
49285
+ legendMode: exportDims ? "export" : "preview",
49194
49286
  ...activeGroupOverride !== void 0 && {
49195
49287
  activeGroup: activeGroupOverride
49196
49288
  }
@@ -49482,30 +49574,12 @@ function renderMap(container, resolved, data, palette, isDark, onClickItem, expo
49482
49574
  if (layout.legend) {
49483
49575
  const legendY = (layout.title ? TITLE_Y + TITLE_FONT_SIZE : 0) + (layout.subtitle ? TITLE_FONT_SIZE : 0) + 8;
49484
49576
  const legendG = svg.append("g").attr("class", "dgmo-map-legend").attr("transform", `translate(0, ${legendY})`);
49485
- const ramp = layout.legend.ramp;
49486
- const scoreGroup = ramp ? {
49487
- name: ramp.metric?.trim() || "Value",
49488
- entries: [],
49489
- gradient: {
49490
- min: ramp.min,
49491
- max: ramp.max,
49492
- hue: ramp.hue,
49493
- base: ramp.base
49494
- }
49495
- } : null;
49496
- const tagGroups = layout.legend.tagGroups.filter((g) => g.entries.length > 0).map((g) => ({ name: g.name, entries: [...g.entries] }));
49497
- const groups = [...scoreGroup ? [scoreGroup] : [], ...tagGroups];
49577
+ const groups = mapLegendGroups(layout.legend);
49498
49578
  if (groups.length > 0) {
49499
- const config = {
49579
+ const config = mapLegendConfig(
49500
49580
  groups,
49501
- position: { placement: "top-center", titleRelation: "below-title" },
49502
- mode: exportDims ? "export" : "preview",
49503
- showEmptyGroups: false,
49504
- // Keep inactive siblings visible as pills so the user can click to flip
49505
- // the active colouring dimension (preview only — export shows just the
49506
- // active group).
49507
- showInactivePills: true
49508
- };
49581
+ exportDims ? "export" : "preview"
49582
+ );
49509
49583
  const state = { activeGroup: layout.legend.activeGroup };
49510
49584
  renderLegendD3(legendG, config, state, palette, isDark, void 0, width);
49511
49585
  }
@@ -49549,6 +49623,7 @@ var init_renderer16 = __esm({
49549
49623
  init_title_constants();
49550
49624
  init_color_utils();
49551
49625
  init_legend_d3();
49626
+ init_legend_band();
49552
49627
  init_layout15();
49553
49628
  LABEL_FONT = 11;
49554
49629
  }
@@ -49570,9 +49645,10 @@ function mapContentAspect(resolved, data, ref = REF) {
49570
49645
  const aspect = w / h;
49571
49646
  return Number.isFinite(aspect) && aspect > 0 ? aspect : FALLBACK_ASPECT;
49572
49647
  }
49573
- function mapExportDimensions(resolved, data, baseWidth = 1200) {
49574
- const raw = mapContentAspect(resolved, data);
49575
- const clamped = Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
49648
+ function mapExportDimensions(resolved, data, baseWidth = 1200, aspectOverride) {
49649
+ const useOverride = aspectOverride !== void 0 && Number.isFinite(aspectOverride) && aspectOverride > 0;
49650
+ const raw = useOverride ? aspectOverride : mapContentAspect(resolved, data);
49651
+ const clamped = useOverride ? raw : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
49576
49652
  const width = baseWidth;
49577
49653
  let height = Math.round(width / clamped);
49578
49654
  let chromeReserve = 0;
@@ -49585,7 +49661,7 @@ function mapExportDimensions(resolved, data, baseWidth = 1200) {
49585
49661
  height = Math.round(chromeReserve + MIN_MAP_BAND);
49586
49662
  floored = true;
49587
49663
  }
49588
- const preferContain = clamped !== raw || floored;
49664
+ const preferContain = useOverride ? floored : clamped !== raw || floored;
49589
49665
  return { width, height, preferContain };
49590
49666
  }
49591
49667
  var FIT_PAD2, TITLE_GAP, ASPECT_MAX, ASPECT_MIN, MIN_MAP_BAND, FALLBACK_ASPECT, REF;
@@ -55295,7 +55371,6 @@ function renderTimelineTagLegendOverlay(container, parsed, palette, isDark, setu
55295
55371
  function renderTimelineHorizontalTimeSort(container, parsed, palette, isDark, setup, hovers, onClickItem, _exportDims, _swimlaneTagGroup, _activeTagGroup, _onTagStateChange, _viewMode) {
55296
55372
  const {
55297
55373
  width,
55298
- height,
55299
55374
  tooltip,
55300
55375
  solid,
55301
55376
  textColor,
@@ -55344,8 +55419,7 @@ function renderTimelineHorizontalTimeSort(container, parsed, palette, isDark, se
55344
55419
  const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
55345
55420
  const eraLabelY = eraReserve ? -(topScaleH + markerReserve + ERA_ROW_H / 2) : 0;
55346
55421
  const innerWidth = width - margin.left - margin.right;
55347
- const availInnerHeight = height - margin.top - margin.bottom;
55348
- const rowH = Math.min(ctx.structural(28), availInnerHeight / sorted.length);
55422
+ const rowH = ctx.structural(28);
55349
55423
  const innerHeight = rowH * sorted.length;
55350
55424
  const usedHeight = margin.top + innerHeight + margin.bottom;
55351
55425
  const xScale = d3Scale2.scaleLinear().domain([minDate - datePadding, maxDate + datePadding]).range([0, innerWidth]);
@@ -57926,7 +58000,12 @@ async function renderForExport(content, theme, palette, viewState, options) {
57926
58000
  }
57927
58001
  }
57928
58002
  const mapResolved = resolveMap2(mapParsed, mapData);
57929
- const dims2 = mapExportDimensions2(mapResolved, mapData, EXPORT_WIDTH);
58003
+ const dims2 = mapExportDimensions2(
58004
+ mapResolved,
58005
+ mapData,
58006
+ EXPORT_WIDTH,
58007
+ options?.mapAspect
58008
+ );
57930
58009
  const container2 = createExportContainer(dims2.width, dims2.height);
57931
58010
  renderMapForExport2(
57932
58011
  container2,
@@ -2786,7 +2786,8 @@ route Miami style: arc
2786
2786
  - Title is the declaration line; `caption` (data-source attribution, travels with the exported PNG) is the only chrome directive. There is no `subtitle`.
2787
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.
2788
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.
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`. 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.
2790
2791
 
2791
2792
  ### Name resolution
2792
2793
 
@@ -2802,7 +2803,7 @@ route Miami style: arc
2802
2803
 
2803
2804
  ### Directives & reserved keys
2804
2805
 
2805
- The directive set is **12, all colon-free**: six naming intent the renderer can't infer — `region-metric`, `poi-metric`, `flow-metric`, `locale`, `active-tag`, `caption` — and six `no-*` cosmetic opt-outs — `no-legend`, `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`. 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.
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.
2806
2807
 
2807
2808
  ---
2808
2809
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,9 @@ import { ScaleContext } from '../utils/scaling';
35
35
 
36
36
  // ── Constants (aligned with infra pattern) ─────────────────
37
37
  const DIAGRAM_PADDING = 20;
38
- const NODE_FONT_SIZE = 13;
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;
39
41
  const MIN_NODE_FONT_SIZE = 9;
40
42
  const EDGE_LABEL_FONT_SIZE = 11;
41
43
  const EDGE_STROKE_WIDTH = 1.5;
@@ -429,7 +431,7 @@ export function renderBoxesAndLines(
429
431
  // between a tag group and the metric label, the tag group wins (AC9).
430
432
  const matchColorGroup = (v: string): string | null => {
431
433
  const lv = v.trim().toLowerCase();
432
- if (lv === 'none') return null;
434
+ if (lv === '' || lv === 'none') return null;
433
435
  const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
434
436
  if (tg) return tg.name;
435
437
  if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
@@ -1087,6 +1089,61 @@ export function renderBoxesAndLines(
1087
1089
  fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
1088
1090
  nodeG.append('title').text(tooltipText);
1089
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);
1090
1147
  } else {
1091
1148
  const maxLabelLines = Math.max(
1092
1149
  2,
@@ -1110,56 +1167,46 @@ export function renderBoxesAndLines(
1110
1167
  }
1111
1168
  }
1112
1169
 
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) {
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
+ ) {
1118
1181
  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
- }
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);
1163
1210
  }
1164
1211
  }
1165
1212
 
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,14 +4300,18 @@ function renderTimelineHorizontalTimeSort(
4301
4300
  ? -(topScaleH + markerReserve + ERA_ROW_H / 2)
4302
4301
  : 0;
4303
4302
  const innerWidth = width - margin.left - margin.right;
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.
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.
4312
4315
  const innerHeight = rowH * sorted.length;
4313
4316
  const usedHeight = margin.top + innerHeight + margin.bottom;
4314
4317
 
@@ -7751,6 +7754,10 @@ export async function renderForExport(
7751
7754
  // here — the Node fs `loadMapData()` seam can't run in a browser. CLI/SSR
7752
7755
  // omit this and fall back to the fs loader.
7753
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;
7754
7761
  }
7755
7762
  ): Promise<string> {
7756
7763
  const exportMode = options?.exportMode ?? false;
@@ -8491,7 +8498,12 @@ export async function renderForExport(
8491
8498
  // aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
8492
8499
  // export matches the content's natural shape — no vertical stretch, no
8493
8500
  // letterbox bands. `preferContain` rides along to the renderer.
8494
- const dims = mapExportDimensions(mapResolved, mapData, EXPORT_WIDTH);
8501
+ const dims = mapExportDimensions(
8502
+ mapResolved,
8503
+ mapData,
8504
+ EXPORT_WIDTH,
8505
+ options?.mapAspect
8506
+ );
8495
8507
  const container = createExportContainer(dims.width, dims.height);
8496
8508
  renderMapForExport(
8497
8509
  container,