@zakkster/lite-charts 1.0.0 → 1.1.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.
Files changed (6) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Charts.d.ts +249 -21
  3. package/Charts.js +1653 -482
  4. package/README.md +156 -102
  5. package/llms.txt +276 -126
  6. package/package.json +6 -4
package/Charts.js CHANGED
@@ -1,15 +1,8 @@
1
1
  /**
2
2
  * @zakkster/lite-charts -- Reactive, zero-GC charts built on lite-scene.
3
3
  *
4
- * v1.0.0 ships seven chart types on three independent kernels:
5
- * - axis kernel: createLineChart, createAreaChart, createBarChart,
6
- * createBubbleChart (LINE/AREA/BAR/BUBBLE_RENDERER)
7
- * - polar kernel: createPieChart, createDonutChart (SLICE_RENDERER)
8
- * - radar kernel: createRadarChart (no renderer indirection yet --
9
- * second variant will trigger the extraction)
10
- *
11
- * Tree-shake boundary: a bundle that imports only one factory keeps just
12
- * its kernel + renderer transitive closure. See README for measured sizes.
4
+ * v1.0.0-alpha.0 ships createLineChart only. Bar/area/scatter/pie follow
5
+ * in subsequent releases on the same substrate.
13
6
  *
14
7
  * Author: Zahary Shinikchiev
15
8
  * License: MIT
@@ -446,10 +439,7 @@ const extractBarSeriesData = (state, data, xAccessor, yAccessor, categories) =>
446
439
  // Linear scan -- categories arrays are small (typically < 30). A
447
440
  // hash map adds setup cost that beats scan only beyond ~1000.
448
441
  for (let c = 0; c < categories.length; c++) {
449
- if (categories[c] === catName) {
450
- idx = c;
451
- break;
452
- }
442
+ if (categories[c] === catName) { idx = c; break; }
453
443
  }
454
444
  if (idx < 0) {
455
445
  idx = categories.length;
@@ -1043,19 +1033,163 @@ const makeAreaDrawFn = (state, refs, plotBoundsBox, yScale, opts) => (ctx) => {
1043
1033
  ctx.restore();
1044
1034
  };
1045
1035
 
1036
+ // ---------------------------------------------------------------------------
1037
+ // Rounded-rect path helper (v1.1.0)
1038
+ // ---------------------------------------------------------------------------
1039
+ //
1040
+ // Uses ctx.roundRect natively where available (Chrome 99+, Firefox 113+,
1041
+ // Safari 16+). Falls back to a hand-traced path with arc corners on older
1042
+ // engines. Per-corner radii (top-left / top-right / bottom-right / bottom-
1043
+ // left) so bar charts can round only the END opposite the baseline: top
1044
+ // for positive bars, bottom for negative bars, neither for stacked
1045
+ // middle segments (handled by passing radii of 0 where flatness is wanted).
1046
+ //
1047
+ // Each radius is clamped to half the shorter side so corners never overlap
1048
+ // on very thin bars -- the rect stays a valid path even at 1px wide.
1049
+
1050
+ const _roundRectPath = (ctx, x, y, w, h, rTL, rTR, rBR, rBL) => {
1051
+ if (w <= 0 || h <= 0) return;
1052
+ const maxR = Math.min(w, h) * 0.5;
1053
+ if (rTL > maxR) rTL = maxR;
1054
+ if (rTR > maxR) rTR = maxR;
1055
+ if (rBR > maxR) rBR = maxR;
1056
+ if (rBL > maxR) rBL = maxR;
1057
+ if (rTL < 0) rTL = 0;
1058
+ if (rTR < 0) rTR = 0;
1059
+ if (rBR < 0) rBR = 0;
1060
+ if (rBL < 0) rBL = 0;
1061
+ // Fast path: native roundRect (uniform args = all corners share radius,
1062
+ // but the canvas spec accepts an array of 4 values for per-corner).
1063
+ if (typeof ctx.roundRect === 'function') {
1064
+ ctx.beginPath();
1065
+ ctx.roundRect(x, y, w, h, [rTL, rTR, rBR, rBL]);
1066
+ return;
1067
+ }
1068
+ // Hand-traced fallback. arcTo per corner; degrades to lineTo when r=0.
1069
+ ctx.beginPath();
1070
+ ctx.moveTo(x + rTL, y);
1071
+ ctx.lineTo(x + w - rTR, y);
1072
+ if (rTR > 0) ctx.arcTo(x + w, y, x + w, y + rTR, rTR);
1073
+ ctx.lineTo(x + w, y + h - rBR);
1074
+ if (rBR > 0) ctx.arcTo(x + w, y + h, x + w - rBR, y + h, rBR);
1075
+ ctx.lineTo(x + rBL, y + h);
1076
+ if (rBL > 0) ctx.arcTo(x, y + h, x, y + h - rBL, rBL);
1077
+ ctx.lineTo(x, y + rTL);
1078
+ if (rTL > 0) ctx.arcTo(x, y, x + rTL, y, rTL);
1079
+ ctx.closePath();
1080
+ };
1081
+
1082
+ // ---------------------------------------------------------------------------
1083
+ // Bar stack pass (v1.1.0)
1084
+ // ---------------------------------------------------------------------------
1085
+ //
1086
+ // When `stack: true`, each visible series contributes to a cumulative
1087
+ // per-category stack instead of getting a horizontal slot. The kernel
1088
+ // calls renderer.postExtract after all series are extracted; the bar
1089
+ // renderer routes that to computeBarStacks, which walks categories and
1090
+ // fills state.stackBottoms / state.stackTops per visible series. The
1091
+ // values are in data space (not pixel) so the y-scale's domain math
1092
+ // still works against them.
1093
+ //
1094
+ // MVP: positive values only. Negative values get clamped to 0 in the
1095
+ // stack (their bars effectively vanish). Diverging stacks (positive
1096
+ // AND negative around a baseline) land in a future cut.
1097
+ //
1098
+ // Each series' domainYMax is overwritten with the max of its OWN
1099
+ // stackTops -- so the kernel's existing y-domain union picks up the
1100
+ // total stack height (the last visible series' max stackTop). This
1101
+ // avoids a special-case in the kernel: stacking is opaque from the
1102
+ // kernel's perspective, the renderer just pre-cooks the y-domain.
1103
+
1104
+ const computeBarStacks = (states, visibility, categoriesRef) => {
1105
+ const nCats = categoriesRef.value.length;
1106
+ if (nCats === 0) return;
1107
+
1108
+ // Lazy-allocate stack buffers per state. ensureFloat32 grows in place.
1109
+ for (let s = 0; s < states.length; s++) {
1110
+ const st = states[s];
1111
+ st.stackBottoms = ensureFloat32(st.stackBottoms, nCats);
1112
+ st.stackTops = ensureFloat32(st.stackTops, nCats);
1113
+ // Zero-fill to handle categories where this series has no row.
1114
+ for (let c = 0; c < nCats; c++) {
1115
+ st.stackBottoms[c] = 0;
1116
+ st.stackTops[c] = 0;
1117
+ }
1118
+ }
1119
+
1120
+ // Cumulative accumulator per category. Walks series in declaration
1121
+ // order -- the user controls stack order by series order, matching
1122
+ // every other charting library's convention.
1123
+ for (let c = 0; c < nCats; c++) {
1124
+ let acc = 0;
1125
+ for (let s = 0; s < states.length; s++) {
1126
+ const visible = visibility[s] ? !!visibility[s]() : true;
1127
+ if (!visible) continue;
1128
+ const st = states[s];
1129
+ // Find this series' value at category c (linear scan; nCats
1130
+ // is small and st.n <= nCats so this stays O(nCats^2) in the
1131
+ // worst case -- fine for the typical 5-30 category range).
1132
+ let v = 0;
1133
+ for (let r = 0; r < st.n; r++) {
1134
+ if ((st.xs[r] | 0) === c) { v = st.ys[r]; break; }
1135
+ }
1136
+ if (v !== v || v < 0) v = 0; // NaN guard + clamp negatives
1137
+ st.stackBottoms[c] = acc;
1138
+ st.stackTops[c] = acc + v;
1139
+ acc += v;
1140
+ }
1141
+ }
1142
+
1143
+ // Overwrite each visible series' y-domain with the GLOBAL stack max
1144
+ // (so the kernel's union picks the cumulative total, not per-series
1145
+ // values).
1146
+ let globalMax = 0;
1147
+ for (let s = 0; s < states.length; s++) {
1148
+ for (let c = 0; c < nCats; c++) {
1149
+ if (states[s].stackTops[c] > globalMax) globalMax = states[s].stackTops[c];
1150
+ }
1151
+ }
1152
+ if (globalMax <= 0) globalMax = 1;
1153
+ for (let s = 0; s < states.length; s++) {
1154
+ states[s].domainYMin = 0;
1155
+ states[s].domainYMax = globalMax;
1156
+ }
1157
+ };
1158
+
1159
+ // Reset stack buffers when stacking is disabled mid-session (avoids stale
1160
+ // state.stackBottoms making the draw fn think stacking is still on).
1161
+ const _clearBarStacks = (states) => {
1162
+ for (let s = 0; s < states.length; s++) {
1163
+ states[s].stackBottoms = null;
1164
+ states[s].stackTops = null;
1165
+ }
1166
+ };
1167
+
1046
1168
  // ---------------------------------------------------------------------------
1047
1169
  // Bar series draw function (v1.1.0)
1048
1170
  // ---------------------------------------------------------------------------
1049
1171
  //
1050
- // One filled rect per category. For grouped layout (multi-series), each
1051
- // series gets a slice of the band, offset symmetrically around the band
1052
- // center: `offsetX = (seriesIdx - (totalSeries - 1) / 2) * groupWidth`.
1053
- // For single-series, totalSeries=1 -> offsetX=0 -> full bandWidth used.
1172
+ // One filled rect per category. Three layouts, picked at draw time from
1173
+ // state shape + opts:
1174
+ // (a) Single series -- offsetX = 0, full bandwidth (minus innerPad)
1175
+ // (b) Grouped -- offsetX symmetric around band center,
1176
+ // barWidth = bandwidth/totalSeries
1177
+ // (c) Stacked -- offsetX = 0, full bandwidth, vertical extent
1178
+ // driven by state.stackBottoms[cat] / .stackTops[cat]
1054
1179
  //
1055
- // Baseline defaults to 0; negative values render bars extending DOWNWARD
1056
- // from the baseline.
1180
+ // Rounded corners apply to the END opposite the baseline so the bar
1181
+ // looks anchored: positive bars round top, negative bars round bottom,
1182
+ // stacked middle segments stay flat (the top segment's top corners
1183
+ // are still rounded -- segments below get visually capped by the
1184
+ // segment above).
1185
+ //
1186
+ // Hover tint: when the chart's crosshair is visible AND its snapIdx
1187
+ // matches the bar's category, an overlay rect is drawn on top with
1188
+ // the configured tint color. Crosshair read is a plain field access
1189
+ // on a mutable singleton -- no signal subscription needed since the
1190
+ // kernel's scene.markDirty path already redraws on crosshair change.
1057
1191
 
1058
- const makeBarDrawFn = (state, refs, plotBoundsBox, xBandScale, yScale, seriesIdx, totalSeries, baseline, innerPadFrac) => (ctx) => {
1192
+ const makeBarDrawFn = (state, refs, plotBoundsBox, xBandScale, yScale, seriesIdx, totalSeries, baseline, innerPadFrac, cornerRadius, hoverTintRef, crosshairDataRef) => (ctx) => {
1059
1193
  if (!refs.visibleRef.value) return;
1060
1194
  const n = state.n;
1061
1195
  if (n === 0) return;
@@ -1066,33 +1200,96 @@ const makeBarDrawFn = (state, refs, plotBoundsBox, xBandScale, yScale, seriesIdx
1066
1200
  const plotT = pb.y;
1067
1201
  const plotB = pb.y + pb.h;
1068
1202
 
1203
+ // Stacked mode? Detected by presence of pre-computed stackTops on the
1204
+ // series state -- the postExtract pass populates them iff opts.stack
1205
+ // is true. Stacked bars use full bandwidth (minus innerPadFrac), no
1206
+ // horizontal offset; vertical extent comes from stackBottoms/stackTops
1207
+ // in data space.
1208
+ const stacked = state.stackBottoms !== null && state.stackTops !== null
1209
+ && state.stackBottoms !== undefined && state.stackTops !== undefined;
1210
+
1069
1211
  const baselinePx = yScale.map(baseline);
1070
- const groupWidth = xBandScale.bandWidth / totalSeries;
1071
- const offsetX = (seriesIdx - (totalSeries - 1) / 2) * groupWidth;
1072
- // Inner padding shrinks each bar slightly within its group slot so
1073
- // adjacent grouped bars don't visually merge. Default 8% of group width.
1074
- const barW = groupWidth * (1 - innerPadFrac);
1212
+ let barW, offsetX;
1213
+ if (stacked) {
1214
+ // Full bandwidth (minus innerPadFrac as a small visual gap between
1215
+ // adjacent categories, mirroring the grouped-layout convention).
1216
+ barW = xBandScale.bandWidth * (1 - innerPadFrac);
1217
+ offsetX = 0;
1218
+ } else {
1219
+ const groupWidth = xBandScale.bandWidth / totalSeries;
1220
+ offsetX = (seriesIdx - (totalSeries - 1) / 2) * groupWidth;
1221
+ barW = groupWidth * (1 - innerPadFrac);
1222
+ }
1075
1223
 
1076
1224
  ctx.fillStyle = refs.colorRef.value;
1077
1225
 
1226
+ const tintColor = hoverTintRef && hoverTintRef.value ? hoverTintRef.value : null;
1227
+ const hoveredCat = (crosshairDataRef && crosshairDataRef.visible)
1228
+ ? (crosshairDataRef.snapIdx | 0)
1229
+ : -1;
1230
+
1231
+ const useRound = cornerRadius > 0;
1232
+
1078
1233
  for (let i = 0; i < n; i++) {
1079
1234
  const y = ys[i];
1080
1235
  if (y !== y) continue; // skip NaN
1081
1236
  const catIdx = xs[i] | 0;
1082
- const cx = xBandScale.map(catIdx) + offsetX;
1083
- const yPx = yScale.map(y);
1084
- let top = yPx < baselinePx ? yPx : baselinePx;
1085
- let h = Math.abs(yPx - baselinePx);
1086
- // Clamp to plot rect so bars don't extend past axes on outlier values.
1087
- if (top < plotT) {
1088
- h -= (plotT - top);
1089
- top = plotT;
1090
- }
1091
- if (top + h > plotB) {
1092
- h = plotB - top;
1237
+
1238
+ let top, h;
1239
+ if (stacked) {
1240
+ const sb = state.stackBottoms[catIdx];
1241
+ const stt = state.stackTops[catIdx];
1242
+ if (stt <= sb) continue; // zero-height segment (e.g. value <= 0)
1243
+ const yPxBottom = yScale.map(sb);
1244
+ const yPxTop = yScale.map(stt);
1245
+ top = yPxTop;
1246
+ h = yPxBottom - yPxTop;
1247
+ } else {
1248
+ const yPx = yScale.map(y);
1249
+ top = yPx < baselinePx ? yPx : baselinePx;
1250
+ h = Math.abs(yPx - baselinePx);
1093
1251
  }
1252
+
1253
+ // Clamp to plot rect.
1254
+ if (top < plotT) { h -= (plotT - top); top = plotT; }
1255
+ if (top + h > plotB) { h = plotB - top; }
1094
1256
  if (h <= 0) continue;
1095
- ctx.fillRect(cx - barW / 2, top, barW, h);
1257
+
1258
+ const barX = xBandScale.map(catIdx) + offsetX - barW / 2;
1259
+
1260
+ // Decide per-corner radii. Round the end OPPOSITE the baseline so
1261
+ // bars look anchored. For stacked, that's always the top (we only
1262
+ // stack positive values in MVP); the topmost segment's top corners
1263
+ // will be the visible rounded ones (lower segments get capped by
1264
+ // the segment above, hiding their unrounded bottoms by adjacency).
1265
+ let rTL = 0, rTR = 0, rBR = 0, rBL = 0;
1266
+ if (useRound) {
1267
+ const isPositive = stacked || y >= baseline;
1268
+ if (isPositive) { rTL = cornerRadius; rTR = cornerRadius; }
1269
+ else { rBR = cornerRadius; rBL = cornerRadius; }
1270
+ }
1271
+
1272
+ if (useRound) {
1273
+ _roundRectPath(ctx, barX, top, barW, h, rTL, rTR, rBR, rBL);
1274
+ ctx.fill();
1275
+ } else {
1276
+ ctx.fillRect(barX, top, barW, h);
1277
+ }
1278
+
1279
+ // Hover tint overlay -- drawn on top of the bar fill with the same
1280
+ // shape. Uses an explicit fillStyle assignment so we don't pollute
1281
+ // the outer-loop fillStyle. Fixed color (no per-bar color math
1282
+ // until v1.2.0 lite-color integration).
1283
+ if (hoveredCat === catIdx && tintColor) {
1284
+ ctx.fillStyle = tintColor;
1285
+ if (useRound) {
1286
+ _roundRectPath(ctx, barX, top, barW, h, rTL, rTR, rBR, rBL);
1287
+ ctx.fill();
1288
+ } else {
1289
+ ctx.fillRect(barX, top, barW, h);
1290
+ }
1291
+ ctx.fillStyle = refs.colorRef.value; // restore for next bar
1292
+ }
1096
1293
  }
1097
1294
  };
1098
1295
 
@@ -1174,20 +1371,21 @@ const buildBarAxis = (parent, opts) => {
1174
1371
  text: cats[i],
1175
1372
  });
1176
1373
  } else {
1177
- labelPool[i].set({visible: false});
1374
+ labelPool[i].set({ visible: false });
1178
1375
  }
1179
1376
  }
1180
1377
  for (let i = n; i < tickPool.length; i++) {
1181
- tickPool[i].set({visible: false});
1182
- labelPool[i].set({visible: false});
1378
+ tickPool[i].set({ visible: false });
1379
+ labelPool[i].set({ visible: false });
1183
1380
  }
1184
1381
  };
1185
1382
 
1186
1383
  const dispose = effect(rebuild);
1187
- return {axisGroup, dispose};
1384
+ return { axisGroup, dispose };
1188
1385
  };
1189
1386
 
1190
1387
 
1388
+
1191
1389
  const _charBuf = new Uint8Array(32);
1192
1390
 
1193
1391
  const formatTickValue = (v, axisFormat, timeUnit) => {
@@ -1254,9 +1452,9 @@ const buildAxis = (parent, opts) => {
1254
1452
  const updateSpine = () => {
1255
1453
  const pb = opts.plotBoundsBox;
1256
1454
  if (isX) {
1257
- spine.set({x: pb.x, y: pb.y + pb.h, dx: pb.w, dy: 0});
1455
+ spine.set({ x: pb.x, y: pb.y + pb.h, dx: pb.w, dy: 0 });
1258
1456
  } else {
1259
- spine.set({x: pb.x, y: pb.y, dx: 0, dy: pb.h});
1457
+ spine.set({ x: pb.x, y: pb.y, dx: 0, dy: pb.h });
1260
1458
  }
1261
1459
  };
1262
1460
 
@@ -1274,7 +1472,7 @@ const buildAxis = (parent, opts) => {
1274
1472
  align: isX ? 'center' : 'right',
1275
1473
  baseline: isX ? 'top' : 'middle',
1276
1474
  }));
1277
- tickPool.push({tickLine: t, label: l});
1475
+ tickPool.push({ tickLine: t, label: l });
1278
1476
  }
1279
1477
  };
1280
1478
 
@@ -1314,8 +1512,8 @@ const buildAxis = (parent, opts) => {
1314
1512
  for (let i = 0; i < tickPool.length; i++) {
1315
1513
  const pair = tickPool[i];
1316
1514
  if (i >= count) {
1317
- pair.tickLine.set({visible: false});
1318
- pair.label.set({visible: false});
1515
+ pair.tickLine.set({ visible: false });
1516
+ pair.label.set({ visible: false });
1319
1517
  continue;
1320
1518
  }
1321
1519
  const px = pixelBuf[i];
@@ -1356,14 +1554,14 @@ const buildAxis = (parent, opts) => {
1356
1554
  });
1357
1555
  }
1358
1556
  } else {
1359
- pair.label.set({visible: false});
1557
+ pair.label.set({ visible: false });
1360
1558
  }
1361
1559
  }
1362
1560
  };
1363
1561
 
1364
1562
  const dispose = effect(rebuild);
1365
1563
 
1366
- return {axisGroup, dispose};
1564
+ return { axisGroup, dispose };
1367
1565
  };
1368
1566
 
1369
1567
  // ---------------------------------------------------------------------------
@@ -1457,19 +1655,19 @@ const buildGrid = (parent, opts) => {
1457
1655
  }
1458
1656
  // Hide unused pool entries.
1459
1657
  for (let i = total; i < linePool.length; i++) {
1460
- linePool[i].set({visible: false});
1658
+ linePool[i].set({ visible: false });
1461
1659
  }
1462
1660
  };
1463
1661
 
1464
1662
  const dispose = effect(rebuild);
1465
- return {gridGroup, dispose};
1663
+ return { gridGroup, dispose };
1466
1664
  };
1467
1665
 
1468
1666
  // ---------------------------------------------------------------------------
1469
1667
  // Default margins / config
1470
1668
  // ---------------------------------------------------------------------------
1471
1669
 
1472
- const DEFAULT_MARGIN = {top: 16, right: 24, bottom: 32, left: 56};
1670
+ const DEFAULT_MARGIN = { top: 16, right: 24, bottom: 32, left: 56 };
1473
1671
  const DEFAULT_AXIS_COLOR = '#888888';
1474
1672
  const DEFAULT_LABEL_COLOR = '#444444';
1475
1673
  const DEFAULT_LINE_COLOR = '#3b82f6';
@@ -1480,7 +1678,7 @@ const DEFAULT_TOOLTIP_BORDER = '#cccccc';
1480
1678
  const DEFAULT_TOOLTIP_MARKER_STROKE = '#ffffff';
1481
1679
  const DEFAULT_LEGEND_POSITION = 'bottom';
1482
1680
  const DEFAULT_GRID_COLOR = 'rgba(0,0,0,0.08)';
1483
- const VALID_LEGEND_POSITIONS = {top: 1, bottom: 1, left: 1, right: 1};
1681
+ const VALID_LEGEND_POSITIONS = { top: 1, bottom: 1, left: 1, right: 1 };
1484
1682
 
1485
1683
  // Pre-allocated constants used by the crosshair draw fn. Avoids `[]` /
1486
1684
  // `Math.PI * 2` allocations on every mousemove redraw.
@@ -1513,13 +1711,6 @@ const formatTooltipValue = (v) => {
1513
1711
  // Domain helpers
1514
1712
  // ---------------------------------------------------------------------------
1515
1713
 
1516
- // Test-API form: returns a fresh [lo, hi] tuple. Kept for the
1517
- // _testHelpers export so existing assertions continue to read the
1518
- // shape they expect (assert.deepEqual(niceYDomain(...), [0, 50])).
1519
- //
1520
- // The chart kernel uses _niceYDomainInto (below) instead -- writes
1521
- // into a chart-owned 2-element scratch array so the data effect
1522
- // allocates zero per update during streaming.
1523
1714
  const niceYDomain = (yMin, yMax, opts) => {
1524
1715
  let lo = yMin;
1525
1716
  let hi = yMax;
@@ -1539,28 +1730,6 @@ const niceYDomain = (yMin, yMax, opts) => {
1539
1730
  return [lo, hi];
1540
1731
  };
1541
1732
 
1542
- // Zero-alloc form. `out` is a 2-element array (`out[0]` = lo, `out[1]` = hi)
1543
- // owned by the caller; we mutate in place. Same math as niceYDomain.
1544
- const _niceYDomainInto = (out, yMin, yMax, opts) => {
1545
- let lo = yMin;
1546
- let hi = yMax;
1547
- if (opts && opts.zero) {
1548
- if (lo > 0) lo = 0;
1549
- if (hi < 0) hi = 0;
1550
- }
1551
- if (opts && opts.nice) {
1552
- const pad = (hi - lo) * 0.05;
1553
- lo -= pad;
1554
- hi += pad;
1555
- }
1556
- if (lo === hi) {
1557
- lo -= 0.5;
1558
- hi += 0.5;
1559
- }
1560
- out[0] = lo;
1561
- out[1] = hi;
1562
- };
1563
-
1564
1733
  const inferXScaleType = (firstRow, xKey) => {
1565
1734
  if (firstRow == null) return 'linear';
1566
1735
  let probe;
@@ -1677,9 +1846,9 @@ const installLegend = (target, canvas, legendEl, position) => {
1677
1846
  : 'flex-start';
1678
1847
  wrapper.style.flexDirection =
1679
1848
  position === 'top' ? 'column-reverse' :
1680
- position === 'bottom' ? 'column' :
1681
- position === 'left' ? 'row-reverse' :
1682
- 'row';
1849
+ position === 'bottom' ? 'column' :
1850
+ position === 'left' ? 'row-reverse' :
1851
+ 'row';
1683
1852
  wrapper.style.gap = '4px';
1684
1853
 
1685
1854
  // Move canvas into wrapper (it's currently a child of target).
@@ -1731,10 +1900,7 @@ const _wireAutoSize = (container, widthAutoSig, heightAutoSig, disposers) => {
1731
1900
  const ro = new ResizeObserver(() => {
1732
1901
  if (scheduled) return;
1733
1902
  scheduled = true;
1734
- const update = () => {
1735
- scheduled = false;
1736
- readSize();
1737
- };
1903
+ const update = () => { scheduled = false; readSize(); };
1738
1904
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(update);
1739
1905
  else update();
1740
1906
  });
@@ -1806,34 +1972,8 @@ const _bisectLookupRow = (state, snapIdx, snapDomainX /*, ctx*/) =>
1806
1972
  const _numericTooltipHeader = (snapIdx, snapDomainX, xScaleType /*, ctx*/) =>
1807
1973
  formatTooltipHeader(snapDomainX, xScaleType);
1808
1974
 
1809
- // Reused opts object for buildAxis when called from the line/area/bubble
1810
- // renderers -- mutated in place across mount() invocations of different
1811
- // charts to avoid the per-mount {orientation:'x', ...opts} allocation
1812
- // the literal-form spread used to do. Mount itself is not hot-path, but
1813
- // this constitutes "no spread in source" hygiene rather than a perf win.
1814
- const _xAxisOptsScratch = {
1815
- orientation: 'x',
1816
- scale: null,
1817
- plotBoundsBox: null,
1818
- plotBoundsSignal: null,
1819
- scaleVersion: null,
1820
- tickColor: null,
1821
- labelColor: null,
1822
- font: null,
1823
- format: null,
1824
- };
1825
-
1826
- const _buildAxisX = (parent, opts /*, ctx*/) => {
1827
- _xAxisOptsScratch.scale = opts.scale;
1828
- _xAxisOptsScratch.plotBoundsBox = opts.plotBoundsBox;
1829
- _xAxisOptsScratch.plotBoundsSignal = opts.plotBoundsSignal;
1830
- _xAxisOptsScratch.scaleVersion = opts.scaleVersion;
1831
- _xAxisOptsScratch.tickColor = opts.tickColor;
1832
- _xAxisOptsScratch.labelColor = opts.labelColor;
1833
- _xAxisOptsScratch.font = opts.font;
1834
- _xAxisOptsScratch.format = opts.format;
1835
- return buildAxis(parent, _xAxisOptsScratch);
1836
- };
1975
+ const _buildAxisX = (parent, opts /*, ctx*/) =>
1976
+ buildAxis(parent, { orientation: 'x', ...opts });
1837
1977
 
1838
1978
  const _extractLineData = (state, data, xAcc, yAcc /*, ctx*/) =>
1839
1979
  extractSeriesData(state, data, xAcc, yAcc);
@@ -1850,7 +1990,7 @@ const _makeAreaDraw = (state, refs, plotBoundsBox, seriesIdx, totalSeries, ctx)
1850
1990
  const _initAreaOpts = (config) => ({
1851
1991
  baseline: config.baseline != null ? config.baseline : 0,
1852
1992
  stroke: config.stroke !== false,
1853
- fillOpacityRef: {value: config.fillOpacity != null ? config.fillOpacity : 0.3},
1993
+ fillOpacityRef: { value: config.fillOpacity != null ? config.fillOpacity : 0.3 },
1854
1994
  });
1855
1995
 
1856
1996
  // Line renderer: numeric x, polyline draw, bisect hit detection, markers on snap.
@@ -1860,7 +2000,7 @@ const LINE_RENDERER = {
1860
2000
  createXScale: makeLinearScale,
1861
2001
  initOpts: null,
1862
2002
  extractData: _extractLineData,
1863
- yDefaults: {nice: true},
2003
+ yDefaults: { nice: true },
1864
2004
  updateXScale: _updateXScaleLinear,
1865
2005
  projectToPixels: true,
1866
2006
  enableXGrid: true,
@@ -1879,7 +2019,7 @@ const AREA_RENDERER = {
1879
2019
  createXScale: makeLinearScale,
1880
2020
  initOpts: _initAreaOpts,
1881
2021
  extractData: _extractLineData,
1882
- yDefaults: {nice: true},
2022
+ yDefaults: { nice: true },
1883
2023
  updateXScale: _updateXScaleLinear,
1884
2024
  projectToPixels: true,
1885
2025
  enableXGrid: true,
@@ -1894,19 +2034,50 @@ const AREA_RENDERER = {
1894
2034
  // Bar-specific renderer methods. None of these are referenced by line/area
1895
2035
  // renderers, so they're tree-shaken when only line or area is imported.
1896
2036
 
1897
- const _initBarOpts = (config) => ({
1898
- baseline: config.baseline != null ? config.baseline : 0,
1899
- paddingInner: config.paddingInner != null ? config.paddingInner : 0.15,
1900
- paddingOuter: config.paddingOuter != null ? config.paddingOuter : 0.1,
1901
- groupInnerPad: config.groupInnerPad != null ? config.groupInnerPad : 0.08,
1902
- });
2037
+ const _initBarOpts = (config) => {
2038
+ // hoverTint: false to disable, true for default white-overlay, or an
2039
+ // explicit CSS color string. Default is a low-alpha white that reads
2040
+ // as a brightening overlay against any series color (parsing-free).
2041
+ let hoverTintValue;
2042
+ if (config.hoverTint === false || config.hoverTint === null) {
2043
+ hoverTintValue = null;
2044
+ } else if (typeof config.hoverTint === 'string') {
2045
+ hoverTintValue = config.hoverTint;
2046
+ } else {
2047
+ hoverTintValue = 'rgba(255,255,255,0.18)';
2048
+ }
2049
+ return {
2050
+ baseline: config.baseline != null ? config.baseline : 0,
2051
+ paddingInner: config.paddingInner != null ? config.paddingInner : 0.15,
2052
+ paddingOuter: config.paddingOuter != null ? config.paddingOuter : 0.1,
2053
+ groupInnerPad: config.groupInnerPad != null ? config.groupInnerPad : 0.08,
2054
+ // v1.1.0
2055
+ stack: config.stack === true,
2056
+ cornerRadius: config.cornerRadius != null ? Math.max(0, +config.cornerRadius) : 0,
2057
+ hoverTintRef: { value: hoverTintValue },
2058
+ };
2059
+ };
1903
2060
 
1904
2061
  const _extractBarData = (state, data, xAcc, yAcc, ctx) =>
1905
2062
  extractBarSeriesData(state, data, xAcc, yAcc, ctx.categoriesRef.value);
1906
2063
 
2064
+ // v1.1.0 stack pass: called by the kernel after every series has been
2065
+ // extracted, before y-domain aggregation. When stacking is on, it
2066
+ // computes per-series cumulative stackBottoms / stackTops and overrides
2067
+ // each series' domainYMax so the y-scale ranges over the total stack.
2068
+ // When stacking is off, it clears any prior stack buffers so re-renders
2069
+ // after a `stack: true -> false` config flip don't keep using stale state.
2070
+ const _barPostExtract = (states, ctx) => {
2071
+ if (ctx.opts.stack) {
2072
+ computeBarStacks(states, ctx.seriesVisibility, ctx.categoriesRef);
2073
+ } else {
2074
+ _clearBarStacks(states);
2075
+ }
2076
+ };
2077
+
1907
2078
  const _updateXScaleBand = (xScale, dxMin, dxMax, rMin, rMax, ctx) =>
1908
2079
  updateBandScale(xScale, ctx.categoriesRef.value.length, rMin, rMax,
1909
- ctx.opts.paddingInner, ctx.opts.paddingOuter);
2080
+ ctx.opts.paddingInner, ctx.opts.paddingOuter);
1910
2081
 
1911
2082
  const _buildAxisBar = (parent, opts, ctx) =>
1912
2083
  buildBarAxis(parent, {
@@ -1922,9 +2093,11 @@ const _buildAxisBar = (parent, opts, ctx) =>
1922
2093
 
1923
2094
  const _makeBarDraw = (state, refs, plotBoundsBox, seriesIdx, totalSeries, ctx) =>
1924
2095
  makeBarDrawFn(state, refs, plotBoundsBox,
1925
- ctx.xScale, ctx.yScale,
1926
- seriesIdx, totalSeries,
1927
- ctx.opts.baseline, ctx.opts.groupInnerPad);
2096
+ ctx.xScale, ctx.yScale,
2097
+ seriesIdx, totalSeries,
2098
+ ctx.opts.baseline, ctx.opts.groupInnerPad,
2099
+ ctx.opts.cornerRadius, ctx.opts.hoverTintRef,
2100
+ ctx.crosshairDataRef);
1928
2101
 
1929
2102
  const _bandHitTest = (canvasX, /*canvasY*/_cy, primary, xScale, ctx) => {
1930
2103
  if (ctx.categoriesRef.value.length === 0) return null;
@@ -1956,7 +2129,8 @@ const BAR_RENDERER = {
1956
2129
  createXScale: makeBandScale,
1957
2130
  initOpts: _initBarOpts,
1958
2131
  extractData: _extractBarData,
1959
- yDefaults: {nice: true, zero: true},
2132
+ postExtract: _barPostExtract, // v1.1.0: stack pass
2133
+ yDefaults: { nice: true, zero: true },
1960
2134
  updateXScale: _updateXScaleBand,
1961
2135
  projectToPixels: false,
1962
2136
  enableXGrid: false,
@@ -2000,20 +2174,100 @@ const _bisectHitTest_canvasY = null; // sentinel for grep -- see _bisectHitTest
2000
2174
  // state.sizeMin / state.sizeMax : value-domain bounds for this series
2001
2175
  // These fields stay null on non-bubble series, costing zero extra memory.
2002
2176
 
2177
+ // ---------------------------------------------------------------------------
2178
+ // Spatial index integration (v1.2.0-alpha.0)
2179
+ // ---------------------------------------------------------------------------
2180
+ //
2181
+ // For charts with dense point clouds (bubble, future scatter / heatmap), the
2182
+ // linear-scan hit-test becomes the bottleneck once point counts pass ~1000.
2183
+ // lite-charts defines a small, allocation-free interface that any spatial
2184
+ // index can implement -- @zakkster/lite-delaunay, a k-d tree, a uniform
2185
+ // grid, etc. The interface stays in lite-charts (so the renderers depend on
2186
+ // nothing extra); the implementation is wired by the consumer via config.
2187
+ //
2188
+ // type SpatialIndexFactory = (pxs, pys, n) -> SpatialIndex
2189
+ //
2190
+ // interface SpatialIndex {
2191
+ // // Write up to `k` nearest indices (by pixel distance from qx, qy) into
2192
+ // // outIndices / outDistSq, filtered to points within maxDistSq.
2193
+ // // Return the count actually written (may be 0 .. k).
2194
+ // // Both output arrays are caller-owned, stable refs -- zero alloc.
2195
+ // findNearest(qx, qy, k, maxDistSq, outIndices, outDistSq) -> number
2196
+ // dispose() -> void
2197
+ // }
2198
+ //
2199
+ // The renderer:
2200
+ // - calls factory(pxs, pys, n) at extract time when n >= threshold;
2201
+ // - disposes the previous index before rebuilding;
2202
+ // - falls back to linear scan when n < threshold OR no factory provided.
2203
+ //
2204
+ // k > 1 matters for bubble specifically: with overlapping discs, the point
2205
+ // whose CENTER is nearest the cursor may not be the one whose DISC contains
2206
+ // the cursor (a small bubble can sit inside a large one). The renderer
2207
+ // asks for the K nearest by center distance, then post-filters by disc
2208
+ // containment + smallest-r tie-break -- preserving v1.0.0 visual semantics.
2209
+ //
2210
+ // For non-overlapping charts (scatter, heatmap cells), the same interface
2211
+ // works with k = 1.
2212
+
2213
+ const SPATIAL_INDEX_DEFAULT_THRESHOLD = 1000;
2214
+ const SPATIAL_INDEX_HIT_BUFFER_SIZE = 8; // k for findNearest; balances
2215
+ // overlap-correctness vs work
2216
+
2217
+ // Allocate per-state output buffers for findNearest. Sized once; reused
2218
+ // across hit-tests. Called lazily at the first hit-test where an index
2219
+ // exists, so non-indexed series pay nothing.
2220
+ const _ensureHitBuffers = (state) => {
2221
+ if (!state._hitIndices) {
2222
+ state._hitIndices = new Int32Array(SPATIAL_INDEX_HIT_BUFFER_SIZE);
2223
+ state._hitDistSq = new Float32Array(SPATIAL_INDEX_HIT_BUFFER_SIZE);
2224
+ }
2225
+ };
2226
+
2227
+ // Dispose helper -- defensive against indices that don't implement dispose().
2228
+ const _disposeSpatialIndex = (state) => {
2229
+ if (state.spatialIndex) {
2230
+ if (typeof state.spatialIndex.dispose === 'function') {
2231
+ state.spatialIndex.dispose();
2232
+ }
2233
+ state.spatialIndex = null;
2234
+ }
2235
+ };
2236
+
2237
+
2003
2238
  const _initBubbleOpts = (config) => {
2004
2239
  const sizeKey = config.size != null ? config.size : 'value';
2240
+ // v1.2.0-alpha.2: per-point color. When `colorKey` is set, each row's
2241
+ // color overrides the series fill. Null when omitted -> series color
2242
+ // path stays the v1.0.0 fast path.
2243
+ const colorKey = config.colorKey != null ? config.colorKey : null;
2005
2244
  return {
2006
2245
  sizeKey,
2007
2246
  sizeAccessor: buildAccessor(sizeKey),
2247
+ colorKey,
2248
+ // Use the RAW accessor here -- color strings (`'#ff0000'`, CSS vars
2249
+ // like `'--c-emerald'`, or `'oklch(...)'`) must not be `+v`-coerced
2250
+ // to NaN. buildRawAccessor returns the value untouched.
2251
+ colorAccessor: colorKey != null ? buildRawAccessor(colorKey) : null,
2008
2252
  minRadius: config.minRadius != null ? +config.minRadius : 4,
2009
2253
  maxRadius: config.maxRadius != null ? +config.maxRadius : 40,
2010
2254
  // 'sqrt' (default): area-proportional, eye-correct per Tukey.
2011
2255
  // 'linear': radius-proportional; useful when the size dimension is
2012
2256
  // already a radius/length quantity rather than a magnitude.
2013
2257
  sizeScaleType: config.sizeScale === 'linear' ? 'linear' : 'sqrt',
2014
- strokeRef: {value: config.stroke != null ? config.stroke : '#ffffff'},
2015
- strokeWidthRef: {value: config.strokeWidth != null ? +config.strokeWidth : 1},
2016
- fillOpacityRef: {value: config.fillOpacity != null ? +config.fillOpacity : 0.6},
2258
+ strokeRef: { value: config.stroke != null ? config.stroke : '#ffffff' },
2259
+ strokeWidthRef: { value: config.strokeWidth != null ? +config.strokeWidth : 1 },
2260
+ fillOpacityRef: { value: config.fillOpacity != null ? +config.fillOpacity : 0.6 },
2261
+ // v1.2.0-alpha.0: spatial-index integration. Pass a factory matching
2262
+ // the SpatialIndexFactory contract (see notes above) to enable
2263
+ // O(log n) hit-test on dense bubble clouds. Threshold defaults to
2264
+ // 1000 -- below that, linear scan is faster than index build + query.
2265
+ spatialIndexFactory: typeof config.spatialIndex === 'function'
2266
+ ? config.spatialIndex
2267
+ : null,
2268
+ spatialIndexThreshold: config.spatialIndexThreshold != null
2269
+ ? +config.spatialIndexThreshold
2270
+ : SPATIAL_INDEX_DEFAULT_THRESHOLD,
2017
2271
  };
2018
2272
  };
2019
2273
 
@@ -2105,6 +2359,44 @@ const extractBubbleData = (state, data, xAcc, yAcc, ctx) => {
2105
2359
  state.sizeMax = vMax === -Infinity ? 1 : vMax;
2106
2360
 
2107
2361
  computeBubbleRadii(state, opts.minRadius, opts.maxRadius, opts.sizeScaleType);
2362
+
2363
+ // v1.2.0-alpha.2: per-point color extraction. When opts.colorAccessor
2364
+ // is set, walk the data and resolve each row's color to a concrete
2365
+ // CSS string so the draw fn can use it directly. state.cs is a plain
2366
+ // string array (no typed-array equivalent for strings) lazily allocated.
2367
+ // Skip the work entirely when colorAccessor is null -- the draw fn
2368
+ // falls back to the series fill via refs.colorRef.
2369
+ if (opts.colorAccessor && Array.isArray(data)) {
2370
+ if (!state.cs || state.cs.length < n) state.cs = new Array(n);
2371
+ const colorAcc = opts.colorAccessor;
2372
+ for (let i = 0; i < n; i++) {
2373
+ const raw = colorAcc(data[i], i);
2374
+ // resolveColor handles CSS-var (--foo) -> resolved value AND
2375
+ // returns plain colors unchanged. null / undefined preserve the
2376
+ // series-fill fallback (draw fn checks for falsy per-row).
2377
+ state.cs[i] = raw != null ? resolveColor(raw) : null;
2378
+ }
2379
+ } else if (state.cs) {
2380
+ // Drop stale per-point colors if colorKey was removed mid-session.
2381
+ state.cs = null;
2382
+ }
2383
+
2384
+ // v1.2.0-alpha.0: invalidate the spatial index whenever extract runs.
2385
+ // The kernel re-projects pxs/pys AFTER this returns, so any pre-built
2386
+ // index is stale by definition. We defer the rebuild until the next
2387
+ // hit-test (lazy) -- no rebuild if the user never hovers.
2388
+ _disposeSpatialIndex(state);
2389
+
2390
+ // Cache max pixel radius squared. The spatial index needs an upper
2391
+ // bound on the query distance (no bubble can contain the cursor if
2392
+ // its CENTER is more than maxR pixels away).
2393
+ let prMax = 0;
2394
+ const prs = state.prs;
2395
+ for (let i = 0; i < n; i++) {
2396
+ const r = prs[i];
2397
+ if (r > prMax) prMax = r;
2398
+ }
2399
+ state.prMaxSq = prMax * prMax;
2108
2400
  };
2109
2401
 
2110
2402
  // Bubble draw fn: one ctx.arc per visible point. Defensive clipping skips
@@ -2120,6 +2412,7 @@ const makeBubbleDrawFn = (state, refs, plotBoundsBox, opts) => (ctx) => {
2120
2412
  const xs = state.pxs;
2121
2413
  const ys = state.pys;
2122
2414
  const rs = state.prs;
2415
+ const cs = state.cs; // null when colorKey unset; per-point color array otherwise
2123
2416
  const pb = plotBoundsBox;
2124
2417
  const plotL = pb.x, plotR = pb.x + pb.w;
2125
2418
  const plotT = pb.y, plotB = pb.y + pb.h;
@@ -2141,7 +2434,9 @@ const makeBubbleDrawFn = (state, refs, plotBoundsBox, opts) => (ctx) => {
2141
2434
  ctx.beginPath();
2142
2435
  ctx.arc(x, y, r, 0, _TWO_PI);
2143
2436
  ctx.globalAlpha = fillAlpha;
2144
- ctx.fillStyle = fillColor;
2437
+ // v1.2.0-alpha.2: per-point color when state.cs is populated and the
2438
+ // specific row has a color; otherwise series fill (v1.0.0 path).
2439
+ ctx.fillStyle = (cs && cs[i]) ? cs[i] : fillColor;
2145
2440
  ctx.fill();
2146
2441
  if (doStroke) {
2147
2442
  ctx.globalAlpha = 1;
@@ -2159,27 +2454,120 @@ const makeBubbleDrawFn = (state, refs, plotBoundsBox, opts) => (ctx) => {
2159
2454
  // for O(log n) lookup, but until n > ~1000 the linear scan is faster
2160
2455
  // (cache-friendly, no tree overhead).
2161
2456
 
2162
- const _bubbleHitTest = (canvasX, canvasY, primary, /*xScale*/_xs, /*ctx*/_ctx) => {
2457
+ const _bubbleHitTest = (canvasX, canvasY, primary, /*xScale*/_xs, ctx) => {
2458
+ // v1.2.0-alpha.2: iterate all visible series, not just primary. Each
2459
+ // series has its own bubbles at different (x, y) so cross-series hit-
2460
+ // test must consider every visible state. With its own spatial index
2461
+ // per series (if applicable). The `primary` arg is kept in the signature
2462
+ // for compatibility but is no longer the only series checked.
2463
+ const states = ctx.seriesStates;
2464
+ const visibility = ctx.seriesVisibility;
2465
+ const opts = ctx.opts;
2466
+ if (!states || !visibility) {
2467
+ // Defensive fallback: ctx wiring missing for some reason. Use the
2468
+ // primary-only path (v1.0.0 behavior).
2469
+ return _bubbleHitTestSingle(canvasX, canvasY, primary, opts);
2470
+ }
2471
+
2472
+ let bestIdx = -1;
2473
+ let bestSeriesIdx = -1;
2474
+ let bestR = Infinity;
2475
+
2476
+ for (let s = 0; s < states.length; s++) {
2477
+ if (!visibility[s]()) continue;
2478
+ const state = states[s];
2479
+ const n = state.n;
2480
+ if (n === 0) continue;
2481
+ if (state.pxs === null || state.pys === null || state.prs === null) continue;
2482
+ const xs = state.pxs, ys = state.pys, rs = state.prs;
2483
+
2484
+ if (opts.spatialIndexFactory && n >= opts.spatialIndexThreshold) {
2485
+ if (!state.spatialIndex) {
2486
+ state.spatialIndex = opts.spatialIndexFactory(xs, ys, n);
2487
+ }
2488
+ _ensureHitBuffers(state);
2489
+ const k = state.spatialIndex.findNearest(
2490
+ canvasX, canvasY,
2491
+ SPATIAL_INDEX_HIT_BUFFER_SIZE,
2492
+ state.prMaxSq,
2493
+ state._hitIndices, state._hitDistSq);
2494
+ const hitIdx = state._hitIndices;
2495
+ const hitDistSq = state._hitDistSq;
2496
+ for (let j = 0; j < k; j++) {
2497
+ const i = hitIdx[j];
2498
+ const r = rs[i];
2499
+ if (r !== r) continue;
2500
+ if (hitDistSq[j] <= r * r && r < bestR) {
2501
+ bestR = r;
2502
+ bestIdx = i;
2503
+ bestSeriesIdx = s;
2504
+ }
2505
+ }
2506
+ } else {
2507
+ for (let i = 0; i < n; i++) {
2508
+ const x = xs[i], y = ys[i], r = rs[i];
2509
+ if (x !== x || y !== y || r !== r) continue;
2510
+ const dx = canvasX - x;
2511
+ const dy = canvasY - y;
2512
+ if (dx * dx + dy * dy <= r * r) {
2513
+ if (r < bestR) {
2514
+ bestR = r;
2515
+ bestIdx = i;
2516
+ bestSeriesIdx = s;
2517
+ }
2518
+ }
2519
+ }
2520
+ }
2521
+ }
2522
+
2523
+ if (bestIdx < 0) return null;
2524
+ const hitState = states[bestSeriesIdx];
2525
+ return {
2526
+ snapIdx: bestIdx,
2527
+ snapDomainX: hitState.xs[bestIdx],
2528
+ snapPixelX: hitState.pxs[bestIdx],
2529
+ snapSeriesIdx: bestSeriesIdx,
2530
+ };
2531
+ };
2532
+
2533
+ // Single-series fallback (kept for the defensive ctx-missing path; should
2534
+ // not normally execute since rendererCtx is always wired in createBaseAxisChart).
2535
+ const _bubbleHitTestSingle = (canvasX, canvasY, primary, opts) => {
2163
2536
  const n = primary.n;
2164
2537
  if (n === 0) return null;
2165
2538
  if (primary.pxs === null || primary.pys === null || primary.prs === null) return null;
2166
- const xs = primary.pxs;
2167
- const ys = primary.pys;
2168
- const rs = primary.prs;
2539
+ const xs = primary.pxs, ys = primary.pys, rs = primary.prs;
2169
2540
  let bestIdx = -1;
2170
2541
  let bestR = Infinity;
2171
- for (let i = 0; i < n; i++) {
2172
- const x = xs[i], y = ys[i], r = rs[i];
2173
- if (x !== x || y !== y || r !== r) continue;
2174
- const dx = canvasX - x;
2175
- const dy = canvasY - y;
2176
- if (dx * dx + dy * dy <= r * r) {
2177
- // Prefer the smaller (visually-topmost) bubble on overlap.
2178
- if (r < bestR) {
2542
+ if (opts.spatialIndexFactory && n >= opts.spatialIndexThreshold) {
2543
+ if (!primary.spatialIndex) {
2544
+ primary.spatialIndex = opts.spatialIndexFactory(xs, ys, n);
2545
+ }
2546
+ _ensureHitBuffers(primary);
2547
+ const k = primary.spatialIndex.findNearest(
2548
+ canvasX, canvasY,
2549
+ SPATIAL_INDEX_HIT_BUFFER_SIZE,
2550
+ primary.prMaxSq,
2551
+ primary._hitIndices, primary._hitDistSq);
2552
+ for (let j = 0; j < k; j++) {
2553
+ const i = primary._hitIndices[j];
2554
+ const r = rs[i];
2555
+ if (r !== r) continue;
2556
+ if (primary._hitDistSq[j] <= r * r && r < bestR) {
2179
2557
  bestR = r;
2180
2558
  bestIdx = i;
2181
2559
  }
2182
2560
  }
2561
+ } else {
2562
+ for (let i = 0; i < n; i++) {
2563
+ const x = xs[i], y = ys[i], r = rs[i];
2564
+ if (x !== x || y !== y || r !== r) continue;
2565
+ const dx = canvasX - x;
2566
+ const dy = canvasY - y;
2567
+ if (dx * dx + dy * dy <= r * r) {
2568
+ if (r < bestR) { bestR = r; bestIdx = i; }
2569
+ }
2570
+ }
2183
2571
  }
2184
2572
  if (bestIdx < 0) return null;
2185
2573
  return {
@@ -2192,8 +2580,16 @@ const _bubbleHitTest = (canvasX, canvasY, primary, /*xScale*/_xs, /*ctx*/_ctx) =
2192
2580
  // Bubble lookup is identity -- the hit-test already returned the exact row
2193
2581
  // index for this series. (Unlike line where the cursor may be between
2194
2582
  // points and we bisect to the nearest x.)
2195
- const _bubbleLookupRow = (state, snapIdx /*, snapDomainX, ctx*/) =>
2196
- snapIdx >= 0 && snapIdx < state.n ? snapIdx : -1;
2583
+ // v1.2.0-alpha.2: for multi-series, only the hit series produces a tooltip
2584
+ // row. The crosshair's snapSeriesIdx tells us which one was hit; other
2585
+ // states return -1 (excluded from the tooltip).
2586
+ const _bubbleLookupRow = (state, snapIdx, _snapDomainX, ctx) => {
2587
+ const cd = ctx.crosshairDataRef;
2588
+ if (cd && cd.snapSeriesIdx >= 0 && state._stateIdx !== cd.snapSeriesIdx) {
2589
+ return -1;
2590
+ }
2591
+ return snapIdx >= 0 && snapIdx < state.n ? snapIdx : -1;
2592
+ };
2197
2593
 
2198
2594
  const _makeBubbleDraw = (state, refs, plotBoundsBox, seriesIdx, totalSeries, ctx) =>
2199
2595
  makeBubbleDrawFn(state, refs, plotBoundsBox, ctx.opts);
@@ -2201,13 +2597,62 @@ const _makeBubbleDraw = (state, refs, plotBoundsBox, seriesIdx, totalSeries, ctx
2201
2597
  const _extractBubbleData = (state, data, xAcc, yAcc, ctx) =>
2202
2598
  extractBubbleData(state, data, xAcc, yAcc, ctx);
2203
2599
 
2600
+ const _bubbleCleanup = (states) => {
2601
+ for (let i = 0; i < states.length; i++) {
2602
+ _disposeSpatialIndex(states[i]);
2603
+ }
2604
+ };
2605
+
2606
+ // v1.2.0-alpha.2: global size domain across visible series. Bubble's per-
2607
+ // series extract sets state.sizeMin / state.sizeMax to the SERIES's range,
2608
+ // so two series with different value ranges would scale independently and
2609
+ // equal raw values would render at different pixel sizes. This hook stamps
2610
+ // the global (visible-series) range onto every state and re-runs
2611
+ // computeBubbleRadii so equal values render equal-area regardless of which
2612
+ // series. Single-series charts skip the rescale (the original computation
2613
+ // is already correct).
2614
+ const _bubblePostExtract = (states, ctx) => {
2615
+ const visibility = ctx.seriesVisibility;
2616
+ let visCount = 0;
2617
+ let globalMin = Infinity;
2618
+ let globalMax = -Infinity;
2619
+ for (let s = 0; s < states.length; s++) {
2620
+ if (!visibility || !visibility[s]()) continue;
2621
+ const st = states[s];
2622
+ if (st.n === 0) continue;
2623
+ visCount++;
2624
+ if (st.sizeMin < globalMin) globalMin = st.sizeMin;
2625
+ if (st.sizeMax > globalMax) globalMax = st.sizeMax;
2626
+ }
2627
+ if (visCount < 2 || globalMin === Infinity) return;
2628
+
2629
+ const opts = ctx.opts;
2630
+ for (let s = 0; s < states.length; s++) {
2631
+ const st = states[s];
2632
+ if (st.n === 0) continue;
2633
+ st.sizeMin = globalMin;
2634
+ st.sizeMax = globalMax;
2635
+ computeBubbleRadii(st, opts.minRadius, opts.maxRadius, opts.sizeScaleType);
2636
+ // Recompute prMaxSq with the new radii. The spatial index was
2637
+ // already invalidated by extract; no extra dispose needed here.
2638
+ let prMax = 0;
2639
+ const prs = st.prs;
2640
+ for (let i = 0; i < st.n; i++) {
2641
+ const r = prs[i];
2642
+ if (r > prMax) prMax = r;
2643
+ }
2644
+ st.prMaxSq = prMax * prMax;
2645
+ }
2646
+ };
2647
+
2204
2648
  const BUBBLE_RENDERER = {
2205
2649
  buildXAccessor: buildAccessor,
2206
2650
  forceXType: null,
2207
2651
  createXScale: makeLinearScale,
2208
2652
  initOpts: _initBubbleOpts,
2209
2653
  extractData: _extractBubbleData,
2210
- yDefaults: {nice: true},
2654
+ postExtract: _bubblePostExtract, // v1.2.0-alpha.2: global size domain
2655
+ yDefaults: { nice: true },
2211
2656
  updateXScale: _updateXScaleLinear,
2212
2657
  projectToPixels: true, // x/y projection; size projection happens in extractData
2213
2658
  enableXGrid: true,
@@ -2217,8 +2662,182 @@ const BUBBLE_RENDERER = {
2217
2662
  drawPerSeriesMarkers: false, // the bubbles ARE the markers
2218
2663
  lookupRow: _bubbleLookupRow,
2219
2664
  formatTooltipHeader: _numericTooltipHeader,
2665
+ cleanup: _bubbleCleanup, // v1.2.0-alpha.0: dispose spatial indices
2666
+ };
2667
+
2668
+ // ---------------------------------------------------------------------------
2669
+ // Scatter renderer (v1.2.0-alpha.1)
2670
+ // ---------------------------------------------------------------------------
2671
+ //
2672
+ // createScatterChart is bubble's simpler sibling: every point gets the SAME
2673
+ // marker size, the data has no size dimension, and the hit-test radius is a
2674
+ // configurable threshold around the marker. Shares the axis kernel with
2675
+ // line / area / bar / bubble; shares the spatial-index foundation from
2676
+ // v1.2.0-alpha.0 (with k = 1 since scatter has no overlap concerns).
2677
+ //
2678
+ // What scatter does NOT do (intentionally; bubble already covers it):
2679
+ // - per-point size from data
2680
+ // - sqrt or linear size scaling
2681
+ // - smallest-on-top tie-break on overlap
2682
+
2683
+ const _initScatterOpts = (config) => {
2684
+ // markerSize is the pixel radius; default 4 (small enough to feel like
2685
+ // a "dot", large enough to click reliably). hitTolerance extends the
2686
+ // hit-test radius beyond the marker for easier targeting; default is
2687
+ // markerSize + 4px (caller can override).
2688
+ const markerSize = config.markerSize != null ? +config.markerSize : 4;
2689
+ const hitTolerance = config.hitTolerance != null
2690
+ ? +config.hitTolerance
2691
+ : markerSize + 4;
2692
+ return {
2693
+ markerSize,
2694
+ hitToleranceSq: hitTolerance * hitTolerance,
2695
+ strokeRef: { value: config.stroke != null ? config.stroke : null },
2696
+ strokeWidthRef: { value: config.strokeWidth != null ? +config.strokeWidth : 0 },
2697
+ fillOpacityRef: { value: config.fillOpacity != null ? +config.fillOpacity : 1 },
2698
+ // v1.2.0-alpha.0: same spatial-index plumbing as bubble. k = 1 in
2699
+ // findNearest because scatter has no overlap; the single nearest
2700
+ // point either is or isn't inside the hit-tolerance disc.
2701
+ spatialIndexFactory: typeof config.spatialIndex === 'function'
2702
+ ? config.spatialIndex
2703
+ : null,
2704
+ spatialIndexThreshold: config.spatialIndexThreshold != null
2705
+ ? +config.spatialIndexThreshold
2706
+ : SPATIAL_INDEX_DEFAULT_THRESHOLD,
2707
+ };
2708
+ };
2709
+
2710
+ // Scatter extract is just extractSeriesData -- no size column to process.
2711
+ // The spatial index gets disposed here on every data / scale change for the
2712
+ // same lazy-rebuild reason as bubble.
2713
+ const _extractScatterData = (state, data, xAcc, yAcc, ctx) => {
2714
+ extractSeriesData(state, data, xAcc, yAcc);
2715
+ _disposeSpatialIndex(state);
2716
+ };
2717
+
2718
+ const makeScatterDrawFn = (state, refs, plotBoundsBox, opts) => (ctx) => {
2719
+ if (!refs.visibleRef.value) return;
2720
+ const n = state.n;
2721
+ if (n === 0) return;
2722
+ if (state.pxs === null || state.pys === null) return;
2723
+ const xs = state.pxs;
2724
+ const ys = state.pys;
2725
+ const pb = plotBoundsBox;
2726
+ const plotL = pb.x, plotR = pb.x + pb.w;
2727
+ const plotT = pb.y, plotB = pb.y + pb.h;
2728
+ const r = opts.markerSize;
2729
+ const r2 = r; // used as AABB margin
2730
+
2731
+ ctx.fillStyle = refs.colorRef.value;
2732
+ const prevAlpha = ctx.globalAlpha;
2733
+ ctx.globalAlpha = opts.fillOpacityRef.value;
2734
+
2735
+ const strokeW = opts.strokeWidthRef.value;
2736
+ const strokeColor = strokeW > 0 && opts.strokeRef.value ? opts.strokeRef.value : null;
2737
+
2738
+ for (let i = 0; i < n; i++) {
2739
+ const x = xs[i], y = ys[i];
2740
+ if (x !== x || y !== y) continue;
2741
+ // Defensive AABB clip -- skip points whose bounding square sits
2742
+ // entirely outside the plot rect.
2743
+ if (x + r2 < plotL || x - r2 > plotR || y + r2 < plotT || y - r2 > plotB) continue;
2744
+ ctx.beginPath();
2745
+ ctx.arc(x, y, r, 0, Math.PI * 2);
2746
+ ctx.fill();
2747
+ if (strokeColor) {
2748
+ ctx.strokeStyle = strokeColor;
2749
+ ctx.lineWidth = strokeW;
2750
+ ctx.stroke();
2751
+ }
2752
+ }
2753
+ ctx.globalAlpha = prevAlpha;
2754
+ };
2755
+
2756
+ const _scatterHitTest = (canvasX, canvasY, primary, /*xScale*/_xs, ctx) => {
2757
+ const n = primary.n;
2758
+ if (n === 0) return null;
2759
+ if (primary.pxs === null || primary.pys === null) return null;
2760
+ const xs = primary.pxs;
2761
+ const ys = primary.pys;
2762
+ const opts = ctx.opts;
2763
+ const toleranceSq = opts.hitToleranceSq;
2764
+
2765
+ // Spatial-index fast path -- k = 1 is enough since scatter has no
2766
+ // overlap semantics. The nearest point either is or isn't within the
2767
+ // hit-tolerance disc.
2768
+ if (opts.spatialIndexFactory && n >= opts.spatialIndexThreshold) {
2769
+ if (!primary.spatialIndex) {
2770
+ primary.spatialIndex = opts.spatialIndexFactory(xs, ys, n);
2771
+ }
2772
+ _ensureHitBuffers(primary);
2773
+ const k = primary.spatialIndex.findNearest(
2774
+ canvasX, canvasY, 1, toleranceSq,
2775
+ primary._hitIndices, primary._hitDistSq);
2776
+ if (k === 0) return null;
2777
+ const idx = primary._hitIndices[0];
2778
+ return {
2779
+ snapIdx: idx,
2780
+ snapDomainX: primary.xs[idx],
2781
+ snapPixelX: xs[idx],
2782
+ };
2783
+ }
2784
+
2785
+ // Linear scan fallback. Tracks the closest point within tolerance.
2786
+ let bestIdx = -1;
2787
+ let bestDsq = toleranceSq; // strict-less below tightens this
2788
+ for (let i = 0; i < n; i++) {
2789
+ const x = xs[i], y = ys[i];
2790
+ if (x !== x || y !== y) continue;
2791
+ const dx = canvasX - x;
2792
+ const dy = canvasY - y;
2793
+ const d = dx * dx + dy * dy;
2794
+ if (d < bestDsq) {
2795
+ bestDsq = d;
2796
+ bestIdx = i;
2797
+ }
2798
+ }
2799
+ if (bestIdx < 0) return null;
2800
+ return {
2801
+ snapIdx: bestIdx,
2802
+ snapDomainX: primary.xs[bestIdx],
2803
+ snapPixelX: xs[bestIdx],
2804
+ };
2805
+ };
2806
+
2807
+ const _scatterLookupRow = (state, snapIdx /*, snapDomainX, ctx*/) =>
2808
+ snapIdx >= 0 && snapIdx < state.n ? snapIdx : -1;
2809
+
2810
+ const _makeScatterDraw = (state, refs, plotBoundsBox, /*seriesIdx*/_si, /*totalSeries*/_ts, ctx) =>
2811
+ makeScatterDrawFn(state, refs, plotBoundsBox, ctx.opts);
2812
+
2813
+ const _scatterCleanup = (states) => {
2814
+ // Same as bubble -- dispose spatial indices on unmount. Defensively
2815
+ // guards against indices that hold external resources.
2816
+ for (let i = 0; i < states.length; i++) {
2817
+ _disposeSpatialIndex(states[i]);
2818
+ }
2819
+ };
2820
+
2821
+ const SCATTER_RENDERER = {
2822
+ buildXAccessor: buildAccessor,
2823
+ forceXType: null,
2824
+ createXScale: makeLinearScale,
2825
+ initOpts: _initScatterOpts,
2826
+ extractData: _extractScatterData,
2827
+ yDefaults: { nice: true },
2828
+ updateXScale: _updateXScaleLinear,
2829
+ projectToPixels: true,
2830
+ enableXGrid: true,
2831
+ buildXAxis: _buildAxisX,
2832
+ makeDrawFn: _makeScatterDraw,
2833
+ hitTest: _scatterHitTest,
2834
+ drawPerSeriesMarkers: false,
2835
+ lookupRow: _scatterLookupRow,
2836
+ formatTooltipHeader: _numericTooltipHeader,
2837
+ cleanup: _scatterCleanup,
2220
2838
  };
2221
2839
 
2840
+
2222
2841
  // ===========================================================================
2223
2842
  // Base x/y axis chart kernel
2224
2843
  // ===========================================================================
@@ -2289,7 +2908,15 @@ const createBaseAxisChart = (config, renderer) => {
2289
2908
  const marginLeft = m.left != null ? m.left : DEFAULT_MARGIN.left;
2290
2909
 
2291
2910
  // -- Series state --
2292
- const seriesStates = normalized.map(() => createSeriesState());
2911
+ // Tag each state with its position in the array. The multi-series bubble
2912
+ // hit-test (v1.2.0-alpha.2) uses this so lookupRow can check whether
2913
+ // a given state is the one that actually got hit; line / area / bar /
2914
+ // scatter ignore it.
2915
+ const seriesStates = normalized.map((_, i) => {
2916
+ const s = createSeriesState();
2917
+ s._stateIdx = i;
2918
+ return s;
2919
+ });
2293
2920
 
2294
2921
  // -- Scales: created once, fields mutated in place --
2295
2922
  // Resolve x scale type: explicit > inferred from first non-empty series.
@@ -2312,7 +2939,7 @@ const createBaseAxisChart = (config, renderer) => {
2312
2939
  // series, in first-seen order. Mutated in place by extractBarSeriesData
2313
2940
  // during data extraction; bar axis + bandScale read .value. Always
2314
2941
  // allocated (cheap) -- non-bar renderers simply never reference it.
2315
- const categoriesRef = {value: []};
2942
+ const categoriesRef = { value: [] };
2316
2943
 
2317
2944
  // Chart-type-specific options bag. `null` for line; structured config
2318
2945
  // for area / bar / future renderers.
@@ -2325,17 +2952,24 @@ const createBaseAxisChart = (config, renderer) => {
2325
2952
  yScale,
2326
2953
  opts: chartOpts,
2327
2954
  categoriesRef,
2955
+ // v1.1.0: stack pass + hover tint need access to per-series
2956
+ // visibility signals and the live crosshair state. Both are
2957
+ // assigned below once the kernel has constructed them (after
2958
+ // chart() is set up).
2959
+ seriesVisibility: null,
2960
+ crosshairDataRef: null,
2961
+ // v1.2.0-alpha.2: multi-series bubble hit-test iterates all visible
2962
+ // series' state arrays to find the best hit across series.
2963
+ seriesStates: null,
2328
2964
  };
2329
-
2330
- // 2-element scratch for the y-domain effect. Replaces both the
2331
- // `[yConf.domain[0], yConf.domain[1]]` literal AND the [lo, hi] tuple
2332
- // niceYDomain used to return per call. Reads stay at _yDomScratch[0/1].
2333
- const _yDomScratch = [0, 0];
2965
+ // seriesStates is declared above rendererCtx in source order; assign it
2966
+ // here now that rendererCtx exists.
2967
+ rendererCtx.seriesStates = seriesStates;
2334
2968
 
2335
2969
  const scaleVersion = signal(0);
2336
2970
 
2337
2971
  // -- Plot bounds: a single mutable box + a signal that publishes "the box changed" --
2338
- const plotBoundsBox = {x: 0, y: 0, w: 0, h: 0};
2972
+ const plotBoundsBox = { x: 0, y: 0, w: 0, h: 0 };
2339
2973
  const plotBoundsSignal = signal(0);
2340
2974
 
2341
2975
  // -- Refs that the draw closures read (mutated by an effect) --
@@ -2343,23 +2977,24 @@ const createBaseAxisChart = (config, renderer) => {
2343
2977
  // draw fns (which run outside a reactive context) can read synchronously
2344
2978
  // without going through signal-call overhead.
2345
2979
  const seriesRefs = normalized.map((s) => ({
2346
- colorRef: {value: '#888'},
2347
- lineWidthRef: {value: s.lineWidth},
2348
- visibleRef: {value: true},
2349
- interpolationRef: {value: INTERP_LINEAR},
2350
- markersRef: {value: null},
2980
+ colorRef: { value: '#888' },
2981
+ lineWidthRef: { value: s.lineWidth },
2982
+ visibleRef: { value: true },
2983
+ interpolationRef: { value: INTERP_LINEAR },
2984
+ markersRef: { value: null },
2351
2985
  }));
2352
2986
 
2353
2987
  // -- Public series-visibility signals. Used by the legend click handler,
2354
2988
  // by chart.setSeriesVisible(), and tracked by the domain + draw effects so
2355
2989
  // toggling rescales the y-domain and triggers redraw.
2356
2990
  const seriesVisibility = normalized.map(() => signal(true));
2991
+ rendererCtx.seriesVisibility = seriesVisibility;
2357
2992
 
2358
2993
  // -- Axis-render styling refs --
2359
2994
  const axisStyleRefs = {
2360
- tickColor: {value: DEFAULT_AXIS_COLOR},
2361
- labelColor: {value: DEFAULT_LABEL_COLOR},
2362
- font: {value: config.font != null ? config.font : DEFAULT_FONT},
2995
+ tickColor: { value: DEFAULT_AXIS_COLOR },
2996
+ labelColor: { value: DEFAULT_LABEL_COLOR },
2997
+ font: { value: config.font != null ? config.font : DEFAULT_FONT },
2363
2998
  };
2364
2999
 
2365
3000
  // (areaOpts and barOpts have been replaced by chartOpts -- a unified
@@ -2380,7 +3015,7 @@ const createBaseAxisChart = (config, renderer) => {
2380
3015
  gridEnableY = config.grid.y !== false;
2381
3016
  if (config.grid.color) gridColorSpec = config.grid.color;
2382
3017
  }
2383
- const gridColorRef = {value: gridColorSpec};
3018
+ const gridColorRef = { value: gridColorSpec };
2384
3019
 
2385
3020
  // -- Legend config --
2386
3021
  // `legend: false` disables. `legend: 'top'|'bottom'|'left'|'right'` is
@@ -2417,7 +3052,7 @@ const createBaseAxisChart = (config, renderer) => {
2417
3052
  const crosshairColorSpec = crosshairOpts && crosshairOpts.color
2418
3053
  ? crosshairOpts.color
2419
3054
  : DEFAULT_CROSSHAIR_COLOR;
2420
- const crosshairColorRef = {value: crosshairColorSpec};
3055
+ const crosshairColorRef = { value: crosshairColorSpec };
2421
3056
  const crosshairDash = crosshairOpts && crosshairOpts.dash ? crosshairOpts.dash : [3, 3];
2422
3057
  const tooltipBgSpec = tooltipOpts && tooltipOpts.background
2423
3058
  ? tooltipOpts.background
@@ -2425,8 +3060,8 @@ const createBaseAxisChart = (config, renderer) => {
2425
3060
  const tooltipBorderSpec = tooltipOpts && tooltipOpts.border
2426
3061
  ? tooltipOpts.border
2427
3062
  : DEFAULT_TOOLTIP_BORDER;
2428
- const tooltipBgRef = {value: tooltipBgSpec};
2429
- const tooltipBorderRef = {value: tooltipBorderSpec};
3063
+ const tooltipBgRef = { value: tooltipBgSpec };
3064
+ const tooltipBorderRef = { value: tooltipBorderSpec };
2430
3065
  const tooltipFormatter = tooltipOpts && typeof tooltipOpts.format === 'function'
2431
3066
  ? tooltipOpts.format
2432
3067
  : null;
@@ -2450,7 +3085,13 @@ const createBaseAxisChart = (config, renderer) => {
2450
3085
  snapDomainX: 0,
2451
3086
  snapPixelX: 0,
2452
3087
  mousePixelY: 0,
3088
+ // v1.2.0-alpha.2: which series the hit belongs to. -1 means
3089
+ // "not series-scoped" (line / area / bar / scatter never set this).
3090
+ // Multi-series bubble's hit-test sets it so lookupRow can scope the
3091
+ // tooltip to just the hit series.
3092
+ snapSeriesIdx: -1,
2453
3093
  };
3094
+ rendererCtx.crosshairDataRef = crosshairData;
2454
3095
  const crosshairVersion = signal(0);
2455
3096
  const crosshairFacade = function () {
2456
3097
  crosshairVersion();
@@ -2619,11 +3260,26 @@ const createBaseAxisChart = (config, renderer) => {
2619
3260
  }
2620
3261
  }
2621
3262
 
3263
+ // v1.1.0: optional post-extract pass. Bar's stack pass uses this
3264
+ // to fill stackBottoms/stackTops per series AND overwrite each
3265
+ // series' domainYMax with the total-stack-height global max. We
3266
+ // re-aggregate y-domain after the hook to pick up those updates.
3267
+ if (renderer.postExtract) {
3268
+ renderer.postExtract(seriesStates, rendererCtx);
3269
+ yMin = Infinity;
3270
+ yMax = -Infinity;
3271
+ for (let i = 0; i < normalized.length; i++) {
3272
+ if (seriesVisibility[i]() && seriesStates[i].n > 0) {
3273
+ if (seriesStates[i].domainYMin < yMin) yMin = seriesStates[i].domainYMin;
3274
+ if (seriesStates[i].domainYMax > yMax) yMax = seriesStates[i].domainYMax;
3275
+ }
3276
+ }
3277
+ if (yMin === Infinity) yMin = 0;
3278
+ if (yMax === -Infinity) yMax = 1;
3279
+ }
3280
+
2622
3281
  if (!anyData) {
2623
- xMin = 0;
2624
- xMax = 1;
2625
- yMin = 0;
2626
- yMax = 1;
3282
+ xMin = 0; xMax = 1; yMin = 0; yMax = 1;
2627
3283
  }
2628
3284
  if (xMin === xMax) xMax = xMin + 1;
2629
3285
 
@@ -2640,14 +3296,9 @@ const createBaseAxisChart = (config, renderer) => {
2640
3296
  const yConf = config.yScale;
2641
3297
  const dxMin = xConf && xConf.domain ? xConf.domain[0] : xMin;
2642
3298
  const dxMax = xConf && xConf.domain ? xConf.domain[1] : xMax;
2643
- // Zero-alloc y-domain: write into _yDomScratch instead of
2644
- // allocating a fresh [lo, hi] tuple per data update.
2645
- if (yConf && yConf.domain) {
2646
- _yDomScratch[0] = yConf.domain[0];
2647
- _yDomScratch[1] = yConf.domain[1];
2648
- } else {
2649
- _niceYDomainInto(_yDomScratch, yMin, yMax, yConf || renderer.yDefaults);
2650
- }
3299
+ const yBase = yConf && yConf.domain
3300
+ ? [yConf.domain[0], yConf.domain[1]]
3301
+ : niceYDomain(yMin, yMax, yConf || renderer.yDefaults);
2651
3302
 
2652
3303
  renderer.updateXScale(
2653
3304
  xScale,
@@ -2655,7 +3306,7 @@ const createBaseAxisChart = (config, renderer) => {
2655
3306
  plotBoundsBox.x, plotBoundsBox.x + plotBoundsBox.w,
2656
3307
  rendererCtx,
2657
3308
  );
2658
- updateLinearScale(yScale, _yDomScratch[0], _yDomScratch[1], plotBoundsBox.y + plotBoundsBox.h, plotBoundsBox.y);
3309
+ updateLinearScale(yScale, yBase[0], yBase[1], plotBoundsBox.y + plotBoundsBox.h, plotBoundsBox.y);
2659
3310
 
2660
3311
  // Renderers that use pre-projected pixel arrays in their draw fn
2661
3312
  // (line / area) request projection; renderers that compute pixels
@@ -2743,7 +3394,9 @@ const createBaseAxisChart = (config, renderer) => {
2743
3394
  normalized.length, // totalSeries
2744
3395
  rendererCtx,
2745
3396
  );
2746
- const node = scene.root.add(pathNode({draw: drawFn}));
3397
+ const node = scene.root.add(pathNode({
3398
+ draw: (ctx) => drawFn(ctx),
3399
+ }));
2747
3400
  seriesNodes.push(node);
2748
3401
  }
2749
3402
 
@@ -2761,7 +3414,9 @@ const createBaseAxisChart = (config, renderer) => {
2761
3414
  // keep it as ONE node (rather than one node per visual) because all
2762
3415
  // pieces share the same gate (crosshair visibility), so coalescing
2763
3416
  // them avoids redundant scene traversal cost.
2764
- const crosshairNode = scene.root.add(pathNode({draw: drawCrosshair}));
3417
+ const crosshairNode = scene.root.add(pathNode({
3418
+ draw: (ctx) => drawCrosshair(ctx),
3419
+ }));
2765
3420
 
2766
3421
  // Dirty bridge: crosshair state changes -> markDirty. The path
2767
3422
  // node's draw is RAW, so it doesn't auto-track.
@@ -2777,7 +3432,7 @@ const createBaseAxisChart = (config, renderer) => {
2777
3432
  const onMove = (ev) => {
2778
3433
  const rect = typeof canvas.getBoundingClientRect === 'function'
2779
3434
  ? canvas.getBoundingClientRect()
2780
- : {left: 0, top: 0, width: canvas.width, height: canvas.height};
3435
+ : { left: 0, top: 0, width: canvas.width, height: canvas.height };
2781
3436
  // CSS pixels relative to canvas top-left. moveCrosshair
2782
3437
  // expects logical (CSS-pixel) coords because plotBoundsBox,
2783
3438
  // pixel buffers, and xScale.invert all operate in logical
@@ -2804,7 +3459,7 @@ const createBaseAxisChart = (config, renderer) => {
2804
3459
  const fontResolved = config.font != null ? config.font : DEFAULT_FONT;
2805
3460
  const labelColorResolved = resolveColor(config.labelColor || DEFAULT_LABEL_COLOR, container);
2806
3461
  legendEl = buildLegendDOM(
2807
- {position: legendPosition, container: legendContainer},
3462
+ { position: legendPosition, container: legendContainer },
2808
3463
  normalized,
2809
3464
  seriesVisibility,
2810
3465
  seriesRefs,
@@ -2836,17 +3491,19 @@ const createBaseAxisChart = (config, renderer) => {
2836
3491
  const unmount = () => {
2837
3492
  if (!mounted) return;
2838
3493
  for (let i = 0; i < disposers.length; i++) {
2839
- try {
2840
- disposers[i]();
2841
- } catch (_) { /* swallow */
2842
- }
3494
+ try { disposers[i](); } catch (_) { /* swallow */ }
2843
3495
  }
2844
3496
  disposers.length = 0;
3497
+ // v1.2.0-alpha.0: per-renderer state cleanup hook. Bubble uses it to
3498
+ // dispose spatial indices (which may hold external resources); other
3499
+ // renderers don't define it and pay only a null-check. Moving the
3500
+ // hook here -- vs an unconditional loop -- keeps the spatial-index
3501
+ // helper out of line / area / bar bundles entirely.
3502
+ if (renderer.cleanup) {
3503
+ try { renderer.cleanup(seriesStates); } catch (_) { /* swallow */ }
3504
+ }
2845
3505
  if (scene) {
2846
- try {
2847
- scene.dispose();
2848
- } catch (_) { /* swallow */
2849
- }
3506
+ try { scene.dispose(); } catch (_) { /* swallow */ }
2850
3507
  scene = null;
2851
3508
  }
2852
3509
  // Legend tear-down: remove whichever DOM we owned.
@@ -2914,13 +3571,19 @@ const createBaseAxisChart = (config, renderer) => {
2914
3571
  // Zero-alloc dedup + mutate. crosshairData is the live mutable
2915
3572
  // reference; we read its current fields, decide whether anything
2916
3573
  // actually changed, and either bail or mutate-and-bump-version.
3574
+ // v1.2.0-alpha.2: snapSeriesIdx participates in dedup so a hover
3575
+ // that moves from series A's point to series B's point (same row
3576
+ // index, different series) still re-renders the tooltip.
3577
+ const hitSeriesIdx = hit.snapSeriesIdx != null ? hit.snapSeriesIdx : -1;
2917
3578
  if (crosshairData.visible
2918
3579
  && crosshairData.snapIdx === hit.snapIdx
3580
+ && crosshairData.snapSeriesIdx === hitSeriesIdx
2919
3581
  && crosshairData.mousePixelY === canvasY) return;
2920
3582
  crosshairData.visible = true;
2921
3583
  crosshairData.snapIdx = hit.snapIdx;
2922
3584
  crosshairData.snapDomainX = hit.snapDomainX;
2923
3585
  crosshairData.snapPixelX = hit.snapPixelX;
3586
+ crosshairData.snapSeriesIdx = hitSeriesIdx;
2924
3587
  crosshairData.mousePixelY = canvasY;
2925
3588
  crosshairVersion.update((x) => (x + 1) | 0);
2926
3589
  };
@@ -2932,6 +3595,7 @@ const createBaseAxisChart = (config, renderer) => {
2932
3595
  crosshairData.snapIdx = -1;
2933
3596
  crosshairData.snapDomainX = 0;
2934
3597
  crosshairData.snapPixelX = 0;
3598
+ crosshairData.snapSeriesIdx = -1;
2935
3599
  crosshairData.mousePixelY = 0;
2936
3600
  crosshairVersion.update((x) => (x + 1) | 0);
2937
3601
  };
@@ -2987,31 +3651,6 @@ const createBaseAxisChart = (config, renderer) => {
2987
3651
  if (tooltipOpts) drawTooltip(ctx, state);
2988
3652
  };
2989
3653
 
2990
- // Tooltip scratch: a stable row pool we resize-and-mutate, plus a
2991
- // stable object passed to the user's tooltipFormatter. Both are
2992
- // allocated ONCE per chart instance and reused across every mousemove.
2993
- // Eliminates the rows = [] + per-series {color,label,value} push that
2994
- // used to fire on each pointer event (mouse polls 125-1000 Hz on
2995
- // gaming mice -- bounded but not free).
2996
- //
2997
- // The user's formatter still receives a normal-looking object via
2998
- // `_tooltipFormatterCtx`; if they retain it past their callback they
2999
- // observe the next mousemove's state -- documented contract, same as
3000
- // the crosshair facade.
3001
- const _tooltipRowPool = [];
3002
- const _ensureRowPool = (n) => {
3003
- while (_tooltipRowPool.length < n) {
3004
- _tooltipRowPool.push({color: '', label: '', value: ''});
3005
- }
3006
- };
3007
- const _tooltipFormatterCtx = {
3008
- snapIdx: 0,
3009
- snapDomainX: 0,
3010
- xScaleType: '',
3011
- category: null,
3012
- rows: _tooltipRowPool, // mutated below to expose a sliced view
3013
- };
3014
-
3015
3654
  const drawTooltip = (ctx, state) => {
3016
3655
  const pb = plotBoundsBox;
3017
3656
  const padding = 8;
@@ -3019,28 +3658,21 @@ const createBaseAxisChart = (config, renderer) => {
3019
3658
  const lineHeight = 14;
3020
3659
  const gap = 12;
3021
3660
 
3022
- // Fill rows from the pool. `rowsLen` is the logical length;
3023
- // _tooltipRowPool.length is the high-water mark capacity.
3024
- let rowsLen = 0;
3661
+ // Build rows. ALLOCATES, but this fires at mousemove rate, not per-frame.
3662
+ const rows = [];
3025
3663
  for (let i = 0; i < seriesStates.length; i++) {
3026
3664
  if (!seriesRefs[i].visibleRef.value) continue;
3027
3665
  const s = seriesStates[i];
3028
3666
  if (s.n === 0) continue;
3029
3667
  const myIdx = renderer.lookupRow(s, state.snapIdx, state.snapDomainX, rendererCtx);
3030
3668
  if (myIdx < 0) continue;
3031
- _ensureRowPool(rowsLen + 1);
3032
- const row = _tooltipRowPool[rowsLen++];
3033
- row.color = seriesRefs[i].colorRef.value;
3034
- row.label = normalized[i].name;
3035
- // formatTooltipValue still allocates the value string -- it's
3036
- // built from formatNumber's char buffer per call. Pooling the
3037
- // string itself would require a stable per-row char buffer
3038
- // and consumers that read .value before the next mousemove.
3039
- // Not worth the contract complexity for a few bytes; the row
3040
- // object reuse already covers the dominant allocation.
3041
- row.value = formatTooltipValue(s.ys[myIdx]);
3042
- }
3043
- if (rowsLen === 0) return;
3669
+ rows.push({
3670
+ color: seriesRefs[i].colorRef.value,
3671
+ label: normalized[i].name,
3672
+ value: formatTooltipValue(s.ys[myIdx]),
3673
+ });
3674
+ }
3675
+ if (rows.length === 0) return;
3044
3676
 
3045
3677
  // Tooltip header: renderer picks the format (category name for bar,
3046
3678
  // formatted number/date for line/area).
@@ -3050,51 +3682,30 @@ const createBaseAxisChart = (config, renderer) => {
3050
3682
 
3051
3683
  // Custom formatter override: replace the whole row set + header.
3052
3684
  // `category` is preserved for the tooltipFormatter contract -- bar
3053
- // tooltips pass it; line/area pass null.
3685
+ // tooltips pass it; line/area pass null. Use the header text the
3686
+ // renderer produced as the default when the formatter doesn't supply one.
3054
3687
  const barCategoryName = renderer.forceXType === 'band'
3055
- && state.snapIdx >= 0
3056
- && state.snapIdx < categoriesRef.value.length
3688
+ && state.snapIdx >= 0
3689
+ && state.snapIdx < categoriesRef.value.length
3057
3690
  ? categoriesRef.value[state.snapIdx]
3058
3691
  : null;
3059
3692
  let headerText;
3060
3693
  if (tooltipFormatter) {
3061
- // Mutate the reused ctx in place. `rows` on the ctx points
3062
- // at the pool; we temporarily set its `length` so the user
3063
- // sees only the valid slice. Restore after the callback so
3064
- // the next mousemove starts from cap, not from the slice.
3065
- _tooltipFormatterCtx.snapIdx = state.snapIdx;
3066
- _tooltipFormatterCtx.snapDomainX = state.snapDomainX;
3067
- _tooltipFormatterCtx.xScaleType = resolvedXType;
3068
- _tooltipFormatterCtx.category = barCategoryName;
3069
- const cap = _tooltipRowPool.length;
3070
- _tooltipRowPool.length = rowsLen;
3071
- const out = tooltipFormatter(_tooltipFormatterCtx);
3072
- _tooltipRowPool.length = cap;
3694
+ const out = tooltipFormatter({
3695
+ snapIdx: state.snapIdx,
3696
+ snapDomainX: state.snapDomainX,
3697
+ xScaleType: resolvedXType,
3698
+ category: barCategoryName,
3699
+ rows,
3700
+ });
3073
3701
  if (typeof out === 'string') {
3074
3702
  headerText = out;
3075
- rowsLen = 0;
3703
+ rows.length = 0;
3076
3704
  } else if (out && typeof out === 'object') {
3077
3705
  headerText = out.header != null ? out.header : defaultHeaderText;
3078
3706
  if (Array.isArray(out.rows)) {
3079
- // User-supplied row array -- copy into pool. We could
3080
- // skip the copy if `out.rows === _tooltipRowPool` (the
3081
- // common "mutate the rows in place" pattern); detect
3082
- // that and short-circuit.
3083
- if (out.rows === _tooltipRowPool) {
3084
- // user mutated our pool directly; len = out.rows.length
3085
- // but capped at our actual pool size
3086
- rowsLen = out.rows.length;
3087
- } else {
3088
- _ensureRowPool(out.rows.length);
3089
- rowsLen = out.rows.length;
3090
- for (let i = 0; i < rowsLen; i++) {
3091
- const src = out.rows[i];
3092
- const dst = _tooltipRowPool[i];
3093
- dst.color = src.color;
3094
- dst.label = src.label;
3095
- dst.value = src.value;
3096
- }
3097
- }
3707
+ rows.length = 0;
3708
+ for (let i = 0; i < out.rows.length; i++) rows.push(out.rows[i]);
3098
3709
  }
3099
3710
  } else {
3100
3711
  headerText = defaultHeaderText;
@@ -3103,21 +3714,15 @@ const createBaseAxisChart = (config, renderer) => {
3103
3714
  headerText = defaultHeaderText;
3104
3715
  }
3105
3716
 
3106
- // Measure widths. Avoid the `label + ': ' + value` allocation by
3107
- // measuring pieces separately and summing.
3717
+ // Measure widths.
3108
3718
  ctx.font = axisStyleRefs.font.value;
3109
3719
  let maxRowWidth = ctx.measureText(headerText).width;
3110
- const colonWidth = ctx.measureText(': ').width;
3111
- for (let i = 0; i < rowsLen; i++) {
3112
- const r = _tooltipRowPool[i];
3113
- const w = swatch + 6
3114
- + ctx.measureText(r.label).width
3115
- + colonWidth
3116
- + ctx.measureText(r.value).width;
3720
+ for (let i = 0; i < rows.length; i++) {
3721
+ const w = swatch + 6 + ctx.measureText(rows[i].label + ': ' + rows[i].value).width;
3117
3722
  if (w > maxRowWidth) maxRowWidth = w;
3118
3723
  }
3119
3724
  const boxW = maxRowWidth + padding * 2;
3120
- const boxH = (rowsLen + 1) * lineHeight + padding;
3725
+ const boxH = (rows.length + 1) * lineHeight + padding;
3121
3726
 
3122
3727
  // Position: right of the crosshair, flip left if the box would clip
3123
3728
  // the right plot edge. Vertically centered on the cursor, clamped to
@@ -3144,24 +3749,13 @@ const createBaseAxisChart = (config, renderer) => {
3144
3749
  ctx.textBaseline = 'top';
3145
3750
  ctx.fillText(headerText, boxX + padding, boxY + padding);
3146
3751
 
3147
- // Rows. Two fillText calls per row -- label + colon, then value --
3148
- // so we don't allocate `label + ': ' + value` per draw. Label and
3149
- // value already exist as stable strings on the row object.
3150
- const labelColor = axisStyleRefs.labelColor.value;
3151
- for (let i = 0; i < rowsLen; i++) {
3152
- const r = _tooltipRowPool[i];
3752
+ // Rows.
3753
+ for (let i = 0; i < rows.length; i++) {
3153
3754
  const rowY = boxY + padding + (i + 1) * lineHeight;
3154
- ctx.fillStyle = r.color;
3755
+ ctx.fillStyle = rows[i].color;
3155
3756
  ctx.fillRect(boxX + padding, rowY + 2, swatch, swatch);
3156
- ctx.fillStyle = labelColor;
3157
- const labelX = boxX + padding + swatch + 6;
3158
- ctx.fillText(r.label, labelX, rowY);
3159
- // Continue with ': ' + value at the measured offset. measureText
3160
- // here is cheap (cached glyph metrics in real browsers) and
3161
- // avoids the per-row string concat.
3162
- const labelW = ctx.measureText(r.label).width;
3163
- ctx.fillText(': ', labelX + labelW, rowY);
3164
- ctx.fillText(r.value, labelX + labelW + ctx.measureText(': ').width, rowY);
3757
+ ctx.fillStyle = axisStyleRefs.labelColor.value;
3758
+ ctx.fillText(rows[i].label + ': ' + rows[i].value, boxX + padding + swatch + 6, rowY);
3165
3759
  }
3166
3760
  };
3167
3761
 
@@ -3210,24 +3804,12 @@ const createBaseAxisChart = (config, renderer) => {
3210
3804
  hideCrosshair,
3211
3805
  setSeriesVisible,
3212
3806
  refreshTheme,
3213
- get scene() {
3214
- return scene;
3215
- },
3216
- get canvas() {
3217
- return canvas;
3218
- },
3219
- get xScale() {
3220
- return xScale;
3221
- },
3222
- get yScale() {
3223
- return yScale;
3224
- },
3225
- get xScaleType() {
3226
- return resolvedXType;
3227
- },
3228
- get legend() {
3229
- return legendEl;
3230
- },
3807
+ get scene() { return scene; },
3808
+ get canvas() { return canvas; },
3809
+ get xScale() { return xScale; },
3810
+ get yScale() { return yScale; },
3811
+ get xScaleType() { return resolvedXType; },
3812
+ get legend() { return legendEl; },
3231
3813
  plotBounds: plotBoundsSignal,
3232
3814
  crosshair: crosshairFacade,
3233
3815
  seriesVisibility,
@@ -3338,7 +3920,7 @@ const extractSliceData = (state, input) => {
3338
3920
  return;
3339
3921
  }
3340
3922
 
3341
- state.values = ensureFloat32(state.values, n);
3923
+ state.values = ensureFloat32(state.values, n);
3342
3924
 
3343
3925
  // Angles need Float64 precision: Float32(PI/2) = 1.5707963705..., which
3344
3926
  // is SLIGHTLY larger than the Float64 PI/2 = 1.5707963267... that
@@ -3493,7 +4075,7 @@ const makeSliceDrawFn = (state, geometry, colorsRef, sliceStrokeRef, sliceStroke
3493
4075
  ctx.fillStyle = fill;
3494
4076
  ctx.beginPath();
3495
4077
  if (rInner > 0) {
3496
- ctx.arc(cx, cy, ro, a0, a1);
4078
+ ctx.arc(cx, cy, ro, a0, a1);
3497
4079
  ctx.arc(cx, cy, rInner, a1, a0, true);
3498
4080
  ctx.closePath();
3499
4081
  } else {
@@ -3560,7 +4142,7 @@ const buildPolarLegendDOM = (state, sliceVisibility, font, labelColor, disposers
3560
4142
  legendEl.style.color = labelColor;
3561
4143
  legendEl.style.lineHeight = '1.4';
3562
4144
  legendEl.style.alignItems = 'center';
3563
- return {legendEl, refresh: null}; // populated below; refresh defined in mount
4145
+ return { legendEl, refresh: null }; // populated below; refresh defined in mount
3564
4146
  };
3565
4147
 
3566
4148
  // Populate the legend element with one row per slice. Called after the
@@ -3637,7 +4219,7 @@ const SLICE_RENDERER = {
3637
4219
  // createBasePolarChart -- the shared scaffold for slice-based polar charts
3638
4220
  // ===========================================================================
3639
4221
 
3640
- const DEFAULT_PIE_MARGIN = {top: 16, right: 16, bottom: 16, left: 16};
4222
+ const DEFAULT_PIE_MARGIN = { top: 16, right: 16, bottom: 16, left: 16 };
3641
4223
  // Default palette for slices when the user doesn't supply per-slice colors.
3642
4224
  // Eight-slot tableau-style cycle; resolvable as raw hex (no CSS-var lookup).
3643
4225
  const DEFAULT_SLICE_PALETTE = [
@@ -3683,17 +4265,17 @@ const createBasePolarChart = (config, renderer) => {
3683
4265
  const heightSig = heightExplicit ? asAccessor(config.height) : heightAutoSig;
3684
4266
 
3685
4267
  const margin = config.margin || DEFAULT_PIE_MARGIN;
3686
- const marginTop = margin.top != null ? margin.top : DEFAULT_PIE_MARGIN.top;
3687
- const marginRight = margin.right != null ? margin.right : DEFAULT_PIE_MARGIN.right;
4268
+ const marginTop = margin.top != null ? margin.top : DEFAULT_PIE_MARGIN.top;
4269
+ const marginRight = margin.right != null ? margin.right : DEFAULT_PIE_MARGIN.right;
3688
4270
  const marginBottom = margin.bottom != null ? margin.bottom : DEFAULT_PIE_MARGIN.bottom;
3689
- const marginLeft = margin.left != null ? margin.left : DEFAULT_PIE_MARGIN.left;
4271
+ const marginLeft = margin.left != null ? margin.left : DEFAULT_PIE_MARGIN.left;
3690
4272
 
3691
4273
  const innerRadiusConfig = config.innerRadius != null ? config.innerRadius : 0;
3692
4274
 
3693
4275
  // ---- Chart state --------------------------------------------------
3694
4276
  const state = makePolarState();
3695
- const geometry = {cx: 0, cy: 0, rOuter: 0, rInner: 0};
3696
- const plotBoundsBox = {x: 0, y: 0, w: 0, h: 0};
4277
+ const geometry = { cx: 0, cy: 0, rOuter: 0, rInner: 0 };
4278
+ const plotBoundsBox = { x: 0, y: 0, w: 0, h: 0 };
3697
4279
  const plotBoundsSignal = signal(0);
3698
4280
  const dataVersion = signal(0); // bumps on data / visibility change
3699
4281
 
@@ -3715,15 +4297,15 @@ const createBasePolarChart = (config, renderer) => {
3715
4297
  };
3716
4298
 
3717
4299
  // ---- Style refs (theme-reactive) ----------------------------------
3718
- const sliceStrokeRef = {value: '#ffffff'};
3719
- const sliceStrokeWidthRef = {value: config.sliceStrokeWidth != null ? +config.sliceStrokeWidth : 1};
3720
- const labelColorRef = {value: '#444444'};
3721
- const fontRef = {value: config.font != null ? config.font : '11px sans-serif'};
3722
- const tooltipBgRef = {value: 'rgba(255,255,255,0.96)'};
3723
- const tooltipBorderRef = {value: '#cccccc'};
4300
+ const sliceStrokeRef = { value: '#ffffff' };
4301
+ const sliceStrokeWidthRef = { value: config.sliceStrokeWidth != null ? +config.sliceStrokeWidth : 1 };
4302
+ const labelColorRef = { value: '#444444' };
4303
+ const fontRef = { value: config.font != null ? config.font : '11px sans-serif' };
4304
+ const tooltipBgRef = { value: 'rgba(255,255,255,0.96)' };
4305
+ const tooltipBorderRef = { value: '#cccccc' };
3724
4306
  // Highlight: index of slice under cursor; -1 = none. Read on every
3725
4307
  // draw; mutated by mouse handler.
3726
- const highlightRef = {value: -1};
4308
+ const highlightRef = { value: -1 };
3727
4309
 
3728
4310
  // ---- Crosshair facade (highlight + tooltip) -----------------------
3729
4311
  // For polar charts the "crosshair" is just a slice highlight + tooltip.
@@ -3736,10 +4318,7 @@ const createBasePolarChart = (config, renderer) => {
3736
4318
  mousePixelY: 0,
3737
4319
  };
3738
4320
  const crosshairVersion = signal(0);
3739
- const crosshairFacade = function () {
3740
- crosshairVersion();
3741
- return crosshairData;
3742
- };
4321
+ const crosshairFacade = function () { crosshairVersion(); return crosshairData; };
3743
4322
  crosshairFacade.peek = () => crosshairData;
3744
4323
  crosshairFacade.set = (s) => {
3745
4324
  if (!s || typeof s !== 'object') return;
@@ -3832,10 +4411,10 @@ const createBasePolarChart = (config, renderer) => {
3832
4411
  canvas.height = h0;
3833
4412
 
3834
4413
  // Resolve theme colors first so the first draw is correct.
3835
- sliceStrokeRef.value = resolveColor(config.sliceStroke != null ? config.sliceStroke : '#ffffff', container);
3836
- labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
3837
- tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
3838
- tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
4414
+ sliceStrokeRef.value = resolveColor(config.sliceStroke != null ? config.sliceStroke : '#ffffff', container);
4415
+ labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
4416
+ tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
4417
+ tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
3839
4418
 
3840
4419
  const schedule = config.schedule || (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => cb());
3841
4420
  scene = createScene(canvas, {
@@ -3853,10 +4432,10 @@ const createBasePolarChart = (config, renderer) => {
3853
4432
  const h = +heightSig() | 0 || 400;
3854
4433
  const wBacking = Math.max(1, Math.round(w * resolvedDpr));
3855
4434
  const hBacking = Math.max(1, Math.round(h * resolvedDpr));
3856
- if (canvas.width !== wBacking) canvas.width = wBacking;
4435
+ if (canvas.width !== wBacking) canvas.width = wBacking;
3857
4436
  if (canvas.height !== hBacking) canvas.height = hBacking;
3858
4437
  if (typeof canvas.style !== 'undefined') {
3859
- canvas.style.width = w + 'px';
4438
+ canvas.style.width = w + 'px';
3860
4439
  canvas.style.height = h + 'px';
3861
4440
  }
3862
4441
  plotBoundsBox.x = marginLeft;
@@ -3907,7 +4486,7 @@ const createBasePolarChart = (config, renderer) => {
3907
4486
 
3908
4487
  // ---- Slice draw node -------------------------------------------
3909
4488
  const sliceDrawFn = renderer.makeDrawFn(state, geometry, resolvedColors, sliceStrokeRef, sliceStrokeWidthRef, highlightRef);
3910
- const sliceNode = scene.root.add(pathNode({draw: sliceDrawFn}));
4489
+ const sliceNode = scene.root.add(pathNode({ draw: (ctx) => sliceDrawFn(ctx) }));
3911
4490
 
3912
4491
  // Effect 4: dirty bridge for data/geometry changes
3913
4492
  disposers.push(effect(() => {
@@ -3918,7 +4497,7 @@ const createBasePolarChart = (config, renderer) => {
3918
4497
 
3919
4498
  // ---- Crosshair / tooltip (slice highlight + box) --------------
3920
4499
  if (interactionEnabled) {
3921
- const crosshairNode = scene.root.add(pathNode({draw: drawCrosshair}));
4500
+ const crosshairNode = scene.root.add(pathNode({ draw: (ctx) => drawCrosshair(ctx) }));
3922
4501
  disposers.push(effect(() => {
3923
4502
  crosshairVersion();
3924
4503
  if (scene) scene.markDirty();
@@ -3928,7 +4507,7 @@ const createBasePolarChart = (config, renderer) => {
3928
4507
  const onMove = (e) => {
3929
4508
  const rect = typeof canvas.getBoundingClientRect === 'function'
3930
4509
  ? canvas.getBoundingClientRect()
3931
- : {left: 0, top: 0, width: canvas.width, height: canvas.height};
4510
+ : { left: 0, top: 0, width: canvas.width, height: canvas.height };
3932
4511
  moveCrosshair(e.clientX - rect.left, e.clientY - rect.top);
3933
4512
  };
3934
4513
  const onLeave = () => hideCrosshair();
@@ -3967,17 +4546,11 @@ const createBasePolarChart = (config, renderer) => {
3967
4546
  const unmount = () => {
3968
4547
  if (!mounted) return;
3969
4548
  for (let i = 0; i < disposers.length; i++) {
3970
- try {
3971
- disposers[i]();
3972
- } catch (e) { /* swallow */
3973
- }
4549
+ try { disposers[i](); } catch (e) { /* swallow */ }
3974
4550
  }
3975
4551
  disposers.length = 0;
3976
4552
  if (scene) {
3977
- try {
3978
- scene.dispose();
3979
- } catch (e) { /* swallow */
3980
- }
4553
+ try { scene.dispose(); } catch (e) { /* swallow */ }
3981
4554
  scene = null;
3982
4555
  }
3983
4556
  if (legendWrapper && legendWrapper.parentNode) {
@@ -4058,10 +4631,8 @@ const createBasePolarChart = (config, renderer) => {
4058
4631
  total: state.total,
4059
4632
  percent: pct,
4060
4633
  });
4061
- if (typeof out === 'string') {
4062
- header = out;
4063
- rowText = '';
4064
- } else if (out && typeof out === 'object') {
4634
+ if (typeof out === 'string') { header = out; rowText = ''; }
4635
+ else if (out && typeof out === 'object') {
4065
4636
  if (out.header != null) header = out.header;
4066
4637
  if (out.value != null) rowText = out.value;
4067
4638
  }
@@ -4118,9 +4689,7 @@ const createBasePolarChart = (config, renderer) => {
4118
4689
  const q = opts && opts.quality != null ? opts.quality : 0.92;
4119
4690
  return canvas.toDataURL(mt, q);
4120
4691
  },
4121
- redraw: () => {
4122
- if (scene) scene.markDirty();
4123
- },
4692
+ redraw: () => { if (scene) scene.markDirty(); },
4124
4693
  moveCrosshair,
4125
4694
  hideCrosshair,
4126
4695
  setSliceVisible: (idx, visible) => {
@@ -4129,10 +4698,10 @@ const createBasePolarChart = (config, renderer) => {
4129
4698
  },
4130
4699
  refreshTheme: () => {
4131
4700
  if (!mounted) return;
4132
- sliceStrokeRef.value = resolveColor(config.sliceStroke != null ? config.sliceStroke : '#ffffff', container);
4133
- labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
4134
- tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
4135
- tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
4701
+ sliceStrokeRef.value = resolveColor(config.sliceStroke != null ? config.sliceStroke : '#ffffff', container);
4702
+ labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
4703
+ tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
4704
+ tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
4136
4705
  refreshResolvedColors();
4137
4706
  // Re-paint legend swatches
4138
4707
  if (legendEl) {
@@ -4143,18 +4712,10 @@ const createBasePolarChart = (config, renderer) => {
4143
4712
  }
4144
4713
  if (scene) scene.markDirty();
4145
4714
  },
4146
- get scene() {
4147
- return scene;
4148
- },
4149
- get canvas() {
4150
- return canvas;
4151
- },
4152
- get geometry() {
4153
- return geometry;
4154
- },
4155
- get legend() {
4156
- return legendEl;
4157
- },
4715
+ get scene() { return scene; },
4716
+ get canvas() { return canvas; },
4717
+ get geometry() { return geometry; },
4718
+ get legend() { return legendEl; },
4158
4719
  plotBounds: plotBoundsSignal,
4159
4720
  crosshair: crosshairFacade,
4160
4721
  sliceVisibility,
@@ -4274,11 +4835,18 @@ const computeRadarGeometry = (geometry, plotBoundsBox, axisCount) => {
4274
4835
  // state, all reads happen at frame time so reactivity + theme changes
4275
4836
  // propagate without recreating closures.
4276
4837
 
4277
- // (A `_radarPoint(geometry, axisIdx, value, vMin, vSpan)` helper was
4278
- // considered but never landed -- the polygon, hit-test, and tooltip paths
4279
- // all inline the same r/x/y math directly to avoid the per-call {x, y}
4280
- // allocation an out-of-line helper would force. Keeping the comment as
4281
- // a "do not reintroduce" marker.)
4838
+ // Convert a (value, axisIdx) pair to a pixel point on the chart. Domain is
4839
+ // [vMin, vMax] (shared across all axes -- MVP radar uses one domain;
4840
+ // per-axis domains can land in v1.3 with the multi-scale infrastructure).
4841
+ const _radarPoint = (geometry, axisIdx, value, vMin, vSpan) => {
4842
+ const t = vSpan > 0 ? (value - vMin) / vSpan : 0;
4843
+ const tClamp = t < 0 ? 0 : t > 1 ? 1 : t;
4844
+ const r = geometry.rOuter * tClamp;
4845
+ return {
4846
+ x: geometry.cx + r * geometry.cosA[axisIdx],
4847
+ y: geometry.cy + r * geometry.sinA[axisIdx],
4848
+ };
4849
+ };
4282
4850
 
4283
4851
  // Polygon draw: one fill (alpha) + one stroke per visible series.
4284
4852
  // All series share the same geometry + value domain.
@@ -4440,7 +5008,7 @@ const radarHitTest = (canvasX, canvasY, states, geometry, domainRef) => {
4440
5008
  // createRadarChart -- the kernel + factory in one
4441
5009
  // ===========================================================================
4442
5010
 
4443
- const DEFAULT_RADAR_MARGIN = {top: 24, right: 24, bottom: 24, left: 24};
5011
+ const DEFAULT_RADAR_MARGIN = { top: 24, right: 24, bottom: 24, left: 24 };
4444
5012
 
4445
5013
  export const createRadarChart = (config) => {
4446
5014
  if (!config || typeof config !== 'object') {
@@ -4453,7 +5021,7 @@ export const createRadarChart = (config) => {
4453
5021
  throw new Error('lite-charts: createRadarChart requires at least 3 axes');
4454
5022
  }
4455
5023
  const axisCount = axisLabelsInitial.length;
4456
- const axisLabelsRef = {value: axisLabelsInitial};
5024
+ const axisLabelsRef = { value: axisLabelsInitial };
4457
5025
 
4458
5026
  // Series input: either a function returning array (reactive) or an
4459
5027
  // array directly. Each series: { name, color, values: [N] }.
@@ -4476,10 +5044,10 @@ export const createRadarChart = (config) => {
4476
5044
  const heightSig = heightExplicit ? asAccessor(config.height) : heightAutoSig;
4477
5045
 
4478
5046
  const margin = config.margin || DEFAULT_RADAR_MARGIN;
4479
- const marginTop = margin.top != null ? margin.top : DEFAULT_RADAR_MARGIN.top;
4480
- const marginRight = margin.right != null ? margin.right : DEFAULT_RADAR_MARGIN.right;
5047
+ const marginTop = margin.top != null ? margin.top : DEFAULT_RADAR_MARGIN.top;
5048
+ const marginRight = margin.right != null ? margin.right : DEFAULT_RADAR_MARGIN.right;
4481
5049
  const marginBottom = margin.bottom != null ? margin.bottom : DEFAULT_RADAR_MARGIN.bottom;
4482
- const marginLeft = margin.left != null ? margin.left : DEFAULT_RADAR_MARGIN.left;
5050
+ const marginLeft = margin.left != null ? margin.left : DEFAULT_RADAR_MARGIN.left;
4483
5051
 
4484
5052
  const gridTicks = config.gridTicks != null ? Math.max(1, +config.gridTicks | 0) : 4;
4485
5053
 
@@ -4487,7 +5055,7 @@ export const createRadarChart = (config) => {
4487
5055
  // domainRef is a stable object reference; mutated in place by the data
4488
5056
  // effect so draw fns always see fresh values.
4489
5057
  const explicitDomain = Array.isArray(config.domain) ? [+config.domain[0], +config.domain[1]] : null;
4490
- const domainRef = {value: explicitDomain ? explicitDomain : [0, 1]};
5058
+ const domainRef = { value: explicitDomain ? explicitDomain : [0, 1] };
4491
5059
 
4492
5060
  // ---- State (per-series) ------------------------------------------
4493
5061
  const seriesStates = []; // array of RadarSeriesState
@@ -4505,19 +5073,19 @@ export const createRadarChart = (config) => {
4505
5073
  cosA: new Float64Array(Math.max(axisCount, 4)),
4506
5074
  sinA: new Float64Array(Math.max(axisCount, 4)),
4507
5075
  };
4508
- const plotBoundsBox = {x: 0, y: 0, w: 0, h: 0};
5076
+ const plotBoundsBox = { x: 0, y: 0, w: 0, h: 0 };
4509
5077
  const plotBoundsSignal = signal(0);
4510
5078
  const dataVersion = signal(0);
4511
5079
 
4512
5080
  // ---- Style refs --------------------------------------------------
4513
- const axisColorRef = {value: '#cccccc'};
4514
- const gridColorRef = {value: '#e6e6e6'};
4515
- const labelColorRef = {value: '#444444'};
4516
- const fontRef = {value: config.font != null ? config.font : '11px sans-serif'};
4517
- const fillOpacityRef = {value: config.fillOpacity != null ? +config.fillOpacity : 0.2};
4518
- const strokeWidthRef = {value: config.strokeWidth != null ? +config.strokeWidth : 2};
4519
- const tooltipBgRef = {value: 'rgba(255,255,255,0.96)'};
4520
- const tooltipBorderRef = {value: '#cccccc'};
5081
+ const axisColorRef = { value: '#cccccc' };
5082
+ const gridColorRef = { value: '#e6e6e6' };
5083
+ const labelColorRef = { value: '#444444' };
5084
+ const fontRef = { value: config.font != null ? config.font : '11px sans-serif' };
5085
+ const fillOpacityRef = { value: config.fillOpacity != null ? +config.fillOpacity : 0.2 };
5086
+ const strokeWidthRef = { value: config.strokeWidth != null ? +config.strokeWidth : 2 };
5087
+ const tooltipBgRef = { value: 'rgba(255,255,255,0.96)' };
5088
+ const tooltipBorderRef = { value: '#cccccc' };
4521
5089
 
4522
5090
  // ---- Crosshair facade --------------------------------------------
4523
5091
  const crosshairData = {
@@ -4529,10 +5097,7 @@ export const createRadarChart = (config) => {
4529
5097
  mousePixelY: 0,
4530
5098
  };
4531
5099
  const crosshairVersion = signal(0);
4532
- const crosshairFacade = function () {
4533
- crosshairVersion();
4534
- return crosshairData;
4535
- };
5100
+ const crosshairFacade = function () { crosshairVersion(); return crosshairData; };
4536
5101
  crosshairFacade.peek = () => crosshairData;
4537
5102
  crosshairFacade.subscribe = (cb) => crosshairVersion.subscribe(() => cb(crosshairData));
4538
5103
 
@@ -4605,11 +5170,11 @@ export const createRadarChart = (config) => {
4605
5170
  canvas.width = w0;
4606
5171
  canvas.height = h0;
4607
5172
 
4608
- axisColorRef.value = resolveColor(config.axisColor != null ? config.axisColor : '#cccccc', container);
4609
- gridColorRef.value = resolveColor(config.gridColor != null ? config.gridColor : '#e6e6e6', container);
4610
- labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
4611
- tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
4612
- tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
5173
+ axisColorRef.value = resolveColor(config.axisColor != null ? config.axisColor : '#cccccc', container);
5174
+ gridColorRef.value = resolveColor(config.gridColor != null ? config.gridColor : '#e6e6e6', container);
5175
+ labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
5176
+ tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
5177
+ tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
4613
5178
 
4614
5179
  const schedule = config.schedule || (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => cb());
4615
5180
  scene = createScene(canvas, {
@@ -4626,10 +5191,10 @@ export const createRadarChart = (config) => {
4626
5191
  const h = +heightSig() | 0 || 400;
4627
5192
  const wBacking = Math.max(1, Math.round(w * resolvedDpr));
4628
5193
  const hBacking = Math.max(1, Math.round(h * resolvedDpr));
4629
- if (canvas.width !== wBacking) canvas.width = wBacking;
5194
+ if (canvas.width !== wBacking) canvas.width = wBacking;
4630
5195
  if (canvas.height !== hBacking) canvas.height = hBacking;
4631
5196
  if (typeof canvas.style !== 'undefined') {
4632
- canvas.style.width = w + 'px';
5197
+ canvas.style.width = w + 'px';
4633
5198
  canvas.style.height = h + 'px';
4634
5199
  }
4635
5200
  plotBoundsBox.x = marginLeft;
@@ -4672,10 +5237,7 @@ export const createRadarChart = (config) => {
4672
5237
  anyData = true;
4673
5238
  }
4674
5239
  }
4675
- if (!anyData) {
4676
- vMin = 0;
4677
- vMax = 1;
4678
- }
5240
+ if (!anyData) { vMin = 0; vMax = 1; }
4679
5241
  if (vMin === vMax) vMax = vMin + 1;
4680
5242
  // Anchor at 0 if everything's non-negative (the conventional radar look).
4681
5243
  if (vMin > 0 && vMin / vMax < 0.5) vMin = 0;
@@ -4700,13 +5262,13 @@ export const createRadarChart = (config) => {
4700
5262
  }));
4701
5263
 
4702
5264
  // Scene nodes (z-ordered): grid -> polygons -> spokes+labels -> crosshair
4703
- const gridDrawFn = makeRadarGridDrawFn(geometry, gridTicks, gridColorRef);
4704
- const polygonDrawFn = makeRadarPolygonDrawFn(seriesStates, resolvedColors, geometry, domainRef, fillOpacityRef, strokeWidthRef);
4705
- const spokesDrawFn = makeRadarSpokesDrawFn(geometry, axisLabelsRef, axisColorRef, labelColorRef, fontRef);
5265
+ const gridDrawFn = makeRadarGridDrawFn(geometry, gridTicks, gridColorRef);
5266
+ const polygonDrawFn = makeRadarPolygonDrawFn(seriesStates, resolvedColors, geometry, domainRef, fillOpacityRef, strokeWidthRef);
5267
+ const spokesDrawFn = makeRadarSpokesDrawFn(geometry, axisLabelsRef, axisColorRef, labelColorRef, fontRef);
4706
5268
 
4707
- scene.root.add(pathNode({draw: gridDrawFn}));
4708
- scene.root.add(pathNode({draw: polygonDrawFn}));
4709
- scene.root.add(pathNode({draw: spokesDrawFn}));
5269
+ scene.root.add(pathNode({ draw: (ctx) => gridDrawFn(ctx) }));
5270
+ scene.root.add(pathNode({ draw: (ctx) => polygonDrawFn(ctx) }));
5271
+ scene.root.add(pathNode({ draw: (ctx) => spokesDrawFn(ctx) }));
4710
5272
 
4711
5273
  // Dirty bridge: data + plotBounds -> markDirty
4712
5274
  disposers.push(effect(() => {
@@ -4717,7 +5279,7 @@ export const createRadarChart = (config) => {
4717
5279
 
4718
5280
  // Crosshair / tooltip
4719
5281
  if (interactionEnabled) {
4720
- scene.root.add(pathNode({draw: drawCrosshair}));
5282
+ scene.root.add(pathNode({ draw: (ctx) => drawCrosshair(ctx) }));
4721
5283
  disposers.push(effect(() => {
4722
5284
  crosshairVersion();
4723
5285
  if (scene) scene.markDirty();
@@ -4726,7 +5288,7 @@ export const createRadarChart = (config) => {
4726
5288
  const onMove = (e) => {
4727
5289
  const rect = typeof canvas.getBoundingClientRect === 'function'
4728
5290
  ? canvas.getBoundingClientRect()
4729
- : {left: 0, top: 0, width: canvas.width, height: canvas.height};
5291
+ : { left: 0, top: 0, width: canvas.width, height: canvas.height };
4730
5292
  moveCrosshair(e.clientX - rect.left, e.clientY - rect.top);
4731
5293
  };
4732
5294
  const onLeave = () => hideCrosshair();
@@ -4814,17 +5376,11 @@ export const createRadarChart = (config) => {
4814
5376
  const unmount = () => {
4815
5377
  if (!mounted) return;
4816
5378
  for (let i = 0; i < disposers.length; i++) {
4817
- try {
4818
- disposers[i]();
4819
- } catch (e) { /* swallow */
4820
- }
5379
+ try { disposers[i](); } catch (e) { /* swallow */ }
4821
5380
  }
4822
5381
  disposers.length = 0;
4823
5382
  if (scene) {
4824
- try {
4825
- scene.dispose();
4826
- } catch (e) { /* swallow */
4827
- }
5383
+ try { scene.dispose(); } catch (e) { /* swallow */ }
4828
5384
  scene = null;
4829
5385
  }
4830
5386
  if (legendWrapper && legendWrapper.parentNode) {
@@ -4845,10 +5401,7 @@ export const createRadarChart = (config) => {
4845
5401
  const moveCrosshair = (canvasX, canvasY) => {
4846
5402
  if (!interactionEnabled || !mounted) return;
4847
5403
  const hit = radarHitTest(canvasX, canvasY, seriesStates, geometry, domainRef);
4848
- if (!hit) {
4849
- hideCrosshair();
4850
- return;
4851
- }
5404
+ if (!hit) { hideCrosshair(); return; }
4852
5405
  if (crosshairData.visible
4853
5406
  && crosshairData.seriesIdx === hit.seriesIdx
4854
5407
  && crosshairData.axisIdx === hit.axisIdx
@@ -4907,15 +5460,10 @@ export const createRadarChart = (config) => {
4907
5460
  value: crosshairData.value,
4908
5461
  rows,
4909
5462
  });
4910
- if (typeof out === 'string') {
4911
- header = out;
4912
- rows.length = 0;
4913
- } else if (out && typeof out === 'object') {
5463
+ if (typeof out === 'string') { header = out; rows.length = 0; }
5464
+ else if (out && typeof out === 'object') {
4914
5465
  if (out.header != null) header = out.header;
4915
- if (Array.isArray(out.rows)) {
4916
- rows.length = 0;
4917
- for (let i = 0; i < out.rows.length; i++) rows.push(out.rows[i]);
4918
- }
5466
+ if (Array.isArray(out.rows)) { rows.length = 0; for (let i = 0; i < out.rows.length; i++) rows.push(out.rows[i]); }
4919
5467
  }
4920
5468
  }
4921
5469
 
@@ -4974,9 +5522,7 @@ export const createRadarChart = (config) => {
4974
5522
  const q = opts && opts.quality != null ? opts.quality : 0.92;
4975
5523
  return canvas.toDataURL(mt, q);
4976
5524
  },
4977
- redraw: () => {
4978
- if (scene) scene.markDirty();
4979
- },
5525
+ redraw: () => { if (scene) scene.markDirty(); },
4980
5526
  moveCrosshair,
4981
5527
  hideCrosshair,
4982
5528
  setSeriesVisible: (idx, visible) => {
@@ -4985,30 +5531,20 @@ export const createRadarChart = (config) => {
4985
5531
  },
4986
5532
  refreshTheme: () => {
4987
5533
  if (!mounted) return;
4988
- axisColorRef.value = resolveColor(config.axisColor != null ? config.axisColor : '#cccccc', container);
4989
- gridColorRef.value = resolveColor(config.gridColor != null ? config.gridColor : '#e6e6e6', container);
4990
- labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
4991
- tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
4992
- tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
5534
+ axisColorRef.value = resolveColor(config.axisColor != null ? config.axisColor : '#cccccc', container);
5535
+ gridColorRef.value = resolveColor(config.gridColor != null ? config.gridColor : '#e6e6e6', container);
5536
+ labelColorRef.value = resolveColor(config.labelColor != null ? config.labelColor : '#444444', container);
5537
+ tooltipBgRef.value = resolveColor(tooltipOpts && tooltipOpts.background ? tooltipOpts.background : 'rgba(255,255,255,0.96)', container);
5538
+ tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border ? tooltipOpts.border : '#cccccc', container);
4993
5539
  refreshResolvedColors();
4994
5540
  if (legendEl) populateRadarLegend();
4995
5541
  if (scene) scene.markDirty();
4996
5542
  },
4997
- get scene() {
4998
- return scene;
4999
- },
5000
- get canvas() {
5001
- return canvas;
5002
- },
5003
- get geometry() {
5004
- return geometry;
5005
- },
5006
- get domain() {
5007
- return domainRef.value.slice();
5008
- },
5009
- get legend() {
5010
- return legendEl;
5011
- },
5543
+ get scene() { return scene; },
5544
+ get canvas() { return canvas; },
5545
+ get geometry() { return geometry; },
5546
+ get domain() { return domainRef.value.slice(); },
5547
+ get legend() { return legendEl; },
5012
5548
  plotBounds: plotBoundsSignal,
5013
5549
  crosshair: crosshairFacade,
5014
5550
  seriesVisibility,
@@ -5068,10 +5604,45 @@ export const _testHelpers = {
5068
5604
  makeRadarSeriesState,
5069
5605
  };
5070
5606
 
5607
+ /**
5608
+ * Create a line chart. Reactive in `data`, `width`, `height`, `series`;
5609
+ * driven by signal-native data + axis kernel + decimation hot path.
5610
+ *
5611
+ * Steady-state `chart.redraw()` is allocation-free (~0 B/call measured).
5612
+ * For >2k visible points the renderer switches to a per-column min/max
5613
+ * decimation pass that scales sub-linearly with N; under 2k it draws
5614
+ * direct polylines.
5615
+ *
5616
+ * @param {import("./Charts.d.ts").LineChartConfig} config
5617
+ * @returns {import("./Charts.d.ts").Chart}
5618
+ * @throws {TypeError} If `config.canvas` is missing and no `mount(host)` is called.
5619
+ *
5620
+ * @example
5621
+ * const data = signal({ xs: [0,1,2,3,4], ys: [10,20,15,25,30] });
5622
+ * const chart = createLineChart({ canvas, data, width: 800, height: 400 });
5623
+ * data.set({ xs, ys: newYs }); // triggers a re-extraction + decimation + redraw
5624
+ * chart.unmount();
5625
+ */
5071
5626
  export const createLineChart = (config) => createBaseAxisChart(config, LINE_RENDERER);
5072
5627
 
5628
+ /**
5629
+ * Create an area chart. Same kernel as the line chart; renders the area
5630
+ * below each series as a filled path plus the upper stroke. `baseline` may
5631
+ * be a numeric Y value or `"bottom"`.
5632
+ *
5633
+ * @param {import("./Charts.d.ts").AreaChartConfig} config
5634
+ * @returns {import("./Charts.d.ts").Chart}
5635
+ */
5073
5636
  export const createAreaChart = (config) => createBaseAxisChart(config, AREA_RENDERER);
5074
5637
 
5638
+ /**
5639
+ * Create a bar chart. Single or grouped multi-series with a band X scale;
5640
+ * supports stacked layout, rounded corners (via `roundRect` with `arcTo`
5641
+ * fallback for older Canvas2D), and per-bar hover tint.
5642
+ *
5643
+ * @param {import("./Charts.d.ts").BarChartConfig} config
5644
+ * @returns {import("./Charts.d.ts").Chart}
5645
+ */
5075
5646
  export const createBarChart = (config) => createBaseAxisChart(config, BAR_RENDERER);
5076
5647
 
5077
5648
  // Bubble lives on the axis kernel via BUBBLE_RENDERER. Each point gets a
@@ -5080,6 +5651,15 @@ export const createBarChart = (config) => createBaseAxisChart(config, BAR_RENDER
5080
5651
  // the same as line/area/bar: importing only `createBubbleChart` drops the
5081
5652
  // polar kernel entirely and the line/area/bar renderers as expected.
5082
5653
 
5654
+ /**
5655
+ * Create a bubble chart. Each point is a circle whose area encodes a
5656
+ * third dimension by default (Tukey-style sqrt scale, switchable to
5657
+ * linear). Multi-series supported; hit-test uses a spatial index when the
5658
+ * point count crosses an internal threshold.
5659
+ *
5660
+ * @param {import("./Charts.d.ts").BubbleChartConfig} config
5661
+ * @returns {import("./Charts.d.ts").Chart}
5662
+ */
5083
5663
  export const createBubbleChart = (config) => createBaseAxisChart(config, BUBBLE_RENDERER);
5084
5664
 
5085
5665
  // Polar slice charts -- pie and donut share the SLICE_RENDERER. The only
@@ -5088,36 +5668,627 @@ export const createBubbleChart = (config) => createBaseAxisChart(config, BUBBLE_
5088
5668
  // Both go through createBasePolarChart (a completely separate kernel from
5089
5669
  // createBaseAxisChart -- importing only createPieChart drops all axis-chart
5090
5670
  // code: xScale/yScale/axes/grid/decimation/bisect/interp/bar helpers).
5671
+
5672
+ /**
5673
+ * Create a pie chart. Renders one slice per data point; hit-test uses an
5674
+ * `atan2` lookup. Lives on the polar kernel (`createBasePolarChart`) --
5675
+ * tree-shaking `createPieChart` alone drops all axis-chart code paths
5676
+ * (xScale/yScale/axes/grid/decimation/bisect/interp/bar helpers).
5677
+ *
5678
+ * @param {import("./Charts.d.ts").PieChartConfig} config
5679
+ * @returns {import("./Charts.d.ts").PolarChart}
5680
+ */
5681
+ export const createPieChart = (config) =>
5682
+ createBasePolarChart({ innerRadius: 0, ...(config || {}) }, SLICE_RENDERER);
5683
+
5684
+ /**
5685
+ * Create a donut chart. Same renderer as the pie chart with a default
5686
+ * `innerRadius` of 0.5; pass any value in [0, 1) to override (still in
5687
+ * the pie-chart factory's overridable space).
5688
+ *
5689
+ * @param {import("./Charts.d.ts").DonutChartConfig} config
5690
+ * @returns {import("./Charts.d.ts").PolarChart}
5691
+ */
5692
+ export const createDonutChart = (config) =>
5693
+ createBasePolarChart({ innerRadius: 0.5, ...(config || {}) }, SLICE_RENDERER);
5694
+
5695
+ /**
5696
+ * Create a scatter chart. Bubble's simpler sibling on the axis kernel:
5697
+ * same data + projection + hit-test path, constant marker size, no third
5698
+ * dimension. Spatial index kicks in at the same N threshold as bubble.
5699
+ *
5700
+ * @param {import("./Charts.d.ts").ScatterChartConfig} config
5701
+ * @returns {import("./Charts.d.ts").Chart}
5702
+ */
5703
+ export const createScatterChart = (config) => createBaseAxisChart(config, SCATTER_RENDERER);
5704
+
5705
+ // ===========================================================================
5706
+ // createBaseGridChart -- 2D categorical grid kernel (v1.2.0-alpha.3)
5707
+ // ===========================================================================
5708
+ //
5709
+ // A FOURTH independent kernel. Used by heatmap; future grid-shaped charts
5710
+ // (correlation matrix, calendar heatmap, dot-matrix) would ride here too.
5711
+ // Stays strictly separate from the axis kernel (no x-axis ticks, no
5712
+ // numeric y-scale, no decimation, no markers, no series in the line/bar
5713
+ // sense) and from the polar/radar kernels.
5091
5714
  //
5092
- // `_applyInnerRadiusDefault` avoids the `{innerRadius, ...(config || {})}`
5093
- // spread the factory used previously -- "no spread in source" hygiene.
5715
+ // The data model is a 2D grid of cells indexed by (x-category, y-category).
5716
+ // Each cell has at most one value; sparse data is supported (missing cells
5717
+ // render as empty space, the hit-test returns null for them).
5718
+ //
5719
+ // Key design points:
5720
+ // - Two band scales (x + y) share the math from `makeBandScale`. The
5721
+ // y band scale uses pixel coords with the same +y-down convention,
5722
+ // so leftEdge(0) is the TOPMOST cell.
5723
+ // - Cells are stored as a flat Float32Array indexed `yIdx * nx + xIdx`,
5724
+ // matched by a Uint8Array `presentMask` for sparse data.
5725
+ // - Per-cell colors are precomputed at extract time into a string Array
5726
+ // (`state.cellColors`), so the draw loop is just
5727
+ // `fillStyle = cellColors[i]; fillRect(...)` -- zero alloc per cell.
5728
+ // - Hit-test is O(1) (one xBand.invert + one yBand.invert + mask check).
5729
+ // - The default color ramp linearly interpolates between two endpoint
5730
+ // hex colors at extract time; `colorFn` overrides for custom (OKLCH,
5731
+ // quantile, diverging) mappings.
5732
+
5733
+ const _DEFAULT_GRID_MARGIN = { top: 20, right: 24, bottom: 56, left: 80 };
5734
+
5735
+ // Parse '#rgb' or '#rrggbb'. Returns [r, g, b] or null.
5736
+ const _parseHexColor = (hex) => {
5737
+ if (typeof hex !== 'string' || hex.length === 0 || hex.charCodeAt(0) !== 35) return null;
5738
+ let r, g, b;
5739
+ if (hex.length === 7) {
5740
+ r = parseInt(hex.slice(1, 3), 16);
5741
+ g = parseInt(hex.slice(3, 5), 16);
5742
+ b = parseInt(hex.slice(5, 7), 16);
5743
+ } else if (hex.length === 4) {
5744
+ r = parseInt(hex.charAt(1) + hex.charAt(1), 16);
5745
+ g = parseInt(hex.charAt(2) + hex.charAt(2), 16);
5746
+ b = parseInt(hex.charAt(3) + hex.charAt(3), 16);
5747
+ } else {
5748
+ return null;
5749
+ }
5750
+ if (r !== r || g !== g || b !== b) return null; // NaN guards
5751
+ return [r, g, b];
5752
+ };
5753
+
5754
+ // Linear RGB interp -> 'rgb(r,g,b)' string. `lo` / `hi` are [r,g,b] arrays.
5755
+ // Allocates a string per call; called at extract time only (not in the
5756
+ // per-frame draw loop), so this is acceptable.
5757
+ const _lerpRGBString = (lo, hi, t) => {
5758
+ const r = (lo[0] + t * (hi[0] - lo[0])) | 0;
5759
+ const g = (lo[1] + t * (hi[1] - lo[1])) | 0;
5760
+ const b = (lo[2] + t * (hi[2] - lo[2])) | 0;
5761
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
5762
+ };
5763
+
5764
+ const _makeGridState = () => ({
5765
+ cells: null, // Float32Array | null; length = nx * ny
5766
+ presentMask: null, // Uint8Array | null
5767
+ cellColors: null, // Array<string|null> | null
5768
+ xCategories: [], // string[]
5769
+ yCategories: [], // string[]
5770
+ nx: 0,
5771
+ ny: 0,
5772
+ vMin: 0,
5773
+ vMax: 1,
5774
+ });
5775
+
5776
+ const _extractGridData = (state, data, opts) => {
5777
+ if (!Array.isArray(data) || data.length === 0) {
5778
+ state.cells = null;
5779
+ state.presentMask = null;
5780
+ state.cellColors = null;
5781
+ state.xCategories = [];
5782
+ state.yCategories = [];
5783
+ state.nx = 0;
5784
+ state.ny = 0;
5785
+ state.vMin = 0;
5786
+ state.vMax = 1;
5787
+ return;
5788
+ }
5094
5789
 
5095
- const _applyInnerRadiusDefault = (config, fallback) => {
5096
- if (config == null || typeof config !== 'object') {
5097
- return {innerRadius: fallback};
5790
+ const xAcc = opts.xAccessor;
5791
+ const yAcc = opts.yAccessor;
5792
+ const vAcc = opts.valueAccessor;
5793
+
5794
+ // Pass 1: collect unique categories in first-seen order.
5795
+ const xCats = [];
5796
+ const yCats = [];
5797
+ const xMap = new Map();
5798
+ const yMap = new Map();
5799
+ for (let i = 0; i < data.length; i++) {
5800
+ const row = data[i];
5801
+ const xv = String(xAcc(row, i));
5802
+ const yv = String(yAcc(row, i));
5803
+ if (!xMap.has(xv)) { xMap.set(xv, xCats.length); xCats.push(xv); }
5804
+ if (!yMap.has(yv)) { yMap.set(yv, yCats.length); yCats.push(yv); }
5098
5805
  }
5099
- if (config.innerRadius != null) return config;
5100
- // Shallow-copy + set default. Done once at chart construction (not hot).
5101
- const out = {innerRadius: fallback};
5102
- for (const k in config) {
5103
- if (Object.prototype.hasOwnProperty.call(config, k)) out[k] = config[k];
5806
+ const nx = xCats.length;
5807
+ const ny = yCats.length;
5808
+ const total = nx * ny;
5809
+
5810
+ state.xCategories = xCats;
5811
+ state.yCategories = yCats;
5812
+ state.nx = nx;
5813
+ state.ny = ny;
5814
+
5815
+ // Grow buffers if needed (typed arrays don't shrink; that's fine -- the
5816
+ // extra capacity is reusable on the next extract if the grid shrinks).
5817
+ if (!state.cells || state.cells.length < total) state.cells = new Float32Array(total);
5818
+ if (!state.presentMask || state.presentMask.length < total) state.presentMask = new Uint8Array(total);
5819
+
5820
+ // Zero the active region (presentMask = 0 means "missing cell").
5821
+ for (let i = 0; i < total; i++) {
5822
+ state.cells[i] = 0;
5823
+ state.presentMask[i] = 0;
5104
5824
  }
5105
- return out;
5825
+
5826
+ // Pass 2: fill cells, track value extents.
5827
+ let vMin = Infinity;
5828
+ let vMax = -Infinity;
5829
+ for (let i = 0; i < data.length; i++) {
5830
+ const row = data[i];
5831
+ const xv = String(xAcc(row, i));
5832
+ const yv = String(yAcc(row, i));
5833
+ const v = +vAcc(row, i);
5834
+ if (v !== v) continue; // NaN -> skip; cell stays "missing"
5835
+ const xIdx = xMap.get(xv);
5836
+ const yIdx = yMap.get(yv);
5837
+ const cellIdx = yIdx * nx + xIdx;
5838
+ state.cells[cellIdx] = v;
5839
+ state.presentMask[cellIdx] = 1;
5840
+ if (v < vMin) vMin = v;
5841
+ if (v > vMax) vMax = v;
5842
+ }
5843
+ state.vMin = vMin === Infinity ? 0 : vMin;
5844
+ state.vMax = vMax === -Infinity ? 1 : vMax;
5106
5845
  };
5107
5846
 
5108
- export const createPieChart = (config) =>
5109
- createBasePolarChart(_applyInnerRadiusDefault(config, 0), SLICE_RENDERER);
5847
+ // Pre-compute the per-cell color string. Called once per extract; the
5848
+ // per-frame draw loop reads from state.cellColors[i] without allocating.
5849
+ const _computeGridColors = (state, opts) => {
5850
+ const total = state.nx * state.ny;
5851
+ if (total === 0) { state.cellColors = null; return; }
5852
+ if (!state.cellColors || state.cellColors.length < total) state.cellColors = new Array(total);
5110
5853
 
5111
- export const createDonutChart = (config) =>
5112
- createBasePolarChart(_applyInnerRadiusDefault(config, 0.5), SLICE_RENDERER);
5854
+ const vMin = state.vMin;
5855
+ const vMax = state.vMax;
5856
+ const span = vMax - vMin;
5857
+ const colorFn = opts.colorFn;
5113
5858
 
5114
- // Stubs for the remaining chart types -- implemented in subsequent sessions.
5115
- // Each will be a thin composition over a base kernel:
5116
- // createHeatmap = createBaseGridChart(config, HEATMAP_RENDERER)
5859
+ if (colorFn) {
5860
+ for (let i = 0; i < total; i++) {
5861
+ state.cellColors[i] = state.presentMask[i] ? colorFn(state.cells[i], vMin, vMax) : null;
5862
+ }
5863
+ return;
5864
+ }
5117
5865
 
5118
- const _notYet = (name) => () => {
5119
- throw new Error('lite-charts: ' + name + ' is not in this version; see roadmap');
5866
+ // Default: linear RGB interp between opts.colorLow and opts.colorHigh.
5867
+ // Fall back to a safe blue ramp if hex parsing fails (CSS-vars, named
5868
+ // colors, oklch() etc. would otherwise produce NaN channels).
5869
+ const lo = _parseHexColor(opts.colorLow) || [219, 234, 254]; // blue-100
5870
+ const hi = _parseHexColor(opts.colorHigh) || [30, 58, 138]; // blue-900
5871
+ for (let i = 0; i < total; i++) {
5872
+ if (!state.presentMask[i]) { state.cellColors[i] = null; continue; }
5873
+ const t = span > 0 ? (state.cells[i] - vMin) / span : 0;
5874
+ state.cellColors[i] = _lerpRGBString(lo, hi, t);
5875
+ }
5120
5876
  };
5121
5877
 
5122
- export const createScatterChart = _notYet('createScatterChart');
5123
- export const createHeatmap = _notYet('createHeatmap');
5878
+ const _makeGridDrawFn = (state, xBand, yBand, opts) => (ctx) => {
5879
+ const nx = state.nx;
5880
+ const ny = state.ny;
5881
+ if (nx === 0 || ny === 0 || !state.cells || !state.cellColors) return;
5882
+
5883
+ const cellW = xBand.bandWidth;
5884
+ const cellH = yBand.bandWidth;
5885
+ const present = state.presentMask;
5886
+ const colors = state.cellColors;
5887
+
5888
+ // Cells.
5889
+ for (let yi = 0; yi < ny; yi++) {
5890
+ const cy = yBand.leftEdge(yi);
5891
+ for (let xi = 0; xi < nx; xi++) {
5892
+ const idx = yi * nx + xi;
5893
+ if (!present[idx]) continue;
5894
+ ctx.fillStyle = colors[idx];
5895
+ ctx.fillRect(xBand.leftEdge(xi), cy, cellW, cellH);
5896
+ }
5897
+ }
5898
+
5899
+ // Optional value labels. Drawn after all cells so labels sit on top.
5900
+ if (opts.showValues) {
5901
+ const cells = state.cells;
5902
+ const fmt = opts.valueFormat || ((v) => v.toFixed(1));
5903
+ ctx.font = opts.valueLabelFont;
5904
+ ctx.textAlign = 'center';
5905
+ ctx.textBaseline = 'middle';
5906
+ ctx.fillStyle = opts.valueLabelColor;
5907
+ for (let yi = 0; yi < ny; yi++) {
5908
+ const cy = yBand.map(yi);
5909
+ for (let xi = 0; xi < nx; xi++) {
5910
+ const idx = yi * nx + xi;
5911
+ if (!present[idx]) continue;
5912
+ ctx.fillText(fmt(cells[idx], xi, yi), xBand.map(xi), cy);
5913
+ }
5914
+ }
5915
+ }
5916
+ };
5917
+
5918
+ const _gridHitTest = (canvasX, canvasY, xBand, yBand, state, pb) => {
5919
+ if (state.nx === 0 || state.ny === 0) return null;
5920
+ if (canvasX < pb.x || canvasX > pb.x + pb.w) return null;
5921
+ if (canvasY < pb.y || canvasY > pb.y + pb.h) return null;
5922
+ const xi = xBand.invert(canvasX);
5923
+ const yi = yBand.invert(canvasY);
5924
+ if (xi < 0 || yi < 0 || xi >= state.nx || yi >= state.ny) return null;
5925
+ const idx = yi * state.nx + xi;
5926
+ if (!state.presentMask[idx]) return null;
5927
+ return { xi, yi, value: state.cells[idx] };
5928
+ };
5929
+
5930
+ // ---- HEATMAP_RENDERER ----------------------------------------------------
5931
+
5932
+ const _initHeatmapOpts = (config) => {
5933
+ const xKey = config.x != null ? config.x : 'x';
5934
+ const yKey = config.y != null ? config.y : 'y';
5935
+ const valueKey = config.value != null ? config.value : 'value';
5936
+ const colors = Array.isArray(config.colors) ? config.colors : null;
5937
+ return {
5938
+ xAccessor: buildRawAccessor(xKey),
5939
+ yAccessor: buildRawAccessor(yKey),
5940
+ valueAccessor: buildAccessor(valueKey),
5941
+ // Default ramp: pale-to-dark blue. Override via `colors: ['#low', '#high']`.
5942
+ colorLow: colors && colors[0] ? colors[0] : '#dbeafe',
5943
+ colorHigh: colors && colors[1] ? colors[1] : '#1e3a8a',
5944
+ // colorFn(v, vMin, vMax) -> 'css color'. Overrides the linear-interp
5945
+ // default entirely; use this for OKLCH ramps, quantile binning,
5946
+ // diverging schemes, etc.
5947
+ colorFn: typeof config.colorFn === 'function' ? config.colorFn : null,
5948
+ showValues: config.showValues === true,
5949
+ valueFormat: typeof config.valueFormat === 'function' ? config.valueFormat : null,
5950
+ valueLabelFont: config.valueLabelFont != null ? config.valueLabelFont : '11px sans-serif',
5951
+ valueLabelColor: config.valueLabelColor != null ? config.valueLabelColor : '#ffffff',
5952
+ cellGap: config.cellGap != null ? Math.max(0, Math.min(0.5, +config.cellGap)) : 0.04,
5953
+ highlightStroke: config.highlightStroke != null ? config.highlightStroke : '#111111',
5954
+ highlightStrokeWidth: config.highlightStrokeWidth != null ? +config.highlightStrokeWidth : 2,
5955
+ tooltipFormat: typeof config.tooltipFormat === 'function' ? config.tooltipFormat : null,
5956
+ labelColor: config.labelColor != null ? config.labelColor : '#444444',
5957
+ labelFont: config.labelFont != null ? config.labelFont : '12px sans-serif',
5958
+ };
5959
+ };
5960
+
5961
+ const HEATMAP_RENDERER = {
5962
+ initOpts: _initHeatmapOpts,
5963
+ extractData: _extractGridData,
5964
+ computeColors: _computeGridColors,
5965
+ makeDrawFn: _makeGridDrawFn,
5966
+ hitTest: _gridHitTest,
5967
+ };
5968
+
5969
+ // ---- createBaseGridChart ------------------------------------------------
5970
+
5971
+ const createBaseGridChart = (config, renderer) => {
5972
+ if (!config || typeof config !== 'object') {
5973
+ throw new Error('lite-charts: createHeatmap requires a config object');
5974
+ }
5975
+
5976
+ // -- Reactive data source --
5977
+ let dataSource;
5978
+ if (typeof config.data === 'function') {
5979
+ dataSource = config.data;
5980
+ } else if (Array.isArray(config.data)) {
5981
+ const arr = config.data;
5982
+ dataSource = () => arr;
5983
+ } else {
5984
+ throw new Error('lite-charts: createHeatmap requires `data` array or accessor function');
5985
+ }
5986
+
5987
+ // -- Dimensions: explicit or auto-observed at mount --
5988
+ const widthExplicit = config.width != null;
5989
+ const heightExplicit = config.height != null;
5990
+ const widthAutoSig = widthExplicit ? null : signal(600);
5991
+ const heightAutoSig = heightExplicit ? null : signal(400);
5992
+ const widthSig = widthExplicit ? asAccessor(config.width) : widthAutoSig;
5993
+ const heightSig = heightExplicit ? asAccessor(config.height) : heightAutoSig;
5994
+
5995
+ // -- Margins --
5996
+ const m = config.margin || _DEFAULT_GRID_MARGIN;
5997
+ const marginTop = m.top != null ? m.top : _DEFAULT_GRID_MARGIN.top;
5998
+ const marginRight = m.right != null ? m.right : _DEFAULT_GRID_MARGIN.right;
5999
+ const marginBottom = m.bottom != null ? m.bottom : _DEFAULT_GRID_MARGIN.bottom;
6000
+ const marginLeft = m.left != null ? m.left : _DEFAULT_GRID_MARGIN.left;
6001
+
6002
+ // -- State + scales + opts --
6003
+ const state = _makeGridState();
6004
+ const opts = renderer.initOpts(config);
6005
+ const xBand = makeBandScale();
6006
+ const yBand = makeBandScale();
6007
+ const plotBoundsBox = { x: 0, y: 0, w: 0, h: 0 };
6008
+ const plotBoundsSignal = signal(0);
6009
+
6010
+ // -- Crosshair / hover state --
6011
+ const hoverData = { visible: false, xi: -1, yi: -1, value: 0, mouseX: 0, mouseY: 0 };
6012
+ const hoverVersion = signal(0);
6013
+
6014
+ // -- Mount-time resources --
6015
+ let canvas = null;
6016
+ let container = null;
6017
+ let ownedCanvas = false;
6018
+ let scene = null;
6019
+ const disposers = [];
6020
+ let mounted = false;
6021
+
6022
+ // -- Chart object (built before mount; some fields populated then) --
6023
+ const chart = {
6024
+ mount: null, // assigned below
6025
+ unmount: null,
6026
+ redraw: () => { if (scene) scene.markDirty(); },
6027
+ // Read-only state introspection for tests / debug. _internal NOT
6028
+ // public API; do not depend on shape across minor versions.
6029
+ _internal: { state, xBand, yBand, plotBoundsBox },
6030
+ get xCategories() { return state.xCategories.slice(); },
6031
+ get yCategories() { return state.yCategories.slice(); },
6032
+ get vMin() { return state.vMin; },
6033
+ get vMax() { return state.vMax; },
6034
+ // moveCrosshair / hover info, useful from tests + custom interactivity.
6035
+ moveHover(canvasX, canvasY) {
6036
+ const hit = renderer.hitTest(canvasX, canvasY, xBand, yBand, state, plotBoundsBox);
6037
+ if (!hit) {
6038
+ if (!hoverData.visible) return;
6039
+ hoverData.visible = false;
6040
+ hoverData.xi = -1;
6041
+ hoverData.yi = -1;
6042
+ hoverVersion.update((v) => (v + 1) | 0);
6043
+ return;
6044
+ }
6045
+ if (hoverData.visible
6046
+ && hoverData.xi === hit.xi
6047
+ && hoverData.yi === hit.yi
6048
+ && hoverData.mouseX === canvasX
6049
+ && hoverData.mouseY === canvasY) return;
6050
+ hoverData.visible = true;
6051
+ hoverData.xi = hit.xi;
6052
+ hoverData.yi = hit.yi;
6053
+ hoverData.value = hit.value;
6054
+ hoverData.mouseX = canvasX;
6055
+ hoverData.mouseY = canvasY;
6056
+ hoverVersion.update((v) => (v + 1) | 0);
6057
+ },
6058
+ hideHover() {
6059
+ if (!hoverData.visible) return;
6060
+ hoverData.visible = false;
6061
+ hoverData.xi = -1;
6062
+ hoverData.yi = -1;
6063
+ hoverVersion.update((v) => (v + 1) | 0);
6064
+ },
6065
+ hover: Object.assign(() => { hoverVersion(); return hoverData; }, {
6066
+ peek: () => hoverData,
6067
+ }),
6068
+ };
6069
+
6070
+ const mount = (target) => {
6071
+ if (mounted) throw new Error('lite-charts: chart already mounted');
6072
+ if (!target) throw new Error('lite-charts: mount() requires an HTMLElement or HTMLCanvasElement');
6073
+
6074
+ if (target.tagName === 'CANVAS') {
6075
+ canvas = target;
6076
+ container = target.parentElement || target;
6077
+ ownedCanvas = false;
6078
+ } else if (typeof target.appendChild === 'function') {
6079
+ if (typeof document === 'undefined') {
6080
+ throw new Error('lite-charts: mount() needs a real document to create a canvas');
6081
+ }
6082
+ canvas = document.createElement('canvas');
6083
+ target.appendChild(canvas);
6084
+ container = target;
6085
+ ownedCanvas = true;
6086
+ } else if (typeof target.getContext === 'function') {
6087
+ canvas = target;
6088
+ container = null;
6089
+ ownedCanvas = false;
6090
+ } else {
6091
+ throw new Error('lite-charts: mount() target must be an HTMLElement or HTMLCanvasElement');
6092
+ }
6093
+
6094
+ // Auto-resize wire-up before the initial size effect runs.
6095
+ if (widthAutoSig || heightAutoSig) {
6096
+ _wireAutoSize(container, widthAutoSig, heightAutoSig, disposers);
6097
+ }
6098
+
6099
+ // Resolve theme-affected colors (CSS-vars -> concrete strings).
6100
+ opts.colorLow = resolveColor(opts.colorLow, container);
6101
+ opts.colorHigh = resolveColor(opts.colorHigh, container);
6102
+ opts.labelColor = resolveColor(opts.labelColor, container);
6103
+ opts.highlightStroke = resolveColor(opts.highlightStroke, container);
6104
+ opts.valueLabelColor = resolveColor(opts.valueLabelColor, container);
6105
+
6106
+ const schedule = config.schedule || (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => cb());
6107
+ scene = createScene(canvas, {
6108
+ background: config.background != null ? config.background : null,
6109
+ autoResize: false,
6110
+ dpr: config.dpr != null ? config.dpr : (typeof devicePixelRatio !== 'undefined' ? devicePixelRatio : 1),
6111
+ schedule,
6112
+ });
6113
+ const resolvedDpr = config.dpr != null ? config.dpr : (typeof devicePixelRatio !== 'undefined' ? devicePixelRatio : 1);
6114
+
6115
+ // Effect 1: dimensions -> backing buffer + plot bounds + band scales.
6116
+ disposers.push(effect(() => {
6117
+ const w = +widthSig() | 0 || 600;
6118
+ const h = +heightSig() | 0 || 400;
6119
+ const wBacking = Math.max(1, Math.round(w * resolvedDpr));
6120
+ const hBacking = Math.max(1, Math.round(h * resolvedDpr));
6121
+ if (canvas.width !== wBacking) canvas.width = wBacking;
6122
+ if (canvas.height !== hBacking) canvas.height = hBacking;
6123
+ if (typeof canvas.style !== 'undefined') {
6124
+ canvas.style.width = w + 'px';
6125
+ canvas.style.height = h + 'px';
6126
+ }
6127
+ plotBoundsBox.x = marginLeft;
6128
+ plotBoundsBox.y = marginTop;
6129
+ plotBoundsBox.w = Math.max(0, w - marginLeft - marginRight);
6130
+ plotBoundsBox.h = Math.max(0, h - marginTop - marginBottom);
6131
+ // Band scales re-stamped after data extract knows nx/ny.
6132
+ updateBandScale(xBand, state.nx, plotBoundsBox.x, plotBoundsBox.x + plotBoundsBox.w, opts.cellGap, opts.cellGap / 2);
6133
+ updateBandScale(yBand, state.ny, plotBoundsBox.y, plotBoundsBox.y + plotBoundsBox.h, opts.cellGap, opts.cellGap / 2);
6134
+ plotBoundsSignal.update((v) => (v + 1) | 0);
6135
+ if (scene) scene.markDirty();
6136
+ }));
6137
+
6138
+ // Effect 2: data -> extract + color compute + band-scale category count.
6139
+ disposers.push(effect(() => {
6140
+ const data = dataSource();
6141
+ renderer.extractData(state, data, opts);
6142
+ renderer.computeColors(state, opts);
6143
+ updateBandScale(xBand, state.nx, plotBoundsBox.x, plotBoundsBox.x + plotBoundsBox.w, opts.cellGap, opts.cellGap / 2);
6144
+ updateBandScale(yBand, state.ny, plotBoundsBox.y, plotBoundsBox.y + plotBoundsBox.h, opts.cellGap, opts.cellGap / 2);
6145
+ if (scene) scene.markDirty();
6146
+ }));
6147
+
6148
+ // Effect 3: hover -> redraw (cell highlight + tooltip).
6149
+ disposers.push(effect(() => {
6150
+ hoverVersion();
6151
+ if (scene) scene.markDirty();
6152
+ }));
6153
+
6154
+ // --- Scene nodes (drawn in this order) ---
6155
+ const cellsDrawFn = renderer.makeDrawFn(state, xBand, yBand, opts);
6156
+ scene.root.add(pathNode({ draw: (ctx) => cellsDrawFn(ctx) }));
6157
+
6158
+ // Axis labels (x below, y to the left). Inline -- no lite-axis dep
6159
+ // since heatmap categories are arbitrary strings, not numeric ticks.
6160
+ scene.root.add(pathNode({ draw: (ctx) => {
6161
+ const nx = state.nx;
6162
+ const ny = state.ny;
6163
+ if (nx === 0 && ny === 0) return;
6164
+ ctx.fillStyle = opts.labelColor;
6165
+ ctx.font = opts.labelFont;
6166
+
6167
+ // X labels (bottom).
6168
+ ctx.textAlign = 'center';
6169
+ ctx.textBaseline = 'top';
6170
+ const xLabelsY = plotBoundsBox.y + plotBoundsBox.h + 8;
6171
+ for (let xi = 0; xi < nx; xi++) {
6172
+ ctx.fillText(state.xCategories[xi], xBand.map(xi), xLabelsY);
6173
+ }
6174
+
6175
+ // Y labels (left).
6176
+ ctx.textAlign = 'right';
6177
+ ctx.textBaseline = 'middle';
6178
+ const yLabelsX = plotBoundsBox.x - 8;
6179
+ for (let yi = 0; yi < ny; yi++) {
6180
+ ctx.fillText(state.yCategories[yi], yLabelsX, yBand.map(yi));
6181
+ }
6182
+ }}));
6183
+
6184
+ // Hover highlight + tooltip.
6185
+ scene.root.add(pathNode({ draw: (ctx) => {
6186
+ if (!hoverData.visible) return;
6187
+ const { xi, yi, value, mouseX, mouseY } = hoverData;
6188
+
6189
+ // Stroke the hovered cell.
6190
+ ctx.strokeStyle = opts.highlightStroke;
6191
+ ctx.lineWidth = opts.highlightStrokeWidth;
6192
+ ctx.strokeRect(
6193
+ xBand.leftEdge(xi),
6194
+ yBand.leftEdge(yi),
6195
+ xBand.bandWidth,
6196
+ yBand.bandWidth,
6197
+ );
6198
+
6199
+ // Tooltip: simple label "xLabel x yLabel: value" near the cursor.
6200
+ const fmt = opts.tooltipFormat;
6201
+ const text = fmt
6202
+ ? fmt({ xi, yi, value, xLabel: state.xCategories[xi], yLabel: state.yCategories[yi] })
6203
+ : (state.xCategories[xi] + ' \u00d7 ' + state.yCategories[yi] + ': ' + (Math.round(value * 100) / 100));
6204
+ ctx.font = opts.labelFont;
6205
+ const metrics = ctx.measureText(text);
6206
+ const tw = (metrics && metrics.width) || (text.length * 7);
6207
+ const th = 18;
6208
+ const pad = 6;
6209
+ // Anchor above-right of the cursor; clamp inside plot rect.
6210
+ let tx = mouseX + 12;
6211
+ let ty = mouseY - th - 6;
6212
+ if (tx + tw + pad * 2 > plotBoundsBox.x + plotBoundsBox.w) {
6213
+ tx = mouseX - tw - pad * 2 - 12;
6214
+ }
6215
+ if (ty < plotBoundsBox.y) ty = mouseY + 12;
6216
+ ctx.fillStyle = 'rgba(20, 20, 20, 0.92)';
6217
+ ctx.fillRect(tx, ty, tw + pad * 2, th);
6218
+ ctx.fillStyle = '#ffffff';
6219
+ ctx.textAlign = 'left';
6220
+ ctx.textBaseline = 'middle';
6221
+ ctx.fillText(text, tx + pad, ty + th / 2);
6222
+ }}));
6223
+
6224
+ // Mouse listeners (if mounted to a real canvas).
6225
+ if (canvas && typeof canvas.addEventListener === 'function') {
6226
+ const onMove = (ev) => {
6227
+ const rect = canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : { left: 0, top: 0 };
6228
+ const cx = ev.clientX - rect.left;
6229
+ const cy = ev.clientY - rect.top;
6230
+ chart.moveHover(cx, cy);
6231
+ };
6232
+ const onLeave = () => chart.hideHover();
6233
+ canvas.addEventListener('mousemove', onMove);
6234
+ canvas.addEventListener('mouseleave', onLeave);
6235
+ disposers.push(() => canvas.removeEventListener('mousemove', onMove));
6236
+ disposers.push(() => canvas.removeEventListener('mouseleave', onLeave));
6237
+ }
6238
+
6239
+ mounted = true;
6240
+ return chart;
6241
+ };
6242
+
6243
+ const unmount = () => {
6244
+ if (!mounted) return;
6245
+ for (let i = 0; i < disposers.length; i++) {
6246
+ try { disposers[i](); } catch (_) { /* swallow */ }
6247
+ }
6248
+ disposers.length = 0;
6249
+ if (scene) {
6250
+ try { scene.dispose(); } catch (_) { /* swallow */ }
6251
+ scene = null;
6252
+ }
6253
+ if (ownedCanvas && container && canvas && canvas.parentNode === container) {
6254
+ container.removeChild(canvas);
6255
+ }
6256
+ canvas = null;
6257
+ container = null;
6258
+ ownedCanvas = false;
6259
+ mounted = false;
6260
+ };
6261
+
6262
+ chart.mount = mount;
6263
+ chart.unmount = unmount;
6264
+ return chart;
6265
+ };
6266
+
6267
+ // v1.2.0-alpha.3: heatmap rides the grid kernel. Currently the only consumer;
6268
+ // future grid charts (correlation matrix, calendar heatmap, dot matrix) would
6269
+ // fit the same kernel by supplying a different RENDERER.
6270
+
6271
+ /**
6272
+ * Create a 2D heatmap. Categorical rows × columns; each cell colored by a
6273
+ * numeric value via a default linear ramp (`colorLow` -> `colorHigh`) or
6274
+ * a custom `colorFn(v, vMin, vMax)`. Sparse grids draw only present cells.
6275
+ *
6276
+ * Rides a third kernel (`createBaseGridChart`) -- importing only
6277
+ * `createHeatmap` tree-shakes the axis-chart and polar-chart code paths.
6278
+ *
6279
+ * @param {import("./Charts.d.ts").HeatmapConfig} config
6280
+ * @returns {import("./Charts.d.ts").Chart}
6281
+ * @throws {Error} If `config` is missing or `data` is not an array / accessor.
6282
+ *
6283
+ * @example
6284
+ * const chart = createHeatmap({
6285
+ * canvas,
6286
+ * data: [
6287
+ * { row: "Mon", col: "9am", value: 12 },
6288
+ * { row: "Mon", col: "10am", value: 8 },
6289
+ * // ...
6290
+ * ],
6291
+ * width: 600, height: 400
6292
+ * });
6293
+ */
6294
+ export const createHeatmap = (config) => createBaseGridChart(config, HEATMAP_RENDERER);