@opendata-ai/openchart-engine 8.3.1 → 8.4.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
@@ -10,11 +10,12 @@ import {
10
10
  getLayoutStrategy,
11
11
  isAxislessMark as isAxislessMark3,
12
12
  MAX_SR_TABLE_ROWS,
13
+ resolveChromeEconomy,
13
14
  resolveTheme as resolveTheme7
14
15
  } from "@opendata-ai/openchart-core";
15
16
 
16
17
  // src/annotations/collisions.ts
17
- import { detectCollision } from "@opendata-ai/openchart-core";
18
+ import { detectCollision, overlapArea as overlapArea2 } from "@opendata-ai/openchart-core";
18
19
 
19
20
  // src/annotations/constants.ts
20
21
  var DEFAULT_ANNOTATION_FONT_SIZE = 13;
@@ -1157,6 +1158,12 @@ function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartAr
1157
1158
  };
1158
1159
  const isText = (a) => a.type === "text" && !!a.label;
1159
1160
  const placedBounds = [];
1161
+ for (const annotation of annotations) {
1162
+ if (isText(annotation) || !annotation.bounds) continue;
1163
+ if (annotation.bounds.width > 0 && annotation.bounds.height > 0) {
1164
+ placedBounds.push(annotation.bounds);
1165
+ }
1166
+ }
1160
1167
  for (let i = 0; i < annotations.length; i++) {
1161
1168
  const annotation = annotations[i];
1162
1169
  if (!isText(annotation) || !isPinned(annotation, i)) continue;
@@ -1223,6 +1230,100 @@ function clampAnnotationsToBounds(annotations, svgWidth, svgHeight, measure = he
1223
1230
  moveAnnotationBy(annotation, label, dx, dy, measure);
1224
1231
  }
1225
1232
  }
1233
+ var REFLINE_OBSTACLE_KINDS = /* @__PURE__ */ new Set([
1234
+ "mark",
1235
+ "data-label",
1236
+ "annotation",
1237
+ "endpoint-label"
1238
+ ]);
1239
+ var MAX_REFLINE_SLIDE_CANDIDATES = 48;
1240
+ function hasExplicitLabelOffset(spec) {
1241
+ return (spec.labelOffset?.dx ?? 0) !== 0 || (spec.labelOffset?.dy ?? 0) !== 0;
1242
+ }
1243
+ function nudgeRefLineLabel(annotation, spec, obstacles, chartArea, measure = heuristicMeasure) {
1244
+ if (annotation.type !== "refline" || !annotation.label || !annotation.line) return false;
1245
+ if (hasExplicitLabelOffset(spec)) return false;
1246
+ const label = annotation.label;
1247
+ const bounds = annotation.bounds ?? annotationBlockBounds(annotation, label, measure);
1248
+ const relevant = obstacles.filter(
1249
+ (obs) => obs.width > 0 && obs.height > 0 && REFLINE_OBSTACLE_KINDS.has(obs.kind)
1250
+ );
1251
+ if (!relevant.some((obs) => detectCollision(bounds, obs))) return false;
1252
+ const { start, end } = annotation.line;
1253
+ const isHorizontal = Math.abs(end.y - start.y) <= Math.abs(end.x - start.x);
1254
+ const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
1255
+ const anchor = label.style.textAnchor;
1256
+ const flipped = anchor === "start" ? "end" : anchor === "end" ? "start" : void 0;
1257
+ const mirror = (value, from, to) => to - (value - from);
1258
+ const candidates = [];
1259
+ if (isHorizontal) {
1260
+ const atEnd = Math.abs(label.x - end.x) <= Math.abs(label.x - start.x);
1261
+ const swapDx = mirror(label.x, atEnd ? end.x : start.x, atEnd ? start.x : end.x) - label.x;
1262
+ const flipDy = (label.y <= start.y ? 1 : -1) * (fontSize + NUDGE_PADDING);
1263
+ candidates.push({ dx: swapDx, dy: 0, textAnchor: flipped });
1264
+ candidates.push({ dx: 0, dy: flipDy });
1265
+ candidates.push({ dx: swapDx, dy: flipDy, textAnchor: flipped });
1266
+ } else {
1267
+ const atEnd = Math.abs(label.y - end.y) <= Math.abs(label.y - start.y);
1268
+ const swapDy = mirror(label.y, atEnd ? end.y : start.y, atEnd ? start.y : end.y) - label.y;
1269
+ const flipDx = -2 * (label.x - start.x);
1270
+ candidates.push({ dx: 0, dy: swapDy });
1271
+ candidates.push({ dx: flipDx, dy: 0, textAnchor: flipped });
1272
+ candidates.push({ dx: flipDx, dy: swapDy, textAnchor: flipped });
1273
+ }
1274
+ const slides = generateNudgeCandidates(bounds, relevant, NUDGE_PADDING).filter((c) => isHorizontal ? c.dy === 0 : c.dx === 0).slice(0, MAX_REFLINE_SLIDE_CANDIDATES);
1275
+ for (const slide of slides) {
1276
+ candidates.push({ dx: slide.dx, dy: slide.dy });
1277
+ }
1278
+ for (const { dx, dy, textAnchor } of candidates) {
1279
+ if (dx === 0 && dy === 0 && !textAnchor) continue;
1280
+ const trial = {
1281
+ ...label,
1282
+ x: label.x + dx,
1283
+ y: label.y + dy,
1284
+ ...textAnchor ? { style: { ...label.style, textAnchor } } : {}
1285
+ };
1286
+ const trialBounds = annotationBlockBounds(annotation, trial, measure);
1287
+ if (relevant.some((obs) => detectCollision(trialBounds, obs))) continue;
1288
+ const centerX = trialBounds.x + trialBounds.width / 2;
1289
+ const centerY = trialBounds.y + trialBounds.height / 2;
1290
+ const inBounds = centerX >= chartArea.x && centerX <= chartArea.x + chartArea.width && centerY >= chartArea.y - fontSize && centerY <= chartArea.y + chartArea.height + fontSize;
1291
+ if (!inBounds) continue;
1292
+ moveAnnotationBy(annotation, label, dx, dy, measure, textAnchor);
1293
+ return true;
1294
+ }
1295
+ return false;
1296
+ }
1297
+ var PINNED_OVERLAP_RATIO = 0.25;
1298
+ function collectPinnedOverlapWarnings(annotations, specs, obstacles, measure = heuristicMeasure) {
1299
+ const marks = obstacles.filter((obs) => obs.kind === "mark" && obs.width > 0 && obs.height > 0);
1300
+ if (marks.length === 0) return [];
1301
+ const warnings = [];
1302
+ for (const [i, annotation] of annotations.entries()) {
1303
+ if (!annotation.label) continue;
1304
+ const spec = specs[annotation.specIndex ?? i];
1305
+ if (!spec) continue;
1306
+ const pinned = spec.type === "text" && hasExplicitOffset(spec) || spec.type === "refline" && hasExplicitLabelOffset(spec);
1307
+ if (!pinned) continue;
1308
+ const bounds = annotation.bounds ?? annotationBlockBounds(annotation, annotation.label, measure);
1309
+ const area3 = bounds.width * bounds.height;
1310
+ if (area3 <= 0) continue;
1311
+ let covered = 0;
1312
+ let hits = 0;
1313
+ for (const mark of marks) {
1314
+ const overlap = overlapArea2(bounds, mark);
1315
+ if (overlap <= 0) continue;
1316
+ covered += overlap;
1317
+ hits++;
1318
+ }
1319
+ if (hits > 0 && covered / area3 > PINNED_OVERLAP_RATIO) {
1320
+ warnings.push(
1321
+ `[openchart] annotation "${annotation.label.text}" overlaps ${hits} mark${hits === 1 ? "" : "s"}; it is pinned by an explicit offset. Remove the offset to let auto-placement find a clear spot.`
1322
+ );
1323
+ }
1324
+ }
1325
+ return warnings;
1326
+ }
1226
1327
 
1227
1328
  // src/annotations/resolve-range.ts
1228
1329
  function resolveRangeAnnotation(annotation, scales, chartArea, isDark, fontFamily) {
@@ -1289,7 +1390,7 @@ function resolveRangeAnnotation(annotation, scales, chartArea, isDark, fontFamil
1289
1390
  }
1290
1391
 
1291
1392
  // src/annotations/resolve-refline.ts
1292
- function resolveRefLineAnnotation(annotation, scales, chartArea, isDark, fontFamily) {
1393
+ function resolveRefLineAnnotation(annotation, scales, chartArea, isDark, measure = heuristicMeasure, fontFamily) {
1293
1394
  let start;
1294
1395
  let end;
1295
1396
  if (annotation.y !== void 0) {
@@ -1382,7 +1483,7 @@ function resolveRefLineAnnotation(annotation, scales, chartArea, isDark, fontFam
1382
1483
  };
1383
1484
  }
1384
1485
  const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
1385
- return {
1486
+ const resolved = {
1386
1487
  type: "refline",
1387
1488
  id: annotation.id,
1388
1489
  line: { start, end },
@@ -1392,6 +1493,10 @@ function resolveRefLineAnnotation(annotation, scales, chartArea, isDark, fontFam
1392
1493
  strokeWidth: annotation.strokeWidth ?? 1,
1393
1494
  zIndex: annotation.zIndex
1394
1495
  };
1496
+ if (label) {
1497
+ resolved.bounds = annotationBlockBounds(resolved, label, measure);
1498
+ }
1499
+ return resolved;
1395
1500
  }
1396
1501
 
1397
1502
  // src/annotations/compute.ts
@@ -1461,6 +1566,7 @@ function computeAnnotations(spec, scalesOrCtx, chartArea, strategy, isDark, obst
1461
1566
  ctx.scales,
1462
1567
  ctx.chartArea,
1463
1568
  ctx.isDark,
1569
+ ctx.measure,
1464
1570
  ctx.fontFamily
1465
1571
  );
1466
1572
  break;
@@ -1476,6 +1582,9 @@ function computeAnnotations(spec, scalesOrCtx, chartArea, strategy, isDark, obst
1476
1582
  ctx.measure
1477
1583
  );
1478
1584
  }
1585
+ if (annotation.type === "refline" && ctx.obstacles.length > 0) {
1586
+ nudgeRefLineLabel(resolved, annotation, ctx.obstacles, ctx.chartArea, ctx.measure);
1587
+ }
1479
1588
  resolved.specIndex = specIndex;
1480
1589
  annotations.push(resolved);
1481
1590
  }
@@ -2274,7 +2383,40 @@ function formatLabelValue(value, ctx) {
2274
2383
  }
2275
2384
 
2276
2385
  // src/charts/utils.ts
2277
- var DEFAULT_COLOR = "#1b7fa3";
2386
+ import {
2387
+ adaptForLightLineStroke,
2388
+ CATEGORICAL_PALETTE,
2389
+ getRepresentativeColor
2390
+ } from "@opendata-ai/openchart-core";
2391
+ var DEFAULT_COLOR = CATEGORICAL_PALETTE[0];
2392
+ var BAR_CORNER_RADIUS = 2;
2393
+ function valueEndCorners(orient, negative) {
2394
+ if (orient === "horizontal") {
2395
+ return negative ? { tl: true, tr: false, br: false, bl: true } : { tl: false, tr: true, br: true, bl: false };
2396
+ }
2397
+ return negative ? { tl: false, tr: false, br: true, bl: true } : { tl: true, tr: true, br: false, bl: false };
2398
+ }
2399
+ function stackSeamStroke(theme) {
2400
+ return resolveKnockoutColor(theme);
2401
+ }
2402
+ function resolveKnockoutColor(theme) {
2403
+ return theme?.colors?.neutral?.surface ?? "#ffffff";
2404
+ }
2405
+ function isStrokeSeriesMark(markType) {
2406
+ return markType === "line" || markType === "area";
2407
+ }
2408
+ function adaptSeriesStroke(color, theme) {
2409
+ const str = getRepresentativeColor(color);
2410
+ return theme && !theme.isDark ? adaptForLightLineStroke(str) : str;
2411
+ }
2412
+ function hasExplicitColorRange(spec) {
2413
+ const colorEnc = spec.encoding?.color;
2414
+ if (!colorEnc || !("field" in colorEnc)) return false;
2415
+ return Array.isArray(colorEnc.scale?.range);
2416
+ }
2417
+ function resolveSeriesStroke(spec, color, theme) {
2418
+ return hasExplicitColorRange(spec) ? getRepresentativeColor(color) : adaptSeriesStroke(color, theme);
2419
+ }
2278
2420
  function scaleValue(scale, scaleType, value) {
2279
2421
  if (value == null) return null;
2280
2422
  if (scaleType === "time" || scaleType === "utc") {
@@ -2348,6 +2490,14 @@ function getSequentialColor(scales, value, fallback = DEFAULT_COLOR) {
2348
2490
  }
2349
2491
 
2350
2492
  // src/charts/bar/compute.ts
2493
+ function applyStackSeams(marks, theme) {
2494
+ const seam = stackSeamStroke(theme);
2495
+ for (const mark of marks) {
2496
+ if (mark.stackGroup === void 0) continue;
2497
+ mark.stroke = seam;
2498
+ mark.strokeWidth = 1;
2499
+ }
2500
+ }
2351
2501
  function orientGradientForHorizontalBar(grad) {
2352
2502
  if (grad.gradient !== "linear") return grad;
2353
2503
  const lg = grad;
@@ -2357,7 +2507,7 @@ function orientGradientForHorizontalBar(grad) {
2357
2507
  }
2358
2508
  var MIN_BAR_WIDTH = 1;
2359
2509
  var MIN_GROUPED_BAR_THICKNESS = 8;
2360
- function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth) {
2510
+ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth, theme) {
2361
2511
  const encoding = spec.encoding;
2362
2512
  const xChannel = encoding.x;
2363
2513
  const yChannel = encoding.y;
@@ -2440,6 +2590,7 @@ function computeBarMarks(spec, scales, _chartArea, _strategy, containerWidth) {
2440
2590
  }
2441
2591
  const categoryField = yChannel.field;
2442
2592
  stampBarKeys(marks, categoryField, colorField);
2593
+ applyStackSeams(marks, theme);
2443
2594
  return applyMarkDefOverrides(marks, spec, bandwidth);
2444
2595
  }
2445
2596
  function stampBarKeys(marks, categoryField, colorField) {
@@ -2485,6 +2636,7 @@ function computeStackedBars(data, valueField, categoryField, colorField, xScale,
2485
2636
  height: bandwidth,
2486
2637
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2487
2638
  cornerRadius: 0,
2639
+ seriesKey: groupKey3,
2488
2640
  data: row,
2489
2641
  aria,
2490
2642
  orient: "horizontal",
@@ -2541,7 +2693,9 @@ function computeGroupedBars(data, valueField, categoryField, colorField, xScale,
2541
2693
  width: barWidth,
2542
2694
  height: subBandHeight,
2543
2695
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2544
- cornerRadius: 2,
2696
+ cornerRadius: BAR_CORNER_RADIUS,
2697
+ cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2698
+ seriesKey: groupKey3,
2545
2699
  data: row,
2546
2700
  aria,
2547
2701
  orient: "horizontal"
@@ -2572,7 +2726,9 @@ function computeColoredBars(data, valueField, categoryField, colorField, xScale,
2572
2726
  width: barWidth,
2573
2727
  height: bandwidth,
2574
2728
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2575
- cornerRadius: 2,
2729
+ cornerRadius: BAR_CORNER_RADIUS,
2730
+ cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2731
+ seriesKey: groupKey3,
2576
2732
  data: row,
2577
2733
  aria,
2578
2734
  orient: "horizontal"
@@ -2607,6 +2763,7 @@ function applyMarkDefOverrides(marks, spec, bandwidth) {
2607
2763
  if (isStacked2 && !isStackRight) continue;
2608
2764
  if (crSpec === "pill") {
2609
2765
  mark.cornerRadius = effectiveHeight / 2;
2766
+ mark.cornerRadiusSides = void 0;
2610
2767
  } else if (typeof crSpec === "number") {
2611
2768
  mark.cornerRadius = crSpec;
2612
2769
  } else {
@@ -2651,7 +2808,8 @@ function computeSimpleBars(data, valueField, categoryField, xScale, yScale, band
2651
2808
  width: barWidth,
2652
2809
  height: bandwidth,
2653
2810
  fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
2654
- cornerRadius: 2,
2811
+ cornerRadius: BAR_CORNER_RADIUS,
2812
+ cornerRadiusSides: valueEndCorners("horizontal", value < 0),
2655
2813
  data: row,
2656
2814
  aria,
2657
2815
  orient: "horizontal"
@@ -2663,7 +2821,7 @@ function computeSimpleBars(data, valueField, categoryField, xScale, yScale, band
2663
2821
  // src/charts/bar/labels.ts
2664
2822
  import {
2665
2823
  estimateTextWidth as estimateTextWidth3,
2666
- getRepresentativeColor,
2824
+ getRepresentativeColor as getRepresentativeColor2,
2667
2825
  pickLabelColor,
2668
2826
  resolveCollisions
2669
2827
  } from "@opendata-ai/openchart-core";
@@ -2703,7 +2861,7 @@ var LABEL_FONT_SIZE = 11;
2703
2861
  var LABEL_FONT_WEIGHT = 600;
2704
2862
  var LABEL_PADDING = 6;
2705
2863
  var MIN_WIDTH_FOR_INSIDE_LABEL = 40;
2706
- function computeBarLabels(marks, _chartArea, density = "auto", labelFormatter, labelPrefix, valueField, labelColor, darkMode = false, fontSize, labelSuffix) {
2864
+ function computeBarLabels(marks, chartArea, density = "auto", labelFormatter, labelPrefix, valueField, labelColor, darkMode = false, fontSize, labelSuffix) {
2707
2865
  const FONT_SIZE = fontSize ?? LABEL_FONT_SIZE;
2708
2866
  const targetMarks = filterByDensity(marks, density);
2709
2867
  const candidates = [];
@@ -2737,7 +2895,7 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormatter, l
2737
2895
  const isStacked2 = mark.stackGroup !== void 0;
2738
2896
  const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
2739
2897
  const isNegative = Number.isFinite(rawNum) ? rawNum < 0 : false;
2740
- const bgColor = getRepresentativeColor(mark.fill);
2898
+ const bgColor = getRepresentativeColor2(mark.fill);
2741
2899
  let anchorX;
2742
2900
  let fill;
2743
2901
  let textAnchor;
@@ -2757,12 +2915,12 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormatter, l
2757
2915
  }
2758
2916
  } else {
2759
2917
  if (isNegative) {
2760
- anchorX = mark.x - LABEL_PADDING;
2761
- fill = labelColor ?? getRepresentativeColor(mark.fill);
2918
+ anchorX = Math.min(mark.x - LABEL_PADDING, chartArea.x + chartArea.width - LABEL_PADDING);
2919
+ fill = labelColor ?? getRepresentativeColor2(mark.fill);
2762
2920
  textAnchor = "end";
2763
2921
  } else {
2764
- anchorX = mark.x + mark.width + LABEL_PADDING;
2765
- fill = labelColor ?? getRepresentativeColor(mark.fill);
2922
+ anchorX = Math.max(mark.x + mark.width + LABEL_PADDING, chartArea.x + LABEL_PADDING);
2923
+ fill = labelColor ?? getRepresentativeColor2(mark.fill);
2766
2924
  textAnchor = "start";
2767
2925
  }
2768
2926
  }
@@ -2846,7 +3004,7 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormatter, l
2846
3004
 
2847
3005
  // src/charts/bar/index.ts
2848
3006
  var barRenderer = (spec, scales, chartArea, strategy, theme, containerWidth) => {
2849
- const marks = computeBarMarks(spec, scales, chartArea, strategy, containerWidth);
3007
+ const marks = computeBarMarks(spec, scales, chartArea, strategy, containerWidth, theme);
2850
3008
  const valueField = spec.encoding?.x && "field" in spec.encoding.x ? spec.encoding.x.field : void 0;
2851
3009
  const labelFormatter = resolveFieldFormatter({
2852
3010
  surfaceFormat: spec.labels.format,
@@ -2981,7 +3139,7 @@ function minimalFreeOffset(intervals) {
2981
3139
 
2982
3140
  // src/charts/beeswarm/compute.ts
2983
3141
  var DEFAULT_DOT_RADIUS2 = 4;
2984
- function computeBeeswarmMarks(spec, scales, chartArea, _strategy) {
3142
+ function computeBeeswarmMarks(spec, scales, chartArea, _strategy, theme) {
2985
3143
  const encoding = spec.encoding;
2986
3144
  const isHorizontal = encoding.x?.type === "quantitative";
2987
3145
  const valueChannel = isHorizontal ? encoding.x : encoding.y;
@@ -3062,7 +3220,7 @@ function computeBeeswarmMarks(spec, scales, chartArea, _strategy) {
3062
3220
  cy: isHorizontal ? cross : dot.position,
3063
3221
  r: dot.radius,
3064
3222
  fill: color,
3065
- stroke: "#ffffff",
3223
+ stroke: resolveKnockoutColor(theme),
3066
3224
  strokeWidth: 1,
3067
3225
  data: dot.row,
3068
3226
  aria
@@ -3081,8 +3239,8 @@ function computeBeeswarmMarks(spec, scales, chartArea, _strategy) {
3081
3239
  }
3082
3240
 
3083
3241
  // src/charts/beeswarm/index.ts
3084
- var beeswarmRenderer = (spec, scales, chartArea, strategy, _theme) => {
3085
- return computeBeeswarmMarks(spec, scales, chartArea, strategy);
3242
+ var beeswarmRenderer = (spec, scales, chartArea, strategy, theme) => {
3243
+ return computeBeeswarmMarks(spec, scales, chartArea, strategy, theme);
3086
3244
  };
3087
3245
 
3088
3246
  // src/charts/calendar/compute.ts
@@ -3090,10 +3248,11 @@ import { defaultNumberFormatter as defaultNumberFormatter4, formatDate } from "@
3090
3248
  import { utcDay, utcMonday, utcSunday } from "d3-time";
3091
3249
  var MIN_CELL_SIZE = 7;
3092
3250
  var MAX_STEP = 20;
3093
- var MIN_STEP = MIN_CELL_SIZE + 1;
3251
+ var MIN_CELL_GAP = 2;
3252
+ var MIN_STEP = MIN_CELL_SIZE + MIN_CELL_GAP;
3094
3253
  var BAND_GAP = 18;
3095
3254
  var WEEKDAY_LABEL_GAP = 6;
3096
- var DEFAULT_CELL_RADIUS = 1;
3255
+ var DEFAULT_CELL_RADIUS = 2;
3097
3256
  var WEEKDAY_LABELS = [
3098
3257
  { dow: 1, label: "Mon" },
3099
3258
  { dow: 3, label: "Wed" },
@@ -3168,7 +3327,7 @@ function computeCalendarMarks(spec, scales, chartArea, theme) {
3168
3327
  MAX_STEP,
3169
3328
  Math.max(MIN_STEP, Math.floor(Math.min(stepFromWidth, stepFromHeight)))
3170
3329
  );
3171
- const cellGap = step >= 14 ? 3 : step >= 10 ? 2 : 1;
3330
+ const cellGap = step >= 14 ? 3 : MIN_CELL_GAP;
3172
3331
  const cell = step - cellGap;
3173
3332
  const gridWidth = maxColumns * step - cellGap;
3174
3333
  const bandHeight = monthRow + 7 * step - cellGap;
@@ -3251,6 +3410,7 @@ function computeCalendarMarks(spec, scales, chartArea, theme) {
3251
3410
  height: cell,
3252
3411
  fill: getSequentialColor(scales, datum.value),
3253
3412
  cornerRadius: cellRadius,
3413
+ shapeRendering: "crispEdges",
3254
3414
  data: datum.row,
3255
3415
  aria: {
3256
3416
  label: `${dateLabel}, ${colorEnc.field}: ${calendarFmt(datum.value)}`
@@ -3266,6 +3426,7 @@ function computeCalendarMarks(spec, scales, chartArea, theme) {
3266
3426
  height: cell,
3267
3427
  fill: theme.colors.annotationFill,
3268
3428
  cornerRadius: cellRadius,
3429
+ shapeRendering: "crispEdges",
3269
3430
  data: {},
3270
3431
  aria: { decorative: true }
3271
3432
  });
@@ -3282,8 +3443,16 @@ var calendarRenderer = (spec, scales, chartArea, _strategy, theme) => {
3282
3443
 
3283
3444
  // src/charts/column/compute.ts
3284
3445
  import { isGradientDef as isGradientDef2 } from "@opendata-ai/openchart-core";
3446
+ function applyStackSeams2(marks, theme) {
3447
+ const seam = stackSeamStroke(theme);
3448
+ for (const mark of marks) {
3449
+ if (mark.stackGroup === void 0) continue;
3450
+ mark.stroke = seam;
3451
+ mark.strokeWidth = 1;
3452
+ }
3453
+ }
3285
3454
  var MIN_COLUMN_HEIGHT = 1;
3286
- function computeColumnMarks(spec, scales, _chartArea, _strategy) {
3455
+ function computeColumnMarks(spec, scales, _chartArea, _strategy, theme) {
3287
3456
  const encoding = spec.encoding;
3288
3457
  const xChannel = encoding.x;
3289
3458
  const yChannel = encoding.y;
@@ -3365,6 +3534,7 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy) {
3365
3534
  }
3366
3535
  const categoryField = xChannel.field;
3367
3536
  stampColumnKeys(marks, categoryField, colorField);
3537
+ applyStackSeams2(marks, theme);
3368
3538
  return applyMarkDefOverrides2(marks, spec, bandwidth);
3369
3539
  }
3370
3540
  function stampColumnKeys(marks, categoryField, colorField) {
@@ -3412,7 +3582,8 @@ function computeSimpleColumns(data, categoryField, valueField, xScale, yScale, b
3412
3582
  width: bandwidth,
3413
3583
  height: columnHeight,
3414
3584
  fill: color,
3415
- cornerRadius: 2,
3585
+ cornerRadius: BAR_CORNER_RADIUS,
3586
+ cornerRadiusSides: valueEndCorners("vertical", value < 0),
3416
3587
  data: row,
3417
3588
  aria,
3418
3589
  orient: "vertical"
@@ -3443,7 +3614,9 @@ function computeColoredColumns(data, categoryField, valueField, colorField, xSca
3443
3614
  width: bandwidth,
3444
3615
  height: columnHeight,
3445
3616
  fill: color,
3446
- cornerRadius: 2,
3617
+ cornerRadius: BAR_CORNER_RADIUS,
3618
+ cornerRadiusSides: valueEndCorners("vertical", value < 0),
3619
+ seriesKey: groupKey3,
3447
3620
  data: row,
3448
3621
  aria,
3449
3622
  orient: "vertical"
@@ -3491,7 +3664,9 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
3491
3664
  width: subBandWidth,
3492
3665
  height: columnHeight,
3493
3666
  fill: color,
3494
- cornerRadius: 2,
3667
+ cornerRadius: BAR_CORNER_RADIUS,
3668
+ cornerRadiusSides: valueEndCorners("vertical", value < 0),
3669
+ seriesKey: groupKey3,
3495
3670
  data: row,
3496
3671
  aria,
3497
3672
  orient: "vertical"
@@ -3532,6 +3707,7 @@ function computeStackedColumns(data, categoryField, valueField, colorField, xSca
3532
3707
  height: columnHeight,
3533
3708
  fill: color,
3534
3709
  cornerRadius: 0,
3710
+ seriesKey: groupKey3,
3535
3711
  data: row,
3536
3712
  aria,
3537
3713
  orient: "vertical",
@@ -3568,6 +3744,7 @@ function applyMarkDefOverrides2(marks, spec, bandwidth) {
3568
3744
  if (isStacked2 && !isStackTop) continue;
3569
3745
  if (crSpec === "pill") {
3570
3746
  mark.cornerRadius = effectiveWidth / 2;
3747
+ mark.cornerRadiusSides = void 0;
3571
3748
  } else if (typeof crSpec === "number") {
3572
3749
  mark.cornerRadius = crSpec;
3573
3750
  } else {
@@ -3583,7 +3760,7 @@ function applyMarkDefOverrides2(marks, spec, bandwidth) {
3583
3760
  // src/charts/column/labels.ts
3584
3761
  import {
3585
3762
  estimateTextWidth as estimateTextWidth4,
3586
- getRepresentativeColor as getRepresentativeColor2,
3763
+ getRepresentativeColor as getRepresentativeColor3,
3587
3764
  pickLabelColor as pickLabelColor2,
3588
3765
  resolveCollisions as resolveCollisions2,
3589
3766
  textAscent
@@ -3630,10 +3807,10 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormatter
3630
3807
  let fill;
3631
3808
  if (isStacked2) {
3632
3809
  anchorY = mark.y + mark.height / 2 - textHeight / 2;
3633
- fill = labelColor ?? pickLabelColor2(getRepresentativeColor2(mark.fill), darkMode);
3810
+ fill = labelColor ?? pickLabelColor2(getRepresentativeColor3(mark.fill), darkMode);
3634
3811
  } else {
3635
3812
  anchorY = isNegative ? mark.y + mark.height + LABEL_OFFSET_Y : mark.y - LABEL_OFFSET_Y - textHeight;
3636
- fill = labelColor ?? getRepresentativeColor2(mark.fill);
3813
+ fill = labelColor ?? getRepresentativeColor3(mark.fill);
3637
3814
  }
3638
3815
  fitsInSegment.push(!(isStacked2 && textHeight > mark.height - 2 * LABEL_PADDING_Y));
3639
3816
  candidates.push({
@@ -3700,7 +3877,7 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormatter
3700
3877
 
3701
3878
  // src/charts/column/index.ts
3702
3879
  var columnRenderer = (spec, scales, chartArea, strategy, theme) => {
3703
- const marks = computeColumnMarks(spec, scales, chartArea, strategy);
3880
+ const marks = computeColumnMarks(spec, scales, chartArea, strategy, theme);
3704
3881
  const valueField = spec.encoding?.y && "field" in spec.encoding.y ? spec.encoding.y.field : void 0;
3705
3882
  const labelFormatter = resolveFieldFormatter({
3706
3883
  surfaceFormat: spec.labels.format,
@@ -3729,7 +3906,7 @@ var columnRenderer = (spec, scales, chartArea, strategy, theme) => {
3729
3906
  var DOT_RADIUS = 6;
3730
3907
  var STEM_WIDTH = 2;
3731
3908
  var STEM_COLOR = "#cccccc";
3732
- function computeDotMarks(spec, scales, _chartArea, _strategy) {
3909
+ function computeDotMarks(spec, scales, _chartArea, _strategy, theme) {
3733
3910
  const encoding = spec.encoding;
3734
3911
  const xChannel = encoding.x;
3735
3912
  const yChannel = encoding.y;
@@ -3759,7 +3936,8 @@ function computeDotMarks(spec, scales, _chartArea, _strategy) {
3759
3936
  xScale,
3760
3937
  yScale,
3761
3938
  bandwidth,
3762
- scales
3939
+ scales,
3940
+ resolveKnockoutColor(theme)
3763
3941
  );
3764
3942
  } else {
3765
3943
  marks = computeLollipopMarks(
@@ -3771,7 +3949,8 @@ function computeDotMarks(spec, scales, _chartArea, _strategy) {
3771
3949
  bandwidth,
3772
3950
  baseline,
3773
3951
  scales,
3774
- isSequentialColor
3952
+ isSequentialColor,
3953
+ resolveKnockoutColor(theme)
3775
3954
  );
3776
3955
  }
3777
3956
  const pointMarks = marks.filter((m) => m.type === "point");
@@ -3786,7 +3965,7 @@ function computeDotMarks(spec, scales, _chartArea, _strategy) {
3786
3965
  }
3787
3966
  return marks;
3788
3967
  }
3789
- function computeDumbbellMarks(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, scales) {
3968
+ function computeDumbbellMarks(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, scales, dotKnockout) {
3790
3969
  const marks = [];
3791
3970
  const categoryGroups = groupByField([...data], categoryField);
3792
3971
  for (const [category, rows] of categoryGroups) {
@@ -3834,7 +4013,7 @@ function computeDumbbellMarks(data, valueField, categoryField, colorField, xScal
3834
4013
  cy,
3835
4014
  r: DOT_RADIUS,
3836
4015
  fill: color,
3837
- stroke: "#ffffff",
4016
+ stroke: dotKnockout,
3838
4017
  strokeWidth: 2,
3839
4018
  data: row,
3840
4019
  aria: dotAria
@@ -3843,7 +4022,7 @@ function computeDumbbellMarks(data, valueField, categoryField, colorField, xScal
3843
4022
  }
3844
4023
  return marks;
3845
4024
  }
3846
- function computeLollipopMarks(data, valueField, categoryField, xScale, yScale, bandwidth, baseline, scales, isSequentialColor = false) {
4025
+ function computeLollipopMarks(data, valueField, categoryField, xScale, yScale, bandwidth, baseline, scales, isSequentialColor = false, dotKnockout = "#ffffff") {
3847
4026
  const marks = [];
3848
4027
  for (const row of data) {
3849
4028
  const category = String(row[categoryField] ?? "");
@@ -3880,7 +4059,7 @@ function computeLollipopMarks(data, valueField, categoryField, xScale, yScale, b
3880
4059
  cy,
3881
4060
  r: DOT_RADIUS,
3882
4061
  fill: color,
3883
- stroke: "#ffffff",
4062
+ stroke: dotKnockout,
3884
4063
  strokeWidth: 2,
3885
4064
  data: row,
3886
4065
  aria: dotAria
@@ -3892,7 +4071,7 @@ function computeLollipopMarks(data, valueField, categoryField, xScale, yScale, b
3892
4071
  // src/charts/dot/labels.ts
3893
4072
  import {
3894
4073
  estimateTextWidth as estimateTextWidth5,
3895
- getRepresentativeColor as getRepresentativeColor3,
4074
+ getRepresentativeColor as getRepresentativeColor4,
3896
4075
  resolveCollisions as resolveCollisions3
3897
4076
  } from "@opendata-ai/openchart-core";
3898
4077
  var LABEL_FONT_SIZE3 = 11;
@@ -3934,7 +4113,7 @@ function computeDotLabels(marks, _chartArea, density = "auto", labelPrefix, labe
3934
4113
  fontFamily: "system-ui, -apple-system, sans-serif",
3935
4114
  fontSize: LABEL_FONT_SIZE3,
3936
4115
  fontWeight: LABEL_FONT_WEIGHT3,
3937
- fill: getRepresentativeColor3(mark.fill),
4116
+ fill: getRepresentativeColor4(mark.fill),
3938
4117
  lineHeight: 1.2,
3939
4118
  textAnchor: "start",
3940
4119
  dominantBaseline: "central",
@@ -3956,8 +4135,8 @@ function computeDotLabels(marks, _chartArea, density = "auto", labelPrefix, labe
3956
4135
  }
3957
4136
 
3958
4137
  // src/charts/dot/index.ts
3959
- var dotRenderer = (spec, scales, chartArea, strategy, _theme) => {
3960
- const marks = computeDotMarks(spec, scales, chartArea, strategy);
4138
+ var dotRenderer = (spec, scales, chartArea, strategy, theme) => {
4139
+ const marks = computeDotMarks(spec, scales, chartArea, strategy, theme);
3961
4140
  const pointMarks = marks.filter((m) => m.type === "point");
3962
4141
  const valueField = spec.encoding?.x && "field" in spec.encoding.x ? spec.encoding.x.field : void 0;
3963
4142
  const labelFormatter = resolveFieldFormatter({
@@ -3984,13 +4163,13 @@ var dotRenderer = (spec, scales, chartArea, strategy, _theme) => {
3984
4163
  };
3985
4164
 
3986
4165
  // src/charts/line/index.ts
3987
- import { getRepresentativeColor as getRepresentativeColor6 } from "@opendata-ai/openchart-core";
4166
+ import { getRepresentativeColor as getRepresentativeColor7 } from "@opendata-ai/openchart-core";
3988
4167
 
3989
4168
  // src/charts/line/area.ts
3990
- import { getRepresentativeColor as getRepresentativeColor4, isGradientDef as isGradientDef3 } from "@opendata-ai/openchart-core";
4169
+ import { getRepresentativeColor as getRepresentativeColor6, isGradientDef as isGradientDef3 } from "@opendata-ai/openchart-core";
3991
4170
  import {
3992
4171
  area as area2,
3993
- line as line2,
4172
+ line as line3,
3994
4173
  stack,
3995
4174
  stackOffsetExpand,
3996
4175
  stackOffsetNone,
@@ -3998,6 +4177,10 @@ import {
3998
4177
  stackOrderNone
3999
4178
  } from "d3-shape";
4000
4179
 
4180
+ // src/charts/line/compute.ts
4181
+ import { getRepresentativeColor as getRepresentativeColor5 } from "@opendata-ai/openchart-core";
4182
+ import { line as line2 } from "d3-shape";
4183
+
4001
4184
  // src/charts/line/curves.ts
4002
4185
  import {
4003
4186
  area,
@@ -4043,29 +4226,218 @@ function buildAreaPath(topPoints, bottomPoints, interpolate) {
4043
4226
  return generator(paired) ?? "";
4044
4227
  }
4045
4228
 
4229
+ // src/charts/line/compute.ts
4230
+ var LINE_STROKE_WIDTH = 2;
4231
+ var DEFAULT_POINT_RADIUS = 3;
4232
+ var ENDPOINT_DOT_RADIUS = 4;
4233
+ var STROKE_DASH_PATTERNS = ["", "6 4", "2 3", "8 4 2 4", "4 4", "1 3"];
4234
+ function computeLineMarks(spec, scales, _chartArea, _strategy, theme) {
4235
+ const encoding = spec.encoding;
4236
+ const xChannel = encoding.x;
4237
+ const yChannel = encoding.y;
4238
+ if (!xChannel || !yChannel || !scales.x || !scales.y) {
4239
+ return [];
4240
+ }
4241
+ const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
4242
+ const isSequentialColor = colorEnc?.type === "quantitative";
4243
+ const colorField = isSequentialColor ? void 0 : colorEnc?.field;
4244
+ const sequentialColorField = isSequentialColor ? colorEnc.field : void 0;
4245
+ const dashEnc = encoding.strokeDash && "field" in encoding.strokeDash ? encoding.strokeDash : void 0;
4246
+ const dashField = dashEnc?.field;
4247
+ const dashIndexByValue = /* @__PURE__ */ new Map();
4248
+ if (dashField) {
4249
+ for (const row of spec.data) {
4250
+ const value = row[dashField];
4251
+ if (value == null) continue;
4252
+ const key = String(value);
4253
+ if (!dashIndexByValue.has(key)) dashIndexByValue.set(key, dashIndexByValue.size);
4254
+ }
4255
+ }
4256
+ const groups = groupByField(spec.data, colorField ?? dashField);
4257
+ const pointSeparator = resolveKnockoutColor(theme);
4258
+ const mutedMarks = [];
4259
+ const highlightedMarks = [];
4260
+ const highlight = spec.highlight ?? [];
4261
+ const highlightActive = highlight.length > 0;
4262
+ for (const [seriesKey, rows] of groups) {
4263
+ const color = isSequentialColor ? getSequentialColor(scales, _getMidValue(rows, sequentialColorField)) : getColor(scales, seriesKey);
4264
+ const strokeColor = spec.markDef.stroke ?? resolveSeriesStroke(spec, color, theme);
4265
+ const sortedRows = xChannel.type === "nominal" || xChannel.type === "ordinal" ? rows : sortByField(rows, xChannel.field);
4266
+ const pointsWithData = [];
4267
+ const segments = [];
4268
+ let currentSegment = [];
4269
+ for (const row of sortedRows) {
4270
+ const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
4271
+ const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
4272
+ if (xVal === null || yVal === null) {
4273
+ if (currentSegment.length > 0) {
4274
+ segments.push(currentSegment);
4275
+ currentSegment = [];
4276
+ }
4277
+ continue;
4278
+ }
4279
+ const point = { x: xVal, y: yVal };
4280
+ currentSegment.push(point);
4281
+ pointsWithData.push({ ...point, row });
4282
+ }
4283
+ if (currentSegment.length > 0) {
4284
+ segments.push(currentSegment);
4285
+ }
4286
+ const curve = resolveCurve(spec.markDef.interpolate);
4287
+ const lineGenerator = line2().x((d) => d.x).y((d) => d.y).curve(curve);
4288
+ const allPoints = [];
4289
+ const pathParts = [];
4290
+ for (const segment of segments) {
4291
+ if (segment.length === 0) continue;
4292
+ const pathStr = lineGenerator(segment);
4293
+ if (pathStr) {
4294
+ pathParts.push(pathStr);
4295
+ }
4296
+ allPoints.push(...segment);
4297
+ }
4298
+ if (allPoints.length === 0) continue;
4299
+ const ariaLabel = seriesKey === "__default__" ? `Line with ${allPoints.length} data points` : `${seriesKey}: line with ${allPoints.length} data points`;
4300
+ const aria = {
4301
+ label: ariaLabel
4302
+ };
4303
+ const combinedPath = pathParts.join(" ");
4304
+ const seriesStyleKey = seriesKey === "__default__" ? void 0 : seriesKey;
4305
+ const styleOverride = seriesStyleKey ? spec.seriesStyles?.[seriesStyleKey] : void 0;
4306
+ const isMuted = highlightActive && !highlight.includes(seriesKey);
4307
+ let resolvedStrokeWidth = styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? LINE_STROKE_WIDTH;
4308
+ if (isMuted) {
4309
+ resolvedStrokeWidth = Math.max(1, resolvedStrokeWidth * 0.75);
4310
+ }
4311
+ let strokeDasharray;
4312
+ if (styleOverride?.lineStyle === "dashed") strokeDasharray = "6 4";
4313
+ else if (styleOverride?.lineStyle === "dotted") strokeDasharray = "2 3";
4314
+ else if (dashField) {
4315
+ const dashValue = rows[0]?.[dashField];
4316
+ if (dashValue != null) {
4317
+ const idx = dashIndexByValue.get(String(dashValue)) ?? 0;
4318
+ const pattern = STROKE_DASH_PATTERNS[idx % STROKE_DASH_PATTERNS.length];
4319
+ if (pattern) strokeDasharray = pattern;
4320
+ }
4321
+ }
4322
+ const keyField = encoding.key && "field" in encoding.key ? encoding.key.field : void 0;
4323
+ const rawPointKeys = pointsWithData.map(
4324
+ (p) => keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field])
4325
+ );
4326
+ const lineMark = {
4327
+ type: "line",
4328
+ key: seriesStyleKey ?? "series",
4329
+ pointKeys: dedupeKeys(rawPointKeys),
4330
+ interpolate: spec.markDef.interpolate ?? "monotone",
4331
+ points: allPoints,
4332
+ path: combinedPath,
4333
+ stroke: strokeColor,
4334
+ strokeWidth: resolvedStrokeWidth,
4335
+ strokeDasharray,
4336
+ opacity: styleOverride?.opacity,
4337
+ seriesKey: seriesStyleKey,
4338
+ data: pointsWithData.map((p) => p.row),
4339
+ dataPoints: pointsWithData.map((p) => ({ x: p.x, y: p.y, datum: p.row })),
4340
+ aria
4341
+ };
4342
+ const bucket = isMuted ? mutedMarks : highlightedMarks;
4343
+ bucket.push(lineMark);
4344
+ const markPoint = spec.markDef.point;
4345
+ const isSingleEndpoint = markPoint === "last" || markPoint === "first";
4346
+ const showPoints = markPoint === true || markPoint === "transparent" || markPoint === "endpoints" || isSingleEndpoint || isSequentialColor;
4347
+ if (showPoints) {
4348
+ const isTransparent = markPoint === "transparent";
4349
+ const isEndpoints = markPoint === "endpoints";
4350
+ const seriesShowPoints = styleOverride?.showPoints !== false;
4351
+ const lastIdx = pointsWithData.length - 1;
4352
+ for (let i = 0; i < pointsWithData.length; i++) {
4353
+ const p = pointsWithData[i];
4354
+ const isEndpoint = i === 0 || i === lastIdx;
4355
+ const isLast = i === lastIdx;
4356
+ const isFirst = i === 0;
4357
+ if (isSingleEndpoint) {
4358
+ if (markPoint === "last" && !isLast) continue;
4359
+ if (markPoint === "first" && !isFirst) continue;
4360
+ }
4361
+ const visible = seriesShowPoints && !isTransparent && (!isEndpoints || isEndpoint);
4362
+ let pointColor = color;
4363
+ if (isSequentialColor) {
4364
+ const val = Number(p.row[sequentialColorField]);
4365
+ pointColor = Number.isFinite(val) ? getSequentialColor(scales, val) : color;
4366
+ }
4367
+ const hollow = isEndpoints && visible;
4368
+ const pointColorStr = getRepresentativeColor5(pointColor);
4369
+ const pointKey = keyField ? serializeKeyValue(p.row[keyField]) : seriesStyleKey ? `${seriesStyleKey}|${serializeKeyValue(p.row[xChannel.field])}` : serializeKeyValue(p.row[xChannel.field]);
4370
+ if (isSingleEndpoint) {
4371
+ bucket.push({
4372
+ type: "point",
4373
+ key: pointKey,
4374
+ cx: p.x,
4375
+ cy: p.y,
4376
+ r: ENDPOINT_DOT_RADIUS,
4377
+ fill: strokeColor,
4378
+ stroke: "transparent",
4379
+ strokeWidth: 0,
4380
+ fillOpacity: 1,
4381
+ data: p.row,
4382
+ aria: { decorative: true }
4383
+ });
4384
+ continue;
4385
+ }
4386
+ const pointMark = {
4387
+ type: "point",
4388
+ key: pointKey,
4389
+ cx: p.x,
4390
+ cy: p.y,
4391
+ r: visible ? DEFAULT_POINT_RADIUS : 0,
4392
+ fill: hollow ? "transparent" : pointColorStr,
4393
+ stroke: hollow ? pointColorStr : visible ? pointSeparator : "transparent",
4394
+ strokeWidth: visible ? 1.5 : 0,
4395
+ fillOpacity: isTransparent ? 0 : 1,
4396
+ data: p.row,
4397
+ aria: {
4398
+ label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`
4399
+ }
4400
+ };
4401
+ bucket.push(pointMark);
4402
+ }
4403
+ }
4404
+ }
4405
+ const allMarks = [...mutedMarks, ...highlightedMarks];
4406
+ const lineMarks = allMarks.filter((m) => m.type === "line");
4407
+ const lineKeys = dedupeKeys(lineMarks.map((m) => m.key));
4408
+ lineMarks.forEach((m, i) => {
4409
+ m.key = lineKeys[i];
4410
+ });
4411
+ const pointMarks = allMarks.filter((m) => m.type === "point");
4412
+ if (pointMarks.length > 0) {
4413
+ const pointKeys = dedupeKeys(pointMarks.map((m) => m.key ?? ""));
4414
+ pointMarks.forEach((m, i) => {
4415
+ m.key = pointKeys[i];
4416
+ });
4417
+ }
4418
+ return allMarks;
4419
+ }
4420
+ function _getMidValue(rows, field) {
4421
+ const values = rows.map((r) => Number(r[field])).filter(Number.isFinite);
4422
+ if (values.length === 0) return 0;
4423
+ const sorted = values.sort((a, b) => a - b);
4424
+ return sorted[Math.floor(sorted.length / 2)];
4425
+ }
4426
+
4046
4427
  // src/charts/line/area.ts
4047
4428
  var DEFAULT_FILL_OPACITY = 0.15;
4048
4429
  function isStacked(stackProp) {
4049
4430
  return stackProp !== null && stackProp !== false;
4050
4431
  }
4051
4432
  var SOLO_GRADIENT_STOPS = [
4052
- { offset: 0, opacity: 0.42 },
4053
- { offset: 0.6, opacity: 0.1 },
4433
+ { offset: 0, opacity: 0.2 },
4054
4434
  { offset: 1, opacity: 0 }
4055
4435
  ];
4056
4436
  var OVERLAP_GRADIENT_STOPS = [
4057
- { offset: 0, opacity: 0.22 },
4058
- { offset: 0.7, opacity: 0.04 },
4059
- { offset: 1, opacity: 0 }
4060
- ];
4061
- var STACKED_GRADIENT_STOPS = [
4062
- { offset: 0, opacity: 0.65 },
4063
- { offset: 1, opacity: 0.35 }
4064
- ];
4065
- var STACKED_GRADIENT_STOPS_LIGHT = [
4066
- { offset: 0, opacity: 0.65 },
4437
+ { offset: 0, opacity: 0.14 },
4067
4438
  { offset: 1, opacity: 0 }
4068
4439
  ];
4440
+ var STACKED_FILL_OPACITY = 0.92;
4069
4441
  function buildGradientFill(colorStr, stops) {
4070
4442
  return {
4071
4443
  gradient: "linear",
@@ -4076,7 +4448,7 @@ function buildGradientFill(colorStr, stops) {
4076
4448
  stops: stops.map((s) => ({ offset: s.offset, color: colorStr, opacity: s.opacity }))
4077
4449
  };
4078
4450
  }
4079
- function computeSingleArea(spec, scales, _chartArea) {
4451
+ function computeSingleArea(spec, scales, _chartArea, theme) {
4080
4452
  const encoding = spec.encoding;
4081
4453
  const xChannel = encoding.x;
4082
4454
  const yChannel = encoding.y;
@@ -4123,7 +4495,7 @@ function computeSingleArea(spec, scales, _chartArea) {
4123
4495
  const curve = resolveCurve(spec.markDef.interpolate);
4124
4496
  const areaGenerator = area2().x((d) => d.x).y0((d) => d.yBottom).y1((d) => d.yTop).curve(curve);
4125
4497
  const pathStr = areaGenerator(validPoints) ?? "";
4126
- const topLineGenerator = line2().x((d) => d.x).y((d) => d.yTop).curve(curve);
4498
+ const topLineGenerator = line3().x((d) => d.x).y((d) => d.yTop).curve(curve);
4127
4499
  const topPathStr = topLineGenerator(validPoints) ?? "";
4128
4500
  const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
4129
4501
  const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
@@ -4136,7 +4508,7 @@ function computeSingleArea(spec, scales, _chartArea) {
4136
4508
  fillValue = markFill;
4137
4509
  fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.opacity ?? (y2Channel ? 0.25 : DEFAULT_FILL_OPACITY);
4138
4510
  } else {
4139
- const colorStr = getRepresentativeColor4(color);
4511
+ const colorStr = getRepresentativeColor6(color);
4140
4512
  fillValue = buildGradientFill(colorStr, defaultGradientStops);
4141
4513
  fillOpacity = 1;
4142
4514
  }
@@ -4155,8 +4527,12 @@ function computeSingleArea(spec, scales, _chartArea) {
4155
4527
  topPath: topPathStr,
4156
4528
  fill: fillValue,
4157
4529
  fillOpacity,
4158
- stroke: spec.markDef.stroke ?? getRepresentativeColor4(isGradientDef3(fillValue) ? color : fillValue),
4159
- strokeWidth: spec.markDef.strokeWidth ?? 1.5,
4530
+ // The top edge is a foreground stroke, so it goes through the same
4531
+ // light-canvas adaptation as a line mark's stroke. Otherwise the area's
4532
+ // own top path and the LineMark the renderer draws over it (both from
4533
+ // this series color) come out as two different colors.
4534
+ stroke: spec.markDef.stroke ?? resolveSeriesStroke(spec, isGradientDef3(fillValue) ? color : fillValue, theme),
4535
+ strokeWidth: spec.markDef.strokeWidth ?? LINE_STROKE_WIDTH,
4160
4536
  seriesKey: seriesKey === "__default__" ? void 0 : seriesKey,
4161
4537
  data: validPoints.map((p) => p.row),
4162
4538
  dataPoints: validPoints.map((p) => ({ x: p.x, y: p.yTop, datum: p.row })),
@@ -4165,13 +4541,13 @@ function computeSingleArea(spec, scales, _chartArea) {
4165
4541
  }
4166
4542
  return marks;
4167
4543
  }
4168
- function computeStackedArea(spec, scales, chartArea, darkMode) {
4544
+ function computeStackedArea(spec, scales, chartArea, theme) {
4169
4545
  const encoding = spec.encoding;
4170
4546
  const xChannel = encoding.x;
4171
4547
  const yChannel = encoding.y;
4172
4548
  const colorField = encoding.color && "field" in encoding.color ? encoding.color.field : void 0;
4173
4549
  if (!xChannel || !yChannel || !scales.x || !scales.y || !colorField) {
4174
- return computeSingleArea(spec, scales, chartArea);
4550
+ return computeSingleArea(spec, scales, chartArea, theme);
4175
4551
  }
4176
4552
  const sortedData = xChannel.type === "nominal" || xChannel.type === "ordinal" ? spec.data : sortByField(spec.data, xChannel.field);
4177
4553
  const seriesKeys = /* @__PURE__ */ new Set();
@@ -4228,7 +4604,7 @@ function computeStackedArea(spec, scales, chartArea, darkMode) {
4228
4604
  const stackCurve = resolveCurve(spec.markDef.interpolate);
4229
4605
  const areaGenerator = area2().x((p) => p.x).y0((p) => p.yBottom).y1((p) => p.yTop).curve(stackCurve);
4230
4606
  const pathStr = areaGenerator(validPoints) ?? "";
4231
- const topLineGenerator = line2().x((p) => p.x).y((p) => p.yTop).curve(stackCurve);
4607
+ const topLineGenerator = line3().x((p) => p.x).y((p) => p.yTop).curve(stackCurve);
4232
4608
  const topPathStr = topLineGenerator(validPoints) ?? "";
4233
4609
  const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
4234
4610
  const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
@@ -4239,265 +4615,67 @@ function computeStackedArea(spec, scales, chartArea, darkMode) {
4239
4615
  let fillValue;
4240
4616
  let fillOpacity;
4241
4617
  if (markFill != null) {
4242
- fillValue = markFill;
4243
- fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.opacity ?? 0.7;
4244
- } else {
4245
- const colorStr = getRepresentativeColor4(color);
4246
- const stackedStops = darkMode ? STACKED_GRADIENT_STOPS : STACKED_GRADIENT_STOPS_LIGHT;
4247
- fillValue = buildGradientFill(colorStr, stackedStops);
4248
- fillOpacity = 1;
4249
- }
4250
- const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
4251
- marks.push({
4252
- type: "area",
4253
- key: seriesKey,
4254
- pointKeys: dedupeKeys(rawPointKeys),
4255
- interpolate: spec.markDef.interpolate ?? "monotone",
4256
- topPoints,
4257
- bottomPoints,
4258
- path: pathStr,
4259
- topPath: topPathStr,
4260
- fill: fillValue,
4261
- fillOpacity,
4262
- stroke: getRepresentativeColor4(color),
4263
- strokeWidth: 1,
4264
- seriesKey,
4265
- data: layer.map((d) => {
4266
- const xStr = String(d.data.__x__);
4267
- return rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? d.data;
4268
- }),
4269
- dataPoints: validPoints.map((p, idx) => {
4270
- const xStr = String(layer[idx]?.data.__x__);
4271
- const datum = rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? layer[idx]?.data ?? {};
4272
- return { x: p.x, y: p.yTop, datum };
4273
- }),
4274
- aria
4275
- });
4276
- }
4277
- return marks;
4278
- }
4279
- function computeAreaMarks(spec, scales, chartArea, darkMode) {
4280
- const encoding = spec.encoding;
4281
- const yChannel = encoding.y;
4282
- let marks;
4283
- if (yChannel && isStacked(yChannel.stack)) {
4284
- marks = computeStackedArea(spec, scales, chartArea, darkMode);
4285
- } else {
4286
- marks = computeSingleArea(spec, scales, chartArea);
4287
- }
4288
- const areaKeys = dedupeKeys(marks.map((m) => m.key));
4289
- marks.forEach((m, i) => {
4290
- m.key = areaKeys[i];
4291
- });
4292
- const highlight = spec.highlight ?? [];
4293
- if (highlight.length > 0) {
4294
- const highlightSet = new Set(highlight);
4295
- marks.sort((a, b) => {
4296
- const aMuted = a.seriesKey != null && !highlightSet.has(a.seriesKey);
4297
- const bMuted = b.seriesKey != null && !highlightSet.has(b.seriesKey);
4298
- if (aMuted === bMuted) return 0;
4299
- return aMuted ? -1 : 1;
4300
- });
4301
- }
4302
- return marks;
4303
- }
4304
-
4305
- // src/charts/line/compute.ts
4306
- import { getRepresentativeColor as getRepresentativeColor5 } from "@opendata-ai/openchart-core";
4307
- import { line as line3 } from "d3-shape";
4308
- var DEFAULT_STROKE_WIDTH = 1.5;
4309
- var DEFAULT_POINT_RADIUS = 3;
4310
- var STROKE_DASH_PATTERNS = ["", "6 4", "2 3", "8 4 2 4", "4 4", "1 3"];
4311
- function computeLineMarks(spec, scales, _chartArea, _strategy) {
4312
- const encoding = spec.encoding;
4313
- const xChannel = encoding.x;
4314
- const yChannel = encoding.y;
4315
- if (!xChannel || !yChannel || !scales.x || !scales.y) {
4316
- return [];
4317
- }
4318
- const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
4319
- const isSequentialColor = colorEnc?.type === "quantitative";
4320
- const colorField = isSequentialColor ? void 0 : colorEnc?.field;
4321
- const sequentialColorField = isSequentialColor ? colorEnc.field : void 0;
4322
- const dashEnc = encoding.strokeDash && "field" in encoding.strokeDash ? encoding.strokeDash : void 0;
4323
- const dashField = dashEnc?.field;
4324
- const dashIndexByValue = /* @__PURE__ */ new Map();
4325
- if (dashField) {
4326
- for (const row of spec.data) {
4327
- const value = row[dashField];
4328
- if (value == null) continue;
4329
- const key = String(value);
4330
- if (!dashIndexByValue.has(key)) dashIndexByValue.set(key, dashIndexByValue.size);
4331
- }
4332
- }
4333
- const groups = groupByField(spec.data, colorField ?? dashField);
4334
- const mutedMarks = [];
4335
- const highlightedMarks = [];
4336
- const highlight = spec.highlight ?? [];
4337
- const highlightActive = highlight.length > 0;
4338
- for (const [seriesKey, rows] of groups) {
4339
- const color = isSequentialColor ? getSequentialColor(scales, _getMidValue(rows, sequentialColorField)) : getColor(scales, seriesKey);
4340
- const strokeColor = spec.markDef.stroke ?? getRepresentativeColor5(color);
4341
- const sortedRows = xChannel.type === "nominal" || xChannel.type === "ordinal" ? rows : sortByField(rows, xChannel.field);
4342
- const pointsWithData = [];
4343
- const segments = [];
4344
- let currentSegment = [];
4345
- for (const row of sortedRows) {
4346
- const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
4347
- const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
4348
- if (xVal === null || yVal === null) {
4349
- if (currentSegment.length > 0) {
4350
- segments.push(currentSegment);
4351
- currentSegment = [];
4352
- }
4353
- continue;
4354
- }
4355
- const point = { x: xVal, y: yVal };
4356
- currentSegment.push(point);
4357
- pointsWithData.push({ ...point, row });
4358
- }
4359
- if (currentSegment.length > 0) {
4360
- segments.push(currentSegment);
4361
- }
4362
- const curve = resolveCurve(spec.markDef.interpolate);
4363
- const lineGenerator = line3().x((d) => d.x).y((d) => d.y).curve(curve);
4364
- const allPoints = [];
4365
- const pathParts = [];
4366
- for (const segment of segments) {
4367
- if (segment.length === 0) continue;
4368
- const pathStr = lineGenerator(segment);
4369
- if (pathStr) {
4370
- pathParts.push(pathStr);
4371
- }
4372
- allPoints.push(...segment);
4373
- }
4374
- if (allPoints.length === 0) continue;
4375
- const ariaLabel = seriesKey === "__default__" ? `Line with ${allPoints.length} data points` : `${seriesKey}: line with ${allPoints.length} data points`;
4376
- const aria = {
4377
- label: ariaLabel
4378
- };
4379
- const combinedPath = pathParts.join(" ");
4380
- const seriesStyleKey = seriesKey === "__default__" ? void 0 : seriesKey;
4381
- const styleOverride = seriesStyleKey ? spec.seriesStyles?.[seriesStyleKey] : void 0;
4382
- const isMuted = highlightActive && !highlight.includes(seriesKey);
4383
- let resolvedStrokeWidth = styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? DEFAULT_STROKE_WIDTH;
4384
- if (isMuted) {
4385
- resolvedStrokeWidth = Math.max(1, resolvedStrokeWidth * 0.75);
4386
- }
4387
- let strokeDasharray;
4388
- if (styleOverride?.lineStyle === "dashed") strokeDasharray = "6 4";
4389
- else if (styleOverride?.lineStyle === "dotted") strokeDasharray = "2 3";
4390
- else if (dashField) {
4391
- const dashValue = rows[0]?.[dashField];
4392
- if (dashValue != null) {
4393
- const idx = dashIndexByValue.get(String(dashValue)) ?? 0;
4394
- const pattern = STROKE_DASH_PATTERNS[idx % STROKE_DASH_PATTERNS.length];
4395
- if (pattern) strokeDasharray = pattern;
4396
- }
4397
- }
4398
- const keyField = encoding.key && "field" in encoding.key ? encoding.key.field : void 0;
4399
- const rawPointKeys = pointsWithData.map(
4400
- (p) => keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field])
4401
- );
4402
- const lineMark = {
4403
- type: "line",
4404
- key: seriesStyleKey ?? "series",
4618
+ fillValue = markFill;
4619
+ fillOpacity = isGradientDef3(markFill) ? 1 : spec.markDef.opacity ?? 0.7;
4620
+ } else {
4621
+ fillValue = getRepresentativeColor6(color);
4622
+ fillOpacity = STACKED_FILL_OPACITY;
4623
+ }
4624
+ const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
4625
+ marks.push({
4626
+ type: "area",
4627
+ key: seriesKey,
4405
4628
  pointKeys: dedupeKeys(rawPointKeys),
4406
4629
  interpolate: spec.markDef.interpolate ?? "monotone",
4407
- points: allPoints,
4408
- path: combinedPath,
4409
- stroke: strokeColor,
4410
- strokeWidth: resolvedStrokeWidth,
4411
- strokeDasharray,
4412
- opacity: styleOverride?.opacity,
4413
- seriesKey: seriesStyleKey,
4414
- data: pointsWithData.map((p) => p.row),
4415
- dataPoints: pointsWithData.map((p) => ({ x: p.x, y: p.y, datum: p.row })),
4630
+ topPoints,
4631
+ bottomPoints,
4632
+ path: pathStr,
4633
+ topPath: topPathStr,
4634
+ fill: fillValue,
4635
+ fillOpacity,
4636
+ stroke: resolveSeriesStroke(spec, color, theme),
4637
+ strokeWidth: 1,
4638
+ seriesKey,
4639
+ stacked: true,
4640
+ stackNormalized: stackProp === "normalize",
4641
+ data: layer.map((d) => {
4642
+ const xStr = String(d.data.__x__);
4643
+ return rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? d.data;
4644
+ }),
4645
+ dataPoints: validPoints.map((p, idx) => {
4646
+ const xStr = String(layer[idx]?.data.__x__);
4647
+ const datum = rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? layer[idx]?.data ?? {};
4648
+ return { x: p.x, y: p.yTop, datum };
4649
+ }),
4416
4650
  aria
4417
- };
4418
- const bucket = isMuted ? mutedMarks : highlightedMarks;
4419
- bucket.push(lineMark);
4420
- const markPoint = spec.markDef.point;
4421
- const isSingleEndpoint = markPoint === "last" || markPoint === "first";
4422
- const showPoints = markPoint === true || markPoint === "transparent" || markPoint === "endpoints" || isSingleEndpoint || isSequentialColor;
4423
- if (showPoints) {
4424
- const isTransparent = markPoint === "transparent";
4425
- const isEndpoints = markPoint === "endpoints";
4426
- const seriesShowPoints = styleOverride?.showPoints !== false;
4427
- const lastIdx = pointsWithData.length - 1;
4428
- for (let i = 0; i < pointsWithData.length; i++) {
4429
- const p = pointsWithData[i];
4430
- const isEndpoint = i === 0 || i === lastIdx;
4431
- const isLast = i === lastIdx;
4432
- const isFirst = i === 0;
4433
- if (isSingleEndpoint) {
4434
- if (markPoint === "last" && !isLast) continue;
4435
- if (markPoint === "first" && !isFirst) continue;
4436
- }
4437
- const visible = seriesShowPoints && !isTransparent && (!isEndpoints || isEndpoint);
4438
- let pointColor = color;
4439
- if (isSequentialColor) {
4440
- const val = Number(p.row[sequentialColorField]);
4441
- pointColor = Number.isFinite(val) ? getSequentialColor(scales, val) : color;
4442
- }
4443
- const hollow = isEndpoints && visible;
4444
- const pointColorStr = getRepresentativeColor5(pointColor);
4445
- const pointKey = keyField ? serializeKeyValue(p.row[keyField]) : seriesStyleKey ? `${seriesStyleKey}|${serializeKeyValue(p.row[xChannel.field])}` : serializeKeyValue(p.row[xChannel.field]);
4446
- if (isSingleEndpoint) {
4447
- bucket.push({
4448
- type: "point",
4449
- key: pointKey,
4450
- cx: p.x,
4451
- cy: p.y,
4452
- r: 3.5,
4453
- fill: strokeColor,
4454
- stroke: "transparent",
4455
- strokeWidth: 0,
4456
- fillOpacity: 1,
4457
- data: p.row,
4458
- aria: { decorative: true }
4459
- });
4460
- continue;
4461
- }
4462
- const pointMark = {
4463
- type: "point",
4464
- key: pointKey,
4465
- cx: p.x,
4466
- cy: p.y,
4467
- r: visible ? DEFAULT_POINT_RADIUS : 0,
4468
- fill: hollow ? "transparent" : pointColorStr,
4469
- stroke: hollow ? pointColorStr : visible ? "#ffffff" : "transparent",
4470
- strokeWidth: visible ? 1.5 : 0,
4471
- fillOpacity: isTransparent ? 0 : 1,
4472
- data: p.row,
4473
- aria: {
4474
- label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`
4475
- }
4476
- };
4477
- bucket.push(pointMark);
4478
- }
4479
- }
4651
+ });
4480
4652
  }
4481
- const allMarks = [...mutedMarks, ...highlightedMarks];
4482
- const lineMarks = allMarks.filter((m) => m.type === "line");
4483
- const lineKeys = dedupeKeys(lineMarks.map((m) => m.key));
4484
- lineMarks.forEach((m, i) => {
4485
- m.key = lineKeys[i];
4653
+ return marks;
4654
+ }
4655
+ function computeAreaMarks(spec, scales, chartArea, theme) {
4656
+ const encoding = spec.encoding;
4657
+ const yChannel = encoding.y;
4658
+ let marks;
4659
+ if (yChannel && isStacked(yChannel.stack)) {
4660
+ marks = computeStackedArea(spec, scales, chartArea, theme);
4661
+ } else {
4662
+ marks = computeSingleArea(spec, scales, chartArea, theme);
4663
+ }
4664
+ const areaKeys = dedupeKeys(marks.map((m) => m.key));
4665
+ marks.forEach((m, i) => {
4666
+ m.key = areaKeys[i];
4486
4667
  });
4487
- const pointMarks = allMarks.filter((m) => m.type === "point");
4488
- if (pointMarks.length > 0) {
4489
- const pointKeys = dedupeKeys(pointMarks.map((m) => m.key ?? ""));
4490
- pointMarks.forEach((m, i) => {
4491
- m.key = pointKeys[i];
4668
+ const highlight = spec.highlight ?? [];
4669
+ if (highlight.length > 0) {
4670
+ const highlightSet = new Set(highlight);
4671
+ marks.sort((a, b) => {
4672
+ const aMuted = a.seriesKey != null && !highlightSet.has(a.seriesKey);
4673
+ const bMuted = b.seriesKey != null && !highlightSet.has(b.seriesKey);
4674
+ if (aMuted === bMuted) return 0;
4675
+ return aMuted ? -1 : 1;
4492
4676
  });
4493
4677
  }
4494
- return allMarks;
4495
- }
4496
- function _getMidValue(rows, field) {
4497
- const values = rows.map((r) => Number(r[field])).filter(Number.isFinite);
4498
- if (values.length === 0) return 0;
4499
- const sorted = values.sort((a, b) => a - b);
4500
- return sorted[Math.floor(sorted.length / 2)];
4678
+ return marks;
4501
4679
  }
4502
4680
 
4503
4681
  // src/charts/line/labels.ts
@@ -4586,8 +4764,8 @@ function computeLineLabels(marks, strategy, density = "auto", labelOffsets, spec
4586
4764
 
4587
4765
  // src/charts/line/index.ts
4588
4766
  var AREA_POINT_RADIUS = 3;
4589
- var lineRenderer = (spec, scales, chartArea, strategy, _theme) => {
4590
- const marks = computeLineMarks(spec, scales, chartArea, strategy);
4767
+ var lineRenderer = (spec, scales, chartArea, strategy, theme) => {
4768
+ const marks = computeLineMarks(spec, scales, chartArea, strategy, theme);
4591
4769
  const lineMarks = marks.filter((m) => m.type === "line");
4592
4770
  const labelMap = computeLineLabels(
4593
4771
  lineMarks,
@@ -4607,11 +4785,11 @@ var lineRenderer = (spec, scales, chartArea, strategy, _theme) => {
4607
4785
  return marks;
4608
4786
  };
4609
4787
  var areaRenderer = (spec, scales, chartArea, strategy, theme) => {
4610
- const areas = computeAreaMarks(spec, scales, chartArea, theme.isDark);
4788
+ const areas = computeAreaMarks(spec, scales, chartArea, theme);
4611
4789
  const encoding = spec.encoding;
4612
4790
  const hasColor = !!(encoding.color && "field" in encoding.color);
4613
- const lines = hasColor ? linesFromAreas(areas) : computeLineMarks(spec, scales, chartArea, strategy);
4614
- const points = hasColor && spec.markDef.point ? pointsFromAreas(areas, spec.markDef.point) : [];
4791
+ const lines = hasColor ? linesFromAreas(areas) : computeLineMarks(spec, scales, chartArea, strategy, theme);
4792
+ const points = hasColor && spec.markDef.point ? pointsFromAreas(areas, spec.markDef.point, resolveKnockoutColor(theme)) : [];
4615
4793
  return [...areas, ...lines, ...points];
4616
4794
  };
4617
4795
  function linesFromAreas(areas) {
@@ -4619,7 +4797,10 @@ function linesFromAreas(areas) {
4619
4797
  type: "line",
4620
4798
  points: a.topPoints,
4621
4799
  path: a.topPath,
4622
- stroke: getRepresentativeColor6(a.fill),
4800
+ // The area already resolved its top-edge stroke (palette color, adapted
4801
+ // for a light canvas, or the author's markDef.stroke). Re-deriving it from
4802
+ // the fill here would paint an unadapted line over the adapted top path.
4803
+ stroke: a.stroke ?? getRepresentativeColor7(a.fill),
4623
4804
  strokeWidth: a.strokeWidth ?? 1,
4624
4805
  seriesKey: a.seriesKey,
4625
4806
  data: a.data,
@@ -4627,12 +4808,12 @@ function linesFromAreas(areas) {
4627
4808
  aria: { label: `${a.seriesKey ?? "Series"}: line with ${a.topPoints.length} data points` }
4628
4809
  }));
4629
4810
  }
4630
- function pointsFromAreas(areas, pointMode) {
4811
+ function pointsFromAreas(areas, pointMode, separator) {
4631
4812
  const isTransparent = pointMode === "transparent";
4632
4813
  const isEndpoints = pointMode === "endpoints";
4633
4814
  const points = [];
4634
4815
  for (const a of areas) {
4635
- const stroke = getRepresentativeColor6(a.fill);
4816
+ const stroke = a.stroke ?? getRepresentativeColor7(a.fill);
4636
4817
  const lastIdx = a.topPoints.length - 1;
4637
4818
  for (let i = 0; i < a.topPoints.length; i++) {
4638
4819
  const pt = a.topPoints[i];
@@ -4645,7 +4826,7 @@ function pointsFromAreas(areas, pointMode) {
4645
4826
  cy: pt.y,
4646
4827
  r: visible ? AREA_POINT_RADIUS : 0,
4647
4828
  fill: hollow ? "transparent" : stroke,
4648
- stroke: hollow ? stroke : visible ? "#ffffff" : "transparent",
4829
+ stroke: hollow ? stroke : visible ? separator : "transparent",
4649
4830
  strokeWidth: visible ? 1.5 : 0,
4650
4831
  fillOpacity: isTransparent ? 0 : 1,
4651
4832
  data: a.data[i] ?? {},
@@ -4695,6 +4876,7 @@ function packSeats(seats, rows) {
4695
4876
  slots.sort((a, b) => b.angle - a.angle || b.radius - a.radius);
4696
4877
  return slots;
4697
4878
  }
4879
+ var SEAT_STROKE_WIDTH = 0.75;
4698
4880
  function resolveMajority(totalSeats, majorityLine) {
4699
4881
  if (majorityLine === false) return null;
4700
4882
  const defaultSeats = Math.floor(totalSeats / 2) + 1;
@@ -4737,6 +4919,7 @@ function computeParliamentMarks(spec, scales, chartArea, _strategy, theme) {
4737
4919
  const seatRadius = typeof seatRadiusOpt === "number" && seatRadiusOpt > 0 ? seatRadiusOpt : Math.max(1.5, autoSeatRadius);
4738
4920
  const centerX = chartArea.x + chartArea.width / 2;
4739
4921
  const centerY = chartArea.y + chartArea.height - seatRadius;
4922
+ const knockout = stackSeamStroke(theme);
4740
4923
  const marks = [];
4741
4924
  let slotIndex = 0;
4742
4925
  let animationIndex = 0;
@@ -4760,8 +4943,10 @@ function computeParliamentMarks(spec, scales, chartArea, _strategy, theme) {
4760
4943
  cy,
4761
4944
  r: seatRadius,
4762
4945
  fill,
4763
- stroke: "none",
4764
- strokeWidth: 0,
4946
+ // Knockout ring: seats are packed tight enough that same-party
4947
+ // neighbours merge into a blob without a hairline of canvas between.
4948
+ stroke: knockout,
4949
+ strokeWidth: SEAT_STROKE_WIDTH,
4765
4950
  data: row,
4766
4951
  aria,
4767
4952
  animationIndex
@@ -4793,7 +4978,7 @@ function computeParliamentMarks(spec, scales, chartArea, _strategy, theme) {
4793
4978
  x: centerX,
4794
4979
  y: Math.max(chartArea.y + theme.fonts.sizes.small, lineTop - 6),
4795
4980
  text: majority.label,
4796
- fill: theme.colors.text,
4981
+ fill: theme.colors.axis,
4797
4982
  fontSize: theme.fonts.sizes.small,
4798
4983
  fontWeight: theme.fonts.weights.semibold,
4799
4984
  textAnchor: "middle",
@@ -4811,9 +4996,15 @@ var parliamentRenderer = (spec, scales, chartArea, strategy, theme) => {
4811
4996
  };
4812
4997
 
4813
4998
  // src/charts/pie/compute.ts
4814
- import { formatPercent as formatPercent2, isConditionalDef, isGradientDef as isGradientDef4 } from "@opendata-ai/openchart-core";
4999
+ import {
5000
+ CATEGORICAL_FILL_PALETTE,
5001
+ formatPercent as formatPercent2,
5002
+ isConditionalDef,
5003
+ isGradientDef as isGradientDef4
5004
+ } from "@opendata-ai/openchart-core";
4815
5005
  import { arc as d3Arc, pie as d3Pie } from "d3-shape";
4816
5006
  var SMALL_SLICE_THRESHOLD = 0.03;
5007
+ var SLICE_STROKE_WIDTH = 1.5;
4817
5008
  var PIE_PAD_ANGLE = 0.01;
4818
5009
  function buildArcPath(geom) {
4819
5010
  return d3Arc()({
@@ -4824,18 +5015,7 @@ function buildArcPath(geom) {
4824
5015
  padAngle: PIE_PAD_ANGLE
4825
5016
  }) ?? "";
4826
5017
  }
4827
- var DEFAULT_PALETTE = [
4828
- "#1b7fa3",
4829
- "#c44e52",
4830
- "#6a9f58",
4831
- "#d47215",
4832
- "#507e79",
4833
- "#9a6a8d",
4834
- "#c4636b",
4835
- "#9c755f",
4836
- "#a88f22",
4837
- "#858078"
4838
- ];
5018
+ var DEFAULT_PALETTE = CATEGORICAL_FILL_PALETTE;
4839
5019
  function groupSmallSlices(slices, threshold) {
4840
5020
  const total = slices.reduce((sum, s) => sum + s.value, 0);
4841
5021
  if (total === 0) return slices;
@@ -4879,7 +5059,9 @@ function computeSweepBounds(startAngle, endAngle) {
4879
5059
  maxY: Math.max(...ys)
4880
5060
  };
4881
5061
  }
4882
- function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false) {
5062
+ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false, theme) {
5063
+ const palette = theme?.colors.categoricalFill ?? DEFAULT_PALETTE;
5064
+ const sliceStroke = resolveKnockoutColor(theme);
4883
5065
  const encoding = spec.encoding;
4884
5066
  const startAngle = spec.markDef.startAngle ?? 0;
4885
5067
  const endAngle = spec.markDef.endAngle ?? Math.PI * 2;
@@ -4950,13 +5132,13 @@ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false) {
4950
5132
  const colorScale = scales.color.scale;
4951
5133
  color = colorScale(slice.label);
4952
5134
  } else {
4953
- color = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
5135
+ color = palette[i % palette.length];
4954
5136
  }
4955
5137
  } else if (scales.color && categoryField) {
4956
5138
  const colorScale = scales.color.scale;
4957
5139
  color = colorScale(slice.label);
4958
5140
  } else {
4959
- color = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
5141
+ color = palette[i % palette.length];
4960
5142
  }
4961
5143
  const path = arcGenerator(arcDatum) ?? "";
4962
5144
  const centroidResult = arcGenerator.centroid(arcDatum);
@@ -4977,8 +5159,9 @@ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false) {
4977
5159
  startAngle: arcDatum.startAngle,
4978
5160
  endAngle: arcDatum.endAngle,
4979
5161
  fill: color,
4980
- stroke: "#ffffff",
4981
- strokeWidth: 2,
5162
+ stroke: sliceStroke,
5163
+ strokeWidth: SLICE_STROKE_WIDTH,
5164
+ seriesKey: slice.label,
4982
5165
  data: slice.originalRow,
4983
5166
  aria
4984
5167
  });
@@ -4996,10 +5179,12 @@ function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false) {
4996
5179
 
4997
5180
  // src/charts/pie/labels.ts
4998
5181
  import { estimateTextWidth as estimateTextWidth7, resolveCollisions as resolveCollisions5 } from "@opendata-ai/openchart-core";
4999
- var LABEL_FONT_SIZE5 = 10;
5182
+ var LABEL_FONT_SIZE5 = 11;
5000
5183
  var LABEL_FONT_WEIGHT5 = 500;
5001
5184
  var LEADER_LINE_OFFSET = 12;
5002
- function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333333") {
5185
+ function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333333", options = {}) {
5186
+ const fontFamily = options.fontFamily ?? "system-ui, -apple-system, sans-serif";
5187
+ const formatValue3 = options.formatValue;
5003
5188
  if (marks.length === 0) return [];
5004
5189
  const centerX = marks[0].center.x;
5005
5190
  const centerY = marks[0].center.y;
@@ -5012,8 +5197,15 @@ function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333
5012
5197
  const ariaLabel = mark.aria.label;
5013
5198
  if (!ariaLabel) continue;
5014
5199
  const firstColon = ariaLabel.indexOf(":");
5015
- const labelText = firstColon >= 0 ? ariaLabel.slice(0, firstColon).trim() : "";
5016
- if (!labelText) continue;
5200
+ const sliceName = firstColon >= 0 ? ariaLabel.slice(0, firstColon).trim() : "";
5201
+ if (!sliceName) continue;
5202
+ const openParen = ariaLabel.lastIndexOf("(");
5203
+ const closeParen = ariaLabel.lastIndexOf(")");
5204
+ const percentText = openParen > firstColon && closeParen > openParen ? ariaLabel.slice(openParen + 1, closeParen).trim() : "";
5205
+ const rawValueText = openParen > firstColon ? ariaLabel.slice(firstColon + 1, openParen).trim() : "";
5206
+ const rawValue = Number(rawValueText);
5207
+ const valueText = formatValue3 && Number.isFinite(rawValue) ? formatValue3(rawValue) : percentText;
5208
+ const labelText = valueText ? `${sliceName} ${valueText}` : sliceName;
5017
5209
  const textWidth = estimateTextWidth7(labelText, LABEL_FONT_SIZE5, LABEL_FONT_WEIGHT5);
5018
5210
  const textHeight = LABEL_FONT_SIZE5 * 1.2;
5019
5211
  const midAngle = (mark.startAngle + mark.endAngle) / 2;
@@ -5034,7 +5226,7 @@ function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333
5034
5226
  // instead of zipping positionally.
5035
5227
  index: marks.indexOf(mark),
5036
5228
  style: {
5037
- fontFamily: "system-ui, -apple-system, sans-serif",
5229
+ fontFamily,
5038
5230
  fontSize: LABEL_FONT_SIZE5,
5039
5231
  fontWeight: LABEL_FONT_WEIGHT5,
5040
5232
  fill: _textFill,
@@ -5078,28 +5270,96 @@ function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333
5078
5270
  }
5079
5271
 
5080
5272
  // src/charts/pie/index.ts
5273
+ function pieLabelOptions(spec, theme) {
5274
+ const valueChannel = spec.encoding.y ?? spec.encoding.x;
5275
+ const valueField = valueChannel && "field" in valueChannel ? valueChannel.field : void 0;
5276
+ if (!spec.labels.format) return { fontFamily: theme.fonts.family };
5277
+ const formatter = resolveFieldFormatter({
5278
+ surfaceFormat: spec.labels.format,
5279
+ values: valueField ? spec.data.map((r) => r[valueField]) : []
5280
+ });
5281
+ return { fontFamily: theme.fonts.family, formatValue: formatter };
5282
+ }
5283
+ function centerLabelMarks(spec, marks, theme) {
5284
+ const config = spec.markDef.centerLabel;
5285
+ if (!config || marks.length === 0) return [];
5286
+ const text = typeof config === "string" ? config : config.text;
5287
+ if (!text) return [];
5288
+ const subtitle = typeof config === "string" ? void 0 : config.subtitle;
5289
+ const { center } = marks[0];
5290
+ const valueSize = theme.fonts.sizes.metricValue;
5291
+ const captionSize = theme.fonts.sizes.small;
5292
+ const valueY = subtitle ? center.y - captionSize * 0.6 : center.y;
5293
+ const out = [
5294
+ {
5295
+ type: "textMark",
5296
+ key: "oc-center-value",
5297
+ x: center.x,
5298
+ y: valueY,
5299
+ text,
5300
+ fill: theme.colors.text,
5301
+ fontSize: valueSize,
5302
+ fontWeight: theme.fonts.weights.semibold,
5303
+ fontFamily: theme.fonts.family,
5304
+ textAnchor: "middle",
5305
+ dominantBaseline: "central",
5306
+ data: {},
5307
+ aria: { decorative: true }
5308
+ }
5309
+ ];
5310
+ if (subtitle) {
5311
+ out.push({
5312
+ type: "textMark",
5313
+ key: "oc-center-subtitle",
5314
+ x: center.x,
5315
+ y: valueY + valueSize * 0.75 + captionSize * 0.4,
5316
+ text: subtitle,
5317
+ fill: theme.colors.axis,
5318
+ fontSize: captionSize,
5319
+ fontWeight: theme.fonts.weights.normal,
5320
+ fontFamily: theme.fonts.family,
5321
+ textAnchor: "middle",
5322
+ dominantBaseline: "central",
5323
+ data: {},
5324
+ aria: { decorative: true }
5325
+ });
5326
+ }
5327
+ return out;
5328
+ }
5081
5329
  var pieRenderer = (spec, scales, chartArea, strategy, theme) => {
5082
- const marks = computePieMarks(spec, scales, chartArea, strategy, false);
5083
- const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
5330
+ const marks = computePieMarks(spec, scales, chartArea, strategy, false, theme);
5331
+ const labels = computePieLabels(
5332
+ marks,
5333
+ chartArea,
5334
+ spec.labels.density,
5335
+ theme.colors.text,
5336
+ pieLabelOptions(spec, theme)
5337
+ );
5084
5338
  for (const label of labels) {
5085
5339
  if (label.index !== void 0 && marks[label.index]) marks[label.index].label = label;
5086
5340
  }
5087
5341
  return marks;
5088
5342
  };
5089
5343
  var donutRenderer = (spec, scales, chartArea, strategy, theme) => {
5090
- const marks = computePieMarks(spec, scales, chartArea, strategy, true);
5091
- const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
5344
+ const marks = computePieMarks(spec, scales, chartArea, strategy, true, theme);
5345
+ const labels = computePieLabels(
5346
+ marks,
5347
+ chartArea,
5348
+ spec.labels.density,
5349
+ theme.colors.text,
5350
+ pieLabelOptions(spec, theme)
5351
+ );
5092
5352
  for (const label of labels) {
5093
5353
  if (label.index !== void 0 && marks[label.index]) marks[label.index].label = label;
5094
5354
  }
5095
- return marks;
5355
+ return [...marks, ...centerLabelMarks(spec, marks, theme)];
5096
5356
  };
5097
5357
 
5098
5358
  // src/charts/range/compute.ts
5099
- import { getRepresentativeColor as getRepresentativeColor7 } from "@opendata-ai/openchart-core";
5100
- var DOT_RADIUS2 = 6;
5359
+ import { getRepresentativeColor as getRepresentativeColor8 } from "@opendata-ai/openchart-core";
5360
+ var DOT_RADIUS2 = 5.5;
5101
5361
  var CONNECTOR_WIDTH = 2;
5102
- var CONNECTOR_COLOR = "#cccccc";
5362
+ var CONNECTOR_OPACITY = 0.6;
5103
5363
  var ARROW_STROKE_WIDTH = 2;
5104
5364
  var ARROWHEAD_LENGTH2 = 8;
5105
5365
  var ARROWHEAD_HALF_WIDTH2 = 4;
@@ -5124,6 +5384,7 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5124
5384
  const bandScale = horizontal ? scales.y.scale : scales.x.scale;
5125
5385
  const valueScale = horizontal ? scales.x.scale : scales.y.scale;
5126
5386
  const bandwidth = bandScale.bandwidth();
5387
+ const knockout = stackSeamStroke(theme);
5127
5388
  const style = spec.markDef.style ?? "dumbbell";
5128
5389
  const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
5129
5390
  const colorByDirection = spec.markDef.colorByDirection === true && !colorEnc;
@@ -5146,7 +5407,7 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5146
5407
  const delta = endVal - startVal;
5147
5408
  const catKey = catKeys[i];
5148
5409
  const accent = colorEnc ? getColor(scales, String(row[colorEnc.field] ?? "")) : colorByDirection ? delta > 0 ? theme.colors.positive : delta < 0 ? theme.colors.negative : theme.colors.axis : getColor(scales, "__default__");
5149
- const accentStroke = getRepresentativeColor7(accent);
5410
+ const accentStroke = getRepresentativeColor8(accent);
5150
5411
  const spanAria = {
5151
5412
  label: `${category}: ${startVal} to ${endVal}`
5152
5413
  };
@@ -5223,15 +5484,16 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5223
5484
  continue;
5224
5485
  }
5225
5486
  if (spanLength > 0) {
5226
- const connectorColor = colorEnc || colorByDirection ? accentStroke : CONNECTOR_COLOR;
5487
+ const colored = !!colorEnc || colorByDirection;
5227
5488
  marks.push({
5228
5489
  type: "rule",
5229
5490
  x1,
5230
5491
  y1,
5231
5492
  x2,
5232
5493
  y2,
5233
- stroke: connectorColor,
5494
+ stroke: colored ? accentStroke : theme.colors.neutral[400],
5234
5495
  strokeWidth: CONNECTOR_WIDTH,
5496
+ ...colored ? {} : { strokeOpacity: CONNECTOR_OPACITY },
5235
5497
  data: row,
5236
5498
  aria: spanAria,
5237
5499
  key: `${catKey}|range`
@@ -5243,8 +5505,8 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5243
5505
  cx: x1,
5244
5506
  cy: y1,
5245
5507
  r: DOT_RADIUS2,
5246
- fill: theme.colors.axis,
5247
- stroke: "#ffffff",
5508
+ fill: theme.colors.neutral[400],
5509
+ stroke: knockout,
5248
5510
  strokeWidth: 2,
5249
5511
  data: row,
5250
5512
  aria: { label: `${category}, start: ${startVal}` },
@@ -5256,7 +5518,7 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5256
5518
  cy: y2,
5257
5519
  r: DOT_RADIUS2,
5258
5520
  fill: accent,
5259
- stroke: "#ffffff",
5521
+ stroke: knockout,
5260
5522
  strokeWidth: 2,
5261
5523
  data: row,
5262
5524
  aria: { label: `${category}, end: ${endVal}` },
@@ -5270,7 +5532,7 @@ function computeRangeMarks(spec, scales, _chartArea, _strategy, theme) {
5270
5532
  // src/charts/range/labels.ts
5271
5533
  import {
5272
5534
  estimateTextWidth as estimateTextWidth8,
5273
- getRepresentativeColor as getRepresentativeColor8,
5535
+ getRepresentativeColor as getRepresentativeColor9,
5274
5536
  resolveCollisions as resolveCollisions6
5275
5537
  } from "@opendata-ai/openchart-core";
5276
5538
  var LABEL_FONT_SIZE6 = 11;
@@ -5306,7 +5568,7 @@ function buildCandidate(mark, text, horizontal, outerSide, index) {
5306
5568
  fontFamily: "system-ui, -apple-system, sans-serif",
5307
5569
  fontSize: LABEL_FONT_SIZE6,
5308
5570
  fontWeight: LABEL_FONT_WEIGHT6,
5309
- fill: getRepresentativeColor8(mark.fill),
5571
+ fill: getRepresentativeColor9(mark.fill),
5310
5572
  lineHeight: 1.2,
5311
5573
  textAnchor: "start",
5312
5574
  dominantBaseline: "central",
@@ -5460,7 +5722,7 @@ function clearRenderers() {
5460
5722
  }
5461
5723
 
5462
5724
  // src/charts/rule/index.ts
5463
- import { getRepresentativeColor as getRepresentativeColor9 } from "@opendata-ai/openchart-core";
5725
+ import { getRepresentativeColor as getRepresentativeColor10 } from "@opendata-ai/openchart-core";
5464
5726
  function computeRuleMarks(spec, scales, chartArea) {
5465
5727
  const encoding = spec.encoding;
5466
5728
  const xChannel = encoding.x;
@@ -5503,7 +5765,7 @@ function computeRuleMarks(spec, scales, chartArea) {
5503
5765
  const y2Val = scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field]);
5504
5766
  if (y2Val != null) y2 = y2Val;
5505
5767
  }
5506
- const color = getRepresentativeColor9(
5768
+ const color = getRepresentativeColor10(
5507
5769
  colorField ? getColor(scales, String(row[colorField] ?? "__default__")) : getColor(scales, "__default__")
5508
5770
  );
5509
5771
  const strokeDashEncoding = encoding.strokeDash && "field" in encoding.strokeDash ? encoding.strokeDash : void 0;
@@ -5538,8 +5800,9 @@ var ruleRenderer = (spec, scales, chartArea, _strategy, _theme) => {
5538
5800
  };
5539
5801
 
5540
5802
  // src/charts/scatter/compute.ts
5541
- import { isOpaqueColor } from "@opendata-ai/openchart-core";
5542
5803
  var DEFAULT_POINT_RADIUS2 = 5;
5804
+ var POINT_STROKE_WIDTH = 1.5;
5805
+ var DENSE_LAYER_POINTS = 200;
5543
5806
  function resolvePosition2(value, channelType, scale) {
5544
5807
  switch (channelType) {
5545
5808
  case "nominal":
@@ -5584,8 +5847,8 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy, theme) {
5584
5847
  const sizeScale = resolvedSize?.scale;
5585
5848
  const constFill = spec.markDef.fill;
5586
5849
  const constSize = spec.markDef.size;
5587
- const bg = theme?.colors?.background;
5588
- const defaultStroke = bg && isOpaqueColor(bg) ? bg : "#ffffff";
5850
+ const defaultStroke = resolveKnockoutColor(theme);
5851
+ const defaultFillOpacity = spec.data.length < DENSE_LAYER_POINTS ? 0.85 : 0.6;
5589
5852
  const keyEnc = encoding.key && "field" in encoding.key ? encoding.key : void 0;
5590
5853
  const keyField = keyEnc?.field;
5591
5854
  const marks = [];
@@ -5627,8 +5890,9 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy, theme) {
5627
5890
  r: radius,
5628
5891
  fill: color,
5629
5892
  stroke: spec.markDef.stroke ?? defaultStroke,
5630
- strokeWidth: spec.markDef.strokeWidth ?? 1,
5631
- fillOpacity: spec.markDef.opacity ?? 0.7,
5893
+ strokeWidth: spec.markDef.strokeWidth ?? POINT_STROKE_WIDTH,
5894
+ fillOpacity: spec.markDef.opacity ?? defaultFillOpacity,
5895
+ ...category ? { seriesKey: category } : {},
5632
5896
  data: row,
5633
5897
  aria
5634
5898
  });
@@ -5649,7 +5913,7 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy, theme) {
5649
5913
  // src/charts/scatter/trendline.ts
5650
5914
  var TRENDLINE_COLOR = "#666666";
5651
5915
  var TRENDLINE_STROKE_WIDTH = 1.5;
5652
- var TRENDLINE_DASH = "6 4";
5916
+ var TRENDLINE_DASH = "4 4";
5653
5917
  function linearRegression(points) {
5654
5918
  const n = points.length;
5655
5919
  if (n < 2) return null;
@@ -5707,7 +5971,7 @@ var scatterRenderer = (spec, scales, chartArea, strategy, theme) => {
5707
5971
  const trendline = spec.markDef?.trendline;
5708
5972
  if (trendline !== false) {
5709
5973
  const config = typeof trendline === "object" ? trendline : void 0;
5710
- const stroke = config?.stroke ?? theme?.colors?.text;
5974
+ const stroke = config?.stroke ?? theme?.colors?.axis;
5711
5975
  const trendLine = computeTrendLine(pointMarks, stroke, config?.strokeWidth);
5712
5976
  if (trendLine) {
5713
5977
  if (config?.layer === "above") {
@@ -5721,7 +5985,7 @@ var scatterRenderer = (spec, scales, chartArea, strategy, theme) => {
5721
5985
  };
5722
5986
 
5723
5987
  // src/charts/text/index.ts
5724
- import { getRepresentativeColor as getRepresentativeColor10 } from "@opendata-ai/openchart-core";
5988
+ import { getRepresentativeColor as getRepresentativeColor11 } from "@opendata-ai/openchart-core";
5725
5989
  var DEFAULT_FONT_SIZE = 12;
5726
5990
  var ALIGN_TO_ANCHOR = {
5727
5991
  left: "start",
@@ -5765,7 +6029,7 @@ function computeTextMarks(spec, scales) {
5765
6029
  }
5766
6030
  const text = String(row[textChannel.field] ?? "");
5767
6031
  if (!text) continue;
5768
- const color = getRepresentativeColor10(
6032
+ const color = getRepresentativeColor11(
5769
6033
  colorField ? getColor(scales, String(row[colorField] ?? "__default__")) : getColor(scales, "__default__")
5770
6034
  );
5771
6035
  const fallbackFontSize = markDef.fontSize ?? DEFAULT_FONT_SIZE;
@@ -5802,7 +6066,7 @@ var textRenderer = (spec, scales, _chartArea, _strategy, _theme) => {
5802
6066
  };
5803
6067
 
5804
6068
  // src/charts/tick/index.ts
5805
- import { getRepresentativeColor as getRepresentativeColor11 } from "@opendata-ai/openchart-core";
6069
+ import { getRepresentativeColor as getRepresentativeColor12 } from "@opendata-ai/openchart-core";
5806
6070
  var DEFAULT_TICK_LENGTH = 18;
5807
6071
  function computeTickMarks(spec, scales, _chartArea) {
5808
6072
  const encoding = spec.encoding;
@@ -5818,7 +6082,7 @@ function computeTickMarks(spec, scales, _chartArea) {
5818
6082
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
5819
6083
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
5820
6084
  if (xVal == null || yVal == null) continue;
5821
- const color = getRepresentativeColor11(
6085
+ const color = getRepresentativeColor12(
5822
6086
  colorField ? getColor(scales, String(row[colorField] ?? "__default__")) : getColor(scales, "__default__")
5823
6087
  );
5824
6088
  const aria = {
@@ -5853,7 +6117,7 @@ var tickRenderer = (spec, scales, chartArea, _strategy, _theme) => {
5853
6117
  import { formatPercent as formatPercent3 } from "@opendata-ai/openchart-core";
5854
6118
  var DEFAULT_UNITS = 100;
5855
6119
  var DEFAULT_COLUMNS = 10;
5856
- var DEFAULT_CORNER_RADIUS = 1;
6120
+ var DEFAULT_CORNER_RADIUS = 2;
5857
6121
  var GAP_FRACTION = 1 / 6;
5858
6122
  function largestRemainderCells(values, units) {
5859
6123
  const clean = values.map((v) => Number.isFinite(v) && v > 0 ? v : 0);
@@ -5933,6 +6197,7 @@ function computeWaffleMarks(spec, scales, chartArea, _strategy) {
5933
6197
  height: cell,
5934
6198
  fill,
5935
6199
  cornerRadius,
6200
+ shapeRendering: "crispEdges",
5936
6201
  data: row,
5937
6202
  aria: k === 0 ? aria : { ...aria, decorative: true }
5938
6203
  });
@@ -6558,25 +6823,25 @@ function emitSpecWarnings(warnings, sink = defaultWarnSink) {
6558
6823
 
6559
6824
  // src/compiler/animation.ts
6560
6825
  var ENTER_DEFAULTS = {
6561
- duration: 500,
6826
+ duration: 450,
6562
6827
  ease: "smooth",
6563
- staggerDelay: 80,
6828
+ staggerDelay: 30,
6564
6829
  staggerOrder: "index"
6565
6830
  };
6566
6831
  var UPDATE_DEFAULTS = {
6567
- duration: 500,
6832
+ duration: 450,
6568
6833
  ease: "smooth",
6569
6834
  staggerDelay: 0,
6570
6835
  staggerOrder: "index"
6571
6836
  };
6572
6837
  var EXIT_DEFAULTS = {
6573
- duration: 300,
6838
+ duration: 250,
6574
6839
  ease: "smooth",
6575
6840
  staggerDelay: 0,
6576
6841
  staggerOrder: "index"
6577
6842
  };
6578
- var DEFAULT_ANNOTATION_DELAY = 200;
6579
- var MAX_TOTAL_STAGGER_MS = 2e3;
6843
+ var DEFAULT_ANNOTATION_DELAY = 150;
6844
+ var MAX_TOTAL_STAGGER_MS = 300;
6580
6845
  function resolveAnimation(spec) {
6581
6846
  if (spec === void 0 || spec === false) return void 0;
6582
6847
  if (spec === true) {
@@ -6966,10 +7231,62 @@ function warnParliamentMajorityRange(markDef, encoding, data, warnings) {
6966
7231
  );
6967
7232
  }
6968
7233
  }
7234
+ function withCalendarColorScale(encoding) {
7235
+ const color = encoding.color;
7236
+ if (!color || !("field" in color) || color.type !== "quantitative") return encoding;
7237
+ if (color.scale?.type) return encoding;
7238
+ return {
7239
+ ...encoding,
7240
+ color: { ...color, scale: { ...color.scale, type: "quantize" } }
7241
+ };
7242
+ }
7243
+ function resolveOtherOption(other) {
7244
+ if (other == null) return null;
7245
+ const threshold = typeof other === "number" ? other : other.threshold;
7246
+ if (!Number.isFinite(threshold) || threshold <= 0) return null;
7247
+ return { threshold, label: typeof other === "object" && other.label || "Other" };
7248
+ }
7249
+ function applyWaffleOther(spec, encoding, markDef, warnings) {
7250
+ const option = resolveOtherOption(markDef.other);
7251
+ const colorField = encoding.color && "field" in encoding.color ? encoding.color.field : void 0;
7252
+ const valueField = (encoding.y ?? encoding.x)?.field;
7253
+ if (!option || !colorField || !valueField) return spec.data;
7254
+ const totals = /* @__PURE__ */ new Map();
7255
+ for (const row of spec.data) {
7256
+ const value = Number(row[valueField] ?? 0);
7257
+ if (!Number.isFinite(value) || value < 0) continue;
7258
+ const cat = String(row[colorField] ?? "");
7259
+ totals.set(cat, (totals.get(cat) ?? 0) + value);
7260
+ }
7261
+ let grandTotal = 0;
7262
+ for (const value of totals.values()) grandTotal += value;
7263
+ if (grandTotal <= 0) return spec.data;
7264
+ const merged = /* @__PURE__ */ new Set();
7265
+ for (const [cat, value] of totals) {
7266
+ if (value / grandTotal < option.threshold) merged.add(cat);
7267
+ }
7268
+ if (merged.size < 2) return spec.data;
7269
+ const kept = [];
7270
+ const bucketed = [];
7271
+ for (const row of spec.data) {
7272
+ const cat = String(row[colorField] ?? "");
7273
+ if (merged.has(cat)) {
7274
+ bucketed.push({ ...row, [colorField]: option.label });
7275
+ } else {
7276
+ kept.push(row);
7277
+ }
7278
+ }
7279
+ warnings.push(
7280
+ `[openchart] WAFFLE_OTHER_BUCKET: merged ${merged.size} categories under ${option.threshold * 100}% of the total into "${option.label}" (${[...merged].join(", ")})`
7281
+ );
7282
+ return [...kept, ...bucketed];
7283
+ }
6969
7284
  function normalizeChartSpec(spec, warnings) {
6970
- const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
7285
+ const inferred = inferEncodingTypes(spec.encoding, spec.data, warnings);
6971
7286
  const markType = resolveMarkType(spec.mark);
6972
7287
  const markDef = resolveMarkDef(spec.mark);
7288
+ const encoding = markType === "calendar" ? withCalendarColorScale(inferred) : inferred;
7289
+ const data = markType === "waffle" ? applyWaffleOther(spec, encoding, markDef, warnings) : spec.data;
6973
7290
  const display = spec.display ?? "full";
6974
7291
  if (display === "sparkline" && markType !== "line" && markType !== "area" && markType !== "bar" && markType !== "point") {
6975
7292
  warnings.push(
@@ -6987,7 +7304,7 @@ function normalizeChartSpec(spec, warnings) {
6987
7304
  return {
6988
7305
  markType,
6989
7306
  markDef,
6990
- data: spec.data,
7307
+ data,
6991
7308
  encoding,
6992
7309
  chrome: normalizeChrome(spec.chrome),
6993
7310
  chromeLayout: spec.chromeLayout,
@@ -7024,7 +7341,17 @@ function normalizeChartSpec(spec, warnings) {
7024
7341
  }
7025
7342
  };
7026
7343
  }
7027
- function normalizeTableSpec(spec, _warnings) {
7344
+ function normalizeTableDensity(spec, warnings) {
7345
+ if (spec.density) return spec.density;
7346
+ if (spec.compact) {
7347
+ warnings.push('[openchart] table "compact" is deprecated; use density: "condensed" instead.');
7348
+ return "condensed";
7349
+ }
7350
+ return "regular";
7351
+ }
7352
+ function normalizeTableSpec(spec, warnings) {
7353
+ const density = normalizeTableDensity(spec, warnings);
7354
+ const totalRow = spec.totalRow === true ? { label: "Total" } : spec.totalRow && typeof spec.totalRow === "object" ? { label: spec.totalRow.label ?? "Total" } : void 0;
7028
7355
  return {
7029
7356
  type: "table",
7030
7357
  data: spec.data,
@@ -7036,7 +7363,11 @@ function normalizeTableSpec(spec, _warnings) {
7036
7363
  search: spec.search ?? false,
7037
7364
  pagination: spec.pagination ?? false,
7038
7365
  stickyFirstColumn: spec.stickyFirstColumn ?? false,
7039
- compact: spec.compact ?? false,
7366
+ density,
7367
+ striped: spec.striped ?? false,
7368
+ sort: spec.sort,
7369
+ totalRow,
7370
+ compact: density === "condensed",
7040
7371
  responsive: spec.responsive ?? true,
7041
7372
  animation: spec.animation,
7042
7373
  watermark: spec.watermark ?? true
@@ -7061,6 +7392,7 @@ function normalizeSankeySpec(spec, _warnings) {
7061
7392
  animation: spec.animation,
7062
7393
  valueFormat: spec.valueFormat,
7063
7394
  linkOpacity: spec.linkOpacity,
7395
+ other: spec.other,
7064
7396
  watermark: spec.watermark ?? true
7065
7397
  };
7066
7398
  }
@@ -7151,7 +7483,10 @@ function normalizeGeoMapSpec(spec, _warnings) {
7151
7483
  geo: {
7152
7484
  features: spec.geo.features,
7153
7485
  idField: spec.geo.idField ?? "id",
7154
- projection: spec.geo.projection ?? "albersUsa",
7486
+ // Left undefined on purpose: compileGeoMap infers the projection from the
7487
+ // topology (resolveDefaultProjection) so a pre-projected atlas doesn't get
7488
+ // silently re-projected by an albersUsa default.
7489
+ projection: spec.geo.projection,
7155
7490
  focus: spec.geo.focus ?? null
7156
7491
  },
7157
7492
  data: spec.data,
@@ -7444,6 +7779,40 @@ function validateRangeSpec(spec, errors) {
7444
7779
  }
7445
7780
  }
7446
7781
  }
7782
+ function validateCenterLabel(spec, markType, errors) {
7783
+ if (!spec.mark || typeof spec.mark !== "object" || Array.isArray(spec.mark)) return;
7784
+ const centerLabel = spec.mark.centerLabel;
7785
+ if (centerLabel === void 0) return;
7786
+ if (markType !== "arc") {
7787
+ errors.push({
7788
+ message: `Spec error: mark.centerLabel is only supported on arc (pie/donut) marks, not "${String(markType)}"`,
7789
+ path: "mark.centerLabel",
7790
+ code: "INVALID_VALUE",
7791
+ suggestion: 'Remove mark.centerLabel, or use mark: { type: "arc", innerRadius: 0.6, centerLabel: "..." } for a donut center stat.'
7792
+ });
7793
+ return;
7794
+ }
7795
+ const isString = typeof centerLabel === "string";
7796
+ const obj = !isString && centerLabel !== null && typeof centerLabel === "object" ? centerLabel : void 0;
7797
+ const validObject = obj !== void 0 && typeof obj.text === "string" && obj.text.length > 0;
7798
+ const validString = isString && centerLabel.length > 0;
7799
+ if (!validString && !validObject) {
7800
+ errors.push({
7801
+ message: `Spec error: mark.centerLabel must be a non-empty string or { text, subtitle? }, got ${JSON.stringify(centerLabel)}`,
7802
+ path: "mark.centerLabel",
7803
+ code: "INVALID_VALUE",
7804
+ suggestion: 'Use centerLabel: "$4.2M" or centerLabel: { text: "$4.2M", subtitle: "Total revenue" }.'
7805
+ });
7806
+ }
7807
+ if (validObject && obj.subtitle !== void 0 && typeof obj.subtitle !== "string") {
7808
+ errors.push({
7809
+ message: `Spec error: mark.centerLabel.subtitle must be a string, got ${JSON.stringify(obj.subtitle)}`,
7810
+ path: "mark.centerLabel.subtitle",
7811
+ code: "INVALID_VALUE",
7812
+ suggestion: 'Use centerLabel: { text: "$4.2M", subtitle: "Total revenue" }.'
7813
+ });
7814
+ }
7815
+ }
7447
7816
  var VALID_WEEK_STARTS = /* @__PURE__ */ new Set(["monday", "sunday"]);
7448
7817
  function validateCalendarSpec(spec, errors) {
7449
7818
  const encoding = spec.encoding;
@@ -7655,6 +8024,7 @@ function validateChartSpec(spec, errors) {
7655
8024
  });
7656
8025
  }
7657
8026
  }
8027
+ validateCenterLabel(spec, markType, errors);
7658
8028
  if (markType === "range") {
7659
8029
  validateRangeSpec(spec, errors);
7660
8030
  }
@@ -9445,7 +9815,10 @@ function assignAnimationIndices(marks, animation) {
9445
9815
  }
9446
9816
 
9447
9817
  // src/compile/color-scale-range.ts
9448
- import { ACHROMATIC_RAMP } from "@opendata-ai/openchart-core";
9818
+ import {
9819
+ ACHROMATIC_RAMP,
9820
+ CATEGORICAL_EXTENDED_PALETTE
9821
+ } from "@opendata-ai/openchart-core";
9449
9822
 
9450
9823
  // src/legend/continuous.ts
9451
9824
  import {
@@ -9453,6 +9826,7 @@ import {
9453
9826
  defaultNumberFormatter as defaultNumberFormatter5,
9454
9827
  resolveNumberFormatter as resolveNumberFormatter2
9455
9828
  } from "@opendata-ai/openchart-core";
9829
+ import { tickStep } from "d3-array";
9456
9830
  import { scaleQuantile } from "d3-scale";
9457
9831
  function extent(values) {
9458
9832
  let min4 = Number.POSITIVE_INFINITY;
@@ -9464,11 +9838,18 @@ function extent(values) {
9464
9838
  return [min4, max4];
9465
9839
  }
9466
9840
  var CONTINUOUS_BAR_HEIGHT = 10;
9841
+ var CONTINUOUS_BINNED_BAR_HEIGHT = 12;
9467
9842
  var CONTINUOUS_LABEL_GAP = 4;
9468
9843
  var BAR_WIDTH_MIN = 160;
9469
9844
  var BAR_WIDTH_MAX = 220;
9470
9845
  var DEFAULT_BIN_COUNT = 5;
9846
+ var COMPACT_SWATCH_SIZE = 11;
9847
+ var COMPACT_SWATCH_GAP = 2;
9848
+ var COMPACT_LABEL_GAP = 6;
9849
+ var COMPACT_LESS_LABEL = "Less";
9850
+ var COMPACT_MORE_LABEL = "More";
9471
9851
  var BINNED_SCALE_TYPES = /* @__PURE__ */ new Set(["quantile", "quantize", "threshold"]);
9852
+ var MAX_BINS = 12;
9472
9853
  function sampleRampColors(colors, k) {
9473
9854
  if (k <= 0 || colors.length === 0) return [];
9474
9855
  if (colors.length === k) return [...colors];
@@ -9517,7 +9898,47 @@ function resolveBarWidth(availableWidth) {
9517
9898
  );
9518
9899
  return Math.max(0, Math.min(preferred, Math.floor(availableWidth)));
9519
9900
  }
9520
- function resolveBinBreaks(channel, values, binCount) {
9901
+ function tidyBreak(value) {
9902
+ return Number(value.toPrecision(12));
9903
+ }
9904
+ function niceQuantizeBreaks(domainMin, domainMax, binCount, diverging) {
9905
+ if (!(domainMax > domainMin)) return [];
9906
+ if (diverging && domainMin < 0 && domainMax > 0) {
9907
+ const classes = binCount % 2 === 0 ? binCount + 1 : binCount;
9908
+ const perSide = (classes - 1) / 2;
9909
+ const magnitude = Math.max(Math.abs(domainMin), Math.abs(domainMax));
9910
+ const step2 = tickStep(0, magnitude, perSide + 0.5) || magnitude / (perSide + 0.5);
9911
+ if (!Number.isFinite(step2) || step2 <= 0) return [];
9912
+ const breaks2 = [];
9913
+ for (let i = 0; i < perSide * 2; i++) {
9914
+ breaks2.push(tidyBreak((i - perSide + 0.5) * step2));
9915
+ }
9916
+ return breaks2;
9917
+ }
9918
+ let step = tickStep(domainMin, domainMax, binCount);
9919
+ if (!Number.isFinite(step) || step <= 0) return [];
9920
+ let breaks = quantizeBreaksForStep(domainMin, domainMax, step);
9921
+ for (let guard = 0; guard < 10 && breaks.length + 1 > binCount; guard++) {
9922
+ step = coarserNiceStep(step);
9923
+ breaks = quantizeBreaksForStep(domainMin, domainMax, step);
9924
+ }
9925
+ return breaks;
9926
+ }
9927
+ function quantizeBreaksForStep(domainMin, domainMax, step) {
9928
+ const epsilon = step * 1e-9;
9929
+ const breaks = [];
9930
+ for (let b = Math.ceil(domainMin / step) * step; b < domainMax - epsilon && breaks.length < MAX_BINS; b += step) {
9931
+ if (b > domainMin + epsilon) breaks.push(tidyBreak(b));
9932
+ }
9933
+ return breaks;
9934
+ }
9935
+ function coarserNiceStep(step) {
9936
+ const power = Math.floor(Math.log10(step));
9937
+ const mantissa = step / 10 ** power;
9938
+ const next = mantissa < 1.5 ? 2 : mantissa < 3.5 ? 5 : 10;
9939
+ return tidyBreak(next * 10 ** power);
9940
+ }
9941
+ function resolveBinBreaks(channel, values, binCount, diverging) {
9521
9942
  const scaleType = channel.scale?.type;
9522
9943
  if (scaleType === "threshold") {
9523
9944
  return channel.scale?.domain ?? [0.5];
@@ -9530,18 +9951,43 @@ function resolveBinBreaks(channel, values, binCount) {
9530
9951
  const [valuesMin, valuesMax] = extent(values);
9531
9952
  const domainMin = explicitDomain?.[0] ?? valuesMin;
9532
9953
  const domainMax = explicitDomain?.[1] ?? valuesMax;
9533
- const breaks = [];
9534
- for (let i = 1; i < binCount; i++) {
9535
- breaks.push(domainMin + (domainMax - domainMin) * i / binCount);
9954
+ return niceQuantizeBreaks(domainMin, domainMax, binCount, diverging);
9955
+ }
9956
+ function resolveBinCount(channel) {
9957
+ if (channel.scale?.type === "threshold") {
9958
+ return (channel.scale?.domain ?? [0.5]).length + 1;
9536
9959
  }
9537
- return breaks;
9960
+ const explicitRange = channel.scale?.range;
9961
+ return explicitRange?.length ?? DEFAULT_BIN_COUNT;
9962
+ }
9963
+ function buildClassScale(values, channel, colors, binCount = DEFAULT_BIN_COUNT, options) {
9964
+ const breaks = resolveBinBreaks(channel, values, binCount, options?.diverging ?? false);
9965
+ const classColors = sampleRampColors(colors, breaks.length + 1);
9966
+ const binIndex = (value) => {
9967
+ if (!Number.isFinite(value)) return -1;
9968
+ let i = 0;
9969
+ while (i < breaks.length && value >= breaks[i]) i++;
9970
+ return i;
9971
+ };
9972
+ return {
9973
+ breaks,
9974
+ colors: classColors,
9975
+ binIndex,
9976
+ color: (value) => classColors[Math.max(0, binIndex(value))] ?? classColors[0]
9977
+ };
9978
+ }
9979
+ function classScaleForChannel(values, channel, theme) {
9980
+ const colors = resolveRampColors(channel, theme);
9981
+ return buildClassScale(values, channel, colors, resolveBinCount(channel), {
9982
+ diverging: isDivergingRamp(colors, theme)
9983
+ });
9538
9984
  }
9539
9985
  function hasContinuousColorScale(spec) {
9540
9986
  const colorEnc = spec.encoding.color;
9541
9987
  if (!colorEnc || "condition" in colorEnc || !("field" in colorEnc)) return false;
9542
9988
  return colorEnc.type === "quantitative";
9543
9989
  }
9544
- function computeContinuousLegendContentForChannel(values, channel, theme, availableWidth) {
9990
+ function computeContinuousLegendContentForChannel(values, channel, theme, availableWidth, classScale) {
9545
9991
  if (values.length === 0) return null;
9546
9992
  const barWidth = resolveBarWidth(availableWidth);
9547
9993
  if (barWidth <= 0) return null;
@@ -9550,17 +9996,15 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
9550
9996
  const formatStr = channel.format;
9551
9997
  const ctx = computeFieldFormatContext2(values);
9552
9998
  if (scaleType && BINNED_SCALE_TYPES.has(scaleType)) {
9553
- const explicitRange2 = channel.scale?.range;
9554
- const binCount = scaleType === "threshold" ? (channel.scale?.domain ?? [0.5]).length + 1 : explicitRange2?.length ?? DEFAULT_BIN_COUNT;
9555
- const breaks = resolveBinBreaks(channel, values, binCount);
9556
- const colors = sampleRampColors(rampColors, binCount);
9557
- const binWidth = barWidth / binCount;
9999
+ const scale = classScale ?? classScaleForChannel(values, channel, theme);
10000
+ const colors = scale.colors;
10001
+ const binWidth = barWidth / colors.length;
9558
10002
  const bins = colors.map((color, i) => ({
9559
10003
  x: i * binWidth,
9560
10004
  width: binWidth,
9561
10005
  color
9562
10006
  }));
9563
- const ticks2 = breaks.map((value, i) => ({
10007
+ const ticks2 = scale.breaks.map((value, i) => ({
9564
10008
  value,
9565
10009
  label: formatLegendValue(value, formatStr, ctx),
9566
10010
  x: (i + 1) * binWidth,
@@ -9569,10 +10013,11 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
9569
10013
  return {
9570
10014
  mode: "binned",
9571
10015
  barWidth,
9572
- barHeight: CONTINUOUS_BAR_HEIGHT,
10016
+ barHeight: CONTINUOUS_BINNED_BAR_HEIGHT,
9573
10017
  colorStops: [],
9574
10018
  bins,
9575
- ticks: ticks2
10019
+ ticks: ticks2,
10020
+ classScale: scale
9576
10021
  };
9577
10022
  }
9578
10023
  const [domainMin, domainMax] = extent(values);
@@ -9616,6 +10061,52 @@ function computeContinuousLegendContentForChannel(values, channel, theme, availa
9616
10061
  ticks
9617
10062
  };
9618
10063
  }
10064
+ function computeCompactClassLegendContent(values, channel, theme, measure) {
10065
+ if (values.length === 0) return null;
10066
+ if (isDivergingRamp(resolveRampColors(channel, theme), theme)) return null;
10067
+ const scale = classScaleForChannel(values, channel, theme);
10068
+ const colors = scale.colors;
10069
+ if (colors.length === 0) return null;
10070
+ const bins = colors.map((color, i) => ({
10071
+ x: i * (COMPACT_SWATCH_SIZE + COMPACT_SWATCH_GAP),
10072
+ width: COMPACT_SWATCH_SIZE,
10073
+ color
10074
+ }));
10075
+ const barWidth = colors.length * COMPACT_SWATCH_SIZE + (colors.length - 1) * COMPACT_SWATCH_GAP;
10076
+ const fontSize = theme.fonts.sizes.small;
10077
+ const fontWeight = theme.fonts.weights.normal;
10078
+ const lessWidth = measure(COMPACT_LESS_LABEL, fontSize, fontWeight);
10079
+ const moreWidth = measure(COMPACT_MORE_LABEL, fontSize, fontWeight);
10080
+ const [domainMin, domainMax] = extent(values);
10081
+ const ticks = [
10082
+ {
10083
+ value: domainMin,
10084
+ label: COMPACT_LESS_LABEL,
10085
+ x: -COMPACT_LABEL_GAP,
10086
+ anchor: "end"
10087
+ },
10088
+ {
10089
+ value: domainMax,
10090
+ label: COMPACT_MORE_LABEL,
10091
+ x: barWidth + COMPACT_LABEL_GAP,
10092
+ anchor: "start"
10093
+ }
10094
+ ];
10095
+ return {
10096
+ mode: "binned",
10097
+ barWidth,
10098
+ barHeight: COMPACT_SWATCH_SIZE,
10099
+ colorStops: [],
10100
+ bins,
10101
+ ticks,
10102
+ classScale: scale,
10103
+ barOffsetX: lessWidth + COMPACT_LABEL_GAP,
10104
+ // Optical centre of the cap height against the swatch row.
10105
+ labelBaselineOffset: COMPACT_SWATCH_SIZE / 2 + fontSize * 0.35,
10106
+ align: "right",
10107
+ blockWidth: lessWidth + COMPACT_LABEL_GAP + barWidth + COMPACT_LABEL_GAP + moreWidth
10108
+ };
10109
+ }
9619
10110
  function computeContinuousLegendContent(spec, theme, availableWidth) {
9620
10111
  if (!hasContinuousColorScale(spec)) return null;
9621
10112
  const colorEnc = spec.encoding.color;
@@ -9624,12 +10115,44 @@ function computeContinuousLegendContent(spec, theme, availableWidth) {
9624
10115
  }
9625
10116
 
9626
10117
  // src/compile/color-scale-range.ts
9627
- var MUTED_COLOR = "#bfc3c8";
9628
- function categoricalColorsForDomain(domain, theme, highlight) {
9629
- const palette = theme.colors.categorical;
10118
+ var FILL_MARKS = /* @__PURE__ */ new Set([
10119
+ "bar",
10120
+ "area",
10121
+ "arc",
10122
+ "waffle",
10123
+ "calendar",
10124
+ "rect"
10125
+ ]);
10126
+ function mutedSeriesColor(theme) {
10127
+ return theme.isDark ? ACHROMATIC_RAMP.fgFaint : "#d4d4d8";
10128
+ }
10129
+ function paletteFor(theme, markType) {
10130
+ return markType && FILL_MARKS.has(markType) ? theme.colors.categoricalFill : theme.colors.categorical;
10131
+ }
10132
+ function extendPalette(palette, needed) {
10133
+ if (needed <= palette.length) return palette;
10134
+ const out = [...palette];
10135
+ for (let i = 0; out.length < needed; i++) {
10136
+ out.push(CATEGORICAL_EXTENDED_PALETTE[i % CATEGORICAL_EXTENDED_PALETTE.length]);
10137
+ }
10138
+ return out;
10139
+ }
10140
+ function defaultSeriesColor(theme, markType) {
10141
+ return paletteFor(theme, markType)[0];
10142
+ }
10143
+ function paletteOverflowWarnings(domainLength, theme, markType) {
10144
+ const palette = paletteFor(theme, markType);
10145
+ if (domainLength <= palette.length) return [];
10146
+ return [
10147
+ `[openchart] color encoding has ${domainLength} categories but the palette carries ${palette.length}; slots ${palette.length + 1}+ come from the extended ramp and are harder to tell apart. Bucket the tail, facet, or direct-label instead.`
10148
+ ];
10149
+ }
10150
+ function categoricalColorsForDomain(domain, theme, highlight, markType) {
10151
+ const palette = extendPalette(paletteFor(theme, markType), domain.length);
9630
10152
  if (highlight && highlight.length > 0) {
9631
10153
  const highlightSet = new Set(highlight);
9632
- return domain.map((v, i) => highlightSet.has(v) ? palette[i % palette.length] : MUTED_COLOR);
10154
+ const muted = mutedSeriesColor(theme);
10155
+ return domain.map((v, i) => highlightSet.has(v) ? palette[i % palette.length] : muted);
9633
10156
  }
9634
10157
  const strategy = theme.seriesStrategy ?? "palette";
9635
10158
  if (strategy !== "accent-neutral") return palette;
@@ -9639,10 +10162,10 @@ function categoricalColorsForDomain(domain, theme, highlight) {
9639
10162
  const neutrals = theme.isDark ? [ACHROMATIC_RAMP.fgMuted, ACHROMATIC_RAMP.fgSubtle, ACHROMATIC_RAMP.fgFaint] : [ACHROMATIC_RAMP.fgFaint, ACHROMATIC_RAMP.fgSubtle, ACHROMATIC_RAMP.fgMuted];
9640
10163
  return domain.map((_, i) => i === 0 ? palette[0] : neutrals[(i - 1) % neutrals.length]);
9641
10164
  }
9642
- function applyColorScaleRange(scales, encoding, theme, highlight) {
9643
- if (!scales.color) return;
10165
+ function applyColorScaleRange(scales, encoding, theme, highlight, markType) {
10166
+ if (!scales.color) return [];
9644
10167
  const hasExplicitRange = !!(encoding.color && "field" in encoding.color && encoding.color.scale?.range?.length);
9645
- if (hasExplicitRange) return;
10168
+ if (hasExplicitRange) return [];
9646
10169
  if (scales.color.type === "sequential") {
9647
10170
  const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
9648
10171
  scales.color.scale.range([
@@ -9655,8 +10178,11 @@ function applyColorScaleRange(scales, encoding, theme, highlight) {
9655
10178
  scale.range(sampleRampColors(seqStops, scale.range().length));
9656
10179
  } else {
9657
10180
  const ordinalScale = scales.color.scale;
9658
- ordinalScale.range(categoricalColorsForDomain(ordinalScale.domain(), theme, highlight));
10181
+ const domain = ordinalScale.domain();
10182
+ ordinalScale.range(categoricalColorsForDomain(domain, theme, highlight, markType));
10183
+ return paletteOverflowWarnings(domain.length, theme, markType);
9659
10184
  }
10185
+ return [];
9660
10186
  }
9661
10187
 
9662
10188
  // src/compile/data-clip.ts
@@ -9683,14 +10209,14 @@ function filterClippedDomains(data, encoding) {
9683
10209
  }
9684
10210
 
9685
10211
  // src/compile/fill-patterns.ts
9686
- import { getRepresentativeColor as getRepresentativeColor12, pickLabelColor as pickLabelColor3 } from "@opendata-ai/openchart-core";
10212
+ import { getRepresentativeColor as getRepresentativeColor13, pickLabelColor as pickLabelColor3 } from "@opendata-ai/openchart-core";
9687
10213
  var PATTERN_SEQUENCE = ["diagonal", "dot", "crosshatch", "vertical"];
9688
10214
  var MIN_PATTERN_EXTENT = 12;
9689
10215
  function applyFillPatterns(marks, markDef, theme) {
9690
10216
  if (markDef.fillPattern !== "auto") return;
9691
10217
  const assignment = /* @__PURE__ */ new Map();
9692
10218
  const resolve = (fill) => {
9693
- const base = getRepresentativeColor12(fill);
10219
+ const base = getRepresentativeColor13(fill);
9694
10220
  let resolved = assignment.get(base);
9695
10221
  if (!resolved) {
9696
10222
  const type = PATTERN_SEQUENCE[assignment.size % PATTERN_SEQUENCE.length];
@@ -10055,7 +10581,7 @@ function evenRange(start, end, count) {
10055
10581
  return Array.from({ length: count }, (_, i) => start + step * i);
10056
10582
  }
10057
10583
  function resolveBandPadding(scaleConfig) {
10058
- const padding = scaleConfig?.padding ?? 0.35;
10584
+ const padding = scaleConfig?.padding ?? DEFAULT_BAND_PADDING;
10059
10585
  return {
10060
10586
  paddingInner: scaleConfig?.paddingInner ?? padding,
10061
10587
  paddingOuter: scaleConfig?.paddingOuter ?? padding
@@ -10072,7 +10598,7 @@ function estimateBandStep(scaleConfig, plotWidth, n) {
10072
10598
  if (denom <= 0) return 0;
10073
10599
  return plotWidth / denom;
10074
10600
  }
10075
- var DEFAULT_BAND_PADDING = 0.35;
10601
+ var DEFAULT_BAND_PADDING = 0.25;
10076
10602
  var HEATMAP_BAND_PADDING = 0.04;
10077
10603
  function buildBandScale(channel, data, rangeStart, rangeEnd, defaultPadding = DEFAULT_BAND_PADDING) {
10078
10604
  const values = channel.scale?.domain ? channel.scale.domain : applyCategoricalSort(uniqueFieldStrings2(data, channel.field), channel.sort);
@@ -10450,6 +10976,16 @@ var LEGEND_RIGHT_WIDTH = 120;
10450
10976
  var RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
10451
10977
  var TOP_LEGEND_MAX_ROWS = 2;
10452
10978
  var ARC_LABEL_CROWDING_LIMIT = 21;
10979
+ function compactClassLegend(spec, theme, measureWidth) {
10980
+ const colorEnc = spec.encoding.color;
10981
+ if (!colorEnc || !("field" in colorEnc)) return null;
10982
+ const values = [];
10983
+ for (const row of spec.data) {
10984
+ const value = Number(row[colorEnc.field]);
10985
+ if (Number.isFinite(value)) values.push(value);
10986
+ }
10987
+ return computeCompactClassLegendContent(values, colorEnc, theme, measureWidth);
10988
+ }
10453
10989
  function hasLegendContent(content) {
10454
10990
  return content.entries.length > 0 || content.continuous != null;
10455
10991
  }
@@ -10501,7 +11037,8 @@ function extractColorEntries(spec, theme) {
10501
11037
  const explicitRange = colorEnc.scale?.range;
10502
11038
  const shape = swatchShapeForType(spec.markType);
10503
11039
  const scaleDomain = explicitDomain ? explicitDomain.map(String) : applyCategoricalSort(dataValues, colorEnc.sort);
10504
- const domainColors = explicitRange ?? categoricalColorsForDomain(scaleDomain, theme, spec.highlight);
11040
+ const rawDomainColors = explicitRange ?? categoricalColorsForDomain(scaleDomain, theme, spec.highlight, spec.markType);
11041
+ const domainColors = isStrokeSeriesMark(spec.markType) ? rawDomainColors.map((c) => resolveSeriesStroke(spec, c, theme)) : rawDomainColors;
10505
11042
  const uniqueValues = explicitDomain ? [
10506
11043
  ...explicitDomain.filter((v) => dataValues.includes(v)),
10507
11044
  ...dataValues.filter((v) => !explicitDomain.includes(v))
@@ -10557,8 +11094,9 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
10557
11094
  return emptyContent("top", theme);
10558
11095
  }
10559
11096
  if (hasContinuousColorScale(spec)) {
10560
- const continuous = computeContinuousLegendContent(spec, theme, availableWidth);
10561
- const continuousPosition = spec.legend?.position === "bottom" ? "bottom" : "top";
11097
+ const compact = spec.markType === "calendar" && !spec.legend?.position ? compactClassLegend(spec, theme, measureWidth) : null;
11098
+ const continuous = compact ?? computeContinuousLegendContent(spec, theme, availableWidth);
11099
+ const continuousPosition = compact ? "bottom" : spec.legend?.position === "bottom" ? "bottom" : "top";
10562
11100
  if (!continuous) {
10563
11101
  return emptyContent(continuousPosition, theme);
10564
11102
  }
@@ -10571,15 +11109,16 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
10571
11109
  fontVariant: "tabular-nums"
10572
11110
  };
10573
11111
  const labelRowHeight = Math.ceil(labelStyle2.fontSize * labelStyle2.lineHeight);
10574
- const height = continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
11112
+ const height = compact ? Math.max(continuous.barHeight, labelRowHeight) : continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
11113
+ const blockWidth = continuous.blockWidth ?? continuous.barWidth;
10575
11114
  return {
10576
11115
  entries: [],
10577
11116
  position: continuousPosition,
10578
11117
  labelStyle: labelStyle2,
10579
11118
  rowCount: 1,
10580
- totalWidth: continuous.barWidth,
11119
+ totalWidth: blockWidth,
10581
11120
  height,
10582
- legendWidth: continuous.barWidth,
11121
+ legendWidth: blockWidth,
10583
11122
  offset: spec.legend?.offset,
10584
11123
  ...categoricalDefaults(theme),
10585
11124
  continuous
@@ -10618,7 +11157,10 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
10618
11157
  fontSize: theme.fonts.sizes.small,
10619
11158
  fontWeight: theme.fonts.weights.normal,
10620
11159
  fill: theme.colors.text,
10621
- lineHeight: 1.3
11160
+ lineHeight: 1.3,
11161
+ // Legend labels are often values or years; lining figures keep the column
11162
+ // of entries from shimmying.
11163
+ fontVariant: "tabular-nums"
10622
11164
  };
10623
11165
  const specPosition = spec.legend?.position === "top-left" ? "top" : spec.legend?.position;
10624
11166
  const resolvedPosition = specPosition ?? (strategy.legendPosition === "right" ? "right" : "top");
@@ -10753,10 +11295,15 @@ function placeLegend(content, chartArea, containerWidth, _theme, xAxisHeight, ax
10753
11295
  const cDx = offset?.dx ?? 0;
10754
11296
  const cDy = offset?.dy ?? 0;
10755
11297
  const cGap = legendGap(containerWidth);
10756
- const boundsX2 = chartArea.x + cDx;
11298
+ const boundsX2 = c.align === "right" ? chartArea.x + chartArea.width - legendWidth + cDx : chartArea.x + cDx;
10757
11299
  const boundsY2 = position === "bottom" ? chartArea.y + chartArea.height + xAxisHeight + cGap + cDy : chartArea.y - axisGapBelowLegend - cGap - height + cDy;
10758
11300
  const bounds2 = { x: boundsX2, y: boundsY2, width: legendWidth, height };
10759
- const bar = { x: bounds2.x, y: bounds2.y, width: c.barWidth, height: c.barHeight };
11301
+ const bar = {
11302
+ x: bounds2.x + (c.barOffsetX ?? 0),
11303
+ y: bounds2.y,
11304
+ width: c.barWidth,
11305
+ height: c.barHeight
11306
+ };
10760
11307
  return {
10761
11308
  type: "continuous",
10762
11309
  mode: c.mode,
@@ -10767,7 +11314,7 @@ function placeLegend(content, chartArea, containerWidth, _theme, xAxisHeight, ax
10767
11314
  colorStops: c.colorStops,
10768
11315
  bins: c.bins.map((b) => ({ ...b, x: b.x + bar.x })),
10769
11316
  ticks: c.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
10770
- labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize
11317
+ labelY: bar.y + (c.labelBaselineOffset ?? bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize)
10771
11318
  };
10772
11319
  }
10773
11320
  if (entries.length === 0) {
@@ -10825,6 +11372,7 @@ import {
10825
11372
  import { scaleLinear as scaleLinear3, scaleSqrt as scaleSqrt3 } from "d3-scale";
10826
11373
  var LABEL_GAP = 6;
10827
11374
  var BASELINE_PAD = 2;
11375
+ var TITLE_GAP = 6;
10828
11376
  var CIRCLE_COUNT = 3;
10829
11377
  var MIN_LEGIBLE_RANGE = [4, 18];
10830
11378
  var SIZE_LEGEND_GAP = 12;
@@ -10882,6 +11430,15 @@ function toPrecision(value, digits) {
10882
11430
  const magnitude = 10 ** (Math.floor(Math.log10(Math.abs(value))) - digits + 1);
10883
11431
  return Math.round(value / magnitude) * magnitude;
10884
11432
  }
11433
+ function sizeLegendTitleStyle(theme) {
11434
+ return {
11435
+ fontFamily: theme.fonts.family,
11436
+ fontSize: theme.fonts.sizes.small,
11437
+ fontWeight: theme.fonts.weights.medium,
11438
+ fill: theme.colors.neutral.secondary,
11439
+ lineHeight: 1.3
11440
+ };
11441
+ }
10885
11442
  function hasSizeChannel(encoding) {
10886
11443
  const size = encoding?.size;
10887
11444
  return !!size && "field" in size && !!size.field && size.type === "quantitative";
@@ -10904,7 +11461,9 @@ function computeSizeLegendContent(sizeEncoding, data, theme, options) {
10904
11461
  const maxRadius = drawScale(resolved.domain[1]);
10905
11462
  if (!Number.isFinite(maxRadius) || maxRadius <= 0) return null;
10906
11463
  const fontSize = theme.fonts.sizes.small;
10907
- const baseline = maxRadius * 2;
11464
+ const title = (sizeEncoding?.title ?? sizeEncoding?.field) || void 0;
11465
+ const titleRowHeight = title ? Math.ceil(fontSize * 1.3) + TITLE_GAP : 0;
11466
+ const baseline = titleRowHeight + maxRadius * 2;
10908
11467
  const cx = maxRadius;
10909
11468
  const lineHeight = fontSize * 1.25;
10910
11469
  let prevLabelY = Number.NEGATIVE_INFINITY;
@@ -10926,10 +11485,14 @@ function computeSizeLegendContent(sizeEncoding, data, theme, options) {
10926
11485
  ...circles.map((c) => estimateTextWidth13(c.label, fontSize, theme.fonts.weights.normal))
10927
11486
  );
10928
11487
  const lastLabelBottom = (circles[circles.length - 1]?.labelY ?? 0) + fontSize * 0.5;
11488
+ const circleBlockWidth = maxRadius * 2 + LABEL_GAP + widestLabel;
11489
+ const titleWidth = title ? estimateTextWidth13(title, fontSize, theme.fonts.weights.medium) : 0;
10929
11490
  return {
10930
11491
  circles,
10931
- width: maxRadius * 2 + LABEL_GAP + widestLabel,
10932
- height: Math.max(baseline, lastLabelBottom) + BASELINE_PAD
11492
+ width: Math.max(circleBlockWidth, titleWidth),
11493
+ height: Math.max(baseline, lastLabelBottom) + BASELINE_PAD,
11494
+ title,
11495
+ titleY: title ? fontSize : void 0
10933
11496
  };
10934
11497
  }
10935
11498
  function placeSizeLegend(content, chartArea, theme, colorLegend) {
@@ -10956,7 +11519,10 @@ function placeSizeLegend(content, chartArea, theme, colorLegend) {
10956
11519
  fill: theme.colors.text,
10957
11520
  lineHeight: 1.3,
10958
11521
  fontVariant: "tabular-nums"
10959
- }
11522
+ },
11523
+ title: content.title,
11524
+ titleStyle: content.title ? sizeLegendTitleStyle(theme) : void 0,
11525
+ titleY: content.titleY
10960
11526
  };
10961
11527
  }
10962
11528
 
@@ -10965,7 +11531,6 @@ import {
10965
11531
  axisTitleOffset,
10966
11532
  computeXAxisExtentFromLabels,
10967
11533
  estimateTextWidth as estimateTextWidth15,
10968
- getAxisTitleOffset,
10969
11534
  truncateRotatedLabel,
10970
11535
  X_AXIS_TITLE_BAND,
10971
11536
  X_AXIS_TITLE_BAND_ROTATED
@@ -11384,6 +11949,16 @@ function clampXTickLabels(ticks, totalWidth, tickAngle, fontSize, fontWeight, me
11384
11949
  return x === tick.position ? tick : { ...tick, labelPosition: x };
11385
11950
  });
11386
11951
  }
11952
+ function capTickCount(ticks, max4) {
11953
+ if (max4 <= 0 || ticks.length <= max4) return ticks;
11954
+ if (max4 === 1) return [ticks[0]];
11955
+ const last = ticks.length - 1;
11956
+ const kept = [];
11957
+ for (let i = 0; i < max4; i++) {
11958
+ kept.push(ticks[Math.round(i * last / (max4 - 1))]);
11959
+ }
11960
+ return kept;
11961
+ }
11387
11962
  function computeAxes(scales, chartArea, strategy, theme, measureText, dataContext) {
11388
11963
  const result = {};
11389
11964
  const baseDensity = strategy.axisLabelDensity;
@@ -11478,6 +12053,9 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
11478
12053
  } else {
11479
12054
  ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
11480
12055
  }
12056
+ if (isContinuousX && !hasExplicitValues && dataContext?.maxXTicks != null) {
12057
+ ticks = capTickCount(ticks, dataContext.maxXTicks);
12058
+ }
11481
12059
  if (tickAngle && Math.abs(tickAngle) > 10) {
11482
12060
  const measureWidth = (text, size, weight) => measureLabel(text, size, weight ?? fontWeight, measureText);
11483
12061
  ticks = ticks.map((t) => {
@@ -11497,7 +12075,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
11497
12075
  const xDomainLine = axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? "") ? void 0 : false);
11498
12076
  result.x = {
11499
12077
  ticks,
11500
- gridlines: axisConfig?.grid ?? dataContext?.markType === "point" ? gridlines : [],
12078
+ gridlines: axisConfig?.grid ?? (!dataContext?.suppressGrid && dataContext?.markType === "point") ? gridlines : [],
11501
12079
  label: axisTitle,
11502
12080
  labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
11503
12081
  tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
@@ -11575,7 +12153,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
11575
12153
  // Not for heatmaps: the cells tile the plot, so a gridline can only draw
11576
12154
  // *through* them. The cell grid already is the grid. Explicit `grid: true`
11577
12155
  // still wins.
11578
- gridlines: axisConfig?.grid ?? dataContext?.markType !== "rect" ? gridlines : [],
12156
+ gridlines: axisConfig?.grid ?? (!dataContext?.suppressGrid && dataContext?.markType !== "rect") ? gridlines : [],
11579
12157
  label: axisTitle,
11580
12158
  labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
11581
12159
  tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
@@ -11626,7 +12204,15 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
11626
12204
  if (result.y?.label && result.y.labelStyle) {
11627
12205
  const isRight = result.y.orient === "right";
11628
12206
  if (isRight) {
11629
- const titleOff = getAxisTitleOffset(totalWidth);
12207
+ const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12208
+ const w = estimateTextWidth15(
12209
+ t.label,
12210
+ result.y.tickLabelStyle.fontSize,
12211
+ result.y.tickLabelStyle.fontWeight ?? 400
12212
+ );
12213
+ return Math.max(max4, w);
12214
+ }, 0);
12215
+ const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
11630
12216
  result.y.titlePosition = {
11631
12217
  x: chartArea.x + chartArea.width + titleOff,
11632
12218
  y: chartArea.y + chartArea.height / 2,
@@ -12107,6 +12693,10 @@ function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
12107
12693
  const v = Number(row[yField]);
12108
12694
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
12109
12695
  }
12696
+ if (maxAbsVal > 0) {
12697
+ const mag = 10 ** (Math.floor(Math.log10(maxAbsVal)) - 1);
12698
+ maxAbsVal = Math.ceil(maxAbsVal / mag) * mag;
12699
+ }
12110
12700
  const ctx = computeFieldFormatContext5(data.map((r) => r[yField]));
12111
12701
  let sampleLabel;
12112
12702
  if (yAxisFormat) {
@@ -12150,7 +12740,8 @@ function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
12150
12740
  const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
12151
12741
  const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
12152
12742
  const halfGlyph = Math.ceil(bodyFontSize / 2);
12153
- const titleCenterOffset = axisTitleOffset3(rightAxisWidth, bodyFontSize, options.width);
12743
+ const rawTickWidth = Math.max(0, rightAxisWidth - 10);
12744
+ const titleCenterOffset = axisTitleOffset3(rawTickWidth, bodyFontSize, options.width);
12154
12745
  const titleExtent = hasRightAxisTitle ? titleCenterOffset + halfGlyph + (options.width < BREAKPOINT_COMPACT_MAX2 ? 0 : AXIS_TITLE_TRAILING_PAD2) : 0;
12155
12746
  const rightReserve = Math.max(tickExtent, titleExtent);
12156
12747
  const optionsWithReserve = {
@@ -12186,7 +12777,7 @@ function compileLayerIndependent(leaves, layerSpec, options, compileChart2) {
12186
12777
  },
12187
12778
  ...layout1.axes.y.label ? {
12188
12779
  titlePosition: {
12189
- x: layout0.area.x + layout0.area.width + axisTitleOffset3(rightAxisWidth, bodyFontSize, layout0.dimensions.width),
12780
+ x: layout0.area.x + layout0.area.width + axisTitleOffset3(rawTickWidth, bodyFontSize, layout0.dimensions.width),
12190
12781
  y: layout0.area.y + layout0.area.height / 2,
12191
12782
  angle: 90
12192
12783
  }
@@ -12599,7 +13190,7 @@ function resolveYouDrawIt(config, marks, scales, chartArea, xField) {
12599
13190
  }
12600
13191
 
12601
13192
  // src/compiler/a11y-warnings.ts
12602
- import { contrastRatio, findAccessibleColor, isOpaqueColor as isOpaqueColor2 } from "@opendata-ai/openchart-core";
13193
+ import { contrastRatio, findAccessibleColor, isOpaqueColor } from "@opendata-ai/openchart-core";
12603
13194
  var GRAPHIC_MIN = 3;
12604
13195
  var TEXT_MIN = 4.5;
12605
13196
  var ADJACENT_FILL_MARKS = /* @__PURE__ */ new Set(["bar", "area", "arc", "waffle"]);
@@ -12607,7 +13198,7 @@ var fmt = (ratio) => `${ratio.toFixed(2)}:1`;
12607
13198
  function collectContrastWarnings(scales, markType, theme) {
12608
13199
  const warnings = [];
12609
13200
  const bg = theme.colors.background;
12610
- const bgIsCheckable = isOpaqueColor2(bg);
13201
+ const bgIsCheckable = isOpaqueColor(bg);
12611
13202
  const series = [];
12612
13203
  if (scales.color && scales.color.type !== "sequential") {
12613
13204
  const ordinal = scales.color.scale;
@@ -12746,7 +13337,7 @@ function buildSparklineAreaGradient(baseColor) {
12746
13337
  x2: 0,
12747
13338
  y2: 1,
12748
13339
  stops: [
12749
- { offset: 0, color: baseColor, opacity: 0.35 },
13340
+ { offset: 0, color: baseColor, opacity: 0.2 },
12750
13341
  { offset: 1, color: baseColor, opacity: 0 }
12751
13342
  ]
12752
13343
  };
@@ -12756,7 +13347,7 @@ function buildSparklineAreaGradient(baseColor) {
12756
13347
  import {
12757
13348
  adaptTheme as adaptTheme2,
12758
13349
  buildD3Formatter as buildD3Formatter2,
12759
- CATEGORICAL_PALETTE,
13350
+ CATEGORICAL_PALETTE as CATEGORICAL_PALETTE2,
12760
13351
  computeChrome as computeChrome3,
12761
13352
  estimateTextWidth as estimateTextWidth18,
12762
13353
  formatNumber as formatNumber3,
@@ -12900,9 +13491,75 @@ function excludePolarFeatures(geojson) {
12900
13491
  if (filtered.length === 0) return geojson;
12901
13492
  return { type: "FeatureCollection", features: filtered };
12902
13493
  }
13494
+ function resolveDefaultProjection(topology) {
13495
+ const bbox = topologyBBox(topology);
13496
+ if (!bbox) return "albersUsa";
13497
+ const [minX, minY, maxX, maxY] = bbox;
13498
+ const preProjected = minX < -180.5 || maxX > 180.5 || minY < -90.5 || maxY > 90.5;
13499
+ if (preProjected) return "identity";
13500
+ if (maxX - minX > 200) return "equalEarth";
13501
+ return "albersUsa";
13502
+ }
13503
+ function topologyBBox(topology) {
13504
+ if (!topology || typeof topology !== "object") return null;
13505
+ const topo = topology;
13506
+ if (Array.isArray(topo.bbox) && topo.bbox.length >= 4) {
13507
+ const box = topo.bbox.slice(0, 4);
13508
+ if (box.every((n) => Number.isFinite(n))) {
13509
+ return [box[0], box[1], box[2], box[3]];
13510
+ }
13511
+ }
13512
+ if (!Array.isArray(topo.arcs) || topo.arcs.length === 0) return null;
13513
+ let minX = Number.POSITIVE_INFINITY;
13514
+ let minY = Number.POSITIVE_INFINITY;
13515
+ let maxX = Number.NEGATIVE_INFINITY;
13516
+ let maxY = Number.NEGATIVE_INFINITY;
13517
+ const quantized = !!topo.transform;
13518
+ for (const arc of topo.arcs) {
13519
+ let x = 0;
13520
+ let y = 0;
13521
+ for (const point of arc) {
13522
+ if (!Array.isArray(point) || point.length < 2) continue;
13523
+ if (quantized) {
13524
+ x += point[0];
13525
+ y += point[1];
13526
+ } else {
13527
+ x = point[0];
13528
+ y = point[1];
13529
+ }
13530
+ if (x < minX) minX = x;
13531
+ if (x > maxX) maxX = x;
13532
+ if (y < minY) minY = y;
13533
+ if (y > maxY) maxY = y;
13534
+ }
13535
+ }
13536
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
13537
+ if (topo.transform) {
13538
+ const { scale, translate } = topo.transform;
13539
+ return [
13540
+ minX * scale[0] + translate[0],
13541
+ minY * scale[1] + translate[1],
13542
+ maxX * scale[0] + translate[0],
13543
+ maxY * scale[1] + translate[1]
13544
+ ];
13545
+ }
13546
+ return [minX, minY, maxX, maxY];
13547
+ }
12903
13548
 
12904
13549
  // src/geo/compile-geo-map.ts
12905
13550
  var OVERLAY_LEGEND_INSET = 18;
13551
+ var NO_DATA_SWATCH_GAP = 12;
13552
+ var NO_DATA_LABEL_GAP = 5;
13553
+ function legendTitleFor(colorEncoding) {
13554
+ const base = colorEncoding.title ?? colorEncoding.field;
13555
+ if (!base) return void 0;
13556
+ const format = colorEncoding.format;
13557
+ if (typeof format === "string") {
13558
+ if (format.includes("%")) return `${base} (%)`;
13559
+ if (format.includes("$")) return `${base} ($)`;
13560
+ }
13561
+ return base;
13562
+ }
12906
13563
  function validateGeoFeatures(geo) {
12907
13564
  if (!geo.features) {
12908
13565
  throw new Error(
@@ -12984,8 +13641,10 @@ function compileGeoMap(spec, options) {
12984
13641
  const legendPosition = mapSpec.legend?.position === "bottom" ? "bottom" : "top";
12985
13642
  const pointLegendOverlay = mapSpec.legend?.position === "top-left";
12986
13643
  let legendBlockHeight = 0;
13644
+ let legendTitle;
12987
13645
  let continuousContent = null;
12988
- if (showChoroplethLegend && colorEncoding && isQuantitative) {
13646
+ let classScale = null;
13647
+ if (colorEncoding && isQuantitative) {
12989
13648
  const colorValues = [];
12990
13649
  for (const row of mapSpec.data) {
12991
13650
  const v = row[colorEncoding.field];
@@ -12996,19 +13655,28 @@ function compileGeoMap(spec, options) {
12996
13655
  }
12997
13656
  const legendChannel = {
12998
13657
  ...colorEncoding,
12999
- scale: { ...colorEncoding.scale, type: "quantile" }
13658
+ scale: { ...colorEncoding.scale, type: colorEncoding.scale?.type ?? "quantize" }
13000
13659
  };
13001
- continuousContent = computeContinuousLegendContentForChannel(
13002
- colorValues,
13003
- legendChannel,
13004
- theme,
13005
- fullArea.width
13006
- );
13660
+ if (colorValues.length > 0) {
13661
+ classScale = classScaleForChannel(colorValues, legendChannel, theme);
13662
+ }
13663
+ if (showChoroplethLegend) {
13664
+ continuousContent = computeContinuousLegendContentForChannel(
13665
+ colorValues,
13666
+ legendChannel,
13667
+ theme,
13668
+ fullArea.width,
13669
+ classScale ?? void 0
13670
+ );
13671
+ }
13007
13672
  if (continuousContent) {
13673
+ legendTitle = legendTitleFor(colorEncoding);
13008
13674
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
13009
- legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
13675
+ const titleRowHeight = legendTitle ? labelRowHeight : 0;
13676
+ legendBlockHeight = titleRowHeight + continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
13010
13677
  }
13011
- } else if (showChoroplethLegend && colorEncoding) {
13678
+ }
13679
+ if (legendBlockHeight === 0 && showChoroplethLegend && colorEncoding && !isQuantitative) {
13012
13680
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
13013
13681
  legendBlockHeight = Math.max(10, labelHeight) + 6;
13014
13682
  }
@@ -13026,7 +13694,14 @@ function compileGeoMap(spec, options) {
13026
13694
  if (mapAreaHeight <= 0) {
13027
13695
  return emptyLayout3(chrome, theme, options, watermark);
13028
13696
  }
13029
- const projectionType = mapSpec.geo.projection;
13697
+ const projectionType = mapSpec.geo.projection ?? resolveDefaultProjection(topology);
13698
+ if (options.dev && !mapSpec.geo.projection && projectionType === "identity") {
13699
+ compileWarnings.push({
13700
+ code: "PROJECTION_INFERRED",
13701
+ message: 'No geo.projection was given and the topology looks pre-projected (its bounding box is outside longitude/latitude range), so projection: "identity" was inferred. Set geo.projection explicitly to silence this.',
13702
+ context: { projection: projectionType }
13703
+ });
13704
+ }
13030
13705
  let pointInset = 0;
13031
13706
  if (mapSpec.points) {
13032
13707
  const sizeRange = mapSpec.points.size?.scale?.range;
@@ -13071,8 +13746,7 @@ function compileGeoMap(spec, options) {
13071
13746
  }
13072
13747
  const droppedFeatures = [];
13073
13748
  const formatter = buildD3Formatter2(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber3;
13074
- const neutralFill = isDarkMode ? "#2a2a2a" : "#e8e8e8";
13075
- const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
13749
+ const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
13076
13750
  let featureMarks;
13077
13751
  let continuousLegend = null;
13078
13752
  let categoricalLegend = null;
@@ -13081,7 +13755,6 @@ function compileGeoMap(spec, options) {
13081
13755
  geoFeatures,
13082
13756
  pathGen,
13083
13757
  neutralFill,
13084
- neutralStroke,
13085
13758
  droppedFeatures
13086
13759
  });
13087
13760
  } else if (isQuantitative) {
@@ -13090,10 +13763,8 @@ function compileGeoMap(spec, options) {
13090
13763
  pathGen,
13091
13764
  joined,
13092
13765
  colorField: colorEncoding.field,
13093
- palette: colorEncoding.scale?.scheme ?? "blue",
13094
- isDarkMode: !!isDarkMode,
13766
+ classScale,
13095
13767
  neutralFill,
13096
- neutralStroke,
13097
13768
  formatter,
13098
13769
  droppedFeatures
13099
13770
  });
@@ -13109,6 +13780,12 @@ function compileGeoMap(spec, options) {
13109
13780
  };
13110
13781
  const legendX = fullArea.x;
13111
13782
  const legendY = legendPosition === "bottom" ? fullArea.y + mapAreaHeight + legendReserveGap : fullArea.y + legendReserveGap;
13783
+ const titleStyle = legendTitle ? {
13784
+ ...labelStyle,
13785
+ fontWeight: theme.fonts.weights.medium,
13786
+ fill: theme.colors.neutral.secondary
13787
+ } : void 0;
13788
+ const titleRowHeight = legendTitle ? Math.ceil(theme.fonts.sizes.small * 1.3) : 0;
13112
13789
  const bounds = {
13113
13790
  x: legendX,
13114
13791
  y: legendY,
@@ -13117,10 +13794,25 @@ function compileGeoMap(spec, options) {
13117
13794
  };
13118
13795
  const bar = {
13119
13796
  x: bounds.x,
13120
- y: bounds.y,
13797
+ y: bounds.y + titleRowHeight,
13121
13798
  width: continuousContent.barWidth,
13122
13799
  height: continuousContent.barHeight
13123
13800
  };
13801
+ const labelY = bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize;
13802
+ const hasUnmatched = featureMarks.some((m) => {
13803
+ const raw = m.data?.[colorEncoding.field];
13804
+ return raw == null || Number.isNaN(Number(raw));
13805
+ });
13806
+ const noDataX = bar.x + bar.width + NO_DATA_SWATCH_GAP;
13807
+ const noData = hasUnmatched && noDataX + bar.height + NO_DATA_LABEL_GAP < fullArea.x + fullArea.width ? {
13808
+ x: noDataX,
13809
+ y: bar.y,
13810
+ size: bar.height,
13811
+ fill: neutralFill,
13812
+ label: "No data",
13813
+ labelX: noDataX + bar.height + NO_DATA_LABEL_GAP,
13814
+ labelY: bar.y + bar.height
13815
+ } : void 0;
13124
13816
  continuousLegend = {
13125
13817
  type: "continuous",
13126
13818
  mode: continuousContent.mode,
@@ -13131,7 +13823,11 @@ function compileGeoMap(spec, options) {
13131
13823
  colorStops: continuousContent.colorStops,
13132
13824
  bins: continuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
13133
13825
  ticks: continuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
13134
- labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize
13826
+ labelY,
13827
+ title: legendTitle,
13828
+ titleStyle,
13829
+ titleY: legendTitle ? bounds.y + labelStyle.fontSize : void 0,
13830
+ noData
13135
13831
  };
13136
13832
  }
13137
13833
  } else {
@@ -13143,7 +13839,6 @@ function compileGeoMap(spec, options) {
13143
13839
  colorScale: colorEncoding.scale,
13144
13840
  isDarkMode: !!isDarkMode,
13145
13841
  neutralFill,
13146
- neutralStroke,
13147
13842
  theme,
13148
13843
  fullArea,
13149
13844
  mapAreaHeight,
@@ -13174,12 +13869,20 @@ function compileGeoMap(spec, options) {
13174
13869
  );
13175
13870
  const interiorPath = pathGen(interiorMesh) ?? "";
13176
13871
  const outlinePath = pathGen(outlineMesh) ?? "";
13177
- const interiorStroke = isDarkMode ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.15)";
13178
- const outlineStroke = isDarkMode ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.3)";
13179
- const borders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
13872
+ const interiorStroke = isDarkMode ? "rgba(255,255,255,0.22)" : "rgba(0,0,0,0.25)";
13873
+ const outlineStroke = isDarkMode ? "rgba(255,255,255,0.45)" : "rgba(0,0,0,0.55)";
13874
+ const borders = {
13875
+ interiorPath,
13876
+ outlinePath,
13877
+ interiorStroke,
13878
+ outlineStroke,
13879
+ interiorWidth: 0.6,
13880
+ outlineWidth: 1.1
13881
+ };
13180
13882
  const pointMarks = [];
13181
13883
  let pointCategoricalLegend = null;
13182
13884
  let pointContinuousLegend = null;
13885
+ let pointSizeLegend = null;
13183
13886
  if (mapSpec.points) {
13184
13887
  const pts = mapSpec.points;
13185
13888
  const lonField = pts.longitude.field;
@@ -13205,7 +13908,7 @@ function compileGeoMap(spec, options) {
13205
13908
  const qScale = scaleQuantile3().domain(colorValues).range(paletteStops);
13206
13909
  pointColorScale = (val) => {
13207
13910
  const v = Number(val);
13208
- return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
13911
+ return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE2[0];
13209
13912
  };
13210
13913
  }
13211
13914
  } else {
@@ -13227,7 +13930,7 @@ function compileGeoMap(spec, options) {
13227
13930
  }
13228
13931
  }
13229
13932
  }
13230
- const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
13933
+ const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE2;
13231
13934
  for (let i = 0; i < pointCategories.length; i++) {
13232
13935
  pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
13233
13936
  }
@@ -13237,9 +13940,8 @@ function compileGeoMap(spec, options) {
13237
13940
  };
13238
13941
  }
13239
13942
  }
13240
- const defaultFill = CATEGORICAL_PALETTE[0];
13241
- const pointOpacity = pts.opacity ?? 0.65;
13242
- let animIndex = 0;
13943
+ const defaultFill = CATEGORICAL_PALETTE2[0];
13944
+ const pointOpacity = pts.opacity ?? 0.85;
13243
13945
  const droppedPoints = [];
13244
13946
  for (let i = 0; i < pts.data.length; i++) {
13245
13947
  const row = pts.data[i];
@@ -13254,7 +13956,7 @@ function compileGeoMap(spec, options) {
13254
13956
  const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
13255
13957
  const fill = pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
13256
13958
  const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
13257
- const stroke = isDarkMode ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.8)";
13959
+ const stroke = resolveKnockoutColor(theme);
13258
13960
  pointMarks.push({
13259
13961
  type: "map-point",
13260
13962
  cx: projected[0],
@@ -13267,9 +13969,13 @@ function compileGeoMap(spec, options) {
13267
13969
  key,
13268
13970
  data: row,
13269
13971
  aria: { role: "img", label: key },
13270
- animationIndex: animIndex++
13972
+ animationIndex: 0
13271
13973
  });
13272
13974
  }
13975
+ pointMarks.sort((a, b) => b.r - a.r);
13976
+ for (let i = 0; i < pointMarks.length; i++) {
13977
+ pointMarks[i].animationIndex = i;
13978
+ }
13273
13979
  if (droppedPoints.length > 0) {
13274
13980
  const sample = droppedPoints.slice(0, 3).map(([lo, la]) => `[${lo}, ${la}]`).join(", ");
13275
13981
  compileWarnings.push({
@@ -13371,6 +14077,42 @@ function compileGeoMap(spec, options) {
13371
14077
  };
13372
14078
  }
13373
14079
  }
14080
+ if (pts.size && showLegend) {
14081
+ const sizeRange = pts.size.scale?.range ?? [
14082
+ ...SIZE_SCALE_DEFAULTS.mapPoint.range
14083
+ ];
14084
+ const content = computeSizeLegendContent(pts.size, pts.data, theme, {
14085
+ curve: SIZE_SCALE_DEFAULTS.mapPoint.curve,
14086
+ range: sizeRange
14087
+ });
14088
+ if (content) {
14089
+ const mapTop = legendPosition === "top" && legendBlockHeight > 0 ? fullArea.y + legendBlockHeight + legendReserveGap : fullArea.y;
14090
+ pointSizeLegend = {
14091
+ type: "size",
14092
+ channel: "size",
14093
+ position: "bottom-right",
14094
+ bounds: {
14095
+ x: fullArea.x + fullArea.width - content.width - OVERLAY_LEGEND_INSET,
14096
+ y: mapTop + mapAreaHeight - content.height - OVERLAY_LEGEND_INSET,
14097
+ width: content.width,
14098
+ height: content.height
14099
+ },
14100
+ circles: content.circles,
14101
+ stroke: theme.colors.neutral[400],
14102
+ labelStyle: {
14103
+ fontFamily: theme.fonts.family,
14104
+ fontSize: theme.fonts.sizes.small,
14105
+ fontWeight: theme.fonts.weights.normal,
14106
+ fill: theme.colors.neutral.secondary,
14107
+ lineHeight: 1.2,
14108
+ fontVariant: "tabular-nums"
14109
+ },
14110
+ title: content.title,
14111
+ titleStyle: content.title ? sizeLegendTitleStyle(theme) : void 0,
14112
+ titleY: content.titleY
14113
+ };
14114
+ }
14115
+ }
13374
14116
  }
13375
14117
  const tooltipChannels = mapSpec.encoding.tooltip ? Array.isArray(mapSpec.encoding.tooltip) ? mapSpec.encoding.tooltip : [mapSpec.encoding.tooltip] : null;
13376
14118
  const tooltipDescriptors = /* @__PURE__ */ new Map();
@@ -13484,6 +14226,7 @@ function compileGeoMap(spec, options) {
13484
14226
  pointMarks,
13485
14227
  pointCategoricalLegend,
13486
14228
  pointContinuousLegend,
14229
+ pointSizeLegend,
13487
14230
  tooltipDescriptors,
13488
14231
  a11y,
13489
14232
  theme,
@@ -13498,7 +14241,7 @@ function compileGeoMap(spec, options) {
13498
14241
  };
13499
14242
  }
13500
14243
  function buildBasemapMarks(opts) {
13501
- const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
14244
+ const { geoFeatures, pathGen, neutralFill, droppedFeatures } = opts;
13502
14245
  const marks = [];
13503
14246
  let animIndex = 0;
13504
14247
  for (const feat of geoFeatures) {
@@ -13524,8 +14267,10 @@ function buildBasemapMarks(opts) {
13524
14267
  type: "map-feature",
13525
14268
  path: pathD,
13526
14269
  fill: neutralFill,
13527
- stroke: neutralStroke,
13528
- strokeWidth: 0.5,
14270
+ // The border meshes draw every line on this map. A per-feature stroke on
14271
+ // top of them double-draws each shared edge into a visible wash.
14272
+ stroke: "none",
14273
+ strokeWidth: 0,
13529
14274
  id: featureId,
13530
14275
  name,
13531
14276
  data: null,
@@ -13543,23 +14288,11 @@ function buildQuantitativeMarks(opts) {
13543
14288
  pathGen,
13544
14289
  joined,
13545
14290
  colorField,
13546
- palette,
13547
- isDarkMode,
14291
+ classScale,
13548
14292
  neutralFill,
13549
- neutralStroke,
13550
14293
  formatter,
13551
14294
  droppedFeatures
13552
14295
  } = opts;
13553
- const values = [];
13554
- for (const row of joined.values()) {
13555
- const raw = row[colorField];
13556
- if (raw != null) {
13557
- const v = Number(raw);
13558
- if (!Number.isNaN(v)) values.push(v);
13559
- }
13560
- }
13561
- const paletteStops = [...SEQUENTIAL_PALETTES2[palette] ?? SEQUENTIAL_PALETTES2.blue];
13562
- const colorScale = scaleQuantile3().domain(values).range(paletteStops);
13563
14296
  const marks = [];
13564
14297
  let animIndex = 0;
13565
14298
  for (const feat of geoFeatures) {
@@ -13573,16 +14306,16 @@ function buildQuantitativeMarks(opts) {
13573
14306
  const dataRow = joined.get(featureId) ?? null;
13574
14307
  const hasData = dataRow !== null;
13575
14308
  let fill = neutralFill;
13576
- let stroke = neutralStroke;
13577
- if (hasData) {
14309
+ let binIndex;
14310
+ if (hasData && classScale) {
13578
14311
  const raw = dataRow[colorField];
13579
14312
  if (raw != null) {
13580
14313
  const v = Number(raw);
13581
14314
  if (!Number.isNaN(v)) {
13582
- fill = colorScale(v);
14315
+ fill = classScale.color(v);
14316
+ binIndex = classScale.binIndex(v);
13583
14317
  }
13584
14318
  }
13585
- stroke = isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
13586
14319
  }
13587
14320
  const props = feat.properties;
13588
14321
  const name = props?.name ?? props?.NAME;
@@ -13599,8 +14332,8 @@ function buildQuantitativeMarks(opts) {
13599
14332
  type: "map-feature",
13600
14333
  path: pathD,
13601
14334
  fill,
13602
- stroke,
13603
- strokeWidth: 0.5,
14335
+ stroke: "none",
14336
+ strokeWidth: 0,
13604
14337
  id: featureId,
13605
14338
  name,
13606
14339
  data: dataRow,
@@ -13610,7 +14343,8 @@ function buildQuantitativeMarks(opts) {
13610
14343
  },
13611
14344
  animationIndex: animIndex++,
13612
14345
  bounds,
13613
- centroid: [cx, cy]
14346
+ centroid: [cx, cy],
14347
+ binIndex
13614
14348
  });
13615
14349
  }
13616
14350
  return { marks };
@@ -13624,7 +14358,6 @@ function buildCategoricalMarks(opts) {
13624
14358
  colorScale: scaleConfig,
13625
14359
  isDarkMode,
13626
14360
  neutralFill,
13627
- neutralStroke,
13628
14361
  theme,
13629
14362
  fullArea,
13630
14363
  mapAreaHeight,
@@ -13652,7 +14385,7 @@ function buildCategoricalMarks(opts) {
13652
14385
  }
13653
14386
  }
13654
14387
  const explicitRange = scaleConfig?.range;
13655
- const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
14388
+ const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE2;
13656
14389
  const categoryColors = /* @__PURE__ */ new Map();
13657
14390
  for (let i = 0; i < categories.length; i++) {
13658
14391
  categoryColors.set(categories[i], colorSource[i % colorSource.length]);
@@ -13670,14 +14403,12 @@ function buildCategoricalMarks(opts) {
13670
14403
  const dataRow = joined.get(featureId) ?? null;
13671
14404
  const hasData = dataRow !== null;
13672
14405
  let fill = neutralFill;
13673
- let stroke = neutralStroke;
13674
14406
  if (hasData) {
13675
14407
  const raw = dataRow[colorField];
13676
14408
  if (raw != null) {
13677
14409
  const cat = String(raw);
13678
14410
  fill = categoryColors.get(cat) ?? neutralFill;
13679
14411
  }
13680
- stroke = isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
13681
14412
  }
13682
14413
  const props = feat.properties;
13683
14414
  const name = props?.name ?? props?.NAME;
@@ -13694,8 +14425,8 @@ function buildCategoricalMarks(opts) {
13694
14425
  type: "map-feature",
13695
14426
  path: pathD,
13696
14427
  fill,
13697
- stroke,
13698
- strokeWidth: 0.5,
14428
+ stroke: "none",
14429
+ strokeWidth: 0,
13699
14430
  id: featureId,
13700
14431
  name,
13701
14432
  data: dataRow,
@@ -13830,14 +14561,17 @@ function emptyLayout3(chrome, theme, options, watermark) {
13830
14561
  borders: {
13831
14562
  interiorPath: "",
13832
14563
  outlinePath: "",
13833
- interiorStroke: "rgba(0,0,0,0.15)",
13834
- outlineStroke: "rgba(0,0,0,0.3)"
14564
+ interiorStroke: "rgba(0,0,0,0.25)",
14565
+ outlineStroke: "rgba(0,0,0,0.55)",
14566
+ interiorWidth: 0.6,
14567
+ outlineWidth: 1.1
13835
14568
  },
13836
14569
  continuousLegend: null,
13837
14570
  categoricalLegend: null,
13838
14571
  pointMarks: [],
13839
14572
  pointCategoricalLegend: null,
13840
14573
  pointContinuousLegend: null,
14574
+ pointSizeLegend: null,
13841
14575
  tooltipDescriptors: /* @__PURE__ */ new Map(),
13842
14576
  a11y: {
13843
14577
  altText: "Empty map",
@@ -13926,6 +14660,7 @@ function resolveCamera(phase) {
13926
14660
 
13927
14661
  // src/graphs/encoding.ts
13928
14662
  import { max as max3, min as min3 } from "d3-array";
14663
+ import { interpolateRgb } from "d3-interpolate";
13929
14664
  import { scaleLinear as scaleLinear5, scaleOrdinal as scaleOrdinal2, scaleSqrt as scaleSqrt4 } from "d3-scale";
13930
14665
  var DEFAULT_NODE_RADIUS = 5;
13931
14666
  var MIN_NODE_RADIUS = 3;
@@ -13933,7 +14668,7 @@ var MAX_NODE_RADIUS = 12;
13933
14668
  var DEFAULT_EDGE_WIDTH = 1;
13934
14669
  var MIN_EDGE_WIDTH = 0.5;
13935
14670
  var MAX_EDGE_WIDTH = 4;
13936
- var DEFAULT_STROKE_WIDTH2 = 1;
14671
+ var DEFAULT_STROKE_WIDTH = 1.5;
13937
14672
  function darkenColor(hex, amount = 0.2) {
13938
14673
  const clean = hex.replace(/^#/, "");
13939
14674
  if (clean.length !== 6 && clean.length !== 3) return hex;
@@ -13987,6 +14722,9 @@ function resolveCategoricalDomain(values, sort, scaleDomain) {
13987
14722
  const sorted = [...seen].sort((a, b) => a.localeCompare(b));
13988
14723
  return sort === "descending" ? sorted.reverse() : sorted;
13989
14724
  }
14725
+ function resolveGraphSurface(theme) {
14726
+ return theme.colors.neutral.surface;
14727
+ }
13990
14728
  function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
13991
14729
  const degrees = computeDegrees(nodes, edges);
13992
14730
  const maxDegree = Math.max(1, ...degrees.values());
@@ -14032,6 +14770,7 @@ function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
14032
14770
  }
14033
14771
  }
14034
14772
  const defaultColor = theme.colors.categorical[0];
14773
+ const surface = resolveGraphSurface(theme);
14035
14774
  let opacityScale;
14036
14775
  if (encoding.nodeOpacity?.field) {
14037
14776
  const field = encoding.nodeOpacity.field;
@@ -14070,7 +14809,7 @@ function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
14070
14809
  }
14071
14810
  }
14072
14811
  const fill = colorFn ? colorFn(node) : defaultColor;
14073
- const stroke = darkenColor(fill);
14812
+ const stroke = surface;
14074
14813
  let label;
14075
14814
  if (encoding.nodeLabel?.field) {
14076
14815
  const labelVal = node[encoding.nodeLabel.field];
@@ -14091,7 +14830,7 @@ function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
14091
14830
  const override = nodeOverrides?.[node.id];
14092
14831
  const finalFill = override?.fill ?? fill;
14093
14832
  const finalRadius = override?.radius ?? radius;
14094
- const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH2;
14833
+ const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH;
14095
14834
  const finalStroke = override?.stroke ?? stroke;
14096
14835
  const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
14097
14836
  return {
@@ -14108,7 +14847,7 @@ function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
14108
14847
  };
14109
14848
  });
14110
14849
  }
14111
- function resolveEdgeVisuals(edges, encoding, theme) {
14850
+ function resolveEdgeVisuals(edges, encoding, theme, nodeFills) {
14112
14851
  let widthScale;
14113
14852
  if (encoding.edgeWidth?.field) {
14114
14853
  const field = encoding.edgeWidth.field;
@@ -14146,7 +14885,19 @@ function resolveEdgeVisuals(edges, encoding, theme) {
14146
14885
  edgeColorFn = (edge) => ordinalScale(String(edge[field] ?? ""));
14147
14886
  }
14148
14887
  }
14149
- const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
14888
+ const fallbackEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
14889
+ const defaultEdgeColorFor = (source, target) => {
14890
+ if (!nodeFills) return fallbackEdgeColor;
14891
+ const a = nodeFills.get(source);
14892
+ const b = nodeFills.get(target);
14893
+ if (!a || !b) return fallbackEdgeColor;
14894
+ if (a === b) return a;
14895
+ try {
14896
+ return interpolateRgb(a, b)(0.5);
14897
+ } catch {
14898
+ return fallbackEdgeColor;
14899
+ }
14900
+ };
14150
14901
  const EDGE_STYLES = ["solid", "dashed", "dotted"];
14151
14902
  let styleFn;
14152
14903
  if (encoding.edgeStyle?.field) {
@@ -14171,7 +14922,7 @@ function resolveEdgeVisuals(edges, encoding, theme) {
14171
14922
  strokeWidth = widthScale(val);
14172
14923
  }
14173
14924
  }
14174
- const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
14925
+ const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColorFor(String(source), String(target));
14175
14926
  const style = styleFn ? styleFn(edge) : "solid";
14176
14927
  return {
14177
14928
  source,
@@ -14217,7 +14968,7 @@ function applyCommunityColors(nodes, colorMap) {
14217
14968
  }
14218
14969
 
14219
14970
  // src/graphs/interaction.ts
14220
- var DEFAULT_DIM_OPACITY = 0.15;
14971
+ var DEFAULT_DIM_OPACITY = 0.3;
14221
14972
  var DEFAULT_CURSOR_RADIUS = 80;
14222
14973
  var DEFAULT_CURSOR_STRENGTH = 30;
14223
14974
  function resolveGraphInteraction(cfg) {
@@ -14440,7 +15191,8 @@ function compileGraph(spec, options) {
14440
15191
  communityColorMap = buildCommunityColorMap(compiledNodes, theme);
14441
15192
  applyCommunityColors(compiledNodes, communityColorMap);
14442
15193
  }
14443
- const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme);
15194
+ const nodeFills = new Map(compiledNodes.map((n) => [n.id, n.fill]));
15195
+ const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme, nodeFills);
14444
15196
  const useCommunitiesForLegend = hasCommunities && !hasNodeColorEncoding;
14445
15197
  const nodeColorField = graphSpec.encoding.nodeColor?.field;
14446
15198
  const legendOrder = nodeColorField ? resolveCategoricalDomain(
@@ -15154,6 +15906,7 @@ function computeGridlines(axes, chartArea, showVertical = false) {
15154
15906
  // src/sankey/compile-sankey.ts
15155
15907
  import {
15156
15908
  adaptTheme as adaptTheme4,
15909
+ buildD3Formatter as buildD3Formatter3,
15157
15910
  computeChrome as computeChrome6,
15158
15911
  defaultNumberFormatter as defaultNumberFormatter10,
15159
15912
  estimateTextWidth as estimateTextWidth21,
@@ -15231,8 +15984,11 @@ function generateLinkPath(link) {
15231
15984
  // src/sankey/compile-sankey.ts
15232
15985
  var LABEL_GAP3 = 6;
15233
15986
  var LINK_OPACITY_LIGHT = 0.5;
15234
- var LINK_OPACITY_DARK = 0.75;
15987
+ var LINK_OPACITY_DARK = 0.6;
15235
15988
  var NODE_CORNER_RADIUS = 2;
15989
+ var VALUE_GAP = 5;
15990
+ var MAX_LABEL_GUTTER = 0.35;
15991
+ var OTHER_ID_PREFIX = "__oc_other_";
15236
15992
  function pickColor(palette, index) {
15237
15993
  return palette[index % palette.length];
15238
15994
  }
@@ -15275,20 +16031,66 @@ function getLinkColors(linkStyle, sourceColor, targetColor, neutralColor) {
15275
16031
  return { sourceColor, targetColor };
15276
16032
  }
15277
16033
  }
16034
+ function resolveOtherConfig(other) {
16035
+ if (other == null) return null;
16036
+ const threshold = typeof other === "number" ? other : other.threshold;
16037
+ if (!Number.isFinite(threshold) || threshold <= 0) return null;
16038
+ const label = (typeof other === "object" ? other.label : void 0) ?? "Other";
16039
+ return { threshold, label };
16040
+ }
16041
+ function bucketSmallNodes(nodes, cfg) {
16042
+ const byDepth = /* @__PURE__ */ new Map();
16043
+ for (const node of nodes) {
16044
+ const depth = node.depth ?? 0;
16045
+ const group = byDepth.get(depth);
16046
+ if (group) group.push(node);
16047
+ else byDepth.set(depth, [node]);
16048
+ }
16049
+ const merged = /* @__PURE__ */ new Map();
16050
+ for (const [depth, group] of byDepth) {
16051
+ const total = group.reduce((sum, n) => sum + (n.value ?? 0), 0);
16052
+ if (total <= 0) continue;
16053
+ const small = group.filter((n) => (n.value ?? 0) / total < cfg.threshold);
16054
+ if (small.length < 2) continue;
16055
+ for (const node of small) merged.set(node.id, `${OTHER_ID_PREFIX}${depth}`);
16056
+ }
16057
+ return merged;
16058
+ }
16059
+ function rewriteRowsForOther(data, sourceField, targetField, valueField, merged) {
16060
+ const out = [];
16061
+ const index = /* @__PURE__ */ new Map();
16062
+ for (const row of data) {
16063
+ const src = String(row[sourceField]);
16064
+ const tgt = String(row[targetField]);
16065
+ const newSrc = merged.get(src) ?? src;
16066
+ const newTgt = merged.get(tgt) ?? tgt;
16067
+ if (newSrc === newTgt) continue;
16068
+ const key = `${newSrc}\0${newTgt}`;
16069
+ const value = Number(row[valueField]) || 0;
16070
+ const existing = index.get(key);
16071
+ if (existing) {
16072
+ existing[valueField] = (Number(existing[valueField]) || 0) + value;
16073
+ continue;
16074
+ }
16075
+ const next = { ...row, [sourceField]: newSrc, [targetField]: newTgt, [valueField]: value };
16076
+ index.set(key, next);
16077
+ out.push(next);
16078
+ }
16079
+ return out;
16080
+ }
16081
+ function labelsLeftForDepth(depth, maxDepth, nodeLabelAlign) {
16082
+ if (nodeLabelAlign === "left") return true;
16083
+ if (nodeLabelAlign === "right") return false;
16084
+ if (depth === 0 && maxDepth > 0) return true;
16085
+ return false;
16086
+ }
15278
16087
  function computeNodeLabel(node, maxDepth, theme, nodeWidth, nodeLabelAlign = "auto", containerWidth, padding) {
15279
16088
  const depth = node.depth ?? 0;
15280
- let placeLeft;
15281
- if (nodeLabelAlign === "left") {
15282
- placeLeft = true;
15283
- } else if (nodeLabelAlign === "right") {
15284
- placeLeft = false;
15285
- } else {
15286
- placeLeft = depth === maxDepth;
15287
- }
16089
+ const placeLeft = labelsLeftForDepth(depth, maxDepth, nodeLabelAlign);
15288
16090
  const style = {
15289
16091
  fontFamily: theme.fonts.family,
15290
16092
  fontSize: theme.fonts.sizes.small,
15291
- fontWeight: theme.fonts.weights.normal,
16093
+ fontWeight: theme.fonts.weights.medium,
15292
16094
  fill: theme.colors.text,
15293
16095
  lineHeight: 1.3
15294
16096
  };
@@ -15421,68 +16223,134 @@ function compileSankey(spec, options) {
15421
16223
  return emptyLayout4(area3, chrome, theme, { ...options, height: grownHeight }, watermark);
15422
16224
  }
15423
16225
  const labelFontSize = theme.fonts.sizes.small;
15424
- const labelFontWeight = theme.fonts.weights.normal;
15425
- const nodeWidth = sankeySpec.nodeWidth ?? 12;
15426
- let layoutArea = { ...area3 };
15427
- let { nodes, links } = computeSankeyLayout(
15428
- sankeySpec.data,
16226
+ const labelFontWeight = theme.fonts.weights.medium;
16227
+ const valueFontWeight = theme.fonts.weights.normal;
16228
+ const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? "auto";
16229
+ let workingData = sankeySpec.data;
16230
+ const runLayout = (rect) => computeSankeyLayout(
16231
+ workingData,
15429
16232
  sourceField,
15430
16233
  targetField,
15431
16234
  valueField,
15432
- layoutArea,
16235
+ rect,
15433
16236
  sankeySpec.nodeWidth,
15434
16237
  sankeySpec.nodePadding,
15435
16238
  sankeySpec.nodeAlign,
15436
16239
  sankeySpec.iterations,
15437
16240
  sankeySpec.nodeSort
15438
16241
  );
15439
- const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? "auto";
15440
- const maxDepthFirst = nodes.reduce((max4, n) => Math.max(max4, n.depth ?? 0), 0);
15441
- const rightEdge = area3.x + area3.width;
15442
- let maxOverflow = 0;
15443
- for (const node of nodes) {
15444
- const depth = node.depth ?? 0;
15445
- const labelsLeft = nodeLabelAlign === "left" || nodeLabelAlign === "auto" && depth === maxDepthFirst;
15446
- if (labelsLeft) continue;
15447
- const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP3;
15448
- const labelText = node.label ?? node.id;
15449
- const labelWidth = estimateTextWidth21(labelText, labelFontSize, labelFontWeight);
15450
- const overflow = labelX + labelWidth - rightEdge;
15451
- if (overflow > maxOverflow) maxOverflow = overflow;
15452
- }
15453
- if (maxOverflow > 0) {
15454
- const margin = Math.ceil(maxOverflow) + 4;
16242
+ let layoutArea = { ...area3 };
16243
+ let { nodes, links } = runLayout(layoutArea);
16244
+ const otherConfig = resolveOtherConfig(sankeySpec.other);
16245
+ const otherLabels = /* @__PURE__ */ new Map();
16246
+ const otherMembers = /* @__PURE__ */ new Map();
16247
+ if (otherConfig) {
16248
+ const merged = bucketSmallNodes(nodes, otherConfig);
16249
+ if (merged.size > 0) {
16250
+ for (const [nodeId, otherId] of merged) {
16251
+ otherLabels.set(otherId, otherConfig.label);
16252
+ const members = otherMembers.get(otherId);
16253
+ if (members) members.push(nodeId);
16254
+ else otherMembers.set(otherId, [nodeId]);
16255
+ }
16256
+ workingData = rewriteRowsForOther(workingData, sourceField, targetField, valueField, merged);
16257
+ ({ nodes, links } = runLayout(layoutArea));
16258
+ }
16259
+ }
16260
+ const applyOtherLabels = (list) => {
16261
+ if (otherLabels.size === 0) return;
16262
+ for (const node of list) {
16263
+ const label = otherLabels.get(node.id);
16264
+ if (label) node.label = label;
16265
+ }
16266
+ };
16267
+ applyOtherLabels(nodes);
16268
+ const nodeFmt = resolveNodeValueFormatter(nodes, resolvedValueFormat, flowFmt);
16269
+ const labelBlockWidth = (node) => {
16270
+ const name = node.label ?? node.id;
16271
+ const nameWidth = estimateTextWidth21(name, labelFontSize, labelFontWeight);
16272
+ const valueWidth = estimateTextWidth21(
16273
+ formatFlowValue(node.value ?? 0, nodeFmt),
16274
+ labelFontSize,
16275
+ valueFontWeight
16276
+ );
16277
+ return nameWidth + VALUE_GAP + valueWidth;
16278
+ };
16279
+ const measureGutters = (list) => {
16280
+ const maxDepth2 = list.reduce((max4, n) => Math.max(max4, n.depth ?? 0), 0);
16281
+ const cap = area3.width * MAX_LABEL_GUTTER;
16282
+ let left = 0;
16283
+ let right = 0;
16284
+ for (const node of list) {
16285
+ const depth = node.depth ?? 0;
16286
+ const width = labelBlockWidth(node) + LABEL_GAP3;
16287
+ if (labelsLeftForDepth(depth, maxDepth2, nodeLabelAlign)) {
16288
+ const need = width - ((node.x0 ?? 0) - area3.x);
16289
+ if (need > left) left = need;
16290
+ } else {
16291
+ const need = width - (area3.x + area3.width - (node.x1 ?? 0));
16292
+ if (need > right) right = need;
16293
+ }
16294
+ }
16295
+ return {
16296
+ left: Math.min(Math.max(left, 0), cap),
16297
+ right: Math.min(Math.max(right, 0), cap)
16298
+ };
16299
+ };
16300
+ const gutters = measureGutters(nodes);
16301
+ if (gutters.left > 0 || gutters.right > 0) {
16302
+ const left = Math.ceil(gutters.left);
16303
+ const right = Math.ceil(gutters.right);
15455
16304
  layoutArea = {
15456
- x: area3.x,
16305
+ x: area3.x + left,
15457
16306
  y: area3.y,
15458
- width: Math.max(area3.width - margin, 40),
16307
+ width: Math.max(area3.width - left - right, 40),
15459
16308
  height: area3.height
15460
16309
  };
15461
- ({ nodes, links } = computeSankeyLayout(
15462
- sankeySpec.data,
15463
- sourceField,
15464
- targetField,
15465
- valueField,
15466
- layoutArea,
15467
- sankeySpec.nodeWidth,
15468
- sankeySpec.nodePadding,
15469
- sankeySpec.nodeAlign,
15470
- sankeySpec.iterations,
15471
- sankeySpec.nodeSort
15472
- ));
16310
+ ({ nodes, links } = runLayout(layoutArea));
16311
+ applyOtherLabels(nodes);
15473
16312
  }
15474
16313
  const nodeColorMap = buildNodeColorMap(
15475
16314
  nodes,
15476
16315
  theme.colors.categorical,
15477
16316
  colorField,
15478
- sankeySpec.data,
16317
+ workingData,
15479
16318
  sourceField,
15480
16319
  targetField
15481
16320
  );
16321
+ for (const otherId of otherMembers.keys()) {
16322
+ nodeColorMap.set(otherId, theme.colors.neutral[300]);
16323
+ }
15482
16324
  const maxDepth = nodes.reduce((max4, n) => Math.max(max4, n.depth ?? 0), 0);
16325
+ const valueStyle = {
16326
+ fontFamily: theme.fonts.family,
16327
+ fontSize: theme.fonts.sizes.small,
16328
+ fontWeight: theme.fonts.weights.normal,
16329
+ fill: theme.colors.axis,
16330
+ fontVariant: "tabular-nums",
16331
+ lineHeight: 1.3
16332
+ };
16333
+ const columnX0 = /* @__PURE__ */ new Map();
16334
+ for (const node of nodes) {
16335
+ const d = node.depth ?? 0;
16336
+ const x0 = node.x0 ?? 0;
16337
+ const current = columnX0.get(d);
16338
+ if (current === void 0 || x0 < current) columnX0.set(d, x0);
16339
+ }
16340
+ const valueLabelFor = (node, depth, text) => {
16341
+ const placedLeft = labelsLeftForDepth(depth, maxDepth, nodeLabelAlign);
16342
+ if (placedLeft || depth === maxDepth) return { text, style: valueStyle };
16343
+ const nextColumn = columnX0.get(depth + 1);
16344
+ if (nextColumn === void 0) return { text, style: valueStyle };
16345
+ const available = nextColumn - LABEL_GAP3 - ((node.x1 ?? 0) + LABEL_GAP3);
16346
+ const needed = estimateTextWidth21(node.label ?? node.id, labelFontSize, labelFontWeight) + VALUE_GAP + estimateTextWidth21(text, labelFontSize, valueFontWeight);
16347
+ return needed <= available ? { text, style: valueStyle } : void 0;
16348
+ };
15483
16349
  const nodeMarks = nodes.map((node) => {
15484
16350
  const fill = nodeColorMap.get(node.id) ?? theme.colors.categorical[0];
15485
16351
  const depth = node.depth ?? 0;
16352
+ const merged = otherMembers.get(node.id);
16353
+ const valueText = formatFlowValue(node.value ?? 0, nodeFmt);
15486
16354
  return {
15487
16355
  type: "sankeyNode",
15488
16356
  x: node.x0 ?? 0,
@@ -15490,6 +16358,9 @@ function compileSankey(spec, options) {
15490
16358
  width: (node.x1 ?? 0) - (node.x0 ?? 0),
15491
16359
  height: (node.y1 ?? 0) - (node.y0 ?? 0),
15492
16360
  fill,
16361
+ // Explicit: a node is a solid block of flow, not an outlined box. Without
16362
+ // this the renderer inherits whatever stroke the SVG context carries.
16363
+ stroke: "none",
15493
16364
  cornerRadius: NODE_CORNER_RADIUS,
15494
16365
  label: computeNodeLabel(
15495
16366
  node,
@@ -15500,13 +16371,14 @@ function compileSankey(spec, options) {
15500
16371
  options.width,
15501
16372
  padding
15502
16373
  ),
16374
+ valueLabel: valueLabelFor(node, depth, valueText),
15503
16375
  nodeId: node.id,
15504
16376
  value: node.value ?? 0,
15505
16377
  depth,
15506
- data: { id: node.id, label: node.label },
16378
+ data: merged ? { id: node.id, label: node.label, merged } : { id: node.id, label: node.label },
15507
16379
  aria: {
15508
16380
  role: "img",
15509
- label: `${node.label}: ${formatFlowValue(node.value ?? 0, flowFmt)}`
16381
+ label: `${node.label}: ${valueText}`
15510
16382
  },
15511
16383
  animationIndex: 0
15512
16384
  // Reassigned below after sorting by depth
@@ -15545,13 +16417,13 @@ function compileSankey(spec, options) {
15545
16417
  const finalLegend = buildSankeyLegend(
15546
16418
  nodeColorMap,
15547
16419
  colorField,
15548
- sankeySpec.data,
16420
+ workingData,
15549
16421
  sourceField,
15550
16422
  targetField,
15551
16423
  theme,
15552
16424
  fullArea
15553
16425
  );
15554
- const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, flowFmt);
16426
+ const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, flowFmt, nodeFmt);
15555
16427
  const a11y = {
15556
16428
  altText: `Sankey diagram with ${nodeMarks.length} nodes and ${linkMarks.length} links`,
15557
16429
  dataTableFallback: linkMarks.map((l) => [l.sourceId, l.targetId, String(l.value)]),
@@ -15635,18 +16507,39 @@ function buildSankeyLegend(nodeColorMap, colorField, data, sourceField, targetFi
15635
16507
  swatchChipFill: theme.colors.annotationFill
15636
16508
  };
15637
16509
  }
16510
+ var MAX_UNIFORM_DECIMALS = 2;
16511
+ function decimalPlaces(value) {
16512
+ const rounded = Number(value.toFixed(MAX_UNIFORM_DECIMALS));
16513
+ if (Number.isInteger(rounded)) return 0;
16514
+ const text = String(rounded);
16515
+ const dot = text.indexOf(".");
16516
+ return dot === -1 ? 0 : text.length - dot - 1;
16517
+ }
16518
+ function resolveNodeValueFormatter(nodes, explicitFormat, fallback) {
16519
+ if (explicitFormat) return fallback;
16520
+ const values = nodes.map((n) => n.value ?? 0).filter((v) => Number.isFinite(v));
16521
+ if (values.length === 0) return fallback;
16522
+ if (values.some((v) => Math.abs(v) >= 1e3)) return fallback;
16523
+ if (values.some((v) => v !== 0 && Math.abs(v) < 5e-3)) return fallback;
16524
+ let decimals = 0;
16525
+ for (const v of values) {
16526
+ decimals = Math.max(decimals, decimalPlaces(v));
16527
+ if (decimals >= MAX_UNIFORM_DECIMALS) break;
16528
+ }
16529
+ return buildD3Formatter3(`,.${decimals}f`) ?? fallback;
16530
+ }
15638
16531
  var defaultFmt3 = defaultNumberFormatter10();
15639
16532
  function formatFlowValue(value, formatter) {
15640
16533
  if (formatter) return formatter(value);
15641
16534
  return defaultFmt3(value);
15642
16535
  }
15643
- function buildTooltipDescriptors(nodes, links, formatter) {
16536
+ function buildTooltipDescriptors(nodes, links, formatter, nodeFormatter) {
15644
16537
  const descriptors = /* @__PURE__ */ new Map();
15645
16538
  for (const node of nodes) {
15646
16539
  const fields = [
15647
16540
  {
15648
16541
  label: "Total flow",
15649
- value: formatFlowValue(node.value, formatter)
16542
+ value: formatFlowValue(node.value, nodeFormatter ?? formatter)
15650
16543
  }
15651
16544
  ];
15652
16545
  descriptors.set(`node-${node.nodeId}`, {
@@ -15767,19 +16660,22 @@ function computeColumnMin(data, key) {
15767
16660
  }
15768
16661
 
15769
16662
  // src/tables/category-colors.ts
15770
- import { adaptColorForDarkMode } from "@opendata-ai/openchart-core";
16663
+ import { adaptColorForDarkMode, findAccessibleColor as findAccessibleColor2 } from "@opendata-ai/openchart-core";
16664
+ import { interpolateRgb as interpolateRgb2 } from "d3-interpolate";
15771
16665
 
15772
16666
  // src/tables/utils.ts
15773
- import { contrastRatio as contrastRatio2 } from "@opendata-ai/openchart-core";
15774
- function accessibleTextColor(bg) {
15775
- const white = "#ffffff";
15776
- const black = "#000000";
15777
- const whiteRatio = contrastRatio2(white, bg);
15778
- const blackRatio = contrastRatio2(black, bg);
15779
- return whiteRatio >= blackRatio ? white : black;
16667
+ import { contrastRatio as contrastRatio2, cssTokenDefault } from "@opendata-ai/openchart-core";
16668
+ function resolveTableSurface(theme) {
16669
+ return theme.colors.neutral.surface;
16670
+ }
16671
+ function accessibleTextColor(bg, theme) {
16672
+ const own = theme.colors.text;
16673
+ const inverse = cssTokenDefault("--oc-text", theme.isDark ? "light" : "dark");
16674
+ return contrastRatio2(inverse, bg) > contrastRatio2(own, bg) ? inverse : own;
15780
16675
  }
15781
16676
 
15782
16677
  // src/tables/category-colors.ts
16678
+ var CHIP_TINT = 0.14;
15783
16679
  function computeCategoryColors(data, column, theme, darkMode) {
15784
16680
  const result = /* @__PURE__ */ new Map();
15785
16681
  const explicitMap = column.categoryColors;
@@ -15789,6 +16685,7 @@ function computeCategoryColors(data, column, theme, darkMode) {
15789
16685
  const autoAssigned = /* @__PURE__ */ new Map();
15790
16686
  const lightBg = "#ffffff";
15791
16687
  const darkBg = theme.colors.background;
16688
+ const surface = resolveTableSurface(theme);
15792
16689
  for (let i = 0; i < data.length; i++) {
15793
16690
  const raw = data[i][column.key];
15794
16691
  if (raw == null) continue;
@@ -15815,10 +16712,11 @@ function computeCategoryColors(data, column, theme, darkMode) {
15815
16712
  if (darkMode && !isExplicit) {
15816
16713
  bg = adaptColorForDarkMode(bg, lightBg, darkBg);
15817
16714
  }
15818
- const textColor = accessibleTextColor(bg);
16715
+ const tint = interpolateRgb2(surface, bg)(CHIP_TINT);
15819
16716
  result.set(i, {
15820
- backgroundColor: bg,
15821
- color: textColor
16717
+ accent: bg,
16718
+ backgroundColor: tint,
16719
+ color: findAccessibleColor2(bg, tint, 4.5)
15822
16720
  });
15823
16721
  }
15824
16722
  return result;
@@ -15826,7 +16724,7 @@ function computeCategoryColors(data, column, theme, darkMode) {
15826
16724
 
15827
16725
  // src/tables/format-cells.ts
15828
16726
  import {
15829
- buildD3Formatter as buildD3Formatter3,
16727
+ buildD3Formatter as buildD3Formatter4,
15830
16728
  defaultNumberFormatter as defaultNumberFormatter11,
15831
16729
  formatDate as formatDate3,
15832
16730
  resolveNumberFormatter as resolveNumberFormatter7
@@ -15849,7 +16747,7 @@ function formatCell(value, column) {
15849
16747
  };
15850
16748
  }
15851
16749
  if (column.format && isNumericValue(value)) {
15852
- const formatter = resolveNumberFormatter7(column.format, { surface: "table" }) ?? buildD3Formatter3(column.format);
16750
+ const formatter = resolveNumberFormatter7(column.format, { surface: "table" }) ?? buildD3Formatter4(column.format);
15853
16751
  if (formatter) {
15854
16752
  return {
15855
16753
  value,
@@ -15881,7 +16779,7 @@ function formatCell(value, column) {
15881
16779
  function formatValueForSearch(value, column) {
15882
16780
  if (value == null) return "";
15883
16781
  if (column.format && isNumericValue(value)) {
15884
- const formatter = resolveNumberFormatter7(column.format, { surface: "table" }) ?? buildD3Formatter3(column.format);
16782
+ const formatter = resolveNumberFormatter7(column.format, { surface: "table" }) ?? buildD3Formatter4(column.format);
15885
16783
  if (formatter) {
15886
16784
  return formatter(value);
15887
16785
  }
@@ -15894,7 +16792,7 @@ function formatValueForSearch(value, column) {
15894
16792
 
15895
16793
  // src/tables/heatmap.ts
15896
16794
  import { adaptColorForDarkMode as adaptColorForDarkMode2, contrastRatio as contrastRatio3 } from "@opendata-ai/openchart-core";
15897
- import { interpolateRgb } from "d3-interpolate";
16795
+ import { interpolateRgb as interpolateRgb3 } from "d3-interpolate";
15898
16796
  import { scaleSequential } from "d3-scale";
15899
16797
  function interpolatorFromStops(stops) {
15900
16798
  if (stops.length === 0) return () => "#ffffff";
@@ -15905,7 +16803,7 @@ function interpolatorFromStops(stops) {
15905
16803
  const lo = Math.floor(segment);
15906
16804
  const hi = Math.min(lo + 1, stops.length - 1);
15907
16805
  const frac = segment - lo;
15908
- return interpolateRgb(stops[lo], stops[hi])(frac);
16806
+ return interpolateRgb3(stops[lo], stops[hi])(frac);
15909
16807
  };
15910
16808
  }
15911
16809
  function resolvePalette(palette, theme) {
@@ -15947,8 +16845,7 @@ function computeHeatmapColors(data, column, theme, darkMode) {
15947
16845
  let stops = resolvePalette(config.palette, theme);
15948
16846
  if (darkMode) {
15949
16847
  const lightBg = "#ffffff";
15950
- const themeBg = theme.colors.background;
15951
- const darkBg = themeBg === "transparent" ? "#09090b" : themeBg;
16848
+ const darkBg = resolveTableSurface(theme);
15952
16849
  if (Array.isArray(config.palette)) {
15953
16850
  stops = stops.map((c) => {
15954
16851
  const ratio = contrastRatio3(c, lightBg);
@@ -15961,10 +16858,12 @@ function computeHeatmapColors(data, column, theme, darkMode) {
15961
16858
  }
15962
16859
  const interpolator = interpolatorFromStops(stops);
15963
16860
  const scale = scaleSequential(interpolator).domain(domain).clamp(true);
16861
+ const surface = resolveTableSurface(theme);
16862
+ const soften = (c) => interpolateRgb3(c, surface)(0.2);
15964
16863
  for (const { index, value } of numericValues) {
15965
16864
  if (value <= domain[0]) continue;
15966
- const bg = scale(value);
15967
- const textColor = accessibleTextColor(bg);
16865
+ const bg = soften(scale(value));
16866
+ const textColor = accessibleTextColor(bg, theme);
15968
16867
  result.set(index, {
15969
16868
  backgroundColor: bg,
15970
16869
  color: textColor
@@ -16058,24 +16957,44 @@ function sortData(data, sort) {
16058
16957
  }
16059
16958
 
16060
16959
  // src/tables/sparkline.ts
16061
- function extractValues(row, columnKey, config) {
16960
+ function extractSparklineValues(row, columnKey, config) {
16062
16961
  const field = config.valuesField ?? columnKey;
16063
16962
  const raw = row[field];
16064
16963
  if (!Array.isArray(raw)) return [];
16065
16964
  return raw.map((v) => typeof v === "number" && Number.isFinite(v) ? v : null).filter((v) => v !== null);
16066
16965
  }
16067
- function computeSparkline(values, config, theme, _darkMode) {
16966
+ function computeSparklineDomain(data, columnKey, config) {
16967
+ const domain = config.domain ?? "shared";
16968
+ if (Array.isArray(domain)) return domain;
16969
+ if (domain === "row") return null;
16970
+ let min4 = Infinity;
16971
+ let max4 = -Infinity;
16972
+ for (const row of data) {
16973
+ for (const v of extractSparklineValues(row, columnKey, config)) {
16974
+ if (v < min4) min4 = v;
16975
+ if (v > max4) max4 = v;
16976
+ }
16977
+ }
16978
+ return Number.isFinite(min4) && Number.isFinite(max4) ? [min4, max4] : null;
16979
+ }
16980
+ function computeSparkline(values, config, theme, _darkMode, domain) {
16068
16981
  if (values.length === 0) return null;
16069
16982
  const type = config.type ?? "line";
16070
16983
  const color = config.color ?? theme.colors.categorical[0];
16071
- let min4 = Infinity;
16072
- let max4 = -Infinity;
16073
- for (const v of values) {
16074
- if (v < min4) min4 = v;
16075
- if (v > max4) max4 = v;
16984
+ let min4;
16985
+ let max4;
16986
+ if (domain) {
16987
+ [min4, max4] = domain;
16988
+ } else {
16989
+ min4 = Infinity;
16990
+ max4 = -Infinity;
16991
+ for (const v of values) {
16992
+ if (v < min4) min4 = v;
16993
+ if (v > max4) max4 = v;
16994
+ }
16076
16995
  }
16077
16996
  const range = max4 - min4;
16078
- const normalize = (v) => range === 0 ? 0.5 : (v - min4) / range;
16997
+ const normalize = (v) => range === 0 ? 0.5 : Math.max(0, Math.min(1, (v - min4) / range));
16079
16998
  const startValue = values[0];
16080
16999
  const endValue = values[values.length - 1];
16081
17000
  if (type === "line") {
@@ -16108,30 +17027,41 @@ function computeSparkline(values, config, theme, _darkMode) {
16108
17027
  endValue
16109
17028
  };
16110
17029
  }
16111
- function computeSparklineForRow(row, columnKey, config, theme, darkMode) {
16112
- const values = extractValues(row, columnKey, config);
16113
- return computeSparkline(values, config, theme, darkMode);
17030
+ function computeSparklineForRow(row, columnKey, config, theme, darkMode, domain) {
17031
+ const values = extractSparklineValues(row, columnKey, config);
17032
+ return computeSparkline(values, config, theme, darkMode, domain);
16114
17033
  }
16115
17034
 
16116
17035
  // src/tables/compile-table.ts
16117
17036
  function determineCellType(col) {
16118
17037
  if (col.sparkline) return "sparkline";
16119
17038
  if (col.bar) return "bar";
17039
+ if (col.delta) return "delta";
16120
17040
  if (col.heatmap) return "heatmap";
16121
17041
  if (col.image) return "image";
16122
17042
  if (col.flag) return "flag";
16123
17043
  if (col.categoryColors) return "category";
16124
17044
  return "text";
16125
17045
  }
16126
- function inferAlignment(col, data) {
16127
- if (col.align) return col.align;
17046
+ var NON_QUANTITATIVE_KEY = /(^|_)(id|zip|postal|code|year|fips)$/i;
17047
+ function snakeCaseKey(key) {
17048
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2");
17049
+ }
17050
+ function inferColumnType(col, data) {
17051
+ if (col.type) return col.type;
17052
+ if (NON_QUANTITATIVE_KEY.test(snakeCaseKey(col.key))) return "nominal";
16128
17053
  for (const row of data) {
16129
17054
  const val = row[col.key];
16130
- if (val != null) {
16131
- return typeof val === "number" ? "right" : "left";
16132
- }
17055
+ if (val == null) continue;
17056
+ if (typeof val === "number") return "quantitative";
17057
+ if (val instanceof Date) return "temporal";
17058
+ return "nominal";
16133
17059
  }
16134
- return "left";
17060
+ return "nominal";
17061
+ }
17062
+ function inferAlignment(col, type) {
17063
+ if (col.align) return col.align;
17064
+ return type === "quantitative" ? "right" : "left";
16135
17065
  }
16136
17066
  function estimateColumnWidth(col, data, fontSize) {
16137
17067
  const MIN_WIDTH = 60;
@@ -16170,14 +17100,28 @@ function resolveColumns(columns, data, totalWidth, theme) {
16170
17100
  const flexTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? 0 : w), 0);
16171
17101
  const remainingWidth = totalWidth - fixedTotal;
16172
17102
  const flexScale = flexTotal > 0 && remainingWidth > 0 ? remainingWidth / flexTotal : 1;
16173
- return columns.map((col, i) => ({
16174
- key: col.key,
16175
- label: col.label ?? col.key,
16176
- width: Math.max(60, isFixed[i] ? naturalWidths[i] : Math.round(naturalWidths[i] * flexScale)),
16177
- sortable: col.sortable ?? true,
16178
- align: inferAlignment(col, data),
16179
- cellType: determineCellType(col)
16180
- }));
17103
+ return columns.map((col, i) => {
17104
+ const type = inferColumnType(col, data);
17105
+ return {
17106
+ key: col.key,
17107
+ label: col.label ?? col.key,
17108
+ width: Math.max(60, isFixed[i] ? naturalWidths[i] : Math.round(naturalWidths[i] * flexScale)),
17109
+ sortable: col.sortable ?? true,
17110
+ align: inferAlignment(col, type),
17111
+ type,
17112
+ // The first column leads the mobile card unless the author says otherwise.
17113
+ priority: col.priority ?? (i === 0 ? 1 : 2),
17114
+ cellType: determineCellType(col)
17115
+ };
17116
+ });
17117
+ }
17118
+ var DIRECTION_WORD = {
17119
+ up: "up",
17120
+ down: "down",
17121
+ flat: "unchanged"
17122
+ };
17123
+ function stripSign(formatted) {
17124
+ return formatted.replace(/^[+\u2212-]/, "");
16181
17125
  }
16182
17126
  function buildCell(value, column, resolvedColumn, heatmapStyle, categoryStyle, barData, sparklineData) {
16183
17127
  const base = formatCell(value, column);
@@ -16219,6 +17163,27 @@ function buildCell(value, column, resolvedColumn, heatmapStyle, categoryStyle, b
16219
17163
  sparklineData
16220
17164
  };
16221
17165
  }
17166
+ case "delta": {
17167
+ if (typeof value !== "number" || !Number.isFinite(value)) {
17168
+ return { ...base, cellType: "text" };
17169
+ }
17170
+ const delta = value;
17171
+ const invert = typeof column.delta === "object" ? column.delta.invert ?? false : false;
17172
+ const direction = delta > 0 ? "up" : delta < 0 ? "down" : "flat";
17173
+ const favorable = invert ? delta < 0 : delta > 0;
17174
+ const unfavorable = invert ? delta > 0 : delta < 0;
17175
+ return {
17176
+ ...base,
17177
+ // The arrow carries the direction, so a sign in the formatted value
17178
+ // would say it twice (and contradict it under `invert`).
17179
+ formattedValue: stripSign(base.formattedValue),
17180
+ cellType: "delta",
17181
+ delta,
17182
+ direction,
17183
+ tone: favorable ? "positive" : unfavorable ? "negative" : "neutral",
17184
+ aria: base.aria ?? `${DIRECTION_WORD[direction]} ${stripSign(base.formattedValue)}`
17185
+ };
17186
+ }
16222
17187
  case "image": {
16223
17188
  const src = typeof value === "string" ? value : "";
16224
17189
  const imgConfig = column.image ?? {};
@@ -16247,15 +17212,52 @@ function buildCell(value, column, resolvedColumn, heatmapStyle, categoryStyle, b
16247
17212
  }
16248
17213
  }
16249
17214
  }
17215
+ function resolveSort(spec, columns, optionsSort) {
17216
+ if (optionsSort) return optionsSort;
17217
+ if (optionsSort === null) return void 0;
17218
+ if (spec.sort) return spec.sort;
17219
+ const barColumn = columns.find((c) => c.cellType === "bar" && c.sortable);
17220
+ return barColumn ? { column: barColumn.key, direction: "desc" } : void 0;
17221
+ }
17222
+ function buildTotalRow(label, columns, resolvedColumns, rows) {
17223
+ const SUMMABLE = /* @__PURE__ */ new Set(["text", "bar", "heatmap"]);
17224
+ const cells = resolvedColumns.map((resolved, i) => {
17225
+ const blank = {
17226
+ value: null,
17227
+ formattedValue: "",
17228
+ style: {},
17229
+ cellType: "text"
17230
+ };
17231
+ if (resolved.type !== "quantitative" || !SUMMABLE.has(resolved.cellType)) return blank;
17232
+ let sum = 0;
17233
+ let seen = false;
17234
+ for (const row of rows) {
17235
+ const v = row[resolved.key];
17236
+ if (typeof v === "number" && Number.isFinite(v)) {
17237
+ sum += v;
17238
+ seen = true;
17239
+ }
17240
+ }
17241
+ if (!seen) return blank;
17242
+ const base = formatCell(sum, columns[i]);
17243
+ return {
17244
+ ...base,
17245
+ style: { ...base.style, fontVariant: "tabular-nums" },
17246
+ cellType: "text"
17247
+ };
17248
+ });
17249
+ return { label, cells };
17250
+ }
16250
17251
  function compileTableLayout(spec, options, theme) {
16251
17252
  const data = spec.data;
16252
17253
  const darkMode = theme.isDark;
16253
17254
  const resolvedColumns = resolveColumns(spec.columns, data, options.width, theme);
17255
+ const sort = resolveSort(spec, resolvedColumns, options.sort);
16254
17256
  const searchIndex = spec.search ? buildSearchIndex(data, spec.columns) : /* @__PURE__ */ new Map();
16255
17257
  let currentData = data;
16256
17258
  let originalIndices = data.map((_, i) => i);
16257
- if (options.sort) {
16258
- const sorted = sortData(currentData, options.sort);
17259
+ if (sort) {
17260
+ const sorted = sortData(currentData, sort);
16259
17261
  originalIndices = sorted.originalIndices.map((i) => originalIndices[i]);
16260
17262
  currentData = sorted.data;
16261
17263
  }
@@ -16265,6 +17267,7 @@ function compileTableLayout(spec, options, theme) {
16265
17267
  originalIndices = filtered.indices;
16266
17268
  }
16267
17269
  const totalFiltered = currentData.length;
17270
+ const filteredData = currentData;
16268
17271
  let pageSize = 0;
16269
17272
  let currentPage = 0;
16270
17273
  let paginationState;
@@ -16288,6 +17291,7 @@ function compileTableLayout(spec, options, theme) {
16288
17291
  const categoryMaps = /* @__PURE__ */ new Map();
16289
17292
  const barMaxes = /* @__PURE__ */ new Map();
16290
17293
  const barMins = /* @__PURE__ */ new Map();
17294
+ const sparklineDomains = /* @__PURE__ */ new Map();
16291
17295
  for (let c = 0; c < spec.columns.length; c++) {
16292
17296
  const col = spec.columns[c];
16293
17297
  const resolved = resolvedColumns[c];
@@ -16301,6 +17305,9 @@ function compileTableLayout(spec, options, theme) {
16301
17305
  barMaxes.set(col.key, computeColumnMax(data, col.key));
16302
17306
  barMins.set(col.key, computeColumnMin(data, col.key));
16303
17307
  }
17308
+ if (resolved.cellType === "sparkline" && col.sparkline) {
17309
+ sparklineDomains.set(col.key, computeSparklineDomain(data, col.key, col.sparkline));
17310
+ }
16304
17311
  }
16305
17312
  const rows = currentData.map((row, i) => {
16306
17313
  const origIdx = originalIndices[i];
@@ -16323,7 +17330,14 @@ function compileTableLayout(spec, options, theme) {
16323
17330
  }
16324
17331
  let sparklineData = null;
16325
17332
  if (resolved.cellType === "sparkline" && col.sparkline) {
16326
- sparklineData = computeSparklineForRow(row, col.key, col.sparkline, theme, darkMode);
17333
+ sparklineData = computeSparklineForRow(
17334
+ row,
17335
+ col.key,
17336
+ col.sparkline,
17337
+ theme,
17338
+ darkMode,
17339
+ sparklineDomains.get(col.key)
17340
+ );
16327
17341
  }
16328
17342
  return buildCell(value, col, resolved, heatmapStyle, categoryStyle, barData, sparklineData);
16329
17343
  });
@@ -16351,7 +17365,7 @@ function compileTableLayout(spec, options, theme) {
16351
17365
  chrome,
16352
17366
  columns: resolvedColumns,
16353
17367
  rows,
16354
- sort: options.sort,
17368
+ sort,
16355
17369
  pagination: paginationState,
16356
17370
  search: {
16357
17371
  enabled: spec.search,
@@ -16359,6 +17373,9 @@ function compileTableLayout(spec, options, theme) {
16359
17373
  query: options.search ?? ""
16360
17374
  },
16361
17375
  stickyFirstColumn: spec.stickyFirstColumn,
17376
+ density: spec.density,
17377
+ striped: spec.striped,
17378
+ totalRow: spec.totalRow ? buildTotalRow(spec.totalRow.label, spec.columns, resolvedColumns, filteredData) : void 0,
16362
17379
  compact: spec.compact,
16363
17380
  a11y: {
16364
17381
  caption,
@@ -16373,7 +17390,7 @@ function compileTableLayout(spec, options, theme) {
16373
17390
  // src/tilemap/compile-tilemap.ts
16374
17391
  import {
16375
17392
  adaptTheme as adaptTheme5,
16376
- CATEGORICAL_PALETTE as CATEGORICAL_PALETTE2,
17393
+ CATEGORICAL_PALETTE as CATEGORICAL_PALETTE3,
16377
17394
  computeChrome as computeChrome8,
16378
17395
  estimateTextWidth as estimateTextWidth23,
16379
17396
  getBreakpoint as getBreakpoint2,
@@ -16383,15 +17400,28 @@ import {
16383
17400
  resolveTheme as resolveTheme6,
16384
17401
  SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES3
16385
17402
  } from "@opendata-ai/openchart-core";
17403
+ import { interpolateRgb as interpolateRgb4 } from "d3-interpolate";
16386
17404
  import { scaleLinear as scaleLinear6 } from "d3-scale";
16387
- var TILE_CORNER_RADIUS = 6;
16388
- var TILE_STROKE_WIDTH = 1;
17405
+ var TILE_CORNER_RADIUS = 2;
17406
+ var TILE_STROKE_WIDTH = 0;
17407
+ var NO_DATA_STROKE_WIDTH = 1;
17408
+ var NO_DATA_BAR_GAP = 12;
17409
+ var NO_DATA_LABEL_GAP2 = 5;
16389
17410
  var LEGEND_SWATCH_SIZE = 10;
16390
17411
  var LEGEND_SWATCH_GAP = 6;
16391
17412
  var LEGEND_ENTRY_GAP = 16;
16392
17413
  function clamp(value, min4, max4) {
16393
17414
  return Math.min(max4, Math.max(min4, value));
16394
17415
  }
17416
+ function blendOnSurface(fill, fillOpacity, theme) {
17417
+ const surface = theme.colors.neutral.surface;
17418
+ if (fillOpacity >= 1) return fill;
17419
+ try {
17420
+ return interpolateRgb4(surface, fill)(fillOpacity);
17421
+ } catch {
17422
+ return fill;
17423
+ }
17424
+ }
16395
17425
  function growContentHeight(contentHeight, chromeLayout, budgetHeight, chrome) {
16396
17426
  if (chromeLayout !== "grow") return contentHeight;
16397
17427
  return Math.max(contentHeight, budgetHeight + chrome.topHeight + chrome.bottomHeight);
@@ -16488,10 +17518,10 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
16488
17518
  const max4 = values.length > 0 ? Math.max(...values) : 100;
16489
17519
  const paletteStops = [...SEQUENTIAL_PALETTES3[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES3.blue];
16490
17520
  const baseColor = isDarkMode ? paletteStops[0] : paletteStops[paletteStops.length - 1];
16491
- const opacityRange = isDarkMode ? [0.15, 1] : [0.2, 1];
17521
+ const opacityRange = isDarkMode ? [0.3, 1] : [0.25, 1];
16492
17522
  const opacityScale = scaleLinear6().domain([min4, max4]).range(opacityRange).clamp(true);
16493
17523
  const showLegend = tilemapSpec.legend?.show !== false;
16494
- const legendBarHeight = 6;
17524
+ const legendBarHeight = 10;
16495
17525
  const legendLabelGap = 6;
16496
17526
  const legendTotalHeight = showLegend ? legendBarHeight + legendLabelGap + 14 : 0;
16497
17527
  const legendGap2 = showLegend ? 8 : 0;
@@ -16506,8 +17536,8 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
16506
17536
  surfaceFormat: tilemapSpec.encoding.value.format ?? tilemapSpec.valueFormat,
16507
17537
  values: Array.from(stateValueMap.values())
16508
17538
  });
16509
- const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
16510
- const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
17539
+ const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
17540
+ const neutralStroke = isDarkMode ? theme.colors.neutral[600] : theme.colors.neutral[300];
16511
17541
  const tiles = [];
16512
17542
  for (const { state: stateCode } of US_STATE_TILES) {
16513
17543
  const pos = tilePositions.positions.get(stateCode);
@@ -16516,7 +17546,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
16516
17546
  const value = hasData ? stateValueMap.get(stateCode) : null;
16517
17547
  const opacity = hasData ? opacityScale(value) : 0;
16518
17548
  const fill = hasData ? baseColor : neutralFill;
16519
- const stroke = hasData ? isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)" : neutralStroke;
17549
+ const stroke = hasData ? "none" : neutralStroke;
16520
17550
  const formattedValue = hasData ? formatter(value) : "\u2013";
16521
17551
  tiles.push(
16522
17552
  buildTileMark({
@@ -16545,10 +17575,16 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
16545
17575
  const o = opacityRange[0] + t * (opacityRange[1] - opacityRange[0]);
16546
17576
  return { offset: t, color: baseColor, opacity: o };
16547
17577
  });
17578
+ const hasEmptyTiles = tiles.some((t) => !t.hasData);
17579
+ const noDataLabel = "No data";
17580
+ const noDataLabelWidth = estimateTextWidth23(noDataLabel, 11);
17581
+ const noDataBlockWidth = hasEmptyTiles ? NO_DATA_BAR_GAP + legendBarHeight + NO_DATA_LABEL_GAP2 + noDataLabelWidth : 0;
17582
+ const barWidth = Math.max(0, legendWidth - noDataBlockWidth);
17583
+ const noDataX = legendX + barWidth + NO_DATA_BAR_GAP;
16548
17584
  gradientLegend = {
16549
17585
  type: "gradient",
16550
17586
  position: "bottom",
16551
- bounds: { x: legendX, y: legendY, width: legendWidth, height: legendBarHeight },
17587
+ bounds: { x: legendX, y: legendY, width: barWidth, height: legendBarHeight },
16552
17588
  labelStyle: {
16553
17589
  fontFamily: theme.fonts.family,
16554
17590
  fontSize: 11,
@@ -16558,7 +17594,16 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
16558
17594
  },
16559
17595
  colorStops: gradientColorStops,
16560
17596
  minLabel: formatter(min4),
16561
- maxLabel: formatter(max4)
17597
+ maxLabel: formatter(max4),
17598
+ noData: hasEmptyTiles && barWidth > 0 ? {
17599
+ x: noDataX,
17600
+ y: legendY,
17601
+ size: legendBarHeight,
17602
+ fill: neutralFill,
17603
+ label: noDataLabel,
17604
+ labelX: noDataX + legendBarHeight + NO_DATA_LABEL_GAP2,
17605
+ labelY: legendY + legendBarHeight
17606
+ } : void 0
16562
17607
  };
16563
17608
  }
16564
17609
  const tooltipDescriptors = buildTooltips(tiles);
@@ -16620,12 +17665,12 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
16620
17665
  for (const cat of categories) {
16621
17666
  categoryColors.set(
16622
17667
  cat,
16623
- tilemapSpec.colors[cat] ?? CATEGORICAL_PALETTE2[categoryColors.size % CATEGORICAL_PALETTE2.length]
17668
+ tilemapSpec.colors[cat] ?? CATEGORICAL_PALETTE3[categoryColors.size % CATEGORICAL_PALETTE3.length]
16624
17669
  );
16625
17670
  }
16626
17671
  } else {
16627
17672
  for (let i = 0; i < categories.length; i++) {
16628
- categoryColors.set(categories[i], CATEGORICAL_PALETTE2[i % CATEGORICAL_PALETTE2.length]);
17673
+ categoryColors.set(categories[i], CATEGORICAL_PALETTE3[i % CATEGORICAL_PALETTE3.length]);
16629
17674
  }
16630
17675
  }
16631
17676
  const showLegend = tilemapSpec.legend?.show !== false;
@@ -16643,8 +17688,8 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
16643
17688
  const legendWidth = tilePositions.gridWidth;
16644
17689
  const legendRows = showLegend ? countLegendRows(legendLabels, legendWidth, legendLabelFontSize) : 0;
16645
17690
  const legendTotalHeight = showLegend ? legendRowHeight * legendRows : 0;
16646
- const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
16647
- const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
17691
+ const neutralFill = isDarkMode ? theme.colors.neutral[800] : theme.colors.neutral[100];
17692
+ const neutralStroke = isDarkMode ? theme.colors.neutral[600] : theme.colors.neutral[300];
16648
17693
  const tiles = [];
16649
17694
  for (const { state: stateCode } of US_STATE_TILES) {
16650
17695
  const pos = tilePositions.positions.get(stateCode);
@@ -16652,7 +17697,7 @@ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDar
16652
17697
  const hasData = stateCategoryMap.has(stateCode);
16653
17698
  const category = hasData ? stateCategoryMap.get(stateCode) : null;
16654
17699
  const fill = hasData ? categoryColors.get(category) ?? neutralFill : neutralFill;
16655
- const stroke = hasData ? isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)" : neutralStroke;
17700
+ const stroke = hasData ? "none" : neutralStroke;
16656
17701
  const formattedValue = category ? formatCategoryLabel(category) : "\u2013";
16657
17702
  tiles.push(
16658
17703
  buildTileMark({
@@ -16753,24 +17798,30 @@ function buildTileMark(opts) {
16753
17798
  } = opts;
16754
17799
  const tileCenterX = gridOffsetX + pos.x + tileSize / 2;
16755
17800
  const tileTopY = gridOffsetY + pos.y;
16756
- const textColor = hasData ? pickLabelColor4(fill, isDarkMode) : "#ffffff";
17801
+ const effectiveColor = hasData ? blendOnSurface(fill, fillOpacity, theme) : fill;
17802
+ const textColor = hasData ? pickLabelColor4(effectiveColor, isDarkMode) : theme.colors.axis;
16757
17803
  const isLightText = textColor === "#ffffff";
16758
17804
  const showValue = showValueLabels && tileSize >= 24;
16759
- const codeFontSize = clamp(Math.round(tileSize * 0.5 * 10) / 10, 6.5, 11);
17805
+ const codeFontSize = clamp(Math.round(tileSize * 0.5 * 10) / 10, 7, 12);
16760
17806
  const valueFontSize = clamp(Math.round(tileSize * 0.42 * 10) / 10, 6.5, 10);
16761
17807
  const labelStyle = {
16762
17808
  fontFamily: theme.fonts.family,
16763
17809
  fontSize: codeFontSize,
16764
- fontWeight: 700,
17810
+ // 600, not 700: at 8-12px a bold two-letter code on a colored tile turns
17811
+ // into a blob. The tracking in the renderer does the separating instead.
17812
+ fontWeight: theme.fonts.weights.semibold,
16765
17813
  fill: textColor,
16766
17814
  lineHeight: 1.2
16767
17815
  };
16768
17816
  const valueLabelStyle = {
16769
17817
  fontFamily: theme.fonts.family,
16770
17818
  fontSize: valueFontSize,
16771
- fontWeight: 300,
16772
- fill: isLightText ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.45)",
16773
- lineHeight: 1.2
17819
+ // 300 is a hinting-dependent weight that renders as 400 on most stacks and
17820
+ // as a ghost on the ones that have it. 450 is the honest secondary weight.
17821
+ fontWeight: 450,
17822
+ fill: isLightText ? "rgba(255,255,255,0.75)" : "rgba(0,0,0,0.55)",
17823
+ lineHeight: 1.2,
17824
+ fontVariant: "tabular-nums"
16774
17825
  };
16775
17826
  const codeCenterY = showValue ? tileTopY + tileSize * 0.28 : tileTopY + tileSize * 0.5;
16776
17827
  const valueLabel = showValue ? {
@@ -16780,7 +17831,7 @@ function buildTileMark(opts) {
16780
17831
  style: valueLabelStyle,
16781
17832
  visible: true
16782
17833
  } : { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false };
16783
- const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.22));
17834
+ const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.1));
16784
17835
  const data = {
16785
17836
  state: stateCode,
16786
17837
  value,
@@ -16798,7 +17849,7 @@ function buildTileMark(opts) {
16798
17849
  fill,
16799
17850
  fillOpacity,
16800
17851
  stroke,
16801
- strokeWidth: TILE_STROKE_WIDTH,
17852
+ strokeWidth: hasData ? TILE_STROKE_WIDTH : NO_DATA_STROKE_WIDTH,
16802
17853
  cornerRadius,
16803
17854
  value,
16804
17855
  formattedValue,
@@ -16904,7 +17955,7 @@ import {
16904
17955
  defaultNumberFormatter as defaultNumberFormatter12,
16905
17956
  formatDate as formatDate4,
16906
17957
  formatPercent as formatPercent4,
16907
- getRepresentativeColor as getRepresentativeColor13
17958
+ getRepresentativeColor as getRepresentativeColor14
16908
17959
  } from "@opendata-ai/openchart-core";
16909
17960
  function formatValue2(value, fieldType, formatter) {
16910
17961
  if (value == null) return "";
@@ -16963,14 +18014,15 @@ function buildExplicitTooltipFields(row, channels, formatters) {
16963
18014
  value: formatValue2(row[ch.field], ch.type, getFormatter(formatters, ch))
16964
18015
  }));
16965
18016
  }
16966
- function buildFields(row, encoding, formatters, color) {
18017
+ function buildFields(row, encoding, formatters, color, titleChannel) {
16967
18018
  if (encoding.tooltip) {
16968
18019
  const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
16969
18020
  return buildExplicitTooltipFields(row, channels, formatters);
16970
18021
  }
16971
18022
  const fields = [];
18023
+ const skipped = [];
16972
18024
  if (encoding.color && "field" in encoding.color) {
16973
- fields.push({
18025
+ (titleChannel === "color" ? skipped : fields).push({
16974
18026
  label: resolveLabel(encoding.color),
16975
18027
  value: formatValue2(
16976
18028
  row[encoding.color.field],
@@ -16981,7 +18033,7 @@ function buildFields(row, encoding, formatters, color) {
16981
18033
  });
16982
18034
  }
16983
18035
  if (encoding.y) {
16984
- fields.push({
18036
+ (titleChannel === "y" ? skipped : fields).push({
16985
18037
  label: resolveLabel(encoding.y),
16986
18038
  value: formatValue2(
16987
18039
  row[encoding.y.field],
@@ -16992,7 +18044,7 @@ function buildFields(row, encoding, formatters, color) {
16992
18044
  });
16993
18045
  }
16994
18046
  if (encoding.x) {
16995
- fields.push({
18047
+ (titleChannel === "x" ? skipped : fields).push({
16996
18048
  label: resolveLabel(encoding.x),
16997
18049
  value: formatValue2(
16998
18050
  row[encoding.x.field],
@@ -17011,7 +18063,7 @@ function buildFields(row, encoding, formatters, color) {
17011
18063
  )
17012
18064
  });
17013
18065
  }
17014
- return fields;
18066
+ return fields.length > 0 ? fields : skipped;
17015
18067
  }
17016
18068
  function computeEncodedFieldsSet(encoding) {
17017
18069
  if (!(encoding.x?.type === "quantitative" && encoding.y?.type === "quantitative")) {
@@ -17025,50 +18077,67 @@ function computeEncodedFieldsSet(encoding) {
17025
18077
  }
17026
18078
  function getTooltipTitle(row, encoding, encodedFields) {
17027
18079
  if (encoding.detail) {
17028
- return String(row[encoding.detail.field] ?? "");
18080
+ return { title: String(row[encoding.detail.field] ?? "") };
17029
18081
  }
17030
18082
  if (encoding.x?.type === "temporal") {
17031
- return formatValue2(row[encoding.x.field], "temporal");
18083
+ return { title: formatValue2(row[encoding.x.field], "temporal"), channel: "x" };
17032
18084
  }
17033
18085
  if (encoding.x?.type === "nominal" || encoding.x?.type === "ordinal") {
17034
- return String(row[encoding.x.field] ?? "");
18086
+ return { title: String(row[encoding.x.field] ?? ""), channel: "x" };
17035
18087
  }
17036
18088
  if (encoding.y?.type === "nominal" || encoding.y?.type === "ordinal") {
17037
- return String(row[encoding.y.field] ?? "");
18089
+ return { title: String(row[encoding.y.field] ?? ""), channel: "y" };
17038
18090
  }
17039
18091
  if (encoding.x?.type === "quantitative" && encoding.y?.type === "quantitative") {
17040
18092
  const fields = encodedFields ?? computeEncodedFieldsSet(encoding);
17041
18093
  for (const key of Object.keys(row)) {
17042
18094
  const value = row[key];
17043
18095
  if (!fields.has(key) && typeof value === "string") {
17044
- return value;
18096
+ return { title: value };
17045
18097
  }
17046
18098
  }
17047
18099
  }
17048
18100
  if (encoding.color && "field" in encoding.color) {
17049
- return String(row[encoding.color.field] ?? "");
18101
+ return { title: String(row[encoding.color.field] ?? ""), channel: "color" };
17050
18102
  }
17051
- return void 0;
18103
+ return {};
17052
18104
  }
17053
- function tooltipsForLine(mark, encoding, formatters, _markIndex, encodedFields) {
18105
+ function tooltipsForLine(mark, encoding, formatters, _markIndex, encodedFields, stackTotals) {
17054
18106
  if (mark.dataPoints) {
17055
18107
  for (const dp of mark.dataPoints) {
17056
- dp.tooltip = {
17057
- title: getTooltipTitle(dp.datum, encoding, encodedFields),
17058
- fields: buildFields(dp.datum, encoding, formatters, mark.stroke)
17059
- };
18108
+ const { title, channel } = getTooltipTitle(dp.datum, encoding, encodedFields);
18109
+ const fields = buildFields(dp.datum, encoding, formatters, mark.stroke, channel);
18110
+ if (stackTotals && encoding.x) {
18111
+ const total = stackTotals.get(stackKeyOf(dp.datum[encoding.x.field]));
18112
+ if (total) fields.push(total);
18113
+ }
18114
+ dp.tooltip = { title, fields };
17060
18115
  }
17061
18116
  }
17062
18117
  return [];
17063
18118
  }
17064
18119
  function tooltipsForPoint(mark, encoding, formatters, markIndex, encodedFields) {
17065
- const title = getTooltipTitle(mark.data, encoding, encodedFields);
17066
- const fields = buildFields(mark.data, encoding, formatters, getRepresentativeColor13(mark.fill));
18120
+ const { title, channel } = getTooltipTitle(mark.data, encoding, encodedFields);
18121
+ const fields = buildFields(
18122
+ mark.data,
18123
+ encoding,
18124
+ formatters,
18125
+ getRepresentativeColor14(mark.fill),
18126
+ channel
18127
+ );
17067
18128
  return [[`point-${markIndex}`, { title, fields }]];
17068
18129
  }
17069
- function tooltipsForRect(mark, encoding, formatters, markIndex, encodedFields) {
17070
- const title = getTooltipTitle(mark.data, encoding, encodedFields);
17071
- const fields = buildFields(mark.data, encoding, formatters, getRepresentativeColor13(mark.fill));
18130
+ function tooltipsForRect(mark, encoding, formatters, markIndex, encodedFields, stackTotals) {
18131
+ const { title, channel } = getTooltipTitle(mark.data, encoding, encodedFields);
18132
+ const fields = buildFields(
18133
+ mark.data,
18134
+ encoding,
18135
+ formatters,
18136
+ getRepresentativeColor14(mark.fill),
18137
+ channel
18138
+ );
18139
+ const total = mark.stackGroup !== void 0 ? stackTotals?.get(mark.stackGroup) : void 0;
18140
+ if (total) fields.push(total);
17072
18141
  return [[`rect-${markIndex}`, { title, fields }]];
17073
18142
  }
17074
18143
  function tooltipsForArc(mark, encoding, formatters, markIndex) {
@@ -17085,7 +18154,7 @@ function tooltipsForArc(mark, encoding, formatters, markIndex) {
17085
18154
  encoding.y.type,
17086
18155
  getFormatter(formatters, encoding.y)
17087
18156
  ),
17088
- color: getRepresentativeColor13(mark.fill)
18157
+ color: getRepresentativeColor14(mark.fill)
17089
18158
  });
17090
18159
  }
17091
18160
  } else if (encoding.y) {
@@ -17096,23 +18165,98 @@ function tooltipsForArc(mark, encoding, formatters, markIndex) {
17096
18165
  encoding.y.type,
17097
18166
  getFormatter(formatters, encoding.y)
17098
18167
  ),
17099
- color: getRepresentativeColor13(mark.fill)
18168
+ color: getRepresentativeColor14(mark.fill)
17100
18169
  });
17101
18170
  }
17102
18171
  const title = colorEnc ? String(row[colorEnc.field] ?? "") : void 0;
17103
18172
  return [[`arc-${markIndex}`, { title, fields }]];
17104
18173
  }
17105
- function tooltipsForArea(mark, encoding, formatters, _markIndex, encodedFields) {
18174
+ function tooltipsForArea(mark, encoding, formatters, _markIndex, encodedFields, stackTotals) {
17106
18175
  if (mark.dataPoints) {
18176
+ const wantsTotal = mark.stacked === true && mark.stackNormalized !== true;
17107
18177
  for (const dp of mark.dataPoints) {
17108
- dp.tooltip = {
17109
- title: getTooltipTitle(dp.datum, encoding, encodedFields),
17110
- fields: buildFields(dp.datum, encoding, formatters, getRepresentativeColor13(mark.fill))
17111
- };
18178
+ const { title, channel } = getTooltipTitle(dp.datum, encoding, encodedFields);
18179
+ const fields = buildFields(
18180
+ dp.datum,
18181
+ encoding,
18182
+ formatters,
18183
+ getRepresentativeColor14(mark.fill),
18184
+ channel
18185
+ );
18186
+ if (wantsTotal && encoding.x) {
18187
+ const total = stackTotals?.get(stackKeyOf(dp.datum[encoding.x.field]));
18188
+ if (total) fields.push(total);
18189
+ }
18190
+ dp.tooltip = { title, fields };
17112
18191
  }
17113
18192
  }
17114
18193
  return [];
17115
18194
  }
18195
+ var TOTAL_LABEL = "Total";
18196
+ function stackKeyOf(value) {
18197
+ return value instanceof Date ? String(value.getTime()) : String(value);
18198
+ }
18199
+ function stackValueChannel(encoding) {
18200
+ if (encoding.y?.type === "quantitative") return encoding.y;
18201
+ if (encoding.x?.type === "quantitative") return encoding.x;
18202
+ return void 0;
18203
+ }
18204
+ function isNormalizedStack(encoding) {
18205
+ return encoding.y?.stack === "normalize" || encoding.x?.stack === "normalize";
18206
+ }
18207
+ function computeRectStackTotals(marks, encoding, formatters) {
18208
+ if (isNormalizedStack(encoding)) return void 0;
18209
+ const valueCh = stackValueChannel(encoding);
18210
+ if (!valueCh) return void 0;
18211
+ const sums = /* @__PURE__ */ new Map();
18212
+ const counts = /* @__PURE__ */ new Map();
18213
+ for (const mark of marks) {
18214
+ if (mark.type !== "rect" || mark.stackGroup === void 0) continue;
18215
+ const value = Number(mark.data[valueCh.field]);
18216
+ if (!Number.isFinite(value)) continue;
18217
+ sums.set(mark.stackGroup, (sums.get(mark.stackGroup) ?? 0) + value);
18218
+ counts.set(mark.stackGroup, (counts.get(mark.stackGroup) ?? 0) + 1);
18219
+ }
18220
+ if (sums.size === 0) return void 0;
18221
+ const formatter = getFormatter(formatters, valueCh);
18222
+ const totals = /* @__PURE__ */ new Map();
18223
+ for (const [group, sum] of sums) {
18224
+ if ((counts.get(group) ?? 0) < 2) continue;
18225
+ totals.set(group, {
18226
+ label: TOTAL_LABEL,
18227
+ value: formatValue2(sum, valueCh.type, formatter),
18228
+ role: "total"
18229
+ });
18230
+ }
18231
+ return totals.size > 0 ? totals : void 0;
18232
+ }
18233
+ function computeAreaStackTotals(marks, encoding, formatters) {
18234
+ const xCh = encoding.x;
18235
+ const yCh = encoding.y;
18236
+ if (!xCh || !yCh) return void 0;
18237
+ const sums = /* @__PURE__ */ new Map();
18238
+ for (const mark of marks) {
18239
+ if (mark.type !== "area") continue;
18240
+ if (mark.stacked !== true || mark.stackNormalized === true) continue;
18241
+ for (const dp of mark.dataPoints ?? []) {
18242
+ const value = Number(dp.datum[yCh.field]);
18243
+ if (!Number.isFinite(value)) continue;
18244
+ const key = stackKeyOf(dp.datum[xCh.field]);
18245
+ sums.set(key, (sums.get(key) ?? 0) + value);
18246
+ }
18247
+ }
18248
+ if (sums.size === 0) return void 0;
18249
+ const formatter = getFormatter(formatters, yCh);
18250
+ const totals = /* @__PURE__ */ new Map();
18251
+ for (const [key, sum] of sums) {
18252
+ totals.set(key, {
18253
+ label: TOTAL_LABEL,
18254
+ value: formatValue2(sum, yCh.type, formatter),
18255
+ role: "total"
18256
+ });
18257
+ }
18258
+ return totals;
18259
+ }
17116
18260
  function computeRangeTooltips(spec, marks) {
17117
18261
  const encoding = spec.encoding;
17118
18262
  const descriptors = /* @__PURE__ */ new Map();
@@ -17125,7 +18269,7 @@ function computeRangeTooltips(spec, marks) {
17125
18269
  const startFmt = getFormatter(fmtCache, startCh);
17126
18270
  const encodedFields = computeEncodedFieldsSet(encoding);
17127
18271
  const contentFor = (row) => {
17128
- const title = getTooltipTitle(row, encoding, encodedFields);
18272
+ const { title } = getTooltipTitle(row, encoding, encodedFields);
17129
18273
  if (encoding.tooltip) {
17130
18274
  const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
17131
18275
  return { title, fields: buildExplicitTooltipFields(row, channels, fmtCache) };
@@ -17187,7 +18331,7 @@ function computeCalendarTooltips(spec, marks) {
17187
18331
  {
17188
18332
  label: resolveLabel(colorEnc),
17189
18333
  value: formatValue2(row[colorEnc.field], colorEnc.type, getFormatter(fmtCache, colorEnc)),
17190
- color: getRepresentativeColor13(mark.fill)
18334
+ color: getRepresentativeColor14(mark.fill)
17191
18335
  }
17192
18336
  ]
17193
18337
  });
@@ -17231,7 +18375,7 @@ function computeWaffleTooltips(spec, marks) {
17231
18375
  {
17232
18376
  label: resolveLabel(valueCh),
17233
18377
  value: formatValue2(row[valueCh.field], valueCh.type, getFormatter(fmtCache, valueCh)),
17234
- color: getRepresentativeColor13(mark.fill)
18378
+ color: getRepresentativeColor14(mark.fill)
17235
18379
  },
17236
18380
  { label: "Share", value: `${cellCounts.get(category) ?? 0} of ${units} units` }
17237
18381
  ]
@@ -17282,7 +18426,7 @@ function computeParliamentTooltips(spec, marks) {
17282
18426
  {
17283
18427
  label: resolveLabel(valueCh),
17284
18428
  value: formatValue2(row[valueCh.field], valueCh.type, getFormatter(fmtCache, valueCh)),
17285
- color: getRepresentativeColor13(mark.fill)
18429
+ color: getRepresentativeColor14(mark.fill)
17286
18430
  },
17287
18431
  { label: "Share", value: share }
17288
18432
  ]
@@ -17318,21 +18462,23 @@ function computeTooltipDescriptors(spec, marks) {
17318
18462
  ].filter((ch) => !!ch && "field" in ch);
17319
18463
  const formatters = buildFormatterCache(spec.data, allChannels);
17320
18464
  const encodedFields = computeEncodedFieldsSet(encoding);
18465
+ const rectStackTotals = computeRectStackTotals(marks, encoding, formatters);
18466
+ const areaStackTotals = computeAreaStackTotals(marks, encoding, formatters);
17321
18467
  for (let i = 0; i < marks.length; i++) {
17322
18468
  const mark = marks[i];
17323
18469
  let entries = [];
17324
18470
  switch (mark.type) {
17325
18471
  case "line":
17326
- entries = tooltipsForLine(mark, encoding, formatters, i, encodedFields);
18472
+ entries = tooltipsForLine(mark, encoding, formatters, i, encodedFields, areaStackTotals);
17327
18473
  break;
17328
18474
  case "area":
17329
- entries = tooltipsForArea(mark, encoding, formatters, i, encodedFields);
18475
+ entries = tooltipsForArea(mark, encoding, formatters, i, encodedFields, areaStackTotals);
17330
18476
  break;
17331
18477
  case "point":
17332
18478
  entries = tooltipsForPoint(mark, encoding, formatters, i, encodedFields);
17333
18479
  break;
17334
18480
  case "rect":
17335
- entries = tooltipsForRect(mark, encoding, formatters, i, encodedFields);
18481
+ entries = tooltipsForRect(mark, encoding, formatters, i, encodedFields, rectStackTotals);
17336
18482
  break;
17337
18483
  case "arc":
17338
18484
  entries = tooltipsForArc(mark, encoding, formatters, i);
@@ -17847,6 +18993,7 @@ function compileChart(spec, optionsInput) {
17847
18993
  const breakpoint = getBreakpoint3(options.width);
17848
18994
  const heightClass = getHeightClass(options.height);
17849
18995
  let strategy = getLayoutStrategy(breakpoint, heightClass);
18996
+ const economy = resolveChromeEconomy(options.width, options.height);
17850
18997
  const rawSpec = expandedSpec;
17851
18998
  const overrides = rawSpec.overrides;
17852
18999
  const rawEncoding = rawSpec.encoding;
@@ -18103,19 +19250,31 @@ function compileChart(spec, optionsInput) {
18103
19250
  };
18104
19251
  }
18105
19252
  const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
18106
- applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
18107
- scales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
19253
+ emitSpecWarnings(
19254
+ applyColorScaleRange(
19255
+ scales,
19256
+ renderSpec.encoding,
19257
+ theme,
19258
+ renderSpec.highlight,
19259
+ chartSpec.markType
19260
+ ),
19261
+ options.onWarn
19262
+ );
19263
+ scales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? defaultSeriesColor(theme, chartSpec.markType);
18108
19264
  if (options.dev) {
18109
19265
  emitSpecWarnings(collectContrastWarnings(scales, chartSpec.markType, theme), options.onWarn);
18110
19266
  }
18111
19267
  const isRadial = isAxislessMark3(chartSpec.markType);
18112
- const skipX = chartSpec.display === "sparkline" && !chartSpec.userExplicit.xAxis;
18113
- const skipY = chartSpec.display === "sparkline" && !chartSpec.userExplicit.yAxis;
19268
+ const axisless = chartSpec.display === "sparkline" || !economy.axes;
19269
+ const skipX = axisless && !chartSpec.userExplicit.xAxis;
19270
+ const skipY = axisless && !chartSpec.userExplicit.yAxis;
18114
19271
  const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
18115
19272
  data: renderSpec.data,
18116
19273
  encoding: renderSpec.encoding,
18117
19274
  skipX,
18118
19275
  skipY,
19276
+ suppressGrid: !economy.gridlines,
19277
+ maxXTicks: economy.maxXTicks,
18119
19278
  markType: chartSpec.markType,
18120
19279
  totalWidth: options.width,
18121
19280
  // If the guardrail stripped chrome, the plan's y-ticks were sized
@@ -18186,6 +19345,17 @@ function compileChart(spec, optionsInput) {
18186
19345
  fontFamily: theme.fonts.family,
18187
19346
  autoThin: chartSpec.autoThin
18188
19347
  });
19348
+ if (options.dev) {
19349
+ emitSpecWarnings(
19350
+ collectPinnedOverlapWarnings(
19351
+ annotations,
19352
+ chartSpec.annotations,
19353
+ obstacles,
19354
+ annotationMeasure
19355
+ ),
19356
+ options.onWarn
19357
+ );
19358
+ }
18189
19359
  let footnotes;
18190
19360
  if (chartSpec.autoThin && annotations.length > 1) {
18191
19361
  const result = thinAnnotations(
@@ -18378,8 +19548,14 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, facetDirection, opt
18378
19548
  encoding: panelEnc
18379
19549
  };
18380
19550
  const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
18381
- applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme, renderSpec.highlight);
18382
- panelScales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
19551
+ applyColorScaleRange(
19552
+ panelScales,
19553
+ panelSpecWithDomains.encoding,
19554
+ theme,
19555
+ renderSpec.highlight,
19556
+ chartSpec.markType
19557
+ );
19558
+ panelScales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? defaultSeriesColor(theme, chartSpec.markType);
18383
19559
  if (options.dev && panelLayouts.length === 0) {
18384
19560
  emitSpecWarnings(
18385
19561
  collectContrastWarnings(panelScales, chartSpec.markType, theme),
@@ -18570,7 +19746,8 @@ function compileLayer2(spec, options) {
18570
19746
  return compileLayer(expanded, resolvedOptions, compileChart);
18571
19747
  }
18572
19748
  function compileTable(spec, options) {
18573
- const { spec: normalized } = compile(spec);
19749
+ const { spec: normalized, warnings: normalizeWarnings } = compile(spec);
19750
+ emitSpecWarnings(normalizeWarnings, options.onWarn);
18574
19751
  const normType = "type" in normalized ? normalized.type : void 0;
18575
19752
  if (normType !== "table") {
18576
19753
  throw new Error(`compileTable received a non-table spec. Use compileChart instead.`);