mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-11 07:23:32 -07:00
heatmaps: part 17
This commit is contained in:
+7
-1
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user