@opendata-ai/openchart-engine 7.9.2 → 7.10.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
@@ -10506,12 +10506,41 @@ import {
10506
10506
  } from "@opendata-ai/openchart-core";
10507
10507
 
10508
10508
  // src/layout/axes/rotation.ts
10509
+ import { TICK_LINE_HEIGHT_FACTOR, X_AXIS_ROTATED_EXTENT_CAP } from "@opendata-ai/openchart-core";
10509
10510
  var BAND_FIT_FRACTION = 0.85;
10510
10511
  var AUTO_ROTATE_ANGLE = -45;
10511
- function resolveBandTickAngle(explicitAngle, maxLabelWidth, bandwidth, labelCount) {
10512
+ var STEEP_ROTATE_ANGLE = -90;
10513
+ var LABEL_GAP = 4;
10514
+ function ribbonSeparation(spacing, angleDeg) {
10515
+ return spacing * Math.abs(Math.sin(angleDeg * Math.PI / 180));
10516
+ }
10517
+ function alongTextClearance(spacing, angleDeg) {
10518
+ return spacing * Math.abs(Math.cos(angleDeg * Math.PI / 180));
10519
+ }
10520
+ function rotatedLabelsFit(spacing, angleDeg, fontSize, maxLabelWidth) {
10521
+ return ribbonSeparation(spacing, angleDeg) >= fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP || alongTextClearance(spacing, angleDeg) >= maxLabelWidth + LABEL_GAP;
10522
+ }
10523
+ function resolveBandTickAngle(explicitAngle, maxLabelWidth, bandwidth, labelCount, spacing, fontSize) {
10512
10524
  if (explicitAngle !== void 0) return explicitAngle;
10513
10525
  if (labelCount < 2) return void 0;
10514
- return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : void 0;
10526
+ if (maxLabelWidth <= bandwidth * BAND_FIT_FRACTION) return void 0;
10527
+ if (rotatedLabelsFit(spacing, AUTO_ROTATE_ANGLE, fontSize, maxLabelWidth)) {
10528
+ return AUTO_ROTATE_ANGLE;
10529
+ }
10530
+ return maxLabelWidth <= X_AXIS_ROTATED_EXTENT_CAP ? STEEP_ROTATE_ANGLE : AUTO_ROTATE_ANGLE;
10531
+ }
10532
+ function bandLabelStride(maxLabelWidth, angleDeg, fontSize, spacing) {
10533
+ if (spacing <= 0) return 1;
10534
+ if (angleDeg === void 0 || angleDeg === 0) {
10535
+ return Math.max(1, Math.ceil((maxLabelWidth + LABEL_GAP) / spacing));
10536
+ }
10537
+ const separation = ribbonSeparation(spacing, angleDeg);
10538
+ const clearance = alongTextClearance(spacing, angleDeg);
10539
+ const required = fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP;
10540
+ const byRibbon = separation > 0 ? Math.ceil(required / separation) : Number.POSITIVE_INFINITY;
10541
+ const byLength = clearance > 0 ? Math.ceil((maxLabelWidth + LABEL_GAP) / clearance) : Number.POSITIVE_INFINITY;
10542
+ const stride = Math.min(byRibbon, byLength);
10543
+ return Number.isFinite(stride) ? Math.max(1, stride) : 1;
10515
10544
  }
10516
10545
 
10517
10546
  // src/layout/axes/thinning.ts
@@ -10608,7 +10637,7 @@ var NUMERIC_SCALE_TYPES = /* @__PURE__ */ new Set([
10608
10637
  "threshold"
10609
10638
  ]);
10610
10639
  var TEMPORAL_SCALE_TYPES = /* @__PURE__ */ new Set(["time", "utc"]);
10611
- function formatTickLabel(value2, resolvedScale) {
10640
+ function formatTickLabel(value2, resolvedScale, compact = false) {
10612
10641
  const axisConfig = resolvedScale.channel.axis || void 0;
10613
10642
  const formatStr = axisConfig?.format;
10614
10643
  const suffix = axisConfig?.labelSuffix ?? "";
@@ -10616,7 +10645,7 @@ function formatTickLabel(value2, resolvedScale) {
10616
10645
  const temporalFmt = buildTemporalFormatter(formatStr);
10617
10646
  if (temporalFmt) return temporalFmt(value2) + suffix;
10618
10647
  const useUtc = resolvedScale.type === "utc";
10619
- return formatDate(value2, void 0, void 0, useUtc) + suffix;
10648
+ return formatDate(value2, void 0, void 0, useUtc, compact) + suffix;
10620
10649
  }
10621
10650
  if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
10622
10651
  const num = value2;
@@ -10643,7 +10672,7 @@ function continuousTicks(resolvedScale, density, targetCount) {
10643
10672
  const count = axCfg?.tickCount ?? targetCount ?? TICK_COUNTS[density];
10644
10673
  return buildContinuousTicks(resolvedScale, count);
10645
10674
  }
10646
- function buildContinuousTicks(resolvedScale, count) {
10675
+ function buildContinuousTicks(resolvedScale, count, compact = false) {
10647
10676
  const scale = resolvedScale.scale;
10648
10677
  if (!("ticks" in scale) || typeof scale.ticks !== "function") {
10649
10678
  return continuousTicks(resolvedScale, "full");
@@ -10666,7 +10695,7 @@ function buildContinuousTicks(resolvedScale, count) {
10666
10695
  return ticks2.map((value2) => ({
10667
10696
  value: value2,
10668
10697
  position: scale(value2),
10669
- label: formatTickLabel(value2, resolvedScale)
10698
+ label: formatTickLabel(value2, resolvedScale, compact)
10670
10699
  }));
10671
10700
  }
10672
10701
  function scaleSupportsTickCount(resolvedScale) {
@@ -10781,12 +10810,12 @@ function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThre
10781
10810
  var CONTINUOUS_TICK_FLOOR = 4;
10782
10811
  var OVERSHOOT_TOLERANCE = 1.5;
10783
10812
  var MIN_QUANTITATIVE_TICKS = 2;
10784
- function ensureMinContinuousTicks(scale, ticks2, requestedCount) {
10813
+ function ensureMinContinuousTicks(scale, ticks2, requestedCount, compact = false) {
10785
10814
  if (ticks2.length >= MIN_QUANTITATIVE_TICKS) return ticks2;
10786
10815
  if (!scale || !scaleSupportsTickCount(scale)) return ticks2;
10787
10816
  let best = ticks2;
10788
10817
  for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
10789
- const candidate = buildContinuousTicks(scale, n);
10818
+ const candidate = buildContinuousTicks(scale, n, compact);
10790
10819
  if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
10791
10820
  if (candidate.length > best.length) best = candidate;
10792
10821
  }
@@ -10799,66 +10828,36 @@ function fitContinuousTicks(scale, initialTicks, initialCount, fontSize, fontWei
10799
10828
  const overshoots = flooredTicks.length > tolerance;
10800
10829
  const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
10801
10830
  if (!overshoots && !overlaps) return flooredTicks;
10831
+ const useCompact = (scale.type === "time" || scale.type === "utc") && orientation === "horizontal";
10832
+ const fitsCleanly = (candidate) => candidate.length <= tolerance && !ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
10833
+ if (useCompact) {
10834
+ const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
10835
+ if (fitsCleanly(compactAtInitial)) return compactAtInitial;
10836
+ }
10802
10837
  const minThreshold = orientation === "vertical" ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
10803
10838
  const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
10804
10839
  let bestWithinFloor;
10840
+ let bestWithinFloorCount;
10805
10841
  for (let n = initialCount - 1; n >= 2; n--) {
10806
10842
  const candidate = buildContinuousTicks(scale, n);
10807
- const candidateOvershoots = candidate.length > tolerance;
10808
- const candidateOverlaps = ticksOverlap(
10809
- candidate,
10810
- fontSize,
10811
- fontWeight,
10812
- measureText,
10813
- orientation
10814
- );
10815
- if (!candidateOvershoots && !candidateOverlaps) {
10816
- return candidate;
10843
+ if (fitsCleanly(candidate)) return candidate;
10844
+ if (useCompact) {
10845
+ const compact = buildContinuousTicks(scale, n, true);
10846
+ if (fitsCleanly(compact)) return compact;
10847
+ }
10848
+ if (candidate.length >= floor) {
10849
+ bestWithinFloor = candidate;
10850
+ bestWithinFloorCount = n;
10817
10851
  }
10818
- if (candidate.length >= floor) bestWithinFloor = candidate;
10819
10852
  }
10820
- const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
10821
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
10853
+ const rawFallback = useCompact ? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true) : bestWithinFloor ?? buildContinuousTicks(scale, floor);
10854
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
10822
10855
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
10823
10856
  }
10824
- function labelSpanAtAngle(tick, angleDeg, fontSize, fontWeight, measureText) {
10825
- const angleRad = Math.abs(angleDeg) * Math.PI / 180;
10826
- const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
10827
- const projected = measureLabel(tick.label, fontSize, fontWeight, measureText) * cosA;
10828
- if (angleDeg === 0) {
10829
- return { left: tick.position - projected / 2, right: tick.position + projected / 2 };
10830
- }
10831
- if (angleDeg < 0) {
10832
- return { left: tick.position - projected, right: tick.position };
10833
- }
10834
- return { left: tick.position, right: tick.position + projected };
10835
- }
10836
- function thinBandTicksIfNeeded(ticks2, angleDeg, fontSize, fontWeight, measureText) {
10837
- if (ticks2.length < 3) return ticks2;
10838
- const minGap = fontSize * 0.5;
10839
- const spans = ticks2.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
10840
- const keep = new Array(ticks2.length).fill(false);
10841
- keep[0] = true;
10842
- let lastRight = spans[0].right;
10843
- for (let i = 1; i < ticks2.length; i++) {
10844
- if (spans[i].left >= lastRight + minGap) {
10845
- keep[i] = true;
10846
- lastRight = spans[i].right;
10847
- }
10848
- }
10857
+ function applyBandLabelStride(ticks2, stride) {
10858
+ if (stride <= 1 || ticks2.length < 3) return ticks2;
10849
10859
  const lastIdx = ticks2.length - 1;
10850
- if (!keep[lastIdx]) {
10851
- keep[lastIdx] = true;
10852
- for (let i = lastIdx - 1; i > 0; i--) {
10853
- if (!keep[i]) continue;
10854
- if (spans[i].right > spans[lastIdx].left) {
10855
- keep[i] = false;
10856
- continue;
10857
- }
10858
- break;
10859
- }
10860
- }
10861
- return ticks2.filter((_, i) => keep[i]);
10860
+ return ticks2.filter((_, i) => (lastIdx - i) % stride === 0);
10862
10861
  }
10863
10862
  function computeAxes(scales, chartArea, strategy, theme, measureText, dataContext) {
10864
10863
  const result = {};
@@ -10909,22 +10908,37 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
10909
10908
  major: true
10910
10909
  }));
10911
10910
  let tickAngle = axisConfig?.labelAngle;
10912
- if (tickAngle === void 0 && scales.x.type === "band" && allTicks.length > 1) {
10913
- const bandwidth = scales.x.scale.bandwidth();
10911
+ let bandStride = 1;
10912
+ if (scales.x.type === "band" && allTicks.length > 1) {
10913
+ const bandScale = scales.x.scale;
10914
+ const bandwidth = bandScale.bandwidth();
10915
+ let anchorSpacing = Number.POSITIVE_INFINITY;
10916
+ for (let i = 1; i < allTicks.length; i++) {
10917
+ const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
10918
+ if (d > 0 && d < anchorSpacing) anchorSpacing = d;
10919
+ }
10920
+ if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
10914
10921
  let maxLabelWidth = 0;
10915
10922
  for (const t of allTicks) {
10916
10923
  const w = measureLabel(t.label, fontSize, fontWeight, measureText);
10917
10924
  if (w > maxLabelWidth) maxLabelWidth = w;
10918
10925
  }
10919
- tickAngle = resolveBandTickAngle(void 0, maxLabelWidth, bandwidth, allTicks.length);
10926
+ tickAngle = resolveBandTickAngle(
10927
+ axisConfig?.labelAngle,
10928
+ maxLabelWidth,
10929
+ bandwidth,
10930
+ allTicks.length,
10931
+ anchorSpacing,
10932
+ fontSize
10933
+ );
10934
+ bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
10920
10935
  }
10921
10936
  const hasExplicitValues = !!axisConfig?.values;
10922
10937
  let ticks2;
10923
10938
  if (hasExplicitValues) {
10924
10939
  ticks2 = allTicks;
10925
10940
  } else if (scales.x.type === "band") {
10926
- const effectiveAngle = tickAngle ?? 0;
10927
- ticks2 = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
10941
+ ticks2 = applyBandLabelStride(allTicks, bandStride);
10928
10942
  } else if (isContinuousX) {
10929
10943
  ticks2 = fitContinuousTicks(
10930
10944
  scales.x,
@@ -11287,15 +11301,23 @@ function evenRange(start, end, count) {
11287
11301
  const step = (end - start) / (count - 1);
11288
11302
  return Array.from({ length: count }, (_, i) => start + step * i);
11289
11303
  }
11304
+ function resolveBandPadding(scaleConfig) {
11305
+ const padding = scaleConfig?.padding ?? 0.35;
11306
+ return {
11307
+ paddingInner: scaleConfig?.paddingInner ?? padding,
11308
+ paddingOuter: scaleConfig?.paddingOuter ?? padding
11309
+ };
11310
+ }
11290
11311
  function estimateBandwidth(scaleConfig, plotWidth, n) {
11312
+ const { paddingInner } = resolveBandPadding(scaleConfig);
11313
+ return estimateBandStep(scaleConfig, plotWidth, n) * (1 - paddingInner);
11314
+ }
11315
+ function estimateBandStep(scaleConfig, plotWidth, n) {
11291
11316
  if (n <= 0) return 0;
11292
- const padding = scaleConfig?.padding ?? 0.35;
11293
- const paddingInner = scaleConfig?.paddingInner ?? padding;
11294
- const paddingOuter = scaleConfig?.paddingOuter ?? padding;
11317
+ const { paddingInner, paddingOuter } = resolveBandPadding(scaleConfig);
11295
11318
  const denom = n - paddingInner + 2 * paddingOuter;
11296
11319
  if (denom <= 0) return 0;
11297
- const step = plotWidth / denom;
11298
- return step * (1 - paddingInner);
11320
+ return plotWidth / denom;
11299
11321
  }
11300
11322
  function buildBandScale(channel, data, rangeStart, rangeEnd) {
11301
11323
  const values = channel.scale?.domain ? channel.scale.domain : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
@@ -11754,8 +11776,17 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11754
11776
  const rightMarginEst = hPad + (isRadial ? hPad : axisMargin);
11755
11777
  const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
11756
11778
  const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
11779
+ const step = estimateBandStep(encoding.x?.scale, plotWidth, xLabels.length);
11780
+ const xTickCount = xAxis?.tickCount;
11781
+ let drawnLabels = xLabels;
11782
+ let anchorSpacing = step;
11783
+ if (xTickCount && xLabels.length > xTickCount) {
11784
+ const capStride = Math.ceil(xLabels.length / xTickCount);
11785
+ drawnLabels = xLabels.filter((_, i) => i % capStride === 0);
11786
+ anchorSpacing = step * capStride;
11787
+ }
11757
11788
  let maxXLabelWidth = 0;
11758
- for (const label of xLabels) {
11789
+ for (const label of drawnLabels) {
11759
11790
  const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
11760
11791
  if (w > maxXLabelWidth) maxXLabelWidth = w;
11761
11792
  }
@@ -11763,7 +11794,9 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11763
11794
  void 0,
11764
11795
  maxXLabelWidth,
11765
11796
  bandwidth,
11766
- xLabels.length
11797
+ drawnLabels.length,
11798
+ anchorSpacing,
11799
+ theme.fonts.sizes.axisTick
11767
11800
  );
11768
11801
  }
11769
11802
  const xAxisExtent = isRadial || xAxisSuppressed ? 0 : computeXAxisExtentFromLabels2({
@@ -13834,7 +13867,7 @@ function generateLinkPath(link) {
13834
13867
  }
13835
13868
 
13836
13869
  // src/sankey/compile-sankey.ts
13837
- var LABEL_GAP = 6;
13870
+ var LABEL_GAP2 = 6;
13838
13871
  var LINK_OPACITY_LIGHT = 0.5;
13839
13872
  var LINK_OPACITY_DARK = 0.75;
13840
13873
  var NODE_CORNER_RADIUS = 2;
@@ -13906,16 +13939,16 @@ function computeNodeLabel(node, maxDepth, theme, nodeWidth, nodeLabelAlign = "au
13906
13939
  let maxWidth;
13907
13940
  if (containerWidth !== void 0) {
13908
13941
  if (placeLeft) {
13909
- maxWidth = x0 - LABEL_GAP - pad2;
13942
+ maxWidth = x0 - LABEL_GAP2 - pad2;
13910
13943
  } else {
13911
- maxWidth = containerWidth - pad2 - (x1 + LABEL_GAP);
13944
+ maxWidth = containerWidth - pad2 - (x1 + LABEL_GAP2);
13912
13945
  }
13913
13946
  if (maxWidth !== void 0 && maxWidth < 0) maxWidth = 0;
13914
13947
  }
13915
13948
  if (placeLeft) {
13916
13949
  return {
13917
13950
  text: node.label ?? node.id,
13918
- x: x0 - LABEL_GAP,
13951
+ x: x0 - LABEL_GAP2,
13919
13952
  y: midY,
13920
13953
  style: { ...style, textAnchor: "end", dominantBaseline: "central" },
13921
13954
  visible: true,
@@ -13924,7 +13957,7 @@ function computeNodeLabel(node, maxDepth, theme, nodeWidth, nodeLabelAlign = "au
13924
13957
  }
13925
13958
  return {
13926
13959
  text: node.label ?? node.id,
13927
- x: x1 + LABEL_GAP,
13960
+ x: x1 + LABEL_GAP2,
13928
13961
  y: midY,
13929
13962
  style: { ...style, textAnchor: "start", dominantBaseline: "central" },
13930
13963
  visible: true,
@@ -14036,7 +14069,7 @@ function compileSankey(spec, options) {
14036
14069
  const depth = node.depth ?? 0;
14037
14070
  const labelsLeft = nodeLabelAlign === "left" || nodeLabelAlign === "auto" && depth === maxDepthFirst;
14038
14071
  if (labelsLeft) continue;
14039
- const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
14072
+ const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP2;
14040
14073
  const labelText = node.label ?? node.id;
14041
14074
  const labelWidth = estimateTextWidth18(labelText, labelFontSize, labelFontWeight);
14042
14075
  const overflow = labelX + labelWidth - rightEdge;