heatmaps: part 5

This commit is contained in:
nym21
2026-05-30 13:16:22 +02:00
parent cc8fde59e8
commit c1ff095e4b
9 changed files with 73 additions and 80 deletions
+1 -1
View File
@@ -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"
*
+1 -9
View File
@@ -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;
+1 -1
View File
@@ -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]
*
+9 -1
View File
@@ -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,
};
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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 }),
};
+47 -47
View File
@@ -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();
}
+10 -17
View File
@@ -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;
}
+1 -1
View File
@@ -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