mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
heatmaps: part 2
This commit is contained in:
@@ -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
|
||||
@@ -301,8 +301,8 @@ export function createPartialOptions() {
|
||||
tree: [
|
||||
{
|
||||
kind: "heatmap",
|
||||
name: "name",
|
||||
title: "name",
|
||||
name: "Demo",
|
||||
title: "Heatmap Demo",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,9 +3,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
> canvas {
|
||||
flex: 1%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user