@opendata-ai/openchart-engine 8.4.0 → 8.4.1

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/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  generateAltText,
7
7
  generateDataTable,
8
8
  getBreakpoint as getBreakpoint3,
9
- getHeightClass,
9
+ getHeightClass as getHeightClass3,
10
10
  getLayoutStrategy,
11
11
  isAxislessMark as isAxislessMark3,
12
12
  MAX_SR_TABLE_ROWS,
@@ -839,6 +839,7 @@ var OBSTACLE_WEIGHTS = {
839
839
  "data-label": 8,
840
840
  "endpoint-label": 8,
841
841
  legend: 6,
842
+ chrome: 6,
842
843
  watermark: 6,
843
844
  "axis-band": 6,
844
845
  "area-fill": 1.5
@@ -1207,7 +1208,7 @@ function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartAr
1207
1208
  placedBounds.push(annotation.bounds ?? annotationBlockBounds(annotation, label, measure));
1208
1209
  }
1209
1210
  }
1210
- function clampAnnotationsToBounds(annotations, svgWidth, svgHeight, measure = heuristicMeasure) {
1211
+ function clampAnnotationsToBounds(annotations, svgWidth, svgHeight, measure = heuristicMeasure, topBound = CLAMP_MARGIN) {
1211
1212
  for (const annotation of annotations) {
1212
1213
  if (annotation.type !== "text" || !annotation.label) continue;
1213
1214
  const label = annotation.label;
@@ -1220,8 +1221,8 @@ function clampAnnotationsToBounds(annotations, svgWidth, svgHeight, measure = he
1220
1221
  if (bounds.x + dx < CLAMP_MARGIN) {
1221
1222
  dx = CLAMP_MARGIN - bounds.x;
1222
1223
  }
1223
- if (bounds.y < CLAMP_MARGIN) {
1224
- dy = CLAMP_MARGIN - bounds.y;
1224
+ if (bounds.y < topBound) {
1225
+ dy = topBound - bounds.y;
1225
1226
  }
1226
1227
  if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
1227
1228
  dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
@@ -1667,7 +1668,7 @@ function computeAnnotations(spec, scalesOrCtx, chartArea, strategy, isDark, obst
1667
1668
  autoQueue.length > 0 ? (i) => autoIndices.has(i) : void 0
1668
1669
  );
1669
1670
  if (ctx.svg.width > 0 && ctx.svg.height > 0) {
1670
- clampAnnotationsToBounds(annotations, ctx.svg.width, ctx.svg.height, ctx.measure);
1671
+ clampAnnotationsToBounds(annotations, ctx.svg.width, ctx.svg.height, ctx.measure, ctx.topBound);
1671
1672
  }
1672
1673
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
1673
1674
  return annotations;
@@ -2867,6 +2868,7 @@ function computeBarLabels(marks, chartArea, density = "auto", labelFormatter, la
2867
2868
  const candidates = [];
2868
2869
  const fitsInSegment = [];
2869
2870
  const displayAnchorX = [];
2871
+ const haloFlags = [];
2870
2872
  const formatter = labelFormatter ?? null;
2871
2873
  for (const mark of targetMarks) {
2872
2874
  let valuePart;
@@ -2928,6 +2930,7 @@ function computeBarLabels(marks, chartArea, density = "auto", labelFormatter, la
2928
2930
  const anchorY = centerY - textHeight / 2;
2929
2931
  const leftEdgeX = textAnchor === "end" ? anchorX - textWidth : textAnchor === "middle" ? anchorX - textWidth / 2 : anchorX;
2930
2932
  const fits = !(isStacked2 && textWidth > mark.width - 2 * LABEL_PADDING);
2933
+ haloFlags.push(!isInside);
2931
2934
  displayAnchorX.push(anchorX);
2932
2935
  fitsInSegment.push(fits);
2933
2936
  candidates.push({
@@ -2972,7 +2975,8 @@ function computeBarLabels(marks, chartArea, density = "auto", labelFormatter, la
2972
2975
  x: displayAnchorX[i],
2973
2976
  y: candidates[i].anchorY + halfHeight,
2974
2977
  style: candidates[i].style,
2975
- visible: false
2978
+ visible: false,
2979
+ ...haloFlags[i] ? {} : { halo: false }
2976
2980
  });
2977
2981
  } else {
2978
2982
  const r = resolvedByOrig.get(i);
@@ -2981,6 +2985,7 @@ function computeBarLabels(marks, chartArea, density = "auto", labelFormatter, la
2981
2985
  const { index: _index, ...rest } = r;
2982
2986
  results.push({
2983
2987
  ...rest,
2988
+ ...haloFlags[i] ? {} : { halo: false },
2984
2989
  x: r.x + dx,
2985
2990
  y: r.y + halfHeight,
2986
2991
  connector: r.connector ? {
@@ -3774,6 +3779,7 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormatter
3774
3779
  const targetMarks = filterByDensity(marks, density);
3775
3780
  const formatter = labelFormatter ?? null;
3776
3781
  const candidates = [];
3782
+ const haloFlags = [];
3777
3783
  const fitsInSegment = [];
3778
3784
  for (const mark of targetMarks) {
3779
3785
  let valuePart;
@@ -3813,6 +3819,7 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormatter
3813
3819
  fill = labelColor ?? getRepresentativeColor3(mark.fill);
3814
3820
  }
3815
3821
  fitsInSegment.push(!(isStacked2 && textHeight > mark.height - 2 * LABEL_PADDING_Y));
3822
+ haloFlags.push(!isStacked2);
3816
3823
  candidates.push({
3817
3824
  text: valuePart,
3818
3825
  anchorX,
@@ -3854,7 +3861,8 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormatter
3854
3861
  x: candidates[i].anchorX,
3855
3862
  y: candidates[i].anchorY + ascent,
3856
3863
  style: candidates[i].style,
3857
- visible: false
3864
+ visible: false,
3865
+ ...haloFlags[i] ? {} : { halo: false }
3858
3866
  });
3859
3867
  continue;
3860
3868
  }
@@ -3863,6 +3871,7 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormatter
3863
3871
  const { index: _index, ...rest } = r;
3864
3872
  results.push({
3865
3873
  ...rest,
3874
+ ...haloFlags[i] ? {} : { halo: false },
3866
3875
  y: r.y + ascent,
3867
3876
  // The connector originates at the label; shift its start to the baseline
3868
3877
  // too so the leader stays glued to the shifted text.
@@ -10271,6 +10280,7 @@ import {
10271
10280
  computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
10272
10281
  defaultNumberFormatter as defaultNumberFormatter6,
10273
10282
  estimateTextWidth as estimateTextWidth16,
10283
+ getHeightClass,
10274
10284
  HPAD_COMPACT_FRACTION,
10275
10285
  HPAD_COMPACT_MIN,
10276
10286
  isAxislessMark,
@@ -12268,6 +12278,7 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12268
12278
  if (renderSpec.display === "sparkline" && !renderSpec.userExplicit?.chrome) {
12269
12279
  chromeMode = "hidden";
12270
12280
  }
12281
+ const crampedTitle = getHeightClass(height) === "cramped";
12271
12282
  if (renderSpec.display === "sparkline" || isRadial) {
12272
12283
  const legendContent = computeLegendContent(
12273
12284
  chartSpec,
@@ -12287,7 +12298,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12287
12298
  chromeMode,
12288
12299
  padding,
12289
12300
  watermark,
12290
- bottomLegendReservation
12301
+ bottomLegendReservation,
12302
+ crampedTitle
12291
12303
  );
12292
12304
  return {
12293
12305
  leftGutter: hPad,
@@ -12411,7 +12423,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12411
12423
  chromeMode,
12412
12424
  padding,
12413
12425
  watermark,
12414
- bottomLegendReservation
12426
+ bottomLegendReservation,
12427
+ crampedTitle
12415
12428
  );
12416
12429
  let effectiveXTickAngle = xTickAngle;
12417
12430
  if (xTickAngle === void 0 && xLabels.length > 1) {
@@ -13550,6 +13563,54 @@ function topologyBBox(topology) {
13550
13563
  var OVERLAY_LEGEND_INSET = 18;
13551
13564
  var NO_DATA_SWATCH_GAP = 12;
13552
13565
  var NO_DATA_LABEL_GAP = 5;
13566
+ var CATEGORICAL_SWATCH_SIZE = 10;
13567
+ var CATEGORICAL_SWATCH_GAP = 6;
13568
+ var CATEGORICAL_ENTRY_GAP = 16;
13569
+ var CATEGORICAL_ROW_HEIGHT = CATEGORICAL_SWATCH_SIZE + 6;
13570
+ function categoricalLegendLabelStyle(theme) {
13571
+ return {
13572
+ fontFamily: theme.fonts.family,
13573
+ fontSize: 11,
13574
+ fontWeight: 400,
13575
+ fill: theme.colors.text,
13576
+ lineHeight: 1.2
13577
+ };
13578
+ }
13579
+ function categoricalEntryWidth(label, labelStyle) {
13580
+ return CATEGORICAL_SWATCH_SIZE + CATEGORICAL_SWATCH_GAP + estimateTextWidth18(label, labelStyle.fontSize, labelStyle.fontWeight) + CATEGORICAL_ENTRY_GAP;
13581
+ }
13582
+ function noDataBlockWidth(labelStyle) {
13583
+ return CATEGORICAL_SWATCH_SIZE + NO_DATA_LABEL_GAP + estimateTextWidth18("No data", labelStyle.fontSize, labelStyle.fontWeight);
13584
+ }
13585
+ function wrapCategoricalEntries(widths, legendWidth) {
13586
+ const placed = [];
13587
+ let x = 0;
13588
+ let row = 0;
13589
+ for (let i = 0; i < widths.length; i++) {
13590
+ if (i > 0 && x + widths[i] > legendWidth) {
13591
+ x = 0;
13592
+ row += 1;
13593
+ }
13594
+ placed.push({ x, row });
13595
+ x += widths[i];
13596
+ }
13597
+ return placed;
13598
+ }
13599
+ function collectCategories(rows, field, domain) {
13600
+ const categories = Array.isArray(domain) ? domain.map((c) => String(c)) : [];
13601
+ const seen = new Set(categories);
13602
+ for (const row of rows) {
13603
+ const raw = row[field];
13604
+ if (raw != null) {
13605
+ const cat = String(raw);
13606
+ if (!seen.has(cat)) {
13607
+ seen.add(cat);
13608
+ categories.push(cat);
13609
+ }
13610
+ }
13611
+ }
13612
+ return categories;
13613
+ }
13553
13614
  function legendTitleFor(colorEncoding) {
13554
13615
  const base = colorEncoding.title ?? colorEncoding.field;
13555
13616
  if (!base) return void 0;
@@ -13678,7 +13739,16 @@ function compileGeoMap(spec, options) {
13678
13739
  }
13679
13740
  if (legendBlockHeight === 0 && showChoroplethLegend && colorEncoding && !isQuantitative) {
13680
13741
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
13681
- legendBlockHeight = Math.max(10, labelHeight) + 6;
13742
+ const labelStyle = categoricalLegendLabelStyle(theme);
13743
+ const widths = collectCategories(
13744
+ mapSpec.data,
13745
+ colorEncoding.field,
13746
+ colorEncoding.scale?.domain
13747
+ ).map((cat) => categoricalEntryWidth(cat, labelStyle));
13748
+ widths.push(noDataBlockWidth(labelStyle));
13749
+ const placed = wrapCategoricalEntries(widths, fullArea.width);
13750
+ const rows = placed.length > 0 ? placed[placed.length - 1].row + 1 : 1;
13751
+ legendBlockHeight = rows * (Math.max(10, labelHeight) + 6);
13682
13752
  }
13683
13753
  if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
13684
13754
  if (mapSpec.points.color.type === "quantitative") {
@@ -13747,6 +13817,11 @@ function compileGeoMap(spec, options) {
13747
13817
  const droppedFeatures = [];
13748
13818
  const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber3;
13749
13819
  const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
13820
+ const noDataPattern = {
13821
+ type: "diagonal",
13822
+ base: neutralFill,
13823
+ line: isDarkMode ? theme.colors.neutral[600] : theme.colors.neutral[300]
13824
+ };
13750
13825
  let featureMarks;
13751
13826
  let continuousLegend = null;
13752
13827
  let categoricalLegend = null;
@@ -13765,6 +13840,7 @@ function compileGeoMap(spec, options) {
13765
13840
  colorField: colorEncoding.field,
13766
13841
  classScale,
13767
13842
  neutralFill,
13843
+ noDataPattern,
13768
13844
  formatter,
13769
13845
  droppedFeatures
13770
13846
  });
@@ -13799,16 +13875,21 @@ function compileGeoMap(spec, options) {
13799
13875
  height: continuousContent.barHeight
13800
13876
  };
13801
13877
  const labelY = bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize;
13802
- const hasUnmatched = featureMarks.some((m) => {
13803
- const raw = m.data?.[colorEncoding.field];
13804
- return raw == null || Number.isNaN(Number(raw));
13805
- });
13878
+ const hasUnmatched = featureMarks.some((m) => m.noData);
13806
13879
  const noDataX = bar.x + bar.width + NO_DATA_SWATCH_GAP;
13807
- const noData = hasUnmatched && noDataX + bar.height + NO_DATA_LABEL_GAP < fullArea.x + fullArea.width ? {
13880
+ const noDataLabelWidth = estimateTextWidth18(
13881
+ "No data",
13882
+ labelStyle.fontSize,
13883
+ labelStyle.fontWeight
13884
+ );
13885
+ const noData = hasUnmatched && noDataX + bar.height + NO_DATA_LABEL_GAP + noDataLabelWidth < fullArea.x + fullArea.width ? {
13808
13886
  x: noDataX,
13809
13887
  y: bar.y,
13810
13888
  size: bar.height,
13811
13889
  fill: neutralFill,
13890
+ // Same hatch the no-data features carry, so the key matches what
13891
+ // is on the map rather than a neutral the ramp's center mimics.
13892
+ pattern: noDataPattern,
13812
13893
  label: "No data",
13813
13894
  labelX: noDataX + bar.height + NO_DATA_LABEL_GAP,
13814
13895
  labelY: bar.y + bar.height
@@ -13839,6 +13920,7 @@ function compileGeoMap(spec, options) {
13839
13920
  colorScale: colorEncoding.scale,
13840
13921
  isDarkMode: !!isDarkMode,
13841
13922
  neutralFill,
13923
+ noDataPattern,
13842
13924
  theme,
13843
13925
  fullArea,
13844
13926
  mapAreaHeight,
@@ -14290,6 +14372,7 @@ function buildQuantitativeMarks(opts) {
14290
14372
  colorField,
14291
14373
  classScale,
14292
14374
  neutralFill,
14375
+ noDataPattern,
14293
14376
  formatter,
14294
14377
  droppedFeatures
14295
14378
  } = opts;
@@ -14307,13 +14390,17 @@ function buildQuantitativeMarks(opts) {
14307
14390
  const hasData = dataRow !== null;
14308
14391
  let fill = neutralFill;
14309
14392
  let binIndex;
14310
- if (hasData && classScale) {
14393
+ let hasValue = false;
14394
+ if (hasData) {
14311
14395
  const raw = dataRow[colorField];
14312
14396
  if (raw != null) {
14313
14397
  const v = Number(raw);
14314
14398
  if (!Number.isNaN(v)) {
14315
- fill = classScale.color(v);
14316
- binIndex = classScale.binIndex(v);
14399
+ hasValue = true;
14400
+ if (classScale) {
14401
+ fill = classScale.color(v);
14402
+ binIndex = classScale.binIndex(v);
14403
+ }
14317
14404
  }
14318
14405
  }
14319
14406
  }
@@ -14344,7 +14431,8 @@ function buildQuantitativeMarks(opts) {
14344
14431
  animationIndex: animIndex++,
14345
14432
  bounds,
14346
14433
  centroid: [cx, cy],
14347
- binIndex
14434
+ binIndex,
14435
+ ...hasValue ? {} : { noData: true, pattern: noDataPattern }
14348
14436
  });
14349
14437
  }
14350
14438
  return { marks };
@@ -14358,6 +14446,7 @@ function buildCategoricalMarks(opts) {
14358
14446
  colorScale: scaleConfig,
14359
14447
  isDarkMode,
14360
14448
  neutralFill,
14449
+ noDataPattern,
14361
14450
  theme,
14362
14451
  fullArea,
14363
14452
  mapAreaHeight,
@@ -14366,24 +14455,7 @@ function buildCategoricalMarks(opts) {
14366
14455
  showLegend,
14367
14456
  droppedFeatures
14368
14457
  } = opts;
14369
- const categories = [];
14370
- const explicitDomain = scaleConfig?.domain;
14371
- if (explicitDomain && Array.isArray(explicitDomain)) {
14372
- for (const cat of explicitDomain) {
14373
- categories.push(String(cat));
14374
- }
14375
- }
14376
- const seen = new Set(categories);
14377
- for (const row of joined.values()) {
14378
- const raw = row[colorField];
14379
- if (raw != null) {
14380
- const cat = String(raw);
14381
- if (!seen.has(cat)) {
14382
- seen.add(cat);
14383
- categories.push(cat);
14384
- }
14385
- }
14386
- }
14458
+ const categories = collectCategories(joined.values(), colorField, scaleConfig?.domain);
14387
14459
  const explicitRange = scaleConfig?.range;
14388
14460
  const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE2;
14389
14461
  const categoryColors = /* @__PURE__ */ new Map();
@@ -14403,10 +14475,12 @@ function buildCategoricalMarks(opts) {
14403
14475
  const dataRow = joined.get(featureId) ?? null;
14404
14476
  const hasData = dataRow !== null;
14405
14477
  let fill = neutralFill;
14478
+ let hasValue = false;
14406
14479
  if (hasData) {
14407
14480
  const raw = dataRow[colorField];
14408
14481
  if (raw != null) {
14409
14482
  const cat = String(raw);
14483
+ hasValue = true;
14410
14484
  fill = categoryColors.get(cat) ?? neutralFill;
14411
14485
  }
14412
14486
  }
@@ -14436,18 +14510,13 @@ function buildCategoricalMarks(opts) {
14436
14510
  },
14437
14511
  animationIndex: animIndex++,
14438
14512
  bounds,
14439
- centroid: [cx, cy]
14513
+ centroid: [cx, cy],
14514
+ ...hasValue ? {} : { noData: true, pattern: noDataPattern }
14440
14515
  });
14441
14516
  }
14442
14517
  let legend = null;
14443
14518
  if (showLegend && categories.length > 0) {
14444
- const labelStyle = {
14445
- fontFamily: theme.fonts.family,
14446
- fontSize: 11,
14447
- fontWeight: 400,
14448
- fill: theme.colors.text,
14449
- lineHeight: 1.2
14450
- };
14519
+ const labelStyle = categoricalLegendLabelStyle(theme);
14451
14520
  const entries = categories.map((cat) => ({
14452
14521
  label: cat,
14453
14522
  color: categoryColors.get(cat),
@@ -14456,19 +14525,67 @@ function buildCategoricalMarks(opts) {
14456
14525
  const legendX = fullArea.x;
14457
14526
  const legendY = legendPosition === "bottom" ? fullArea.y + mapAreaHeight + legendReserveGap : fullArea.y + legendReserveGap;
14458
14527
  const legendWidth = fullArea.width;
14459
- const swatchSize = 10;
14460
- const swatchGap = 6;
14461
- const entryGap = 16;
14528
+ const swatchSize = CATEGORICAL_SWATCH_SIZE;
14529
+ const swatchGap = CATEGORICAL_SWATCH_GAP;
14530
+ const entryGap = CATEGORICAL_ENTRY_GAP;
14531
+ const entryWidths = entries.map((e) => categoricalEntryWidth(e.label, labelStyle));
14532
+ const placed = wrapCategoricalEntries(entryWidths, legendWidth);
14533
+ const entryPositions = placed.map((p, i) => {
14534
+ const x = legendX + p.x;
14535
+ const y = legendY + p.row * CATEGORICAL_ROW_HEIGHT;
14536
+ return {
14537
+ x,
14538
+ y,
14539
+ labelX: x + swatchSize + swatchGap,
14540
+ labelY: y + swatchSize / 2,
14541
+ width: entryWidths[i],
14542
+ row: p.row
14543
+ };
14544
+ });
14545
+ let rows = placed.length > 0 ? placed[placed.length - 1].row + 1 : 1;
14546
+ const hasUnmatched = marks.some((m) => m.noData);
14547
+ const last = placed[placed.length - 1];
14548
+ const blockWidth = noDataBlockWidth(labelStyle);
14549
+ let noDataOffsetX = last ? last.x + entryWidths[entryWidths.length - 1] : 0;
14550
+ let noDataRow = last ? last.row : 0;
14551
+ if (noDataOffsetX + blockWidth > legendWidth) {
14552
+ noDataOffsetX = 0;
14553
+ noDataRow += 1;
14554
+ }
14555
+ const noDataX = legendX + noDataOffsetX;
14556
+ const noDataY = legendY + noDataRow * CATEGORICAL_ROW_HEIGHT;
14557
+ const chipSize = Math.round(swatchSize * 0.6);
14558
+ const noData = hasUnmatched && blockWidth <= legendWidth ? {
14559
+ x: noDataX + (swatchSize - chipSize) / 2,
14560
+ y: noDataY + swatchSize / 2 - chipSize / 2,
14561
+ size: chipSize,
14562
+ fill: neutralFill,
14563
+ // The same hatch the no-data features carry, so the key matches
14564
+ // what is on the map.
14565
+ pattern: noDataPattern,
14566
+ label: "No data",
14567
+ labelX: noDataX + swatchSize + NO_DATA_LABEL_GAP,
14568
+ labelY: noDataY + swatchSize / 2
14569
+ } : void 0;
14570
+ if (noData) rows = Math.max(rows, noDataRow + 1);
14462
14571
  legend = {
14463
14572
  type: "categorical",
14464
14573
  position: legendPosition,
14465
- bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
14574
+ bounds: {
14575
+ x: legendX,
14576
+ y: legendY,
14577
+ width: legendWidth,
14578
+ height: rows * CATEGORICAL_ROW_HEIGHT
14579
+ },
14466
14580
  labelStyle,
14467
14581
  entries,
14582
+ entryPositions,
14583
+ rowHeight: CATEGORICAL_ROW_HEIGHT,
14468
14584
  swatchSize,
14469
14585
  swatchGap,
14470
14586
  entryGap,
14471
- swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"
14587
+ swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)",
14588
+ noData
14472
14589
  };
14473
14590
  }
14474
14591
  return { marks, legend };
@@ -15302,6 +15419,7 @@ import {
15302
15419
  computeFieldFormatContext as computeFieldFormatContext6,
15303
15420
  defaultNumberFormatter as defaultNumberFormatter9,
15304
15421
  estimateTextWidth as estimateTextWidth20,
15422
+ getHeightClass as getHeightClass2,
15305
15423
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION3,
15306
15424
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN3,
15307
15425
  isAxislessMark as isAxislessMark2,
@@ -15414,6 +15532,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15414
15532
  chromeMode = "hidden";
15415
15533
  }
15416
15534
  const bottomLegendReservation = plan ? hasLegendContent(plan.legendContent) && plan.legendContent.position === "bottom" ? plan.legendContent.height + legendGap(width) : 0 : legendLayoutHasContent(legendLayout) && legendLayout.position === "bottom" ? legendLayout.bounds.height + legendGap(width) : 0;
15535
+ const isCrampedHeight = getHeightClass2(height) === "cramped";
15417
15536
  const chrome = plan?.chrome ?? computeChrome5(
15418
15537
  chromeToInput(spec.chrome),
15419
15538
  theme,
@@ -15422,7 +15541,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15422
15541
  chromeMode,
15423
15542
  padding,
15424
15543
  watermark,
15425
- bottomLegendReservation
15544
+ bottomLegendReservation,
15545
+ isCrampedHeight
15426
15546
  );
15427
15547
  const chromeLayout = resolveChromeLayout(spec, options);
15428
15548
  const effectiveHeight = chromeLayout === "grow" ? height + chrome.topHeight + chrome.bottomHeight : height;
@@ -15460,7 +15580,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15460
15580
  margins: margins2,
15461
15581
  theme,
15462
15582
  xAxisHeight: xAxisSpace,
15463
- effectiveAxisGap: 0
15583
+ effectiveAxisGap: 0,
15584
+ topPad: padding
15464
15585
  };
15465
15586
  }
15466
15587
  const total = { x: 0, y: 0, width, height: effectiveHeight };
@@ -15497,9 +15618,9 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15497
15618
  const inlineTickOverhang = yIsInlinePre ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD : 0;
15498
15619
  const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
15499
15620
  const topPad = width < NARROW_VIEWPORT_MAX3 ? padding + TOP_PAD_EXTRA_NARROW2 : padding;
15500
- const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== "hidden";
15621
+ const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== "hidden" && !isCrampedHeight;
15501
15622
  const tentativeMetricsHeight = wantsMetrics ? metricBarHeight(metricFonts(theme)) : 0;
15502
- const wantsSearch = !!spec.seriesSearch && chromeMode !== "hidden";
15623
+ const wantsSearch = !!spec.seriesSearch && chromeMode !== "hidden" && !isCrampedHeight;
15503
15624
  const searchReservation = wantsSearch ? SERIES_SEARCH_INPUT_HEIGHT + SERIES_SEARCH_GAP : 0;
15504
15625
  const margins = {
15505
15626
  top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
@@ -15757,6 +15878,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15757
15878
  ) : void 0,
15758
15879
  xAxisHeight,
15759
15880
  effectiveAxisGap: fallbackEffectiveAxisGap,
15881
+ topPad,
15760
15882
  chromeStripped: true
15761
15883
  };
15762
15884
  }
@@ -15781,7 +15903,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15781
15903
  metrics: resolvedMetrics,
15782
15904
  seriesSearch: wantsSearch ? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea) : void 0,
15783
15905
  xAxisHeight,
15784
- effectiveAxisGap
15906
+ effectiveAxisGap,
15907
+ topPad
15785
15908
  };
15786
15909
  }
15787
15910
  function resolveSeriesSearch(spec, y, area3) {
@@ -17063,40 +17186,73 @@ function inferAlignment(col, type) {
17063
17186
  if (col.align) return col.align;
17064
17187
  return type === "quantitative" ? "right" : "left";
17065
17188
  }
17066
- function estimateColumnWidth(col, data, fontSize) {
17189
+ function cellPaddingX(density) {
17190
+ if (density === "condensed") return 24;
17191
+ if (density === "relaxed") return 40;
17192
+ return 32;
17193
+ }
17194
+ function estimateColumnWidth(col, data, fontSize, paddingX) {
17067
17195
  const MIN_WIDTH = 60;
17068
- const PADDING = 24;
17069
17196
  if (col.sparkline) return 140;
17070
- if (col.image) return (col.image.width ?? 24) + PADDING;
17197
+ if (col.image) return (col.image.width ?? 24) + paddingX;
17071
17198
  if (col.flag) return 60;
17072
17199
  const label = col.label ?? col.key;
17073
- const headerWidth = estimateTextWidth22(label, fontSize, 600) + PADDING;
17200
+ const headerWidth = estimateTextWidth22(label, fontSize, 600) + paddingX;
17074
17201
  const sampleSize = Math.min(100, data.length);
17075
17202
  let maxDataWidth = 0;
17076
17203
  for (let i = 0; i < sampleSize; i++) {
17077
17204
  const val = data[i][col.key];
17078
17205
  const text = val == null ? "" : String(val);
17079
- const width = estimateTextWidth22(text, fontSize, 400) + PADDING;
17206
+ const width = estimateTextWidth22(text, fontSize, 400) + paddingX;
17080
17207
  if (width > maxDataWidth) maxDataWidth = width;
17081
17208
  }
17082
17209
  return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
17083
17210
  }
17084
- function resolveColumns(columns, data, totalWidth, theme) {
17211
+ var MIN_COLUMN_WIDTH = 60;
17212
+ function resolveColumns(columns, data, totalWidth, theme, density, warnings = []) {
17085
17213
  const fontSize = theme.fonts.sizes.body;
17086
- const isFixed = columns.map((col) => !!(col.sparkline || col.image || col.flag));
17214
+ const paddingX = cellPaddingX(density);
17215
+ const isFixed = columns.map((col) => !!(col.sparkline || col.image || col.flag || col.width));
17087
17216
  const naturalWidths = columns.map((col) => {
17088
17217
  if (col.width) {
17089
17218
  if (col.width.endsWith("px")) {
17090
17219
  return parseInt(col.width, 10) || 100;
17091
17220
  }
17092
17221
  if (col.width.endsWith("%")) {
17093
- return parseFloat(col.width) / 100 * totalWidth || 100;
17222
+ return Math.round(parseFloat(col.width) / 100 * totalWidth) || 100;
17094
17223
  }
17095
17224
  return parseInt(col.width, 10) || 100;
17096
17225
  }
17097
- return estimateColumnWidth(col, data, fontSize);
17226
+ return estimateColumnWidth(col, data, fontSize, paddingX);
17098
17227
  });
17099
- const fixedTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? w : 0), 0);
17228
+ const explicitIndices = columns.map((_, i) => i).filter((i) => isFixed[i] && !!columns[i].width);
17229
+ const visualFixedTotal = columns.reduce(
17230
+ (sum, col, i) => sum + (isFixed[i] && !col.width ? naturalWidths[i] : 0),
17231
+ 0
17232
+ );
17233
+ const explicitTotal = explicitIndices.reduce((sum, i) => sum + naturalWidths[i], 0);
17234
+ const budgetForExplicit = totalWidth - visualFixedTotal;
17235
+ const scaledExplicitWidths = /* @__PURE__ */ new Map();
17236
+ if (explicitTotal > 0 && budgetForExplicit > 0 && explicitTotal > budgetForExplicit) {
17237
+ const scale = budgetForExplicit / explicitTotal;
17238
+ let cumulative = 0;
17239
+ let cumulativeTarget = 0;
17240
+ let floored = false;
17241
+ for (const i of explicitIndices) {
17242
+ cumulativeTarget += naturalWidths[i] * scale;
17243
+ const target = Math.round(cumulativeTarget) - cumulative;
17244
+ const width = Math.max(MIN_COLUMN_WIDTH, target);
17245
+ if (width > target) floored = true;
17246
+ scaledExplicitWidths.set(i, width);
17247
+ cumulative += width;
17248
+ }
17249
+ const outcome = floored ? `scaled proportionally down, but at least one column hit the ${MIN_COLUMN_WIDTH}px minimum, so the table will still overflow.` : "scaled proportionally to fit.";
17250
+ warnings.push(
17251
+ `[openchart] TABLE_WIDTH_OVERFLOW: explicit column widths summed to ${explicitTotal}px, exceeding the ${budgetForExplicit}px available in a ${totalWidth}px-wide table; ${outcome}`
17252
+ );
17253
+ }
17254
+ const resolvedFixedWidths = naturalWidths.map((w, i) => scaledExplicitWidths.get(i) ?? w);
17255
+ const fixedTotal = resolvedFixedWidths.reduce((sum, w, i) => sum + (isFixed[i] ? w : 0), 0);
17100
17256
  const flexTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? 0 : w), 0);
17101
17257
  const remainingWidth = totalWidth - fixedTotal;
17102
17258
  const flexScale = flexTotal > 0 && remainingWidth > 0 ? remainingWidth / flexTotal : 1;
@@ -17105,7 +17261,10 @@ function resolveColumns(columns, data, totalWidth, theme) {
17105
17261
  return {
17106
17262
  key: col.key,
17107
17263
  label: col.label ?? col.key,
17108
- width: Math.max(60, isFixed[i] ? naturalWidths[i] : Math.round(naturalWidths[i] * flexScale)),
17264
+ width: Math.max(
17265
+ MIN_COLUMN_WIDTH,
17266
+ isFixed[i] ? resolvedFixedWidths[i] : Math.round(naturalWidths[i] * flexScale)
17267
+ ),
17109
17268
  sortable: col.sortable ?? true,
17110
17269
  align: inferAlignment(col, type),
17111
17270
  type,
@@ -17228,6 +17387,8 @@ function buildTotalRow(label, columns, resolvedColumns, rows) {
17228
17387
  style: {},
17229
17388
  cellType: "text"
17230
17389
  };
17390
+ const col = columns[i];
17391
+ if (col.total === false) return blank;
17231
17392
  if (resolved.type !== "quantitative" || !SUMMABLE.has(resolved.cellType)) return blank;
17232
17393
  let sum = 0;
17233
17394
  let seen = false;
@@ -17251,7 +17412,17 @@ function buildTotalRow(label, columns, resolvedColumns, rows) {
17251
17412
  function compileTableLayout(spec, options, theme) {
17252
17413
  const data = spec.data;
17253
17414
  const darkMode = theme.isDark;
17254
- const resolvedColumns = resolveColumns(spec.columns, data, options.width, theme);
17415
+ const columnWarnings = [];
17416
+ const resolvedColumns = resolveColumns(
17417
+ spec.columns,
17418
+ data,
17419
+ options.width,
17420
+ theme,
17421
+ // The deprecated `compact` flag renders condensed padding too.
17422
+ spec.density ?? (spec.compact ? "condensed" : void 0),
17423
+ columnWarnings
17424
+ );
17425
+ emitSpecWarnings(columnWarnings, options.onWarn);
17255
17426
  const sort = resolveSort(spec, resolvedColumns, options.sort);
17256
17427
  const searchIndex = spec.search ? buildSearchIndex(data, spec.columns) : /* @__PURE__ */ new Map();
17257
17428
  let currentData = data;
@@ -17578,8 +17749,8 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
17578
17749
  const hasEmptyTiles = tiles.some((t) => !t.hasData);
17579
17750
  const noDataLabel = "No data";
17580
17751
  const noDataLabelWidth = estimateTextWidth23(noDataLabel, 11);
17581
- const noDataBlockWidth = hasEmptyTiles ? NO_DATA_BAR_GAP + legendBarHeight + NO_DATA_LABEL_GAP2 + noDataLabelWidth : 0;
17582
- const barWidth = Math.max(0, legendWidth - noDataBlockWidth);
17752
+ const noDataBlockWidth2 = hasEmptyTiles ? NO_DATA_BAR_GAP + legendBarHeight + NO_DATA_LABEL_GAP2 + noDataLabelWidth : 0;
17753
+ const barWidth = Math.max(0, legendWidth - noDataBlockWidth2);
17583
17754
  const noDataX = legendX + barWidth + NO_DATA_BAR_GAP;
17584
17755
  gradientLegend = {
17585
17756
  type: "gradient",
@@ -18991,7 +19162,7 @@ function compileChart(spec, optionsInput) {
18991
19162
  chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
18992
19163
  }
18993
19164
  const breakpoint = getBreakpoint3(options.width);
18994
- const heightClass = getHeightClass(options.height);
19165
+ const heightClass = getHeightClass3(options.height);
18995
19166
  let strategy = getLayoutStrategy(breakpoint, heightClass);
18996
19167
  const economy = resolveChromeEconomy(options.width, options.height);
18997
19168
  const rawSpec = expandedSpec;
@@ -19135,7 +19306,7 @@ function compileChart(spec, optionsInput) {
19135
19306
  watermark = false;
19136
19307
  chartSpec = { ...chartSpec, watermark: false };
19137
19308
  }
19138
- if (heightClass === "cramped" && !chartSpec.userExplicit.watermark) {
19309
+ if ((heightClass === "minimal" || heightClass === "cramped") && !chartSpec.userExplicit.watermark) {
19139
19310
  watermark = false;
19140
19311
  chartSpec = { ...chartSpec, watermark: false };
19141
19312
  }
@@ -19333,6 +19504,16 @@ function compileChart(spec, optionsInput) {
19333
19504
  if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
19334
19505
  obstacles.push({ ...endpointLabels.bounds, kind: "endpoint-label" });
19335
19506
  }
19507
+ const chromeBottomY = dims.topPad + dims.chrome.topHeight;
19508
+ if (dims.chrome.topHeight > 0) {
19509
+ obstacles.push({
19510
+ x: 0,
19511
+ y: 0,
19512
+ width: dims.total.width,
19513
+ height: chromeBottomY,
19514
+ kind: "chrome"
19515
+ });
19516
+ }
19336
19517
  const annotationMeasure = options.measureText ? (text, font) => options.measureText(text, font.fontSize, font.fontWeight).width : heuristicMeasure;
19337
19518
  let annotations = computeAnnotations(chartSpec, {
19338
19519
  scales,
@@ -19343,7 +19524,10 @@ function compileChart(spec, optionsInput) {
19343
19524
  svg: { width: dims.total.width, height: dims.total.height },
19344
19525
  measure: annotationMeasure,
19345
19526
  fontFamily: theme.fonts.family,
19346
- autoThin: chartSpec.autoThin
19527
+ autoThin: chartSpec.autoThin,
19528
+ // Hand-placed blocks skip every nudge pass, so the clamp is the only lever
19529
+ // that keeps an author's offset from parking a callout on the title.
19530
+ topBound: dims.chrome.topHeight > 0 ? chromeBottomY : void 0
19347
19531
  });
19348
19532
  if (options.dev) {
19349
19533
  emitSpecWarnings(
@@ -19609,7 +19793,8 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, facetDirection, opt
19609
19793
  svg: { width: options.width, height: options.height },
19610
19794
  measure: annotationMeasure,
19611
19795
  fontFamily: theme.fonts.family,
19612
- autoThin: panelSpecWithDomains.autoThin
19796
+ autoThin: panelSpecWithDomains.autoThin,
19797
+ topBound: dims.chrome.topHeight > 0 ? dims.topPad + dims.chrome.topHeight : void 0
19613
19798
  });
19614
19799
  if (chartSpec.autoThin && panelAnnotations.length > 1) {
19615
19800
  const thinned = thinAnnotations(