diff --git a/website/scripts/_types.js b/website/scripts/_types.js index 6031921d7..1e0da3f02 100644 --- a/website/scripts/_types.js +++ b/website/scripts/_types.js @@ -12,7 +12,7 @@ * * @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" * diff --git a/website/scripts/main.js b/website/scripts/main.js index 44b2024d3..d61dfdadb 100644 --- a/website/scripts/main.js +++ b/website/scripts/main.js @@ -8,7 +8,10 @@ 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 } 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 { asideElement, @@ -153,9 +156,10 @@ function initSelected() { element = heatmapElement; if (firstTimeLoadingHeatmap) { - initHeatmap(option); + initHeatmap(); } firstTimeLoadingHeatmap = false; + setHeatmapOption(option); break; } diff --git a/website/scripts/options/partial.js b/website/scripts/options/partial.js index 26f412089..c61a6492f 100644 --- a/website/scripts/options/partial.js +++ b/website/scripts/options/partial.js @@ -25,6 +25,7 @@ import { createNetworkSection } from "./network.js"; import { createMiningSection } from "./mining.js"; import { createCointimeSection } from "./cointime.js"; import { createInvestingSection } from "./investing.js"; +import { demoHeatmapOption } from "../../src/heatmap/demo.js"; // Re-export types for external consumers export * from "./types.js"; @@ -298,13 +299,7 @@ export function createPartialOptions() { { name: "Heatmaps", - tree: [ - { - kind: "heatmap", - name: "Demo", - title: "Heatmap Demo", - }, - ], + tree: [demoHeatmapOption], }, { diff --git a/website/scripts/options/types.js b/website/scripts/options/types.js index e8ea93182..4d8d12fdf 100644 --- a/website/scripts/options/types.js +++ b/website/scripts/options/types.js @@ -107,14 +107,14 @@ * @typedef {Object} PartialHeatmapOptionSpecific * @property {"heatmap"} kind * @property {string} title - * @property {HeatmapDataSource} data + * @property {HeatmapPointSource} points * @property {HeatmapCells} cells * @property {HeatmapColorFn} color * @property {HeatmapTooltipFn} [tooltip] * * @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption * - * @typedef {Required & ProcessedOptionAddons} HeatmapOption + * @typedef {Required> & Pick & ProcessedOptionAddons} HeatmapOption * * @typedef {Object} PartialUrlOptionSpecific * @property {"link"} [kind] diff --git a/website/src/heatmap/cells.js b/website/src/heatmap/cells.js new file mode 100644 index 000000000..ab30f1941 --- /dev/null +++ b/website/src/heatmap/cells.js @@ -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 }; +} diff --git a/website/src/heatmap/demo.js b/website/src/heatmap/demo.js index d109e2480..b5a0b1508 100644 --- a/website/src/heatmap/demo.js +++ b/website/src/heatmap/demo.js @@ -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; -const ROWS = 100; +import { createAverageCells } from "./cells.js"; +import { INFERNO_LUT, intensityColor } from "./lut.js"; +import { GENESIS_DATE, todayISODate } from "./time.js"; -export const demoSource = { - cols: COLS, - rows: ROWS, - getColor, +const ROWS = 160; +const DAY_MS = 86_400_000; +const GENESIS_TIME = Date.parse(`${GENESIS_DATE}T00:00:00Z`); + +/** @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 {number} row + * @param {string} date + * @param {AbortSignal} signal + * @returns {Promise} */ -function getColor(col, row) { - const x = col / (COLS - 1); - const y = row / (ROWS - 1); - const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01); - const blob = Math.exp( - -(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028), +async function fetchDemoPoints(date, signal) { + throwIfAborted(signal); + + const values = new Float32Array(ROWS); + const endTime = Date.parse(`${todayISODate()}T00:00:00Z`); + 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( - 255, - Math.max(0, ((ridge * 0.65 + blob * 0.45 + floor) * 255) | 0), - ); - return INFERNO_LUT[i]; + + for (let row = 0; row < ROWS; row++) { + const y = row / (ROWS - 1); + const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01); + const blob = Math.exp( + -(((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"); + } } diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index ea8f35b72..bf8983910 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -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 { 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 { dateRange, GENESIS_DATE, todayISODate, toISODate } from "./time.js"; + +/** + * @typedef {Object} RangeChoice + * @property {string} label + * @property {string} date + */ + +const MAX_PARALLEL_FETCHES = 8; + +/** @type {ReturnType | 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} */ +let currentDateIndex = new Map(); +/** @type {Map} */ +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. - * - * @param {HeatmapOption} option */ -export async function init(option) { - const { headerElement, headingElement } = createHeader(); - headingElement.innerHTML = option.title; +export function init() { + if (initialized) return; + initialized = true; + + const header = createHeader(); + headingElement = header.headingElement; + const { headerElement } = header; heatmapElement.append(headerElement); + heatmapElement.append(createRangeControls()); - const canvas = document.createElement("canvas"); + canvas = document.createElement("canvas"); heatmapElement.append(canvas); - await next(); + renderer = createRenderer(canvas); - let renderer = createRenderer(canvas); - let source = demoSource; + canvas.addEventListener("mousemove", updateTooltip); + canvas.addEventListener("mouseleave", () => canvas?.removeAttribute("title")); + onThemeChange(paint); - function render() { - renderer.paint(source.cols, source.rows, source.getColor); - } - - const { width, height } = canvas.getBoundingClientRect(); - if (renderer.resize(width, height)) { - render(); - } + void next().then(resizeAndRebuild); new ResizeObserver( debounce(() => { - const { width, height } = canvas.getBoundingClientRect(); - if (renderer.resize(width, height)) { - render(); - } - }, 1000), + resizeAndRebuild(); + }, 250), ).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} [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} + */ +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`), + ), + ), + ); +} diff --git a/website/src/heatmap/lut.js b/website/src/heatmap/lut.js index 0ed5b4dd7..abffe1512 100644 --- a/website/src/heatmap/lut.js +++ b/website/src/heatmap/lut.js @@ -1,3 +1,5 @@ +/** @import { HeatmapColorFn } from "./types.js" */ + const INFERNO_STOPS = [ [0, 0, 0, 0], [0.13, 40, 11, 84], @@ -12,6 +14,21 @@ const INFERNO_STOPS = [ export const INFERNO_LUT = createColorLut(INFERNO_STOPS); +/** + * @param {Object} args + * @param {ArrayLike} args.light + * @param {ArrayLike} 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]. */ diff --git a/website/src/heatmap/style.css b/website/src/heatmap/style.css index 09a8dfbc2..e3f79d278 100644 --- a/website/src/heatmap/style.css +++ b/website/src/heatmap/style.css @@ -1,10 +1,73 @@ #heatmap { height: 100%; + width: 100%; + min-height: 0; display: flex; 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 { - flex: 1%; + flex: 1; min-height: 0; + min-width: 0; + width: 100%; + display: block; + image-rendering: pixelated; } } diff --git a/website/src/heatmap/time.js b/website/src/heatmap/time.js index 6a7059c25..3a7e98d81 100644 --- a/website/src/heatmap/time.js +++ b/website/src/heatmap/time.js @@ -1,4 +1,5 @@ const DAY_MS = 86_400_000; +export const GENESIS_DATE = "2009-01-03"; /** * @param {Date} date diff --git a/website/src/heatmap/types.js b/website/src/heatmap/types.js index 6c30bd636..add7fa3cb 100644 --- a/website/src/heatmap/types.js +++ b/website/src/heatmap/types.js @@ -1,27 +1,38 @@ /** - * @typedef {Object} HeatmapDataSource - * @property {(signal: AbortSignal) => Promise} list - * @property {(date: string, signal: AbortSignal) => Promise} fetch + * @typedef {Object} HeatmapImplicitPoints + * @property {"implicit"} kind + * @property {number} yStart + * @property {number} yStep + * @property {ArrayLike} values + * + * @typedef {Object} HeatmapExplicitPoints + * @property {"explicit"} kind + * @property {ArrayLike} y + * @property {ArrayLike} values + * + * @typedef {HeatmapImplicitPoints | HeatmapExplicitPoints} HeatmapPoints + * + * @typedef {Object} HeatmapPointSource + * @property {(date: string, signal: AbortSignal) => Promise} 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 - * @property {(args: { dates: string[], width: number, height: number }) => unknown} create - * @property {(grid: unknown, dateIndex: number, snapshot: unknown) => number | undefined} add - * @property {(grid: unknown, col: number, row: number) => unknown} getValue + * @property {(args: { dates: readonly string[], width: number, height: number }) => HeatmapGrid} create * - * @typedef {Object} HeatmapColorContext - * @property {boolean} dark - * @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 + * @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn + * @typedef {(context: { grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn */ export {};