@opendata-ai/openchart-engine 8.8.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
@@ -9520,7 +9753,68 @@ function validateLayerSpec(spec, errors) {
9520
9753
  }
9521
9754
  function validateAnimation(spec, errors) {
9522
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
+ }
9523
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
+ }
9524
9818
  const update = animation.update;
9525
9819
  if (!update || typeof update !== "object" || Array.isArray(update)) return;
9526
9820
  const maxMarks = update.maxMarks;
@@ -9534,6 +9828,60 @@ function validateAnimation(spec, errors) {
9534
9828
  });
9535
9829
  }
9536
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
+ }
9537
9885
  function validateSpec(spec) {
9538
9886
  const errors = [];
9539
9887
  if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
@@ -9550,6 +9898,31 @@ function validateSpec(spec) {
9550
9898
  };
9551
9899
  }
9552
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
+ }
9553
9926
  const hasLayer = "layer" in obj && Array.isArray(obj.layer);
9554
9927
  const hasMark = "mark" in obj;
9555
9928
  const isTable = obj.type === "table";
@@ -9771,10 +10144,7 @@ function compileBarList(spec, options) {
9771
10144
  const rowContentHeight = Math.max(barHeight, LABEL_FONT_SIZE7 * 1.4);
9772
10145
  const rowHeight = rowContentHeight + DEFAULT_ROW_GAP;
9773
10146
  const maxFittingRows = Math.max(1, Math.floor(fullArea.height / rowHeight));
9774
- const validRows = barlistSpec.data.filter((row) => {
9775
- const val = row[valueField];
9776
- return val !== null && val !== void 0 && !Number.isNaN(Number(val));
9777
- }).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));
9778
10148
  if (validRows.length === 0) {
9779
10149
  return emptyLayout2(chrome, theme, options, watermark);
9780
10150
  }
@@ -10170,7 +10540,9 @@ import {
10170
10540
  import {
10171
10541
  computeFieldFormatContext as computeFieldFormatContext2,
10172
10542
  defaultNumberFormatter as defaultNumberFormatter5,
10173
- resolveNumberFormatter as resolveNumberFormatter2
10543
+ estimateTextWidth as estimateTextWidth10,
10544
+ resolveNumberFormatter as resolveNumberFormatter2,
10545
+ resolveSchemeName as resolveSchemeName3
10174
10546
  } from "@opendata-ai/openchart-core";
10175
10547
  import { tickStep } from "d3-array";
10176
10548
  import { scaleQuantile } from "d3-scale";
@@ -10209,10 +10581,8 @@ function sampleRampColors(colors, k) {
10209
10581
  function numericFieldValues(spec, field) {
10210
10582
  const out = [];
10211
10583
  for (const row of spec.data) {
10212
- const v = row[field];
10213
- if (v == null) continue;
10214
- const num = typeof v === "number" ? v : Number(v);
10215
- if (Number.isFinite(num)) out.push(num);
10584
+ const value = finiteNumber(row[field]);
10585
+ if (value !== void 0) out.push(value);
10216
10586
  }
10217
10587
  return out;
10218
10588
  }
@@ -10235,6 +10605,10 @@ function isDivergingRamp(colors, theme) {
10235
10605
  function resolveRampColors(channel, theme) {
10236
10606
  const explicitRange = channel.scale?.range;
10237
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
+ }
10238
10612
  return Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
10239
10613
  }
10240
10614
  function resolveBarWidth(availableWidth) {
@@ -10356,6 +10730,22 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
10356
10730
  x: (i + 1) * binWidth,
10357
10731
  anchor: "middle"
10358
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
+ }
10359
10749
  return {
10360
10750
  mode: "binned",
10361
10751
  barWidth,
@@ -10363,6 +10753,7 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
10363
10753
  colorStops: [],
10364
10754
  bins,
10365
10755
  ticks: ticks2,
10756
+ labelRows: Math.max(1, rowEnds.length),
10366
10757
  classScale: scale
10367
10758
  };
10368
10759
  }
@@ -10598,7 +10989,7 @@ import {
10598
10989
  BREAKPOINT_COMPACT_MAX as BREAKPOINT_COMPACT_MAX2,
10599
10990
  computeFieldFormatContext as computeFieldFormatContext5,
10600
10991
  defaultNumberFormatter as defaultNumberFormatter7,
10601
- estimateTextWidth as estimateTextWidth17,
10992
+ estimateTextWidth as estimateTextWidth18,
10602
10993
  resolveNumberFormatter as resolveNumberFormatter5,
10603
10994
  resolveTheme as resolveTheme2,
10604
10995
  TICK_LABEL_OFFSET
@@ -10616,7 +11007,7 @@ import {
10616
11007
  computeFieldFormatContext as computeFieldFormatContext4,
10617
11008
  computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
10618
11009
  defaultNumberFormatter as defaultNumberFormatter6,
10619
- estimateTextWidth as estimateTextWidth16,
11010
+ estimateTextWidth as estimateTextWidth17,
10620
11011
  getHeightClass,
10621
11012
  HPAD_COMPACT_FRACTION,
10622
11013
  HPAD_COMPACT_MIN,
@@ -10633,7 +11024,7 @@ import {
10633
11024
  } from "@opendata-ai/openchart-core";
10634
11025
 
10635
11026
  // src/endpoint-labels/predict.ts
10636
- 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";
10637
11028
  function predictEndpointLabelsWidth(spec, _theme) {
10638
11029
  if (spec.endpointLabels === false) return 0;
10639
11030
  if (spec.markType !== "line" && spec.markType !== "area") return 0;
@@ -10678,12 +11069,12 @@ function predictEndpointLabelsWidth(spec, _theme) {
10678
11069
  const text = content ? composeEndpointText(content, name, sample) : name;
10679
11070
  const lines = wrapText2(text, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
10680
11071
  for (const line4 of lines) {
10681
- 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);
10682
11073
  if (w > maxWidth) maxWidth = w;
10683
11074
  }
10684
11075
  }
10685
11076
  if (!content && sample) {
10686
- 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);
10687
11078
  if (w > maxWidth) maxWidth = w;
10688
11079
  }
10689
11080
  return maxWidth;
@@ -10700,7 +11091,7 @@ function isEndsBoth(spec) {
10700
11091
  }
10701
11092
 
10702
11093
  // src/legend/compute.ts
10703
- 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";
10704
11095
 
10705
11096
  // src/layout/scales.ts
10706
11097
  import { computeFieldFormatContext as computeFieldFormatContext3 } from "@opendata-ai/openchart-core";
@@ -10722,11 +11113,9 @@ import {
10722
11113
  } from "d3-scale";
10723
11114
  function numericFieldValues2(data, field) {
10724
11115
  const result = [];
10725
- for (const d of data) {
10726
- const v = d[field];
10727
- if (v == null) continue;
10728
- const n = typeof v === "number" ? v : Number(v);
10729
- 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);
10730
11119
  }
10731
11120
  return result;
10732
11121
  }
@@ -11126,8 +11515,8 @@ function computeScales(spec, chartArea, data) {
11126
11515
  const sums = /* @__PURE__ */ new Map();
11127
11516
  for (const row of data) {
11128
11517
  const cat = String(row[yField] ?? "");
11129
- const val = Number(row[xField] ?? 0);
11130
- if (Number.isFinite(val) && val > 0) {
11518
+ const val = finiteNumber(row[xField]);
11519
+ if (val !== void 0 && val > 0) {
11131
11520
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11132
11521
  }
11133
11522
  }
@@ -11148,8 +11537,8 @@ function computeScales(spec, chartArea, data) {
11148
11537
  const sums = /* @__PURE__ */ new Map();
11149
11538
  for (const row of data) {
11150
11539
  const cat = String(row[yField] ?? "");
11151
- const val = Number(row[xField] ?? 0);
11152
- if (Number.isFinite(val) && val > 0) {
11540
+ const val = finiteNumber(row[xField]);
11541
+ if (val !== void 0 && val > 0) {
11153
11542
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11154
11543
  }
11155
11544
  }
@@ -11213,8 +11602,8 @@ function computeScales(spec, chartArea, data) {
11213
11602
  const sums = /* @__PURE__ */ new Map();
11214
11603
  for (const row of data) {
11215
11604
  const cat = String(row[xField] ?? "");
11216
- const val = Number(row[yField] ?? 0);
11217
- if (Number.isFinite(val) && val > 0) {
11605
+ const val = finiteNumber(row[yField]);
11606
+ if (val !== void 0 && val > 0) {
11218
11607
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11219
11608
  }
11220
11609
  }
@@ -11235,8 +11624,8 @@ function computeScales(spec, chartArea, data) {
11235
11624
  const sums = /* @__PURE__ */ new Map();
11236
11625
  for (const row of data) {
11237
11626
  const cat = String(row[xField] ?? "");
11238
- const val = Number(row[yField] ?? 0);
11239
- if (Number.isFinite(val) && val > 0) {
11627
+ const val = finiteNumber(row[yField]);
11628
+ if (val !== void 0 && val > 0) {
11240
11629
  sums.set(cat, (sums.get(cat) ?? 0) + val);
11241
11630
  }
11242
11631
  }
@@ -11290,7 +11679,7 @@ function computeScales(spec, chartArea, data) {
11290
11679
  }
11291
11680
 
11292
11681
  // src/legend/wrap.ts
11293
- import { COMPACT_WIDTH, estimateTextWidth as estimateTextWidth11 } from "@opendata-ai/openchart-core";
11682
+ import { COMPACT_WIDTH, estimateTextWidth as estimateTextWidth12 } from "@opendata-ai/openchart-core";
11294
11683
  var SWATCH_SIZE = 12;
11295
11684
  var SWATCH_GAP = 6;
11296
11685
  var ENTRY_GAP = 16;
@@ -11304,7 +11693,7 @@ function measureLegendWrap(entries, maxWidth, labelStyle, maxRows, entryGap = EN
11304
11693
  if (entries.length === 0) {
11305
11694
  return { rowCount: 0, fittingCount: 0, rowWidths: [], placements: [] };
11306
11695
  }
11307
- const measureWidth = measure ?? estimateTextWidth11;
11696
+ const measureWidth = measure ?? estimateTextWidth12;
11308
11697
  let rowCount = 1;
11309
11698
  let rowWidth = 0;
11310
11699
  const rowWidths = [];
@@ -11450,7 +11839,7 @@ function emptyContent(position, theme) {
11450
11839
  };
11451
11840
  }
11452
11841
  function computeLegendContent(spec, strategy, theme, availableWidth, availableHeight, watermark = true, measure, endpointLabelsDemoted) {
11453
- const measureWidth = measure ?? estimateTextWidth12;
11842
+ const measureWidth = measure ?? estimateTextWidth13;
11454
11843
  const sparklineHidden = spec.display === "sparkline" && !spec.userExplicit.legend;
11455
11844
  if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
11456
11845
  return emptyContent("top", theme);
@@ -11471,7 +11860,7 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
11471
11860
  fontVariant: "tabular-nums"
11472
11861
  };
11473
11862
  const labelRowHeight = Math.ceil(labelStyle2.fontSize * labelStyle2.lineHeight);
11474
- 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);
11475
11864
  const blockWidth = continuous.blockWidth ?? continuous.barWidth;
11476
11865
  return {
11477
11866
  entries: [],
@@ -11728,7 +12117,7 @@ function placeLegend(content, chartArea, containerWidth, _theme, xAxisHeight, ax
11728
12117
  import {
11729
12118
  abbreviateNumber,
11730
12119
  buildD3Formatter,
11731
- estimateTextWidth as estimateTextWidth13,
12120
+ estimateTextWidth as estimateTextWidth14,
11732
12121
  formatNumber as formatNumber3
11733
12122
  } from "@opendata-ai/openchart-core";
11734
12123
  import { scaleLinear as scaleLinear3, scaleSqrt as scaleSqrt3 } from "d3-scale";
@@ -11844,11 +12233,11 @@ function computeSizeLegendContent(sizeEncoding, data, theme, options) {
11844
12233
  };
11845
12234
  });
11846
12235
  const widestLabel = Math.max(
11847
- ...circles.map((c) => estimateTextWidth13(c.label, fontSize, theme.fonts.weights.normal))
12236
+ ...circles.map((c) => estimateTextWidth14(c.label, fontSize, theme.fonts.weights.normal))
11848
12237
  );
11849
12238
  const lastLabelBottom = (circles[circles.length - 1]?.labelY ?? 0) + fontSize * 0.5;
11850
12239
  const circleBlockWidth = maxRadius * 2 + LABEL_GAP + widestLabel;
11851
- const titleWidth = title ? estimateTextWidth13(title, fontSize, theme.fonts.weights.medium) : 0;
12240
+ const titleWidth = title ? estimateTextWidth14(title, fontSize, theme.fonts.weights.medium) : 0;
11852
12241
  return {
11853
12242
  circles,
11854
12243
  width: Math.max(circleBlockWidth, titleWidth),
@@ -11892,7 +12281,7 @@ function placeSizeLegend(content, chartArea, theme, colorLegend) {
11892
12281
  import {
11893
12282
  axisTitleOffset,
11894
12283
  computeXAxisExtentFromLabels,
11895
- estimateTextWidth as estimateTextWidth15,
12284
+ estimateTextWidth as estimateTextWidth16,
11896
12285
  truncateRotatedLabel,
11897
12286
  X_AXIS_TITLE_BAND,
11898
12287
  X_AXIS_TITLE_BAND_ROTATED
@@ -11937,11 +12326,11 @@ function bandLabelStride(maxLabelWidth, angleDeg, fontSize, spacing) {
11937
12326
  }
11938
12327
 
11939
12328
  // src/layout/axes/thinning.ts
11940
- import { estimateTextWidth as estimateTextWidth14 } from "@opendata-ai/openchart-core";
12329
+ import { estimateTextWidth as estimateTextWidth15 } from "@opendata-ai/openchart-core";
11941
12330
  var MIN_TICK_GAP_FACTOR = 0.5;
11942
12331
  var MIN_TICK_COUNT = 2;
11943
12332
  function measureLabel(text, fontSize, fontWeight, measureText) {
11944
- return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth14(text, fontSize, fontWeight);
12333
+ return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth15(text, fontSize, fontWeight);
11945
12334
  }
11946
12335
  function ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation = "horizontal") {
11947
12336
  if (ticks.length < 2) return false;
@@ -12418,6 +12807,35 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12418
12807
  if (isContinuousX && !hasExplicitValues && dataContext?.maxXTicks != null) {
12419
12808
  ticks = capTickCount(ticks, dataContext.maxXTicks);
12420
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
+ }
12421
12839
  if (tickAngle && Math.abs(tickAngle) > 10) {
12422
12840
  const measureWidth = (text, size, weight) => measureLabel(text, size, weight ?? fontWeight, measureText);
12423
12841
  ticks = ticks.map((t) => {
@@ -12567,7 +12985,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12567
12985
  const isRight = result.y.orient === "right";
12568
12986
  if (isRight) {
12569
12987
  const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12570
- const w = estimateTextWidth15(
12988
+ const w = estimateTextWidth16(
12571
12989
  t.label,
12572
12990
  result.y.tickLabelStyle.fontSize,
12573
12991
  result.y.tickLabelStyle.fontWeight ?? 400
@@ -12582,7 +13000,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12582
13000
  };
12583
13001
  } else {
12584
13002
  const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12585
- const w = estimateTextWidth15(
13003
+ const w = estimateTextWidth16(
12586
13004
  t.label,
12587
13005
  result.y.tickLabelStyle.fontSize,
12588
13006
  result.y.tickLabelStyle.fontWeight ?? 400
@@ -12610,7 +13028,7 @@ function createMeasureFn(measureText) {
12610
13028
  if (measureText) {
12611
13029
  return (text, fontSize, fontWeight) => measureText(text, fontSize, fontWeight).width;
12612
13030
  }
12613
- return estimateTextWidth16;
13031
+ return estimateTextWidth17;
12614
13032
  }
12615
13033
  function sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx) {
12616
13034
  if (yAxisFormat) {
@@ -12975,6 +13393,19 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12975
13393
  }
12976
13394
 
12977
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
+ }
12978
13409
  function compileLayer(spec, options, compileChart2) {
12979
13410
  const leaves = flattenLayers(spec);
12980
13411
  if (leaves.length === 0) {
@@ -12982,7 +13413,11 @@ function compileLayer(spec, options, compileChart2) {
12982
13413
  }
12983
13414
  if (leaves.length === 1) {
12984
13415
  const singleSpec = buildPrimarySpec(leaves, spec);
12985
- 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 };
12986
13421
  }
12987
13422
  const layerOptions = { ...options, layered: true };
12988
13423
  if (spec.resolve?.scale?.y === "independent") {
@@ -12991,6 +13426,7 @@ function compileLayer(spec, options, compileChart2) {
12991
13426
  const primarySpec = buildPrimarySpec(leaves, spec);
12992
13427
  const primaryLayout = compileChart2(primarySpec, layerOptions);
12993
13428
  const allMarks = [];
13429
+ const tooltipDescriptors = /* @__PURE__ */ new Map();
12994
13430
  const seenLabels = /* @__PURE__ */ new Set();
12995
13431
  const pLegend = primaryLayout.legend;
12996
13432
  const mergedLegendEntries = "entries" in pLegend ? [...pLegend.entries] : [];
@@ -12999,19 +13435,20 @@ function compileLayer(spec, options, compileChart2) {
12999
13435
  }
13000
13436
  const indexedLeaves = leaves.map((leaf, i) => ({
13001
13437
  leaf,
13438
+ leafIndex: i,
13002
13439
  zIndex: leaf.zIndex ?? i
13003
13440
  }));
13004
13441
  indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
13005
13442
  const leafOptions = { ...layerOptions, frozenChartArea: primaryLayout.area };
13006
13443
  const sharedDomains = computeSharedDomains(leaves);
13007
- for (const { leaf } of indexedLeaves) {
13444
+ for (const { leaf, leafIndex } of indexedLeaves) {
13008
13445
  const themedLeaf = {
13009
13446
  ...withSharedDomains(leaf, sharedDomains),
13010
13447
  theme: leaf.theme ?? spec.theme,
13011
13448
  darkMode: leaf.darkMode ?? spec.darkMode
13012
13449
  };
13013
13450
  const leafLayout = compileChart2(themedLeaf, leafOptions);
13014
- allMarks.push(...leafLayout.marks);
13451
+ appendLayerMarks(leafLayout, leafIndex, allMarks, tooltipDescriptors);
13015
13452
  const leafLeg = leafLayout.legend;
13016
13453
  if ("entries" in leafLeg) {
13017
13454
  for (const entry of leafLeg.entries) {
@@ -13029,6 +13466,7 @@ function compileLayer(spec, options, compileChart2) {
13029
13466
  return {
13030
13467
  ...primaryLayout,
13031
13468
  marks: allMarks,
13469
+ tooltipDescriptors,
13032
13470
  legend: mergedLegend,
13033
13471
  // `legends` is what the renderer iterates, so the merged color legend has to
13034
13472
  // land in it too. Spreading `primaryLayout` alone would leave legends[0] as
@@ -13042,7 +13480,7 @@ function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
13042
13480
  const yEnc = encoding.y;
13043
13481
  const yField = yEnc.field;
13044
13482
  if (!yField) return 40;
13045
- const m = measure ?? estimateTextWidth17;
13483
+ const m = measure ?? estimateTextWidth18;
13046
13484
  const yType = yEnc.type;
13047
13485
  if (yType === "nominal" || yType === "ordinal") {
13048
13486
  let maxWidth = 0;
@@ -13232,20 +13670,17 @@ function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
13232
13670
  mergedLegendEntries.push(entry);
13233
13671
  }
13234
13672
  }
13235
- const l0Count = layout0.marks.length;
13236
- const mergedTooltips = new Map(layout0.tooltipDescriptors);
13237
- for (const [key, value] of layout1.tooltipDescriptors) {
13238
- const match = /^(rect|point|arc)-(\d+)$/.exec(key);
13239
- if (match) {
13240
- const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
13241
- mergedTooltips.set(offsetKey, value);
13242
- } else {
13243
- mergedTooltips.set(key, value);
13244
- }
13245
- }
13246
13673
  const z0 = leaf0.zIndex ?? 0;
13247
13674
  const z1 = leaf1.zIndex ?? 1;
13248
- 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
+ }
13249
13684
  const mergedLegend = {
13250
13685
  ...layout0.legend,
13251
13686
  ..."entries" in l0Legend ? { entries: mergedLegendEntries } : {}
@@ -13312,8 +13747,8 @@ function withUnionXDomain(leaf0, leaf1) {
13312
13747
  let min4 = Number.POSITIVE_INFINITY;
13313
13748
  let max4 = Number.NEGATIVE_INFINITY;
13314
13749
  for (const v of allValues) {
13315
- const n = typeof v === "number" ? v : Number(v);
13316
- if (!Number.isFinite(n)) continue;
13750
+ const n = finiteNumber(v);
13751
+ if (n === void 0) continue;
13317
13752
  if (n < min4) min4 = n;
13318
13753
  if (n > max4) max4 = n;
13319
13754
  }
@@ -13344,8 +13779,8 @@ function alignYDomains(leaf0, leaf1) {
13344
13779
  if (yEnc0.scale?.domain || yEnc1.scale?.domain) return void 0;
13345
13780
  const includeZero0 = yEnc0.scale?.zero !== false;
13346
13781
  const includeZero1 = yEnc1.scale?.zero !== false;
13347
- const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
13348
- 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);
13349
13784
  if (vals0.length === 0 || vals1.length === 0) return void 0;
13350
13785
  const niced = (vals, includeZero) => {
13351
13786
  let lo = Math.min(...vals);
@@ -13416,8 +13851,8 @@ function computeSharedDomains(leaves) {
13416
13851
  break;
13417
13852
  }
13418
13853
  for (const row of leaf.data ?? []) {
13419
- const v = Number(row[enc.field]);
13420
- if (!Number.isFinite(v)) continue;
13854
+ const v = finiteNumber(row[enc.field]);
13855
+ if (v === void 0) continue;
13421
13856
  if (v < lo) lo = v;
13422
13857
  if (v > hi) hi = v;
13423
13858
  sawAny = true;
@@ -13574,7 +14009,7 @@ function resolveYouDrawIt(config, marks, scales, chartArea, xField) {
13574
14009
  }
13575
14010
 
13576
14011
  // src/compiler/a11y-warnings.ts
13577
- import { contrastRatio, findAccessibleColor, isOpaqueColor } from "@opendata-ai/openchart-core";
14012
+ import { contrastRatio as contrastRatio2, findAccessibleColor, isOpaqueColor as isOpaqueColor2 } from "@opendata-ai/openchart-core";
13578
14013
  var GRAPHIC_MIN = 3;
13579
14014
  var TEXT_MIN = 4.5;
13580
14015
  var ADJACENT_FILL_MARKS = /* @__PURE__ */ new Set(["bar", "area", "arc", "waffle"]);
@@ -13582,7 +14017,7 @@ var fmt = (ratio) => `${ratio.toFixed(2)}:1`;
13582
14017
  function collectContrastWarnings(scales, markType, theme) {
13583
14018
  const warnings = [];
13584
14019
  const bg = theme.colors.background;
13585
- const bgIsCheckable = isOpaqueColor(bg);
14020
+ const bgIsCheckable = isOpaqueColor2(bg);
13586
14021
  const series = [];
13587
14022
  if (scales.color && scales.color.type !== "sequential") {
13588
14023
  const ordinal = scales.color.scale;
@@ -13594,7 +14029,7 @@ function collectContrastWarnings(scales, markType, theme) {
13594
14029
  for (let i = 0; i < series.length - 1; i++) {
13595
14030
  const a = series[i];
13596
14031
  const b = series[i + 1];
13597
- const ratio = contrastRatio(a.color, b.color);
14032
+ const ratio = contrastRatio2(a.color, b.color);
13598
14033
  if (ratio < GRAPHIC_MIN) {
13599
14034
  const suggestion = findAccessibleColor(b.color, a.color, GRAPHIC_MIN);
13600
14035
  warnings.push(
@@ -13605,7 +14040,7 @@ function collectContrastWarnings(scales, markType, theme) {
13605
14040
  }
13606
14041
  if (bgIsCheckable) {
13607
14042
  for (const s of series) {
13608
- const ratio = contrastRatio(s.color, bg);
14043
+ const ratio = contrastRatio2(s.color, bg);
13609
14044
  if (ratio < GRAPHIC_MIN) {
13610
14045
  const suggestion = findAccessibleColor(s.color, bg, GRAPHIC_MIN);
13611
14046
  warnings.push(
@@ -13618,7 +14053,7 @@ function collectContrastWarnings(scales, markType, theme) {
13618
14053
  { name: "theme.colors.annotationText", color: theme.colors.annotationText }
13619
14054
  ];
13620
14055
  for (const token of textTokens) {
13621
- const ratio = contrastRatio(token.color, bg);
14056
+ const ratio = contrastRatio2(token.color, bg);
13622
14057
  if (ratio < TEXT_MIN) {
13623
14058
  const suggestion = findAccessibleColor(token.color, bg, TEXT_MIN);
13624
14059
  warnings.push(
@@ -13733,16 +14168,14 @@ import {
13733
14168
  buildD3Formatter as buildD3Formatter2,
13734
14169
  CATEGORICAL_PALETTE as CATEGORICAL_PALETTE2,
13735
14170
  computeChrome as computeChrome3,
13736
- estimateTextWidth as estimateTextWidth18,
14171
+ estimateTextWidth as estimateTextWidth19,
13737
14172
  formatNumber as formatNumber4,
13738
14173
  getBreakpoint,
13739
14174
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION2,
13740
14175
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN2,
13741
- resolveTheme as resolveTheme3,
13742
- SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES2
14176
+ resolveTheme as resolveTheme3
13743
14177
  } from "@opendata-ai/openchart-core";
13744
14178
  import { geoArea, geoPath } from "d3-geo";
13745
- import { scaleQuantile as scaleQuantile3 } from "d3-scale";
13746
14179
  import { feature as topoFeature, mesh as topoMesh } from "topojson-client";
13747
14180
 
13748
14181
  // src/geo/join.ts
@@ -13776,25 +14209,50 @@ function suggestNormalization(unmatchedKeys, featureIds) {
13776
14209
  function joinDataToFeatures(features, data, dataKeyField, featureIdField) {
13777
14210
  const warnings = [];
13778
14211
  const dataLookup = /* @__PURE__ */ new Map();
14212
+ const duplicates = /* @__PURE__ */ new Set();
14213
+ const lowerCounts = /* @__PURE__ */ new Map();
14214
+ let missingKeys = 0;
13779
14215
  for (const row of data) {
13780
- 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);
13781
14223
  dataLookup.set(key, row);
14224
+ lowerCounts.set(key.toLowerCase(), (lowerCounts.get(key.toLowerCase()) ?? 0) + 1);
13782
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();
13783
14239
  const joined = /* @__PURE__ */ new Map();
13784
14240
  const usedDataKeys = /* @__PURE__ */ new Set();
13785
14241
  let lowerLookup = null;
13786
14242
  let unmatchedFeatureCount = 0;
13787
14243
  for (const feature of features) {
13788
14244
  const featureId = featureIdField === "id" ? String(feature.id) : String(feature.properties[featureIdField] ?? feature.id);
13789
- let match = dataLookup.get(featureId);
13790
- if (!match) {
14245
+ let match = duplicates.has(featureId) ? void 0 : dataLookup.get(featureId);
14246
+ if (!match && !duplicates.has(featureId)) {
13791
14247
  if (!lowerLookup) {
13792
14248
  lowerLookup = /* @__PURE__ */ new Map();
13793
14249
  for (const [key, row] of dataLookup) {
13794
- lowerLookup.set(key.toLowerCase(), row);
14250
+ if (lowerCounts.get(key.toLowerCase()) === 1) lowerLookup.set(key.toLowerCase(), row);
13795
14251
  }
13796
14252
  }
13797
- 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);
13798
14256
  }
13799
14257
  if (match) {
13800
14258
  joined.set(feature.id, match);
@@ -13803,6 +14261,12 @@ function joinDataToFeatures(features, data, dataKeyField, featureIdField) {
13803
14261
  unmatchedFeatureCount++;
13804
14262
  }
13805
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
+ });
13806
14270
  const unmatchedDataKeys = [];
13807
14271
  for (const key of dataLookup.keys()) {
13808
14272
  if (!usedDataKeys.has(key)) {
@@ -13948,10 +14412,10 @@ function categoricalLegendLabelStyle(theme) {
13948
14412
  };
13949
14413
  }
13950
14414
  function categoricalEntryWidth(label, labelStyle) {
13951
- 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;
13952
14416
  }
13953
14417
  function noDataBlockWidth(labelStyle) {
13954
- 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);
13955
14419
  }
13956
14420
  function wrapCategoricalEntries(widths, legendWidth) {
13957
14421
  const placed = [];
@@ -14079,11 +14543,8 @@ function compileGeoMap(spec, options) {
14079
14543
  if (colorEncoding && isQuantitative) {
14080
14544
  const colorValues = [];
14081
14545
  for (const row of mapSpec.data) {
14082
- const v = row[colorEncoding.field];
14083
- if (v != null) {
14084
- const n = Number(v);
14085
- if (!Number.isNaN(n)) colorValues.push(n);
14086
- }
14546
+ const value = finiteNumber(row[colorEncoding.field]);
14547
+ if (value !== void 0) colorValues.push(value);
14087
14548
  }
14088
14549
  const legendChannel = {
14089
14550
  ...colorEncoding,
@@ -14105,7 +14566,7 @@ function compileGeoMap(spec, options) {
14105
14566
  legendTitle = legendTitleFor(colorEncoding);
14106
14567
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14107
14568
  const titleRowHeight = legendTitle ? labelRowHeight : 0;
14108
- legendBlockHeight = titleRowHeight + continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
14569
+ legendBlockHeight = titleRowHeight + continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight * (continuousContent.labelRows ?? 1);
14109
14570
  }
14110
14571
  }
14111
14572
  if (legendBlockHeight === 0 && showChoroplethLegend && colorEncoding && !isQuantitative) {
@@ -14121,10 +14582,24 @@ function compileGeoMap(spec, options) {
14121
14582
  const rows = placed.length > 0 ? placed[placed.length - 1].row + 1 : 1;
14122
14583
  legendBlockHeight = rows * (Math.max(10, labelHeight) + 6);
14123
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;
14124
14599
  if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
14125
14600
  if (mapSpec.points.color.type === "quantitative") {
14126
14601
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14127
- legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
14602
+ legendBlockHeight += (pointContinuousContent?.barHeight ?? CONTINUOUS_BAR_HEIGHT) + CONTINUOUS_LABEL_GAP + labelRowHeight * (pointContinuousContent?.labelRows ?? 1) + 8;
14128
14603
  } else {
14129
14604
  const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14130
14605
  legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8;
@@ -14187,6 +14662,10 @@ function compileGeoMap(spec, options) {
14187
14662
  }
14188
14663
  const droppedFeatures = [];
14189
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
+ };
14190
14669
  const neutralFill = theme.colors.neutral[100];
14191
14670
  const noDataPattern = {
14192
14671
  type: "diagonal",
@@ -14249,7 +14728,7 @@ function compileGeoMap(spec, options) {
14249
14728
  const labelY = bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize;
14250
14729
  const hasUnmatched = featureMarks.some((m) => m.noData);
14251
14730
  const noDataX = bar.x + bar.width + NO_DATA_SWATCH_GAP;
14252
- const noDataLabelWidth = estimateTextWidth18(
14731
+ const noDataLabelWidth = estimateTextWidth19(
14253
14732
  "No data",
14254
14733
  labelStyle.fontSize,
14255
14734
  labelStyle.fontWeight
@@ -14348,21 +14827,10 @@ function compileGeoMap(spec, options) {
14348
14827
  const pointCategoryColors = /* @__PURE__ */ new Map();
14349
14828
  if (pts.color) {
14350
14829
  if (pts.color.type === "quantitative") {
14351
- const colorValues = [];
14352
- for (const row of pts.data) {
14353
- const raw = row[pts.color.field];
14354
- if (raw != null) {
14355
- const v = Number(raw);
14356
- if (Number.isFinite(v)) colorValues.push(v);
14357
- }
14358
- }
14359
- if (colorValues.length > 0) {
14360
- const palette = pts.color.scale?.scheme ?? "blue";
14361
- const paletteStops = [...SEQUENTIAL_PALETTES2[palette] ?? SEQUENTIAL_PALETTES2.blue];
14362
- const qScale = scaleQuantile3().domain(colorValues).range(paletteStops);
14830
+ if (pointClassScale) {
14363
14831
  pointColorScale = (val) => {
14364
- const v = Number(val);
14365
- return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE2[0];
14832
+ const value = finiteNumber(val);
14833
+ return value !== void 0 ? pointClassScale.color(value) : neutralFill;
14366
14834
  };
14367
14835
  }
14368
14836
  } else {
@@ -14399,16 +14867,19 @@ function compileGeoMap(spec, options) {
14399
14867
  const droppedPoints = [];
14400
14868
  for (let i = 0; i < pts.data.length; i++) {
14401
14869
  const row = pts.data[i];
14402
- const lon = Number(row[lonField]);
14403
- const lat = Number(row[latField]);
14404
- 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;
14405
14875
  const projected = projection([lon, lat]);
14406
- if (!projected) {
14876
+ if (!projected || !projected.every(Number.isFinite)) {
14407
14877
  droppedPoints.push([lon, lat]);
14408
14878
  continue;
14409
14879
  }
14410
- const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
14411
- 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;
14412
14883
  const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
14413
14884
  const stroke = resolveKnockoutColor(theme);
14414
14885
  pointMarks.push({
@@ -14472,24 +14943,7 @@ function compileGeoMap(spec, options) {
14472
14943
  };
14473
14944
  }
14474
14945
  if (pts.color && pts.color.type === "quantitative" && showLegend) {
14475
- const ptColorValues = [];
14476
- for (const row of pts.data) {
14477
- const v = row[pts.color.field];
14478
- if (v != null) {
14479
- const n = Number(v);
14480
- if (Number.isFinite(n)) ptColorValues.push(n);
14481
- }
14482
- }
14483
- const ptLegendChannel = {
14484
- ...pts.color,
14485
- scale: { ...pts.color.scale, type: "quantile" }
14486
- };
14487
- const ptContinuousContent = computeContinuousLegendContentForChannel(
14488
- ptColorValues,
14489
- ptLegendChannel,
14490
- theme,
14491
- fullArea.width
14492
- );
14946
+ const ptContinuousContent = pointContinuousContent;
14493
14947
  if (ptContinuousContent) {
14494
14948
  const labelStyle = {
14495
14949
  fontFamily: theme.fonts.family,
@@ -14504,7 +14958,7 @@ function compileGeoMap(spec, options) {
14504
14958
  const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
14505
14959
  const legendY = pointLegendOverlay ? fullArea.y + OVERLAY_LEGEND_INSET : fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
14506
14960
  const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
14507
- const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
14961
+ const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight * (ptContinuousContent.labelRows ?? 1);
14508
14962
  const bounds = {
14509
14963
  x: legendX,
14510
14964
  y: legendY,
@@ -14578,17 +15032,17 @@ function compileGeoMap(spec, options) {
14578
15032
  if (tooltipChannels) {
14579
15033
  for (const ch of tooltipChannels) {
14580
15034
  const val = mark.data[ch.field];
14581
- if (val != null) {
15035
+ if (val != null || ch.type === "quantitative") {
14582
15036
  fields.push({
14583
15037
  label: ch.title ?? ch.field,
14584
- value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
15038
+ value: ch.type === "quantitative" ? formatQuantitative(val) : String(val)
14585
15039
  });
14586
15040
  }
14587
15041
  }
14588
- } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
15042
+ } else if (colorEncoding && isQuantitative) {
14589
15043
  fields.push({
14590
15044
  label: colorEncoding.title ?? colorEncoding.field,
14591
- value: formatter(Number(mark.data[colorEncoding.field]))
15045
+ value: formatQuantitative(mark.data[colorEncoding.field])
14592
15046
  });
14593
15047
  } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
14594
15048
  fields.push({
@@ -14605,10 +15059,10 @@ function compileGeoMap(spec, options) {
14605
15059
  if (ptTooltipChannels) {
14606
15060
  for (const ch of ptTooltipChannels) {
14607
15061
  const val = pm.data[ch.field];
14608
- if (val != null) {
15062
+ if (val != null || ch.type === "quantitative") {
14609
15063
  fields.push({
14610
15064
  label: ch.title ?? ch.field,
14611
- value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
15065
+ value: ch.type === "quantitative" ? formatQuantitative(val) : String(val)
14612
15066
  });
14613
15067
  }
14614
15068
  }
@@ -14621,16 +15075,15 @@ function compileGeoMap(spec, options) {
14621
15075
  }
14622
15076
  if (pts.color) {
14623
15077
  const val = pm.data[pts.color.field];
14624
- if (val != null)
15078
+ if (val != null || pts.color.type === "quantitative")
14625
15079
  fields.push({
14626
15080
  label: pts.color.title ?? pts.color.field,
14627
- value: pts.color.type === "quantitative" ? formatter(Number(val)) : String(val)
15081
+ value: pts.color.type === "quantitative" ? formatQuantitative(val) : String(val)
14628
15082
  });
14629
15083
  }
14630
15084
  if (pts.size) {
14631
15085
  const val = pm.data[pts.size.field];
14632
- if (val != null)
14633
- 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) });
14634
15087
  }
14635
15088
  }
14636
15089
  if (fields.length > 0) {
@@ -14688,7 +15141,7 @@ function compileGeoMap(spec, options) {
14688
15141
  height: contentHeight,
14689
15142
  animation: resolvedAnimation,
14690
15143
  watermark,
14691
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth18(text, fontSize), height: fontSize })),
15144
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth19(text, fontSize), height: fontSize })),
14692
15145
  warnings: compileWarnings,
14693
15146
  mapSize: { width: fullArea.width, height: mapAreaHeight },
14694
15147
  focus: resolvedFocus
@@ -14759,22 +15212,13 @@ function buildQuantitativeMarks(opts) {
14759
15212
  }
14760
15213
  const featureId = feat.id;
14761
15214
  const dataRow = joined.get(featureId) ?? null;
14762
- const hasData = dataRow !== null;
14763
15215
  let fill = neutralFill;
14764
15216
  let binIndex;
14765
- let hasValue = false;
14766
- if (hasData) {
14767
- const raw = dataRow[colorField];
14768
- if (raw != null) {
14769
- const v = Number(raw);
14770
- if (!Number.isNaN(v)) {
14771
- hasValue = true;
14772
- if (classScale) {
14773
- fill = classScale.color(v);
14774
- binIndex = classScale.binIndex(v);
14775
- }
14776
- }
14777
- }
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);
14778
15222
  }
14779
15223
  const props = feat.properties;
14780
15224
  const name = props?.name ?? props?.NAME;
@@ -14798,7 +15242,7 @@ function buildQuantitativeMarks(opts) {
14798
15242
  data: dataRow,
14799
15243
  aria: {
14800
15244
  role: "img",
14801
- 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)
14802
15246
  },
14803
15247
  animationIndex: animIndex++,
14804
15248
  bounds,
@@ -15073,7 +15517,7 @@ function emptyLayout3(chrome, theme, options, watermark) {
15073
15517
  height: options.height,
15074
15518
  animation: void 0,
15075
15519
  watermark,
15076
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth18(text, fontSize), height: fontSize })),
15520
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth19(text, fontSize), height: fontSize })),
15077
15521
  warnings: [],
15078
15522
  mapSize: { width: 0, height: 0 },
15079
15523
  focus: null
@@ -15824,7 +16268,7 @@ import {
15824
16268
  computeChrome as computeChrome5,
15825
16269
  computeFieldFormatContext as computeFieldFormatContext6,
15826
16270
  defaultNumberFormatter as defaultNumberFormatter9,
15827
- estimateTextWidth as estimateTextWidth20,
16271
+ estimateTextWidth as estimateTextWidth21,
15828
16272
  getHeightClass as getHeightClass2,
15829
16273
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION3,
15830
16274
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN3,
@@ -15841,7 +16285,7 @@ import {
15841
16285
  } from "@opendata-ai/openchart-core";
15842
16286
 
15843
16287
  // src/layout/metrics.ts
15844
- import { estimateTextWidth as estimateTextWidth19 } from "@opendata-ai/openchart-core";
16288
+ import { estimateTextWidth as estimateTextWidth20 } from "@opendata-ai/openchart-core";
15845
16289
  var DEFAULT_LABEL_FONT_SIZE = 10;
15846
16290
  var DEFAULT_VALUE_FONT_SIZE = 22;
15847
16291
  var LABEL_LINE_HEIGHT_RATIO = 1.4;
@@ -15874,7 +16318,7 @@ function computeMetricBar(metrics, metricsTopY, metricsArea, remainingChartHeigh
15874
16318
  const cellWidth = metricsArea.width / metrics.length;
15875
16319
  for (const metric of metrics) {
15876
16320
  const text = valueRunText(metric);
15877
- 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);
15878
16322
  if (measured > cellWidth - CELL_INNER_PAD) return void 0;
15879
16323
  }
15880
16324
  const labelLine = fonts.label * LABEL_LINE_HEIGHT_RATIO;
@@ -16009,7 +16453,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16009
16453
  if (xField) {
16010
16454
  for (const row of spec.data) {
16011
16455
  const label = String(row[xField] ?? "");
16012
- 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);
16013
16457
  if (w > maxLabelWidth) maxLabelWidth = w;
16014
16458
  }
16015
16459
  }
@@ -16063,7 +16507,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16063
16507
  const label = String(row[colorField] ?? "");
16064
16508
  if (!seen.has(label)) {
16065
16509
  seen.add(label);
16066
- const w = estimateTextWidth20(label, 11, 600);
16510
+ const w = estimateTextWidth21(label, 11, 600);
16067
16511
  if (w > maxLabelWidth) maxLabelWidth = w;
16068
16512
  }
16069
16513
  }
@@ -16083,7 +16527,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16083
16527
  const maxXStr = String(maxX);
16084
16528
  for (const ann of spec.annotations) {
16085
16529
  if (ann.type === "text" && String(ann.x) === maxXStr) {
16086
- const textWidth = estimateTextWidth20(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
16530
+ const textWidth = estimateTextWidth21(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
16087
16531
  const dx = ann.offset?.dx ?? 0;
16088
16532
  const anchor = ann.anchor ?? "auto";
16089
16533
  const baseRightExtent = anchor === "right" ? textWidth : anchor === "left" ? 0 : textWidth / 2;
@@ -16105,12 +16549,12 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16105
16549
  let maxLabelWidth = 0;
16106
16550
  for (const row of spec.data) {
16107
16551
  const label = String(row[yField] ?? "");
16108
- 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);
16109
16553
  if (yLabelField) {
16110
16554
  const subtitle = String(row[yLabelField] ?? "");
16111
16555
  if (subtitle) {
16112
16556
  const gap = theme.fonts.sizes.axisTick * 0.6;
16113
- const subtitleWidth = estimateTextWidth20(
16557
+ const subtitleWidth = estimateTextWidth21(
16114
16558
  subtitle,
16115
16559
  theme.fonts.sizes.axisTick,
16116
16560
  theme.fonts.weights.normal
@@ -16147,7 +16591,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16147
16591
  }
16148
16592
  const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? "-" : "";
16149
16593
  const labelEst = negPrefix + sampleLabel;
16150
- const labelWidth = estimateTextWidth20(
16594
+ const labelWidth = estimateTextWidth21(
16151
16595
  labelEst,
16152
16596
  theme.fonts.sizes.axisTick,
16153
16597
  theme.fonts.weights.normal
@@ -16178,7 +16622,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
16178
16622
  })(maxAbsValForTitle);
16179
16623
  }
16180
16624
  const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? "-" : "";
16181
- estTickLabelWidth = estimateTextWidth20(
16625
+ estTickLabelWidth = estimateTextWidth21(
16182
16626
  negPrefixForTitle + sampleLabelForTitle,
16183
16627
  theme.fonts.sizes.axisTick,
16184
16628
  theme.fonts.weights.normal
@@ -16439,7 +16883,7 @@ import {
16439
16883
  buildD3Formatter as buildD3Formatter3,
16440
16884
  computeChrome as computeChrome6,
16441
16885
  defaultNumberFormatter as defaultNumberFormatter10,
16442
- estimateTextWidth as estimateTextWidth21,
16886
+ estimateTextWidth as estimateTextWidth22,
16443
16887
  resolveTheme as resolveTheme5
16444
16888
  } from "@opendata-ai/openchart-core";
16445
16889
 
@@ -16452,21 +16896,19 @@ var ALIGN_MAP = {
16452
16896
  center: sankeyCenter
16453
16897
  };
16454
16898
  function computeSankeyLayout(data, sourceField, targetField, valueField, area3, nodeWidth, nodePadding, nodeAlign, iterations, nodeSort) {
16899
+ const links = [];
16455
16900
  const nodeSet = /* @__PURE__ */ new Set();
16456
16901
  for (const row of data) {
16457
- nodeSet.add(String(row[sourceField]));
16458
- nodeSet.add(String(row[targetField]));
16459
- }
16460
- const nodes = [...nodeSet].map((id) => ({
16461
- id,
16462
- label: id
16463
- }));
16464
- const links = data.map((row) => ({
16465
- source: String(row[sourceField]),
16466
- target: String(row[targetField]),
16467
- value: Number(row[valueField]) || 0,
16468
- data: { ...row }
16469
- }));
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 }));
16470
16912
  const alignFn = ALIGN_MAP[nodeAlign] ?? sankeyJustify;
16471
16913
  const generator = sankey().nodeId((d) => d.id).nodeAlign(alignFn).nodeWidth(nodeWidth).nodePadding(nodePadding).extent([
16472
16914
  [area3.x, area3.y],
@@ -16596,10 +17038,11 @@ function rewriteRowsForOther(data, sourceField, targetField, valueField, merged)
16596
17038
  const newTgt = merged.get(tgt) ?? tgt;
16597
17039
  if (newSrc === newTgt) continue;
16598
17040
  const key = `${newSrc}\0${newTgt}`;
16599
- const value = Number(row[valueField]) || 0;
17041
+ const value = finiteNumber(row[valueField]);
17042
+ if (value === void 0 || value <= 0) continue;
16600
17043
  const existing = index.get(key);
16601
17044
  if (existing) {
16602
- existing[valueField] = (Number(existing[valueField]) || 0) + value;
17045
+ existing[valueField] = (finiteNumber(existing[valueField]) ?? 0) + value;
16603
17046
  continue;
16604
17047
  }
16605
17048
  const next = { ...row, [sourceField]: newSrc, [targetField]: newTgt, [valueField]: value };
@@ -16716,12 +17159,40 @@ function compileSankey(spec, options) {
16716
17159
  const targetField = sankeySpec.encoding.target.field;
16717
17160
  const valueField = sankeySpec.encoding.value.field;
16718
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
+ }
16719
17190
  const flowFmt = resolveFieldFormatter({
16720
17191
  surfaceFormat: resolvedValueFormat,
16721
- values: sankeySpec.data.map((r) => r[valueField])
17192
+ values: measuredData.map((r) => r[valueField])
16722
17193
  });
16723
17194
  const tempNodeIds = /* @__PURE__ */ new Set();
16724
- for (const row of sankeySpec.data) {
17195
+ for (const row of measuredData) {
16725
17196
  tempNodeIds.add(String(row[sourceField]));
16726
17197
  tempNodeIds.add(String(row[targetField]));
16727
17198
  }
@@ -16729,14 +17200,14 @@ function compileSankey(spec, options) {
16729
17200
  [...tempNodeIds].map((id) => ({ id })),
16730
17201
  theme.colors.categorical,
16731
17202
  colorField,
16732
- sankeySpec.data,
17203
+ measuredData,
16733
17204
  sourceField,
16734
17205
  targetField
16735
17206
  );
16736
17207
  const legend = buildSankeyLegend(
16737
17208
  tempColorMap,
16738
17209
  colorField,
16739
- sankeySpec.data,
17210
+ measuredData,
16740
17211
  sourceField,
16741
17212
  targetField,
16742
17213
  theme,
@@ -16756,7 +17227,7 @@ function compileSankey(spec, options) {
16756
17227
  const labelFontWeight = theme.fonts.weights.medium;
16757
17228
  const valueFontWeight = theme.fonts.weights.normal;
16758
17229
  const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? "auto";
16759
- let workingData = sankeySpec.data;
17230
+ let workingData = measuredData;
16760
17231
  const runLayout = (rect) => computeSankeyLayout(
16761
17232
  workingData,
16762
17233
  sourceField,
@@ -16798,8 +17269,8 @@ function compileSankey(spec, options) {
16798
17269
  const nodeFmt = resolveNodeValueFormatter(nodes, resolvedValueFormat, flowFmt);
16799
17270
  const labelBlockWidth = (node) => {
16800
17271
  const name = node.label ?? node.id;
16801
- const nameWidth = estimateTextWidth21(name, labelFontSize, labelFontWeight);
16802
- const valueWidth = estimateTextWidth21(
17272
+ const nameWidth = estimateTextWidth22(name, labelFontSize, labelFontWeight);
17273
+ const valueWidth = estimateTextWidth22(
16803
17274
  formatFlowValue(node.value ?? 0, nodeFmt),
16804
17275
  labelFontSize,
16805
17276
  valueFontWeight
@@ -16873,7 +17344,7 @@ function compileSankey(spec, options) {
16873
17344
  const nextColumn = columnX0.get(depth + 1);
16874
17345
  if (nextColumn === void 0) return { text, style: valueStyle };
16875
17346
  const available = nextColumn - LABEL_GAP3 - ((node.x1 ?? 0) + LABEL_GAP3);
16876
- 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);
16877
17348
  return needed <= available ? { text, style: valueStyle } : void 0;
16878
17349
  };
16879
17350
  const nodeMarks = nodes.map((node) => {
@@ -17131,7 +17602,7 @@ function emptyLayout4(area3, chrome, theme, options, watermark) {
17131
17602
  }
17132
17603
 
17133
17604
  // src/tables/compile-table.ts
17134
- 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";
17135
17606
 
17136
17607
  // src/tables/bar-column.ts
17137
17608
  var NEGATIVE_BAR_COLOR = "#c44e52";
@@ -17194,14 +17665,14 @@ import { adaptColorForDarkMode, findAccessibleColor as findAccessibleColor2 } fr
17194
17665
  import { interpolateRgb as interpolateRgb2 } from "d3-interpolate";
17195
17666
 
17196
17667
  // src/tables/utils.ts
17197
- import { contrastRatio as contrastRatio2, cssTokenDefault } from "@opendata-ai/openchart-core";
17668
+ import { contrastRatio as contrastRatio3, cssTokenDefault } from "@opendata-ai/openchart-core";
17198
17669
  function resolveTableSurface(theme) {
17199
17670
  return theme.colors.neutral.surface;
17200
17671
  }
17201
17672
  function accessibleTextColor(bg, theme) {
17202
17673
  const own = theme.colors.text;
17203
17674
  const inverse = cssTokenDefault("--oc-text", theme.isDark ? "light" : "dark");
17204
- return contrastRatio2(inverse, bg) > contrastRatio2(own, bg) ? inverse : own;
17675
+ return contrastRatio3(inverse, bg) > contrastRatio3(own, bg) ? inverse : own;
17205
17676
  }
17206
17677
 
17207
17678
  // src/tables/category-colors.ts
@@ -17321,7 +17792,7 @@ function formatValueForSearch(value, column) {
17321
17792
  }
17322
17793
 
17323
17794
  // src/tables/heatmap.ts
17324
- 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";
17325
17796
  import { interpolateRgb as interpolateRgb3 } from "d3-interpolate";
17326
17797
  import { scaleSequential } from "d3-scale";
17327
17798
  function interpolatorFromStops(stops) {
@@ -17378,7 +17849,7 @@ function computeHeatmapColors(data, column, theme, darkMode) {
17378
17849
  const darkBg = resolveTableSurface(theme);
17379
17850
  if (Array.isArray(config.palette)) {
17380
17851
  stops = stops.map((c) => {
17381
- const ratio = contrastRatio3(c, lightBg);
17852
+ const ratio = contrastRatio4(c, lightBg);
17382
17853
  if (ratio < 1.15) return darkBg;
17383
17854
  return adaptColorForDarkMode2(c, lightBg, darkBg);
17384
17855
  });
@@ -17604,13 +18075,13 @@ function estimateColumnWidth(col, data, fontSize, paddingX) {
17604
18075
  if (col.image) return (col.image.width ?? 24) + paddingX;
17605
18076
  if (col.flag) return 60;
17606
18077
  const label = col.label ?? col.key;
17607
- const headerWidth = estimateTextWidth22(label, fontSize, 600) + paddingX;
18078
+ const headerWidth = estimateTextWidth23(label, fontSize, 600) + paddingX;
17608
18079
  const sampleSize = Math.min(100, data.length);
17609
18080
  let maxDataWidth = 0;
17610
18081
  for (let i = 0; i < sampleSize; i++) {
17611
18082
  const val = data[i][col.key];
17612
18083
  const text = val == null ? "" : String(val);
17613
- const width = estimateTextWidth22(text, fontSize, 400) + paddingX;
18084
+ const width = estimateTextWidth23(text, fontSize, 400) + paddingX;
17614
18085
  if (width > maxDataWidth) maxDataWidth = width;
17615
18086
  }
17616
18087
  return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
@@ -17887,9 +18358,17 @@ function compileTableLayout(spec, options, theme) {
17887
18358
  sparklineDomains.set(col.key, computeSparklineDomain(data, col.key, col.sparkline));
17888
18359
  }
17889
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
+ );
17890
18369
  const rows = currentData.map((row, i) => {
17891
18370
  const origIdx = originalIndices[i];
17892
- const rowId = spec.rowKey ? String(row[spec.rowKey] ?? origIdx) : String(origIdx);
18371
+ const rowId = identities?.[origIdx].observation ?? String(origIdx);
17893
18372
  const cells = spec.columns.map((col, c) => {
17894
18373
  const resolved = resolvedColumns[c];
17895
18374
  const value = row[col.key];
@@ -17970,13 +18449,13 @@ import {
17970
18449
  adaptTheme as adaptTheme5,
17971
18450
  CATEGORICAL_PALETTE as CATEGORICAL_PALETTE3,
17972
18451
  computeChrome as computeChrome8,
17973
- estimateTextWidth as estimateTextWidth23,
18452
+ estimateTextWidth as estimateTextWidth24,
17974
18453
  getBreakpoint as getBreakpoint2,
17975
18454
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION4,
17976
18455
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN4,
17977
18456
  pickLabelColor as pickLabelColor4,
17978
18457
  resolveTheme as resolveTheme6,
17979
- SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES3
18458
+ SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES2
17980
18459
  } from "@opendata-ai/openchart-core";
17981
18460
  import { interpolateRgb as interpolateRgb4 } from "d3-interpolate";
17982
18461
  import { scaleLinear as scaleLinear6 } from "d3-scale";
@@ -18083,18 +18562,15 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
18083
18562
  const stateValueMap = /* @__PURE__ */ new Map();
18084
18563
  for (const row of tilemapSpec.data) {
18085
18564
  const stateCode = String(row[stateField]);
18086
- const raw = row[valueField];
18087
- if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== void 0) {
18088
- const value = Number(raw);
18089
- if (!Number.isNaN(value)) {
18090
- stateValueMap.set(stateCode, value);
18091
- }
18565
+ const value = finiteNumber(row[valueField]);
18566
+ if (STATE_CODE_SET.has(stateCode) && value !== void 0) {
18567
+ stateValueMap.set(stateCode, value);
18092
18568
  }
18093
18569
  }
18094
18570
  const values = Array.from(stateValueMap.values());
18095
18571
  const min4 = values.length > 0 ? Math.min(...values) : 0;
18096
18572
  const max4 = values.length > 0 ? Math.max(...values) : 100;
18097
- const paletteStops = [...SEQUENTIAL_PALETTES3[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES3.blue];
18573
+ const paletteStops = [...SEQUENTIAL_PALETTES2[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES2.blue];
18098
18574
  const baseColor = isDarkMode ? paletteStops[0] : paletteStops[paletteStops.length - 1];
18099
18575
  const opacityRange = isDarkMode ? [0.3, 1] : [0.25, 1];
18100
18576
  const opacityScale = scaleLinear6().domain([min4, max4]).range(opacityRange).clamp(true);
@@ -18155,7 +18631,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
18155
18631
  });
18156
18632
  const hasEmptyTiles = tiles.some((t) => !t.hasData);
18157
18633
  const noDataLabel = "No data";
18158
- const noDataLabelWidth = estimateTextWidth23(noDataLabel, 11);
18634
+ const noDataLabelWidth = estimateTextWidth24(noDataLabel, 11);
18159
18635
  const noDataBlockWidth2 = hasEmptyTiles ? NO_DATA_BAR_GAP + legendBarHeight + NO_DATA_LABEL_GAP2 + noDataLabelWidth : 0;
18160
18636
  const barWidth = Math.max(0, legendWidth - noDataBlockWidth2);
18161
18637
  const noDataX = legendX + barWidth + NO_DATA_BAR_GAP;
@@ -18212,7 +18688,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
18212
18688
  height: contentHeight,
18213
18689
  animation: resolvedAnimation,
18214
18690
  watermark,
18215
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth23(text, fontSize), height: fontSize }))
18691
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth24(text, fontSize), height: fontSize }))
18216
18692
  };
18217
18693
  }
18218
18694
  function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark, chromeLayout) {
@@ -18353,7 +18829,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
18353
18829
  height: contentHeight,
18354
18830
  animation: resolvedAnimation,
18355
18831
  watermark,
18356
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth23(text, fontSize), height: fontSize }))
18832
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth24(text, fontSize), height: fontSize }))
18357
18833
  };
18358
18834
  }
18359
18835
  function buildTileMark(opts) {
@@ -18479,7 +18955,7 @@ function countLegendRows(labels, availableWidth, fontSize) {
18479
18955
  let rows = 1;
18480
18956
  let offsetX = 0;
18481
18957
  for (let i = 0; i < labels.length; i++) {
18482
- const labelWidth = estimateTextWidth23(labels[i], fontSize);
18958
+ const labelWidth = estimateTextWidth24(labels[i], fontSize);
18483
18959
  const entryWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_GAP + labelWidth + LEGEND_ENTRY_GAP;
18484
18960
  if (i > 0 && offsetX + entryWidth > availableWidth) {
18485
18961
  rows++;
@@ -18523,7 +18999,7 @@ function emptyLayout5(chrome, theme, options, watermark) {
18523
18999
  height: options.height,
18524
19000
  watermark,
18525
19001
  animation: void 0,
18526
- measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth23(text, fontSize), height: fontSize }))
19002
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth24(text, fontSize), height: fontSize }))
18527
19003
  };
18528
19004
  }
18529
19005
 
@@ -19169,8 +19645,8 @@ function runBin(data, transform) {
19169
19645
  let min4 = Infinity;
19170
19646
  let max4 = -Infinity;
19171
19647
  for (const row of data) {
19172
- const v = Number(row[field]);
19173
- if (Number.isFinite(v)) {
19648
+ const v = finiteNumber(row[field]);
19649
+ if (v !== void 0) {
19174
19650
  if (params.scaleType === "log" && v <= 0) continue;
19175
19651
  if (v < min4) min4 = v;
19176
19652
  if (v > max4) max4 = v;
@@ -19182,9 +19658,9 @@ function runBin(data, transform) {
19182
19658
  if (params.scaleType === "log") {
19183
19659
  const edges = computeLogBinEdges(extent3, maxbins);
19184
19660
  return data.map((row) => {
19185
- const v = Number(row[field]);
19661
+ const v = finiteNumber(row[field]);
19186
19662
  const newRow = { ...row };
19187
- if (!Number.isFinite(v) || v <= 0) {
19663
+ if (v === void 0 || v <= 0) {
19188
19664
  newRow[startAs] = null;
19189
19665
  if (endAs) newRow[endAs] = null;
19190
19666
  } else {
@@ -19204,9 +19680,9 @@ function runBin(data, transform) {
19204
19680
  }
19205
19681
  const step = params.step ?? computeStep(extent3, maxbins, nice);
19206
19682
  return data.map((row) => {
19207
- const v = Number(row[field]);
19683
+ const v = finiteNumber(row[field]);
19208
19684
  const newRow = { ...row };
19209
- if (!Number.isFinite(v)) {
19685
+ if (v === void 0) {
19210
19686
  newRow[startAs] = null;
19211
19687
  if (endAs) newRow[endAs] = null;
19212
19688
  } else {
@@ -19288,7 +19764,7 @@ function groupRows(data, groupby) {
19288
19764
  if (groupby.length === 0) return /* @__PURE__ */ new Map([["", data]]);
19289
19765
  const groups = /* @__PURE__ */ new Map();
19290
19766
  for (const row of data) {
19291
- const key = groupby.map((f) => String(row[f] ?? "")).join("\0");
19767
+ const key = groupKey(row, groupby);
19292
19768
  const bucket = groups.get(key);
19293
19769
  if (bucket) bucket.push(row);
19294
19770
  else groups.set(key, [row]);
@@ -19308,10 +19784,8 @@ function runDensity(data, transform) {
19308
19784
  for (const rows of groups.values()) {
19309
19785
  const samples = [];
19310
19786
  for (const r of rows) {
19311
- const raw = r[field];
19312
- if (raw == null || raw === "") continue;
19313
- const v = Number(raw);
19314
- if (Number.isFinite(v)) samples.push(v);
19787
+ const value = finiteNumber(r[field]);
19788
+ if (value !== void 0) samples.push(value);
19315
19789
  }
19316
19790
  if (samples.length === 0) continue;
19317
19791
  const bandwidth = transform.bandwidth && transform.bandwidth > 0 ? transform.bandwidth : silvermanBandwidth(samples);
@@ -19483,14 +19957,11 @@ function runFold(data, transform) {
19483
19957
  }
19484
19958
 
19485
19959
  // src/transforms/joinaggregate.ts
19486
- function groupKey2(row, groupby) {
19487
- return groupby.map((f) => String(row[f] ?? "")).join("\0");
19488
- }
19489
19960
  function runJoinAggregate(data, transform) {
19490
19961
  const groupby = transform.groupby ?? [];
19491
19962
  const groups = /* @__PURE__ */ new Map();
19492
19963
  for (const row of data) {
19493
- const key = groupKey2(row, groupby);
19964
+ const key = groupKey(row, groupby);
19494
19965
  const bucket = groups.get(key);
19495
19966
  if (bucket) bucket.push(row);
19496
19967
  else groups.set(key, [row]);
@@ -19499,12 +19970,11 @@ function runJoinAggregate(data, transform) {
19499
19970
  for (const [key, rows] of groups) {
19500
19971
  const summary = {};
19501
19972
  for (const { op, field, as } of transform.joinaggregate) {
19502
- const values = op === "count" ? rows.map(() => 1) : rows.map((r) => Number(r[field])).filter((v) => Number.isFinite(v));
19503
- summary[as] = computeAggregate(op, values);
19973
+ summary[as] = aggregateRows(op, field, rows);
19504
19974
  }
19505
19975
  summaries.set(key, summary);
19506
19976
  }
19507
- return data.map((row) => ({ ...row, ...summaries.get(groupKey2(row, groupby)) }));
19977
+ return data.map((row) => ({ ...row, ...summaries.get(groupKey(row, groupby)) }));
19508
19978
  }
19509
19979
 
19510
19980
  // src/transforms/timeunit.ts
@@ -19573,9 +20043,6 @@ function runTimeUnit(data, transform) {
19573
20043
  }
19574
20044
 
19575
20045
  // src/transforms/window.ts
19576
- function groupKey3(row, groupby) {
19577
- return groupby.map((f) => String(row[f] ?? "")).join("\0");
19578
- }
19579
20046
  function tryParseDate(val) {
19580
20047
  if (val == null) return NaN;
19581
20048
  if (typeof val === "number") return new Date(val).getTime();
@@ -19605,7 +20072,7 @@ function runWindow(data, transform) {
19605
20072
  const indexed = data.map((row, i) => ({ row, originalIndex: i }));
19606
20073
  const groups = /* @__PURE__ */ new Map();
19607
20074
  for (const entry of indexed) {
19608
- const key = groupby.length > 0 ? groupKey3(entry.row, groupby) : "";
20075
+ const key = groupby.length > 0 ? groupKey(entry.row, groupby) : "";
19609
20076
  const existing = groups.get(key);
19610
20077
  if (existing) {
19611
20078
  existing.push(entry);
@@ -19642,26 +20109,25 @@ function runWindow(data, transform) {
19642
20109
  case "diff": {
19643
20110
  const lagIdx = i - offset;
19644
20111
  if (lagIdx >= 0) {
19645
- const current = Number(entry.row[def.field]);
19646
- const lagged = Number(sorted[lagIdx].row[def.field]);
19647
- 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;
19648
20115
  }
19649
20116
  break;
19650
20117
  }
19651
20118
  case "pct_change": {
19652
20119
  const lagIdx = i - offset;
19653
20120
  if (lagIdx >= 0) {
19654
- const current = Number(entry.row[def.field]);
19655
- const lagged = Number(sorted[lagIdx].row[def.field]);
19656
- if (Number.isFinite(current) && Number.isFinite(lagged) && lagged !== 0) {
19657
- 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;
19658
20125
  }
19659
20126
  }
19660
20127
  break;
19661
20128
  }
19662
20129
  case "cumsum": {
19663
- const val = Number(entry.row[def.field]);
19664
- const addend = Number.isFinite(val) ? val : 0;
20130
+ const addend = finiteNumber(entry.row[def.field]) ?? 0;
19665
20131
  if (i === 0) {
19666
20132
  computed = addend;
19667
20133
  } else {
@@ -20108,6 +20574,7 @@ function compileChart(spec, optionsInput) {
20108
20574
  const skipY = axisless && !chartSpec.userExplicit.yAxis;
20109
20575
  const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
20110
20576
  data: renderSpec.data,
20577
+ preserveEnd: typeof spec.responsive === "object" ? spec.responsive.preserveEnd : false,
20111
20578
  encoding: renderSpec.encoding,
20112
20579
  skipX,
20113
20580
  skipY,
@@ -20163,7 +20630,7 @@ function compileChart(spec, optionsInput) {
20163
20630
  obstacles.push(...computeMarkObstacles(marks, scales));
20164
20631
  for (const mark of marks) {
20165
20632
  if ("label" in mark && mark.label?.visible) {
20166
- obstacles.push({ ...computeLabelBounds(mark.label), kind: "data-label" });
20633
+ obstacles.push({ ...computeLabelBounds2(mark.label), kind: "data-label" });
20167
20634
  }
20168
20635
  }
20169
20636
  const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
@@ -20222,14 +20689,61 @@ function compileChart(spec, optionsInput) {
20222
20689
  const reserve = footnoteBandHeight(footnotes.length, theme);
20223
20690
  const currentReserve = options.footnoteReserve ?? 0;
20224
20691
  if (!options.frozenChartArea && reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
20692
+ const grow = (spec.chromeLayout ?? options.chromeLayout) === "grow";
20225
20693
  return compileChart(spec, {
20226
20694
  ...optionsInput,
20695
+ height: optionsInput.height + (grow ? reserve - currentReserve : 0),
20227
20696
  footnoteReserve: reserve,
20228
20697
  footnotePass: (options.footnotePass ?? 0) + 1
20229
20698
  });
20230
20699
  }
20231
20700
  }
20232
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
+ }
20233
20747
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
20234
20748
  const altText = chartSpec.a11y?.description ?? generateAltText(
20235
20749
  {
@@ -20249,10 +20763,14 @@ function compileChart(spec, optionsInput) {
20249
20763
  chartSpec.data
20250
20764
  );
20251
20765
  assignAnimationIndices(marks, resolvedAnimation);
20252
- const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
20766
+ const chrome = {
20767
+ ...dims.chrome,
20768
+ ...footnotes ? { footnotes } : {},
20769
+ ...seriesSummary.length ? { seriesSummary } : {}
20770
+ };
20253
20771
  const xInvert = resolveScaleInvert(scales.x);
20254
20772
  const yInvert = resolveScaleInvert(scales.y);
20255
- return {
20773
+ const layout = {
20256
20774
  area: chartArea,
20257
20775
  chrome,
20258
20776
  metrics: dims.metrics,
@@ -20293,6 +20811,18 @@ function compileChart(spec, optionsInput) {
20293
20811
  ...yInvert ? { yInvert } : {},
20294
20812
  ...markRenderMode === "canvas" ? { markRenderMode } : {}
20295
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;
20296
20826
  }
20297
20827
  function compileFaceted(chartSpec, renderSpec, facetChannel, facetDirection, options, theme, strategy, watermark, resolvedAnimation, crosshair, rawSpec, optionsInput) {
20298
20828
  const measure = createMeasureFn(options.measureText);
@@ -20629,6 +21159,176 @@ function compileGeoMap2(spec, options) {
20629
21159
  function compileBarList2(spec, options) {
20630
21160
  return compileBarList(spec, options);
20631
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
+ }
20632
21332
  export {
20633
21333
  ARROWHEAD_LENGTH,
20634
21334
  AUTO_CANVAS_THRESHOLD,
@@ -20639,12 +21339,14 @@ export {
20639
21339
  EXIT_DEFAULTS,
20640
21340
  MAX_3D_NODES,
20641
21341
  UPDATE_DEFAULTS,
21342
+ applySpecPatch,
20642
21343
  buildArcPath,
20643
21344
  buildAreaPath,
20644
21345
  buildEdgeTooltip,
20645
21346
  buildLinePath,
20646
21347
  clampStaggerDelay,
20647
21348
  clearRenderers,
21349
+ compareObservations,
20648
21350
  compile,
20649
21351
  compileBarList2 as compileBarList,
20650
21352
  compileChart,
@@ -20656,17 +21358,23 @@ export {
20656
21358
  compileTileMap2 as compileTileMap,
20657
21359
  computeArrowheadPoints,
20658
21360
  dedupeKeys,
21361
+ diagnoseChart,
20659
21362
  evaluatePredicate,
20660
21363
  facetMinHeight,
20661
21364
  getChartRenderer,
20662
21365
  heuristicMeasure,
20663
21366
  isConditionalValueDef,
20664
21367
  legendGap,
21368
+ linkedEntityKeys,
20665
21369
  normalizeSpec,
21370
+ observationKey,
20666
21371
  parseAnnotationSpans,
21372
+ recommendCompositions,
21373
+ reconcileReaderSelection,
20667
21374
  registerChartRenderer,
20668
21375
  resolveAnimation,
20669
21376
  resolveConditionalValue,
21377
+ resolveObservationIdentities,
20670
21378
  runBin,
20671
21379
  runCalculate,
20672
21380
  runFilter,