@opendata-ai/openchart-engine 2.10.0 → 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,6 +1,8 @@
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,
@@ -10,7 +12,7 @@ import {
10
12
  } from "@opendata-ai/openchart-core";
11
13
 
12
14
  // src/annotations/compute.ts
13
- import { estimateTextWidth } from "@opendata-ai/openchart-core";
15
+ import { detectCollision, estimateTextWidth } from "@opendata-ai/openchart-core";
14
16
  var DEFAULT_ANNOTATION_FONT_SIZE = 12;
15
17
  var DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
16
18
  var DEFAULT_LINE_HEIGHT = 1.3;
@@ -325,41 +327,34 @@ function estimateLabelBounds(label) {
325
327
  const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
326
328
  return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
327
329
  }
328
- function rectsOverlap(a, b) {
329
- 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;
330
- }
331
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
+ }
332
346
  function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, chartArea, obstacles) {
333
347
  if (annotation.type !== "text" || !annotation.label) return false;
334
348
  const labelBounds = estimateLabelBounds(annotation.label);
335
349
  const collidingObs = obstacles.filter(
336
- (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(labelBounds, obs)
350
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs)
337
351
  );
338
352
  if (collidingObs.length === 0) return false;
339
353
  const px = resolvePosition(originalAnnotation.x, scales.x);
340
354
  const py = resolvePosition(originalAnnotation.y, scales.y);
341
355
  if (px === null || py === null) return false;
342
- const candidates = [];
356
+ const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
343
357
  const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split("\n").length);
344
- for (const obs of collidingObs) {
345
- const currentLabelTop = labelBounds.y;
346
- const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
347
- const belowDy = targetLabelTop - currentLabelTop;
348
- candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
349
- const currentLabelBottom = labelBounds.y + labelBounds.height;
350
- const targetLabelBottom = obs.y - NUDGE_PADDING;
351
- const aboveDy = targetLabelBottom - currentLabelBottom;
352
- candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
353
- const currentLabelRight = labelBounds.x + labelBounds.width;
354
- const targetLabelRight = obs.x - NUDGE_PADDING;
355
- const leftDx = targetLabelRight - currentLabelRight;
356
- candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
357
- const currentLabelLeft = labelBounds.x;
358
- const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
359
- const rightDx = targetLabelLeft - currentLabelLeft;
360
- candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
361
- }
362
- candidates.sort((a, b) => a.distance - b.distance);
363
358
  for (const { dx, dy } of candidates) {
364
359
  const newLabelX = annotation.label.x + dx;
365
360
  const newLabelY = annotation.label.y + dy;
@@ -388,12 +383,12 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
388
383
  };
389
384
  const candidateBounds = estimateLabelBounds(candidateLabel);
390
385
  const stillCollides = obstacles.some(
391
- (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(candidateBounds, obs)
386
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs)
392
387
  );
393
388
  if (stillCollides) continue;
394
389
  const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
395
390
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
396
- 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;
397
392
  if (inBounds) {
398
393
  if (candidateLabel.connector && dx === 0 && dy !== 0) {
399
394
  candidateLabel.connector = {
@@ -407,6 +402,74 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
407
402
  }
408
403
  return false;
409
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
+ }
410
473
  function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = []) {
411
474
  if (strategy.annotationPosition === "tooltip-only") {
412
475
  return [];
@@ -432,6 +495,7 @@ function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, o
432
495
  annotations.push(resolved);
433
496
  }
434
497
  }
498
+ resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
435
499
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
436
500
  return annotations;
437
501
  }
@@ -5993,6 +6057,8 @@ var HEIGHT_MINIMAL_THRESHOLD = 120;
5993
6057
  var HEIGHT_REDUCED_THRESHOLD = 200;
5994
6058
  var WIDTH_MINIMAL_THRESHOLD = 150;
5995
6059
  var WIDTH_REDUCED_THRESHOLD = 300;
6060
+ var MIN_TICK_GAP_FACTOR = 1;
6061
+ var MIN_TICK_COUNT = 2;
5996
6062
  var DENSITY_ORDER = ["full", "reduced", "minimal"];
5997
6063
  function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
5998
6064
  let density = baseDensity;
@@ -6005,17 +6071,49 @@ function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThre
6005
6071
  }
6006
6072
  return density;
6007
6073
  }
6008
- 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) {
6009
6104
  const scale = resolvedScale.scale;
6010
- const count = resolvedScale.channel.axis?.tickCount ?? TICK_COUNTS[density];
6011
- const ticks2 = scale.ticks(count);
6012
- 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) => ({
6013
6109
  value,
6014
6110
  position: scale(value),
6015
6111
  label: formatTickLabel(value, resolvedScale)
6016
6112
  }));
6113
+ if (explicitCount) return ticks2;
6114
+ return thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText);
6017
6115
  }
6018
- function categoricalTicks(resolvedScale, density) {
6116
+ function categoricalTicks(resolvedScale, density, fontSize, fontWeight, measureText) {
6019
6117
  const scale = resolvedScale.scale;
6020
6118
  const domain = scale.domain();
6021
6119
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
@@ -6025,7 +6123,7 @@ function categoricalTicks(resolvedScale, density) {
6025
6123
  const step = Math.ceil(domain.length / maxTicks);
6026
6124
  selectedValues = domain.filter((_, i) => i % step === 0);
6027
6125
  }
6028
- return selectedValues.map((value) => {
6126
+ const ticks2 = selectedValues.map((value) => {
6029
6127
  const bandScale = resolvedScale.type === "band" ? scale : null;
6030
6128
  const pos = bandScale ? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2 : scale(value) ?? 0;
6031
6129
  return {
@@ -6034,6 +6132,10 @@ function categoricalTicks(resolvedScale, density) {
6034
6132
  label: value
6035
6133
  };
6036
6134
  });
6135
+ if (resolvedScale.type !== "band" && !explicitTickCount) {
6136
+ return thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText);
6137
+ }
6138
+ return ticks2;
6037
6139
  }
6038
6140
  function formatTickLabel(value, resolvedScale) {
6039
6141
  const formatStr = resolvedScale.channel.axis?.format;
@@ -6052,7 +6154,7 @@ function formatTickLabel(value, resolvedScale) {
6052
6154
  }
6053
6155
  return String(value);
6054
6156
  }
6055
- function computeAxes(scales, chartArea, strategy, theme) {
6157
+ function computeAxes(scales, chartArea, strategy, theme, measureText) {
6056
6158
  const result = {};
6057
6159
  const baseDensity = strategy.axisLabelDensity;
6058
6160
  const yDensity = effectiveDensity(
@@ -6082,8 +6184,10 @@ function computeAxes(scales, chartArea, strategy, theme) {
6082
6184
  fill: theme.colors.text,
6083
6185
  lineHeight: 1.3
6084
6186
  };
6187
+ const { fontSize } = tickLabelStyle;
6188
+ const { fontWeight } = tickLabelStyle;
6085
6189
  if (scales.x) {
6086
- 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);
6087
6191
  const gridlines = ticks2.map((t) => ({
6088
6192
  position: t.position,
6089
6193
  major: true
@@ -6093,11 +6197,7 @@ function computeAxes(scales, chartArea, strategy, theme) {
6093
6197
  const bandwidth = scales.x.scale.bandwidth();
6094
6198
  let maxLabelWidth = 0;
6095
6199
  for (const t of ticks2) {
6096
- const w = estimateTextWidth7(
6097
- t.label,
6098
- theme.fonts.sizes.axisTick,
6099
- theme.fonts.weights.normal
6100
- );
6200
+ const w = measureLabel(t.label, fontSize, fontWeight, measureText);
6101
6201
  if (w > maxLabelWidth) maxLabelWidth = w;
6102
6202
  }
6103
6203
  if (maxLabelWidth > bandwidth * 0.85) {
@@ -6116,7 +6216,7 @@ function computeAxes(scales, chartArea, strategy, theme) {
6116
6216
  };
6117
6217
  }
6118
6218
  if (scales.y) {
6119
- 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);
6120
6220
  const gridlines = ticks2.map((t) => ({
6121
6221
  position: t.position,
6122
6222
  major: true
@@ -6543,7 +6643,7 @@ function computeScales(spec, chartArea, data) {
6543
6643
  }
6544
6644
 
6545
6645
  // src/legend/compute.ts
6546
- import { estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
6646
+ import { BRAND_RESERVE_WIDTH, estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
6547
6647
  var SWATCH_SIZE2 = 12;
6548
6648
  var SWATCH_GAP2 = 6;
6549
6649
  var ENTRY_GAP2 = 16;
@@ -6685,7 +6785,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
6685
6785
  entryGap: 4
6686
6786
  };
6687
6787
  }
6688
- const availableWidth = chartArea.width - LEGEND_PADDING * 2;
6788
+ const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
6689
6789
  const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
6690
6790
  if (maxFit < entries.length) {
6691
6791
  entries = truncateEntries(entries, maxFit);
@@ -6716,7 +6816,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
6716
6816
  bounds: {
6717
6817
  x: chartArea.x + offsetDx,
6718
6818
  y: (resolvedPosition === "bottom" ? chartArea.y + chartArea.height - legendHeight : chartArea.y) + offsetDy,
6719
- width: Math.min(totalWidth, chartArea.width),
6819
+ width: Math.min(totalWidth, availableWidth),
6720
6820
  height: legendHeight
6721
6821
  },
6722
6822
  labelStyle,
@@ -7497,8 +7597,28 @@ var builtinRenderers = {
7497
7597
  for (const [type, renderer] of Object.entries(builtinRenderers)) {
7498
7598
  registerChartRenderer(type, renderer);
7499
7599
  }
7500
- function computeRowObstacles(marks, scales) {
7501
- 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) {
7502
7622
  const rows = /* @__PURE__ */ new Map();
7503
7623
  for (const mark of marks) {
7504
7624
  let cy;
@@ -7657,7 +7777,7 @@ function compileChart(spec, options) {
7657
7777
  }
7658
7778
  scales.defaultColor = theme.colors.categorical[0];
7659
7779
  const isRadial = chartSpec.type === "pie" || chartSpec.type === "donut";
7660
- 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);
7661
7781
  if (!isRadial) {
7662
7782
  computeGridlines(axes, chartArea);
7663
7783
  }
@@ -7667,7 +7787,18 @@ function compileChart(spec, options) {
7667
7787
  if (finalLegend.bounds.width > 0) {
7668
7788
  obstacles.push(finalLegend.bounds);
7669
7789
  }
7670
- 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 });
7671
7802
  const annotations = computeAnnotations(
7672
7803
  chartSpec,
7673
7804
  scales,