@opendata-ai/openchart-engine 2.9.1 → 2.11.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
@@ -1,15 +1,18 @@
1
1
  // src/compile.ts
2
2
  import {
3
3
  adaptTheme as adaptTheme2,
4
+ BRAND_RESERVE_WIDTH as BRAND_RESERVE_WIDTH2,
5
+ computeLabelBounds,
4
6
  generateAltText,
5
7
  generateDataTable,
6
8
  getBreakpoint,
9
+ getHeightClass,
7
10
  getLayoutStrategy,
8
11
  resolveTheme as resolveTheme2
9
12
  } from "@opendata-ai/openchart-core";
10
13
 
11
14
  // src/annotations/compute.ts
12
- import { estimateTextWidth } from "@opendata-ai/openchart-core";
15
+ import { detectCollision, estimateTextWidth } from "@opendata-ai/openchart-core";
13
16
  var DEFAULT_ANNOTATION_FONT_SIZE = 12;
14
17
  var DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
15
18
  var DEFAULT_LINE_HEIGHT = 1.3;
@@ -324,41 +327,34 @@ function estimateLabelBounds(label) {
324
327
  const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
325
328
  return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
326
329
  }
327
- function rectsOverlap(a, b) {
328
- return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
329
- }
330
330
  var NUDGE_PADDING = 6;
331
+ function generateNudgeCandidates(selfBounds, obstacles, padding) {
332
+ const candidates = [];
333
+ for (const obs of obstacles) {
334
+ const belowDy = obs.y + obs.height + padding - selfBounds.y;
335
+ candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
336
+ const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
337
+ candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
338
+ const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
339
+ candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
340
+ const rightDx = obs.x + obs.width + padding - selfBounds.x;
341
+ candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
342
+ }
343
+ candidates.sort((a, b) => a.distance - b.distance);
344
+ return candidates;
345
+ }
331
346
  function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, chartArea, obstacles) {
332
347
  if (annotation.type !== "text" || !annotation.label) return false;
333
348
  const labelBounds = estimateLabelBounds(annotation.label);
334
349
  const collidingObs = obstacles.filter(
335
- (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(labelBounds, obs)
350
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs)
336
351
  );
337
352
  if (collidingObs.length === 0) return false;
338
353
  const px = resolvePosition(originalAnnotation.x, scales.x);
339
354
  const py = resolvePosition(originalAnnotation.y, scales.y);
340
355
  if (px === null || py === null) return false;
341
- const candidates = [];
356
+ const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
342
357
  const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split("\n").length);
343
- for (const obs of collidingObs) {
344
- const currentLabelTop = labelBounds.y;
345
- const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
346
- const belowDy = targetLabelTop - currentLabelTop;
347
- candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
348
- const currentLabelBottom = labelBounds.y + labelBounds.height;
349
- const targetLabelBottom = obs.y - NUDGE_PADDING;
350
- const aboveDy = targetLabelBottom - currentLabelBottom;
351
- candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
352
- const currentLabelRight = labelBounds.x + labelBounds.width;
353
- const targetLabelRight = obs.x - NUDGE_PADDING;
354
- const leftDx = targetLabelRight - currentLabelRight;
355
- candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
356
- const currentLabelLeft = labelBounds.x;
357
- const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
358
- const rightDx = targetLabelLeft - currentLabelLeft;
359
- candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
360
- }
361
- candidates.sort((a, b) => a.distance - b.distance);
362
358
  for (const { dx, dy } of candidates) {
363
359
  const newLabelX = annotation.label.x + dx;
364
360
  const newLabelY = annotation.label.y + dy;
@@ -387,12 +383,12 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
387
383
  };
388
384
  const candidateBounds = estimateLabelBounds(candidateLabel);
389
385
  const stillCollides = obstacles.some(
390
- (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(candidateBounds, obs)
386
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs)
391
387
  );
392
388
  if (stillCollides) continue;
393
389
  const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
394
390
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
395
- const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
391
+ const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
396
392
  if (inBounds) {
397
393
  if (candidateLabel.connector && dx === 0 && dy !== 0) {
398
394
  candidateLabel.connector = {
@@ -406,6 +402,74 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
406
402
  }
407
403
  return false;
408
404
  }
405
+ function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartArea) {
406
+ const placedBounds = [];
407
+ for (let i = 0; i < annotations.length; i++) {
408
+ const annotation = annotations[i];
409
+ if (annotation.type !== "text" || !annotation.label) {
410
+ continue;
411
+ }
412
+ const bounds = estimateLabelBounds(annotation.label);
413
+ const collidingBounds = placedBounds.filter(
414
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb)
415
+ );
416
+ if (collidingBounds.length > 0) {
417
+ const originalSpec = originalSpecs[i];
418
+ if (originalSpec?.type === "text") {
419
+ const px = resolvePosition(originalSpec.x, scales.x);
420
+ const py = resolvePosition(originalSpec.y, scales.y);
421
+ if (px !== null && py !== null) {
422
+ const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
423
+ const fontSize = bounds.height / Math.max(1, annotation.label.text.split("\n").length);
424
+ for (const { dx, dy } of candidates) {
425
+ const newLabelX = annotation.label.x + dx;
426
+ const newLabelY = annotation.label.y + dy;
427
+ const candidateLabel = {
428
+ ...annotation.label,
429
+ x: newLabelX,
430
+ y: newLabelY
431
+ };
432
+ const candidateBounds = estimateLabelBounds(candidateLabel);
433
+ const stillCollides = placedBounds.some(
434
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb)
435
+ );
436
+ if (stillCollides) continue;
437
+ const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
438
+ const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
439
+ const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
440
+ if (inBounds) {
441
+ let newConnector = annotation.label.connector;
442
+ if (newConnector) {
443
+ const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
444
+ const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
445
+ const connStyle = newConnector.style === "curve" ? "curve" : "straight";
446
+ const newFrom = computeConnectorOrigin(
447
+ newLabelX,
448
+ newLabelY,
449
+ annotation.label.text,
450
+ annFontSize,
451
+ annFontWeight,
452
+ px,
453
+ py,
454
+ connStyle
455
+ );
456
+ newConnector = { ...newConnector, from: newFrom };
457
+ }
458
+ annotation.label = {
459
+ ...annotation.label,
460
+ x: newLabelX,
461
+ y: newLabelY,
462
+ connector: newConnector
463
+ };
464
+ break;
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+ placedBounds.push(estimateLabelBounds(annotation.label));
471
+ }
472
+ }
409
473
  function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = []) {
410
474
  if (strategy.annotationPosition === "tooltip-only") {
411
475
  return [];
@@ -431,6 +495,7 @@ function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, o
431
495
  annotations.push(resolved);
432
496
  }
433
497
  }
498
+ resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
434
499
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
435
500
  return annotations;
436
501
  }
@@ -5979,6 +6044,7 @@ var DEFAULT_COLLISION_PADDING = 5;
5979
6044
  import {
5980
6045
  abbreviateNumber as abbreviateNumber3,
5981
6046
  buildD3Formatter as buildD3Formatter3,
6047
+ estimateTextWidth as estimateTextWidth7,
5982
6048
  formatDate,
5983
6049
  formatNumber as formatNumber3
5984
6050
  } from "@opendata-ai/openchart-core";
@@ -5991,6 +6057,8 @@ var HEIGHT_MINIMAL_THRESHOLD = 120;
5991
6057
  var HEIGHT_REDUCED_THRESHOLD = 200;
5992
6058
  var WIDTH_MINIMAL_THRESHOLD = 150;
5993
6059
  var WIDTH_REDUCED_THRESHOLD = 300;
6060
+ var MIN_TICK_GAP_FACTOR = 1;
6061
+ var MIN_TICK_COUNT = 2;
5994
6062
  var DENSITY_ORDER = ["full", "reduced", "minimal"];
5995
6063
  function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
5996
6064
  let density = baseDensity;
@@ -6003,17 +6071,49 @@ function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThre
6003
6071
  }
6004
6072
  return density;
6005
6073
  }
6006
- function continuousTicks(resolvedScale, density) {
6074
+ function measureLabel(text, fontSize, fontWeight, measureText) {
6075
+ return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth7(text, fontSize, fontWeight);
6076
+ }
6077
+ function ticksOverlap(ticks2, fontSize, fontWeight, measureText) {
6078
+ if (ticks2.length < 2) return false;
6079
+ const minGap = fontSize * MIN_TICK_GAP_FACTOR;
6080
+ for (let i = 0; i < ticks2.length - 1; i++) {
6081
+ const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText);
6082
+ const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText);
6083
+ const aRight = ticks2[i].position + aWidth / 2;
6084
+ const bLeft = ticks2[i + 1].position - bWidth / 2;
6085
+ if (aRight + minGap > bLeft) return true;
6086
+ }
6087
+ return false;
6088
+ }
6089
+ function thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText) {
6090
+ if (!ticksOverlap(ticks2, fontSize, fontWeight, measureText)) return ticks2;
6091
+ let current = ticks2;
6092
+ while (current.length > MIN_TICK_COUNT) {
6093
+ const thinned = [current[0]];
6094
+ for (let i = 2; i < current.length - 1; i += 2) {
6095
+ thinned.push(current[i]);
6096
+ }
6097
+ if (current.length > 1) thinned.push(current[current.length - 1]);
6098
+ current = thinned;
6099
+ if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
6100
+ }
6101
+ return current;
6102
+ }
6103
+ function continuousTicks(resolvedScale, density, fontSize, fontWeight, measureText) {
6007
6104
  const scale = resolvedScale.scale;
6008
- const count = resolvedScale.channel.axis?.tickCount ?? TICK_COUNTS[density];
6009
- const ticks2 = scale.ticks(count);
6010
- return ticks2.map((value) => ({
6105
+ const explicitCount = resolvedScale.channel.axis?.tickCount;
6106
+ const count = explicitCount ?? TICK_COUNTS[density];
6107
+ const rawTicks = scale.ticks(count);
6108
+ const ticks2 = rawTicks.map((value) => ({
6011
6109
  value,
6012
6110
  position: scale(value),
6013
6111
  label: formatTickLabel(value, resolvedScale)
6014
6112
  }));
6113
+ if (explicitCount) return ticks2;
6114
+ return thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText);
6015
6115
  }
6016
- function categoricalTicks(resolvedScale, density) {
6116
+ function categoricalTicks(resolvedScale, density, fontSize, fontWeight, measureText) {
6017
6117
  const scale = resolvedScale.scale;
6018
6118
  const domain = scale.domain();
6019
6119
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
@@ -6023,7 +6123,7 @@ function categoricalTicks(resolvedScale, density) {
6023
6123
  const step = Math.ceil(domain.length / maxTicks);
6024
6124
  selectedValues = domain.filter((_, i) => i % step === 0);
6025
6125
  }
6026
- return selectedValues.map((value) => {
6126
+ const ticks2 = selectedValues.map((value) => {
6027
6127
  const bandScale = resolvedScale.type === "band" ? scale : null;
6028
6128
  const pos = bandScale ? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2 : scale(value) ?? 0;
6029
6129
  return {
@@ -6032,6 +6132,10 @@ function categoricalTicks(resolvedScale, density) {
6032
6132
  label: value
6033
6133
  };
6034
6134
  });
6135
+ if (resolvedScale.type !== "band" && !explicitTickCount) {
6136
+ return thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText);
6137
+ }
6138
+ return ticks2;
6035
6139
  }
6036
6140
  function formatTickLabel(value, resolvedScale) {
6037
6141
  const formatStr = resolvedScale.channel.axis?.format;
@@ -6050,7 +6154,7 @@ function formatTickLabel(value, resolvedScale) {
6050
6154
  }
6051
6155
  return String(value);
6052
6156
  }
6053
- function computeAxes(scales, chartArea, strategy, theme) {
6157
+ function computeAxes(scales, chartArea, strategy, theme, measureText) {
6054
6158
  const result = {};
6055
6159
  const baseDensity = strategy.axisLabelDensity;
6056
6160
  const yDensity = effectiveDensity(
@@ -6080,25 +6184,39 @@ function computeAxes(scales, chartArea, strategy, theme) {
6080
6184
  fill: theme.colors.text,
6081
6185
  lineHeight: 1.3
6082
6186
  };
6187
+ const { fontSize } = tickLabelStyle;
6188
+ const { fontWeight } = tickLabelStyle;
6083
6189
  if (scales.x) {
6084
- const ticks2 = scales.x.type === "band" || scales.x.type === "point" || scales.x.type === "ordinal" ? categoricalTicks(scales.x, xDensity) : continuousTicks(scales.x, xDensity);
6190
+ const ticks2 = scales.x.type === "band" || scales.x.type === "point" || scales.x.type === "ordinal" ? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText) : continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
6085
6191
  const gridlines = ticks2.map((t) => ({
6086
6192
  position: t.position,
6087
6193
  major: true
6088
6194
  }));
6195
+ let tickAngle = scales.x.channel.axis?.tickAngle;
6196
+ if (tickAngle === void 0 && scales.x.type === "band" && ticks2.length > 1) {
6197
+ const bandwidth = scales.x.scale.bandwidth();
6198
+ let maxLabelWidth = 0;
6199
+ for (const t of ticks2) {
6200
+ const w = measureLabel(t.label, fontSize, fontWeight, measureText);
6201
+ if (w > maxLabelWidth) maxLabelWidth = w;
6202
+ }
6203
+ if (maxLabelWidth > bandwidth * 0.85) {
6204
+ tickAngle = -45;
6205
+ }
6206
+ }
6089
6207
  result.x = {
6090
6208
  ticks: ticks2,
6091
6209
  gridlines: scales.x.channel.axis?.grid ? gridlines : [],
6092
6210
  label: scales.x.channel.axis?.label,
6093
6211
  labelStyle: axisLabelStyle,
6094
6212
  tickLabelStyle,
6095
- tickAngle: scales.x.channel.axis?.tickAngle,
6213
+ tickAngle,
6096
6214
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
6097
6215
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height }
6098
6216
  };
6099
6217
  }
6100
6218
  if (scales.y) {
6101
- const ticks2 = scales.y.type === "band" || scales.y.type === "point" || scales.y.type === "ordinal" ? categoricalTicks(scales.y, yDensity) : continuousTicks(scales.y, yDensity);
6219
+ const ticks2 = scales.y.type === "band" || scales.y.type === "point" || scales.y.type === "ordinal" ? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText) : continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
6102
6220
  const gridlines = ticks2.map((t) => ({
6103
6221
  position: t.position,
6104
6222
  major: true
@@ -6119,7 +6237,7 @@ function computeAxes(scales, chartArea, strategy, theme) {
6119
6237
  }
6120
6238
 
6121
6239
  // src/layout/dimensions.ts
6122
- import { computeChrome as computeChrome2, estimateTextWidth as estimateTextWidth7 } from "@opendata-ai/openchart-core";
6240
+ import { computeChrome as computeChrome2, estimateTextWidth as estimateTextWidth8 } from "@opendata-ai/openchart-core";
6123
6241
  function chromeToInput(chrome) {
6124
6242
  return {
6125
6243
  title: chrome.title,
@@ -6129,11 +6247,28 @@ function chromeToInput(chrome) {
6129
6247
  footer: chrome.footer
6130
6248
  };
6131
6249
  }
6132
- function computeDimensions(spec, options, legendLayout, theme) {
6250
+ function scalePadding(basePadding, width, height) {
6251
+ const minDim = Math.min(width, height);
6252
+ if (minDim >= 500) return basePadding;
6253
+ if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
6254
+ const t = (minDim - 200) / 300;
6255
+ return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
6256
+ }
6257
+ var MIN_CHART_WIDTH = 60;
6258
+ var MIN_CHART_HEIGHT = 40;
6259
+ function computeDimensions(spec, options, legendLayout, theme, strategy) {
6133
6260
  const { width, height } = options;
6134
- const padding = theme.spacing.padding;
6261
+ const padding = scalePadding(theme.spacing.padding, width, height);
6135
6262
  const axisMargin = theme.spacing.axisMargin;
6136
- const chrome = computeChrome2(chromeToInput(spec.chrome), theme, width, options.measureText);
6263
+ const chromeMode = strategy?.chromeMode ?? "full";
6264
+ const chrome = computeChrome2(
6265
+ chromeToInput(spec.chrome),
6266
+ theme,
6267
+ width,
6268
+ options.measureText,
6269
+ chromeMode,
6270
+ padding
6271
+ );
6137
6272
  const total = { x: 0, y: 0, width, height };
6138
6273
  const isRadial = spec.type === "pie" || spec.type === "donut";
6139
6274
  const encoding = spec.encoding;
@@ -6150,7 +6285,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
6150
6285
  if (xField) {
6151
6286
  for (const row of spec.data) {
6152
6287
  const label = String(row[xField] ?? "");
6153
- const w = estimateTextWidth7(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
6288
+ const w = estimateTextWidth8(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
6154
6289
  if (w > maxLabelWidth) maxLabelWidth = w;
6155
6290
  }
6156
6291
  }
@@ -6176,7 +6311,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
6176
6311
  const label = String(row[colorField] ?? "");
6177
6312
  if (!seen.has(label)) {
6178
6313
  seen.add(label);
6179
- const w = estimateTextWidth7(label, 11, 600);
6314
+ const w = estimateTextWidth8(label, 11, 600);
6180
6315
  if (w > maxLabelWidth) maxLabelWidth = w;
6181
6316
  }
6182
6317
  }
@@ -6191,7 +6326,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
6191
6326
  let maxLabelWidth = 0;
6192
6327
  for (const row of spec.data) {
6193
6328
  const label = String(row[yField] ?? "");
6194
- const w = estimateTextWidth7(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
6329
+ const w = estimateTextWidth8(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
6195
6330
  if (w > maxLabelWidth) maxLabelWidth = w;
6196
6331
  }
6197
6332
  if (maxLabelWidth > 0) {
@@ -6213,7 +6348,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
6213
6348
  else sampleLabel = "0.0";
6214
6349
  const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? "-" : "";
6215
6350
  const labelEst = negPrefix + sampleLabel;
6216
- const labelWidth = estimateTextWidth7(
6351
+ const labelWidth = estimateTextWidth8(
6217
6352
  labelEst,
6218
6353
  theme.fonts.sizes.axisTick,
6219
6354
  theme.fonts.weights.normal
@@ -6234,12 +6369,38 @@ function computeDimensions(spec, options, legendLayout, theme) {
6234
6369
  margins.bottom += legendLayout.bounds.height + 4;
6235
6370
  }
6236
6371
  }
6237
- const chartArea = {
6372
+ let chartArea = {
6238
6373
  x: margins.left,
6239
6374
  y: margins.top,
6240
6375
  width: Math.max(0, width - margins.left - margins.right),
6241
6376
  height: Math.max(0, height - margins.top - margins.bottom)
6242
6377
  };
6378
+ if ((chartArea.width < MIN_CHART_WIDTH || chartArea.height < MIN_CHART_HEIGHT) && chromeMode !== "hidden") {
6379
+ const fallbackMode = chromeMode === "full" ? "compact" : "hidden";
6380
+ const fallbackChrome = computeChrome2(
6381
+ chromeToInput(spec.chrome),
6382
+ theme,
6383
+ width,
6384
+ options.measureText,
6385
+ fallbackMode,
6386
+ padding
6387
+ );
6388
+ const newTop = padding + fallbackChrome.topHeight + axisMargin;
6389
+ const topDelta = margins.top - newTop;
6390
+ const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
6391
+ const bottomDelta = margins.bottom - newBottom;
6392
+ if (topDelta > 0 || bottomDelta > 0) {
6393
+ margins.top = newTop + (legendLayout.entries.length > 0 && legendLayout.position === "top" ? legendLayout.bounds.height + 4 : 0);
6394
+ margins.bottom = newBottom;
6395
+ chartArea = {
6396
+ x: margins.left,
6397
+ y: margins.top,
6398
+ width: Math.max(0, width - margins.left - margins.right),
6399
+ height: Math.max(0, height - margins.top - margins.bottom)
6400
+ };
6401
+ return { total, chrome: fallbackChrome, chartArea, margins, theme };
6402
+ }
6403
+ }
6243
6404
  return { total, chrome, chartArea, margins, theme };
6244
6405
  }
6245
6406
 
@@ -6482,12 +6643,14 @@ function computeScales(spec, chartArea, data) {
6482
6643
  }
6483
6644
 
6484
6645
  // src/legend/compute.ts
6485
- import { estimateTextWidth as estimateTextWidth8 } from "@opendata-ai/openchart-core";
6646
+ import { BRAND_RESERVE_WIDTH, estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
6486
6647
  var SWATCH_SIZE2 = 12;
6487
6648
  var SWATCH_GAP2 = 6;
6488
6649
  var ENTRY_GAP2 = 16;
6489
6650
  var LEGEND_PADDING = 8;
6490
6651
  var LEGEND_RIGHT_WIDTH = 120;
6652
+ var RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
6653
+ var TOP_LEGEND_MAX_ROWS = 2;
6491
6654
  function swatchShapeForType(chartType) {
6492
6655
  switch (chartType) {
6493
6656
  case "line":
@@ -6513,8 +6676,41 @@ function extractColorEntries(spec, theme) {
6513
6676
  active: true
6514
6677
  }));
6515
6678
  }
6679
+ function entriesThatFit(entries, maxWidth, maxRows, labelStyle) {
6680
+ let row = 1;
6681
+ let rowWidth = 0;
6682
+ for (let i = 0; i < entries.length; i++) {
6683
+ const labelWidth = estimateTextWidth9(
6684
+ entries[i].label,
6685
+ labelStyle.fontSize,
6686
+ labelStyle.fontWeight
6687
+ );
6688
+ const entryWidth = SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
6689
+ if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
6690
+ row++;
6691
+ rowWidth = entryWidth;
6692
+ if (row > maxRows) return i;
6693
+ } else {
6694
+ rowWidth += entryWidth;
6695
+ }
6696
+ }
6697
+ return entries.length;
6698
+ }
6699
+ function truncateEntries(entries, maxCount) {
6700
+ if (maxCount >= entries.length || maxCount <= 0) return entries;
6701
+ const truncated = entries.slice(0, maxCount);
6702
+ const remaining = entries.length - maxCount;
6703
+ truncated.push({
6704
+ label: `+${remaining} more`,
6705
+ color: "#999999",
6706
+ shape: "square",
6707
+ active: false,
6708
+ overflow: true
6709
+ });
6710
+ return truncated;
6711
+ }
6516
6712
  function computeLegend(spec, strategy, theme, chartArea) {
6517
- if (spec.legend?.show === false) {
6713
+ if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
6518
6714
  return {
6519
6715
  position: "top",
6520
6716
  entries: [],
@@ -6531,7 +6727,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
6531
6727
  entryGap: ENTRY_GAP2
6532
6728
  };
6533
6729
  }
6534
- const entries = extractColorEntries(spec, theme);
6730
+ let entries = extractColorEntries(spec, theme);
6535
6731
  const labelStyle = {
6536
6732
  fontFamily: theme.fonts.family,
6537
6733
  fontSize: theme.fonts.sizes.small,
@@ -6553,13 +6749,22 @@ function computeLegend(spec, strategy, theme, chartArea) {
6553
6749
  }
6554
6750
  if (resolvedPosition === "right" || resolvedPosition === "bottom-right") {
6555
6751
  const maxLabelWidth = Math.max(
6556
- ...entries.map((e) => estimateTextWidth8(e.label, labelStyle.fontSize, labelStyle.fontWeight))
6752
+ ...entries.map((e) => estimateTextWidth9(e.label, labelStyle.fontSize, labelStyle.fontWeight))
6557
6753
  );
6558
6754
  const legendWidth = Math.min(
6559
6755
  LEGEND_RIGHT_WIDTH,
6560
6756
  SWATCH_SIZE2 + SWATCH_GAP2 + maxLabelWidth + LEGEND_PADDING * 2
6561
6757
  );
6562
6758
  const entryHeight = Math.max(SWATCH_SIZE2, labelStyle.fontSize * labelStyle.lineHeight);
6759
+ const maxHeightRatio = strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
6760
+ const maxLegendHeight = chartArea.height * maxHeightRatio;
6761
+ const maxEntries = Math.max(
6762
+ 1,
6763
+ Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4))
6764
+ );
6765
+ if (entries.length > maxEntries) {
6766
+ entries = truncateEntries(entries, maxEntries);
6767
+ }
6563
6768
  const legendHeight2 = entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
6564
6769
  const clampedHeight = Math.min(legendHeight2, chartArea.height);
6565
6770
  const legendY = resolvedPosition === "bottom-right" ? chartArea.y + chartArea.height - clampedHeight : chartArea.y;
@@ -6580,11 +6785,29 @@ function computeLegend(spec, strategy, theme, chartArea) {
6580
6785
  entryGap: 4
6581
6786
  };
6582
6787
  }
6788
+ const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
6789
+ const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
6790
+ if (maxFit < entries.length) {
6791
+ entries = truncateEntries(entries, maxFit);
6792
+ }
6583
6793
  const totalWidth = entries.reduce((sum, entry) => {
6584
- const labelWidth = estimateTextWidth8(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
6794
+ const labelWidth = estimateTextWidth9(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
6585
6795
  return sum + SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
6586
6796
  }, 0);
6587
- const legendHeight = SWATCH_SIZE2 + LEGEND_PADDING * 2;
6797
+ let rowCount = 1;
6798
+ let rowWidth = 0;
6799
+ for (const entry of entries) {
6800
+ const labelWidth = estimateTextWidth9(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
6801
+ const entryWidth = SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
6802
+ if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
6803
+ rowCount++;
6804
+ rowWidth = entryWidth;
6805
+ } else {
6806
+ rowWidth += entryWidth;
6807
+ }
6808
+ }
6809
+ const rowHeight = SWATCH_SIZE2 + 4;
6810
+ const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
6588
6811
  const offsetDx = spec.legend?.offset?.dx ?? 0;
6589
6812
  const offsetDy = spec.legend?.offset?.dy ?? 0;
6590
6813
  return {
@@ -6593,7 +6816,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
6593
6816
  bounds: {
6594
6817
  x: chartArea.x + offsetDx,
6595
6818
  y: (resolvedPosition === "bottom" ? chartArea.y + chartArea.height - legendHeight : chartArea.y) + offsetDy,
6596
- width: Math.min(totalWidth, chartArea.width),
6819
+ width: Math.min(totalWidth, availableWidth),
6597
6820
  height: legendHeight
6598
6821
  },
6599
6822
  labelStyle,
@@ -6604,7 +6827,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
6604
6827
  }
6605
6828
 
6606
6829
  // src/tables/compile-table.ts
6607
- import { computeChrome as computeChrome3, estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
6830
+ import { computeChrome as computeChrome3, estimateTextWidth as estimateTextWidth10 } from "@opendata-ai/openchart-core";
6608
6831
 
6609
6832
  // src/tables/bar-column.ts
6610
6833
  var NEGATIVE_BAR_COLOR = "#c44e52";
@@ -7011,13 +7234,13 @@ function estimateColumnWidth(col, data, fontSize) {
7011
7234
  if (col.image) return (col.image.width ?? 24) + PADDING;
7012
7235
  if (col.flag) return 60;
7013
7236
  const label = col.label ?? col.key;
7014
- const headerWidth = estimateTextWidth9(label, fontSize, 600) + PADDING;
7237
+ const headerWidth = estimateTextWidth10(label, fontSize, 600) + PADDING;
7015
7238
  const sampleSize = Math.min(100, data.length);
7016
7239
  let maxDataWidth = 0;
7017
7240
  for (let i = 0; i < sampleSize; i++) {
7018
7241
  const val = data[i][col.key];
7019
7242
  const text = val == null ? "" : String(val);
7020
- const width = estimateTextWidth9(text, fontSize, 400) + PADDING;
7243
+ const width = estimateTextWidth10(text, fontSize, 400) + PADDING;
7021
7244
  if (width > maxDataWidth) maxDataWidth = width;
7022
7245
  }
7023
7246
  return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
@@ -7374,8 +7597,28 @@ var builtinRenderers = {
7374
7597
  for (const [type, renderer] of Object.entries(builtinRenderers)) {
7375
7598
  registerChartRenderer(type, renderer);
7376
7599
  }
7377
- function computeRowObstacles(marks, scales) {
7378
- if (!scales.y || scales.y.type !== "band") return [];
7600
+ function computeMarkObstacles(marks, scales) {
7601
+ if (scales.y?.type === "band") {
7602
+ return computeBandRowObstacles(marks, scales);
7603
+ }
7604
+ const obstacles = [];
7605
+ for (const mark of marks) {
7606
+ if (mark.type === "rect") {
7607
+ const rm = mark;
7608
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
7609
+ } else if (mark.type === "point") {
7610
+ const pm = mark;
7611
+ obstacles.push({
7612
+ x: pm.cx - pm.r,
7613
+ y: pm.cy - pm.r,
7614
+ width: pm.r * 2,
7615
+ height: pm.r * 2
7616
+ });
7617
+ }
7618
+ }
7619
+ return obstacles;
7620
+ }
7621
+ function computeBandRowObstacles(marks, scales) {
7379
7622
  const rows = /* @__PURE__ */ new Map();
7380
7623
  for (const mark of marks) {
7381
7624
  let cy;
@@ -7427,7 +7670,8 @@ function compileChart(spec, options) {
7427
7670
  }
7428
7671
  let chartSpec = normalized;
7429
7672
  const breakpoint = getBreakpoint(options.width);
7430
- const strategy = getLayoutStrategy(breakpoint);
7673
+ const heightClass = getHeightClass(options.height);
7674
+ const strategy = getLayoutStrategy(breakpoint, heightClass);
7431
7675
  const rawSpec = spec;
7432
7676
  const overrides = rawSpec.overrides;
7433
7677
  if (overrides?.[breakpoint]) {
@@ -7478,7 +7722,7 @@ function compileChart(spec, options) {
7478
7722
  height: options.height
7479
7723
  };
7480
7724
  const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
7481
- const dims = computeDimensions(chartSpec, options, legendLayout, theme);
7725
+ const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
7482
7726
  const chartArea = dims.chartArea;
7483
7727
  const legendArea = { ...chartArea };
7484
7728
  if (legendLayout.entries.length > 0) {
@@ -7533,7 +7777,7 @@ function compileChart(spec, options) {
7533
7777
  }
7534
7778
  scales.defaultColor = theme.colors.categorical[0];
7535
7779
  const isRadial = chartSpec.type === "pie" || chartSpec.type === "donut";
7536
- const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme);
7780
+ const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme, options.measureText);
7537
7781
  if (!isRadial) {
7538
7782
  computeGridlines(axes, chartArea);
7539
7783
  }
@@ -7543,7 +7787,18 @@ function compileChart(spec, options) {
7543
7787
  if (finalLegend.bounds.width > 0) {
7544
7788
  obstacles.push(finalLegend.bounds);
7545
7789
  }
7546
- obstacles.push(...computeRowObstacles(marks, scales));
7790
+ obstacles.push(...computeMarkObstacles(marks, scales));
7791
+ for (const mark of marks) {
7792
+ if (mark.type !== "area" && mark.label?.visible) {
7793
+ obstacles.push(computeLabelBounds(mark.label));
7794
+ }
7795
+ }
7796
+ const brandPadding = theme.spacing.padding;
7797
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH2;
7798
+ const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
7799
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
7800
+ const brandY = firstBottomChrome ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
7801
+ obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH2, height: 30 });
7547
7802
  const annotations = computeAnnotations(
7548
7803
  chartSpec,
7549
7804
  scales,