mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-16 09:49:44 -07:00
heatmaps: part 19
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { createSelect } from "../../../scripts/utils/dom.js";
|
||||
import { GENESIS_DATE, todayISODate, toISODate } from "../time.js";
|
||||
import { createHeatmapPersistedValue, findChoiceByKey } from "./shared.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} RangeChoice
|
||||
* @property {string} label
|
||||
* @property {string} date
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {(range: { from: string, to: string }) => void} onChange
|
||||
*/
|
||||
export function createDateControls(option, onChange) {
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const fromChoices = createFromChoices(currentYear);
|
||||
const toChoices = createToChoices(currentYear);
|
||||
const fallbackFromChoice = fromChoices.at(-1) ?? fromChoices[0];
|
||||
const fallbackToChoice = toChoices[0];
|
||||
const defaultFromChoice = findChoiceByKey(
|
||||
fromChoices,
|
||||
option.defaults?.from ?? "",
|
||||
fallbackFromChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
const defaultToChoice = findChoiceByKey(
|
||||
toChoices,
|
||||
option.defaults?.to ?? "",
|
||||
fallbackToChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
|
||||
const persistedFrom = createHeatmapPersistedValue(
|
||||
option,
|
||||
"from",
|
||||
"hm_from",
|
||||
rangeChoiceLabel(defaultFromChoice),
|
||||
);
|
||||
const persistedTo = createHeatmapPersistedValue(
|
||||
option,
|
||||
"to",
|
||||
"hm_to",
|
||||
rangeChoiceLabel(defaultToChoice),
|
||||
);
|
||||
|
||||
let fromChoice = findChoiceByKey(
|
||||
fromChoices,
|
||||
persistedFrom.value,
|
||||
defaultFromChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
let toChoice = findChoiceByKey(
|
||||
toChoices,
|
||||
persistedTo.value,
|
||||
defaultToChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice);
|
||||
}
|
||||
persistDateChoices();
|
||||
|
||||
const fromSelect = createSelect({
|
||||
id: "heatmap-from",
|
||||
label: "from",
|
||||
choices: fromChoices,
|
||||
initialValue: fromChoice,
|
||||
onChange(choice) {
|
||||
fromChoice = choice;
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice);
|
||||
toSelect.set(toChoice);
|
||||
}
|
||||
persistDateChoices();
|
||||
onChange({ from: fromChoice.date, to: toChoice.date });
|
||||
},
|
||||
toKey: rangeChoiceLabel,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
const toSelect = createSelect({
|
||||
id: "heatmap-to",
|
||||
label: "to",
|
||||
choices: toChoices,
|
||||
initialValue: toChoice,
|
||||
onChange(choice) {
|
||||
toChoice = choice;
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
fromChoice = findSameLabelChoice(
|
||||
fromChoices,
|
||||
toChoice,
|
||||
defaultFromChoice,
|
||||
);
|
||||
fromSelect.set(fromChoice);
|
||||
}
|
||||
persistDateChoices();
|
||||
onChange({ from: fromChoice.date, to: toChoice.date });
|
||||
},
|
||||
toKey: rangeChoiceLabel,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
|
||||
return {
|
||||
elements: [fromSelect.element, toSelect.element],
|
||||
from: fromChoice.date,
|
||||
to: toChoice.date,
|
||||
};
|
||||
|
||||
function persistDateChoices() {
|
||||
persistedFrom.setImmediate(rangeChoiceLabel(fromChoice));
|
||||
persistedTo.setImmediate(rangeChoiceLabel(toChoice));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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: year === 2009 ? GENESIS_DATE : yearStartISODate(year),
|
||||
});
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} currentYear
|
||||
* @returns {RangeChoice[]}
|
||||
*/
|
||||
function createToChoices(currentYear) {
|
||||
const today = todayISODate();
|
||||
const todayTime = Date.parse(`${today}T00:00:00Z`);
|
||||
const choices = [{ label: "today", date: today }];
|
||||
for (let year = currentYear; year >= 2009; year--) {
|
||||
choices.push({ label: String(year), date: yearEndISODate(year, todayTime) });
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/** @param {RangeChoice} choice */
|
||||
function rangeChoiceLabel(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly RangeChoice[]} choices
|
||||
* @param {RangeChoice} choice
|
||||
* @param {RangeChoice} fallback
|
||||
*/
|
||||
function findSameLabelChoice(choices, choice, fallback) {
|
||||
return choices.find((candidate) => candidate.label === choice.label) ?? fallback;
|
||||
}
|
||||
|
||||
/** @param {number} year */
|
||||
function yearStartISODate(year) {
|
||||
return toISODate(new Date(Date.UTC(year, 0, 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} year
|
||||
* @param {number} todayTime
|
||||
*/
|
||||
function yearEndISODate(year, todayTime) {
|
||||
return toISODate(new Date(Math.min(Date.UTC(year, 11, 31), todayTime)));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createDateControls } from "./dates.js";
|
||||
import { createYControls } from "./y.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} HeatmapControlSelection
|
||||
* @property {string} from
|
||||
* @property {string} to
|
||||
* @property {number | undefined} yMin
|
||||
* @property {number | undefined} yMax
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {(range: { from: string, to: string }) => void} args.onRangeChange
|
||||
* @param {(range: { yMin: number | undefined, yMax: number | undefined }) => void} args.onYRangeChange
|
||||
*/
|
||||
export function createHeatmapControls({ onRangeChange, onYRangeChange }) {
|
||||
const element = document.createElement("fieldset");
|
||||
|
||||
return {
|
||||
element,
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @returns {HeatmapControlSelection}
|
||||
*/
|
||||
setOption(option) {
|
||||
const dates = createDateControls(option, onRangeChange);
|
||||
const y = createYControls(option, onYRangeChange);
|
||||
element.replaceChildren(...dates.elements, ...y.elements);
|
||||
return {
|
||||
from: dates.from,
|
||||
to: dates.to,
|
||||
yMin: y.yMin,
|
||||
yMax: y.yMax,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createPersistedValue } from "../../../scripts/utils/persisted.js";
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {string} key
|
||||
* @param {string} urlKey
|
||||
* @param {string} defaultValue
|
||||
*/
|
||||
export function createHeatmapPersistedValue(option, key, urlKey, defaultValue) {
|
||||
return createPersistedValue({
|
||||
defaultValue,
|
||||
storageKey: `${heatmapStoragePrefix(option)}-${key}`,
|
||||
urlKey,
|
||||
serialize: (value) => value,
|
||||
deserialize: (value) => value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {readonly T[]} choices
|
||||
* @param {string} key
|
||||
* @param {T} fallback
|
||||
* @param {(choice: T) => string} toKey
|
||||
*/
|
||||
export function findChoiceByKey(choices, key, fallback, toKey) {
|
||||
return choices.find((candidate) => toKey(candidate) === key) ?? fallback;
|
||||
}
|
||||
|
||||
/** @param {HeatmapOption} option */
|
||||
function heatmapStoragePrefix(option) {
|
||||
return `heatmap-${option.path.join("-")}`;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createSelect } from "../../../scripts/utils/dom.js";
|
||||
import { createHeatmapPersistedValue, findChoiceByKey } from "./shared.js";
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {(range: { yMin: number | undefined, yMax: number | undefined }) => void} onChange
|
||||
*/
|
||||
export function createYControls(option, onChange) {
|
||||
const y = option.axis?.y;
|
||||
const choices = y?.choices;
|
||||
if (!choices || choices.length < 2) {
|
||||
return { elements: [], yMin: undefined, yMax: undefined };
|
||||
}
|
||||
|
||||
const fallbackMinChoice = choices[0];
|
||||
const fallbackMaxChoice = choices.at(-1) ?? choices[0];
|
||||
const defaultMinChoice = findChoiceByKey(
|
||||
choices,
|
||||
String(option.defaults?.yMin ?? ""),
|
||||
fallbackMinChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
const defaultMaxChoice = findChoiceByKey(
|
||||
choices,
|
||||
String(option.defaults?.yMax ?? ""),
|
||||
fallbackMaxChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
const persistedMin = createHeatmapPersistedValue(
|
||||
option,
|
||||
"y-min",
|
||||
"hm_y_min",
|
||||
axisChoiceKey(defaultMinChoice),
|
||||
);
|
||||
const persistedMax = createHeatmapPersistedValue(
|
||||
option,
|
||||
"y-max",
|
||||
"hm_y_max",
|
||||
axisChoiceKey(defaultMaxChoice),
|
||||
);
|
||||
|
||||
let minChoice = findChoiceByKey(
|
||||
choices,
|
||||
persistedMin.value,
|
||||
defaultMinChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
let maxChoice = findChoiceByKey(
|
||||
choices,
|
||||
persistedMax.value,
|
||||
defaultMaxChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
if (minChoice.value > maxChoice.value) {
|
||||
maxChoice = minChoice;
|
||||
}
|
||||
persistYChoices();
|
||||
|
||||
const minSelect = createSelect({
|
||||
id: "heatmap-y-min",
|
||||
label: "min",
|
||||
choices,
|
||||
initialValue: minChoice,
|
||||
onChange(choice) {
|
||||
minChoice = choice;
|
||||
if (minChoice.value > maxChoice.value) {
|
||||
maxChoice = minChoice;
|
||||
maxSelect.set(maxChoice);
|
||||
}
|
||||
persistYChoices();
|
||||
onChange({ yMin: minChoice.value, yMax: maxChoice.value });
|
||||
},
|
||||
toKey: axisChoiceKey,
|
||||
toLabel: axisChoiceLabel,
|
||||
});
|
||||
const maxSelect = createSelect({
|
||||
id: "heatmap-y-max",
|
||||
label: "max",
|
||||
choices: Array.from(choices).reverse(),
|
||||
initialValue: maxChoice,
|
||||
onChange(choice) {
|
||||
maxChoice = choice;
|
||||
if (minChoice.value > maxChoice.value) {
|
||||
minChoice = maxChoice;
|
||||
minSelect.set(minChoice);
|
||||
}
|
||||
persistYChoices();
|
||||
onChange({ yMin: minChoice.value, yMax: maxChoice.value });
|
||||
},
|
||||
toKey: axisChoiceKey,
|
||||
toLabel: axisChoiceLabel,
|
||||
});
|
||||
|
||||
return {
|
||||
elements: [minSelect.element, maxSelect.element],
|
||||
yMin: minChoice.value,
|
||||
yMax: maxChoice.value,
|
||||
};
|
||||
|
||||
function persistYChoices() {
|
||||
persistedMin.setImmediate(axisChoiceKey(minChoice));
|
||||
persistedMax.setImmediate(axisChoiceKey(maxChoice));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceKey(choice) {
|
||||
return String(choice.value);
|
||||
}
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceLabel(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
/** @import { PartialHeatmapOption } from "../../scripts/options/types.js" */
|
||||
/** @import { HeatmapPoints } from "./types.js" */
|
||||
|
||||
import { createAverageGrid } from "./grid.js";
|
||||
import { INFERNO_LUT, intensityColor } from "./lut.js";
|
||||
import { GENESIS_DATE, todayISODate } from "./time.js";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** @import { HeatmapGrid, HeatmapGridFactory, HeatmapRange } from "./types.js" */
|
||||
|
||||
/**
|
||||
* Generic date/y binning with average merge semantics.
|
||||
*
|
||||
|
||||
+44
-486
@@ -1,52 +1,32 @@
|
||||
/** @import { HeatmapOption } from "../../scripts/options/types.js" */
|
||||
/** @import { HeatmapAxisChoice, HeatmapGrid, HeatmapPoints } from "./types.js" */
|
||||
|
||||
import { createHeader, createSelect } from "../../scripts/utils/dom.js";
|
||||
import { createHeader } 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 { createHeatmapControls } from "./controls/index.js";
|
||||
import { createHeatmapLoader } from "./loader.js";
|
||||
import { createRenderer } from "./renderer.js";
|
||||
import { dateRange, GENESIS_DATE, todayISODate, toISODate } from "./time.js";
|
||||
import { createTooltipView } from "./tooltip/view.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 {ReturnType<typeof createTooltipView> | undefined} */
|
||||
let tooltipView;
|
||||
/** @type {ReturnType<typeof createHeatmapControls> | undefined} */
|
||||
let controls;
|
||||
/** @type {ReturnType<typeof createHeatmapLoader> | undefined} */
|
||||
let loader;
|
||||
/** @type {HTMLHeadingElement | undefined} */
|
||||
let headingElement;
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let rangeControlsElement;
|
||||
/** @type {HTMLElement[]} */
|
||||
let dateControlElements = [];
|
||||
/** @type {HTMLElement[]} */
|
||||
let yControlElements = [];
|
||||
/** @type {HeatmapOption | undefined} */
|
||||
let currentOption;
|
||||
/** @type {HeatmapGrid | undefined} */
|
||||
let currentGrid;
|
||||
/** @type {string[]} */
|
||||
let currentDates = [];
|
||||
/** @type {Map<string, HeatmapPoints>} */
|
||||
let pointsByDate = new Map();
|
||||
/** @type {AbortController | undefined} */
|
||||
let abortController;
|
||||
const dirtyCols = new Set();
|
||||
let loadGeneration = 0;
|
||||
let paintScheduled = false;
|
||||
let initialized = false;
|
||||
let from = yearStartISODate(new Date().getUTCFullYear());
|
||||
let to = todayISODate();
|
||||
let from = "";
|
||||
let to = "";
|
||||
/** @type {number | undefined} */
|
||||
let yMin;
|
||||
/** @type {number | undefined} */
|
||||
@@ -62,12 +42,28 @@ export function init() {
|
||||
const header = createHeader();
|
||||
headingElement = header.headingElement;
|
||||
const { headerElement } = header;
|
||||
controls = createHeatmapControls({
|
||||
onRangeChange(range) {
|
||||
from = range.from;
|
||||
to = range.to;
|
||||
hideTooltip();
|
||||
loadRange();
|
||||
},
|
||||
onYRangeChange(range) {
|
||||
yMin = range.yMin;
|
||||
yMax = range.yMax;
|
||||
hideTooltip();
|
||||
rebuildGrid();
|
||||
},
|
||||
});
|
||||
|
||||
heatmapElement.append(headerElement);
|
||||
heatmapElement.append(createRangeControls());
|
||||
heatmapElement.append(controls.element);
|
||||
|
||||
canvas = document.createElement("canvas");
|
||||
heatmapElement.append(canvas);
|
||||
renderer = createRenderer(canvas);
|
||||
loader = createHeatmapLoader({ addDateToGrid, rebuildGrid, paint });
|
||||
tooltipView = createTooltipView(heatmapElement);
|
||||
|
||||
canvas.addEventListener("pointermove", updateHoverTooltip);
|
||||
@@ -89,10 +85,14 @@ export function setOption(option) {
|
||||
init();
|
||||
if (currentOption !== option) {
|
||||
currentOption = option;
|
||||
pointsByDate = new Map();
|
||||
updateDateControls(option);
|
||||
updateYControls(option);
|
||||
renderRangeControls();
|
||||
loader?.reset();
|
||||
const selection = controls?.setOption(option);
|
||||
if (selection) {
|
||||
from = selection.from;
|
||||
to = selection.to;
|
||||
yMin = selection.yMin;
|
||||
yMax = selection.yMax;
|
||||
}
|
||||
if (headingElement) headingElement.textContent = option.title;
|
||||
hideTooltip();
|
||||
}
|
||||
@@ -106,127 +106,34 @@ function resizeAndRebuild() {
|
||||
}
|
||||
|
||||
function loadRange() {
|
||||
if (!currentOption) return;
|
||||
|
||||
abortController?.abort();
|
||||
const generation = ++loadGeneration;
|
||||
const option = currentOption;
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
currentDates = dateRange(from, to);
|
||||
|
||||
/** @type {{ date: string, dateIndex: number }[]} */
|
||||
const missing = [];
|
||||
for (let dateIndex = 0; dateIndex < currentDates.length; dateIndex++) {
|
||||
const date = currentDates[dateIndex];
|
||||
if (!pointsByDate.has(date)) missing.push({ date, dateIndex });
|
||||
}
|
||||
if (!missing.length) {
|
||||
rebuildGrid();
|
||||
abortController = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
let needsRebuild = false;
|
||||
const workers = Array.from({
|
||||
length: Math.min(MAX_PARALLEL_FETCHES, missing.length),
|
||||
}).map(async () => {
|
||||
let index = nextMissingIndex();
|
||||
while (index !== undefined) {
|
||||
const entry = missing[index];
|
||||
try {
|
||||
const points = await option.points.fetch(
|
||||
entry.date,
|
||||
controller.signal,
|
||||
(points) => {
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
setPoints(entry, points);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
setPoints(entry, points);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error(`Failed to fetch heatmap points for ${entry.date}`, error);
|
||||
}
|
||||
index = nextMissingIndex();
|
||||
}
|
||||
});
|
||||
|
||||
rebuildGrid();
|
||||
|
||||
void Promise.all(workers).then(() => {
|
||||
if (isCurrentLoad(option, controller, generation)) {
|
||||
if (needsRebuild) {
|
||||
rebuildGrid();
|
||||
} else {
|
||||
paint();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function nextMissingIndex() {
|
||||
if (cursor >= missing.length) return undefined;
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ date: string, dateIndex: number }} entry
|
||||
* @param {HeatmapPoints} points
|
||||
*/
|
||||
function setPoints(entry, points) {
|
||||
const previous = pointsByDate.get(entry.date);
|
||||
if (previous && samePoints(previous, points)) return;
|
||||
pointsByDate.set(entry.date, points);
|
||||
if (previous) {
|
||||
needsRebuild = true;
|
||||
} else {
|
||||
addDateToGrid(entry.dateIndex, points);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {AbortController} controller
|
||||
* @param {number} generation
|
||||
*/
|
||||
function isCurrentLoad(option, controller, generation) {
|
||||
return (
|
||||
currentOption === option &&
|
||||
abortController === controller &&
|
||||
loadGeneration === generation &&
|
||||
!controller.signal.aborted
|
||||
);
|
||||
if (!currentOption || !loader) return;
|
||||
loader.load({ option: currentOption, from, to });
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
const dates = loader?.dates;
|
||||
if (
|
||||
!currentOption ||
|
||||
!renderer ||
|
||||
!loader ||
|
||||
!dates?.length ||
|
||||
renderer.width < 1 ||
|
||||
renderer.height < 1 ||
|
||||
!currentDates.length
|
||||
renderer.height < 1
|
||||
) {
|
||||
currentGrid = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
currentGrid = currentOption.grid.create({
|
||||
dates: currentDates,
|
||||
dates,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
yMin,
|
||||
yMax,
|
||||
});
|
||||
|
||||
for (let i = 0; i < currentDates.length; i++) {
|
||||
const points = pointsByDate.get(currentDates[i]);
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const points = loader.getPoint(dates[i]);
|
||||
if (points) currentGrid.add(i, points);
|
||||
}
|
||||
|
||||
@@ -248,20 +155,6 @@ function addDateToGrid(dateIndex, points) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapPoints} a
|
||||
* @param {HeatmapPoints} b
|
||||
*/
|
||||
function samePoints(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a.kind !== b.kind || a.values !== b.values) return false;
|
||||
if (a.kind === "implicit" && b.kind === "implicit") {
|
||||
return a.yStart === b.yStart && a.yStep === b.yStep;
|
||||
}
|
||||
if (a.kind === "explicit" && b.kind === "explicit") return a.y === b.y;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param {number} col */
|
||||
function schedulePaint(col) {
|
||||
dirtyCols.add(col);
|
||||
@@ -270,9 +163,8 @@ function schedulePaint(col) {
|
||||
requestAnimationFrame(() => {
|
||||
paintScheduled = false;
|
||||
if (!dirtyCols.size) return;
|
||||
const cols = Array.from(dirtyCols);
|
||||
paint(dirtyCols);
|
||||
dirtyCols.clear();
|
||||
paint(cols);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,337 +242,3 @@ function updateTooltip(event, placement) {
|
||||
function hideTooltip() {
|
||||
tooltipView?.hide();
|
||||
}
|
||||
|
||||
function createRangeControls() {
|
||||
const fieldset = document.createElement("fieldset");
|
||||
rangeControlsElement = fieldset;
|
||||
return fieldset;
|
||||
}
|
||||
|
||||
/** @param {HeatmapOption} option */
|
||||
function updateDateControls(option) {
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const fromChoices = createFromChoices(currentYear);
|
||||
const toChoices = createToChoices(currentYear);
|
||||
const fallbackFromChoice = fromChoices.at(-1) ?? fromChoices[0];
|
||||
const fallbackToChoice = toChoices[0];
|
||||
const defaultFromChoice = findChoiceByKey(
|
||||
fromChoices,
|
||||
option.defaults?.from ?? "",
|
||||
fallbackFromChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
const defaultToChoice = findChoiceByKey(
|
||||
toChoices,
|
||||
option.defaults?.to ?? "",
|
||||
fallbackToChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
|
||||
const persistedFrom = createHeatmapPersistedValue(
|
||||
option,
|
||||
"from",
|
||||
"hm_from",
|
||||
rangeChoiceLabel(defaultFromChoice),
|
||||
);
|
||||
const persistedTo = createHeatmapPersistedValue(
|
||||
option,
|
||||
"to",
|
||||
"hm_to",
|
||||
rangeChoiceLabel(defaultToChoice),
|
||||
);
|
||||
|
||||
let fromChoice = findChoiceByKey(
|
||||
fromChoices,
|
||||
persistedFrom.value,
|
||||
defaultFromChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
let toChoice = findChoiceByKey(
|
||||
toChoices,
|
||||
persistedTo.value,
|
||||
defaultToChoice,
|
||||
rangeChoiceLabel,
|
||||
);
|
||||
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice);
|
||||
}
|
||||
from = fromChoice.date;
|
||||
to = toChoice.date;
|
||||
persistDateChoices();
|
||||
|
||||
const fromSelect = createSelect({
|
||||
id: "heatmap-from",
|
||||
label: "from",
|
||||
choices: fromChoices,
|
||||
initialValue: fromChoice,
|
||||
onChange(choice) {
|
||||
fromChoice = choice;
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice);
|
||||
toSelect.set(toChoice);
|
||||
}
|
||||
persistDateChoices();
|
||||
setRange(fromChoice.date, toChoice.date);
|
||||
},
|
||||
toKey: rangeChoiceLabel,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
const toSelect = createSelect({
|
||||
id: "heatmap-to",
|
||||
label: "to",
|
||||
choices: toChoices,
|
||||
initialValue: toChoice,
|
||||
onChange(choice) {
|
||||
toChoice = choice;
|
||||
if (fromChoice.date > toChoice.date) {
|
||||
fromChoice = findSameLabelChoice(fromChoices, toChoice, defaultFromChoice);
|
||||
fromSelect.set(fromChoice);
|
||||
}
|
||||
persistDateChoices();
|
||||
setRange(fromChoice.date, toChoice.date);
|
||||
},
|
||||
toKey: rangeChoiceLabel,
|
||||
toLabel: rangeChoiceLabel,
|
||||
});
|
||||
|
||||
dateControlElements = [fromSelect.element, toSelect.element];
|
||||
|
||||
function persistDateChoices() {
|
||||
persistedFrom.setImmediate(rangeChoiceLabel(fromChoice));
|
||||
persistedTo.setImmediate(rangeChoiceLabel(toChoice));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {HeatmapOption} option */
|
||||
function updateYControls(option) {
|
||||
const y = option.axis?.y;
|
||||
const choices = y?.choices;
|
||||
if (!choices || choices.length < 2) {
|
||||
yMin = undefined;
|
||||
yMax = undefined;
|
||||
yControlElements = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackMinChoice = choices[0];
|
||||
const fallbackMaxChoice = choices.at(-1) ?? choices[0];
|
||||
const defaultMinChoice = findChoiceByKey(
|
||||
choices,
|
||||
String(option.defaults?.yMin ?? ""),
|
||||
fallbackMinChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
const defaultMaxChoice = findChoiceByKey(
|
||||
choices,
|
||||
String(option.defaults?.yMax ?? ""),
|
||||
fallbackMaxChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
const persistedMin = createHeatmapPersistedValue(
|
||||
option,
|
||||
"y-min",
|
||||
"hm_y_min",
|
||||
axisChoiceKey(defaultMinChoice),
|
||||
);
|
||||
const persistedMax = createHeatmapPersistedValue(
|
||||
option,
|
||||
"y-max",
|
||||
"hm_y_max",
|
||||
axisChoiceKey(defaultMaxChoice),
|
||||
);
|
||||
|
||||
let minChoice = findChoiceByKey(
|
||||
choices,
|
||||
persistedMin.value,
|
||||
defaultMinChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
let maxChoice = findChoiceByKey(
|
||||
choices,
|
||||
persistedMax.value,
|
||||
defaultMaxChoice,
|
||||
axisChoiceKey,
|
||||
);
|
||||
if (minChoice.value > maxChoice.value) {
|
||||
maxChoice = minChoice;
|
||||
}
|
||||
yMin = minChoice.value;
|
||||
yMax = maxChoice.value;
|
||||
persistYChoices();
|
||||
|
||||
const minSelect = createSelect({
|
||||
id: "heatmap-y-min",
|
||||
label: "min",
|
||||
choices,
|
||||
initialValue: minChoice,
|
||||
onChange(choice) {
|
||||
minChoice = choice;
|
||||
if (minChoice.value > maxChoice.value) {
|
||||
maxChoice = minChoice;
|
||||
maxSelect.set(maxChoice);
|
||||
}
|
||||
persistYChoices();
|
||||
setYRange(minChoice.value, maxChoice.value);
|
||||
},
|
||||
toKey: axisChoiceKey,
|
||||
toLabel: axisChoiceLabel,
|
||||
});
|
||||
const maxSelect = createSelect({
|
||||
id: "heatmap-y-max",
|
||||
label: "max",
|
||||
choices: Array.from(choices).reverse(),
|
||||
initialValue: maxChoice,
|
||||
onChange(choice) {
|
||||
maxChoice = choice;
|
||||
if (minChoice.value > maxChoice.value) {
|
||||
minChoice = maxChoice;
|
||||
minSelect.set(minChoice);
|
||||
}
|
||||
persistYChoices();
|
||||
setYRange(minChoice.value, maxChoice.value);
|
||||
},
|
||||
toKey: axisChoiceKey,
|
||||
toLabel: axisChoiceLabel,
|
||||
});
|
||||
|
||||
yControlElements = [minSelect.element, maxSelect.element];
|
||||
|
||||
function persistYChoices() {
|
||||
persistedMin.setImmediate(axisChoiceKey(minChoice));
|
||||
persistedMax.setImmediate(axisChoiceKey(maxChoice));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRangeControls() {
|
||||
if (!rangeControlsElement) return;
|
||||
rangeControlsElement.replaceChildren(
|
||||
...dateControlElements,
|
||||
...yControlElements,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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: year === 2009 ? GENESIS_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 rangeChoiceLabel(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly RangeChoice[]} choices
|
||||
* @param {RangeChoice} choice
|
||||
* @param {RangeChoice} fallback
|
||||
*/
|
||||
function findSameLabelChoice(choices, choice, fallback) {
|
||||
return choices.find((candidate) => candidate.label === choice.label) ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} nextFrom
|
||||
* @param {string} nextTo
|
||||
*/
|
||||
function setRange(nextFrom, nextTo) {
|
||||
from = nextFrom;
|
||||
to = nextTo;
|
||||
hideTooltip();
|
||||
loadRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} nextYMin
|
||||
* @param {number} nextYMax
|
||||
*/
|
||||
function setYRange(nextYMin, nextYMax) {
|
||||
yMin = nextYMin;
|
||||
yMax = nextYMax;
|
||||
hideTooltip();
|
||||
rebuildGrid();
|
||||
}
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceKey(choice) {
|
||||
return String(choice.value);
|
||||
}
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceLabel(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
|
||||
/** @param {HeatmapOption} option */
|
||||
function heatmapStoragePrefix(option) {
|
||||
return `heatmap-${option.path.join("-")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {string} key
|
||||
* @param {string} urlKey
|
||||
* @param {string} defaultValue
|
||||
*/
|
||||
function createHeatmapPersistedValue(option, key, urlKey, defaultValue) {
|
||||
return createPersistedValue({
|
||||
defaultValue,
|
||||
storageKey: `${heatmapStoragePrefix(option)}-${key}`,
|
||||
urlKey,
|
||||
serialize: (value) => value,
|
||||
deserialize: (value) => value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {readonly T[]} choices
|
||||
* @param {string} key
|
||||
* @param {T} fallback
|
||||
* @param {(choice: T) => string} toKey
|
||||
*/
|
||||
function findChoiceByKey(choices, key, fallback, toKey) {
|
||||
return choices.find((candidate) => toKey(candidate) === key) ?? fallback;
|
||||
}
|
||||
|
||||
/** @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`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { dateRange } from "./time.js";
|
||||
|
||||
const MAX_PARALLEL_FETCHES = 8;
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {(dateIndex: number, points: HeatmapPoints) => void} args.addDateToGrid
|
||||
* @param {() => void} args.rebuildGrid
|
||||
* @param {() => void} args.paint
|
||||
*/
|
||||
export function createHeatmapLoader({ addDateToGrid, rebuildGrid, paint }) {
|
||||
/** @type {string[]} */
|
||||
let dates = [];
|
||||
/** @type {Map<string, HeatmapPoints>} */
|
||||
let pointsByDate = new Map();
|
||||
/** @type {AbortController | undefined} */
|
||||
let abortController;
|
||||
/** @type {HeatmapOption | undefined} */
|
||||
let activeOption;
|
||||
let generation = 0;
|
||||
|
||||
return {
|
||||
get dates() {
|
||||
return dates;
|
||||
},
|
||||
/** @param {string} date */
|
||||
getPoint(date) {
|
||||
return pointsByDate.get(date);
|
||||
},
|
||||
reset() {
|
||||
pointsByDate = new Map();
|
||||
},
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HeatmapOption} args.option
|
||||
* @param {string} args.from
|
||||
* @param {string} args.to
|
||||
*/
|
||||
load({ option, from, to }) {
|
||||
abortController?.abort();
|
||||
const controller = new AbortController();
|
||||
const currentGeneration = ++generation;
|
||||
activeOption = option;
|
||||
abortController = controller;
|
||||
dates = dateRange(from, to);
|
||||
|
||||
/** @type {{ date: string, dateIndex: number }[]} */
|
||||
const missing = [];
|
||||
for (let dateIndex = 0; dateIndex < dates.length; dateIndex++) {
|
||||
const date = dates[dateIndex];
|
||||
if (!pointsByDate.has(date)) missing.push({ date, dateIndex });
|
||||
}
|
||||
|
||||
if (!missing.length) {
|
||||
rebuildGrid();
|
||||
abortController = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
let needsRebuild = false;
|
||||
const workers = Array.from({
|
||||
length: Math.min(MAX_PARALLEL_FETCHES, missing.length),
|
||||
}).map(async () => {
|
||||
let index = nextMissingIndex();
|
||||
while (index !== undefined) {
|
||||
const entry = missing[index];
|
||||
try {
|
||||
const points = await option.points.fetch(
|
||||
entry.date,
|
||||
controller.signal,
|
||||
(points) => {
|
||||
if (isCurrentLoad(option, controller, currentGeneration)) {
|
||||
setPoints(entry, points);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (isCurrentLoad(option, controller, currentGeneration)) {
|
||||
setPoints(entry, points);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error(
|
||||
`Failed to fetch heatmap points for ${entry.date}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
index = nextMissingIndex();
|
||||
}
|
||||
});
|
||||
|
||||
rebuildGrid();
|
||||
|
||||
void Promise.all(workers).then(() => {
|
||||
if (isCurrentLoad(option, controller, currentGeneration)) {
|
||||
if (needsRebuild) {
|
||||
rebuildGrid();
|
||||
} else {
|
||||
paint();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function nextMissingIndex() {
|
||||
if (cursor >= missing.length) return undefined;
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ date: string, dateIndex: number }} entry
|
||||
* @param {HeatmapPoints} points
|
||||
*/
|
||||
function setPoints(entry, points) {
|
||||
const previous = pointsByDate.get(entry.date);
|
||||
if (previous && samePoints(previous, points)) return;
|
||||
pointsByDate.set(entry.date, points);
|
||||
if (previous) {
|
||||
needsRebuild = true;
|
||||
} else {
|
||||
addDateToGrid(entry.dateIndex, points);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {HeatmapOption} option
|
||||
* @param {AbortController} controller
|
||||
* @param {number} currentGeneration
|
||||
*/
|
||||
function isCurrentLoad(option, controller, currentGeneration) {
|
||||
return (
|
||||
activeOption === option &&
|
||||
abortController === controller &&
|
||||
generation === currentGeneration &&
|
||||
!controller.signal.aborted
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapPoints} a
|
||||
* @param {HeatmapPoints} b
|
||||
*/
|
||||
function samePoints(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a.kind !== b.kind || a.values !== b.values) return false;
|
||||
if (a.kind === "implicit" && b.kind === "implicit") {
|
||||
return a.yStart === b.yStart && a.yStep === b.yStep;
|
||||
}
|
||||
if (a.kind === "explicit" && b.kind === "explicit") return a.y === b.y;
|
||||
return false;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
/** @import { HeatmapColorFn } from "./types.js" */
|
||||
|
||||
const INFERNO_STOPS = [
|
||||
[0, 0, 0, 0],
|
||||
[0.13, 40, 11, 84],
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/** @import { PartialHeatmapOption } from "../../scripts/options/types.js" */
|
||||
/** @import { HeatmapPoints } from "./types.js" */
|
||||
|
||||
import { brk } from "../../scripts/utils/client.js";
|
||||
import { createAverageGrid } from "./grid.js";
|
||||
import { INFERNO_LUT, logIntensityColor } from "./lut.js";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** @import { HeatmapTooltipFn } from "../types.js" */
|
||||
|
||||
import { numberToShortUSFormat } from "../../../scripts/utils/format.js";
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user