@opendata-ai/openchart-engine 8.4.0 → 8.5.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/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.
@@ -7421,7 +7430,8 @@ function normalizeGraphSpec(spec, warnings) {
7421
7430
  watermark: spec.watermark ?? true,
7422
7431
  animation: spec.animation,
7423
7432
  interaction: spec.interaction,
7424
- legend: spec.legend
7433
+ legend: spec.legend,
7434
+ dimensions: spec.dimensions
7425
7435
  };
7426
7436
  }
7427
7437
  function normalizeTileMapSpec(spec, warnings) {
@@ -10271,6 +10281,7 @@ import {
10271
10281
  computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
10272
10282
  defaultNumberFormatter as defaultNumberFormatter6,
10273
10283
  estimateTextWidth as estimateTextWidth16,
10284
+ getHeightClass,
10274
10285
  HPAD_COMPACT_FRACTION,
10275
10286
  HPAD_COMPACT_MIN,
10276
10287
  isAxislessMark,
@@ -12268,6 +12279,7 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12268
12279
  if (renderSpec.display === "sparkline" && !renderSpec.userExplicit?.chrome) {
12269
12280
  chromeMode = "hidden";
12270
12281
  }
12282
+ const crampedTitle = getHeightClass(height) === "cramped";
12271
12283
  if (renderSpec.display === "sparkline" || isRadial) {
12272
12284
  const legendContent = computeLegendContent(
12273
12285
  chartSpec,
@@ -12287,7 +12299,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12287
12299
  chromeMode,
12288
12300
  padding,
12289
12301
  watermark,
12290
- bottomLegendReservation
12302
+ bottomLegendReservation,
12303
+ crampedTitle
12291
12304
  );
12292
12305
  return {
12293
12306
  leftGutter: hPad,
@@ -12411,7 +12424,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12411
12424
  chromeMode,
12412
12425
  padding,
12413
12426
  watermark,
12414
- bottomLegendReservation
12427
+ bottomLegendReservation,
12428
+ crampedTitle
12415
12429
  );
12416
12430
  let effectiveXTickAngle = xTickAngle;
12417
12431
  if (xTickAngle === void 0 && xLabels.length > 1) {
@@ -13550,6 +13564,54 @@ function topologyBBox(topology) {
13550
13564
  var OVERLAY_LEGEND_INSET = 18;
13551
13565
  var NO_DATA_SWATCH_GAP = 12;
13552
13566
  var NO_DATA_LABEL_GAP = 5;
13567
+ var CATEGORICAL_SWATCH_SIZE = 10;
13568
+ var CATEGORICAL_SWATCH_GAP = 6;
13569
+ var CATEGORICAL_ENTRY_GAP = 16;
13570
+ var CATEGORICAL_ROW_HEIGHT = CATEGORICAL_SWATCH_SIZE + 6;
13571
+ function categoricalLegendLabelStyle(theme) {
13572
+ return {
13573
+ fontFamily: theme.fonts.family,
13574
+ fontSize: 11,
13575
+ fontWeight: 400,
13576
+ fill: theme.colors.text,
13577
+ lineHeight: 1.2
13578
+ };
13579
+ }
13580
+ function categoricalEntryWidth(label, labelStyle) {
13581
+ return CATEGORICAL_SWATCH_SIZE + CATEGORICAL_SWATCH_GAP + estimateTextWidth18(label, labelStyle.fontSize, labelStyle.fontWeight) + CATEGORICAL_ENTRY_GAP;
13582
+ }
13583
+ function noDataBlockWidth(labelStyle) {
13584
+ return CATEGORICAL_SWATCH_SIZE + NO_DATA_LABEL_GAP + estimateTextWidth18("No data", labelStyle.fontSize, labelStyle.fontWeight);
13585
+ }
13586
+ function wrapCategoricalEntries(widths, legendWidth) {
13587
+ const placed = [];
13588
+ let x = 0;
13589
+ let row = 0;
13590
+ for (let i = 0; i < widths.length; i++) {
13591
+ if (i > 0 && x + widths[i] > legendWidth) {
13592
+ x = 0;
13593
+ row += 1;
13594
+ }
13595
+ placed.push({ x, row });
13596
+ x += widths[i];
13597
+ }
13598
+ return placed;
13599
+ }
13600
+ function collectCategories(rows, field, domain) {
13601
+ const categories = Array.isArray(domain) ? domain.map((c) => String(c)) : [];
13602
+ const seen = new Set(categories);
13603
+ for (const row of rows) {
13604
+ const raw = row[field];
13605
+ if (raw != null) {
13606
+ const cat = String(raw);
13607
+ if (!seen.has(cat)) {
13608
+ seen.add(cat);
13609
+ categories.push(cat);
13610
+ }
13611
+ }
13612
+ }
13613
+ return categories;
13614
+ }
13553
13615
  function legendTitleFor(colorEncoding) {
13554
13616
  const base = colorEncoding.title ?? colorEncoding.field;
13555
13617
  if (!base) return void 0;
@@ -13678,7 +13740,16 @@ function compileGeoMap(spec, options) {
13678
13740
  }
13679
13741
  if (legendBlockHeight === 0 && showChoroplethLegend && colorEncoding && !isQuantitative) {
13680
13742
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
13681
- legendBlockHeight = Math.max(10, labelHeight) + 6;
13743
+ const labelStyle = categoricalLegendLabelStyle(theme);
13744
+ const widths = collectCategories(
13745
+ mapSpec.data,
13746
+ colorEncoding.field,
13747
+ colorEncoding.scale?.domain
13748
+ ).map((cat) => categoricalEntryWidth(cat, labelStyle));
13749
+ widths.push(noDataBlockWidth(labelStyle));
13750
+ const placed = wrapCategoricalEntries(widths, fullArea.width);
13751
+ const rows = placed.length > 0 ? placed[placed.length - 1].row + 1 : 1;
13752
+ legendBlockHeight = rows * (Math.max(10, labelHeight) + 6);
13682
13753
  }
13683
13754
  if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
13684
13755
  if (mapSpec.points.color.type === "quantitative") {
@@ -13747,6 +13818,11 @@ function compileGeoMap(spec, options) {
13747
13818
  const droppedFeatures = [];
13748
13819
  const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber3;
13749
13820
  const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
13821
+ const noDataPattern = {
13822
+ type: "diagonal",
13823
+ base: neutralFill,
13824
+ line: isDarkMode ? theme.colors.neutral[600] : theme.colors.neutral[300]
13825
+ };
13750
13826
  let featureMarks;
13751
13827
  let continuousLegend = null;
13752
13828
  let categoricalLegend = null;
@@ -13765,6 +13841,7 @@ function compileGeoMap(spec, options) {
13765
13841
  colorField: colorEncoding.field,
13766
13842
  classScale,
13767
13843
  neutralFill,
13844
+ noDataPattern,
13768
13845
  formatter,
13769
13846
  droppedFeatures
13770
13847
  });
@@ -13799,16 +13876,21 @@ function compileGeoMap(spec, options) {
13799
13876
  height: continuousContent.barHeight
13800
13877
  };
13801
13878
  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
- });
13879
+ const hasUnmatched = featureMarks.some((m) => m.noData);
13806
13880
  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 ? {
13881
+ const noDataLabelWidth = estimateTextWidth18(
13882
+ "No data",
13883
+ labelStyle.fontSize,
13884
+ labelStyle.fontWeight
13885
+ );
13886
+ const noData = hasUnmatched && noDataX + bar.height + NO_DATA_LABEL_GAP + noDataLabelWidth < fullArea.x + fullArea.width ? {
13808
13887
  x: noDataX,
13809
13888
  y: bar.y,
13810
13889
  size: bar.height,
13811
13890
  fill: neutralFill,
13891
+ // Same hatch the no-data features carry, so the key matches what
13892
+ // is on the map rather than a neutral the ramp's center mimics.
13893
+ pattern: noDataPattern,
13812
13894
  label: "No data",
13813
13895
  labelX: noDataX + bar.height + NO_DATA_LABEL_GAP,
13814
13896
  labelY: bar.y + bar.height
@@ -13839,6 +13921,7 @@ function compileGeoMap(spec, options) {
13839
13921
  colorScale: colorEncoding.scale,
13840
13922
  isDarkMode: !!isDarkMode,
13841
13923
  neutralFill,
13924
+ noDataPattern,
13842
13925
  theme,
13843
13926
  fullArea,
13844
13927
  mapAreaHeight,
@@ -14290,6 +14373,7 @@ function buildQuantitativeMarks(opts) {
14290
14373
  colorField,
14291
14374
  classScale,
14292
14375
  neutralFill,
14376
+ noDataPattern,
14293
14377
  formatter,
14294
14378
  droppedFeatures
14295
14379
  } = opts;
@@ -14307,13 +14391,17 @@ function buildQuantitativeMarks(opts) {
14307
14391
  const hasData = dataRow !== null;
14308
14392
  let fill = neutralFill;
14309
14393
  let binIndex;
14310
- if (hasData && classScale) {
14394
+ let hasValue = false;
14395
+ if (hasData) {
14311
14396
  const raw = dataRow[colorField];
14312
14397
  if (raw != null) {
14313
14398
  const v = Number(raw);
14314
14399
  if (!Number.isNaN(v)) {
14315
- fill = classScale.color(v);
14316
- binIndex = classScale.binIndex(v);
14400
+ hasValue = true;
14401
+ if (classScale) {
14402
+ fill = classScale.color(v);
14403
+ binIndex = classScale.binIndex(v);
14404
+ }
14317
14405
  }
14318
14406
  }
14319
14407
  }
@@ -14344,7 +14432,8 @@ function buildQuantitativeMarks(opts) {
14344
14432
  animationIndex: animIndex++,
14345
14433
  bounds,
14346
14434
  centroid: [cx, cy],
14347
- binIndex
14435
+ binIndex,
14436
+ ...hasValue ? {} : { noData: true, pattern: noDataPattern }
14348
14437
  });
14349
14438
  }
14350
14439
  return { marks };
@@ -14358,6 +14447,7 @@ function buildCategoricalMarks(opts) {
14358
14447
  colorScale: scaleConfig,
14359
14448
  isDarkMode,
14360
14449
  neutralFill,
14450
+ noDataPattern,
14361
14451
  theme,
14362
14452
  fullArea,
14363
14453
  mapAreaHeight,
@@ -14366,24 +14456,7 @@ function buildCategoricalMarks(opts) {
14366
14456
  showLegend,
14367
14457
  droppedFeatures
14368
14458
  } = 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
- }
14459
+ const categories = collectCategories(joined.values(), colorField, scaleConfig?.domain);
14387
14460
  const explicitRange = scaleConfig?.range;
14388
14461
  const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE2;
14389
14462
  const categoryColors = /* @__PURE__ */ new Map();
@@ -14403,10 +14476,12 @@ function buildCategoricalMarks(opts) {
14403
14476
  const dataRow = joined.get(featureId) ?? null;
14404
14477
  const hasData = dataRow !== null;
14405
14478
  let fill = neutralFill;
14479
+ let hasValue = false;
14406
14480
  if (hasData) {
14407
14481
  const raw = dataRow[colorField];
14408
14482
  if (raw != null) {
14409
14483
  const cat = String(raw);
14484
+ hasValue = true;
14410
14485
  fill = categoryColors.get(cat) ?? neutralFill;
14411
14486
  }
14412
14487
  }
@@ -14436,18 +14511,13 @@ function buildCategoricalMarks(opts) {
14436
14511
  },
14437
14512
  animationIndex: animIndex++,
14438
14513
  bounds,
14439
- centroid: [cx, cy]
14514
+ centroid: [cx, cy],
14515
+ ...hasValue ? {} : { noData: true, pattern: noDataPattern }
14440
14516
  });
14441
14517
  }
14442
14518
  let legend = null;
14443
14519
  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
- };
14520
+ const labelStyle = categoricalLegendLabelStyle(theme);
14451
14521
  const entries = categories.map((cat) => ({
14452
14522
  label: cat,
14453
14523
  color: categoryColors.get(cat),
@@ -14456,19 +14526,67 @@ function buildCategoricalMarks(opts) {
14456
14526
  const legendX = fullArea.x;
14457
14527
  const legendY = legendPosition === "bottom" ? fullArea.y + mapAreaHeight + legendReserveGap : fullArea.y + legendReserveGap;
14458
14528
  const legendWidth = fullArea.width;
14459
- const swatchSize = 10;
14460
- const swatchGap = 6;
14461
- const entryGap = 16;
14529
+ const swatchSize = CATEGORICAL_SWATCH_SIZE;
14530
+ const swatchGap = CATEGORICAL_SWATCH_GAP;
14531
+ const entryGap = CATEGORICAL_ENTRY_GAP;
14532
+ const entryWidths = entries.map((e) => categoricalEntryWidth(e.label, labelStyle));
14533
+ const placed = wrapCategoricalEntries(entryWidths, legendWidth);
14534
+ const entryPositions = placed.map((p, i) => {
14535
+ const x = legendX + p.x;
14536
+ const y = legendY + p.row * CATEGORICAL_ROW_HEIGHT;
14537
+ return {
14538
+ x,
14539
+ y,
14540
+ labelX: x + swatchSize + swatchGap,
14541
+ labelY: y + swatchSize / 2,
14542
+ width: entryWidths[i],
14543
+ row: p.row
14544
+ };
14545
+ });
14546
+ let rows = placed.length > 0 ? placed[placed.length - 1].row + 1 : 1;
14547
+ const hasUnmatched = marks.some((m) => m.noData);
14548
+ const last = placed[placed.length - 1];
14549
+ const blockWidth = noDataBlockWidth(labelStyle);
14550
+ let noDataOffsetX = last ? last.x + entryWidths[entryWidths.length - 1] : 0;
14551
+ let noDataRow = last ? last.row : 0;
14552
+ if (noDataOffsetX + blockWidth > legendWidth) {
14553
+ noDataOffsetX = 0;
14554
+ noDataRow += 1;
14555
+ }
14556
+ const noDataX = legendX + noDataOffsetX;
14557
+ const noDataY = legendY + noDataRow * CATEGORICAL_ROW_HEIGHT;
14558
+ const chipSize = Math.round(swatchSize * 0.6);
14559
+ const noData = hasUnmatched && blockWidth <= legendWidth ? {
14560
+ x: noDataX + (swatchSize - chipSize) / 2,
14561
+ y: noDataY + swatchSize / 2 - chipSize / 2,
14562
+ size: chipSize,
14563
+ fill: neutralFill,
14564
+ // The same hatch the no-data features carry, so the key matches
14565
+ // what is on the map.
14566
+ pattern: noDataPattern,
14567
+ label: "No data",
14568
+ labelX: noDataX + swatchSize + NO_DATA_LABEL_GAP,
14569
+ labelY: noDataY + swatchSize / 2
14570
+ } : void 0;
14571
+ if (noData) rows = Math.max(rows, noDataRow + 1);
14462
14572
  legend = {
14463
14573
  type: "categorical",
14464
14574
  position: legendPosition,
14465
- bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
14575
+ bounds: {
14576
+ x: legendX,
14577
+ y: legendY,
14578
+ width: legendWidth,
14579
+ height: rows * CATEGORICAL_ROW_HEIGHT
14580
+ },
14466
14581
  labelStyle,
14467
14582
  entries,
14583
+ entryPositions,
14584
+ rowHeight: CATEGORICAL_ROW_HEIGHT,
14468
14585
  swatchSize,
14469
14586
  swatchGap,
14470
14587
  entryGap,
14471
- swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"
14588
+ swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)",
14589
+ noData
14472
14590
  };
14473
14591
  }
14474
14592
  return { marks, legend };
@@ -14992,6 +15110,9 @@ function resolveGraphInteraction(cfg) {
14992
15110
  };
14993
15111
  }
14994
15112
 
15113
+ // src/graphs/types.ts
15114
+ var MAX_3D_NODES = 2e3;
15115
+
14995
15116
  // src/graphs/compile-graph.ts
14996
15117
  var graphNumberFormatter = defaultNumberFormatter8({ allIntegers: false, surface: "chart" });
14997
15118
  function mergeOverrides(...layers) {
@@ -15147,6 +15268,7 @@ function compileGraph(spec, options) {
15147
15268
  );
15148
15269
  }
15149
15270
  const graphSpec = normalized;
15271
+ const numDimensions = resolveNumDimensions(graphSpec, options.onWarn);
15150
15272
  for (const [name, ch] of [
15151
15273
  ["nodeColor", graphSpec.encoding.nodeColor],
15152
15274
  ["edgeColor", graphSpec.encoding.edgeColor]
@@ -15288,9 +15410,39 @@ function compileGraph(spec, options) {
15288
15410
  legendField,
15289
15411
  initialHighlight,
15290
15412
  seedNodeIds,
15291
- edgeLegend
15413
+ edgeLegend,
15414
+ numDimensions
15292
15415
  };
15293
15416
  }
15417
+ function resolveNumDimensions(spec, onWarn) {
15418
+ const requested = spec.dimensions;
15419
+ if (requested !== void 0 && requested !== 2 && requested !== 3) {
15420
+ throw new Error(
15421
+ `Spec error: graph "dimensions" must be 2 or 3, received ${JSON.stringify(requested)}.`
15422
+ );
15423
+ }
15424
+ if (requested !== 3) return 2;
15425
+ if (spec.nodes.length > MAX_3D_NODES) {
15426
+ onWarn?.(
15427
+ `dimensions: 3 is unavailable for ${spec.nodes.length} nodes (the limit is ${MAX_3D_NODES}); rendering in 2D.`
15428
+ );
15429
+ return 2;
15430
+ }
15431
+ if (spec.interaction?.cursorRepulsion) {
15432
+ onWarn?.("interaction.cursorRepulsion is not supported in 3D; it will be ignored.");
15433
+ }
15434
+ if (spec.interaction?.springyDrag) {
15435
+ onWarn?.("interaction.springyDrag is not supported in 3D; it will be ignored.");
15436
+ }
15437
+ const hasStroke = (o) => o?.stroke !== void 0 || o?.strokeWidth !== void 0;
15438
+ const strokeOverrides = Object.values(spec.nodeOverrides ?? {}).some(hasStroke) || hasStroke(spec.seedNode?.style);
15439
+ if (strokeOverrides) {
15440
+ onWarn?.(
15441
+ "nodeOverrides/seedNode.style stroke and strokeWidth are not supported in 3D; they will be ignored."
15442
+ );
15443
+ }
15444
+ return 3;
15445
+ }
15294
15446
  var DEFAULT_COLLISION_PADDING = 5;
15295
15447
 
15296
15448
  // src/layout/dimensions.ts
@@ -15302,6 +15454,7 @@ import {
15302
15454
  computeFieldFormatContext as computeFieldFormatContext6,
15303
15455
  defaultNumberFormatter as defaultNumberFormatter9,
15304
15456
  estimateTextWidth as estimateTextWidth20,
15457
+ getHeightClass as getHeightClass2,
15305
15458
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION3,
15306
15459
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN3,
15307
15460
  isAxislessMark as isAxislessMark2,
@@ -15414,6 +15567,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15414
15567
  chromeMode = "hidden";
15415
15568
  }
15416
15569
  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;
15570
+ const isCrampedHeight = getHeightClass2(height) === "cramped";
15417
15571
  const chrome = plan?.chrome ?? computeChrome5(
15418
15572
  chromeToInput(spec.chrome),
15419
15573
  theme,
@@ -15422,7 +15576,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15422
15576
  chromeMode,
15423
15577
  padding,
15424
15578
  watermark,
15425
- bottomLegendReservation
15579
+ bottomLegendReservation,
15580
+ isCrampedHeight
15426
15581
  );
15427
15582
  const chromeLayout = resolveChromeLayout(spec, options);
15428
15583
  const effectiveHeight = chromeLayout === "grow" ? height + chrome.topHeight + chrome.bottomHeight : height;
@@ -15460,7 +15615,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15460
15615
  margins: margins2,
15461
15616
  theme,
15462
15617
  xAxisHeight: xAxisSpace,
15463
- effectiveAxisGap: 0
15618
+ effectiveAxisGap: 0,
15619
+ topPad: padding
15464
15620
  };
15465
15621
  }
15466
15622
  const total = { x: 0, y: 0, width, height: effectiveHeight };
@@ -15497,9 +15653,9 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15497
15653
  const inlineTickOverhang = yIsInlinePre ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD : 0;
15498
15654
  const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
15499
15655
  const topPad = width < NARROW_VIEWPORT_MAX3 ? padding + TOP_PAD_EXTRA_NARROW2 : padding;
15500
- const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== "hidden";
15656
+ const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== "hidden" && !isCrampedHeight;
15501
15657
  const tentativeMetricsHeight = wantsMetrics ? metricBarHeight(metricFonts(theme)) : 0;
15502
- const wantsSearch = !!spec.seriesSearch && chromeMode !== "hidden";
15658
+ const wantsSearch = !!spec.seriesSearch && chromeMode !== "hidden" && !isCrampedHeight;
15503
15659
  const searchReservation = wantsSearch ? SERIES_SEARCH_INPUT_HEIGHT + SERIES_SEARCH_GAP : 0;
15504
15660
  const margins = {
15505
15661
  top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
@@ -15757,6 +15913,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15757
15913
  ) : void 0,
15758
15914
  xAxisHeight,
15759
15915
  effectiveAxisGap: fallbackEffectiveAxisGap,
15916
+ topPad,
15760
15917
  chromeStripped: true
15761
15918
  };
15762
15919
  }
@@ -15781,7 +15938,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15781
15938
  metrics: resolvedMetrics,
15782
15939
  seriesSearch: wantsSearch ? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea) : void 0,
15783
15940
  xAxisHeight,
15784
- effectiveAxisGap
15941
+ effectiveAxisGap,
15942
+ topPad
15785
15943
  };
15786
15944
  }
15787
15945
  function resolveSeriesSearch(spec, y, area3) {
@@ -17063,40 +17221,73 @@ function inferAlignment(col, type) {
17063
17221
  if (col.align) return col.align;
17064
17222
  return type === "quantitative" ? "right" : "left";
17065
17223
  }
17066
- function estimateColumnWidth(col, data, fontSize) {
17224
+ function cellPaddingX(density) {
17225
+ if (density === "condensed") return 24;
17226
+ if (density === "relaxed") return 40;
17227
+ return 32;
17228
+ }
17229
+ function estimateColumnWidth(col, data, fontSize, paddingX) {
17067
17230
  const MIN_WIDTH = 60;
17068
- const PADDING = 24;
17069
17231
  if (col.sparkline) return 140;
17070
- if (col.image) return (col.image.width ?? 24) + PADDING;
17232
+ if (col.image) return (col.image.width ?? 24) + paddingX;
17071
17233
  if (col.flag) return 60;
17072
17234
  const label = col.label ?? col.key;
17073
- const headerWidth = estimateTextWidth22(label, fontSize, 600) + PADDING;
17235
+ const headerWidth = estimateTextWidth22(label, fontSize, 600) + paddingX;
17074
17236
  const sampleSize = Math.min(100, data.length);
17075
17237
  let maxDataWidth = 0;
17076
17238
  for (let i = 0; i < sampleSize; i++) {
17077
17239
  const val = data[i][col.key];
17078
17240
  const text = val == null ? "" : String(val);
17079
- const width = estimateTextWidth22(text, fontSize, 400) + PADDING;
17241
+ const width = estimateTextWidth22(text, fontSize, 400) + paddingX;
17080
17242
  if (width > maxDataWidth) maxDataWidth = width;
17081
17243
  }
17082
17244
  return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
17083
17245
  }
17084
- function resolveColumns(columns, data, totalWidth, theme) {
17246
+ var MIN_COLUMN_WIDTH = 60;
17247
+ function resolveColumns(columns, data, totalWidth, theme, density, warnings = []) {
17085
17248
  const fontSize = theme.fonts.sizes.body;
17086
- const isFixed = columns.map((col) => !!(col.sparkline || col.image || col.flag));
17249
+ const paddingX = cellPaddingX(density);
17250
+ const isFixed = columns.map((col) => !!(col.sparkline || col.image || col.flag || col.width));
17087
17251
  const naturalWidths = columns.map((col) => {
17088
17252
  if (col.width) {
17089
17253
  if (col.width.endsWith("px")) {
17090
17254
  return parseInt(col.width, 10) || 100;
17091
17255
  }
17092
17256
  if (col.width.endsWith("%")) {
17093
- return parseFloat(col.width) / 100 * totalWidth || 100;
17257
+ return Math.round(parseFloat(col.width) / 100 * totalWidth) || 100;
17094
17258
  }
17095
17259
  return parseInt(col.width, 10) || 100;
17096
17260
  }
17097
- return estimateColumnWidth(col, data, fontSize);
17261
+ return estimateColumnWidth(col, data, fontSize, paddingX);
17098
17262
  });
17099
- const fixedTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? w : 0), 0);
17263
+ const explicitIndices = columns.map((_, i) => i).filter((i) => isFixed[i] && !!columns[i].width);
17264
+ const visualFixedTotal = columns.reduce(
17265
+ (sum, col, i) => sum + (isFixed[i] && !col.width ? naturalWidths[i] : 0),
17266
+ 0
17267
+ );
17268
+ const explicitTotal = explicitIndices.reduce((sum, i) => sum + naturalWidths[i], 0);
17269
+ const budgetForExplicit = totalWidth - visualFixedTotal;
17270
+ const scaledExplicitWidths = /* @__PURE__ */ new Map();
17271
+ if (explicitTotal > 0 && budgetForExplicit > 0 && explicitTotal > budgetForExplicit) {
17272
+ const scale = budgetForExplicit / explicitTotal;
17273
+ let cumulative = 0;
17274
+ let cumulativeTarget = 0;
17275
+ let floored = false;
17276
+ for (const i of explicitIndices) {
17277
+ cumulativeTarget += naturalWidths[i] * scale;
17278
+ const target = Math.round(cumulativeTarget) - cumulative;
17279
+ const width = Math.max(MIN_COLUMN_WIDTH, target);
17280
+ if (width > target) floored = true;
17281
+ scaledExplicitWidths.set(i, width);
17282
+ cumulative += width;
17283
+ }
17284
+ 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.";
17285
+ warnings.push(
17286
+ `[openchart] TABLE_WIDTH_OVERFLOW: explicit column widths summed to ${explicitTotal}px, exceeding the ${budgetForExplicit}px available in a ${totalWidth}px-wide table; ${outcome}`
17287
+ );
17288
+ }
17289
+ const resolvedFixedWidths = naturalWidths.map((w, i) => scaledExplicitWidths.get(i) ?? w);
17290
+ const fixedTotal = resolvedFixedWidths.reduce((sum, w, i) => sum + (isFixed[i] ? w : 0), 0);
17100
17291
  const flexTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? 0 : w), 0);
17101
17292
  const remainingWidth = totalWidth - fixedTotal;
17102
17293
  const flexScale = flexTotal > 0 && remainingWidth > 0 ? remainingWidth / flexTotal : 1;
@@ -17105,7 +17296,10 @@ function resolveColumns(columns, data, totalWidth, theme) {
17105
17296
  return {
17106
17297
  key: col.key,
17107
17298
  label: col.label ?? col.key,
17108
- width: Math.max(60, isFixed[i] ? naturalWidths[i] : Math.round(naturalWidths[i] * flexScale)),
17299
+ width: Math.max(
17300
+ MIN_COLUMN_WIDTH,
17301
+ isFixed[i] ? resolvedFixedWidths[i] : Math.round(naturalWidths[i] * flexScale)
17302
+ ),
17109
17303
  sortable: col.sortable ?? true,
17110
17304
  align: inferAlignment(col, type),
17111
17305
  type,
@@ -17228,6 +17422,8 @@ function buildTotalRow(label, columns, resolvedColumns, rows) {
17228
17422
  style: {},
17229
17423
  cellType: "text"
17230
17424
  };
17425
+ const col = columns[i];
17426
+ if (col.total === false) return blank;
17231
17427
  if (resolved.type !== "quantitative" || !SUMMABLE.has(resolved.cellType)) return blank;
17232
17428
  let sum = 0;
17233
17429
  let seen = false;
@@ -17251,7 +17447,17 @@ function buildTotalRow(label, columns, resolvedColumns, rows) {
17251
17447
  function compileTableLayout(spec, options, theme) {
17252
17448
  const data = spec.data;
17253
17449
  const darkMode = theme.isDark;
17254
- const resolvedColumns = resolveColumns(spec.columns, data, options.width, theme);
17450
+ const columnWarnings = [];
17451
+ const resolvedColumns = resolveColumns(
17452
+ spec.columns,
17453
+ data,
17454
+ options.width,
17455
+ theme,
17456
+ // The deprecated `compact` flag renders condensed padding too.
17457
+ spec.density ?? (spec.compact ? "condensed" : void 0),
17458
+ columnWarnings
17459
+ );
17460
+ emitSpecWarnings(columnWarnings, options.onWarn);
17255
17461
  const sort = resolveSort(spec, resolvedColumns, options.sort);
17256
17462
  const searchIndex = spec.search ? buildSearchIndex(data, spec.columns) : /* @__PURE__ */ new Map();
17257
17463
  let currentData = data;
@@ -17578,8 +17784,8 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
17578
17784
  const hasEmptyTiles = tiles.some((t) => !t.hasData);
17579
17785
  const noDataLabel = "No data";
17580
17786
  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);
17787
+ const noDataBlockWidth2 = hasEmptyTiles ? NO_DATA_BAR_GAP + legendBarHeight + NO_DATA_LABEL_GAP2 + noDataLabelWidth : 0;
17788
+ const barWidth = Math.max(0, legendWidth - noDataBlockWidth2);
17583
17789
  const noDataX = legendX + barWidth + NO_DATA_BAR_GAP;
17584
17790
  gradientLegend = {
17585
17791
  type: "gradient",
@@ -18991,7 +19197,7 @@ function compileChart(spec, optionsInput) {
18991
19197
  chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
18992
19198
  }
18993
19199
  const breakpoint = getBreakpoint3(options.width);
18994
- const heightClass = getHeightClass(options.height);
19200
+ const heightClass = getHeightClass3(options.height);
18995
19201
  let strategy = getLayoutStrategy(breakpoint, heightClass);
18996
19202
  const economy = resolveChromeEconomy(options.width, options.height);
18997
19203
  const rawSpec = expandedSpec;
@@ -19135,7 +19341,7 @@ function compileChart(spec, optionsInput) {
19135
19341
  watermark = false;
19136
19342
  chartSpec = { ...chartSpec, watermark: false };
19137
19343
  }
19138
- if (heightClass === "cramped" && !chartSpec.userExplicit.watermark) {
19344
+ if ((heightClass === "minimal" || heightClass === "cramped") && !chartSpec.userExplicit.watermark) {
19139
19345
  watermark = false;
19140
19346
  chartSpec = { ...chartSpec, watermark: false };
19141
19347
  }
@@ -19333,6 +19539,16 @@ function compileChart(spec, optionsInput) {
19333
19539
  if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
19334
19540
  obstacles.push({ ...endpointLabels.bounds, kind: "endpoint-label" });
19335
19541
  }
19542
+ const chromeBottomY = dims.topPad + dims.chrome.topHeight;
19543
+ if (dims.chrome.topHeight > 0) {
19544
+ obstacles.push({
19545
+ x: 0,
19546
+ y: 0,
19547
+ width: dims.total.width,
19548
+ height: chromeBottomY,
19549
+ kind: "chrome"
19550
+ });
19551
+ }
19336
19552
  const annotationMeasure = options.measureText ? (text, font) => options.measureText(text, font.fontSize, font.fontWeight).width : heuristicMeasure;
19337
19553
  let annotations = computeAnnotations(chartSpec, {
19338
19554
  scales,
@@ -19343,7 +19559,10 @@ function compileChart(spec, optionsInput) {
19343
19559
  svg: { width: dims.total.width, height: dims.total.height },
19344
19560
  measure: annotationMeasure,
19345
19561
  fontFamily: theme.fonts.family,
19346
- autoThin: chartSpec.autoThin
19562
+ autoThin: chartSpec.autoThin,
19563
+ // Hand-placed blocks skip every nudge pass, so the clamp is the only lever
19564
+ // that keeps an author's offset from parking a callout on the title.
19565
+ topBound: dims.chrome.topHeight > 0 ? chromeBottomY : void 0
19347
19566
  });
19348
19567
  if (options.dev) {
19349
19568
  emitSpecWarnings(
@@ -19609,7 +19828,8 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, facetDirection, opt
19609
19828
  svg: { width: options.width, height: options.height },
19610
19829
  measure: annotationMeasure,
19611
19830
  fontFamily: theme.fonts.family,
19612
- autoThin: panelSpecWithDomains.autoThin
19831
+ autoThin: panelSpecWithDomains.autoThin,
19832
+ topBound: dims.chrome.topHeight > 0 ? dims.topPad + dims.chrome.topHeight : void 0
19613
19833
  });
19614
19834
  if (chartSpec.autoThin && panelAnnotations.length > 1) {
19615
19835
  const thinned = thinAnnotations(
@@ -19785,6 +20005,7 @@ export {
19785
20005
  DEFAULT_LINE_HEIGHT,
19786
20006
  ENTER_DEFAULTS,
19787
20007
  EXIT_DEFAULTS,
20008
+ MAX_3D_NODES,
19788
20009
  UPDATE_DEFAULTS,
19789
20010
  buildArcPath,
19790
20011
  buildAreaPath,