@opendata-ai/openchart-engine 8.5.2 → 8.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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,33 @@ 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`;
6547
+ const chScale = ch.scale;
6548
+ let binDef;
6549
+ if (chScale?.type === "log") {
6550
+ binDef = ch.bin === true ? { scaleType: "log" } : { ...ch.bin, scaleType: "log" };
6551
+ } else {
6552
+ binDef = ch.bin === true ? true : ch.bin;
6553
+ }
6397
6554
  const binTransform = {
6398
- bin: ch.bin === true ? true : ch.bin,
6555
+ bin: binDef,
6399
6556
  field,
6400
- as: outputField
6557
+ as: emitInterval ? [outputField, endField] : outputField
6401
6558
  };
6402
6559
  generatedTransforms.push(binTransform);
6403
6560
  const { bin: _bin, ...rest } = ch;
6404
- updatedEncoding[channel] = { ...rest, field: outputField };
6561
+ updatedEncoding[channel] = {
6562
+ ...rest,
6563
+ field: outputField,
6564
+ // The axis should read `amount`, not `bin_amount`. Scoped to the same
6565
+ // guard: retitling every binned channel would move existing baselines.
6566
+ ...emitInterval && rest.title == null ? { title: field } : {}
6567
+ };
6568
+ if (emitInterval) {
6569
+ updatedEncoding.x2 = { field: endField, type: "quantitative" };
6570
+ }
6405
6571
  changed = true;
6406
6572
  }
6407
6573
  const current = updatedEncoding[channel] ?? ch;
@@ -6622,7 +6788,9 @@ function expandChannelSugar(spec, warnings) {
6622
6788
  }
6623
6789
  var COUNT_GROUP_CHANNELS = [
6624
6790
  "x",
6791
+ "x2",
6625
6792
  "y",
6793
+ "y2",
6626
6794
  "color",
6627
6795
  "detail",
6628
6796
  "strokeDash",
@@ -6743,6 +6911,130 @@ function resolveSortSugar(spec, inheritedData) {
6743
6911
  }
6744
6912
  return changed ? { ...spec, encoding: updated } : spec;
6745
6913
  }
6914
+ var HISTOGRAM_DEFAULT_MAXBINS = 20;
6915
+ var PROPORTION_FIELD = "__proportion";
6916
+ var GROUP_TOTAL_FIELD = "__group_total";
6917
+ function expandHistogramMark(spec) {
6918
+ if (markTypeOf(spec) !== "histogram") return spec;
6919
+ const markDef = typeof spec.mark === "string" ? {} : { ...spec.mark };
6920
+ const { binCount, normalize, ...restMark } = markDef;
6921
+ const encoding = { ...spec.encoding ?? {} };
6922
+ const x = { ...encoding.x ?? {} };
6923
+ if (x.bin == null) {
6924
+ const binParams = {
6925
+ maxbins: typeof binCount === "number" ? binCount : HISTOGRAM_DEFAULT_MAXBINS
6926
+ };
6927
+ const xScale = x.scale;
6928
+ if (xScale?.type === "log") {
6929
+ binParams.scaleType = "log";
6930
+ }
6931
+ x.bin = binParams;
6932
+ }
6933
+ x.type = x.type ?? "quantitative";
6934
+ encoding.x = x;
6935
+ if (encoding.y == null) {
6936
+ encoding.y = { aggregate: "count" };
6937
+ }
6938
+ const out = {
6939
+ ...spec,
6940
+ mark: { ...restMark, type: "bar" },
6941
+ encoding
6942
+ };
6943
+ if (normalize === true) {
6944
+ out.__histogramNormalize = true;
6945
+ }
6946
+ return out;
6947
+ }
6948
+ var DENSITY_OVERLAP_FILL_OPACITY = 0.4;
6949
+ function distributionGroupFields(encoding) {
6950
+ const fields = [];
6951
+ for (const channel of ["color", "detail"]) {
6952
+ const ch = encoding[channel];
6953
+ if (typeof ch?.field === "string" && !fields.includes(ch.field)) fields.push(ch.field);
6954
+ }
6955
+ return fields;
6956
+ }
6957
+ var DENSITY_VALUE_FIELD = "value";
6958
+ var DENSITY_OUTPUT_FIELD = "density";
6959
+ function expandDensityMark(spec) {
6960
+ if (markTypeOf(spec) !== "density") return spec;
6961
+ const markDef = typeof spec.mark === "string" ? {} : { ...spec.mark };
6962
+ const { bandwidth, cumulative, steps, ...restMark } = markDef;
6963
+ const encoding = { ...spec.encoding ?? {} };
6964
+ const x = { ...encoding.x ?? {} };
6965
+ const field = typeof x.field === "string" ? x.field : void 0;
6966
+ if (field == null) return spec;
6967
+ const groupFields = distributionGroupFields(encoding);
6968
+ const densityTransform = {
6969
+ density: field,
6970
+ as: [DENSITY_VALUE_FIELD, DENSITY_OUTPUT_FIELD]
6971
+ };
6972
+ if (groupFields.length > 0) densityTransform.groupby = groupFields;
6973
+ if (typeof bandwidth === "number") densityTransform.bandwidth = bandwidth;
6974
+ if (cumulative === true) densityTransform.cumulative = true;
6975
+ if (typeof steps === "number") densityTransform.steps = steps;
6976
+ const transforms = (spec.transform ?? []).slice().concat(densityTransform);
6977
+ encoding.x = {
6978
+ ...x,
6979
+ field: DENSITY_VALUE_FIELD,
6980
+ type: "quantitative",
6981
+ ...x.title == null ? { title: field } : {}
6982
+ };
6983
+ const y = { ...encoding.y ?? {} };
6984
+ encoding.y = {
6985
+ title: cumulative === true ? "Cumulative share" : "Density",
6986
+ // A density's absolute height is not a quantity readers interpret, so the
6987
+ // axis is off unless the author asks for it.
6988
+ axis: false,
6989
+ ...y,
6990
+ field: DENSITY_OUTPUT_FIELD,
6991
+ type: "quantitative",
6992
+ // Areas stack by default on a color field; overlapping translucent curves
6993
+ // are the whole point of a density comparison.
6994
+ stack: y.stack ?? null
6995
+ };
6996
+ const out = {
6997
+ ...spec,
6998
+ mark: {
6999
+ interpolate: "linear",
7000
+ ...groupFields.length > 0 ? { fillOpacity: DENSITY_OVERLAP_FILL_OPACITY } : {},
7001
+ ...restMark,
7002
+ type: "area"
7003
+ },
7004
+ encoding,
7005
+ transform: transforms
7006
+ };
7007
+ if (out.crosshair === void 0) out.crosshair = false;
7008
+ if (out.endpointLabels === void 0) out.endpointLabels = false;
7009
+ return out;
7010
+ }
7011
+ function applyHistogramNormalize(spec) {
7012
+ if (spec.__histogramNormalize !== true) return spec;
7013
+ const { __histogramNormalize: _flag, ...rest } = spec;
7014
+ const encoding = { ...rest.encoding ?? {} };
7015
+ const y = { ...encoding.y ?? {} };
7016
+ const countField = typeof y.field === "string" ? y.field : "__count";
7017
+ const groupby = distributionGroupFields(encoding);
7018
+ const transforms = (rest.transform ?? []).slice();
7019
+ transforms.push({
7020
+ joinaggregate: [{ op: "sum", field: countField, as: GROUP_TOTAL_FIELD }],
7021
+ groupby
7022
+ });
7023
+ transforms.push({
7024
+ calculate: { op: "/", field: countField, field2: GROUP_TOTAL_FIELD },
7025
+ as: PROPORTION_FIELD
7026
+ });
7027
+ encoding.y = {
7028
+ ...y,
7029
+ field: PROPORTION_FIELD,
7030
+ type: "quantitative",
7031
+ title: y.title === "Count" || y.title == null ? "Share" : y.title,
7032
+ // `axis: false` is an author turning the axis off; don't spread it into an
7033
+ // object and hand back an axis they asked not to have.
7034
+ axis: y.axis === false || y.axis === null ? y.axis : { format: "percent", ...y.axis ?? {} }
7035
+ };
7036
+ return { ...rest, encoding, transform: transforms };
7037
+ }
6746
7038
  function expandChartSugar(spec, warnings, inheritedData) {
6747
7039
  let out = unwrapDataValues(spec);
6748
7040
  out = expandTopLevelTitle(out);
@@ -6752,9 +7044,12 @@ function expandChartSugar(spec, warnings, inheritedData) {
6752
7044
  out = applyFixedSizeDefault(out);
6753
7045
  out = expandAnnotationSugar(out, warnings);
6754
7046
  out = expandChannelSugar(out, warnings);
6755
- out = expandEncodingSugar(out);
7047
+ out = expandHistogramMark(out);
7048
+ out = expandDensityMark(out);
7049
+ out = expandEncodingSugar(out, inheritedData);
6756
7050
  out = resolveSortSugar(out, inheritedData);
6757
7051
  out = expandCountAggregate(out);
7052
+ out = applyHistogramNormalize(out);
6758
7053
  return out;
6759
7054
  }
6760
7055
  function expandLayerSugar(spec, warnings, inheritedData, parentEncoding) {
@@ -7074,7 +7369,7 @@ function normalizeChrome(chrome) {
7074
7369
  brand: normalizeChromeField(chrome.brand)
7075
7370
  };
7076
7371
  }
7077
- function inferFieldType(data, field) {
7372
+ function inferFieldType2(data, field) {
7078
7373
  const sampleSize = Math.min(50, data.length);
7079
7374
  let numericCount = 0;
7080
7375
  let dateCount = 0;
@@ -7116,13 +7411,13 @@ function inferEncodingTypes(encoding, data, warnings) {
7116
7411
  if ("condition" in spec) continue;
7117
7412
  if (!("field" in spec)) continue;
7118
7413
  if (!spec.type) {
7119
- const inferred = inferFieldType(data, spec.field);
7414
+ const inferred = inferFieldType2(data, spec.field);
7120
7415
  result[channel] = { ...spec, type: inferred };
7121
7416
  warnings.push(
7122
7417
  `Inferred encoding.${channel}.type as "${inferred}" from data values for field "${spec.field}"`
7123
7418
  );
7124
7419
  } else {
7125
- const actualType = inferFieldType(data, spec.field);
7420
+ const actualType = inferFieldType2(data, spec.field);
7126
7421
  if (spec.type === "nominal" && actualType === "temporal") {
7127
7422
  warnings.push(`Field "${spec.field}" looks temporal but was declared as nominal`);
7128
7423
  }
@@ -7599,7 +7894,7 @@ function flattenLayers(spec, parentData, parentEncoding, parentTransforms, paren
7599
7894
 
7600
7895
  // src/compiler/validate.ts
7601
7896
  import {
7602
- inferFieldType as inferFieldType2,
7897
+ inferFieldType as inferFieldType3,
7603
7898
  MARK_ENCODING_RULES,
7604
7899
  MARK_TYPES,
7605
7900
  resolveSchemeName as resolveSchemeName2,
@@ -7718,8 +8013,8 @@ function validateRangeSpec(spec, errors) {
7718
8013
  if (!x?.field || typeof x.field !== "string" || !y?.field || typeof y.field !== "string") {
7719
8014
  return;
7720
8015
  }
7721
- const xType = x.type ?? inferFieldType2(data, x.field);
7722
- const yType = y.type ?? inferFieldType2(data, y.field);
8016
+ const xType = x.type ?? inferFieldType3(data, x.field);
8017
+ const yType = y.type ?? inferFieldType3(data, y.field);
7723
8018
  const xIsCategory = xType === "nominal" || xType === "ordinal";
7724
8019
  const yIsCategory = yType === "nominal" || yType === "ordinal";
7725
8020
  if (xIsCategory && yIsCategory) {
@@ -7856,7 +8151,7 @@ function validateCalendarSpec(spec, errors) {
7856
8151
  }
7857
8152
  const x = encoding.x;
7858
8153
  if (!x?.field || typeof x.field !== "string") return;
7859
- const xType = x.type ?? inferFieldType2(data, x.field);
8154
+ const xType = x.type ?? inferFieldType3(data, x.field);
7860
8155
  if (xType !== "temporal") {
7861
8156
  if (!x.type) {
7862
8157
  errors.push({
@@ -8065,12 +8360,16 @@ function validateChartSpec(spec, errors) {
8065
8360
  if (typeof f === "string") transformFields.add(f);
8066
8361
  }
8067
8362
  }
8068
- const opList = t.aggregate ?? t.window;
8363
+ const opList = t.aggregate ?? t.window ?? t.joinaggregate;
8069
8364
  if (Array.isArray(opList)) {
8070
8365
  for (const op of opList) {
8071
8366
  if (op && typeof op.as === "string") transformFields.add(op.as);
8072
8367
  }
8073
8368
  }
8369
+ if (typeof t.density === "string" && t.as == null) {
8370
+ transformFields.add("value");
8371
+ transformFields.add("density");
8372
+ }
8074
8373
  if (Array.isArray(t.groupby)) {
8075
8374
  for (const g of t.groupby) {
8076
8375
  if (typeof g === "string") transformFields.add(g);
@@ -8205,7 +8504,7 @@ function validateChartSpec(spec, errors) {
8205
8504
  suggestion: "Provide a series name (string) or an array of series names."
8206
8505
  });
8207
8506
  } else {
8208
- const colorType = colorCh.type ?? (colorCh.field && Array.isArray(spec.data) && spec.data.length > 0 ? inferFieldType2(spec.data, colorCh.field) : void 0);
8507
+ const colorType = colorCh.type ?? (colorCh.field && Array.isArray(spec.data) && spec.data.length > 0 ? inferFieldType3(spec.data, colorCh.field) : void 0);
8209
8508
  if (colorType === "quantitative") {
8210
8509
  errors.push({
8211
8510
  message: "Spec error: encoding.color.highlight is not supported on quantitative color channels",
@@ -8336,7 +8635,7 @@ function validateChartSpec(spec, errors) {
8336
8635
  const data = Array.isArray(spec.data) ? spec.data : [];
8337
8636
  const colorCh = encoding?.color;
8338
8637
  if (colorCh && typeof colorCh === "object" && typeof colorCh.field === "string") {
8339
- const colorType = colorCh.type ?? inferFieldType2(data, colorCh.field);
8638
+ const colorType = colorCh.type ?? inferFieldType3(data, colorCh.field);
8340
8639
  if (colorType !== "quantitative") {
8341
8640
  const distinct = new Set(data.map((row) => row[colorCh.field]));
8342
8641
  if (distinct.size > 1) {
@@ -9756,8 +10055,14 @@ function computeBandRowObstacles(marks, scales) {
9756
10055
  }
9757
10056
  return obstacles;
9758
10057
  }
10058
+ function isBinnedBarEncoding(markType, encoding) {
10059
+ return markType === "bar" && encoding.x?.type === "quantitative" && !!encoding.x2 && encoding.y?.type === "quantitative";
10060
+ }
9759
10061
  function resolveRendererKey(markType, encoding, markDef) {
9760
10062
  if (markType === "bar") {
10063
+ if (isBinnedBarEncoding(markType, encoding)) {
10064
+ return "bar:binned";
10065
+ }
9761
10066
  const xType = encoding.x?.type;
9762
10067
  const yType = encoding.y?.type;
9763
10068
  const isVertical = (xType === "nominal" || xType === "ordinal" || xType === "temporal") && yType === "quantitative";
@@ -10750,10 +11055,25 @@ function computeScales(spec, chartArea, data) {
10750
11055
  }
10751
11056
  }
10752
11057
  }
11058
+ if (spec.markType === "area" && encoding.x?.type === "quantitative" && encoding.x.scale?.zero === void 0) {
11059
+ if (!encoding.x.scale) {
11060
+ encoding.x.scale = { zero: false };
11061
+ } else {
11062
+ encoding.x.scale.zero = false;
11063
+ }
11064
+ }
11065
+ const binnedX = isBinnedBarEncoding(spec.markType, encoding) ? encoding.x : void 0;
11066
+ if (binnedX && binnedX.scale?.zero === void 0) {
11067
+ if (!binnedX.scale) {
11068
+ binnedX.scale = { zero: false };
11069
+ } else {
11070
+ binnedX.scale.zero = false;
11071
+ }
11072
+ }
10753
11073
  if (encoding.x) {
10754
11074
  let xData = data;
10755
11075
  let xChannel = encoding.x;
10756
- if (spec.markType === "range" && encoding.x2 && encoding.x.type === "quantitative") {
11076
+ if ((spec.markType === "range" || spec.markType === "bar") && encoding.x2 && encoding.x.type === "quantitative") {
10757
11077
  const xField = encoding.x.field;
10758
11078
  const x2Field = encoding.x2.field;
10759
11079
  xData = [...data, ...data.map((row) => ({ [xField]: row[x2Field] }))];
@@ -10825,7 +11145,7 @@ function computeScales(spec, chartArea, data) {
10825
11145
  if (encoding.y) {
10826
11146
  let yData = data;
10827
11147
  let yChannel = encoding.y;
10828
- if (spec.markType === "range" && encoding.y2 && encoding.y.type === "quantitative") {
11148
+ if ((spec.markType === "range" || spec.markType === "bar") && encoding.y2 && encoding.y.type === "quantitative") {
10829
11149
  const yField = encoding.y.field;
10830
11150
  const y2Field = encoding.y2.field;
10831
11151
  yData = [...data, ...data.map((row) => ({ [yField]: row[y2Field] }))];
@@ -11378,7 +11698,7 @@ import {
11378
11698
  abbreviateNumber,
11379
11699
  buildD3Formatter,
11380
11700
  estimateTextWidth as estimateTextWidth13,
11381
- formatNumber as formatNumber2
11701
+ formatNumber as formatNumber3
11382
11702
  } from "@opendata-ai/openchart-core";
11383
11703
  import { scaleLinear as scaleLinear3, scaleSqrt as scaleSqrt3 } from "d3-scale";
11384
11704
  var LABEL_GAP = 6;
@@ -11399,7 +11719,7 @@ function formatValue(value, formatStr) {
11399
11719
  if (fmt2) return fmt2(value);
11400
11720
  }
11401
11721
  if (Math.abs(value) >= 1e3) return abbreviateNumber(value);
11402
- return formatNumber2(value);
11722
+ return formatNumber3(value);
11403
11723
  }
11404
11724
  function niceFloor(value) {
11405
11725
  if (!Number.isFinite(value) || value <= 0) return value;
@@ -12321,7 +12641,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
12321
12641
  const yAxisSuppressed = encoding.y?.axis === false;
12322
12642
  let leftGutter = hPad + axisMargin;
12323
12643
  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") {
12644
+ const isBinnedBar = isBinnedBarEncoding(renderSpec.markType, encoding);
12645
+ if (!isBinnedBar && (encoding.y.type === "nominal" || encoding.y.type === "ordinal" || renderSpec.markType === "bar" || renderSpec.markType === "circle" || renderSpec.markType === "lollipop")) {
12325
12646
  const yField = encoding.y.field;
12326
12647
  const yLabelField = yAxisCfg?.labelField;
12327
12648
  let maxLabelWidth = 0;
@@ -13364,7 +13685,7 @@ import {
13364
13685
  CATEGORICAL_PALETTE as CATEGORICAL_PALETTE2,
13365
13686
  computeChrome as computeChrome3,
13366
13687
  estimateTextWidth as estimateTextWidth18,
13367
- formatNumber as formatNumber3,
13688
+ formatNumber as formatNumber4,
13368
13689
  getBreakpoint,
13369
13690
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION2,
13370
13691
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN2,
@@ -13816,7 +14137,7 @@ function compileGeoMap(spec, options) {
13816
14137
  compileWarnings.push(...filteredJoinWarnings);
13817
14138
  }
13818
14139
  const droppedFeatures = [];
13819
- const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber3;
14140
+ const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber4;
13820
14141
  const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
13821
14142
  const noDataPattern = {
13822
14143
  type: "diagonal",
@@ -15727,7 +16048,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
15727
16048
  const yAxisSuppressed = encoding.y?.axis === false;
15728
16049
  const yIsInline = yIsInlinePre;
15729
16050
  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")) {
16051
+ const isBinnedBar = isBinnedBarEncoding(spec.markType, encoding);
16052
+ if (!plan && !isBinnedBar && (spec.markType === "bar" || spec.markType === "circle" || spec.markType === "lollipop" || encoding.y.type === "nominal" || encoding.y.type === "ordinal")) {
15731
16053
  const yField = encoding.y.field;
15732
16054
  const yLabelField = encoding.y.axis?.labelField;
15733
16055
  let maxLabelWidth = 0;
@@ -18593,6 +18915,60 @@ function computeWaffleTooltips(spec, marks) {
18593
18915
  }
18594
18916
  return descriptors;
18595
18917
  }
18918
+ function isBinnedBarSpec(spec) {
18919
+ return isBinnedBarEncoding(spec.markType, spec.encoding);
18920
+ }
18921
+ function computeBinnedBarTooltips(spec, marks) {
18922
+ const encoding = spec.encoding;
18923
+ const descriptors = /* @__PURE__ */ new Map();
18924
+ const xCh = encoding.x;
18925
+ const x2Ch = encoding.x2;
18926
+ const yCh = encoding.y;
18927
+ if (!xCh || !x2Ch || !yCh) return descriptors;
18928
+ const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
18929
+ const allChannels = [
18930
+ xCh,
18931
+ yCh,
18932
+ colorEnc,
18933
+ ...encoding.tooltip ? Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip] : []
18934
+ ].filter((ch) => !!ch && "field" in ch);
18935
+ const fmtCache = buildFormatterCache(spec.data, allChannels);
18936
+ const xFormatter = getFormatter(fmtCache, xCh);
18937
+ const stackTotals = computeRectStackTotals(marks, encoding, fmtCache);
18938
+ for (let i = 0; i < marks.length; i++) {
18939
+ const mark = marks[i];
18940
+ if (mark.type !== "rect") continue;
18941
+ const row = mark.data;
18942
+ const start = formatValue2(row[xCh.field], "quantitative", xFormatter);
18943
+ const end = formatValue2(row[x2Ch.field], "quantitative", xFormatter);
18944
+ const title = `${start} \u2013 ${end}`;
18945
+ if (encoding.tooltip) {
18946
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
18947
+ descriptors.set(`rect-${i}`, {
18948
+ title,
18949
+ fields: buildExplicitTooltipFields(row, channels, fmtCache)
18950
+ });
18951
+ continue;
18952
+ }
18953
+ const fields = [];
18954
+ if (colorEnc) {
18955
+ fields.push({
18956
+ label: resolveLabel(colorEnc),
18957
+ value: String(row[colorEnc.field] ?? ""),
18958
+ color: getRepresentativeColor14(mark.fill)
18959
+ });
18960
+ }
18961
+ fields.push({
18962
+ label: resolveLabel(yCh),
18963
+ value: formatValue2(row[yCh.field], yCh.type, getFormatter(fmtCache, yCh)),
18964
+ ...colorEnc ? {} : { color: getRepresentativeColor14(mark.fill) }
18965
+ });
18966
+ const total = mark.stackGroup !== void 0 ? stackTotals?.get(mark.stackGroup) : void 0;
18967
+ if (total) fields.push(total);
18968
+ descriptors.set(`rect-${i}`, { title, fields });
18969
+ }
18970
+ return descriptors;
18971
+ }
18596
18972
  function computeParliamentTooltips(spec, marks) {
18597
18973
  const encoding = spec.encoding;
18598
18974
  const descriptors = /* @__PURE__ */ new Map();
@@ -18657,6 +19033,9 @@ function computeTooltipDescriptors(spec, marks) {
18657
19033
  if (spec.markType === "parliament") {
18658
19034
  return computeParliamentTooltips(spec, marks);
18659
19035
  }
19036
+ if (isBinnedBarSpec(spec)) {
19037
+ return computeBinnedBarTooltips(spec, marks);
19038
+ }
18660
19039
  const encoding = spec.encoding;
18661
19040
  const descriptors = /* @__PURE__ */ new Map();
18662
19041
  const allChannels = [
@@ -18717,6 +19096,19 @@ function computeStep(extent3, maxbins, nice) {
18717
19096
  }
18718
19097
  return step;
18719
19098
  }
19099
+ function computeLogBinEdges(extent3, maxbins) {
19100
+ const lo = Math.max(extent3[0], Number.MIN_VALUE);
19101
+ const hi = Math.max(extent3[1], lo);
19102
+ const logMin = Math.log10(lo);
19103
+ const logMax = Math.log10(hi);
19104
+ const logSpan = logMax - logMin;
19105
+ if (logSpan === 0) return [lo, lo * 10];
19106
+ const edges = [];
19107
+ for (let i = 0; i <= maxbins; i++) {
19108
+ edges.push(10 ** (logMin + i / maxbins * logSpan));
19109
+ }
19110
+ return edges;
19111
+ }
18720
19112
  function runBin(data, transform) {
18721
19113
  const params = transform.bin === true ? {} : transform.bin;
18722
19114
  const maxbins = params.maxbins ?? 10;
@@ -18729,14 +19121,38 @@ function runBin(data, transform) {
18729
19121
  for (const row of data) {
18730
19122
  const v = Number(row[field]);
18731
19123
  if (Number.isFinite(v)) {
19124
+ if (params.scaleType === "log" && v <= 0) continue;
18732
19125
  if (v < min4) min4 = v;
18733
19126
  if (v > max4) max4 = v;
18734
19127
  }
18735
19128
  }
18736
19129
  extent3 = [min4 === Infinity ? 0 : min4, max4 === -Infinity ? 0 : max4];
18737
19130
  }
18738
- const step = params.step ?? computeStep(extent3, maxbins, nice);
18739
19131
  const [startAs, endAs] = Array.isArray(transform.as) ? transform.as : [transform.as, void 0];
19132
+ if (params.scaleType === "log") {
19133
+ const edges = computeLogBinEdges(extent3, maxbins);
19134
+ return data.map((row) => {
19135
+ const v = Number(row[field]);
19136
+ const newRow = { ...row };
19137
+ if (!Number.isFinite(v) || v <= 0) {
19138
+ newRow[startAs] = null;
19139
+ if (endAs) newRow[endAs] = null;
19140
+ } else {
19141
+ let lo = 0;
19142
+ let hi = edges.length - 2;
19143
+ while (lo < hi) {
19144
+ const mid = lo + hi + 1 >> 1;
19145
+ if (edges[mid] <= v) lo = mid;
19146
+ else hi = mid - 1;
19147
+ }
19148
+ const idx = Math.min(lo, edges.length - 2);
19149
+ newRow[startAs] = edges[idx];
19150
+ if (endAs) newRow[endAs] = edges[idx + 1];
19151
+ }
19152
+ return newRow;
19153
+ });
19154
+ }
19155
+ const step = params.step ?? computeStep(extent3, maxbins, nice);
18740
19156
  return data.map((row) => {
18741
19157
  const v = Number(row[field]);
18742
19158
  const newRow = { ...row };
@@ -18788,6 +19204,135 @@ function runCalculate(data, transform) {
18788
19204
  }));
18789
19205
  }
18790
19206
 
19207
+ // src/transforms/density.ts
19208
+ import { deviation, quantile } from "d3-array";
19209
+ var DEFAULT_STEPS = 200;
19210
+ var MAX_STEPS = 1e4;
19211
+ var EXTENT_PADDING_BANDWIDTHS = 3;
19212
+ var DEGENERATE_BANDWIDTH = 1;
19213
+ var SQRT_2PI = Math.sqrt(2 * Math.PI);
19214
+ function silvermanBandwidth(values) {
19215
+ const n = values.length;
19216
+ if (n < 2) return DEGENERATE_BANDWIDTH;
19217
+ const sd = deviation(values) ?? 0;
19218
+ const sorted = [...values].sort((a, b) => a - b);
19219
+ const q1 = quantile(sorted, 0.25) ?? 0;
19220
+ const q3 = quantile(sorted, 0.75) ?? 0;
19221
+ const iqrScale = (q3 - q1) / 1.34;
19222
+ const spread = iqrScale > 0 ? Math.min(sd, iqrScale) : sd;
19223
+ if (!(spread > 0)) return DEGENERATE_BANDWIDTH;
19224
+ return 1.06 * spread * n ** (-1 / 5);
19225
+ }
19226
+ function resolveSteps(requested) {
19227
+ if (requested == null || !Number.isFinite(requested)) return DEFAULT_STEPS;
19228
+ return Math.min(MAX_STEPS, Math.max(2, Math.round(requested)));
19229
+ }
19230
+ function isUsableExtent(extent3) {
19231
+ const [lo, hi] = extent3;
19232
+ return Number.isFinite(lo) && Number.isFinite(hi) && hi > lo;
19233
+ }
19234
+ function gaussian(u) {
19235
+ return Math.exp(-0.5 * u * u) / SQRT_2PI;
19236
+ }
19237
+ function groupRows(data, groupby) {
19238
+ if (groupby.length === 0) return /* @__PURE__ */ new Map([["", data]]);
19239
+ const groups = /* @__PURE__ */ new Map();
19240
+ for (const row of data) {
19241
+ const key = groupby.map((f) => String(row[f] ?? "")).join("\0");
19242
+ const bucket = groups.get(key);
19243
+ if (bucket) bucket.push(row);
19244
+ else groups.set(key, [row]);
19245
+ }
19246
+ return groups;
19247
+ }
19248
+ function runDensity(data, transform) {
19249
+ const field = transform.density;
19250
+ const groupby = transform.groupby ?? [];
19251
+ const steps = resolveSteps(transform.steps);
19252
+ const [valueAs, densityAs] = transform.as ?? ["value", "density"];
19253
+ const cumulative = transform.cumulative === true;
19254
+ const counts = transform.counts === true;
19255
+ const groups = groupRows(data, groupby);
19256
+ const prepared = [];
19257
+ let widestBandwidth = 0;
19258
+ for (const rows of groups.values()) {
19259
+ const samples = [];
19260
+ for (const r of rows) {
19261
+ const raw = r[field];
19262
+ if (raw == null || raw === "") continue;
19263
+ const v = Number(raw);
19264
+ if (Number.isFinite(v)) samples.push(v);
19265
+ }
19266
+ if (samples.length === 0) continue;
19267
+ const bandwidth = transform.bandwidth && transform.bandwidth > 0 ? transform.bandwidth : silvermanBandwidth(samples);
19268
+ widestBandwidth = Math.max(widestBandwidth, bandwidth);
19269
+ prepared.push({ rows, samples, bandwidth });
19270
+ }
19271
+ if (prepared.length === 0) return [];
19272
+ let lo;
19273
+ let hi;
19274
+ if (transform.extent && isUsableExtent(transform.extent)) {
19275
+ [lo, hi] = transform.extent;
19276
+ } else {
19277
+ let dataMin = Number.POSITIVE_INFINITY;
19278
+ let dataMax = Number.NEGATIVE_INFINITY;
19279
+ for (const p of prepared) {
19280
+ for (const v of p.samples) {
19281
+ if (v < dataMin) dataMin = v;
19282
+ if (v > dataMax) dataMax = v;
19283
+ }
19284
+ }
19285
+ const pad = widestBandwidth * EXTENT_PADDING_BANDWIDTHS;
19286
+ lo = dataMin - pad;
19287
+ hi = dataMax + pad;
19288
+ }
19289
+ if (!(hi > lo)) {
19290
+ lo -= DEGENERATE_BANDWIDTH;
19291
+ hi += DEGENERATE_BANDWIDTH;
19292
+ }
19293
+ const stride = (hi - lo) / (steps - 1);
19294
+ const out = [];
19295
+ for (const { rows, samples, bandwidth } of prepared) {
19296
+ const groupValues = {};
19297
+ for (const f of groupby) groupValues[f] = rows[0][f];
19298
+ const n = samples.length;
19299
+ const scale = counts ? n : 1;
19300
+ const densities = new Array(steps);
19301
+ for (let i = 0; i < steps; i++) {
19302
+ const x = lo + i * stride;
19303
+ let sum = 0;
19304
+ for (const v of samples) sum += gaussian((x - v) / bandwidth);
19305
+ densities[i] = sum / (n * bandwidth);
19306
+ }
19307
+ if (cumulative) {
19308
+ const running = new Array(steps);
19309
+ let acc = 0;
19310
+ running[0] = 0;
19311
+ for (let i = 1; i < steps; i++) {
19312
+ acc += (densities[i] + densities[i - 1]) / 2 * stride;
19313
+ running[i] = acc;
19314
+ }
19315
+ const total = running[steps - 1] || 1;
19316
+ for (let i = 0; i < steps; i++) {
19317
+ out.push({
19318
+ ...groupValues,
19319
+ [valueAs]: lo + i * stride,
19320
+ [densityAs]: running[i] / total * scale
19321
+ });
19322
+ }
19323
+ continue;
19324
+ }
19325
+ for (let i = 0; i < steps; i++) {
19326
+ out.push({
19327
+ ...groupValues,
19328
+ [valueAs]: lo + i * stride,
19329
+ [densityAs]: densities[i] * scale
19330
+ });
19331
+ }
19332
+ }
19333
+ return out;
19334
+ }
19335
+
18791
19336
  // src/transforms/filter.ts
18792
19337
  function applyOffset2(anchor, offset, unit) {
18793
19338
  const d = new Date(anchor.getTime());
@@ -18887,6 +19432,31 @@ function runFold(data, transform) {
18887
19432
  return result;
18888
19433
  }
18889
19434
 
19435
+ // src/transforms/joinaggregate.ts
19436
+ function groupKey2(row, groupby) {
19437
+ return groupby.map((f) => String(row[f] ?? "")).join("\0");
19438
+ }
19439
+ function runJoinAggregate(data, transform) {
19440
+ const groupby = transform.groupby ?? [];
19441
+ const groups = /* @__PURE__ */ new Map();
19442
+ for (const row of data) {
19443
+ const key = groupKey2(row, groupby);
19444
+ const bucket = groups.get(key);
19445
+ if (bucket) bucket.push(row);
19446
+ else groups.set(key, [row]);
19447
+ }
19448
+ const summaries = /* @__PURE__ */ new Map();
19449
+ for (const [key, rows] of groups) {
19450
+ const summary = {};
19451
+ for (const { op, field, as } of transform.joinaggregate) {
19452
+ const values = op === "count" ? rows.map(() => 1) : rows.map((r) => Number(r[field])).filter((v) => Number.isFinite(v));
19453
+ summary[as] = computeAggregate(op, values);
19454
+ }
19455
+ summaries.set(key, summary);
19456
+ }
19457
+ return data.map((row) => ({ ...row, ...summaries.get(groupKey2(row, groupby)) }));
19458
+ }
19459
+
18890
19460
  // src/transforms/timeunit.ts
18891
19461
  function extractTimeUnit(date, unit) {
18892
19462
  switch (unit) {
@@ -18953,7 +19523,7 @@ function runTimeUnit(data, transform) {
18953
19523
  }
18954
19524
 
18955
19525
  // src/transforms/window.ts
18956
- function groupKey2(row, groupby) {
19526
+ function groupKey3(row, groupby) {
18957
19527
  return groupby.map((f) => String(row[f] ?? "")).join("\0");
18958
19528
  }
18959
19529
  function tryParseDate(val) {
@@ -18985,7 +19555,7 @@ function runWindow(data, transform) {
18985
19555
  const indexed = data.map((row, i) => ({ row, originalIndex: i }));
18986
19556
  const groups = /* @__PURE__ */ new Map();
18987
19557
  for (const entry of indexed) {
18988
- const key = groupby.length > 0 ? groupKey2(entry.row, groupby) : "";
19558
+ const key = groupby.length > 0 ? groupKey3(entry.row, groupby) : "";
18989
19559
  const existing = groups.get(key);
18990
19560
  if (existing) {
18991
19561
  existing.push(entry);
@@ -19091,6 +19661,10 @@ function runTransforms(data, transforms) {
19091
19661
  result = runTimeUnit(result, transform);
19092
19662
  } else if ("aggregate" in transform) {
19093
19663
  result = runAggregate(result, transform);
19664
+ } else if ("joinaggregate" in transform) {
19665
+ result = runJoinAggregate(result, transform);
19666
+ } else if ("density" in transform) {
19667
+ result = runDensity(result, transform);
19094
19668
  } else if ("fold" in transform) {
19095
19669
  result = runFold(result, transform);
19096
19670
  } else if ("window" in transform) {
@@ -19455,6 +20029,14 @@ function compileChart(spec, optionsInput) {
19455
20029
  height: chartArea.height
19456
20030
  };
19457
20031
  }
20032
+ if (chartSpec.userExplicit.labels && chartSpec.labels?.density !== "none" && isBinnedBarEncoding(chartSpec.markType, renderSpec.encoding)) {
20033
+ emitSpecWarnings(
20034
+ [
20035
+ "[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."
20036
+ ],
20037
+ options.onWarn
20038
+ );
20039
+ }
19458
20040
  const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
19459
20041
  emitSpecWarnings(
19460
20042
  applyColorScaleRange(