@opendata-ai/openchart-engine 7.8.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
@@ -2125,6 +2125,32 @@ function computeEndpointLabels(spec, marks, theme, chartArea, strategy, endpoint
2125
2125
  // src/charts/bar/compute.ts
2126
2126
  import { isGradientDef, NARROW_VIEWPORT_MAX } from "@opendata-ai/openchart-core";
2127
2127
 
2128
+ // src/compiler/keys.ts
2129
+ var NULL_KEY = "\u2205";
2130
+ function serializeKeyValue(v) {
2131
+ if (v === null || v === void 0) return NULL_KEY;
2132
+ if (v instanceof Date) return String(v.getTime());
2133
+ return String(v);
2134
+ }
2135
+ function dedupeKeys(keys) {
2136
+ const counts = /* @__PURE__ */ new Map();
2137
+ for (const k of keys) {
2138
+ counts.set(k, (counts.get(k) ?? 0) + 1);
2139
+ }
2140
+ const seen = /* @__PURE__ */ new Map();
2141
+ const result = [];
2142
+ for (const k of keys) {
2143
+ if (counts.get(k) > 1) {
2144
+ const idx = seen.get(k) ?? 0;
2145
+ result.push(`${k}${idx}`);
2146
+ seen.set(k, idx + 1);
2147
+ } else {
2148
+ result.push(k);
2149
+ }
2150
+ }
2151
+ return result;
2152
+ }
2153
+
2128
2154
  // src/transforms/predicates.ts
2129
2155
  function isFieldPredicate(pred) {
2130
2156
  return "field" in pred;
@@ -2374,8 +2400,21 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth) {
2374
2400
  );
2375
2401
  }
2376
2402
  }
2403
+ const categoryField = yChannel.field;
2404
+ stampBarKeys(marks, categoryField, colorField);
2377
2405
  return applyMarkDefOverrides(marks, spec, bandwidth);
2378
2406
  }
2407
+ function stampBarKeys(marks, categoryField, colorField) {
2408
+ const rawKeys = marks.map((m) => {
2409
+ const series = colorField ? String(m.data[colorField] ?? "") : "";
2410
+ const cat = serializeKeyValue(m.data[categoryField]);
2411
+ return `${series}|${cat}`;
2412
+ });
2413
+ const keys = dedupeKeys(rawKeys);
2414
+ for (let i = 0; i < marks.length; i++) {
2415
+ marks[i].key = keys[i];
2416
+ }
2417
+ }
2379
2418
  function computeStackedBars(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, _baseline, scales, stackMode = "zero") {
2380
2419
  const marks = [];
2381
2420
  const categoryGroups = groupByField(data, categoryField);
@@ -2873,8 +2912,21 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy) {
2873
2912
  conditionalColor
2874
2913
  );
2875
2914
  }
2915
+ const categoryField = xChannel.field;
2916
+ stampColumnKeys(marks, categoryField, colorField);
2876
2917
  return applyMarkDefOverrides2(marks, spec, bandwidth);
2877
2918
  }
2919
+ function stampColumnKeys(marks, categoryField, colorField) {
2920
+ const rawKeys = marks.map((m) => {
2921
+ const series = colorField ? String(m.data[colorField] ?? "") : "";
2922
+ const cat = serializeKeyValue(m.data[categoryField]);
2923
+ return `${series}|${cat}`;
2924
+ });
2925
+ const keys = dedupeKeys(rawKeys);
2926
+ for (let i = 0; i < marks.length; i++) {
2927
+ marks[i].key = keys[i];
2928
+ }
2929
+ }
2878
2930
  function computeSimpleColumns(data, categoryField, valueField, xScale, yScale, bandwidth, baseline, scales, sequentialColor = false, conditionalColor) {
2879
2931
  const marks = [];
2880
2932
  for (const row of data) {
@@ -3198,8 +3250,9 @@ function computeDotMarks(spec, scales, _chartArea, _strategy) {
3198
3250
  const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
3199
3251
  const isSequentialColor = colorEnc?.type === "quantitative";
3200
3252
  const colorField = isSequentialColor ? void 0 : colorEnc?.field;
3253
+ let marks;
3201
3254
  if (colorField) {
3202
- return computeDumbbellMarks(
3255
+ marks = computeDumbbellMarks(
3203
3256
  spec.data,
3204
3257
  xChannel.field,
3205
3258
  yChannel.field,
@@ -3209,18 +3262,30 @@ function computeDotMarks(spec, scales, _chartArea, _strategy) {
3209
3262
  bandwidth,
3210
3263
  scales
3211
3264
  );
3265
+ } else {
3266
+ marks = computeLollipopMarks(
3267
+ spec.data,
3268
+ xChannel.field,
3269
+ yChannel.field,
3270
+ xScale,
3271
+ yScale,
3272
+ bandwidth,
3273
+ baseline,
3274
+ scales,
3275
+ isSequentialColor
3276
+ );
3212
3277
  }
3213
- return computeLollipopMarks(
3214
- spec.data,
3215
- xChannel.field,
3216
- yChannel.field,
3217
- xScale,
3218
- yScale,
3219
- bandwidth,
3220
- baseline,
3221
- scales,
3222
- isSequentialColor
3223
- );
3278
+ const pointMarks = marks.filter((m) => m.type === "point");
3279
+ const rawKeys = pointMarks.map((m) => {
3280
+ const cat = serializeKeyValue(m.data[yChannel.field]);
3281
+ const series = colorField ? String(m.data[colorField] ?? "") : "";
3282
+ return series ? `${series}|${cat}` : cat;
3283
+ });
3284
+ const keys = dedupeKeys(rawKeys);
3285
+ for (let i = 0; i < pointMarks.length; i++) {
3286
+ pointMarks[i].key = keys[i];
3287
+ }
3288
+ return marks;
3224
3289
  }
3225
3290
  function computeDumbbellMarks(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, scales) {
3226
3291
  const marks = [];
@@ -4335,6 +4400,23 @@ function resolveCurve(interpolate) {
4335
4400
  if (!interpolate) return monotoneX;
4336
4401
  return CURVE_MAP[interpolate] ?? monotoneX;
4337
4402
  }
4403
+ function buildLinePath(points, interpolate) {
4404
+ if (points.length === 0) return "";
4405
+ const curve = resolveCurve(interpolate);
4406
+ const generator = line_default().x((d) => d.x).y((d) => d.y).curve(curve);
4407
+ return generator(points) ?? "";
4408
+ }
4409
+ function buildAreaPath(topPoints, bottomPoints, interpolate) {
4410
+ if (topPoints.length === 0) return "";
4411
+ const curve = resolveCurve(interpolate);
4412
+ const paired = topPoints.map((tp, i) => ({
4413
+ x: tp.x,
4414
+ yTop: tp.y,
4415
+ yBottom: bottomPoints[i]?.y ?? 0
4416
+ }));
4417
+ const generator = area_default().x((d) => d.x).y0((d) => d.yBottom).y1((d) => d.yTop).curve(curve);
4418
+ return generator(paired) ?? "";
4419
+ }
4338
4420
 
4339
4421
  // src/charts/line/area.ts
4340
4422
  var DEFAULT_FILL_OPACITY = 0.15;
@@ -4436,8 +4518,15 @@ function computeSingleArea(spec, scales, _chartArea) {
4436
4518
  fillValue = buildGradientFill(colorStr, defaultGradientStops);
4437
4519
  fillOpacity = 1;
4438
4520
  }
4521
+ const keyField = encoding.key && "field" in encoding.key ? encoding.key.field : void 0;
4522
+ const rawPointKeys = validPoints.map(
4523
+ (p) => keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field])
4524
+ );
4439
4525
  marks.push({
4440
4526
  type: "area",
4527
+ key: seriesKey === "__default__" ? "area" : seriesKey,
4528
+ pointKeys: dedupeKeys(rawPointKeys),
4529
+ interpolate: spec.markDef.interpolate ?? "monotone",
4441
4530
  topPoints,
4442
4531
  bottomPoints,
4443
4532
  path: pathStr,
@@ -4536,8 +4625,12 @@ function computeStackedArea(spec, scales, chartArea, darkMode) {
4536
4625
  fillValue = buildGradientFill(colorStr, stackedStops);
4537
4626
  fillOpacity = 1;
4538
4627
  }
4628
+ const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
4539
4629
  marks.push({
4540
4630
  type: "area",
4631
+ key: seriesKey,
4632
+ pointKeys: dedupeKeys(rawPointKeys),
4633
+ interpolate: spec.markDef.interpolate ?? "monotone",
4541
4634
  topPoints,
4542
4635
  bottomPoints,
4543
4636
  path: pathStr,
@@ -4570,6 +4663,10 @@ function computeAreaMarks(spec, scales, chartArea, darkMode) {
4570
4663
  } else {
4571
4664
  marks = computeSingleArea(spec, scales, chartArea);
4572
4665
  }
4666
+ const areaKeys = dedupeKeys(marks.map((m) => m.key));
4667
+ marks.forEach((m, i) => {
4668
+ m.key = areaKeys[i];
4669
+ });
4573
4670
  const highlight = spec.highlight ?? [];
4574
4671
  if (highlight.length > 0) {
4575
4672
  const highlightSet = new Set(highlight);
@@ -4655,8 +4752,15 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4655
4752
  let strokeDasharray;
4656
4753
  if (styleOverride?.lineStyle === "dashed") strokeDasharray = "6 4";
4657
4754
  else if (styleOverride?.lineStyle === "dotted") strokeDasharray = "2 3";
4755
+ const keyField = encoding.key && "field" in encoding.key ? encoding.key.field : void 0;
4756
+ const rawPointKeys = pointsWithData.map(
4757
+ (p) => keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field])
4758
+ );
4658
4759
  const lineMark = {
4659
4760
  type: "line",
4761
+ key: seriesStyleKey ?? "series",
4762
+ pointKeys: dedupeKeys(rawPointKeys),
4763
+ interpolate: spec.markDef.interpolate ?? "monotone",
4660
4764
  points: allPoints,
4661
4765
  path: combinedPath,
4662
4766
  stroke: strokeColor,
@@ -4695,9 +4799,11 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4695
4799
  }
4696
4800
  const hollow = isEndpoints && visible;
4697
4801
  const pointColorStr = getRepresentativeColor5(pointColor);
4802
+ const pointKey = keyField ? serializeKeyValue(p.row[keyField]) : seriesStyleKey ? `${seriesStyleKey}|${serializeKeyValue(p.row[xChannel.field])}` : serializeKeyValue(p.row[xChannel.field]);
4698
4803
  if (isSingleEndpoint) {
4699
4804
  bucket.push({
4700
4805
  type: "point",
4806
+ key: pointKey,
4701
4807
  cx: p.x,
4702
4808
  cy: p.y,
4703
4809
  r: 3.5,
@@ -4712,6 +4818,7 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4712
4818
  }
4713
4819
  const pointMark = {
4714
4820
  type: "point",
4821
+ key: pointKey,
4715
4822
  cx: p.x,
4716
4823
  cy: p.y,
4717
4824
  r: visible ? DEFAULT_POINT_RADIUS : 0,
@@ -4728,7 +4835,20 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4728
4835
  }
4729
4836
  }
4730
4837
  }
4731
- return [...mutedMarks, ...highlightedMarks];
4838
+ const allMarks = [...mutedMarks, ...highlightedMarks];
4839
+ const lineMarks = allMarks.filter((m) => m.type === "line");
4840
+ const lineKeys = dedupeKeys(lineMarks.map((m) => m.key));
4841
+ lineMarks.forEach((m, i) => {
4842
+ m.key = lineKeys[i];
4843
+ });
4844
+ const pointMarks = allMarks.filter((m) => m.type === "point");
4845
+ if (pointMarks.length > 0) {
4846
+ const pointKeys = dedupeKeys(pointMarks.map((m) => m.key ?? ""));
4847
+ pointMarks.forEach((m, i) => {
4848
+ m.key = pointKeys[i];
4849
+ });
4850
+ }
4851
+ return allMarks;
4732
4852
  }
4733
4853
  function _getMidValue(rows, field) {
4734
4854
  const values = rows.map((r) => Number(r[field])).filter(Number.isFinite);
@@ -5028,6 +5148,14 @@ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false) {
5028
5148
  aria
5029
5149
  });
5030
5150
  }
5151
+ const rawKeys = marks.map((m) => {
5152
+ const label = categoryField ? String(m.data[categoryField] ?? "") : String(m.data.label ?? m.data.name ?? m.data.category ?? "");
5153
+ return serializeKeyValue(label);
5154
+ });
5155
+ const keys = dedupeKeys(rawKeys);
5156
+ for (let i = 0; i < marks.length; i++) {
5157
+ marks[i].key = keys[i];
5158
+ }
5031
5159
  return marks;
5032
5160
  }
5033
5161
 
@@ -5202,6 +5330,12 @@ function computeRuleMarks(spec, scales, chartArea) {
5202
5330
  aria
5203
5331
  });
5204
5332
  }
5333
+ const posField = xChannel?.field ?? yChannel?.field;
5334
+ const rawKeys = marks.map((m, i) => posField ? serializeKeyValue(m.data[posField]) : String(i));
5335
+ const keys = dedupeKeys(rawKeys);
5336
+ for (let i = 0; i < marks.length; i++) {
5337
+ marks[i].key = keys[i];
5338
+ }
5205
5339
  return marks;
5206
5340
  }
5207
5341
  var ruleRenderer = (spec, scales, chartArea, _strategy, _theme) => {
@@ -6190,19 +6324,19 @@ function copy(source, target) {
6190
6324
  return target.domain(source.domain()).range(source.range()).interpolate(source.interpolate()).clamp(source.clamp()).unknown(source.unknown());
6191
6325
  }
6192
6326
  function transformer() {
6193
- 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;
6194
6328
  function rescale() {
6195
6329
  var n = Math.min(domain.length, range2.length);
6196
- if (clamp !== identity) clamp = clamper(domain[0], domain[n - 1]);
6330
+ if (clamp2 !== identity) clamp2 = clamper(domain[0], domain[n - 1]);
6197
6331
  piecewise = n > 2 ? polymap : bimap;
6198
6332
  output = input = null;
6199
6333
  return scale;
6200
6334
  }
6201
6335
  function scale(x2) {
6202
- 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)));
6203
6337
  }
6204
6338
  scale.invert = function(y2) {
6205
- 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)));
6206
6340
  };
6207
6341
  scale.domain = function(_) {
6208
6342
  return arguments.length ? (domain = Array.from(_, number2), rescale()) : domain.slice();
@@ -6214,7 +6348,7 @@ function transformer() {
6214
6348
  return range2 = Array.from(_), interpolate = round_default, rescale();
6215
6349
  };
6216
6350
  scale.clamp = function(_) {
6217
- return arguments.length ? (clamp = _ ? true : identity, rescale()) : clamp !== identity;
6351
+ return arguments.length ? (clamp2 = _ ? true : identity, rescale()) : clamp2 !== identity;
6218
6352
  };
6219
6353
  scale.interpolate = function(_) {
6220
6354
  return arguments.length ? (interpolate = _, rescale()) : interpolate;
@@ -7546,15 +7680,15 @@ function utcTime() {
7546
7680
 
7547
7681
  // ../../node_modules/.bun/d3-scale@4.0.2/node_modules/d3-scale/src/sequential.js
7548
7682
  function transformer2() {
7549
- 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;
7550
7684
  function scale(x2) {
7551
- 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));
7552
7686
  }
7553
7687
  scale.domain = function(_) {
7554
7688
  return arguments.length ? ([x0, x1] = _, t02 = transform(x0 = +x0), t12 = transform(x1 = +x1), k10 = t02 === t12 ? 0 : 1 / (t12 - t02), scale) : [x0, x1];
7555
7689
  };
7556
7690
  scale.clamp = function(_) {
7557
- return arguments.length ? (clamp = !!_, scale) : clamp;
7691
+ return arguments.length ? (clamp2 = !!_, scale) : clamp2;
7558
7692
  };
7559
7693
  scale.interpolator = function(_) {
7560
7694
  return arguments.length ? (interpolator = _, scale) : interpolator;
@@ -7639,6 +7773,8 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy) {
7639
7773
  const [radiusMin, radiusMax] = explicitRange ?? [MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS];
7640
7774
  sizeScale = sqrt2().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
7641
7775
  }
7776
+ const keyEnc = encoding.key && "field" in encoding.key ? encoding.key : void 0;
7777
+ const keyField = keyEnc?.field;
7642
7778
  const marks = [];
7643
7779
  for (const row of spec.data) {
7644
7780
  const rawX = row[xChannel.field];
@@ -7682,6 +7818,16 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy) {
7682
7818
  aria
7683
7819
  });
7684
7820
  }
7821
+ const rawKeys = marks.map((m) => {
7822
+ if (keyField) return serializeKeyValue(m.data[keyField]);
7823
+ const cat = colorField ? String(m.data[colorField] ?? "") : "";
7824
+ const xVal = serializeKeyValue(m.data[xChannel.field]);
7825
+ return cat ? `${cat}|${xVal}` : xVal;
7826
+ });
7827
+ const keys = dedupeKeys(rawKeys);
7828
+ for (let i = 0; i < marks.length; i++) {
7829
+ marks[i].key = keys[i];
7830
+ }
7685
7831
  return marks;
7686
7832
  }
7687
7833
 
@@ -7799,6 +7945,12 @@ function computeTextMarks(spec, scales) {
7799
7945
  aria
7800
7946
  });
7801
7947
  }
7948
+ const posField = xChannel?.field ?? yChannel?.field;
7949
+ const rawKeys = marks.map((m, i) => posField ? serializeKeyValue(m.data[posField]) : String(i));
7950
+ const keys = dedupeKeys(rawKeys);
7951
+ for (let i = 0; i < marks.length; i++) {
7952
+ marks[i].key = keys[i];
7953
+ }
7802
7954
  return marks;
7803
7955
  }
7804
7956
  var textRenderer = (spec, scales, _chartArea, _strategy, _theme) => {
@@ -7841,6 +7993,12 @@ function computeTickMarks(spec, scales, _chartArea) {
7841
7993
  aria
7842
7994
  });
7843
7995
  }
7996
+ const posField = isHorizontal ? xChannel.field : yChannel.field;
7997
+ const rawKeys = marks.map((m) => serializeKeyValue(m.data[posField]));
7998
+ const keys = dedupeKeys(rawKeys);
7999
+ for (let i = 0; i < marks.length; i++) {
8000
+ marks[i].key = keys[i];
8001
+ }
7844
8002
  return marks;
7845
8003
  }
7846
8004
  var tickRenderer = (spec, scales, chartArea, _strategy, _theme) => {
@@ -7893,69 +8051,70 @@ var ENTER_DEFAULTS = {
7893
8051
  duration: 500,
7894
8052
  ease: "smooth",
7895
8053
  staggerDelay: 80,
7896
- staggerOrder: "index",
7897
- annotationDelay: 200
8054
+ staggerOrder: "index"
8055
+ };
8056
+ var UPDATE_DEFAULTS = {
8057
+ duration: 500,
8058
+ ease: "smooth",
8059
+ staggerDelay: 0,
8060
+ staggerOrder: "index"
7898
8061
  };
8062
+ var EXIT_DEFAULTS = {
8063
+ duration: 300,
8064
+ ease: "smooth",
8065
+ staggerDelay: 0,
8066
+ staggerOrder: "index"
8067
+ };
8068
+ var DEFAULT_ANNOTATION_DELAY = 200;
7899
8069
  var MAX_TOTAL_STAGGER_MS = 2e3;
7900
8070
  function resolveAnimation(spec) {
7901
8071
  if (spec === void 0 || spec === false) return void 0;
7902
8072
  if (spec === true) {
7903
8073
  return {
7904
- enabled: true,
7905
- duration: ENTER_DEFAULTS.duration,
7906
- ease: ENTER_DEFAULTS.ease,
7907
- staggerDelay: ENTER_DEFAULTS.staggerDelay,
7908
- staggerOrder: ENTER_DEFAULTS.staggerOrder,
7909
- annotationDelay: ENTER_DEFAULTS.annotationDelay
8074
+ enter: { ...ENTER_DEFAULTS },
8075
+ update: { ...UPDATE_DEFAULTS },
8076
+ exit: { ...EXIT_DEFAULTS },
8077
+ annotationDelay: DEFAULT_ANNOTATION_DELAY
7910
8078
  };
7911
8079
  }
7912
8080
  const config = spec;
7913
- if (config.enter === false || config.enter === void 0 && !hasAnyPhase(config)) {
7914
- return void 0;
7915
- }
7916
- const enterConfig = resolvePhaseConfig(config.enter);
8081
+ const enter = resolvePhase(config.enter, ENTER_DEFAULTS);
8082
+ const update = resolvePhase(config.update, UPDATE_DEFAULTS);
8083
+ const exit = resolvePhase(config.exit, EXIT_DEFAULTS);
8084
+ if (!enter && !update && !exit) return void 0;
7917
8085
  return {
7918
- enabled: true,
7919
- duration: enterConfig.duration,
7920
- ease: enterConfig.ease,
7921
- staggerDelay: enterConfig.staggerDelay,
7922
- staggerOrder: enterConfig.staggerOrder,
7923
- annotationDelay: config.annotationDelay ?? ENTER_DEFAULTS.annotationDelay
8086
+ enter: enter ?? void 0,
8087
+ update: update ?? void 0,
8088
+ exit: exit ?? void 0,
8089
+ annotationDelay: config.annotationDelay ?? DEFAULT_ANNOTATION_DELAY
7924
8090
  };
7925
8091
  }
7926
8092
  function clampStaggerDelay(delay, elementCount) {
7927
8093
  if (elementCount <= 1) return 0;
7928
8094
  return Math.min(delay, MAX_TOTAL_STAGGER_MS / elementCount);
7929
8095
  }
7930
- function hasAnyPhase(config) {
7931
- return config.enter !== void 0 || config.update !== void 0 || config.exit !== void 0;
7932
- }
7933
- function resolvePhaseConfig(phase) {
8096
+ function resolvePhase(phase, defaults) {
8097
+ if (phase === false) return null;
7934
8098
  if (phase === void 0 || phase === true) {
7935
- return {
7936
- duration: ENTER_DEFAULTS.duration,
7937
- ease: ENTER_DEFAULTS.ease,
7938
- staggerDelay: ENTER_DEFAULTS.staggerDelay,
7939
- staggerOrder: ENTER_DEFAULTS.staggerOrder
7940
- };
8099
+ return { ...defaults };
7941
8100
  }
7942
8101
  const cfg = phase;
7943
- const stagger = resolveStagger(cfg.stagger);
8102
+ const stagger = resolveStagger(cfg.stagger, defaults);
7944
8103
  return {
7945
- duration: cfg.duration ?? ENTER_DEFAULTS.duration,
7946
- ease: cfg.ease ?? ENTER_DEFAULTS.ease,
8104
+ duration: cfg.duration ?? defaults.duration,
8105
+ ease: cfg.ease ?? defaults.ease,
7947
8106
  staggerDelay: stagger.delay,
7948
8107
  staggerOrder: stagger.order
7949
8108
  };
7950
8109
  }
7951
- function resolveStagger(stagger) {
8110
+ function resolveStagger(stagger, defaults) {
7952
8111
  if (stagger === false) return { delay: 0, order: "index" };
7953
8112
  if (stagger === void 0 || stagger === true) {
7954
- return { delay: ENTER_DEFAULTS.staggerDelay, order: ENTER_DEFAULTS.staggerOrder };
8113
+ return { delay: defaults.staggerDelay, order: defaults.staggerOrder };
7955
8114
  }
7956
8115
  return {
7957
- delay: stagger.delay ?? ENTER_DEFAULTS.staggerDelay,
7958
- order: stagger.order ?? ENTER_DEFAULTS.staggerOrder
8116
+ delay: stagger.delay ?? defaults.staggerDelay,
8117
+ order: stagger.order ?? defaults.staggerOrder
7959
8118
  };
7960
8119
  }
7961
8120
 
@@ -8160,7 +8319,7 @@ function inferFieldType(data, field) {
8160
8319
  }
8161
8320
  function inferEncodingTypes(encoding, data, warnings) {
8162
8321
  const result = { ...encoding };
8163
- for (const channel of ["x", "y", "color", "size", "detail"]) {
8322
+ for (const channel of ["x", "y", "color", "size", "detail", "key"]) {
8164
8323
  const spec = result[channel];
8165
8324
  if (!spec) continue;
8166
8325
  if ("condition" in spec) continue;
@@ -9799,8 +9958,8 @@ function getMarkPrimaryValue(mark) {
9799
9958
  }
9800
9959
  }
9801
9960
  function assignAnimationIndices(marks, animation) {
9802
- if (!animation?.enabled) return;
9803
- if (animation.staggerOrder === "value") {
9961
+ if (!animation?.enter) return;
9962
+ if (animation.enter.staggerOrder === "value") {
9804
9963
  const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
9805
9964
  indexed.sort((a, b) => {
9806
9965
  const av = getMarkPrimaryValue(a.mark);
@@ -9900,9 +10059,9 @@ import {
9900
10059
  AXIS_TITLE_TRAILING_PAD as AXIS_TITLE_TRAILING_PAD2,
9901
10060
  abbreviateNumber as abbreviateNumber4,
9902
10061
  BREAKPOINT_COMPACT_MAX as BREAKPOINT_COMPACT_MAX2,
9903
- estimateTextWidth as estimateTextWidth13,
10062
+ estimateTextWidth as estimateTextWidth15,
9904
10063
  formatNumber as formatNumber5,
9905
- getAxisTitleOffset,
10064
+ getAxisTitleOffset as getAxisTitleOffset2,
9906
10065
  resolveTheme as resolveTheme2,
9907
10066
  TICK_LABEL_OFFSET
9908
10067
  } from "@opendata-ai/openchart-core";
@@ -9911,11 +10070,11 @@ import {
9911
10070
  import {
9912
10071
  AXIS_TITLE_TRAILING_PAD,
9913
10072
  abbreviateNumber as abbreviateNumber3,
9914
- axisTitleOffset,
10073
+ axisTitleOffset as axisTitleOffset2,
9915
10074
  BREAKPOINT_COMPACT_MAX,
9916
10075
  computeChrome as computeChrome2,
9917
- computeXAxisExtentFromLabels,
9918
- estimateTextWidth as estimateTextWidth12,
10076
+ computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
10077
+ estimateTextWidth as estimateTextWidth14,
9919
10078
  formatNumber as formatNumber4,
9920
10079
  HPAD_COMPACT_FRACTION,
9921
10080
  HPAD_COMPACT_MIN,
@@ -10337,6 +10496,15 @@ function placeLegend(content, chartArea, containerWidth, _theme, xAxisHeight) {
10337
10496
  };
10338
10497
  }
10339
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
+
10340
10508
  // src/layout/axes/rotation.ts
10341
10509
  var BAND_FIT_FRACTION = 0.85;
10342
10510
  var AUTO_ROTATE_ANGLE = -45;
@@ -10346,6 +10514,50 @@ function resolveBandTickAngle(explicitAngle, maxLabelWidth, bandwidth, labelCoun
10346
10514
  return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : void 0;
10347
10515
  }
10348
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
+
10349
10561
  // src/layout/axes/ticks.ts
10350
10562
  import {
10351
10563
  abbreviateNumber as abbreviateNumber2,
@@ -10535,168 +10747,508 @@ function resolveExplicitTicks(values, resolvedScale) {
10535
10747
  });
10536
10748
  }
10537
10749
 
10538
- // src/layout/scales.ts
10539
- function fieldValues(data, field) {
10540
- return data.map((d) => d[field]).filter((v) => v != null);
10541
- }
10542
- function parseDates(values) {
10543
- return values.map((v) => v instanceof Date ? v : new Date(String(v))).filter((d) => !Number.isNaN(d.getTime()));
10544
- }
10545
- function parseNumbers(values) {
10546
- return values.map((v) => typeof v === "number" ? v : Number(v)).filter((n) => Number.isFinite(n));
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";
10547
10763
  }
10548
- function uniqueStrings(values) {
10549
- const seen = /* @__PURE__ */ new Set();
10550
- const result = [];
10551
- for (const v of values) {
10552
- const s = String(v);
10553
- if (!seen.has(s)) {
10554
- seen.add(s);
10555
- result.push(s);
10556
- }
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)];
10557
10778
  }
10558
- return result;
10559
- }
10560
- function applyCategoricalSort(values, sort) {
10561
- if (!sort) return values;
10562
- const sorted = [...values].sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
10563
- if (sort === "descending") sorted.reverse();
10564
- return sorted;
10779
+ return density;
10565
10780
  }
10566
- function applyContinuousConfig(scale, channel) {
10567
- if (channel.scale?.clamp) {
10568
- scale.clamp(true);
10569
- }
10570
- if (channel.scale?.reverse) {
10571
- const [r0, r1] = scale.range();
10572
- scale.range([r1, r0]);
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;
10573
10792
  }
10793
+ return best;
10574
10794
  }
10575
- function buildTimeScale(channel, data, rangeStart, rangeEnd) {
10576
- const values = parseDates(fieldValues(data, channel.field));
10577
- const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
10578
- const scale = time().domain(domain).range([rangeStart, rangeEnd]);
10579
- if (!channel.scale?.domain && channel.scale?.nice === true) {
10580
- scale.nice();
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;
10817
+ }
10818
+ if (candidate.length >= floor) bestWithinFloor = candidate;
10581
10819
  }
10582
- applyContinuousConfig(scale, channel);
10583
- return { scale, type: "time", channel };
10820
+ const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
10821
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
10822
+ return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
10584
10823
  }
10585
- function buildUtcScale(channel, data, rangeStart, rangeEnd) {
10586
- const values = parseDates(fieldValues(data, channel.field));
10587
- const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
10588
- const scale = utcTime().domain(domain).range([rangeStart, rangeEnd]);
10589
- if (!channel.scale?.domain && channel.scale?.nice === true) {
10590
- scale.nice();
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 };
10591
10830
  }
10592
- applyContinuousConfig(scale, channel);
10593
- return { scale, type: "utc", channel };
10594
- }
10595
- function buildLinearScale(channel, data, rangeStart, rangeEnd) {
10596
- const values = parseNumbers(fieldValues(data, channel.field));
10597
- let domainMin;
10598
- let domainMax;
10599
- if (channel.scale?.domain) {
10600
- const [d0, d1] = channel.scale.domain;
10601
- domainMin = d0;
10602
- domainMax = d1;
10603
- } else {
10604
- domainMin = min2(values) ?? 0;
10605
- domainMax = max2(values) ?? 1;
10606
- if (channel.scale?.zero !== false) {
10607
- domainMin = Math.min(0, domainMin);
10608
- domainMax = Math.max(0, domainMax);
10609
- }
10831
+ if (angleDeg < 0) {
10832
+ return { left: tick.position - projected, right: tick.position };
10610
10833
  }
10611
- const scale = linear2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10612
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10613
- scale.nice();
10614
- if (channel.scale?.zero === false) {
10615
- const [nicedMin, nicedMax] = scale.domain();
10616
- if (nicedMin < domainMin || nicedMax > domainMax) {
10617
- scale.domain([domainMin, domainMax]);
10618
- scale.nice(20);
10834
+ return { left: tick.position, right: tick.position + projected };
10835
+ }
10836
+ function thinBandTicksIfNeeded(ticks2, angleDeg, fontSize, fontWeight, measureText) {
10837
+ if (ticks2.length < 3) return ticks2;
10838
+ const minGap = fontSize * 0.5;
10839
+ const spans = ticks2.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
10840
+ const keep = new Array(ticks2.length).fill(false);
10841
+ keep[0] = true;
10842
+ let lastRight = spans[0].right;
10843
+ for (let i = 1; i < ticks2.length; i++) {
10844
+ if (spans[i].left >= lastRight + minGap) {
10845
+ keep[i] = true;
10846
+ lastRight = spans[i].right;
10847
+ }
10848
+ }
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;
10619
10857
  }
10858
+ break;
10620
10859
  }
10621
10860
  }
10622
- applyContinuousConfig(scale, channel);
10623
- return { scale, type: "linear", channel };
10624
- }
10625
- function buildLogScale(channel, data, rangeStart, rangeEnd) {
10626
- const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
10627
- const domainMin = channel.scale?.domain ? channel.scale.domain[0] : min2(values) ?? 1;
10628
- const domainMax = channel.scale?.domain ? channel.scale.domain[1] : max2(values) ?? 10;
10629
- const scale = log().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10630
- if (channel.scale?.base !== void 0) {
10631
- scale.base(channel.scale.base);
10632
- }
10633
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10634
- scale.nice();
10635
- }
10636
- applyContinuousConfig(scale, channel);
10637
- return { scale, type: "log", channel };
10861
+ return ticks2.filter((_, i) => keep[i]);
10638
10862
  }
10639
- function buildPowScale(channel, data, rangeStart, rangeEnd) {
10640
- const values = parseNumbers(fieldValues(data, channel.field));
10641
- let domainMin;
10642
- let domainMax;
10643
- if (channel.scale?.domain) {
10644
- [domainMin, domainMax] = channel.scale.domain;
10645
- } else {
10646
- domainMin = min2(values) ?? 0;
10647
- domainMax = max2(values) ?? 1;
10648
- if (channel.scale?.zero !== false) {
10649
- domainMin = Math.min(0, domainMin);
10650
- 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);
10651
10906
  }
10652
- }
10653
- const scale = pow().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10654
- if (channel.scale?.exponent !== void 0) {
10655
- scale.exponent(channel.scale.exponent);
10656
- }
10657
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10658
- scale.nice();
10659
- }
10660
- applyContinuousConfig(scale, channel);
10661
- return { scale, type: "pow", channel };
10662
- }
10663
- function buildSqrtScale(channel, data, rangeStart, rangeEnd) {
10664
- const values = parseNumbers(fieldValues(data, channel.field));
10665
- let domainMin;
10666
- let domainMax;
10667
- if (channel.scale?.domain) {
10668
- [domainMin, domainMax] = channel.scale.domain;
10669
- } else {
10670
- domainMin = min2(values) ?? 0;
10671
- domainMax = max2(values) ?? 1;
10672
- if (channel.scale?.zero !== false) {
10673
- domainMin = Math.min(0, domainMin);
10674
- domainMax = Math.max(0, domainMax);
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);
10675
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
+ };
10676
10965
  }
10677
- const scale = sqrt2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10678
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
10679
- scale.nice();
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
+ };
10680
11040
  }
10681
- applyContinuousConfig(scale, channel);
10682
- return { scale, type: "sqrt", channel };
10683
- }
10684
- function buildSymlogScale(channel, data, rangeStart, rangeEnd) {
10685
- const values = parseNumbers(fieldValues(data, channel.field));
10686
- let domainMin;
10687
- let domainMax;
10688
- if (channel.scale?.domain) {
10689
- [domainMin, domainMax] = channel.scale.domain;
10690
- } else {
10691
- domainMin = min2(values) ?? 0;
10692
- domainMax = max2(values) ?? 1;
10693
- if (channel.scale?.zero !== false) {
10694
- domainMin = Math.min(0, domainMin);
10695
- domainMax = Math.max(0, domainMax);
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
+ };
10696
11059
  }
10697
11060
  }
10698
- const scale = symlog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
10699
- if (channel.scale?.constant !== void 0) {
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;
11088
+ }
11089
+
11090
+ // src/layout/scales.ts
11091
+ function fieldValues(data, field) {
11092
+ return data.map((d) => d[field]).filter((v) => v != null);
11093
+ }
11094
+ function parseDates(values) {
11095
+ return values.map((v) => v instanceof Date ? v : new Date(String(v))).filter((d) => !Number.isNaN(d.getTime()));
11096
+ }
11097
+ function parseNumbers(values) {
11098
+ return values.map((v) => typeof v === "number" ? v : Number(v)).filter((n) => Number.isFinite(n));
11099
+ }
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) {
10700
11252
  scale.constant(channel.scale.constant);
10701
11253
  }
10702
11254
  if (!channel.scale?.domain && channel.scale?.nice !== false) {
@@ -11032,7 +11584,7 @@ function createMeasureFn(measureText) {
11032
11584
  if (measureText) {
11033
11585
  return (text, fontSize, fontWeight) => measureText(text, fontSize, fontWeight).width;
11034
11586
  }
11035
- return estimateTextWidth12;
11587
+ return estimateTextWidth14;
11036
11588
  }
11037
11589
  function sampleLabelFromMagnitude(maxAbsVal) {
11038
11590
  return maxAbsVal >= 1e3 ? abbreviateNumber3(maxAbsVal) : formatNumber4(maxAbsVal);
@@ -11081,11 +11633,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11081
11633
  };
11082
11634
  }
11083
11635
  const yAxisCfg = encoding.y?.axis ?? void 0;
11084
- const yTickPositionExplicit = yAxisCfg?.tickPosition;
11085
11636
  const yIsContinuous = encoding.y?.type === "quantitative" || encoding.y?.type === "temporal";
11086
- const yIsLineOrArea = renderSpec.markType === "line" || renderSpec.markType === "area";
11087
- const yAxisOrient = yAxisCfg?.orient;
11088
- const yIsInline = yTickPositionExplicit === "inline" || yTickPositionExplicit === void 0 && yIsLineOrArea && yIsContinuous && yAxisOrient !== "right";
11637
+ const yIsInline = yTickPositionIsInline(encoding.y, renderSpec.markType);
11089
11638
  const yAxisSuppressed = encoding.y?.axis === false;
11090
11639
  let leftGutter = hPad + axisMargin;
11091
11640
  if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
@@ -11212,7 +11761,7 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11212
11761
  xLabels.length
11213
11762
  );
11214
11763
  }
11215
- const xAxisExtent = isRadial || xAxisSuppressed ? 0 : computeXAxisExtentFromLabels({
11764
+ const xAxisExtent = isRadial || xAxisSuppressed ? 0 : computeXAxisExtentFromLabels2({
11216
11765
  labels: xLabels,
11217
11766
  tickAngle: effectiveXTickAngle,
11218
11767
  hasTitle: hasXAxisTitle,
@@ -11336,1270 +11885,904 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
11336
11885
  const yAxisDef = encoding.y?.axis;
11337
11886
  if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
11338
11887
  let estTickLabelWidth = 0;
11339
- if (encoding.y?.field && yIsContinuous) {
11888
+ if (encoding.y?.field && yIsContinuous && !yIsInline) {
11340
11889
  if (yTickValues.length > 0) {
11341
11890
  const titleProvArea = {
11342
11891
  x: finalGutter,
11343
11892
  y: topMargin,
11344
11893
  width: Math.max(0, width - finalGutter - hPad),
11345
11894
  height: chartHeight
11346
- };
11347
- const scales = computeScales(renderSpec, titleProvArea, renderSpec.data);
11348
- if (scales.y && scaleSupportsTickCount(scales.y)) {
11349
- const ticks2 = buildContinuousTicks(scales.y, yTickCount || 5);
11350
- for (const t of ticks2) {
11351
- const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
11352
- if (w > estTickLabelWidth) estTickLabelWidth = w;
11353
- }
11354
- }
11355
- }
11356
- }
11357
- const titleFontSize = theme.fonts.sizes.body;
11358
- const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
11359
- const halfGlyph = Math.ceil(titleFontSize / 2);
11360
- const rotatedLabelMargin = offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
11361
- gutterWithTitle = Math.max(finalGutter, hPad + rotatedLabelMargin);
11362
- }
11363
- finalPlan = {
11364
- leftGutter: gutterWithTitle,
11365
- xAxisExtent,
11366
- legendContent,
11367
- chrome,
11368
- yTickValues,
11369
- yTickCount,
11370
- converged: prev === null || converged,
11371
- inlineYLabelInset,
11372
- endpointLabelsDemoted
11373
- };
11374
- break;
11375
- }
11376
- prev = { gutter: newGutter, rowCount: legendContent.rowCount, xAxisExtent };
11377
- leftGutter = newGutter;
11378
- }
11379
- if (!finalPlan) throw new Error("resolveLayoutPlan: loop exited without producing a plan");
11380
- return finalPlan;
11381
- }
11382
-
11383
- // src/compile/layer.ts
11384
- function compileLayer(spec, options, compileChart2) {
11385
- const leaves = flattenLayers(spec);
11386
- if (leaves.length === 0) {
11387
- throw new Error("LayerSpec has no leaf chart specs after flattening");
11388
- }
11389
- if (leaves.length === 1) {
11390
- const singleSpec = buildPrimarySpec(leaves, spec);
11391
- return compileChart2(singleSpec, options);
11392
- }
11393
- if (spec.resolve?.scale?.y === "independent") {
11394
- return compileLayerIndependent(leaves, spec, options, compileChart2);
11395
- }
11396
- const primarySpec = buildPrimarySpec(leaves, spec);
11397
- const primaryLayout = compileChart2(primarySpec, options);
11398
- const allMarks = [];
11399
- const seenLabels = /* @__PURE__ */ new Set();
11400
- const pLegend = primaryLayout.legend;
11401
- const mergedLegendEntries = "entries" in pLegend ? [...pLegend.entries] : [];
11402
- for (const entry of mergedLegendEntries) {
11403
- seenLabels.add(entry.label);
11404
- }
11405
- const indexedLeaves = leaves.map((leaf, i) => ({
11406
- leaf,
11407
- zIndex: leaf.zIndex ?? i
11408
- }));
11409
- indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
11410
- const leafOptions = { ...options, frozenChartArea: primaryLayout.area };
11411
- for (const { leaf } of indexedLeaves) {
11412
- const themedLeaf = {
11413
- ...leaf,
11414
- theme: leaf.theme ?? spec.theme,
11415
- darkMode: leaf.darkMode ?? spec.darkMode
11416
- };
11417
- const leafLayout = compileChart2(themedLeaf, leafOptions);
11418
- allMarks.push(...leafLayout.marks);
11419
- const leafLeg = leafLayout.legend;
11420
- if ("entries" in leafLeg) {
11421
- for (const entry of leafLeg.entries) {
11422
- if (!seenLabels.has(entry.label)) {
11423
- seenLabels.add(entry.label);
11424
- mergedLegendEntries.push(entry);
11425
- }
11426
- }
11427
- }
11428
- }
11429
- return {
11430
- ...primaryLayout,
11431
- marks: allMarks,
11432
- legend: {
11433
- ...primaryLayout.legend,
11434
- ..."entries" in pLegend ? { entries: mergedLegendEntries } : {}
11435
- }
11436
- };
11437
- }
11438
- function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
11439
- if (!encoding?.y) return 40;
11440
- const yEnc = encoding.y;
11441
- const yField = yEnc.field;
11442
- if (!yField) return 40;
11443
- const m = measure ?? estimateTextWidth13;
11444
- const yType = yEnc.type;
11445
- if (yType === "nominal" || yType === "ordinal") {
11446
- let maxWidth = 0;
11447
- for (const row of data) {
11448
- const label = String(row[yField] ?? "");
11449
- const w = m(label, baseFontSize, 400);
11450
- if (w > maxWidth) maxWidth = w;
11451
- }
11452
- return maxWidth > 0 ? maxWidth + 10 : 40;
11453
- }
11454
- const yAxisFormat = encoding.y.axis?.format;
11455
- let maxAbsVal = 0;
11456
- for (const row of data) {
11457
- const v = Number(row[yField]);
11458
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
11459
- }
11460
- let sampleLabel;
11461
- if (yAxisFormat) {
11462
- try {
11463
- const fmt = format(yAxisFormat);
11464
- sampleLabel = fmt(maxAbsVal);
11465
- } catch {
11466
- sampleLabel = String(maxAbsVal);
11467
- }
11468
- } else {
11469
- sampleLabel = Math.abs(maxAbsVal) >= 1e3 ? abbreviateNumber4(maxAbsVal) : formatNumber5(maxAbsVal);
11470
- }
11471
- const hasNeg = data.some((r) => Number(r[yField]) < 0);
11472
- const labelEst = (hasNeg ? "-" : "") + sampleLabel;
11473
- return m(labelEst, baseFontSize, 400) + 10;
11474
- }
11475
- function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
11476
- if (leaves.length > 2) {
11477
- throw new Error(
11478
- `Independent y-scales support at most 2 layers (left and right y-axis). Got ${leaves.length} layers.`
11479
- );
11480
- }
11481
- const leaf0 = leaves[0];
11482
- const leaf1 = leaves[1];
11483
- const xType0 = leaf0.encoding?.x?.type;
11484
- const xType1 = leaf1.encoding?.x?.type;
11485
- if (xType0 && xType1 && xType0 !== xType1) {
11486
- throw new Error(
11487
- `Dual-axis charts require matching x-field types across layers. Layer 0 has '${xType0}', layer 1 has '${xType1}'.`
11488
- );
11489
- }
11490
- const theme = resolveTheme2(layerSpec.theme ?? leaf1.theme);
11491
- const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
11492
- const measureFn = createMeasureFn(options.measureText);
11493
- const rightAxisWidth = estimateYAxisLabelWidth(
11494
- leaf1.data,
11495
- leaf1.encoding,
11496
- axisFontSize,
11497
- measureFn
11498
- );
11499
- const yAxisConfig = leaf1.encoding?.y?.axis || void 0;
11500
- const hasRightAxisTitle = !!yAxisConfig?.title;
11501
- const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
11502
- const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
11503
- const axisTitleOffset4 = getAxisTitleOffset(options.width);
11504
- const halfGlyph = Math.ceil(bodyFontSize / 2);
11505
- const titleExtent = hasRightAxisTitle ? axisTitleOffset4 + halfGlyph + (options.width < BREAKPOINT_COMPACT_MAX2 ? 0 : AXIS_TITLE_TRAILING_PAD2) : 0;
11506
- const rightReserve = Math.max(tickExtent, titleExtent);
11507
- const optionsWithReserve = {
11508
- ...options,
11509
- rightAxisReserve: rightReserve
11510
- };
11511
- const xField0 = leaf0.encoding?.x?.field;
11512
- const xField1 = leaf1.encoding?.x?.field;
11513
- const unionXValues = /* @__PURE__ */ new Set();
11514
- if (xField0) for (const row of leaf0.data) unionXValues.add(row[xField0]);
11515
- if (xField1) for (const row of leaf1.data) unionXValues.add(row[xField1]);
11516
- let leaf0WithUnionX = ensureXDomainCoverage(leaf0, xField0, unionXValues);
11517
- let leaf1WithUnionX = ensureXDomainCoverage(leaf1, xField1, unionXValues);
11518
- const aligned = alignYDomains(leaf0WithUnionX, leaf1WithUnionX);
11519
- if (aligned) {
11520
- leaf0WithUnionX = withYDomain(leaf0WithUnionX, aligned.domain0);
11521
- leaf1WithUnionX = withYDomain(leaf1WithUnionX, aligned.domain1);
11522
- }
11523
- const primary0 = buildPrimarySpec([leaf0WithUnionX], layerSpec);
11524
- const layout0 = compileChart2(primary0, optionsWithReserve);
11525
- const primary1 = buildPrimarySpec([leaf1WithUnionX], layerSpec);
11526
- primary1.annotations = [];
11527
- const layout1 = compileChart2(primary1, optionsWithReserve);
11528
- const y2Axis = layout1.axes.y ? {
11529
- ...layout1.axes.y,
11530
- orient: "right",
11531
- gridlines: [],
11532
- start: {
11533
- x: layout0.area.x + layout0.area.width,
11534
- y: layout0.area.y
11535
- },
11536
- end: {
11537
- x: layout0.area.x + layout0.area.width,
11538
- y: layout0.area.y + layout0.area.height
11539
- },
11540
- ...layout1.axes.y.label ? {
11541
- titlePosition: {
11542
- x: layout0.area.x + layout0.area.width + getAxisTitleOffset(layout0.dimensions.width),
11543
- y: layout0.area.y + layout0.area.height / 2,
11544
- angle: 90
11545
- }
11546
- } : {}
11547
- } : void 0;
11548
- const layer0HasBars = layout0.marks.some((m) => m.type === "rect");
11549
- const layer1HasBars = layout1.marks.some((m) => m.type === "rect");
11550
- const bandCenterByCategory = /* @__PURE__ */ new Map();
11551
- if (layer0HasBars && layout0.axes.x?.ticks) {
11552
- for (const tick of layout0.axes.x.ticks) {
11553
- bandCenterByCategory.set(String(tick.label), tick.position);
11554
- }
11555
- } else if (layer1HasBars && layout1.axes.x?.ticks) {
11556
- for (const tick of layout1.axes.x.ticks) {
11557
- bandCenterByCategory.set(String(tick.label), tick.position);
11558
- }
11559
- }
11560
- const remapMarkX = (xField, mark) => {
11561
- if (!xField || bandCenterByCategory.size === 0) return mark;
11562
- if (mark.type === "line") {
11563
- const newPoints = mark.points.map((p, i) => {
11564
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11565
- return bx !== void 0 ? { ...p, x: bx } : p;
11566
- });
11567
- const newDataPoints = mark.dataPoints?.map((dp, i) => {
11568
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11569
- return bx !== void 0 ? { ...dp, x: bx } : dp;
11570
- });
11571
- const newPath = line_default().x((p) => p.x).y((p) => p.y).curve(monotoneX)(newPoints) ?? void 0;
11572
- return { ...mark, points: newPoints, dataPoints: newDataPoints, path: newPath };
11573
- }
11574
- if (mark.type === "area") {
11575
- const newTopPoints = mark.topPoints.map((p, i) => {
11576
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11577
- return bx !== void 0 ? { ...p, x: bx } : p;
11578
- });
11579
- const newBottomPoints = mark.bottomPoints.map((p, i) => {
11580
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11581
- return bx !== void 0 ? { ...p, x: bx } : p;
11582
- });
11583
- const newDataPoints = mark.dataPoints?.map((dp, i) => {
11584
- const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ""));
11585
- return bx !== void 0 ? { ...dp, x: bx } : dp;
11586
- });
11587
- const areaGen = area_default().x((p) => p.x).y0((p) => p.yBottom).y1((p) => p.yTop).curve(monotoneX);
11588
- const topLineGen = line_default().x((p) => p.x).y((p) => p.yTop).curve(monotoneX);
11589
- const combined = newTopPoints.map((tp, i) => ({
11590
- x: tp.x,
11591
- yTop: tp.y,
11592
- yBottom: newBottomPoints[i]?.y ?? tp.y
11593
- }));
11594
- const newPath = areaGen(combined) ?? "";
11595
- const newTopPath = topLineGen(combined) ?? "";
11596
- return {
11597
- ...mark,
11598
- topPoints: newTopPoints,
11599
- bottomPoints: newBottomPoints,
11600
- dataPoints: newDataPoints,
11601
- path: newPath,
11602
- topPath: newTopPath
11603
- };
11604
- }
11605
- if (mark.type === "point") {
11606
- const bx = bandCenterByCategory.get(String(mark.data[xField] ?? ""));
11607
- return bx !== void 0 ? { ...mark, cx: bx } : mark;
11608
- }
11609
- return mark;
11610
- };
11611
- const adjustedMarks0 = bandCenterByCategory.size > 0 && !layer0HasBars ? layout0.marks.map((m) => remapMarkX(xField0, m)) : layout0.marks;
11612
- const taggedMarks1 = layout1.marks.map((mark) => {
11613
- const tagged = { ...mark, yScale: "y2" };
11614
- if (bandCenterByCategory.size > 0 && !layer1HasBars) {
11615
- return remapMarkX(xField1, tagged);
11616
- }
11617
- return tagged;
11618
- });
11619
- const seenLabels = /* @__PURE__ */ new Set();
11620
- const l0Legend = layout0.legend;
11621
- const l1Legend = layout1.legend;
11622
- const mergedLegendEntries = "entries" in l0Legend ? [...l0Legend.entries] : [];
11623
- for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
11624
- const l1Entries = "entries" in l1Legend ? l1Legend.entries : [];
11625
- for (const entry of l1Entries) {
11626
- if (!seenLabels.has(entry.label)) {
11627
- seenLabels.add(entry.label);
11628
- mergedLegendEntries.push(entry);
11629
- }
11630
- }
11631
- const l0Count = layout0.marks.length;
11632
- const mergedTooltips = new Map(layout0.tooltipDescriptors);
11633
- for (const [key, value2] of layout1.tooltipDescriptors) {
11634
- const match = /^(rect|point|arc)-(\d+)$/.exec(key);
11635
- if (match) {
11636
- const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
11637
- mergedTooltips.set(offsetKey, value2);
11638
- } else {
11639
- mergedTooltips.set(key, value2);
11640
- }
11641
- }
11642
- const z0 = leaf0.zIndex ?? 0;
11643
- const z1 = leaf1.zIndex ?? 1;
11644
- const marks = z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
11645
- return {
11646
- ...layout0,
11647
- axes: {
11648
- x: layout0.axes.x,
11649
- y: layout0.axes.y,
11650
- y2: y2Axis
11651
- },
11652
- marks,
11653
- legend: {
11654
- ...layout0.legend,
11655
- ..."entries" in l0Legend ? { entries: mergedLegendEntries } : {}
11656
- },
11657
- tooltipDescriptors: mergedTooltips
11658
- };
11659
- }
11660
- function ensureXDomainCoverage(leaf, xField, allXValues) {
11661
- if (!xField || allXValues.size === 0) return leaf;
11662
- const existingXValues = /* @__PURE__ */ new Set();
11663
- for (const row of leaf.data) existingXValues.add(row[xField]);
11664
- const missingRows = [];
11665
- for (const xVal of allXValues) {
11666
- if (!existingXValues.has(xVal)) {
11667
- missingRows.push({ [xField]: xVal });
11668
- }
11669
- }
11670
- if (missingRows.length === 0) return leaf;
11671
- return {
11672
- ...leaf,
11673
- data: [...leaf.data, ...missingRows]
11674
- };
11675
- }
11676
- function alignYDomains(leaf0, leaf1) {
11677
- const yEnc0 = leaf0.encoding?.y;
11678
- const yEnc1 = leaf1.encoding?.y;
11679
- if (!yEnc0 || !yEnc1) return void 0;
11680
- if (yEnc0.type !== "quantitative" || yEnc1.type !== "quantitative") return void 0;
11681
- if (yEnc0.scale?.domain || yEnc1.scale?.domain) return void 0;
11682
- const includeZero0 = yEnc0.scale?.zero !== false;
11683
- const includeZero1 = yEnc1.scale?.zero !== false;
11684
- const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
11685
- const vals1 = leaf1.data.map((r) => Number(r[yEnc1.field])).filter(Number.isFinite);
11686
- if (vals0.length === 0 || vals1.length === 0) return void 0;
11687
- const niced = (vals, includeZero) => {
11688
- let lo = Math.min(...vals);
11689
- let hi = Math.max(...vals);
11690
- if (includeZero) {
11691
- lo = Math.min(0, lo);
11692
- hi = Math.max(0, hi);
11693
- }
11694
- const s = linear2().domain([lo, hi]);
11695
- s.nice();
11696
- const [dLo, dHi] = s.domain();
11697
- return [dLo, dHi];
11698
- };
11699
- const [min0, max0] = niced(vals0, includeZero0);
11700
- const [min1, max1] = niced(vals1, includeZero1);
11701
- const span0 = max0 - min0;
11702
- const span1 = max1 - min1;
11703
- if (span0 === 0 || span1 === 0) return void 0;
11704
- const zf0 = (0 - min0) / span0;
11705
- const zf1 = (0 - min1) / span1;
11706
- const zeroInDomain0 = zf0 >= -1e-3 && zf0 <= 1.001;
11707
- const zeroInDomain1 = zf1 >= -1e-3 && zf1 <= 1.001;
11708
- if (!zeroInDomain0 || !zeroInDomain1) return void 0;
11709
- if (Math.abs(zf0 - zf1) < 1e-3) {
11710
- return { domain0: [min0, max0], domain1: [min1, max1] };
11711
- }
11712
- const targetZf = Math.max(zf0, zf1);
11713
- const align = (dMin, dMax, currentZf) => {
11714
- if (Math.abs(currentZf - targetZf) < 1e-3) return [dMin, dMax];
11715
- if (targetZf > currentZf) {
11716
- const newMin = -(targetZf / (1 - targetZf)) * dMax;
11717
- return [newMin, dMax];
11718
- }
11719
- const newMax = -dMin * (1 - targetZf) / targetZf;
11720
- return [dMin, newMax];
11721
- };
11722
- const domain0 = align(min0, max0, zf0);
11723
- const domain1 = align(min1, max1, zf1);
11724
- return { domain0, domain1 };
11725
- }
11726
- function withYDomain(leaf, domain) {
11727
- if (!leaf.encoding?.y) return leaf;
11728
- return {
11729
- ...leaf,
11730
- encoding: {
11731
- ...leaf.encoding,
11732
- y: {
11733
- ...leaf.encoding.y,
11734
- scale: {
11735
- ...leaf.encoding.y.scale,
11736
- domain
11737
- }
11738
- }
11739
- }
11740
- };
11741
- }
11742
- function buildPrimarySpec(leaves, layerSpec) {
11743
- const allData = leaves.flatMap((leaf) => leaf.data);
11744
- const primary = {
11745
- ...leaves[0],
11746
- data: allData,
11747
- chrome: layerSpec.chrome ?? leaves[0].chrome,
11748
- annotations: layerSpec.annotations ?? leaves[0].annotations,
11749
- labels: layerSpec.labels ?? leaves[0].labels,
11750
- legend: layerSpec.legend ?? leaves[0].legend,
11751
- responsive: layerSpec.responsive ?? leaves[0].responsive,
11752
- theme: layerSpec.theme ?? leaves[0].theme,
11753
- darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
11754
- watermark: layerSpec.watermark ?? leaves[0].watermark,
11755
- hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
11756
- endpointLabels: layerSpec.endpointLabels ?? leaves[0].endpointLabels
11757
- };
11758
- return primary;
11759
- }
11760
-
11761
- // src/compile/watermark-obstacle.ts
11762
- import { BRAND_RESERVE_WIDTH as BRAND_RESERVE_WIDTH2 } from "@opendata-ai/openchart-core";
11763
- var WATERMARK_HEIGHT = 30;
11764
- var X_AXIS_EXTENT_WITH_LABEL = 48;
11765
- var X_AXIS_EXTENT_TICKS_ONLY = 26;
11766
- function computeWatermarkObstacle(dims, watermark, axes, theme) {
11767
- if (!watermark) return null;
11768
- const chartArea = dims.chartArea;
11769
- const brandPadding = theme.spacing.padding;
11770
- const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH2;
11771
- const xAxisExtent = axes.x?.label ? X_AXIS_EXTENT_WITH_LABEL : axes.x ? X_AXIS_EXTENT_TICKS_ONLY : 0;
11772
- const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
11773
- const brandY = firstBottomChrome ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
11774
- return { x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH2, height: WATERMARK_HEIGHT };
11775
- }
11776
-
11777
- // src/compiler/sparkline-defaults.ts
11778
- var TREND_DEADBAND = 5e-4;
11779
- function computeTrend(values) {
11780
- const finite = [];
11781
- for (const v of values) {
11782
- if (Number.isFinite(v)) finite.push(v);
11783
- }
11784
- if (finite.length < 2) return "neutral";
11785
- const n = finite.length;
11786
- let sumX = 0;
11787
- let sumY = 0;
11788
- for (let i = 0; i < n; i++) {
11789
- sumX += i;
11790
- sumY += finite[i];
11791
- }
11792
- const meanX = sumX / n;
11793
- const meanY = sumY / n;
11794
- let num = 0;
11795
- let den = 0;
11796
- for (let i = 0; i < n; i++) {
11797
- const dx = i - meanX;
11798
- num += dx * (finite[i] - meanY);
11799
- den += dx * dx;
11800
- }
11801
- if (den === 0) return "neutral";
11802
- const slope = num / den;
11803
- const scale = Math.abs(meanY) || 1;
11804
- const relSlope = slope / scale;
11805
- if (Math.abs(relSlope) < TREND_DEADBAND) return "neutral";
11806
- return relSlope > 0 ? "up" : "down";
11807
- }
11808
- function trendColor(trend, theme) {
11809
- if (trend === "up") return theme.colors.positive;
11810
- if (trend === "down") return theme.colors.negative;
11811
- return theme.colors.categorical[0];
11812
- }
11813
- function computeTrendFromData(data, yField) {
11814
- if (!yField) return "neutral";
11815
- const values = [];
11816
- for (const row of data) {
11817
- const v = Number(row[yField]);
11818
- 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;
11819
11927
  }
11820
- return computeTrend(values);
11821
- }
11822
- function hasExplicitColor(markDef, encodingHasColor) {
11823
- if (encodingHasColor) return { fill: true, stroke: true };
11824
- return {
11825
- fill: markDef?.fill !== void 0,
11826
- stroke: markDef?.stroke !== void 0
11827
- };
11828
- }
11829
- function buildSparklineAreaGradient(baseColor) {
11830
- return {
11831
- gradient: "linear",
11832
- x1: 0,
11833
- y1: 0,
11834
- x2: 0,
11835
- y2: 1,
11836
- stops: [
11837
- { offset: 0, color: baseColor, opacity: 0.35 },
11838
- { offset: 1, color: baseColor, opacity: 0 }
11839
- ]
11840
- };
11928
+ if (!finalPlan) throw new Error("resolveLayoutPlan: loop exited without producing a plan");
11929
+ return finalPlan;
11841
11930
  }
11842
11931
 
11843
- // src/graphs/compile-graph.ts
11844
- import { adaptTheme as adaptTheme2, computeChrome as computeChrome3, resolveTheme as resolveTheme3 } from "@opendata-ai/openchart-core";
11845
-
11846
- // src/graphs/encoding.ts
11847
- var DEFAULT_NODE_RADIUS = 5;
11848
- var MIN_NODE_RADIUS = 3;
11849
- var MAX_NODE_RADIUS = 12;
11850
- var DEFAULT_EDGE_WIDTH = 1;
11851
- var MIN_EDGE_WIDTH = 0.5;
11852
- var MAX_EDGE_WIDTH = 4;
11853
- var DEFAULT_STROKE_WIDTH2 = 1;
11854
- function darkenColor(hex2, amount = 0.2) {
11855
- const clean = hex2.replace(/^#/, "");
11856
- if (clean.length !== 6 && clean.length !== 3) return hex2;
11857
- const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
11858
- const r = Math.max(0, Math.round(parseInt(full.substring(0, 2), 16) * (1 - amount)));
11859
- const g = Math.max(0, Math.round(parseInt(full.substring(2, 4), 16) * (1 - amount)));
11860
- const b = Math.max(0, Math.round(parseInt(full.substring(4, 6), 16) * (1 - amount)));
11861
- return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
11862
- }
11863
- function hexWithOpacity(hex2, opacity) {
11864
- const clean = hex2.replace(/^#/, "");
11865
- if (clean.length !== 6 && clean.length !== 3) {
11866
- return hex2;
11867
- }
11868
- const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
11869
- const r = parseInt(full.substring(0, 2), 16);
11870
- const g = parseInt(full.substring(2, 4), 16);
11871
- const b = parseInt(full.substring(4, 6), 16);
11872
- return `rgba(${r}, ${g}, ${b}, ${opacity})`;
11873
- }
11874
- function computeDegrees(nodes, edges) {
11875
- const degrees = /* @__PURE__ */ new Map();
11876
- for (const node of nodes) {
11877
- 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");
11878
11937
  }
11879
- for (const edge of edges) {
11880
- degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1);
11881
- 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);
11882
11941
  }
11883
- return degrees;
11884
- }
11885
- function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
11886
- const degrees = computeDegrees(nodes, edges);
11887
- const maxDegree = Math.max(1, ...degrees.values());
11888
- let sizeScale;
11889
- if (encoding.nodeSize?.field) {
11890
- const field = encoding.nodeSize.field;
11891
- const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
11892
- const sizeMin = min2(values) ?? 0;
11893
- const sizeMax = max2(values) ?? 1;
11894
- 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);
11895
11944
  }
11896
- let colorFn;
11897
- if (encoding.nodeColor?.field) {
11898
- const field = encoding.nodeColor.field;
11899
- const fieldType = encoding.nodeColor.type ?? "nominal";
11900
- const scaleConfig = encoding.nodeColor.scale;
11901
- if (fieldType === "quantitative") {
11902
- const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
11903
- const colorMin = min2(values) ?? 0;
11904
- const colorMax = max2(values) ?? 1;
11905
- const seqPalettes = Object.values(theme.colors.sequential);
11906
- const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
11907
- const domain = scaleConfig?.domain && scaleConfig.domain.length === 2 ? scaleConfig.domain : [colorMin, colorMax];
11908
- const range2 = scaleConfig?.range && scaleConfig.range.length >= 2 ? scaleConfig.range : [palette[0], palette[palette.length - 1]];
11909
- const colorScale = linear2().domain(domain).range(range2);
11910
- colorFn = (node) => {
11911
- const val = Number(node[field]);
11912
- return Number.isFinite(val) ? colorScale(val) : theme.colors.categorical[0];
11913
- };
11914
- } else {
11915
- const domain = scaleConfig?.domain && Array.isArray(scaleConfig.domain) ? scaleConfig.domain : [...new Set(nodes.map((n) => String(n[field] ?? "")))];
11916
- const range2 = scaleConfig?.range && scaleConfig.range.length > 0 ? scaleConfig.range : theme.colors.categorical;
11917
- const ordinalScale = ordinal().domain(domain).range(range2);
11918
- colorFn = (node) => ordinalScale(String(node[field] ?? ""));
11919
- }
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);
11920
11953
  }
11921
- const defaultColor = theme.colors.categorical[0];
11922
- return nodes.map((node) => {
11923
- let radius = DEFAULT_NODE_RADIUS;
11924
- if (sizeScale && encoding.nodeSize?.field) {
11925
- const val = Number(node[encoding.nodeSize.field]);
11926
- if (Number.isFinite(val)) {
11927
- radius = sizeScale(val);
11928
- }
11929
- }
11930
- const fill = colorFn ? colorFn(node) : defaultColor;
11931
- const stroke = darkenColor(fill);
11932
- let label;
11933
- if (encoding.nodeLabel?.field) {
11934
- const labelVal = node[encoding.nodeLabel.field];
11935
- label = labelVal != null ? String(labelVal) : void 0;
11936
- } else {
11937
- label = node.id;
11938
- }
11939
- const degree = degrees.get(node.id) ?? 0;
11940
- const labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
11941
- const { id: _id, ...rest } = node;
11942
- const data = { id: node.id, ...rest };
11943
- const override = nodeOverrides?.[node.id];
11944
- const finalFill = override?.fill ?? fill;
11945
- const finalRadius = override?.radius ?? radius;
11946
- const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH2;
11947
- const finalStroke = override?.stroke ?? stroke;
11948
- const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
11949
- return {
11950
- id: node.id,
11951
- radius: finalRadius,
11952
- fill: finalFill,
11953
- stroke: finalStroke,
11954
- strokeWidth: finalStrokeWidth,
11955
- label,
11956
- labelPriority: finalLabelPriority,
11957
- community: void 0,
11958
- 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
11959
11965
  };
11960
- });
11961
- }
11962
- function resolveEdgeVisuals(edges, encoding, theme) {
11963
- let widthScale;
11964
- if (encoding.edgeWidth?.field) {
11965
- const field = encoding.edgeWidth.field;
11966
- const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
11967
- const widthMin = min2(values) ?? 0;
11968
- const widthMax = max2(values) ?? 1;
11969
- widthScale = linear2().domain([widthMin, widthMax]).range([MIN_EDGE_WIDTH, MAX_EDGE_WIDTH]);
11970
- }
11971
- let edgeColorFn;
11972
- if (encoding.edgeColor?.field) {
11973
- const field = encoding.edgeColor.field;
11974
- const fieldType = encoding.edgeColor.type ?? "nominal";
11975
- const scaleConfig = encoding.edgeColor.scale;
11976
- if (fieldType === "quantitative") {
11977
- const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
11978
- const colorMin = min2(values) ?? 0;
11979
- const colorMax = max2(values) ?? 1;
11980
- const seqPalettes = Object.values(theme.colors.sequential);
11981
- const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
11982
- const domain = scaleConfig?.domain && scaleConfig.domain.length === 2 ? scaleConfig.domain : [colorMin, colorMax];
11983
- const range2 = scaleConfig?.range && scaleConfig.range.length >= 2 ? scaleConfig.range : [palette[0], palette[palette.length - 1]];
11984
- const colorScale = linear2().domain(domain).range(range2);
11985
- edgeColorFn = (edge) => {
11986
- const val = Number(edge[field]);
11987
- return Number.isFinite(val) ? colorScale(val) : hexWithOpacity(theme.colors.axis, 0.4);
11988
- };
11989
- } else {
11990
- const domain = scaleConfig?.domain && Array.isArray(scaleConfig.domain) ? scaleConfig.domain : [...new Set(edges.map((e) => String(e[field] ?? "")))];
11991
- const range2 = scaleConfig?.range && scaleConfig.range.length > 0 ? scaleConfig.range : theme.colors.categorical;
11992
- const ordinalScale = ordinal().domain(domain).range(range2);
11993
- edgeColorFn = (edge) => ordinalScale(String(edge[field] ?? ""));
11994
- }
11995
- }
11996
- const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
11997
- const EDGE_STYLES = ["solid", "dashed", "dotted"];
11998
- let styleFn;
11999
- if (encoding.edgeStyle?.field) {
12000
- const field = encoding.edgeStyle.field;
12001
- const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? "")))];
12002
- const styleMap = /* @__PURE__ */ new Map();
12003
- for (let i = 0; i < uniqueValues.length; i++) {
12004
- styleMap.set(uniqueValues[i], EDGE_STYLES[i % EDGE_STYLES.length]);
12005
- }
12006
- styleFn = (edge) => styleMap.get(String(edge[field] ?? "")) ?? "solid";
12007
- }
12008
- return edges.map((edge) => {
12009
- const { source, target, ...rest } = edge;
12010
- let strokeWidth = DEFAULT_EDGE_WIDTH;
12011
- if (widthScale && encoding.edgeWidth?.field) {
12012
- const val = Number(edge[encoding.edgeWidth.field]);
12013
- if (Number.isFinite(val)) {
12014
- 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
+ }
12015
11975
  }
12016
11976
  }
12017
- const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
12018
- const style = styleFn ? styleFn(edge) : "solid";
12019
- return {
12020
- source,
12021
- target,
12022
- stroke,
12023
- strokeWidth,
12024
- style,
12025
- data: { source, target, ...rest }
12026
- };
12027
- });
12028
- }
12029
-
12030
- // src/graphs/community.ts
12031
- function assignCommunities(nodes, clusteringField) {
12032
- if (!clusteringField) return;
12033
- for (const node of nodes) {
12034
- const value2 = node.data[clusteringField];
12035
- node.community = value2 != null ? String(value2) : void 0;
12036
11977
  }
11978
+ return {
11979
+ ...primaryLayout,
11980
+ marks: allMarks,
11981
+ legend: {
11982
+ ...primaryLayout.legend,
11983
+ ..."entries" in pLegend ? { entries: mergedLegendEntries } : {}
11984
+ }
11985
+ };
12037
11986
  }
12038
- function buildCommunityColorMap(nodes, theme) {
12039
- const colorMap = /* @__PURE__ */ new Map();
12040
- const palette = theme.colors.categorical;
12041
- let colorIndex = 0;
12042
- for (const node of nodes) {
12043
- if (node.community != null && !colorMap.has(node.community)) {
12044
- colorMap.set(node.community, palette[colorIndex % palette.length]);
12045
- 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;
12046
12000
  }
12001
+ return maxWidth > 0 ? maxWidth + 10 : 40;
12047
12002
  }
12048
- return colorMap;
12049
- }
12050
- function applyCommunityColors(nodes, colorMap) {
12051
- for (const node of nodes) {
12052
- if (node.community != null) {
12053
- const communityColor = colorMap.get(node.community);
12054
- if (communityColor) {
12055
- node.fill = communityColor;
12056
- node.stroke = darkenColor(communityColor);
12057
- }
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);
12058
12016
  }
12017
+ } else {
12018
+ sampleLabel = Math.abs(maxAbsVal) >= 1e3 ? abbreviateNumber4(maxAbsVal) : formatNumber5(maxAbsVal);
12059
12019
  }
12020
+ const hasNeg = data.some((r) => Number(r[yField]) < 0);
12021
+ const labelEst = (hasNeg ? "-" : "") + sampleLabel;
12022
+ return m(labelEst, baseFontSize, 400) + 10;
12060
12023
  }
12061
-
12062
- // src/graphs/compile-graph.ts
12063
- var SWATCH_SIZE2 = 12;
12064
- var SWATCH_GAP2 = 6;
12065
- var ENTRY_GAP2 = 16;
12066
- function buildGraphLegend(nodes, communityColorMap, hasCommunities, theme, nodeColorField) {
12067
- const labelStyle = {
12068
- fontFamily: theme.fonts.family,
12069
- fontSize: theme.fonts.sizes.small,
12070
- fontWeight: theme.fonts.weights.normal,
12071
- fill: theme.colors.text,
12072
- 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
12073
12059
  };
12074
- let entries;
12075
- if (hasCommunities && communityColorMap.size > 0) {
12076
- entries = [...communityColorMap.entries()].map(([label, color2]) => ({
12077
- label,
12078
- color: color2,
12079
- shape: "circle",
12080
- active: true
12081
- }));
12082
- } else {
12083
- const categoryColors = /* @__PURE__ */ new Map();
12084
- for (const node of nodes) {
12085
- const category = nodeColorField ? String(node.data[nodeColorField] ?? node.label ?? node.id) : node.label ?? node.id;
12086
- if (!categoryColors.has(category)) {
12087
- 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
12088
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);
12089
12103
  }
12090
- if (categoryColors.size <= 1) {
12091
- 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);
12092
12187
  } else {
12093
- entries = [...categoryColors.entries()].map(([label, color2]) => ({
12094
- label,
12095
- color: color2,
12096
- shape: "circle",
12097
- active: true
12098
- }));
12188
+ mergedTooltips.set(key, value2);
12099
12189
  }
12100
12190
  }
12191
+ const z0 = leaf0.zIndex ?? 0;
12192
+ const z1 = leaf1.zIndex ?? 1;
12193
+ const marks = z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
12101
12194
  return {
12102
- position: "top",
12103
- entries,
12104
- bounds: { x: 0, y: 0, width: 0, height: 0 },
12105
- labelStyle,
12106
- swatchSize: SWATCH_SIZE2,
12107
- swatchGap: SWATCH_GAP2,
12108
- entryGap: ENTRY_GAP2,
12109
- 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
12110
12207
  };
12111
12208
  }
12112
- function buildGraphTooltips(nodes) {
12113
- const descriptors = /* @__PURE__ */ new Map();
12114
- for (const node of nodes) {
12115
- const fields = [];
12116
- if (node.community != null) {
12117
- fields.push({
12118
- label: "Community",
12119
- value: node.community,
12120
- color: node.fill
12121
- });
12122
- }
12123
- for (const [key, value2] of Object.entries(node.data)) {
12124
- if (key === "id") continue;
12125
- if (value2 == null) continue;
12126
- fields.push({
12127
- label: key,
12128
- value: typeof value2 === "number" ? value2.toLocaleString() : String(value2)
12129
- });
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 });
12130
12217
  }
12131
- descriptors.set(node.id, {
12132
- title: node.label ?? node.id,
12133
- fields
12134
- });
12135
12218
  }
12136
- return descriptors;
12219
+ if (missingRows.length === 0) return leaf;
12220
+ return {
12221
+ ...leaf,
12222
+ data: [...leaf.data, ...missingRows]
12223
+ };
12137
12224
  }
12138
- function compileGraph(spec, options) {
12139
- const { spec: normalized } = compile(spec);
12140
- if (!("type" in normalized) || normalized.type !== "graph") {
12141
- throw new Error(
12142
- "compileGraph received a non-graph spec. Use compileChart or compileTable instead."
12143
- );
12144
- }
12145
- const graphSpec = normalized;
12146
- const rawWatermark = spec.watermark;
12147
- const watermark = rawWatermark !== void 0 ? graphSpec.watermark : options.watermark ?? true;
12148
- const mergedThemeConfig = options.theme ? { ...graphSpec.theme, ...options.theme } : graphSpec.theme;
12149
- let theme = resolveTheme3(mergedThemeConfig);
12150
- if (options.darkMode) {
12151
- theme = adaptTheme2(theme);
12152
- }
12153
- const compiledNodes = resolveNodeVisuals(
12154
- graphSpec.nodes,
12155
- graphSpec.encoding,
12156
- graphSpec.edges,
12157
- theme,
12158
- graphSpec.nodeOverrides
12159
- );
12160
- const clusteringField = graphSpec.layout.clustering?.field;
12161
- const hasCommunities = !!clusteringField;
12162
- assignCommunities(compiledNodes, clusteringField);
12163
- const hasNodeColorEncoding = !!graphSpec.encoding.nodeColor?.field;
12164
- let communityColorMap = /* @__PURE__ */ new Map();
12165
- if (hasCommunities && !hasNodeColorEncoding) {
12166
- communityColorMap = buildCommunityColorMap(compiledNodes, theme);
12167
- applyCommunityColors(compiledNodes, communityColorMap);
12168
- }
12169
- const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme);
12170
- const useCommunitiesForLegend = hasCommunities && !hasNodeColorEncoding;
12171
- const legend = buildGraphLegend(
12172
- compiledNodes,
12173
- communityColorMap,
12174
- useCommunitiesForLegend,
12175
- theme,
12176
- graphSpec.encoding.nodeColor?.field
12177
- );
12178
- const tooltipDescriptors = buildGraphTooltips(compiledNodes);
12179
- const communityCount = communityColorMap.size;
12180
- const altParts = [
12181
- `Network graph with ${compiledNodes.length} nodes and ${compiledEdges.length} edges`
12182
- ];
12183
- if (communityCount > 0) {
12184
- altParts.push(`organized into ${communityCount} communities`);
12185
- }
12186
- const a11y = {
12187
- altText: altParts.join(", "),
12188
- dataTableFallback: compiledNodes.map((n) => [n.id, n.community ?? "", n.label ?? ""]),
12189
- role: "img",
12190
- 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];
12191
12247
  };
12192
- const collisionPadding = graphSpec.layout.collisionPadding ?? 2;
12193
- const maxRadius = compiledNodes.length > 0 ? Math.max(...compiledNodes.map((n) => n.radius)) : DEFAULT_COLLISION_PADDING;
12194
- const simulationConfig = {
12195
- chargeStrength: graphSpec.layout.chargeStrength ?? -300,
12196
- linkDistance: graphSpec.layout.linkDistance ?? 30,
12197
- clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
12198
- alphaDecay: 0.0228,
12199
- velocityDecay: 0.4,
12200
- collisionRadius: maxRadius + collisionPadding,
12201
- collisionPadding,
12202
- linkStrength: graphSpec.layout.linkStrength,
12203
- 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];
12204
12270
  };
12205
- const chrome = computeChrome3(
12206
- {
12207
- title: graphSpec.chrome.title,
12208
- subtitle: graphSpec.chrome.subtitle,
12209
- source: graphSpec.chrome.source,
12210
- byline: graphSpec.chrome.byline,
12211
- footer: graphSpec.chrome.footer
12212
- },
12213
- theme,
12214
- options.width,
12215
- options.measureText,
12216
- "full",
12217
- void 0,
12218
- watermark
12219
- );
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;
12220
12277
  return {
12221
- nodes: compiledNodes,
12222
- edges: compiledEdges,
12223
- legend,
12224
- chrome,
12225
- tooltipDescriptors,
12226
- a11y,
12227
- theme,
12228
- dimensions: {
12229
- width: options.width,
12230
- height: options.height
12231
- },
12232
- simulationConfig,
12233
- 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
+ }
12234
12289
  };
12235
12290
  }
12236
- var DEFAULT_COLLISION_PADDING = 5;
12237
-
12238
- // src/layout/axes.ts
12239
- import {
12240
- axisTitleOffset as axisTitleOffset2,
12241
- computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
12242
- estimateTextWidth as estimateTextWidth15,
12243
- getAxisTitleOffset as getAxisTitleOffset2,
12244
- X_AXIS_TITLE_BAND_ROTATED
12245
- } 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
+ }
12246
12309
 
12247
- // src/layout/axes/thinning.ts
12248
- import { estimateTextWidth as estimateTextWidth14 } from "@opendata-ai/openchart-core";
12249
- var MIN_TICK_GAP_FACTOR = 0.5;
12250
- var MIN_TICK_COUNT = 2;
12251
- function measureLabel(text, fontSize, fontWeight, measureText) {
12252
- 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 };
12253
12324
  }
12254
- function ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
12255
- if (ticks2.length < 2) return false;
12256
- const minGap = fontSize * MIN_TICK_GAP_FACTOR;
12257
- if (orientation === "vertical") {
12258
- const sorted = [...ticks2].sort((a, b) => a.position - b.position);
12259
- const labelHeight = fontSize * 1.2;
12260
- for (let i = 0; i < sorted.length - 1; i++) {
12261
- const aBottom = sorted[i].position + labelHeight / 2;
12262
- const bTop = sorted[i + 1].position - labelHeight / 2;
12263
- if (aBottom + minGap > bTop) return true;
12264
- }
12265
- 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);
12266
12332
  }
12267
- for (let i = 0; i < ticks2.length - 1; i++) {
12268
- const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText);
12269
- const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText);
12270
- const aRight = ticks2[i].position + aWidth / 2;
12271
- const bLeft = ticks2[i + 1].position - bWidth / 2;
12272
- 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];
12273
12340
  }
12274
- return false;
12275
- }
12276
- function thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
12277
- if (!ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation)) return ticks2;
12278
- let current = ticks2;
12279
- while (current.length > MIN_TICK_COUNT) {
12280
- const thinned = [current[0]];
12281
- for (let i = 2; i < current.length - 1; i += 2) {
12282
- thinned.push(current[i]);
12283
- }
12284
- if (current.length > 1) thinned.push(current[current.length - 1]);
12285
- current = thinned;
12286
- 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;
12287
12349
  }
12288
- 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";
12289
12356
  }
12290
-
12291
- // src/layout/axes.ts
12292
- var HEIGHT_MINIMAL_THRESHOLD = 80;
12293
- var HEIGHT_REDUCED_THRESHOLD = 100;
12294
- var WIDTH_MINIMAL_THRESHOLD = 150;
12295
- var WIDTH_REDUCED_THRESHOLD = 300;
12296
- var DENSITY_ORDER = ["full", "reduced", "minimal"];
12297
- var GROUNDED_MARKS = /* @__PURE__ */ new Set(["bar", "tick", "lollipop"]);
12298
- function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
12299
- let density = baseDensity;
12300
- if (axisLength < minimalThreshold) {
12301
- density = "minimal";
12302
- } else if (axisLength < reducedThreshold) {
12303
- const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
12304
- const reducedIdx = DENSITY_ORDER.indexOf("reduced");
12305
- density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
12306
- }
12307
- 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];
12308
12361
  }
12309
- var CONTINUOUS_TICK_FLOOR = 4;
12310
- var OVERSHOOT_TOLERANCE = 1.5;
12311
- var MIN_QUANTITATIVE_TICKS = 2;
12312
- function ensureMinContinuousTicks(scale, ticks2, requestedCount) {
12313
- if (ticks2.length >= MIN_QUANTITATIVE_TICKS) return ticks2;
12314
- if (!scale || !scaleSupportsTickCount(scale)) return ticks2;
12315
- let best = ticks2;
12316
- for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
12317
- const candidate = buildContinuousTicks(scale, n);
12318
- if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
12319
- 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);
12320
12368
  }
12321
- return best;
12369
+ return computeTrend(values);
12322
12370
  }
12323
- function fitContinuousTicks(scale, initialTicks, initialCount, fontSize, fontWeight, axisLength, orientation, measureText) {
12324
- if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
12325
- const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
12326
- const tolerance = initialCount * OVERSHOOT_TOLERANCE;
12327
- const overshoots = flooredTicks.length > tolerance;
12328
- const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
12329
- if (!overshoots && !overlaps) return flooredTicks;
12330
- const minThreshold = orientation === "vertical" ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
12331
- const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
12332
- let bestWithinFloor;
12333
- for (let n = initialCount - 1; n >= 2; n--) {
12334
- const candidate = buildContinuousTicks(scale, n);
12335
- const candidateOvershoots = candidate.length > tolerance;
12336
- const candidateOverlaps = ticksOverlap(
12337
- candidate,
12338
- fontSize,
12339
- fontWeight,
12340
- measureText,
12341
- orientation
12342
- );
12343
- if (!candidateOvershoots && !candidateOverlaps) {
12344
- return candidate;
12345
- }
12346
- if (candidate.length >= floor) bestWithinFloor = candidate;
12347
- }
12348
- const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
12349
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
12350
- 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
+ };
12351
12377
  }
12352
- function bandTicksOverlapAtAngle(ticks2, angleDeg, fontSize, fontWeight, measureText) {
12353
- if (ticks2.length < 2) return false;
12354
- const angleRad = Math.abs(angleDeg) * Math.PI / 180;
12355
- const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
12356
- const minGap = fontSize * 0.5;
12357
- for (let i = 0; i < ticks2.length - 1; i++) {
12358
- const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText) * cosA;
12359
- const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText) * cosA;
12360
- const aRight = ticks2[i].position + aWidth / 2;
12361
- const bLeft = ticks2[i + 1].position - bWidth / 2;
12362
- if (aRight + minGap > bLeft) return true;
12363
- }
12364
- 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
+ };
12365
12390
  }
12366
- function thinBandTicksIfNeeded(ticks2, angleDeg, fontSize, fontWeight, measureText) {
12367
- if (!bandTicksOverlapAtAngle(ticks2, angleDeg, fontSize, fontWeight, measureText)) return ticks2;
12368
- let current = ticks2;
12369
- while (current.length > 2) {
12370
- const thinned = [current[0]];
12371
- for (let i = 2; i < current.length - 1; i += 2) {
12372
- thinned.push(current[i]);
12373
- }
12374
- if (current.length > 1) thinned.push(current[current.length - 1]);
12375
- current = thinned;
12376
- 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;
12377
12416
  }
12378
- 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})`;
12379
12422
  }
12380
- function computeAxes(scales, chartArea, strategy, theme, measureText, dataContext) {
12381
- const result = {};
12382
- const baseDensity = strategy.axisLabelDensity;
12383
- const yDensity = effectiveDensity(
12384
- baseDensity,
12385
- chartArea.height,
12386
- HEIGHT_MINIMAL_THRESHOLD,
12387
- HEIGHT_REDUCED_THRESHOLD
12388
- );
12389
- const xDensity = effectiveDensity(
12390
- baseDensity,
12391
- chartArea.width,
12392
- WIDTH_MINIMAL_THRESHOLD,
12393
- WIDTH_REDUCED_THRESHOLD
12394
- );
12395
- const tickLabelStyle = {
12396
- fontFamily: theme.fonts.family,
12397
- fontSize: theme.fonts.sizes.axisTick,
12398
- fontWeight: theme.fonts.weights.normal,
12399
- fill: theme.colors.axis,
12400
- lineHeight: 1.2,
12401
- fontVariant: "tabular-nums"
12402
- };
12403
- const axisLabelStyle = {
12404
- fontFamily: theme.fonts.family,
12405
- fontSize: theme.fonts.sizes.body,
12406
- fontWeight: theme.fonts.weights.medium,
12407
- fill: theme.colors.text,
12408
- lineHeight: 1.3
12409
- };
12410
- const { fontSize } = tickLabelStyle;
12411
- const { fontWeight } = tickLabelStyle;
12412
- if (scales.x && !dataContext?.skipX && scales.x.channel.axis !== false) {
12413
- const axisConfig = scales.x.channel.axis;
12414
- const isContinuousX = scales.x.type !== "band" && scales.x.type !== "point" && scales.x.type !== "ordinal";
12415
- const xTargetCount = isContinuousX ? targetTickCount(chartArea.width, xDensity, "x") : void 0;
12416
- let allTicks;
12417
- if (axisConfig?.values) {
12418
- allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
12419
- } else if (!isContinuousX) {
12420
- 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
+ };
12421
12463
  } else {
12422
- 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] ?? ""));
12423
12468
  }
12424
- const gridlines = allTicks.map((t) => ({
12425
- position: t.position,
12426
- major: true
12427
- }));
12428
- let tickAngle = axisConfig?.labelAngle;
12429
- if (tickAngle === void 0 && scales.x.type === "band" && allTicks.length > 1) {
12430
- const bandwidth = scales.x.scale.bandwidth();
12431
- let maxLabelWidth = 0;
12432
- for (const t of allTicks) {
12433
- const w = measureLabel(t.label, fontSize, fontWeight, measureText);
12434
- 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);
12435
12477
  }
12436
- tickAngle = resolveBandTickAngle(void 0, maxLabelWidth, bandwidth, allTicks.length);
12437
12478
  }
12438
- const hasExplicitValues = !!axisConfig?.values;
12439
- let ticks2;
12440
- if (hasExplicitValues) {
12441
- ticks2 = allTicks;
12442
- } else if (scales.x.type === "band") {
12443
- const effectiveAngle = tickAngle ?? 0;
12444
- ticks2 = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
12445
- } else if (isContinuousX) {
12446
- ticks2 = fitContinuousTicks(
12447
- scales.x,
12448
- allTicks,
12449
- xTargetCount ?? allTicks.length,
12450
- fontSize,
12451
- fontWeight,
12452
- chartArea.width,
12453
- "horizontal",
12454
- measureText
12455
- );
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;
12456
12485
  } else {
12457
- ticks2 = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
12486
+ label = node.id;
12458
12487
  }
12459
- const axisTitle = axisConfig?.title;
12460
- const xLabelColor = axisConfig?.labelColor;
12461
- const xTickPosition = axisConfig?.tickPosition ?? "gutter";
12462
- const xDomainLine = axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? "") ? void 0 : false);
12463
- result.x = {
12464
- ticks: ticks2,
12465
- gridlines: axisConfig?.grid ?? dataContext?.markType === "point" ? gridlines : [],
12466
- label: axisTitle,
12467
- labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
12468
- tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
12469
- tickAngle,
12470
- start: { x: chartArea.x, y: chartArea.y + chartArea.height },
12471
- end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
12472
- orient: axisConfig?.orient,
12473
- domainLine: xDomainLine,
12474
- tickMarks: axisConfig?.ticks,
12475
- offset: axisConfig?.offset,
12476
- titlePadding: axisConfig?.titlePadding,
12477
- labelPadding: axisConfig?.labelPadding,
12478
- labelOverlap: axisConfig?.labelOverlap,
12479
- labelFlush: axisConfig?.labelFlush,
12480
- 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
12481
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]);
12482
12519
  }
12483
- if (scales.y && !dataContext?.skipY && scales.y.channel.axis !== false) {
12484
- const axisConfig = scales.y.channel.axis;
12485
- const isContinuousY = scales.y.type !== "band" && scales.y.type !== "point" && scales.y.type !== "ordinal";
12486
- const yTargetCount = isContinuousY ? targetTickCount(chartArea.height, yDensity, "y") : void 0;
12487
- let allTicks;
12488
- if (axisConfig?.values) {
12489
- allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
12490
- } else if (dataContext?.precomputedYTicks && dataContext.precomputedYTicks.length > 0 && isContinuousY) {
12491
- allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
12492
- } else if (!isContinuousY) {
12493
- const yFieldName = dataContext?.encoding.y?.field;
12494
- const yLabelField = axisConfig?.labelField;
12495
- allTicks = categoricalTicks(
12496
- scales.y,
12497
- yDensity,
12498
- "vertical",
12499
- yFieldName && yLabelField && dataContext ? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField } : void 0
12500
- );
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
+ };
12501
12538
  } else {
12502
- 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] ?? ""));
12503
12543
  }
12504
- const shouldThinY = scales.y.type !== "band" && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
12505
- let ticks2;
12506
- if (!shouldThinY) {
12507
- ticks2 = allTicks;
12508
- } else if (isContinuousY) {
12509
- ticks2 = fitContinuousTicks(
12510
- scales.y,
12511
- allTicks,
12512
- yTargetCount ?? allTicks.length,
12513
- fontSize,
12514
- fontWeight,
12515
- chartArea.height,
12516
- "vertical",
12517
- measureText
12518
- );
12519
- } else {
12520
- 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]);
12521
12554
  }
12522
- const gridlines = ticks2.map((t) => ({
12523
- position: t.position,
12524
- major: true
12525
- }));
12526
- const axisTitle = axisConfig?.title;
12527
- const tickAngle = axisConfig?.labelAngle;
12528
- const yLabelColor = axisConfig?.labelColor;
12529
- const isContinuousYAxis = scales.y.type !== "band" && scales.y.type !== "point" && scales.y.type !== "ordinal";
12530
- const isLineOrArea2 = dataContext?.markType === "line" || dataContext?.markType === "area";
12531
- const yTickPosition = axisConfig?.tickPosition ?? (isLineOrArea2 && isContinuousYAxis && axisConfig?.orient !== "right" ? "inline" : "gutter");
12532
- const yDomainLine = axisConfig?.domain ?? (yTickPosition === "inline" ? false : void 0);
12533
- const yTickMarks = axisConfig?.ticks ?? (yTickPosition === "inline" ? false : void 0);
12534
- result.y = {
12535
- ticks: ticks2,
12536
- // Y-axis gridlines are shown by default (standard editorial practice)
12537
- gridlines,
12538
- label: axisTitle,
12539
- labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
12540
- tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
12541
- tickAngle,
12542
- start: { x: chartArea.x, y: chartArea.y },
12543
- end: { x: chartArea.x, y: chartArea.y + chartArea.height },
12544
- orient: axisConfig?.orient,
12545
- domainLine: yDomainLine,
12546
- tickMarks: yTickMarks,
12547
- offset: axisConfig?.offset,
12548
- titlePadding: axisConfig?.titlePadding,
12549
- labelPadding: axisConfig?.labelPadding,
12550
- labelOverlap: axisConfig?.labelOverlap,
12551
- labelFlush: axisConfig?.labelFlush,
12552
- 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 }
12553
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;
12554
12585
  }
12555
- const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
12556
- if (result.x) {
12557
- const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
12558
- result.x.extent = computeXAxisExtentFromLabels2({
12559
- labels: result.x.ticks.map((t) => t.label),
12560
- tickAngle: result.x.tickAngle,
12561
- hasTitle: !!result.x.label,
12562
- tickFontSize: result.x.tickLabelStyle.fontSize,
12563
- tickFontWeight: result.x.tickLabelStyle.fontWeight,
12564
- xAxisHeight: theme.spacing.xAxisHeight,
12565
- measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText)
12566
- });
12567
- if (result.x.label) {
12568
- const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
12569
- result.x.titlePosition = {
12570
- x: chartArea.x + chartArea.width / 2,
12571
- y: chartArea.y + chartArea.height + rotatedBand
12572
- };
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++;
12573
12595
  }
12574
12596
  }
12575
- if (result.y?.label && result.y.labelStyle) {
12576
- const isRight = result.y.orient === "right";
12577
- if (isRight) {
12578
- const titleOff = getAxisTitleOffset2(totalWidth);
12579
- result.y.titlePosition = {
12580
- x: chartArea.x + chartArea.width + titleOff,
12581
- y: chartArea.y + chartArea.height / 2,
12582
- angle: 90
12583
- };
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 = [];
12584
12641
  } else {
12585
- const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12586
- const w = estimateTextWidth15(
12587
- t.label,
12588
- result.y.tickLabelStyle.fontSize,
12589
- result.y.tickLabelStyle.fontWeight ?? 400
12590
- );
12591
- return Math.max(max4, w);
12592
- }, 0);
12593
- const titleOff = axisTitleOffset2(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
12594
- result.y.titlePosition = {
12595
- x: chartArea.x - titleOff,
12596
- y: chartArea.y + chartArea.height / 2,
12597
- angle: -90
12598
- };
12642
+ entries = [...categoryColors.entries()].map(([label, color2]) => ({
12643
+ label,
12644
+ color: color2,
12645
+ shape: "circle",
12646
+ active: true
12647
+ }));
12599
12648
  }
12600
12649
  }
12601
- 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
+ };
12602
12784
  }
12785
+ var DEFAULT_COLLISION_PADDING = 5;
12603
12786
 
12604
12787
  // src/layout/dimensions.ts
12605
12788
  import {
@@ -12780,12 +12963,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
12780
12963
  const base = theme.spacing.xAxisHeight;
12781
12964
  xAxisHeight = hasXAxisLabel ? base + 22 : base;
12782
12965
  }
12783
- const yAxisCfgPre = encoding.y?.axis ?? void 0;
12784
- const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition;
12785
- const yIsContinuousPre = encoding.y?.type === "quantitative" || encoding.y?.type === "temporal";
12786
- const yIsLineOrAreaPre = spec.markType === "line" || spec.markType === "area";
12787
- const yAxisOrientPre = yAxisCfgPre?.orient;
12788
- const yIsInlinePre = (yTickPositionExplicitPre ?? (yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== "right" ? "inline" : "gutter")) === "inline";
12966
+ const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
12789
12967
  const inlineTickOverhang = yIsInlinePre ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD : 0;
12790
12968
  const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
12791
12969
  const topPad = width < NARROW_VIEWPORT_MAX3 ? padding + TOP_PAD_EXTRA_NARROW2 : padding;
@@ -12938,7 +13116,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
12938
13116
  const yFieldForTitle = encoding.y?.field;
12939
13117
  const yAxisFormatForTitle = yAxis?.format;
12940
13118
  let estTickLabelWidth = 0;
12941
- if (yFieldForTitle && (encoding.y?.type === "quantitative" || encoding.y?.type === "temporal")) {
13119
+ if (yFieldForTitle && !yIsInlinePre && (encoding.y?.type === "quantitative" || encoding.y?.type === "temporal")) {
12942
13120
  let maxAbsValForTitle = 0;
12943
13121
  for (const row of spec.data) {
12944
13122
  const v = Number(row[yFieldForTitle]);
@@ -12968,7 +13146,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
12968
13146
  );
12969
13147
  }
12970
13148
  const titleFontSize = theme.fonts.sizes.body;
12971
- const offset = axisTitleOffset3(estTickLabelWidth, titleFontSize, width);
13149
+ const offset = axisTitleOffset3(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
12972
13150
  const halfGlyph = Math.ceil(titleFontSize / 2);
12973
13151
  const rotatedLabelMargin = offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX3 ? 0 : AXIS_TITLE_TRAILING_PAD3);
12974
13152
  margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
@@ -14782,6 +14960,9 @@ import {
14782
14960
  computeChrome as computeChrome7,
14783
14961
  estimateTextWidth as estimateTextWidth20,
14784
14962
  formatNumber as formatNumber8,
14963
+ getBreakpoint,
14964
+ HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION3,
14965
+ HPAD_COMPACT_MIN as HPAD_COMPACT_MIN3,
14785
14966
  pickLabelColor as pickLabelColor2,
14786
14967
  resolveTheme as resolveTheme5,
14787
14968
  SEQUENTIAL_PALETTES
@@ -14791,6 +14972,9 @@ var TILE_STROKE_WIDTH = 1;
14791
14972
  var LEGEND_SWATCH_SIZE = 10;
14792
14973
  var LEGEND_SWATCH_GAP = 6;
14793
14974
  var LEGEND_ENTRY_GAP = 16;
14975
+ function clamp(value2, min4, max4) {
14976
+ return Math.min(max4, Math.max(min4, value2));
14977
+ }
14794
14978
  function compileTileMap(spec, options) {
14795
14979
  const { spec: normalized } = compile(spec);
14796
14980
  if (!("type" in normalized) || normalized.type !== "tilemap") {
@@ -14823,7 +15007,7 @@ function compileTileMap(spec, options) {
14823
15007
  void 0,
14824
15008
  watermark
14825
15009
  );
14826
- 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;
14827
15011
  const fullArea = {
14828
15012
  x: padding,
14829
15013
  y: padding + chrome.topHeight,
@@ -14936,7 +15120,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
14936
15120
  keyboardNavigable: tiles.length > 0
14937
15121
  };
14938
15122
  const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
14939
- const padding = theme.spacing.padding;
15123
+ const padding = fullArea.x;
14940
15124
  const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
14941
15125
  return {
14942
15126
  area: fullArea,
@@ -14991,16 +15175,20 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
14991
15175
  }
14992
15176
  }
14993
15177
  const showLegend = tilemapSpec.legend?.show !== false;
15178
+ const legendLabelFontSize = 11;
14994
15179
  const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
14995
- const legendTotalHeight = showLegend ? legendRowHeight : 0;
15180
+ const legendLabels = categories.map(formatCategoryLabel);
14996
15181
  const legendGap2 = showLegend ? 8 : 0;
14997
- const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap2;
15182
+ const estimatedRows = showLegend ? countLegendRows(legendLabels, fullArea.width, legendLabelFontSize) : 0;
15183
+ const tileAreaHeight = fullArea.height - legendRowHeight * estimatedRows - legendGap2;
14998
15184
  const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
14999
15185
  const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
15000
15186
  const tileGridOffsetY = fullArea.y;
15001
15187
  const legendX = tileGridOffsetX;
15002
15188
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap2;
15003
15189
  const legendWidth = tilePositions.gridWidth;
15190
+ const legendRows = showLegend ? countLegendRows(legendLabels, legendWidth, legendLabelFontSize) : 0;
15191
+ const legendTotalHeight = showLegend ? legendRowHeight * legendRows : 0;
15004
15192
  const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
15005
15193
  const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
15006
15194
  const tiles = [];
@@ -15036,7 +15224,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15036
15224
  if (showLegend) {
15037
15225
  const labelStyle = {
15038
15226
  fontFamily: theme.fonts.family,
15039
- fontSize: 11,
15227
+ fontSize: legendLabelFontSize,
15040
15228
  fontWeight: 400,
15041
15229
  fill: theme.colors.text,
15042
15230
  lineHeight: 1.2
@@ -15049,7 +15237,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15049
15237
  categoricalLegend = {
15050
15238
  type: "categorical",
15051
15239
  position: "bottom",
15052
- bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
15240
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendTotalHeight },
15053
15241
  labelStyle,
15054
15242
  entries,
15055
15243
  swatchSize: LEGEND_SWATCH_SIZE,
@@ -15067,7 +15255,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
15067
15255
  keyboardNavigable: tiles.length > 0
15068
15256
  };
15069
15257
  const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
15070
- const padding = theme.spacing.padding;
15258
+ const padding = fullArea.x;
15071
15259
  const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
15072
15260
  return {
15073
15261
  area: fullArea,
@@ -15106,27 +15294,32 @@ function buildTileMark(opts) {
15106
15294
  const tileTopY = gridOffsetY + pos.y;
15107
15295
  const textColor = hasData ? pickLabelColor2(fill, isDarkMode) : "#ffffff";
15108
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);
15109
15300
  const labelStyle = {
15110
15301
  fontFamily: theme.fonts.family,
15111
- fontSize: tileSize > 24 ? 10 : 7,
15302
+ fontSize: codeFontSize,
15112
15303
  fontWeight: 700,
15113
15304
  fill: textColor,
15114
15305
  lineHeight: 1.2
15115
15306
  };
15116
15307
  const valueLabelStyle = {
15117
15308
  fontFamily: theme.fonts.family,
15118
- fontSize: tileSize > 24 ? 10 : 7,
15309
+ fontSize: valueFontSize,
15119
15310
  fontWeight: 300,
15120
15311
  fill: isLightText ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.45)",
15121
15312
  lineHeight: 1.2
15122
15313
  };
15123
- 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 ? {
15124
15316
  text: formattedValue,
15125
15317
  x: tileCenterX,
15126
15318
  y: tileTopY + tileSize * 0.78,
15127
15319
  style: valueLabelStyle,
15128
15320
  visible: true
15129
- };
15321
+ } : { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false };
15322
+ const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.22));
15130
15323
  const data = {
15131
15324
  state: stateCode,
15132
15325
  value: value2,
@@ -15145,14 +15338,14 @@ function buildTileMark(opts) {
15145
15338
  fillOpacity,
15146
15339
  stroke,
15147
15340
  strokeWidth: TILE_STROKE_WIDTH,
15148
- cornerRadius: TILE_CORNER_RADIUS,
15341
+ cornerRadius,
15149
15342
  value: value2,
15150
15343
  formattedValue,
15151
15344
  hasData,
15152
15345
  label: {
15153
15346
  text: stateCode,
15154
15347
  x: tileCenterX,
15155
- y: tileTopY + tileSize * 0.28,
15348
+ y: codeCenterY,
15156
15349
  style: labelStyle,
15157
15350
  visible: true
15158
15351
  },
@@ -15191,6 +15384,22 @@ function buildTooltips(tiles, fieldLabel = "Value") {
15191
15384
  function formatCategoryLabel(category) {
15192
15385
  return category.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
15193
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
+ }
15194
15403
  function emptyLayout4(chrome, theme, options, watermark) {
15195
15404
  return {
15196
15405
  area: { x: 0, y: 0, width: 0, height: 0 },
@@ -16006,7 +16215,7 @@ function compileChart(spec, options) {
16006
16215
  if (rawTransforms && rawTransforms.length > 0) {
16007
16216
  chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
16008
16217
  }
16009
- const breakpoint = getBreakpoint(options.width);
16218
+ const breakpoint = getBreakpoint2(options.width);
16010
16219
  const heightClass = getHeightClass(options.height);
16011
16220
  let strategy = getLayoutStrategy(breakpoint, heightClass);
16012
16221
  const rawSpec = expandedSpec;
@@ -16572,6 +16781,11 @@ function compileBarList2(spec, options) {
16572
16781
  return compileBarList(spec, options);
16573
16782
  }
16574
16783
  export {
16784
+ ENTER_DEFAULTS,
16785
+ EXIT_DEFAULTS,
16786
+ UPDATE_DEFAULTS,
16787
+ buildAreaPath,
16788
+ buildLinePath,
16575
16789
  clampStaggerDelay,
16576
16790
  clearRenderers,
16577
16791
  compile,
@@ -16582,6 +16796,7 @@ export {
16582
16796
  compileSankey2 as compileSankey,
16583
16797
  compileTable,
16584
16798
  compileTileMap2 as compileTileMap,
16799
+ dedupeKeys,
16585
16800
  evaluatePredicate,
16586
16801
  getChartRenderer,
16587
16802
  heuristicMeasure,
@@ -16595,6 +16810,7 @@ export {
16595
16810
  runFilter,
16596
16811
  runTimeUnit,
16597
16812
  runTransforms,
16813
+ serializeKeyValue,
16598
16814
  validateSpec
16599
16815
  };
16600
16816
  //# sourceMappingURL=index.js.map