graphein 0.5.0 → 0.7.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) {
@@ -8584,6 +8739,10 @@ function buildSparklineCanvas(tokens, width, height, values, sketch) {
8584
8739
  }
8585
8740
  function drawKpi(surface, spec, tokens, size, context) {
8586
8741
  const kpi = spec;
8742
+ if (surface.headless) {
8743
+ drawKpiCanvas(surface, kpi, tokens, size, context);
8744
+ return;
8745
+ }
8587
8746
  const data = kpi.data ?? [];
8588
8747
  const value = resolveValue(kpi.value, data);
8589
8748
  const delta = resolveValue(kpi.delta, data);
@@ -8652,6 +8811,116 @@ function drawKpi(surface, spec, tokens, size, context) {
8652
8811
  }
8653
8812
  }
8654
8813
  }
8814
+ function drawKpiCanvas(surface, kpi, tokens, size, context) {
8815
+ const ctx = surface.marks.ctx;
8816
+ const data = kpi.data ?? [];
8817
+ const value = resolveValue(kpi.value, data);
8818
+ const delta = resolveValue(kpi.delta, data);
8819
+ const sparkField = sparklineField(kpi);
8820
+ const sparkValues = sparkField ? sparklineValues(data, sparkField) : [];
8821
+ const hasSparkline = sparkValues.length > 0;
8822
+ const framed = context?.framed === true;
8823
+ const rect = cardRectFor(surface, tokens, size, kpi);
8824
+ const pad3 = tokens.spacing.lg;
8825
+ const sparkReserve = hasSparkline ? Math.min(44, Math.max(28, rect.height * 0.18)) + tokens.spacing.sm : 0;
8826
+ if (!framed) {
8827
+ ctx.save();
8828
+ ctx.beginPath();
8829
+ roundedRect(ctx, rect.x, rect.y, rect.width, rect.height, tokens.radius.lg);
8830
+ ctx.fillStyle = tokens.color.surface;
8831
+ ctx.fill();
8832
+ ctx.lineWidth = 1;
8833
+ ctx.strokeStyle = tokens.color.border;
8834
+ ctx.stroke();
8835
+ ctx.restore();
8836
+ }
8837
+ const left = rect.x + (framed ? 0 : pad3);
8838
+ const contentWidth = Math.max(1, rect.width - (framed ? 0 : pad3 * 2));
8839
+ const valueText = value == null ? "\u2014" : formatValue(value, kpi.format);
8840
+ const sizeByCard = Math.min(rect.width, rect.height) * 0.22;
8841
+ const sizeByText = contentWidth / Math.max(1, valueText.length * 0.58);
8842
+ const valueSize = clamp5(Math.min(sizeByCard, sizeByText), 28, 64);
8843
+ const labelH = kpi.label ? tokens.font.size.base * 1.25 : 0;
8844
+ const deltaH = delta != null ? tokens.font.size.large : 0;
8845
+ const stackH = labelH + valueSize + deltaH + tokens.spacing.sm * 2;
8846
+ const avail = rect.height - sparkReserve;
8847
+ let y = rect.y + Math.max(framed ? 0 : pad3, (avail - stackH) / 2);
8848
+ if (kpi.label) {
8849
+ paintCanvasText(ctx, {
8850
+ x: left,
8851
+ y,
8852
+ text: kpi.label.toUpperCase(),
8853
+ font: fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.medium),
8854
+ color: tokens.color.textMuted,
8855
+ size: tokens.font.size.base,
8856
+ baseline: "top"
8857
+ });
8858
+ y += labelH + tokens.spacing.sm;
8859
+ }
8860
+ paintCanvasText(ctx, {
8861
+ x: left,
8862
+ y,
8863
+ text: valueText,
8864
+ font: fontString(valueSize, tokens.font.family, tokens.font.weight.bold),
8865
+ color: tokens.color.text,
8866
+ size: valueSize,
8867
+ baseline: "top"
8868
+ });
8869
+ y += valueSize + tokens.spacing.sm;
8870
+ if (delta != null) {
8871
+ const positive = delta >= 0;
8872
+ paintCanvasText(ctx, {
8873
+ x: left,
8874
+ y,
8875
+ text: `${positive ? "\u25B2" : "\u25BC"} ${formatDelta(delta, kpi.format)}`,
8876
+ font: fontString(tokens.font.size.large, tokens.font.family, tokens.font.weight.medium),
8877
+ color: positive ? tokens.color.positive : tokens.color.negative,
8878
+ size: tokens.font.size.large,
8879
+ baseline: "top"
8880
+ });
8881
+ }
8882
+ if (hasSparkline) {
8883
+ const sparkH = clamp5(rect.height * 0.18, 24, 40);
8884
+ const inset = framed ? 0 : pad3;
8885
+ const sparkW = Math.max(0, rect.width - inset * 2);
8886
+ const x0 = rect.x + inset;
8887
+ const y1 = rect.y + rect.height - inset;
8888
+ paintSparklineCanvas(ctx, tokens, x0, y1 - sparkH, sparkW, sparkH, sparkValues);
8889
+ }
8890
+ }
8891
+ function paintSparklineCanvas(ctx, tokens, x0, y0, width, height, values) {
8892
+ if (values.length === 0 || width <= 0 || height <= 0) return;
8893
+ const stroke = 1.75;
8894
+ const pad3 = stroke;
8895
+ const innerH = Math.max(1, height - pad3 * 2);
8896
+ const n = values.length;
8897
+ const min = Math.min(...values);
8898
+ const max = Math.max(...values);
8899
+ const span = max - min;
8900
+ const xAt = (i) => x0 + (n === 1 ? width / 2 : i / (n - 1) * width);
8901
+ const yAt = (v) => y0 + pad3 + (1 - (span === 0 ? 0.5 : (v - min) / span)) * innerH;
8902
+ ctx.save();
8903
+ if (n === 1) {
8904
+ ctx.beginPath();
8905
+ ctx.arc(xAt(0), yAt(values[0]), 2.25, 0, Math.PI * 2);
8906
+ ctx.fillStyle = tokens.color.accent;
8907
+ ctx.fill();
8908
+ } else {
8909
+ ctx.beginPath();
8910
+ for (let i = 0; i < n; i++) {
8911
+ const px = xAt(i);
8912
+ const py = yAt(values[i]);
8913
+ if (i === 0) ctx.moveTo(px, py);
8914
+ else ctx.lineTo(px, py);
8915
+ }
8916
+ ctx.strokeStyle = tokens.color.accent;
8917
+ ctx.lineWidth = stroke;
8918
+ ctx.lineJoin = "round";
8919
+ ctx.lineCap = "round";
8920
+ ctx.stroke();
8921
+ }
8922
+ ctx.restore();
8923
+ }
8655
8924
 
8656
8925
  // src/charts/sankey.ts
8657
8926
  var center = (n) => (n.y0 + n.y1) / 2;
@@ -8698,11 +8967,13 @@ function buildGraph(spec, palette) {
8698
8967
  target.targetLinks.push(link);
8699
8968
  links.push(link);
8700
8969
  }
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);
8970
+ const feedback = removeFeedbackLinks(order);
8971
+ if (feedback.size > 0) {
8972
+ for (let i = links.length - 1; i >= 0; i -= 1) {
8973
+ if (feedback.has(links[i])) links.splice(i, 1);
8974
+ }
8705
8975
  }
8976
+ computeNodeValues(order);
8706
8977
  assignLayers(order);
8707
8978
  const maxLayer = order.reduce((m, n) => Math.max(m, n.layer), 0);
8708
8979
  const columns = Array.from({ length: maxLayer + 1 }, () => []);
@@ -8710,6 +8981,7 @@ function buildGraph(spec, palette) {
8710
8981
  return { nodes: order, links, columns, maxLayer };
8711
8982
  }
8712
8983
  function assignLayers(nodes) {
8984
+ for (const n of nodes) n.layer = 0;
8713
8985
  const indeg = /* @__PURE__ */ new Map();
8714
8986
  for (const n of nodes) indeg.set(n, n.targetLinks.length);
8715
8987
  const queue = nodes.filter((n) => (indeg.get(n) ?? 0) === 0);
@@ -8725,14 +8997,58 @@ function assignLayers(nodes) {
8725
8997
  if (d === 0) queue.push(t);
8726
8998
  }
8727
8999
  }
9000
+ for (const n of nodes) {
9001
+ if (!seen.has(n)) {
9002
+ seen.add(n);
9003
+ const incoming = n.targetLinks.reduce((m, l) => Math.max(m, l.source.layer + 1), 0);
9004
+ n.layer = Math.max(n.layer, incoming);
9005
+ }
9006
+ }
8728
9007
  const maxLayer = nodes.reduce((m, n) => Math.max(m, n.layer), 0);
8729
9008
  for (const n of nodes) {
8730
9009
  if (n.sourceLinks.length === 0 && n.targetLinks.length > 0) n.layer = maxLayer;
8731
9010
  }
8732
9011
  }
9012
+ function removeFeedbackLinks(nodes) {
9013
+ const visiting = /* @__PURE__ */ new Set();
9014
+ const visited = /* @__PURE__ */ new Set();
9015
+ const feedback = /* @__PURE__ */ new Set();
9016
+ const visit = (n) => {
9017
+ visiting.add(n);
9018
+ for (const link of n.sourceLinks) {
9019
+ const target = link.target;
9020
+ if (visiting.has(target)) {
9021
+ feedback.add(link);
9022
+ continue;
9023
+ }
9024
+ if (!visited.has(target)) visit(target);
9025
+ }
9026
+ visiting.delete(n);
9027
+ visited.add(n);
9028
+ };
9029
+ for (const n of nodes) {
9030
+ if (!visited.has(n)) visit(n);
9031
+ }
9032
+ if (feedback.size === 0) return feedback;
9033
+ for (const n of nodes) {
9034
+ n.sourceLinks = n.sourceLinks.filter((l) => !feedback.has(l));
9035
+ n.targetLinks = n.targetLinks.filter((l) => !feedback.has(l));
9036
+ }
9037
+ return feedback;
9038
+ }
9039
+ function computeNodeValues(nodes) {
9040
+ for (const n of nodes) {
9041
+ const out = n.sourceLinks.reduce((s, l) => s + l.value, 0);
9042
+ const inc = n.targetLinks.reduce((s, l) => s + l.value, 0);
9043
+ n.value = Math.max(out, inc);
9044
+ }
9045
+ }
8733
9046
  function layout(graph, area2, nodeWidth, pad3) {
8734
9047
  const { columns, links, maxLayer } = graph;
8735
9048
  const kx = maxLayer > 0 ? (area2.width - nodeWidth) / maxLayer : 0;
9049
+ const maxColumnSize = columns.reduce((m, col) => Math.max(m, col.length), 0);
9050
+ const desiredPad = Math.max(0, pad3);
9051
+ const effectivePad = maxColumnSize > 1 && desiredPad * (maxColumnSize - 1) >= area2.height && area2.height > 0 ? area2.height * 0.25 / (maxColumnSize - 1) : desiredPad;
8736
9052
  for (const col of columns) {
8737
9053
  for (const n of col) {
8738
9054
  n.x0 = area2.x + n.layer * kx;
@@ -8743,26 +9059,26 @@ function layout(graph, area2, nodeWidth, pad3) {
8743
9059
  for (const col of columns) {
8744
9060
  if (col.length === 0) continue;
8745
9061
  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);
9062
+ const avail = Math.max(0, area2.height - (col.length - 1) * effectivePad);
9063
+ if (sum > 0) ky = Math.min(ky, avail / sum);
8748
9064
  }
8749
- if (!Number.isFinite(ky) || ky <= 0) ky = 1;
9065
+ if (!Number.isFinite(ky) || ky < 0) ky = 0;
8750
9066
  for (const col of columns) {
8751
9067
  let y = area2.y;
8752
9068
  for (const n of col) {
8753
9069
  n.y0 = y;
8754
- n.y1 = y + Math.max(1, n.value * ky);
8755
- y = n.y1 + pad3;
9070
+ n.y1 = y + Math.max(0, n.value * ky);
9071
+ y = n.y1 + effectivePad;
8756
9072
  }
8757
9073
  centerColumn(col, area2);
8758
9074
  }
8759
- for (const l of links) l.width = Math.max(1, l.value * ky);
9075
+ for (const l of links) l.width = Math.max(0, l.value * ky);
8760
9076
  for (let i = 0; i < 6; i += 1) {
8761
9077
  const alpha = Math.pow(0.97, i);
8762
9078
  relax(columns, true, alpha);
8763
- resolveAll(columns, area2, pad3);
9079
+ resolveAll(columns, area2, effectivePad);
8764
9080
  relax(columns, false, alpha);
8765
- resolveAll(columns, area2, pad3);
9081
+ resolveAll(columns, area2, effectivePad);
8766
9082
  }
8767
9083
  computeLinkBreadths(graph);
8768
9084
  }
@@ -9004,6 +9320,7 @@ function featureKey(feature, featureId) {
9004
9320
  if (featureId === "id" && feature.id != null) return toKey(feature.id);
9005
9321
  const v = feature.properties?.[featureId];
9006
9322
  if (v != null) return toKey(v);
9323
+ return "";
9007
9324
  }
9008
9325
  if (feature.id != null) return toKey(feature.id);
9009
9326
  const props = feature.properties ?? {};
@@ -9419,6 +9736,43 @@ function buildTable(opts) {
9419
9736
  if (footer) table.appendChild(footer);
9420
9737
  container.appendChild(table);
9421
9738
  }
9739
+ function paintTableCanvas(opts) {
9740
+ const { ctx, rect, tokens } = opts;
9741
+ const metrics = densityMetrics(opts.density);
9742
+ const available = Math.max(0, rect.width - 2);
9743
+ const widths = computeFittedWidths(opts.columns, available);
9744
+ opts.widths = widths;
9745
+ ctx.save();
9746
+ ctx.beginPath();
9747
+ roundedRect(ctx, rect.x, rect.y, Math.max(0, rect.width), Math.max(0, rect.height), tokens.radius.sm);
9748
+ ctx.fillStyle = tokens.color.background;
9749
+ ctx.fill();
9750
+ ctx.lineWidth = 1;
9751
+ ctx.strokeStyle = tokens.color.border;
9752
+ ctx.stroke();
9753
+ ctx.clip();
9754
+ const headerRows = normalizedHeaderRows(opts);
9755
+ const headerHeight = headerRows.length * metrics.headerHeight;
9756
+ const footerHeight = opts.footerRow ? metrics.rowHeight : 0;
9757
+ const bodyTop = rect.y + headerHeight;
9758
+ const bodyBottom = rect.y + rect.height - footerHeight;
9759
+ const bodyHeight = Math.max(0, bodyBottom - bodyTop);
9760
+ const visibleRows = Math.max(0, Math.min(opts.rowCount, Math.floor(bodyHeight / metrics.rowHeight)));
9761
+ const clipped = visibleRows < opts.rowCount;
9762
+ const cueReserve = clipped && opts.clippedCue !== false && visibleRows > 0 ? metrics.rowHeight : 0;
9763
+ const rowsToDraw = Math.max(0, visibleRows - (cueReserve > 0 ? 1 : 0));
9764
+ paintHeaderCanvas(opts, widths, headerRows, metrics);
9765
+ for (let rowIndex = 0; rowIndex < rowsToDraw; rowIndex += 1) {
9766
+ paintBodyRowCanvas(opts, widths, rowIndex, bodyTop + rowIndex * metrics.rowHeight, metrics);
9767
+ }
9768
+ if (cueReserve > 0) {
9769
+ paintClippedCueCanvas(opts, bodyTop + rowsToDraw * metrics.rowHeight, metrics, opts.rowCount - rowsToDraw);
9770
+ }
9771
+ if (opts.footerRow) {
9772
+ paintFooterCanvas(opts, widths, rect.y + rect.height - metrics.rowHeight, metrics);
9773
+ }
9774
+ ctx.restore();
9775
+ }
9422
9776
  var FILL_EPS = 0.5;
9423
9777
  var cachedScrollbarWidth = null;
9424
9778
  function scrollbarWidth() {
@@ -9766,6 +10120,263 @@ function densityMetrics(density) {
9766
10120
  if (density === "compact") return { rowHeight: 24, headerHeight: 28, padding: "3px 8px", paddingX: 8 };
9767
10121
  return { rowHeight: DEFAULT_BODY_ROW_HEIGHT, headerHeight: HEADER_ROW_HEIGHT, padding: "6px 10px", paddingX: 10 };
9768
10122
  }
10123
+ function normalizedHeaderRows(opts) {
10124
+ if (opts.headerRows) return opts.headerRows;
10125
+ return [
10126
+ opts.columns.map((column, colIndex) => ({
10127
+ title: column.title,
10128
+ align: column.align,
10129
+ colIndex
10130
+ }))
10131
+ ];
10132
+ }
10133
+ function paintHeaderCanvas(opts, widths, rows, metrics) {
10134
+ rows.forEach((row, rowIndex) => {
10135
+ let cursor = 0;
10136
+ for (const cell of row) {
10137
+ const start = cell.colIndex ?? cursor;
10138
+ const colSpan = Math.max(1, cell.colSpan ?? 1);
10139
+ const rowSpan = Math.max(1, cell.rowSpan ?? 1);
10140
+ const x = opts.rect.x + sumWidths(widths.slice(0, start));
10141
+ const y = opts.rect.y + rowIndex * metrics.headerHeight;
10142
+ const w = sumWidths(widths.slice(start, start + colSpan));
10143
+ const h = metrics.headerHeight * rowSpan;
10144
+ paintCellBox(opts.ctx, x, y, w, h, opts.tokens.color.surface, opts.tokens.color.border);
10145
+ paintCellText(opts, {
10146
+ x,
10147
+ y,
10148
+ width: w,
10149
+ height: h,
10150
+ text: cell.title,
10151
+ align: cell.align ?? "left",
10152
+ color: opts.tokens.color.text,
10153
+ weight: opts.tokens.font.weight.bold,
10154
+ paddingX: metrics.paddingX
10155
+ });
10156
+ cursor = start + colSpan;
10157
+ }
10158
+ });
10159
+ }
10160
+ function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics) {
10161
+ const cls = opts.rowClass?.(rowIndex) ?? "normal";
10162
+ let x = opts.rect.x;
10163
+ for (let colIndex = 0; colIndex < opts.columns.length; colIndex += 1) {
10164
+ const column = opts.columns[colIndex];
10165
+ const width = widths[colIndex] ?? 0;
10166
+ const cell = opts.getCell(rowIndex, colIndex);
10167
+ const baseBg = canvasRowBackground(opts, rowIndex, cls);
10168
+ const conditional = resolveCanvasConditional(opts, column, colIndex, cell, baseBg);
10169
+ const bg = conditional.background ?? baseBg;
10170
+ paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, bg, opts.tokens.color.border);
10171
+ if (conditional.bar) paintDataBar(opts, x, y, width, metrics.rowHeight, conditional.bar);
10172
+ const indent = opts.cellIndent?.(rowIndex, colIndex) ?? 0;
10173
+ const text = conditional.showValue === false ? "" : formatDisplayValue(cell.value, column);
10174
+ const color = negativeTextColor(cell.value, column, conditional.color ?? opts.tokens.color.text);
10175
+ const weight = conditional.weight ?? (cls === "normal" ? opts.tokens.font.weight.normal : opts.tokens.font.weight.bold);
10176
+ paintCellValueWithIcon(opts, {
10177
+ x,
10178
+ y,
10179
+ width,
10180
+ height: metrics.rowHeight,
10181
+ text,
10182
+ align: column.align,
10183
+ color,
10184
+ weight,
10185
+ paddingX: metrics.paddingX + indent,
10186
+ icon: conditional.icon,
10187
+ iconColor: conditional.iconColor,
10188
+ iconPosition: conditional.iconPosition
10189
+ });
10190
+ x += width;
10191
+ }
10192
+ }
10193
+ function paintFooterCanvas(opts, widths, y, metrics) {
10194
+ let x = opts.rect.x;
10195
+ for (let colIndex = 0; colIndex < opts.columns.length; colIndex += 1) {
10196
+ const column = opts.columns[colIndex];
10197
+ const width = widths[colIndex] ?? 0;
10198
+ const cell = opts.footerRow?.cells[colIndex] ?? { value: null};
10199
+ paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, opts.tokens.color.surface, opts.tokens.color.border, true);
10200
+ paintCellText(opts, {
10201
+ x,
10202
+ y,
10203
+ width,
10204
+ height: metrics.rowHeight,
10205
+ text: cell.label ? String(cell.value ?? "") : formatDisplayValue(cell.value, column),
10206
+ align: column.align,
10207
+ color: negativeTextColor(cell.value, column, opts.tokens.color.text),
10208
+ weight: opts.tokens.font.weight.bold,
10209
+ paddingX: metrics.paddingX
10210
+ });
10211
+ x += width;
10212
+ }
10213
+ }
10214
+ function paintClippedCueCanvas(opts, y, metrics, remaining) {
10215
+ paintCellBox(opts.ctx, opts.rect.x, y, opts.rect.width, metrics.rowHeight, opts.tokens.color.background, opts.tokens.color.border);
10216
+ paintCellText(opts, {
10217
+ x: opts.rect.x,
10218
+ y,
10219
+ width: opts.rect.width,
10220
+ height: metrics.rowHeight,
10221
+ text: `\u2026${remaining} more`,
10222
+ align: "center",
10223
+ color: opts.tokens.color.textMuted,
10224
+ weight: opts.tokens.font.weight.medium,
10225
+ paddingX: metrics.paddingX
10226
+ });
10227
+ }
10228
+ function paintCellBox(ctx, x, y, width, height, background, border, topBorder = false) {
10229
+ ctx.save();
10230
+ ctx.fillStyle = background;
10231
+ ctx.fillRect(x, y, width, height);
10232
+ ctx.strokeStyle = border;
10233
+ ctx.lineWidth = 1;
10234
+ ctx.beginPath();
10235
+ ctx.moveTo(x + width, y);
10236
+ ctx.lineTo(x + width, y + height);
10237
+ ctx.moveTo(x, y + height);
10238
+ ctx.lineTo(x + width, y + height);
10239
+ if (topBorder) {
10240
+ ctx.moveTo(x, y);
10241
+ ctx.lineTo(x + width, y);
10242
+ }
10243
+ ctx.stroke();
10244
+ ctx.restore();
10245
+ }
10246
+ function paintCellValueWithIcon(opts, input) {
10247
+ const font = fontString(opts.tokens.font.size.small, opts.tokens.font.family, input.weight);
10248
+ const gap = input.icon ? 6 : 0;
10249
+ const iconFont = fontString(opts.tokens.font.size.small, opts.tokens.font.family, opts.tokens.font.weight.bold);
10250
+ const iconW = input.icon ? measureText(input.icon, iconFont).width : 0;
10251
+ const textMax = Math.max(0, input.width - input.paddingX * 2 - iconW - gap);
10252
+ const text = ellipsize(input.text, textMax, font);
10253
+ const textW = measureText(text, font).width;
10254
+ const groupW = textW + iconW + gap;
10255
+ let start = input.x + input.paddingX;
10256
+ if (input.align === "right") start = input.x + input.width - input.paddingX - groupW;
10257
+ else if (input.align === "center") start = input.x + (input.width - groupW) / 2;
10258
+ const midY = input.y + input.height / 2;
10259
+ const iconFirst = input.icon && input.iconPosition !== "right";
10260
+ const iconX = iconFirst ? start : start + textW + gap;
10261
+ const textX = iconFirst ? start + iconW + gap : start;
10262
+ if (input.icon) {
10263
+ paintCanvasText(opts.ctx, {
10264
+ x: iconX,
10265
+ y: midY,
10266
+ text: input.icon,
10267
+ font: iconFont,
10268
+ color: input.iconColor ?? input.color,
10269
+ size: opts.tokens.font.size.small,
10270
+ baseline: "middle"
10271
+ });
10272
+ }
10273
+ paintRawCellText(opts, textX, midY, text, font, input.color, opts.tokens.font.size.small);
10274
+ }
10275
+ function paintCellText(opts, input) {
10276
+ const font = fontString(opts.tokens.font.size.small, opts.tokens.font.family, input.weight);
10277
+ const maxWidth = Math.max(0, input.width - input.paddingX * 2);
10278
+ const text = ellipsize(input.text, maxWidth, font);
10279
+ const x = input.align === "right" ? input.x + input.width - input.paddingX : input.align === "center" ? input.x + input.width / 2 : input.x + input.paddingX;
10280
+ paintRawCellText(opts, x, input.y + input.height / 2, text, font, input.color, opts.tokens.font.size.small, input.align);
10281
+ }
10282
+ function paintRawCellText(opts, x, y, text, font, color, size, align = "left") {
10283
+ if (!text) return;
10284
+ paintCanvasText(opts.ctx, {
10285
+ x,
10286
+ y,
10287
+ text,
10288
+ font,
10289
+ color,
10290
+ size,
10291
+ align,
10292
+ baseline: "middle"
10293
+ });
10294
+ }
10295
+ function ellipsize(text, maxWidth, font) {
10296
+ if (maxWidth <= 0 || text.length === 0) return "";
10297
+ if (measureText(text, font).width <= maxWidth) return text;
10298
+ const ellipsis = "\u2026";
10299
+ const ellipsisW = measureText(ellipsis, font).width;
10300
+ if (ellipsisW > maxWidth) return "";
10301
+ let lo = 0;
10302
+ let hi = text.length;
10303
+ while (lo < hi) {
10304
+ const mid = Math.ceil((lo + hi) / 2);
10305
+ if (measureText(text.slice(0, mid) + ellipsis, font).width <= maxWidth) lo = mid;
10306
+ else hi = mid - 1;
10307
+ }
10308
+ return text.slice(0, lo) + ellipsis;
10309
+ }
10310
+ function resolveCanvasConditional(opts, column, colIndex, cell, rowBg) {
10311
+ const cf = column.conditionalFormat;
10312
+ const raw = cell.raw;
10313
+ const domain = opts.conditionalDomains?.[colIndex] ?? null;
10314
+ if (!cf) return {};
10315
+ if ((cf.type === "colorScale" || cf.type === "bar" || cf.type === "icon") && (raw == null || !Number.isFinite(raw))) {
10316
+ return {};
10317
+ }
10318
+ const numeric = raw;
10319
+ if (cf.type === "colorScale") {
10320
+ if (!domain) return {};
10321
+ const midpoint = cf.midpoint ?? (domain[0] + domain[1]) / 2;
10322
+ const scale = cf.diverging === true || cf.midpoint !== void 0 ? divergingColorScale({ domain: [domain[0], midpoint, domain[1]], interpolator: diverging(cf.scheme ?? "redblue") }) : sequentialColorScale({ domain, interpolator: sequential(cf.scheme ?? "blues") });
10323
+ const color2 = scale.map(numeric);
10324
+ if (cf.target === "text") return { color: rgbaToCss(color2) };
10325
+ return { background: rgbaToCss(color2), color: rgbaToCss(readableTextColor(color2)) };
10326
+ }
10327
+ if (cf.type === "rules") {
10328
+ const style = evalRules(raw, cf.rules);
10329
+ return {
10330
+ background: style.background,
10331
+ color: style.color,
10332
+ weight: style.weight === "bold" ? opts.tokens.font.weight.bold : style.weight === "normal" ? opts.tokens.font.weight.normal : void 0,
10333
+ icon: style.icon,
10334
+ iconColor: style.color,
10335
+ iconPosition: "left"
10336
+ };
10337
+ }
10338
+ if (cf.type === "icon") {
10339
+ const semantic = iconForValue(cf.set, numeric, domain, cf.rules);
10340
+ if (!semantic) return {};
10341
+ return {
10342
+ icon: semantic.icon,
10343
+ iconColor: toneColor(semantic.tone, { up: opts.tokens.color.positive, mid: "#d97706", down: opts.tokens.color.negative }),
10344
+ iconPosition: cf.position ?? "left"
10345
+ };
10346
+ }
10347
+ if (!domain) return {};
10348
+ const diverging2 = cf.negativeColor !== void 0 || cf.baseline === "zero";
10349
+ const baseline = diverging2 ? normalized(0, domain) : 0;
10350
+ const value = normalized(numeric, domain);
10351
+ const leftRatio = diverging2 ? Math.min(baseline, value) : 0;
10352
+ const widthRatio = diverging2 ? Math.abs(value - baseline) : value;
10353
+ const positive = numeric >= 0;
10354
+ const color = alphaColor(positive ? cf.color ?? opts.tokens.color.accent : cf.negativeColor ?? opts.tokens.color.negative, 0.85);
10355
+ return { background: rowBg, showValue: cf.showValue !== false, bar: { leftRatio, widthRatio, color } };
10356
+ }
10357
+ function paintDataBar(opts, x, y, width, height, bar) {
10358
+ const left = x + 4 + bar.leftRatio * Math.max(0, width - 8);
10359
+ const barW = bar.widthRatio * Math.max(0, width - 8);
10360
+ opts.ctx.save();
10361
+ opts.ctx.globalAlpha *= 0.9;
10362
+ opts.ctx.beginPath();
10363
+ roundedRect(opts.ctx, left, y + 5, barW, Math.max(0, height - 10), Math.max(2, opts.tokens.radius.sm - 1));
10364
+ opts.ctx.fillStyle = bar.color;
10365
+ opts.ctx.fill();
10366
+ opts.ctx.restore();
10367
+ }
10368
+ function canvasRowBackground(opts, rowIndex, cls) {
10369
+ if (cls !== "normal") return opts.tokens.color.surface;
10370
+ if (opts.striped === true && rowIndex % 2 === 1) return subtleStripe(opts.tokens);
10371
+ return opts.tokens.color.background;
10372
+ }
10373
+ function negativeTextColor(value, column, fallback) {
10374
+ const raw = typeof value === "number" ? value : Number(value);
10375
+ if (Number.isFinite(raw) && raw < 0 && (column.negativeStyle === "red" || column.negativeStyle === "parens-red")) {
10376
+ return "#dc2626";
10377
+ }
10378
+ return fallback;
10379
+ }
9769
10380
  function applyStickyColumn(cell, opts, colIndex, isHeader) {
9770
10381
  if (colIndex == null || colIndex >= (opts.rowHeaderSpan ?? 0)) return;
9771
10382
  cell.style.position = "sticky";
@@ -9822,36 +10433,71 @@ var ROW_HEIGHT = 30;
9822
10433
  var BUFFER_ROWS = 8;
9823
10434
  function drawTable(surface, spec, tokens, size) {
9824
10435
  if (spec.type !== "table") return;
10436
+ if (surface.headless) {
10437
+ drawTableCanvas(surface, spec, tokens, size);
10438
+ return;
10439
+ }
9825
10440
  renderTable(surface, spec, tokens, size);
9826
10441
  }
9827
- function renderTable(surface, spec, tokens, size) {
10442
+ function drawTableCanvas(surface, spec, tokens, size) {
9828
10443
  const rect = drawTitleBlock(surface, tokens, size, spec.title);
9829
10444
  const data = spec.data ?? [];
9830
10445
  const columns = resolveColumns(data, spec);
9831
10446
  const read = columns.map((column) => accessor(column.key));
9832
- const sketch = resolveSketch(spec) != null;
9833
- let sortState = initialSortState(spec, columns);
9834
- let sortedRows = sortRows(data, columns, read, sortState);
10447
+ const sortState = initialSortState(spec, columns);
10448
+ const sortedRows = sortRows(data, columns, read, sortState);
9835
10449
  const domains = columns.map(
9836
10450
  (column, colIndex) => resolveConditionalDomain(
9837
10451
  column.conditionalFormat,
9838
10452
  data.map((row) => numericRaw(read[colIndex](row)))
9839
10453
  )
9840
10454
  );
9841
- const host = document.createElement("div");
9842
- surface.overlay.appendChild(host);
9843
- let frame = 0;
9844
- const renderWindow = () => {
9845
- const scrollTop = host.scrollTop || 0;
9846
- const rowHeight = densityRowHeight(spec.density);
9847
- const start = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_ROWS);
9848
- const visible = Math.ceil(Math.max(rect.height, host.clientHeight || rect.height) / rowHeight) + BUFFER_ROWS * 2;
9849
- const end = Math.min(sortedRows.length, start + visible);
9850
- buildTable({
9851
- container: host,
9852
- tokens,
9853
- rect,
9854
- columns,
10455
+ paintTableCanvas({
10456
+ ctx: surface.marks.ctx,
10457
+ tokens,
10458
+ rect,
10459
+ columns,
10460
+ rowCount: sortedRows.length,
10461
+ getCell(rowIndex, colIndex) {
10462
+ const value = read[colIndex](sortedRows[rowIndex]);
10463
+ return { value, raw: numericRaw(value) };
10464
+ },
10465
+ striped: spec.striped === true,
10466
+ conditionalDomains: domains,
10467
+ headerRows: buildTableHeaderRows(columns),
10468
+ footerRow: buildTotalsRow(sortedRows, columns, read, spec),
10469
+ density: spec.density,
10470
+ sketch: resolveSketch(spec) != null
10471
+ });
10472
+ }
10473
+ function renderTable(surface, spec, tokens, size) {
10474
+ const rect = drawTitleBlock(surface, tokens, size, spec.title);
10475
+ const data = spec.data ?? [];
10476
+ const columns = resolveColumns(data, spec);
10477
+ const read = columns.map((column) => accessor(column.key));
10478
+ const sketch = resolveSketch(spec) != null;
10479
+ let sortState = initialSortState(spec, columns);
10480
+ let sortedRows = sortRows(data, columns, read, sortState);
10481
+ const domains = columns.map(
10482
+ (column, colIndex) => resolveConditionalDomain(
10483
+ column.conditionalFormat,
10484
+ data.map((row) => numericRaw(read[colIndex](row)))
10485
+ )
10486
+ );
10487
+ const host = document.createElement("div");
10488
+ surface.overlay.appendChild(host);
10489
+ let frame = 0;
10490
+ const renderWindow = () => {
10491
+ const scrollTop = host.scrollTop || 0;
10492
+ const rowHeight = densityRowHeight(spec.density);
10493
+ const start = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_ROWS);
10494
+ const visible = Math.ceil(Math.max(rect.height, host.clientHeight || rect.height) / rowHeight) + BUFFER_ROWS * 2;
10495
+ const end = Math.min(sortedRows.length, start + visible);
10496
+ buildTable({
10497
+ container: host,
10498
+ tokens,
10499
+ rect,
10500
+ columns,
9855
10501
  rowCount: sortedRows.length,
9856
10502
  getCell(rowIndex, colIndex) {
9857
10503
  const value = read[colIndex](sortedRows[rowIndex]);
@@ -9994,10 +10640,80 @@ function requestFrame(callback) {
9994
10640
  var PATH_SEPARATOR2 = "\0";
9995
10641
  function drawMatrix(surface, spec, tokens, size) {
9996
10642
  if (spec.type !== "matrix") return;
10643
+ if (surface.headless) {
10644
+ drawMatrixCanvas(surface, spec, tokens, size);
10645
+ return;
10646
+ }
9997
10647
  renderMatrix(surface, spec, tokens, size);
9998
10648
  }
10649
+ function drawMatrixCanvas(surface, spec, tokens, size) {
10650
+ const rect = drawTitleBlock(surface, tokens, size, spec.title);
10651
+ const prepared = prepareMatrix(spec);
10652
+ paintTableCanvas({
10653
+ ctx: surface.marks.ctx,
10654
+ tokens,
10655
+ rect,
10656
+ columns: prepared.columns,
10657
+ rowCount: prepared.rows.length,
10658
+ getCell(rowIndex, colIndex) {
10659
+ const row = prepared.rows[rowIndex];
10660
+ if (colIndex === 0) return { value: rowLabel(row), raw: null };
10661
+ const { leaf, valueIndex } = prepared.leafValueColumns[colIndex - 1];
10662
+ const value = row.cellsByColumnKey.get(leaf.path.join(PATH_SEPARATOR2))?.values[valueIndex] ?? null;
10663
+ const display = displayMatrixValue(value, rowIndex, colIndex - 1, prepared.denominators, spec);
10664
+ return { value: display, raw: display };
10665
+ },
10666
+ rowClass(rowIndex) {
10667
+ const row = prepared.rows[rowIndex];
10668
+ return row.isGrandTotal === true ? "grandtotal" : row.isSubtotal === true ? "subtotal" : "normal";
10669
+ },
10670
+ cellIndent(rowIndex, colIndex) {
10671
+ return colIndex === 0 ? prepared.rows[rowIndex].depth * 16 : 0;
10672
+ },
10673
+ rowHeaderSpan: 1,
10674
+ striped: false,
10675
+ conditionalDomains: prepared.bodyDomains,
10676
+ headerRows: prepared.headerRows,
10677
+ density: spec.density,
10678
+ sketch: resolveSketch(spec) != null
10679
+ });
10680
+ }
9999
10681
  function renderMatrix(surface, spec, tokens, size) {
10000
10682
  const rect = drawTitleBlock(surface, tokens, size, spec.title);
10683
+ const prepared = prepareMatrix(spec);
10684
+ const host = document.createElement("div");
10685
+ surface.overlay.appendChild(host);
10686
+ buildTable({
10687
+ container: host,
10688
+ tokens,
10689
+ rect,
10690
+ columns: prepared.columns,
10691
+ rowCount: prepared.rows.length,
10692
+ getCell(rowIndex, colIndex) {
10693
+ const row = prepared.rows[rowIndex];
10694
+ if (colIndex === 0) return { value: rowLabel(row), raw: null };
10695
+ const { leaf, valueIndex } = prepared.leafValueColumns[colIndex - 1];
10696
+ const value = row.cellsByColumnKey.get(leaf.path.join(PATH_SEPARATOR2))?.values[valueIndex] ?? null;
10697
+ const display = displayMatrixValue(value, rowIndex, colIndex - 1, prepared.denominators, spec);
10698
+ return { value: display, raw: display };
10699
+ },
10700
+ rowClass(rowIndex) {
10701
+ const row = prepared.rows[rowIndex];
10702
+ return row.isGrandTotal === true ? "grandtotal" : row.isSubtotal === true ? "subtotal" : "normal";
10703
+ },
10704
+ cellIndent(rowIndex, colIndex) {
10705
+ return colIndex === 0 ? prepared.rows[rowIndex].depth * 16 : 0;
10706
+ },
10707
+ rowHeaderSpan: 1,
10708
+ stickyHeader: true,
10709
+ striped: false,
10710
+ conditionalDomains: prepared.bodyDomains,
10711
+ headerRows: prepared.headerRows,
10712
+ density: spec.density,
10713
+ sketch: resolveSketch(spec) != null
10714
+ });
10715
+ }
10716
+ function prepareMatrix(spec) {
10001
10717
  const data = spec.data ?? [];
10002
10718
  const result = pivot(data, {
10003
10719
  rows: spec.rows,
@@ -10048,37 +10764,14 @@ function renderMatrix(surface, spec, tokens, size) {
10048
10764
  )
10049
10765
  );
10050
10766
  });
10051
- const host = document.createElement("div");
10052
- surface.overlay.appendChild(host);
10053
- buildTable({
10054
- container: host,
10055
- tokens,
10056
- rect,
10767
+ return {
10768
+ rows: result.rows,
10057
10769
  columns,
10058
- rowCount: result.rows.length,
10059
- getCell(rowIndex, colIndex) {
10060
- const row = result.rows[rowIndex];
10061
- if (colIndex === 0) return { value: rowLabel(row), raw: null };
10062
- const { leaf, valueIndex } = leafValueColumns[colIndex - 1];
10063
- const value = row.cellsByColumnKey.get(leaf.path.join(PATH_SEPARATOR2))?.values[valueIndex] ?? null;
10064
- const display = displayMatrixValue(value, rowIndex, colIndex - 1, denominators, spec);
10065
- return { value: display, raw: display };
10066
- },
10067
- rowClass(rowIndex) {
10068
- const row = result.rows[rowIndex];
10069
- return row.isGrandTotal === true ? "grandtotal" : row.isSubtotal === true ? "subtotal" : "normal";
10070
- },
10071
- cellIndent(rowIndex, colIndex) {
10072
- return colIndex === 0 ? result.rows[rowIndex].depth * 16 : 0;
10073
- },
10074
- rowHeaderSpan: 1,
10075
- stickyHeader: true,
10076
- striped: false,
10077
- conditionalDomains: bodyDomains,
10078
- headerRows: buildMatrixHeaderRows(result.columnTree, sortedLeaves, spec, sortedLeaves !== result.columnLeaves),
10079
- density: spec.density,
10080
- sketch: resolveSketch(spec) != null
10081
- });
10770
+ leafValueColumns,
10771
+ denominators,
10772
+ bodyDomains,
10773
+ headerRows: buildMatrixHeaderRows(result.columnTree, sortedLeaves, spec, sortedLeaves !== result.columnLeaves)
10774
+ };
10082
10775
  }
10083
10776
  function rowLabel(row) {
10084
10777
  if (row.isGrandTotal === true) return row.label;
@@ -10659,6 +11352,10 @@ function emptyNotice(host, message, color) {
10659
11352
  // src/charts/slicers/dropdown.ts
10660
11353
  function drawDropdown(surface, spec, tokens, size, context) {
10661
11354
  const s = spec;
11355
+ if (surface.headless) {
11356
+ drawDropdownCanvas(surface, s, tokens, size, context);
11357
+ return;
11358
+ }
10662
11359
  const shell = mountSlicerShell(surface, tokens, size, {
10663
11360
  title: s.title,
10664
11361
  label: slicerLabel(s)
@@ -10739,10 +11436,61 @@ function drawDropdown(surface, spec, tokens, size, context) {
10739
11436
  trigger.addEventListener("click", openMenu);
10740
11437
  refreshChrome();
10741
11438
  }
11439
+ function drawDropdownCanvas(surface, s, tokens, size, context) {
11440
+ const ctx = surface.marks.ctx;
11441
+ const rect = drawTitleBlock(surface, tokens, size, s.title ?? slicerLabel(s));
11442
+ const options = slicerOptions(s, context);
11443
+ const display = (v) => formatValue(v, void 0) || "\u2014";
11444
+ const placeholder = s.placeholder ?? (s.multiple ? "Any" : "All");
11445
+ const current = currentValue(s, context);
11446
+ const selected = new Set((current?.kind === "set" ? current.values : []).map((v) => toKey(v)));
11447
+ const picks = options.filter((o) => selected.has(toKey(o)));
11448
+ const label = selected.size === 0 ? placeholder : picks.length === 1 ? display(picks[0]) : s.multiple ? `${picks.length} selected` : display(picks[0]);
11449
+ const h = Math.min(42, Math.max(32, rect.height));
11450
+ const y = rect.y + Math.max(0, (rect.height - h) * 0.12);
11451
+ ctx.save();
11452
+ ctx.beginPath();
11453
+ roundedRect(ctx, rect.x, y, rect.width, h, tokens.radius.md);
11454
+ ctx.fillStyle = tokens.color.surface;
11455
+ ctx.fill();
11456
+ ctx.lineWidth = 1;
11457
+ ctx.strokeStyle = selected.size ? tokens.color.accent : tokens.color.border;
11458
+ ctx.stroke();
11459
+ ctx.restore();
11460
+ const font = fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal);
11461
+ ctx.save();
11462
+ ctx.beginPath();
11463
+ ctx.rect(rect.x + tokens.spacing.sm, y, Math.max(0, rect.width - tokens.spacing.sm * 4 - 14), h);
11464
+ ctx.clip();
11465
+ paintCanvasText(ctx, {
11466
+ x: rect.x + tokens.spacing.sm,
11467
+ y: y + h / 2,
11468
+ text: label,
11469
+ font,
11470
+ color: selected.size ? tokens.color.text : tokens.color.textMuted,
11471
+ size: tokens.font.size.base,
11472
+ baseline: "middle"
11473
+ });
11474
+ ctx.restore();
11475
+ paintCanvasText(ctx, {
11476
+ x: rect.x + rect.width - tokens.spacing.sm,
11477
+ y: y + h / 2,
11478
+ text: "\u25BE",
11479
+ font,
11480
+ color: tokens.color.textMuted,
11481
+ size: tokens.font.size.base,
11482
+ align: "right",
11483
+ baseline: "middle"
11484
+ });
11485
+ }
10742
11486
 
10743
11487
  // src/charts/slicers/search.ts
10744
11488
  function drawSearch(surface, spec, tokens, size, context) {
10745
11489
  const s = spec;
11490
+ if (surface.headless) {
11491
+ drawSearchCanvas(surface, s, tokens, size, context);
11492
+ return;
11493
+ }
10746
11494
  const shell = mountSlicerShell(surface, tokens, size, {
10747
11495
  title: s.title,
10748
11496
  label: slicerLabel(s)
@@ -10782,10 +11530,47 @@ function drawSearch(surface, spec, tokens, size, context) {
10782
11530
  });
10783
11531
  shell.setClear(initial ? clear : null);
10784
11532
  }
11533
+ function drawSearchCanvas(surface, s, tokens, size, context) {
11534
+ const ctx = surface.marks.ctx;
11535
+ const rect = drawTitleBlock(surface, tokens, size, s.title ?? slicerLabel(s));
11536
+ const current = currentValue(s, context);
11537
+ const value = current?.kind === "text" ? current.query : "";
11538
+ const text = value || (s.placeholder ?? `Search ${s.field}\u2026`);
11539
+ const h = Math.min(42, Math.max(32, rect.height));
11540
+ const y = rect.y + Math.max(0, (rect.height - h) * 0.12);
11541
+ ctx.save();
11542
+ ctx.beginPath();
11543
+ roundedRect(ctx, rect.x, y, rect.width, h, tokens.radius.md);
11544
+ ctx.fillStyle = tokens.color.surface;
11545
+ ctx.fill();
11546
+ ctx.lineWidth = 1;
11547
+ ctx.strokeStyle = value ? tokens.color.accent : tokens.color.border;
11548
+ ctx.stroke();
11549
+ ctx.restore();
11550
+ const font = fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal);
11551
+ ctx.save();
11552
+ ctx.beginPath();
11553
+ ctx.rect(rect.x + tokens.spacing.sm, y, Math.max(0, rect.width - tokens.spacing.sm * 2), h);
11554
+ ctx.clip();
11555
+ paintCanvasText(ctx, {
11556
+ x: rect.x + tokens.spacing.sm,
11557
+ y: y + h / 2,
11558
+ text,
11559
+ font,
11560
+ color: value ? tokens.color.text : tokens.color.textMuted,
11561
+ size: tokens.font.size.base,
11562
+ baseline: "middle"
11563
+ });
11564
+ ctx.restore();
11565
+ }
10785
11566
 
10786
11567
  // src/charts/slicers/list.ts
10787
11568
  function drawList(surface, spec, tokens, size, context) {
10788
11569
  const s = spec;
11570
+ if (surface.headless) {
11571
+ drawListCanvas(surface, s, tokens, size, context);
11572
+ return;
11573
+ }
10789
11574
  const shell = mountSlicerShell(surface, tokens, size, {
10790
11575
  title: s.title,
10791
11576
  label: slicerLabel(s)
@@ -10909,10 +11694,95 @@ function drawList(surface, spec, tokens, size, context) {
10909
11694
  rebuild();
10910
11695
  shell.setClear(selected.size ? clearAll : null);
10911
11696
  }
11697
+ function drawListCanvas(surface, s, tokens, size, context) {
11698
+ const ctx = surface.marks.ctx;
11699
+ const rect = drawTitleBlock(surface, tokens, size, s.title ?? slicerLabel(s));
11700
+ const options = slicerOptions(s, context);
11701
+ const display = (v) => formatValue(v, void 0) || "\u2014";
11702
+ const current = currentValue(s, context);
11703
+ const selected = new Set((current?.kind === "set" ? current.values : []).map((v) => toKey(v)));
11704
+ const rowH = Math.max(24, tokens.font.size.base + tokens.spacing.sm + 2);
11705
+ const pad3 = tokens.spacing.xs;
11706
+ const visibleRows = Math.max(1, Math.floor((rect.height - pad3 * 2) / rowH));
11707
+ ctx.save();
11708
+ ctx.beginPath();
11709
+ roundedRect(ctx, rect.x, rect.y, rect.width, rect.height, tokens.radius.md);
11710
+ ctx.fillStyle = tokens.color.surface;
11711
+ ctx.fill();
11712
+ ctx.lineWidth = 1;
11713
+ ctx.strokeStyle = tokens.color.border;
11714
+ ctx.stroke();
11715
+ ctx.clip();
11716
+ const font = fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal);
11717
+ const checkSize = 12;
11718
+ options.slice(0, visibleRows).forEach((opt, i) => {
11719
+ const y = rect.y + pad3 + i * rowH;
11720
+ const key = toKey(opt);
11721
+ const active = selected.has(key);
11722
+ if (active) {
11723
+ ctx.beginPath();
11724
+ roundedRect(ctx, rect.x + pad3, y + 1, rect.width - pad3 * 2, rowH - 2, tokens.radius.sm);
11725
+ ctx.fillStyle = tokens.color.background;
11726
+ ctx.fill();
11727
+ }
11728
+ const bx = rect.x + tokens.spacing.sm;
11729
+ const by = y + rowH / 2 - checkSize / 2;
11730
+ ctx.beginPath();
11731
+ roundedRect(ctx, bx, by, checkSize, checkSize, 3);
11732
+ ctx.fillStyle = active ? tokens.color.accent : tokens.color.background;
11733
+ ctx.fill();
11734
+ ctx.lineWidth = 1;
11735
+ ctx.strokeStyle = active ? tokens.color.accent : tokens.color.border;
11736
+ ctx.stroke();
11737
+ if (active) {
11738
+ paintCanvasText(ctx, {
11739
+ x: bx + checkSize / 2,
11740
+ y: y + rowH / 2,
11741
+ text: "\u2713",
11742
+ font: fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.bold),
11743
+ color: tokens.color.surface,
11744
+ size: tokens.font.size.small,
11745
+ align: "center",
11746
+ baseline: "middle"
11747
+ });
11748
+ }
11749
+ ctx.save();
11750
+ ctx.beginPath();
11751
+ ctx.rect(bx + checkSize + tokens.spacing.sm, y, Math.max(0, rect.width - checkSize - tokens.spacing.sm * 4), rowH);
11752
+ ctx.clip();
11753
+ paintCanvasText(ctx, {
11754
+ x: bx + checkSize + tokens.spacing.sm,
11755
+ y: y + rowH / 2,
11756
+ text: display(opt),
11757
+ font,
11758
+ color: tokens.color.text,
11759
+ size: tokens.font.size.base,
11760
+ baseline: "middle"
11761
+ });
11762
+ ctx.restore();
11763
+ });
11764
+ if (options.length > visibleRows) {
11765
+ paintCanvasText(ctx, {
11766
+ x: rect.x + rect.width - tokens.spacing.sm,
11767
+ y: rect.y + rect.height - tokens.spacing.xs,
11768
+ text: `+${options.length - visibleRows} more`,
11769
+ font: fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.medium),
11770
+ color: tokens.color.textMuted,
11771
+ size: tokens.font.size.small,
11772
+ align: "right",
11773
+ baseline: "alphabetic"
11774
+ });
11775
+ }
11776
+ ctx.restore();
11777
+ }
10912
11778
 
10913
11779
  // src/charts/slicers/range.ts
10914
11780
  function drawRange(surface, spec, tokens, size, context) {
10915
11781
  const s = spec;
11782
+ if (surface.headless) {
11783
+ drawRangeCanvas(surface, s, tokens, size, context);
11784
+ return;
11785
+ }
10916
11786
  const shell = mountSlicerShell(surface, tokens, size, {
10917
11787
  title: s.title,
10918
11788
  label: slicerLabel(s)
@@ -10954,6 +11824,80 @@ function drawRange(surface, spec, tokens, size, context) {
10954
11824
  const active = current?.kind === "range" && (low > min || high < max);
10955
11825
  shell.setClear(active ? clear : null);
10956
11826
  }
11827
+ function drawRangeCanvas(surface, s, tokens, size, context) {
11828
+ const ctx = surface.marks.ctx;
11829
+ const rect = drawTitleBlock(surface, tokens, size, s.title ?? slicerLabel(s));
11830
+ const dataExtent = extent(slicerSource(s, context), s.field);
11831
+ const min = s.min ?? dataExtent?.[0] ?? 0;
11832
+ const max = s.max ?? dataExtent?.[1] ?? 1;
11833
+ const font = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.medium);
11834
+ if (!(max > min)) {
11835
+ paintCanvasText(ctx, {
11836
+ x: rect.x,
11837
+ y: rect.y,
11838
+ text: `No numeric range for "${s.field}".`,
11839
+ font,
11840
+ color: tokens.color.textMuted,
11841
+ size: tokens.font.size.small,
11842
+ baseline: "top"
11843
+ });
11844
+ return;
11845
+ }
11846
+ const current = currentValue(s, context);
11847
+ const rawLow = current?.kind === "range" && current.min != null ? toNumber(current.min) : min;
11848
+ const rawHigh = current?.kind === "range" && current.max != null ? toNumber(current.max) : max;
11849
+ const low = Number.isNaN(rawLow) ? min : Math.max(min, Math.min(max, rawLow));
11850
+ const high = Number.isNaN(rawHigh) ? max : Math.max(low, Math.min(max, rawHigh));
11851
+ const fmt = (v) => formatValue(v, s.format);
11852
+ paintSliderSnapshot(ctx, tokens, rect, min, max, low, high, fmt);
11853
+ }
11854
+ function paintSliderSnapshot(ctx, tokens, rect, min, max, low, high, format) {
11855
+ const span = Math.max(1e-9, max - min);
11856
+ const trackX = rect.x + 8;
11857
+ const trackW = Math.max(0, rect.width - 16);
11858
+ const midY = rect.y + Math.min(Math.max(rect.height * 0.58, 34), Math.max(34, rect.height - 14));
11859
+ const lowX = trackX + (low - min) / span * trackW;
11860
+ const highX = trackX + (high - min) / span * trackW;
11861
+ const font = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.medium);
11862
+ paintCanvasText(ctx, {
11863
+ x: rect.x,
11864
+ y: rect.y,
11865
+ text: format(low),
11866
+ font,
11867
+ color: tokens.color.textMuted,
11868
+ size: tokens.font.size.small,
11869
+ baseline: "top"
11870
+ });
11871
+ paintCanvasText(ctx, {
11872
+ x: rect.x + rect.width,
11873
+ y: rect.y,
11874
+ text: format(high),
11875
+ font,
11876
+ color: tokens.color.textMuted,
11877
+ size: tokens.font.size.small,
11878
+ align: "right",
11879
+ baseline: "top"
11880
+ });
11881
+ ctx.save();
11882
+ ctx.beginPath();
11883
+ roundedRect(ctx, trackX, midY - 2, trackW, 4, 2);
11884
+ ctx.fillStyle = tokens.color.border;
11885
+ ctx.fill();
11886
+ ctx.beginPath();
11887
+ roundedRect(ctx, lowX, midY - 2, Math.max(0, highX - lowX), 4, 2);
11888
+ ctx.fillStyle = tokens.color.accent;
11889
+ ctx.fill();
11890
+ for (const x of [lowX, highX]) {
11891
+ ctx.beginPath();
11892
+ ctx.arc(x, midY, 8, 0, Math.PI * 2);
11893
+ ctx.fillStyle = tokens.color.surface;
11894
+ ctx.fill();
11895
+ ctx.lineWidth = 2;
11896
+ ctx.strokeStyle = tokens.color.accent;
11897
+ ctx.stroke();
11898
+ }
11899
+ ctx.restore();
11900
+ }
10957
11901
 
10958
11902
  // src/charts/slicers/dateRange.ts
10959
11903
  var DAY_MS = 864e5;
@@ -10978,6 +11922,10 @@ function toISODate(ms) {
10978
11922
  }
10979
11923
  function drawDateRange(surface, spec, tokens, size, context) {
10980
11924
  const s = spec;
11925
+ if (surface.headless) {
11926
+ drawDateRangeCanvas(surface, s, tokens, size, context);
11927
+ return;
11928
+ }
10981
11929
  const shell = mountSlicerShell(surface, tokens, size, {
10982
11930
  title: s.title,
10983
11931
  label: slicerLabel(s)
@@ -11050,6 +11998,89 @@ function drawDateRange(surface, spec, tokens, size, context) {
11050
11998
  const active = current?.kind === "range" && (low > min || high < max);
11051
11999
  shell.setClear(active ? clear : null);
11052
12000
  }
12001
+ function drawDateRangeCanvas(surface, s, tokens, size, context) {
12002
+ const ctx = surface.marks.ctx;
12003
+ const rect = drawTitleBlock(surface, tokens, size, s.title ?? slicerLabel(s));
12004
+ const ext = dateExtent(slicerSource(s, context), s.field);
12005
+ const font = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.medium);
12006
+ if (!ext || !(ext[1] > ext[0])) {
12007
+ paintCanvasText(ctx, {
12008
+ x: rect.x,
12009
+ y: rect.y,
12010
+ text: `No date range for "${s.field}".`,
12011
+ font,
12012
+ color: tokens.color.textMuted,
12013
+ size: tokens.font.size.small,
12014
+ baseline: "top"
12015
+ });
12016
+ return;
12017
+ }
12018
+ const [min, max] = ext;
12019
+ const current = currentValue(s, context);
12020
+ const toMs = (v, fallback) => {
12021
+ if (v == null) return fallback;
12022
+ const d = toDate(v);
12023
+ return d ? d.getTime() : fallback;
12024
+ };
12025
+ const low = current?.kind === "range" ? toMs(current.min, min) : min;
12026
+ const high = current?.kind === "range" ? toMs(current.max, max) : max;
12027
+ paintDateFields(ctx, tokens, rect, toISODate(low), toISODate(high));
12028
+ }
12029
+ function paintDateFields(ctx, tokens, rect, low, high) {
12030
+ const labelFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.medium);
12031
+ const valueFont = fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal);
12032
+ const gap = tokens.spacing.sm;
12033
+ const fieldH = 36;
12034
+ const labelH = tokens.font.size.small + 4;
12035
+ const y = rect.y + Math.max(0, (rect.height - (labelH + fieldH)) * 0.12);
12036
+ const arrowW = 18;
12037
+ const fieldW = Math.max(44, (rect.width - gap * 2 - arrowW) / 2);
12038
+ const paintField = (x, label, value) => {
12039
+ paintCanvasText(ctx, {
12040
+ x,
12041
+ y,
12042
+ text: label,
12043
+ font: labelFont,
12044
+ color: tokens.color.textMuted,
12045
+ size: tokens.font.size.small,
12046
+ baseline: "top"
12047
+ });
12048
+ const fy = y + labelH;
12049
+ ctx.save();
12050
+ ctx.beginPath();
12051
+ roundedRect(ctx, x, fy, fieldW, fieldH, tokens.radius.md);
12052
+ ctx.fillStyle = tokens.color.surface;
12053
+ ctx.fill();
12054
+ ctx.lineWidth = 1;
12055
+ ctx.strokeStyle = tokens.color.border;
12056
+ ctx.stroke();
12057
+ ctx.beginPath();
12058
+ ctx.rect(x + tokens.spacing.sm, fy, Math.max(0, fieldW - tokens.spacing.sm * 2), fieldH);
12059
+ ctx.clip();
12060
+ paintCanvasText(ctx, {
12061
+ x: x + tokens.spacing.sm,
12062
+ y: fy + fieldH / 2,
12063
+ text: value,
12064
+ font: valueFont,
12065
+ color: tokens.color.text,
12066
+ size: tokens.font.size.base,
12067
+ baseline: "middle"
12068
+ });
12069
+ ctx.restore();
12070
+ };
12071
+ paintField(rect.x, "From", low);
12072
+ paintCanvasText(ctx, {
12073
+ x: rect.x + fieldW + gap + arrowW / 2,
12074
+ y: y + labelH + fieldH / 2,
12075
+ text: "\u2192",
12076
+ font: valueFont,
12077
+ color: tokens.color.accent,
12078
+ size: tokens.font.size.base,
12079
+ align: "center",
12080
+ baseline: "middle"
12081
+ });
12082
+ paintField(rect.x + fieldW + gap * 2 + arrowW, "To", high);
12083
+ }
11053
12084
 
11054
12085
  // src/interaction/cartesian.ts
11055
12086
  var HIT_RADIUS = 26;
@@ -12015,6 +13046,12 @@ function buildGroups(leaves) {
12015
13046
  }
12016
13047
  return order.map((key) => groups.get(key));
12017
13048
  }
13049
+ function tileProbe(treemap, tile) {
13050
+ const probe = { [treemap.encoding.category.field]: tile.category };
13051
+ const groupField = treemap.encoding.group?.field;
13052
+ if (groupField) probe[groupField] = tile.group;
13053
+ return probe;
13054
+ }
12018
13055
  function drawRect2(ctx, pen, rect, fill, stroke) {
12019
13056
  if (rect.width <= 0 || rect.height <= 0) return;
12020
13057
  if (pen) {
@@ -12059,6 +13096,7 @@ function addTileLabels(surface, tokens, tile, valueFormat) {
12059
13096
  function drawTreemap(surface, spec, tokens, size, context) {
12060
13097
  const treemap = spec;
12061
13098
  const ctx = surface.marks.ctx;
13099
+ const emphasis = context?.emphasis ?? null;
12062
13100
  const content = drawTitleBlock(surface, tokens, size, treemap.title);
12063
13101
  const leaves = buildLeaves(treemap);
12064
13102
  colorLeaves(leaves, treemap, tokens);
@@ -12080,6 +13118,12 @@ function drawTreemap(surface, spec, tokens, size, context) {
12080
13118
  const tiles = [];
12081
13119
  const stroke = tokens.color.background;
12082
13120
  const groupRects = [];
13121
+ const drawTile = (tile) => {
13122
+ const alpha = emphasis ? emphasis.match(tileProbe(treemap, tile)) ? 1 : emphasis.dim : 1;
13123
+ if (alpha !== 1) ctx.globalAlpha = alpha;
13124
+ drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
13125
+ if (alpha !== 1) ctx.globalAlpha = 1;
13126
+ };
12083
13127
  ctx.save();
12084
13128
  if (treemap.encoding.group) {
12085
13129
  const groups = buildGroups(leaves);
@@ -12094,14 +13138,14 @@ function drawTreemap(surface, spec, tokens, size, context) {
12094
13138
  for (const leafNode of layoutTreemap(groupNode.item.leaves, inner)) {
12095
13139
  const tile = { ...leafNode.item, rect: leafNode.rect };
12096
13140
  tiles.push(tile);
12097
- drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
13141
+ drawTile(tile);
12098
13142
  }
12099
13143
  }
12100
13144
  } else {
12101
13145
  for (const leafNode of layoutTreemap(leaves, content)) {
12102
13146
  const tile = { ...leafNode.item, rect: leafNode.rect };
12103
13147
  tiles.push(tile);
12104
- drawRect2(ctx, pen, tile.rect, tile.fillCss, stroke);
13148
+ drawTile(tile);
12105
13149
  }
12106
13150
  }
12107
13151
  ctx.restore();
@@ -14863,88 +15907,6 @@ function facetReport(spec, layout2, tokens, size) {
14863
15907
  };
14864
15908
  }
14865
15909
 
14866
- // src/runtime/headless.ts
14867
- var DOM_ONLY_TYPES = /* @__PURE__ */ new Set([
14868
- "kpi",
14869
- "table",
14870
- "matrix",
14871
- "dropdown",
14872
- "list",
14873
- "search",
14874
- "range",
14875
- "dateRange",
14876
- "dashboard"
14877
- ]);
14878
- function headlessTokens(spec) {
14879
- const tokens = resolveTheme(spec.theme);
14880
- const sketch = resolveSketch(spec);
14881
- return sketch?.font ? withSketchFont(tokens) : tokens;
14882
- }
14883
- function renderToContext(target, spec) {
14884
- const type = spec.type;
14885
- if (DOM_ONLY_TYPES.has(type)) {
14886
- throw new Error(
14887
- `Graphein: "${type}" is a DOM-only visual and cannot be rendered to a canvas headlessly. Headless rendering supports the canvas charts (line, area, bar, scatter, box, pie, heatmap, sankey, choropleth, combo, histogram, funnel).`
14888
- );
14889
- }
14890
- const ctx = target.marks;
14891
- const size = { width: target.width, height: target.height };
14892
- const tokens = headlessTokens(spec);
14893
- const surface = {
14894
- headless: true,
14895
- width: size.width,
14896
- height: size.height,
14897
- dpr: 1,
14898
- marks: { ctx },
14899
- interaction: { ctx: target.interaction ?? ctx }
14900
- };
14901
- const bg = spec.background ?? tokens.color.background;
14902
- ctx.save();
14903
- ctx.fillStyle = bg;
14904
- ctx.fillRect(0, 0, size.width, size.height);
14905
- ctx.restore();
14906
- const src = spec.data ?? [];
14907
- const data = spec.transform?.length ? applyTransforms(spec.transform, src) : src;
14908
- const effectiveSpec = data === src ? spec : { ...spec, data };
14909
- if (isFaceted(effectiveSpec)) {
14910
- const layout2 = buildFacetModels(effectiveSpec, tokens, size);
14911
- if (layout2) {
14912
- drawFacet(surface, effectiveSpec, layout2, tokens);
14913
- return facetReport(effectiveSpec, layout2, tokens, size);
14914
- }
14915
- }
14916
- let reportModel;
14917
- if (CARTESIAN_TYPES.has(type)) {
14918
- const renderer = cartesianRenderers[type];
14919
- const model = buildCartesianModel(effectiveSpec, tokens, size);
14920
- reportModel = model;
14921
- drawAxesUnderlay(surface, model);
14922
- if (renderer) renderer(surface, model);
14923
- drawTrendlines(surface, model);
14924
- drawAnnotations(surface, model);
14925
- drawOverlay(surface, model);
14926
- drawAnnotationLabels(surface, model);
14927
- drawTrendlineLabels(surface, model);
14928
- } else {
14929
- const renderer = customRenderers[type];
14930
- if (!renderer) {
14931
- throw new Error(`Graphein: no renderer registered for chart type "${type}".`);
14932
- }
14933
- const rctx = {
14934
- emphasis: null,
14935
- store: createSelectionStore(),
14936
- param: void 0,
14937
- def: void 0,
14938
- sourceData: src,
14939
- framed: false,
14940
- requestRedraw: () => {
14941
- }
14942
- };
14943
- renderer(surface, effectiveSpec, tokens, size, rctx);
14944
- }
14945
- return buildRenderReport({ type, spec, data, tokens, size, model: reportModel });
14946
- }
14947
-
14948
15910
  // src/render/fonts.ts
14949
15911
  function fontFaceSet() {
14950
15912
  try {
@@ -16055,9 +17017,180 @@ function renderDashboard(target, spec, options) {
16055
17017
  };
16056
17018
  }
16057
17019
 
17020
+ // src/runtime/dashboardHeadless.ts
17021
+ var HEADER_H = 44;
17022
+ var SECTION_TITLE_H = 26;
17023
+ function renderDashboardToContext(target, spec) {
17024
+ const ctx = target.marks;
17025
+ const tokens = resolveTheme(spec.theme);
17026
+ const layout2 = resolveDashboardLayout(spec.layout);
17027
+ const gap = layout2.gap;
17028
+ const pad3 = layout2.padding ?? Math.round(gap * 1.4);
17029
+ ctx.save();
17030
+ ctx.fillStyle = spec.background ?? tokens.color.background;
17031
+ ctx.fillRect(0, 0, target.width, target.height);
17032
+ ctx.restore();
17033
+ const titleText3 = typeof spec.title === "string" ? spec.title : spec.title?.text;
17034
+ let cursorY = pad3;
17035
+ if (titleText3 || spec.subtitle) {
17036
+ ctx.save();
17037
+ ctx.fillStyle = tokens.color.accent;
17038
+ ctx.fillRect(pad3, cursorY + 2, 5, 30);
17039
+ ctx.fillStyle = tokens.color.text;
17040
+ ctx.textBaseline = "top";
17041
+ ctx.font = `${tokens.font.weight.bold} ${tokens.font.size.title}px ${tokens.font.family}`;
17042
+ if (titleText3) ctx.fillText(titleText3, pad3 + 14, cursorY + 2);
17043
+ if (spec.subtitle) {
17044
+ ctx.fillStyle = tokens.color.textMuted;
17045
+ ctx.font = `${tokens.font.weight.normal} ${tokens.font.size.small}px ${tokens.font.family}`;
17046
+ ctx.fillText(spec.subtitle, pad3 + 14, cursorY + 24);
17047
+ }
17048
+ ctx.restore();
17049
+ cursorY += HEADER_H;
17050
+ }
17051
+ const wired = wireViews(spec.views, spec.interactions ?? "auto");
17052
+ const sections = resolveDashboardSections(wired, layout2);
17053
+ let markCount = 0;
17054
+ let allOk = true;
17055
+ let views = 0;
17056
+ for (const section of sections) {
17057
+ if (section.collapsed) continue;
17058
+ if (section.section?.title) {
17059
+ ctx.save();
17060
+ ctx.fillStyle = tokens.color.text;
17061
+ ctx.textBaseline = "top";
17062
+ ctx.font = `${tokens.font.weight.bold} ${tokens.font.size.base}px ${tokens.font.family}`;
17063
+ ctx.fillText(section.section.title, pad3, cursorY);
17064
+ ctx.restore();
17065
+ cursorY += SECTION_TITLE_H;
17066
+ }
17067
+ const cols = Math.max(1, section.cols);
17068
+ const innerW = target.width - pad3 * 2;
17069
+ const colW = (innerW - gap * (cols - 1)) / cols;
17070
+ const cells = placeViews(section.views, cols, layout2.preset);
17071
+ const flow = { x: 1, y: 1, rowH: 0 };
17072
+ let maxBottom = cursorY;
17073
+ for (const cell of cells) {
17074
+ let gx = cell.x;
17075
+ let gy = cell.y;
17076
+ if (gx == null || gy == null) {
17077
+ if (flow.x + cell.w - 1 > cols) {
17078
+ flow.x = 1;
17079
+ flow.y += Math.max(1, flow.rowH);
17080
+ flow.rowH = 0;
17081
+ }
17082
+ gx = flow.x;
17083
+ gy = flow.y;
17084
+ flow.x += cell.w;
17085
+ flow.rowH = Math.max(flow.rowH, cell.h);
17086
+ }
17087
+ const cx = pad3 + (gx - 1) * (colW + gap);
17088
+ const cy = cursorY + (gy - 1) * (section.rowHeight + gap);
17089
+ const cw = cell.w * colW + (cell.w - 1) * gap;
17090
+ const ch = cell.h * section.rowHeight + (cell.h - 1) * gap;
17091
+ const viewSpec = inheritInto(cell.view.spec, spec, tokens);
17092
+ ctx.save();
17093
+ ctx.translate(cx, cy);
17094
+ ctx.beginPath();
17095
+ ctx.rect(0, 0, cw, ch);
17096
+ ctx.clip();
17097
+ try {
17098
+ const r = renderToContext({ marks: ctx, interaction: target.interaction, width: cw, height: ch }, viewSpec);
17099
+ markCount += r.markCount;
17100
+ if (!r.ok) allOk = false;
17101
+ views++;
17102
+ } catch {
17103
+ allOk = false;
17104
+ }
17105
+ ctx.restore();
17106
+ maxBottom = Math.max(maxBottom, cy + ch);
17107
+ }
17108
+ cursorY = maxBottom + gap;
17109
+ }
17110
+ return {
17111
+ type: "dashboard",
17112
+ size: { width: target.width, height: target.height },
17113
+ markCount,
17114
+ seriesCount: views,
17115
+ colorCount: 1,
17116
+ ok: allOk,
17117
+ diagnostics: [],
17118
+ summary: `Dashboard of ${views} view${views === 1 ? "" : "s"}.`
17119
+ };
17120
+ }
17121
+
17122
+ // src/runtime/headless.ts
17123
+ function headlessTokens(spec) {
17124
+ const tokens = resolveTheme(spec.theme);
17125
+ const sketch = resolveSketch(spec);
17126
+ return sketch?.font ? withSketchFont(tokens) : tokens;
17127
+ }
17128
+ function renderToContext(target, spec) {
17129
+ const type = spec.type;
17130
+ if (type === "dashboard") {
17131
+ return renderDashboardToContext(target, spec);
17132
+ }
17133
+ const ctx = target.marks;
17134
+ const size = { width: target.width, height: target.height };
17135
+ const tokens = headlessTokens(spec);
17136
+ const surface = {
17137
+ headless: true,
17138
+ width: size.width,
17139
+ height: size.height,
17140
+ dpr: 1,
17141
+ marks: { ctx },
17142
+ interaction: { ctx: target.interaction ?? ctx }
17143
+ };
17144
+ const bg = spec.background ?? tokens.color.background;
17145
+ ctx.save();
17146
+ ctx.fillStyle = bg;
17147
+ ctx.fillRect(0, 0, size.width, size.height);
17148
+ ctx.restore();
17149
+ const src = spec.data ?? [];
17150
+ const data = spec.transform?.length ? applyTransforms(spec.transform, src) : src;
17151
+ const effectiveSpec = data === src ? spec : { ...spec, data };
17152
+ if (isFaceted(effectiveSpec)) {
17153
+ const layout2 = buildFacetModels(effectiveSpec, tokens, size);
17154
+ if (layout2) {
17155
+ drawFacet(surface, effectiveSpec, layout2, tokens);
17156
+ return facetReport(effectiveSpec, layout2, tokens, size);
17157
+ }
17158
+ }
17159
+ let reportModel;
17160
+ if (CARTESIAN_TYPES.has(type)) {
17161
+ const renderer = cartesianRenderers[type];
17162
+ const model = buildCartesianModel(effectiveSpec, tokens, size);
17163
+ reportModel = model;
17164
+ drawAxesUnderlay(surface, model);
17165
+ if (renderer) renderer(surface, model);
17166
+ drawTrendlines(surface, model);
17167
+ drawAnnotations(surface, model);
17168
+ drawOverlay(surface, model);
17169
+ drawAnnotationLabels(surface, model);
17170
+ drawTrendlineLabels(surface, model);
17171
+ } else {
17172
+ const renderer = customRenderers[type];
17173
+ if (!renderer) {
17174
+ throw new Error(`Graphein: no renderer registered for chart type "${type}".`);
17175
+ }
17176
+ const rctx = {
17177
+ emphasis: null,
17178
+ store: createSelectionStore(),
17179
+ param: void 0,
17180
+ def: void 0,
17181
+ sourceData: src,
17182
+ framed: false,
17183
+ requestRedraw: () => {
17184
+ }
17185
+ };
17186
+ renderer(surface, effectiveSpec, tokens, size, rctx);
17187
+ }
17188
+ return buildRenderReport({ type, spec, data, tokens, size, model: reportModel });
17189
+ }
17190
+
16058
17191
  // src/index.ts
16059
17192
  var VERSION = "0.1.0";
16060
17193
 
16061
- export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, relativeLuminance, render, renderDashboard, renderToContext, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
17194
+ export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, relativeLuminance, render, renderDashboard, renderToContext, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
16062
17195
  //# sourceMappingURL=index.js.map
16063
17196
  //# sourceMappingURL=index.js.map