Files
brk/website/src/heatmap/grid.js
T
2026-06-01 13:20:34 +02:00

172 lines
5.0 KiB
JavaScript

/**
* Generic date/y binning with average merge semantics.
*
* @param {Object} args
* @param {number} args.yMin
* @param {number} args.yMax
* @param {number} [args.minCellSize]
* @param {number} [args.maxCols]
* @param {number} [args.nativeRows]
* @param {"bottom" | "top"} [args.yOrigin]
* @returns {HeatmapGridFactory}
*/
export function createAverageGrid({
yMin: defaultYMin,
yMax: defaultYMax,
minCellSize = 1,
maxCols = Number.POSITIVE_INFINITY,
nativeRows = Number.POSITIVE_INFINITY,
yOrigin = "bottom",
}) {
return {
create({ dates, width, height, yMin = defaultYMin, yMax = defaultYMax }) {
const cols = Math.max(
1,
Math.min(
dates.length || 1,
maxCols,
Math.floor(width / minCellSize) || 1,
),
);
const rows = Math.max(
1,
Math.min(nativeRows, Math.floor(height / minCellSize) || 1),
);
const sums = new Float64Array(cols * rows);
const counts = new Uint32Array(cols * rows);
const maxByCol = new Float64Array(cols);
let maxValue = 0;
const ySpan = yMax - yMin;
/** @param {number} dateIndex */
function toCol(dateIndex) {
if (dateIndex < 0 || dateIndex >= dates.length) return undefined;
return clamp(
Math.floor((dateIndex * cols) / dates.length),
0,
cols - 1,
);
}
/** @param {number} y */
function toRow(y) {
if (!Number.isFinite(y) || !Number.isFinite(ySpan) || ySpan <= 0) {
return undefined;
}
const t = (y - yMin) / ySpan;
if (t < 0 || t > 1) return undefined;
const row = clamp(Math.floor(t * rows), 0, rows - 1);
return yOrigin === "top" ? row : rows - 1 - row;
}
/**
* @param {number} col
* @param {number} y
* @param {number} value
*/
function addValue(col, y, value) {
if (!Number.isFinite(value)) return false;
const row = toRow(y);
if (row === undefined) return false;
const index = row * cols + col;
sums[index] += value;
counts[index] += 1;
return true;
}
/** @param {number} col */
function updateColumnMax(col) {
let max = 0;
for (let row = 0; row < rows; row++) {
const index = row * cols + col;
if (counts[index]) max = Math.max(max, sums[index] / counts[index]);
}
maxByCol[col] = max;
maxValue = 0;
for (let c = 0; c < cols; c++) {
maxValue = Math.max(maxValue, maxByCol[c]);
}
}
/** @type {HeatmapGrid} */
const grid = {
dates,
cols,
rows,
add(dateIndex, points) {
const col = toCol(dateIndex);
if (col === undefined) return undefined;
let dirty = false;
if (points.kind === "implicit") {
for (let i = 0; i < points.values.length; i++) {
if (
addValue(
col,
points.yStart + i * points.yStep,
points.values[i],
)
) {
dirty = true;
}
}
} else {
const length = Math.min(points.y.length, points.values.length);
for (let i = 0; i < length; i++) {
if (addValue(col, points.y[i], points.values[i])) dirty = true;
}
}
if (!dirty) return undefined;
const previousMax = maxValue;
updateColumnMax(col);
return { col, maxChanged: maxValue !== previousMax };
},
getValue(col, row) {
if (col < 0 || col >= cols || row < 0 || row >= rows) {
return Number.NaN;
}
const index = row * cols + col;
return counts[index] ? sums[index] / counts[index] : Number.NaN;
},
getCount(col, row) {
if (col < 0 || col >= cols || row < 0 || row >= rows) return 0;
return counts[row * cols + col];
},
getMaxValue() {
return maxValue;
},
getDateIndexRange(col) {
if (col < 0 || col >= cols || dates.length === 0) {
return emptyRange();
}
const start = Math.ceil((col * dates.length) / cols);
const end = Math.floor(((col + 1) * dates.length - 1) / cols);
return { start, end: clamp(end, start, dates.length - 1) };
},
getYRange(row) {
if (row < 0 || row >= rows || ySpan <= 0) return emptyRange();
const index = yOrigin === "top" ? row : rows - row - 1;
const start = yMin + (index / rows) * ySpan;
const end = yMin + ((index + 1) / rows) * ySpan;
return { start, end };
},
};
return grid;
},
};
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/** @returns {HeatmapRange} */
function emptyRange() {
return { start: Number.NaN, end: Number.NaN };
}