graphein 0.5.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) {
@@ -3561,7 +3588,7 @@ function computeBins(values, opts = {}) {
3561
3588
  [min, max] = opts.extent;
3562
3589
  } else {
3563
3590
  for (const v of values) {
3564
- if (Number.isNaN(v)) continue;
3591
+ if (!Number.isFinite(v)) continue;
3565
3592
  if (v < min) min = v;
3566
3593
  if (v > max) max = v;
3567
3594
  }
@@ -3585,7 +3612,7 @@ function applyBin(transform2, data) {
3585
3612
  const finite4 = [];
3586
3613
  for (const row of data) {
3587
3614
  const n = toNumber(read(row));
3588
- if (!Number.isNaN(n)) finite4.push(n);
3615
+ if (Number.isFinite(n)) finite4.push(n);
3589
3616
  }
3590
3617
  const layout2 = computeBins(finite4, {
3591
3618
  maxbins: transform2.maxbins,
@@ -3599,7 +3626,7 @@ function applyBin(transform2, data) {
3599
3626
  const out = { ...row };
3600
3627
  const v = toNumber(read(row));
3601
3628
  const outside = transform2.extent != null && (v < transform2.extent[0] || v > transform2.extent[1]);
3602
- if (layout2 == null || Number.isNaN(v) || outside) {
3629
+ if (layout2 == null || !Number.isFinite(v) || outside) {
3603
3630
  out[startField] = null;
3604
3631
  if (endField) out[endField] = null;
3605
3632
  return out;
@@ -4646,6 +4673,26 @@ function validateSpec(spec) {
4646
4673
  }
4647
4674
  }
4648
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
+ }
4649
4696
  if (type === "kpi") {
4650
4697
  const value = spec.value;
4651
4698
  if (value === void 0) {
@@ -6277,6 +6324,21 @@ function stackedYExtent(rows, xField, yField) {
6277
6324
  for (const v of neg.values()) if (v < min) min = v;
6278
6325
  return [min, max];
6279
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
+ }
6280
6342
  function resolveSeriesField(spec) {
6281
6343
  const enc = spec.encoding;
6282
6344
  if (enc.series?.field) return enc.series.field;
@@ -6338,7 +6400,8 @@ function buildCartesianModel(spec, tokens, opts) {
6338
6400
  let categories;
6339
6401
  let xDomainNum = null;
6340
6402
  if (xKind === "band" || xKind === "point") {
6341
- 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));
6342
6405
  } else if (xKind === "time") {
6343
6406
  if (opts.shared?.xDomain) {
6344
6407
  xDomainNum = opts.shared.xDomain;
@@ -6351,12 +6414,23 @@ function buildCartesianModel(spec, tokens, opts) {
6351
6414
  } else {
6352
6415
  xDomainNum = extent(data, xField) ?? [0, 1];
6353
6416
  const xScaleCfg = enc.x.scale;
6354
- 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") {
6355
6425
  xDomainNum = [xScaleCfg.domain[0], xScaleCfg.domain[1]];
6356
6426
  } else if (xScaleCfg?.nice !== false) {
6357
6427
  xDomainNum = niceDomain(xDomainNum[0], xDomainNum[1], 10);
6358
6428
  }
6359
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;
6360
6434
  const sharedY = opts.shared?.yDomain;
6361
6435
  let yDomain;
6362
6436
  if (sharedY) {
@@ -6367,24 +6441,39 @@ function buildCartesianModel(spec, tokens, opts) {
6367
6441
  const ext = extent(data, yField) ?? [0, 1];
6368
6442
  yDomain = [ext[0], ext[1]];
6369
6443
  }
6370
- if (wantsZeroBaseline(spec) && !sharedY) {
6444
+ const forceZero = yIsLog ? false : yScaleCfg?.zero ?? wantsZeroBaseline(spec);
6445
+ if (forceZero && !sharedY) {
6371
6446
  yDomain = [Math.min(0, yDomain[0]), Math.max(0, yDomain[1])];
6372
6447
  }
6373
- const yScaleCfg = enc.y.scale;
6374
6448
  const xAxisCfg = axisCfg(spec, "x");
6375
6449
  const yAxisCfg = axisCfg(spec, "y");
6376
6450
  const yTickCount = yAxisCfg.ticks ?? 6;
6451
+ const yExplicitTicks = yAxisCfg.tickValues && yAxisCfg.tickValues.length ? yAxisCfg.tickValues.slice() : null;
6377
6452
  const yExplicit = yScaleCfg?.domain && typeof yScaleCfg.domain[0] === "number" || !!sharedY;
6378
6453
  let yTickValues;
6379
- 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) {
6380
6466
  if (yScaleCfg?.domain && typeof yScaleCfg.domain[0] === "number") {
6381
6467
  yDomain = [yScaleCfg.domain[0], yScaleCfg.domain[1]];
6382
6468
  }
6383
6469
  const lo = Math.min(yDomain[0], yDomain[1]);
6384
6470
  const hi = Math.max(yDomain[0], yDomain[1]);
6385
- yTickValues = numericTickValues(yDomain[0], yDomain[1], yTickCount).filter(
6471
+ yTickValues = (yExplicitTicks ?? numericTickValues(yDomain[0], yDomain[1], yTickCount)).filter(
6386
6472
  (v) => v >= lo - 1e-9 && v <= hi + 1e-9
6387
6473
  );
6474
+ } else if (yExplicitTicks) {
6475
+ yTickValues = yExplicitTicks;
6476
+ yDomain = [Math.min(yDomain[0], ...yExplicitTicks), Math.max(yDomain[1], ...yExplicitTicks)];
6388
6477
  } else if (yScaleCfg?.nice !== false) {
6389
6478
  yTickValues = numericTickValues(yDomain[0], yDomain[1], yTickCount);
6390
6479
  if (yTickValues.length >= 2) {
@@ -6412,7 +6501,19 @@ function buildCartesianModel(spec, tokens, opts) {
6412
6501
  xStepMs = xt.length > 1 ? xt[1] - xt[0] : 0;
6413
6502
  xLabels = xt.map((v) => enc.x.format ? formatValue(v, enc.x.format) : smartDate(v, xStepMs));
6414
6503
  } else {
6415
- 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
+ }
6416
6517
  xTickValues = xt;
6417
6518
  xLabels = xt.map((v) => formatNumericTick(v, enc.x.format ?? xAxisCfg.format));
6418
6519
  }
@@ -6439,9 +6540,10 @@ function buildCartesianModel(spec, tokens, opts) {
6439
6540
  yAxis: { show: yShow, labels: yLabels, title: resolveAxisTitle(enc.y, yAxisCfg) }
6440
6541
  });
6441
6542
  const plot = frame.plot;
6442
- 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 });
6443
6545
  const yPixel = (value) => yScale.map(toNumber(value));
6444
- 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);
6445
6547
  const xModel = buildXModel(xKind, {
6446
6548
  field: xField,
6447
6549
  type: xType,
@@ -6517,7 +6619,8 @@ function buildXModel(kind, args) {
6517
6619
  };
6518
6620
  }
6519
6621
  const domain = args.domainNum ?? [0, 1];
6520
- 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 });
6521
6624
  return {
6522
6625
  kind,
6523
6626
  field: args.field,
@@ -7231,6 +7334,12 @@ function boxProbe(model, geom) {
7231
7334
  if (model.seriesField) probe[model.seriesField] = geom.seriesValue;
7232
7335
  return probe;
7233
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
+ }
7234
7343
  function drawBox(surface, model) {
7235
7344
  const layout2 = computeBoxes(model);
7236
7345
  if (!layout2) return;
@@ -7262,9 +7371,7 @@ function buildBoxInteraction(model) {
7262
7371
  return {
7263
7372
  region: model.plot,
7264
7373
  hitTest: (px, py) => {
7265
- const hit = geoms.find(
7266
- (g) => px >= g.left - 4 && px <= g.right + 4 && py >= g.top - 6 && py <= g.bottom + 6
7267
- );
7374
+ const hit = geoms.find((g) => hitsBoxGeom(model, g, showOutliers, px, py));
7268
7375
  if (!hit) return null;
7269
7376
  const s = hit.stats;
7270
7377
  const rows = [
@@ -7297,10 +7404,15 @@ function buildBoxInteraction(model) {
7297
7404
  };
7298
7405
  },
7299
7406
  pick: (px, py) => {
7300
- const hit = geoms.find(
7301
- (g) => px >= g.left - 4 && px <= g.right + 4 && py >= g.top - 6 && py <= g.bottom + 6
7302
- );
7407
+ const hit = geoms.find((g) => hitsBoxGeom(model, g, showOutliers, px, py));
7303
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
+ }
7304
7416
  return { kind: "point", fields: [model.x.field], tuples: [[hit.xValue]] };
7305
7417
  }
7306
7418
  };
@@ -7557,6 +7669,18 @@ function measureMax(labels, font) {
7557
7669
  for (const label of labels) max = Math.max(max, measureText(label, font).width);
7558
7670
  return max;
7559
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
+ }
7560
7684
  function drawRoundedCell(ctx, x, y, width, height, radius) {
7561
7685
  if (!finite3(x) || !finite3(y) || !finite3(width) || !finite3(height) || width <= 0 || height <= 0) return;
7562
7686
  ctx.beginPath();
@@ -7605,7 +7729,7 @@ function drawHeatmap(surface, spec, tokens, size, context) {
7605
7729
  const { x, y, color } = heatmap.encoding;
7606
7730
  const xCats = uniqueStrings(rows, x.field);
7607
7731
  const yCats = uniqueStrings(rows, y.field);
7608
- const colorDomain = extent(rows, color.field);
7732
+ const colorDomain = heatmapColorDomain(rows, color.field);
7609
7733
  if (xCats.length === 0 || yCats.length === 0 || !colorDomain || content.width <= 0 || content.height <= 0) return;
7610
7734
  const labelSize = tokens.font.size.small;
7611
7735
  const labelFont = fontString(labelSize, tokens.font.family, tokens.font.weight.normal);
@@ -7772,16 +7896,33 @@ function clamp3(value, min, max) {
7772
7896
  function direction(angle) {
7773
7897
  return { x: Math.sin(angle), y: -Math.cos(angle) };
7774
7898
  }
7775
- function formatPercent(share) {
7776
- const pct = share * 100;
7777
- const text = pct < 10 ? pct.toFixed(1) : pct.toFixed(0);
7778
- 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}%`;
7779
7919
  }
7780
- function labelText(slice, placement, options) {
7920
+ function labelText(slice, placement, options, percents) {
7781
7921
  const content = options.content ?? (placement === "inside" ? "percent" : "category-percent");
7922
+ const percentText = formatPercent(percents.get(slice.index) ?? 0);
7782
7923
  switch (content) {
7783
7924
  case "percent":
7784
- return formatPercent(slice.share);
7925
+ return percentText;
7785
7926
  case "value":
7786
7927
  return options.formatValue(slice.value);
7787
7928
  case "category":
@@ -7790,7 +7931,7 @@ function labelText(slice, placement, options) {
7790
7931
  return `${slice.label} \xB7 ${options.formatValue(slice.value)}`;
7791
7932
  case "category-percent":
7792
7933
  default:
7793
- return `${slice.label} \xB7 ${formatPercent(slice.share)}`;
7934
+ return `${slice.label} \xB7 ${percentText}`;
7794
7935
  }
7795
7936
  }
7796
7937
  function relaxColumn(idealYs, lineHeight2, top, bottom) {
@@ -7820,6 +7961,10 @@ function planPieLabels(options) {
7820
7961
  const origInnerR = options.innerR;
7821
7962
  const insideSlices = [];
7822
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
+ });
7823
7968
  const insideLabelR = origInnerR > 0 ? (origInnerR + origOuterR) / 2 : origOuterR * 0.62;
7824
7969
  const radialBand = origInnerR > 0 ? origOuterR - origInnerR : origOuterR;
7825
7970
  for (const slice of slices) {
@@ -7829,20 +7974,20 @@ function planPieLabels(options) {
7829
7974
  if (placement === "inside") goesInside = true;
7830
7975
  else if (placement === "outside") goesInside = false;
7831
7976
  else {
7832
- const insideText = labelText(slice, "inside", options);
7977
+ const insideText = labelText(slice, "inside", options, percents);
7833
7978
  const tangential = (slice.endAngle - slice.startAngle) * insideLabelR;
7834
7979
  const fitsTangential = measure(insideText) + INSIDE_PAD <= tangential;
7835
7980
  const fitsRadial = lineHeight2 * 0.8 <= radialBand;
7836
7981
  goesInside = fitsTangential && fitsRadial;
7837
7982
  }
7838
7983
  if (goesInside) {
7839
- insideSlices.push({ slice, mid, text: labelText(slice, "inside", options) });
7984
+ insideSlices.push({ slice, mid, text: labelText(slice, "inside", options, percents) });
7840
7985
  } else {
7841
7986
  const dir = direction(mid);
7842
7987
  outsideSlices.push({
7843
7988
  slice,
7844
7989
  mid,
7845
- text: labelText(slice, "outside", options),
7990
+ text: labelText(slice, "outside", options, percents),
7846
7991
  side: dir.x >= 0 ? "right" : "left"
7847
7992
  });
7848
7993
  }
@@ -8229,17 +8374,29 @@ function buildStages(spec, palette) {
8229
8374
  order.push(key);
8230
8375
  }
8231
8376
  }
8232
- return order.map((key) => {
8377
+ const stages = [];
8378
+ for (const key of order) {
8233
8379
  const { category, value } = totals.get(key);
8380
+ if (value <= 0) continue;
8234
8381
  const rgba2 = colorScale.map(key);
8235
- return {
8382
+ stages.push({
8236
8383
  key,
8237
8384
  label: key === "" ? "(blank)" : key,
8238
8385
  value,
8239
8386
  category,
8240
8387
  color: rgbaToCss(rgba2),
8241
8388
  rgba: rgba2
8242
- };
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);
8243
8400
  });
8244
8401
  }
8245
8402
  function drawFunnel(surface, spec, tokens, size, context) {
@@ -8277,12 +8434,10 @@ function drawFunnel(surface, spec, tokens, size, context) {
8277
8434
  const n = stages.length;
8278
8435
  const gap = Math.min(6, Math.max(2, content.height / n / 12));
8279
8436
  const bandH = content.height / n;
8280
- const widthOf = (v) => v / maxValue * funnelW;
8437
+ const widthOf = (v) => Math.max(0, v) / maxValue * funnelW;
8281
8438
  const alphaOf = (s) => emphasis ? emphasis.match({ [stageField]: s.category }) ? 1 : emphasis.dim : 1;
8282
- const pctOf = (i) => {
8283
- if (mode === "previous") return i === 0 ? 1 : stages[i].value / stages[i - 1].value;
8284
- return stages[i].value / stages[0].value;
8285
- };
8439
+ const percents = funnelPercents(stages, mode);
8440
+ const pctOf = (i) => percents[i] ?? 0;
8286
8441
  const sketch = resolveSketch(spec);
8287
8442
  const pen = sketch ? new RoughPen(ctx, sketch) : null;
8288
8443
  const bands = [];
@@ -8426,8 +8581,8 @@ function valueField(ref) {
8426
8581
  function formatDelta(delta, specFormat) {
8427
8582
  const sign2 = delta >= 0 ? "+" : "-";
8428
8583
  const abs = Math.abs(delta);
8429
- if (abs <= 1) return sign2 + formatNumber2(abs, ".1%");
8430
8584
  if (specFormat) return sign2 + formatValue(abs, specFormat);
8585
+ if (abs <= 1) return sign2 + formatNumber2(abs, ".1%");
8431
8586
  return sign2 + formatNumber2(abs, ",.0f");
8432
8587
  }
8433
8588
  function cardRectFor(surface, tokens, size, spec) {
@@ -8698,11 +8853,13 @@ function buildGraph(spec, palette) {
8698
8853
  target.targetLinks.push(link);
8699
8854
  links.push(link);
8700
8855
  }
8701
- for (const n of order) {
8702
- const out = n.sourceLinks.reduce((s, l) => s + l.value, 0);
8703
- const inc = n.targetLinks.reduce((s, l) => s + l.value, 0);
8704
- 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
+ }
8705
8861
  }
8862
+ computeNodeValues(order);
8706
8863
  assignLayers(order);
8707
8864
  const maxLayer = order.reduce((m, n) => Math.max(m, n.layer), 0);
8708
8865
  const columns = Array.from({ length: maxLayer + 1 }, () => []);
@@ -8710,6 +8867,7 @@ function buildGraph(spec, palette) {
8710
8867
  return { nodes: order, links, columns, maxLayer };
8711
8868
  }
8712
8869
  function assignLayers(nodes) {
8870
+ for (const n of nodes) n.layer = 0;
8713
8871
  const indeg = /* @__PURE__ */ new Map();
8714
8872
  for (const n of nodes) indeg.set(n, n.targetLinks.length);
8715
8873
  const queue = nodes.filter((n) => (indeg.get(n) ?? 0) === 0);
@@ -8725,14 +8883,58 @@ function assignLayers(nodes) {
8725
8883
  if (d === 0) queue.push(t);
8726
8884
  }
8727
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
+ }
8728
8893
  const maxLayer = nodes.reduce((m, n) => Math.max(m, n.layer), 0);
8729
8894
  for (const n of nodes) {
8730
8895
  if (n.sourceLinks.length === 0 && n.targetLinks.length > 0) n.layer = maxLayer;
8731
8896
  }
8732
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
+ }
8733
8932
  function layout(graph, area2, nodeWidth, pad3) {
8734
8933
  const { columns, links, maxLayer } = graph;
8735
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;
8736
8938
  for (const col of columns) {
8737
8939
  for (const n of col) {
8738
8940
  n.x0 = area2.x + n.layer * kx;
@@ -8743,26 +8945,26 @@ function layout(graph, area2, nodeWidth, pad3) {
8743
8945
  for (const col of columns) {
8744
8946
  if (col.length === 0) continue;
8745
8947
  const sum = col.reduce((s, n) => s + n.value, 0);
8746
- const avail = area2.height - (col.length - 1) * pad3;
8747
- 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);
8748
8950
  }
8749
- if (!Number.isFinite(ky) || ky <= 0) ky = 1;
8951
+ if (!Number.isFinite(ky) || ky < 0) ky = 0;
8750
8952
  for (const col of columns) {
8751
8953
  let y = area2.y;
8752
8954
  for (const n of col) {
8753
8955
  n.y0 = y;
8754
- n.y1 = y + Math.max(1, n.value * ky);
8755
- y = n.y1 + pad3;
8956
+ n.y1 = y + Math.max(0, n.value * ky);
8957
+ y = n.y1 + effectivePad;
8756
8958
  }
8757
8959
  centerColumn(col, area2);
8758
8960
  }
8759
- 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);
8760
8962
  for (let i = 0; i < 6; i += 1) {
8761
8963
  const alpha = Math.pow(0.97, i);
8762
8964
  relax(columns, true, alpha);
8763
- resolveAll(columns, area2, pad3);
8965
+ resolveAll(columns, area2, effectivePad);
8764
8966
  relax(columns, false, alpha);
8765
- resolveAll(columns, area2, pad3);
8967
+ resolveAll(columns, area2, effectivePad);
8766
8968
  }
8767
8969
  computeLinkBreadths(graph);
8768
8970
  }
@@ -9004,6 +9206,7 @@ function featureKey(feature, featureId) {
9004
9206
  if (featureId === "id" && feature.id != null) return toKey(feature.id);
9005
9207
  const v = feature.properties?.[featureId];
9006
9208
  if (v != null) return toKey(v);
9209
+ return "";
9007
9210
  }
9008
9211
  if (feature.id != null) return toKey(feature.id);
9009
9212
  const props = feature.properties ?? {};
@@ -12015,6 +12218,12 @@ function buildGroups(leaves) {
12015
12218
  }
12016
12219
  return order.map((key) => groups.get(key));
12017
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
+ }
12018
12227
  function drawRect2(ctx, pen, rect, fill, stroke) {
12019
12228
  if (rect.width <= 0 || rect.height <= 0) return;
12020
12229
  if (pen) {
@@ -12059,6 +12268,7 @@ function addTileLabels(surface, tokens, tile, valueFormat) {
12059
12268
  function drawTreemap(surface, spec, tokens, size, context) {
12060
12269
  const treemap = spec;
12061
12270
  const ctx = surface.marks.ctx;
12271
+ const emphasis = context?.emphasis ?? null;
12062
12272
  const content = drawTitleBlock(surface, tokens, size, treemap.title);
12063
12273
  const leaves = buildLeaves(treemap);
12064
12274
  colorLeaves(leaves, treemap, tokens);
@@ -12080,6 +12290,12 @@ function drawTreemap(surface, spec, tokens, size, context) {
12080
12290
  const tiles = [];
12081
12291
  const stroke = tokens.color.background;
12082
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
+ };
12083
12299
  ctx.save();
12084
12300
  if (treemap.encoding.group) {
12085
12301
  const groups = buildGroups(leaves);
@@ -12094,14 +12310,14 @@ function drawTreemap(surface, spec, tokens, size, context) {
12094
12310
  for (const leafNode of layoutTreemap(groupNode.item.leaves, inner)) {
12095
12311
  const tile = { ...leafNode.item, rect: leafNode.rect };
12096
12312
  tiles.push(tile);
12097
- drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
12313
+ drawTile(tile);
12098
12314
  }
12099
12315
  }
12100
12316
  } else {
12101
12317
  for (const leafNode of layoutTreemap(leaves, content)) {
12102
12318
  const tile = { ...leafNode.item, rect: leafNode.rect };
12103
12319
  tiles.push(tile);
12104
- drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
12320
+ drawTile(tile);
12105
12321
  }
12106
12322
  }
12107
12323
  ctx.restore();