diff --git a/website/scripts/_types.js b/website/scripts/_types.js index 1e0da3f02..cd8ad6aa5 100644 --- a/website/scripts/_types.js +++ b/website/scripts/_types.js @@ -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" * diff --git a/website/scripts/main.js b/website/scripts/main.js index d61dfdadb..a8d96768d 100644 --- a/website/scripts/main.js +++ b/website/scripts/main.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; diff --git a/website/scripts/options/types.js b/website/scripts/options/types.js index 4d8d12fdf..9f3470892 100644 --- a/website/scripts/options/types.js +++ b/website/scripts/options/types.js @@ -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] * diff --git a/website/scripts/utils/dom.js b/website/scripts/utils/dom.js index be3429635..2ba978925 100644 --- a/website/scripts/utils/dom.js +++ b/website/scripts/utils/dom.js @@ -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, }; } - diff --git a/website/src/heatmap/cells.js b/website/src/heatmap/cells.js index ab30f1941..6c311df2c 100644 --- a/website/src/heatmap/cells.js +++ b/website/src/heatmap/cells.js @@ -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, diff --git a/website/src/heatmap/demo.js b/website/src/heatmap/demo.js index b5a0b1508..15dc83edb 100644 --- a/website/src/heatmap/demo.js +++ b/website/src/heatmap/demo.js @@ -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 }), }; diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index bf8983910..431ea4805 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -30,8 +30,6 @@ let currentOption; let currentGrid; /** @type {string[]} */ let currentDates = []; -/** @type {Map} */ -let currentDateIndex = new Map(); /** @type {Map} */ 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} - */ -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(); } diff --git a/website/src/heatmap/style.css b/website/src/heatmap/style.css index e3f79d278..fcd6514b6 100644 --- a/website/src/heatmap/style.css +++ b/website/src/heatmap/style.css @@ -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; } diff --git a/website/src/heatmap/types.js b/website/src/heatmap/types.js index add7fa3cb..53eee78bd 100644 --- a/website/src/heatmap/types.js +++ b/website/src/heatmap/types.js @@ -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