From 15b0cd2445ca75cf9a62732c08bc1772e3c42525 Mon Sep 17 00:00:00 2001 From: nym21 Date: Mon, 1 Jun 2026 13:03:39 +0200 Subject: [PATCH] heatmaps: part 17 --- website/AGENTS.md | 8 ++- website/src/heatmap/demo.js | 6 +- website/src/heatmap/grid.js | 4 ++ website/src/heatmap/index.js | 92 +++++++++++++++++++++------- website/src/heatmap/lut.js | 16 ++--- website/src/heatmap/oracle.js | 10 ++- website/src/heatmap/style.css | 19 +++++- website/src/heatmap/tooltip.js | 27 -------- website/src/heatmap/tooltip/index.js | 43 +++++++++++++ website/src/heatmap/tooltip/view.js | 68 ++++++++++++++++++++ website/src/heatmap/types.js | 3 +- 11 files changed, 227 insertions(+), 69 deletions(-) delete mode 100644 website/src/heatmap/tooltip.js create mode 100644 website/src/heatmap/tooltip/index.js create mode 100644 website/src/heatmap/tooltip/view.js diff --git a/website/AGENTS.md b/website/AGENTS.md index cae75690f..49f27bc96 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -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 diff --git a/website/src/heatmap/demo.js b/website/src/heatmap/demo.js index 518e6403e..edd62618c 100644 --- a/website/src/heatmap/demo.js +++ b/website/src/heatmap/demo.js @@ -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(), }; /** diff --git a/website/src/heatmap/grid.js b/website/src/heatmap/grid.js index d04c9cac5..524ca38a7 100644 --- a/website/src/heatmap/grid.js +++ b/website/src/heatmap/grid.js @@ -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; }, diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index 440d46264..6ac5d2550 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -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 | 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(); } diff --git a/website/src/heatmap/lut.js b/website/src/heatmap/lut.js index eb192f5d5..1a1e39103 100644 --- a/website/src/heatmap/lut.js +++ b/website/src/heatmap/lut.js @@ -15,32 +15,26 @@ const INFERNO_STOPS = [ export const INFERNO_LUT = createColorLut(INFERNO_STOPS); /** - * @param {Object} args - * @param {ArrayLike} args.light - * @param {ArrayLike} args.dark + * @param {ArrayLike} 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} args.light - * @param {ArrayLike} args.dark + * @param {ArrayLike} 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; diff --git a/website/src/heatmap/oracle.js b/website/src/heatmap/oracle.js index 144a3ded2..b70203d61 100644 --- a/website/src/heatmap/oracle.js +++ b/website/src/heatmap/oracle.js @@ -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" }, + ), }; } diff --git a/website/src/heatmap/style.css b/website/src/heatmap/style.css index 757d591c8..23f2d3c25 100644 --- a/website/src/heatmap/style.css +++ b/website/src/heatmap/style.css @@ -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; } } diff --git a/website/src/heatmap/tooltip.js b/website/src/heatmap/tooltip.js deleted file mode 100644 index fb8cc378f..000000000 --- a/website/src/heatmap/tooltip.js +++ /dev/null @@ -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); -} diff --git a/website/src/heatmap/tooltip/index.js b/website/src/heatmap/tooltip/index.js new file mode 100644 index 000000000..de40fff0f --- /dev/null +++ b/website/src/heatmap/tooltip/index.js @@ -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; +} diff --git a/website/src/heatmap/tooltip/view.js b/website/src/heatmap/tooltip/view.js new file mode 100644 index 000000000..ae6c40891 --- /dev/null +++ b/website/src/heatmap/tooltip/view.js @@ -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)); +} diff --git a/website/src/heatmap/types.js b/website/src/heatmap/types.js index 4a1ea818d..ba3e05db4 100644 --- a/website/src/heatmap/types.js +++ b/website/src/heatmap/types.js @@ -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 */