graphein 0.4.0 → 0.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
@@ -456,6 +456,10 @@ function logScale(opts) {
456
456
  map(value) {
457
457
  const [d0, d1] = transformedDomain;
458
458
  const [r0, r1] = range;
459
+ if (!Number.isFinite(value) || value * sign2 <= 0) return NaN;
460
+ if (d0 === d1) {
461
+ return clamp11 ? clampValue2(r0, Math.min(r0, r1), Math.max(r0, r1)) : r0;
462
+ }
459
463
  const transformed = transform(value, sign2, base);
460
464
  const mapped = r0 + (transformed - d0) / (d1 - d0) * (r1 - r0);
461
465
  return clamp11 ? clampValue2(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
@@ -463,6 +467,7 @@ function logScale(opts) {
463
467
  invert(pixel) {
464
468
  const [d0, d1] = transformedDomain;
465
469
  const [r0, r1] = range;
470
+ if (d0 === d1) return domain[0];
466
471
  const input = clamp11 ? clampValue2(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
467
472
  return untransform(d0 + (input - r0) / (r1 - r0) * (d1 - d0), sign2, base);
468
473
  },
@@ -1938,6 +1943,10 @@ var SI_SYMBOLS = [
1938
1943
  function groupThousands(intDigits) {
1939
1944
  return intDigits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1940
1945
  }
1946
+ function clampDigits(value, min, max) {
1947
+ if (!Number.isFinite(value)) return min;
1948
+ return Math.min(max, Math.max(min, Math.trunc(value)));
1949
+ }
1941
1950
  function applyGroup(numStr, group) {
1942
1951
  if (!group) return numStr;
1943
1952
  const neg = numStr.startsWith("-");
@@ -1957,11 +1966,11 @@ function formatSI(value, precision) {
1957
1966
  if (abs >= factor) {
1958
1967
  const scaled = value / factor;
1959
1968
  const digits2 = precision ?? (Math.abs(scaled) >= 100 ? 0 : Math.abs(scaled) >= 10 ? 1 : 2);
1960
- return trimZeros(scaled.toFixed(digits2)) + symbol;
1969
+ return trimZeros(scaled.toFixed(clampDigits(digits2, 0, 100))) + symbol;
1961
1970
  }
1962
1971
  }
1963
1972
  const digits = precision ?? (abs >= 100 || abs === 0 ? 0 : abs >= 1 ? 1 : 2);
1964
- return trimZeros(value.toFixed(digits));
1973
+ return trimZeros(value.toFixed(clampDigits(digits, 0, 100)));
1965
1974
  }
1966
1975
  function formatNumber2(value, spec) {
1967
1976
  if (value == null || Number.isNaN(value)) return "";
@@ -1971,10 +1980,10 @@ function formatNumber2(value, spec) {
1971
1980
  let body;
1972
1981
  switch (type) {
1973
1982
  case "f":
1974
- body = applyGroup(value.toFixed(precision ?? 2), group);
1983
+ body = applyGroup(value.toFixed(clampDigits(precision ?? 2, 0, 100)), group);
1975
1984
  break;
1976
1985
  case "%":
1977
- body = applyGroup((value * 100).toFixed(precision ?? 0), group) + "%";
1986
+ body = applyGroup((value * 100).toFixed(clampDigits(precision ?? 0, 0, 100)), group) + "%";
1978
1987
  break;
1979
1988
  case "s":
1980
1989
  body = formatSI(value, precision);
@@ -1983,17 +1992,17 @@ function formatNumber2(value, spec) {
1983
1992
  body = applyGroup(Math.round(value).toString(), group);
1984
1993
  break;
1985
1994
  case "e":
1986
- body = value.toExponential(precision ?? 2);
1995
+ body = value.toExponential(clampDigits(precision ?? 2, 0, 100));
1987
1996
  break;
1988
1997
  case "g":
1989
- body = trimZeros(Number(value.toPrecision(precision ?? 6)).toString());
1998
+ body = trimZeros(Number(value.toPrecision(clampDigits(precision ?? 6, 1, 100))).toString());
1990
1999
  body = applyGroup(body, group);
1991
2000
  break;
1992
2001
  default: {
1993
2002
  if (Number.isInteger(value)) {
1994
2003
  body = applyGroup(value.toString(), group);
1995
2004
  } else {
1996
- const digits = precision ?? 6;
2005
+ const digits = clampDigits(precision ?? 6, 0, 100);
1997
2006
  body = applyGroup(trimZeros(value.toFixed(digits)), group);
1998
2007
  }
1999
2008
  }
@@ -2122,7 +2131,13 @@ function toNumber(value) {
2122
2131
  }
2123
2132
  return NaN;
2124
2133
  }
2134
+ var ISO_YEAR_RE = /^(\d{4})$/;
2125
2135
  var ISO_DATE_RE = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/;
2136
+ var ISO_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?$/;
2137
+ function isLocalCalendarDate(year, month, day) {
2138
+ const d = new Date(year, month - 1, day);
2139
+ return d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day;
2140
+ }
2126
2141
  function toDate(value) {
2127
2142
  if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
2128
2143
  if (typeof value === "number") {
@@ -2130,8 +2145,20 @@ function toDate(value) {
2130
2145
  return Number.isNaN(d.getTime()) ? null : d;
2131
2146
  }
2132
2147
  if (typeof value === "string") {
2148
+ const y = ISO_YEAR_RE.exec(value);
2149
+ if (y) {
2150
+ const d = new Date(Date.parse(value));
2151
+ return d.getUTCFullYear() === +y[1] && d.getUTCMonth() === 0 && d.getUTCDate() === 1 ? d : null;
2152
+ }
2133
2153
  const m = ISO_DATE_RE.exec(value);
2134
- if (m) return new Date(+m[1], +m[2] - 1, m[3] ? +m[3] : 1);
2154
+ if (m) {
2155
+ const year = +m[1];
2156
+ const month = +m[2];
2157
+ const day = m[3] ? +m[3] : 1;
2158
+ return isLocalCalendarDate(year, month, day) ? new Date(year, month - 1, day) : null;
2159
+ }
2160
+ const dt = ISO_DATETIME_RE.exec(value);
2161
+ if (dt && !isLocalCalendarDate(+dt[1], +dt[2], +dt[3])) return null;
2135
2162
  const ms = Date.parse(value);
2136
2163
  return Number.isNaN(ms) ? null : new Date(ms);
2137
2164
  }
@@ -2527,7 +2554,7 @@ function coerceFinite(value) {
2527
2554
  if (value === null || value === void 0) {
2528
2555
  return null;
2529
2556
  }
2530
- const numberValue = Number(value);
2557
+ const numberValue = toNumber(value);
2531
2558
  return Number.isFinite(numberValue) ? numberValue : null;
2532
2559
  }
2533
2560
  function makePathKey(path) {
@@ -2635,6 +2662,9 @@ function analyzeCategory(rows, categoryField, measureField) {
2635
2662
  });
2636
2663
  return { count: order.length, total, top, bottom, topShare: total > 0 ? top.value / total : 0 };
2637
2664
  }
2665
+ function xLabel(x) {
2666
+ return x instanceof Date ? prettyDate(x) : toKey(x);
2667
+ }
2638
2668
  function analyzeSeriesFamily(rows, xField, yField, seriesField) {
2639
2669
  const readX = accessor(xField);
2640
2670
  const readY = accessor(yField);
@@ -2653,7 +2683,7 @@ function analyzeSeriesFamily(rows, xField, yField, seriesField) {
2653
2683
  }
2654
2684
  const x = readX(d);
2655
2685
  g.values.push(y);
2656
- g.labels.push(toKey(x));
2686
+ g.labels.push(xLabel(x));
2657
2687
  g.raws.push(x);
2658
2688
  }
2659
2689
  const out = [];
@@ -3558,7 +3588,7 @@ function computeBins(values, opts = {}) {
3558
3588
  [min, max] = opts.extent;
3559
3589
  } else {
3560
3590
  for (const v of values) {
3561
- if (Number.isNaN(v)) continue;
3591
+ if (!Number.isFinite(v)) continue;
3562
3592
  if (v < min) min = v;
3563
3593
  if (v > max) max = v;
3564
3594
  }
@@ -3582,7 +3612,7 @@ function applyBin(transform2, data) {
3582
3612
  const finite4 = [];
3583
3613
  for (const row of data) {
3584
3614
  const n = toNumber(read(row));
3585
- if (!Number.isNaN(n)) finite4.push(n);
3615
+ if (Number.isFinite(n)) finite4.push(n);
3586
3616
  }
3587
3617
  const layout2 = computeBins(finite4, {
3588
3618
  maxbins: transform2.maxbins,
@@ -3596,7 +3626,7 @@ function applyBin(transform2, data) {
3596
3626
  const out = { ...row };
3597
3627
  const v = toNumber(read(row));
3598
3628
  const outside = transform2.extent != null && (v < transform2.extent[0] || v > transform2.extent[1]);
3599
- if (layout2 == null || Number.isNaN(v) || outside) {
3629
+ if (layout2 == null || !Number.isFinite(v) || outside) {
3600
3630
  out[startField] = null;
3601
3631
  if (endField) out[endField] = null;
3602
3632
  return out;
@@ -4643,6 +4673,26 @@ function validateSpec(spec) {
4643
4673
  }
4644
4674
  }
4645
4675
  }
4676
+ if (isObject3(spec.encoding)) {
4677
+ for (const ch of ["x", "y"]) {
4678
+ const channel = spec.encoding[ch];
4679
+ if (!isObject3(channel) || !isObject3(channel.scale)) continue;
4680
+ const scale = channel.scale;
4681
+ if (scale.type !== "log") continue;
4682
+ const domain = scale.domain;
4683
+ if (Array.isArray(domain) && domain.some((v) => typeof v === "number" && v <= 0)) {
4684
+ warn(
4685
+ `encoding.${ch}.scale.domain`,
4686
+ 'A "log" scale needs a strictly positive domain; values <= 0 have no logarithm. The domain will be clamped to the positive data range.',
4687
+ {
4688
+ rule: "log-scale-nonpositive-domain",
4689
+ severity: "warning",
4690
+ fix: [{ op: "remove", path: toPointer(`encoding.${ch}.scale.domain`) }]
4691
+ }
4692
+ );
4693
+ }
4694
+ }
4695
+ }
4646
4696
  if (type === "kpi") {
4647
4697
  const value = spec.value;
4648
4698
  if (value === void 0) {
@@ -6274,6 +6324,21 @@ function stackedYExtent(rows, xField, yField) {
6274
6324
  for (const v of neg.values()) if (v < min) min = v;
6275
6325
  return [min, max];
6276
6326
  }
6327
+ function positiveLogDomain(requested, data, field, base) {
6328
+ let lo = requested[0];
6329
+ let hi = requested[1];
6330
+ if (!(lo > 0) || !(hi > 0)) {
6331
+ const read = accessor(field);
6332
+ const positives = data.map((d) => toNumber(read(d))).filter((v) => Number.isFinite(v) && v > 0);
6333
+ const pmin = positives.length ? Math.min(...positives) : 1;
6334
+ const pmax = positives.length ? Math.max(...positives) : base;
6335
+ if (!(lo > 0)) lo = pmin;
6336
+ if (!(hi > 0)) hi = pmax;
6337
+ }
6338
+ if (hi < lo) [lo, hi] = [hi, lo];
6339
+ if (lo === hi) hi = lo * base;
6340
+ return [lo, hi];
6341
+ }
6277
6342
  function resolveSeriesField(spec) {
6278
6343
  const enc = spec.encoding;
6279
6344
  if (enc.series?.field) return enc.series.field;
@@ -6335,7 +6400,8 @@ function buildCartesianModel(spec, tokens, opts) {
6335
6400
  let categories;
6336
6401
  let xDomainNum = null;
6337
6402
  if (xKind === "band" || xKind === "point") {
6338
- categories = opts.shared?.categories ?? uniqueStrings(data, xField);
6403
+ const cfgDomain = enc.x.scale?.domain;
6404
+ categories = opts.shared?.categories ?? (Array.isArray(cfgDomain) && typeof cfgDomain[0] === "string" ? cfgDomain : uniqueStrings(data, xField));
6339
6405
  } else if (xKind === "time") {
6340
6406
  if (opts.shared?.xDomain) {
6341
6407
  xDomainNum = opts.shared.xDomain;
@@ -6348,12 +6414,23 @@ function buildCartesianModel(spec, tokens, opts) {
6348
6414
  } else {
6349
6415
  xDomainNum = extent(data, xField) ?? [0, 1];
6350
6416
  const xScaleCfg = enc.x.scale;
6351
- if (xScaleCfg?.domain && typeof xScaleCfg.domain[0] === "number") {
6417
+ if (xScaleCfg?.type === "log") {
6418
+ xDomainNum = positiveLogDomain(
6419
+ xScaleCfg.domain && typeof xScaleCfg.domain[0] === "number" ? [xScaleCfg.domain[0], xScaleCfg.domain[1]] : xDomainNum,
6420
+ data,
6421
+ xField,
6422
+ xScaleCfg.base ?? 10
6423
+ );
6424
+ } else if (xScaleCfg?.domain && typeof xScaleCfg.domain[0] === "number") {
6352
6425
  xDomainNum = [xScaleCfg.domain[0], xScaleCfg.domain[1]];
6353
6426
  } else if (xScaleCfg?.nice !== false) {
6354
6427
  xDomainNum = niceDomain(xDomainNum[0], xDomainNum[1], 10);
6355
6428
  }
6356
6429
  }
6430
+ const yScaleCfg = enc.y.scale;
6431
+ const yIsLog = yScaleCfg?.type === "log";
6432
+ const yBase = yScaleCfg?.base ?? 10;
6433
+ const yClamp = yScaleCfg?.clamp === true;
6357
6434
  const sharedY = opts.shared?.yDomain;
6358
6435
  let yDomain;
6359
6436
  if (sharedY) {
@@ -6364,24 +6441,39 @@ function buildCartesianModel(spec, tokens, opts) {
6364
6441
  const ext = extent(data, yField) ?? [0, 1];
6365
6442
  yDomain = [ext[0], ext[1]];
6366
6443
  }
6367
- if (wantsZeroBaseline(spec) && !sharedY) {
6444
+ const forceZero = yIsLog ? false : yScaleCfg?.zero ?? wantsZeroBaseline(spec);
6445
+ if (forceZero && !sharedY) {
6368
6446
  yDomain = [Math.min(0, yDomain[0]), Math.max(0, yDomain[1])];
6369
6447
  }
6370
- const yScaleCfg = enc.y.scale;
6371
6448
  const xAxisCfg = axisCfg(spec, "x");
6372
6449
  const yAxisCfg = axisCfg(spec, "y");
6373
6450
  const yTickCount = yAxisCfg.ticks ?? 6;
6451
+ const yExplicitTicks = yAxisCfg.tickValues && yAxisCfg.tickValues.length ? yAxisCfg.tickValues.slice() : null;
6374
6452
  const yExplicit = yScaleCfg?.domain && typeof yScaleCfg.domain[0] === "number" || !!sharedY;
6375
6453
  let yTickValues;
6376
- if (yExplicit) {
6454
+ if (yIsLog) {
6455
+ if (yScaleCfg?.domain && typeof yScaleCfg.domain[0] === "number") {
6456
+ yDomain = positiveLogDomain([yScaleCfg.domain[0], yScaleCfg.domain[1]], data, yField, yBase);
6457
+ } else if (!sharedY) {
6458
+ yDomain = positiveLogDomain(yDomain, data, yField, yBase);
6459
+ }
6460
+ const lo = Math.min(yDomain[0], yDomain[1]);
6461
+ const hi = Math.max(yDomain[0], yDomain[1]);
6462
+ yTickValues = (yExplicitTicks ?? logScale({ domain: yDomain, range: [0, 1], base: yBase }).ticks()).filter(
6463
+ (v) => v > 0 && v >= lo - 1e-9 && v <= hi + 1e-9
6464
+ );
6465
+ } else if (yExplicit) {
6377
6466
  if (yScaleCfg?.domain && typeof yScaleCfg.domain[0] === "number") {
6378
6467
  yDomain = [yScaleCfg.domain[0], yScaleCfg.domain[1]];
6379
6468
  }
6380
6469
  const lo = Math.min(yDomain[0], yDomain[1]);
6381
6470
  const hi = Math.max(yDomain[0], yDomain[1]);
6382
- yTickValues = numericTickValues(yDomain[0], yDomain[1], yTickCount).filter(
6471
+ yTickValues = (yExplicitTicks ?? numericTickValues(yDomain[0], yDomain[1], yTickCount)).filter(
6383
6472
  (v) => v >= lo - 1e-9 && v <= hi + 1e-9
6384
6473
  );
6474
+ } else if (yExplicitTicks) {
6475
+ yTickValues = yExplicitTicks;
6476
+ yDomain = [Math.min(yDomain[0], ...yExplicitTicks), Math.max(yDomain[1], ...yExplicitTicks)];
6385
6477
  } else if (yScaleCfg?.nice !== false) {
6386
6478
  yTickValues = numericTickValues(yDomain[0], yDomain[1], yTickCount);
6387
6479
  if (yTickValues.length >= 2) {
@@ -6409,7 +6501,19 @@ function buildCartesianModel(spec, tokens, opts) {
6409
6501
  xStepMs = xt.length > 1 ? xt[1] - xt[0] : 0;
6410
6502
  xLabels = xt.map((v) => enc.x.format ? formatValue(v, enc.x.format) : smartDate(v, xStepMs));
6411
6503
  } else {
6412
- const xt = numericTickValues(xDomainNum[0], xDomainNum[1], xAxisCfg.ticks ?? 8);
6504
+ const xLo = Math.min(xDomainNum[0], xDomainNum[1]);
6505
+ const xHi = Math.max(xDomainNum[0], xDomainNum[1]);
6506
+ const xExplicitTicks = xAxisCfg.tickValues && xAxisCfg.tickValues.length ? xAxisCfg.tickValues.slice() : null;
6507
+ let xt;
6508
+ if (xExplicitTicks) {
6509
+ xt = xExplicitTicks.filter((v) => v >= xLo - 1e-9 && v <= xHi + 1e-9);
6510
+ } else if (enc.x.scale?.type === "log") {
6511
+ xt = logScale({ domain: xDomainNum, range: [0, 1], base: enc.x.scale.base ?? 10 }).ticks().filter((v) => v > 0 && v >= xLo - 1e-9 && v <= xHi + 1e-9);
6512
+ } else {
6513
+ xt = numericTickValues(xDomainNum[0], xDomainNum[1], xAxisCfg.ticks ?? 8).filter(
6514
+ (v) => v >= xLo - 1e-9 && v <= xHi + 1e-9
6515
+ );
6516
+ }
6413
6517
  xTickValues = xt;
6414
6518
  xLabels = xt.map((v) => formatNumericTick(v, enc.x.format ?? xAxisCfg.format));
6415
6519
  }
@@ -6436,9 +6540,10 @@ function buildCartesianModel(spec, tokens, opts) {
6436
6540
  yAxis: { show: yShow, labels: yLabels, title: resolveAxisTitle(enc.y, yAxisCfg) }
6437
6541
  });
6438
6542
  const plot = frame.plot;
6439
- const yScale = linearScale({ domain: yDomain, range: [plot.y + plot.height, plot.y] });
6543
+ const yRange = [plot.y + plot.height, plot.y];
6544
+ const yScale = yIsLog ? logScale({ domain: yDomain, range: yRange, base: yBase, clamp: yClamp }) : linearScale({ domain: yDomain, range: yRange, clamp: yClamp });
6440
6545
  const yPixel = (value) => yScale.map(toNumber(value));
6441
- const baseline = clampPx(yScale.map(0), plot.y, plot.y + plot.height);
6546
+ const baseline = yIsLog ? plot.y + plot.height : clampPx(yScale.map(0), plot.y, plot.y + plot.height);
6442
6547
  const xModel = buildXModel(xKind, {
6443
6548
  field: xField,
6444
6549
  type: xType,
@@ -6514,7 +6619,8 @@ function buildXModel(kind, args) {
6514
6619
  };
6515
6620
  }
6516
6621
  const domain = args.domainNum ?? [0, 1];
6517
- const continuous = kind === "time" ? timeScale({ domain, range: args.range }) : linearScale({ domain, range: args.range });
6622
+ const clamp11 = args.scaleCfg?.clamp === true;
6623
+ const continuous = kind === "time" ? timeScale({ domain, range: args.range }) : args.scaleCfg?.type === "log" ? logScale({ domain, range: args.range, base: args.scaleCfg.base ?? 10, clamp: clamp11 }) : linearScale({ domain, range: args.range, clamp: clamp11 });
6518
6624
  return {
6519
6625
  kind,
6520
6626
  field: args.field,
@@ -7228,6 +7334,12 @@ function boxProbe(model, geom) {
7228
7334
  if (model.seriesField) probe[model.seriesField] = geom.seriesValue;
7229
7335
  return probe;
7230
7336
  }
7337
+ function hitsBoxGeom(model, geom, showOutliers, px, py) {
7338
+ const inWhiskerExtent = px >= geom.left - 4 && px <= geom.right + 4 && py >= geom.top - 6 && py <= geom.bottom + 6;
7339
+ if (inWhiskerExtent) return true;
7340
+ if (!showOutliers || geom.stats.outliers.length === 0) return false;
7341
+ return geom.stats.outliers.some((o) => Math.abs(px - geom.cx) <= 6 && Math.abs(py - model.y.pixel(o)) <= 6);
7342
+ }
7231
7343
  function drawBox(surface, model) {
7232
7344
  const layout2 = computeBoxes(model);
7233
7345
  if (!layout2) return;
@@ -7259,9 +7371,7 @@ function buildBoxInteraction(model) {
7259
7371
  return {
7260
7372
  region: model.plot,
7261
7373
  hitTest: (px, py) => {
7262
- const hit = geoms.find(
7263
- (g) => px >= g.left - 4 && px <= g.right + 4 && py >= g.top - 6 && py <= g.bottom + 6
7264
- );
7374
+ const hit = geoms.find((g) => hitsBoxGeom(model, g, showOutliers, px, py));
7265
7375
  if (!hit) return null;
7266
7376
  const s = hit.stats;
7267
7377
  const rows = [
@@ -7294,10 +7404,15 @@ function buildBoxInteraction(model) {
7294
7404
  };
7295
7405
  },
7296
7406
  pick: (px, py) => {
7297
- const hit = geoms.find(
7298
- (g) => px >= g.left - 4 && px <= g.right + 4 && py >= g.top - 6 && py <= g.bottom + 6
7299
- );
7407
+ const hit = geoms.find((g) => hitsBoxGeom(model, g, showOutliers, px, py));
7300
7408
  if (!hit) return null;
7409
+ if (model.series.length > 1 && model.seriesField && hit.seriesValue !== void 0) {
7410
+ return {
7411
+ kind: "point",
7412
+ fields: [model.x.field, model.seriesField],
7413
+ tuples: [[hit.xValue, hit.seriesValue]]
7414
+ };
7415
+ }
7301
7416
  return { kind: "point", fields: [model.x.field], tuples: [[hit.xValue]] };
7302
7417
  }
7303
7418
  };
@@ -7554,6 +7669,18 @@ function measureMax(labels, font) {
7554
7669
  for (const label of labels) max = Math.max(max, measureText(label, font).width);
7555
7670
  return max;
7556
7671
  }
7672
+ function heatmapColorDomain(rows, field) {
7673
+ const read = accessor(field);
7674
+ let min = Infinity;
7675
+ let max = -Infinity;
7676
+ for (const row of rows) {
7677
+ const value = toNumber(read(row));
7678
+ if (!finite3(value)) continue;
7679
+ min = Math.min(min, value);
7680
+ max = Math.max(max, value);
7681
+ }
7682
+ return min === Infinity ? null : [min, max];
7683
+ }
7557
7684
  function drawRoundedCell(ctx, x, y, width, height, radius) {
7558
7685
  if (!finite3(x) || !finite3(y) || !finite3(width) || !finite3(height) || width <= 0 || height <= 0) return;
7559
7686
  ctx.beginPath();
@@ -7602,7 +7729,7 @@ function drawHeatmap(surface, spec, tokens, size, context) {
7602
7729
  const { x, y, color } = heatmap.encoding;
7603
7730
  const xCats = uniqueStrings(rows, x.field);
7604
7731
  const yCats = uniqueStrings(rows, y.field);
7605
- const colorDomain = extent(rows, color.field);
7732
+ const colorDomain = heatmapColorDomain(rows, color.field);
7606
7733
  if (xCats.length === 0 || yCats.length === 0 || !colorDomain || content.width <= 0 || content.height <= 0) return;
7607
7734
  const labelSize = tokens.font.size.small;
7608
7735
  const labelFont = fontString(labelSize, tokens.font.family, tokens.font.weight.normal);
@@ -7769,16 +7896,33 @@ function clamp3(value, min, max) {
7769
7896
  function direction(angle) {
7770
7897
  return { x: Math.sin(angle), y: -Math.cos(angle) };
7771
7898
  }
7772
- function formatPercent(share) {
7773
- const pct = share * 100;
7774
- const text = pct < 10 ? pct.toFixed(1) : pct.toFixed(0);
7775
- return `${text.replace(/\.0$/, "")}%`;
7899
+ function reconcilePercents(values) {
7900
+ const cleaned = values.map((value) => Number.isFinite(value) && value > 0 ? value : 0);
7901
+ const total = cleaned.reduce((sum, value) => sum + value, 0);
7902
+ if (total <= 0) return cleaned.map(() => 0);
7903
+ const exact = cleaned.map((value, index) => {
7904
+ const pct = value / total * 100;
7905
+ const floor = Math.floor(pct);
7906
+ return { index, floor, remainder: pct - floor };
7907
+ });
7908
+ let remaining = 100 - exact.reduce((sum, item) => sum + item.floor, 0);
7909
+ const out = exact.map((item) => item.floor);
7910
+ for (const item of [...exact].sort((a, b) => b.remainder - a.remainder || a.index - b.index)) {
7911
+ if (remaining <= 0) break;
7912
+ out[item.index]++;
7913
+ remaining--;
7914
+ }
7915
+ return out;
7916
+ }
7917
+ function formatPercent(percent) {
7918
+ return `${percent}%`;
7776
7919
  }
7777
- function labelText(slice, placement, options) {
7920
+ function labelText(slice, placement, options, percents) {
7778
7921
  const content = options.content ?? (placement === "inside" ? "percent" : "category-percent");
7922
+ const percentText = formatPercent(percents.get(slice.index) ?? 0);
7779
7923
  switch (content) {
7780
7924
  case "percent":
7781
- return formatPercent(slice.share);
7925
+ return percentText;
7782
7926
  case "value":
7783
7927
  return options.formatValue(slice.value);
7784
7928
  case "category":
@@ -7787,7 +7931,7 @@ function labelText(slice, placement, options) {
7787
7931
  return `${slice.label} \xB7 ${options.formatValue(slice.value)}`;
7788
7932
  case "category-percent":
7789
7933
  default:
7790
- return `${slice.label} \xB7 ${formatPercent(slice.share)}`;
7934
+ return `${slice.label} \xB7 ${percentText}`;
7791
7935
  }
7792
7936
  }
7793
7937
  function relaxColumn(idealYs, lineHeight2, top, bottom) {
@@ -7817,6 +7961,10 @@ function planPieLabels(options) {
7817
7961
  const origInnerR = options.innerR;
7818
7962
  const insideSlices = [];
7819
7963
  const outsideSlices = [];
7964
+ const percents = /* @__PURE__ */ new Map();
7965
+ reconcilePercents(slices.map((slice) => slice.value)).forEach((percent, i) => {
7966
+ percents.set(slices[i].index, percent);
7967
+ });
7820
7968
  const insideLabelR = origInnerR > 0 ? (origInnerR + origOuterR) / 2 : origOuterR * 0.62;
7821
7969
  const radialBand = origInnerR > 0 ? origOuterR - origInnerR : origOuterR;
7822
7970
  for (const slice of slices) {
@@ -7826,20 +7974,20 @@ function planPieLabels(options) {
7826
7974
  if (placement === "inside") goesInside = true;
7827
7975
  else if (placement === "outside") goesInside = false;
7828
7976
  else {
7829
- const insideText = labelText(slice, "inside", options);
7977
+ const insideText = labelText(slice, "inside", options, percents);
7830
7978
  const tangential = (slice.endAngle - slice.startAngle) * insideLabelR;
7831
7979
  const fitsTangential = measure(insideText) + INSIDE_PAD <= tangential;
7832
7980
  const fitsRadial = lineHeight2 * 0.8 <= radialBand;
7833
7981
  goesInside = fitsTangential && fitsRadial;
7834
7982
  }
7835
7983
  if (goesInside) {
7836
- insideSlices.push({ slice, mid, text: labelText(slice, "inside", options) });
7984
+ insideSlices.push({ slice, mid, text: labelText(slice, "inside", options, percents) });
7837
7985
  } else {
7838
7986
  const dir = direction(mid);
7839
7987
  outsideSlices.push({
7840
7988
  slice,
7841
7989
  mid,
7842
- text: labelText(slice, "outside", options),
7990
+ text: labelText(slice, "outside", options, percents),
7843
7991
  side: dir.x >= 0 ? "right" : "left"
7844
7992
  });
7845
7993
  }
@@ -8226,17 +8374,29 @@ function buildStages(spec, palette) {
8226
8374
  order.push(key);
8227
8375
  }
8228
8376
  }
8229
- return order.map((key) => {
8377
+ const stages = [];
8378
+ for (const key of order) {
8230
8379
  const { category, value } = totals.get(key);
8380
+ if (value <= 0) continue;
8231
8381
  const rgba2 = colorScale.map(key);
8232
- return {
8382
+ stages.push({
8233
8383
  key,
8234
8384
  label: key === "" ? "(blank)" : key,
8235
8385
  value,
8236
8386
  category,
8237
8387
  color: rgbaToCss(rgba2),
8238
8388
  rgba: rgba2
8239
- };
8389
+ });
8390
+ }
8391
+ return stages;
8392
+ }
8393
+ function safeRatio(numerator, denominator) {
8394
+ return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 ? numerator / denominator : 0;
8395
+ }
8396
+ function funnelPercents(stages, mode = "first") {
8397
+ return stages.map((stage, i) => {
8398
+ if (mode === "previous") return i === 0 ? 1 : safeRatio(stage.value, stages[i - 1].value);
8399
+ return safeRatio(stage.value, stages[0]?.value ?? 0);
8240
8400
  });
8241
8401
  }
8242
8402
  function drawFunnel(surface, spec, tokens, size, context) {
@@ -8274,12 +8434,10 @@ function drawFunnel(surface, spec, tokens, size, context) {
8274
8434
  const n = stages.length;
8275
8435
  const gap = Math.min(6, Math.max(2, content.height / n / 12));
8276
8436
  const bandH = content.height / n;
8277
- const widthOf = (v) => v / maxValue * funnelW;
8437
+ const widthOf = (v) => Math.max(0, v) / maxValue * funnelW;
8278
8438
  const alphaOf = (s) => emphasis ? emphasis.match({ [stageField]: s.category }) ? 1 : emphasis.dim : 1;
8279
- const pctOf = (i) => {
8280
- if (mode === "previous") return i === 0 ? 1 : stages[i].value / stages[i - 1].value;
8281
- return stages[i].value / stages[0].value;
8282
- };
8439
+ const percents = funnelPercents(stages, mode);
8440
+ const pctOf = (i) => percents[i] ?? 0;
8283
8441
  const sketch = resolveSketch(spec);
8284
8442
  const pen = sketch ? new RoughPen(ctx, sketch) : null;
8285
8443
  const bands = [];
@@ -8423,8 +8581,8 @@ function valueField(ref) {
8423
8581
  function formatDelta(delta, specFormat) {
8424
8582
  const sign2 = delta >= 0 ? "+" : "-";
8425
8583
  const abs = Math.abs(delta);
8426
- if (abs <= 1) return sign2 + formatNumber2(abs, ".1%");
8427
8584
  if (specFormat) return sign2 + formatValue(abs, specFormat);
8585
+ if (abs <= 1) return sign2 + formatNumber2(abs, ".1%");
8428
8586
  return sign2 + formatNumber2(abs, ",.0f");
8429
8587
  }
8430
8588
  function cardRectFor(surface, tokens, size, spec) {
@@ -8695,11 +8853,13 @@ function buildGraph(spec, palette) {
8695
8853
  target.targetLinks.push(link);
8696
8854
  links.push(link);
8697
8855
  }
8698
- for (const n of order) {
8699
- const out = n.sourceLinks.reduce((s, l) => s + l.value, 0);
8700
- const inc = n.targetLinks.reduce((s, l) => s + l.value, 0);
8701
- n.value = Math.max(out, inc);
8856
+ const feedback = removeFeedbackLinks(order);
8857
+ if (feedback.size > 0) {
8858
+ for (let i = links.length - 1; i >= 0; i -= 1) {
8859
+ if (feedback.has(links[i])) links.splice(i, 1);
8860
+ }
8702
8861
  }
8862
+ computeNodeValues(order);
8703
8863
  assignLayers(order);
8704
8864
  const maxLayer = order.reduce((m, n) => Math.max(m, n.layer), 0);
8705
8865
  const columns = Array.from({ length: maxLayer + 1 }, () => []);
@@ -8707,6 +8867,7 @@ function buildGraph(spec, palette) {
8707
8867
  return { nodes: order, links, columns, maxLayer };
8708
8868
  }
8709
8869
  function assignLayers(nodes) {
8870
+ for (const n of nodes) n.layer = 0;
8710
8871
  const indeg = /* @__PURE__ */ new Map();
8711
8872
  for (const n of nodes) indeg.set(n, n.targetLinks.length);
8712
8873
  const queue = nodes.filter((n) => (indeg.get(n) ?? 0) === 0);
@@ -8722,14 +8883,58 @@ function assignLayers(nodes) {
8722
8883
  if (d === 0) queue.push(t);
8723
8884
  }
8724
8885
  }
8886
+ for (const n of nodes) {
8887
+ if (!seen.has(n)) {
8888
+ seen.add(n);
8889
+ const incoming = n.targetLinks.reduce((m, l) => Math.max(m, l.source.layer + 1), 0);
8890
+ n.layer = Math.max(n.layer, incoming);
8891
+ }
8892
+ }
8725
8893
  const maxLayer = nodes.reduce((m, n) => Math.max(m, n.layer), 0);
8726
8894
  for (const n of nodes) {
8727
8895
  if (n.sourceLinks.length === 0 && n.targetLinks.length > 0) n.layer = maxLayer;
8728
8896
  }
8729
8897
  }
8898
+ function removeFeedbackLinks(nodes) {
8899
+ const visiting = /* @__PURE__ */ new Set();
8900
+ const visited = /* @__PURE__ */ new Set();
8901
+ const feedback = /* @__PURE__ */ new Set();
8902
+ const visit = (n) => {
8903
+ visiting.add(n);
8904
+ for (const link of n.sourceLinks) {
8905
+ const target = link.target;
8906
+ if (visiting.has(target)) {
8907
+ feedback.add(link);
8908
+ continue;
8909
+ }
8910
+ if (!visited.has(target)) visit(target);
8911
+ }
8912
+ visiting.delete(n);
8913
+ visited.add(n);
8914
+ };
8915
+ for (const n of nodes) {
8916
+ if (!visited.has(n)) visit(n);
8917
+ }
8918
+ if (feedback.size === 0) return feedback;
8919
+ for (const n of nodes) {
8920
+ n.sourceLinks = n.sourceLinks.filter((l) => !feedback.has(l));
8921
+ n.targetLinks = n.targetLinks.filter((l) => !feedback.has(l));
8922
+ }
8923
+ return feedback;
8924
+ }
8925
+ function computeNodeValues(nodes) {
8926
+ for (const n of nodes) {
8927
+ const out = n.sourceLinks.reduce((s, l) => s + l.value, 0);
8928
+ const inc = n.targetLinks.reduce((s, l) => s + l.value, 0);
8929
+ n.value = Math.max(out, inc);
8930
+ }
8931
+ }
8730
8932
  function layout(graph, area2, nodeWidth, pad3) {
8731
8933
  const { columns, links, maxLayer } = graph;
8732
8934
  const kx = maxLayer > 0 ? (area2.width - nodeWidth) / maxLayer : 0;
8935
+ const maxColumnSize = columns.reduce((m, col) => Math.max(m, col.length), 0);
8936
+ const desiredPad = Math.max(0, pad3);
8937
+ const effectivePad = maxColumnSize > 1 && desiredPad * (maxColumnSize - 1) >= area2.height && area2.height > 0 ? area2.height * 0.25 / (maxColumnSize - 1) : desiredPad;
8733
8938
  for (const col of columns) {
8734
8939
  for (const n of col) {
8735
8940
  n.x0 = area2.x + n.layer * kx;
@@ -8740,26 +8945,26 @@ function layout(graph, area2, nodeWidth, pad3) {
8740
8945
  for (const col of columns) {
8741
8946
  if (col.length === 0) continue;
8742
8947
  const sum = col.reduce((s, n) => s + n.value, 0);
8743
- const avail = area2.height - (col.length - 1) * pad3;
8744
- if (sum > 0 && avail > 0) ky = Math.min(ky, avail / sum);
8948
+ const avail = Math.max(0, area2.height - (col.length - 1) * effectivePad);
8949
+ if (sum > 0) ky = Math.min(ky, avail / sum);
8745
8950
  }
8746
- if (!Number.isFinite(ky) || ky <= 0) ky = 1;
8951
+ if (!Number.isFinite(ky) || ky < 0) ky = 0;
8747
8952
  for (const col of columns) {
8748
8953
  let y = area2.y;
8749
8954
  for (const n of col) {
8750
8955
  n.y0 = y;
8751
- n.y1 = y + Math.max(1, n.value * ky);
8752
- y = n.y1 + pad3;
8956
+ n.y1 = y + Math.max(0, n.value * ky);
8957
+ y = n.y1 + effectivePad;
8753
8958
  }
8754
8959
  centerColumn(col, area2);
8755
8960
  }
8756
- for (const l of links) l.width = Math.max(1, l.value * ky);
8961
+ for (const l of links) l.width = Math.max(0, l.value * ky);
8757
8962
  for (let i = 0; i < 6; i += 1) {
8758
8963
  const alpha = Math.pow(0.97, i);
8759
8964
  relax(columns, true, alpha);
8760
- resolveAll(columns, area2, pad3);
8965
+ resolveAll(columns, area2, effectivePad);
8761
8966
  relax(columns, false, alpha);
8762
- resolveAll(columns, area2, pad3);
8967
+ resolveAll(columns, area2, effectivePad);
8763
8968
  }
8764
8969
  computeLinkBreadths(graph);
8765
8970
  }
@@ -9001,6 +9206,7 @@ function featureKey(feature, featureId) {
9001
9206
  if (featureId === "id" && feature.id != null) return toKey(feature.id);
9002
9207
  const v = feature.properties?.[featureId];
9003
9208
  if (v != null) return toKey(v);
9209
+ return "";
9004
9210
  }
9005
9211
  if (feature.id != null) return toKey(feature.id);
9006
9212
  const props = feature.properties ?? {};
@@ -12012,6 +12218,12 @@ function buildGroups(leaves) {
12012
12218
  }
12013
12219
  return order.map((key) => groups.get(key));
12014
12220
  }
12221
+ function tileProbe(treemap, tile) {
12222
+ const probe = { [treemap.encoding.category.field]: tile.category };
12223
+ const groupField = treemap.encoding.group?.field;
12224
+ if (groupField) probe[groupField] = tile.group;
12225
+ return probe;
12226
+ }
12015
12227
  function drawRect2(ctx, pen, rect, fill, stroke) {
12016
12228
  if (rect.width <= 0 || rect.height <= 0) return;
12017
12229
  if (pen) {
@@ -12056,6 +12268,7 @@ function addTileLabels(surface, tokens, tile, valueFormat) {
12056
12268
  function drawTreemap(surface, spec, tokens, size, context) {
12057
12269
  const treemap = spec;
12058
12270
  const ctx = surface.marks.ctx;
12271
+ const emphasis = context?.emphasis ?? null;
12059
12272
  const content = drawTitleBlock(surface, tokens, size, treemap.title);
12060
12273
  const leaves = buildLeaves(treemap);
12061
12274
  colorLeaves(leaves, treemap, tokens);
@@ -12077,6 +12290,12 @@ function drawTreemap(surface, spec, tokens, size, context) {
12077
12290
  const tiles = [];
12078
12291
  const stroke = tokens.color.background;
12079
12292
  const groupRects = [];
12293
+ const drawTile = (tile) => {
12294
+ const alpha = emphasis ? emphasis.match(tileProbe(treemap, tile)) ? 1 : emphasis.dim : 1;
12295
+ if (alpha !== 1) ctx.globalAlpha = alpha;
12296
+ drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
12297
+ if (alpha !== 1) ctx.globalAlpha = 1;
12298
+ };
12080
12299
  ctx.save();
12081
12300
  if (treemap.encoding.group) {
12082
12301
  const groups = buildGroups(leaves);
@@ -12091,14 +12310,14 @@ function drawTreemap(surface, spec, tokens, size, context) {
12091
12310
  for (const leafNode of layoutTreemap(groupNode.item.leaves, inner)) {
12092
12311
  const tile = { ...leafNode.item, rect: leafNode.rect };
12093
12312
  tiles.push(tile);
12094
- drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
12313
+ drawTile(tile);
12095
12314
  }
12096
12315
  }
12097
12316
  } else {
12098
12317
  for (const leafNode of layoutTreemap(leaves, content)) {
12099
12318
  const tile = { ...leafNode.item, rect: leafNode.rect };
12100
12319
  tiles.push(tile);
12101
- drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
12320
+ drawTile(tile);
12102
12321
  }
12103
12322
  }
12104
12323
  ctx.restore();