From 6938204a249fbfa109db14ba3c3830c38ecc4634 Mon Sep 17 00:00:00 2001 From: nym21 Date: Fri, 29 May 2026 23:17:39 +0200 Subject: [PATCH] heatmaps: part 2 --- website/{CLAUDE.md => AGENTS.md} | 8 +- website/scripts/options/partial.js | 4 +- website/src/heatmap/demo.js | 29 ++++++++ website/src/heatmap/index.js | 113 ++-------------------------- website/src/heatmap/lut.js | 38 ++++++++++ website/src/heatmap/renderer.js | 116 +++++++++++++++++++++++++++++ website/src/heatmap/style.css | 7 +- website/src/heatmap/time.js | 31 ++++++++ 8 files changed, 233 insertions(+), 113 deletions(-) rename website/{CLAUDE.md => AGENTS.md} (63%) create mode 100644 website/src/heatmap/demo.js create mode 100644 website/src/heatmap/lut.js create mode 100644 website/src/heatmap/renderer.js create mode 100644 website/src/heatmap/time.js diff --git a/website/CLAUDE.md b/website/AGENTS.md similarity index 63% rename from website/CLAUDE.md rename to website/AGENTS.md index 4c5f9d17f..cae75690f 100644 --- a/website/CLAUDE.md +++ b/website/AGENTS.md @@ -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 diff --git a/website/scripts/options/partial.js b/website/scripts/options/partial.js index 69472f05e..26f412089 100644 --- a/website/scripts/options/partial.js +++ b/website/scripts/options/partial.js @@ -301,8 +301,8 @@ export function createPartialOptions() { tree: [ { kind: "heatmap", - name: "name", - title: "name", + name: "Demo", + title: "Heatmap Demo", }, ], }, diff --git a/website/src/heatmap/demo.js b/website/src/heatmap/demo.js new file mode 100644 index 000000000..d109e2480 --- /dev/null +++ b/website/src/heatmap/demo.js @@ -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]; +} diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index cb60800a0..ea8f35b72 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -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} 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); - } - }, - }; -} diff --git a/website/src/heatmap/lut.js b/website/src/heatmap/lut.js new file mode 100644 index 000000000..0ed5b4dd7 --- /dev/null +++ b/website/src/heatmap/lut.js @@ -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; +} diff --git a/website/src/heatmap/renderer.js b/website/src/heatmap/renderer.js new file mode 100644 index 000000000..52d24ff5e --- /dev/null +++ b/website/src/heatmap/renderer.js @@ -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} [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); + }, + }; +} diff --git a/website/src/heatmap/style.css b/website/src/heatmap/style.css index 226a65e9f..09a8dfbc2 100644 --- a/website/src/heatmap/style.css +++ b/website/src/heatmap/style.css @@ -3,9 +3,8 @@ display: flex; flex-direction: column; - canvas { - display: block; - width: 100%; - height: 100%; + > canvas { + flex: 1%; + min-height: 0; } } diff --git a/website/src/heatmap/time.js b/website/src/heatmap/time.js new file mode 100644 index 000000000..6a7059c25 --- /dev/null +++ b/website/src/heatmap/time.js @@ -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; +}