@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.
- package/CHANGELOG.md +96 -0
- package/Charts.d.ts +249 -21
- package/Charts.js +1653 -482
- package/README.md +156 -102
- package/llms.txt +276 -126
- 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
|
|
5
|
-
*
|
|
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.
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
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
|
-
//
|
|
1056
|
-
//
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
-
|
|
2196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2331
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
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,
|
|
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({
|
|
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({
|
|
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
|
-
//
|
|
3023
|
-
|
|
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
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
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
|
-
|
|
3056
|
-
|
|
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
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3080
|
-
|
|
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.
|
|
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
|
-
|
|
3111
|
-
|
|
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 = (
|
|
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.
|
|
3148
|
-
|
|
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 =
|
|
3755
|
+
ctx.fillStyle = rows[i].color;
|
|
3155
3756
|
ctx.fillRect(boxX + padding, rowY + 2, swatch, swatch);
|
|
3156
|
-
ctx.fillStyle = labelColor;
|
|
3157
|
-
|
|
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
|
-
|
|
3215
|
-
},
|
|
3216
|
-
get
|
|
3217
|
-
|
|
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
|
|
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,
|
|
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
|
|
3687
|
-
const marginRight
|
|
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
|
|
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
|
|
3719
|
-
const sliceStrokeWidthRef = {value: config.sliceStrokeWidth != null ? +config.sliceStrokeWidth : 1};
|
|
3720
|
-
const labelColorRef
|
|
3721
|
-
const fontRef
|
|
3722
|
-
const tooltipBgRef
|
|
3723
|
-
const tooltipBorderRef
|
|
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
|
|
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
|
|
3836
|
-
labelColorRef.value
|
|
3837
|
-
tooltipBgRef.value
|
|
3838
|
-
tooltipBorderRef.value
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
4133
|
-
labelColorRef.value
|
|
4134
|
-
tooltipBgRef.value
|
|
4135
|
-
tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border
|
|
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
|
-
|
|
4148
|
-
},
|
|
4149
|
-
get
|
|
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
|
-
//
|
|
4278
|
-
//
|
|
4279
|
-
//
|
|
4280
|
-
|
|
4281
|
-
|
|
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
|
|
4480
|
-
const marginRight
|
|
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
|
|
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
|
|
4514
|
-
const gridColorRef
|
|
4515
|
-
const labelColorRef
|
|
4516
|
-
const fontRef
|
|
4517
|
-
const fillOpacityRef
|
|
4518
|
-
const strokeWidthRef
|
|
4519
|
-
const tooltipBgRef
|
|
4520
|
-
const tooltipBorderRef
|
|
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
|
|
4609
|
-
gridColorRef.value
|
|
4610
|
-
labelColorRef.value
|
|
4611
|
-
tooltipBgRef.value
|
|
4612
|
-
tooltipBorderRef.value
|
|
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
|
|
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
|
|
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
|
|
4704
|
-
const polygonDrawFn
|
|
4705
|
-
const spokesDrawFn
|
|
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
|
-
|
|
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
|
|
4989
|
-
gridColorRef.value
|
|
4990
|
-
labelColorRef.value
|
|
4991
|
-
tooltipBgRef.value
|
|
4992
|
-
tooltipBorderRef.value = resolveColor(tooltipOpts && tooltipOpts.border
|
|
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
|
-
|
|
4999
|
-
},
|
|
5000
|
-
get
|
|
5001
|
-
|
|
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
|
-
//
|
|
5093
|
-
//
|
|
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
|
|
5096
|
-
|
|
5097
|
-
|
|
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
|
-
|
|
5100
|
-
|
|
5101
|
-
const
|
|
5102
|
-
|
|
5103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5109
|
-
|
|
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
|
-
|
|
5112
|
-
|
|
5854
|
+
const vMin = state.vMin;
|
|
5855
|
+
const vMax = state.vMax;
|
|
5856
|
+
const span = vMax - vMin;
|
|
5857
|
+
const colorFn = opts.colorFn;
|
|
5113
5858
|
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
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
|
-
|
|
5119
|
-
|
|
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
|
-
|
|
5123
|
-
|
|
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);
|