graphein 0.11.0 → 0.13.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
@@ -2349,6 +2349,15 @@ function aggregateValues(values, op) {
2349
2349
  }
2350
2350
  return null;
2351
2351
  }
2352
+ function resolveValueRef(ref, data) {
2353
+ if (ref == null) return null;
2354
+ if (typeof ref === "number") return Number.isFinite(ref) ? ref : null;
2355
+ const read = accessor(ref.field);
2356
+ return aggregateValues(
2357
+ (data ?? []).map((d) => read(d)),
2358
+ ref.aggregate ?? "sum"
2359
+ );
2360
+ }
2352
2361
  function groupBy(rows, keyFields2) {
2353
2362
  const grouped = /* @__PURE__ */ new Map();
2354
2363
  for (const row of rows) {
@@ -2646,7 +2655,7 @@ function fieldTitle(f) {
2646
2655
  if (!f) return void 0;
2647
2656
  return f.title ?? f.field;
2648
2657
  }
2649
- function resolveValueRef(ref, data) {
2658
+ function resolveValueRef2(ref, data) {
2650
2659
  if (ref == null) return null;
2651
2660
  if (typeof ref === "number") return Number.isFinite(ref) ? ref : null;
2652
2661
  return aggregateValues(data.map(accessor(ref.field)), ref.aggregate ?? "sum");
@@ -2747,9 +2756,9 @@ function analyzeChart(spec) {
2747
2756
  if (rows.length === 0 && !VALUE_TYPES.has(type)) return null;
2748
2757
  if (VALUE_TYPES.has(type)) {
2749
2758
  const s = spec;
2750
- const value = resolveValueRef(s.value, rows);
2759
+ const value = resolveValueRef2(s.value, rows);
2751
2760
  if (value === null) return null;
2752
- const target = resolveValueRef(s.target, rows);
2761
+ const target = resolveValueRef2(s.target, rows);
2753
2762
  const toTarget = target === null ? null : value - target;
2754
2763
  const pctOfTarget = target ? value / target : null;
2755
2764
  return {
@@ -4248,18 +4257,58 @@ function validateSpec(spec) {
4248
4257
  }
4249
4258
  }
4250
4259
  if (type === "kpi") {
4251
- const value = spec.value;
4252
- if (value === void 0) {
4253
- err("value", '"kpi" requires a "value" (a number or { field, aggregate? }).');
4254
- } else if (isObject3(value)) {
4255
- checkField("value.field", value.field);
4256
- } else if (typeof value !== "number") {
4257
- err("value", '"value" must be a number or an object with a "field".');
4260
+ checkValueRef("value", spec.value, "kpi", true);
4261
+ checkValueRef("delta", spec.delta, "delta", false);
4262
+ if (spec.align !== void 0 && !["start", "center", "end"].includes(spec.align)) {
4263
+ err("align", '"align" must be one of: start, center, end.');
4264
+ }
4265
+ if (spec.labelPosition !== void 0 && !["above", "below"].includes(spec.labelPosition)) {
4266
+ err("labelPosition", '"labelPosition" must be one of: above, below.');
4267
+ }
4268
+ if (spec.unit !== void 0 && typeof spec.unit !== "string") {
4269
+ err("unit", '"unit" must be a string.');
4270
+ }
4271
+ if (spec.color !== void 0 && typeof spec.color !== "string") {
4272
+ err("color", '"color" must be a string (a CSS color).');
4273
+ }
4274
+ if (spec.sparkline !== void 0 && typeof spec.sparkline !== "boolean") {
4275
+ if (!isObject3(spec.sparkline)) {
4276
+ err("sparkline", '"sparkline" must be a boolean or an object with { field?, markers? }.');
4277
+ } else {
4278
+ if (spec.sparkline.field !== void 0) checkField("sparkline.field", spec.sparkline.field);
4279
+ if (spec.sparkline.markers !== void 0 && typeof spec.sparkline.markers !== "boolean") {
4280
+ err("sparkline.markers", '"sparkline.markers" must be a boolean.');
4281
+ }
4282
+ }
4283
+ }
4284
+ if (spec.comparisons !== void 0) {
4285
+ if (!Array.isArray(spec.comparisons)) {
4286
+ err("comparisons", '"comparisons" must be an array of { label, delta, amount? }.');
4287
+ } else {
4288
+ spec.comparisons.forEach((c, i) => {
4289
+ if (!isObject3(c)) {
4290
+ err(`comparisons[${i}]`, 'Each comparison must be an object with a "label" and "delta".');
4291
+ return;
4292
+ }
4293
+ if (typeof c.label !== "string" || c.label === "") {
4294
+ err(`comparisons[${i}].label`, 'Each comparison needs a non-empty "label".');
4295
+ }
4296
+ checkValueRef(`comparisons[${i}].delta`, c.delta, "comparison", true);
4297
+ if (c.amount !== void 0) checkValueRef(`comparisons[${i}].amount`, c.amount, "comparison amount", false);
4298
+ if (c.invert !== void 0 && typeof c.invert !== "boolean") {
4299
+ err(`comparisons[${i}].invert`, '"invert" must be a boolean.');
4300
+ }
4301
+ });
4302
+ }
4258
4303
  }
4259
4304
  }
4260
4305
  if (type === "gauge") {
4261
4306
  checkValueRef("value", spec.value, "gauge", true);
4262
4307
  checkValueRef("target", spec.target, "target", false);
4308
+ checkValueRef("compare", spec.compare, "compare", false);
4309
+ if (spec.legend !== void 0 && typeof spec.legend !== "boolean") {
4310
+ err("legend", '"legend" must be a boolean.');
4311
+ }
4263
4312
  const max = spec.max;
4264
4313
  if (max === void 0) {
4265
4314
  err("max", `"gauge" requires a numeric "max" (the scale's full-scale value).`);
@@ -5709,15 +5758,19 @@ function overlayTextToCanvasCmd(o, font) {
5709
5758
  const m = t.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
5710
5759
  return m ? parseFloat(m[1]) * Math.PI / 180 : void 0;
5711
5760
  })();
5761
+ const shiftX100 = t.includes("translateX(-100%)") || t.includes("translate(-100%");
5762
+ const shiftX50 = t.includes("translateX(-50%)") || t.includes("translate(-50%");
5712
5763
  let align = "left";
5713
5764
  let x = o.left;
5714
5765
  const y = o.top;
5715
5766
  if (o.width != null) {
5716
5767
  align = o.align ?? "left";
5717
5768
  x = align === "right" ? o.left + o.width : align === "center" ? o.left + o.width / 2 : o.left;
5718
- } else if (t.includes("translateX(-100%)")) {
5769
+ if (shiftX100) x -= o.width;
5770
+ else if (shiftX50) x -= o.width / 2;
5771
+ } else if (shiftX100) {
5719
5772
  align = "right";
5720
- } else if (t.includes("translateX(-50%)") || t.includes("translate(-50%")) {
5773
+ } else if (shiftX50) {
5721
5774
  align = "center";
5722
5775
  }
5723
5776
  const baseline = t.includes("translateY(-50%)") || t.includes(",-50%") || t.includes(", -50%") ? "middle" : "top";
@@ -7080,13 +7133,13 @@ function rowAlpha(emphasis, row) {
7080
7133
  if (!emphasis) return 1;
7081
7134
  return emphasis.match(row) ? 1 : emphasis.dim;
7082
7135
  }
7083
- function seriesMatches(emphasis, series) {
7084
- if (!emphasis) return true;
7085
- return series.rows.some((row) => emphasis.match(row));
7086
- }
7087
- function seriesAlpha(emphasis, series) {
7088
- if (!emphasis) return 1;
7089
- return seriesMatches(emphasis, series) ? 1 : emphasis.dim;
7136
+ function seriesEmphasisMode(emphasis, series) {
7137
+ if (!emphasis) return "all";
7138
+ let matched = 0;
7139
+ for (const row of series.rows) if (emphasis.match(row)) matched++;
7140
+ if (matched === 0) return "none";
7141
+ if (matched === series.rows.length) return "all";
7142
+ return "partial";
7090
7143
  }
7091
7144
 
7092
7145
  // src/charts/line.ts
@@ -7114,6 +7167,24 @@ function buildSeriesPoints(model, s) {
7114
7167
  gap: () => ({ x: NaN, y: NaN })
7115
7168
  });
7116
7169
  }
7170
+ function matchPointPixels(model, s) {
7171
+ const emphasis = model.emphasis;
7172
+ if (!emphasis) return [];
7173
+ const readX = accessor(model.x.field);
7174
+ const readY = accessor(model.y.field ?? "");
7175
+ const out = [];
7176
+ for (const d of s.rows) {
7177
+ if (!emphasis.match(d)) continue;
7178
+ const px = model.x.pixel(readX(d));
7179
+ if (px == null) continue;
7180
+ const yRaw = readY(d);
7181
+ if (yRaw == null || yRaw === "") continue;
7182
+ const py = model.y.pixel(yRaw);
7183
+ if (Number.isNaN(py)) continue;
7184
+ out.push({ x: px, y: py });
7185
+ }
7186
+ return out;
7187
+ }
7117
7188
  function finiteRuns(points) {
7118
7189
  const runs = [];
7119
7190
  let run = [];
@@ -7165,6 +7236,18 @@ function drawLineSketch(ctx, model, allSeries, pen, filled, showPoints) {
7165
7236
  if (alpha !== 1) ctx.globalAlpha = prev;
7166
7237
  }
7167
7238
  }
7239
+ for (const { series, mode, matchPoints } of allSeries) {
7240
+ if (mode !== "partial") continue;
7241
+ ctx.globalAlpha = 1;
7242
+ for (const p of matchPoints) {
7243
+ pen.circle(p.x, p.y, 3.4, {
7244
+ fill: series.color,
7245
+ stroke: model.tokens.color.background,
7246
+ strokeWidth: 1.5
7247
+ });
7248
+ }
7249
+ }
7250
+ ctx.globalAlpha = 1;
7168
7251
  }
7169
7252
  function drawLine(surface, model) {
7170
7253
  const ctx = surface.marks.ctx;
@@ -7173,11 +7256,16 @@ function drawLine(surface, model) {
7173
7256
  const curve = resolveCurve(spec.curve);
7174
7257
  const lineGen = line({ curve });
7175
7258
  const areaGen = spec.area ? area({ curve }) : null;
7176
- const allSeries = model.series.map((s) => ({
7177
- series: s,
7178
- points: buildSeriesPoints(model, s),
7179
- alpha: seriesAlpha(model.emphasis, s)
7180
- }));
7259
+ const allSeries = model.series.map((s) => {
7260
+ const mode = seriesEmphasisMode(model.emphasis, s);
7261
+ return {
7262
+ series: s,
7263
+ points: buildSeriesPoints(model, s),
7264
+ alpha: mode === "all" ? 1 : model.emphasis?.dim ?? 1,
7265
+ mode,
7266
+ matchPoints: matchPointPixels(model, s)
7267
+ };
7268
+ });
7181
7269
  const showPoints = spec.points || allSeries.some((s) => s.points.filter((p) => !Number.isNaN(p.x)).length === 1);
7182
7270
  ctx.save();
7183
7271
  ctx.beginPath();
@@ -7232,6 +7320,20 @@ function drawLine(surface, model) {
7232
7320
  ctx.globalAlpha = 1;
7233
7321
  }
7234
7322
  }
7323
+ for (const { series, mode, matchPoints } of allSeries) {
7324
+ if (mode !== "partial") continue;
7325
+ ctx.fillStyle = series.color;
7326
+ ctx.strokeStyle = tokens.color.background;
7327
+ ctx.lineWidth = 1.5;
7328
+ ctx.globalAlpha = 1;
7329
+ for (const p of matchPoints) {
7330
+ ctx.beginPath();
7331
+ ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
7332
+ ctx.fill();
7333
+ ctx.stroke();
7334
+ }
7335
+ }
7336
+ ctx.globalAlpha = 1;
7235
7337
  ctx.restore();
7236
7338
  }
7237
7339
 
@@ -7242,6 +7344,8 @@ function buildBands(model, stacked) {
7242
7344
  const cumulative = /* @__PURE__ */ new Map();
7243
7345
  const threshold = stacked ? Infinity : Math.max(2, Math.round(model.plot.width));
7244
7346
  return model.series.map((series) => {
7347
+ const mode = seriesEmphasisMode(model.emphasis, series);
7348
+ const matchPoints = [];
7245
7349
  const points = [];
7246
7350
  for (const row of series.rows) {
7247
7351
  const xv = readX(row);
@@ -7254,19 +7358,23 @@ function buildBands(model, stacked) {
7254
7358
  const base = cumulative.get(key) ?? 0;
7255
7359
  const top = base + (missing ? 0 : value);
7256
7360
  cumulative.set(key, top);
7361
+ const y1 = missing ? NaN : model.y.pixel(top);
7257
7362
  points.push({
7258
7363
  x: px == null ? NaN : px,
7259
7364
  y0: model.y.pixel(base),
7260
- y1: missing ? NaN : model.y.pixel(top),
7365
+ y1,
7261
7366
  sortKey: px == null ? Number.POSITIVE_INFINITY : px
7262
7367
  });
7368
+ if (model.emphasis?.match(row) && px != null && !Number.isNaN(y1)) matchPoints.push({ x: px, y: y1 });
7263
7369
  } else {
7370
+ const y1 = missing ? NaN : model.y.pixel(value);
7264
7371
  points.push({
7265
7372
  x: px == null ? NaN : px,
7266
7373
  y0: model.y.baseline,
7267
- y1: missing ? NaN : model.y.pixel(value),
7374
+ y1,
7268
7375
  sortKey: px == null ? Number.POSITIVE_INFINITY : px
7269
7376
  });
7377
+ if (model.emphasis?.match(row) && px != null && !Number.isNaN(y1)) matchPoints.push({ x: px, y: y1 });
7270
7378
  }
7271
7379
  }
7272
7380
  points.sort((a, b) => a.sortKey - b.sortKey);
@@ -7276,7 +7384,7 @@ function buildBands(model, stacked) {
7276
7384
  getY: (p) => p.y1,
7277
7385
  gap: () => ({ x: NaN, y0: NaN, y1: NaN })
7278
7386
  }) : band;
7279
- return { series, points: reduced, alpha: seriesAlpha(model.emphasis, series) };
7387
+ return { series, points: reduced, alpha: mode === "all" ? 1 : model.emphasis?.dim ?? 1, mode, matchPoints };
7280
7388
  });
7281
7389
  }
7282
7390
  function drawAreaSketch(ctx, model, bands, pen, stacked) {
@@ -7311,6 +7419,18 @@ function drawAreaSketch(ctx, model, bands, pen, stacked) {
7311
7419
  );
7312
7420
  if (alpha !== 1) ctx.globalAlpha = prev;
7313
7421
  }
7422
+ for (const { series, mode, matchPoints } of bands) {
7423
+ if (mode !== "partial") continue;
7424
+ ctx.globalAlpha = 1;
7425
+ for (const p of matchPoints) {
7426
+ pen.circle(p.x, p.y, 3.4, {
7427
+ fill: series.color,
7428
+ stroke: model.tokens.color.background,
7429
+ strokeWidth: 1.5
7430
+ });
7431
+ }
7432
+ }
7433
+ ctx.globalAlpha = 1;
7314
7434
  }
7315
7435
  function drawArea(surface, model) {
7316
7436
  const ctx = surface.marks.ctx;
@@ -7360,6 +7480,20 @@ function drawArea(surface, model) {
7360
7480
  ctx.stroke();
7361
7481
  ctx.globalAlpha = 1;
7362
7482
  }
7483
+ for (const { series, mode, matchPoints } of bands) {
7484
+ if (mode !== "partial") continue;
7485
+ ctx.fillStyle = series.color;
7486
+ ctx.strokeStyle = tokens.color.background;
7487
+ ctx.lineWidth = 1.5;
7488
+ ctx.globalAlpha = 1;
7489
+ for (const p of matchPoints) {
7490
+ ctx.beginPath();
7491
+ ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
7492
+ ctx.fill();
7493
+ ctx.stroke();
7494
+ }
7495
+ }
7496
+ ctx.globalAlpha = 1;
7363
7497
  ctx.restore();
7364
7498
  }
7365
7499
 
@@ -7427,6 +7561,8 @@ function drawGroupedBars(ctx, model, radius, pen) {
7427
7561
  const subWidth = seriesCount > 1 ? bandWidth / seriesCount : bandWidth;
7428
7562
  const gap = seriesCount > 1 && subWidth > 3 ? Math.min(1, subWidth * 0.15) : 0;
7429
7563
  const barThick = Math.max(0, subWidth - gap);
7564
+ const t = model.entranceProgress ?? 1;
7565
+ const grow = (pixel) => model.y.baseline + (pixel - model.y.baseline) * t;
7430
7566
  model.series.forEach((series, seriesIndex) => {
7431
7567
  ctx.fillStyle = series.color;
7432
7568
  for (const row of series.rows) {
@@ -7436,7 +7572,7 @@ function drawGroupedBars(ctx, model, radius, pen) {
7436
7572
  const center2 = model.x.pixel(readX(row));
7437
7573
  if (center2 == null) continue;
7438
7574
  const catStart = center2 - bandWidth / 2 + seriesIndex * subWidth + gap / 2;
7439
- const rect = orientedRect(horizontal, catStart, barThick, model.y.baseline, model.y.pixel(value), series.color);
7575
+ const rect = orientedRect(horizontal, catStart, barThick, grow(model.y.baseline), grow(model.y.pixel(value)), series.color);
7440
7576
  if (rect) drawRect(ctx, rect, radius, pen, rowAlpha(model.emphasis, row));
7441
7577
  }
7442
7578
  });
@@ -7450,6 +7586,8 @@ function drawStackedBars(ctx, model, radius, pen) {
7450
7586
  const segments = [];
7451
7587
  const roundedEnds = /* @__PURE__ */ new Map();
7452
7588
  const bandWidth = model.x.bandwidth;
7589
+ const t = model.entranceProgress ?? 1;
7590
+ const grow = (pixel) => model.y.baseline + (pixel - model.y.baseline) * t;
7453
7591
  for (const series of model.series) {
7454
7592
  for (const row of series.rows) {
7455
7593
  const xValue = readX(row);
@@ -7464,7 +7602,7 @@ function drawStackedBars(ctx, model, radius, pen) {
7464
7602
  const base = totals.get(categoryKey2) ?? 0;
7465
7603
  const top = base + value;
7466
7604
  totals.set(categoryKey2, top);
7467
- const rect = orientedRect(horizontal, center2 - bandWidth / 2, bandWidth, model.y.pixel(base), model.y.pixel(top), series.color);
7605
+ const rect = orientedRect(horizontal, center2 - bandWidth / 2, bandWidth, grow(model.y.pixel(base)), grow(model.y.pixel(top)), series.color);
7468
7606
  if (!rect) continue;
7469
7607
  segments.push({ ...rect, stackEndKey, alpha: rowAlpha(model.emphasis, row) });
7470
7608
  roundedEnds.set(stackEndKey, segments.length - 1);
@@ -8597,6 +8735,8 @@ function drawPie(surface, spec, tokens, size, context) {
8597
8735
  const colorField = pie.encoding.color.field;
8598
8736
  const emphasis = context?.emphasis ?? null;
8599
8737
  const sliceAlpha = (slice) => emphasis ? emphasis.match({ [colorField]: slice.category }) ? 1 : emphasis.dim : 1;
8738
+ const sweepT = context?.sweepProgress ?? 1;
8739
+ const sweepEnd = TAU3 * sweepT;
8600
8740
  const content = drawTitleBlock(surface, tokens, size, pie.title);
8601
8741
  const slices = buildSlices(pie, tokens.color.palette);
8602
8742
  const legendPosition = resolveLegendPosition(pie, content.width, content.height);
@@ -8654,9 +8794,11 @@ function drawPie(surface, spec, tokens, size, context) {
8654
8794
  if (sketch) {
8655
8795
  const pen = new RoughPen(ctx, sketch);
8656
8796
  for (const slice of slices) {
8797
+ const end = Math.min(slice.endAngle, sweepEnd);
8798
+ if (end <= slice.startAngle) continue;
8657
8799
  const alpha = sliceAlpha(slice);
8658
8800
  if (alpha !== 1) ctx.globalAlpha = alpha;
8659
- pen.wedge(cx, cy, innerRadius, outerRadius, slice.startAngle, slice.endAngle, {
8801
+ pen.wedge(cx, cy, innerRadius, outerRadius, slice.startAngle, end, {
8660
8802
  fill: slice.color,
8661
8803
  stroke: separator
8662
8804
  });
@@ -8665,11 +8807,13 @@ function drawPie(surface, spec, tokens, size, context) {
8665
8807
  ctx.restore();
8666
8808
  } else {
8667
8809
  for (const slice of slices) {
8810
+ const end = Math.min(slice.endAngle, sweepEnd);
8811
+ if (end <= slice.startAngle) continue;
8668
8812
  const wedge = arc({
8669
8813
  innerRadius,
8670
8814
  outerRadius,
8671
8815
  startAngle: slice.startAngle,
8672
- endAngle: slice.endAngle
8816
+ endAngle: end
8673
8817
  });
8674
8818
  ctx.beginPath();
8675
8819
  wedge(ctx, cx, cy);
@@ -8681,7 +8825,7 @@ function drawPie(surface, spec, tokens, size, context) {
8681
8825
  }
8682
8826
  ctx.restore();
8683
8827
  }
8684
- if (labelPlan && labelCfg) {
8828
+ if (sweepT >= 1 && labelPlan && labelCfg) {
8685
8829
  ctx.save();
8686
8830
  ctx.lineWidth = 1;
8687
8831
  ctx.lineJoin = "round";
@@ -8725,7 +8869,7 @@ function drawPie(surface, spec, tokens, size, context) {
8725
8869
  });
8726
8870
  }
8727
8871
  }
8728
- if (innerRadius >= 34) {
8872
+ if (sweepT >= 1 && innerRadius >= 34) {
8729
8873
  addOverlayText(surface, tokens, {
8730
8874
  left: cx,
8731
8875
  top: cy - tokens.font.size.small - 2,
@@ -9010,19 +9154,9 @@ function drawFunnel(surface, spec, tokens, size, context) {
9010
9154
 
9011
9155
  // src/charts/kpi.ts
9012
9156
  var SVG_NS = "http://www.w3.org/2000/svg";
9013
- var SKETCH_BORDER_RADIUS = "255px 15px 225px 15px / 15px 225px 15px 255px";
9014
9157
  function clamp5(value, min, max) {
9015
9158
  return Math.max(min, Math.min(max, value));
9016
9159
  }
9017
- function resolveValue(ref, data) {
9018
- if (ref == null) return null;
9019
- if (typeof ref === "number") return Number.isFinite(ref) ? ref : null;
9020
- const read = accessor(ref.field);
9021
- return aggregateValues(
9022
- data.map((d) => read(d)),
9023
- ref.aggregate ?? "sum"
9024
- );
9025
- }
9026
9160
  function valueField(ref) {
9027
9161
  return typeof ref === "number" ? null : ref.field;
9028
9162
  }
@@ -9033,6 +9167,12 @@ function formatDelta(delta, specFormat) {
9033
9167
  if (abs <= 1) return sign2 + formatNumber2(abs, ".1%");
9034
9168
  return sign2 + formatNumber2(abs, ",.0f");
9035
9169
  }
9170
+ function flexAlign(align) {
9171
+ return align === "start" ? "flex-start" : align === "end" ? "flex-end" : "center";
9172
+ }
9173
+ function textAlignOf(align) {
9174
+ return align === "start" ? "left" : align === "end" ? "right" : "center";
9175
+ }
9036
9176
  function cardRectFor(surface, tokens, size, spec) {
9037
9177
  if (spec.title) return drawTitleBlock(surface, tokens, size, spec.title);
9038
9178
  return {
@@ -9042,41 +9182,25 @@ function cardRectFor(surface, tokens, size, spec) {
9042
9182
  height: Math.max(0, size.height - CHROME_PAD.top - CHROME_PAD.bottom)
9043
9183
  };
9044
9184
  }
9045
- function createCard(tokens, rect, hasSparkline, framed = false) {
9046
- const card = document.createElement("div");
9047
- const pad3 = tokens.spacing.lg;
9048
- const sparkReserve = hasSparkline ? Math.min(44, Math.max(28, rect.height * 0.18)) + tokens.spacing.sm : 0;
9049
- card.style.position = "absolute";
9050
- card.style.left = `${rect.x}px`;
9051
- card.style.top = `${rect.y}px`;
9052
- card.style.width = `${rect.width}px`;
9053
- card.style.height = `${rect.height}px`;
9054
- card.style.boxSizing = "border-box";
9055
- card.style.background = framed ? "transparent" : tokens.color.surface;
9056
- card.style.border = framed ? "none" : `1px solid ${tokens.color.border}`;
9057
- card.style.borderRadius = `${tokens.radius.lg}px`;
9058
- card.style.padding = framed ? `0 0 ${sparkReserve}px` : `${pad3}px ${pad3}px ${pad3 + sparkReserve}px`;
9059
- card.style.display = "flex";
9060
- card.style.flexDirection = "column";
9061
- card.style.justifyContent = "center";
9062
- card.style.alignItems = "flex-start";
9063
- card.style.gap = `${tokens.spacing.sm}px`;
9064
- card.style.overflow = "hidden";
9065
- card.style.pointerEvents = "none";
9066
- return card;
9185
+ function valueFontSize(rect, contentWidth, valueText, unit, isRich) {
9186
+ const effectiveLen = valueText.length + (unit ? unit.length * 0.5 + 0.4 : 0);
9187
+ const sizeByCard = Math.min(rect.width, rect.height) * (isRich ? 0.17 : 0.28);
9188
+ const sizeByText = contentWidth / Math.max(1, effectiveLen * 0.58);
9189
+ const lo = isRich ? 22 : 26;
9190
+ const hi = isRich ? 52 : 76;
9191
+ return clamp5(Math.min(sizeByCard, sizeByText), lo, hi);
9067
9192
  }
9068
- function addText2(card, text, style) {
9069
- const el = document.createElement("div");
9070
- el.textContent = text;
9071
- Object.assign(el.style, style);
9072
- card.appendChild(el);
9073
- return el;
9193
+ function unitFontSize(valueSize) {
9194
+ return Math.max(12, valueSize * 0.46);
9074
9195
  }
9075
9196
  function sparklineField(spec) {
9076
9197
  if (!spec.sparkline) return null;
9077
- if (typeof spec.sparkline === "object") return spec.sparkline.field;
9198
+ if (typeof spec.sparkline === "object") return spec.sparkline.field ?? valueField(spec.value);
9078
9199
  return valueField(spec.value);
9079
9200
  }
9201
+ function sparklineMarkers(spec) {
9202
+ return typeof spec.sparkline === "object" ? spec.sparkline.markers !== false : true;
9203
+ }
9080
9204
  function sparklineValues(data, field) {
9081
9205
  const read = accessor(field);
9082
9206
  const values = [];
@@ -9086,41 +9210,165 @@ function sparklineValues(data, field) {
9086
9210
  }
9087
9211
  return values;
9088
9212
  }
9089
- function buildSparkline(tokens, width, height, values) {
9213
+ function kpiSparklineSeries(spec, data) {
9214
+ const field = sparklineField(spec);
9215
+ return field ? sparklineValues(data, field) : [];
9216
+ }
9217
+ function morphSparkline(to, from, progress) {
9218
+ if (progress == null || progress >= 1 || to.length === 0) return to;
9219
+ const t = progress;
9220
+ if (from && from.length === to.length) return to.map((v, i) => from[i] + (v - from[i]) * t);
9221
+ const base = to[0];
9222
+ return to.map((v) => base + (v - base) * t);
9223
+ }
9224
+ function resolveComparison(cmp, data, parentFormat) {
9225
+ const delta = resolveValueRef(cmp.delta, data);
9226
+ if (delta == null) return null;
9227
+ const rawPositive = delta >= 0;
9228
+ const tri = rawPositive ? "\u25B2" : "\u25BC";
9229
+ let text = `${tri} ${formatValue(delta, cmp.format ?? ".0%")}`;
9230
+ if (cmp.amount !== void 0) {
9231
+ const amount = resolveValueRef(cmp.amount, data);
9232
+ if (amount != null) text += ` : ${formatValue(amount, cmp.amountFormat ?? parentFormat ?? ",.0f")}`;
9233
+ }
9234
+ return { label: cmp.label, text, positive: cmp.invert ? !rawPositive : rawPositive };
9235
+ }
9236
+ function layoutKpi(rect, tokens, framed, f) {
9237
+ const font = tokens.font;
9238
+ const pad3 = framed ? 0 : tokens.spacing.lg;
9239
+ const left = rect.x + pad3;
9240
+ const contentWidth = Math.max(1, rect.width - pad3 * 2);
9241
+ const availTop = rect.y + pad3;
9242
+ const availH = Math.max(0, rect.height - pad3 * 2);
9243
+ const labelSize = Math.round(clamp5(f.valueSize * 0.4, font.size.small, font.size.title));
9244
+ const labelH = f.hasLabel ? Math.round(labelSize * 1.35) : 0;
9245
+ const valueH = Math.round(f.valueSize * 1.06);
9246
+ const deltaSize = font.size.large;
9247
+ const deltaH = f.hasDelta ? Math.round(deltaSize * 1.5) : 0;
9248
+ const compSize = font.size.large;
9249
+ const compRowH = Math.round(compSize * 1.55);
9250
+ const gapLabel = Math.round(labelSize * 0.25);
9251
+ const gapDelta = f.hasDelta ? tokens.spacing.xs : 0;
9252
+ const gapSpark = tokens.spacing.sm;
9253
+ const dividerPad = tokens.spacing.sm;
9254
+ const headerH = (f.hasLabel && f.labelAbove ? labelH + gapLabel : 0) + valueH + (f.hasLabel && !f.labelAbove ? gapLabel + labelH : 0) + (f.hasDelta ? gapDelta + deltaH : 0);
9255
+ const footerH = f.compCount > 0 ? dividerPad + 1 + dividerPad + f.compCount * compRowH : 0;
9256
+ let headerTop;
9257
+ let footerTop;
9258
+ let spark = null;
9259
+ if (f.hasSparkline) {
9260
+ headerTop = availTop;
9261
+ footerTop = availTop + availH - footerH;
9262
+ const sparkTop = headerTop + headerH + gapSpark;
9263
+ const sparkBottom = footerTop - (footerH > 0 ? gapSpark : 0);
9264
+ spark = { x: left, y: sparkTop, width: contentWidth, height: Math.max(24, sparkBottom - sparkTop) };
9265
+ } else {
9266
+ const groupH = headerH + (footerH > 0 ? tokens.spacing.md + footerH : 0);
9267
+ headerTop = availTop + Math.max(0, (availH - groupH) / 2);
9268
+ footerTop = headerTop + headerH + (footerH > 0 ? tokens.spacing.md : 0);
9269
+ }
9270
+ let y = headerTop;
9271
+ let labelY = -1;
9272
+ if (f.hasLabel && f.labelAbove) {
9273
+ labelY = y;
9274
+ y += labelH + gapLabel;
9275
+ }
9276
+ const valueY = y;
9277
+ y += valueH;
9278
+ if (f.hasLabel && !f.labelAbove) {
9279
+ y += gapLabel;
9280
+ labelY = y;
9281
+ y += labelH;
9282
+ }
9283
+ let deltaY = -1;
9284
+ if (f.hasDelta) {
9285
+ y += gapDelta;
9286
+ deltaY = y;
9287
+ }
9288
+ let dividerY = -1;
9289
+ let compStartY = -1;
9290
+ if (f.compCount > 0) {
9291
+ dividerY = footerTop + dividerPad;
9292
+ compStartY = dividerY + 1 + dividerPad;
9293
+ }
9294
+ return {
9295
+ left,
9296
+ contentWidth,
9297
+ labelSize,
9298
+ labelY,
9299
+ valueY,
9300
+ valueH,
9301
+ deltaSize,
9302
+ deltaY,
9303
+ spark,
9304
+ compSize,
9305
+ compRowH,
9306
+ dividerY,
9307
+ compStartY
9308
+ };
9309
+ }
9310
+ function sparkMarkers(values, xAt, yAt, r, tokens) {
9311
+ const n = values.length;
9312
+ if (n < 2) return [];
9313
+ let minIdx = 0;
9314
+ let maxIdx = 0;
9315
+ for (let i = 1; i < n; i++) {
9316
+ if (values[i] < values[minIdx]) minIdx = i;
9317
+ if (values[i] > values[maxIdx]) maxIdx = i;
9318
+ }
9319
+ const lastIdx = n - 1;
9320
+ const out = [];
9321
+ if (maxIdx !== lastIdx) out.push({ x: xAt(maxIdx), y: yAt(values[maxIdx]), r, fill: tokens.color.positive });
9322
+ if (minIdx !== lastIdx) out.push({ x: xAt(minIdx), y: yAt(values[minIdx]), r, fill: tokens.color.negative });
9323
+ const lastStroke = lastIdx === maxIdx ? tokens.color.positive : lastIdx === minIdx ? tokens.color.negative : tokens.color.text;
9324
+ out.push({
9325
+ x: xAt(lastIdx),
9326
+ y: yAt(values[lastIdx]),
9327
+ r,
9328
+ fill: tokens.color.surface,
9329
+ stroke: lastStroke,
9330
+ strokeWidth: 1.75
9331
+ });
9332
+ return out;
9333
+ }
9334
+ function sparkScales(values, width, height, markers) {
9335
+ const n = values.length;
9336
+ const r = clamp5(Math.min(height, 40) * 0.12, 2.5, 4);
9337
+ const padX = markers ? r + 1 : 1.75;
9338
+ const padY = markers ? r + 1 : 1.75;
9339
+ const innerW = Math.max(1, width - padX * 2);
9340
+ const innerH = Math.max(1, height - padY * 2);
9341
+ const min = Math.min(...values);
9342
+ const max = Math.max(...values);
9343
+ const span = max - min;
9344
+ const xAt = (i) => n === 1 ? width / 2 : padX + i / (n - 1) * innerW;
9345
+ const yAt = (v) => padY + (1 - (span === 0 ? 0.5 : (v - min) / span)) * innerH;
9346
+ return { xAt, yAt, r };
9347
+ }
9348
+ function buildSparkline(tokens, width, height, values, markers) {
9090
9349
  if (values.length === 0 || width <= 0 || height <= 0) return null;
9091
9350
  const svg = document.createElementNS(SVG_NS, "svg");
9092
9351
  svg.setAttribute("width", String(width));
9093
9352
  svg.setAttribute("height", String(height));
9094
9353
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
9095
- svg.setAttribute("preserveAspectRatio", "none");
9096
9354
  svg.style.display = "block";
9097
9355
  svg.style.overflow = "visible";
9098
9356
  const stroke = 1.75;
9099
- const pad3 = stroke;
9100
- const innerH = Math.max(1, height - pad3 * 2);
9357
+ const line2 = tokens.color.textMuted;
9358
+ const { xAt, yAt, r } = sparkScales(values, width, height, markers);
9101
9359
  const n = values.length;
9102
- const min = Math.min(...values);
9103
- const max = Math.max(...values);
9104
- const span = max - min;
9105
- const xAt = (i) => n === 1 ? width / 2 : i / (n - 1) * width;
9106
- const yAt = (v) => {
9107
- const t = span === 0 ? 0.5 : (v - min) / span;
9108
- return pad3 + (1 - t) * innerH;
9109
- };
9110
9360
  if (n === 1) {
9111
9361
  const dot = document.createElementNS(SVG_NS, "circle");
9112
9362
  dot.setAttribute("cx", String(width / 2));
9113
9363
  dot.setAttribute("cy", String(yAt(values[0])));
9114
- dot.setAttribute("r", "2.25");
9364
+ dot.setAttribute("r", "2.5");
9115
9365
  dot.setAttribute("fill", tokens.color.accent);
9116
9366
  svg.appendChild(dot);
9117
9367
  return svg;
9118
9368
  }
9119
9369
  let lineD = "";
9120
- for (let i = 0; i < n; i++) {
9121
- lineD += `${i === 0 ? "M" : "L"}${xAt(i).toFixed(2)} ${yAt(values[i]).toFixed(2)}`;
9122
- }
9123
- const areaD = `${lineD}L${width.toFixed(2)} ${height}L0 ${height}Z`;
9370
+ for (let i = 0; i < n; i++) lineD += `${i === 0 ? "M" : "L"}${xAt(i).toFixed(2)} ${yAt(values[i]).toFixed(2)}`;
9371
+ const areaD = `${lineD}L${xAt(n - 1).toFixed(2)} ${height}L${xAt(0).toFixed(2)} ${height}Z`;
9124
9372
  const gradId = `graphein-spark-${Math.random().toString(36).slice(2, 9)}`;
9125
9373
  const defs = document.createElementNS(SVG_NS, "defs");
9126
9374
  const grad = document.createElementNS(SVG_NS, "linearGradient");
@@ -9131,11 +9379,11 @@ function buildSparkline(tokens, width, height, values) {
9131
9379
  grad.setAttribute("y2", "1");
9132
9380
  const stopTop = document.createElementNS(SVG_NS, "stop");
9133
9381
  stopTop.setAttribute("offset", "0");
9134
- stopTop.setAttribute("stop-color", tokens.color.accent);
9135
- stopTop.setAttribute("stop-opacity", "0.24");
9382
+ stopTop.setAttribute("stop-color", line2);
9383
+ stopTop.setAttribute("stop-opacity", "0.18");
9136
9384
  const stopBottom = document.createElementNS(SVG_NS, "stop");
9137
9385
  stopBottom.setAttribute("offset", "1");
9138
- stopBottom.setAttribute("stop-color", tokens.color.accent);
9386
+ stopBottom.setAttribute("stop-color", line2);
9139
9387
  stopBottom.setAttribute("stop-opacity", "0");
9140
9388
  grad.appendChild(stopTop);
9141
9389
  grad.appendChild(stopBottom);
@@ -9148,14 +9396,43 @@ function buildSparkline(tokens, width, height, values) {
9148
9396
  const path = document.createElementNS(SVG_NS, "path");
9149
9397
  path.setAttribute("d", lineD);
9150
9398
  path.setAttribute("fill", "none");
9151
- path.setAttribute("stroke", tokens.color.accent);
9399
+ path.setAttribute("stroke", line2);
9152
9400
  path.setAttribute("stroke-width", String(stroke));
9153
9401
  path.setAttribute("stroke-linejoin", "round");
9154
9402
  path.setAttribute("stroke-linecap", "round");
9155
9403
  svg.appendChild(path);
9404
+ if (markers) {
9405
+ for (const m of sparkMarkers(values, xAt, yAt, r, tokens)) {
9406
+ const c = document.createElementNS(SVG_NS, "circle");
9407
+ c.setAttribute("cx", m.x.toFixed(2));
9408
+ c.setAttribute("cy", m.y.toFixed(2));
9409
+ c.setAttribute("r", String(m.r));
9410
+ c.setAttribute("fill", m.fill ?? "none");
9411
+ if (m.stroke) {
9412
+ c.setAttribute("stroke", m.stroke);
9413
+ c.setAttribute("stroke-width", String(m.strokeWidth ?? 1.75));
9414
+ }
9415
+ svg.appendChild(c);
9416
+ }
9417
+ }
9156
9418
  return svg;
9157
9419
  }
9158
- function buildSparklineCanvas(tokens, width, height, values, sketch) {
9420
+ function paintSparkMarkers(ctx, values, xAt, yAt, r, tokens) {
9421
+ for (const m of sparkMarkers(values, xAt, yAt, r, tokens)) {
9422
+ ctx.beginPath();
9423
+ ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2);
9424
+ if (m.fill) {
9425
+ ctx.fillStyle = m.fill;
9426
+ ctx.fill();
9427
+ }
9428
+ if (m.stroke) {
9429
+ ctx.lineWidth = m.strokeWidth ?? 1.75;
9430
+ ctx.strokeStyle = m.stroke;
9431
+ ctx.stroke();
9432
+ }
9433
+ }
9434
+ }
9435
+ function buildSparklineCanvas(tokens, width, height, values, markers, sketch) {
9159
9436
  if (values.length === 0 || width <= 0 || height <= 0) return null;
9160
9437
  const dpr = getDevicePixelRatio();
9161
9438
  const canvas = document.createElement("canvas");
@@ -9167,207 +9444,339 @@ function buildSparklineCanvas(tokens, width, height, values, sketch) {
9167
9444
  const ctx = canvas.getContext("2d");
9168
9445
  if (!ctx) return null;
9169
9446
  ctx.scale(dpr, dpr);
9170
- const stroke = 1.75;
9171
- const pad3 = stroke;
9172
- const innerH = Math.max(1, height - pad3 * 2);
9447
+ const { xAt, yAt, r } = sparkScales(values, width, height, markers);
9173
9448
  const n = values.length;
9174
- const min = Math.min(...values);
9175
- const max = Math.max(...values);
9176
- const span = max - min;
9177
- const xAt = (i) => n === 1 ? width / 2 : i / (n - 1) * width;
9178
- const yAt = (v) => pad3 + (1 - (span === 0 ? 0.5 : (v - min) / span)) * innerH;
9179
9449
  const pen = new RoughPen(ctx, sketch);
9180
9450
  if (n === 1) {
9181
9451
  pen.circle(width / 2, yAt(values[0]), 2.5, { fill: tokens.color.accent });
9182
9452
  return canvas;
9183
9453
  }
9184
9454
  const points = values.map((v, i) => ({ x: xAt(i), y: yAt(v) }));
9185
- pen.trendStroke(points, { stroke: tokens.color.accent, strokeWidth: stroke });
9455
+ pen.trendStroke(points, { stroke: tokens.color.textMuted, strokeWidth: 1.75 });
9456
+ if (markers) paintSparkMarkers(ctx, values, xAt, yAt, r, tokens);
9186
9457
  return canvas;
9187
9458
  }
9188
- function drawKpi(surface, spec, tokens, size, context) {
9189
- const kpi = spec;
9190
- if (surface.headless) {
9191
- drawKpiCanvas(surface, kpi, tokens, size, context);
9459
+ function paintSparklineCanvas(ctx, tokens, box, values, markers) {
9460
+ if (values.length === 0 || box.width <= 0 || box.height <= 0) return;
9461
+ const { xAt: xAt0, yAt: yAt0, r } = sparkScales(values, box.width, box.height, markers);
9462
+ const xAt = (i) => box.x + xAt0(i);
9463
+ const yAt = (v) => box.y + yAt0(v);
9464
+ const n = values.length;
9465
+ ctx.save();
9466
+ if (n === 1) {
9467
+ ctx.beginPath();
9468
+ ctx.arc(xAt(0), yAt(values[0]), 2.5, 0, Math.PI * 2);
9469
+ ctx.fillStyle = tokens.color.accent;
9470
+ ctx.fill();
9471
+ ctx.restore();
9192
9472
  return;
9193
9473
  }
9474
+ ctx.globalAlpha = 0.16;
9475
+ ctx.beginPath();
9476
+ ctx.moveTo(xAt(0), yAt(values[0]));
9477
+ for (let i = 1; i < n; i++) ctx.lineTo(xAt(i), yAt(values[i]));
9478
+ ctx.lineTo(xAt(n - 1), box.y + box.height);
9479
+ ctx.lineTo(xAt(0), box.y + box.height);
9480
+ ctx.closePath();
9481
+ ctx.fillStyle = tokens.color.textMuted;
9482
+ ctx.fill();
9483
+ ctx.globalAlpha = 1;
9484
+ ctx.beginPath();
9485
+ for (let i = 0; i < n; i++) {
9486
+ const px = xAt(i);
9487
+ const py = yAt(values[i]);
9488
+ if (i === 0) ctx.moveTo(px, py);
9489
+ else ctx.lineTo(px, py);
9490
+ }
9491
+ ctx.strokeStyle = tokens.color.textMuted;
9492
+ ctx.lineWidth = 1.75;
9493
+ ctx.lineJoin = "round";
9494
+ ctx.lineCap = "round";
9495
+ ctx.stroke();
9496
+ if (markers) paintSparkMarkers(ctx, values, xAt, yAt, r, tokens);
9497
+ ctx.restore();
9498
+ }
9499
+ function drawKpi(surface, spec, tokens, size, context) {
9500
+ const kpi = spec;
9194
9501
  const data = kpi.data ?? [];
9195
- const value = resolveValue(kpi.value, data);
9196
- const delta = resolveValue(kpi.delta, data);
9502
+ const value = resolveValueRef(kpi.value, data);
9503
+ const delta = resolveValueRef(kpi.delta, data);
9197
9504
  const sparkField = sparklineField(kpi);
9198
9505
  const sparkValues = sparkField ? sparklineValues(data, sparkField) : [];
9199
9506
  const hasSparkline = sparkValues.length > 0;
9200
- const sketch = resolveSketch(spec);
9507
+ const markers = sparklineMarkers(kpi);
9508
+ const comparisons = kpi.comparisons?.map((c) => resolveComparison(c, data, kpi.format)).filter((c) => c != null) ?? [];
9509
+ const hasDelta = delta != null;
9510
+ const isRich = hasDelta || hasSparkline || comparisons.length > 0;
9511
+ const align = kpi.align ?? "center";
9512
+ const labelAbove = kpi.labelPosition === "above" || kpi.labelPosition == null && isRich;
9201
9513
  const framed = context?.framed === true;
9202
9514
  const rect = cardRectFor(surface, tokens, size, kpi);
9203
- const card = createCard(tokens, rect, hasSparkline, framed);
9204
- if (sketch) card.style.borderRadius = SKETCH_BORDER_RADIUS;
9205
- if (kpi.label) {
9206
- addText2(card, kpi.label, {
9515
+ const progress = context?.valueProgress;
9516
+ const from = context?.valueFrom ?? 0;
9517
+ const displayValue = value != null && progress != null && progress < 1 ? from + (value - from) * progress : value;
9518
+ const displaySparkValues = morphSparkline(sparkValues, context?.sparklineFrom, progress);
9519
+ const valueText = value == null ? "\u2014" : formatValue(value, kpi.format);
9520
+ const displayText = displayValue == null ? "\u2014" : formatValue(displayValue, kpi.format);
9521
+ const fromText = context?.valueFrom != null && value != null ? formatValue(context.valueFrom, kpi.format) : valueText;
9522
+ const sizingText = fromText.length > valueText.length ? fromText : valueText;
9523
+ const pad3 = framed ? 0 : tokens.spacing.lg;
9524
+ const contentWidth = Math.max(1, rect.width - pad3 * 2);
9525
+ const valueSize = valueFontSize(rect, contentWidth, sizingText, kpi.unit, isRich);
9526
+ const geom = layoutKpi(rect, tokens, framed, {
9527
+ hasLabel: !!kpi.label,
9528
+ labelAbove,
9529
+ hasDelta,
9530
+ hasSparkline,
9531
+ compCount: comparisons.length,
9532
+ valueSize
9533
+ });
9534
+ const sketch = resolveSketch(spec);
9535
+ if (surface.headless) {
9536
+ drawKpiCanvas(surface, kpi, tokens, {
9537
+ align,
9538
+ valueSize,
9539
+ displayText,
9540
+ delta,
9541
+ comparisons,
9542
+ sparkValues: displaySparkValues,
9543
+ markers,
9544
+ geom
9545
+ });
9546
+ return;
9547
+ }
9548
+ const card = document.createElement("div");
9549
+ card.style.position = "absolute";
9550
+ card.style.left = `${rect.x}px`;
9551
+ card.style.top = `${rect.y}px`;
9552
+ card.style.width = `${rect.width}px`;
9553
+ card.style.height = `${rect.height}px`;
9554
+ card.style.boxSizing = "border-box";
9555
+ card.style.overflow = "hidden";
9556
+ card.style.pointerEvents = "none";
9557
+ const ox = rect.x;
9558
+ const oy = rect.y;
9559
+ const addAt = (el, left, top, width) => {
9560
+ el.style.position = "absolute";
9561
+ el.style.left = `${left - ox}px`;
9562
+ el.style.top = `${top - oy}px`;
9563
+ el.style.width = `${width}px`;
9564
+ card.appendChild(el);
9565
+ };
9566
+ if (kpi.label && geom.labelY >= 0) {
9567
+ const el = document.createElement("div");
9568
+ el.textContent = kpi.label;
9569
+ Object.assign(el.style, {
9207
9570
  color: tokens.color.textMuted,
9208
9571
  fontFamily: tokens.font.family,
9209
- fontSize: `${tokens.font.size.base}px`,
9572
+ fontSize: `${geom.labelSize}px`,
9210
9573
  fontWeight: String(tokens.font.weight.medium),
9211
- letterSpacing: "0.04em",
9212
9574
  lineHeight: "1.25",
9213
- textTransform: "uppercase",
9214
- maxWidth: "100%",
9575
+ textAlign: align === "start" ? "left" : align === "end" ? "right" : "center",
9215
9576
  overflow: "hidden",
9216
9577
  textOverflow: "ellipsis",
9217
9578
  whiteSpace: "nowrap"
9218
9579
  });
9219
- }
9220
- const valueText = value == null ? "\u2014" : formatValue(value, kpi.format);
9221
- const contentWidth = Math.max(1, rect.width - tokens.spacing.lg * 2);
9222
- const sizeByCard = Math.min(rect.width, rect.height) * 0.22;
9223
- const sizeByText = contentWidth / Math.max(1, valueText.length * 0.58);
9224
- const valueSize = clamp5(Math.min(sizeByCard, sizeByText), 28, 64);
9225
- addText2(card, valueText, {
9226
- color: tokens.color.text,
9580
+ addAt(el, geom.left, geom.labelY, geom.contentWidth);
9581
+ }
9582
+ const valueWrap = document.createElement("div");
9583
+ valueWrap.style.display = "flex";
9584
+ valueWrap.style.alignItems = "baseline";
9585
+ valueWrap.style.justifyContent = flexAlign(align);
9586
+ valueWrap.style.gap = "0.12em";
9587
+ valueWrap.style.height = `${geom.valueH}px`;
9588
+ valueWrap.style.overflow = "hidden";
9589
+ const num2 = document.createElement("div");
9590
+ num2.textContent = displayText;
9591
+ Object.assign(num2.style, {
9592
+ color: kpi.color ?? tokens.color.text,
9227
9593
  fontFamily: tokens.font.family,
9228
9594
  fontSize: `${valueSize}px`,
9229
9595
  fontWeight: String(tokens.font.weight.bold),
9230
9596
  letterSpacing: "-0.03em",
9231
9597
  lineHeight: "1.02",
9232
- maxWidth: "100%",
9233
9598
  overflow: "hidden",
9234
9599
  textOverflow: "ellipsis",
9235
9600
  whiteSpace: "nowrap"
9236
9601
  });
9237
- if (delta != null) {
9602
+ valueWrap.appendChild(num2);
9603
+ if (kpi.unit) {
9604
+ const unit = document.createElement("div");
9605
+ unit.textContent = kpi.unit;
9606
+ Object.assign(unit.style, {
9607
+ color: tokens.color.textMuted,
9608
+ fontFamily: tokens.font.family,
9609
+ fontSize: `${unitFontSize(valueSize)}px`,
9610
+ fontWeight: String(tokens.font.weight.medium),
9611
+ whiteSpace: "nowrap",
9612
+ flex: "0 0 auto"
9613
+ });
9614
+ valueWrap.appendChild(unit);
9615
+ }
9616
+ addAt(valueWrap, geom.left, geom.valueY, geom.contentWidth);
9617
+ if (hasDelta && geom.deltaY >= 0 && delta != null) {
9238
9618
  const positive = delta >= 0;
9239
- addText2(card, `${positive ? "\u25B2" : "\u25BC"} ${formatDelta(delta, kpi.format)}`, {
9619
+ const el = document.createElement("div");
9620
+ el.textContent = `${positive ? "\u25B2" : "\u25BC"} ${formatDelta(delta, kpi.format)}`;
9621
+ Object.assign(el.style, {
9240
9622
  color: positive ? tokens.color.positive : tokens.color.negative,
9241
9623
  fontFamily: tokens.font.family,
9242
- fontSize: `${tokens.font.size.large}px`,
9624
+ fontSize: `${geom.deltaSize}px`,
9243
9625
  fontWeight: String(tokens.font.weight.medium),
9244
- lineHeight: "1",
9626
+ lineHeight: "1.2",
9627
+ textAlign: align === "start" ? "left" : align === "end" ? "right" : "center",
9245
9628
  whiteSpace: "nowrap"
9246
9629
  });
9630
+ addAt(el, geom.left, geom.deltaY, geom.contentWidth);
9631
+ }
9632
+ if (geom.spark) {
9633
+ const node = sketch ? buildSparklineCanvas(tokens, geom.spark.width, geom.spark.height, displaySparkValues, markers, sketch) : buildSparkline(tokens, geom.spark.width, geom.spark.height, displaySparkValues, markers);
9634
+ if (node) addAt(node, geom.spark.x, geom.spark.y, geom.spark.width);
9635
+ }
9636
+ if (comparisons.length > 0 && geom.dividerY >= 0) {
9637
+ const divider = document.createElement("div");
9638
+ divider.style.height = "1px";
9639
+ divider.style.background = tokens.color.border;
9640
+ addAt(divider, geom.left, geom.dividerY, geom.contentWidth);
9641
+ comparisons.forEach((c, i) => {
9642
+ const row = document.createElement("div");
9643
+ row.style.display = "flex";
9644
+ row.style.alignItems = "baseline";
9645
+ row.style.gap = "0.5em";
9646
+ row.style.height = `${geom.compRowH}px`;
9647
+ row.style.overflow = "hidden";
9648
+ const label = document.createElement("span");
9649
+ label.textContent = c.label;
9650
+ Object.assign(label.style, {
9651
+ color: tokens.color.text,
9652
+ fontFamily: tokens.font.family,
9653
+ fontSize: `${geom.compSize}px`,
9654
+ fontWeight: String(tokens.font.weight.medium),
9655
+ whiteSpace: "nowrap",
9656
+ flex: "0 0 auto"
9657
+ });
9658
+ const val = document.createElement("span");
9659
+ val.textContent = c.text;
9660
+ Object.assign(val.style, {
9661
+ color: c.positive ? tokens.color.positive : tokens.color.negative,
9662
+ fontFamily: tokens.font.family,
9663
+ fontSize: `${geom.compSize}px`,
9664
+ fontWeight: String(tokens.font.weight.medium),
9665
+ whiteSpace: "nowrap"
9666
+ });
9667
+ row.appendChild(label);
9668
+ row.appendChild(val);
9669
+ addAt(row, geom.left, geom.compStartY + i * geom.compRowH, geom.contentWidth);
9670
+ });
9247
9671
  }
9248
9672
  surface.overlay.appendChild(card);
9249
- if (hasSparkline) {
9250
- const pad3 = framed ? 0 : tokens.spacing.lg;
9251
- const sparkH = clamp5(rect.height * 0.18, 24, 40);
9252
- const sparkW = Math.max(0, rect.width - pad3 * 2);
9253
- const node = sketch ? buildSparklineCanvas(tokens, sparkW, sparkH, sparkValues, sketch) : buildSparkline(tokens, sparkW, sparkH, sparkValues);
9254
- if (node) {
9255
- node.style.position = "absolute";
9256
- node.style.left = `${pad3}px`;
9257
- node.style.bottom = `${pad3}px`;
9258
- card.appendChild(node);
9259
- }
9260
- }
9261
9673
  }
9262
- function drawKpiCanvas(surface, kpi, tokens, size, context) {
9674
+ function drawKpiCanvas(surface, kpi, tokens, p) {
9263
9675
  const ctx = surface.marks.ctx;
9264
- const data = kpi.data ?? [];
9265
- const value = resolveValue(kpi.value, data);
9266
- const delta = resolveValue(kpi.delta, data);
9267
- const sparkField = sparklineField(kpi);
9268
- const sparkValues = sparkField ? sparklineValues(data, sparkField) : [];
9269
- const hasSparkline = sparkValues.length > 0;
9270
- const framed = context?.framed === true;
9271
- const rect = cardRectFor(surface, tokens, size, kpi);
9272
- const pad3 = tokens.spacing.lg;
9273
- const sparkReserve = hasSparkline ? Math.min(44, Math.max(28, rect.height * 0.18)) + tokens.spacing.sm : 0;
9274
- if (!framed) {
9275
- ctx.save();
9276
- ctx.beginPath();
9277
- roundedRect(ctx, rect.x, rect.y, rect.width, rect.height, tokens.radius.lg);
9278
- ctx.fillStyle = tokens.color.surface;
9279
- ctx.fill();
9280
- ctx.lineWidth = 1;
9281
- ctx.strokeStyle = tokens.color.border;
9282
- ctx.stroke();
9283
- ctx.restore();
9284
- }
9285
- const left = rect.x + (framed ? 0 : pad3);
9286
- const contentWidth = Math.max(1, rect.width - (framed ? 0 : pad3 * 2));
9287
- const valueText = value == null ? "\u2014" : formatValue(value, kpi.format);
9288
- const sizeByCard = Math.min(rect.width, rect.height) * 0.22;
9289
- const sizeByText = contentWidth / Math.max(1, valueText.length * 0.58);
9290
- const valueSize = clamp5(Math.min(sizeByCard, sizeByText), 28, 64);
9291
- const labelH = kpi.label ? tokens.font.size.base * 1.25 : 0;
9292
- const deltaH = delta != null ? tokens.font.size.large : 0;
9293
- const stackH = labelH + valueSize + deltaH + tokens.spacing.sm * 2;
9294
- const avail = rect.height - sparkReserve;
9295
- let y = rect.y + Math.max(framed ? 0 : pad3, (avail - stackH) / 2);
9296
- if (kpi.label) {
9676
+ const { geom } = p;
9677
+ const alignX = () => p.align === "start" ? geom.left : p.align === "end" ? geom.left + geom.contentWidth : geom.left + geom.contentWidth / 2;
9678
+ const alignCanvas = textAlignOf(p.align);
9679
+ if (kpi.label && geom.labelY >= 0) {
9297
9680
  paintCanvasText(ctx, {
9298
- x: left,
9299
- y,
9300
- text: kpi.label.toUpperCase(),
9301
- font: fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.medium),
9681
+ x: alignX(),
9682
+ y: geom.labelY,
9683
+ text: kpi.label,
9684
+ font: fontString(geom.labelSize, tokens.font.family, tokens.font.weight.medium),
9302
9685
  color: tokens.color.textMuted,
9303
- size: tokens.font.size.base,
9686
+ size: geom.labelSize,
9687
+ align: alignCanvas,
9304
9688
  baseline: "top"
9305
9689
  });
9306
- y += labelH + tokens.spacing.sm;
9307
9690
  }
9691
+ const unitSize = kpi.unit ? unitFontSize(p.valueSize) : 0;
9692
+ const numberFont = fontString(p.valueSize, tokens.font.family, tokens.font.weight.bold);
9693
+ const unitFont = kpi.unit ? fontString(unitSize, tokens.font.family, tokens.font.weight.medium) : "";
9694
+ ctx.save();
9695
+ ctx.font = numberFont;
9696
+ const numberW = ctx.measureText(p.displayText).width;
9697
+ let unitW = 0;
9698
+ if (kpi.unit) {
9699
+ ctx.font = unitFont;
9700
+ unitW = ctx.measureText(kpi.unit).width;
9701
+ }
9702
+ ctx.restore();
9703
+ const unitGap = kpi.unit ? p.valueSize * 0.12 : 0;
9704
+ const totalW = numberW + (kpi.unit ? unitGap + unitW : 0);
9705
+ const groupX = p.align === "start" ? geom.left : p.align === "end" ? geom.left + geom.contentWidth - totalW : geom.left + (geom.contentWidth - totalW) / 2;
9706
+ const baselineY = geom.valueY + p.valueSize * 0.82;
9308
9707
  paintCanvasText(ctx, {
9309
- x: left,
9310
- y,
9311
- text: valueText,
9312
- font: fontString(valueSize, tokens.font.family, tokens.font.weight.bold),
9313
- color: tokens.color.text,
9314
- size: valueSize,
9315
- baseline: "top"
9708
+ x: groupX,
9709
+ y: baselineY,
9710
+ text: p.displayText,
9711
+ font: numberFont,
9712
+ color: kpi.color ?? tokens.color.text,
9713
+ size: p.valueSize,
9714
+ align: "left",
9715
+ baseline: "alphabetic"
9316
9716
  });
9317
- y += valueSize + tokens.spacing.sm;
9318
- if (delta != null) {
9319
- const positive = delta >= 0;
9717
+ if (kpi.unit) {
9320
9718
  paintCanvasText(ctx, {
9321
- x: left,
9322
- y,
9323
- text: `${positive ? "\u25B2" : "\u25BC"} ${formatDelta(delta, kpi.format)}`,
9324
- font: fontString(tokens.font.size.large, tokens.font.family, tokens.font.weight.medium),
9719
+ x: groupX + numberW + unitGap,
9720
+ y: baselineY,
9721
+ text: kpi.unit,
9722
+ font: unitFont,
9723
+ color: tokens.color.textMuted,
9724
+ size: unitSize,
9725
+ align: "left",
9726
+ baseline: "alphabetic"
9727
+ });
9728
+ }
9729
+ if (p.delta != null && geom.deltaY >= 0) {
9730
+ const positive = p.delta >= 0;
9731
+ paintCanvasText(ctx, {
9732
+ x: alignX(),
9733
+ y: geom.deltaY,
9734
+ text: `${positive ? "\u25B2" : "\u25BC"} ${formatDelta(p.delta, kpi.format)}`,
9735
+ font: fontString(geom.deltaSize, tokens.font.family, tokens.font.weight.medium),
9325
9736
  color: positive ? tokens.color.positive : tokens.color.negative,
9326
- size: tokens.font.size.large,
9737
+ size: geom.deltaSize,
9738
+ align: alignCanvas,
9327
9739
  baseline: "top"
9328
9740
  });
9329
9741
  }
9330
- if (hasSparkline) {
9331
- const sparkH = clamp5(rect.height * 0.18, 24, 40);
9332
- const inset = framed ? 0 : pad3;
9333
- const sparkW = Math.max(0, rect.width - inset * 2);
9334
- const x0 = rect.x + inset;
9335
- const y1 = rect.y + rect.height - inset;
9336
- paintSparklineCanvas(ctx, tokens, x0, y1 - sparkH, sparkW, sparkH, sparkValues);
9742
+ if (geom.spark && p.sparkValues.length > 0) {
9743
+ paintSparklineCanvas(ctx, tokens, geom.spark, p.sparkValues, p.markers);
9337
9744
  }
9338
- }
9339
- function paintSparklineCanvas(ctx, tokens, x0, y0, width, height, values) {
9340
- if (values.length === 0 || width <= 0 || height <= 0) return;
9341
- const stroke = 1.75;
9342
- const pad3 = stroke;
9343
- const innerH = Math.max(1, height - pad3 * 2);
9344
- const n = values.length;
9345
- const min = Math.min(...values);
9346
- const max = Math.max(...values);
9347
- const span = max - min;
9348
- const xAt = (i) => x0 + (n === 1 ? width / 2 : i / (n - 1) * width);
9349
- const yAt = (v) => y0 + pad3 + (1 - (span === 0 ? 0.5 : (v - min) / span)) * innerH;
9350
- ctx.save();
9351
- if (n === 1) {
9352
- ctx.beginPath();
9353
- ctx.arc(xAt(0), yAt(values[0]), 2.25, 0, Math.PI * 2);
9354
- ctx.fillStyle = tokens.color.accent;
9355
- ctx.fill();
9356
- } else {
9357
- ctx.beginPath();
9358
- for (let i = 0; i < n; i++) {
9359
- const px = xAt(i);
9360
- const py = yAt(values[i]);
9361
- if (i === 0) ctx.moveTo(px, py);
9362
- else ctx.lineTo(px, py);
9363
- }
9364
- ctx.strokeStyle = tokens.color.accent;
9365
- ctx.lineWidth = stroke;
9366
- ctx.lineJoin = "round";
9367
- ctx.lineCap = "round";
9368
- ctx.stroke();
9745
+ if (p.comparisons.length > 0 && geom.dividerY >= 0) {
9746
+ ctx.save();
9747
+ ctx.fillStyle = tokens.color.border;
9748
+ ctx.fillRect(geom.left, geom.dividerY, geom.contentWidth, 1);
9749
+ ctx.restore();
9750
+ const compFont = fontString(geom.compSize, tokens.font.family, tokens.font.weight.medium);
9751
+ const gap = geom.compSize * 0.5;
9752
+ p.comparisons.forEach((c, i) => {
9753
+ const rowY = geom.compStartY + i * geom.compRowH + geom.compRowH / 2;
9754
+ ctx.save();
9755
+ ctx.font = compFont;
9756
+ const labelW = ctx.measureText(c.label).width;
9757
+ ctx.restore();
9758
+ paintCanvasText(ctx, {
9759
+ x: geom.left,
9760
+ y: rowY,
9761
+ text: c.label,
9762
+ font: compFont,
9763
+ color: tokens.color.text,
9764
+ size: geom.compSize,
9765
+ align: "left",
9766
+ baseline: "middle"
9767
+ });
9768
+ paintCanvasText(ctx, {
9769
+ x: geom.left + labelW + gap,
9770
+ y: rowY,
9771
+ text: c.text,
9772
+ font: compFont,
9773
+ color: c.positive ? tokens.color.positive : tokens.color.negative,
9774
+ size: geom.compSize,
9775
+ align: "left",
9776
+ baseline: "middle"
9777
+ });
9778
+ });
9369
9779
  }
9370
- ctx.restore();
9371
9780
  }
9372
9781
 
9373
9782
  // src/charts/sankey.ts
@@ -9834,18 +10243,24 @@ function drawLegend3(surface, tokens, legend, domain, scheme, format) {
9834
10243
  transform: "translateX(-100%)"
9835
10244
  });
9836
10245
  }
9837
- function drawChoropleth(surface, spec, tokens, size) {
10246
+ function drawChoropleth(surface, spec, tokens, size, context) {
9838
10247
  const choropleth = spec;
10248
+ const emphasis = context?.emphasis ?? null;
9839
10249
  const content = drawTitleBlock(surface, tokens, size, choropleth.title);
9840
10250
  const features = choropleth.geo?.features ?? [];
9841
10251
  if (features.length === 0 || content.width <= 0 || content.height <= 0) return;
9842
10252
  const readKey = accessor(choropleth.encoding.key.field);
9843
10253
  const readColor = accessor(choropleth.encoding.color.field);
9844
10254
  const valueByKey = /* @__PURE__ */ new Map();
10255
+ const rowByKey = /* @__PURE__ */ new Map();
9845
10256
  for (const row of choropleth.data ?? []) {
9846
- const k = toKey(readKey(row));
9847
- const v = toNumber(readColor(row));
9848
- if (k !== "" && Number.isFinite(v)) valueByKey.set(k, v);
10257
+ const datum = row;
10258
+ const k = toKey(readKey(datum));
10259
+ const v = toNumber(readColor(datum));
10260
+ if (k !== "" && Number.isFinite(v)) {
10261
+ valueByKey.set(k, v);
10262
+ rowByKey.set(k, datum);
10263
+ }
9849
10264
  }
9850
10265
  const projection = choropleth.projection ?? "mercator";
9851
10266
  const project = projector(
@@ -9863,6 +10278,7 @@ function drawChoropleth(surface, spec, tokens, size) {
9863
10278
  for (const feature of features) {
9864
10279
  const key = featureKey(feature, choropleth.featureId);
9865
10280
  const value = valueByKey.has(key) ? valueByKey.get(key) : null;
10281
+ const row = rowByKey.get(key);
9866
10282
  if (value != null) {
9867
10283
  if (value < vMin) vMin = value;
9868
10284
  if (value > vMax) vMax = value;
@@ -9885,7 +10301,7 @@ function drawChoropleth(surface, spec, tokens, size) {
9885
10301
  }
9886
10302
  if (rings.length) polys.push(rings);
9887
10303
  }
9888
- projected.push({ key, name: featureName(feature, key), value, polys });
10304
+ projected.push({ key, name: featureName(feature, key), value, row, polys });
9889
10305
  }
9890
10306
  const spanX = maxX - minX;
9891
10307
  const spanY = maxY - minY;
@@ -9932,6 +10348,7 @@ function drawChoropleth(surface, spec, tokens, size) {
9932
10348
  key: pf.key,
9933
10349
  name: pf.name,
9934
10350
  value: pf.value,
10351
+ row: pf.row,
9935
10352
  color: pf.value == null ? noDataFill : rgbaToCss(cscale.map(pf.value)),
9936
10353
  polys,
9937
10354
  cx: count ? cx / count : 0,
@@ -9946,6 +10363,7 @@ function drawChoropleth(surface, spec, tokens, size) {
9946
10363
  ctx.strokeStyle = tokens.color.background;
9947
10364
  ctx.lineWidth = 0.75;
9948
10365
  for (const f of screen) {
10366
+ ctx.globalAlpha = rowAlpha(emphasis, f.row ?? { [choropleth.encoding.key.field]: f.key });
9949
10367
  ctx.fillStyle = f.color;
9950
10368
  for (const polygon of f.polys) {
9951
10369
  ctx.beginPath();
@@ -9966,6 +10384,7 @@ function drawChoropleth(surface, spec, tokens, size) {
9966
10384
  }
9967
10385
  }
9968
10386
  }
10387
+ ctx.globalAlpha = 1;
9969
10388
  }
9970
10389
  ctx.restore();
9971
10390
  if (legendHeight > 0) {
@@ -10120,7 +10539,7 @@ function colorToTone(color) {
10120
10539
  var HEADER_ROW_HEIGHT = 32;
10121
10540
  var MIN_COL_WIDTH = 72;
10122
10541
  var DEFAULT_BODY_ROW_HEIGHT = 30;
10123
- var SKETCH_BORDER_RADIUS2 = "14px 6px 16px 7px / 7px 15px 6px 14px";
10542
+ var SKETCH_BORDER_RADIUS = "14px 6px 16px 7px / 7px 15px 6px 14px";
10124
10543
  function formatCellValue(value, column) {
10125
10544
  return formatValue(value, column.format);
10126
10545
  }
@@ -10159,8 +10578,8 @@ function buildTable(opts) {
10159
10578
  overflow: "auto",
10160
10579
  pointerEvents: "auto",
10161
10580
  boxSizing: "border-box",
10162
- border: `1px solid ${tokens.color.border}`,
10163
- borderRadius: opts.sketch ? SKETCH_BORDER_RADIUS2 : `${tokens.radius.sm}px`,
10581
+ border: `1px solid ${opts.sketch ? "transparent" : tokens.color.border}`,
10582
+ borderRadius: opts.sketch ? SKETCH_BORDER_RADIUS : `${tokens.radius.sm}px`,
10164
10583
  background: tokens.color.background
10165
10584
  });
10166
10585
  container.replaceChildren();
@@ -10190,14 +10609,23 @@ function paintTableCanvas(opts) {
10190
10609
  const available = Math.max(0, rect.width - 2);
10191
10610
  const widths = computeFittedWidths(opts.columns, available);
10192
10611
  opts.widths = widths;
10612
+ const pen = opts.sketch ? new RoughPen(ctx, opts.sketch) : null;
10613
+ const w = Math.max(0, rect.width);
10614
+ const h = Math.max(0, rect.height);
10193
10615
  ctx.save();
10194
10616
  ctx.beginPath();
10195
- roundedRect(ctx, rect.x, rect.y, Math.max(0, rect.width), Math.max(0, rect.height), tokens.radius.sm);
10196
- ctx.fillStyle = tokens.color.background;
10197
- ctx.fill();
10198
- ctx.lineWidth = 1;
10199
- ctx.strokeStyle = tokens.color.border;
10200
- ctx.stroke();
10617
+ if (pen) {
10618
+ ctx.rect(rect.x, rect.y, w, h);
10619
+ ctx.fillStyle = tokens.color.background;
10620
+ ctx.fill();
10621
+ } else {
10622
+ roundedRect(ctx, rect.x, rect.y, w, h, tokens.radius.sm);
10623
+ ctx.fillStyle = tokens.color.background;
10624
+ ctx.fill();
10625
+ ctx.lineWidth = 1;
10626
+ ctx.strokeStyle = tokens.color.border;
10627
+ ctx.stroke();
10628
+ }
10201
10629
  ctx.clip();
10202
10630
  const headerRows = normalizedHeaderRows(opts);
10203
10631
  const headerHeight = headerRows.length * metrics.headerHeight;
@@ -10209,17 +10637,18 @@ function paintTableCanvas(opts) {
10209
10637
  const clipped = visibleRows < opts.rowCount;
10210
10638
  const cueReserve = clipped && opts.clippedCue !== false && visibleRows > 0 ? metrics.rowHeight : 0;
10211
10639
  const rowsToDraw = Math.max(0, visibleRows - (cueReserve > 0 ? 1 : 0));
10212
- paintHeaderCanvas(opts, widths, headerRows, metrics);
10640
+ paintHeaderCanvas(opts, widths, headerRows, metrics, pen);
10213
10641
  for (let rowIndex = 0; rowIndex < rowsToDraw; rowIndex += 1) {
10214
- paintBodyRowCanvas(opts, widths, rowIndex, bodyTop + rowIndex * metrics.rowHeight, metrics);
10642
+ paintBodyRowCanvas(opts, widths, rowIndex, bodyTop + rowIndex * metrics.rowHeight, metrics, pen);
10215
10643
  }
10216
10644
  if (cueReserve > 0) {
10217
10645
  paintClippedCueCanvas(opts, bodyTop + rowsToDraw * metrics.rowHeight, metrics, opts.rowCount - rowsToDraw);
10218
10646
  }
10219
10647
  if (opts.footerRow) {
10220
- paintFooterCanvas(opts, widths, rect.y + rect.height - metrics.rowHeight, metrics);
10648
+ paintFooterCanvas(opts, widths, rect.y + rect.height - metrics.rowHeight, metrics, pen);
10221
10649
  }
10222
10650
  ctx.restore();
10651
+ if (pen) pen.rect(rect.x, rect.y, w, h, { stroke: tokens.color.border });
10223
10652
  }
10224
10653
  var FILL_EPS = 0.5;
10225
10654
  var cachedScrollbarWidth = null;
@@ -10312,14 +10741,14 @@ function makeHeaderCell(opts, title2, colIndex, align, rowIndex = 0) {
10312
10741
  th.textContent = title2;
10313
10742
  const isSorted = colIndex != null && opts.sortState?.col === colIndex;
10314
10743
  if (isSorted) th.setAttribute("aria-sort", opts.sortState?.dir === "desc" ? "descending" : "ascending");
10315
- setCellBaseStyles(th, opts.tokens, align ?? "left", opts.stickyHeader !== false, metrics.padding);
10744
+ setCellBaseStyles(th, opts.tokens, align ?? "left", opts.stickyHeader !== false, metrics.padding, gridBorderColor(opts));
10316
10745
  setStyles(th, {
10317
10746
  top: `${rowIndex * metrics.headerHeight}px`,
10318
10747
  zIndex: "4",
10319
10748
  background: opts.tokens.color.surface,
10320
10749
  color: opts.tokens.color.text,
10321
10750
  fontWeight: String(opts.tokens.font.weight.bold),
10322
- borderBottom: `1px solid ${opts.tokens.color.border}`,
10751
+ borderBottom: `1px solid ${gridBorderColor(opts)}`,
10323
10752
  userSelect: "none",
10324
10753
  height: `${metrics.headerHeight}px`
10325
10754
  });
@@ -10377,19 +10806,19 @@ function makeBodyRow(opts, rowIndex, rowHeight) {
10377
10806
  const td = document.createElement("td");
10378
10807
  const cell = opts.getCell(rowIndex, colIndex);
10379
10808
  const rowBg = rowBackground(opts, rowIndex, cls);
10380
- setCellBaseStyles(td, opts.tokens, column.align, false, metrics.padding);
10809
+ setCellBaseStyles(td, opts.tokens, column.align, false, metrics.padding, gridBorderColor(opts));
10381
10810
  setStyles(td, {
10382
10811
  position: colIndex < (opts.rowHeaderSpan ?? 0) ? "sticky" : "relative",
10383
10812
  background: rowBg,
10384
10813
  fontWeight: cls === "normal" ? String(opts.tokens.font.weight.normal) : String(opts.tokens.font.weight.bold),
10385
10814
  color: opts.tokens.color.text,
10386
- borderBottom: `1px solid ${opts.tokens.color.border}`,
10815
+ borderBottom: `1px solid ${gridBorderColor(opts)}`,
10387
10816
  height: rowHeight ? `${rowHeight}px` : `${metrics.rowHeight}px`,
10388
10817
  whiteSpace: column.wrap === true ? "normal" : "nowrap"
10389
10818
  });
10390
10819
  const indent = opts.cellIndent?.(rowIndex, colIndex) ?? 0;
10391
10820
  if (indent > 0) td.style.paddingLeft = `${metrics.paddingX + indent}px`;
10392
- applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg);
10821
+ applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg, rowIndex);
10393
10822
  if (td.childNodes.length === 0) {
10394
10823
  td.textContent = formatDisplayValue(cell.value, column);
10395
10824
  applyNegativeStyle(td, cell.value, column);
@@ -10407,7 +10836,7 @@ function buildFooter(opts) {
10407
10836
  opts.columns.forEach((column, colIndex) => {
10408
10837
  const td = document.createElement("td");
10409
10838
  const cell = opts.footerRow?.cells[colIndex] ?? { value: null};
10410
- setCellBaseStyles(td, opts.tokens, column.align, false, metrics.padding);
10839
+ setCellBaseStyles(td, opts.tokens, column.align, false, metrics.padding, gridBorderColor(opts));
10411
10840
  setStyles(td, {
10412
10841
  position: colIndex < (opts.rowHeaderSpan ?? 0) ? "sticky" : "sticky",
10413
10842
  bottom: "0",
@@ -10415,8 +10844,8 @@ function buildFooter(opts) {
10415
10844
  background: opts.tokens.color.surface,
10416
10845
  color: opts.tokens.color.text,
10417
10846
  fontWeight: String(opts.tokens.font.weight.bold),
10418
- borderTop: `1px solid ${opts.tokens.color.border}`,
10419
- borderBottom: `1px solid ${opts.tokens.color.border}`,
10847
+ borderTop: `1px solid ${gridBorderColor(opts)}`,
10848
+ borderBottom: `1px solid ${gridBorderColor(opts)}`,
10420
10849
  height: `${metrics.rowHeight}px`,
10421
10850
  whiteSpace: column.wrap === true ? "normal" : "nowrap"
10422
10851
  });
@@ -10442,7 +10871,7 @@ function makeSpacerRow(opts, height) {
10442
10871
  tr.appendChild(td);
10443
10872
  return tr;
10444
10873
  }
10445
- function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg) {
10874
+ function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg, rowIndex = 0) {
10446
10875
  const cf = column.conditionalFormat;
10447
10876
  const raw = cell.raw;
10448
10877
  const domain = opts.conditionalDomains?.[colIndex] ?? null;
@@ -10487,13 +10916,27 @@ function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg) {
10487
10916
  }
10488
10917
  if (!domain) return;
10489
10918
  const ratio = normalized(numeric, domain);
10490
- const bar = document.createElement("div");
10491
10919
  const diverging2 = cf.negativeColor !== void 0 || cf.baseline === "zero";
10492
10920
  const positive = numeric >= 0;
10493
10921
  const barColor = alphaColor(positive ? cf.color ?? opts.tokens.color.accent : cf.negativeColor ?? opts.tokens.color.negative, 0.85);
10494
10922
  const baseline = diverging2 ? normalized(0, domain) : 0;
10495
10923
  const left = diverging2 ? Math.min(baseline, normalized(numeric, domain)) : 0;
10496
10924
  const width = diverging2 ? Math.abs(normalized(numeric, domain) - baseline) : ratio;
10925
+ const text = document.createElement("span");
10926
+ text.textContent = cf.showValue === false ? "" : formatDisplayValue(cell.value, column);
10927
+ setStyles(text, overlayTextStyles(opts.tokens.color.text));
10928
+ td.style.background = rowBg;
10929
+ if (opts.sketch) {
10930
+ const colWidth = opts.widths?.[colIndex] ?? column.width ?? defaultColumnWidth(column);
10931
+ const rowHeight = densityMetrics(opts.density).rowHeight;
10932
+ const seed = (opts.sketch.seed ^ Math.imul(rowIndex + 1, 374761393) ^ Math.imul(colIndex + 1, 668265263)) >>> 0;
10933
+ const canvas = buildSketchDataBar(opts.sketch, barColor, colWidth, rowHeight, left, width, seed);
10934
+ if (canvas) td.appendChild(canvas);
10935
+ td.appendChild(text);
10936
+ applyNegativeStyle(text, cell.value, column);
10937
+ return;
10938
+ }
10939
+ const bar = document.createElement("div");
10497
10940
  setStyles(bar, {
10498
10941
  position: "absolute",
10499
10942
  left: `calc(${Math.round(left * 1e3) / 10}% + 4px)`,
@@ -10505,14 +10948,38 @@ function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg) {
10505
10948
  opacity: "0.9",
10506
10949
  pointerEvents: "none"
10507
10950
  });
10508
- const text = document.createElement("span");
10509
- text.textContent = cf.showValue === false ? "" : formatDisplayValue(cell.value, column);
10510
- setStyles(text, overlayTextStyles(opts.tokens.color.text));
10511
- td.style.background = rowBg;
10512
10951
  td.append(bar, text);
10513
10952
  applyNegativeStyle(text, cell.value, column);
10514
10953
  }
10515
- function setCellBaseStyles(cell, tokens, align, sticky, padding = "6px 10px") {
10954
+ function buildSketchDataBar(sketch, color, colWidth, rowHeight, leftRatio, widthRatio, seed) {
10955
+ const avail = Math.max(0, colWidth - 8);
10956
+ const barW = widthRatio * avail;
10957
+ const barH = Math.max(0, rowHeight - 10);
10958
+ if (barW <= 0.5 || barH <= 0) return null;
10959
+ const pad3 = 3;
10960
+ const leftPx = 4 + leftRatio * avail;
10961
+ const dpr = getDevicePixelRatio();
10962
+ const canvas = document.createElement("canvas");
10963
+ const cssW = barW + pad3 * 2;
10964
+ const cssH = barH + pad3 * 2;
10965
+ canvas.width = Math.max(1, Math.round(cssW * dpr));
10966
+ canvas.height = Math.max(1, Math.round(cssH * dpr));
10967
+ setStyles(canvas, {
10968
+ position: "absolute",
10969
+ left: `${leftPx - pad3}px`,
10970
+ top: `${5 - pad3}px`,
10971
+ width: `${cssW}px`,
10972
+ height: `${cssH}px`,
10973
+ pointerEvents: "none"
10974
+ });
10975
+ const ctx = canvas.getContext("2d");
10976
+ if (!ctx) return null;
10977
+ ctx.scale(dpr, dpr);
10978
+ const pen = new RoughPen(ctx, { ...sketch, seed });
10979
+ pen.rect(pad3, pad3, barW, barH, { stroke: color, fill: color, fillAlpha: 0.9, strokeAlpha: 0.9 });
10980
+ return canvas;
10981
+ }
10982
+ function setCellBaseStyles(cell, tokens, align, sticky, padding = "6px 10px", borderColor = tokens.color.border) {
10516
10983
  setStyles(cell, {
10517
10984
  boxSizing: "border-box",
10518
10985
  padding,
@@ -10520,11 +10987,14 @@ function setCellBaseStyles(cell, tokens, align, sticky, padding = "6px 10px") {
10520
10987
  whiteSpace: "nowrap",
10521
10988
  overflow: "hidden",
10522
10989
  textOverflow: "ellipsis",
10523
- borderRight: `1px solid ${tokens.color.border}`,
10990
+ borderRight: `1px solid ${borderColor}`,
10524
10991
  verticalAlign: "middle"
10525
10992
  });
10526
10993
  if (sticky) cell.style.position = "sticky";
10527
10994
  }
10995
+ function gridBorderColor(opts) {
10996
+ return opts.sketch ? "transparent" : opts.tokens.color.border;
10997
+ }
10528
10998
  function renderIconValue(td, icon, color, value, position) {
10529
10999
  const iconSpan = document.createElement("span");
10530
11000
  iconSpan.textContent = icon;
@@ -10578,7 +11048,7 @@ function normalizedHeaderRows(opts) {
10578
11048
  }))
10579
11049
  ];
10580
11050
  }
10581
- function paintHeaderCanvas(opts, widths, rows, metrics) {
11051
+ function paintHeaderCanvas(opts, widths, rows, metrics, pen) {
10582
11052
  rows.forEach((row, rowIndex) => {
10583
11053
  let cursor = 0;
10584
11054
  for (const cell of row) {
@@ -10589,7 +11059,7 @@ function paintHeaderCanvas(opts, widths, rows, metrics) {
10589
11059
  const y = opts.rect.y + rowIndex * metrics.headerHeight;
10590
11060
  const w = sumWidths(widths.slice(start, start + colSpan));
10591
11061
  const h = metrics.headerHeight * rowSpan;
10592
- paintCellBox(opts.ctx, x, y, w, h, opts.tokens.color.surface, opts.tokens.color.border);
11062
+ paintCellBox(opts.ctx, x, y, w, h, opts.tokens.color.surface, opts.tokens.color.border, false, pen);
10593
11063
  paintCellText(opts, {
10594
11064
  x,
10595
11065
  y,
@@ -10605,7 +11075,7 @@ function paintHeaderCanvas(opts, widths, rows, metrics) {
10605
11075
  }
10606
11076
  });
10607
11077
  }
10608
- function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics) {
11078
+ function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics, pen) {
10609
11079
  const cls = opts.rowClass?.(rowIndex) ?? "normal";
10610
11080
  let x = opts.rect.x;
10611
11081
  for (let colIndex = 0; colIndex < opts.columns.length; colIndex += 1) {
@@ -10615,8 +11085,8 @@ function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics) {
10615
11085
  const baseBg = canvasRowBackground(opts, rowIndex, cls);
10616
11086
  const conditional = resolveCanvasConditional(opts, column, colIndex, cell, baseBg);
10617
11087
  const bg = conditional.background ?? baseBg;
10618
- paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, bg, opts.tokens.color.border);
10619
- if (conditional.bar) paintDataBar(opts, x, y, width, metrics.rowHeight, conditional.bar);
11088
+ paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, bg, opts.tokens.color.border, false, pen);
11089
+ if (conditional.bar) paintDataBar(opts, x, y, width, metrics.rowHeight, conditional.bar, pen);
10620
11090
  const indent = opts.cellIndent?.(rowIndex, colIndex) ?? 0;
10621
11091
  const text = conditional.showValue === false ? "" : formatDisplayValue(cell.value, column);
10622
11092
  const color = negativeTextColor(cell.value, column, conditional.color ?? opts.tokens.color.text);
@@ -10638,13 +11108,13 @@ function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics) {
10638
11108
  x += width;
10639
11109
  }
10640
11110
  }
10641
- function paintFooterCanvas(opts, widths, y, metrics) {
11111
+ function paintFooterCanvas(opts, widths, y, metrics, pen) {
10642
11112
  let x = opts.rect.x;
10643
11113
  for (let colIndex = 0; colIndex < opts.columns.length; colIndex += 1) {
10644
11114
  const column = opts.columns[colIndex];
10645
11115
  const width = widths[colIndex] ?? 0;
10646
11116
  const cell = opts.footerRow?.cells[colIndex] ?? { value: null};
10647
- paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, opts.tokens.color.surface, opts.tokens.color.border, true);
11117
+ paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, opts.tokens.color.surface, opts.tokens.color.border, true, pen);
10648
11118
  paintCellText(opts, {
10649
11119
  x,
10650
11120
  y,
@@ -10673,10 +11143,25 @@ function paintClippedCueCanvas(opts, y, metrics, remaining) {
10673
11143
  paddingX: metrics.paddingX
10674
11144
  });
10675
11145
  }
10676
- function paintCellBox(ctx, x, y, width, height, background, border, topBorder = false) {
11146
+ function paintCellBox(ctx, x, y, width, height, background, border, topBorder = false, pen) {
10677
11147
  ctx.save();
10678
11148
  ctx.fillStyle = background;
10679
11149
  ctx.fillRect(x, y, width, height);
11150
+ if (pen) {
11151
+ pen.polyline(
11152
+ [
11153
+ { x: x + width, y },
11154
+ { x: x + width, y: y + height },
11155
+ { x, y: y + height }
11156
+ ],
11157
+ { stroke: border, strokeWidth: 1 }
11158
+ );
11159
+ if (topBorder) {
11160
+ pen.polyline([{ x, y }, { x: x + width, y }], { stroke: border, strokeWidth: 1 });
11161
+ }
11162
+ ctx.restore();
11163
+ return;
11164
+ }
10680
11165
  ctx.strokeStyle = border;
10681
11166
  ctx.lineWidth = 1;
10682
11167
  ctx.beginPath();
@@ -10787,9 +11272,18 @@ function resolveCanvasConditional(opts, column, colIndex, cell, rowBg) {
10787
11272
  const color = alphaColor(positive ? cf.color ?? opts.tokens.color.accent : cf.negativeColor ?? opts.tokens.color.negative, 0.85);
10788
11273
  return { background: rowBg, showValue: cf.showValue !== false, bar: { leftRatio, widthRatio, color } };
10789
11274
  }
10790
- function paintDataBar(opts, x, y, width, height, bar) {
11275
+ function paintDataBar(opts, x, y, width, height, bar, pen) {
10791
11276
  const left = x + 4 + bar.leftRatio * Math.max(0, width - 8);
10792
11277
  const barW = bar.widthRatio * Math.max(0, width - 8);
11278
+ if (pen) {
11279
+ pen.rect(left, y + 5, barW, Math.max(0, height - 10), {
11280
+ stroke: bar.color,
11281
+ fill: bar.color,
11282
+ fillAlpha: 0.9,
11283
+ strokeAlpha: 0.9
11284
+ });
11285
+ return;
11286
+ }
10793
11287
  opts.ctx.save();
10794
11288
  opts.ctx.globalAlpha *= 0.9;
10795
11289
  opts.ctx.beginPath();
@@ -10798,6 +11292,72 @@ function paintDataBar(opts, x, y, width, height, bar) {
10798
11292
  opts.ctx.fill();
10799
11293
  opts.ctx.restore();
10800
11294
  }
11295
+ function createSketchGridCanvas(rect) {
11296
+ const dpr = getDevicePixelRatio();
11297
+ const w = Math.max(0, rect.width);
11298
+ const h = Math.max(0, rect.height);
11299
+ const canvas = document.createElement("canvas");
11300
+ canvas.width = Math.max(1, Math.round(w * dpr));
11301
+ canvas.height = Math.max(1, Math.round(h * dpr));
11302
+ setStyles(canvas, {
11303
+ position: "absolute",
11304
+ left: `${rect.x}px`,
11305
+ top: `${rect.y}px`,
11306
+ width: `${w}px`,
11307
+ height: `${h}px`,
11308
+ pointerEvents: "none"
11309
+ });
11310
+ return { canvas, ctx: canvas.getContext("2d"), dpr };
11311
+ }
11312
+ function paintSketchGridOverlay(ctx, o) {
11313
+ const w = Math.max(0, o.rect.width);
11314
+ const h = Math.max(0, o.rect.height);
11315
+ ctx.save();
11316
+ ctx.setTransform(o.dpr, 0, 0, o.dpr, 0, 0);
11317
+ ctx.clearRect(0, 0, w, h);
11318
+ const metrics = densityMetrics(o.density);
11319
+ const headerHeight = o.headerRowCount * metrics.headerHeight;
11320
+ const footerHeight = o.hasFooter ? metrics.rowHeight : 0;
11321
+ const bodyTop = headerHeight;
11322
+ const bodyBottom = Math.max(bodyTop, h - footerHeight);
11323
+ const border = o.tokens.color.border;
11324
+ const base = o.sketch.seed >>> 0;
11325
+ const stroke = (x1, y1, x2, y2, seed) => {
11326
+ const pen = new RoughPen(ctx, { ...o.sketch, seed: seed >>> 0 });
11327
+ pen.polyline(
11328
+ [
11329
+ { x: x1, y: y1 },
11330
+ { x: x2, y: y2 }
11331
+ ],
11332
+ { stroke: border, strokeWidth: 1 }
11333
+ );
11334
+ };
11335
+ let cursor = 0;
11336
+ for (let i = 0; i < o.columns.length - 1; i += 1) {
11337
+ cursor += o.widths[i] ?? 0;
11338
+ const pinned = i < (o.rowHeaderSpan ?? 0);
11339
+ const x = pinned ? cursor : cursor - o.scrollLeft;
11340
+ if (x <= 0 || x >= w) continue;
11341
+ stroke(x, 0, x, h, base ^ Math.imul(i + 1, 2246822519));
11342
+ }
11343
+ ctx.save();
11344
+ ctx.beginPath();
11345
+ ctx.rect(0, bodyTop, w, Math.max(0, bodyBottom - bodyTop));
11346
+ ctx.clip();
11347
+ const firstRow = Math.max(0, Math.floor(o.scrollTop / metrics.rowHeight) - 1);
11348
+ for (let r = firstRow; r < o.rowCount; r += 1) {
11349
+ const y = bodyTop + (r + 1) * metrics.rowHeight - o.scrollTop;
11350
+ if (y <= bodyTop) continue;
11351
+ if (y >= bodyBottom) break;
11352
+ stroke(0, y, w, y, base ^ Math.imul(r + 1, 3266489917));
11353
+ }
11354
+ ctx.restore();
11355
+ if (headerHeight > 0 && headerHeight < h) stroke(0, headerHeight, w, headerHeight, (base ^ 2654435769) >>> 0);
11356
+ if (footerHeight > 0 && bodyBottom > bodyTop) stroke(0, bodyBottom, w, bodyBottom, (base ^ 2246822507) >>> 0);
11357
+ const framePen = new RoughPen(ctx, { ...o.sketch, seed: base });
11358
+ framePen.rect(1.5, 1.5, Math.max(0, w - 3), Math.max(0, h - 3), { stroke: border, strokeWidth: 1 });
11359
+ ctx.restore();
11360
+ }
10801
11361
  function canvasRowBackground(opts, rowIndex, cls) {
10802
11362
  if (cls !== "normal") return opts.tokens.color.surface;
10803
11363
  if (opts.striped === true && rowIndex % 2 === 1) return subtleStripe(opts.tokens);
@@ -10900,7 +11460,7 @@ function drawTableCanvas(surface, spec, tokens, size) {
10900
11460
  headerRows: buildTableHeaderRows(columns),
10901
11461
  footerRow: buildTotalsRow(sortedRows, columns, read, spec),
10902
11462
  density: spec.density,
10903
- sketch: resolveSketch(spec) != null
11463
+ sketch: resolveSketch(spec)
10904
11464
  });
10905
11465
  }
10906
11466
  function renderTable(surface, spec, tokens, size) {
@@ -10908,7 +11468,7 @@ function renderTable(surface, spec, tokens, size) {
10908
11468
  const data = spec.data ?? [];
10909
11469
  const columns = resolveColumns(data, spec);
10910
11470
  const read = columns.map((column) => accessor(column.key));
10911
- const sketch = resolveSketch(spec) != null;
11471
+ const sketch = resolveSketch(spec);
10912
11472
  let sortState = initialSortState(spec, columns);
10913
11473
  let sortedRows = sortRows(data, columns, read, sortState);
10914
11474
  const domains = columns.map(
@@ -10919,6 +11479,9 @@ function renderTable(surface, spec, tokens, size) {
10919
11479
  );
10920
11480
  const host = document.createElement("div");
10921
11481
  surface.overlay.appendChild(host);
11482
+ const headerRowCount = buildTableHeaderRows(columns)?.length ?? 1;
11483
+ const overlay = sketch ? createSketchGridCanvas(rect) : null;
11484
+ if (overlay) surface.overlay.appendChild(overlay.canvas);
10922
11485
  let frame = 0;
10923
11486
  const renderWindow = () => {
10924
11487
  const scrollTop = host.scrollTop || 0;
@@ -10926,7 +11489,8 @@ function renderTable(surface, spec, tokens, size) {
10926
11489
  const start = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_ROWS);
10927
11490
  const visible = Math.ceil(Math.max(rect.height, host.clientHeight || rect.height) / rowHeight) + BUFFER_ROWS * 2;
10928
11491
  const end = Math.min(sortedRows.length, start + visible);
10929
- buildTable({
11492
+ const footerRow = buildTotalsRow(sortedRows, columns, read, spec);
11493
+ const buildOpts = {
10930
11494
  container: host,
10931
11495
  tokens,
10932
11496
  rect,
@@ -10947,11 +11511,28 @@ function renderTable(surface, spec, tokens, size) {
10947
11511
  sortState: sortState ?? void 0,
10948
11512
  conditionalDomains: domains,
10949
11513
  headerRows: buildTableHeaderRows(columns),
10950
- footerRow: buildTotalsRow(sortedRows, columns, read, spec),
11514
+ footerRow,
10951
11515
  density: spec.density,
10952
11516
  visibleRange: { start, end, rowHeight },
10953
11517
  sketch
10954
- });
11518
+ };
11519
+ buildTable(buildOpts);
11520
+ if (sketch && overlay?.ctx) {
11521
+ paintSketchGridOverlay(overlay.ctx, {
11522
+ sketch,
11523
+ tokens,
11524
+ rect,
11525
+ dpr: overlay.dpr,
11526
+ columns,
11527
+ widths: buildOpts.widths ?? [],
11528
+ rowCount: sortedRows.length,
11529
+ headerRowCount,
11530
+ hasFooter: Boolean(footerRow),
11531
+ density: spec.density,
11532
+ scrollLeft: host.scrollLeft || 0,
11533
+ scrollTop
11534
+ });
11535
+ }
10955
11536
  };
10956
11537
  host.addEventListener("scroll", () => {
10957
11538
  if (frame !== 0) return;
@@ -11108,15 +11689,16 @@ function drawMatrixCanvas(surface, spec, tokens, size) {
11108
11689
  conditionalDomains: prepared.bodyDomains,
11109
11690
  headerRows: prepared.headerRows,
11110
11691
  density: spec.density,
11111
- sketch: resolveSketch(spec) != null
11692
+ sketch: resolveSketch(spec)
11112
11693
  });
11113
11694
  }
11114
11695
  function renderMatrix(surface, spec, tokens, size) {
11115
11696
  const rect = drawTitleBlock(surface, tokens, size, spec.title);
11116
11697
  const prepared = prepareMatrix(spec);
11698
+ const sketch = resolveSketch(spec);
11117
11699
  const host = document.createElement("div");
11118
11700
  surface.overlay.appendChild(host);
11119
- buildTable({
11701
+ const buildOpts = {
11120
11702
  container: host,
11121
11703
  tokens,
11122
11704
  rect,
@@ -11143,8 +11725,45 @@ function renderMatrix(surface, spec, tokens, size) {
11143
11725
  conditionalDomains: prepared.bodyDomains,
11144
11726
  headerRows: prepared.headerRows,
11145
11727
  density: spec.density,
11146
- sketch: resolveSketch(spec) != null
11147
- });
11728
+ sketch
11729
+ };
11730
+ buildTable(buildOpts);
11731
+ if (sketch) {
11732
+ const overlay = createSketchGridCanvas(rect);
11733
+ if (overlay.ctx) {
11734
+ surface.overlay.appendChild(overlay.canvas);
11735
+ const ctx = overlay.ctx;
11736
+ const draw = () => {
11737
+ paintSketchGridOverlay(ctx, {
11738
+ sketch,
11739
+ tokens,
11740
+ rect,
11741
+ dpr: overlay.dpr,
11742
+ columns: prepared.columns,
11743
+ widths: buildOpts.widths ?? [],
11744
+ rowCount: prepared.rows.length,
11745
+ headerRowCount: prepared.headerRows.length,
11746
+ hasFooter: false,
11747
+ rowHeaderSpan: 1,
11748
+ density: spec.density,
11749
+ scrollLeft: host.scrollLeft || 0,
11750
+ scrollTop: host.scrollTop || 0
11751
+ });
11752
+ };
11753
+ draw();
11754
+ let frame = 0;
11755
+ host.addEventListener("scroll", () => {
11756
+ if (frame !== 0) return;
11757
+ frame = typeof requestAnimationFrame === "function" ? requestAnimationFrame(() => {
11758
+ frame = 0;
11759
+ draw();
11760
+ }) : window.setTimeout(() => {
11761
+ frame = 0;
11762
+ draw();
11763
+ }, 16);
11764
+ });
11765
+ }
11766
+ }
11148
11767
  }
11149
11768
  function prepareMatrix(spec) {
11150
11769
  const data = spec.data ?? [];
@@ -13721,11 +14340,6 @@ function drawTreemap(surface, spec, tokens, size, context) {
13721
14340
  function clamp8(value, min, max) {
13722
14341
  return Math.max(min, Math.min(max, value));
13723
14342
  }
13724
- function resolveValue2(ref, data) {
13725
- if (ref == null) return null;
13726
- if (typeof ref === "number") return Number.isFinite(ref) ? ref : null;
13727
- return aggregateValues((data ?? []).map(accessor(ref.field)), ref.aggregate ?? "sum");
13728
- }
13729
14343
  function valueField2(ref) {
13730
14344
  return typeof ref === "number" ? null : ref.field;
13731
14345
  }
@@ -13749,7 +14363,7 @@ function arcPoints(cx, cy, radius, startAngle, endAngle) {
13749
14363
  }
13750
14364
  return points;
13751
14365
  }
13752
- function strokePoints(ctx, pen, points, color, width) {
14366
+ function strokePoints(ctx, pen, points, color, width, cap2 = "butt") {
13753
14367
  if (points.length < 2) return;
13754
14368
  if (pen) {
13755
14369
  pen.polyline(points, { stroke: color, strokeWidth: width });
@@ -13760,29 +14374,12 @@ function strokePoints(ctx, pen, points, color, width) {
13760
14374
  ctx.moveTo(points[0].x, points[0].y);
13761
14375
  for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
13762
14376
  ctx.lineWidth = width;
13763
- ctx.lineCap = "round";
14377
+ ctx.lineCap = cap2;
13764
14378
  ctx.lineJoin = "round";
13765
14379
  ctx.strokeStyle = color;
13766
14380
  ctx.stroke();
13767
14381
  ctx.restore();
13768
14382
  }
13769
- function drawCircle(ctx, pen, cx, cy, radius, fill, stroke) {
13770
- if (pen) {
13771
- pen.circle(cx, cy, radius, { fill, stroke: stroke ?? fill, strokeWidth: 1 });
13772
- return;
13773
- }
13774
- ctx.save();
13775
- ctx.beginPath();
13776
- ctx.arc(cx, cy, radius, 0, Math.PI * 2);
13777
- ctx.fillStyle = fill;
13778
- ctx.fill();
13779
- if (stroke) {
13780
- ctx.strokeStyle = stroke;
13781
- ctx.lineWidth = 1;
13782
- ctx.stroke();
13783
- }
13784
- ctx.restore();
13785
- }
13786
14383
  function drawCenteredMessage(surface, tokens, rect, text) {
13787
14384
  addOverlayText(surface, tokens, {
13788
14385
  left: rect.x,
@@ -13794,86 +14391,158 @@ function drawCenteredMessage(surface, tokens, rect, text) {
13794
14391
  align: "center"
13795
14392
  });
13796
14393
  }
14394
+ var LEGEND_SWATCH3 = 11;
14395
+ function measureLegendWidth(entries, font) {
14396
+ let maxLabel = 0;
14397
+ for (const e of entries) maxLabel = Math.max(maxLabel, measureText(e.label, font).width);
14398
+ return Math.ceil(LEGEND_SWATCH3 + 6 + maxLabel + 4);
14399
+ }
14400
+ function drawGaugeLegend(surface, tokens, area2, entries) {
14401
+ if (entries.length === 0) return;
14402
+ const labelSize = tokens.font.size.small;
14403
+ const rowH = Math.round(labelSize * 1.7);
14404
+ const font = fontString(labelSize, tokens.font.family, tokens.font.weight.normal);
14405
+ const colW = measureLegendWidth(entries, font);
14406
+ const x = area2.x + area2.width - colW;
14407
+ let y = area2.y;
14408
+ if (surface.headless) {
14409
+ const ctx = surface.marks.ctx;
14410
+ for (const e of entries) {
14411
+ const midY = y + rowH / 2;
14412
+ paintLegendSwatch(ctx, x, midY, "square", e.color, LEGEND_SWATCH3);
14413
+ paintCanvasText(ctx, {
14414
+ x: x + LEGEND_SWATCH3 + 6,
14415
+ y: midY,
14416
+ text: e.label,
14417
+ font,
14418
+ color: tokens.color.text,
14419
+ size: labelSize,
14420
+ baseline: "middle"
14421
+ });
14422
+ y += rowH;
14423
+ }
14424
+ return;
14425
+ }
14426
+ for (const e of entries) {
14427
+ const row = document.createElement("div");
14428
+ row.style.position = "absolute";
14429
+ row.style.left = `${x}px`;
14430
+ row.style.top = `${y}px`;
14431
+ row.style.display = "flex";
14432
+ row.style.alignItems = "center";
14433
+ row.style.gap = "6px";
14434
+ row.style.pointerEvents = "none";
14435
+ const swatch = document.createElement("span");
14436
+ swatch.style.flex = "0 0 auto";
14437
+ swatch.style.width = `${LEGEND_SWATCH3}px`;
14438
+ swatch.style.height = `${LEGEND_SWATCH3}px`;
14439
+ swatch.style.borderRadius = "2px";
14440
+ swatch.style.background = e.color;
14441
+ const label = document.createElement("span");
14442
+ label.textContent = e.label;
14443
+ label.style.font = font;
14444
+ label.style.color = tokens.color.text;
14445
+ label.style.whiteSpace = "nowrap";
14446
+ row.appendChild(swatch);
14447
+ row.appendChild(label);
14448
+ surface.overlay.appendChild(row);
14449
+ y += rowH;
14450
+ }
14451
+ }
14452
+ function bandFillColor(gauge, value, tokens, accent) {
14453
+ const bands = gauge.bands;
14454
+ if (!bands?.length) return accent;
14455
+ const palette = tokens.color.palette;
14456
+ for (let i = 0; i < bands.length; i++) {
14457
+ if (value <= bands[i].to) return bands[i].color ?? palette[i % palette.length] ?? accent;
14458
+ }
14459
+ const last = bands.length - 1;
14460
+ return bands[last].color ?? palette[last % palette.length] ?? accent;
14461
+ }
13797
14462
  function drawGauge(surface, spec, tokens, size, context) {
13798
14463
  const gauge = spec;
13799
14464
  const ctx = surface.marks.ctx;
13800
14465
  const content = drawTitleBlock(surface, tokens, size, gauge.title);
13801
14466
  const data = gauge.data ?? [];
13802
- const value = resolveValue2(gauge.value, data);
13803
- const target = resolveValue2(gauge.target, data);
14467
+ const value = resolveValueRef(gauge.value, data);
14468
+ const target = resolveValueRef(gauge.target, data);
14469
+ const compare2 = resolveValueRef(gauge.compare, data);
13804
14470
  const min = gauge.min ?? 0;
13805
14471
  const max = gauge.max;
13806
14472
  if (value == null || !Number.isFinite(min) || !Number.isFinite(max) || max <= min || content.width <= 0 || content.height <= 0) {
13807
14473
  drawCenteredMessage(surface, tokens, content, "No value");
13808
14474
  return;
13809
14475
  }
14476
+ const sketch = resolveSketch(spec);
14477
+ const pen = sketch ? new RoughPen(ctx, sketch) : null;
14478
+ const accent = tokens.color.palette[0] ?? tokens.color.accent;
14479
+ const fill = bandFillColor(gauge, value, tokens, accent);
14480
+ const track = tokens.dark ? withAlpha3(tokens.color.textMuted, 0.3) : tokens.color.border;
14481
+ const compareColor = tokens.color.textMuted;
14482
+ const markColor = tokens.dark ? withAlpha3("#ffffff", 0.92) : withAlpha3(tokens.color.text, 0.82);
14483
+ const targetMark = target != null && target >= min && target <= max ? target : null;
13810
14484
  const valueText = formatValue(value, gauge.format);
13811
14485
  const caption = gauge.label ?? titleText(gauge.title) ?? valueField2(gauge.value) ?? "Value";
13812
- const valueSize = clamp8(Math.min(content.width / Math.max(6, valueText.length * 0.55), tokens.font.size.title * 2.4), 24, 42);
13813
14486
  const captionSize = tokens.font.size.small;
13814
- const readoutReserve = valueSize + captionSize + 24;
13815
- const radius = Math.max(18, Math.min((content.width - 48) / 2, content.height - readoutReserve - 18));
13816
- const cx = content.x + content.width / 2;
13817
- const cy = content.y + Math.max(radius + 8, (content.height - readoutReserve + radius) / 2);
13818
- const trackWidth = clamp8(radius * 0.17, 10, 22);
13819
- const accent = tokens.color.palette[0] ?? tokens.color.accent;
13820
- const track = withAlpha3(tokens.color.border || tokens.color.textMuted, tokens.dark ? 0.75 : 0.9);
13821
- const needleColor = tokens.color.text;
14487
+ const captionReserve = captionSize + 16;
14488
+ const showLegend = gauge.legend ?? compare2 != null;
14489
+ const legendEntries = [];
14490
+ if (showLegend) {
14491
+ legendEntries.push({ label: gauge.valueLabel ?? "Actual", color: fill, symbol: "square" });
14492
+ if (compare2 != null) {
14493
+ legendEntries.push({ label: gauge.compareLabel ?? "Prior period", color: compareColor, symbol: "square" });
14494
+ }
14495
+ legendEntries.push({ label: gauge.trackLabel ?? "Target", color: targetMark != null ? markColor : track, symbol: "square" });
14496
+ }
14497
+ const legendFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
14498
+ const legendW = legendEntries.length ? measureLegendWidth(legendEntries, legendFont) + 12 : 0;
14499
+ const dialW = Math.max(40, content.width - legendW);
14500
+ const radius = clamp8(
14501
+ Math.min((dialW - 16) / 2, content.height - captionReserve - 16),
14502
+ 18,
14503
+ Math.min(dialW, content.height * 2)
14504
+ );
14505
+ const trackWidth = clamp8(radius * 0.17, 14, 54);
14506
+ const cx = content.x + dialW / 2;
14507
+ const blockH = radius + trackWidth / 2 + captionReserve;
14508
+ const topMargin = Math.max(0, (content.height - blockH) / 2);
14509
+ const cy = content.y + topMargin + radius + trackWidth / 2;
13822
14510
  const toAngle = (v) => Math.PI - clamp8((v - min) / (max - min), 0, 1) * Math.PI;
13823
- const valueAngle = toAngle(value);
13824
- const sketch = resolveSketch(spec);
13825
- const pen = sketch ? new RoughPen(ctx, sketch) : null;
14511
+ const sweepT = context?.sweepProgress ?? 1;
14512
+ const from = context?.valueFrom ?? min;
14513
+ const displayValue = from + (value - from) * sweepT;
14514
+ const shownText = formatValue(displayValue, gauge.format);
14515
+ const valueAngle = toAngle(displayValue);
13826
14516
  strokePoints(ctx, pen, arcPoints(cx, cy, radius, Math.PI, 0), track, trackWidth);
13827
- if (gauge.bands?.length) {
13828
- let prev = min;
13829
- gauge.bands.forEach((band, i) => {
13830
- const to = clamp8(band.to, min, max);
13831
- if (to <= prev) return;
13832
- const color = band.color ?? tokens.color.palette[i % tokens.color.palette.length] ?? accent;
13833
- strokePoints(ctx, pen, arcPoints(cx, cy, radius, toAngle(prev), toAngle(to)), color, trackWidth);
13834
- prev = to;
13835
- });
13836
- if (prev < max) {
13837
- strokePoints(ctx, pen, arcPoints(cx, cy, radius, toAngle(prev), 0), withAlpha3(tokens.color.border, 0.6), trackWidth);
13838
- }
13839
- } else {
13840
- strokePoints(ctx, pen, arcPoints(cx, cy, radius, Math.PI, valueAngle), accent, trackWidth);
13841
- }
13842
- const needleEnd = pointOnArc(cx, cy, radius - trackWidth * 0.45, valueAngle);
13843
- strokePoints(ctx, pen, [{ x: cx, y: cy }, needleEnd], needleColor, Math.max(1.5, tokens.stroke.thick));
13844
- drawCircle(ctx, pen, cx, cy, Math.max(3, trackWidth * 0.22), tokens.color.surface, needleColor);
13845
- if (target != null) {
13846
- const a = toAngle(target);
13847
- const outer = pointOnArc(cx, cy, radius + trackWidth * 0.55, a);
13848
- const inner = pointOnArc(cx, cy, radius - trackWidth * 0.7, a);
13849
- strokePoints(ctx, pen, [outer, inner], tokens.color.negative ?? tokens.color.text, Math.max(2, tokens.stroke.thick));
13850
- }
13851
- const leftTick = pointOnArc(cx, cy, radius, Math.PI);
13852
- const rightTick = pointOnArc(cx, cy, radius, 0);
13853
- addOverlayText(surface, tokens, {
13854
- left: leftTick.x - 32,
13855
- top: leftTick.y + 6,
13856
- width: 64,
13857
- text: formatValue(min, gauge.format),
13858
- color: tokens.color.textMuted,
13859
- size: tokens.font.size.tiny,
13860
- align: "center"
13861
- });
13862
- addOverlayText(surface, tokens, {
13863
- left: rightTick.x - 32,
13864
- top: rightTick.y + 6,
13865
- width: 64,
13866
- text: formatValue(max, gauge.format),
13867
- color: tokens.color.textMuted,
13868
- size: tokens.font.size.tiny,
13869
- align: "center"
13870
- });
13871
- const readoutTop = Math.min(content.y + content.height - valueSize - captionSize - 8, cy + 14);
14517
+ if (valueAngle < Math.PI) {
14518
+ strokePoints(ctx, pen, arcPoints(cx, cy, radius, Math.PI, valueAngle), fill, trackWidth);
14519
+ }
14520
+ if (compare2 != null) {
14521
+ const innerWidth = clamp8(trackWidth * 0.42, 6, 22);
14522
+ const innerRadius = Math.max(radius * 0.35, radius - trackWidth / 2 - 2 - innerWidth / 2);
14523
+ const compareFrom = context?.compareFrom ?? min;
14524
+ const displayCompare = compareFrom + (compare2 - compareFrom) * sweepT;
14525
+ const compareAngle = toAngle(displayCompare);
14526
+ if (compareAngle < Math.PI) {
14527
+ strokePoints(ctx, pen, arcPoints(cx, cy, innerRadius, Math.PI, compareAngle), compareColor, innerWidth);
14528
+ }
14529
+ }
14530
+ if (targetMark != null) {
14531
+ const a = toAngle(targetMark);
14532
+ const overhang = clamp8(trackWidth * 0.3, 4, 10);
14533
+ const outer = pointOnArc(cx, cy, radius + trackWidth / 2 + overhang, a);
14534
+ const inner = pointOnArc(cx, cy, radius - trackWidth / 2 - overhang, a);
14535
+ strokePoints(ctx, pen, [outer, inner], markColor, 3, "round");
14536
+ }
14537
+ if (legendEntries.length) {
14538
+ drawGaugeLegend(surface, tokens, { x: content.x, y: content.y, width: content.width, height: content.height }, legendEntries);
14539
+ }
14540
+ const valueSize = clamp8(radius * 0.5, 22, 72);
13872
14541
  addOverlayText(surface, tokens, {
13873
14542
  left: content.x,
13874
- top: readoutTop,
13875
- width: content.width,
13876
- text: valueText,
14543
+ top: cy - radius * 0.32 - valueSize / 2,
14544
+ width: dialW,
14545
+ text: shownText,
13877
14546
  color: tokens.color.text,
13878
14547
  size: valueSize,
13879
14548
  weight: tokens.font.weight.bold,
@@ -13881,20 +14550,20 @@ function drawGauge(surface, spec, tokens, size, context) {
13881
14550
  });
13882
14551
  addOverlayText(surface, tokens, {
13883
14552
  left: content.x,
13884
- top: readoutTop + valueSize + 4,
13885
- width: content.width,
14553
+ top: cy + 8,
14554
+ width: dialW,
13886
14555
  text: caption,
13887
14556
  color: tokens.color.textMuted,
13888
14557
  size: captionSize,
13889
14558
  align: "center"
13890
14559
  });
13891
14560
  const hitTest = (px, py) => {
13892
- const dx = px - cx;
13893
- const dy = py - cy;
13894
- const distance = Math.hypot(dx, dy);
13895
- const withinDial = distance <= radius + trackWidth * 2 && py >= cy - radius - trackWidth * 2 && py <= cy + readoutReserve;
14561
+ const withinDial = px >= cx - radius - trackWidth && px <= cx + radius + trackWidth && py >= cy - radius - trackWidth && py <= cy + captionReserve + 4;
13896
14562
  if (!withinDial) return null;
13897
- const rows = [{ label: "value", value: valueText }];
14563
+ const rows = [{ label: gauge.valueLabel ?? "value", value: valueText }];
14564
+ if (compare2 != null) {
14565
+ rows.push({ label: gauge.compareLabel ?? "prior period", value: formatValue(compare2, gauge.format), muted: true });
14566
+ }
13898
14567
  if (target != null) rows.push({ label: "target", value: formatValue(target, gauge.format), muted: true });
13899
14568
  return {
13900
14569
  key: "value",
@@ -13922,7 +14591,7 @@ function titleText2(title2) {
13922
14591
  function valueField3(ref) {
13923
14592
  return typeof ref === "number" ? null : ref.field;
13924
14593
  }
13925
- function resolveValue3(ref, data) {
14594
+ function resolveValue(ref, data) {
13926
14595
  if (ref == null) return null;
13927
14596
  if (typeof ref === "number") return Number.isFinite(ref) ? ref : null;
13928
14597
  return aggregateValues((data ?? []).map(accessor(ref.field)), ref.aggregate ?? "sum");
@@ -13954,8 +14623,8 @@ function drawBullet(surface, spec, tokens, size, context) {
13954
14623
  const ctx = surface.marks.ctx;
13955
14624
  const content = drawTitleBlock(surface, tokens, size, bullet.title);
13956
14625
  const data = bullet.data ?? [];
13957
- const value = resolveValue3(bullet.value, data);
13958
- const target = resolveValue3(bullet.target, data);
14626
+ const value = resolveValue(bullet.value, data);
14627
+ const target = resolveValue(bullet.target, data);
13959
14628
  const caption = bullet.label ?? titleText2(bullet.title) ?? valueField3(bullet.value) ?? "Value";
13960
14629
  const fmt = bullet.format;
13961
14630
  const finiteRanges = (bullet.ranges ?? []).filter((r) => Number.isFinite(r)).sort((a, b) => a - b);
@@ -14138,7 +14807,7 @@ function noData(surface, tokens, content) {
14138
14807
  }
14139
14808
  function normalizeDays(days) {
14140
14809
  if (Array.isArray(days)) {
14141
- return days.filter((d) => Number.isFinite(d.value)).map((d) => ({ isoDate: isoDay(d.date), date: startOfLocalDay(d.date), value: d.value }));
14810
+ return days.filter((d) => Number.isFinite(d.value)).map((d) => ({ isoDate: isoDay(d.date), date: startOfLocalDay(d.date), value: d.value, row: d.row }));
14142
14811
  }
14143
14812
  const out = [];
14144
14813
  const dayMap = days;
@@ -14160,19 +14829,20 @@ function buildCalendarGrid(days) {
14160
14829
  const start = addDays(minDay, -minDay.getDay());
14161
14830
  const end = addDays(maxDay, 6 - maxDay.getDay());
14162
14831
  const weeks = Math.floor(diffDays(end, start) / 7) + 1;
14163
- const byValue = new Map(values.map((d) => [d.isoDate, d.value]));
14832
+ const byValue = new Map(values.map((d) => [d.isoDate, { value: d.value, row: d.row }]));
14164
14833
  const cells = [];
14165
14834
  const byIsoDate = /* @__PURE__ */ new Map();
14166
14835
  for (let d = start; diffDays(end, d) >= 0; d = addDays(d, 1)) {
14167
14836
  const isoDate = isoDay(d);
14168
- const value = byValue.get(isoDate);
14837
+ const dayValue = byValue.get(isoDate);
14169
14838
  const cell = {
14170
14839
  isoDate,
14171
14840
  date: d,
14172
14841
  week: Math.floor(diffDays(d, start) / 7),
14173
14842
  weekday: d.getDay(),
14174
- value,
14175
- present: value !== void 0
14843
+ value: dayValue?.value,
14844
+ row: dayValue?.row,
14845
+ present: dayValue !== void 0
14176
14846
  };
14177
14847
  cells.push(cell);
14178
14848
  byIsoDate.set(isoDate, cell);
@@ -14191,9 +14861,9 @@ function collectDays(spec) {
14191
14861
  const isoDate = isoDay(day);
14192
14862
  const current = totals.get(isoDate);
14193
14863
  if (current) current.value += value;
14194
- else totals.set(isoDate, { date: day, value });
14864
+ else totals.set(isoDate, { date: day, value, row });
14195
14865
  }
14196
- return [...totals.entries()].map(([isoDate, { date, value }]) => ({ isoDate, date, value }));
14866
+ return [...totals.entries()].map(([isoDate, { date, value, row }]) => ({ isoDate, date, value, row }));
14197
14867
  }
14198
14868
  function monthAnchors(grid) {
14199
14869
  const seen = /* @__PURE__ */ new Set();
@@ -14217,6 +14887,8 @@ function humanDate(date) {
14217
14887
  function drawCalendarHeatmap(surface, spec, tokens, size, context) {
14218
14888
  const cal = spec;
14219
14889
  const ctx = surface.marks.ctx;
14890
+ const emphasis = context?.emphasis ?? null;
14891
+ const dateField = cal.encoding.date.field;
14220
14892
  const content = drawTitleBlock(surface, tokens, size, cal.title);
14221
14893
  const days = collectDays(cal);
14222
14894
  const grid = buildCalendarGrid(days);
@@ -14256,6 +14928,7 @@ function drawCalendarHeatmap(surface, spec, tokens, size, context) {
14256
14928
  const y = gridRect.y + c.weekday * (cell + GAP2);
14257
14929
  const fill = c.present && c.value !== void 0 ? rgbaToCss(cscale.map(c.value)) : tokens.color.surface;
14258
14930
  laidOut.set(c.isoDate, { ...c, x, y, size: cell, fill });
14931
+ ctx.globalAlpha = c.present ? rowAlpha(emphasis, c.row ?? { [dateField]: c.isoDate }) : 1;
14259
14932
  if (pen) {
14260
14933
  pen.rect(x, y, cell, cell, {
14261
14934
  fill,
@@ -14269,6 +14942,7 @@ function drawCalendarHeatmap(surface, spec, tokens, size, context) {
14269
14942
  ctx.lineWidth = 1;
14270
14943
  ctx.strokeRect(x + 0.5, y + 0.5, Math.max(0, cell - 1), Math.max(0, cell - 1));
14271
14944
  }
14945
+ ctx.globalAlpha = 1;
14272
14946
  }
14273
14947
  ctx.restore();
14274
14948
  for (const m of monthAnchors(grid)) {
@@ -14546,13 +15220,15 @@ function buildWaterfallModel(spec, tokens, opts) {
14546
15220
  }
14547
15221
 
14548
15222
  // src/charts/waterfall.ts
14549
- function drawConnectors(surface, model) {
15223
+ function drawConnectors(surface, model, alphaOf) {
14550
15224
  if (model.connectors.length === 0) return;
14551
15225
  const ctx = surface.marks.ctx;
14552
15226
  const color = model.tokens.color.axis;
14553
15227
  if (model.sketch) {
14554
15228
  const pen = new RoughPen(ctx, model.sketch);
14555
- for (const c of model.connectors) {
15229
+ for (let i = 0; i < model.connectors.length; i += 1) {
15230
+ const c = model.connectors[i];
15231
+ ctx.globalAlpha = alphaOf(model.bars[i]);
14556
15232
  pen.polyline(
14557
15233
  [
14558
15234
  { x: c.x1, y: c.y },
@@ -14560,6 +15236,7 @@ function drawConnectors(surface, model) {
14560
15236
  ],
14561
15237
  { stroke: color }
14562
15238
  );
15239
+ ctx.globalAlpha = 1;
14563
15240
  }
14564
15241
  return;
14565
15242
  }
@@ -14567,23 +15244,28 @@ function drawConnectors(surface, model) {
14567
15244
  ctx.strokeStyle = color;
14568
15245
  ctx.lineWidth = 1;
14569
15246
  ctx.setLineDash([2, 2]);
14570
- ctx.beginPath();
14571
- for (const c of model.connectors) {
15247
+ for (let i = 0; i < model.connectors.length; i += 1) {
15248
+ const c = model.connectors[i];
14572
15249
  const y = Math.round(c.y) + 0.5;
15250
+ ctx.globalAlpha = alphaOf(model.bars[i]);
15251
+ ctx.beginPath();
14573
15252
  ctx.moveTo(c.x1, y);
14574
15253
  ctx.lineTo(c.x2, y);
15254
+ ctx.stroke();
15255
+ ctx.globalAlpha = 1;
14575
15256
  }
14576
- ctx.stroke();
14577
15257
  ctx.restore();
14578
15258
  }
14579
- function drawBars2(surface, model) {
15259
+ function drawBars2(surface, model, alphaOf) {
14580
15260
  const ctx = surface.marks.ctx;
14581
15261
  const pen = model.sketch ? new RoughPen(ctx, model.sketch) : null;
14582
15262
  for (const bar of model.bars) {
14583
15263
  const { rect } = bar;
14584
15264
  if (rect.width <= 0 || rect.height <= 0) continue;
15265
+ ctx.globalAlpha = alphaOf(bar);
14585
15266
  if (pen) {
14586
15267
  pen.rect(rect.x, rect.y, rect.width, rect.height, { stroke: bar.color, fill: bar.color });
15268
+ ctx.globalAlpha = 1;
14587
15269
  continue;
14588
15270
  }
14589
15271
  const r = Math.min(model.cornerRadius, rect.width / 2, rect.height / 2);
@@ -14591,6 +15273,7 @@ function drawBars2(surface, model) {
14591
15273
  roundedRect(ctx, rect.x, rect.y, rect.width, rect.height, [r, r, r, r]);
14592
15274
  ctx.fillStyle = bar.color;
14593
15275
  ctx.fill();
15276
+ ctx.globalAlpha = 1;
14594
15277
  }
14595
15278
  }
14596
15279
  function barLabelText(bar, format) {
@@ -14620,17 +15303,19 @@ function drawLabels(surface, model) {
14620
15303
  });
14621
15304
  }
14622
15305
  }
14623
- function drawWaterfall(surface, spec, tokens, size, _context) {
15306
+ function drawWaterfall(surface, spec, tokens, size, context) {
14624
15307
  const waterfall = spec;
14625
15308
  const model = buildWaterfallModel(waterfall, tokens, size);
15309
+ const emphasis = context?.emphasis ?? null;
15310
+ const stageField = waterfall.encoding.stage.field;
15311
+ const alphaOf = (bar) => bar.stageValue === void 0 ? 1 : rowAlpha(emphasis, { [stageField]: bar.stageValue });
14626
15312
  drawAxesUnderlay(surface, model.base);
14627
- drawConnectors(surface, model);
14628
- drawBars2(surface, model);
15313
+ drawConnectors(surface, model, alphaOf);
15314
+ drawBars2(surface, model, alphaOf);
14629
15315
  drawLabels(surface, model);
14630
15316
  drawOverlay(surface, model.base);
14631
15317
  if (model.bars.length === 0) return;
14632
15318
  const { plot, format } = model;
14633
- const stageField = waterfall.encoding.stage.field;
14634
15319
  const valueLabel2 = waterfall.encoding.value.title ?? waterfall.encoding.value.field;
14635
15320
  const fmtNum = (v) => format ? formatValue(v, format) : formatNumber2(v, ",");
14636
15321
  const hitBar = (px, py) => {
@@ -14813,7 +15498,7 @@ var LABEL_WIDTH = 68;
14813
15498
  function fmtValue(v, format) {
14814
15499
  return format ? formatValue(v, format) : formatNumber2(v, ",");
14815
15500
  }
14816
- function drawLines(surface, model) {
15501
+ function drawLines(surface, model, alphaOf) {
14817
15502
  const ctx = surface.marks.ctx;
14818
15503
  const pen = model.sketch ? new RoughPen(ctx, model.sketch) : null;
14819
15504
  ctx.save();
@@ -14823,6 +15508,7 @@ function drawLines(surface, model) {
14823
15508
  for (const series of model.series) {
14824
15509
  const points = series.points;
14825
15510
  if (points.length === 0) continue;
15511
+ ctx.globalAlpha = alphaOf(series);
14826
15512
  if (pen && points.length >= 2) {
14827
15513
  pen.polyline(points, { stroke: series.color });
14828
15514
  } else if (points.length >= 2) {
@@ -14848,16 +15534,18 @@ function drawLines(surface, model) {
14848
15534
  ctx.stroke();
14849
15535
  }
14850
15536
  }
15537
+ ctx.globalAlpha = 1;
14851
15538
  }
14852
15539
  ctx.restore();
14853
15540
  }
14854
- function drawEndLabels(surface, model) {
15541
+ function drawEndLabels(surface, model, alphaOf) {
14855
15542
  if (!model.showLabels) return;
14856
15543
  const { tokens } = model;
14857
15544
  const labelSize = tokens.font.size.tiny;
14858
15545
  const valueSize = tokens.font.size.tiny;
14859
15546
  for (const series of model.series) {
14860
15547
  if (series.points.length === 0) continue;
15548
+ const opacity = alphaOf(series) === 1 ? 1 : 0.5;
14861
15549
  const first = series.points[0];
14862
15550
  const last = series.points[series.points.length - 1];
14863
15551
  addOverlayText(surface, tokens, {
@@ -14868,7 +15556,8 @@ function drawEndLabels(surface, model) {
14868
15556
  color: series.color,
14869
15557
  size: labelSize,
14870
15558
  weight: tokens.font.weight.bold,
14871
- align: "right"
15559
+ align: "right",
15560
+ opacity
14872
15561
  });
14873
15562
  addOverlayText(surface, tokens, {
14874
15563
  left: first.x - 8 - LABEL_WIDTH,
@@ -14877,7 +15566,8 @@ function drawEndLabels(surface, model) {
14877
15566
  text: fmtValue(first.value, model.format),
14878
15567
  color: tokens.color.textMuted,
14879
15568
  size: valueSize,
14880
- align: "right"
15569
+ align: "right",
15570
+ opacity
14881
15571
  });
14882
15572
  if (last !== first) {
14883
15573
  addOverlayText(surface, tokens, {
@@ -14888,7 +15578,8 @@ function drawEndLabels(surface, model) {
14888
15578
  color: series.color,
14889
15579
  size: valueSize,
14890
15580
  weight: tokens.font.weight.medium,
14891
- align: "left"
15581
+ align: "left",
15582
+ opacity
14892
15583
  });
14893
15584
  }
14894
15585
  }
@@ -14906,16 +15597,18 @@ function nearestPoint(model, px, py) {
14906
15597
  }
14907
15598
  return best ? { series: best.series, point: best.point } : null;
14908
15599
  }
14909
- function drawSlope(surface, spec, tokens, size, _context) {
15600
+ function drawSlope(surface, spec, tokens, size, context) {
14910
15601
  const slope = spec;
14911
15602
  const model = buildSlopeModel(slope, tokens, size);
15603
+ const emphasis = context?.emphasis ?? null;
15604
+ const seriesField = slope.encoding.series.field;
15605
+ const alphaOf = (series) => emphasis ? emphasis.match({ [seriesField]: series.value }) ? 1 : emphasis.dim : 1;
14912
15606
  drawAxesUnderlay(surface, model.base);
14913
- drawLines(surface, model);
15607
+ drawLines(surface, model, alphaOf);
14914
15608
  drawOverlay(surface, model.base);
14915
- drawEndLabels(surface, model);
15609
+ drawEndLabels(surface, model, alphaOf);
14916
15610
  if (model.series.length === 0) return;
14917
15611
  const { plot } = model;
14918
- const seriesField = slope.encoding.series.field;
14919
15612
  return {
14920
15613
  region: { x: plot.x, y: plot.y, width: plot.width, height: plot.height },
14921
15614
  hitTest: (px, py) => {
@@ -15108,10 +15801,12 @@ function layoutPlot(content, labelWidth, tokens) {
15108
15801
  }
15109
15802
  };
15110
15803
  }
15111
- function drawDumbbell(surface, spec, tokens, size, _context) {
15804
+ function drawDumbbell(surface, spec, tokens, size, context) {
15112
15805
  const dumbbell = spec;
15113
15806
  const model = buildDumbbellModel(dumbbell, tokens);
15114
15807
  const ctx = surface.marks.ctx;
15808
+ const emphasis = context?.emphasis ?? null;
15809
+ const categoryField = dumbbell.encoding.category.field;
15115
15810
  let content = drawTitleBlock(surface, tokens, size, dumbbell.title);
15116
15811
  const legendEntries = model.groups.map((group) => ({ label: group.label, color: group.color, symbol: "circle" }));
15117
15812
  content = drawCategoricalLegend(surface, tokens, content, legendEntries, "top");
@@ -15172,8 +15867,10 @@ function drawDumbbell(surface, spec, tokens, size, _context) {
15172
15867
  }).sort((a, b) => a.x - b.x);
15173
15868
  if (pixelDots.length === 0) continue;
15174
15869
  pixelRows.push({ ...row, y: cy, pixelDots });
15870
+ ctx.globalAlpha = rowAlpha(emphasis, { [categoryField]: row.catRawValue });
15175
15871
  drawConnector(ctx, pen, pixelDots[0].x, pixelDots[pixelDots.length - 1].x, cy, tokens);
15176
15872
  for (const dot of pixelDots) drawDot(ctx, pen, dot.x, cy, dot.color, tokens);
15873
+ ctx.globalAlpha = 1;
15177
15874
  addOverlayText(surface, tokens, {
15178
15875
  left: content.x,
15179
15876
  top: cy,
@@ -15201,7 +15898,6 @@ function drawDumbbell(surface, spec, tokens, size, _context) {
15201
15898
  }
15202
15899
  }
15203
15900
  }
15204
- const categoryField = dumbbell.encoding.category.field;
15205
15901
  const nearestDot = (px, py) => {
15206
15902
  let best = null;
15207
15903
  for (const row of pixelRows) {
@@ -16537,6 +17233,8 @@ var CANVAS_MARK_TYPES = /* @__PURE__ */ new Set([
16537
17233
  "sankey",
16538
17234
  "choropleth"
16539
17235
  ]);
17236
+ var COUNT_UP_TYPES = /* @__PURE__ */ new Set(["kpi"]);
17237
+ var SWEEP_TYPES = /* @__PURE__ */ new Set(["pie", "gauge"]);
16540
17238
  var EMPTY_DATA = [];
16541
17239
  function legendConfigFor(model) {
16542
17240
  const legend = model.spec.legend;
@@ -16632,6 +17330,9 @@ function render(target, spec, options) {
16632
17330
  let interaction;
16633
17331
  let entrance;
16634
17332
  let updateTransition;
17333
+ let lastValue = null;
17334
+ let lastCompare = null;
17335
+ let lastSparkline = null;
16635
17336
  let destroyed = false;
16636
17337
  let lastDrawnW = -1;
16637
17338
  let lastDrawnH = -1;
@@ -16659,7 +17360,7 @@ function render(target, spec, options) {
16659
17360
  }
16660
17361
  }
16661
17362
  const hostListeners = /* @__PURE__ */ new Set();
16662
- const draw = (animateEntrance) => {
17363
+ const draw = (animateEntrance, valueTweenFrom, compareTweenFrom, sparklineTweenFrom) => {
16663
17364
  entrance?.cancel();
16664
17365
  entrance = void 0;
16665
17366
  updateTransition?.cancel();
@@ -16689,6 +17390,11 @@ function render(target, spec, options) {
16689
17390
  let facetRpt;
16690
17391
  let legendSelect = null;
16691
17392
  const type = currentSpec.type;
17393
+ if (COUNT_UP_TYPES.has(type) || SWEEP_TYPES.has(type)) {
17394
+ lastValue = resolveValueRef(effectiveSpec.value, effectiveSpec.data ?? EMPTY_DATA);
17395
+ lastCompare = SWEEP_TYPES.has(type) ? resolveValueRef(effectiveSpec.compare, effectiveSpec.data ?? EMPTY_DATA) : null;
17396
+ lastSparkline = type === "kpi" ? kpiSparklineSeries(effectiveSpec, effectiveSpec.data ?? EMPTY_DATA) : null;
17397
+ }
16692
17398
  const facetLayout = isFaceted(effectiveSpec) ? buildFacetModels(effectiveSpec, tokens, size) : null;
16693
17399
  if (facetLayout) {
16694
17400
  drawFacet(surface, effectiveSpec, facetLayout, tokens);
@@ -16719,7 +17425,8 @@ function render(target, spec, options) {
16719
17425
  tokens,
16720
17426
  currentSpec.background,
16721
17427
  anim,
16722
- finishEntrance
17428
+ finishEntrance,
17429
+ type === "bar" ? "grow" : "wipe"
16723
17430
  );
16724
17431
  }
16725
17432
  } else {
@@ -16737,11 +17444,44 @@ function render(target, spec, options) {
16737
17444
  }
16738
17445
  };
16739
17446
  interactionModel = renderer(surface, effectiveSpec, tokens, size, ctx) ?? null;
17447
+ if (anim.enabled) {
17448
+ if (COUNT_UP_TYPES.has(type) || SWEEP_TYPES.has(type)) {
17449
+ const key = COUNT_UP_TYPES.has(type) ? "valueProgress" : "sweepProgress";
17450
+ entrance = runCustomProgress(anim, finishEntrance, (t) => {
17451
+ surface.overlay.replaceChildren();
17452
+ surface.marks.clear();
17453
+ paintBackground(surface, tokens, currentSpec.background);
17454
+ renderer(surface, effectiveSpec, tokens, size, { ...ctx, [key]: t });
17455
+ });
17456
+ } else {
17457
+ entrance = runFadeEntrance(surface, anim, finishEntrance);
17458
+ }
17459
+ } else if ((valueTweenFrom !== void 0 || sparklineTweenFrom !== void 0) && (COUNT_UP_TYPES.has(type) || SWEEP_TYPES.has(type))) {
17460
+ const tween = resolveUpdate(currentSpec.animation, {
17461
+ reducedMotion: prefersReducedMotion(),
17462
+ disabled: globalThis.__GRAPHEIN_DISABLE_ANIM === true
17463
+ });
17464
+ if (tween.enabled) {
17465
+ const key = COUNT_UP_TYPES.has(type) ? "valueProgress" : "sweepProgress";
17466
+ entrance = runCustomProgress(tween, finishEntrance, (t) => {
17467
+ surface.overlay.replaceChildren();
17468
+ surface.marks.clear();
17469
+ paintBackground(surface, tokens, currentSpec.background);
17470
+ renderer(surface, effectiveSpec, tokens, size, {
17471
+ ...ctx,
17472
+ valueFrom: valueTweenFrom,
17473
+ compareFrom: compareTweenFrom,
17474
+ sparklineFrom: sparklineTweenFrom,
17475
+ [key]: t
17476
+ });
17477
+ });
17478
+ }
17479
+ }
16740
17480
  } else {
16741
17481
  drawPlaceholder(surface, tokens, `\u201C${type}\u201D renderer not yet registered`);
16742
- }
16743
- if (anim.enabled) {
16744
- entrance = runFadeEntrance(surface, anim, finishEntrance);
17482
+ if (anim.enabled) {
17483
+ entrance = runFadeEntrance(surface, anim, finishEntrance);
17484
+ }
16745
17485
  }
16746
17486
  }
16747
17487
  if (!interaction) interaction = new InteractionController(surface, tokens);
@@ -16865,8 +17605,18 @@ function render(target, spec, options) {
16865
17605
  });
16866
17606
  const wantFade = anim.enabled && CANVAS_MARK_TYPES.has(prevType) && CANVAS_MARK_TYPES.has(next.type);
16867
17607
  const before = wantFade ? snapshotLayer(surface.marks) : void 0;
17608
+ const prevValue = lastValue;
17609
+ const prevCompare = lastCompare;
17610
+ const prevSparkline = lastSparkline;
17611
+ const wantValueTween = prevType === next.type && (COUNT_UP_TYPES.has(next.type) || SWEEP_TYPES.has(next.type)) && prevValue != null;
17612
+ const wantSparklineTween = prevType === next.type && next.type === "kpi" && prevSparkline != null && prevSparkline.length > 0;
16868
17613
  currentSpec = next;
16869
- draw(false);
17614
+ draw(
17615
+ false,
17616
+ wantValueTween ? prevValue ?? void 0 : void 0,
17617
+ wantValueTween ? prevCompare ?? void 0 : void 0,
17618
+ wantValueTween || wantSparklineTween ? prevSparkline ?? void 0 : void 0
17619
+ );
16870
17620
  if (before && surface.width === prevW && surface.height === prevH && surface.marks.canvas.width === before.width && surface.marks.canvas.height === before.height) {
16871
17621
  updateTransition = runUpdateCrossfade(surface, before, anim);
16872
17622
  }
@@ -16886,7 +17636,7 @@ function render(target, spec, options) {
16886
17636
  }
16887
17637
  };
16888
17638
  }
16889
- function runCartesianEntrance(surface, model, renderer, tokens, background, anim, onDone) {
17639
+ function runCartesianEntrance(surface, model, renderer, tokens, background, anim, onDone, mode = "wipe") {
16890
17640
  const ctx = surface.marks.ctx;
16891
17641
  const { plot } = model;
16892
17642
  const bg = background ?? tokens.color.background;
@@ -16897,6 +17647,14 @@ function runCartesianEntrance(surface, model, renderer, tokens, background, anim
16897
17647
  ctx.fillRect(0, 0, surface.width, surface.height);
16898
17648
  ctx.restore();
16899
17649
  drawAxesUnderlay(surface, model);
17650
+ if (mode === "grow") {
17651
+ model.entranceProgress = t;
17652
+ renderer(surface, model);
17653
+ model.entranceProgress = void 0;
17654
+ drawTrendlines(surface, model);
17655
+ drawAnnotations(surface, model);
17656
+ return;
17657
+ }
16900
17658
  if (t <= 0) return;
16901
17659
  ctx.save();
16902
17660
  ctx.globalAlpha = Math.min(1, 0.2 + 0.8 * t);
@@ -16919,6 +17677,17 @@ function runCartesianEntrance(surface, model, renderer, tokens, background, anim
16919
17677
  }
16920
17678
  });
16921
17679
  }
17680
+ function runCustomProgress(anim, onDone, paint) {
17681
+ return animate({
17682
+ duration: anim.duration,
17683
+ easing: anim.easing,
17684
+ onUpdate: paint,
17685
+ onComplete: () => {
17686
+ paint(1);
17687
+ onDone();
17688
+ }
17689
+ });
17690
+ }
16922
17691
  function runFadeEntrance(surface, anim, onDone) {
16923
17692
  const root = surface.root;
16924
17693
  const apply = (t) => {
@@ -17070,12 +17839,17 @@ function autoParamName(view) {
17070
17839
  const existing = view.spec.params?.[0]?.name;
17071
17840
  return existing ?? `__sel__${view.id}`;
17072
17841
  }
17842
+ function roleOf(view) {
17843
+ return view.interact ?? "auto";
17844
+ }
17073
17845
  function wireViews(views, interactions) {
17074
17846
  const out = views.map((v) => ({ ...v, spec: v.spec }));
17075
17847
  if (interactions === "none") return out;
17076
17848
  const sources = [];
17077
17849
  out.forEach((v, i) => {
17078
17850
  const type = v.spec.type;
17851
+ const role = roleOf(v);
17852
+ if (role === "none" || role === "target") return;
17079
17853
  if (isSlicerType(type)) {
17080
17854
  const s = v.spec;
17081
17855
  sources.push({
@@ -17106,11 +17880,15 @@ function wireViews(views, interactions) {
17106
17880
  name: src.param,
17107
17881
  select: { type: "point", on: "click", fields: src.fields }
17108
17882
  });
17109
- out[src.view].spec = addHighlight(out[src.view].spec, src.param);
17883
+ if (roleOf(out[src.view]) !== "source") {
17884
+ out[src.view].spec = addHighlight(out[src.view].spec, src.param);
17885
+ }
17110
17886
  }
17111
17887
  out.forEach((v, j) => {
17112
17888
  if (isSlicerType(v.spec.type)) return;
17113
17889
  if (j === src.view) return;
17890
+ const trole = roleOf(v);
17891
+ if (trole === "none" || trole === "source") return;
17114
17892
  out[j].spec = addFilterParam(out[j].spec, src.param);
17115
17893
  });
17116
17894
  }
@@ -17160,6 +17938,41 @@ var DEFAULT_BREAKPOINTS = [
17160
17938
  { maxWidth: 960, cols: 6 }
17161
17939
  ];
17162
17940
  var STRIP_SLICERS = /* @__PURE__ */ new Set(["dropdown", "search", "dateRange", "range"]);
17941
+ var DASHBOARD_CLASS = "graphein-dashboard";
17942
+ var DASHBOARD_CELL_CLASS = "graphein-dashboard-cell";
17943
+ var DASHBOARD_SOURCE_CLASS = "is-source";
17944
+ function dashboardStyles(tokens) {
17945
+ const accent = tokens.color.accent;
17946
+ const surface = tokens.color.surface;
17947
+ return `
17948
+ .${DASHBOARD_CLASS} .${DASHBOARD_CELL_CLASS}[data-framed="true"] {
17949
+ transition: border-color 120ms ease, background-color 120ms ease;
17950
+ }
17951
+ .${DASHBOARD_CLASS} .${DASHBOARD_CELL_CLASS}[data-framed="true"]:hover {
17952
+ border-color: ${accent};
17953
+ background-color: ${surface};
17954
+ }
17955
+ .${DASHBOARD_CLASS} .${DASHBOARD_CELL_CLASS}:focus-visible {
17956
+ outline: 2px solid ${accent};
17957
+ outline-offset: 2px;
17958
+ }
17959
+ .${DASHBOARD_CLASS} .${DASHBOARD_CELL_CLASS}.${DASHBOARD_SOURCE_CLASS} {
17960
+ border-color: ${accent};
17961
+ box-shadow: inset 0 0 0 1px ${accent};
17962
+ }
17963
+ `;
17964
+ }
17965
+ function selectionIsActive(value) {
17966
+ if (value == null) return false;
17967
+ if (Array.isArray(value)) return value.length > 0;
17968
+ if (typeof value !== "object") return true;
17969
+ const v = value;
17970
+ if (v.kind === "point") return Array.isArray(v.tuples) && v.tuples.length > 0;
17971
+ if (v.kind === "set") return Array.isArray(v.values) && v.values.length > 0;
17972
+ if (v.kind === "range") return v.min != null || v.max != null;
17973
+ if (v.kind === "text") return typeof v.query === "string" && v.query.length > 0;
17974
+ return true;
17975
+ }
17163
17976
  function stripCellHeight(view, rowHeight) {
17164
17977
  const titled = !!(view.title || view.subtitle);
17165
17978
  const titlePad = titled ? 30 : 0;
@@ -17302,6 +18115,12 @@ function renderDashboard(target, spec, options) {
17302
18115
  let root;
17303
18116
  let resizeObs;
17304
18117
  let destroyed = false;
18118
+ let sourceCells = /* @__PURE__ */ new Map();
18119
+ const syncSourceCell = (name, value) => {
18120
+ const cell = sourceCells.get(name);
18121
+ if (!cell) return;
18122
+ cell.classList.toggle(DASHBOARD_SOURCE_CLASS, selectionIsActive(value));
18123
+ };
17305
18124
  const build = () => {
17306
18125
  const tokens = resolveTheme(currentSpec.theme);
17307
18126
  const layout2 = resolveDashboardLayout(currentSpec.layout);
@@ -17314,8 +18133,9 @@ function renderDashboard(target, spec, options) {
17314
18133
  store.set(param.name, param.value ?? null);
17315
18134
  }
17316
18135
  }
18136
+ sourceCells = /* @__PURE__ */ new Map();
17317
18137
  root = document.createElement("div");
17318
- root.className = "graphein-dashboard";
18138
+ root.className = DASHBOARD_CLASS;
17319
18139
  Object.assign(root.style, {
17320
18140
  boxSizing: "border-box",
17321
18141
  width: currentSpec.dimensions?.width ? `${currentSpec.dimensions.width}px` : "100%",
@@ -17329,6 +18149,10 @@ function renderDashboard(target, spec, options) {
17329
18149
  flexDirection: "column",
17330
18150
  gap: `${layout2.gap}px`
17331
18151
  });
18152
+ const style = document.createElement("style");
18153
+ style.setAttribute("data-graphein-dashboard-style", "");
18154
+ style.textContent = dashboardStyles(tokens);
18155
+ root.appendChild(style);
17332
18156
  const titleText3 = typeof currentSpec.title === "string" ? currentSpec.title : currentSpec.title?.text;
17333
18157
  if (titleText3 || currentSpec.subtitle) {
17334
18158
  const header = document.createElement("div");
@@ -17388,6 +18212,7 @@ function renderDashboard(target, spec, options) {
17388
18212
  };
17389
18213
  const cellChrome = (cell, view) => {
17390
18214
  const framed = view?.frame ?? true;
18215
+ cell.dataset.framed = framed ? "true" : "false";
17391
18216
  Object.assign(cell.style, {
17392
18217
  minWidth: "0",
17393
18218
  minHeight: "0",
@@ -17399,9 +18224,10 @@ function renderDashboard(target, spec, options) {
17399
18224
  background: view?.background ?? (framed ? tokens.color.surface : "transparent")
17400
18225
  });
17401
18226
  };
17402
- const createCard2 = (view) => {
18227
+ const createCard = (view) => {
17403
18228
  const cell = document.createElement("div");
17404
18229
  cellChrome(cell, view);
18230
+ cell.classList.add(DASHBOARD_CELL_CLASS);
17405
18231
  cell.setAttribute("data-view-id", view.id);
17406
18232
  const hasCardChrome = !!(view.title || view.subtitle || view.accent || view.background || view.frame === false || view.padding === "none");
17407
18233
  if (!hasCardChrome) return { cell, host: cell };
@@ -17428,13 +18254,14 @@ function renderDashboard(target, spec, options) {
17428
18254
  padding: `${tokens.spacing.sm}px ${tokens.spacing.md}px ${tokens.spacing.xs}px ${tokens.spacing.md}px`,
17429
18255
  display: "flex",
17430
18256
  flexDirection: "column",
17431
- gap: "2px"
18257
+ gap: "2px",
18258
+ borderBottom: `1px solid ${tokens.color.border}`
17432
18259
  });
17433
18260
  if (view.title) {
17434
18261
  const title2 = document.createElement("div");
17435
18262
  title2.textContent = view.title;
17436
18263
  Object.assign(title2.style, {
17437
- font: `${tokens.font.weight.bold} ${tokens.font.size.base}px ${tokens.font.family}`,
18264
+ font: `${tokens.font.weight.medium} ${tokens.font.size.base}px ${tokens.font.family}`,
17438
18265
  color: tokens.color.text,
17439
18266
  letterSpacing: "-0.01em"
17440
18267
  });
@@ -17462,6 +18289,24 @@ function renderDashboard(target, spec, options) {
17462
18289
  cell.appendChild(host);
17463
18290
  return { cell, host };
17464
18291
  };
18292
+ const publishingParams = (view) => {
18293
+ const names = /* @__PURE__ */ new Set();
18294
+ for (const param of view.spec.params ?? []) {
18295
+ if (param?.name) names.add(param.name);
18296
+ }
18297
+ if (isSlicerType(view.spec.type)) {
18298
+ const slicer = view.spec;
18299
+ const name = typeof slicer.param === "string" ? slicer.param : typeof slicer.field === "string" ? slicer.field : void 0;
18300
+ if (name) names.add(name);
18301
+ }
18302
+ return [...names];
18303
+ };
18304
+ const registerSourceCell = (view, cell) => {
18305
+ for (const name of publishingParams(view)) {
18306
+ sourceCells.set(name, cell);
18307
+ syncSourceCell(name, store.get(name));
18308
+ }
18309
+ };
17465
18310
  const sectionPlans = resolveDashboardSections(gridViews, layout2);
17466
18311
  const layoutGroups = [];
17467
18312
  const deferredMounts = [];
@@ -17474,7 +18319,8 @@ function renderDashboard(target, spec, options) {
17474
18319
  gap: `${layout2.gap}px`
17475
18320
  });
17476
18321
  for (const view of stripViews) {
17477
- const { cell, host } = createCard2(view);
18322
+ const { cell, host } = createCard(view);
18323
+ registerSourceCell(view, cell);
17478
18324
  Object.assign(cell.style, {
17479
18325
  flex: "1 1 220px",
17480
18326
  minWidth: "200px",
@@ -17520,7 +18366,13 @@ function renderDashboard(target, spec, options) {
17520
18366
  if (plan.section?.title) {
17521
18367
  const title2 = document.createElement("span");
17522
18368
  title2.textContent = plan.section.title;
17523
- Object.assign(title2.style, { fontWeight: String(tokens.font.weight.bold), fontSize: `${tokens.font.size.large}px` });
18369
+ Object.assign(title2.style, {
18370
+ color: tokens.color.textMuted,
18371
+ fontWeight: String(tokens.font.weight.medium),
18372
+ fontSize: `${tokens.font.size.small}px`,
18373
+ letterSpacing: "0.06em",
18374
+ textTransform: "uppercase"
18375
+ });
17524
18376
  titles.appendChild(title2);
17525
18377
  }
17526
18378
  if (plan.section?.subtitle) {
@@ -17572,7 +18424,8 @@ function renderDashboard(target, spec, options) {
17572
18424
  parent.appendChild(grid);
17573
18425
  const cells = placeViews(plan.views, plan.cols, layout2.preset ?? "auto");
17574
18426
  for (const cellInfo of cells) {
17575
- const { cell, host } = createCard2(cellInfo.view);
18427
+ const { cell, host } = createCard(cellInfo.view);
18428
+ registerSourceCell(cellInfo.view, cell);
17576
18429
  grid.appendChild(cell);
17577
18430
  cellInfo.el = cell;
17578
18431
  deferredMounts.push({ el: host, view: cellInfo.view, mounted: false });
@@ -17645,8 +18498,10 @@ function renderDashboard(target, spec, options) {
17645
18498
  instances.length = 0;
17646
18499
  if (root && root.parentNode) root.parentNode.removeChild(root);
17647
18500
  root = void 0;
18501
+ sourceCells = /* @__PURE__ */ new Map();
17648
18502
  };
17649
18503
  const unsubscribe = store.subscribe((name, value) => {
18504
+ syncSourceCell(name, value);
17650
18505
  for (const listener of [...hostListeners]) listener(name, value);
17651
18506
  });
17652
18507
  build();
@@ -17864,6 +18719,6 @@ function renderToContext(target, spec) {
17864
18719
  // src/index.ts
17865
18720
  var VERSION = "0.1.0";
17866
18721
 
17867
- 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, categoricalSchemes, 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, ellipsize, 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, profileColumns, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, recommendChart, relativeLuminance, render, renderDashboard, renderToContext, repairReport, 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 };
18722
+ 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, categoricalSchemes, 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, ellipsize, 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, profileColumns, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, recommendChart, relativeLuminance, render, renderDashboard, renderToContext, repairReport, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, resolveValueRef, 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 };
17868
18723
  //# sourceMappingURL=index.js.map
17869
18724
  //# sourceMappingURL=index.js.map