mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
heatmaps: part 5
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* @import { Color } from "./utils/colors.js"
|
||||
*
|
||||
* @import { HeatmapPointSource, HeatmapCells, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js"
|
||||
* @import { HeatmapPointSource, HeatmapGridFactory, 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,10 +8,7 @@ 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,
|
||||
setOption as setHeatmapOption,
|
||||
} from "../src/heatmap/index.js";
|
||||
import { setOption as setHeatmapOption } from "../src/heatmap/index.js";
|
||||
import { readStored, removeStored, writeToStorage } from "./utils/storage.js";
|
||||
import {
|
||||
asideElement,
|
||||
@@ -130,7 +127,6 @@ function initSelected() {
|
||||
|
||||
let previousElement = /** @type {HTMLElement | undefined} */ (undefined);
|
||||
let firstTimeLoadingChart = true;
|
||||
let firstTimeLoadingHeatmap = true;
|
||||
let firstTimeLoadingExplorer = true;
|
||||
|
||||
options.selected.onChange((option) => {
|
||||
@@ -155,10 +151,6 @@ function initSelected() {
|
||||
|
||||
element = heatmapElement;
|
||||
|
||||
if (firstTimeLoadingHeatmap) {
|
||||
initHeatmap();
|
||||
}
|
||||
firstTimeLoadingHeatmap = false;
|
||||
setHeatmapOption(option);
|
||||
|
||||
break;
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
* @property {"heatmap"} kind
|
||||
* @property {string} title
|
||||
* @property {HeatmapPointSource} points
|
||||
* @property {HeatmapCells} cells
|
||||
* @property {HeatmapGridFactory} grid
|
||||
* @property {HeatmapColorFn} color
|
||||
* @property {HeatmapTooltipFn} [tooltip]
|
||||
*
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createLabeledInput({
|
||||
* @param {Object} args
|
||||
* @param {T} args.initialValue
|
||||
* @param {string} [args.id]
|
||||
* @param {string} [args.label]
|
||||
* @param {readonly T[]} args.choices
|
||||
* @param {(value: T) => void} [args.onChange]
|
||||
* @param {(choice: T) => string} [args.toKey]
|
||||
@@ -245,6 +246,7 @@ export function createRadios({
|
||||
* @param {Object} args
|
||||
* @param {T} args.initialValue
|
||||
* @param {string} [args.id]
|
||||
* @param {string} [args.label]
|
||||
* @param {readonly T[]} args.choices
|
||||
* @param {(value: T) => void} [args.onChange]
|
||||
* @param {(choice: T) => string} [args.toKey]
|
||||
@@ -254,6 +256,7 @@ export function createRadios({
|
||||
*/
|
||||
export function createSelect({
|
||||
id,
|
||||
label,
|
||||
choices: unsortedChoices,
|
||||
groups,
|
||||
initialValue,
|
||||
@@ -284,6 +287,12 @@ export function createSelect({
|
||||
const select = window.document.createElement("select");
|
||||
select.id = id ?? "";
|
||||
select.name = id ?? "";
|
||||
if (label) {
|
||||
const labelElement = window.document.createElement("label");
|
||||
labelElement.htmlFor = select.id;
|
||||
labelElement.textContent = label;
|
||||
field.append(labelElement);
|
||||
}
|
||||
field.append(select);
|
||||
|
||||
/** @param {T} choice */
|
||||
@@ -348,4 +357,3 @@ export function createHeader(title = "", level = 1) {
|
||||
headingElement,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** @import { HeatmapCells, HeatmapGrid, HeatmapRange } from "./types.js" */
|
||||
/** @import { HeatmapGrid, HeatmapGridFactory, HeatmapRange } from "./types.js" */
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
@@ -18,7 +18,7 @@ function clamp(value, min, max) {
|
||||
* @param {number} [args.minCellSize]
|
||||
* @param {number} [args.maxCols]
|
||||
* @param {number} [args.nativeRows]
|
||||
* @returns {HeatmapCells}
|
||||
* @returns {HeatmapGridFactory}
|
||||
*/
|
||||
export function createAverageCells({
|
||||
yStart,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const demoHeatmapOption = {
|
||||
points: {
|
||||
fetch: fetchDemoPoints,
|
||||
},
|
||||
cells: createAverageCells({ yStart: 0, yEnd: 1, nativeRows: ROWS }),
|
||||
grid: createAverageCells({ yStart: 0, yEnd: 1, nativeRows: ROWS }),
|
||||
color: intensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }),
|
||||
};
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ let currentOption;
|
||||
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} */
|
||||
@@ -98,11 +96,12 @@ function loadRange() {
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
currentDates = dateRange(from, to);
|
||||
currentDateIndex = createDateIndex(currentDates);
|
||||
|
||||
rebuildGrid();
|
||||
|
||||
const missing = currentDates.filter((date) => !pointsByDate.has(date));
|
||||
const missing = currentDates.flatMap((date, dateIndex) =>
|
||||
pointsByDate.has(date) ? [] : [{ date, dateIndex }],
|
||||
);
|
||||
let completed = currentDates.length - missing.length;
|
||||
let failed = 0;
|
||||
updateStatus(completed, currentDates.length, failed);
|
||||
@@ -117,15 +116,15 @@ function loadRange() {
|
||||
while (index !== undefined) {
|
||||
const date = missing[index];
|
||||
try {
|
||||
const points = await option.points.fetch(date, controller.signal);
|
||||
const points = await option.points.fetch(date.date, controller.signal);
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
pointsByDate.set(date, points);
|
||||
addDateToGrid(date, points);
|
||||
pointsByDate.set(date.date, points);
|
||||
addDateToGrid(date.dateIndex, points);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
failed += 1;
|
||||
console.error(`Failed to fetch heatmap points for ${date}`, error);
|
||||
console.error(`Failed to fetch heatmap points for ${date.date}`, error);
|
||||
} finally {
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
completed += 1;
|
||||
@@ -176,7 +175,7 @@ function rebuildGrid() {
|
||||
return;
|
||||
}
|
||||
|
||||
currentGrid = currentOption.cells.create({
|
||||
currentGrid = currentOption.grid.create({
|
||||
dates: currentDates,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
@@ -191,13 +190,11 @@ function rebuildGrid() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {number} dateIndex
|
||||
* @param {HeatmapPoints} points
|
||||
*/
|
||||
function addDateToGrid(date, points) {
|
||||
function addDateToGrid(dateIndex, points) {
|
||||
if (!currentGrid) return;
|
||||
const dateIndex = currentDateIndex.get(date);
|
||||
if (dateIndex === undefined) return;
|
||||
const dirtyCol = currentGrid.add(dateIndex, points);
|
||||
if (dirtyCol !== undefined) paint([dirtyCol]);
|
||||
}
|
||||
@@ -229,18 +226,6 @@ function updateTooltip(event) {
|
||||
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
|
||||
@@ -272,10 +257,15 @@ function createRangeControls() {
|
||||
|
||||
const fromSelect = createSelect({
|
||||
id: "heatmap-from",
|
||||
label: "from",
|
||||
choices: fromChoices,
|
||||
initialValue: fromChoice,
|
||||
onChange(choice) {
|
||||
fromChoice = choice;
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
toChoice = findFirstToChoiceOnOrAfter(toChoices, fromChoice.date);
|
||||
setSelectChoice(toSelect, toChoice);
|
||||
}
|
||||
setRange(fromChoice.date, toChoice.date);
|
||||
},
|
||||
toKey: rangeChoiceKey,
|
||||
@@ -283,20 +273,22 @@ function createRangeControls() {
|
||||
});
|
||||
const toSelect = createSelect({
|
||||
id: "heatmap-to",
|
||||
label: "to",
|
||||
choices: toChoices,
|
||||
initialValue: toChoice,
|
||||
onChange(choice) {
|
||||
toChoice = choice;
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
fromChoice = findLastFromChoiceOnOrBefore(fromChoices, toChoice.date);
|
||||
setSelectChoice(fromSelect, fromChoice);
|
||||
}
|
||||
setRange(fromChoice.date, toChoice.date);
|
||||
},
|
||||
toKey: rangeChoiceKey,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
|
||||
const fromLabel = createControlField("from", fromSelect);
|
||||
const toLabel = createControlField("to", toSelect);
|
||||
|
||||
fieldset.append(fromLabel, toLabel, statusElement);
|
||||
fieldset.append(fromSelect, toSelect, statusElement);
|
||||
|
||||
return fieldset;
|
||||
}
|
||||
@@ -308,7 +300,10 @@ function createRangeControls() {
|
||||
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) });
|
||||
choices.push({
|
||||
label: String(year),
|
||||
date: year === 2009 ? GENESIS_DATE : yearStartISODate(year),
|
||||
});
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
@@ -340,18 +335,28 @@ function rangeChoiceLabel(choice) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {HTMLElement} control
|
||||
* @param {readonly RangeChoice[]} choices
|
||||
* @param {string} date
|
||||
*/
|
||||
function createControlField(text, control) {
|
||||
const label = document.createElement("div");
|
||||
label.classList.add("heatmap-control");
|
||||
const span = document.createElement("span");
|
||||
span.textContent = text;
|
||||
function findFirstToChoiceOnOrAfter(choices, date) {
|
||||
return choices.find((choice) => choice.date >= date) ?? choices[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly RangeChoice[]} choices
|
||||
* @param {string} date
|
||||
*/
|
||||
function findLastFromChoiceOnOrBefore(choices, date) {
|
||||
return choices.findLast((choice) => choice.date <= date) ?? choices[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} control
|
||||
* @param {RangeChoice} choice
|
||||
*/
|
||||
function setSelectChoice(control, choice) {
|
||||
const select = control.querySelector("select");
|
||||
if (select) select.ariaLabel = text;
|
||||
label.append(span, control);
|
||||
return label;
|
||||
if (select) select.value = rangeChoiceKey(choice);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,13 +364,8 @@ function createControlField(text, control) {
|
||||
* @param {string} nextTo
|
||||
*/
|
||||
function setRange(nextFrom, nextTo) {
|
||||
if (nextFrom > nextTo) {
|
||||
from = nextTo;
|
||||
to = nextFrom;
|
||||
} else {
|
||||
from = nextFrom;
|
||||
to = nextTo;
|
||||
}
|
||||
from = nextFrom;
|
||||
to = nextTo;
|
||||
loadRange();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
margin-right: var(--negative-main-padding);
|
||||
}
|
||||
|
||||
> fieldset.heatmap-controls {
|
||||
> fieldset {
|
||||
flex-shrink: 0;
|
||||
text-transform: lowercase;
|
||||
overflow-x: auto;
|
||||
@@ -31,32 +31,25 @@
|
||||
padding: 0.5rem var(--main-padding);
|
||||
gap: 1rem;
|
||||
|
||||
.heatmap-control {
|
||||
> div.field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color);
|
||||
|
||||
span {
|
||||
> label {
|
||||
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;
|
||||
}
|
||||
select {
|
||||
width: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-status {
|
||||
> small {
|
||||
color: var(--off-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
* @property {(col: number) => HeatmapRange} getDateIndexRange
|
||||
* @property {(row: number) => HeatmapRange} getYRange
|
||||
*
|
||||
* @typedef {Object} HeatmapCells
|
||||
* @typedef {Object} HeatmapGridFactory
|
||||
* @property {(args: { dates: readonly string[], width: number, height: number }) => HeatmapGrid} create
|
||||
*
|
||||
* @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
|
||||
|
||||
Reference in New Issue
Block a user