@opendata-ai/openchart-engine 8.7.0 → 8.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,210 @@
1
+ // src/design/diagnostics.ts
2
+ import { computeLabelBounds, contrastRatio, isOpaqueColor } from "@opendata-ai/openchart-core";
3
+
4
+ // src/design/identity.ts
5
+ function observationKey(value) {
6
+ if (typeof value === "string") return `string:${value}`;
7
+ if (typeof value === "number" && Number.isFinite(value)) return `number:${value}`;
8
+ if (typeof value === "boolean") return `boolean:${value}`;
9
+ if (value instanceof Date && Number.isFinite(value.getTime()))
10
+ return `date:${value.toISOString()}`;
11
+ return null;
12
+ }
13
+ function resolveObservationIdentities(data, keyField, seriesField) {
14
+ const keys = data.map((row) => keyField ? observationKey(row[keyField]) : null);
15
+ const counts = /* @__PURE__ */ new Map();
16
+ for (const key of keys) if (key !== null) counts.set(key, (counts.get(key) ?? 0) + 1);
17
+ return data.map((row, index) => ({
18
+ observation: keys[index] !== null && counts.get(keys[index]) === 1 ? keys[index] : `revision-row:${index}`,
19
+ persistent: keys[index] !== null && counts.get(keys[index]) === 1,
20
+ series: seriesField ? observationKey(row[seriesField]) ?? void 0 : void 0
21
+ }));
22
+ }
23
+ function reconcileReaderSelection(selection, rows, keyField, seriesField, visibleKeys) {
24
+ const identities = resolveObservationIdentities(rows, keyField, seriesField);
25
+ const byKey = new Map(identities.filter((id) => id.persistent).map((id) => [id.observation, id]));
26
+ const data = new Map(identities.map((id, i) => [id.observation, rows[i]]));
27
+ let cleared = false;
28
+ const pinned = selection.pinned.flatMap((item) => {
29
+ const id = byKey.get(item.observation);
30
+ if (!item.persistent || !id) {
31
+ cleared = true;
32
+ return [];
33
+ }
34
+ const datum = data.get(id.observation);
35
+ return [
36
+ {
37
+ ...id,
38
+ datum,
39
+ previous: JSON.stringify(datum) === JSON.stringify(item.datum) ? void 0 : item.datum,
40
+ state: visibleKeys && !visibleKeys.has(id.observation) ? "filtered" : "visible"
41
+ }
42
+ ];
43
+ });
44
+ return {
45
+ preview: null,
46
+ pinned,
47
+ announcement: cleared ? "Selection cleared: the observation was removed or no longer has a unique key." : ""
48
+ };
49
+ }
50
+ function compareObservations(before, after, a, b) {
51
+ if (![before, after].every(Number.isFinite) || !a.measure || a.measure !== b.measure || a.unit !== b.unit || a.representation !== b.representation)
52
+ return null;
53
+ const percent = a.representation === "percent" || a.representation === "proportion";
54
+ return {
55
+ absolute: (after - before) * (a.representation === "proportion" ? 100 : 1),
56
+ ...before !== 0 ? { relative: (after - before) / Math.abs(before) } : {},
57
+ unit: percent ? "percentage points" : a.unit
58
+ };
59
+ }
60
+ function linkedEntityKeys(row, source, target) {
61
+ if (source.dataset !== target.dataset) return [];
62
+ const value = row[source.entityField];
63
+ if (source.kind === target.kind && source.transform === target.transform) {
64
+ const key = observationKey(value);
65
+ return key ? [key] : [];
66
+ }
67
+ if (source.kind === "group" && target.kind === "row") {
68
+ const group = String(value);
69
+ const members = source.groupMembers && Object.hasOwn(source.groupMembers, group) ? source.groupMembers[group] : [];
70
+ return members.flatMap((member) => {
71
+ const key = observationKey(member);
72
+ return key ? [key] : [];
73
+ });
74
+ }
75
+ return [];
76
+ }
77
+
78
+ // src/design/diagnostics.ts
79
+ function diagnoseChart(spec, layout) {
80
+ const diagnostics = [];
81
+ const viewport = layout.dimensions;
82
+ const emit = (code, path, message, origin, status = "failed", severity = "warning") => {
83
+ diagnostics.push({ code, path, message, origin, status, severity, viewport });
84
+ };
85
+ const key = spec.encoding.key?.field;
86
+ if (key && resolveObservationIdentities(spec.data, key).some((id) => !id.persistent)) {
87
+ emit(
88
+ "identity.invalid-key",
89
+ "/encoding/key",
90
+ "Observation keys must be present and unique. Ambiguous selections cannot persist across revisions.",
91
+ "author",
92
+ "failed",
93
+ "error"
94
+ );
95
+ }
96
+ const channels = [spec.encoding.x, spec.encoding.y].filter(
97
+ (channel) => channel && "field" in channel
98
+ );
99
+ for (const channel of channels) {
100
+ if (!channel || !("field" in channel)) continue;
101
+ const missing = spec.data.filter(
102
+ (row) => row[channel.field] == null || row[channel.field] === ""
103
+ ).length;
104
+ if (missing)
105
+ emit(
106
+ "data.missing",
107
+ "/data",
108
+ `${missing} observations have no ${channel.field}. Missing values are not zero; explain omissions or gaps in the methodology.`,
109
+ "author"
110
+ );
111
+ const meta = spec.fields?.[channel.field];
112
+ if (meta?.representation === "percent" && channel.format?.includes("%")) {
113
+ emit(
114
+ "format.percent-scale",
115
+ "/encoding",
116
+ "Percent values are already 0\u2013100; a percent formatter multiplies them by 100. Choose a number format or supply proportions.",
117
+ "author"
118
+ );
119
+ }
120
+ }
121
+ const colors = spec.encoding.color;
122
+ if (colors && "field" in colors && colors.type === "nominal") {
123
+ const count = new Set(spec.data.map((row) => row[colors.field])).size;
124
+ if (count > 6)
125
+ emit(
126
+ "color.crowded",
127
+ "/encoding/color",
128
+ `${count} series compete for color. Highlight an authored focus or use small multiples.`,
129
+ "author"
130
+ );
131
+ }
132
+ const background = layout.theme.colors.background;
133
+ if (!isOpaqueColor(background)) {
134
+ emit(
135
+ "contrast.unknown-background",
136
+ "/theme/colors/background",
137
+ "Contrast cannot be verified against a transparent background. Supply the host surface color for a check.",
138
+ spec.theme?.colors ? "author" : "default",
139
+ "uncheckable",
140
+ "info"
141
+ );
142
+ } else if (contrastRatio(layout.theme.colors.axis, background) < 4.5) {
143
+ emit(
144
+ "contrast.axis",
145
+ "/theme/colors/axis",
146
+ "Axis text contrast is below 4.5:1 against the resolved background.",
147
+ spec.theme?.colors ? "author" : "default"
148
+ );
149
+ }
150
+ const labels = layout.marks.flatMap(
151
+ (mark) => "label" in mark && mark.label?.visible ? [computeLabelBounds(mark.label)] : []
152
+ );
153
+ let collision = false;
154
+ for (let i = 0; i < labels.length && !collision; i++) {
155
+ for (let j = i + 1; j < labels.length; j++) {
156
+ const a = labels[i];
157
+ const b = labels[j];
158
+ if (a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y) {
159
+ collision = true;
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ if (collision)
165
+ emit(
166
+ "labels.collision",
167
+ "/labels",
168
+ "Visible data labels overlap at this viewport. Increase space or reduce label density.",
169
+ spec.labels ? "author" : "default"
170
+ );
171
+ if (spec.labels && typeof spec.labels === "object" && spec.labels.density === "all" && layout.marks.some((mark) => "label" in mark && mark.label && !mark.label.visible)) {
172
+ emit(
173
+ "labels.hidden",
174
+ "/labels",
175
+ "Some requested labels cannot fit at this viewport. Inspect the data view or give the figure more room.",
176
+ "author"
177
+ );
178
+ }
179
+ if (layout.area.width < 80 || layout.area.height < 80)
180
+ emit(
181
+ "layout.constrained",
182
+ "/chromeLayout",
183
+ "Essential chart content has less than 80px of plotting space. Grow the figure or simplify its composition.",
184
+ spec.chromeLayout ? "author" : "default"
185
+ );
186
+ if (layout.theme.fonts.sizes.axisTick < 12) {
187
+ emit(
188
+ "type.small-axis",
189
+ "/theme/fonts/sizes/axisTick",
190
+ "Axis labels are smaller than the 12px editorial target.",
191
+ spec.theme?.fonts?.sizes?.axisTick !== void 0 ? "author" : "default",
192
+ "failed",
193
+ "info"
194
+ );
195
+ diagnostics[diagnostics.length - 1].fix = {
196
+ label: "Use 12px axis labels",
197
+ precondition: JSON.stringify(spec),
198
+ changes: [{ path: "/theme/fonts/sizes/axisTick", value: 12 }]
199
+ };
200
+ }
201
+ return diagnostics;
202
+ }
203
+
1
204
  // src/compile.ts
2
205
  import {
3
206
  adaptTheme as adaptTheme6,
4
- computeLabelBounds,
207
+ computeLabelBounds as computeLabelBounds2,
5
208
  footnoteBandHeight,
6
209
  generateAltText,
7
210
  generateDataTable,
@@ -11,7 +214,8 @@ import {
11
214
  isAxislessMark as isAxislessMark3,
12
215
  MAX_SR_TABLE_ROWS,
13
216
  resolveChromeEconomy,
14
- resolveTheme as resolveTheme7
217
+ resolveTheme as resolveTheme7,
218
+ wrapText as wrapText3
15
219
  } from "@opendata-ai/openchart-core";
16
220
 
17
221
  // src/annotations/collisions.ts
@@ -2298,6 +2502,14 @@ function dedupeKeys(keys) {
2298
2502
  return result;
2299
2503
  }
2300
2504
 
2505
+ // src/data/finite-number.ts
2506
+ function finiteNumber(value) {
2507
+ if (typeof value !== "number" && typeof value !== "string") return void 0;
2508
+ if (typeof value === "string" && value.trim() === "") return void 0;
2509
+ const number = Number(value);
2510
+ return Number.isFinite(number) ? number : void 0;
2511
+ }
2512
+
2301
2513
  // src/transforms/predicates.ts
2302
2514
  function isFieldPredicate(pred) {
2303
2515
  return "field" in pred;
@@ -2429,9 +2641,10 @@ function scaleValue(scale, scaleType, value) {
2429
2641
  const result = scale(String(value));
2430
2642
  return result ?? null;
2431
2643
  }
2432
- const num = typeof value === "number" ? value : Number(value);
2433
- if (!Number.isFinite(num)) return null;
2434
- return scale(num);
2644
+ const number = finiteNumber(value);
2645
+ if (number === void 0) return null;
2646
+ const position = scale(number);
2647
+ return Number.isFinite(position) ? position : null;
2435
2648
  }
2436
2649
  function groupByField(data, field) {
2437
2650
  const groups = /* @__PURE__ */ new Map();
@@ -2520,6 +2733,7 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth, th
2520
2733
  if (typeof yScale.bandwidth !== "function") {
2521
2734
  return [];
2522
2735
  }
2736
+ const measuredData = spec.data.filter((row) => finiteNumber(row[xChannel.field]) !== void 0);
2523
2737
  const bandwidth = yScale.bandwidth();
2524
2738
  const xDomain = xScale.domain();
2525
2739
  const xIncludesZero = xDomain[0] <= 0 && xDomain[1] >= 0;
@@ -2531,7 +2745,7 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth, th
2531
2745
  let marks;
2532
2746
  if (!colorField || isSequentialColor) {
2533
2747
  marks = computeSimpleBars(
2534
- spec.data,
2748
+ measuredData,
2535
2749
  xChannel.field,
2536
2750
  yChannel.field,
2537
2751
  xScale,
@@ -2544,12 +2758,12 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth, th
2544
2758
  );
2545
2759
  } else {
2546
2760
  const categoryGroups = groupByField(spec.data, yChannel.field);
2547
- const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
2761
+ const needsStacking = xChannel.stack === "normalize" || xChannel.stack === "center" || Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
2548
2762
  if (needsStacking) {
2549
2763
  const stackEnabled = xChannel.stack !== null && xChannel.stack !== false;
2550
2764
  if (!stackEnabled) {
2551
2765
  marks = computeGroupedBars(
2552
- spec.data,
2766
+ measuredData,
2553
2767
  xChannel.field,
2554
2768
  yChannel.field,
2555
2769
  colorField,
@@ -2563,7 +2777,7 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth, th
2563
2777
  } else {
2564
2778
  const stackMode = xChannel.stack === "normalize" ? "normalize" : xChannel.stack === "center" ? "center" : "zero";
2565
2779
  marks = computeStackedBars(
2566
- spec.data,
2780
+ measuredData,
2567
2781
  xChannel.field,
2568
2782
  yChannel.field,
2569
2783
  colorField,
@@ -2577,7 +2791,7 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth, th
2577
2791
  }
2578
2792
  } else {
2579
2793
  marks = computeColoredBars(
2580
- spec.data,
2794
+ measuredData,
2581
2795
  xChannel.field,
2582
2796
  yChannel.field,
2583
2797
  colorField,
@@ -2618,16 +2832,16 @@ function computeStackedBars(data, valueField, categoryField, colorField, xScale,
2618
2832
  }
2619
2833
  let cumulativeValue = stackMode === "center" ? -categoryTotal / 2 : 0;
2620
2834
  for (const row of rows) {
2621
- const groupKey4 = String(row[colorField] ?? "");
2835
+ const groupKey2 = String(row[colorField] ?? "");
2622
2836
  const rawValue = Number(row[valueField] ?? 0);
2623
2837
  if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
2624
2838
  const value = stackMode === "normalize" && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
2625
- const color = getColor(scales, groupKey4);
2839
+ const color = getColor(scales, groupKey2);
2626
2840
  const xLeft = xScale(cumulativeValue);
2627
2841
  const xRight = xScale(cumulativeValue + value);
2628
2842
  const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
2629
2843
  const aria = {
2630
- label: `${category}, ${groupKey4}: ${formatLabelValue(rawValue)}`
2844
+ label: `${category}, ${groupKey2}: ${formatLabelValue(rawValue)}`
2631
2845
  };
2632
2846
  marks.push({
2633
2847
  type: "rect",
@@ -2637,7 +2851,7 @@ function computeStackedBars(data, valueField, categoryField, colorField, xScale,
2637
2851
  height: bandwidth,
2638
2852
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2639
2853
  cornerRadius: 0,
2640
- seriesKey: groupKey4,
2854
+ seriesKey: groupKey2,
2641
2855
  data: row,
2642
2856
  aria,
2643
2857
  orient: "horizontal",
@@ -2676,16 +2890,16 @@ function computeGroupedBars(data, valueField, categoryField, colorField, xScale,
2676
2890
  const bandY = yScale(category);
2677
2891
  if (bandY === void 0) continue;
2678
2892
  for (const row of rows) {
2679
- const groupKey4 = String(row[colorField] ?? "");
2893
+ const groupKey2 = String(row[colorField] ?? "");
2680
2894
  const value = Number(row[valueField] ?? 0);
2681
2895
  if (!Number.isFinite(value)) continue;
2682
- const groupIndex = groupIndexMap.get(groupKey4) ?? 0;
2683
- const color = getColor(scales, groupKey4);
2896
+ const groupIndex = groupIndexMap.get(groupKey2) ?? 0;
2897
+ const color = getColor(scales, groupKey2);
2684
2898
  const xPos = value >= 0 ? baseline : xScale(value);
2685
2899
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
2686
2900
  const subY = bandY + groupOffset + groupIndex * (subBandHeight + gap);
2687
2901
  const aria = {
2688
- label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
2902
+ label: `${category}, ${groupKey2}: ${formatLabelValue(value)}`
2689
2903
  };
2690
2904
  marks.push({
2691
2905
  type: "rect",
@@ -2696,7 +2910,7 @@ function computeGroupedBars(data, valueField, categoryField, colorField, xScale,
2696
2910
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2697
2911
  cornerRadius: BAR_CORNER_RADIUS,
2698
2912
  cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2699
- seriesKey: groupKey4,
2913
+ seriesKey: groupKey2,
2700
2914
  data: row,
2701
2915
  aria,
2702
2916
  orient: "horizontal"
@@ -2713,12 +2927,12 @@ function computeColoredBars(data, valueField, categoryField, colorField, xScale,
2713
2927
  if (!Number.isFinite(value)) continue;
2714
2928
  const bandY = yScale(category);
2715
2929
  if (bandY === void 0) continue;
2716
- const groupKey4 = String(row[colorField] ?? "");
2717
- const color = getColor(scales, groupKey4);
2930
+ const groupKey2 = String(row[colorField] ?? "");
2931
+ const color = getColor(scales, groupKey2);
2718
2932
  const xPos = value >= 0 ? baseline : xScale(value);
2719
2933
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
2720
2934
  const aria = {
2721
- label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
2935
+ label: `${category}, ${groupKey2}: ${formatLabelValue(value)}`
2722
2936
  };
2723
2937
  marks.push({
2724
2938
  type: "rect",
@@ -2729,7 +2943,7 @@ function computeColoredBars(data, valueField, categoryField, colorField, xScale,
2729
2943
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2730
2944
  cornerRadius: BAR_CORNER_RADIUS,
2731
2945
  cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2732
- seriesKey: groupKey4,
2946
+ seriesKey: groupKey2,
2733
2947
  data: row,
2734
2948
  aria,
2735
2949
  orient: "horizontal"
@@ -3062,10 +3276,8 @@ function buildSizeScale(sizeEncoding, data, options) {
3062
3276
  const field = sizeEncoding.field;
3063
3277
  const values = [];
3064
3278
  for (const row of data) {
3065
- const raw = row[field];
3066
- if (raw == null || raw === "") continue;
3067
- const v = Number(raw);
3068
- if (Number.isFinite(v)) values.push(v);
3279
+ const value = finiteNumber(row[field]);
3280
+ if (value !== void 0) values.push(value);
3069
3281
  }
3070
3282
  const explicitDomain = sizeEncoding.scale?.domain;
3071
3283
  if (!explicitDomain && values.length === 0) return null;
@@ -3166,10 +3378,8 @@ function computeBeeswarmMarks(spec, scales, chartArea, _strategy, theme) {
3166
3378
  const sizeScale = resolvedSize?.scale;
3167
3379
  const dots = [];
3168
3380
  for (const row of spec.data) {
3169
- const raw = row[valueChannel.field];
3170
- if (raw == null) continue;
3171
- const value = Number(raw);
3172
- if (!Number.isFinite(value)) continue;
3381
+ const value = finiteNumber(row[valueChannel.field]);
3382
+ if (value === void 0) continue;
3173
3383
  const position = positionScale(value);
3174
3384
  if (!Number.isFinite(position)) continue;
3175
3385
  let lane = "__default__";
@@ -3181,12 +3391,12 @@ function computeBeeswarmMarks(spec, scales, chartArea, _strategy, theme) {
3181
3391
  laneCenter = bandStart + bandScale.bandwidth() / 2;
3182
3392
  }
3183
3393
  let radius = DEFAULT_DOT_RADIUS2;
3184
- if (sizeScale && sizeField) {
3185
- const sizeVal = Number(row[sizeField]);
3186
- if (Number.isFinite(sizeVal)) {
3187
- radius = sizeScale(sizeVal);
3188
- }
3394
+ if (sizeField) {
3395
+ const sizeValue = finiteNumber(row[sizeField]);
3396
+ if (sizeValue === void 0) continue;
3397
+ if (sizeScale) radius = sizeScale(sizeValue);
3189
3398
  }
3399
+ if (!Number.isFinite(radius) || radius < 0) continue;
3190
3400
  dots.push({ row, value, position, radius, lane, laneCenter });
3191
3401
  }
3192
3402
  const offsets = dodgeOffsets(
@@ -3202,8 +3412,8 @@ function computeBeeswarmMarks(spec, scales, chartArea, _strategy, theme) {
3202
3412
  const cross = dot.laneCenter + offsets[i];
3203
3413
  let color;
3204
3414
  if (isSequentialColor && colorField) {
3205
- const colorVal = Number(dot.row[colorField]);
3206
- color = Number.isFinite(colorVal) ? getSequentialColor(scales, colorVal) : getColor(scales, "__default__");
3415
+ const value = finiteNumber(dot.row[colorField]);
3416
+ color = value !== void 0 ? getSequentialColor(scales, value) : getColor(scales, "__default__");
3207
3417
  } else if (colorField) {
3208
3418
  color = getColor(scales, String(dot.row[colorField] ?? "__default__"));
3209
3419
  } else {
@@ -3274,9 +3484,9 @@ function computeBinnedBarMarks(spec, scales, _chartArea, _strategy, theme) {
3274
3484
  return marks;
3275
3485
  }
3276
3486
  function binGeometry(row, xField, x2Field, xScale) {
3277
- const start = Number(row[xField]);
3278
- const end = Number(row[x2Field]);
3279
- if (!Number.isFinite(start) || !Number.isFinite(end)) return void 0;
3487
+ const start = finiteNumber(row[xField]);
3488
+ const end = finiteNumber(row[x2Field]);
3489
+ if (start === void 0 || end === void 0) return void 0;
3280
3490
  const px0 = xScale(start);
3281
3491
  const px1 = xScale(end);
3282
3492
  const left = Math.min(px0, px1);
@@ -3304,8 +3514,8 @@ function overlappingBins(groups, spec, scales, xScale, yScale, baseline, colorFi
3304
3514
  for (const row of rows) {
3305
3515
  const geom = binGeometry(row, xField, x2Field, xScale);
3306
3516
  if (!geom) continue;
3307
- const value = Number(row[yField]);
3308
- if (!Number.isFinite(value)) continue;
3517
+ const value = finiteNumber(row[yField]);
3518
+ if (value === void 0) continue;
3309
3519
  const endY = yScale(value);
3310
3520
  const top = Math.min(baseline, endY);
3311
3521
  const height = Math.max(Math.abs(baseline - endY), MIN_BAR_HEIGHT);
@@ -3341,8 +3551,8 @@ function stackedBins(groups, spec, scales, xScale, yScale, baseline, theme) {
3341
3551
  for (const row of rows) {
3342
3552
  const geom = binGeometry(row, xField, x2Field, xScale);
3343
3553
  if (!geom) continue;
3344
- const value = Number(row[yField]);
3345
- if (!Number.isFinite(value) || value <= 0) continue;
3554
+ const value = finiteNumber(row[yField]);
3555
+ if (value === void 0 || value <= 0) continue;
3346
3556
  const binKey = serializeKeyValue(row[xField]);
3347
3557
  const used = offsets.get(binKey) ?? 0;
3348
3558
  const segmentHeight = Math.max(baseline - yScale(value), MIN_BAR_HEIGHT);
@@ -3441,8 +3651,8 @@ function computeCalendarMarks(spec, scales, chartArea, theme) {
3441
3651
  const day = parseUtcDay(row[xEnc.field]);
3442
3652
  if (!day) continue;
3443
3653
  years.add(day.getUTCFullYear());
3444
- const value = Number(row[colorEnc.field]);
3445
- if (!Number.isFinite(value)) continue;
3654
+ const value = finiteNumber(row[colorEnc.field]);
3655
+ if (value === void 0) continue;
3446
3656
  dayData.set(isoDayKey(day), { row, value });
3447
3657
  }
3448
3658
  if (years.size === 0) return [];
@@ -3603,6 +3813,7 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy, theme) {
3603
3813
  if (typeof xScale.bandwidth !== "function") {
3604
3814
  return [];
3605
3815
  }
3816
+ const measuredData = spec.data.filter((row) => finiteNumber(row[yChannel.field]) !== void 0);
3606
3817
  const bandwidth = xScale.bandwidth();
3607
3818
  const yDomain = yScale.domain();
3608
3819
  const yIncludesZero = yDomain[0] <= 0 && yDomain[1] >= 0;
@@ -3614,12 +3825,12 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy, theme) {
3614
3825
  let marks;
3615
3826
  if (colorField && !isSequentialColor) {
3616
3827
  const categoryGroups = groupByField(spec.data, xChannel.field);
3617
- const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
3828
+ const needsStacking = yChannel.stack === "normalize" || yChannel.stack === "center" || Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
3618
3829
  if (needsStacking) {
3619
3830
  const stackEnabled = yChannel.stack !== null && yChannel.stack !== false;
3620
3831
  if (!stackEnabled) {
3621
3832
  marks = computeGroupedColumns(
3622
- spec.data,
3833
+ measuredData,
3623
3834
  xChannel.field,
3624
3835
  yChannel.field,
3625
3836
  colorField,
@@ -3632,7 +3843,7 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy, theme) {
3632
3843
  } else {
3633
3844
  const stackMode = yChannel.stack === "normalize" ? "normalize" : yChannel.stack === "center" ? "center" : "zero";
3634
3845
  marks = computeStackedColumns(
3635
- spec.data,
3846
+ measuredData,
3636
3847
  xChannel.field,
3637
3848
  yChannel.field,
3638
3849
  colorField,
@@ -3646,7 +3857,7 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy, theme) {
3646
3857
  }
3647
3858
  } else {
3648
3859
  marks = computeColoredColumns(
3649
- spec.data,
3860
+ measuredData,
3650
3861
  xChannel.field,
3651
3862
  yChannel.field,
3652
3863
  colorField,
@@ -3659,7 +3870,7 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy, theme) {
3659
3870
  }
3660
3871
  } else {
3661
3872
  marks = computeSimpleColumns(
3662
- spec.data,
3873
+ measuredData,
3663
3874
  xChannel.field,
3664
3875
  yChannel.field,
3665
3876
  xScale,
@@ -3738,13 +3949,13 @@ function computeColoredColumns(data, categoryField, valueField, colorField, xSca
3738
3949
  if (!Number.isFinite(value)) continue;
3739
3950
  const bandX = xScale(category);
3740
3951
  if (bandX === void 0) continue;
3741
- const groupKey4 = String(row[colorField] ?? "");
3742
- const color = getColor(scales, groupKey4);
3952
+ const groupKey2 = String(row[colorField] ?? "");
3953
+ const color = getColor(scales, groupKey2);
3743
3954
  const yPos = yScale(value);
3744
3955
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
3745
3956
  const y = value >= 0 ? yPos : baseline;
3746
3957
  const aria = {
3747
- label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
3958
+ label: `${category}, ${groupKey2}: ${formatLabelValue(value)}`
3748
3959
  };
3749
3960
  marks.push({
3750
3961
  type: "rect",
@@ -3755,7 +3966,7 @@ function computeColoredColumns(data, categoryField, valueField, colorField, xSca
3755
3966
  fill: color,
3756
3967
  cornerRadius: BAR_CORNER_RADIUS,
3757
3968
  cornerRadiusSides: valueEndCorners("vertical", value < 0),
3758
- seriesKey: groupKey4,
3969
+ seriesKey: groupKey2,
3759
3970
  data: row,
3760
3971
  aria,
3761
3972
  orient: "vertical"
@@ -3784,17 +3995,17 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
3784
3995
  const bandX = xScale(category);
3785
3996
  if (bandX === void 0) continue;
3786
3997
  for (const row of rows) {
3787
- const groupKey4 = String(row[colorField] ?? "");
3998
+ const groupKey2 = String(row[colorField] ?? "");
3788
3999
  const value = Number(row[valueField] ?? 0);
3789
4000
  if (!Number.isFinite(value)) continue;
3790
- const groupIndex = groupIndexMap.get(groupKey4) ?? 0;
3791
- const color = getColor(scales, groupKey4);
4001
+ const groupIndex = groupIndexMap.get(groupKey2) ?? 0;
4002
+ const color = getColor(scales, groupKey2);
3792
4003
  const yPos = yScale(value);
3793
4004
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
3794
4005
  const y = value >= 0 ? yPos : baseline;
3795
4006
  const subX = bandX + groupIndex * (subBandWidth + gap);
3796
4007
  const aria = {
3797
- label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
4008
+ label: `${category}, ${groupKey2}: ${formatLabelValue(value)}`
3798
4009
  };
3799
4010
  marks.push({
3800
4011
  type: "rect",
@@ -3805,7 +4016,7 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
3805
4016
  fill: color,
3806
4017
  cornerRadius: BAR_CORNER_RADIUS,
3807
4018
  cornerRadiusSides: valueEndCorners("vertical", value < 0),
3808
- seriesKey: groupKey4,
4019
+ seriesKey: groupKey2,
3809
4020
  data: row,
3810
4021
  aria,
3811
4022
  orient: "vertical"
@@ -3827,16 +4038,16 @@ function computeStackedColumns(data, categoryField, valueField, colorField, xSca
3827
4038
  }
3828
4039
  let cumulativeValue = stackMode === "center" ? -categoryTotal / 2 : 0;
3829
4040
  for (const row of rows) {
3830
- const groupKey4 = String(row[colorField] ?? "");
4041
+ const groupKey2 = String(row[colorField] ?? "");
3831
4042
  const rawValue = Number(row[valueField] ?? 0);
3832
4043
  if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
3833
4044
  const value = stackMode === "normalize" && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
3834
- const color = getColor(scales, groupKey4);
4045
+ const color = getColor(scales, groupKey2);
3835
4046
  const yTop = yScale(cumulativeValue + value);
3836
4047
  const yBottom = yScale(cumulativeValue);
3837
4048
  const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
3838
4049
  const aria = {
3839
- label: `${category}, ${groupKey4}: ${formatLabelValue(rawValue)}`
4050
+ label: `${category}, ${groupKey2}: ${formatLabelValue(rawValue)}`
3840
4051
  };
3841
4052
  marks.push({
3842
4053
  type: "rect",
@@ -3846,7 +4057,7 @@ function computeStackedColumns(data, categoryField, valueField, colorField, xSca
3846
4057
  height: columnHeight,
3847
4058
  fill: color,
3848
4059
  cornerRadius: 0,
3849
- seriesKey: groupKey4,
4060
+ seriesKey: groupKey2,
3850
4061
  data: row,
3851
4062
  aria,
3852
4063
  orient: "vertical",
@@ -4117,8 +4328,8 @@ function computeDumbbellMarks(data, valueField, categoryField, colorField, xScal
4117
4328
  const cy = bandY + bandwidth / 2;
4118
4329
  const xValues = [];
4119
4330
  for (const row of rows) {
4120
- const value = Number(row[valueField] ?? 0);
4121
- if (Number.isFinite(value)) xValues.push(value);
4331
+ const value = finiteNumber(row[valueField]);
4332
+ if (value !== void 0) xValues.push(value);
4122
4333
  }
4123
4334
  if (xValues.length === 0) continue;
4124
4335
  const minVal = Math.min(...xValues);
@@ -4142,8 +4353,8 @@ function computeDumbbellMarks(data, valueField, categoryField, colorField, xScal
4142
4353
  });
4143
4354
  }
4144
4355
  for (const row of rows) {
4145
- const value = Number(row[valueField] ?? 0);
4146
- if (!Number.isFinite(value)) continue;
4356
+ const value = finiteNumber(row[valueField]);
4357
+ if (value === void 0) continue;
4147
4358
  const cx = xScale(value);
4148
4359
  const colorCategory = String(row[colorField] ?? "");
4149
4360
  const color = getColor(scales, colorCategory);
@@ -4169,8 +4380,8 @@ function computeLollipopMarks(data, valueField, categoryField, xScale, yScale, b
4169
4380
  const marks = [];
4170
4381
  for (const row of data) {
4171
4382
  const category = String(row[categoryField] ?? "");
4172
- const value = Number(row[valueField] ?? 0);
4173
- if (!Number.isFinite(value)) continue;
4383
+ const value = finiteNumber(row[valueField]);
4384
+ if (value === void 0) continue;
4174
4385
  const bandY = yScale(category);
4175
4386
  if (bandY === void 0) continue;
4176
4387
  const cx = xScale(value);
@@ -4504,8 +4715,8 @@ function computeLineMarks(spec, scales, _chartArea, _strategy, theme) {
4504
4715
  const visible = seriesShowPoints && !isTransparent && (!isEndpoints || isEndpoint);
4505
4716
  let pointColor = color;
4506
4717
  if (isSequentialColor) {
4507
- const val = Number(p.row[sequentialColorField]);
4508
- pointColor = Number.isFinite(val) ? getSequentialColor(scales, val) : color;
4718
+ const value = finiteNumber(p.row[sequentialColorField]);
4719
+ pointColor = value !== void 0 ? getSequentialColor(scales, value) : color;
4509
4720
  }
4510
4721
  const hollow = isEndpoints && visible;
4511
4722
  const pointColorStr = getRepresentativeColor5(pointColor);
@@ -4561,7 +4772,10 @@ function computeLineMarks(spec, scales, _chartArea, _strategy, theme) {
4561
4772
  return allMarks;
4562
4773
  }
4563
4774
  function _getMidValue(rows, field) {
4564
- const values = rows.map((r) => Number(r[field])).filter(Number.isFinite);
4775
+ const values = rows.flatMap((row) => {
4776
+ const value = finiteNumber(row[field]);
4777
+ return value === void 0 ? [] : [value];
4778
+ });
4565
4779
  if (values.length === 0) return 0;
4566
4780
  const sorted = values.sort((a, b) => a - b);
4567
4781
  return sorted[Math.floor(sorted.length / 2)];
@@ -4621,25 +4835,26 @@ function computeSingleArea(spec, scales, _chartArea, theme) {
4621
4835
  const color = getColor(scales, seriesKey);
4622
4836
  const sortedRows = xChannel.type === "nominal" || xChannel.type === "ordinal" ? rows : sortByField(rows, xChannel.field);
4623
4837
  const validPoints = [];
4838
+ const segments = [[]];
4624
4839
  const y2Channel = encoding.y2;
4625
4840
  for (const row of sortedRows) {
4626
4841
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
4627
4842
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
4628
- if (xVal === null || yVal === null) continue;
4629
- const yBottomVal = y2Channel && row[y2Channel.field] != null ? scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field]) : null;
4630
- validPoints.push({
4631
- x: xVal,
4632
- yTop: yVal,
4633
- yBottom: yBottomVal ?? baselineY,
4634
- row
4635
- });
4843
+ const yBottomVal = y2Channel ? scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field]) : baselineY;
4844
+ if (xVal === null || yVal === null || yBottomVal === null) {
4845
+ if (segments[segments.length - 1].length > 0) segments.push([]);
4846
+ continue;
4847
+ }
4848
+ const point = { x: xVal, yTop: yVal, yBottom: yBottomVal, row };
4849
+ validPoints.push(point);
4850
+ segments[segments.length - 1].push(point);
4636
4851
  }
4637
4852
  if (validPoints.length === 0) continue;
4638
4853
  const curve = resolveCurve(spec.markDef.interpolate);
4639
4854
  const areaGenerator = area2().x((d) => d.x).y0((d) => d.yBottom).y1((d) => d.yTop).curve(curve);
4640
- const pathStr = areaGenerator(validPoints) ?? "";
4855
+ const pathStr = segments.map((segment) => areaGenerator(segment) ?? "").join(" ");
4641
4856
  const topLineGenerator = line3().x((d) => d.x).y((d) => d.yTop).curve(curve);
4642
- const topPathStr = topLineGenerator(validPoints) ?? "";
4857
+ const topPathStr = segments.map((segment) => topLineGenerator(segment) ?? "").join(" ");
4643
4858
  const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
4644
4859
  const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
4645
4860
  const ariaLabel = seriesKey === "__default__" ? `Area with ${validPoints.length} data points` : `${seriesKey}: area with ${validPoints.length} data points`;
@@ -4715,6 +4930,7 @@ function computeStackedArea(spec, scales, chartArea, theme) {
4715
4930
  }
4716
4931
  const keys = Array.from(seriesKeys);
4717
4932
  const xValues = Array.from(xValueSet);
4933
+ const missingBoundaries = /* @__PURE__ */ new Set();
4718
4934
  const pivotData = xValues.map((xVal) => {
4719
4935
  const pivot = { __x__: xVal };
4720
4936
  for (const key of keys) {
@@ -4724,7 +4940,9 @@ function computeStackedArea(spec, scales, chartArea, theme) {
4724
4940
  if (xRows) {
4725
4941
  for (const row of xRows) {
4726
4942
  const series = String(row[colorField]);
4727
- pivot[series] = row[yChannel.field] ?? 0;
4943
+ const value = finiteNumber(row[yChannel.field]);
4944
+ if (value === void 0) missingBoundaries.add(xVal);
4945
+ pivot[series] = value ?? 0;
4728
4946
  }
4729
4947
  }
4730
4948
  return pivot;
@@ -4739,19 +4957,27 @@ function computeStackedArea(spec, scales, chartArea, theme) {
4739
4957
  const seriesKey = layer.key;
4740
4958
  const color = getColor(scales, seriesKey);
4741
4959
  const validPoints = [];
4960
+ const segments = [[]];
4742
4961
  for (const d of layer) {
4743
4962
  const xVal = scaleValue(scales.x.scale, scales.x.type, d.data.__x__);
4744
- if (xVal === null) continue;
4963
+ const xKey = String(d.data.__x__);
4745
4964
  const yTop = yScale(d[1]);
4746
4965
  const yBottom = yScale(d[0]);
4747
- validPoints.push({ x: xVal, yTop, yBottom });
4966
+ if (xVal === null || missingBoundaries.has(xKey) || !Number.isFinite(yTop) || !Number.isFinite(yBottom)) {
4967
+ if (segments[segments.length - 1].length > 0) segments.push([]);
4968
+ continue;
4969
+ }
4970
+ const datum = rowsByXSeries.get(`${xKey}::${seriesKey}`) ?? d.data;
4971
+ const point = { x: xVal, yTop, yBottom, xKey, datum };
4972
+ validPoints.push(point);
4973
+ segments[segments.length - 1].push(point);
4748
4974
  }
4749
4975
  if (validPoints.length === 0) continue;
4750
4976
  const stackCurve = resolveCurve(spec.markDef.interpolate);
4751
4977
  const areaGenerator = area2().x((p) => p.x).y0((p) => p.yBottom).y1((p) => p.yTop).curve(stackCurve);
4752
- const pathStr = areaGenerator(validPoints) ?? "";
4978
+ const pathStr = segments.map((segment) => areaGenerator(segment) ?? "").join(" ");
4753
4979
  const topLineGenerator = line3().x((p) => p.x).y((p) => p.yTop).curve(stackCurve);
4754
- const topPathStr = topLineGenerator(validPoints) ?? "";
4980
+ const topPathStr = segments.map((segment) => topLineGenerator(segment) ?? "").join(" ");
4755
4981
  const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
4756
4982
  const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
4757
4983
  const aria = {
@@ -4767,7 +4993,7 @@ function computeStackedArea(spec, scales, chartArea, theme) {
4767
4993
  fillValue = getRepresentativeColor6(color);
4768
4994
  fillOpacity = spec.markDef.fillOpacity ?? STACKED_FILL_OPACITY;
4769
4995
  }
4770
- const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
4996
+ const rawPointKeys = validPoints.map((point) => serializeKeyValue(point.xKey));
4771
4997
  marks.push({
4772
4998
  type: "area",
4773
4999
  key: seriesKey,
@@ -4784,15 +5010,8 @@ function computeStackedArea(spec, scales, chartArea, theme) {
4784
5010
  seriesKey,
4785
5011
  stacked: true,
4786
5012
  stackNormalized: stackProp === "normalize",
4787
- data: layer.map((d) => {
4788
- const xStr = String(d.data.__x__);
4789
- return rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? d.data;
4790
- }),
4791
- dataPoints: validPoints.map((p, idx) => {
4792
- const xStr = String(layer[idx]?.data.__x__);
4793
- const datum = rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? layer[idx]?.data ?? {};
4794
- return { x: p.x, y: p.yTop, datum };
4795
- }),
5013
+ data: validPoints.map((point) => point.datum),
5014
+ dataPoints: validPoints.map((point) => ({ x: point.x, y: point.yTop, datum: point.datum })),
4796
5015
  aria
4797
5016
  });
4798
5017
  }
@@ -5044,8 +5263,8 @@ function computeParliamentMarks(spec, scales, chartArea, _strategy, theme) {
5044
5263
  const partyRows = /* @__PURE__ */ new Map();
5045
5264
  for (const row of spec.data) {
5046
5265
  const party = String(row[colorField] ?? "");
5047
- const val = Number(row[valueChannel.field] ?? 0);
5048
- if (!Number.isFinite(val) || val < 0) continue;
5266
+ const val = finiteNumber(row[valueChannel.field]);
5267
+ if (val === void 0 || val < 0) continue;
5049
5268
  partyTotals.set(party, (partyTotals.get(party) ?? 0) + Math.round(val));
5050
5269
  if (!partyRows.has(party)) partyRows.set(party, row);
5051
5270
  }
@@ -5221,8 +5440,8 @@ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false, th
5221
5440
  const categoryRows = /* @__PURE__ */ new Map();
5222
5441
  for (const row of spec.data) {
5223
5442
  const cat = String(row[categoryField] ?? "");
5224
- const val = Number(row[valueChannel.field] ?? 0);
5225
- if (!Number.isFinite(val) || val < 0) continue;
5443
+ const val = finiteNumber(row[valueChannel.field]);
5444
+ if (val === void 0 || val < 0) continue;
5226
5445
  categoryTotals.set(cat, (categoryTotals.get(cat) ?? 0) + val);
5227
5446
  if (!categoryRows.has(cat)) {
5228
5447
  categoryRows.set(cat, row);
@@ -5241,8 +5460,8 @@ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false, th
5241
5460
  } else {
5242
5461
  for (let i = 0; i < spec.data.length; i++) {
5243
5462
  const row = spec.data[i];
5244
- const val = Number(row[valueChannel.field] ?? 0);
5245
- if (!Number.isFinite(val) || val < 0) continue;
5463
+ const val = finiteNumber(row[valueChannel.field]);
5464
+ if (val === void 0 || val < 0) continue;
5246
5465
  const label = String(row.label ?? row.name ?? row.category ?? `Slice ${i + 1}`);
5247
5466
  slices.push({ label, value: val, originalRow: row });
5248
5467
  }
@@ -5544,9 +5763,9 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5544
5763
  const startRaw = row[startChannel.field];
5545
5764
  const endRaw = row[endChannel.field];
5546
5765
  if (startRaw == null || endRaw == null) continue;
5547
- const startVal = Number(startRaw);
5548
- const endVal = Number(endRaw);
5549
- if (!Number.isFinite(startVal) || !Number.isFinite(endVal)) continue;
5766
+ const startVal = finiteNumber(startRaw);
5767
+ const endVal = finiteNumber(endRaw);
5768
+ if (startVal === void 0 || endVal === void 0) continue;
5550
5769
  const center = bandPos + bandwidth / 2;
5551
5770
  const pStart = valueScale(startVal);
5552
5771
  const pEnd = valueScale(endVal);
@@ -5818,8 +6037,8 @@ function computeRectMarks(spec, scales, _chartArea) {
5818
6037
  if (xVal == null || yVal == null) continue;
5819
6038
  let fill;
5820
6039
  if (colorEnc && isSequential) {
5821
- const value = Number(row[colorEnc.field]);
5822
- if (!Number.isFinite(value)) continue;
6040
+ const value = finiteNumber(row[colorEnc.field]);
6041
+ if (value === void 0) continue;
5823
6042
  fill = getSequentialColor(scales, value);
5824
6043
  } else if (colorEnc) {
5825
6044
  fill = getColor(scales, String(row[colorEnc.field] ?? "__default__"));
@@ -5950,6 +6169,7 @@ var DEFAULT_POINT_RADIUS2 = 5;
5950
6169
  var POINT_STROKE_WIDTH = 1.5;
5951
6170
  var DENSE_LAYER_POINTS = 200;
5952
6171
  function resolvePosition2(value, channelType, scale) {
6172
+ if (value == null) return void 0;
5953
6173
  switch (channelType) {
5954
6174
  case "nominal":
5955
6175
  case "ordinal": {
@@ -5967,9 +6187,10 @@ function resolvePosition2(value, channelType, scale) {
5967
6187
  return Number.isNaN(px) ? void 0 : px;
5968
6188
  }
5969
6189
  default: {
5970
- const num = Number(value);
5971
- if (!Number.isFinite(num)) return void 0;
5972
- return scale(num);
6190
+ const number = finiteNumber(value);
6191
+ if (number === void 0) return void 0;
6192
+ const position = scale(number);
6193
+ return Number.isFinite(position) ? position : void 0;
5973
6194
  }
5974
6195
  }
5975
6196
  }
@@ -6007,20 +6228,20 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy, theme) {
6007
6228
  const category = colorField && !isSequentialColor ? String(row[colorField] ?? "") : void 0;
6008
6229
  let color;
6009
6230
  if (isSequentialColor && colorField) {
6010
- const val = Number(row[colorField]);
6011
- color = Number.isFinite(val) ? getSequentialColor(scales, val) : getColor(scales, "__default__");
6231
+ const value = finiteNumber(row[colorField]);
6232
+ color = value !== void 0 ? getSequentialColor(scales, value) : getColor(scales, "__default__");
6012
6233
  } else if (colorField) {
6013
6234
  color = getColor(scales, category ?? "__default__");
6014
6235
  } else {
6015
6236
  color = constFill ?? getColor(scales, "__default__");
6016
6237
  }
6017
6238
  let radius = constSize != null ? constSize : DEFAULT_POINT_RADIUS2;
6018
- if (sizeScale && sizeField) {
6019
- const sizeVal = Number(row[sizeField]);
6020
- if (Number.isFinite(sizeVal)) {
6021
- radius = sizeScale(sizeVal);
6022
- }
6239
+ if (sizeField) {
6240
+ const sizeValue = finiteNumber(row[sizeField]);
6241
+ if (sizeValue === void 0) continue;
6242
+ if (sizeScale) radius = sizeScale(sizeValue);
6023
6243
  }
6244
+ if (!Number.isFinite(radius) || radius < 0) continue;
6024
6245
  const labelParts = [`${xChannel.field}=${rawX}`, `${yChannel.field}=${rawY}`];
6025
6246
  if (category) labelParts.push(`${colorField}=${category}`);
6026
6247
  if (sizeField && row[sizeField] != null) {
@@ -6291,8 +6512,8 @@ function computeWaffleMarks(spec, scales, chartArea, _strategy) {
6291
6512
  const categoryRows = /* @__PURE__ */ new Map();
6292
6513
  for (const row of spec.data) {
6293
6514
  const cat = String(row[colorField] ?? "");
6294
- const val = Number(row[valueChannel.field] ?? 0);
6295
- if (!Number.isFinite(val) || val < 0) continue;
6515
+ const val = finiteNumber(row[valueChannel.field]);
6516
+ if (val === void 0 || val < 0) continue;
6296
6517
  categoryTotals.set(cat, (categoryTotals.get(cat) ?? 0) + val);
6297
6518
  if (!categoryRows.has(cat)) {
6298
6519
  categoryRows.set(cat, row);
@@ -6467,7 +6688,16 @@ function computeAggregate(op, values) {
6467
6688
  }
6468
6689
  }
6469
6690
  function groupKey(row, groupby) {
6470
- return groupby.map((f) => String(row[f] ?? "")).join("\0");
6691
+ return JSON.stringify(groupby.map((field) => [typeof row[field], row[field] ?? null]));
6692
+ }
6693
+ function aggregateRows(op, field, rows) {
6694
+ if (op === "count") return rows.length;
6695
+ if (op === "distinct") return new Set(rows.map((row) => row[field])).size;
6696
+ const values = rows.flatMap((row) => {
6697
+ const value = finiteNumber(row[field]);
6698
+ return value === void 0 ? [] : [value];
6699
+ });
6700
+ return values.length ? computeAggregate(op, values) : null;
6471
6701
  }
6472
6702
  function runAggregate(data, transform) {
6473
6703
  const { aggregate, groupby } = transform;
@@ -6488,16 +6718,7 @@ function runAggregate(data, transform) {
6488
6718
  outRow[field] = rows[0][field];
6489
6719
  }
6490
6720
  for (const agg of aggregate) {
6491
- if (agg.op === "distinct") {
6492
- outRow[agg.as] = new Set(rows.map((r) => r[agg.field])).size;
6493
- continue;
6494
- }
6495
- const values = rows.map((r) => {
6496
- if (agg.op === "count") return 1;
6497
- const v = Number(r[agg.field]);
6498
- return Number.isFinite(v) ? v : NaN;
6499
- }).filter((v) => !Number.isNaN(v));
6500
- outRow[agg.as] = computeAggregate(agg.op, values);
6721
+ outRow[agg.as] = aggregateRows(agg.op, agg.field, rows);
6501
6722
  }
6502
6723
  result.push(outRow);
6503
6724
  }
@@ -7126,6 +7347,7 @@ function emitSpecWarnings(warnings, sink = defaultWarnSink) {
7126
7347
  }
7127
7348
 
7128
7349
  // src/compiler/animation.ts
7350
+ import { FLUID_SPRING, springDuration } from "@opendata-ai/openchart-core";
7129
7351
  var ENTER_DEFAULTS = {
7130
7352
  duration: 450,
7131
7353
  ease: "smooth",
@@ -7156,10 +7378,16 @@ function resolveAnimation(spec) {
7156
7378
  annotationDelay: DEFAULT_ANNOTATION_DELAY
7157
7379
  };
7158
7380
  }
7159
- const config = spec;
7160
- const enter = resolvePhase(config.enter, ENTER_DEFAULTS);
7161
- const update = resolvePhase(config.update, UPDATE_DEFAULTS);
7162
- const exit = resolvePhase(config.exit, EXIT_DEFAULTS);
7381
+ const config = typeof spec === "string" ? { preset: spec } : spec;
7382
+ const preset = config.preset ?? "legacy";
7383
+ const spring = preset === "fluid" ? { ...FLUID_SPRING } : void 0;
7384
+ const base = (defaults) => preset === "legacy" ? defaults : { ...defaults, duration: spring ? springDuration(spring) : 250, spring, staggerDelay: 0 };
7385
+ const enter = resolvePhase(config.enter, base(ENTER_DEFAULTS));
7386
+ const update = resolvePhase(config.update, base(UPDATE_DEFAULTS));
7387
+ const exit = resolvePhase(
7388
+ config.exit,
7389
+ preset === "legacy" ? EXIT_DEFAULTS : { ...EXIT_DEFAULTS, duration: 160 }
7390
+ );
7163
7391
  if (update && config.update && typeof config.update === "object" && !Array.isArray(config.update)) {
7164
7392
  const cap = config.update.maxMarks;
7165
7393
  if (typeof cap === "number" && Number.isFinite(cap) && cap >= 1) {
@@ -7171,7 +7399,7 @@ function resolveAnimation(spec) {
7171
7399
  enter: enter ?? void 0,
7172
7400
  update: update ?? void 0,
7173
7401
  exit: exit ?? void 0,
7174
- annotationDelay: config.annotationDelay ?? DEFAULT_ANNOTATION_DELAY
7402
+ annotationDelay: config.annotationDelay ?? (preset === "legacy" ? DEFAULT_ANNOTATION_DELAY : 0)
7175
7403
  };
7176
7404
  }
7177
7405
  function clampStaggerDelay(delay, elementCount) {
@@ -7184,9 +7412,14 @@ function resolvePhase(phase, defaults) {
7184
7412
  return { ...defaults };
7185
7413
  }
7186
7414
  const cfg = phase;
7415
+ if (cfg.spring && (cfg.duration !== void 0 || cfg.ease !== void 0)) {
7416
+ throw new Error("An animation phase cannot combine spring with duration or ease.");
7417
+ }
7418
+ const spring = cfg.spring ?? (cfg.duration !== void 0 || cfg.ease !== void 0 ? void 0 : defaults.spring);
7187
7419
  const stagger = resolveStagger(cfg.stagger, defaults);
7188
7420
  return {
7189
- duration: cfg.duration ?? defaults.duration,
7421
+ duration: spring ? springDuration(spring) : cfg.duration ?? defaults.duration,
7422
+ spring,
7190
7423
  ease: cfg.ease ?? defaults.ease,
7191
7424
  staggerDelay: stagger.delay,
7192
7425
  staggerOrder: stagger.order
@@ -9108,6 +9341,35 @@ function validateTileMapSpec(spec, errors) {
9108
9341
  });
9109
9342
  }
9110
9343
  }
9344
+ function validateGeoMapZoom(zoom, errors) {
9345
+ if (zoom === void 0 || typeof zoom === "boolean") return;
9346
+ if (typeof zoom !== "object" || zoom === null || Array.isArray(zoom)) {
9347
+ errors.push({
9348
+ message: "Spec error: geo.zoom must be a boolean or an object { maxZoom?, controls? }",
9349
+ path: "geo.zoom",
9350
+ code: "INVALID_TYPE",
9351
+ suggestion: "Use zoom: true, or zoom: { maxZoom: 8, controls: true }"
9352
+ });
9353
+ return;
9354
+ }
9355
+ const cfg = zoom;
9356
+ if (cfg.maxZoom !== void 0 && (typeof cfg.maxZoom !== "number" || !Number.isFinite(cfg.maxZoom) || cfg.maxZoom < 1)) {
9357
+ errors.push({
9358
+ message: "Spec error: geo.zoom.maxZoom must be a number >= 1",
9359
+ path: "geo.zoom.maxZoom",
9360
+ code: "INVALID_VALUE",
9361
+ suggestion: "maxZoom is a multiple of the full map, e.g. maxZoom: 8. The default is 12."
9362
+ });
9363
+ }
9364
+ if (cfg.controls !== void 0 && typeof cfg.controls !== "boolean") {
9365
+ errors.push({
9366
+ message: "Spec error: geo.zoom.controls must be a boolean",
9367
+ path: "geo.zoom.controls",
9368
+ code: "INVALID_TYPE",
9369
+ suggestion: "Use controls: false to hide the zoom buttons"
9370
+ });
9371
+ }
9372
+ }
9111
9373
  function validateGeoMapSpec(spec, errors) {
9112
9374
  if (!spec.geo || typeof spec.geo !== "object") {
9113
9375
  errors.push({
@@ -9228,6 +9490,7 @@ function validateGeoMapSpec(spec, errors) {
9228
9490
  });
9229
9491
  }
9230
9492
  }
9493
+ validateGeoMapZoom(geo.zoom, errors);
9231
9494
  const encoding = spec.encoding;
9232
9495
  const hasPoints = spec.points && typeof spec.points === "object" && !Array.isArray(spec.points);
9233
9496
  const specData = spec.data;
@@ -9490,7 +9753,68 @@ function validateLayerSpec(spec, errors) {
9490
9753
  }
9491
9754
  function validateAnimation(spec, errors) {
9492
9755
  const animation = spec.animation;
9756
+ if (typeof animation === "string" && !["fluid", "subtle", "legacy"].includes(animation)) {
9757
+ errors.push({
9758
+ code: "INVALID_VALUE",
9759
+ suggestion: "Use a named preset or separate valid spring and tween phase settings.",
9760
+ path: "animation",
9761
+ message: "Unknown animation preset."
9762
+ });
9763
+ }
9493
9764
  if (!animation || typeof animation !== "object" || Array.isArray(animation)) return;
9765
+ const config = animation;
9766
+ if (config.preset !== void 0 && !["fluid", "subtle", "legacy"].includes(String(config.preset))) {
9767
+ errors.push({
9768
+ code: "INVALID_VALUE",
9769
+ suggestion: "Use a named preset or separate valid spring and tween phase settings.",
9770
+ path: "animation.preset",
9771
+ message: "Animation preset must be fluid, subtle, or legacy."
9772
+ });
9773
+ }
9774
+ for (const phase of ["enter", "update", "exit"]) {
9775
+ const value = config[phase];
9776
+ if (!value || typeof value !== "object") continue;
9777
+ const settings = value;
9778
+ if (settings.spring !== void 0) {
9779
+ if (settings.duration !== void 0 || settings.ease !== void 0) {
9780
+ errors.push({
9781
+ code: "INVALID_VALUE",
9782
+ suggestion: "Use a named preset or separate valid spring and tween phase settings.",
9783
+ path: `animation.${phase}`,
9784
+ message: "A phase cannot combine spring with duration or ease."
9785
+ });
9786
+ }
9787
+ if (!settings.spring || typeof settings.spring !== "object" || Array.isArray(settings.spring)) {
9788
+ errors.push({
9789
+ code: "INVALID_VALUE",
9790
+ suggestion: "Use a named preset or separate valid spring and tween phase settings.",
9791
+ path: `animation.${phase}.spring`,
9792
+ message: "Spring must be a configuration object."
9793
+ });
9794
+ } else {
9795
+ for (const [key, number] of Object.entries(settings.spring)) {
9796
+ const known = ["stiffness", "damping", "mass"].includes(key);
9797
+ if (known && number === void 0) continue;
9798
+ if (!known || typeof number !== "number" || !Number.isFinite(number) || number <= 0) {
9799
+ errors.push({
9800
+ code: "INVALID_VALUE",
9801
+ suggestion: "Use a named preset or separate valid spring and tween phase settings.",
9802
+ path: `animation.${phase}.spring.${key}`,
9803
+ message: "Spring parameters must be finite positive stiffness, damping, or mass."
9804
+ });
9805
+ }
9806
+ }
9807
+ }
9808
+ }
9809
+ if (settings.duration !== void 0 && (typeof settings.duration !== "number" || !Number.isFinite(settings.duration) || settings.duration < 0)) {
9810
+ errors.push({
9811
+ code: "INVALID_VALUE",
9812
+ suggestion: "Use a named preset or separate valid spring and tween phase settings.",
9813
+ path: `animation.${phase}.duration`,
9814
+ message: "Animation duration must be finite and nonnegative."
9815
+ });
9816
+ }
9817
+ }
9494
9818
  const update = animation.update;
9495
9819
  if (!update || typeof update !== "object" || Array.isArray(update)) return;
9496
9820
  const maxMarks = update.maxMarks;
@@ -9504,6 +9828,60 @@ function validateAnimation(spec, errors) {
9504
9828
  });
9505
9829
  }
9506
9830
  }
9831
+ function validateFieldMetadata(obj, errors) {
9832
+ if (obj.fields === void 0) return;
9833
+ const fail = (path, message) => errors.push({
9834
+ path,
9835
+ message,
9836
+ code: "INVALID_VALUE",
9837
+ suggestion: "Use string labels, units and field references, and documented metadata enum values."
9838
+ });
9839
+ if (!obj.fields || typeof obj.fields !== "object" || Array.isArray(obj.fields)) {
9840
+ fail("fields", "Field metadata must be an object keyed by field name.");
9841
+ return;
9842
+ }
9843
+ for (const [field, value] of Object.entries(obj.fields)) {
9844
+ const path = `fields.${field}`;
9845
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9846
+ fail(path, "Field metadata must be an object.");
9847
+ continue;
9848
+ }
9849
+ const metadata = value;
9850
+ for (const key of ["label", "unit", "measure", "provenance", "statusField"]) {
9851
+ if (metadata[key] !== void 0 && typeof metadata[key] !== "string")
9852
+ fail(`${path}.${key}`, `${key} must be a string.`);
9853
+ }
9854
+ for (const [key, values] of Object.entries({
9855
+ type: ["text", "number", "date"],
9856
+ role: ["identity"],
9857
+ representation: ["number", "percent", "proportion"]
9858
+ })) {
9859
+ if (metadata[key] !== void 0 && !values.includes(metadata[key]))
9860
+ fail(`${path}.${key}`, `${key} must be one of ${values.join(", ")}.`);
9861
+ }
9862
+ if (metadata.interval !== void 0) {
9863
+ const interval = metadata.interval;
9864
+ if (!interval || typeof interval !== "object" || Array.isArray(interval) || typeof interval.lower !== "string" || !interval.lower || typeof interval.upper !== "string" || !interval.upper) {
9865
+ fail(`${path}.interval`, "Interval lower and upper must name supplied numeric fields.");
9866
+ } else if (Array.isArray(obj.data)) {
9867
+ for (const [index, row] of obj.data.entries()) {
9868
+ if (!row || typeof row !== "object") continue;
9869
+ const lower = row[interval.lower];
9870
+ const upper = row[interval.upper];
9871
+ if ([lower, upper].some(
9872
+ (bound) => bound != null && (typeof bound !== "number" || !Number.isFinite(bound))
9873
+ ) || typeof lower === "number" && typeof upper === "number" && lower > upper) {
9874
+ fail(
9875
+ `${path}.interval`,
9876
+ `Row ${index + 1} must supply finite interval bounds with lower <= upper, or missing values.`
9877
+ );
9878
+ break;
9879
+ }
9880
+ }
9881
+ }
9882
+ }
9883
+ }
9884
+ }
9507
9885
  function validateSpec(spec) {
9508
9886
  const errors = [];
9509
9887
  if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
@@ -9520,6 +9898,31 @@ function validateSpec(spec) {
9520
9898
  };
9521
9899
  }
9522
9900
  const obj = spec;
9901
+ validateFieldMetadata(obj, errors);
9902
+ if (obj.chrome !== void 0) {
9903
+ const chrome = obj.chrome;
9904
+ if (!chrome || typeof chrome !== "object" || Array.isArray(chrome)) {
9905
+ errors.push({
9906
+ path: "chrome",
9907
+ code: "INVALID_TYPE",
9908
+ message: "Chrome must be an object of text elements.",
9909
+ suggestion: "Use string text or a text object with a string text property."
9910
+ });
9911
+ } else {
9912
+ for (const key of ["title", "subtitle", "source", "footer", "byline", "eyebrow"]) {
9913
+ const value = chrome[key];
9914
+ if (value === void 0) continue;
9915
+ if (typeof value !== "string" && (!value || typeof value !== "object" || Array.isArray(value) || typeof value.text !== "string")) {
9916
+ errors.push({
9917
+ path: `chrome.${key}`,
9918
+ code: "INVALID_TYPE",
9919
+ message: `${key} must be text or an object with a string text property.`,
9920
+ suggestion: "Preserve authored styling in the object and provide text as a string."
9921
+ });
9922
+ }
9923
+ }
9924
+ }
9925
+ }
9523
9926
  const hasLayer = "layer" in obj && Array.isArray(obj.layer);
9524
9927
  const hasMark = "mark" in obj;
9525
9928
  const isTable = obj.type === "table";
@@ -9741,10 +10144,7 @@ function compileBarList(spec, options) {
9741
10144
  const rowContentHeight = Math.max(barHeight, LABEL_FONT_SIZE7 * 1.4);
9742
10145
  const rowHeight = rowContentHeight + DEFAULT_ROW_GAP;
9743
10146
  const maxFittingRows = Math.max(1, Math.floor(fullArea.height / rowHeight));
9744
- const validRows = barlistSpec.data.filter((row) => {
9745
- const val = row[valueField];
9746
- return val !== null && val !== void 0 && !Number.isNaN(Number(val));
9747
- }).sort((a, b) => Number(b[valueField]) - Number(a[valueField])).slice(0, Math.min(barlistSpec.maxItems, maxFittingRows));
10147
+ const validRows = barlistSpec.data.filter((row) => finiteNumber(row[valueField]) !== void 0).sort((a, b) => Number(b[valueField]) - Number(a[valueField])).slice(0, Math.min(barlistSpec.maxItems, maxFittingRows));
9748
10148
  if (validRows.length === 0) {
9749
10149
  return emptyLayout2(chrome, theme, options, watermark);
9750
10150
  }
@@ -10140,7 +10540,9 @@ import {
10140
10540
  import {
10141
10541
  computeFieldFormatContext as computeFieldFormatContext2,
10142
10542
  defaultNumberFormatter as defaultNumberFormatter5,
10143
- resolveNumberFormatter as resolveNumberFormatter2
10543
+ estimateTextWidth as estimateTextWidth10,
10544
+ resolveNumberFormatter as resolveNumberFormatter2,
10545
+ resolveSchemeName as resolveSchemeName3
10144
10546
  } from "@opendata-ai/openchart-core";
10145
10547
  import { tickStep } from "d3-array";
10146
10548
  import { scaleQuantile } from "d3-scale";
@@ -10179,10 +10581,8 @@ function sampleRampColors(colors, k) {
10179
10581
  function numericFieldValues(spec, field) {
10180
10582
  const out = [];
10181
10583
  for (const row of spec.data) {
10182
- const v = row[field];
10183
- if (v == null) continue;
10184
- const num = typeof v === "number" ? v : Number(v);
10185
- if (Number.isFinite(num)) out.push(num);
10584
+ const value = finiteNumber(row[field]);
10585
+ if (value !== void 0) out.push(value);
10186
10586
  }
10187
10587
  return out;
10188
10588
  }
@@ -10205,6 +10605,10 @@ function isDivergingRamp(colors, theme) {
10205
10605
  function resolveRampColors(channel, theme) {
10206
10606
  const explicitRange = channel.scale?.range;
10207
10607
  if (explicitRange?.length) return explicitRange;
10608
+ if (channel.scale?.scheme) {
10609
+ const scheme = resolveSchemeName3(channel.scale.scheme);
10610
+ if (scheme) return channel.scale.reverse ? [...scheme].reverse() : scheme;
10611
+ }
10208
10612
  return Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
10209
10613
  }
10210
10614
  function resolveBarWidth(availableWidth) {
@@ -10326,6 +10730,22 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
10326
10730
  x: (i + 1) * binWidth,
10327
10731
  anchor: "middle"
10328
10732
  }));
10733
+ const rowEnds = [];
10734
+ const rowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
10735
+ for (const tick of ticks2) {
10736
+ const width = estimateTextWidth10(
10737
+ tick.label,
10738
+ theme.fonts.sizes.small,
10739
+ theme.fonts.weights.normal
10740
+ );
10741
+ if (tick.x - width / 2 < 0) tick.anchor = "start";
10742
+ else if (tick.x + width / 2 > barWidth) tick.anchor = "end";
10743
+ const left = tick.x - (tick.anchor === "middle" ? width / 2 : tick.anchor === "end" ? width : 0);
10744
+ let row = rowEnds.findIndex((end) => end + 6 <= left);
10745
+ if (row < 0) row = rowEnds.length;
10746
+ rowEnds[row] = left + width;
10747
+ if (row > 0) tick.offsetY = row * rowHeight;
10748
+ }
10329
10749
  return {
10330
10750
  mode: "binned",
10331
10751
  barWidth,
@@ -10333,6 +10753,7 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
10333
10753
  colorStops: [],
10334
10754
  bins,
10335
10755
  ticks: ticks2,
10756
+ labelRows: Math.max(1, rowEnds.length),
10336
10757
  classScale: scale
10337
10758
  };
10338
10759
  }
@@ -10568,7 +10989,7 @@ import {
10568
10989
  BREAKPOINT_COMPACT_MAX as BREAKPOINT_COMPACT_MAX2,
10569
10990
  computeFieldFormatContext as computeFieldFormatContext5,
10570
10991
  defaultNumberFormatter as defaultNumberFormatter7,
10571
- estimateTextWidth as estimateTextWidth17,
10992
+ estimateTextWidth as estimateTextWidth18,
10572
10993
  resolveNumberFormatter as resolveNumberFormatter5,
10573
10994
  resolveTheme as resolveTheme2,
10574
10995
  TICK_LABEL_OFFSET
@@ -10586,7 +11007,7 @@ import {
10586
11007
  computeFieldFormatContext as computeFieldFormatContext4,
10587
11008
  computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
10588
11009
  defaultNumberFormatter as defaultNumberFormatter6,
10589
- estimateTextWidth as estimateTextWidth16,
11010
+ estimateTextWidth as estimateTextWidth17,
10590
11011
  getHeightClass,
10591
11012
  HPAD_COMPACT_FRACTION,
10592
11013
  HPAD_COMPACT_MIN,
@@ -10603,7 +11024,7 @@ import {
10603
11024
  } from "@opendata-ai/openchart-core";
10604
11025
 
10605
11026
  // src/endpoint-labels/predict.ts
10606
- import { estimateTextWidth as estimateTextWidth10, wrapText as wrapText2 } from "@opendata-ai/openchart-core";
11027
+ import { estimateTextWidth as estimateTextWidth11, wrapText as wrapText2 } from "@opendata-ai/openchart-core";
10607
11028
  function predictEndpointLabelsWidth(spec, _theme) {
10608
11029
  if (spec.endpointLabels === false) return 0;
10609
11030
  if (spec.markType !== "line" && spec.markType !== "area") return 0;
@@ -10648,12 +11069,12 @@ function predictEndpointLabelsWidth(spec, _theme) {
10648
11069
  const text = content ? composeEndpointText(content, name, sample) : name;
10649
11070
  const lines = wrapText2(text, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
10650
11071
  for (const line4 of lines) {
10651
- const w = estimateTextWidth10(line4, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
11072
+ const w = estimateTextWidth11(line4, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
10652
11073
  if (w > maxWidth) maxWidth = w;
10653
11074
  }
10654
11075
  }
10655
11076
  if (!content && sample) {
10656
- const w = estimateTextWidth10(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
11077
+ const w = estimateTextWidth11(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
10657
11078
  if (w > maxWidth) maxWidth = w;
10658
11079
  }
10659
11080
  return maxWidth;
@@ -10670,7 +11091,7 @@ function isEndsBoth(spec) {
10670
11091
  }
10671
11092
 
10672
11093
  // src/legend/compute.ts
10673
- import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH as COMPACT_WIDTH2, estimateTextWidth as estimateTextWidth12 } from "@opendata-ai/openchart-core";
11094
+ import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH as COMPACT_WIDTH2, estimateTextWidth as estimateTextWidth13 } from "@opendata-ai/openchart-core";
10674
11095
 
10675
11096
  // src/layout/scales.ts
10676
11097
  import { computeFieldFormatContext as computeFieldFormatContext3 } from "@opendata-ai/openchart-core";
@@ -10692,11 +11113,9 @@ import {
10692
11113
  } from "d3-scale";
10693
11114
  function numericFieldValues2(data, field) {
10694
11115
  const result = [];
10695
- for (const d of data) {
10696
- const v = d[field];
10697
- if (v == null) continue;
10698
- const n = typeof v === "number" ? v : Number(v);
10699
- if (Number.isFinite(n)) result.push(n);
11116
+ for (const row of data) {
11117
+ const value = finiteNumber(row[field]);
11118
+ if (value !== void 0) result.push(value);
10700
11119
  }
10701
11120
  return result;
10702
11121
  }
@@ -11096,8 +11515,8 @@ function computeScales(spec, chartArea, data) {
11096
11515
  const sums = /* @__PURE__ */ new Map();
11097
11516
  for (const row of data) {
11098
11517
  const cat = String(row[yField] ?? "");
11099
- const val = Number(row[xField] ?? 0);
11100
- if (Number.isFinite(val) && val > 0) {
11518
+ const val = finiteNumber(row[xField]);
11519
+ if (val !== void 0 && val > 0) {
11101
11520
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11102
11521
  }
11103
11522
  }
@@ -11118,8 +11537,8 @@ function computeScales(spec, chartArea, data) {
11118
11537
  const sums = /* @__PURE__ */ new Map();
11119
11538
  for (const row of data) {
11120
11539
  const cat = String(row[yField] ?? "");
11121
- const val = Number(row[xField] ?? 0);
11122
- if (Number.isFinite(val) && val > 0) {
11540
+ const val = finiteNumber(row[xField]);
11541
+ if (val !== void 0 && val > 0) {
11123
11542
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11124
11543
  }
11125
11544
  }
@@ -11183,8 +11602,8 @@ function computeScales(spec, chartArea, data) {
11183
11602
  const sums = /* @__PURE__ */ new Map();
11184
11603
  for (const row of data) {
11185
11604
  const cat = String(row[xField] ?? "");
11186
- const val = Number(row[yField] ?? 0);
11187
- if (Number.isFinite(val) && val > 0) {
11605
+ const val = finiteNumber(row[yField]);
11606
+ if (val !== void 0 && val > 0) {
11188
11607
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11189
11608
  }
11190
11609
  }
@@ -11205,8 +11624,8 @@ function computeScales(spec, chartArea, data) {
11205
11624
  const sums = /* @__PURE__ */ new Map();
11206
11625
  for (const row of data) {
11207
11626
  const cat = String(row[xField] ?? "");
11208
- const val = Number(row[yField] ?? 0);
11209
- if (Number.isFinite(val) && val > 0) {
11627
+ const val = finiteNumber(row[yField]);
11628
+ if (val !== void 0 && val > 0) {
11210
11629
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11211
11630
  }
11212
11631
  }
@@ -11260,7 +11679,7 @@ function computeScales(spec, chartArea, data) {
11260
11679
  }
11261
11680
 
11262
11681
  // src/legend/wrap.ts
11263
- import { COMPACT_WIDTH, estimateTextWidth as estimateTextWidth11 } from "@opendata-ai/openchart-core";
11682
+ import { COMPACT_WIDTH, estimateTextWidth as estimateTextWidth12 } from "@opendata-ai/openchart-core";
11264
11683
  var SWATCH_SIZE = 12;
11265
11684
  var SWATCH_GAP = 6;
11266
11685
  var ENTRY_GAP = 16;
@@ -11274,7 +11693,7 @@ function measureLegendWrap(entries, maxWidth, labelStyle, maxRows, entryGap = EN
11274
11693
  if (entries.length === 0) {
11275
11694
  return { rowCount: 0, fittingCount: 0, rowWidths: [], placements: [] };
11276
11695
  }
11277
- const measureWidth = measure ?? estimateTextWidth11;
11696
+ const measureWidth = measure ?? estimateTextWidth12;
11278
11697
  let rowCount = 1;
11279
11698
  let rowWidth = 0;
11280
11699
  const rowWidths = [];
@@ -11420,7 +11839,7 @@ function emptyContent(position, theme) {
11420
11839
  };
11421
11840
  }
11422
11841
  function computeLegendContent(spec, strategy, theme, availableWidth, availableHeight, watermark = true, measure, endpointLabelsDemoted) {
11423
- const measureWidth = measure ?? estimateTextWidth12;
11842
+ const measureWidth = measure ?? estimateTextWidth13;
11424
11843
  const sparklineHidden = spec.display === "sparkline" && !spec.userExplicit.legend;
11425
11844
  if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
11426
11845
  return emptyContent("top", theme);
@@ -11441,7 +11860,7 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
11441
11860
  fontVariant: "tabular-nums"
11442
11861
  };
11443
11862
  const labelRowHeight = Math.ceil(labelStyle2.fontSize * labelStyle2.lineHeight);
11444
- const height = compact ? Math.max(continuous.barHeight, labelRowHeight) : continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
11863
+ const height = compact ? Math.max(continuous.barHeight, labelRowHeight) : continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight * (continuous.labelRows ?? 1);
11445
11864
  const blockWidth = continuous.blockWidth ?? continuous.barWidth;
11446
11865
  return {
11447
11866
  entries: [],
@@ -11698,7 +12117,7 @@ function placeLegend(content, chartArea, containerWidth, _theme, xAxisHeight, ax
11698
12117
  import {
11699
12118
  abbreviateNumber,
11700
12119
  buildD3Formatter,
11701
- estimateTextWidth as estimateTextWidth13,
12120
+ estimateTextWidth as estimateTextWidth14,
11702
12121
  formatNumber as formatNumber3
11703
12122
  } from "@opendata-ai/openchart-core";
11704
12123
  import { scaleLinear as scaleLinear3, scaleSqrt as scaleSqrt3 } from "d3-scale";
@@ -11814,11 +12233,11 @@ function computeSizeLegendContent(sizeEncoding, data, theme, options) {
11814
12233
  };
11815
12234
  });
11816
12235
  const widestLabel = Math.max(
11817
- ...circles.map((c) => estimateTextWidth13(c.label, fontSize, theme.fonts.weights.normal))
12236
+ ...circles.map((c) => estimateTextWidth14(c.label, fontSize, theme.fonts.weights.normal))
11818
12237
  );
11819
12238
  const lastLabelBottom = (circles[circles.length - 1]?.labelY ?? 0) + fontSize * 0.5;
11820
12239
  const circleBlockWidth = maxRadius * 2 + LABEL_GAP + widestLabel;
11821
- const titleWidth = title ? estimateTextWidth13(title, fontSize, theme.fonts.weights.medium) : 0;
12240
+ const titleWidth = title ? estimateTextWidth14(title, fontSize, theme.fonts.weights.medium) : 0;
11822
12241
  return {
11823
12242
  circles,
11824
12243
  width: Math.max(circleBlockWidth, titleWidth),
@@ -11862,7 +12281,7 @@ function placeSizeLegend(content, chartArea, theme, colorLegend) {
11862
12281
  import {
11863
12282
  axisTitleOffset,
11864
12283
  computeXAxisExtentFromLabels,
11865
- estimateTextWidth as estimateTextWidth15,
12284
+ estimateTextWidth as estimateTextWidth16,
11866
12285
  truncateRotatedLabel,
11867
12286
  X_AXIS_TITLE_BAND,
11868
12287
  X_AXIS_TITLE_BAND_ROTATED
@@ -11907,11 +12326,11 @@ function bandLabelStride(maxLabelWidth, angleDeg, fontSize, spacing) {
11907
12326
  }
11908
12327
 
11909
12328
  // src/layout/axes/thinning.ts
11910
- import { estimateTextWidth as estimateTextWidth14 } from "@opendata-ai/openchart-core";
12329
+ import { estimateTextWidth as estimateTextWidth15 } from "@opendata-ai/openchart-core";
11911
12330
  var MIN_TICK_GAP_FACTOR = 0.5;
11912
12331
  var MIN_TICK_COUNT = 2;
11913
12332
  function measureLabel(text, fontSize, fontWeight, measureText) {
11914
- return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth14(text, fontSize, fontWeight);
12333
+ return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth15(text, fontSize, fontWeight);
11915
12334
  }
11916
12335
  function ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation = "horizontal") {
11917
12336
  if (ticks.length < 2) return false;
@@ -12388,6 +12807,35 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12388
12807
  if (isContinuousX && !hasExplicitValues && dataContext?.maxXTicks != null) {
12389
12808
  ticks = capTickCount(ticks, dataContext.maxXTicks);
12390
12809
  }
12810
+ if (dataContext?.preserveEnd && !hasExplicitValues && (scales.x.type === "time" || scales.x.type === "utc")) {
12811
+ const scale = scales.x.scale;
12812
+ const domain = scale.domain();
12813
+ const value = domain[domain.length - 1];
12814
+ const boundary = {
12815
+ value,
12816
+ position: scale(value),
12817
+ label: formatTickLabel(value, scales.x)
12818
+ };
12819
+ if (Number.isFinite(boundary.position)) {
12820
+ ticks = ticks.filter(
12821
+ (tick) => Number(tick.value) !== Number(value) && tick.label !== boundary.label
12822
+ );
12823
+ ticks.push(boundary);
12824
+ ticks.sort((a, b) => a.position - b.position);
12825
+ ticks = thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
12826
+ }
12827
+ }
12828
+ if (dataContext?.preserveEnd && !hasExplicitValues && !isContinuousX) {
12829
+ const domain = scales.x.scale.domain();
12830
+ const value = domain[domain.length - 1];
12831
+ const boundary = resolveExplicitTicks([value], scales.x)[0];
12832
+ if (boundary) {
12833
+ ticks = ticks.filter((tick) => tick.label !== boundary.label);
12834
+ ticks.push(boundary);
12835
+ ticks.sort((a, b) => a.position - b.position);
12836
+ ticks = thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
12837
+ }
12838
+ }
12391
12839
  if (tickAngle && Math.abs(tickAngle) > 10) {
12392
12840
  const measureWidth = (text, size, weight) => measureLabel(text, size, weight ?? fontWeight, measureText);
12393
12841
  ticks = ticks.map((t) => {
@@ -12537,7 +12985,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12537
12985
  const isRight = result.y.orient === "right";
12538
12986
  if (isRight) {
12539
12987
  const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12540
- const w = estimateTextWidth15(
12988
+ const w = estimateTextWidth16(
12541
12989
  t.label,
12542
12990
  result.y.tickLabelStyle.fontSize,
12543
12991
  result.y.tickLabelStyle.fontWeight ?? 400
@@ -12552,7 +13000,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12552
13000
  };
12553
13001
  } else {
12554
13002
  const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12555
- const w = estimateTextWidth15(
13003
+ const w = estimateTextWidth16(
12556
13004
  t.label,
12557
13005
  result.y.tickLabelStyle.fontSize,
12558
13006
  result.y.tickLabelStyle.fontWeight ?? 400
@@ -12580,7 +13028,7 @@ function createMeasureFn(measureText) {
12580
13028
  if (measureText) {
12581
13029
  return (text, fontSize, fontWeight) => measureText(text, fontSize, fontWeight).width;
12582
13030
  }
12583
- return estimateTextWidth16;
13031
+ return estimateTextWidth17;
12584
13032
  }
12585
13033
  function sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx) {
12586
13034
  if (yAxisFormat) {
@@ -12945,6 +13393,19 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12945
13393
  }
12946
13394
 
12947
13395
  // src/compile/layer.ts
13396
+ function appendLayerMarks(layout, leafIndex, marks, tooltips, sourceMarks = layout.marks) {
13397
+ const scope = `layer:${leafIndex}:`;
13398
+ for (const [index, mark] of sourceMarks.entries()) {
13399
+ const content = layout.tooltipDescriptors.get(`${mark.type}-${index}`);
13400
+ if (content) tooltips.set(`${mark.type}-${marks.length}`, content);
13401
+ const key = `${scope}${mark.type}:${mark.key !== void 0 ? `key:${mark.key}` : `index:${index}`}`;
13402
+ marks.push({
13403
+ ...mark,
13404
+ key,
13405
+ ...(mark.type === "line" || mark.type === "area") && mark.pointKeys ? { pointKeys: mark.pointKeys.map((pointKey) => `${scope}${pointKey}`) } : {}
13406
+ });
13407
+ }
13408
+ }
12948
13409
  function compileLayer(spec, options, compileChart2) {
12949
13410
  const leaves = flattenLayers(spec);
12950
13411
  if (leaves.length === 0) {
@@ -12952,7 +13413,11 @@ function compileLayer(spec, options, compileChart2) {
12952
13413
  }
12953
13414
  if (leaves.length === 1) {
12954
13415
  const singleSpec = buildPrimarySpec(leaves, spec);
12955
- return compileChart2(singleSpec, options);
13416
+ const layout = compileChart2(singleSpec, options);
13417
+ const marks = [];
13418
+ const tooltipDescriptors2 = /* @__PURE__ */ new Map();
13419
+ appendLayerMarks(layout, 0, marks, tooltipDescriptors2);
13420
+ return { ...layout, marks, tooltipDescriptors: tooltipDescriptors2 };
12956
13421
  }
12957
13422
  const layerOptions = { ...options, layered: true };
12958
13423
  if (spec.resolve?.scale?.y === "independent") {
@@ -12961,6 +13426,7 @@ function compileLayer(spec, options, compileChart2) {
12961
13426
  const primarySpec = buildPrimarySpec(leaves, spec);
12962
13427
  const primaryLayout = compileChart2(primarySpec, layerOptions);
12963
13428
  const allMarks = [];
13429
+ const tooltipDescriptors = /* @__PURE__ */ new Map();
12964
13430
  const seenLabels = /* @__PURE__ */ new Set();
12965
13431
  const pLegend = primaryLayout.legend;
12966
13432
  const mergedLegendEntries = "entries" in pLegend ? [...pLegend.entries] : [];
@@ -12969,19 +13435,20 @@ function compileLayer(spec, options, compileChart2) {
12969
13435
  }
12970
13436
  const indexedLeaves = leaves.map((leaf, i) => ({
12971
13437
  leaf,
13438
+ leafIndex: i,
12972
13439
  zIndex: leaf.zIndex ?? i
12973
13440
  }));
12974
13441
  indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
12975
13442
  const leafOptions = { ...layerOptions, frozenChartArea: primaryLayout.area };
12976
13443
  const sharedDomains = computeSharedDomains(leaves);
12977
- for (const { leaf } of indexedLeaves) {
13444
+ for (const { leaf, leafIndex } of indexedLeaves) {
12978
13445
  const themedLeaf = {
12979
13446
  ...withSharedDomains(leaf, sharedDomains),
12980
13447
  theme: leaf.theme ?? spec.theme,
12981
13448
  darkMode: leaf.darkMode ?? spec.darkMode
12982
13449
  };
12983
13450
  const leafLayout = compileChart2(themedLeaf, leafOptions);
12984
- allMarks.push(...leafLayout.marks);
13451
+ appendLayerMarks(leafLayout, leafIndex, allMarks, tooltipDescriptors);
12985
13452
  const leafLeg = leafLayout.legend;
12986
13453
  if ("entries" in leafLeg) {
12987
13454
  for (const entry of leafLeg.entries) {
@@ -12999,6 +13466,7 @@ function compileLayer(spec, options, compileChart2) {
12999
13466
  return {
13000
13467
  ...primaryLayout,
13001
13468
  marks: allMarks,
13469
+ tooltipDescriptors,
13002
13470
  legend: mergedLegend,
13003
13471
  // `legends` is what the renderer iterates, so the merged color legend has to
13004
13472
  // land in it too. Spreading `primaryLayout` alone would leave legends[0] as
@@ -13012,7 +13480,7 @@ function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
13012
13480
  const yEnc = encoding.y;
13013
13481
  const yField = yEnc.field;
13014
13482
  if (!yField) return 40;
13015
- const m = measure ?? estimateTextWidth17;
13483
+ const m = measure ?? estimateTextWidth18;
13016
13484
  const yType = yEnc.type;
13017
13485
  if (yType === "nominal" || yType === "ordinal") {
13018
13486
  let maxWidth = 0;
@@ -13202,20 +13670,17 @@ function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
13202
13670
  mergedLegendEntries.push(entry);
13203
13671
  }
13204
13672
  }
13205
- const l0Count = layout0.marks.length;
13206
- const mergedTooltips = new Map(layout0.tooltipDescriptors);
13207
- for (const [key, value] of layout1.tooltipDescriptors) {
13208
- const match = /^(rect|point|arc)-(\d+)$/.exec(key);
13209
- if (match) {
13210
- const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
13211
- mergedTooltips.set(offsetKey, value);
13212
- } else {
13213
- mergedTooltips.set(key, value);
13214
- }
13215
- }
13216
13673
  const z0 = leaf0.zIndex ?? 0;
13217
13674
  const z1 = leaf1.zIndex ?? 1;
13218
- const marks = z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
13675
+ const marks = [];
13676
+ const mergedTooltips = /* @__PURE__ */ new Map();
13677
+ const layers = [
13678
+ { layout: layout0, leafIndex: 0, sourceMarks: adjustedMarks0, zIndex: z0 },
13679
+ { layout: layout1, leafIndex: 1, sourceMarks: taggedMarks1, zIndex: z1 }
13680
+ ].sort((a, b) => a.zIndex - b.zIndex);
13681
+ for (const layer of layers) {
13682
+ appendLayerMarks(layer.layout, layer.leafIndex, marks, mergedTooltips, layer.sourceMarks);
13683
+ }
13219
13684
  const mergedLegend = {
13220
13685
  ...layout0.legend,
13221
13686
  ..."entries" in l0Legend ? { entries: mergedLegendEntries } : {}
@@ -13282,8 +13747,8 @@ function withUnionXDomain(leaf0, leaf1) {
13282
13747
  let min4 = Number.POSITIVE_INFINITY;
13283
13748
  let max4 = Number.NEGATIVE_INFINITY;
13284
13749
  for (const v of allValues) {
13285
- const n = typeof v === "number" ? v : Number(v);
13286
- if (!Number.isFinite(n)) continue;
13750
+ const n = finiteNumber(v);
13751
+ if (n === void 0) continue;
13287
13752
  if (n < min4) min4 = n;
13288
13753
  if (n > max4) max4 = n;
13289
13754
  }
@@ -13314,8 +13779,8 @@ function alignYDomains(leaf0, leaf1) {
13314
13779
  if (yEnc0.scale?.domain || yEnc1.scale?.domain) return void 0;
13315
13780
  const includeZero0 = yEnc0.scale?.zero !== false;
13316
13781
  const includeZero1 = yEnc1.scale?.zero !== false;
13317
- const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
13318
- const vals1 = leaf1.data.map((r) => Number(r[yEnc1.field])).filter(Number.isFinite);
13782
+ const vals0 = leaf0.data.map((row) => finiteNumber(row[yEnc0.field])).filter((value) => value !== void 0);
13783
+ const vals1 = leaf1.data.map((row) => finiteNumber(row[yEnc1.field])).filter((value) => value !== void 0);
13319
13784
  if (vals0.length === 0 || vals1.length === 0) return void 0;
13320
13785
  const niced = (vals, includeZero) => {
13321
13786
  let lo = Math.min(...vals);
@@ -13386,8 +13851,8 @@ function computeSharedDomains(leaves) {
13386
13851
  break;
13387
13852
  }
13388
13853
  for (const row of leaf.data ?? []) {
13389
- const v = Number(row[enc.field]);
13390
- if (!Number.isFinite(v)) continue;
13854
+ const v = finiteNumber(row[enc.field]);
13855
+ if (v === void 0) continue;
13391
13856
  if (v < lo) lo = v;
13392
13857
  if (v > hi) hi = v;
13393
13858
  sawAny = true;
@@ -13544,7 +14009,7 @@ function resolveYouDrawIt(config, marks, scales, chartArea, xField) {
13544
14009
  }
13545
14010
 
13546
14011
  // src/compiler/a11y-warnings.ts
13547
- import { contrastRatio, findAccessibleColor, isOpaqueColor } from "@opendata-ai/openchart-core";
14012
+ import { contrastRatio as contrastRatio2, findAccessibleColor, isOpaqueColor as isOpaqueColor2 } from "@opendata-ai/openchart-core";
13548
14013
  var GRAPHIC_MIN = 3;
13549
14014
  var TEXT_MIN = 4.5;
13550
14015
  var ADJACENT_FILL_MARKS = /* @__PURE__ */ new Set(["bar", "area", "arc", "waffle"]);
@@ -13552,7 +14017,7 @@ var fmt = (ratio) => `${ratio.toFixed(2)}:1`;
13552
14017
  function collectContrastWarnings(scales, markType, theme) {
13553
14018
  const warnings = [];
13554
14019
  const bg = theme.colors.background;
13555
- const bgIsCheckable = isOpaqueColor(bg);
14020
+ const bgIsCheckable = isOpaqueColor2(bg);
13556
14021
  const series = [];
13557
14022
  if (scales.color && scales.color.type !== "sequential") {
13558
14023
  const ordinal = scales.color.scale;
@@ -13564,7 +14029,7 @@ function collectContrastWarnings(scales, markType, theme) {
13564
14029
  for (let i = 0; i < series.length - 1; i++) {
13565
14030
  const a = series[i];
13566
14031
  const b = series[i + 1];
13567
- const ratio = contrastRatio(a.color, b.color);
14032
+ const ratio = contrastRatio2(a.color, b.color);
13568
14033
  if (ratio < GRAPHIC_MIN) {
13569
14034
  const suggestion = findAccessibleColor(b.color, a.color, GRAPHIC_MIN);
13570
14035
  warnings.push(
@@ -13575,7 +14040,7 @@ function collectContrastWarnings(scales, markType, theme) {
13575
14040
  }
13576
14041
  if (bgIsCheckable) {
13577
14042
  for (const s of series) {
13578
- const ratio = contrastRatio(s.color, bg);
14043
+ const ratio = contrastRatio2(s.color, bg);
13579
14044
  if (ratio < GRAPHIC_MIN) {
13580
14045
  const suggestion = findAccessibleColor(s.color, bg, GRAPHIC_MIN);
13581
14046
  warnings.push(
@@ -13588,7 +14053,7 @@ function collectContrastWarnings(scales, markType, theme) {
13588
14053
  { name: "theme.colors.annotationText", color: theme.colors.annotationText }
13589
14054
  ];
13590
14055
  for (const token of textTokens) {
13591
- const ratio = contrastRatio(token.color, bg);
14056
+ const ratio = contrastRatio2(token.color, bg);
13592
14057
  if (ratio < TEXT_MIN) {
13593
14058
  const suggestion = findAccessibleColor(token.color, bg, TEXT_MIN);
13594
14059
  warnings.push(
@@ -13703,16 +14168,14 @@ import {
13703
14168
  buildD3Formatter as buildD3Formatter2,
13704
14169
  CATEGORICAL_PALETTE as CATEGORICAL_PALETTE2,
13705
14170
  computeChrome as computeChrome3,
13706
- estimateTextWidth as estimateTextWidth18,
14171
+ estimateTextWidth as estimateTextWidth19,
13707
14172
  formatNumber as formatNumber4,
13708
14173
  getBreakpoint,
13709
14174
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION2,
13710
14175
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN2,
13711
- resolveTheme as resolveTheme3,
13712
- SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES2
14176
+ resolveTheme as resolveTheme3
13713
14177
  } from "@opendata-ai/openchart-core";
13714
14178
  import { geoArea, geoPath } from "d3-geo";
13715
- import { scaleQuantile as scaleQuantile3 } from "d3-scale";
13716
14179
  import { feature as topoFeature, mesh as topoMesh } from "topojson-client";
13717
14180
 
13718
14181
  // src/geo/join.ts
@@ -13746,25 +14209,50 @@ function suggestNormalization(unmatchedKeys, featureIds) {
13746
14209
  function joinDataToFeatures(features, data, dataKeyField, featureIdField) {
13747
14210
  const warnings = [];
13748
14211
  const dataLookup = /* @__PURE__ */ new Map();
14212
+ const duplicates = /* @__PURE__ */ new Set();
14213
+ const lowerCounts = /* @__PURE__ */ new Map();
14214
+ let missingKeys = 0;
13749
14215
  for (const row of data) {
13750
- const key = String(row[dataKeyField]);
14216
+ const value = row[dataKeyField];
14217
+ if (value == null || String(value).trim() === "") {
14218
+ missingKeys++;
14219
+ continue;
14220
+ }
14221
+ const key = String(value);
14222
+ if (dataLookup.has(key)) duplicates.add(key);
13751
14223
  dataLookup.set(key, row);
14224
+ lowerCounts.set(key.toLowerCase(), (lowerCounts.get(key.toLowerCase()) ?? 0) + 1);
13752
14225
  }
14226
+ if (duplicates.size)
14227
+ warnings.push({
14228
+ code: "DUPLICATE_DATA_KEYS",
14229
+ message: `${duplicates.size} duplicate map key(s) are ambiguous and will use neutral fill. Filter or aggregate to one observation per feature.`,
14230
+ context: { keys: [...duplicates] }
14231
+ });
14232
+ if (missingKeys)
14233
+ warnings.push({
14234
+ code: "MISSING_DATA_KEYS",
14235
+ message: `${missingKeys} data row(s) have no map key.`,
14236
+ context: { count: missingKeys }
14237
+ });
14238
+ const ambiguousFallbacks = /* @__PURE__ */ new Set();
13753
14239
  const joined = /* @__PURE__ */ new Map();
13754
14240
  const usedDataKeys = /* @__PURE__ */ new Set();
13755
14241
  let lowerLookup = null;
13756
14242
  let unmatchedFeatureCount = 0;
13757
14243
  for (const feature of features) {
13758
14244
  const featureId = featureIdField === "id" ? String(feature.id) : String(feature.properties[featureIdField] ?? feature.id);
13759
- let match = dataLookup.get(featureId);
13760
- if (!match) {
14245
+ let match = duplicates.has(featureId) ? void 0 : dataLookup.get(featureId);
14246
+ if (!match && !duplicates.has(featureId)) {
13761
14247
  if (!lowerLookup) {
13762
14248
  lowerLookup = /* @__PURE__ */ new Map();
13763
14249
  for (const [key, row] of dataLookup) {
13764
- lowerLookup.set(key.toLowerCase(), row);
14250
+ if (lowerCounts.get(key.toLowerCase()) === 1) lowerLookup.set(key.toLowerCase(), row);
13765
14251
  }
13766
14252
  }
13767
- match = lowerLookup.get(featureId.toLowerCase());
14253
+ const lower = featureId.toLowerCase();
14254
+ match = lowerLookup.get(lower);
14255
+ if (!match && (lowerCounts.get(lower) ?? 0) > 1) ambiguousFallbacks.add(featureId);
13768
14256
  }
13769
14257
  if (match) {
13770
14258
  joined.set(feature.id, match);
@@ -13773,6 +14261,12 @@ function joinDataToFeatures(features, data, dataKeyField, featureIdField) {
13773
14261
  unmatchedFeatureCount++;
13774
14262
  }
13775
14263
  }
14264
+ if (ambiguousFallbacks.size)
14265
+ warnings.push({
14266
+ code: "AMBIGUOUS_DATA_KEYS",
14267
+ message: `${ambiguousFallbacks.size} feature(s) match multiple case variants and will use neutral fill. Use exact, unique keys.`,
14268
+ context: { keys: [...ambiguousFallbacks] }
14269
+ });
13776
14270
  const unmatchedDataKeys = [];
13777
14271
  for (const key of dataLookup.keys()) {
13778
14272
  if (!usedDataKeys.has(key)) {
@@ -13918,10 +14412,10 @@ function categoricalLegendLabelStyle(theme) {
13918
14412
  };
13919
14413
  }
13920
14414
  function categoricalEntryWidth(label, labelStyle) {
13921
- return CATEGORICAL_SWATCH_SIZE + CATEGORICAL_SWATCH_GAP + estimateTextWidth18(label, labelStyle.fontSize, labelStyle.fontWeight) + CATEGORICAL_ENTRY_GAP;
14415
+ return CATEGORICAL_SWATCH_SIZE + CATEGORICAL_SWATCH_GAP + estimateTextWidth19(label, labelStyle.fontSize, labelStyle.fontWeight) + CATEGORICAL_ENTRY_GAP;
13922
14416
  }
13923
14417
  function noDataBlockWidth(labelStyle) {
13924
- return CATEGORICAL_SWATCH_SIZE + NO_DATA_LABEL_GAP + estimateTextWidth18("No data", labelStyle.fontSize, labelStyle.fontWeight);
14418
+ return CATEGORICAL_SWATCH_SIZE + NO_DATA_LABEL_GAP + estimateTextWidth19("No data", labelStyle.fontSize, labelStyle.fontWeight);
13925
14419
  }
13926
14420
  function wrapCategoricalEntries(widths, legendWidth) {
13927
14421
  const placed = [];
@@ -14049,11 +14543,8 @@ function compileGeoMap(spec, options) {
14049
14543
  if (colorEncoding && isQuantitative) {
14050
14544
  const colorValues = [];
14051
14545
  for (const row of mapSpec.data) {
14052
- const v = row[colorEncoding.field];
14053
- if (v != null) {
14054
- const n = Number(v);
14055
- if (!Number.isNaN(n)) colorValues.push(n);
14056
- }
14546
+ const value = finiteNumber(row[colorEncoding.field]);
14547
+ if (value !== void 0) colorValues.push(value);
14057
14548
  }
14058
14549
  const legendChannel = {
14059
14550
  ...colorEncoding,
@@ -14075,7 +14566,7 @@ function compileGeoMap(spec, options) {
14075
14566
  legendTitle = legendTitleFor(colorEncoding);
14076
14567
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14077
14568
  const titleRowHeight = legendTitle ? labelRowHeight : 0;
14078
- legendBlockHeight = titleRowHeight + continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
14569
+ legendBlockHeight = titleRowHeight + continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight * (continuousContent.labelRows ?? 1);
14079
14570
  }
14080
14571
  }
14081
14572
  if (legendBlockHeight === 0 && showChoroplethLegend && colorEncoding && !isQuantitative) {
@@ -14091,10 +14582,24 @@ function compileGeoMap(spec, options) {
14091
14582
  const rows = placed.length > 0 ? placed[placed.length - 1].row + 1 : 1;
14092
14583
  legendBlockHeight = rows * (Math.max(10, labelHeight) + 6);
14093
14584
  }
14585
+ const pointColor = mapSpec.points?.color;
14586
+ const pointColorChannel = pointColor?.type === "quantitative" ? { ...pointColor, scale: { ...pointColor.scale, type: "quantile" } } : void 0;
14587
+ const pointColorValues = pointColorChannel ? mapSpec.points.data.flatMap((row) => {
14588
+ const value = finiteNumber(row[pointColorChannel.field]);
14589
+ return value === void 0 ? [] : [value];
14590
+ }) : [];
14591
+ const pointClassScale = pointColorChannel && pointColorValues.length ? classScaleForChannel(pointColorValues, pointColorChannel, theme) : void 0;
14592
+ const pointContinuousContent = showLegend && pointColorChannel ? computeContinuousLegendContentForChannel(
14593
+ pointColorValues,
14594
+ pointColorChannel,
14595
+ theme,
14596
+ fullArea.width,
14597
+ pointClassScale
14598
+ ) : null;
14094
14599
  if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
14095
14600
  if (mapSpec.points.color.type === "quantitative") {
14096
14601
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14097
- legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
14602
+ legendBlockHeight += (pointContinuousContent?.barHeight ?? CONTINUOUS_BAR_HEIGHT) + CONTINUOUS_LABEL_GAP + labelRowHeight * (pointContinuousContent?.labelRows ?? 1) + 8;
14098
14603
  } else {
14099
14604
  const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14100
14605
  legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8;
@@ -14157,6 +14662,10 @@ function compileGeoMap(spec, options) {
14157
14662
  }
14158
14663
  const droppedFeatures = [];
14159
14664
  const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber4;
14665
+ const formatQuantitative = (value) => {
14666
+ const number = finiteNumber(value);
14667
+ return number === void 0 ? "No data" : formatter(number);
14668
+ };
14160
14669
  const neutralFill = theme.colors.neutral[100];
14161
14670
  const noDataPattern = {
14162
14671
  type: "diagonal",
@@ -14219,7 +14728,7 @@ function compileGeoMap(spec, options) {
14219
14728
  const labelY = bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize;
14220
14729
  const hasUnmatched = featureMarks.some((m) => m.noData);
14221
14730
  const noDataX = bar.x + bar.width + NO_DATA_SWATCH_GAP;
14222
- const noDataLabelWidth = estimateTextWidth18(
14731
+ const noDataLabelWidth = estimateTextWidth19(
14223
14732
  "No data",
14224
14733
  labelStyle.fontSize,
14225
14734
  labelStyle.fontWeight
@@ -14318,21 +14827,10 @@ function compileGeoMap(spec, options) {
14318
14827
  const pointCategoryColors = /* @__PURE__ */ new Map();
14319
14828
  if (pts.color) {
14320
14829
  if (pts.color.type === "quantitative") {
14321
- const colorValues = [];
14322
- for (const row of pts.data) {
14323
- const raw = row[pts.color.field];
14324
- if (raw != null) {
14325
- const v = Number(raw);
14326
- if (Number.isFinite(v)) colorValues.push(v);
14327
- }
14328
- }
14329
- if (colorValues.length > 0) {
14330
- const palette = pts.color.scale?.scheme ?? "blue";
14331
- const paletteStops = [...SEQUENTIAL_PALETTES2[palette] ?? SEQUENTIAL_PALETTES2.blue];
14332
- const qScale = scaleQuantile3().domain(colorValues).range(paletteStops);
14830
+ if (pointClassScale) {
14333
14831
  pointColorScale = (val) => {
14334
- const v = Number(val);
14335
- return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE2[0];
14832
+ const value = finiteNumber(val);
14833
+ return value !== void 0 ? pointClassScale.color(value) : neutralFill;
14336
14834
  };
14337
14835
  }
14338
14836
  } else {
@@ -14369,16 +14867,19 @@ function compileGeoMap(spec, options) {
14369
14867
  const droppedPoints = [];
14370
14868
  for (let i = 0; i < pts.data.length; i++) {
14371
14869
  const row = pts.data[i];
14372
- const lon = Number(row[lonField]);
14373
- const lat = Number(row[latField]);
14374
- if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
14870
+ const lon = finiteNumber(row[lonField]);
14871
+ const lat = finiteNumber(row[latField]);
14872
+ if (lon === void 0 || lat === void 0) continue;
14873
+ const sizeValue = pts.size ? finiteNumber(row[pts.size.field]) : void 0;
14874
+ if (pts.size && sizeValue === void 0) continue;
14375
14875
  const projected = projection([lon, lat]);
14376
- if (!projected) {
14876
+ if (!projected || !projected.every(Number.isFinite)) {
14377
14877
  droppedPoints.push([lon, lat]);
14378
14878
  continue;
14379
14879
  }
14380
- const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
14381
- const fill = pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
14880
+ const r = sizeScale && sizeValue !== void 0 ? sizeScale.scale(sizeValue) : 5;
14881
+ if (!Number.isFinite(r) || r < 0) continue;
14882
+ const fill = pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : pts.color?.type === "quantitative" ? neutralFill : defaultFill;
14382
14883
  const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
14383
14884
  const stroke = resolveKnockoutColor(theme);
14384
14885
  pointMarks.push({
@@ -14442,24 +14943,7 @@ function compileGeoMap(spec, options) {
14442
14943
  };
14443
14944
  }
14444
14945
  if (pts.color && pts.color.type === "quantitative" && showLegend) {
14445
- const ptColorValues = [];
14446
- for (const row of pts.data) {
14447
- const v = row[pts.color.field];
14448
- if (v != null) {
14449
- const n = Number(v);
14450
- if (Number.isFinite(n)) ptColorValues.push(n);
14451
- }
14452
- }
14453
- const ptLegendChannel = {
14454
- ...pts.color,
14455
- scale: { ...pts.color.scale, type: "quantile" }
14456
- };
14457
- const ptContinuousContent = computeContinuousLegendContentForChannel(
14458
- ptColorValues,
14459
- ptLegendChannel,
14460
- theme,
14461
- fullArea.width
14462
- );
14946
+ const ptContinuousContent = pointContinuousContent;
14463
14947
  if (ptContinuousContent) {
14464
14948
  const labelStyle = {
14465
14949
  fontFamily: theme.fonts.family,
@@ -14474,7 +14958,7 @@ function compileGeoMap(spec, options) {
14474
14958
  const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
14475
14959
  const legendY = pointLegendOverlay ? fullArea.y + OVERLAY_LEGEND_INSET : fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
14476
14960
  const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14477
- const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
14961
+ const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight * (ptContinuousContent.labelRows ?? 1);
14478
14962
  const bounds = {
14479
14963
  x: legendX,
14480
14964
  y: legendY,
@@ -14548,17 +15032,17 @@ function compileGeoMap(spec, options) {
14548
15032
  if (tooltipChannels) {
14549
15033
  for (const ch of tooltipChannels) {
14550
15034
  const val = mark.data[ch.field];
14551
- if (val != null) {
15035
+ if (val != null || ch.type === "quantitative") {
14552
15036
  fields.push({
14553
15037
  label: ch.title ?? ch.field,
14554
- value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
15038
+ value: ch.type === "quantitative" ? formatQuantitative(val) : String(val)
14555
15039
  });
14556
15040
  }
14557
15041
  }
14558
- } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
15042
+ } else if (colorEncoding && isQuantitative) {
14559
15043
  fields.push({
14560
15044
  label: colorEncoding.title ?? colorEncoding.field,
14561
- value: formatter(Number(mark.data[colorEncoding.field]))
15045
+ value: formatQuantitative(mark.data[colorEncoding.field])
14562
15046
  });
14563
15047
  } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
14564
15048
  fields.push({
@@ -14575,10 +15059,10 @@ function compileGeoMap(spec, options) {
14575
15059
  if (ptTooltipChannels) {
14576
15060
  for (const ch of ptTooltipChannels) {
14577
15061
  const val = pm.data[ch.field];
14578
- if (val != null) {
15062
+ if (val != null || ch.type === "quantitative") {
14579
15063
  fields.push({
14580
15064
  label: ch.title ?? ch.field,
14581
- value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
15065
+ value: ch.type === "quantitative" ? formatQuantitative(val) : String(val)
14582
15066
  });
14583
15067
  }
14584
15068
  }
@@ -14591,16 +15075,15 @@ function compileGeoMap(spec, options) {
14591
15075
  }
14592
15076
  if (pts.color) {
14593
15077
  const val = pm.data[pts.color.field];
14594
- if (val != null)
15078
+ if (val != null || pts.color.type === "quantitative")
14595
15079
  fields.push({
14596
15080
  label: pts.color.title ?? pts.color.field,
14597
- value: pts.color.type === "quantitative" ? formatter(Number(val)) : String(val)
15081
+ value: pts.color.type === "quantitative" ? formatQuantitative(val) : String(val)
14598
15082
  });
14599
15083
  }
14600
15084
  if (pts.size) {
14601
15085
  const val = pm.data[pts.size.field];
14602
- if (val != null)
14603
- fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
15086
+ fields.push({ label: pts.size.title ?? pts.size.field, value: formatQuantitative(val) });
14604
15087
  }
14605
15088
  }
14606
15089
  if (fields.length > 0) {
@@ -14658,7 +15141,7 @@ function compileGeoMap(spec, options) {
14658
15141
  height: contentHeight,
14659
15142
  animation: resolvedAnimation,
14660
15143
  watermark,
14661
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth18(text, fontSize), height: fontSize })),
15144
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth19(text, fontSize), height: fontSize })),
14662
15145
  warnings: compileWarnings,
14663
15146
  mapSize: { width: fullArea.width, height: mapAreaHeight },
14664
15147
  focus: resolvedFocus
@@ -14729,22 +15212,13 @@ function buildQuantitativeMarks(opts) {
14729
15212
  }
14730
15213
  const featureId = feat.id;
14731
15214
  const dataRow = joined.get(featureId) ?? null;
14732
- const hasData = dataRow !== null;
14733
15215
  let fill = neutralFill;
14734
15216
  let binIndex;
14735
- let hasValue = false;
14736
- if (hasData) {
14737
- const raw = dataRow[colorField];
14738
- if (raw != null) {
14739
- const v = Number(raw);
14740
- if (!Number.isNaN(v)) {
14741
- hasValue = true;
14742
- if (classScale) {
14743
- fill = classScale.color(v);
14744
- binIndex = classScale.binIndex(v);
14745
- }
14746
- }
14747
- }
15217
+ const value = finiteNumber(dataRow?.[colorField]);
15218
+ const hasValue = value !== void 0;
15219
+ if (value !== void 0 && classScale) {
15220
+ fill = classScale.color(value);
15221
+ binIndex = classScale.binIndex(value);
14748
15222
  }
14749
15223
  const props = feat.properties;
14750
15224
  const name = props?.name ?? props?.NAME;
@@ -14768,7 +15242,7 @@ function buildQuantitativeMarks(opts) {
14768
15242
  data: dataRow,
14769
15243
  aria: {
14770
15244
  role: "img",
14771
- label: name ? `${name}: ${hasData && dataRow[colorField] != null && !Number.isNaN(Number(dataRow[colorField])) ? formatter(Number(dataRow[colorField])) : "no data"}` : String(featureId)
15245
+ label: name ? `${name}: ${value !== void 0 ? formatter(value) : "no data"}` : String(featureId)
14772
15246
  },
14773
15247
  animationIndex: animIndex++,
14774
15248
  bounds,
@@ -15043,7 +15517,7 @@ function emptyLayout3(chrome, theme, options, watermark) {
15043
15517
  height: options.height,
15044
15518
  animation: void 0,
15045
15519
  watermark,
15046
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth18(text, fontSize), height: fontSize })),
15520
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth19(text, fontSize), height: fontSize })),
15047
15521
  warnings: [],
15048
15522
  mapSize: { width: 0, height: 0 },
15049
15523
  focus: null
@@ -15794,7 +16268,7 @@ import {
15794
16268
  computeChrome as computeChrome5,
15795
16269
  computeFieldFormatContext as computeFieldFormatContext6,
15796
16270
  defaultNumberFormatter as defaultNumberFormatter9,
15797
- estimateTextWidth as estimateTextWidth20,
16271
+ estimateTextWidth as estimateTextWidth21,
15798
16272
  getHeightClass as getHeightClass2,
15799
16273
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION3,
15800
16274
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN3,
@@ -15811,7 +16285,7 @@ import {
15811
16285
  } from "@opendata-ai/openchart-core";
15812
16286
 
15813
16287
  // src/layout/metrics.ts
15814
- import { estimateTextWidth as estimateTextWidth19 } from "@opendata-ai/openchart-core";
16288
+ import { estimateTextWidth as estimateTextWidth20 } from "@opendata-ai/openchart-core";
15815
16289
  var DEFAULT_LABEL_FONT_SIZE = 10;
15816
16290
  var DEFAULT_VALUE_FONT_SIZE = 22;
15817
16291
  var LABEL_LINE_HEIGHT_RATIO = 1.4;
@@ -15844,7 +16318,7 @@ function computeMetricBar(metrics, metricsTopY, metricsArea, remainingChartHeigh
15844
16318
  const cellWidth = metricsArea.width / metrics.length;
15845
16319
  for (const metric of metrics) {
15846
16320
  const text = valueRunText(metric);
15847
- const measured = measureText ? measureText(text, fonts.value, 510).width : estimateTextWidth19(text, fonts.value, 510);
16321
+ const measured = measureText ? measureText(text, fonts.value, 510).width : estimateTextWidth20(text, fonts.value, 510);
15848
16322
  if (measured > cellWidth - CELL_INNER_PAD) return void 0;
15849
16323
  }
15850
16324
  const labelLine = fonts.label * LABEL_LINE_HEIGHT_RATIO;
@@ -15979,7 +16453,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15979
16453
  if (xField) {
15980
16454
  for (const row of spec.data) {
15981
16455
  const label = String(row[xField] ?? "");
15982
- const w = estimateTextWidth20(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
16456
+ const w = estimateTextWidth21(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
15983
16457
  if (w > maxLabelWidth) maxLabelWidth = w;
15984
16458
  }
15985
16459
  }
@@ -16033,7 +16507,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16033
16507
  const label = String(row[colorField] ?? "");
16034
16508
  if (!seen.has(label)) {
16035
16509
  seen.add(label);
16036
- const w = estimateTextWidth20(label, 11, 600);
16510
+ const w = estimateTextWidth21(label, 11, 600);
16037
16511
  if (w > maxLabelWidth) maxLabelWidth = w;
16038
16512
  }
16039
16513
  }
@@ -16053,7 +16527,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16053
16527
  const maxXStr = String(maxX);
16054
16528
  for (const ann of spec.annotations) {
16055
16529
  if (ann.type === "text" && String(ann.x) === maxXStr) {
16056
- const textWidth = estimateTextWidth20(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
16530
+ const textWidth = estimateTextWidth21(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
16057
16531
  const dx = ann.offset?.dx ?? 0;
16058
16532
  const anchor = ann.anchor ?? "auto";
16059
16533
  const baseRightExtent = anchor === "right" ? textWidth : anchor === "left" ? 0 : textWidth / 2;
@@ -16075,12 +16549,12 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16075
16549
  let maxLabelWidth = 0;
16076
16550
  for (const row of spec.data) {
16077
16551
  const label = String(row[yField] ?? "");
16078
- let w = estimateTextWidth20(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
16552
+ let w = estimateTextWidth21(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
16079
16553
  if (yLabelField) {
16080
16554
  const subtitle = String(row[yLabelField] ?? "");
16081
16555
  if (subtitle) {
16082
16556
  const gap = theme.fonts.sizes.axisTick * 0.6;
16083
- const subtitleWidth = estimateTextWidth20(
16557
+ const subtitleWidth = estimateTextWidth21(
16084
16558
  subtitle,
16085
16559
  theme.fonts.sizes.axisTick,
16086
16560
  theme.fonts.weights.normal
@@ -16117,7 +16591,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16117
16591
  }
16118
16592
  const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? "-" : "";
16119
16593
  const labelEst = negPrefix + sampleLabel;
16120
- const labelWidth = estimateTextWidth20(
16594
+ const labelWidth = estimateTextWidth21(
16121
16595
  labelEst,
16122
16596
  theme.fonts.sizes.axisTick,
16123
16597
  theme.fonts.weights.normal
@@ -16148,7 +16622,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16148
16622
  })(maxAbsValForTitle);
16149
16623
  }
16150
16624
  const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? "-" : "";
16151
- estTickLabelWidth = estimateTextWidth20(
16625
+ estTickLabelWidth = estimateTextWidth21(
16152
16626
  negPrefixForTitle + sampleLabelForTitle,
16153
16627
  theme.fonts.sizes.axisTick,
16154
16628
  theme.fonts.weights.normal
@@ -16409,7 +16883,7 @@ import {
16409
16883
  buildD3Formatter as buildD3Formatter3,
16410
16884
  computeChrome as computeChrome6,
16411
16885
  defaultNumberFormatter as defaultNumberFormatter10,
16412
- estimateTextWidth as estimateTextWidth21,
16886
+ estimateTextWidth as estimateTextWidth22,
16413
16887
  resolveTheme as resolveTheme5
16414
16888
  } from "@opendata-ai/openchart-core";
16415
16889
 
@@ -16422,21 +16896,19 @@ var ALIGN_MAP = {
16422
16896
  center: sankeyCenter
16423
16897
  };
16424
16898
  function computeSankeyLayout(data, sourceField, targetField, valueField, area3, nodeWidth, nodePadding, nodeAlign, iterations, nodeSort) {
16899
+ const links = [];
16425
16900
  const nodeSet = /* @__PURE__ */ new Set();
16426
16901
  for (const row of data) {
16427
- nodeSet.add(String(row[sourceField]));
16428
- nodeSet.add(String(row[targetField]));
16429
- }
16430
- const nodes = [...nodeSet].map((id) => ({
16431
- id,
16432
- label: id
16433
- }));
16434
- const links = data.map((row) => ({
16435
- source: String(row[sourceField]),
16436
- target: String(row[targetField]),
16437
- value: Number(row[valueField]) || 0,
16438
- data: { ...row }
16439
- }));
16902
+ const value = finiteNumber(row[valueField]);
16903
+ if (value === void 0 || value <= 0) continue;
16904
+ const source = String(row[sourceField]);
16905
+ const target = String(row[targetField]);
16906
+ nodeSet.add(source);
16907
+ nodeSet.add(target);
16908
+ links.push({ source, target, value, data: { ...row } });
16909
+ }
16910
+ if (links.length === 0) return { nodes: [], links: [] };
16911
+ const nodes = [...nodeSet].map((id) => ({ id, label: id }));
16440
16912
  const alignFn = ALIGN_MAP[nodeAlign] ?? sankeyJustify;
16441
16913
  const generator = sankey().nodeId((d) => d.id).nodeAlign(alignFn).nodeWidth(nodeWidth).nodePadding(nodePadding).extent([
16442
16914
  [area3.x, area3.y],
@@ -16566,10 +17038,11 @@ function rewriteRowsForOther(data, sourceField, targetField, valueField, merged)
16566
17038
  const newTgt = merged.get(tgt) ?? tgt;
16567
17039
  if (newSrc === newTgt) continue;
16568
17040
  const key = `${newSrc}\0${newTgt}`;
16569
- const value = Number(row[valueField]) || 0;
17041
+ const value = finiteNumber(row[valueField]);
17042
+ if (value === void 0 || value <= 0) continue;
16570
17043
  const existing = index.get(key);
16571
17044
  if (existing) {
16572
- existing[valueField] = (Number(existing[valueField]) || 0) + value;
17045
+ existing[valueField] = (finiteNumber(existing[valueField]) ?? 0) + value;
16573
17046
  continue;
16574
17047
  }
16575
17048
  const next = { ...row, [sourceField]: newSrc, [targetField]: newTgt, [valueField]: value };
@@ -16686,12 +17159,40 @@ function compileSankey(spec, options) {
16686
17159
  const targetField = sankeySpec.encoding.target.field;
16687
17160
  const valueField = sankeySpec.encoding.value.field;
16688
17161
  const colorField = sankeySpec.encoding.color?.field;
17162
+ let missingFlowCount = 0;
17163
+ let negativeFlowCount = 0;
17164
+ let zeroFlowCount = 0;
17165
+ const measuredData = sankeySpec.data.filter((row) => {
17166
+ const value = finiteNumber(row[valueField]);
17167
+ if (value === void 0) missingFlowCount++;
17168
+ else if (value < 0) negativeFlowCount++;
17169
+ else if (value === 0) zeroFlowCount++;
17170
+ else return true;
17171
+ return false;
17172
+ });
17173
+ const omittedCount = missingFlowCount + negativeFlowCount + zeroFlowCount;
17174
+ if (omittedCount > 0) {
17175
+ const reasons = [
17176
+ missingFlowCount > 0 ? `${missingFlowCount} missing, nonnumeric, or nonfinite` : "",
17177
+ negativeFlowCount > 0 ? `${negativeFlowCount} negative` : "",
17178
+ zeroFlowCount > 0 ? `${zeroFlowCount} zero-width` : ""
17179
+ ].filter(Boolean);
17180
+ emitSpecWarnings(
17181
+ [
17182
+ `Sankey omitted ${omittedCount} flow rows from "${valueField}" (${reasons.join(", ")}). Sankey geometry requires positive finite values. Supply measured positive amounts; zero flows have no drawable width.`
17183
+ ],
17184
+ options.onWarn
17185
+ );
17186
+ }
17187
+ if (measuredData.length === 0) {
17188
+ return emptyLayout4(fullArea, chrome, theme, { ...options, height: grownHeight }, watermark);
17189
+ }
16689
17190
  const flowFmt = resolveFieldFormatter({
16690
17191
  surfaceFormat: resolvedValueFormat,
16691
- values: sankeySpec.data.map((r) => r[valueField])
17192
+ values: measuredData.map((r) => r[valueField])
16692
17193
  });
16693
17194
  const tempNodeIds = /* @__PURE__ */ new Set();
16694
- for (const row of sankeySpec.data) {
17195
+ for (const row of measuredData) {
16695
17196
  tempNodeIds.add(String(row[sourceField]));
16696
17197
  tempNodeIds.add(String(row[targetField]));
16697
17198
  }
@@ -16699,14 +17200,14 @@ function compileSankey(spec, options) {
16699
17200
  [...tempNodeIds].map((id) => ({ id })),
16700
17201
  theme.colors.categorical,
16701
17202
  colorField,
16702
- sankeySpec.data,
17203
+ measuredData,
16703
17204
  sourceField,
16704
17205
  targetField
16705
17206
  );
16706
17207
  const legend = buildSankeyLegend(
16707
17208
  tempColorMap,
16708
17209
  colorField,
16709
- sankeySpec.data,
17210
+ measuredData,
16710
17211
  sourceField,
16711
17212
  targetField,
16712
17213
  theme,
@@ -16726,7 +17227,7 @@ function compileSankey(spec, options) {
16726
17227
  const labelFontWeight = theme.fonts.weights.medium;
16727
17228
  const valueFontWeight = theme.fonts.weights.normal;
16728
17229
  const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? "auto";
16729
- let workingData = sankeySpec.data;
17230
+ let workingData = measuredData;
16730
17231
  const runLayout = (rect) => computeSankeyLayout(
16731
17232
  workingData,
16732
17233
  sourceField,
@@ -16768,8 +17269,8 @@ function compileSankey(spec, options) {
16768
17269
  const nodeFmt = resolveNodeValueFormatter(nodes, resolvedValueFormat, flowFmt);
16769
17270
  const labelBlockWidth = (node) => {
16770
17271
  const name = node.label ?? node.id;
16771
- const nameWidth = estimateTextWidth21(name, labelFontSize, labelFontWeight);
16772
- const valueWidth = estimateTextWidth21(
17272
+ const nameWidth = estimateTextWidth22(name, labelFontSize, labelFontWeight);
17273
+ const valueWidth = estimateTextWidth22(
16773
17274
  formatFlowValue(node.value ?? 0, nodeFmt),
16774
17275
  labelFontSize,
16775
17276
  valueFontWeight
@@ -16843,7 +17344,7 @@ function compileSankey(spec, options) {
16843
17344
  const nextColumn = columnX0.get(depth + 1);
16844
17345
  if (nextColumn === void 0) return { text, style: valueStyle };
16845
17346
  const available = nextColumn - LABEL_GAP3 - ((node.x1 ?? 0) + LABEL_GAP3);
16846
- const needed = estimateTextWidth21(node.label ?? node.id, labelFontSize, labelFontWeight) + VALUE_GAP + estimateTextWidth21(text, labelFontSize, valueFontWeight);
17347
+ const needed = estimateTextWidth22(node.label ?? node.id, labelFontSize, labelFontWeight) + VALUE_GAP + estimateTextWidth22(text, labelFontSize, valueFontWeight);
16847
17348
  return needed <= available ? { text, style: valueStyle } : void 0;
16848
17349
  };
16849
17350
  const nodeMarks = nodes.map((node) => {
@@ -17101,7 +17602,7 @@ function emptyLayout4(area3, chrome, theme, options, watermark) {
17101
17602
  }
17102
17603
 
17103
17604
  // src/tables/compile-table.ts
17104
- import { computeChrome as computeChrome7, estimateTextWidth as estimateTextWidth22 } from "@opendata-ai/openchart-core";
17605
+ import { computeChrome as computeChrome7, estimateTextWidth as estimateTextWidth23 } from "@opendata-ai/openchart-core";
17105
17606
 
17106
17607
  // src/tables/bar-column.ts
17107
17608
  var NEGATIVE_BAR_COLOR = "#c44e52";
@@ -17164,14 +17665,14 @@ import { adaptColorForDarkMode, findAccessibleColor as findAccessibleColor2 } fr
17164
17665
  import { interpolateRgb as interpolateRgb2 } from "d3-interpolate";
17165
17666
 
17166
17667
  // src/tables/utils.ts
17167
- import { contrastRatio as contrastRatio2, cssTokenDefault } from "@opendata-ai/openchart-core";
17668
+ import { contrastRatio as contrastRatio3, cssTokenDefault } from "@opendata-ai/openchart-core";
17168
17669
  function resolveTableSurface(theme) {
17169
17670
  return theme.colors.neutral.surface;
17170
17671
  }
17171
17672
  function accessibleTextColor(bg, theme) {
17172
17673
  const own = theme.colors.text;
17173
17674
  const inverse = cssTokenDefault("--oc-text", theme.isDark ? "light" : "dark");
17174
- return contrastRatio2(inverse, bg) > contrastRatio2(own, bg) ? inverse : own;
17675
+ return contrastRatio3(inverse, bg) > contrastRatio3(own, bg) ? inverse : own;
17175
17676
  }
17176
17677
 
17177
17678
  // src/tables/category-colors.ts
@@ -17291,7 +17792,7 @@ function formatValueForSearch(value, column) {
17291
17792
  }
17292
17793
 
17293
17794
  // src/tables/heatmap.ts
17294
- import { adaptColorForDarkMode as adaptColorForDarkMode2, contrastRatio as contrastRatio3 } from "@opendata-ai/openchart-core";
17795
+ import { adaptColorForDarkMode as adaptColorForDarkMode2, contrastRatio as contrastRatio4 } from "@opendata-ai/openchart-core";
17295
17796
  import { interpolateRgb as interpolateRgb3 } from "d3-interpolate";
17296
17797
  import { scaleSequential } from "d3-scale";
17297
17798
  function interpolatorFromStops(stops) {
@@ -17348,7 +17849,7 @@ function computeHeatmapColors(data, column, theme, darkMode) {
17348
17849
  const darkBg = resolveTableSurface(theme);
17349
17850
  if (Array.isArray(config.palette)) {
17350
17851
  stops = stops.map((c) => {
17351
- const ratio = contrastRatio3(c, lightBg);
17852
+ const ratio = contrastRatio4(c, lightBg);
17352
17853
  if (ratio < 1.15) return darkBg;
17353
17854
  return adaptColorForDarkMode2(c, lightBg, darkBg);
17354
17855
  });
@@ -17574,13 +18075,13 @@ function estimateColumnWidth(col, data, fontSize, paddingX) {
17574
18075
  if (col.image) return (col.image.width ?? 24) + paddingX;
17575
18076
  if (col.flag) return 60;
17576
18077
  const label = col.label ?? col.key;
17577
- const headerWidth = estimateTextWidth22(label, fontSize, 600) + paddingX;
18078
+ const headerWidth = estimateTextWidth23(label, fontSize, 600) + paddingX;
17578
18079
  const sampleSize = Math.min(100, data.length);
17579
18080
  let maxDataWidth = 0;
17580
18081
  for (let i = 0; i < sampleSize; i++) {
17581
18082
  const val = data[i][col.key];
17582
18083
  const text = val == null ? "" : String(val);
17583
- const width = estimateTextWidth22(text, fontSize, 400) + paddingX;
18084
+ const width = estimateTextWidth23(text, fontSize, 400) + paddingX;
17584
18085
  if (width > maxDataWidth) maxDataWidth = width;
17585
18086
  }
17586
18087
  return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
@@ -17857,9 +18358,17 @@ function compileTableLayout(spec, options, theme) {
17857
18358
  sparklineDomains.set(col.key, computeSparklineDomain(data, col.key, col.sparkline));
17858
18359
  }
17859
18360
  }
18361
+ const identities = spec.rowKey ? resolveObservationIdentities(data, spec.rowKey) : null;
18362
+ if (identities?.some((identity) => !identity.persistent))
18363
+ emitSpecWarnings(
18364
+ [
18365
+ "TABLE_ROW_KEY_AMBIGUOUS: rowKey contains duplicate or missing values; affected rows use revision-local identities."
18366
+ ],
18367
+ options.onWarn
18368
+ );
17860
18369
  const rows = currentData.map((row, i) => {
17861
18370
  const origIdx = originalIndices[i];
17862
- const rowId = spec.rowKey ? String(row[spec.rowKey] ?? origIdx) : String(origIdx);
18371
+ const rowId = identities?.[origIdx].observation ?? String(origIdx);
17863
18372
  const cells = spec.columns.map((col, c) => {
17864
18373
  const resolved = resolvedColumns[c];
17865
18374
  const value = row[col.key];
@@ -17940,13 +18449,13 @@ import {
17940
18449
  adaptTheme as adaptTheme5,
17941
18450
  CATEGORICAL_PALETTE as CATEGORICAL_PALETTE3,
17942
18451
  computeChrome as computeChrome8,
17943
- estimateTextWidth as estimateTextWidth23,
18452
+ estimateTextWidth as estimateTextWidth24,
17944
18453
  getBreakpoint as getBreakpoint2,
17945
18454
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION4,
17946
18455
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN4,
17947
18456
  pickLabelColor as pickLabelColor4,
17948
18457
  resolveTheme as resolveTheme6,
17949
- SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES3
18458
+ SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES2
17950
18459
  } from "@opendata-ai/openchart-core";
17951
18460
  import { interpolateRgb as interpolateRgb4 } from "d3-interpolate";
17952
18461
  import { scaleLinear as scaleLinear6 } from "d3-scale";
@@ -18053,18 +18562,15 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
18053
18562
  const stateValueMap = /* @__PURE__ */ new Map();
18054
18563
  for (const row of tilemapSpec.data) {
18055
18564
  const stateCode = String(row[stateField]);
18056
- const raw = row[valueField];
18057
- if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== void 0) {
18058
- const value = Number(raw);
18059
- if (!Number.isNaN(value)) {
18060
- stateValueMap.set(stateCode, value);
18061
- }
18565
+ const value = finiteNumber(row[valueField]);
18566
+ if (STATE_CODE_SET.has(stateCode) && value !== void 0) {
18567
+ stateValueMap.set(stateCode, value);
18062
18568
  }
18063
18569
  }
18064
18570
  const values = Array.from(stateValueMap.values());
18065
18571
  const min4 = values.length > 0 ? Math.min(...values) : 0;
18066
18572
  const max4 = values.length > 0 ? Math.max(...values) : 100;
18067
- const paletteStops = [...SEQUENTIAL_PALETTES3[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES3.blue];
18573
+ const paletteStops = [...SEQUENTIAL_PALETTES2[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES2.blue];
18068
18574
  const baseColor = isDarkMode ? paletteStops[0] : paletteStops[paletteStops.length - 1];
18069
18575
  const opacityRange = isDarkMode ? [0.3, 1] : [0.25, 1];
18070
18576
  const opacityScale = scaleLinear6().domain([min4, max4]).range(opacityRange).clamp(true);
@@ -18125,7 +18631,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
18125
18631
  });
18126
18632
  const hasEmptyTiles = tiles.some((t) => !t.hasData);
18127
18633
  const noDataLabel = "No data";
18128
- const noDataLabelWidth = estimateTextWidth23(noDataLabel, 11);
18634
+ const noDataLabelWidth = estimateTextWidth24(noDataLabel, 11);
18129
18635
  const noDataBlockWidth2 = hasEmptyTiles ? NO_DATA_BAR_GAP + legendBarHeight + NO_DATA_LABEL_GAP2 + noDataLabelWidth : 0;
18130
18636
  const barWidth = Math.max(0, legendWidth - noDataBlockWidth2);
18131
18637
  const noDataX = legendX + barWidth + NO_DATA_BAR_GAP;
@@ -18182,7 +18688,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
18182
18688
  height: contentHeight,
18183
18689
  animation: resolvedAnimation,
18184
18690
  watermark,
18185
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth23(text, fontSize), height: fontSize }))
18691
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth24(text, fontSize), height: fontSize }))
18186
18692
  };
18187
18693
  }
18188
18694
  function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark, chromeLayout) {
@@ -18323,7 +18829,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
18323
18829
  height: contentHeight,
18324
18830
  animation: resolvedAnimation,
18325
18831
  watermark,
18326
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth23(text, fontSize), height: fontSize }))
18832
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth24(text, fontSize), height: fontSize }))
18327
18833
  };
18328
18834
  }
18329
18835
  function buildTileMark(opts) {
@@ -18449,7 +18955,7 @@ function countLegendRows(labels, availableWidth, fontSize) {
18449
18955
  let rows = 1;
18450
18956
  let offsetX = 0;
18451
18957
  for (let i = 0; i < labels.length; i++) {
18452
- const labelWidth = estimateTextWidth23(labels[i], fontSize);
18958
+ const labelWidth = estimateTextWidth24(labels[i], fontSize);
18453
18959
  const entryWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_GAP + labelWidth + LEGEND_ENTRY_GAP;
18454
18960
  if (i > 0 && offsetX + entryWidth > availableWidth) {
18455
18961
  rows++;
@@ -18493,7 +18999,7 @@ function emptyLayout5(chrome, theme, options, watermark) {
18493
18999
  height: options.height,
18494
19000
  watermark,
18495
19001
  animation: void 0,
18496
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth23(text, fontSize), height: fontSize }))
19002
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth24(text, fontSize), height: fontSize }))
18497
19003
  };
18498
19004
  }
18499
19005
 
@@ -19139,8 +19645,8 @@ function runBin(data, transform) {
19139
19645
  let min4 = Infinity;
19140
19646
  let max4 = -Infinity;
19141
19647
  for (const row of data) {
19142
- const v = Number(row[field]);
19143
- if (Number.isFinite(v)) {
19648
+ const v = finiteNumber(row[field]);
19649
+ if (v !== void 0) {
19144
19650
  if (params.scaleType === "log" && v <= 0) continue;
19145
19651
  if (v < min4) min4 = v;
19146
19652
  if (v > max4) max4 = v;
@@ -19152,9 +19658,9 @@ function runBin(data, transform) {
19152
19658
  if (params.scaleType === "log") {
19153
19659
  const edges = computeLogBinEdges(extent3, maxbins);
19154
19660
  return data.map((row) => {
19155
- const v = Number(row[field]);
19661
+ const v = finiteNumber(row[field]);
19156
19662
  const newRow = { ...row };
19157
- if (!Number.isFinite(v) || v <= 0) {
19663
+ if (v === void 0 || v <= 0) {
19158
19664
  newRow[startAs] = null;
19159
19665
  if (endAs) newRow[endAs] = null;
19160
19666
  } else {
@@ -19174,9 +19680,9 @@ function runBin(data, transform) {
19174
19680
  }
19175
19681
  const step = params.step ?? computeStep(extent3, maxbins, nice);
19176
19682
  return data.map((row) => {
19177
- const v = Number(row[field]);
19683
+ const v = finiteNumber(row[field]);
19178
19684
  const newRow = { ...row };
19179
- if (!Number.isFinite(v)) {
19685
+ if (v === void 0) {
19180
19686
  newRow[startAs] = null;
19181
19687
  if (endAs) newRow[endAs] = null;
19182
19688
  } else {
@@ -19258,7 +19764,7 @@ function groupRows(data, groupby) {
19258
19764
  if (groupby.length === 0) return /* @__PURE__ */ new Map([["", data]]);
19259
19765
  const groups = /* @__PURE__ */ new Map();
19260
19766
  for (const row of data) {
19261
- const key = groupby.map((f) => String(row[f] ?? "")).join("\0");
19767
+ const key = groupKey(row, groupby);
19262
19768
  const bucket = groups.get(key);
19263
19769
  if (bucket) bucket.push(row);
19264
19770
  else groups.set(key, [row]);
@@ -19278,10 +19784,8 @@ function runDensity(data, transform) {
19278
19784
  for (const rows of groups.values()) {
19279
19785
  const samples = [];
19280
19786
  for (const r of rows) {
19281
- const raw = r[field];
19282
- if (raw == null || raw === "") continue;
19283
- const v = Number(raw);
19284
- if (Number.isFinite(v)) samples.push(v);
19787
+ const value = finiteNumber(r[field]);
19788
+ if (value !== void 0) samples.push(value);
19285
19789
  }
19286
19790
  if (samples.length === 0) continue;
19287
19791
  const bandwidth = transform.bandwidth && transform.bandwidth > 0 ? transform.bandwidth : silvermanBandwidth(samples);
@@ -19453,14 +19957,11 @@ function runFold(data, transform) {
19453
19957
  }
19454
19958
 
19455
19959
  // src/transforms/joinaggregate.ts
19456
- function groupKey2(row, groupby) {
19457
- return groupby.map((f) => String(row[f] ?? "")).join("\0");
19458
- }
19459
19960
  function runJoinAggregate(data, transform) {
19460
19961
  const groupby = transform.groupby ?? [];
19461
19962
  const groups = /* @__PURE__ */ new Map();
19462
19963
  for (const row of data) {
19463
- const key = groupKey2(row, groupby);
19964
+ const key = groupKey(row, groupby);
19464
19965
  const bucket = groups.get(key);
19465
19966
  if (bucket) bucket.push(row);
19466
19967
  else groups.set(key, [row]);
@@ -19469,12 +19970,11 @@ function runJoinAggregate(data, transform) {
19469
19970
  for (const [key, rows] of groups) {
19470
19971
  const summary = {};
19471
19972
  for (const { op, field, as } of transform.joinaggregate) {
19472
- const values = op === "count" ? rows.map(() => 1) : rows.map((r) => Number(r[field])).filter((v) => Number.isFinite(v));
19473
- summary[as] = computeAggregate(op, values);
19973
+ summary[as] = aggregateRows(op, field, rows);
19474
19974
  }
19475
19975
  summaries.set(key, summary);
19476
19976
  }
19477
- return data.map((row) => ({ ...row, ...summaries.get(groupKey2(row, groupby)) }));
19977
+ return data.map((row) => ({ ...row, ...summaries.get(groupKey(row, groupby)) }));
19478
19978
  }
19479
19979
 
19480
19980
  // src/transforms/timeunit.ts
@@ -19543,9 +20043,6 @@ function runTimeUnit(data, transform) {
19543
20043
  }
19544
20044
 
19545
20045
  // src/transforms/window.ts
19546
- function groupKey3(row, groupby) {
19547
- return groupby.map((f) => String(row[f] ?? "")).join("\0");
19548
- }
19549
20046
  function tryParseDate(val) {
19550
20047
  if (val == null) return NaN;
19551
20048
  if (typeof val === "number") return new Date(val).getTime();
@@ -19575,7 +20072,7 @@ function runWindow(data, transform) {
19575
20072
  const indexed = data.map((row, i) => ({ row, originalIndex: i }));
19576
20073
  const groups = /* @__PURE__ */ new Map();
19577
20074
  for (const entry of indexed) {
19578
- const key = groupby.length > 0 ? groupKey3(entry.row, groupby) : "";
20075
+ const key = groupby.length > 0 ? groupKey(entry.row, groupby) : "";
19579
20076
  const existing = groups.get(key);
19580
20077
  if (existing) {
19581
20078
  existing.push(entry);
@@ -19612,26 +20109,25 @@ function runWindow(data, transform) {
19612
20109
  case "diff": {
19613
20110
  const lagIdx = i - offset;
19614
20111
  if (lagIdx >= 0) {
19615
- const current = Number(entry.row[def.field]);
19616
- const lagged = Number(sorted[lagIdx].row[def.field]);
19617
- computed = Number.isFinite(current) && Number.isFinite(lagged) ? current - lagged : null;
20112
+ const current = finiteNumber(entry.row[def.field]);
20113
+ const lagged = finiteNumber(sorted[lagIdx].row[def.field]);
20114
+ computed = current !== void 0 && lagged !== void 0 ? finiteNumber(current - lagged) ?? null : null;
19618
20115
  }
19619
20116
  break;
19620
20117
  }
19621
20118
  case "pct_change": {
19622
20119
  const lagIdx = i - offset;
19623
20120
  if (lagIdx >= 0) {
19624
- const current = Number(entry.row[def.field]);
19625
- const lagged = Number(sorted[lagIdx].row[def.field]);
19626
- if (Number.isFinite(current) && Number.isFinite(lagged) && lagged !== 0) {
19627
- computed = (current - lagged) / lagged;
20121
+ const current = finiteNumber(entry.row[def.field]);
20122
+ const lagged = finiteNumber(sorted[lagIdx].row[def.field]);
20123
+ if (current !== void 0 && lagged !== void 0 && lagged !== 0) {
20124
+ computed = finiteNumber((current - lagged) / lagged) ?? null;
19628
20125
  }
19629
20126
  }
19630
20127
  break;
19631
20128
  }
19632
20129
  case "cumsum": {
19633
- const val = Number(entry.row[def.field]);
19634
- const addend = Number.isFinite(val) ? val : 0;
20130
+ const addend = finiteNumber(entry.row[def.field]) ?? 0;
19635
20131
  if (i === 0) {
19636
20132
  computed = addend;
19637
20133
  } else {
@@ -20078,6 +20574,7 @@ function compileChart(spec, optionsInput) {
20078
20574
  const skipY = axisless && !chartSpec.userExplicit.yAxis;
20079
20575
  const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
20080
20576
  data: renderSpec.data,
20577
+ preserveEnd: typeof spec.responsive === "object" ? spec.responsive.preserveEnd : false,
20081
20578
  encoding: renderSpec.encoding,
20082
20579
  skipX,
20083
20580
  skipY,
@@ -20133,7 +20630,7 @@ function compileChart(spec, optionsInput) {
20133
20630
  obstacles.push(...computeMarkObstacles(marks, scales));
20134
20631
  for (const mark of marks) {
20135
20632
  if ("label" in mark && mark.label?.visible) {
20136
- obstacles.push({ ...computeLabelBounds(mark.label), kind: "data-label" });
20633
+ obstacles.push({ ...computeLabelBounds2(mark.label), kind: "data-label" });
20137
20634
  }
20138
20635
  }
20139
20636
  const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
@@ -20192,14 +20689,61 @@ function compileChart(spec, optionsInput) {
20192
20689
  const reserve = footnoteBandHeight(footnotes.length, theme);
20193
20690
  const currentReserve = options.footnoteReserve ?? 0;
20194
20691
  if (!options.frozenChartArea && reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
20692
+ const grow = (spec.chromeLayout ?? options.chromeLayout) === "grow";
20195
20693
  return compileChart(spec, {
20196
20694
  ...optionsInput,
20695
+ height: optionsInput.height + (grow ? reserve - currentReserve : 0),
20197
20696
  footnoteReserve: reserve,
20198
20697
  footnotePass: (options.footnotePass ?? 0) + 1
20199
20698
  });
20200
20699
  }
20201
20700
  }
20202
20701
  }
20702
+ const responsive = spec.responsive;
20703
+ const wantsSummary = typeof responsive === "object" && responsive.labelSummary;
20704
+ const seriesSummary = wantsSummary && endpointLabels.entries.length === 0 ? computeEndpointLabels(chartSpec, marks, theme, chartArea, void 0, false).entries.map(
20705
+ (entry) => ({
20706
+ key: entry.seriesKey,
20707
+ text: `${entry.seriesKey} \xB7 ${entry.value}`,
20708
+ lines: wrapText3(
20709
+ `${entry.seriesKey} \xB7 ${entry.value}`,
20710
+ theme.fonts.sizes.small,
20711
+ 500,
20712
+ Math.max(1, options.width - 2 * theme.spacing.padding),
20713
+ options.measureText
20714
+ ).flatMap((line4) => {
20715
+ const max4 = Math.max(1, options.width - 2 * theme.spacing.padding);
20716
+ const chunks = [];
20717
+ let chunk = "";
20718
+ for (const character of line4) {
20719
+ const candidate = chunk + character;
20720
+ if (chunk && measure(candidate, theme.fonts.sizes.small, 500) > max4) {
20721
+ chunks.push(chunk);
20722
+ chunk = character;
20723
+ } else chunk = candidate;
20724
+ }
20725
+ if (chunk) chunks.push(chunk);
20726
+ return chunks;
20727
+ }),
20728
+ color: entry.color
20729
+ })
20730
+ ) : [];
20731
+ if (seriesSummary.length) {
20732
+ const reserve = footnoteBandHeight(
20733
+ (footnotes?.length ?? 0) + seriesSummary.reduce((count, entry) => count + entry.lines.length, 0),
20734
+ theme
20735
+ );
20736
+ const previousReserve = options.footnoteReserve ?? 0;
20737
+ if (!options.frozenChartArea && reserve > previousReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
20738
+ const grow = (spec.chromeLayout ?? options.chromeLayout) === "grow";
20739
+ return compileChart(spec, {
20740
+ ...optionsInput,
20741
+ height: optionsInput.height + (grow ? reserve - previousReserve : 0),
20742
+ footnoteReserve: reserve,
20743
+ footnotePass: (options.footnotePass ?? 0) + 1
20744
+ });
20745
+ }
20746
+ }
20203
20747
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
20204
20748
  const altText = chartSpec.a11y?.description ?? generateAltText(
20205
20749
  {
@@ -20219,10 +20763,14 @@ function compileChart(spec, optionsInput) {
20219
20763
  chartSpec.data
20220
20764
  );
20221
20765
  assignAnimationIndices(marks, resolvedAnimation);
20222
- const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
20766
+ const chrome = {
20767
+ ...dims.chrome,
20768
+ ...footnotes ? { footnotes } : {},
20769
+ ...seriesSummary.length ? { seriesSummary } : {}
20770
+ };
20223
20771
  const xInvert = resolveScaleInvert(scales.x);
20224
20772
  const yInvert = resolveScaleInvert(scales.y);
20225
- return {
20773
+ const layout = {
20226
20774
  area: chartArea,
20227
20775
  chrome,
20228
20776
  metrics: dims.metrics,
@@ -20263,6 +20811,18 @@ function compileChart(spec, optionsInput) {
20263
20811
  ...yInvert ? { yInvert } : {},
20264
20812
  ...markRenderMode === "canvas" ? { markRenderMode } : {}
20265
20813
  };
20814
+ if (options.onDiagnostic) {
20815
+ const diagnosticSpec = {
20816
+ ...expandedSpec,
20817
+ data: chartSpec.data,
20818
+ encoding: chartSpec.encoding
20819
+ };
20820
+ for (const diagnostic of diagnoseChart(diagnosticSpec, layout)) {
20821
+ if (diagnostic.fix) diagnostic.fix.precondition = JSON.stringify(spec);
20822
+ options.onDiagnostic(diagnostic);
20823
+ }
20824
+ }
20825
+ return layout;
20266
20826
  }
20267
20827
  function compileFaceted(chartSpec, renderSpec, facetChannel, facetDirection, options, theme, strategy, watermark, resolvedAnimation, crosshair, rawSpec, optionsInput) {
20268
20828
  const measure = createMeasureFn(options.measureText);
@@ -20599,6 +21159,176 @@ function compileGeoMap2(spec, options) {
20599
21159
  function compileBarList2(spec, options) {
20600
21160
  return compileBarList(spec, options);
20601
21161
  }
21162
+
21163
+ // src/design/patch.ts
21164
+ var unsafe = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
21165
+ function applySpecPatch(spec, patch) {
21166
+ if (JSON.stringify(spec) !== patch.precondition)
21167
+ throw new Error("This suggestion is stale. Review the current document again.");
21168
+ const copy = structuredClone(spec);
21169
+ for (const change of patch.changes) {
21170
+ if (!change.path.startsWith("/")) throw new Error("Patch paths must be JSON pointers.");
21171
+ const keys = change.path.slice(1).split("/").map((key) => key.replace(/~1/g, "/").replace(/~0/g, "~"));
21172
+ if (keys.some((key) => unsafe.has(key) || key.length === 0))
21173
+ throw new Error("Unsafe patch path.");
21174
+ let parent = copy;
21175
+ for (const key of keys.slice(0, -1)) {
21176
+ if (parent[key] === void 0) parent[key] = {};
21177
+ if (!parent[key] || typeof parent[key] !== "object")
21178
+ throw new Error("Patch path is no longer an object.");
21179
+ parent = parent[key];
21180
+ }
21181
+ parent[keys[keys.length - 1]] = structuredClone(change.value);
21182
+ }
21183
+ return copy;
21184
+ }
21185
+
21186
+ // src/design/recommend.ts
21187
+ function recommendCompositions(data, question, roles) {
21188
+ if (!data.length)
21189
+ return {
21190
+ recommendations: [],
21191
+ missingRoles: ["data"],
21192
+ explanation: "Add observations before choosing a composition."
21193
+ };
21194
+ const required = {
21195
+ comparison: ["measure", "category"],
21196
+ change: ["measure", "time"],
21197
+ distribution: ["measure"],
21198
+ composition: ["measure", "category"],
21199
+ geography: ["measure", "category"],
21200
+ relationships: ["measure", "secondMeasure"]
21201
+ };
21202
+ const missingRoles = required[question].filter(
21203
+ (role) => !roles[role] || !data.some((row) => Object.hasOwn(row, roles[role]))
21204
+ );
21205
+ if (missingRoles.length) return { recommendations: [], missingRoles };
21206
+ if (question === "geography")
21207
+ return {
21208
+ recommendations: [],
21209
+ missingRoles: ["geographic features and join key"],
21210
+ explanation: "Choose a geography and explicit join before creating a map."
21211
+ };
21212
+ for (const field of [
21213
+ roles.measure,
21214
+ ...question === "relationships" ? [roles.secondMeasure] : []
21215
+ ]) {
21216
+ const present = data.map((row) => row[field]).filter((value) => value != null);
21217
+ if (!present.length || present.some((value) => typeof value !== "number" || !Number.isFinite(value)))
21218
+ return {
21219
+ recommendations: [],
21220
+ missingRoles: ["numeric measure"],
21221
+ explanation: `Correct the type or values of \u201C${field}\u201D in Data before using it as a measure.`
21222
+ };
21223
+ }
21224
+ if (question === "change") {
21225
+ if (data.some(
21226
+ (row) => row[roles.time] == null || !Number.isFinite(Date.parse(String(row[roles.time])))
21227
+ ))
21228
+ return {
21229
+ recommendations: [],
21230
+ missingRoles: ["valid date"],
21231
+ explanation: "Correct missing or invalid dates before connecting observations."
21232
+ };
21233
+ const keys = data.map(
21234
+ (row) => JSON.stringify([roles.series ? row[roles.series] : null, row[roles.time]])
21235
+ );
21236
+ if (new Set(keys).size !== keys.length)
21237
+ return {
21238
+ recommendations: [],
21239
+ missingRoles: ["unique date per series"],
21240
+ explanation: "Repeated dates need a series field or an explicit aggregation decision."
21241
+ };
21242
+ }
21243
+ const measure = { field: roles.measure, type: "quantitative" };
21244
+ const category = { field: roles.category, type: "nominal", sort: null };
21245
+ const recommendations = [];
21246
+ const add = (id, title, reason, tradeoff, spec) => recommendations.push({ id, title, reason, tradeoff, spec });
21247
+ if (question === "comparison" || question === "composition") {
21248
+ const seen = new Set(data.map((row) => row[roles.category]));
21249
+ if (seen.size !== data.length)
21250
+ return {
21251
+ recommendations: [],
21252
+ missingRoles: ["aggregation or a unique category"],
21253
+ explanation: "Repeated categories need an explicit grouping or aggregation decision."
21254
+ };
21255
+ add(
21256
+ "bars",
21257
+ "Compare on a shared baseline",
21258
+ "Position and length make differences easy to judge.",
21259
+ "Long category lists need more vertical space. Input order is preserved.",
21260
+ { mark: "bar", data, encoding: { x: measure, y: category } }
21261
+ );
21262
+ add(
21263
+ "dots",
21264
+ "A quieter dot comparison",
21265
+ "Points emphasize positions without a wall of filled bars.",
21266
+ "Small differences may need direct value labels.",
21267
+ { mark: "point", data, encoding: { x: measure, y: category } }
21268
+ );
21269
+ if (question === "composition" && data.every(
21270
+ (row) => typeof row[roles.measure] === "number" && Number(row[roles.measure]) >= 0
21271
+ ) && data.some((row) => Number(row[roles.measure]) > 0) && data.length <= 6) {
21272
+ add(
21273
+ "shares",
21274
+ "Show parts of a whole",
21275
+ "A donut makes the whole explicit when categories form a complete partition.",
21276
+ "Only use this if the categories are mutually exclusive parts of the same total; bars are more precise.",
21277
+ {
21278
+ mark: { type: "arc", innerRadius: 0.65 },
21279
+ data,
21280
+ encoding: { theta: measure, color: category }
21281
+ }
21282
+ );
21283
+ }
21284
+ } else if (question === "change") {
21285
+ const encoding = {
21286
+ x: { field: roles.time, type: "temporal" },
21287
+ y: measure,
21288
+ ...roles.series ? { color: { field: roles.series, type: "nominal" } } : {}
21289
+ };
21290
+ add(
21291
+ "trend",
21292
+ "Trace change over time",
21293
+ "Connected observations make direction and reversals visible.",
21294
+ "Connections assume comparable measurements across dates; missing observations need explanation.",
21295
+ { mark: "line", data, encoding }
21296
+ );
21297
+ if (roles.series)
21298
+ add(
21299
+ "small-multiples",
21300
+ "Give every series its own space",
21301
+ "Shared scales reduce crowding and retain comparison.",
21302
+ "Requires more height than an overlaid chart.",
21303
+ {
21304
+ mark: "line",
21305
+ data,
21306
+ encoding: { ...encoding, facet: { field: roles.series, type: "nominal" } }
21307
+ }
21308
+ );
21309
+ } else if (question === "distribution") {
21310
+ add(
21311
+ "distribution",
21312
+ "Show the distribution",
21313
+ "A histogram reveals spread, skew, and concentrations.",
21314
+ "Bin boundaries affect appearance. Review them before drawing conclusions.",
21315
+ { mark: "histogram", data, encoding: { x: measure } }
21316
+ );
21317
+ } else {
21318
+ add(
21319
+ "relationship",
21320
+ "Inspect the relationship",
21321
+ "A scatterplot exposes clusters and unusual observations.",
21322
+ "Association does not establish causation; dense data may overlap.",
21323
+ {
21324
+ mark: "point",
21325
+ data,
21326
+ encoding: { x: measure, y: { field: roles.secondMeasure, type: "quantitative" } }
21327
+ }
21328
+ );
21329
+ }
21330
+ return { recommendations: recommendations.slice(0, 3), missingRoles: [] };
21331
+ }
20602
21332
  export {
20603
21333
  ARROWHEAD_LENGTH,
20604
21334
  AUTO_CANVAS_THRESHOLD,
@@ -20609,12 +21339,14 @@ export {
20609
21339
  EXIT_DEFAULTS,
20610
21340
  MAX_3D_NODES,
20611
21341
  UPDATE_DEFAULTS,
21342
+ applySpecPatch,
20612
21343
  buildArcPath,
20613
21344
  buildAreaPath,
20614
21345
  buildEdgeTooltip,
20615
21346
  buildLinePath,
20616
21347
  clampStaggerDelay,
20617
21348
  clearRenderers,
21349
+ compareObservations,
20618
21350
  compile,
20619
21351
  compileBarList2 as compileBarList,
20620
21352
  compileChart,
@@ -20626,17 +21358,23 @@ export {
20626
21358
  compileTileMap2 as compileTileMap,
20627
21359
  computeArrowheadPoints,
20628
21360
  dedupeKeys,
21361
+ diagnoseChart,
20629
21362
  evaluatePredicate,
20630
21363
  facetMinHeight,
20631
21364
  getChartRenderer,
20632
21365
  heuristicMeasure,
20633
21366
  isConditionalValueDef,
20634
21367
  legendGap,
21368
+ linkedEntityKeys,
20635
21369
  normalizeSpec,
21370
+ observationKey,
20636
21371
  parseAnnotationSpans,
21372
+ recommendCompositions,
21373
+ reconcileReaderSelection,
20637
21374
  registerChartRenderer,
20638
21375
  resolveAnimation,
20639
21376
  resolveConditionalValue,
21377
+ resolveObservationIdentities,
20640
21378
  runBin,
20641
21379
  runCalculate,
20642
21380
  runFilter,