@opendata-ai/openchart-engine 7.9.0 → 7.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  computeLabelBounds,
5
5
  generateAltText,
6
6
  generateDataTable,
7
- getBreakpoint,
7
+ getBreakpoint as getBreakpoint2,
8
8
  getHeightClass,
9
9
  getLayoutStrategy,
10
10
  resolveTheme as resolveTheme6
@@ -6324,19 +6324,19 @@ function copy(source, target) {
6324
6324
  return target.domain(source.domain()).range(source.range()).interpolate(source.interpolate()).clamp(source.clamp()).unknown(source.unknown());
6325
6325
  }
6326
6326
  function transformer() {
6327
- var domain = unit, range2 = unit, interpolate = value_default, transform, untransform, unknown, clamp = identity, piecewise, output, input;
6327
+ var domain = unit, range2 = unit, interpolate = value_default, transform, untransform, unknown, clamp2 = identity, piecewise, output, input;
6328
6328
  function rescale() {
6329
6329
  var n = Math.min(domain.length, range2.length);
6330
- if (clamp !== identity) clamp = clamper(domain[0], domain[n - 1]);
6330
+ if (clamp2 !== identity) clamp2 = clamper(domain[0], domain[n - 1]);
6331
6331
  piecewise = n > 2 ? polymap : bimap;
6332
6332
  output = input = null;
6333
6333
  return scale;
6334
6334
  }
6335
6335
  function scale(x2) {
6336
- return x2 == null || isNaN(x2 = +x2) ? unknown : (output || (output = piecewise(domain.map(transform), range2, interpolate)))(transform(clamp(x2)));
6336
+ return x2 == null || isNaN(x2 = +x2) ? unknown : (output || (output = piecewise(domain.map(transform), range2, interpolate)))(transform(clamp2(x2)));
6337
6337
  }
6338
6338
  scale.invert = function(y2) {
6339
- return clamp(untransform((input || (input = piecewise(range2, domain.map(transform), number_default)))(y2)));
6339
+ return clamp2(untransform((input || (input = piecewise(range2, domain.map(transform), number_default)))(y2)));
6340
6340
  };
6341
6341
  scale.domain = function(_) {
6342
6342
  return arguments.length ? (domain = Array.from(_, number2), rescale()) : domain.slice();
@@ -6348,7 +6348,7 @@ function transformer() {
6348
6348
  return range2 = Array.from(_), interpolate = round_default, rescale();
6349
6349
  };
6350
6350
  scale.clamp = function(_) {
6351
- return arguments.length ? (clamp = _ ? true : identity, rescale()) : clamp !== identity;
6351
+ return arguments.length ? (clamp2 = _ ? true : identity, rescale()) : clamp2 !== identity;
6352
6352
  };
6353
6353
  scale.interpolate = function(_) {
6354
6354
  return arguments.length ? (interpolate = _, rescale()) : interpolate;
@@ -7680,15 +7680,15 @@ function utcTime() {
7680
7680
 
7681
7681
  // ../../node_modules/.bun/d3-scale@4.0.2/node_modules/d3-scale/src/sequential.js
7682
7682
  function transformer2() {
7683
- var x0 = 0, x1 = 1, t02, t12, k10, transform, interpolator = identity, clamp = false, unknown;
7683
+ var x0 = 0, x1 = 1, t02, t12, k10, transform, interpolator = identity, clamp2 = false, unknown;
7684
7684
  function scale(x2) {
7685
- return x2 == null || isNaN(x2 = +x2) ? unknown : interpolator(k10 === 0 ? 0.5 : (x2 = (transform(x2) - t02) * k10, clamp ? Math.max(0, Math.min(1, x2)) : x2));
7685
+ return x2 == null || isNaN(x2 = +x2) ? unknown : interpolator(k10 === 0 ? 0.5 : (x2 = (transform(x2) - t02) * k10, clamp2 ? Math.max(0, Math.min(1, x2)) : x2));
7686
7686
  }
7687
7687
  scale.domain = function(_) {
7688
7688
  return arguments.length ? ([x0, x1] = _, t02 = transform(x0 = +x0), t12 = transform(x1 = +x1), k10 = t02 === t12 ? 0 : 1 / (t12 - t02), scale) : [x0, x1];
7689
7689
  };
7690
7690
  scale.clamp = function(_) {
7691
- return arguments.length ? (clamp = !!_, scale) : clamp;
7691
+ return arguments.length ? (clamp2 = !!_, scale) : clamp2;
7692
7692
  };
7693
7693
  scale.interpolator = function(_) {
7694
7694
  return arguments.length ? (interpolator = _, scale) : interpolator;
@@ -10059,9 +10059,9 @@ import {
10059
10059
  AXIS_TITLE_TRAILING_PAD as AXIS_TITLE_TRAILING_PAD2,
10060
10060
  abbreviateNumber as abbreviateNumber4,
10061
10061
  BREAKPOINT_COMPACT_MAX as BREAKPOINT_COMPACT_MAX2,
10062
- estimateTextWidth as estimateTextWidth13,
10062
+ estimateTextWidth as estimateTextWidth15,
10063
10063
  formatNumber as formatNumber5,
10064
- getAxisTitleOffset,
10064
+ getAxisTitleOffset as getAxisTitleOffset2,
10065
10065
  resolveTheme as resolveTheme2,
10066
10066
  TICK_LABEL_OFFSET
10067
10067
  } from "@opendata-ai/openchart-core";
@@ -10070,11 +10070,11 @@ import {
10070
10070
  import {
10071
10071
  AXIS_TITLE_TRAILING_PAD,
10072
10072
  abbreviateNumber as abbreviateNumber3,
10073
- axisTitleOffset,
10073
+ axisTitleOffset as axisTitleOffset2,
10074
10074
  BREAKPOINT_COMPACT_MAX,
10075
10075
  computeChrome as computeChrome2,
10076
- computeXAxisExtentFromLabels,
10077
- estimateTextWidth as estimateTextWidth12,
10076
+ computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
10077
+ estimateTextWidth as estimateTextWidth14,
10078
10078
  formatNumber as formatNumber4,
10079
10079
  HPAD_COMPACT_FRACTION,
10080
10080
  HPAD_COMPACT_MIN,
@@ -10496,6 +10496,15 @@ function placeLegend(content, chartArea, containerWidth, _theme, xAxisHeight) {
10496
10496
  };
10497
10497
  }
10498
10498
 
10499
+ // src/layout/axes.ts
10500
+ import {
10501
+ axisTitleOffset,
10502
+ computeXAxisExtentFromLabels,
10503
+ estimateTextWidth as estimateTextWidth13,
10504
+ getAxisTitleOffset,
10505
+ X_AXIS_TITLE_BAND_ROTATED
10506
+ } from "@opendata-ai/openchart-core";
10507
+
10499
10508
  // src/layout/axes/rotation.ts
10500
10509
  var BAND_FIT_FRACTION = 0.85;
10501
10510
  var AUTO_ROTATE_ANGLE = -45;
@@ -10505,6 +10514,50 @@ function resolveBandTickAngle(explicitAngle, maxLabelWidth, bandwidth, labelCoun
10505
10514
  return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : void 0;
10506
10515
  }
10507
10516
 
10517
+ // src/layout/axes/thinning.ts
10518
+ import { estimateTextWidth as estimateTextWidth12 } from "@opendata-ai/openchart-core";
10519
+ var MIN_TICK_GAP_FACTOR = 0.5;
10520
+ var MIN_TICK_COUNT = 2;
10521
+ function measureLabel(text, fontSize, fontWeight, measureText) {
10522
+ return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth12(text, fontSize, fontWeight);
10523
+ }
10524
+ function ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
10525
+ if (ticks2.length < 2) return false;
10526
+ const minGap = fontSize * MIN_TICK_GAP_FACTOR;
10527
+ if (orientation === "vertical") {
10528
+ const sorted = [...ticks2].sort((a, b) => a.position - b.position);
10529
+ const labelHeight = fontSize * 1.2;
10530
+ for (let i = 0; i < sorted.length - 1; i++) {
10531
+ const aBottom = sorted[i].position + labelHeight / 2;
10532
+ const bTop = sorted[i + 1].position - labelHeight / 2;
10533
+ if (aBottom + minGap > bTop) return true;
10534
+ }
10535
+ return false;
10536
+ }
10537
+ for (let i = 0; i < ticks2.length - 1; i++) {
10538
+ const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText);
10539
+ const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText);
10540
+ const aRight = ticks2[i].position + aWidth / 2;
10541
+ const bLeft = ticks2[i + 1].position - bWidth / 2;
10542
+ if (aRight + minGap > bLeft) return true;
10543
+ }
10544
+ return false;
10545
+ }
10546
+ function thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
10547
+ if (!ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation)) return ticks2;
10548
+ let current = ticks2;
10549
+ while (current.length > MIN_TICK_COUNT) {
10550
+ const thinned = [current[0]];
10551
+ for (let i = 2; i < current.length - 1; i += 2) {
10552
+ thinned.push(current[i]);
10553
+ }
10554
+ if (current.length > 1) thinned.push(current[current.length - 1]);
10555
+ current = thinned;
10556
+ if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
10557
+ }
10558
+ return current;
10559
+ }
10560
+
10508
10561
  // src/layout/axes/ticks.ts
10509
10562
  import {
10510
10563
  abbreviateNumber as abbreviateNumber2,
@@ -10694,199 +10747,539 @@ function resolveExplicitTicks(values, resolvedScale) {
10694
10747
  });
10695
10748
  }
10696
10749
 
10697
- // src/layout/scales.ts
10698
- function fieldValues(data, field) {
10699
- return data.map((d) => d[field]).filter((v) => v != null);
10700
- }
10701
- function parseDates(values) {
10702
- return values.map((v) => v instanceof Date ? v : new Date(String(v))).filter((d) => !Number.isNaN(d.getTime()));
10703
- }
10704
- function parseNumbers(values) {
10705
- return values.map((v) => typeof v === "number" ? v : Number(v)).filter((n) => Number.isFinite(n));
10706
- }
10707
- function uniqueStrings(values) {
10708
- const seen = /* @__PURE__ */ new Set();
10709
- const result = [];
10710
- for (const v of values) {
10711
- const s = String(v);
10712
- if (!seen.has(s)) {
10713
- seen.add(s);
10714
- result.push(s);
10715
- }
10716
- }
10717
- return result;
10718
- }
10719
- function applyCategoricalSort(values, sort) {
10720
- if (!sort) return values;
10721
- const sorted = [...values].sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
10722
- if (sort === "descending") sorted.reverse();
10723
- return sorted;
10724
- }
10725
- function applyContinuousConfig(scale, channel) {
10726
- if (channel.scale?.clamp) {
10727
- scale.clamp(true);
10728
- }
10729
- if (channel.scale?.reverse) {
10730
- const [r0, r1] = scale.range();
10731
- scale.range([r1, r0]);
10732
- }
10750
+ // src/layout/axes.ts
10751
+ var CATEGORICAL_SCALE_TYPES = /* @__PURE__ */ new Set(["band", "point", "ordinal"]);
10752
+ function yTickPositionIsInline(yChannel, markType, resolvedContinuous) {
10753
+ if (!yChannel) return false;
10754
+ const axisCfg = yChannel.axis ?? void 0;
10755
+ const explicit = axisCfg?.tickPosition;
10756
+ if (explicit) return explicit === "inline";
10757
+ const isLineOrArea2 = markType === "line" || markType === "area";
10758
+ if (!isLineOrArea2) return false;
10759
+ if (axisCfg?.orient === "right") return false;
10760
+ if (resolvedContinuous !== void 0) return resolvedContinuous;
10761
+ const explicitScaleType = yChannel.scale?.type;
10762
+ return explicitScaleType ? !CATEGORICAL_SCALE_TYPES.has(explicitScaleType) : yChannel.type === "quantitative" || yChannel.type === "temporal";
10733
10763
  }
10734
- function buildTimeScale(channel, data, rangeStart, rangeEnd) {
10735
- const values = parseDates(fieldValues(data, channel.field));
10736
- const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
10737
- const scale = time().domain(domain).range([rangeStart, rangeEnd]);
10738
- if (!channel.scale?.domain && channel.scale?.nice === true) {
10739
- scale.nice();
10764
+ var HEIGHT_MINIMAL_THRESHOLD = 80;
10765
+ var HEIGHT_REDUCED_THRESHOLD = 100;
10766
+ var WIDTH_MINIMAL_THRESHOLD = 150;
10767
+ var WIDTH_REDUCED_THRESHOLD = 300;
10768
+ var DENSITY_ORDER = ["full", "reduced", "minimal"];
10769
+ var GROUNDED_MARKS = /* @__PURE__ */ new Set(["bar", "tick", "lollipop"]);
10770
+ function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
10771
+ let density = baseDensity;
10772
+ if (axisLength < minimalThreshold) {
10773
+ density = "minimal";
10774
+ } else if (axisLength < reducedThreshold) {
10775
+ const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
10776
+ const reducedIdx = DENSITY_ORDER.indexOf("reduced");
10777
+ density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
10740
10778
  }
10741
- applyContinuousConfig(scale, channel);
10742
- return { scale, type: "time", channel };
10779
+ return density;
10743
10780
  }
10744
- function buildUtcScale(channel, data, rangeStart, rangeEnd) {
10745
- const values = parseDates(fieldValues(data, channel.field));
10746
- const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
10747
- const scale = utcTime().domain(domain).range([rangeStart, rangeEnd]);
10748
- if (!channel.scale?.domain && channel.scale?.nice === true) {
10749
- scale.nice();
10781
+ var CONTINUOUS_TICK_FLOOR = 4;
10782
+ var OVERSHOOT_TOLERANCE = 1.5;
10783
+ var MIN_QUANTITATIVE_TICKS = 2;
10784
+ function ensureMinContinuousTicks(scale, ticks2, requestedCount) {
10785
+ if (ticks2.length >= MIN_QUANTITATIVE_TICKS) return ticks2;
10786
+ if (!scale || !scaleSupportsTickCount(scale)) return ticks2;
10787
+ let best = ticks2;
10788
+ for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
10789
+ const candidate = buildContinuousTicks(scale, n);
10790
+ if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
10791
+ if (candidate.length > best.length) best = candidate;
10750
10792
  }
10751
- applyContinuousConfig(scale, channel);
10752
- return { scale, type: "utc", channel };
10793
+ return best;
10753
10794
  }
10754
- function buildLinearScale(channel, data, rangeStart, rangeEnd) {
10755
- const values = parseNumbers(fieldValues(data, channel.field));
10756
- let domainMin;
10757
- let domainMax;
10758
- if (channel.scale?.domain) {
10759
- const [d0, d1] = channel.scale.domain;
10760
- domainMin = d0;
10761
- domainMax = d1;
10762
- } else {
10763
- domainMin = min2(values) ?? 0;
10764
- domainMax = max2(values) ?? 1;
10765
- if (channel.scale?.zero !== false) {
10766
- domainMin = Math.min(0, domainMin);
10767
- domainMax = Math.max(0, domainMax);
10768
- }
10769
- }
10770
- const scale = linear2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10771
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10772
- scale.nice();
10773
- if (channel.scale?.zero === false) {
10774
- const [nicedMin, nicedMax] = scale.domain();
10775
- if (nicedMin < domainMin || nicedMax > domainMax) {
10776
- scale.domain([domainMin, domainMax]);
10777
- scale.nice(20);
10778
- }
10795
+ function fitContinuousTicks(scale, initialTicks, initialCount, fontSize, fontWeight, axisLength, orientation, measureText) {
10796
+ if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
10797
+ const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
10798
+ const tolerance = initialCount * OVERSHOOT_TOLERANCE;
10799
+ const overshoots = flooredTicks.length > tolerance;
10800
+ const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
10801
+ if (!overshoots && !overlaps) return flooredTicks;
10802
+ const minThreshold = orientation === "vertical" ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
10803
+ const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
10804
+ let bestWithinFloor;
10805
+ for (let n = initialCount - 1; n >= 2; n--) {
10806
+ 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;
10779
10817
  }
10818
+ if (candidate.length >= floor) bestWithinFloor = candidate;
10780
10819
  }
10781
- applyContinuousConfig(scale, channel);
10782
- return { scale, type: "linear", channel };
10783
- }
10784
- function buildLogScale(channel, data, rangeStart, rangeEnd) {
10785
- const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
10786
- const domainMin = channel.scale?.domain ? channel.scale.domain[0] : min2(values) ?? 1;
10787
- const domainMax = channel.scale?.domain ? channel.scale.domain[1] : max2(values) ?? 10;
10788
- const scale = log().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10789
- if (channel.scale?.base !== void 0) {
10790
- scale.base(channel.scale.base);
10791
- }
10792
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10793
- scale.nice();
10794
- }
10795
- applyContinuousConfig(scale, channel);
10796
- return { scale, type: "log", channel };
10820
+ const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
10821
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
10822
+ return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
10797
10823
  }
10798
- function buildPowScale(channel, data, rangeStart, rangeEnd) {
10799
- const values = parseNumbers(fieldValues(data, channel.field));
10800
- let domainMin;
10801
- let domainMax;
10802
- if (channel.scale?.domain) {
10803
- [domainMin, domainMax] = channel.scale.domain;
10804
- } else {
10805
- domainMin = min2(values) ?? 0;
10806
- domainMax = max2(values) ?? 1;
10807
- if (channel.scale?.zero !== false) {
10808
- domainMin = Math.min(0, domainMin);
10809
- domainMax = Math.max(0, domainMax);
10810
- }
10811
- }
10812
- const scale = pow().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10813
- if (channel.scale?.exponent !== void 0) {
10814
- scale.exponent(channel.scale.exponent);
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 };
10815
10830
  }
10816
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10817
- scale.nice();
10831
+ if (angleDeg < 0) {
10832
+ return { left: tick.position - projected, right: tick.position };
10818
10833
  }
10819
- applyContinuousConfig(scale, channel);
10820
- return { scale, type: "pow", channel };
10834
+ return { left: tick.position, right: tick.position + projected };
10821
10835
  }
10822
- function buildSqrtScale(channel, data, rangeStart, rangeEnd) {
10823
- const values = parseNumbers(fieldValues(data, channel.field));
10824
- let domainMin;
10825
- let domainMax;
10826
- if (channel.scale?.domain) {
10827
- [domainMin, domainMax] = channel.scale.domain;
10828
- } else {
10829
- domainMin = min2(values) ?? 0;
10830
- domainMax = max2(values) ?? 1;
10831
- if (channel.scale?.zero !== false) {
10832
- domainMin = Math.min(0, domainMin);
10833
- domainMax = Math.max(0, domainMax);
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
+ }
10849
+ 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 + minGap > spans[lastIdx].left) {
10855
+ keep[i] = false;
10856
+ continue;
10857
+ }
10858
+ break;
10834
10859
  }
10835
10860
  }
10836
- const scale = sqrt2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10837
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10838
- scale.nice();
10839
- }
10840
- applyContinuousConfig(scale, channel);
10841
- return { scale, type: "sqrt", channel };
10861
+ return ticks2.filter((_, i) => keep[i]);
10842
10862
  }
10843
- function buildSymlogScale(channel, data, rangeStart, rangeEnd) {
10844
- const values = parseNumbers(fieldValues(data, channel.field));
10845
- let domainMin;
10846
- let domainMax;
10847
- if (channel.scale?.domain) {
10848
- [domainMin, domainMax] = channel.scale.domain;
10849
- } else {
10850
- domainMin = min2(values) ?? 0;
10851
- domainMax = max2(values) ?? 1;
10852
- if (channel.scale?.zero !== false) {
10853
- domainMin = Math.min(0, domainMin);
10854
- domainMax = Math.max(0, domainMax);
10863
+ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContext) {
10864
+ const result = {};
10865
+ const baseDensity = strategy.axisLabelDensity;
10866
+ const yDensity = effectiveDensity(
10867
+ baseDensity,
10868
+ chartArea.height,
10869
+ HEIGHT_MINIMAL_THRESHOLD,
10870
+ HEIGHT_REDUCED_THRESHOLD
10871
+ );
10872
+ const xDensity = effectiveDensity(
10873
+ baseDensity,
10874
+ chartArea.width,
10875
+ WIDTH_MINIMAL_THRESHOLD,
10876
+ WIDTH_REDUCED_THRESHOLD
10877
+ );
10878
+ const tickLabelStyle = {
10879
+ fontFamily: theme.fonts.family,
10880
+ fontSize: theme.fonts.sizes.axisTick,
10881
+ fontWeight: theme.fonts.weights.normal,
10882
+ fill: theme.colors.axis,
10883
+ lineHeight: 1.2,
10884
+ fontVariant: "tabular-nums"
10885
+ };
10886
+ const axisLabelStyle = {
10887
+ fontFamily: theme.fonts.family,
10888
+ fontSize: theme.fonts.sizes.body,
10889
+ fontWeight: theme.fonts.weights.medium,
10890
+ fill: theme.colors.text,
10891
+ lineHeight: 1.3
10892
+ };
10893
+ const { fontSize } = tickLabelStyle;
10894
+ const { fontWeight } = tickLabelStyle;
10895
+ if (scales.x && !dataContext?.skipX && scales.x.channel.axis !== false) {
10896
+ const axisConfig = scales.x.channel.axis;
10897
+ const isContinuousX = scales.x.type !== "band" && scales.x.type !== "point" && scales.x.type !== "ordinal";
10898
+ const xTargetCount = isContinuousX ? targetTickCount(chartArea.width, xDensity, "x") : void 0;
10899
+ let allTicks;
10900
+ if (axisConfig?.values) {
10901
+ allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
10902
+ } else if (!isContinuousX) {
10903
+ allTicks = categoricalTicks(scales.x, xDensity, "horizontal");
10904
+ } else {
10905
+ allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
10906
+ }
10907
+ const gridlines = allTicks.map((t) => ({
10908
+ position: t.position,
10909
+ major: true
10910
+ }));
10911
+ let tickAngle = axisConfig?.labelAngle;
10912
+ if (tickAngle === void 0 && scales.x.type === "band" && allTicks.length > 1) {
10913
+ const bandwidth = scales.x.scale.bandwidth();
10914
+ let maxLabelWidth = 0;
10915
+ for (const t of allTicks) {
10916
+ const w = measureLabel(t.label, fontSize, fontWeight, measureText);
10917
+ if (w > maxLabelWidth) maxLabelWidth = w;
10918
+ }
10919
+ tickAngle = resolveBandTickAngle(void 0, maxLabelWidth, bandwidth, allTicks.length);
10920
+ }
10921
+ const hasExplicitValues = !!axisConfig?.values;
10922
+ let ticks2;
10923
+ if (hasExplicitValues) {
10924
+ ticks2 = allTicks;
10925
+ } else if (scales.x.type === "band") {
10926
+ const effectiveAngle = tickAngle ?? 0;
10927
+ ticks2 = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
10928
+ } else if (isContinuousX) {
10929
+ ticks2 = fitContinuousTicks(
10930
+ scales.x,
10931
+ allTicks,
10932
+ xTargetCount ?? allTicks.length,
10933
+ fontSize,
10934
+ fontWeight,
10935
+ chartArea.width,
10936
+ "horizontal",
10937
+ measureText
10938
+ );
10939
+ } else {
10940
+ ticks2 = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
10855
10941
  }
10942
+ const axisTitle = axisConfig?.title;
10943
+ const xLabelColor = axisConfig?.labelColor;
10944
+ const xTickPosition = axisConfig?.tickPosition ?? "gutter";
10945
+ const xDomainLine = axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? "") ? void 0 : false);
10946
+ result.x = {
10947
+ ticks: ticks2,
10948
+ gridlines: axisConfig?.grid ?? dataContext?.markType === "point" ? gridlines : [],
10949
+ label: axisTitle,
10950
+ labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
10951
+ tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
10952
+ tickAngle,
10953
+ start: { x: chartArea.x, y: chartArea.y + chartArea.height },
10954
+ end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
10955
+ orient: axisConfig?.orient,
10956
+ domainLine: xDomainLine,
10957
+ tickMarks: axisConfig?.ticks,
10958
+ offset: axisConfig?.offset,
10959
+ titlePadding: axisConfig?.titlePadding,
10960
+ labelPadding: axisConfig?.labelPadding,
10961
+ labelOverlap: axisConfig?.labelOverlap,
10962
+ labelFlush: axisConfig?.labelFlush,
10963
+ tickPosition: xTickPosition
10964
+ };
10856
10965
  }
10857
- const scale = symlog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10858
- if (channel.scale?.constant !== void 0) {
10859
- scale.constant(channel.scale.constant);
10966
+ if (scales.y && !dataContext?.skipY && scales.y.channel.axis !== false) {
10967
+ const axisConfig = scales.y.channel.axis;
10968
+ const isContinuousY = scales.y.type !== "band" && scales.y.type !== "point" && scales.y.type !== "ordinal";
10969
+ const yTargetCount = isContinuousY ? targetTickCount(chartArea.height, yDensity, "y") : void 0;
10970
+ let allTicks;
10971
+ if (axisConfig?.values) {
10972
+ allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
10973
+ } else if (dataContext?.precomputedYTicks && dataContext.precomputedYTicks.length > 0 && isContinuousY) {
10974
+ allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
10975
+ } else if (!isContinuousY) {
10976
+ const yFieldName = dataContext?.encoding.y?.field;
10977
+ const yLabelField = axisConfig?.labelField;
10978
+ allTicks = categoricalTicks(
10979
+ scales.y,
10980
+ yDensity,
10981
+ "vertical",
10982
+ yFieldName && yLabelField && dataContext ? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField } : void 0
10983
+ );
10984
+ } else {
10985
+ allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
10986
+ }
10987
+ const shouldThinY = scales.y.type !== "band" && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
10988
+ let ticks2;
10989
+ if (!shouldThinY) {
10990
+ ticks2 = allTicks;
10991
+ } else if (isContinuousY) {
10992
+ ticks2 = fitContinuousTicks(
10993
+ scales.y,
10994
+ allTicks,
10995
+ yTargetCount ?? allTicks.length,
10996
+ fontSize,
10997
+ fontWeight,
10998
+ chartArea.height,
10999
+ "vertical",
11000
+ measureText
11001
+ );
11002
+ } else {
11003
+ ticks2 = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, "vertical");
11004
+ }
11005
+ const gridlines = ticks2.map((t) => ({
11006
+ position: t.position,
11007
+ major: true
11008
+ }));
11009
+ const axisTitle = axisConfig?.title;
11010
+ const tickAngle = axisConfig?.labelAngle;
11011
+ const yLabelColor = axisConfig?.labelColor;
11012
+ const isContinuousYAxis = scales.y.type !== "band" && scales.y.type !== "point" && scales.y.type !== "ordinal";
11013
+ const yTickPosition = yTickPositionIsInline(
11014
+ dataContext?.encoding.y,
11015
+ dataContext?.markType ?? "",
11016
+ isContinuousYAxis
11017
+ ) ? "inline" : "gutter";
11018
+ const yDomainLine = axisConfig?.domain ?? (yTickPosition === "inline" ? false : void 0);
11019
+ const yTickMarks = axisConfig?.ticks ?? (yTickPosition === "inline" ? false : void 0);
11020
+ result.y = {
11021
+ ticks: ticks2,
11022
+ // Y-axis gridlines are shown by default (standard editorial practice)
11023
+ gridlines,
11024
+ label: axisTitle,
11025
+ labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
11026
+ tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
11027
+ tickAngle,
11028
+ start: { x: chartArea.x, y: chartArea.y },
11029
+ end: { x: chartArea.x, y: chartArea.y + chartArea.height },
11030
+ orient: axisConfig?.orient,
11031
+ domainLine: yDomainLine,
11032
+ tickMarks: yTickMarks,
11033
+ offset: axisConfig?.offset,
11034
+ titlePadding: axisConfig?.titlePadding,
11035
+ labelPadding: axisConfig?.labelPadding,
11036
+ labelOverlap: axisConfig?.labelOverlap,
11037
+ labelFlush: axisConfig?.labelFlush,
11038
+ tickPosition: yTickPosition
11039
+ };
10860
11040
  }
10861
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10862
- scale.nice();
11041
+ const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
11042
+ if (result.x) {
11043
+ const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
11044
+ result.x.extent = computeXAxisExtentFromLabels({
11045
+ labels: result.x.ticks.map((t) => t.label),
11046
+ tickAngle: result.x.tickAngle,
11047
+ hasTitle: !!result.x.label,
11048
+ tickFontSize: result.x.tickLabelStyle.fontSize,
11049
+ tickFontWeight: result.x.tickLabelStyle.fontWeight,
11050
+ xAxisHeight: theme.spacing.xAxisHeight,
11051
+ measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText)
11052
+ });
11053
+ if (result.x.label) {
11054
+ const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
11055
+ result.x.titlePosition = {
11056
+ x: chartArea.x + chartArea.width / 2,
11057
+ y: chartArea.y + chartArea.height + rotatedBand
11058
+ };
11059
+ }
10863
11060
  }
10864
- applyContinuousConfig(scale, channel);
10865
- return { scale, type: "symlog", channel };
11061
+ if (result.y?.label && result.y.labelStyle) {
11062
+ const isRight = result.y.orient === "right";
11063
+ if (isRight) {
11064
+ const titleOff = getAxisTitleOffset(totalWidth);
11065
+ result.y.titlePosition = {
11066
+ x: chartArea.x + chartArea.width + titleOff,
11067
+ y: chartArea.y + chartArea.height / 2,
11068
+ angle: 90
11069
+ };
11070
+ } else {
11071
+ const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
11072
+ const w = estimateTextWidth13(
11073
+ t.label,
11074
+ result.y.tickLabelStyle.fontSize,
11075
+ result.y.tickLabelStyle.fontWeight ?? 400
11076
+ );
11077
+ return Math.max(max4, w);
11078
+ }, 0);
11079
+ const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
11080
+ result.y.titlePosition = {
11081
+ x: chartArea.x - titleOff,
11082
+ y: chartArea.y + chartArea.height / 2,
11083
+ angle: -90
11084
+ };
11085
+ }
11086
+ }
11087
+ return result;
10866
11088
  }
10867
- function buildQuantileScale(channel, data, rangeStart, rangeEnd) {
10868
- const values = parseNumbers(fieldValues(data, channel.field));
10869
- const range2 = channel.scale?.range ? channel.scale.range : evenRange(rangeStart, rangeEnd, 4);
10870
- const scale = quantile2().domain(values).range(range2);
10871
- return { scale, type: "quantile", channel };
11089
+
11090
+ // src/layout/scales.ts
11091
+ function fieldValues(data, field) {
11092
+ return data.map((d) => d[field]).filter((v) => v != null);
10872
11093
  }
10873
- function buildQuantizeScale(channel, data, rangeStart, rangeEnd) {
10874
- const values = parseNumbers(fieldValues(data, channel.field));
10875
- const domainMin = channel.scale?.domain ? channel.scale.domain[0] : min2(values) ?? 0;
10876
- const domainMax = channel.scale?.domain ? channel.scale.domain[1] : max2(values) ?? 1;
10877
- const range2 = channel.scale?.range ? channel.scale.range : evenRange(rangeStart, rangeEnd, 4);
10878
- const scale = quantize().domain([domainMin, domainMax]).range(range2);
10879
- return { scale, type: "quantize", channel };
11094
+ function parseDates(values) {
11095
+ return values.map((v) => v instanceof Date ? v : new Date(String(v))).filter((d) => !Number.isNaN(d.getTime()));
10880
11096
  }
10881
- function buildThresholdScale(channel, _data, rangeStart, rangeEnd) {
10882
- const domainBreaks = channel.scale?.domain ? channel.scale.domain : [0.5];
10883
- const range2 = channel.scale?.range ? channel.scale.range : evenRange(rangeStart, rangeEnd, domainBreaks.length + 1);
10884
- const scale = threshold().domain(domainBreaks).range(range2);
10885
- return { scale, type: "threshold", channel };
11097
+ function parseNumbers(values) {
11098
+ return values.map((v) => typeof v === "number" ? v : Number(v)).filter((n) => Number.isFinite(n));
10886
11099
  }
10887
- function evenRange(start, end, count) {
10888
- if (count <= 1) return [start];
10889
- const step = (end - start) / (count - 1);
11100
+ function uniqueStrings(values) {
11101
+ const seen = /* @__PURE__ */ new Set();
11102
+ const result = [];
11103
+ for (const v of values) {
11104
+ const s = String(v);
11105
+ if (!seen.has(s)) {
11106
+ seen.add(s);
11107
+ result.push(s);
11108
+ }
11109
+ }
11110
+ return result;
11111
+ }
11112
+ function applyCategoricalSort(values, sort) {
11113
+ if (!sort) return values;
11114
+ const sorted = [...values].sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
11115
+ if (sort === "descending") sorted.reverse();
11116
+ return sorted;
11117
+ }
11118
+ function applyContinuousConfig(scale, channel) {
11119
+ if (channel.scale?.clamp) {
11120
+ scale.clamp(true);
11121
+ }
11122
+ if (channel.scale?.reverse) {
11123
+ const [r0, r1] = scale.range();
11124
+ scale.range([r1, r0]);
11125
+ }
11126
+ }
11127
+ function buildTimeScale(channel, data, rangeStart, rangeEnd) {
11128
+ const values = parseDates(fieldValues(data, channel.field));
11129
+ const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
11130
+ const scale = time().domain(domain).range([rangeStart, rangeEnd]);
11131
+ if (!channel.scale?.domain && channel.scale?.nice === true) {
11132
+ scale.nice();
11133
+ }
11134
+ applyContinuousConfig(scale, channel);
11135
+ return { scale, type: "time", channel };
11136
+ }
11137
+ function buildUtcScale(channel, data, rangeStart, rangeEnd) {
11138
+ const values = parseDates(fieldValues(data, channel.field));
11139
+ const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
11140
+ const scale = utcTime().domain(domain).range([rangeStart, rangeEnd]);
11141
+ if (!channel.scale?.domain && channel.scale?.nice === true) {
11142
+ scale.nice();
11143
+ }
11144
+ applyContinuousConfig(scale, channel);
11145
+ return { scale, type: "utc", channel };
11146
+ }
11147
+ function buildLinearScale(channel, data, rangeStart, rangeEnd) {
11148
+ const values = parseNumbers(fieldValues(data, channel.field));
11149
+ let domainMin;
11150
+ let domainMax;
11151
+ if (channel.scale?.domain) {
11152
+ const [d0, d1] = channel.scale.domain;
11153
+ domainMin = d0;
11154
+ domainMax = d1;
11155
+ } else {
11156
+ domainMin = min2(values) ?? 0;
11157
+ domainMax = max2(values) ?? 1;
11158
+ if (channel.scale?.zero !== false) {
11159
+ domainMin = Math.min(0, domainMin);
11160
+ domainMax = Math.max(0, domainMax);
11161
+ }
11162
+ }
11163
+ const scale = linear2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
11164
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
11165
+ scale.nice();
11166
+ if (channel.scale?.zero === false) {
11167
+ const [nicedMin, nicedMax] = scale.domain();
11168
+ if (nicedMin < domainMin || nicedMax > domainMax) {
11169
+ scale.domain([domainMin, domainMax]);
11170
+ scale.nice(20);
11171
+ }
11172
+ }
11173
+ }
11174
+ applyContinuousConfig(scale, channel);
11175
+ return { scale, type: "linear", channel };
11176
+ }
11177
+ function buildLogScale(channel, data, rangeStart, rangeEnd) {
11178
+ const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
11179
+ const domainMin = channel.scale?.domain ? channel.scale.domain[0] : min2(values) ?? 1;
11180
+ const domainMax = channel.scale?.domain ? channel.scale.domain[1] : max2(values) ?? 10;
11181
+ const scale = log().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
11182
+ if (channel.scale?.base !== void 0) {
11183
+ scale.base(channel.scale.base);
11184
+ }
11185
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
11186
+ scale.nice();
11187
+ }
11188
+ applyContinuousConfig(scale, channel);
11189
+ return { scale, type: "log", channel };
11190
+ }
11191
+ function buildPowScale(channel, data, rangeStart, rangeEnd) {
11192
+ const values = parseNumbers(fieldValues(data, channel.field));
11193
+ let domainMin;
11194
+ let domainMax;
11195
+ if (channel.scale?.domain) {
11196
+ [domainMin, domainMax] = channel.scale.domain;
11197
+ } else {
11198
+ domainMin = min2(values) ?? 0;
11199
+ domainMax = max2(values) ?? 1;
11200
+ if (channel.scale?.zero !== false) {
11201
+ domainMin = Math.min(0, domainMin);
11202
+ domainMax = Math.max(0, domainMax);
11203
+ }
11204
+ }
11205
+ const scale = pow().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
11206
+ if (channel.scale?.exponent !== void 0) {
11207
+ scale.exponent(channel.scale.exponent);
11208
+ }
11209
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
11210
+ scale.nice();
11211
+ }
11212
+ applyContinuousConfig(scale, channel);
11213
+ return { scale, type: "pow", channel };
11214
+ }
11215
+ function buildSqrtScale(channel, data, rangeStart, rangeEnd) {
11216
+ const values = parseNumbers(fieldValues(data, channel.field));
11217
+ let domainMin;
11218
+ let domainMax;
11219
+ if (channel.scale?.domain) {
11220
+ [domainMin, domainMax] = channel.scale.domain;
11221
+ } else {
11222
+ domainMin = min2(values) ?? 0;
11223
+ domainMax = max2(values) ?? 1;
11224
+ if (channel.scale?.zero !== false) {
11225
+ domainMin = Math.min(0, domainMin);
11226
+ domainMax = Math.max(0, domainMax);
11227
+ }
11228
+ }
11229
+ const scale = sqrt2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
11230
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
11231
+ scale.nice();
11232
+ }
11233
+ applyContinuousConfig(scale, channel);
11234
+ return { scale, type: "sqrt", channel };
11235
+ }
11236
+ function buildSymlogScale(channel, data, rangeStart, rangeEnd) {
11237
+ const values = parseNumbers(fieldValues(data, channel.field));
11238
+ let domainMin;
11239
+ let domainMax;
11240
+ if (channel.scale?.domain) {
11241
+ [domainMin, domainMax] = channel.scale.domain;
11242
+ } else {
11243
+ domainMin = min2(values) ?? 0;
11244
+ domainMax = max2(values) ?? 1;
11245
+ if (channel.scale?.zero !== false) {
11246
+ domainMin = Math.min(0, domainMin);
11247
+ domainMax = Math.max(0, domainMax);
11248
+ }
11249
+ }
11250
+ const scale = symlog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
11251
+ if (channel.scale?.constant !== void 0) {
11252
+ scale.constant(channel.scale.constant);
11253
+ }
11254
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
11255
+ scale.nice();
11256
+ }
11257
+ applyContinuousConfig(scale, channel);
11258
+ return { scale, type: "symlog", channel };
11259
+ }
11260
+ function buildQuantileScale(channel, data, rangeStart, rangeEnd) {
11261
+ const values = parseNumbers(fieldValues(data, channel.field));
11262
+ const range2 = channel.scale?.range ? channel.scale.range : evenRange(rangeStart, rangeEnd, 4);
11263
+ const scale = quantile2().domain(values).range(range2);
11264
+ return { scale, type: "quantile", channel };
11265
+ }
11266
+ function buildQuantizeScale(channel, data, rangeStart, rangeEnd) {
11267
+ const values = parseNumbers(fieldValues(data, channel.field));
11268
+ const domainMin = channel.scale?.domain ? channel.scale.domain[0] : min2(values) ?? 0;
11269
+ const domainMax = channel.scale?.domain ? channel.scale.domain[1] : max2(values) ?? 1;
11270
+ const range2 = channel.scale?.range ? channel.scale.range : evenRange(rangeStart, rangeEnd, 4);
11271
+ const scale = quantize().domain([domainMin, domainMax]).range(range2);
11272
+ return { scale, type: "quantize", channel };
11273
+ }
11274
+ function buildThresholdScale(channel, _data, rangeStart, rangeEnd) {
11275
+ const domainBreaks = channel.scale?.domain ? channel.scale.domain : [0.5];
11276
+ const range2 = channel.scale?.range ? channel.scale.range : evenRange(rangeStart, rangeEnd, domainBreaks.length + 1);
11277
+ const scale = threshold().domain(domainBreaks).range(range2);
11278
+ return { scale, type: "threshold", channel };
11279
+ }
11280
+ function evenRange(start, end, count) {
11281
+ if (count <= 1) return [start];
11282
+ const step = (end - start) / (count - 1);
10890
11283
  return Array.from({ length: count }, (_, i) => start + step * i);
10891
11284
  }
10892
11285
  function estimateBandwidth(scaleConfig, plotWidth, n) {
@@ -11191,7 +11584,7 @@ function createMeasureFn(measureText) {
11191
11584
  if (measureText) {
11192
11585
  return (text, fontSize, fontWeight) => measureText(text, fontSize, fontWeight).width;
11193
11586
  }
11194
- return estimateTextWidth12;
11587
+ return estimateTextWidth14;
11195
11588
  }
11196
11589
  function sampleLabelFromMagnitude(maxAbsVal) {
11197
11590
  return maxAbsVal >= 1e3 ? abbreviateNumber3(maxAbsVal) : formatNumber4(maxAbsVal);
@@ -11240,11 +11633,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11240
11633
  };
11241
11634
  }
11242
11635
  const yAxisCfg = encoding.y?.axis ?? void 0;
11243
- const yTickPositionExplicit = yAxisCfg?.tickPosition;
11244
11636
  const yIsContinuous = encoding.y?.type === "quantitative" || encoding.y?.type === "temporal";
11245
- const yIsLineOrArea = renderSpec.markType === "line" || renderSpec.markType === "area";
11246
- const yAxisOrient = yAxisCfg?.orient;
11247
- const yIsInline = yTickPositionExplicit === "inline" || yTickPositionExplicit === void 0 && yIsLineOrArea && yIsContinuous && yAxisOrient !== "right";
11637
+ const yIsInline = yTickPositionIsInline(encoding.y, renderSpec.markType);
11248
11638
  const yAxisSuppressed = encoding.y?.axis === false;
11249
11639
  let leftGutter = hPad + axisMargin;
11250
11640
  if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
@@ -11371,7 +11761,7 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11371
11761
  xLabels.length
11372
11762
  );
11373
11763
  }
11374
- const xAxisExtent = isRadial || xAxisSuppressed ? 0 : computeXAxisExtentFromLabels({
11764
+ const xAxisExtent = isRadial || xAxisSuppressed ? 0 : computeXAxisExtentFromLabels2({
11375
11765
  labels: xLabels,
11376
11766
  tickAngle: effectiveXTickAngle,
11377
11767
  hasTitle: hasXAxisTitle,
@@ -11495,1270 +11885,904 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11495
11885
  const yAxisDef = encoding.y?.axis;
11496
11886
  if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
11497
11887
  let estTickLabelWidth = 0;
11498
- if (encoding.y?.field && yIsContinuous) {
11888
+ if (encoding.y?.field && yIsContinuous && !yIsInline) {
11499
11889
  if (yTickValues.length > 0) {
11500
11890
  const titleProvArea = {
11501
11891
  x: finalGutter,
11502
11892
  y: topMargin,
11503
11893
  width: Math.max(0, width - finalGutter - hPad),
11504
11894
  height: chartHeight
11505
- };
11506
- const scales = computeScales(renderSpec, titleProvArea, renderSpec.data);
11507
- if (scales.y && scaleSupportsTickCount(scales.y)) {
11508
- const ticks2 = buildContinuousTicks(scales.y, yTickCount || 5);
11509
- for (const t of ticks2) {
11510
- const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
11511
- if (w > estTickLabelWidth) estTickLabelWidth = w;
11512
- }
11513
- }
11514
- }
11515
- }
11516
- const titleFontSize = theme.fonts.sizes.body;
11517
- const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
11518
- const halfGlyph = Math.ceil(titleFontSize / 2);
11519
- const rotatedLabelMargin = offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
11520
- gutterWithTitle = Math.max(finalGutter, hPad + rotatedLabelMargin);
11521
- }
11522
- finalPlan = {
11523
- leftGutter: gutterWithTitle,
11524
- xAxisExtent,
11525
- legendContent,
11526
- chrome,
11527
- yTickValues,
11528
- yTickCount,
11529
- converged: prev === null || converged,
11530
- inlineYLabelInset,
11531
- endpointLabelsDemoted
11532
- };
11533
- break;
11534
- }
11535
- prev = { gutter: newGutter, rowCount: legendContent.rowCount, xAxisExtent };
11536
- leftGutter = newGutter;
11537
- }
11538
- if (!finalPlan) throw new Error("resolveLayoutPlan: loop exited without producing a plan");
11539
- return finalPlan;
11540
- }
11541
-
11542
- // src/compile/layer.ts
11543
- function compileLayer(spec, options, compileChart2) {
11544
- const leaves = flattenLayers(spec);
11545
- if (leaves.length === 0) {
11546
- throw new Error("LayerSpec has no leaf chart specs after flattening");
11547
- }
11548
- if (leaves.length === 1) {
11549
- const singleSpec = buildPrimarySpec(leaves, spec);
11550
- return compileChart2(singleSpec, options);
11551
- }
11552
- if (spec.resolve?.scale?.y === "independent") {
11553
- return compileLayerIndependent(leaves, spec, options, compileChart2);
11554
- }
11555
- const primarySpec = buildPrimarySpec(leaves, spec);
11556
- const primaryLayout = compileChart2(primarySpec, options);
11557
- const allMarks = [];
11558
- const seenLabels = /* @__PURE__ */ new Set();
11559
- const pLegend = primaryLayout.legend;
11560
- const mergedLegendEntries = "entries" in pLegend ? [...pLegend.entries] : [];
11561
- for (const entry of mergedLegendEntries) {
11562
- seenLabels.add(entry.label);
11563
- }
11564
- const indexedLeaves = leaves.map((leaf, i) => ({
11565
- leaf,
11566
- zIndex: leaf.zIndex ?? i
11567
- }));
11568
- indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
11569
- const leafOptions = { ...options, frozenChartArea: primaryLayout.area };
11570
- for (const { leaf } of indexedLeaves) {
11571
- const themedLeaf = {
11572
- ...leaf,
11573
- theme: leaf.theme ?? spec.theme,
11574
- darkMode: leaf.darkMode ?? spec.darkMode
11575
- };
11576
- const leafLayout = compileChart2(themedLeaf, leafOptions);
11577
- allMarks.push(...leafLayout.marks);
11578
- const leafLeg = leafLayout.legend;
11579
- if ("entries" in leafLeg) {
11580
- for (const entry of leafLeg.entries) {
11581
- if (!seenLabels.has(entry.label)) {
11582
- seenLabels.add(entry.label);
11583
- mergedLegendEntries.push(entry);
11584
- }
11585
- }
11586
- }
11587
- }
11588
- return {
11589
- ...primaryLayout,
11590
- marks: allMarks,
11591
- legend: {
11592
- ...primaryLayout.legend,
11593
- ..."entries" in pLegend ? { entries: mergedLegendEntries } : {}
11594
- }
11595
- };
11596
- }
11597
- function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
11598
- if (!encoding?.y) return 40;
11599
- const yEnc = encoding.y;
11600
- const yField = yEnc.field;
11601
- if (!yField) return 40;
11602
- const m = measure ?? estimateTextWidth13;
11603
- const yType = yEnc.type;
11604
- if (yType === "nominal" || yType === "ordinal") {
11605
- let maxWidth = 0;
11606
- for (const row of data) {
11607
- const label = String(row[yField] ?? "");
11608
- const w = m(label, baseFontSize, 400);
11609
- if (w > maxWidth) maxWidth = w;
11610
- }
11611
- return maxWidth > 0 ? maxWidth + 10 : 40;
11612
- }
11613
- const yAxisFormat = encoding.y.axis?.format;
11614
- let maxAbsVal = 0;
11615
- for (const row of data) {
11616
- const v = Number(row[yField]);
11617
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
11618
- }
11619
- let sampleLabel;
11620
- if (yAxisFormat) {
11621
- try {
11622
- const fmt = format(yAxisFormat);
11623
- sampleLabel = fmt(maxAbsVal);
11624
- } catch {
11625
- sampleLabel = String(maxAbsVal);
11626
- }
11627
- } else {
11628
- sampleLabel = Math.abs(maxAbsVal) >= 1e3 ? abbreviateNumber4(maxAbsVal) : formatNumber5(maxAbsVal);
11629
- }
11630
- const hasNeg = data.some((r) => Number(r[yField]) < 0);
11631
- const labelEst = (hasNeg ? "-" : "") + sampleLabel;
11632
- return m(labelEst, baseFontSize, 400) + 10;
11633
- }
11634
- function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
11635
- if (leaves.length > 2) {
11636
- throw new Error(
11637
- `Independent y-scales support at most 2 layers (left and right y-axis). Got ${leaves.length} layers.`
11638
- );
11639
- }
11640
- const leaf0 = leaves[0];
11641
- const leaf1 = leaves[1];
11642
- const xType0 = leaf0.encoding?.x?.type;
11643
- const xType1 = leaf1.encoding?.x?.type;
11644
- if (xType0 && xType1 && xType0 !== xType1) {
11645
- throw new Error(
11646
- `Dual-axis charts require matching x-field types across layers. Layer 0 has '${xType0}', layer 1 has '${xType1}'.`
11647
- );
11648
- }
11649
- const theme = resolveTheme2(layerSpec.theme ?? leaf1.theme);
11650
- const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
11651
- const measureFn = createMeasureFn(options.measureText);
11652
- const rightAxisWidth = estimateYAxisLabelWidth(
11653
- leaf1.data,
11654
- leaf1.encoding,
11655
- axisFontSize,
11656
- measureFn
11657
- );
11658
- const yAxisConfig = leaf1.encoding?.y?.axis || void 0;
11659
- const hasRightAxisTitle = !!yAxisConfig?.title;
11660
- const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
11661
- const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
11662
- const axisTitleOffset4 = getAxisTitleOffset(options.width);
11663
- const halfGlyph = Math.ceil(bodyFontSize / 2);
11664
- const titleExtent = hasRightAxisTitle ? axisTitleOffset4 + halfGlyph + (options.width < BREAKPOINT_COMPACT_MAX2 ? 0 : AXIS_TITLE_TRAILING_PAD2) : 0;
11665
- const rightReserve = Math.max(tickExtent, titleExtent);
11666
- const optionsWithReserve = {
11667
- ...options,
11668
- rightAxisReserve: rightReserve
11669
- };
11670
- const xField0 = leaf0.encoding?.x?.field;
11671
- const xField1 = leaf1.encoding?.x?.field;
11672
- const unionXValues = /* @__PURE__ */ new Set();
11673
- if (xField0) for (const row of leaf0.data) unionXValues.add(row[xField0]);
11674
- if (xField1) for (const row of leaf1.data) unionXValues.add(row[xField1]);
11675
- let leaf0WithUnionX = ensureXDomainCoverage(leaf0, xField0, unionXValues);
11676
- let leaf1WithUnionX = ensureXDomainCoverage(leaf1, xField1, unionXValues);
11677
- const aligned = alignYDomains(leaf0WithUnionX, leaf1WithUnionX);
11678
- if (aligned) {
11679
- leaf0WithUnionX = withYDomain(leaf0WithUnionX, aligned.domain0);
11680
- leaf1WithUnionX = withYDomain(leaf1WithUnionX, aligned.domain1);
11681
- }
11682
- const primary0 = buildPrimarySpec([leaf0WithUnionX], layerSpec);
11683
- const layout0 = compileChart2(primary0, optionsWithReserve);
11684
- const primary1 = buildPrimarySpec([leaf1WithUnionX], layerSpec);
11685
- primary1.annotations = [];
11686
- const layout1 = compileChart2(primary1, optionsWithReserve);
11687
- const y2Axis = layout1.axes.y ? {
11688
- ...layout1.axes.y,
11689
- orient: "right",
11690
- gridlines: [],
11691
- start: {
11692
- x: layout0.area.x + layout0.area.width,
11693
- y: layout0.area.y
11694
- },
11695
- end: {
11696
- x: layout0.area.x + layout0.area.width,
11697
- y: layout0.area.y + layout0.area.height
11698
- },
11699
- ...layout1.axes.y.label ? {
11700
- titlePosition: {
11701
- x: layout0.area.x + layout0.area.width + getAxisTitleOffset(layout0.dimensions.width),
11702
- y: layout0.area.y + layout0.area.height / 2,
11703
- angle: 90
11704
- }
11705
- } : {}
11706
- } : void 0;
11707
- const layer0HasBars = layout0.marks.some((m) => m.type === "rect");
11708
- const layer1HasBars = layout1.marks.some((m) => m.type === "rect");
11709
- const bandCenterByCategory = /* @__PURE__ */ new Map();
11710
- if (layer0HasBars && layout0.axes.x?.ticks) {
11711
- for (const tick of layout0.axes.x.ticks) {
11712
- bandCenterByCategory.set(String(tick.label), tick.position);
11713
- }
11714
- } else if (layer1HasBars && layout1.axes.x?.ticks) {
11715
- for (const tick of layout1.axes.x.ticks) {
11716
- bandCenterByCategory.set(String(tick.label), tick.position);
11717
- }
11718
- }
11719
- const remapMarkX = (xField, mark) => {
11720
- if (!xField || bandCenterByCategory.size === 0) return mark;
11721
- if (mark.type === "line") {
11722
- const newPoints = mark.points.map((p, i) => {
11723
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11724
- return bx !== void 0 ? { ...p, x: bx } : p;
11725
- });
11726
- const newDataPoints = mark.dataPoints?.map((dp, i) => {
11727
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11728
- return bx !== void 0 ? { ...dp, x: bx } : dp;
11729
- });
11730
- const newPath = line_default().x((p) => p.x).y((p) => p.y).curve(monotoneX)(newPoints) ?? void 0;
11731
- return { ...mark, points: newPoints, dataPoints: newDataPoints, path: newPath };
11732
- }
11733
- if (mark.type === "area") {
11734
- const newTopPoints = mark.topPoints.map((p, i) => {
11735
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11736
- return bx !== void 0 ? { ...p, x: bx } : p;
11737
- });
11738
- const newBottomPoints = mark.bottomPoints.map((p, i) => {
11739
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11740
- return bx !== void 0 ? { ...p, x: bx } : p;
11741
- });
11742
- const newDataPoints = mark.dataPoints?.map((dp, i) => {
11743
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11744
- return bx !== void 0 ? { ...dp, x: bx } : dp;
11745
- });
11746
- const areaGen = area_default().x((p) => p.x).y0((p) => p.yBottom).y1((p) => p.yTop).curve(monotoneX);
11747
- const topLineGen = line_default().x((p) => p.x).y((p) => p.yTop).curve(monotoneX);
11748
- const combined = newTopPoints.map((tp, i) => ({
11749
- x: tp.x,
11750
- yTop: tp.y,
11751
- yBottom: newBottomPoints[i]?.y ?? tp.y
11752
- }));
11753
- const newPath = areaGen(combined) ?? "";
11754
- const newTopPath = topLineGen(combined) ?? "";
11755
- return {
11756
- ...mark,
11757
- topPoints: newTopPoints,
11758
- bottomPoints: newBottomPoints,
11759
- dataPoints: newDataPoints,
11760
- path: newPath,
11761
- topPath: newTopPath
11762
- };
11763
- }
11764
- if (mark.type === "point") {
11765
- const bx = bandCenterByCategory.get(String(mark.data[xField] ?? ""));
11766
- return bx !== void 0 ? { ...mark, cx: bx } : mark;
11767
- }
11768
- return mark;
11769
- };
11770
- const adjustedMarks0 = bandCenterByCategory.size > 0 && !layer0HasBars ? layout0.marks.map((m) => remapMarkX(xField0, m)) : layout0.marks;
11771
- const taggedMarks1 = layout1.marks.map((mark) => {
11772
- const tagged = { ...mark, yScale: "y2" };
11773
- if (bandCenterByCategory.size > 0 && !layer1HasBars) {
11774
- return remapMarkX(xField1, tagged);
11775
- }
11776
- return tagged;
11777
- });
11778
- const seenLabels = /* @__PURE__ */ new Set();
11779
- const l0Legend = layout0.legend;
11780
- const l1Legend = layout1.legend;
11781
- const mergedLegendEntries = "entries" in l0Legend ? [...l0Legend.entries] : [];
11782
- for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
11783
- const l1Entries = "entries" in l1Legend ? l1Legend.entries : [];
11784
- for (const entry of l1Entries) {
11785
- if (!seenLabels.has(entry.label)) {
11786
- seenLabels.add(entry.label);
11787
- mergedLegendEntries.push(entry);
11788
- }
11789
- }
11790
- const l0Count = layout0.marks.length;
11791
- const mergedTooltips = new Map(layout0.tooltipDescriptors);
11792
- for (const [key, value2] of layout1.tooltipDescriptors) {
11793
- const match = /^(rect|point|arc)-(\d+)$/.exec(key);
11794
- if (match) {
11795
- const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
11796
- mergedTooltips.set(offsetKey, value2);
11797
- } else {
11798
- mergedTooltips.set(key, value2);
11799
- }
11800
- }
11801
- const z0 = leaf0.zIndex ?? 0;
11802
- const z1 = leaf1.zIndex ?? 1;
11803
- const marks = z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
11804
- return {
11805
- ...layout0,
11806
- axes: {
11807
- x: layout0.axes.x,
11808
- y: layout0.axes.y,
11809
- y2: y2Axis
11810
- },
11811
- marks,
11812
- legend: {
11813
- ...layout0.legend,
11814
- ..."entries" in l0Legend ? { entries: mergedLegendEntries } : {}
11815
- },
11816
- tooltipDescriptors: mergedTooltips
11817
- };
11818
- }
11819
- function ensureXDomainCoverage(leaf, xField, allXValues) {
11820
- if (!xField || allXValues.size === 0) return leaf;
11821
- const existingXValues = /* @__PURE__ */ new Set();
11822
- for (const row of leaf.data) existingXValues.add(row[xField]);
11823
- const missingRows = [];
11824
- for (const xVal of allXValues) {
11825
- if (!existingXValues.has(xVal)) {
11826
- missingRows.push({ [xField]: xVal });
11827
- }
11828
- }
11829
- if (missingRows.length === 0) return leaf;
11830
- return {
11831
- ...leaf,
11832
- data: [...leaf.data, ...missingRows]
11833
- };
11834
- }
11835
- function alignYDomains(leaf0, leaf1) {
11836
- const yEnc0 = leaf0.encoding?.y;
11837
- const yEnc1 = leaf1.encoding?.y;
11838
- if (!yEnc0 || !yEnc1) return void 0;
11839
- if (yEnc0.type !== "quantitative" || yEnc1.type !== "quantitative") return void 0;
11840
- if (yEnc0.scale?.domain || yEnc1.scale?.domain) return void 0;
11841
- const includeZero0 = yEnc0.scale?.zero !== false;
11842
- const includeZero1 = yEnc1.scale?.zero !== false;
11843
- const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
11844
- const vals1 = leaf1.data.map((r) => Number(r[yEnc1.field])).filter(Number.isFinite);
11845
- if (vals0.length === 0 || vals1.length === 0) return void 0;
11846
- const niced = (vals, includeZero) => {
11847
- let lo = Math.min(...vals);
11848
- let hi = Math.max(...vals);
11849
- if (includeZero) {
11850
- lo = Math.min(0, lo);
11851
- hi = Math.max(0, hi);
11852
- }
11853
- const s = linear2().domain([lo, hi]);
11854
- s.nice();
11855
- const [dLo, dHi] = s.domain();
11856
- return [dLo, dHi];
11857
- };
11858
- const [min0, max0] = niced(vals0, includeZero0);
11859
- const [min1, max1] = niced(vals1, includeZero1);
11860
- const span0 = max0 - min0;
11861
- const span1 = max1 - min1;
11862
- if (span0 === 0 || span1 === 0) return void 0;
11863
- const zf0 = (0 - min0) / span0;
11864
- const zf1 = (0 - min1) / span1;
11865
- const zeroInDomain0 = zf0 >= -1e-3 && zf0 <= 1.001;
11866
- const zeroInDomain1 = zf1 >= -1e-3 && zf1 <= 1.001;
11867
- if (!zeroInDomain0 || !zeroInDomain1) return void 0;
11868
- if (Math.abs(zf0 - zf1) < 1e-3) {
11869
- return { domain0: [min0, max0], domain1: [min1, max1] };
11870
- }
11871
- const targetZf = Math.max(zf0, zf1);
11872
- const align = (dMin, dMax, currentZf) => {
11873
- if (Math.abs(currentZf - targetZf) < 1e-3) return [dMin, dMax];
11874
- if (targetZf > currentZf) {
11875
- const newMin = -(targetZf / (1 - targetZf)) * dMax;
11876
- return [newMin, dMax];
11877
- }
11878
- const newMax = -dMin * (1 - targetZf) / targetZf;
11879
- return [dMin, newMax];
11880
- };
11881
- const domain0 = align(min0, max0, zf0);
11882
- const domain1 = align(min1, max1, zf1);
11883
- return { domain0, domain1 };
11884
- }
11885
- function withYDomain(leaf, domain) {
11886
- if (!leaf.encoding?.y) return leaf;
11887
- return {
11888
- ...leaf,
11889
- encoding: {
11890
- ...leaf.encoding,
11891
- y: {
11892
- ...leaf.encoding.y,
11893
- scale: {
11894
- ...leaf.encoding.y.scale,
11895
- domain
11896
- }
11897
- }
11898
- }
11899
- };
11900
- }
11901
- function buildPrimarySpec(leaves, layerSpec) {
11902
- const allData = leaves.flatMap((leaf) => leaf.data);
11903
- const primary = {
11904
- ...leaves[0],
11905
- data: allData,
11906
- chrome: layerSpec.chrome ?? leaves[0].chrome,
11907
- annotations: layerSpec.annotations ?? leaves[0].annotations,
11908
- labels: layerSpec.labels ?? leaves[0].labels,
11909
- legend: layerSpec.legend ?? leaves[0].legend,
11910
- responsive: layerSpec.responsive ?? leaves[0].responsive,
11911
- theme: layerSpec.theme ?? leaves[0].theme,
11912
- darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
11913
- watermark: layerSpec.watermark ?? leaves[0].watermark,
11914
- hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
11915
- endpointLabels: layerSpec.endpointLabels ?? leaves[0].endpointLabels
11916
- };
11917
- return primary;
11918
- }
11919
-
11920
- // src/compile/watermark-obstacle.ts
11921
- import { BRAND_RESERVE_WIDTH as BRAND_RESERVE_WIDTH2 } from "@opendata-ai/openchart-core";
11922
- var WATERMARK_HEIGHT = 30;
11923
- var X_AXIS_EXTENT_WITH_LABEL = 48;
11924
- var X_AXIS_EXTENT_TICKS_ONLY = 26;
11925
- function computeWatermarkObstacle(dims, watermark, axes, theme) {
11926
- if (!watermark) return null;
11927
- const chartArea = dims.chartArea;
11928
- const brandPadding = theme.spacing.padding;
11929
- const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH2;
11930
- const xAxisExtent = axes.x?.label ? X_AXIS_EXTENT_WITH_LABEL : axes.x ? X_AXIS_EXTENT_TICKS_ONLY : 0;
11931
- const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
11932
- const brandY = firstBottomChrome ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
11933
- return { x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH2, height: WATERMARK_HEIGHT };
11934
- }
11935
-
11936
- // src/compiler/sparkline-defaults.ts
11937
- var TREND_DEADBAND = 5e-4;
11938
- function computeTrend(values) {
11939
- const finite = [];
11940
- for (const v of values) {
11941
- if (Number.isFinite(v)) finite.push(v);
11942
- }
11943
- if (finite.length < 2) return "neutral";
11944
- const n = finite.length;
11945
- let sumX = 0;
11946
- let sumY = 0;
11947
- for (let i = 0; i < n; i++) {
11948
- sumX += i;
11949
- sumY += finite[i];
11950
- }
11951
- const meanX = sumX / n;
11952
- const meanY = sumY / n;
11953
- let num = 0;
11954
- let den = 0;
11955
- for (let i = 0; i < n; i++) {
11956
- const dx = i - meanX;
11957
- num += dx * (finite[i] - meanY);
11958
- den += dx * dx;
11959
- }
11960
- if (den === 0) return "neutral";
11961
- const slope = num / den;
11962
- const scale = Math.abs(meanY) || 1;
11963
- const relSlope = slope / scale;
11964
- if (Math.abs(relSlope) < TREND_DEADBAND) return "neutral";
11965
- return relSlope > 0 ? "up" : "down";
11966
- }
11967
- function trendColor(trend, theme) {
11968
- if (trend === "up") return theme.colors.positive;
11969
- if (trend === "down") return theme.colors.negative;
11970
- return theme.colors.categorical[0];
11971
- }
11972
- function computeTrendFromData(data, yField) {
11973
- if (!yField) return "neutral";
11974
- const values = [];
11975
- for (const row of data) {
11976
- const v = Number(row[yField]);
11977
- if (Number.isFinite(v)) values.push(v);
11895
+ };
11896
+ const scales = computeScales(renderSpec, titleProvArea, renderSpec.data);
11897
+ if (scales.y && scaleSupportsTickCount(scales.y)) {
11898
+ const ticks2 = buildContinuousTicks(scales.y, yTickCount || 5);
11899
+ for (const t of ticks2) {
11900
+ const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
11901
+ if (w > estTickLabelWidth) estTickLabelWidth = w;
11902
+ }
11903
+ }
11904
+ }
11905
+ }
11906
+ const titleFontSize = theme.fonts.sizes.body;
11907
+ const offset = axisTitleOffset2(estTickLabelWidth, titleFontSize, width, yIsInline);
11908
+ const halfGlyph = Math.ceil(titleFontSize / 2);
11909
+ const rotatedLabelMargin = offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
11910
+ gutterWithTitle = Math.max(finalGutter, hPad + rotatedLabelMargin);
11911
+ }
11912
+ finalPlan = {
11913
+ leftGutter: gutterWithTitle,
11914
+ xAxisExtent,
11915
+ legendContent,
11916
+ chrome,
11917
+ yTickValues,
11918
+ yTickCount,
11919
+ converged: prev === null || converged,
11920
+ inlineYLabelInset,
11921
+ endpointLabelsDemoted
11922
+ };
11923
+ break;
11924
+ }
11925
+ prev = { gutter: newGutter, rowCount: legendContent.rowCount, xAxisExtent };
11926
+ leftGutter = newGutter;
11978
11927
  }
11979
- return computeTrend(values);
11980
- }
11981
- function hasExplicitColor(markDef, encodingHasColor) {
11982
- if (encodingHasColor) return { fill: true, stroke: true };
11983
- return {
11984
- fill: markDef?.fill !== void 0,
11985
- stroke: markDef?.stroke !== void 0
11986
- };
11987
- }
11988
- function buildSparklineAreaGradient(baseColor) {
11989
- return {
11990
- gradient: "linear",
11991
- x1: 0,
11992
- y1: 0,
11993
- x2: 0,
11994
- y2: 1,
11995
- stops: [
11996
- { offset: 0, color: baseColor, opacity: 0.35 },
11997
- { offset: 1, color: baseColor, opacity: 0 }
11998
- ]
11999
- };
11928
+ if (!finalPlan) throw new Error("resolveLayoutPlan: loop exited without producing a plan");
11929
+ return finalPlan;
12000
11930
  }
12001
11931
 
12002
- // src/graphs/compile-graph.ts
12003
- import { adaptTheme as adaptTheme2, computeChrome as computeChrome3, resolveTheme as resolveTheme3 } from "@opendata-ai/openchart-core";
12004
-
12005
- // src/graphs/encoding.ts
12006
- var DEFAULT_NODE_RADIUS = 5;
12007
- var MIN_NODE_RADIUS = 3;
12008
- var MAX_NODE_RADIUS = 12;
12009
- var DEFAULT_EDGE_WIDTH = 1;
12010
- var MIN_EDGE_WIDTH = 0.5;
12011
- var MAX_EDGE_WIDTH = 4;
12012
- var DEFAULT_STROKE_WIDTH2 = 1;
12013
- function darkenColor(hex2, amount = 0.2) {
12014
- const clean = hex2.replace(/^#/, "");
12015
- if (clean.length !== 6 && clean.length !== 3) return hex2;
12016
- const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
12017
- const r = Math.max(0, Math.round(parseInt(full.substring(0, 2), 16) * (1 - amount)));
12018
- const g = Math.max(0, Math.round(parseInt(full.substring(2, 4), 16) * (1 - amount)));
12019
- const b = Math.max(0, Math.round(parseInt(full.substring(4, 6), 16) * (1 - amount)));
12020
- return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
12021
- }
12022
- function hexWithOpacity(hex2, opacity) {
12023
- const clean = hex2.replace(/^#/, "");
12024
- if (clean.length !== 6 && clean.length !== 3) {
12025
- return hex2;
12026
- }
12027
- const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
12028
- const r = parseInt(full.substring(0, 2), 16);
12029
- const g = parseInt(full.substring(2, 4), 16);
12030
- const b = parseInt(full.substring(4, 6), 16);
12031
- return `rgba(${r}, ${g}, ${b}, ${opacity})`;
12032
- }
12033
- function computeDegrees(nodes, edges) {
12034
- const degrees = /* @__PURE__ */ new Map();
12035
- for (const node of nodes) {
12036
- degrees.set(node.id, 0);
11932
+ // src/compile/layer.ts
11933
+ function compileLayer(spec, options, compileChart2) {
11934
+ const leaves = flattenLayers(spec);
11935
+ if (leaves.length === 0) {
11936
+ throw new Error("LayerSpec has no leaf chart specs after flattening");
12037
11937
  }
12038
- for (const edge of edges) {
12039
- degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1);
12040
- degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1);
11938
+ if (leaves.length === 1) {
11939
+ const singleSpec = buildPrimarySpec(leaves, spec);
11940
+ return compileChart2(singleSpec, options);
12041
11941
  }
12042
- return degrees;
12043
- }
12044
- function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
12045
- const degrees = computeDegrees(nodes, edges);
12046
- const maxDegree = Math.max(1, ...degrees.values());
12047
- let sizeScale;
12048
- if (encoding.nodeSize?.field) {
12049
- const field = encoding.nodeSize.field;
12050
- const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
12051
- const sizeMin = min2(values) ?? 0;
12052
- const sizeMax = max2(values) ?? 1;
12053
- sizeScale = sqrt2().domain([sizeMin, sizeMax]).range([MIN_NODE_RADIUS, MAX_NODE_RADIUS]);
11942
+ if (spec.resolve?.scale?.y === "independent") {
11943
+ return compileLayerIndependent(leaves, spec, options, compileChart2);
12054
11944
  }
12055
- let colorFn;
12056
- if (encoding.nodeColor?.field) {
12057
- const field = encoding.nodeColor.field;
12058
- const fieldType = encoding.nodeColor.type ?? "nominal";
12059
- const scaleConfig = encoding.nodeColor.scale;
12060
- if (fieldType === "quantitative") {
12061
- const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
12062
- const colorMin = min2(values) ?? 0;
12063
- const colorMax = max2(values) ?? 1;
12064
- const seqPalettes = Object.values(theme.colors.sequential);
12065
- const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
12066
- const domain = scaleConfig?.domain && scaleConfig.domain.length === 2 ? scaleConfig.domain : [colorMin, colorMax];
12067
- const range2 = scaleConfig?.range && scaleConfig.range.length >= 2 ? scaleConfig.range : [palette[0], palette[palette.length - 1]];
12068
- const colorScale = linear2().domain(domain).range(range2);
12069
- colorFn = (node) => {
12070
- const val = Number(node[field]);
12071
- return Number.isFinite(val) ? colorScale(val) : theme.colors.categorical[0];
12072
- };
12073
- } else {
12074
- const domain = scaleConfig?.domain && Array.isArray(scaleConfig.domain) ? scaleConfig.domain : [...new Set(nodes.map((n) => String(n[field] ?? "")))];
12075
- const range2 = scaleConfig?.range && scaleConfig.range.length > 0 ? scaleConfig.range : theme.colors.categorical;
12076
- const ordinalScale = ordinal().domain(domain).range(range2);
12077
- colorFn = (node) => ordinalScale(String(node[field] ?? ""));
12078
- }
11945
+ const primarySpec = buildPrimarySpec(leaves, spec);
11946
+ const primaryLayout = compileChart2(primarySpec, options);
11947
+ const allMarks = [];
11948
+ const seenLabels = /* @__PURE__ */ new Set();
11949
+ const pLegend = primaryLayout.legend;
11950
+ const mergedLegendEntries = "entries" in pLegend ? [...pLegend.entries] : [];
11951
+ for (const entry of mergedLegendEntries) {
11952
+ seenLabels.add(entry.label);
12079
11953
  }
12080
- const defaultColor = theme.colors.categorical[0];
12081
- return nodes.map((node) => {
12082
- let radius = DEFAULT_NODE_RADIUS;
12083
- if (sizeScale && encoding.nodeSize?.field) {
12084
- const val = Number(node[encoding.nodeSize.field]);
12085
- if (Number.isFinite(val)) {
12086
- radius = sizeScale(val);
12087
- }
12088
- }
12089
- const fill = colorFn ? colorFn(node) : defaultColor;
12090
- const stroke = darkenColor(fill);
12091
- let label;
12092
- if (encoding.nodeLabel?.field) {
12093
- const labelVal = node[encoding.nodeLabel.field];
12094
- label = labelVal != null ? String(labelVal) : void 0;
12095
- } else {
12096
- label = node.id;
12097
- }
12098
- const degree = degrees.get(node.id) ?? 0;
12099
- const labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
12100
- const { id: _id, ...rest } = node;
12101
- const data = { id: node.id, ...rest };
12102
- const override = nodeOverrides?.[node.id];
12103
- const finalFill = override?.fill ?? fill;
12104
- const finalRadius = override?.radius ?? radius;
12105
- const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH2;
12106
- const finalStroke = override?.stroke ?? stroke;
12107
- const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
12108
- return {
12109
- id: node.id,
12110
- radius: finalRadius,
12111
- fill: finalFill,
12112
- stroke: finalStroke,
12113
- strokeWidth: finalStrokeWidth,
12114
- label,
12115
- labelPriority: finalLabelPriority,
12116
- community: void 0,
12117
- data
11954
+ const indexedLeaves = leaves.map((leaf, i) => ({
11955
+ leaf,
11956
+ zIndex: leaf.zIndex ?? i
11957
+ }));
11958
+ indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
11959
+ const leafOptions = { ...options, frozenChartArea: primaryLayout.area };
11960
+ for (const { leaf } of indexedLeaves) {
11961
+ const themedLeaf = {
11962
+ ...leaf,
11963
+ theme: leaf.theme ?? spec.theme,
11964
+ darkMode: leaf.darkMode ?? spec.darkMode
12118
11965
  };
12119
- });
12120
- }
12121
- function resolveEdgeVisuals(edges, encoding, theme) {
12122
- let widthScale;
12123
- if (encoding.edgeWidth?.field) {
12124
- const field = encoding.edgeWidth.field;
12125
- const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
12126
- const widthMin = min2(values) ?? 0;
12127
- const widthMax = max2(values) ?? 1;
12128
- widthScale = linear2().domain([widthMin, widthMax]).range([MIN_EDGE_WIDTH, MAX_EDGE_WIDTH]);
12129
- }
12130
- let edgeColorFn;
12131
- if (encoding.edgeColor?.field) {
12132
- const field = encoding.edgeColor.field;
12133
- const fieldType = encoding.edgeColor.type ?? "nominal";
12134
- const scaleConfig = encoding.edgeColor.scale;
12135
- if (fieldType === "quantitative") {
12136
- const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
12137
- const colorMin = min2(values) ?? 0;
12138
- const colorMax = max2(values) ?? 1;
12139
- const seqPalettes = Object.values(theme.colors.sequential);
12140
- const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
12141
- const domain = scaleConfig?.domain && scaleConfig.domain.length === 2 ? scaleConfig.domain : [colorMin, colorMax];
12142
- const range2 = scaleConfig?.range && scaleConfig.range.length >= 2 ? scaleConfig.range : [palette[0], palette[palette.length - 1]];
12143
- const colorScale = linear2().domain(domain).range(range2);
12144
- edgeColorFn = (edge) => {
12145
- const val = Number(edge[field]);
12146
- return Number.isFinite(val) ? colorScale(val) : hexWithOpacity(theme.colors.axis, 0.4);
12147
- };
12148
- } else {
12149
- const domain = scaleConfig?.domain && Array.isArray(scaleConfig.domain) ? scaleConfig.domain : [...new Set(edges.map((e) => String(e[field] ?? "")))];
12150
- const range2 = scaleConfig?.range && scaleConfig.range.length > 0 ? scaleConfig.range : theme.colors.categorical;
12151
- const ordinalScale = ordinal().domain(domain).range(range2);
12152
- edgeColorFn = (edge) => ordinalScale(String(edge[field] ?? ""));
12153
- }
12154
- }
12155
- const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
12156
- const EDGE_STYLES = ["solid", "dashed", "dotted"];
12157
- let styleFn;
12158
- if (encoding.edgeStyle?.field) {
12159
- const field = encoding.edgeStyle.field;
12160
- const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? "")))];
12161
- const styleMap = /* @__PURE__ */ new Map();
12162
- for (let i = 0; i < uniqueValues.length; i++) {
12163
- styleMap.set(uniqueValues[i], EDGE_STYLES[i % EDGE_STYLES.length]);
12164
- }
12165
- styleFn = (edge) => styleMap.get(String(edge[field] ?? "")) ?? "solid";
12166
- }
12167
- return edges.map((edge) => {
12168
- const { source, target, ...rest } = edge;
12169
- let strokeWidth = DEFAULT_EDGE_WIDTH;
12170
- if (widthScale && encoding.edgeWidth?.field) {
12171
- const val = Number(edge[encoding.edgeWidth.field]);
12172
- if (Number.isFinite(val)) {
12173
- strokeWidth = widthScale(val);
11966
+ const leafLayout = compileChart2(themedLeaf, leafOptions);
11967
+ allMarks.push(...leafLayout.marks);
11968
+ const leafLeg = leafLayout.legend;
11969
+ if ("entries" in leafLeg) {
11970
+ for (const entry of leafLeg.entries) {
11971
+ if (!seenLabels.has(entry.label)) {
11972
+ seenLabels.add(entry.label);
11973
+ mergedLegendEntries.push(entry);
11974
+ }
12174
11975
  }
12175
11976
  }
12176
- const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
12177
- const style = styleFn ? styleFn(edge) : "solid";
12178
- return {
12179
- source,
12180
- target,
12181
- stroke,
12182
- strokeWidth,
12183
- style,
12184
- data: { source, target, ...rest }
12185
- };
12186
- });
12187
- }
12188
-
12189
- // src/graphs/community.ts
12190
- function assignCommunities(nodes, clusteringField) {
12191
- if (!clusteringField) return;
12192
- for (const node of nodes) {
12193
- const value2 = node.data[clusteringField];
12194
- node.community = value2 != null ? String(value2) : void 0;
12195
11977
  }
11978
+ return {
11979
+ ...primaryLayout,
11980
+ marks: allMarks,
11981
+ legend: {
11982
+ ...primaryLayout.legend,
11983
+ ..."entries" in pLegend ? { entries: mergedLegendEntries } : {}
11984
+ }
11985
+ };
12196
11986
  }
12197
- function buildCommunityColorMap(nodes, theme) {
12198
- const colorMap = /* @__PURE__ */ new Map();
12199
- const palette = theme.colors.categorical;
12200
- let colorIndex = 0;
12201
- for (const node of nodes) {
12202
- if (node.community != null && !colorMap.has(node.community)) {
12203
- colorMap.set(node.community, palette[colorIndex % palette.length]);
12204
- colorIndex++;
11987
+ function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
11988
+ if (!encoding?.y) return 40;
11989
+ const yEnc = encoding.y;
11990
+ const yField = yEnc.field;
11991
+ if (!yField) return 40;
11992
+ const m = measure ?? estimateTextWidth15;
11993
+ const yType = yEnc.type;
11994
+ if (yType === "nominal" || yType === "ordinal") {
11995
+ let maxWidth = 0;
11996
+ for (const row of data) {
11997
+ const label = String(row[yField] ?? "");
11998
+ const w = m(label, baseFontSize, 400);
11999
+ if (w > maxWidth) maxWidth = w;
12205
12000
  }
12001
+ return maxWidth > 0 ? maxWidth + 10 : 40;
12206
12002
  }
12207
- return colorMap;
12208
- }
12209
- function applyCommunityColors(nodes, colorMap) {
12210
- for (const node of nodes) {
12211
- if (node.community != null) {
12212
- const communityColor = colorMap.get(node.community);
12213
- if (communityColor) {
12214
- node.fill = communityColor;
12215
- node.stroke = darkenColor(communityColor);
12216
- }
12003
+ const yAxisFormat = encoding.y.axis?.format;
12004
+ let maxAbsVal = 0;
12005
+ for (const row of data) {
12006
+ const v = Number(row[yField]);
12007
+ if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
12008
+ }
12009
+ let sampleLabel;
12010
+ if (yAxisFormat) {
12011
+ try {
12012
+ const fmt = format(yAxisFormat);
12013
+ sampleLabel = fmt(maxAbsVal);
12014
+ } catch {
12015
+ sampleLabel = String(maxAbsVal);
12217
12016
  }
12017
+ } else {
12018
+ sampleLabel = Math.abs(maxAbsVal) >= 1e3 ? abbreviateNumber4(maxAbsVal) : formatNumber5(maxAbsVal);
12218
12019
  }
12020
+ const hasNeg = data.some((r) => Number(r[yField]) < 0);
12021
+ const labelEst = (hasNeg ? "-" : "") + sampleLabel;
12022
+ return m(labelEst, baseFontSize, 400) + 10;
12219
12023
  }
12220
-
12221
- // src/graphs/compile-graph.ts
12222
- var SWATCH_SIZE2 = 12;
12223
- var SWATCH_GAP2 = 6;
12224
- var ENTRY_GAP2 = 16;
12225
- function buildGraphLegend(nodes, communityColorMap, hasCommunities, theme, nodeColorField) {
12226
- const labelStyle = {
12227
- fontFamily: theme.fonts.family,
12228
- fontSize: theme.fonts.sizes.small,
12229
- fontWeight: theme.fonts.weights.normal,
12230
- fill: theme.colors.text,
12231
- lineHeight: 1.3
12024
+ function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
12025
+ if (leaves.length > 2) {
12026
+ throw new Error(
12027
+ `Independent y-scales support at most 2 layers (left and right y-axis). Got ${leaves.length} layers.`
12028
+ );
12029
+ }
12030
+ const leaf0 = leaves[0];
12031
+ const leaf1 = leaves[1];
12032
+ const xType0 = leaf0.encoding?.x?.type;
12033
+ const xType1 = leaf1.encoding?.x?.type;
12034
+ if (xType0 && xType1 && xType0 !== xType1) {
12035
+ throw new Error(
12036
+ `Dual-axis charts require matching x-field types across layers. Layer 0 has '${xType0}', layer 1 has '${xType1}'.`
12037
+ );
12038
+ }
12039
+ const theme = resolveTheme2(layerSpec.theme ?? leaf1.theme);
12040
+ const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
12041
+ const measureFn = createMeasureFn(options.measureText);
12042
+ const rightAxisWidth = estimateYAxisLabelWidth(
12043
+ leaf1.data,
12044
+ leaf1.encoding,
12045
+ axisFontSize,
12046
+ measureFn
12047
+ );
12048
+ const yAxisConfig = leaf1.encoding?.y?.axis || void 0;
12049
+ const hasRightAxisTitle = !!yAxisConfig?.title;
12050
+ const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
12051
+ const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
12052
+ const axisTitleOffset4 = getAxisTitleOffset2(options.width);
12053
+ const halfGlyph = Math.ceil(bodyFontSize / 2);
12054
+ const titleExtent = hasRightAxisTitle ? axisTitleOffset4 + halfGlyph + (options.width < BREAKPOINT_COMPACT_MAX2 ? 0 : AXIS_TITLE_TRAILING_PAD2) : 0;
12055
+ const rightReserve = Math.max(tickExtent, titleExtent);
12056
+ const optionsWithReserve = {
12057
+ ...options,
12058
+ rightAxisReserve: rightReserve
12232
12059
  };
12233
- let entries;
12234
- if (hasCommunities && communityColorMap.size > 0) {
12235
- entries = [...communityColorMap.entries()].map(([label, color2]) => ({
12236
- label,
12237
- color: color2,
12238
- shape: "circle",
12239
- active: true
12240
- }));
12241
- } else {
12242
- const categoryColors = /* @__PURE__ */ new Map();
12243
- for (const node of nodes) {
12244
- const category = nodeColorField ? String(node.data[nodeColorField] ?? node.label ?? node.id) : node.label ?? node.id;
12245
- if (!categoryColors.has(category)) {
12246
- categoryColors.set(category, node.fill);
12060
+ const xField0 = leaf0.encoding?.x?.field;
12061
+ const xField1 = leaf1.encoding?.x?.field;
12062
+ const unionXValues = /* @__PURE__ */ new Set();
12063
+ if (xField0) for (const row of leaf0.data) unionXValues.add(row[xField0]);
12064
+ if (xField1) for (const row of leaf1.data) unionXValues.add(row[xField1]);
12065
+ let leaf0WithUnionX = ensureXDomainCoverage(leaf0, xField0, unionXValues);
12066
+ let leaf1WithUnionX = ensureXDomainCoverage(leaf1, xField1, unionXValues);
12067
+ const aligned = alignYDomains(leaf0WithUnionX, leaf1WithUnionX);
12068
+ if (aligned) {
12069
+ leaf0WithUnionX = withYDomain(leaf0WithUnionX, aligned.domain0);
12070
+ leaf1WithUnionX = withYDomain(leaf1WithUnionX, aligned.domain1);
12071
+ }
12072
+ const primary0 = buildPrimarySpec([leaf0WithUnionX], layerSpec);
12073
+ const layout0 = compileChart2(primary0, optionsWithReserve);
12074
+ const primary1 = buildPrimarySpec([leaf1WithUnionX], layerSpec);
12075
+ primary1.annotations = [];
12076
+ const layout1 = compileChart2(primary1, optionsWithReserve);
12077
+ const y2Axis = layout1.axes.y ? {
12078
+ ...layout1.axes.y,
12079
+ orient: "right",
12080
+ gridlines: [],
12081
+ start: {
12082
+ x: layout0.area.x + layout0.area.width,
12083
+ y: layout0.area.y
12084
+ },
12085
+ end: {
12086
+ x: layout0.area.x + layout0.area.width,
12087
+ y: layout0.area.y + layout0.area.height
12088
+ },
12089
+ ...layout1.axes.y.label ? {
12090
+ titlePosition: {
12091
+ x: layout0.area.x + layout0.area.width + getAxisTitleOffset2(layout0.dimensions.width),
12092
+ y: layout0.area.y + layout0.area.height / 2,
12093
+ angle: 90
12247
12094
  }
12095
+ } : {}
12096
+ } : void 0;
12097
+ const layer0HasBars = layout0.marks.some((m) => m.type === "rect");
12098
+ const layer1HasBars = layout1.marks.some((m) => m.type === "rect");
12099
+ const bandCenterByCategory = /* @__PURE__ */ new Map();
12100
+ if (layer0HasBars && layout0.axes.x?.ticks) {
12101
+ for (const tick of layout0.axes.x.ticks) {
12102
+ bandCenterByCategory.set(String(tick.label), tick.position);
12248
12103
  }
12249
- if (categoryColors.size <= 1) {
12250
- entries = [];
12104
+ } else if (layer1HasBars && layout1.axes.x?.ticks) {
12105
+ for (const tick of layout1.axes.x.ticks) {
12106
+ bandCenterByCategory.set(String(tick.label), tick.position);
12107
+ }
12108
+ }
12109
+ const remapMarkX = (xField, mark) => {
12110
+ if (!xField || bandCenterByCategory.size === 0) return mark;
12111
+ if (mark.type === "line") {
12112
+ const newPoints = mark.points.map((p, i) => {
12113
+ const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
12114
+ return bx !== void 0 ? { ...p, x: bx } : p;
12115
+ });
12116
+ const newDataPoints = mark.dataPoints?.map((dp, i) => {
12117
+ const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
12118
+ return bx !== void 0 ? { ...dp, x: bx } : dp;
12119
+ });
12120
+ const newPath = line_default().x((p) => p.x).y((p) => p.y).curve(monotoneX)(newPoints) ?? void 0;
12121
+ return { ...mark, points: newPoints, dataPoints: newDataPoints, path: newPath };
12122
+ }
12123
+ if (mark.type === "area") {
12124
+ const newTopPoints = mark.topPoints.map((p, i) => {
12125
+ const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
12126
+ return bx !== void 0 ? { ...p, x: bx } : p;
12127
+ });
12128
+ const newBottomPoints = mark.bottomPoints.map((p, i) => {
12129
+ const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
12130
+ return bx !== void 0 ? { ...p, x: bx } : p;
12131
+ });
12132
+ const newDataPoints = mark.dataPoints?.map((dp, i) => {
12133
+ const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
12134
+ return bx !== void 0 ? { ...dp, x: bx } : dp;
12135
+ });
12136
+ const areaGen = area_default().x((p) => p.x).y0((p) => p.yBottom).y1((p) => p.yTop).curve(monotoneX);
12137
+ const topLineGen = line_default().x((p) => p.x).y((p) => p.yTop).curve(monotoneX);
12138
+ const combined = newTopPoints.map((tp, i) => ({
12139
+ x: tp.x,
12140
+ yTop: tp.y,
12141
+ yBottom: newBottomPoints[i]?.y ?? tp.y
12142
+ }));
12143
+ const newPath = areaGen(combined) ?? "";
12144
+ const newTopPath = topLineGen(combined) ?? "";
12145
+ return {
12146
+ ...mark,
12147
+ topPoints: newTopPoints,
12148
+ bottomPoints: newBottomPoints,
12149
+ dataPoints: newDataPoints,
12150
+ path: newPath,
12151
+ topPath: newTopPath
12152
+ };
12153
+ }
12154
+ if (mark.type === "point") {
12155
+ const bx = bandCenterByCategory.get(String(mark.data[xField] ?? ""));
12156
+ return bx !== void 0 ? { ...mark, cx: bx } : mark;
12157
+ }
12158
+ return mark;
12159
+ };
12160
+ const adjustedMarks0 = bandCenterByCategory.size > 0 && !layer0HasBars ? layout0.marks.map((m) => remapMarkX(xField0, m)) : layout0.marks;
12161
+ const taggedMarks1 = layout1.marks.map((mark) => {
12162
+ const tagged = { ...mark, yScale: "y2" };
12163
+ if (bandCenterByCategory.size > 0 && !layer1HasBars) {
12164
+ return remapMarkX(xField1, tagged);
12165
+ }
12166
+ return tagged;
12167
+ });
12168
+ const seenLabels = /* @__PURE__ */ new Set();
12169
+ const l0Legend = layout0.legend;
12170
+ const l1Legend = layout1.legend;
12171
+ const mergedLegendEntries = "entries" in l0Legend ? [...l0Legend.entries] : [];
12172
+ for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
12173
+ const l1Entries = "entries" in l1Legend ? l1Legend.entries : [];
12174
+ for (const entry of l1Entries) {
12175
+ if (!seenLabels.has(entry.label)) {
12176
+ seenLabels.add(entry.label);
12177
+ mergedLegendEntries.push(entry);
12178
+ }
12179
+ }
12180
+ const l0Count = layout0.marks.length;
12181
+ const mergedTooltips = new Map(layout0.tooltipDescriptors);
12182
+ for (const [key, value2] of layout1.tooltipDescriptors) {
12183
+ const match = /^(rect|point|arc)-(\d+)$/.exec(key);
12184
+ if (match) {
12185
+ const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
12186
+ mergedTooltips.set(offsetKey, value2);
12251
12187
  } else {
12252
- entries = [...categoryColors.entries()].map(([label, color2]) => ({
12253
- label,
12254
- color: color2,
12255
- shape: "circle",
12256
- active: true
12257
- }));
12188
+ mergedTooltips.set(key, value2);
12258
12189
  }
12259
12190
  }
12191
+ const z0 = leaf0.zIndex ?? 0;
12192
+ const z1 = leaf1.zIndex ?? 1;
12193
+ const marks = z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
12260
12194
  return {
12261
- position: "top",
12262
- entries,
12263
- bounds: { x: 0, y: 0, width: 0, height: 0 },
12264
- labelStyle,
12265
- swatchSize: SWATCH_SIZE2,
12266
- swatchGap: SWATCH_GAP2,
12267
- entryGap: ENTRY_GAP2,
12268
- swatchChipFill: theme.colors.annotationFill
12195
+ ...layout0,
12196
+ axes: {
12197
+ x: layout0.axes.x,
12198
+ y: layout0.axes.y,
12199
+ y2: y2Axis
12200
+ },
12201
+ marks,
12202
+ legend: {
12203
+ ...layout0.legend,
12204
+ ..."entries" in l0Legend ? { entries: mergedLegendEntries } : {}
12205
+ },
12206
+ tooltipDescriptors: mergedTooltips
12269
12207
  };
12270
12208
  }
12271
- function buildGraphTooltips(nodes) {
12272
- const descriptors = /* @__PURE__ */ new Map();
12273
- for (const node of nodes) {
12274
- const fields = [];
12275
- if (node.community != null) {
12276
- fields.push({
12277
- label: "Community",
12278
- value: node.community,
12279
- color: node.fill
12280
- });
12281
- }
12282
- for (const [key, value2] of Object.entries(node.data)) {
12283
- if (key === "id") continue;
12284
- if (value2 == null) continue;
12285
- fields.push({
12286
- label: key,
12287
- value: typeof value2 === "number" ? value2.toLocaleString() : String(value2)
12288
- });
12209
+ function ensureXDomainCoverage(leaf, xField, allXValues) {
12210
+ if (!xField || allXValues.size === 0) return leaf;
12211
+ const existingXValues = /* @__PURE__ */ new Set();
12212
+ for (const row of leaf.data) existingXValues.add(row[xField]);
12213
+ const missingRows = [];
12214
+ for (const xVal of allXValues) {
12215
+ if (!existingXValues.has(xVal)) {
12216
+ missingRows.push({ [xField]: xVal });
12289
12217
  }
12290
- descriptors.set(node.id, {
12291
- title: node.label ?? node.id,
12292
- fields
12293
- });
12294
12218
  }
12295
- return descriptors;
12219
+ if (missingRows.length === 0) return leaf;
12220
+ return {
12221
+ ...leaf,
12222
+ data: [...leaf.data, ...missingRows]
12223
+ };
12296
12224
  }
12297
- function compileGraph(spec, options) {
12298
- const { spec: normalized } = compile(spec);
12299
- if (!("type" in normalized) || normalized.type !== "graph") {
12300
- throw new Error(
12301
- "compileGraph received a non-graph spec. Use compileChart or compileTable instead."
12302
- );
12303
- }
12304
- const graphSpec = normalized;
12305
- const rawWatermark = spec.watermark;
12306
- const watermark = rawWatermark !== void 0 ? graphSpec.watermark : options.watermark ?? true;
12307
- const mergedThemeConfig = options.theme ? { ...graphSpec.theme, ...options.theme } : graphSpec.theme;
12308
- let theme = resolveTheme3(mergedThemeConfig);
12309
- if (options.darkMode) {
12310
- theme = adaptTheme2(theme);
12311
- }
12312
- const compiledNodes = resolveNodeVisuals(
12313
- graphSpec.nodes,
12314
- graphSpec.encoding,
12315
- graphSpec.edges,
12316
- theme,
12317
- graphSpec.nodeOverrides
12318
- );
12319
- const clusteringField = graphSpec.layout.clustering?.field;
12320
- const hasCommunities = !!clusteringField;
12321
- assignCommunities(compiledNodes, clusteringField);
12322
- const hasNodeColorEncoding = !!graphSpec.encoding.nodeColor?.field;
12323
- let communityColorMap = /* @__PURE__ */ new Map();
12324
- if (hasCommunities && !hasNodeColorEncoding) {
12325
- communityColorMap = buildCommunityColorMap(compiledNodes, theme);
12326
- applyCommunityColors(compiledNodes, communityColorMap);
12327
- }
12328
- const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme);
12329
- const useCommunitiesForLegend = hasCommunities && !hasNodeColorEncoding;
12330
- const legend = buildGraphLegend(
12331
- compiledNodes,
12332
- communityColorMap,
12333
- useCommunitiesForLegend,
12334
- theme,
12335
- graphSpec.encoding.nodeColor?.field
12336
- );
12337
- const tooltipDescriptors = buildGraphTooltips(compiledNodes);
12338
- const communityCount = communityColorMap.size;
12339
- const altParts = [
12340
- `Network graph with ${compiledNodes.length} nodes and ${compiledEdges.length} edges`
12341
- ];
12342
- if (communityCount > 0) {
12343
- altParts.push(`organized into ${communityCount} communities`);
12344
- }
12345
- const a11y = {
12346
- altText: altParts.join(", "),
12347
- dataTableFallback: compiledNodes.map((n) => [n.id, n.community ?? "", n.label ?? ""]),
12348
- role: "img",
12349
- keyboardNavigable: compiledNodes.length > 0
12225
+ function alignYDomains(leaf0, leaf1) {
12226
+ const yEnc0 = leaf0.encoding?.y;
12227
+ const yEnc1 = leaf1.encoding?.y;
12228
+ if (!yEnc0 || !yEnc1) return void 0;
12229
+ if (yEnc0.type !== "quantitative" || yEnc1.type !== "quantitative") return void 0;
12230
+ if (yEnc0.scale?.domain || yEnc1.scale?.domain) return void 0;
12231
+ const includeZero0 = yEnc0.scale?.zero !== false;
12232
+ const includeZero1 = yEnc1.scale?.zero !== false;
12233
+ const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
12234
+ const vals1 = leaf1.data.map((r) => Number(r[yEnc1.field])).filter(Number.isFinite);
12235
+ if (vals0.length === 0 || vals1.length === 0) return void 0;
12236
+ const niced = (vals, includeZero) => {
12237
+ let lo = Math.min(...vals);
12238
+ let hi = Math.max(...vals);
12239
+ if (includeZero) {
12240
+ lo = Math.min(0, lo);
12241
+ hi = Math.max(0, hi);
12242
+ }
12243
+ const s = linear2().domain([lo, hi]);
12244
+ s.nice();
12245
+ const [dLo, dHi] = s.domain();
12246
+ return [dLo, dHi];
12350
12247
  };
12351
- const collisionPadding = graphSpec.layout.collisionPadding ?? 2;
12352
- const maxRadius = compiledNodes.length > 0 ? Math.max(...compiledNodes.map((n) => n.radius)) : DEFAULT_COLLISION_PADDING;
12353
- const simulationConfig = {
12354
- chargeStrength: graphSpec.layout.chargeStrength ?? -300,
12355
- linkDistance: graphSpec.layout.linkDistance ?? 30,
12356
- clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
12357
- alphaDecay: 0.0228,
12358
- velocityDecay: 0.4,
12359
- collisionRadius: maxRadius + collisionPadding,
12360
- collisionPadding,
12361
- linkStrength: graphSpec.layout.linkStrength,
12362
- centerForce: graphSpec.layout.centerForce
12248
+ const [min0, max0] = niced(vals0, includeZero0);
12249
+ const [min1, max1] = niced(vals1, includeZero1);
12250
+ const span0 = max0 - min0;
12251
+ const span1 = max1 - min1;
12252
+ if (span0 === 0 || span1 === 0) return void 0;
12253
+ const zf0 = (0 - min0) / span0;
12254
+ const zf1 = (0 - min1) / span1;
12255
+ const zeroInDomain0 = zf0 >= -1e-3 && zf0 <= 1.001;
12256
+ const zeroInDomain1 = zf1 >= -1e-3 && zf1 <= 1.001;
12257
+ if (!zeroInDomain0 || !zeroInDomain1) return void 0;
12258
+ if (Math.abs(zf0 - zf1) < 1e-3) {
12259
+ return { domain0: [min0, max0], domain1: [min1, max1] };
12260
+ }
12261
+ const targetZf = Math.max(zf0, zf1);
12262
+ const align = (dMin, dMax, currentZf) => {
12263
+ if (Math.abs(currentZf - targetZf) < 1e-3) return [dMin, dMax];
12264
+ if (targetZf > currentZf) {
12265
+ const newMin = -(targetZf / (1 - targetZf)) * dMax;
12266
+ return [newMin, dMax];
12267
+ }
12268
+ const newMax = -dMin * (1 - targetZf) / targetZf;
12269
+ return [dMin, newMax];
12363
12270
  };
12364
- const chrome = computeChrome3(
12365
- {
12366
- title: graphSpec.chrome.title,
12367
- subtitle: graphSpec.chrome.subtitle,
12368
- source: graphSpec.chrome.source,
12369
- byline: graphSpec.chrome.byline,
12370
- footer: graphSpec.chrome.footer
12371
- },
12372
- theme,
12373
- options.width,
12374
- options.measureText,
12375
- "full",
12376
- void 0,
12377
- watermark
12378
- );
12271
+ const domain0 = align(min0, max0, zf0);
12272
+ const domain1 = align(min1, max1, zf1);
12273
+ return { domain0, domain1 };
12274
+ }
12275
+ function withYDomain(leaf, domain) {
12276
+ if (!leaf.encoding?.y) return leaf;
12379
12277
  return {
12380
- nodes: compiledNodes,
12381
- edges: compiledEdges,
12382
- legend,
12383
- chrome,
12384
- tooltipDescriptors,
12385
- a11y,
12386
- theme,
12387
- dimensions: {
12388
- width: options.width,
12389
- height: options.height
12390
- },
12391
- simulationConfig,
12392
- watermark
12278
+ ...leaf,
12279
+ encoding: {
12280
+ ...leaf.encoding,
12281
+ y: {
12282
+ ...leaf.encoding.y,
12283
+ scale: {
12284
+ ...leaf.encoding.y.scale,
12285
+ domain
12286
+ }
12287
+ }
12288
+ }
12393
12289
  };
12394
12290
  }
12395
- var DEFAULT_COLLISION_PADDING = 5;
12396
-
12397
- // src/layout/axes.ts
12398
- import {
12399
- axisTitleOffset as axisTitleOffset2,
12400
- computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
12401
- estimateTextWidth as estimateTextWidth15,
12402
- getAxisTitleOffset as getAxisTitleOffset2,
12403
- X_AXIS_TITLE_BAND_ROTATED
12404
- } from "@opendata-ai/openchart-core";
12291
+ function buildPrimarySpec(leaves, layerSpec) {
12292
+ const allData = leaves.flatMap((leaf) => leaf.data);
12293
+ const primary = {
12294
+ ...leaves[0],
12295
+ data: allData,
12296
+ chrome: layerSpec.chrome ?? leaves[0].chrome,
12297
+ annotations: layerSpec.annotations ?? leaves[0].annotations,
12298
+ labels: layerSpec.labels ?? leaves[0].labels,
12299
+ legend: layerSpec.legend ?? leaves[0].legend,
12300
+ responsive: layerSpec.responsive ?? leaves[0].responsive,
12301
+ theme: layerSpec.theme ?? leaves[0].theme,
12302
+ darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
12303
+ watermark: layerSpec.watermark ?? leaves[0].watermark,
12304
+ hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
12305
+ endpointLabels: layerSpec.endpointLabels ?? leaves[0].endpointLabels
12306
+ };
12307
+ return primary;
12308
+ }
12405
12309
 
12406
- // src/layout/axes/thinning.ts
12407
- import { estimateTextWidth as estimateTextWidth14 } from "@opendata-ai/openchart-core";
12408
- var MIN_TICK_GAP_FACTOR = 0.5;
12409
- var MIN_TICK_COUNT = 2;
12410
- function measureLabel(text, fontSize, fontWeight, measureText) {
12411
- return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth14(text, fontSize, fontWeight);
12310
+ // src/compile/watermark-obstacle.ts
12311
+ import { BRAND_RESERVE_WIDTH as BRAND_RESERVE_WIDTH2 } from "@opendata-ai/openchart-core";
12312
+ var WATERMARK_HEIGHT = 30;
12313
+ var X_AXIS_EXTENT_WITH_LABEL = 48;
12314
+ var X_AXIS_EXTENT_TICKS_ONLY = 26;
12315
+ function computeWatermarkObstacle(dims, watermark, axes, theme) {
12316
+ if (!watermark) return null;
12317
+ const chartArea = dims.chartArea;
12318
+ const brandPadding = theme.spacing.padding;
12319
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH2;
12320
+ const xAxisExtent = axes.x?.label ? X_AXIS_EXTENT_WITH_LABEL : axes.x ? X_AXIS_EXTENT_TICKS_ONLY : 0;
12321
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
12322
+ const brandY = firstBottomChrome ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
12323
+ return { x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH2, height: WATERMARK_HEIGHT };
12412
12324
  }
12413
- function ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
12414
- if (ticks2.length < 2) return false;
12415
- const minGap = fontSize * MIN_TICK_GAP_FACTOR;
12416
- if (orientation === "vertical") {
12417
- const sorted = [...ticks2].sort((a, b) => a.position - b.position);
12418
- const labelHeight = fontSize * 1.2;
12419
- for (let i = 0; i < sorted.length - 1; i++) {
12420
- const aBottom = sorted[i].position + labelHeight / 2;
12421
- const bTop = sorted[i + 1].position - labelHeight / 2;
12422
- if (aBottom + minGap > bTop) return true;
12423
- }
12424
- return false;
12325
+
12326
+ // src/compiler/sparkline-defaults.ts
12327
+ var TREND_DEADBAND = 5e-4;
12328
+ function computeTrend(values) {
12329
+ const finite = [];
12330
+ for (const v of values) {
12331
+ if (Number.isFinite(v)) finite.push(v);
12425
12332
  }
12426
- for (let i = 0; i < ticks2.length - 1; i++) {
12427
- const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText);
12428
- const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText);
12429
- const aRight = ticks2[i].position + aWidth / 2;
12430
- const bLeft = ticks2[i + 1].position - bWidth / 2;
12431
- if (aRight + minGap > bLeft) return true;
12333
+ if (finite.length < 2) return "neutral";
12334
+ const n = finite.length;
12335
+ let sumX = 0;
12336
+ let sumY = 0;
12337
+ for (let i = 0; i < n; i++) {
12338
+ sumX += i;
12339
+ sumY += finite[i];
12432
12340
  }
12433
- return false;
12434
- }
12435
- function thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
12436
- if (!ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation)) return ticks2;
12437
- let current = ticks2;
12438
- while (current.length > MIN_TICK_COUNT) {
12439
- const thinned = [current[0]];
12440
- for (let i = 2; i < current.length - 1; i += 2) {
12441
- thinned.push(current[i]);
12442
- }
12443
- if (current.length > 1) thinned.push(current[current.length - 1]);
12444
- current = thinned;
12445
- if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
12341
+ const meanX = sumX / n;
12342
+ const meanY = sumY / n;
12343
+ let num = 0;
12344
+ let den = 0;
12345
+ for (let i = 0; i < n; i++) {
12346
+ const dx = i - meanX;
12347
+ num += dx * (finite[i] - meanY);
12348
+ den += dx * dx;
12446
12349
  }
12447
- return current;
12350
+ if (den === 0) return "neutral";
12351
+ const slope = num / den;
12352
+ const scale = Math.abs(meanY) || 1;
12353
+ const relSlope = slope / scale;
12354
+ if (Math.abs(relSlope) < TREND_DEADBAND) return "neutral";
12355
+ return relSlope > 0 ? "up" : "down";
12448
12356
  }
12449
-
12450
- // src/layout/axes.ts
12451
- var HEIGHT_MINIMAL_THRESHOLD = 80;
12452
- var HEIGHT_REDUCED_THRESHOLD = 100;
12453
- var WIDTH_MINIMAL_THRESHOLD = 150;
12454
- var WIDTH_REDUCED_THRESHOLD = 300;
12455
- var DENSITY_ORDER = ["full", "reduced", "minimal"];
12456
- var GROUNDED_MARKS = /* @__PURE__ */ new Set(["bar", "tick", "lollipop"]);
12457
- function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
12458
- let density = baseDensity;
12459
- if (axisLength < minimalThreshold) {
12460
- density = "minimal";
12461
- } else if (axisLength < reducedThreshold) {
12462
- const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
12463
- const reducedIdx = DENSITY_ORDER.indexOf("reduced");
12464
- density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
12465
- }
12466
- return density;
12357
+ function trendColor(trend, theme) {
12358
+ if (trend === "up") return theme.colors.positive;
12359
+ if (trend === "down") return theme.colors.negative;
12360
+ return theme.colors.categorical[0];
12467
12361
  }
12468
- var CONTINUOUS_TICK_FLOOR = 4;
12469
- var OVERSHOOT_TOLERANCE = 1.5;
12470
- var MIN_QUANTITATIVE_TICKS = 2;
12471
- function ensureMinContinuousTicks(scale, ticks2, requestedCount) {
12472
- if (ticks2.length >= MIN_QUANTITATIVE_TICKS) return ticks2;
12473
- if (!scale || !scaleSupportsTickCount(scale)) return ticks2;
12474
- let best = ticks2;
12475
- for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
12476
- const candidate = buildContinuousTicks(scale, n);
12477
- if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
12478
- if (candidate.length > best.length) best = candidate;
12362
+ function computeTrendFromData(data, yField) {
12363
+ if (!yField) return "neutral";
12364
+ const values = [];
12365
+ for (const row of data) {
12366
+ const v = Number(row[yField]);
12367
+ if (Number.isFinite(v)) values.push(v);
12479
12368
  }
12480
- return best;
12369
+ return computeTrend(values);
12481
12370
  }
12482
- function fitContinuousTicks(scale, initialTicks, initialCount, fontSize, fontWeight, axisLength, orientation, measureText) {
12483
- if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
12484
- const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
12485
- const tolerance = initialCount * OVERSHOOT_TOLERANCE;
12486
- const overshoots = flooredTicks.length > tolerance;
12487
- const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
12488
- if (!overshoots && !overlaps) return flooredTicks;
12489
- const minThreshold = orientation === "vertical" ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
12490
- const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
12491
- let bestWithinFloor;
12492
- for (let n = initialCount - 1; n >= 2; n--) {
12493
- const candidate = buildContinuousTicks(scale, n);
12494
- const candidateOvershoots = candidate.length > tolerance;
12495
- const candidateOverlaps = ticksOverlap(
12496
- candidate,
12497
- fontSize,
12498
- fontWeight,
12499
- measureText,
12500
- orientation
12501
- );
12502
- if (!candidateOvershoots && !candidateOverlaps) {
12503
- return candidate;
12504
- }
12505
- if (candidate.length >= floor) bestWithinFloor = candidate;
12506
- }
12507
- const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
12508
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
12509
- return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
12371
+ function hasExplicitColor(markDef, encodingHasColor) {
12372
+ if (encodingHasColor) return { fill: true, stroke: true };
12373
+ return {
12374
+ fill: markDef?.fill !== void 0,
12375
+ stroke: markDef?.stroke !== void 0
12376
+ };
12510
12377
  }
12511
- function bandTicksOverlapAtAngle(ticks2, angleDeg, fontSize, fontWeight, measureText) {
12512
- if (ticks2.length < 2) return false;
12513
- const angleRad = Math.abs(angleDeg) * Math.PI / 180;
12514
- const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
12515
- const minGap = fontSize * 0.5;
12516
- for (let i = 0; i < ticks2.length - 1; i++) {
12517
- const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText) * cosA;
12518
- const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText) * cosA;
12519
- const aRight = ticks2[i].position + aWidth / 2;
12520
- const bLeft = ticks2[i + 1].position - bWidth / 2;
12521
- if (aRight + minGap > bLeft) return true;
12522
- }
12523
- return false;
12378
+ function buildSparklineAreaGradient(baseColor) {
12379
+ return {
12380
+ gradient: "linear",
12381
+ x1: 0,
12382
+ y1: 0,
12383
+ x2: 0,
12384
+ y2: 1,
12385
+ stops: [
12386
+ { offset: 0, color: baseColor, opacity: 0.35 },
12387
+ { offset: 1, color: baseColor, opacity: 0 }
12388
+ ]
12389
+ };
12524
12390
  }
12525
- function thinBandTicksIfNeeded(ticks2, angleDeg, fontSize, fontWeight, measureText) {
12526
- if (!bandTicksOverlapAtAngle(ticks2, angleDeg, fontSize, fontWeight, measureText)) return ticks2;
12527
- let current = ticks2;
12528
- while (current.length > 2) {
12529
- const thinned = [current[0]];
12530
- for (let i = 2; i < current.length - 1; i += 2) {
12531
- thinned.push(current[i]);
12532
- }
12533
- if (current.length > 1) thinned.push(current[current.length - 1]);
12534
- current = thinned;
12535
- if (!bandTicksOverlapAtAngle(current, angleDeg, fontSize, fontWeight, measureText)) break;
12391
+
12392
+ // src/graphs/compile-graph.ts
12393
+ import { adaptTheme as adaptTheme2, computeChrome as computeChrome3, resolveTheme as resolveTheme3 } from "@opendata-ai/openchart-core";
12394
+
12395
+ // src/graphs/encoding.ts
12396
+ var DEFAULT_NODE_RADIUS = 5;
12397
+ var MIN_NODE_RADIUS = 3;
12398
+ var MAX_NODE_RADIUS = 12;
12399
+ var DEFAULT_EDGE_WIDTH = 1;
12400
+ var MIN_EDGE_WIDTH = 0.5;
12401
+ var MAX_EDGE_WIDTH = 4;
12402
+ var DEFAULT_STROKE_WIDTH2 = 1;
12403
+ function darkenColor(hex2, amount = 0.2) {
12404
+ const clean = hex2.replace(/^#/, "");
12405
+ if (clean.length !== 6 && clean.length !== 3) return hex2;
12406
+ const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
12407
+ const r = Math.max(0, Math.round(parseInt(full.substring(0, 2), 16) * (1 - amount)));
12408
+ const g = Math.max(0, Math.round(parseInt(full.substring(2, 4), 16) * (1 - amount)));
12409
+ const b = Math.max(0, Math.round(parseInt(full.substring(4, 6), 16) * (1 - amount)));
12410
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
12411
+ }
12412
+ function hexWithOpacity(hex2, opacity) {
12413
+ const clean = hex2.replace(/^#/, "");
12414
+ if (clean.length !== 6 && clean.length !== 3) {
12415
+ return hex2;
12536
12416
  }
12537
- return current;
12417
+ const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
12418
+ const r = parseInt(full.substring(0, 2), 16);
12419
+ const g = parseInt(full.substring(2, 4), 16);
12420
+ const b = parseInt(full.substring(4, 6), 16);
12421
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
12538
12422
  }
12539
- function computeAxes(scales, chartArea, strategy, theme, measureText, dataContext) {
12540
- const result = {};
12541
- const baseDensity = strategy.axisLabelDensity;
12542
- const yDensity = effectiveDensity(
12543
- baseDensity,
12544
- chartArea.height,
12545
- HEIGHT_MINIMAL_THRESHOLD,
12546
- HEIGHT_REDUCED_THRESHOLD
12547
- );
12548
- const xDensity = effectiveDensity(
12549
- baseDensity,
12550
- chartArea.width,
12551
- WIDTH_MINIMAL_THRESHOLD,
12552
- WIDTH_REDUCED_THRESHOLD
12553
- );
12554
- const tickLabelStyle = {
12555
- fontFamily: theme.fonts.family,
12556
- fontSize: theme.fonts.sizes.axisTick,
12557
- fontWeight: theme.fonts.weights.normal,
12558
- fill: theme.colors.axis,
12559
- lineHeight: 1.2,
12560
- fontVariant: "tabular-nums"
12561
- };
12562
- const axisLabelStyle = {
12563
- fontFamily: theme.fonts.family,
12564
- fontSize: theme.fonts.sizes.body,
12565
- fontWeight: theme.fonts.weights.medium,
12566
- fill: theme.colors.text,
12567
- lineHeight: 1.3
12568
- };
12569
- const { fontSize } = tickLabelStyle;
12570
- const { fontWeight } = tickLabelStyle;
12571
- if (scales.x && !dataContext?.skipX && scales.x.channel.axis !== false) {
12572
- const axisConfig = scales.x.channel.axis;
12573
- const isContinuousX = scales.x.type !== "band" && scales.x.type !== "point" && scales.x.type !== "ordinal";
12574
- const xTargetCount = isContinuousX ? targetTickCount(chartArea.width, xDensity, "x") : void 0;
12575
- let allTicks;
12576
- if (axisConfig?.values) {
12577
- allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
12578
- } else if (!isContinuousX) {
12579
- allTicks = categoricalTicks(scales.x, xDensity, "horizontal");
12423
+ function computeDegrees(nodes, edges) {
12424
+ const degrees = /* @__PURE__ */ new Map();
12425
+ for (const node of nodes) {
12426
+ degrees.set(node.id, 0);
12427
+ }
12428
+ for (const edge of edges) {
12429
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1);
12430
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1);
12431
+ }
12432
+ return degrees;
12433
+ }
12434
+ function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
12435
+ const degrees = computeDegrees(nodes, edges);
12436
+ const maxDegree = Math.max(1, ...degrees.values());
12437
+ let sizeScale;
12438
+ if (encoding.nodeSize?.field) {
12439
+ const field = encoding.nodeSize.field;
12440
+ const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
12441
+ const sizeMin = min2(values) ?? 0;
12442
+ const sizeMax = max2(values) ?? 1;
12443
+ sizeScale = sqrt2().domain([sizeMin, sizeMax]).range([MIN_NODE_RADIUS, MAX_NODE_RADIUS]);
12444
+ }
12445
+ let colorFn;
12446
+ if (encoding.nodeColor?.field) {
12447
+ const field = encoding.nodeColor.field;
12448
+ const fieldType = encoding.nodeColor.type ?? "nominal";
12449
+ const scaleConfig = encoding.nodeColor.scale;
12450
+ if (fieldType === "quantitative") {
12451
+ const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
12452
+ const colorMin = min2(values) ?? 0;
12453
+ const colorMax = max2(values) ?? 1;
12454
+ const seqPalettes = Object.values(theme.colors.sequential);
12455
+ const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
12456
+ const domain = scaleConfig?.domain && scaleConfig.domain.length === 2 ? scaleConfig.domain : [colorMin, colorMax];
12457
+ const range2 = scaleConfig?.range && scaleConfig.range.length >= 2 ? scaleConfig.range : [palette[0], palette[palette.length - 1]];
12458
+ const colorScale = linear2().domain(domain).range(range2);
12459
+ colorFn = (node) => {
12460
+ const val = Number(node[field]);
12461
+ return Number.isFinite(val) ? colorScale(val) : theme.colors.categorical[0];
12462
+ };
12580
12463
  } else {
12581
- allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
12464
+ const domain = scaleConfig?.domain && Array.isArray(scaleConfig.domain) ? scaleConfig.domain : [...new Set(nodes.map((n) => String(n[field] ?? "")))];
12465
+ const range2 = scaleConfig?.range && scaleConfig.range.length > 0 ? scaleConfig.range : theme.colors.categorical;
12466
+ const ordinalScale = ordinal().domain(domain).range(range2);
12467
+ colorFn = (node) => ordinalScale(String(node[field] ?? ""));
12582
12468
  }
12583
- const gridlines = allTicks.map((t) => ({
12584
- position: t.position,
12585
- major: true
12586
- }));
12587
- let tickAngle = axisConfig?.labelAngle;
12588
- if (tickAngle === void 0 && scales.x.type === "band" && allTicks.length > 1) {
12589
- const bandwidth = scales.x.scale.bandwidth();
12590
- let maxLabelWidth = 0;
12591
- for (const t of allTicks) {
12592
- const w = measureLabel(t.label, fontSize, fontWeight, measureText);
12593
- if (w > maxLabelWidth) maxLabelWidth = w;
12469
+ }
12470
+ const defaultColor = theme.colors.categorical[0];
12471
+ return nodes.map((node) => {
12472
+ let radius = DEFAULT_NODE_RADIUS;
12473
+ if (sizeScale && encoding.nodeSize?.field) {
12474
+ const val = Number(node[encoding.nodeSize.field]);
12475
+ if (Number.isFinite(val)) {
12476
+ radius = sizeScale(val);
12594
12477
  }
12595
- tickAngle = resolveBandTickAngle(void 0, maxLabelWidth, bandwidth, allTicks.length);
12596
12478
  }
12597
- const hasExplicitValues = !!axisConfig?.values;
12598
- let ticks2;
12599
- if (hasExplicitValues) {
12600
- ticks2 = allTicks;
12601
- } else if (scales.x.type === "band") {
12602
- const effectiveAngle = tickAngle ?? 0;
12603
- ticks2 = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
12604
- } else if (isContinuousX) {
12605
- ticks2 = fitContinuousTicks(
12606
- scales.x,
12607
- allTicks,
12608
- xTargetCount ?? allTicks.length,
12609
- fontSize,
12610
- fontWeight,
12611
- chartArea.width,
12612
- "horizontal",
12613
- measureText
12614
- );
12479
+ const fill = colorFn ? colorFn(node) : defaultColor;
12480
+ const stroke = darkenColor(fill);
12481
+ let label;
12482
+ if (encoding.nodeLabel?.field) {
12483
+ const labelVal = node[encoding.nodeLabel.field];
12484
+ label = labelVal != null ? String(labelVal) : void 0;
12615
12485
  } else {
12616
- ticks2 = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
12486
+ label = node.id;
12617
12487
  }
12618
- const axisTitle = axisConfig?.title;
12619
- const xLabelColor = axisConfig?.labelColor;
12620
- const xTickPosition = axisConfig?.tickPosition ?? "gutter";
12621
- const xDomainLine = axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? "") ? void 0 : false);
12622
- result.x = {
12623
- ticks: ticks2,
12624
- gridlines: axisConfig?.grid ?? dataContext?.markType === "point" ? gridlines : [],
12625
- label: axisTitle,
12626
- labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
12627
- tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
12628
- tickAngle,
12629
- start: { x: chartArea.x, y: chartArea.y + chartArea.height },
12630
- end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
12631
- orient: axisConfig?.orient,
12632
- domainLine: xDomainLine,
12633
- tickMarks: axisConfig?.ticks,
12634
- offset: axisConfig?.offset,
12635
- titlePadding: axisConfig?.titlePadding,
12636
- labelPadding: axisConfig?.labelPadding,
12637
- labelOverlap: axisConfig?.labelOverlap,
12638
- labelFlush: axisConfig?.labelFlush,
12639
- tickPosition: xTickPosition
12488
+ const degree = degrees.get(node.id) ?? 0;
12489
+ const labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
12490
+ const { id: _id, ...rest } = node;
12491
+ const data = { id: node.id, ...rest };
12492
+ const override = nodeOverrides?.[node.id];
12493
+ const finalFill = override?.fill ?? fill;
12494
+ const finalRadius = override?.radius ?? radius;
12495
+ const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH2;
12496
+ const finalStroke = override?.stroke ?? stroke;
12497
+ const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
12498
+ return {
12499
+ id: node.id,
12500
+ radius: finalRadius,
12501
+ fill: finalFill,
12502
+ stroke: finalStroke,
12503
+ strokeWidth: finalStrokeWidth,
12504
+ label,
12505
+ labelPriority: finalLabelPriority,
12506
+ community: void 0,
12507
+ data
12640
12508
  };
12509
+ });
12510
+ }
12511
+ function resolveEdgeVisuals(edges, encoding, theme) {
12512
+ let widthScale;
12513
+ if (encoding.edgeWidth?.field) {
12514
+ const field = encoding.edgeWidth.field;
12515
+ const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
12516
+ const widthMin = min2(values) ?? 0;
12517
+ const widthMax = max2(values) ?? 1;
12518
+ widthScale = linear2().domain([widthMin, widthMax]).range([MIN_EDGE_WIDTH, MAX_EDGE_WIDTH]);
12641
12519
  }
12642
- if (scales.y && !dataContext?.skipY && scales.y.channel.axis !== false) {
12643
- const axisConfig = scales.y.channel.axis;
12644
- const isContinuousY = scales.y.type !== "band" && scales.y.type !== "point" && scales.y.type !== "ordinal";
12645
- const yTargetCount = isContinuousY ? targetTickCount(chartArea.height, yDensity, "y") : void 0;
12646
- let allTicks;
12647
- if (axisConfig?.values) {
12648
- allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
12649
- } else if (dataContext?.precomputedYTicks && dataContext.precomputedYTicks.length > 0 && isContinuousY) {
12650
- allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
12651
- } else if (!isContinuousY) {
12652
- const yFieldName = dataContext?.encoding.y?.field;
12653
- const yLabelField = axisConfig?.labelField;
12654
- allTicks = categoricalTicks(
12655
- scales.y,
12656
- yDensity,
12657
- "vertical",
12658
- yFieldName && yLabelField && dataContext ? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField } : void 0
12659
- );
12520
+ let edgeColorFn;
12521
+ if (encoding.edgeColor?.field) {
12522
+ const field = encoding.edgeColor.field;
12523
+ const fieldType = encoding.edgeColor.type ?? "nominal";
12524
+ const scaleConfig = encoding.edgeColor.scale;
12525
+ if (fieldType === "quantitative") {
12526
+ const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
12527
+ const colorMin = min2(values) ?? 0;
12528
+ const colorMax = max2(values) ?? 1;
12529
+ const seqPalettes = Object.values(theme.colors.sequential);
12530
+ const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
12531
+ const domain = scaleConfig?.domain && scaleConfig.domain.length === 2 ? scaleConfig.domain : [colorMin, colorMax];
12532
+ const range2 = scaleConfig?.range && scaleConfig.range.length >= 2 ? scaleConfig.range : [palette[0], palette[palette.length - 1]];
12533
+ const colorScale = linear2().domain(domain).range(range2);
12534
+ edgeColorFn = (edge) => {
12535
+ const val = Number(edge[field]);
12536
+ return Number.isFinite(val) ? colorScale(val) : hexWithOpacity(theme.colors.axis, 0.4);
12537
+ };
12660
12538
  } else {
12661
- allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
12539
+ const domain = scaleConfig?.domain && Array.isArray(scaleConfig.domain) ? scaleConfig.domain : [...new Set(edges.map((e) => String(e[field] ?? "")))];
12540
+ const range2 = scaleConfig?.range && scaleConfig.range.length > 0 ? scaleConfig.range : theme.colors.categorical;
12541
+ const ordinalScale = ordinal().domain(domain).range(range2);
12542
+ edgeColorFn = (edge) => ordinalScale(String(edge[field] ?? ""));
12662
12543
  }
12663
- const shouldThinY = scales.y.type !== "band" && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
12664
- let ticks2;
12665
- if (!shouldThinY) {
12666
- ticks2 = allTicks;
12667
- } else if (isContinuousY) {
12668
- ticks2 = fitContinuousTicks(
12669
- scales.y,
12670
- allTicks,
12671
- yTargetCount ?? allTicks.length,
12672
- fontSize,
12673
- fontWeight,
12674
- chartArea.height,
12675
- "vertical",
12676
- measureText
12677
- );
12678
- } else {
12679
- ticks2 = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, "vertical");
12544
+ }
12545
+ const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
12546
+ const EDGE_STYLES = ["solid", "dashed", "dotted"];
12547
+ let styleFn;
12548
+ if (encoding.edgeStyle?.field) {
12549
+ const field = encoding.edgeStyle.field;
12550
+ const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? "")))];
12551
+ const styleMap = /* @__PURE__ */ new Map();
12552
+ for (let i = 0; i < uniqueValues.length; i++) {
12553
+ styleMap.set(uniqueValues[i], EDGE_STYLES[i % EDGE_STYLES.length]);
12680
12554
  }
12681
- const gridlines = ticks2.map((t) => ({
12682
- position: t.position,
12683
- major: true
12684
- }));
12685
- const axisTitle = axisConfig?.title;
12686
- const tickAngle = axisConfig?.labelAngle;
12687
- const yLabelColor = axisConfig?.labelColor;
12688
- const isContinuousYAxis = scales.y.type !== "band" && scales.y.type !== "point" && scales.y.type !== "ordinal";
12689
- const isLineOrArea2 = dataContext?.markType === "line" || dataContext?.markType === "area";
12690
- const yTickPosition = axisConfig?.tickPosition ?? (isLineOrArea2 && isContinuousYAxis && axisConfig?.orient !== "right" ? "inline" : "gutter");
12691
- const yDomainLine = axisConfig?.domain ?? (yTickPosition === "inline" ? false : void 0);
12692
- const yTickMarks = axisConfig?.ticks ?? (yTickPosition === "inline" ? false : void 0);
12693
- result.y = {
12694
- ticks: ticks2,
12695
- // Y-axis gridlines are shown by default (standard editorial practice)
12696
- gridlines,
12697
- label: axisTitle,
12698
- labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
12699
- tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
12700
- tickAngle,
12701
- start: { x: chartArea.x, y: chartArea.y },
12702
- end: { x: chartArea.x, y: chartArea.y + chartArea.height },
12703
- orient: axisConfig?.orient,
12704
- domainLine: yDomainLine,
12705
- tickMarks: yTickMarks,
12706
- offset: axisConfig?.offset,
12707
- titlePadding: axisConfig?.titlePadding,
12708
- labelPadding: axisConfig?.labelPadding,
12709
- labelOverlap: axisConfig?.labelOverlap,
12710
- labelFlush: axisConfig?.labelFlush,
12711
- tickPosition: yTickPosition
12555
+ styleFn = (edge) => styleMap.get(String(edge[field] ?? "")) ?? "solid";
12556
+ }
12557
+ return edges.map((edge) => {
12558
+ const { source, target, ...rest } = edge;
12559
+ let strokeWidth = DEFAULT_EDGE_WIDTH;
12560
+ if (widthScale && encoding.edgeWidth?.field) {
12561
+ const val = Number(edge[encoding.edgeWidth.field]);
12562
+ if (Number.isFinite(val)) {
12563
+ strokeWidth = widthScale(val);
12564
+ }
12565
+ }
12566
+ const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
12567
+ const style = styleFn ? styleFn(edge) : "solid";
12568
+ return {
12569
+ source,
12570
+ target,
12571
+ stroke,
12572
+ strokeWidth,
12573
+ style,
12574
+ data: { source, target, ...rest }
12712
12575
  };
12576
+ });
12577
+ }
12578
+
12579
+ // src/graphs/community.ts
12580
+ function assignCommunities(nodes, clusteringField) {
12581
+ if (!clusteringField) return;
12582
+ for (const node of nodes) {
12583
+ const value2 = node.data[clusteringField];
12584
+ node.community = value2 != null ? String(value2) : void 0;
12713
12585
  }
12714
- const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
12715
- if (result.x) {
12716
- const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
12717
- result.x.extent = computeXAxisExtentFromLabels2({
12718
- labels: result.x.ticks.map((t) => t.label),
12719
- tickAngle: result.x.tickAngle,
12720
- hasTitle: !!result.x.label,
12721
- tickFontSize: result.x.tickLabelStyle.fontSize,
12722
- tickFontWeight: result.x.tickLabelStyle.fontWeight,
12723
- xAxisHeight: theme.spacing.xAxisHeight,
12724
- measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText)
12725
- });
12726
- if (result.x.label) {
12727
- const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
12728
- result.x.titlePosition = {
12729
- x: chartArea.x + chartArea.width / 2,
12730
- y: chartArea.y + chartArea.height + rotatedBand
12731
- };
12586
+ }
12587
+ function buildCommunityColorMap(nodes, theme) {
12588
+ const colorMap = /* @__PURE__ */ new Map();
12589
+ const palette = theme.colors.categorical;
12590
+ let colorIndex = 0;
12591
+ for (const node of nodes) {
12592
+ if (node.community != null && !colorMap.has(node.community)) {
12593
+ colorMap.set(node.community, palette[colorIndex % palette.length]);
12594
+ colorIndex++;
12732
12595
  }
12733
12596
  }
12734
- if (result.y?.label && result.y.labelStyle) {
12735
- const isRight = result.y.orient === "right";
12736
- if (isRight) {
12737
- const titleOff = getAxisTitleOffset2(totalWidth);
12738
- result.y.titlePosition = {
12739
- x: chartArea.x + chartArea.width + titleOff,
12740
- y: chartArea.y + chartArea.height / 2,
12741
- angle: 90
12742
- };
12597
+ return colorMap;
12598
+ }
12599
+ function applyCommunityColors(nodes, colorMap) {
12600
+ for (const node of nodes) {
12601
+ if (node.community != null) {
12602
+ const communityColor = colorMap.get(node.community);
12603
+ if (communityColor) {
12604
+ node.fill = communityColor;
12605
+ node.stroke = darkenColor(communityColor);
12606
+ }
12607
+ }
12608
+ }
12609
+ }
12610
+
12611
+ // src/graphs/compile-graph.ts
12612
+ var SWATCH_SIZE2 = 12;
12613
+ var SWATCH_GAP2 = 6;
12614
+ var ENTRY_GAP2 = 16;
12615
+ function buildGraphLegend(nodes, communityColorMap, hasCommunities, theme, nodeColorField) {
12616
+ const labelStyle = {
12617
+ fontFamily: theme.fonts.family,
12618
+ fontSize: theme.fonts.sizes.small,
12619
+ fontWeight: theme.fonts.weights.normal,
12620
+ fill: theme.colors.text,
12621
+ lineHeight: 1.3
12622
+ };
12623
+ let entries;
12624
+ if (hasCommunities && communityColorMap.size > 0) {
12625
+ entries = [...communityColorMap.entries()].map(([label, color2]) => ({
12626
+ label,
12627
+ color: color2,
12628
+ shape: "circle",
12629
+ active: true
12630
+ }));
12631
+ } else {
12632
+ const categoryColors = /* @__PURE__ */ new Map();
12633
+ for (const node of nodes) {
12634
+ const category = nodeColorField ? String(node.data[nodeColorField] ?? node.label ?? node.id) : node.label ?? node.id;
12635
+ if (!categoryColors.has(category)) {
12636
+ categoryColors.set(category, node.fill);
12637
+ }
12638
+ }
12639
+ if (categoryColors.size <= 1) {
12640
+ entries = [];
12743
12641
  } else {
12744
- const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12745
- const w = estimateTextWidth15(
12746
- t.label,
12747
- result.y.tickLabelStyle.fontSize,
12748
- result.y.tickLabelStyle.fontWeight ?? 400
12749
- );
12750
- return Math.max(max4, w);
12751
- }, 0);
12752
- const titleOff = axisTitleOffset2(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
12753
- result.y.titlePosition = {
12754
- x: chartArea.x - titleOff,
12755
- y: chartArea.y + chartArea.height / 2,
12756
- angle: -90
12757
- };
12642
+ entries = [...categoryColors.entries()].map(([label, color2]) => ({
12643
+ label,
12644
+ color: color2,
12645
+ shape: "circle",
12646
+ active: true
12647
+ }));
12758
12648
  }
12759
12649
  }
12760
- return result;
12650
+ return {
12651
+ position: "top",
12652
+ entries,
12653
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
12654
+ labelStyle,
12655
+ swatchSize: SWATCH_SIZE2,
12656
+ swatchGap: SWATCH_GAP2,
12657
+ entryGap: ENTRY_GAP2,
12658
+ swatchChipFill: theme.colors.annotationFill
12659
+ };
12660
+ }
12661
+ function buildGraphTooltips(nodes) {
12662
+ const descriptors = /* @__PURE__ */ new Map();
12663
+ for (const node of nodes) {
12664
+ const fields = [];
12665
+ if (node.community != null) {
12666
+ fields.push({
12667
+ label: "Community",
12668
+ value: node.community,
12669
+ color: node.fill
12670
+ });
12671
+ }
12672
+ for (const [key, value2] of Object.entries(node.data)) {
12673
+ if (key === "id") continue;
12674
+ if (value2 == null) continue;
12675
+ fields.push({
12676
+ label: key,
12677
+ value: typeof value2 === "number" ? value2.toLocaleString() : String(value2)
12678
+ });
12679
+ }
12680
+ descriptors.set(node.id, {
12681
+ title: node.label ?? node.id,
12682
+ fields
12683
+ });
12684
+ }
12685
+ return descriptors;
12686
+ }
12687
+ function compileGraph(spec, options) {
12688
+ const { spec: normalized } = compile(spec);
12689
+ if (!("type" in normalized) || normalized.type !== "graph") {
12690
+ throw new Error(
12691
+ "compileGraph received a non-graph spec. Use compileChart or compileTable instead."
12692
+ );
12693
+ }
12694
+ const graphSpec = normalized;
12695
+ const rawWatermark = spec.watermark;
12696
+ const watermark = rawWatermark !== void 0 ? graphSpec.watermark : options.watermark ?? true;
12697
+ const mergedThemeConfig = options.theme ? { ...graphSpec.theme, ...options.theme } : graphSpec.theme;
12698
+ let theme = resolveTheme3(mergedThemeConfig);
12699
+ if (options.darkMode) {
12700
+ theme = adaptTheme2(theme);
12701
+ }
12702
+ const compiledNodes = resolveNodeVisuals(
12703
+ graphSpec.nodes,
12704
+ graphSpec.encoding,
12705
+ graphSpec.edges,
12706
+ theme,
12707
+ graphSpec.nodeOverrides
12708
+ );
12709
+ const clusteringField = graphSpec.layout.clustering?.field;
12710
+ const hasCommunities = !!clusteringField;
12711
+ assignCommunities(compiledNodes, clusteringField);
12712
+ const hasNodeColorEncoding = !!graphSpec.encoding.nodeColor?.field;
12713
+ let communityColorMap = /* @__PURE__ */ new Map();
12714
+ if (hasCommunities && !hasNodeColorEncoding) {
12715
+ communityColorMap = buildCommunityColorMap(compiledNodes, theme);
12716
+ applyCommunityColors(compiledNodes, communityColorMap);
12717
+ }
12718
+ const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme);
12719
+ const useCommunitiesForLegend = hasCommunities && !hasNodeColorEncoding;
12720
+ const legend = buildGraphLegend(
12721
+ compiledNodes,
12722
+ communityColorMap,
12723
+ useCommunitiesForLegend,
12724
+ theme,
12725
+ graphSpec.encoding.nodeColor?.field
12726
+ );
12727
+ const tooltipDescriptors = buildGraphTooltips(compiledNodes);
12728
+ const communityCount = communityColorMap.size;
12729
+ const altParts = [
12730
+ `Network graph with ${compiledNodes.length} nodes and ${compiledEdges.length} edges`
12731
+ ];
12732
+ if (communityCount > 0) {
12733
+ altParts.push(`organized into ${communityCount} communities`);
12734
+ }
12735
+ const a11y = {
12736
+ altText: altParts.join(", "),
12737
+ dataTableFallback: compiledNodes.map((n) => [n.id, n.community ?? "", n.label ?? ""]),
12738
+ role: "img",
12739
+ keyboardNavigable: compiledNodes.length > 0
12740
+ };
12741
+ const collisionPadding = graphSpec.layout.collisionPadding ?? 2;
12742
+ const maxRadius = compiledNodes.length > 0 ? Math.max(...compiledNodes.map((n) => n.radius)) : DEFAULT_COLLISION_PADDING;
12743
+ const simulationConfig = {
12744
+ chargeStrength: graphSpec.layout.chargeStrength ?? -300,
12745
+ linkDistance: graphSpec.layout.linkDistance ?? 30,
12746
+ clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
12747
+ alphaDecay: 0.0228,
12748
+ velocityDecay: 0.4,
12749
+ collisionRadius: maxRadius + collisionPadding,
12750
+ collisionPadding,
12751
+ linkStrength: graphSpec.layout.linkStrength,
12752
+ centerForce: graphSpec.layout.centerForce
12753
+ };
12754
+ const chrome = computeChrome3(
12755
+ {
12756
+ title: graphSpec.chrome.title,
12757
+ subtitle: graphSpec.chrome.subtitle,
12758
+ source: graphSpec.chrome.source,
12759
+ byline: graphSpec.chrome.byline,
12760
+ footer: graphSpec.chrome.footer
12761
+ },
12762
+ theme,
12763
+ options.width,
12764
+ options.measureText,
12765
+ "full",
12766
+ void 0,
12767
+ watermark
12768
+ );
12769
+ return {
12770
+ nodes: compiledNodes,
12771
+ edges: compiledEdges,
12772
+ legend,
12773
+ chrome,
12774
+ tooltipDescriptors,
12775
+ a11y,
12776
+ theme,
12777
+ dimensions: {
12778
+ width: options.width,
12779
+ height: options.height
12780
+ },
12781
+ simulationConfig,
12782
+ watermark
12783
+ };
12761
12784
  }
12785
+ var DEFAULT_COLLISION_PADDING = 5;
12762
12786
 
12763
12787
  // src/layout/dimensions.ts
12764
12788
  import {
@@ -12939,12 +12963,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
12939
12963
  const base = theme.spacing.xAxisHeight;
12940
12964
  xAxisHeight = hasXAxisLabel ? base + 22 : base;
12941
12965
  }
12942
- const yAxisCfgPre = encoding.y?.axis ?? void 0;
12943
- const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition;
12944
- const yIsContinuousPre = encoding.y?.type === "quantitative" || encoding.y?.type === "temporal";
12945
- const yIsLineOrAreaPre = spec.markType === "line" || spec.markType === "area";
12946
- const yAxisOrientPre = yAxisCfgPre?.orient;
12947
- const yIsInlinePre = (yTickPositionExplicitPre ?? (yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== "right" ? "inline" : "gutter")) === "inline";
12966
+ const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
12948
12967
  const inlineTickOverhang = yIsInlinePre ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD : 0;
12949
12968
  const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
12950
12969
  const topPad = width < NARROW_VIEWPORT_MAX3 ? padding + TOP_PAD_EXTRA_NARROW2 : padding;
@@ -13097,7 +13116,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
13097
13116
  const yFieldForTitle = encoding.y?.field;
13098
13117
  const yAxisFormatForTitle = yAxis?.format;
13099
13118
  let estTickLabelWidth = 0;
13100
- if (yFieldForTitle && (encoding.y?.type === "quantitative" || encoding.y?.type === "temporal")) {
13119
+ if (yFieldForTitle && !yIsInlinePre && (encoding.y?.type === "quantitative" || encoding.y?.type === "temporal")) {
13101
13120
  let maxAbsValForTitle = 0;
13102
13121
  for (const row of spec.data) {
13103
13122
  const v = Number(row[yFieldForTitle]);
@@ -13127,7 +13146,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
13127
13146
  );
13128
13147
  }
13129
13148
  const titleFontSize = theme.fonts.sizes.body;
13130
- const offset = axisTitleOffset3(estTickLabelWidth, titleFontSize, width);
13149
+ const offset = axisTitleOffset3(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
13131
13150
  const halfGlyph = Math.ceil(titleFontSize / 2);
13132
13151
  const rotatedLabelMargin = offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX3 ? 0 : AXIS_TITLE_TRAILING_PAD3);
13133
13152
  margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
@@ -14941,6 +14960,9 @@ import {
14941
14960
  computeChrome as computeChrome7,
14942
14961
  estimateTextWidth as estimateTextWidth20,
14943
14962
  formatNumber as formatNumber8,
14963
+ getBreakpoint,
14964
+ HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION3,
14965
+ HPAD_COMPACT_MIN as HPAD_COMPACT_MIN3,
14944
14966
  pickLabelColor as pickLabelColor2,
14945
14967
  resolveTheme as resolveTheme5,
14946
14968
  SEQUENTIAL_PALETTES
@@ -14950,6 +14972,9 @@ var TILE_STROKE_WIDTH = 1;
14950
14972
  var LEGEND_SWATCH_SIZE = 10;
14951
14973
  var LEGEND_SWATCH_GAP = 6;
14952
14974
  var LEGEND_ENTRY_GAP = 16;
14975
+ function clamp(value2, min4, max4) {
14976
+ return Math.min(max4, Math.max(min4, value2));
14977
+ }
14953
14978
  function compileTileMap(spec, options) {
14954
14979
  const { spec: normalized } = compile(spec);
14955
14980
  if (!("type" in normalized) || normalized.type !== "tilemap") {
@@ -14982,7 +15007,7 @@ function compileTileMap(spec, options) {
14982
15007
  void 0,
14983
15008
  watermark
14984
15009
  );
14985
- const padding = theme.spacing.padding;
15010
+ const padding = getBreakpoint(options.width) === "compact" ? Math.max(HPAD_COMPACT_MIN3, Math.round(theme.spacing.padding * HPAD_COMPACT_FRACTION3)) : theme.spacing.padding;
14986
15011
  const fullArea = {
14987
15012
  x: padding,
14988
15013
  y: padding + chrome.topHeight,
@@ -15095,7 +15120,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
15095
15120
  keyboardNavigable: tiles.length > 0
15096
15121
  };
15097
15122
  const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
15098
- const padding = theme.spacing.padding;
15123
+ const padding = fullArea.x;
15099
15124
  const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
15100
15125
  return {
15101
15126
  area: fullArea,
@@ -15150,16 +15175,20 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15150
15175
  }
15151
15176
  }
15152
15177
  const showLegend = tilemapSpec.legend?.show !== false;
15178
+ const legendLabelFontSize = 11;
15153
15179
  const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
15154
- const legendTotalHeight = showLegend ? legendRowHeight : 0;
15180
+ const legendLabels = categories.map(formatCategoryLabel);
15155
15181
  const legendGap2 = showLegend ? 8 : 0;
15156
- const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap2;
15182
+ const estimatedRows = showLegend ? countLegendRows(legendLabels, fullArea.width, legendLabelFontSize) : 0;
15183
+ const tileAreaHeight = fullArea.height - legendRowHeight * estimatedRows - legendGap2;
15157
15184
  const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
15158
15185
  const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
15159
15186
  const tileGridOffsetY = fullArea.y;
15160
15187
  const legendX = tileGridOffsetX;
15161
15188
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap2;
15162
15189
  const legendWidth = tilePositions.gridWidth;
15190
+ const legendRows = showLegend ? countLegendRows(legendLabels, legendWidth, legendLabelFontSize) : 0;
15191
+ const legendTotalHeight = showLegend ? legendRowHeight * legendRows : 0;
15163
15192
  const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
15164
15193
  const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
15165
15194
  const tiles = [];
@@ -15195,7 +15224,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15195
15224
  if (showLegend) {
15196
15225
  const labelStyle = {
15197
15226
  fontFamily: theme.fonts.family,
15198
- fontSize: 11,
15227
+ fontSize: legendLabelFontSize,
15199
15228
  fontWeight: 400,
15200
15229
  fill: theme.colors.text,
15201
15230
  lineHeight: 1.2
@@ -15208,7 +15237,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15208
15237
  categoricalLegend = {
15209
15238
  type: "categorical",
15210
15239
  position: "bottom",
15211
- bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
15240
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendTotalHeight },
15212
15241
  labelStyle,
15213
15242
  entries,
15214
15243
  swatchSize: LEGEND_SWATCH_SIZE,
@@ -15226,7 +15255,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15226
15255
  keyboardNavigable: tiles.length > 0
15227
15256
  };
15228
15257
  const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
15229
- const padding = theme.spacing.padding;
15258
+ const padding = fullArea.x;
15230
15259
  const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
15231
15260
  return {
15232
15261
  area: fullArea,
@@ -15265,27 +15294,32 @@ function buildTileMark(opts) {
15265
15294
  const tileTopY = gridOffsetY + pos.y;
15266
15295
  const textColor = hasData ? pickLabelColor2(fill, isDarkMode) : "#ffffff";
15267
15296
  const isLightText = textColor === "#ffffff";
15297
+ const showValue = tileSize >= 24;
15298
+ const codeFontSize = clamp(Math.round(tileSize * 0.5 * 10) / 10, 6.5, 11);
15299
+ const valueFontSize = clamp(Math.round(tileSize * 0.42 * 10) / 10, 6.5, 10);
15268
15300
  const labelStyle = {
15269
15301
  fontFamily: theme.fonts.family,
15270
- fontSize: tileSize > 24 ? 10 : 7,
15302
+ fontSize: codeFontSize,
15271
15303
  fontWeight: 700,
15272
15304
  fill: textColor,
15273
15305
  lineHeight: 1.2
15274
15306
  };
15275
15307
  const valueLabelStyle = {
15276
15308
  fontFamily: theme.fonts.family,
15277
- fontSize: tileSize > 24 ? 10 : 7,
15309
+ fontSize: valueFontSize,
15278
15310
  fontWeight: 300,
15279
15311
  fill: isLightText ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.45)",
15280
15312
  lineHeight: 1.2
15281
15313
  };
15282
- const valueLabel = tileSize < 24 ? { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false } : {
15314
+ const codeCenterY = showValue ? tileTopY + tileSize * 0.28 : tileTopY + tileSize * 0.5;
15315
+ const valueLabel = showValue ? {
15283
15316
  text: formattedValue,
15284
15317
  x: tileCenterX,
15285
15318
  y: tileTopY + tileSize * 0.78,
15286
15319
  style: valueLabelStyle,
15287
15320
  visible: true
15288
- };
15321
+ } : { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false };
15322
+ const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.22));
15289
15323
  const data = {
15290
15324
  state: stateCode,
15291
15325
  value: value2,
@@ -15304,14 +15338,14 @@ function buildTileMark(opts) {
15304
15338
  fillOpacity,
15305
15339
  stroke,
15306
15340
  strokeWidth: TILE_STROKE_WIDTH,
15307
- cornerRadius: TILE_CORNER_RADIUS,
15341
+ cornerRadius,
15308
15342
  value: value2,
15309
15343
  formattedValue,
15310
15344
  hasData,
15311
15345
  label: {
15312
15346
  text: stateCode,
15313
15347
  x: tileCenterX,
15314
- y: tileTopY + tileSize * 0.28,
15348
+ y: codeCenterY,
15315
15349
  style: labelStyle,
15316
15350
  visible: true
15317
15351
  },
@@ -15350,6 +15384,22 @@ function buildTooltips(tiles, fieldLabel = "Value") {
15350
15384
  function formatCategoryLabel(category) {
15351
15385
  return category.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
15352
15386
  }
15387
+ function countLegendRows(labels, availableWidth, fontSize) {
15388
+ if (labels.length === 0) return 0;
15389
+ let rows = 1;
15390
+ let offsetX = 0;
15391
+ for (let i = 0; i < labels.length; i++) {
15392
+ const labelWidth = estimateTextWidth20(labels[i], fontSize);
15393
+ const entryWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_GAP + labelWidth + LEGEND_ENTRY_GAP;
15394
+ if (i > 0 && offsetX + entryWidth > availableWidth) {
15395
+ rows++;
15396
+ offsetX = entryWidth;
15397
+ } else {
15398
+ offsetX += entryWidth;
15399
+ }
15400
+ }
15401
+ return rows;
15402
+ }
15353
15403
  function emptyLayout4(chrome, theme, options, watermark) {
15354
15404
  return {
15355
15405
  area: { x: 0, y: 0, width: 0, height: 0 },
@@ -16165,7 +16215,7 @@ function compileChart(spec, options) {
16165
16215
  if (rawTransforms && rawTransforms.length > 0) {
16166
16216
  chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
16167
16217
  }
16168
- const breakpoint = getBreakpoint(options.width);
16218
+ const breakpoint = getBreakpoint2(options.width);
16169
16219
  const heightClass = getHeightClass(options.height);
16170
16220
  let strategy = getLayoutStrategy(breakpoint, heightClass);
16171
16221
  const rawSpec = expandedSpec;