mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
heatmaps: part 4
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* @import { Color } from "./utils/colors.js"
|
||||
*
|
||||
* @import { HeatmapDataSource, HeatmapCells, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js"
|
||||
* @import { HeatmapPointSource, HeatmapCells, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js"
|
||||
*
|
||||
* @import { Option, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, AnySeriesBlueprint, SeriesType, AnyFetchedSeriesBlueprint, ExplorerOption, UrlOption, PartialOptionsGroup, OptionsGroup, PartialOptionsTree, UtxoCohortObject, AddrCohortObject, CohortObject, CohortGroupObject, FetchedLineSeriesBlueprint, FetchedBaselineSeriesBlueprint, FetchedHistogramSeriesBlueprint, FetchedDotsBaselineSeriesBlueprint, PatternAll, PatternFull, PatternWithAdjusted, PatternWithPercentiles, PatternBasic, PatternBasicWithMarketCap, PatternBasicWithoutMarketCap, PatternWithoutRelative, CohortAll, CohortFull, CohortWithAdjusted, CohortWithPercentiles, CohortBasic, CohortBasicWithMarketCap, CohortBasicWithoutMarketCap, CohortWithoutRelative, CohortAddr, CohortLongTerm, CohortAgeRange, CohortAgeRangeWithMatured, CohortGroupFull, CohortGroupWithAdjusted, CohortGroupWithPercentiles, CohortGroupLongTerm, CohortGroupAgeRange, CohortGroupBasic, CohortGroupBasicWithMarketCap, CohortGroupBasicWithoutMarketCap, CohortGroupWithoutRelative, CohortGroupAddr, UtxoCohortGroupObject, AddrCohortGroupObject, FetchedDotsSeriesBlueprint, HeatmapOption, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.js"
|
||||
*
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from "./panes/chart.js";
|
||||
import { init as initExplorer } from "./explorer/index.js";
|
||||
import { init as initSearch } from "./panes/search.js";
|
||||
import { init as initHeatmap } from "../src/heatmap/index.js";
|
||||
import {
|
||||
init as initHeatmap,
|
||||
setOption as setHeatmapOption,
|
||||
} from "../src/heatmap/index.js";
|
||||
import { readStored, removeStored, writeToStorage } from "./utils/storage.js";
|
||||
import {
|
||||
asideElement,
|
||||
@@ -153,9 +156,10 @@ function initSelected() {
|
||||
element = heatmapElement;
|
||||
|
||||
if (firstTimeLoadingHeatmap) {
|
||||
initHeatmap(option);
|
||||
initHeatmap();
|
||||
}
|
||||
firstTimeLoadingHeatmap = false;
|
||||
setHeatmapOption(option);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createNetworkSection } from "./network.js";
|
||||
import { createMiningSection } from "./mining.js";
|
||||
import { createCointimeSection } from "./cointime.js";
|
||||
import { createInvestingSection } from "./investing.js";
|
||||
import { demoHeatmapOption } from "../../src/heatmap/demo.js";
|
||||
|
||||
// Re-export types for external consumers
|
||||
export * from "./types.js";
|
||||
@@ -298,13 +299,7 @@ export function createPartialOptions() {
|
||||
|
||||
{
|
||||
name: "Heatmaps",
|
||||
tree: [
|
||||
{
|
||||
kind: "heatmap",
|
||||
name: "Demo",
|
||||
title: "Heatmap Demo",
|
||||
},
|
||||
],
|
||||
tree: [demoHeatmapOption],
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -107,14 +107,14 @@
|
||||
* @typedef {Object} PartialHeatmapOptionSpecific
|
||||
* @property {"heatmap"} kind
|
||||
* @property {string} title
|
||||
* @property {HeatmapDataSource} data
|
||||
* @property {HeatmapPointSource} points
|
||||
* @property {HeatmapCells} cells
|
||||
* @property {HeatmapColorFn} color
|
||||
* @property {HeatmapTooltipFn} [tooltip]
|
||||
*
|
||||
* @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption
|
||||
*
|
||||
* @typedef {Required<PartialHeatmapOption> & ProcessedOptionAddons} HeatmapOption
|
||||
* @typedef {Required<Omit<PartialHeatmapOption, "tooltip">> & Pick<PartialHeatmapOption, "tooltip"> & ProcessedOptionAddons} HeatmapOption
|
||||
*
|
||||
* @typedef {Object} PartialUrlOptionSpecific
|
||||
* @property {"link"} [kind]
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/** @import { HeatmapCells, HeatmapGrid, HeatmapRange } from "./types.js" */
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
*/
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic date/y binning with average merge semantics.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {number} args.yStart
|
||||
* @param {number} args.yEnd
|
||||
* @param {number} [args.minCellSize]
|
||||
* @param {number} [args.maxCols]
|
||||
* @param {number} [args.nativeRows]
|
||||
* @returns {HeatmapCells}
|
||||
*/
|
||||
export function createAverageCells({
|
||||
yStart,
|
||||
yEnd,
|
||||
minCellSize = 1,
|
||||
maxCols = Number.POSITIVE_INFINITY,
|
||||
nativeRows = Number.POSITIVE_INFINITY,
|
||||
}) {
|
||||
return {
|
||||
create({ dates, width, height }) {
|
||||
const cols = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
dates.length || 1,
|
||||
maxCols,
|
||||
Math.floor(width / minCellSize) || 1,
|
||||
),
|
||||
);
|
||||
const rows = Math.max(
|
||||
1,
|
||||
Math.min(nativeRows, Math.floor(height / minCellSize) || 1),
|
||||
);
|
||||
const sums = new Float64Array(cols * rows);
|
||||
const counts = new Uint32Array(cols * rows);
|
||||
const ySpan = yEnd - yStart;
|
||||
|
||||
/** @param {number} dateIndex */
|
||||
function toCol(dateIndex) {
|
||||
if (dateIndex < 0 || dateIndex >= dates.length) return undefined;
|
||||
return clamp(Math.floor((dateIndex * cols) / dates.length), 0, cols - 1);
|
||||
}
|
||||
|
||||
/** @param {number} y */
|
||||
function toRow(y) {
|
||||
if (!Number.isFinite(y) || !Number.isFinite(ySpan) || ySpan <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const t = (y - yStart) / ySpan;
|
||||
if (t < 0 || t > 1) return undefined;
|
||||
return rows - 1 - clamp(Math.floor(t * rows), 0, rows - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dateIndex
|
||||
* @param {number} y
|
||||
* @param {number} value
|
||||
*/
|
||||
function addValue(dateIndex, y, value) {
|
||||
if (!Number.isFinite(value)) return undefined;
|
||||
const col = toCol(dateIndex);
|
||||
const row = toRow(y);
|
||||
if (col === undefined || row === undefined) return undefined;
|
||||
const index = row * cols + col;
|
||||
sums[index] += value;
|
||||
counts[index] += 1;
|
||||
return col;
|
||||
}
|
||||
|
||||
/** @type {HeatmapGrid} */
|
||||
const grid = {
|
||||
dates,
|
||||
cols,
|
||||
rows,
|
||||
add(dateIndex, points) {
|
||||
let dirty;
|
||||
if (points.kind === "implicit") {
|
||||
for (let i = 0; i < points.values.length; i++) {
|
||||
const col = addValue(
|
||||
dateIndex,
|
||||
points.yStart + i * points.yStep,
|
||||
points.values[i],
|
||||
);
|
||||
dirty = col ?? dirty;
|
||||
}
|
||||
} else {
|
||||
const length = Math.min(points.y.length, points.values.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const col = addValue(dateIndex, points.y[i], points.values[i]);
|
||||
dirty = col ?? dirty;
|
||||
}
|
||||
}
|
||||
return dirty;
|
||||
},
|
||||
getValue(col, row) {
|
||||
if (col < 0 || col >= cols || row < 0 || row >= rows) {
|
||||
return Number.NaN;
|
||||
}
|
||||
const index = row * cols + col;
|
||||
return counts[index] ? sums[index] / counts[index] : Number.NaN;
|
||||
},
|
||||
getDateIndexRange(col) {
|
||||
if (col < 0 || col >= cols || dates.length === 0) {
|
||||
return emptyRange();
|
||||
}
|
||||
const start = Math.floor((col * dates.length) / cols);
|
||||
const end = Math.floor(((col + 1) * dates.length - 1) / cols);
|
||||
return { start, end: clamp(end, start, dates.length - 1) };
|
||||
},
|
||||
getYRange(row) {
|
||||
if (row < 0 || row >= rows || ySpan <= 0) return emptyRange();
|
||||
const start = yStart + ((rows - row - 1) / rows) * ySpan;
|
||||
const end = yStart + ((rows - row) / rows) * ySpan;
|
||||
return { start, end };
|
||||
},
|
||||
};
|
||||
|
||||
return grid;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {HeatmapRange} */
|
||||
function emptyRange() {
|
||||
return { start: Number.NaN, end: Number.NaN };
|
||||
}
|
||||
+58
-21
@@ -1,29 +1,66 @@
|
||||
import { INFERNO_LUT } from "./lut.js";
|
||||
/** @import { PartialHeatmapOption } from "../../scripts/options/types.js" */
|
||||
/** @import { HeatmapPoints } from "./types.js" */
|
||||
|
||||
const COLS = 100;
|
||||
const ROWS = 100;
|
||||
import { createAverageCells } from "./cells.js";
|
||||
import { INFERNO_LUT, intensityColor } from "./lut.js";
|
||||
import { GENESIS_DATE, todayISODate } from "./time.js";
|
||||
|
||||
export const demoSource = {
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
getColor,
|
||||
const ROWS = 160;
|
||||
const DAY_MS = 86_400_000;
|
||||
const GENESIS_TIME = Date.parse(`${GENESIS_DATE}T00:00:00Z`);
|
||||
|
||||
/** @satisfies {PartialHeatmapOption} */
|
||||
export const demoHeatmapOption = {
|
||||
kind: "heatmap",
|
||||
name: "Demo",
|
||||
title: "Heatmap Demo",
|
||||
points: {
|
||||
fetch: fetchDemoPoints,
|
||||
},
|
||||
cells: createAverageCells({ yStart: 0, yEnd: 1, nativeRows: ROWS }),
|
||||
color: intensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }),
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} col
|
||||
* @param {number} row
|
||||
* @param {string} date
|
||||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<HeatmapPoints>}
|
||||
*/
|
||||
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),
|
||||
async function fetchDemoPoints(date, signal) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const values = new Float32Array(ROWS);
|
||||
const endTime = Date.parse(`${todayISODate()}T00:00:00Z`);
|
||||
const time = Date.parse(`${date}T00:00:00Z`);
|
||||
const x = Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(time - GENESIS_TIME) / Math.max(DAY_MS, endTime - GENESIS_TIME),
|
||||
),
|
||||
);
|
||||
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];
|
||||
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
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;
|
||||
values[row] = Math.min(1, Math.max(0, ridge * 0.65 + blob * 0.45 + floor));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "implicit",
|
||||
yStart: 0,
|
||||
yStep: 1 / (ROWS - 1),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {AbortSignal} signal */
|
||||
function throwIfAborted(signal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
}
|
||||
|
||||
+370
-24
@@ -1,41 +1,387 @@
|
||||
import { createHeader } from "../../scripts/utils/dom.js";
|
||||
/** @import { HeatmapOption } from "../../scripts/options/types.js" */
|
||||
/** @import { HeatmapGrid, HeatmapPoints } from "./types.js" */
|
||||
|
||||
import { createHeader, createSelect } 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 { dark, onChange as onThemeChange } from "../../scripts/utils/theme.js";
|
||||
import { createRenderer } from "./renderer.js";
|
||||
import { dateRange, GENESIS_DATE, todayISODate, toISODate } from "./time.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} RangeChoice
|
||||
* @property {string} label
|
||||
* @property {string} date
|
||||
*/
|
||||
|
||||
const MAX_PARALLEL_FETCHES = 8;
|
||||
|
||||
/** @type {ReturnType<typeof createRenderer> | undefined} */
|
||||
let renderer;
|
||||
/** @type {HTMLCanvasElement | undefined} */
|
||||
let canvas;
|
||||
/** @type {HTMLHeadingElement | undefined} */
|
||||
let headingElement;
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let statusElement;
|
||||
/** @type {HeatmapOption | undefined} */
|
||||
let currentOption;
|
||||
/** @type {HeatmapGrid | undefined} */
|
||||
let currentGrid;
|
||||
/** @type {string[]} */
|
||||
let currentDates = [];
|
||||
/** @type {Map<string, number>} */
|
||||
let currentDateIndex = new Map();
|
||||
/** @type {Map<string, HeatmapPoints>} */
|
||||
let pointsByDate = new Map();
|
||||
/** @type {AbortController | undefined} */
|
||||
let abortController;
|
||||
let loadGeneration = 0;
|
||||
let initialized = false;
|
||||
let from = GENESIS_DATE;
|
||||
let to = todayISODate();
|
||||
|
||||
/**
|
||||
* Initializes the heatmap pane once for the app lifetime.
|
||||
*
|
||||
* @param {HeatmapOption} option
|
||||
*/
|
||||
export async function init(option) {
|
||||
const { headerElement, headingElement } = createHeader();
|
||||
headingElement.innerHTML = option.title;
|
||||
export function init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const header = createHeader();
|
||||
headingElement = header.headingElement;
|
||||
const { headerElement } = header;
|
||||
heatmapElement.append(headerElement);
|
||||
heatmapElement.append(createRangeControls());
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas = document.createElement("canvas");
|
||||
heatmapElement.append(canvas);
|
||||
await next();
|
||||
renderer = createRenderer(canvas);
|
||||
|
||||
let renderer = createRenderer(canvas);
|
||||
let source = demoSource;
|
||||
canvas.addEventListener("mousemove", updateTooltip);
|
||||
canvas.addEventListener("mouseleave", () => canvas?.removeAttribute("title"));
|
||||
onThemeChange(paint);
|
||||
|
||||
function render() {
|
||||
renderer.paint(source.cols, source.rows, source.getColor);
|
||||
}
|
||||
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
if (renderer.resize(width, height)) {
|
||||
render();
|
||||
}
|
||||
void next().then(resizeAndRebuild);
|
||||
|
||||
new ResizeObserver(
|
||||
debounce(() => {
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
if (renderer.resize(width, height)) {
|
||||
render();
|
||||
}
|
||||
}, 1000),
|
||||
resizeAndRebuild();
|
||||
}, 250),
|
||||
).observe(heatmapElement);
|
||||
}
|
||||
|
||||
/** @param {HeatmapOption} option */
|
||||
export function setOption(option) {
|
||||
init();
|
||||
if (currentOption !== option) {
|
||||
currentOption = option;
|
||||
pointsByDate = new Map();
|
||||
if (headingElement) headingElement.textContent = option.title;
|
||||
if (canvas) canvas.removeAttribute("title");
|
||||
}
|
||||
loadRange();
|
||||
}
|
||||
|
||||
function resizeAndRebuild() {
|
||||
if (!canvas || !renderer) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
if (renderer.resize(width, height)) rebuildGrid();
|
||||
}
|
||||
|
||||
function loadRange() {
|
||||
if (!currentOption) return;
|
||||
|
||||
abortController?.abort();
|
||||
const generation = ++loadGeneration;
|
||||
const option = currentOption;
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
currentDates = dateRange(from, to);
|
||||
currentDateIndex = createDateIndex(currentDates);
|
||||
|
||||
rebuildGrid();
|
||||
|
||||
const missing = currentDates.filter((date) => !pointsByDate.has(date));
|
||||
let completed = currentDates.length - missing.length;
|
||||
let failed = 0;
|
||||
updateStatus(completed, currentDates.length, failed);
|
||||
|
||||
if (!missing.length) return;
|
||||
|
||||
let cursor = 0;
|
||||
const workers = Array.from({
|
||||
length: Math.min(MAX_PARALLEL_FETCHES, missing.length),
|
||||
}).map(async () => {
|
||||
let index = nextMissingIndex();
|
||||
while (index !== undefined) {
|
||||
const date = missing[index];
|
||||
try {
|
||||
const points = await option.points.fetch(date, controller.signal);
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
pointsByDate.set(date, points);
|
||||
addDateToGrid(date, points);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
failed += 1;
|
||||
console.error(`Failed to fetch heatmap points for ${date}`, error);
|
||||
} finally {
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
completed += 1;
|
||||
updateStatus(completed, currentDates.length, failed);
|
||||
}
|
||||
}
|
||||
index = nextMissingIndex();
|
||||
}
|
||||
});
|
||||
|
||||
void Promise.all(workers).then(() => {
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
updateStatus(completed, currentDates.length, failed);
|
||||
}
|
||||
});
|
||||
|
||||
function nextMissingIndex() {
|
||||
if (cursor >= missing.length) return undefined;
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {AbortController} controller
|
||||
* @param {number} generation
|
||||
*/
|
||||
function isCurrentLoad(option, controller, generation) {
|
||||
return (
|
||||
currentOption === option &&
|
||||
abortController === controller &&
|
||||
loadGeneration === generation &&
|
||||
!controller.signal.aborted
|
||||
);
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
if (
|
||||
!currentOption ||
|
||||
!renderer ||
|
||||
renderer.width < 1 ||
|
||||
renderer.height < 1 ||
|
||||
!currentDates.length
|
||||
) {
|
||||
currentGrid = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
currentGrid = currentOption.cells.create({
|
||||
dates: currentDates,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
});
|
||||
|
||||
for (let i = 0; i < currentDates.length; i++) {
|
||||
const points = pointsByDate.get(currentDates[i]);
|
||||
if (points) currentGrid.add(i, points);
|
||||
}
|
||||
|
||||
paint();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {HeatmapPoints} points
|
||||
*/
|
||||
function addDateToGrid(date, points) {
|
||||
if (!currentGrid) return;
|
||||
const dateIndex = currentDateIndex.get(date);
|
||||
if (dateIndex === undefined) return;
|
||||
const dirtyCol = currentGrid.add(dateIndex, points);
|
||||
if (dirtyCol !== undefined) paint([dirtyCol]);
|
||||
}
|
||||
|
||||
/** @param {Iterable<number>} [dirty] */
|
||||
function paint(dirty) {
|
||||
if (!renderer || !currentGrid || !currentOption) return;
|
||||
const grid = currentGrid;
|
||||
const option = currentOption;
|
||||
renderer.paint(
|
||||
grid.cols,
|
||||
grid.rows,
|
||||
(col, row) =>
|
||||
option.color(grid.getValue(col, row), { dark, 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");
|
||||
return;
|
||||
}
|
||||
canvas.title = currentOption.tooltip({ grid: currentGrid, col, row });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly string[]} dates
|
||||
* @returns {Map<string, number>}
|
||||
*/
|
||||
function createDateIndex(dates) {
|
||||
const map = new Map();
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
map.set(dates[i], i);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} completed
|
||||
* @param {number} total
|
||||
* @param {number} failed
|
||||
*/
|
||||
function updateStatus(completed, total, failed) {
|
||||
if (!statusElement) return;
|
||||
if (completed >= total) {
|
||||
statusElement.textContent = failed ? `${failed} failed` : "";
|
||||
} else {
|
||||
statusElement.textContent = failed
|
||||
? `${completed}/${total} · ${failed} failed`
|
||||
: `${completed}/${total}`;
|
||||
}
|
||||
}
|
||||
|
||||
function createRangeControls() {
|
||||
const fieldset = document.createElement("fieldset");
|
||||
fieldset.classList.add("heatmap-controls");
|
||||
|
||||
statusElement = document.createElement("small");
|
||||
statusElement.classList.add("heatmap-status");
|
||||
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const fromChoices = createFromChoices(currentYear);
|
||||
const toChoices = createToChoices(currentYear);
|
||||
let fromChoice = fromChoices[0];
|
||||
let toChoice = toChoices[0];
|
||||
|
||||
const fromSelect = createSelect({
|
||||
id: "heatmap-from",
|
||||
choices: fromChoices,
|
||||
initialValue: fromChoice,
|
||||
onChange(choice) {
|
||||
fromChoice = choice;
|
||||
setRange(fromChoice.date, toChoice.date);
|
||||
},
|
||||
toKey: rangeChoiceKey,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
const toSelect = createSelect({
|
||||
id: "heatmap-to",
|
||||
choices: toChoices,
|
||||
initialValue: toChoice,
|
||||
onChange(choice) {
|
||||
toChoice = choice;
|
||||
setRange(fromChoice.date, toChoice.date);
|
||||
},
|
||||
toKey: rangeChoiceKey,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
|
||||
const fromLabel = createControlField("from", fromSelect);
|
||||
const toLabel = createControlField("to", toSelect);
|
||||
|
||||
fieldset.append(fromLabel, toLabel, statusElement);
|
||||
|
||||
return fieldset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} currentYear
|
||||
* @returns {RangeChoice[]}
|
||||
*/
|
||||
function createFromChoices(currentYear) {
|
||||
const choices = [{ label: "genesis", date: GENESIS_DATE }];
|
||||
for (let year = 2009; year <= currentYear; year++) {
|
||||
choices.push({ label: String(year), date: yearStartISODate(year) });
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} currentYear
|
||||
* @returns {RangeChoice[]}
|
||||
*/
|
||||
function createToChoices(currentYear) {
|
||||
const choices = [{ label: "today", date: todayISODate() }];
|
||||
for (let year = currentYear; year >= 2009; year--) {
|
||||
choices.push({ label: String(year), date: yearEndISODate(year) });
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RangeChoice} choice
|
||||
*/
|
||||
function rangeChoiceKey(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RangeChoice} choice
|
||||
*/
|
||||
function rangeChoiceLabel(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {HTMLElement} control
|
||||
*/
|
||||
function createControlField(text, control) {
|
||||
const label = document.createElement("div");
|
||||
label.classList.add("heatmap-control");
|
||||
const span = document.createElement("span");
|
||||
span.textContent = text;
|
||||
const select = control.querySelector("select");
|
||||
if (select) select.ariaLabel = text;
|
||||
label.append(span, control);
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} nextFrom
|
||||
* @param {string} nextTo
|
||||
*/
|
||||
function setRange(nextFrom, nextTo) {
|
||||
if (nextFrom > nextTo) {
|
||||
from = nextTo;
|
||||
to = nextFrom;
|
||||
} else {
|
||||
from = nextFrom;
|
||||
to = nextTo;
|
||||
}
|
||||
loadRange();
|
||||
}
|
||||
|
||||
/** @param {number} year */
|
||||
function yearStartISODate(year) {
|
||||
return toISODate(new Date(Date.UTC(year, 0, 1)));
|
||||
}
|
||||
|
||||
/** @param {number} year */
|
||||
function yearEndISODate(year) {
|
||||
return toISODate(
|
||||
new Date(
|
||||
Math.min(
|
||||
Date.UTC(year, 11, 31),
|
||||
Date.parse(`${todayISODate()}T00:00:00Z`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @import { HeatmapColorFn } from "./types.js" */
|
||||
|
||||
const INFERNO_STOPS = [
|
||||
[0, 0, 0, 0],
|
||||
[0.13, 40, 11, 84],
|
||||
@@ -12,6 +14,21 @@ const INFERNO_STOPS = [
|
||||
|
||||
export const INFERNO_LUT = createColorLut(INFERNO_STOPS);
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {ArrayLike<number>} args.light
|
||||
* @param {ArrayLike<number>} args.dark
|
||||
* @returns {HeatmapColorFn}
|
||||
*/
|
||||
export function intensityColor({ light, dark }) {
|
||||
return (value, context) => {
|
||||
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 {number[][]} stops - Tuples of [position, red, green, blue].
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
#heatmap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--main-padding);
|
||||
background-color: var(--background-color);
|
||||
|
||||
> header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
margin-bottom: -0.25rem;
|
||||
padding-left: var(--main-padding);
|
||||
margin-left: var(--negative-main-padding);
|
||||
padding-right: var(--main-padding);
|
||||
margin-right: var(--negative-main-padding);
|
||||
}
|
||||
|
||||
> fieldset.heatmap-controls {
|
||||
flex-shrink: 0;
|
||||
text-transform: lowercase;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
margin: 0.5rem var(--negative-main-padding);
|
||||
padding: 0.5rem var(--main-padding);
|
||||
gap: 1rem;
|
||||
|
||||
.heatmap-control {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color);
|
||||
|
||||
span {
|
||||
color: var(--off-color);
|
||||
}
|
||||
|
||||
> div.field {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-shrink: 0;
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
|
||||
select {
|
||||
width: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-status {
|
||||
color: var(--off-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
> canvas {
|
||||
flex: 1%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const DAY_MS = 86_400_000;
|
||||
export const GENESIS_DATE = "2009-01-03";
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
/**
|
||||
* @typedef {Object} HeatmapDataSource
|
||||
* @property {(signal: AbortSignal) => Promise<string[]>} list
|
||||
* @property {(date: string, signal: AbortSignal) => Promise<unknown>} fetch
|
||||
* @typedef {Object} HeatmapImplicitPoints
|
||||
* @property {"implicit"} kind
|
||||
* @property {number} yStart
|
||||
* @property {number} yStep
|
||||
* @property {ArrayLike<number>} values
|
||||
*
|
||||
* @typedef {Object} HeatmapExplicitPoints
|
||||
* @property {"explicit"} kind
|
||||
* @property {ArrayLike<number>} y
|
||||
* @property {ArrayLike<number>} values
|
||||
*
|
||||
* @typedef {HeatmapImplicitPoints | HeatmapExplicitPoints} HeatmapPoints
|
||||
*
|
||||
* @typedef {Object} HeatmapPointSource
|
||||
* @property {(date: string, signal: AbortSignal) => Promise<HeatmapPoints>} fetch
|
||||
*
|
||||
* @typedef {Object} HeatmapRange
|
||||
* @property {number} start
|
||||
* @property {number} end
|
||||
*
|
||||
* @typedef {Object} HeatmapGrid
|
||||
* @property {readonly string[]} dates
|
||||
* @property {number} cols
|
||||
* @property {number} rows
|
||||
* @property {(dateIndex: number, points: HeatmapPoints) => number | undefined} add
|
||||
* @property {(col: number, row: number) => number} getValue
|
||||
* @property {(col: number) => HeatmapRange} getDateIndexRange
|
||||
* @property {(row: number) => HeatmapRange} getYRange
|
||||
*
|
||||
* @typedef {Object} HeatmapCells
|
||||
* @property {(args: { dates: string[], width: number, height: number }) => unknown} create
|
||||
* @property {(grid: unknown, dateIndex: number, snapshot: unknown) => number | undefined} add
|
||||
* @property {(grid: unknown, col: number, row: number) => unknown} getValue
|
||||
* @property {(args: { dates: readonly string[], width: number, height: number }) => HeatmapGrid} create
|
||||
*
|
||||
* @typedef {Object} HeatmapColorContext
|
||||
* @property {boolean} dark
|
||||
* @property {unknown} grid
|
||||
* @property {number} col
|
||||
* @property {number} row
|
||||
*
|
||||
* @typedef {(value: unknown, context: HeatmapColorContext) => number} HeatmapColorFn
|
||||
*
|
||||
* @typedef {Object} HeatmapTooltipContext
|
||||
* @property {unknown} grid
|
||||
* @property {number} col
|
||||
* @property {number} row
|
||||
*
|
||||
* @typedef {(context: HeatmapTooltipContext) => string} HeatmapTooltipFn
|
||||
* @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
|
||||
* @typedef {(context: { grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user