mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-24 01:18:10 -07:00
heatmaps: part 4
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
* @import { Color } from "./utils/colors.js"
|
* @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"
|
* @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";
|
} from "./panes/chart.js";
|
||||||
import { init as initExplorer } from "./explorer/index.js";
|
import { init as initExplorer } from "./explorer/index.js";
|
||||||
import { init as initSearch } from "./panes/search.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 { readStored, removeStored, writeToStorage } from "./utils/storage.js";
|
||||||
import {
|
import {
|
||||||
asideElement,
|
asideElement,
|
||||||
@@ -153,9 +156,10 @@ function initSelected() {
|
|||||||
element = heatmapElement;
|
element = heatmapElement;
|
||||||
|
|
||||||
if (firstTimeLoadingHeatmap) {
|
if (firstTimeLoadingHeatmap) {
|
||||||
initHeatmap(option);
|
initHeatmap();
|
||||||
}
|
}
|
||||||
firstTimeLoadingHeatmap = false;
|
firstTimeLoadingHeatmap = false;
|
||||||
|
setHeatmapOption(option);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { createNetworkSection } from "./network.js";
|
|||||||
import { createMiningSection } from "./mining.js";
|
import { createMiningSection } from "./mining.js";
|
||||||
import { createCointimeSection } from "./cointime.js";
|
import { createCointimeSection } from "./cointime.js";
|
||||||
import { createInvestingSection } from "./investing.js";
|
import { createInvestingSection } from "./investing.js";
|
||||||
|
import { demoHeatmapOption } from "../../src/heatmap/demo.js";
|
||||||
|
|
||||||
// Re-export types for external consumers
|
// Re-export types for external consumers
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
@@ -298,13 +299,7 @@ export function createPartialOptions() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
name: "Heatmaps",
|
name: "Heatmaps",
|
||||||
tree: [
|
tree: [demoHeatmapOption],
|
||||||
{
|
|
||||||
kind: "heatmap",
|
|
||||||
name: "Demo",
|
|
||||||
title: "Heatmap Demo",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -107,14 +107,14 @@
|
|||||||
* @typedef {Object} PartialHeatmapOptionSpecific
|
* @typedef {Object} PartialHeatmapOptionSpecific
|
||||||
* @property {"heatmap"} kind
|
* @property {"heatmap"} kind
|
||||||
* @property {string} title
|
* @property {string} title
|
||||||
* @property {HeatmapDataSource} data
|
* @property {HeatmapPointSource} points
|
||||||
* @property {HeatmapCells} cells
|
* @property {HeatmapCells} cells
|
||||||
* @property {HeatmapColorFn} color
|
* @property {HeatmapColorFn} color
|
||||||
* @property {HeatmapTooltipFn} [tooltip]
|
* @property {HeatmapTooltipFn} [tooltip]
|
||||||
*
|
*
|
||||||
* @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption
|
* @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption
|
||||||
*
|
*
|
||||||
* @typedef {Required<PartialHeatmapOption> & ProcessedOptionAddons} HeatmapOption
|
* @typedef {Required<Omit<PartialHeatmapOption, "tooltip">> & Pick<PartialHeatmapOption, "tooltip"> & ProcessedOptionAddons} HeatmapOption
|
||||||
*
|
*
|
||||||
* @typedef {Object} PartialUrlOptionSpecific
|
* @typedef {Object} PartialUrlOptionSpecific
|
||||||
* @property {"link"} [kind]
|
* @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;
|
import { createAverageCells } from "./cells.js";
|
||||||
const ROWS = 100;
|
import { INFERNO_LUT, intensityColor } from "./lut.js";
|
||||||
|
import { GENESIS_DATE, todayISODate } from "./time.js";
|
||||||
|
|
||||||
export const demoSource = {
|
const ROWS = 160;
|
||||||
cols: COLS,
|
const DAY_MS = 86_400_000;
|
||||||
rows: ROWS,
|
const GENESIS_TIME = Date.parse(`${GENESIS_DATE}T00:00:00Z`);
|
||||||
getColor,
|
|
||||||
|
/** @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 {string} date
|
||||||
* @param {number} row
|
* @param {AbortSignal} signal
|
||||||
|
* @returns {Promise<HeatmapPoints>}
|
||||||
*/
|
*/
|
||||||
function getColor(col, row) {
|
async function fetchDemoPoints(date, signal) {
|
||||||
const x = col / (COLS - 1);
|
throwIfAborted(signal);
|
||||||
const y = row / (ROWS - 1);
|
|
||||||
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
|
const values = new Float32Array(ROWS);
|
||||||
const blob = Math.exp(
|
const endTime = Date.parse(`${todayISODate()}T00:00:00Z`);
|
||||||
-(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028),
|
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(
|
for (let row = 0; row < ROWS; row++) {
|
||||||
255,
|
const y = row / (ROWS - 1);
|
||||||
Math.max(0, ((ridge * 0.65 + blob * 0.45 + floor) * 255) | 0),
|
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
|
||||||
);
|
const blob = Math.exp(
|
||||||
return INFERNO_LUT[i];
|
-(((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 { heatmapElement } from "../../scripts/utils/elements.js";
|
||||||
import { debounce, next } from "../../scripts/utils/timing.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 { 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.
|
* Initializes the heatmap pane once for the app lifetime.
|
||||||
*
|
|
||||||
* @param {HeatmapOption} option
|
|
||||||
*/
|
*/
|
||||||
export async function init(option) {
|
export function init() {
|
||||||
const { headerElement, headingElement } = createHeader();
|
if (initialized) return;
|
||||||
headingElement.innerHTML = option.title;
|
initialized = true;
|
||||||
|
|
||||||
|
const header = createHeader();
|
||||||
|
headingElement = header.headingElement;
|
||||||
|
const { headerElement } = header;
|
||||||
heatmapElement.append(headerElement);
|
heatmapElement.append(headerElement);
|
||||||
|
heatmapElement.append(createRangeControls());
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
canvas = document.createElement("canvas");
|
||||||
heatmapElement.append(canvas);
|
heatmapElement.append(canvas);
|
||||||
await next();
|
renderer = createRenderer(canvas);
|
||||||
|
|
||||||
let renderer = createRenderer(canvas);
|
canvas.addEventListener("mousemove", updateTooltip);
|
||||||
let source = demoSource;
|
canvas.addEventListener("mouseleave", () => canvas?.removeAttribute("title"));
|
||||||
|
onThemeChange(paint);
|
||||||
|
|
||||||
function render() {
|
void next().then(resizeAndRebuild);
|
||||||
renderer.paint(source.cols, source.rows, source.getColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
|
||||||
if (renderer.resize(width, height)) {
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
new ResizeObserver(
|
new ResizeObserver(
|
||||||
debounce(() => {
|
debounce(() => {
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
resizeAndRebuild();
|
||||||
if (renderer.resize(width, height)) {
|
}, 250),
|
||||||
render();
|
|
||||||
}
|
|
||||||
}, 1000),
|
|
||||||
).observe(heatmapElement);
|
).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 = [
|
const INFERNO_STOPS = [
|
||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
[0.13, 40, 11, 84],
|
[0.13, 40, 11, 84],
|
||||||
@@ -12,6 +14,21 @@ const INFERNO_STOPS = [
|
|||||||
|
|
||||||
export const INFERNO_LUT = createColorLut(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].
|
* @param {number[][]} stops - Tuples of [position, red, green, blue].
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,10 +1,73 @@
|
|||||||
#heatmap {
|
#heatmap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
> canvas {
|
||||||
flex: 1%;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
|
export const GENESIS_DATE = "2009-01-03";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {Object} HeatmapDataSource
|
* @typedef {Object} HeatmapImplicitPoints
|
||||||
* @property {(signal: AbortSignal) => Promise<string[]>} list
|
* @property {"implicit"} kind
|
||||||
* @property {(date: string, signal: AbortSignal) => Promise<unknown>} fetch
|
* @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
|
* @typedef {Object} HeatmapCells
|
||||||
* @property {(args: { dates: string[], width: number, height: number }) => unknown} create
|
* @property {(args: { dates: readonly string[], width: number, height: number }) => HeatmapGrid} create
|
||||||
* @property {(grid: unknown, dateIndex: number, snapshot: unknown) => number | undefined} add
|
|
||||||
* @property {(grid: unknown, col: number, row: number) => unknown} getValue
|
|
||||||
*
|
*
|
||||||
* @typedef {Object} HeatmapColorContext
|
* @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
|
||||||
* @property {boolean} dark
|
* @typedef {(context: { grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn
|
||||||
* @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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Reference in New Issue
Block a user