heatmaps: part 2

This commit is contained in:
nym21
2026-05-29 23:17:39 +02:00
parent 52883bbdba
commit 6938204a24
8 changed files with 233 additions and 113 deletions
+7 -1
View File
@@ -8,4 +8,10 @@ npx --package typescript tsc --noEmit --pretty false | grep -v "modules/"
# Code
Codex will review your output once you are done.
ALWAYS
- fast
- KISS
- DRY
- reads like english
- easy to understand
+2 -2
View File
@@ -301,8 +301,8 @@ export function createPartialOptions() {
tree: [
{
kind: "heatmap",
name: "name",
title: "name",
name: "Demo",
title: "Heatmap Demo",
},
],
},
+29
View File
@@ -0,0 +1,29 @@
import { INFERNO_LUT } from "./lut.js";
const COLS = 100;
const ROWS = 100;
export const demoSource = {
cols: COLS,
rows: ROWS,
getColor,
};
/**
* @param {number} col
* @param {number} row
*/
function getColor(col, row) {
const x = col / (COLS - 1);
const y = row / (ROWS - 1);
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
const blob = Math.exp(
-(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028),
);
const floor = x * 0.18 + (1 - y) * 0.12;
const i = Math.min(
255,
Math.max(0, ((ridge * 0.65 + blob * 0.45 + floor) * 255) | 0),
);
return INFERNO_LUT[i];
}
+7 -106
View File
@@ -1,8 +1,12 @@
import { createHeader } from "../../scripts/utils/dom.js";
import { heatmapElement } from "../../scripts/utils/elements.js";
import { debounce, next } from "../../scripts/utils/timing.js";
import { demoSource } from "./demo.js";
import { createRenderer } from "./renderer.js";
/**
* Initializes the heatmap pane once for the app lifetime.
*
* @param {HeatmapOption} option
*/
export async function init(option) {
@@ -15,25 +19,10 @@ export async function init(option) {
await next();
let renderer = createRenderer(canvas);
function randomColor() {
// Inferno-ish: just random for now
const r = (Math.random() * 255) | 0;
const g = (Math.random() * 100) | 0;
const b = (Math.random() * 200) | 0;
return 0xff000000 | (b << 16) | (g << 8) | r; // ABGR
}
/**
* @param {number} col
* @param {number} row
*/
function getColor(col, row) {
return randomColor();
}
let source = demoSource;
function render() {
renderer.paint(100, 100, getColor);
renderer.paint(source.cols, source.rows, source.getColor);
}
const { width, height } = canvas.getBoundingClientRect();
@@ -44,97 +33,9 @@ export async function init(option) {
new ResizeObserver(
debounce(() => {
const { width, height } = canvas.getBoundingClientRect();
if (width && height && renderer.resize(width, height)) {
if (renderer.resize(width, height)) {
render();
}
}, 1000),
).observe(heatmapElement);
}
/** @param {HTMLCanvasElement} */
export function createRenderer(canvas) {
const context = canvas.getContext("2d");
if (!context) throw "Expected context from canvas";
let width = 0;
let height = 0;
let imageData = new ImageData(1, 1);
let buffer = new Uint32Array();
return {
/**
* @param {number} w
* @param {number} h
* @returns {boolean} wether the canvas was actually resized (true) or not (false)
*/
resize(w, h) {
if (w == width && h == height) return false;
const bound = canvas.getBoundingClientRect();
width = Math.floor(Math.min(w, bound.width));
height = Math.floor(Math.min(h, bound.height));
canvas.width = width;
canvas.height = height;
imageData = context.createImageData(width, height);
buffer = new Uint32Array(imageData.data.buffer);
return true;
},
get width() {
return width;
},
get height() {
return height;
},
/**
* Full repaint: iterate all cells, colorize, one blit.
* @param {number} cols
* @param {number} rows
* @param {(col: number, row: number) => number} getColor - returns ABGR uint32
*/
paint(cols, rows, getColor) {
const colX = new Int32Array(cols + 1);
for (let c = 0; c <= cols; c++) colX[c] = ((c * width) / cols + 0.5) | 0;
const rowY = new Int32Array(rows + 1);
for (let r = 0; r <= rows; r++) rowY[r] = ((r * height) / rows + 0.5) | 0;
for (let c = 0; c < cols; c++) {
const x0 = colX[c];
const x1 = colX[c + 1];
for (let r = 0; r < rows; r++) {
const color = getColor(c, r);
const y0 = rowY[r];
const y1 = rowY[r + 1];
for (let y = y0; y < y1; y++) {
buffer.fill(color, y * width + x0, y * width + x1);
}
}
}
context.putImageData(imageData, 0, 0);
},
/**
* Incremental repaint: only dirty columns, blit each separately.
* @param {number} cols
* @param {number} rows
* @param {Iterable<number>} dirty
* @param {(col: number, row: number) => number} getColor
*/
paintCols(cols, rows, dirty, getColor) {
const colW = width / cols;
const rowH = height / rows;
for (const c of dirty) {
const x0 = (c * colW) | 0;
const x1 = Math.min(width, ((c + 1) * colW) | 0);
for (let r = 0; r < rows; r++) {
const color = getColor(c, r);
const y0 = (r * rowH) | 0;
const y1 = Math.min(height, ((r + 1) * rowH) | 0);
for (let y = y0; y < y1; y++) {
buffer.fill(color, y * width + x0, y * width + x1);
}
}
context.putImageData(imageData, 0, 0, x0, 0, x1 - x0, height);
}
},
};
}
+38
View File
@@ -0,0 +1,38 @@
const INFERNO_STOPS = [
[0, 0, 0, 0],
[0.13, 40, 11, 84],
[0.25, 101, 21, 110],
[0.38, 159, 42, 99],
[0.5, 212, 72, 66],
[0.63, 245, 125, 21],
[0.75, 250, 193, 39],
[0.88, 252, 243, 105],
[1, 252, 255, 164],
];
export const INFERNO_LUT = createColorLut(INFERNO_STOPS);
/**
* @param {number[][]} stops - Tuples of [position, red, green, blue].
*/
export function createColorLut(stops) {
const lut = new Uint32Array(256);
for (let i = 0; i < lut.length; i++) {
const t = i / 255;
let a = stops[0];
let b = stops[stops.length - 1];
for (let j = 0; j < stops.length - 1; j++) {
if (t >= stops[j][0] && t <= stops[j + 1][0]) {
a = stops[j];
b = stops[j + 1];
break;
}
}
const f = a[0] === b[0] ? 0 : (t - a[0]) / (b[0] - a[0]);
const r = (a[1] + f * (b[1] - a[1]) + 0.5) | 0;
const g = (a[2] + f * (b[2] - a[2]) + 0.5) | 0;
const blue = (a[3] + f * (b[3] - a[3]) + 0.5) | 0;
lut[i] = 0xff000000 | (blue << 16) | (g << 8) | r;
}
return lut;
}
+116
View File
@@ -0,0 +1,116 @@
/** @param {HTMLCanvasElement} canvas */
export function createRenderer(canvas) {
const context = canvas.getContext("2d");
if (!context) throw "Expected context from canvas";
let width = 0;
let height = 0;
let imageData = new ImageData(1, 1);
let buffer = new Uint32Array();
const emptyGeometry = {
cols: -1,
rows: -1,
colX: new Int32Array(0),
rowOffset: new Int32Array(0),
};
let geometry = { ...emptyGeometry };
/**
* @param {number} cols
* @param {number} rows
*/
function getGeometry(cols, rows) {
if (geometry.cols === cols && geometry.rows === rows) return geometry;
const colX = new Int32Array(cols + 1);
for (let c = 0; c <= cols; c++) {
colX[c] = ((c * width) / cols + 0.5) | 0;
}
const rowOffset = new Int32Array(rows + 1);
for (let r = 0; r <= rows; r++) {
rowOffset[r] = (((r * height) / rows + 0.5) | 0) * width;
}
geometry = { cols, rows, colX, rowOffset };
return geometry;
}
/**
* @param {number} col
* @param {number} rows
* @param {number} x0
* @param {number} x1
* @param {Int32Array} rowOffset
* @param {(col: number, row: number) => number} getColor
*/
function paintColumn(col, rows, x0, x1, rowOffset, getColor) {
if (x0 === x1) return false;
for (let r = 0; r < rows; r++) {
const color = getColor(col, r);
for (let off = rowOffset[r]; off < rowOffset[r + 1]; off += width) {
buffer.fill(color, off + x0, off + x1);
}
}
return true;
}
return {
/**
* @param {number} w
* @param {number} h
* @returns {boolean} whether the canvas was actually resized (true) or not (false)
*/
resize(w, h) {
const bound = canvas.getBoundingClientRect();
const nextWidth = Math.floor(Math.min(w, bound.width));
const nextHeight = Math.floor(Math.min(h, bound.height));
if (nextWidth < 1 || nextHeight < 1) return false;
if (nextWidth === width && nextHeight === height) return false;
width = nextWidth;
height = nextHeight;
canvas.width = width;
canvas.height = height;
geometry = { ...emptyGeometry };
imageData = context.createImageData(width, height);
buffer = new Uint32Array(imageData.data.buffer);
return true;
},
get width() {
return width;
},
get height() {
return height;
},
/**
* Paint all cells or only dirty columns.
* @param {number} cols
* @param {number} rows
* @param {(col: number, row: number) => number} getColor - returns ABGR uint32
* @param {Iterable<number>} [dirty]
*/
paint(cols, rows, getColor, dirty) {
if (cols < 1 || rows < 1 || width < 1 || height < 1) return;
const { colX, rowOffset } = getGeometry(cols, rows);
if (dirty) {
for (const c of dirty) {
if (c < 0 || c >= cols) continue;
const x0 = colX[c];
const x1 = colX[c + 1];
if (paintColumn(c, rows, x0, x1, rowOffset, getColor)) {
context.putImageData(imageData, 0, 0, x0, 0, x1 - x0, height);
}
}
return;
}
for (let c = 0; c < cols; c++) {
paintColumn(c, rows, colX[c], colX[c + 1], rowOffset, getColor);
}
context.putImageData(imageData, 0, 0);
},
};
}
+3 -4
View File
@@ -3,9 +3,8 @@
display: flex;
flex-direction: column;
canvas {
display: block;
width: 100%;
height: 100%;
> canvas {
flex: 1%;
min-height: 0;
}
}
+31
View File
@@ -0,0 +1,31 @@
const DAY_MS = 86_400_000;
/**
* @param {Date} date
*/
export function toISODate(date) {
return date.toISOString().slice(0, 10);
}
export function todayISODate() {
return toISODate(new Date());
}
/**
* Inclusive UTC date range.
*
* @param {string} from
* @param {string} to
*/
export function dateRange(from, to) {
const dates = [];
for (
let time = Date.parse(`${from}T00:00:00Z`),
end = Date.parse(`${to}T00:00:00Z`);
time <= end;
time += DAY_MS
) {
dates.push(toISODate(new Date(time)));
}
return dates;
}