@opendata-ai/openchart-engine 8.5.2 → 8.6.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
@@ -2618,16 +2618,16 @@ function computeStackedBars(data, valueField, categoryField, colorField, xScale,
2618
2618
  }
2619
2619
  let cumulativeValue = stackMode === "center" ? -categoryTotal / 2 : 0;
2620
2620
  for (const row of rows) {
2621
- const groupKey3 = String(row[colorField] ?? "");
2621
+ const groupKey4 = String(row[colorField] ?? "");
2622
2622
  const rawValue = Number(row[valueField] ?? 0);
2623
2623
  if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
2624
2624
  const value = stackMode === "normalize" && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
2625
- const color = getColor(scales, groupKey3);
2625
+ const color = getColor(scales, groupKey4);
2626
2626
  const xLeft = xScale(cumulativeValue);
2627
2627
  const xRight = xScale(cumulativeValue + value);
2628
2628
  const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
2629
2629
  const aria = {
2630
- label: `${category}, ${groupKey3}: ${formatLabelValue(rawValue)}`
2630
+ label: `${category}, ${groupKey4}: ${formatLabelValue(rawValue)}`
2631
2631
  };
2632
2632
  marks.push({
2633
2633
  type: "rect",
@@ -2637,7 +2637,7 @@ function computeStackedBars(data, valueField, categoryField, colorField, xScale,
2637
2637
  height: bandwidth,
2638
2638
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2639
2639
  cornerRadius: 0,
2640
- seriesKey: groupKey3,
2640
+ seriesKey: groupKey4,
2641
2641
  data: row,
2642
2642
  aria,
2643
2643
  orient: "horizontal",
@@ -2676,16 +2676,16 @@ function computeGroupedBars(data, valueField, categoryField, colorField, xScale,
2676
2676
  const bandY = yScale(category);
2677
2677
  if (bandY === void 0) continue;
2678
2678
  for (const row of rows) {
2679
- const groupKey3 = String(row[colorField] ?? "");
2679
+ const groupKey4 = String(row[colorField] ?? "");
2680
2680
  const value = Number(row[valueField] ?? 0);
2681
2681
  if (!Number.isFinite(value)) continue;
2682
- const groupIndex = groupIndexMap.get(groupKey3) ?? 0;
2683
- const color = getColor(scales, groupKey3);
2682
+ const groupIndex = groupIndexMap.get(groupKey4) ?? 0;
2683
+ const color = getColor(scales, groupKey4);
2684
2684
  const xPos = value >= 0 ? baseline : xScale(value);
2685
2685
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
2686
2686
  const subY = bandY + groupOffset + groupIndex * (subBandHeight + gap);
2687
2687
  const aria = {
2688
- label: `${category}, ${groupKey3}: ${formatLabelValue(value)}`
2688
+ label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
2689
2689
  };
2690
2690
  marks.push({
2691
2691
  type: "rect",
@@ -2696,7 +2696,7 @@ function computeGroupedBars(data, valueField, categoryField, colorField, xScale,
2696
2696
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2697
2697
  cornerRadius: BAR_CORNER_RADIUS,
2698
2698
  cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2699
- seriesKey: groupKey3,
2699
+ seriesKey: groupKey4,
2700
2700
  data: row,
2701
2701
  aria,
2702
2702
  orient: "horizontal"
@@ -2713,12 +2713,12 @@ function computeColoredBars(data, valueField, categoryField, colorField, xScale,
2713
2713
  if (!Number.isFinite(value)) continue;
2714
2714
  const bandY = yScale(category);
2715
2715
  if (bandY === void 0) continue;
2716
- const groupKey3 = String(row[colorField] ?? "");
2717
- const color = getColor(scales, groupKey3);
2716
+ const groupKey4 = String(row[colorField] ?? "");
2717
+ const color = getColor(scales, groupKey4);
2718
2718
  const xPos = value >= 0 ? baseline : xScale(value);
2719
2719
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
2720
2720
  const aria = {
2721
- label: `${category}, ${groupKey3}: ${formatLabelValue(value)}`
2721
+ label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
2722
2722
  };
2723
2723
  marks.push({
2724
2724
  type: "rect",
@@ -2729,7 +2729,7 @@ function computeColoredBars(data, valueField, categoryField, colorField, xScale,
2729
2729
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2730
2730
  cornerRadius: BAR_CORNER_RADIUS,
2731
2731
  cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2732
- seriesKey: groupKey3,
2732
+ seriesKey: groupKey4,
2733
2733
  data: row,
2734
2734
  aria,
2735
2735
  orient: "horizontal"
@@ -3248,6 +3248,140 @@ var beeswarmRenderer = (spec, scales, chartArea, strategy, theme) => {
3248
3248
  return computeBeeswarmMarks(spec, scales, chartArea, strategy, theme);
3249
3249
  };
3250
3250
 
3251
+ // src/charts/binned-bar/compute.ts
3252
+ import { formatNumber } from "@opendata-ai/openchart-core";
3253
+ var OVERLAP_FILL_OPACITY = 0.55;
3254
+ var BIN_GAP = 1;
3255
+ var MIN_BIN_WIDTH = 1;
3256
+ var MIN_BAR_HEIGHT = 1;
3257
+ function computeBinnedBarMarks(spec, scales, _chartArea, _strategy, theme) {
3258
+ const encoding = spec.encoding;
3259
+ const xChannel = encoding.x;
3260
+ const x2Channel = encoding.x2;
3261
+ const yChannel = encoding.y;
3262
+ if (!xChannel || !x2Channel || !yChannel || !scales.x || !scales.y) return [];
3263
+ const xScale = scales.x.scale;
3264
+ const yScale = scales.y.scale;
3265
+ const yDomain = yScale.domain();
3266
+ const yIncludesZero = yDomain[0] <= 0 && yDomain[1] >= 0;
3267
+ const baseline = yIncludesZero ? yScale(0) : yScale(yDomain[0]);
3268
+ const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
3269
+ const colorField = colorEnc?.type === "quantitative" ? void 0 : colorEnc?.field;
3270
+ const groups = groupByField(spec.data, colorField);
3271
+ const stackEnabled = groups.size > 1 && (yChannel.stack === true || yChannel.stack === "zero" || yChannel.stack === "normalize" || yChannel.stack === "center");
3272
+ const marks = groups.size > 1 && stackEnabled ? stackedBins(groups, spec, scales, xScale, yScale, baseline, theme) : overlappingBins(groups, spec, scales, xScale, yScale, baseline, colorField);
3273
+ stampKeys(marks, xChannel.field, colorField);
3274
+ return marks;
3275
+ }
3276
+ 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;
3280
+ const px0 = xScale(start);
3281
+ const px1 = xScale(end);
3282
+ const left = Math.min(px0, px1);
3283
+ const span = Math.abs(px1 - px0);
3284
+ return { x: left, width: Math.max(span - BIN_GAP, MIN_BIN_WIDTH) };
3285
+ }
3286
+ function ariaFor(row, xField, x2Field, yField, seriesKey) {
3287
+ const start = Number(row[xField]);
3288
+ const end = Number(row[x2Field]);
3289
+ const value = Number(row[yField]);
3290
+ const range = `${formatNumber(start)} to ${formatNumber(end)}`;
3291
+ const prefix = seriesKey ? `${seriesKey}, ` : "";
3292
+ return { label: `${prefix}${range}: ${formatNumber(value)}` };
3293
+ }
3294
+ function overlappingBins(groups, spec, scales, xScale, yScale, baseline, colorField) {
3295
+ const encoding = spec.encoding;
3296
+ const xField = encoding.x.field;
3297
+ const x2Field = encoding.x2.field;
3298
+ const yField = encoding.y.field;
3299
+ const multi = groups.size > 1;
3300
+ const fillOpacity = multi ? spec.markDef.fillOpacity ?? OVERLAP_FILL_OPACITY : spec.markDef.fillOpacity;
3301
+ const marks = [];
3302
+ for (const [key, rows] of groups) {
3303
+ const fill = getColor(scales, key);
3304
+ for (const row of rows) {
3305
+ const geom = binGeometry(row, xField, x2Field, xScale);
3306
+ if (!geom) continue;
3307
+ const value = Number(row[yField]);
3308
+ if (!Number.isFinite(value)) continue;
3309
+ const endY = yScale(value);
3310
+ const top = Math.min(baseline, endY);
3311
+ const height = Math.max(Math.abs(baseline - endY), MIN_BAR_HEIGHT);
3312
+ marks.push({
3313
+ type: "rect",
3314
+ x: geom.x,
3315
+ y: top,
3316
+ width: geom.width,
3317
+ height,
3318
+ fill,
3319
+ fillOpacity,
3320
+ // Histogram bars are contiguous; rounded corners read as gaps.
3321
+ cornerRadius: 0,
3322
+ orient: "vertical",
3323
+ ...colorField ? { seriesKey: key } : {},
3324
+ data: row,
3325
+ aria: ariaFor(row, xField, x2Field, yField, colorField ? key : void 0)
3326
+ });
3327
+ }
3328
+ }
3329
+ return marks;
3330
+ }
3331
+ function stackedBins(groups, spec, scales, xScale, yScale, baseline, theme) {
3332
+ const encoding = spec.encoding;
3333
+ const xField = encoding.x.field;
3334
+ const x2Field = encoding.x2.field;
3335
+ const yField = encoding.y.field;
3336
+ const offsets = /* @__PURE__ */ new Map();
3337
+ const seam = stackSeamStroke(theme);
3338
+ const marks = [];
3339
+ for (const [key, rows] of groups) {
3340
+ const fill = getColor(scales, key);
3341
+ for (const row of rows) {
3342
+ const geom = binGeometry(row, xField, x2Field, xScale);
3343
+ if (!geom) continue;
3344
+ const value = Number(row[yField]);
3345
+ if (!Number.isFinite(value) || value <= 0) continue;
3346
+ const binKey = serializeKeyValue(row[xField]);
3347
+ const used = offsets.get(binKey) ?? 0;
3348
+ const segmentHeight = Math.max(baseline - yScale(value), MIN_BAR_HEIGHT);
3349
+ const top = baseline - used - segmentHeight;
3350
+ offsets.set(binKey, used + segmentHeight);
3351
+ marks.push({
3352
+ type: "rect",
3353
+ x: geom.x,
3354
+ y: top,
3355
+ width: geom.width,
3356
+ height: segmentHeight,
3357
+ fill,
3358
+ cornerRadius: 0,
3359
+ orient: "vertical",
3360
+ stackGroup: binKey,
3361
+ stroke: seam,
3362
+ strokeWidth: 1,
3363
+ seriesKey: key,
3364
+ data: row,
3365
+ aria: ariaFor(row, xField, x2Field, yField, key)
3366
+ });
3367
+ }
3368
+ }
3369
+ return marks;
3370
+ }
3371
+ function stampKeys(marks, xField, colorField) {
3372
+ const raw = marks.map((m) => {
3373
+ const series = colorField ? String(m.data[colorField] ?? "") : "";
3374
+ return `${series}|${serializeKeyValue(m.data[xField])}`;
3375
+ });
3376
+ const keys = dedupeKeys(raw);
3377
+ for (let i = 0; i < marks.length; i++) {
3378
+ marks[i].key = keys[i];
3379
+ }
3380
+ }
3381
+
3382
+ // src/charts/binned-bar/index.ts
3383
+ var binnedBarRenderer = (spec, scales, chartArea, strategy, theme) => computeBinnedBarMarks(spec, scales, chartArea, strategy, theme);
3384
+
3251
3385
  // src/charts/calendar/compute.ts
3252
3386
  import { defaultNumberFormatter as defaultNumberFormatter4, formatDate } from "@opendata-ai/openchart-core";
3253
3387
  import { utcDay, utcMonday, utcSunday } from "d3-time";
@@ -3604,13 +3738,13 @@ function computeColoredColumns(data, categoryField, valueField, colorField, xSca
3604
3738
  if (!Number.isFinite(value)) continue;
3605
3739
  const bandX = xScale(category);
3606
3740
  if (bandX === void 0) continue;
3607
- const groupKey3 = String(row[colorField] ?? "");
3608
- const color = getColor(scales, groupKey3);
3741
+ const groupKey4 = String(row[colorField] ?? "");
3742
+ const color = getColor(scales, groupKey4);
3609
3743
  const yPos = yScale(value);
3610
3744
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
3611
3745
  const y = value >= 0 ? yPos : baseline;
3612
3746
  const aria = {
3613
- label: `${category}, ${groupKey3}: ${formatLabelValue(value)}`
3747
+ label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
3614
3748
  };
3615
3749
  marks.push({
3616
3750
  type: "rect",
@@ -3621,7 +3755,7 @@ function computeColoredColumns(data, categoryField, valueField, colorField, xSca
3621
3755
  fill: color,
3622
3756
  cornerRadius: BAR_CORNER_RADIUS,
3623
3757
  cornerRadiusSides: valueEndCorners("vertical", value < 0),
3624
- seriesKey: groupKey3,
3758
+ seriesKey: groupKey4,
3625
3759
  data: row,
3626
3760
  aria,
3627
3761
  orient: "vertical"
@@ -3650,17 +3784,17 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
3650
3784
  const bandX = xScale(category);
3651
3785
  if (bandX === void 0) continue;
3652
3786
  for (const row of rows) {
3653
- const groupKey3 = String(row[colorField] ?? "");
3787
+ const groupKey4 = String(row[colorField] ?? "");
3654
3788
  const value = Number(row[valueField] ?? 0);
3655
3789
  if (!Number.isFinite(value)) continue;
3656
- const groupIndex = groupIndexMap.get(groupKey3) ?? 0;
3657
- const color = getColor(scales, groupKey3);
3790
+ const groupIndex = groupIndexMap.get(groupKey4) ?? 0;
3791
+ const color = getColor(scales, groupKey4);
3658
3792
  const yPos = yScale(value);
3659
3793
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
3660
3794
  const y = value >= 0 ? yPos : baseline;
3661
3795
  const subX = bandX + groupIndex * (subBandWidth + gap);
3662
3796
  const aria = {
3663
- label: `${category}, ${groupKey3}: ${formatLabelValue(value)}`
3797
+ label: `${category}, ${groupKey4}: ${formatLabelValue(value)}`
3664
3798
  };
3665
3799
  marks.push({
3666
3800
  type: "rect",
@@ -3671,7 +3805,7 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
3671
3805
  fill: color,
3672
3806
  cornerRadius: BAR_CORNER_RADIUS,
3673
3807
  cornerRadiusSides: valueEndCorners("vertical", value < 0),
3674
- seriesKey: groupKey3,
3808
+ seriesKey: groupKey4,
3675
3809
  data: row,
3676
3810
  aria,
3677
3811
  orient: "vertical"
@@ -3693,16 +3827,16 @@ function computeStackedColumns(data, categoryField, valueField, colorField, xSca
3693
3827
  }
3694
3828
  let cumulativeValue = stackMode === "center" ? -categoryTotal / 2 : 0;
3695
3829
  for (const row of rows) {
3696
- const groupKey3 = String(row[colorField] ?? "");
3830
+ const groupKey4 = String(row[colorField] ?? "");
3697
3831
  const rawValue = Number(row[valueField] ?? 0);
3698
3832
  if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
3699
3833
  const value = stackMode === "normalize" && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
3700
- const color = getColor(scales, groupKey3);
3834
+ const color = getColor(scales, groupKey4);
3701
3835
  const yTop = yScale(cumulativeValue + value);
3702
3836
  const yBottom = yScale(cumulativeValue);
3703
3837
  const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
3704
3838
  const aria = {
3705
- label: `${category}, ${groupKey3}: ${formatLabelValue(rawValue)}`
3839
+ label: `${category}, ${groupKey4}: ${formatLabelValue(rawValue)}`
3706
3840
  };
3707
3841
  marks.push({
3708
3842
  type: "rect",
@@ -3712,7 +3846,7 @@ function computeStackedColumns(data, categoryField, valueField, colorField, xSca
3712
3846
  height: columnHeight,
3713
3847
  fill: color,
3714
3848
  cornerRadius: 0,
3715
- seriesKey: groupKey3,
3849
+ seriesKey: groupKey4,
3716
3850
  data: row,
3717
3851
  aria,
3718
3852
  orient: "vertical",
@@ -4515,7 +4649,10 @@ function computeSingleArea(spec, scales, _chartArea, theme) {
4515
4649
  let fillOpacity;
4516
4650
  if (markFill != null) {
4517
4651
  fillValue = markFill;
4518
- fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.opacity ?? (y2Channel ? 0.25 : DEFAULT_FILL_OPACITY);
4652
+ fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.fillOpacity ?? spec.markDef.opacity ?? (y2Channel ? 0.25 : DEFAULT_FILL_OPACITY);
4653
+ } else if (spec.markDef.fillOpacity != null) {
4654
+ fillValue = getRepresentativeColor6(color);
4655
+ fillOpacity = spec.markDef.fillOpacity;
4519
4656
  } else {
4520
4657
  const colorStr = getRepresentativeColor6(color);
4521
4658
  fillValue = buildGradientFill(colorStr, defaultGradientStops);
@@ -4625,10 +4762,10 @@ function computeStackedArea(spec, scales, chartArea, theme) {
4625
4762
  let fillOpacity;
4626
4763
  if (markFill != null) {
4627
4764
  fillValue = markFill;
4628
- fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.opacity ?? 0.7;
4765
+ fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.fillOpacity ?? spec.markDef.opacity ?? 0.7;
4629
4766
  } else {
4630
4767
  fillValue = getRepresentativeColor6(color);
4631
- fillOpacity = STACKED_FILL_OPACITY;
4768
+ fillOpacity = spec.markDef.fillOpacity ?? STACKED_FILL_OPACITY;
4632
4769
  }
4633
4770
  const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
4634
4771
  marks.push({
@@ -5659,7 +5796,7 @@ var rangeRenderer = (spec, scales, chartArea, strategy, theme) => {
5659
5796
  };
5660
5797
 
5661
5798
  // src/charts/rect/index.ts
5662
- import { formatNumber } from "@opendata-ai/openchart-core";
5799
+ import { formatNumber as formatNumber2 } from "@opendata-ai/openchart-core";
5663
5800
  function bandwidthOf(scale) {
5664
5801
  const band = scale?.scale;
5665
5802
  return typeof band?.bandwidth === "function" ? band.bandwidth() : 0;
@@ -5690,7 +5827,7 @@ function computeRectMarks(spec, scales, _chartArea) {
5690
5827
  fill = getColor(scales, "__default__");
5691
5828
  }
5692
5829
  const colorValue = colorEnc ? row[colorEnc.field] : void 0;
5693
- const colorLabel = colorEnc && colorValue != null ? `, ${colorEnc.field}: ${typeof colorValue === "number" ? formatNumber(colorValue) : String(colorValue)}` : "";
5830
+ const colorLabel = colorEnc && colorValue != null ? `, ${colorEnc.field}: ${typeof colorValue === "number" ? formatNumber2(colorValue) : String(colorValue)}` : "";
5694
5831
  const aria = {
5695
5832
  label: `${row[xChannel.field]}, ${row[yChannel.field]}${colorLabel}`
5696
5833
  };
@@ -6228,6 +6365,14 @@ var builtinRenderers = {
6228
6365
  // horizontal bars
6229
6366
  "bar:vertical": columnRenderer,
6230
6367
  // vertical bars (old 'column')
6368
+ "bar:binned": binnedBarRenderer,
6369
+ // histogram: rects sized from a linear x scale via x2
6370
+ // Safety net. `compileChart` always expands sugar before dispatch, so these
6371
+ // keys should be unreachable — but an unregistered key yields zero marks
6372
+ // with no error, i.e. a silently blank chart, which is the exact failure
6373
+ // this whole mark family exists to remove.
6374
+ histogram: binnedBarRenderer,
6375
+ density: areaRenderer,
6231
6376
  point: scatterRenderer,
6232
6377
  // old 'scatter'
6233
6378
  arc: pieRenderer,
@@ -6270,7 +6415,7 @@ import {
6270
6415
  } from "@opendata-ai/openchart-core";
6271
6416
 
6272
6417
  // src/compile/spec-sugar.ts
6273
- import { resolveSchemeName } from "@opendata-ai/openchart-core";
6418
+ import { inferFieldType, resolveSchemeName } from "@opendata-ai/openchart-core";
6274
6419
 
6275
6420
  // src/transforms/aggregate.ts
6276
6421
  function computeAggregate(op, values) {
@@ -6382,9 +6527,11 @@ function uniqueFieldStrings(data, field) {
6382
6527
  }
6383
6528
  return out;
6384
6529
  }
6385
- function expandEncodingSugar(spec) {
6530
+ function expandEncodingSugar(spec, inheritedData) {
6386
6531
  const encoding = spec.encoding;
6387
6532
  if (!encoding) return spec;
6533
+ const markType = markTypeOf(spec);
6534
+ const specData = Array.isArray(spec.data) ? spec.data : inheritedData;
6388
6535
  const generatedTransforms = [];
6389
6536
  const updatedEncoding = { ...encoding };
6390
6537
  let changed = false;
@@ -6394,14 +6541,26 @@ function expandEncodingSugar(spec) {
6394
6541
  if (ch.bin != null && ch.bin !== false) {
6395
6542
  const field = ch.field;
6396
6543
  const outputField = `bin_${field}`;
6544
+ const resolvedType = ch.type ?? (specData && specData.length > 0 ? inferFieldType(specData, field) : void 0);
6545
+ const emitInterval = channel === "x" && resolvedType === "quantitative" && markType === "bar";
6546
+ const endField = `${outputField}_end`;
6397
6547
  const binTransform = {
6398
6548
  bin: ch.bin === true ? true : ch.bin,
6399
6549
  field,
6400
- as: outputField
6550
+ as: emitInterval ? [outputField, endField] : outputField
6401
6551
  };
6402
6552
  generatedTransforms.push(binTransform);
6403
6553
  const { bin: _bin, ...rest } = ch;
6404
- updatedEncoding[channel] = { ...rest, field: outputField };
6554
+ updatedEncoding[channel] = {
6555
+ ...rest,
6556
+ field: outputField,
6557
+ // The axis should read `amount`, not `bin_amount`. Scoped to the same
6558
+ // guard: retitling every binned channel would move existing baselines.
6559
+ ...emitInterval && rest.title == null ? { title: field } : {}
6560
+ };
6561
+ if (emitInterval) {
6562
+ updatedEncoding.x2 = { field: endField, type: "quantitative" };
6563
+ }
6405
6564
  changed = true;
6406
6565
  }
6407
6566
  const current = updatedEncoding[channel] ?? ch;
@@ -6622,7 +6781,9 @@ function expandChannelSugar(spec, warnings) {
6622
6781
  }
6623
6782
  var COUNT_GROUP_CHANNELS = [
6624
6783
  "x",
6784
+ "x2",
6625
6785
  "y",
6786
+ "y2",
6626
6787
  "color",
6627
6788
  "detail",
6628
6789
  "strokeDash",
@@ -6743,6 +6904,123 @@ function resolveSortSugar(spec, inheritedData) {
6743
6904
  }
6744
6905
  return changed ? { ...spec, encoding: updated } : spec;
6745
6906
  }
6907
+ var HISTOGRAM_DEFAULT_MAXBINS = 20;
6908
+ var PROPORTION_FIELD = "__proportion";
6909
+ var GROUP_TOTAL_FIELD = "__group_total";
6910
+ function expandHistogramMark(spec) {
6911
+ if (markTypeOf(spec) !== "histogram") return spec;
6912
+ const markDef = typeof spec.mark === "string" ? {} : { ...spec.mark };
6913
+ const { binCount, normalize, ...restMark } = markDef;
6914
+ const encoding = { ...spec.encoding ?? {} };
6915
+ const x = { ...encoding.x ?? {} };
6916
+ if (x.bin == null) {
6917
+ x.bin = { maxbins: typeof binCount === "number" ? binCount : HISTOGRAM_DEFAULT_MAXBINS };
6918
+ }
6919
+ x.type = x.type ?? "quantitative";
6920
+ encoding.x = x;
6921
+ if (encoding.y == null) {
6922
+ encoding.y = { aggregate: "count" };
6923
+ }
6924
+ const out = {
6925
+ ...spec,
6926
+ mark: { ...restMark, type: "bar" },
6927
+ encoding
6928
+ };
6929
+ if (normalize === true) {
6930
+ out.__histogramNormalize = true;
6931
+ }
6932
+ return out;
6933
+ }
6934
+ var DENSITY_OVERLAP_FILL_OPACITY = 0.4;
6935
+ function distributionGroupFields(encoding) {
6936
+ const fields = [];
6937
+ for (const channel of ["color", "detail"]) {
6938
+ const ch = encoding[channel];
6939
+ if (typeof ch?.field === "string" && !fields.includes(ch.field)) fields.push(ch.field);
6940
+ }
6941
+ return fields;
6942
+ }
6943
+ var DENSITY_VALUE_FIELD = "value";
6944
+ var DENSITY_OUTPUT_FIELD = "density";
6945
+ function expandDensityMark(spec) {
6946
+ if (markTypeOf(spec) !== "density") return spec;
6947
+ const markDef = typeof spec.mark === "string" ? {} : { ...spec.mark };
6948
+ const { bandwidth, cumulative, steps, ...restMark } = markDef;
6949
+ const encoding = { ...spec.encoding ?? {} };
6950
+ const x = { ...encoding.x ?? {} };
6951
+ const field = typeof x.field === "string" ? x.field : void 0;
6952
+ if (field == null) return spec;
6953
+ const groupFields = distributionGroupFields(encoding);
6954
+ const densityTransform = {
6955
+ density: field,
6956
+ as: [DENSITY_VALUE_FIELD, DENSITY_OUTPUT_FIELD]
6957
+ };
6958
+ if (groupFields.length > 0) densityTransform.groupby = groupFields;
6959
+ if (typeof bandwidth === "number") densityTransform.bandwidth = bandwidth;
6960
+ if (cumulative === true) densityTransform.cumulative = true;
6961
+ if (typeof steps === "number") densityTransform.steps = steps;
6962
+ const transforms = (spec.transform ?? []).slice().concat(densityTransform);
6963
+ encoding.x = {
6964
+ ...x,
6965
+ field: DENSITY_VALUE_FIELD,
6966
+ type: "quantitative",
6967
+ ...x.title == null ? { title: field } : {}
6968
+ };
6969
+ const y = { ...encoding.y ?? {} };
6970
+ encoding.y = {
6971
+ title: cumulative === true ? "Cumulative share" : "Density",
6972
+ // A density's absolute height is not a quantity readers interpret, so the
6973
+ // axis is off unless the author asks for it.
6974
+ axis: false,
6975
+ ...y,
6976
+ field: DENSITY_OUTPUT_FIELD,
6977
+ type: "quantitative",
6978
+ // Areas stack by default on a color field; overlapping translucent curves
6979
+ // are the whole point of a density comparison.
6980
+ stack: y.stack ?? null
6981
+ };
6982
+ const out = {
6983
+ ...spec,
6984
+ mark: {
6985
+ interpolate: "linear",
6986
+ ...groupFields.length > 0 ? { fillOpacity: DENSITY_OVERLAP_FILL_OPACITY } : {},
6987
+ ...restMark,
6988
+ type: "area"
6989
+ },
6990
+ encoding,
6991
+ transform: transforms
6992
+ };
6993
+ if (out.crosshair === void 0) out.crosshair = false;
6994
+ if (out.endpointLabels === void 0) out.endpointLabels = false;
6995
+ return out;
6996
+ }
6997
+ function applyHistogramNormalize(spec) {
6998
+ if (spec.__histogramNormalize !== true) return spec;
6999
+ const { __histogramNormalize: _flag, ...rest } = spec;
7000
+ const encoding = { ...rest.encoding ?? {} };
7001
+ const y = { ...encoding.y ?? {} };
7002
+ const countField = typeof y.field === "string" ? y.field : "__count";
7003
+ const groupby = distributionGroupFields(encoding);
7004
+ const transforms = (rest.transform ?? []).slice();
7005
+ transforms.push({
7006
+ joinaggregate: [{ op: "sum", field: countField, as: GROUP_TOTAL_FIELD }],
7007
+ groupby
7008
+ });
7009
+ transforms.push({
7010
+ calculate: { op: "/", field: countField, field2: GROUP_TOTAL_FIELD },
7011
+ as: PROPORTION_FIELD
7012
+ });
7013
+ encoding.y = {
7014
+ ...y,
7015
+ field: PROPORTION_FIELD,
7016
+ type: "quantitative",
7017
+ title: y.title === "Count" || y.title == null ? "Share" : y.title,
7018
+ // `axis: false` is an author turning the axis off; don't spread it into an
7019
+ // object and hand back an axis they asked not to have.
7020
+ axis: y.axis === false || y.axis === null ? y.axis : { format: "percent", ...y.axis ?? {} }
7021
+ };
7022
+ return { ...rest, encoding, transform: transforms };
7023
+ }
6746
7024
  function expandChartSugar(spec, warnings, inheritedData) {
6747
7025
  let out = unwrapDataValues(spec);
6748
7026
  out = expandTopLevelTitle(out);
@@ -6752,9 +7030,12 @@ function expandChartSugar(spec, warnings, inheritedData) {
6752
7030
  out = applyFixedSizeDefault(out);
6753
7031
  out = expandAnnotationSugar(out, warnings);
6754
7032
  out = expandChannelSugar(out, warnings);
6755
- out = expandEncodingSugar(out);
7033
+ out = expandHistogramMark(out);
7034
+ out = expandDensityMark(out);
7035
+ out = expandEncodingSugar(out, inheritedData);
6756
7036
  out = resolveSortSugar(out, inheritedData);
6757
7037
  out = expandCountAggregate(out);
7038
+ out = applyHistogramNormalize(out);
6758
7039
  return out;
6759
7040
  }
6760
7041
  function expandLayerSugar(spec, warnings, inheritedData, parentEncoding) {
@@ -7074,7 +7355,7 @@ function normalizeChrome(chrome) {
7074
7355
  brand: normalizeChromeField(chrome.brand)
7075
7356
  };
7076
7357
  }
7077
- function inferFieldType(data, field) {
7358
+ function inferFieldType2(data, field) {
7078
7359
  const sampleSize = Math.min(50, data.length);
7079
7360
  let numericCount = 0;
7080
7361
  let dateCount = 0;
@@ -7116,13 +7397,13 @@ function inferEncodingTypes(encoding, data, warnings) {
7116
7397
  if ("condition" in spec) continue;
7117
7398
  if (!("field" in spec)) continue;
7118
7399
  if (!spec.type) {
7119
- const inferred = inferFieldType(data, spec.field);
7400
+ const inferred = inferFieldType2(data, spec.field);
7120
7401
  result[channel] = { ...spec, type: inferred };
7121
7402
  warnings.push(
7122
7403
  `Inferred encoding.${channel}.type as "${inferred}" from data values for field "${spec.field}"`
7123
7404
  );
7124
7405
  } else {
7125
- const actualType = inferFieldType(data, spec.field);
7406
+ const actualType = inferFieldType2(data, spec.field);
7126
7407
  if (spec.type === "nominal" && actualType === "temporal") {
7127
7408
  warnings.push(`Field "${spec.field}" looks temporal but was declared as nominal`);
7128
7409
  }
@@ -7599,7 +7880,7 @@ function flattenLayers(spec, parentData, parentEncoding, parentTransforms, paren
7599
7880
 
7600
7881
  // src/compiler/validate.ts
7601
7882
  import {
7602
- inferFieldType as inferFieldType2,
7883
+ inferFieldType as inferFieldType3,
7603
7884
  MARK_ENCODING_RULES,
7604
7885
  MARK_TYPES,
7605
7886
  resolveSchemeName as resolveSchemeName2,
@@ -7718,8 +7999,8 @@ function validateRangeSpec(spec, errors) {
7718
7999
  if (!x?.field || typeof x.field !== "string" || !y?.field || typeof y.field !== "string") {
7719
8000
  return;
7720
8001
  }
7721
- const xType = x.type ?? inferFieldType2(data, x.field);
7722
- const yType = y.type ?? inferFieldType2(data, y.field);
8002
+ const xType = x.type ?? inferFieldType3(data, x.field);
8003
+ const yType = y.type ?? inferFieldType3(data, y.field);
7723
8004
  const xIsCategory = xType === "nominal" || xType === "ordinal";
7724
8005
  const yIsCategory = yType === "nominal" || yType === "ordinal";
7725
8006
  if (xIsCategory && yIsCategory) {
@@ -7856,7 +8137,7 @@ function validateCalendarSpec(spec, errors) {
7856
8137
  }
7857
8138
  const x = encoding.x;
7858
8139
  if (!x?.field || typeof x.field !== "string") return;
7859
- const xType = x.type ?? inferFieldType2(data, x.field);
8140
+ const xType = x.type ?? inferFieldType3(data, x.field);
7860
8141
  if (xType !== "temporal") {
7861
8142
  if (!x.type) {
7862
8143
  errors.push({
@@ -8065,12 +8346,16 @@ function validateChartSpec(spec, errors) {
8065
8346
  if (typeof f === "string") transformFields.add(f);
8066
8347
  }
8067
8348
  }
8068
- const opList = t.aggregate ?? t.window;
8349
+ const opList = t.aggregate ?? t.window ?? t.joinaggregate;
8069
8350
  if (Array.isArray(opList)) {
8070
8351
  for (const op of opList) {
8071
8352
  if (op && typeof op.as === "string") transformFields.add(op.as);
8072
8353
  }
8073
8354
  }
8355
+ if (typeof t.density === "string" && t.as == null) {
8356
+ transformFields.add("value");
8357
+ transformFields.add("density");
8358
+ }
8074
8359
  if (Array.isArray(t.groupby)) {
8075
8360
  for (const g of t.groupby) {
8076
8361
  if (typeof g === "string") transformFields.add(g);
@@ -8205,7 +8490,7 @@ function validateChartSpec(spec, errors) {
8205
8490
  suggestion: "Provide a series name (string) or an array of series names."
8206
8491
  });
8207
8492
  } else {
8208
- const colorType = colorCh.type ?? (colorCh.field && Array.isArray(spec.data) && spec.data.length > 0 ? inferFieldType2(spec.data, colorCh.field) : void 0);
8493
+ const colorType = colorCh.type ?? (colorCh.field && Array.isArray(spec.data) && spec.data.length > 0 ? inferFieldType3(spec.data, colorCh.field) : void 0);
8209
8494
  if (colorType === "quantitative") {
8210
8495
  errors.push({
8211
8496
  message: "Spec error: encoding.color.highlight is not supported on quantitative color channels",
@@ -8336,7 +8621,7 @@ function validateChartSpec(spec, errors) {
8336
8621
  const data = Array.isArray(spec.data) ? spec.data : [];
8337
8622
  const colorCh = encoding?.color;
8338
8623
  if (colorCh && typeof colorCh === "object" && typeof colorCh.field === "string") {
8339
- const colorType = colorCh.type ?? inferFieldType2(data, colorCh.field);
8624
+ const colorType = colorCh.type ?? inferFieldType3(data, colorCh.field);
8340
8625
  if (colorType !== "quantitative") {
8341
8626
  const distinct = new Set(data.map((row) => row[colorCh.field]));
8342
8627
  if (distinct.size > 1) {
@@ -9756,8 +10041,14 @@ function computeBandRowObstacles(marks, scales) {
9756
10041
  }
9757
10042
  return obstacles;
9758
10043
  }
10044
+ function isBinnedBarEncoding(markType, encoding) {
10045
+ return markType === "bar" && encoding.x?.type === "quantitative" && !!encoding.x2 && encoding.y?.type === "quantitative";
10046
+ }
9759
10047
  function resolveRendererKey(markType, encoding, markDef) {
9760
10048
  if (markType === "bar") {
10049
+ if (isBinnedBarEncoding(markType, encoding)) {
10050
+ return "bar:binned";
10051
+ }
9761
10052
  const xType = encoding.x?.type;
9762
10053
  const yType = encoding.y?.type;
9763
10054
  const isVertical = (xType === "nominal" || xType === "ordinal" || xType === "temporal") && yType === "quantitative";
@@ -10750,10 +11041,25 @@ function computeScales(spec, chartArea, data) {
10750
11041
  }
10751
11042
  }
10752
11043
  }
11044
+ if (spec.markType === "area" && encoding.x?.type === "quantitative" && encoding.x.scale?.zero === void 0) {
11045
+ if (!encoding.x.scale) {
11046
+ encoding.x.scale = { zero: false };
11047
+ } else {
11048
+ encoding.x.scale.zero = false;
11049
+ }
11050
+ }
11051
+ const binnedX = isBinnedBarEncoding(spec.markType, encoding) ? encoding.x : void 0;
11052
+ if (binnedX && binnedX.scale?.zero === void 0) {
11053
+ if (!binnedX.scale) {
11054
+ binnedX.scale = { zero: false };
11055
+ } else {
11056
+ binnedX.scale.zero = false;
11057
+ }
11058
+ }
10753
11059
  if (encoding.x) {
10754
11060
  let xData = data;
10755
11061
  let xChannel = encoding.x;
10756
- if (spec.markType === "range" && encoding.x2 && encoding.x.type === "quantitative") {
11062
+ if ((spec.markType === "range" || spec.markType === "bar") && encoding.x2 && encoding.x.type === "quantitative") {
10757
11063
  const xField = encoding.x.field;
10758
11064
  const x2Field = encoding.x2.field;
10759
11065
  xData = [...data, ...data.map((row) => ({ [xField]: row[x2Field] }))];
@@ -10825,7 +11131,7 @@ function computeScales(spec, chartArea, data) {
10825
11131
  if (encoding.y) {
10826
11132
  let yData = data;
10827
11133
  let yChannel = encoding.y;
10828
- if (spec.markType === "range" && encoding.y2 && encoding.y.type === "quantitative") {
11134
+ if ((spec.markType === "range" || spec.markType === "bar") && encoding.y2 && encoding.y.type === "quantitative") {
10829
11135
  const yField = encoding.y.field;
10830
11136
  const y2Field = encoding.y2.field;
10831
11137
  yData = [...data, ...data.map((row) => ({ [yField]: row[y2Field] }))];
@@ -11378,7 +11684,7 @@ import {
11378
11684
  abbreviateNumber,
11379
11685
  buildD3Formatter,
11380
11686
  estimateTextWidth as estimateTextWidth13,
11381
- formatNumber as formatNumber2
11687
+ formatNumber as formatNumber3
11382
11688
  } from "@opendata-ai/openchart-core";
11383
11689
  import { scaleLinear as scaleLinear3, scaleSqrt as scaleSqrt3 } from "d3-scale";
11384
11690
  var LABEL_GAP = 6;
@@ -11399,7 +11705,7 @@ function formatValue(value, formatStr) {
11399
11705
  if (fmt2) return fmt2(value);
11400
11706
  }
11401
11707
  if (Math.abs(value) >= 1e3) return abbreviateNumber(value);
11402
- return formatNumber2(value);
11708
+ return formatNumber3(value);
11403
11709
  }
11404
11710
  function niceFloor(value) {
11405
11711
  if (!Number.isFinite(value) || value <= 0) return value;
@@ -12321,7 +12627,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12321
12627
  const yAxisSuppressed = encoding.y?.axis === false;
12322
12628
  let leftGutter = hPad + axisMargin;
12323
12629
  if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
12324
- if (encoding.y.type === "nominal" || encoding.y.type === "ordinal" || renderSpec.markType === "bar" || renderSpec.markType === "circle" || renderSpec.markType === "lollipop") {
12630
+ const isBinnedBar = isBinnedBarEncoding(renderSpec.markType, encoding);
12631
+ if (!isBinnedBar && (encoding.y.type === "nominal" || encoding.y.type === "ordinal" || renderSpec.markType === "bar" || renderSpec.markType === "circle" || renderSpec.markType === "lollipop")) {
12325
12632
  const yField = encoding.y.field;
12326
12633
  const yLabelField = yAxisCfg?.labelField;
12327
12634
  let maxLabelWidth = 0;
@@ -13364,7 +13671,7 @@ import {
13364
13671
  CATEGORICAL_PALETTE as CATEGORICAL_PALETTE2,
13365
13672
  computeChrome as computeChrome3,
13366
13673
  estimateTextWidth as estimateTextWidth18,
13367
- formatNumber as formatNumber3,
13674
+ formatNumber as formatNumber4,
13368
13675
  getBreakpoint,
13369
13676
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION2,
13370
13677
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN2,
@@ -13816,7 +14123,7 @@ function compileGeoMap(spec, options) {
13816
14123
  compileWarnings.push(...filteredJoinWarnings);
13817
14124
  }
13818
14125
  const droppedFeatures = [];
13819
- const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber3;
14126
+ const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber4;
13820
14127
  const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
13821
14128
  const noDataPattern = {
13822
14129
  type: "diagonal",
@@ -15727,7 +16034,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15727
16034
  const yAxisSuppressed = encoding.y?.axis === false;
15728
16035
  const yIsInline = yIsInlinePre;
15729
16036
  if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
15730
- if (!plan && (spec.markType === "bar" || spec.markType === "circle" || spec.markType === "lollipop" || encoding.y.type === "nominal" || encoding.y.type === "ordinal")) {
16037
+ const isBinnedBar = isBinnedBarEncoding(spec.markType, encoding);
16038
+ if (!plan && !isBinnedBar && (spec.markType === "bar" || spec.markType === "circle" || spec.markType === "lollipop" || encoding.y.type === "nominal" || encoding.y.type === "ordinal")) {
15731
16039
  const yField = encoding.y.field;
15732
16040
  const yLabelField = encoding.y.axis?.labelField;
15733
16041
  let maxLabelWidth = 0;
@@ -18593,6 +18901,60 @@ function computeWaffleTooltips(spec, marks) {
18593
18901
  }
18594
18902
  return descriptors;
18595
18903
  }
18904
+ function isBinnedBarSpec(spec) {
18905
+ return isBinnedBarEncoding(spec.markType, spec.encoding);
18906
+ }
18907
+ function computeBinnedBarTooltips(spec, marks) {
18908
+ const encoding = spec.encoding;
18909
+ const descriptors = /* @__PURE__ */ new Map();
18910
+ const xCh = encoding.x;
18911
+ const x2Ch = encoding.x2;
18912
+ const yCh = encoding.y;
18913
+ if (!xCh || !x2Ch || !yCh) return descriptors;
18914
+ const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
18915
+ const allChannels = [
18916
+ xCh,
18917
+ yCh,
18918
+ colorEnc,
18919
+ ...encoding.tooltip ? Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip] : []
18920
+ ].filter((ch) => !!ch && "field" in ch);
18921
+ const fmtCache = buildFormatterCache(spec.data, allChannels);
18922
+ const xFormatter = getFormatter(fmtCache, xCh);
18923
+ const stackTotals = computeRectStackTotals(marks, encoding, fmtCache);
18924
+ for (let i = 0; i < marks.length; i++) {
18925
+ const mark = marks[i];
18926
+ if (mark.type !== "rect") continue;
18927
+ const row = mark.data;
18928
+ const start = formatValue2(row[xCh.field], "quantitative", xFormatter);
18929
+ const end = formatValue2(row[x2Ch.field], "quantitative", xFormatter);
18930
+ const title = `${start} \u2013 ${end}`;
18931
+ if (encoding.tooltip) {
18932
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
18933
+ descriptors.set(`rect-${i}`, {
18934
+ title,
18935
+ fields: buildExplicitTooltipFields(row, channels, fmtCache)
18936
+ });
18937
+ continue;
18938
+ }
18939
+ const fields = [];
18940
+ if (colorEnc) {
18941
+ fields.push({
18942
+ label: resolveLabel(colorEnc),
18943
+ value: String(row[colorEnc.field] ?? ""),
18944
+ color: getRepresentativeColor14(mark.fill)
18945
+ });
18946
+ }
18947
+ fields.push({
18948
+ label: resolveLabel(yCh),
18949
+ value: formatValue2(row[yCh.field], yCh.type, getFormatter(fmtCache, yCh)),
18950
+ ...colorEnc ? {} : { color: getRepresentativeColor14(mark.fill) }
18951
+ });
18952
+ const total = mark.stackGroup !== void 0 ? stackTotals?.get(mark.stackGroup) : void 0;
18953
+ if (total) fields.push(total);
18954
+ descriptors.set(`rect-${i}`, { title, fields });
18955
+ }
18956
+ return descriptors;
18957
+ }
18596
18958
  function computeParliamentTooltips(spec, marks) {
18597
18959
  const encoding = spec.encoding;
18598
18960
  const descriptors = /* @__PURE__ */ new Map();
@@ -18657,6 +19019,9 @@ function computeTooltipDescriptors(spec, marks) {
18657
19019
  if (spec.markType === "parliament") {
18658
19020
  return computeParliamentTooltips(spec, marks);
18659
19021
  }
19022
+ if (isBinnedBarSpec(spec)) {
19023
+ return computeBinnedBarTooltips(spec, marks);
19024
+ }
18660
19025
  const encoding = spec.encoding;
18661
19026
  const descriptors = /* @__PURE__ */ new Map();
18662
19027
  const allChannels = [
@@ -18788,6 +19153,135 @@ function runCalculate(data, transform) {
18788
19153
  }));
18789
19154
  }
18790
19155
 
19156
+ // src/transforms/density.ts
19157
+ import { deviation, quantile } from "d3-array";
19158
+ var DEFAULT_STEPS = 200;
19159
+ var MAX_STEPS = 1e4;
19160
+ var EXTENT_PADDING_BANDWIDTHS = 3;
19161
+ var DEGENERATE_BANDWIDTH = 1;
19162
+ var SQRT_2PI = Math.sqrt(2 * Math.PI);
19163
+ function silvermanBandwidth(values) {
19164
+ const n = values.length;
19165
+ if (n < 2) return DEGENERATE_BANDWIDTH;
19166
+ const sd = deviation(values) ?? 0;
19167
+ const sorted = [...values].sort((a, b) => a - b);
19168
+ const q1 = quantile(sorted, 0.25) ?? 0;
19169
+ const q3 = quantile(sorted, 0.75) ?? 0;
19170
+ const iqrScale = (q3 - q1) / 1.34;
19171
+ const spread = iqrScale > 0 ? Math.min(sd, iqrScale) : sd;
19172
+ if (!(spread > 0)) return DEGENERATE_BANDWIDTH;
19173
+ return 1.06 * spread * n ** (-1 / 5);
19174
+ }
19175
+ function resolveSteps(requested) {
19176
+ if (requested == null || !Number.isFinite(requested)) return DEFAULT_STEPS;
19177
+ return Math.min(MAX_STEPS, Math.max(2, Math.round(requested)));
19178
+ }
19179
+ function isUsableExtent(extent3) {
19180
+ const [lo, hi] = extent3;
19181
+ return Number.isFinite(lo) && Number.isFinite(hi) && hi > lo;
19182
+ }
19183
+ function gaussian(u) {
19184
+ return Math.exp(-0.5 * u * u) / SQRT_2PI;
19185
+ }
19186
+ function groupRows(data, groupby) {
19187
+ if (groupby.length === 0) return /* @__PURE__ */ new Map([["", data]]);
19188
+ const groups = /* @__PURE__ */ new Map();
19189
+ for (const row of data) {
19190
+ const key = groupby.map((f) => String(row[f] ?? "")).join("\0");
19191
+ const bucket = groups.get(key);
19192
+ if (bucket) bucket.push(row);
19193
+ else groups.set(key, [row]);
19194
+ }
19195
+ return groups;
19196
+ }
19197
+ function runDensity(data, transform) {
19198
+ const field = transform.density;
19199
+ const groupby = transform.groupby ?? [];
19200
+ const steps = resolveSteps(transform.steps);
19201
+ const [valueAs, densityAs] = transform.as ?? ["value", "density"];
19202
+ const cumulative = transform.cumulative === true;
19203
+ const counts = transform.counts === true;
19204
+ const groups = groupRows(data, groupby);
19205
+ const prepared = [];
19206
+ let widestBandwidth = 0;
19207
+ for (const rows of groups.values()) {
19208
+ const samples = [];
19209
+ for (const r of rows) {
19210
+ const raw = r[field];
19211
+ if (raw == null || raw === "") continue;
19212
+ const v = Number(raw);
19213
+ if (Number.isFinite(v)) samples.push(v);
19214
+ }
19215
+ if (samples.length === 0) continue;
19216
+ const bandwidth = transform.bandwidth && transform.bandwidth > 0 ? transform.bandwidth : silvermanBandwidth(samples);
19217
+ widestBandwidth = Math.max(widestBandwidth, bandwidth);
19218
+ prepared.push({ rows, samples, bandwidth });
19219
+ }
19220
+ if (prepared.length === 0) return [];
19221
+ let lo;
19222
+ let hi;
19223
+ if (transform.extent && isUsableExtent(transform.extent)) {
19224
+ [lo, hi] = transform.extent;
19225
+ } else {
19226
+ let dataMin = Number.POSITIVE_INFINITY;
19227
+ let dataMax = Number.NEGATIVE_INFINITY;
19228
+ for (const p of prepared) {
19229
+ for (const v of p.samples) {
19230
+ if (v < dataMin) dataMin = v;
19231
+ if (v > dataMax) dataMax = v;
19232
+ }
19233
+ }
19234
+ const pad = widestBandwidth * EXTENT_PADDING_BANDWIDTHS;
19235
+ lo = dataMin - pad;
19236
+ hi = dataMax + pad;
19237
+ }
19238
+ if (!(hi > lo)) {
19239
+ lo -= DEGENERATE_BANDWIDTH;
19240
+ hi += DEGENERATE_BANDWIDTH;
19241
+ }
19242
+ const stride = (hi - lo) / (steps - 1);
19243
+ const out = [];
19244
+ for (const { rows, samples, bandwidth } of prepared) {
19245
+ const groupValues = {};
19246
+ for (const f of groupby) groupValues[f] = rows[0][f];
19247
+ const n = samples.length;
19248
+ const scale = counts ? n : 1;
19249
+ const densities = new Array(steps);
19250
+ for (let i = 0; i < steps; i++) {
19251
+ const x = lo + i * stride;
19252
+ let sum = 0;
19253
+ for (const v of samples) sum += gaussian((x - v) / bandwidth);
19254
+ densities[i] = sum / (n * bandwidth);
19255
+ }
19256
+ if (cumulative) {
19257
+ const running = new Array(steps);
19258
+ let acc = 0;
19259
+ running[0] = 0;
19260
+ for (let i = 1; i < steps; i++) {
19261
+ acc += (densities[i] + densities[i - 1]) / 2 * stride;
19262
+ running[i] = acc;
19263
+ }
19264
+ const total = running[steps - 1] || 1;
19265
+ for (let i = 0; i < steps; i++) {
19266
+ out.push({
19267
+ ...groupValues,
19268
+ [valueAs]: lo + i * stride,
19269
+ [densityAs]: running[i] / total * scale
19270
+ });
19271
+ }
19272
+ continue;
19273
+ }
19274
+ for (let i = 0; i < steps; i++) {
19275
+ out.push({
19276
+ ...groupValues,
19277
+ [valueAs]: lo + i * stride,
19278
+ [densityAs]: densities[i] * scale
19279
+ });
19280
+ }
19281
+ }
19282
+ return out;
19283
+ }
19284
+
18791
19285
  // src/transforms/filter.ts
18792
19286
  function applyOffset2(anchor, offset, unit) {
18793
19287
  const d = new Date(anchor.getTime());
@@ -18887,6 +19381,31 @@ function runFold(data, transform) {
18887
19381
  return result;
18888
19382
  }
18889
19383
 
19384
+ // src/transforms/joinaggregate.ts
19385
+ function groupKey2(row, groupby) {
19386
+ return groupby.map((f) => String(row[f] ?? "")).join("\0");
19387
+ }
19388
+ function runJoinAggregate(data, transform) {
19389
+ const groupby = transform.groupby ?? [];
19390
+ const groups = /* @__PURE__ */ new Map();
19391
+ for (const row of data) {
19392
+ const key = groupKey2(row, groupby);
19393
+ const bucket = groups.get(key);
19394
+ if (bucket) bucket.push(row);
19395
+ else groups.set(key, [row]);
19396
+ }
19397
+ const summaries = /* @__PURE__ */ new Map();
19398
+ for (const [key, rows] of groups) {
19399
+ const summary = {};
19400
+ for (const { op, field, as } of transform.joinaggregate) {
19401
+ const values = op === "count" ? rows.map(() => 1) : rows.map((r) => Number(r[field])).filter((v) => Number.isFinite(v));
19402
+ summary[as] = computeAggregate(op, values);
19403
+ }
19404
+ summaries.set(key, summary);
19405
+ }
19406
+ return data.map((row) => ({ ...row, ...summaries.get(groupKey2(row, groupby)) }));
19407
+ }
19408
+
18890
19409
  // src/transforms/timeunit.ts
18891
19410
  function extractTimeUnit(date, unit) {
18892
19411
  switch (unit) {
@@ -18953,7 +19472,7 @@ function runTimeUnit(data, transform) {
18953
19472
  }
18954
19473
 
18955
19474
  // src/transforms/window.ts
18956
- function groupKey2(row, groupby) {
19475
+ function groupKey3(row, groupby) {
18957
19476
  return groupby.map((f) => String(row[f] ?? "")).join("\0");
18958
19477
  }
18959
19478
  function tryParseDate(val) {
@@ -18985,7 +19504,7 @@ function runWindow(data, transform) {
18985
19504
  const indexed = data.map((row, i) => ({ row, originalIndex: i }));
18986
19505
  const groups = /* @__PURE__ */ new Map();
18987
19506
  for (const entry of indexed) {
18988
- const key = groupby.length > 0 ? groupKey2(entry.row, groupby) : "";
19507
+ const key = groupby.length > 0 ? groupKey3(entry.row, groupby) : "";
18989
19508
  const existing = groups.get(key);
18990
19509
  if (existing) {
18991
19510
  existing.push(entry);
@@ -19091,6 +19610,10 @@ function runTransforms(data, transforms) {
19091
19610
  result = runTimeUnit(result, transform);
19092
19611
  } else if ("aggregate" in transform) {
19093
19612
  result = runAggregate(result, transform);
19613
+ } else if ("joinaggregate" in transform) {
19614
+ result = runJoinAggregate(result, transform);
19615
+ } else if ("density" in transform) {
19616
+ result = runDensity(result, transform);
19094
19617
  } else if ("fold" in transform) {
19095
19618
  result = runFold(result, transform);
19096
19619
  } else if ("window" in transform) {
@@ -19455,6 +19978,14 @@ function compileChart(spec, optionsInput) {
19455
19978
  height: chartArea.height
19456
19979
  };
19457
19980
  }
19981
+ if (chartSpec.userExplicit.labels && chartSpec.labels?.density !== "none" && isBinnedBarEncoding(chartSpec.markType, renderSpec.encoding)) {
19982
+ emitSpecWarnings(
19983
+ [
19984
+ "[openchart] `labels` has no effect on a histogram (binned bar): a value over every bin obscures the distribution. Remove it, or use mark: 'bar' over pre-aggregated categories if you need per-bar values."
19985
+ ],
19986
+ options.onWarn
19987
+ );
19988
+ }
19458
19989
  const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
19459
19990
  emitSpecWarnings(
19460
19991
  applyColorScaleRange(