heatmaps: part 17

This commit is contained in:
nym21
2026-06-01 13:03:39 +02:00
parent 76720434d7
commit 15b0cd2445
11 changed files with 227 additions and 69 deletions
+7 -1
View File
@@ -13,5 +13,11 @@ ALWAYS
- fast
- KISS
- DRY
- very well organized
- contained
- colocated
- prefer one concept per file
- prefer more files and folders than big files
- reads like english
- easy to understand
- very easy to understand
- very easy to maintain
+3 -3
View File
@@ -4,7 +4,7 @@
import { createAverageGrid } from "./grid.js";
import { INFERNO_LUT, intensityColor } from "./lut.js";
import { GENESIS_DATE, todayISODate } from "./time.js";
import { defaultTooltip } from "./tooltip.js";
import { defaultTooltip } from "./tooltip/index.js";
const ROWS = 160;
const DAY_MS = 86_400_000;
@@ -19,8 +19,8 @@ export const demoHeatmapOption = {
fetch: fetchDemoPoints,
},
grid: createAverageGrid({ yMin: 0, yMax: 1, nativeRows: ROWS }),
color: intensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }),
tooltip: defaultTooltip,
color: intensityColor(INFERNO_LUT),
tooltip: defaultTooltip(),
};
/**
+4
View File
@@ -129,6 +129,10 @@ export function createAverageGrid({
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;
},
+70 -22
View File
@@ -5,9 +5,9 @@ import { createHeader, createSelect } from "../../scripts/utils/dom.js";
import { heatmapElement } from "../../scripts/utils/elements.js";
import { createPersistedValue } from "../../scripts/utils/persisted.js";
import { debounce, next } from "../../scripts/utils/timing.js";
import { dark, onChange as onThemeChange } from "../../scripts/utils/theme.js";
import { createRenderer } from "./renderer.js";
import { dateRange, GENESIS_DATE, todayISODate, toISODate } from "./time.js";
import { createTooltipView } from "./tooltip/view.js";
/**
* @typedef {Object} RangeChoice
@@ -21,6 +21,8 @@ const MAX_PARALLEL_FETCHES = 8;
let renderer;
/** @type {HTMLCanvasElement | undefined} */
let canvas;
/** @type {ReturnType<typeof createTooltipView> | undefined} */
let tooltipView;
/** @type {HTMLHeadingElement | undefined} */
let headingElement;
/** @type {HTMLElement | undefined} */
@@ -68,10 +70,12 @@ export function init() {
canvas = document.createElement("canvas");
heatmapElement.append(canvas);
renderer = createRenderer(canvas);
tooltipView = createTooltipView(heatmapElement);
canvas.addEventListener("mousemove", updateTooltip);
canvas.addEventListener("mouseleave", () => canvas?.removeAttribute("title"));
onThemeChange(paint);
canvas.addEventListener("pointermove", updateHoverTooltip);
canvas.addEventListener("pointerdown", updateTapTooltip);
canvas.addEventListener("pointerleave", hideHoverTooltip);
canvas.addEventListener("pointercancel", hideTooltip);
void next().then(resizeAndRebuild);
@@ -92,7 +96,7 @@ export function setOption(option) {
updateYControls(option);
renderRangeControls();
if (headingElement) headingElement.textContent = option.title;
if (canvas) canvas.removeAttribute("title");
hideTooltip();
}
loadRange();
}
@@ -293,28 +297,71 @@ function paint(dirty) {
renderer.paint(
grid.cols,
grid.rows,
(col, row) =>
option.color(grid.getValue(col, row), { dark, grid, col, row }),
(col, row) => option.color(grid.getValue(col, row), { grid, col, row }),
dirty,
);
}
/** @param {MouseEvent} event */
function updateTooltip(event) {
if (!canvas || !currentGrid || !currentOption?.tooltip) return;
const rect = canvas.getBoundingClientRect();
const col = Math.floor(((event.clientX - rect.left) * currentGrid.cols) / rect.width);
const row = Math.floor(((event.clientY - rect.top) * currentGrid.rows) / rect.height);
if (col < 0 || col >= currentGrid.cols || row < 0 || row >= currentGrid.rows) {
canvas.removeAttribute("title");
/** @param {PointerEvent} event */
function updateHoverTooltip(event) {
if (event.pointerType !== "mouse") return;
updateTooltip(event, "auto");
}
/** @param {PointerEvent} event */
function updateTapTooltip(event) {
if (event.pointerType === "mouse") return;
updateTooltip(event, "above");
}
/** @param {PointerEvent} event */
function hideHoverTooltip(event) {
if (event.pointerType === "mouse") hideTooltip();
}
/**
* @param {PointerEvent} event
* @param {"auto" | "above"} placement
*/
function updateTooltip(event, placement) {
if (!canvas || !currentGrid || !currentOption?.tooltip || !tooltipView) {
hideTooltip();
return;
}
canvas.title = currentOption.tooltip({
option: currentOption,
grid: currentGrid,
col,
row,
});
const rect = canvas.getBoundingClientRect();
const col = Math.floor(
((event.clientX - rect.left) * currentGrid.cols) / rect.width,
);
const row = Math.floor(
((event.clientY - rect.top) * currentGrid.rows) / rect.height,
);
if (
col < 0 ||
col >= currentGrid.cols ||
row < 0 ||
row >= currentGrid.rows
) {
hideTooltip();
return;
}
if (currentGrid.getCount(col, row) === 0) {
hideTooltip();
return;
}
tooltipView.show(
event,
currentOption.tooltip({
option: currentOption,
grid: currentGrid,
col,
row,
}),
{ placement },
);
}
function hideTooltip() {
tooltipView?.hide();
}
/**
@@ -597,6 +644,7 @@ function findSameLabelChoice(choices, choice, fallback) {
function setRange(nextFrom, nextTo) {
from = nextFrom;
to = nextTo;
hideTooltip();
loadRange();
}
@@ -607,7 +655,7 @@ function setRange(nextFrom, nextTo) {
function setYRange(nextYMin, nextYMax) {
yMin = nextYMin;
yMax = nextYMax;
if (canvas) canvas.removeAttribute("title");
hideTooltip();
rebuildGrid();
}
+5 -11
View File
@@ -15,32 +15,26 @@ const INFERNO_STOPS = [
export const INFERNO_LUT = createColorLut(INFERNO_STOPS);
/**
* @param {Object} args
* @param {ArrayLike<number>} args.light
* @param {ArrayLike<number>} args.dark
* @param {ArrayLike<number>} lut
* @returns {HeatmapColorFn}
*/
export function intensityColor({ light, dark }) {
return (value, context) => {
export function intensityColor(lut) {
return (value) => {
if (!Number.isFinite(value)) return 0x00000000;
const lut = context.dark ? dark : light;
const index = Math.min(255, Math.max(0, Math.round(value * 255)));
return lut[index] ?? 0x00000000;
};
}
/**
* @param {Object} args
* @param {ArrayLike<number>} args.light
* @param {ArrayLike<number>} args.dark
* @param {ArrayLike<number>} lut
* @returns {HeatmapColorFn}
*/
export function logIntensityColor({ light, dark }) {
export function logIntensityColor(lut) {
return (value, context) => {
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
const max = context.grid.getMaxValue();
if (max <= 0) return 0x00000000;
const lut = context.dark ? dark : light;
const t = Math.log2(value + 1) / Math.log2(max + 1);
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
return lut[index] ?? 0x00000000;
+7 -3
View File
@@ -4,7 +4,7 @@
import { brk } from "../../scripts/utils/client.js";
import { createAverageGrid } from "./grid.js";
import { INFERNO_LUT, logIntensityColor } from "./lut.js";
import { defaultTooltip } from "./tooltip.js";
import { defaultTooltip } from "./tooltip/index.js";
const BINS = 2400;
const MIN_LOG = -8;
@@ -55,7 +55,7 @@ function createOracleHeatmapOption(mode, name) {
nativeRows: BINS,
yOrigin: "top",
}),
color: logIntensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }),
color: logIntensityColor(INFERNO_LUT),
axis: {
y: {
label: "amount",
@@ -75,7 +75,11 @@ function createOracleHeatmapOption(mode, name) {
from: "genesis",
to: "today",
},
tooltip: defaultTooltip,
tooltip: defaultTooltip(
mode === "outputs"
? { valueLabel: "Outputs", averageLabel: "Avg outputs" }
: { valueLabel: "Payment signal", averageLabel: "Avg payment signal" },
),
};
}
+18 -1
View File
@@ -4,8 +4,8 @@
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
padding: var(--main-padding);
background-color: var(--background-color);
> header {
flex-shrink: 0;
@@ -56,11 +56,28 @@
}
> canvas {
background-color: var(--black);
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
display: block;
image-rendering: pixelated;
touch-action: manipulation;
}
> [role="tooltip"] {
position: absolute;
z-index: 1;
max-width: min(18rem, calc(100% - 1rem));
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color);
background-color: var(--background-color);
color: var(--color);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
pointer-events: none;
white-space: pre;
}
}
-27
View File
@@ -1,27 +0,0 @@
/** @import { HeatmapTooltipFn } from "./types.js" */
import { numberToShortUSFormat } from "../../scripts/utils/format.js";
/** @satisfies {HeatmapTooltipFn} */
export const defaultTooltip = ({ option, grid, col, row }) => {
const dateRange = grid.getDateIndexRange(col);
const yRange = grid.getYRange(row);
const value = grid.getValue(col, row);
const yLabel = option.axis?.y?.label ?? "y";
const formatY = option.axis?.y?.format ?? formatNumber;
const from = grid.dates[dateRange.start] ?? "";
const to = grid.dates[dateRange.end] ?? from;
const date = from === to ? from : `${from} to ${to}`;
return [
date,
`${yLabel} ${formatY(yRange.start)} to ${formatY(yRange.end)}`,
`value ${formatNumber(value)}`,
].join("\n");
};
/** @param {number} value */
function formatNumber(value) {
return numberToShortUSFormat(value);
}
+43
View File
@@ -0,0 +1,43 @@
/** @import { HeatmapTooltipFn } from "../types.js" */
import { numberToShortUSFormat } from "../../../scripts/utils/format.js";
/**
* @param {Object} [args]
* @param {string} [args.valueLabel]
* @param {string} [args.averageLabel]
* @returns {HeatmapTooltipFn}
*/
export function defaultTooltip({
valueLabel = "Value",
averageLabel = "Avg value",
} = {}) {
return ({ option, grid, col, row }) => {
const dateRange = grid.getDateIndexRange(col);
const yRange = grid.getYRange(row);
const value = grid.getValue(col, row);
const yLabel = option.axis?.y?.label ?? "y";
const formatY = option.axis?.y?.format ?? formatNumber;
const label = grid.getCount(col, row) > 1 ? averageLabel : valueLabel;
const from = grid.dates[dateRange.start] ?? "";
const to = grid.dates[dateRange.end] ?? from;
const date = from === to ? from : `${from} to ${to}`;
return [
date,
`${capitalize(yLabel)}: ${formatY(yRange.start)} to ${formatY(yRange.end)}`,
`${label}: ${formatNumber(value)}`,
].join("\n");
};
}
/** @param {number} value */
function formatNumber(value) {
return numberToShortUSFormat(value);
}
/** @param {string} value */
function capitalize(value) {
return value ? value[0].toUpperCase() + value.slice(1) : value;
}
+68
View File
@@ -0,0 +1,68 @@
const OFFSET = 12;
const EDGE_PADDING = 8;
/** @typedef {"auto" | "above"} TooltipPlacement */
/**
* @param {HTMLElement} parent
*/
export function createTooltipView(parent) {
const element = document.createElement("div");
element.hidden = true;
element.setAttribute("role", "tooltip");
parent.append(element);
return {
/**
* @param {PointerEvent} event
* @param {string} text
* @param {{ placement?: TooltipPlacement }} [options]
*/
show(event, text, { placement = "auto" } = {}) {
if (element.textContent !== text) element.textContent = text;
element.hidden = false;
place(event, parent, element, placement);
},
hide() {
element.hidden = true;
},
};
}
/**
* @param {PointerEvent} event
* @param {HTMLElement} parent
* @param {HTMLElement} element
* @param {TooltipPlacement} placement
*/
function place(event, parent, element, placement) {
const parentRect = parent.getBoundingClientRect();
const x = event.clientX - parentRect.left;
const y = event.clientY - parentRect.top;
const width = element.offsetWidth;
const height = element.offsetHeight;
let left = placement === "above" ? x - width / 2 : x + OFFSET;
let top = placement === "above" ? y - height - OFFSET : y + OFFSET;
if (left + width + EDGE_PADDING > parentRect.width) {
left = x - width - OFFSET;
}
if (placement === "above" && top < EDGE_PADDING) {
top = y + OFFSET;
} else if (top + height + EDGE_PADDING > parentRect.height) {
top = y - height - OFFSET;
}
element.style.left = `${clamp(left, EDGE_PADDING, parentRect.width - width - EDGE_PADDING)}px`;
element.style.top = `${clamp(top, EDGE_PADDING, parentRect.height - height - EDGE_PADDING)}px`;
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), Math.max(min, max));
}
+2 -1
View File
@@ -37,6 +37,7 @@
* @property {number} rows
* @property {(dateIndex: number, points: HeatmapPoints) => HeatmapGridAddResult | undefined} add
* @property {(col: number, row: number) => number} getValue
* @property {(col: number, row: number) => number} getCount
* @property {() => number} getMaxValue
* @property {(col: number) => HeatmapRange} getDateIndexRange
* @property {(row: number) => HeatmapRange} getYRange
@@ -51,7 +52,7 @@
* @typedef {Object} HeatmapAxis
* @property {{ label: string, choices?: HeatmapAxisChoice[], format?: (value: number) => string }} [y]
*
* @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
* @typedef {(value: number, context: { grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
* @typedef {(context: { option: { axis?: HeatmapAxis }, grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn
*/