From f599115f6c1b755da84d7be823a14ac131dbd040 Mon Sep 17 00:00:00 2001 From: nym21 Date: Sun, 7 Jun 2026 01:38:38 +0200 Subject: [PATCH] website: redesign part 15 --- website_next/home/style.css | 4 +- website_next/learn/charts/config.js | 17 -- website_next/learn/charts/highlight.js | 19 ++- website_next/learn/charts/index.js | 221 +------------------------ website_next/learn/charts/loader.js | 60 +++++++ website_next/learn/charts/renderer.js | 159 ++++++++++++++++++ website_next/learn/charts/scrubber.js | 34 +++- website_next/learn/charts/style.css | 2 +- website_next/learn/cohort-series.js | 13 +- website_next/routes.js | 16 +- 10 files changed, 282 insertions(+), 263 deletions(-) delete mode 100644 website_next/learn/charts/config.js create mode 100644 website_next/learn/charts/loader.js create mode 100644 website_next/learn/charts/renderer.js diff --git a/website_next/home/style.css b/website_next/home/style.css index 273916f7f..1ccbe2501 100644 --- a/website_next/home/style.css +++ b/website_next/home/style.css @@ -1,11 +1,9 @@ main.home { - --home-offset: 6rem; - display: grid; gap: 2rem; place-items: center; align-content: center; - padding: var(--home-offset) var(--page-x); + padding: 6rem var(--page-x); h1 { margin: 0; diff --git a/website_next/learn/charts/config.js b/website_next/learn/charts/config.js deleted file mode 100644 index 9eeb42791..000000000 --- a/website_next/learn/charts/config.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @param {ChartSeries[]} series - * @returns {ChartSeries[]} - */ -export function createSeries(series) { - return series; -} - -/** - * @param {ChartSeries} series - * @returns {ChartSeries} - */ -export function referenceLine(series) { - return { ...series, role: "line" }; -} - -/** @typedef {import("./index.js").ChartSeries} ChartSeries */ diff --git a/website_next/learn/charts/highlight.js b/website_next/learn/charts/highlight.js index 7c274e4a8..172f43ddb 100644 --- a/website_next/learn/charts/highlight.js +++ b/website_next/learn/charts/highlight.js @@ -40,13 +40,14 @@ export function createSeriesHighlight(items) { } /** @param {number} index */ - function activateItem(index) { - setActive(items[index], true); + function previewItem(index) { + scrollToItem(index); + items[index].dataset.preview = ""; } /** @param {number} index */ - function clearItem(index) { - clearState(items[index]); + function clearPreview(index) { + delete items[index].dataset.preview; } items.forEach((item, index) => { @@ -62,11 +63,6 @@ export function createSeriesHighlight(items) { */ function add(node, index) { seriesNodes[index].push(node); - node.addEventListener("pointerenter", () => { - scrollToItem(index); - activateItem(index); - }); - node.addEventListener("pointerleave", () => clearItem(index)); } function clearNodes() { @@ -79,7 +75,9 @@ export function createSeriesHighlight(items) { return { add, + clearPreview, clearNodes, + preview: previewItem, }; } @@ -101,6 +99,7 @@ function setActive(element, active) { function clearState(element) { delete element.dataset.active; delete element.dataset.muted; + delete element.dataset.preview; } /** @typedef {(SVGPathElement | SVGCircleElement)[]} SeriesNode */ @@ -108,5 +107,7 @@ function clearState(element) { /** * @typedef {Object} SeriesHighlight * @property {(node: SVGPathElement | SVGCircleElement, index: number) => void} add + * @property {(index: number) => void} clearPreview * @property {() => void} clearNodes + * @property {(index: number) => void} preview */ diff --git a/website_next/learn/charts/index.js b/website_next/learn/charts/index.js index bc0bc533b..cb0822db1 100644 --- a/website_next/learn/charts/index.js +++ b/website_next/learn/charts/index.js @@ -1,22 +1,15 @@ -import { brk } from "../../utils/client.js"; -import { renderBarPlot } from "./bar/index.js"; import { createFullscreenButton } from "./fullscreen.js"; -import { createSeriesHighlight } from "./highlight.js"; import { onChartVisibility } from "./intersection.js"; import { createLegend } from "./legend.js"; -import { renderLinePlot } from "./line/index.js"; +import { createChartRenderer } from "./renderer.js"; import { createScaleControl, getDefaultScale, saveScale, } from "./scale.js"; -import { createScrubber } from "./scrubber.js"; -import { renderDotsPlot } from "./dots/index.js"; import { createSvgElement } from "./svg.js"; -import { renderStackedPlot } from "./stacked/index.js"; import { createTimeframeControl, - fetchTimeframe, getDefaultTimeframe, saveTimeframe, } from "./timeframes.js"; @@ -25,203 +18,7 @@ import { getDefaultView, saveView, } from "./views.js"; -import { - FALLBACK_VIEWBOX_HEIGHT, - getViewBoxHeight, - VIEWBOX_WIDTH, -} from "./viewbox.js"; - -/** @typedef {import("./legend.js").Readout} Readout */ -/** @typedef {import("./scale.js").ChartScale} ChartScale */ -/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */ -/** @typedef {import("./views.js").ChartView} ChartView */ - -/** - * @param {ChartResult} result - * @returns {{ date: Date, value: number }[]} - */ -function createEntries(result) { - /** @type {{ date: Date, value: number }[]} */ - const entries = []; - /** @type {number | undefined} */ - let lastValue; - - for (const [date, value] of result.dateEntries()) { - if (typeof value === "number" && Number.isFinite(value)) lastValue = value; - if (lastValue !== undefined) entries.push({ date, value: lastValue }); - } - - return entries; -} - -/** - * @param {Chart} chart - * @param {TimeframeValue} timeframe - * @returns {Promise} - */ -async function loadSeries(chart, timeframe) { - return Promise.all( - chart.series.map(async (item) => ({ - series: item, - color: item.color(), - entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)), - })), - ); -} - -/** @param {Chart} chart */ -function createLoadedSeriesCache(chart) { - /** @type {TimeframeValue | undefined} */ - let cachedTimeframe; - /** @type {Promise | undefined} */ - let cachedPromise; - - /** @param {TimeframeValue} timeframe */ - return function getLoadedSeries(timeframe) { - if (timeframe !== cachedTimeframe || !cachedPromise) { - cachedTimeframe = timeframe; - cachedPromise = loadSeries(chart, timeframe).catch((error) => { - if (timeframe === cachedTimeframe) cachedPromise = undefined; - throw error; - }); - } - - return cachedPromise; - }; -} - -/** - * @param {ChartView} view - * @param {SVGGElement} group - * @param {LoadedSeries[]} loadedSeries - * @param {number} height - * @param {SeriesHighlight} highlight - * @param {ChartScale} scale - */ -function renderPlot(view, group, loadedSeries, height, highlight, scale) { - switch (view) { - case "line": - return renderLinePlot(group, loadedSeries, height, highlight, scale); - case "bar": - case "bar-reversed": - return renderBarPlot( - group, - loadedSeries, - height, - highlight, - { reversed: view === "bar-reversed" }, - scale, - ); - case "dots": - return renderDotsPlot(group, loadedSeries, height, highlight, scale); - default: - return renderStackedPlot( - group, - loadedSeries, - height, - highlight, - { reversed: view === "stacked-reversed" }, - scale, - ); - } -} - -/** - * @param {SVGSVGElement} svg - * @param {Readout} readout - * @param {HTMLElement[]} items - * @param {HTMLElement} status - * @param {Chart} chart - * @param {() => ChartView} getView - * @param {() => ChartScale} getScale - * @param {() => TimeframeValue} getTimeframe - */ -function createChartRenderer( - svg, - readout, - items, - status, - chart, - getView, - getScale, - getTimeframe, -) { - const group = createSvgElement("g"); - const highlight = createSeriesHighlight(items); - const getLoadedSeries = createLoadedSeriesCache(chart); - /** @type {LoadedSeries[]} */ - let loadedSeries = []; - /** @type {ReturnType | undefined} */ - let scrubber; - const resizeObserver = new ResizeObserver(renderCurrent); - let active = false; - let loadId = 0; - - svg.append(group); - - function renderCurrent() { - if (!active || !loadedSeries.length) return; - - const height = getViewBoxHeight(svg); - - svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`); - group.replaceChildren(); - highlight.clearNodes(); - scrubber ??= createScrubber(svg, readout, highlight); - scrubber.setSeries( - renderPlot(getView(), group, loadedSeries, height, highlight, getScale()), - height, - ); - } - - async function loadCurrent() { - const id = (loadId += 1); - svg.setAttribute("aria-busy", "true"); - - try { - const nextSeries = await getLoadedSeries(getTimeframe()); - - if (id !== loadId || !active) return; - - loadedSeries = nextSeries; - renderCurrent(); - status.textContent = ""; - } catch (error) { - if (id !== loadId) return; - console.error(error); - status.textContent = "Chart unavailable"; - } finally { - if (id === loadId) svg.removeAttribute("aria-busy"); - } - } - - function resume() { - if (active) return; - - active = true; - resizeObserver.observe(svg); - void loadCurrent(); - } - - function suspend() { - if (!active) return; - - active = false; - loadId += 1; - resizeObserver.disconnect(); - group.replaceChildren(); - highlight.clearNodes(); - scrubber?.clear(); - svg.removeAttribute("aria-busy"); - } - - return { - loadCurrent, - renderCurrent, - resume, - suspend, - }; -} +import { FALLBACK_VIEWBOX_HEIGHT, VIEWBOX_WIDTH } from "./viewbox.js"; /** @param {Chart} chart */ export function createChart(chart) { @@ -251,16 +48,16 @@ export function createChart(chart) { status.setAttribute("aria-live", "polite"); status.setAttribute("role", "status"); - const renderer = createChartRenderer( + const renderer = createChartRenderer({ svg, readout, items, status, chart, - () => currentView, - () => currentScale, - () => currentTimeframe, - ); + getView: () => currentView, + getScale: () => currentScale, + getTimeframe: () => currentTimeframe, + }); const viewControl = createViewControl(currentView, (view) => { currentView = view; saveView(chartKey, view); @@ -305,7 +102,7 @@ export function createChart(chart) { * @property {string} label * @property {() => string} color * @property {"line"} [role] - * @property {(client: typeof brk) => import("./timeframes.js").TimeframeMetric} metric + * @property {(client: typeof import("../../utils/client.js").brk) => import("./timeframes.js").TimeframeMetric} metric */ /** @@ -319,5 +116,3 @@ export function createChart(chart) { * @property {string} color * @property {{ date: Date, value: number }[]} entries */ - -/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */ diff --git a/website_next/learn/charts/loader.js b/website_next/learn/charts/loader.js new file mode 100644 index 000000000..45085462a --- /dev/null +++ b/website_next/learn/charts/loader.js @@ -0,0 +1,60 @@ +import { brk } from "../../utils/client.js"; +import { fetchTimeframe } from "./timeframes.js"; + +/** + * @param {ChartResult} result + * @returns {{ date: Date, value: number }[]} + */ +function createEntries(result) { + /** @type {{ date: Date, value: number }[]} */ + const entries = []; + /** @type {number | undefined} */ + let lastValue; + + for (const [date, value] of result.dateEntries()) { + if (typeof value === "number" && Number.isFinite(value)) lastValue = value; + if (lastValue !== undefined) entries.push({ date, value: lastValue }); + } + + return entries; +} + +/** + * @param {Chart} chart + * @param {TimeframeValue} timeframe + */ +function loadSeries(chart, timeframe) { + return Promise.all( + chart.series.map(async (item) => ({ + series: item, + color: item.color(), + entries: createEntries(await fetchTimeframe(item.metric(brk), timeframe)), + })), + ); +} + +/** @param {Chart} chart */ +export function createSeriesLoader(chart) { + /** @type {TimeframeValue | undefined} */ + let cachedTimeframe; + /** @type {Promise | undefined} */ + let cachedPromise; + + /** @param {TimeframeValue} timeframe */ + return function loadTimeframe(timeframe) { + if (timeframe !== cachedTimeframe || !cachedPromise) { + cachedTimeframe = timeframe; + cachedPromise = loadSeries(chart, timeframe).catch((error) => { + if (timeframe === cachedTimeframe) cachedPromise = undefined; + throw error; + }); + } + + return cachedPromise; + }; +} + +/** @typedef {import("./index.js").Chart} Chart */ +/** @typedef {import("./index.js").ChartResult} ChartResult */ +/** @typedef {import("./index.js").LoadedSeries} LoadedSeries */ +/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */ diff --git a/website_next/learn/charts/renderer.js b/website_next/learn/charts/renderer.js new file mode 100644 index 000000000..7dc87f441 --- /dev/null +++ b/website_next/learn/charts/renderer.js @@ -0,0 +1,159 @@ +import { renderBarPlot } from "./bar/index.js"; +import { createSeriesHighlight } from "./highlight.js"; +import { createSeriesLoader } from "./loader.js"; +import { renderLinePlot } from "./line/index.js"; +import { createScrubber } from "./scrubber.js"; +import { renderDotsPlot } from "./dots/index.js"; +import { createSvgElement } from "./svg.js"; +import { renderStackedPlot } from "./stacked/index.js"; +import { getViewBoxHeight, VIEWBOX_WIDTH } from "./viewbox.js"; + +/** + * @param {ChartView} view + * @param {SVGGElement} group + * @param {LoadedSeries[]} loadedSeries + * @param {number} height + * @param {SeriesHighlight} highlight + * @param {ChartScale} scale + */ +function renderPlot(view, group, loadedSeries, height, highlight, scale) { + switch (view) { + case "line": + return renderLinePlot(group, loadedSeries, height, highlight, scale); + case "bar": + case "bar-reversed": + return renderBarPlot( + group, + loadedSeries, + height, + highlight, + { reversed: view === "bar-reversed" }, + scale, + ); + case "dots": + return renderDotsPlot(group, loadedSeries, height, highlight, scale); + default: + return renderStackedPlot( + group, + loadedSeries, + height, + highlight, + { reversed: view === "stacked-reversed" }, + scale, + ); + } +} + +/** + * @param {Object} args + * @param {SVGSVGElement} args.svg + * @param {Readout} args.readout + * @param {HTMLElement[]} args.items + * @param {HTMLElement} args.status + * @param {Chart} args.chart + * @param {() => ChartView} args.getView + * @param {() => ChartScale} args.getScale + * @param {() => TimeframeValue} args.getTimeframe + */ +export function createChartRenderer({ + svg, + readout, + items, + status, + chart, + getView, + getScale, + getTimeframe, +}) { + const group = createSvgElement("g"); + const highlight = createSeriesHighlight(items); + const loadSeries = createSeriesLoader(chart); + /** @type {LoadedSeries[]} */ + let loadedSeries = []; + /** @type {ReturnType | undefined} */ + let scrubber; + const resizeObserver = new ResizeObserver(renderCurrent); + let active = false; + let loadId = 0; + + svg.append(group); + + function renderCurrent() { + if (!active || !loadedSeries.length) return; + + const height = getViewBoxHeight(svg); + + svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`); + group.replaceChildren(); + highlight.clearNodes(); + scrubber ??= createScrubber(svg, readout, highlight); + scrubber.setSeries( + renderPlot( + getView(), + group, + loadedSeries, + height, + highlight, + getScale(), + ), + height, + ); + } + + async function loadCurrent() { + const id = (loadId += 1); + svg.setAttribute("aria-busy", "true"); + + try { + const nextSeries = await loadSeries(getTimeframe()); + + if (id !== loadId || !active) return; + + loadedSeries = nextSeries; + renderCurrent(); + status.textContent = ""; + } catch (error) { + if (id !== loadId) return; + console.error(error); + status.textContent = "Chart unavailable"; + } finally { + if (id === loadId) svg.removeAttribute("aria-busy"); + } + } + + function resume() { + if (active) return; + + active = true; + resizeObserver.observe(svg); + void loadCurrent(); + } + + function suspend() { + if (!active) return; + + active = false; + loadedSeries = []; + loadId += 1; + resizeObserver.disconnect(); + group.replaceChildren(); + highlight.clearNodes(); + scrubber?.clear(); + svg.removeAttribute("aria-busy"); + } + + return { + loadCurrent, + renderCurrent, + resume, + suspend, + }; +} + +/** @typedef {import("./index.js").Chart} Chart */ +/** @typedef {import("./index.js").LoadedSeries} LoadedSeries */ +/** @typedef {import("./legend.js").Readout} Readout */ +/** @typedef {import("./scale.js").ChartScale} ChartScale */ +/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */ +/** @typedef {import("./views.js").ChartView} ChartView */ +/** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */ diff --git a/website_next/learn/charts/scrubber.js b/website_next/learn/charts/scrubber.js index ad578df86..d5efe380f 100644 --- a/website_next/learn/charts/scrubber.js +++ b/website_next/learn/charts/scrubber.js @@ -63,6 +63,8 @@ export function createScrubber(svg, readout, highlight) { let markers = []; let height = 0; let stepCount = 0; + /** @type {number | undefined} */ + let previewIndex; group.dataset.scrubber = "root"; guide.dataset.scrubber = "guide"; @@ -108,11 +110,28 @@ export function createScrubber(svg, readout, highlight) { function clear() { series = []; markers = []; + clearPreview(); group.replaceChildren(guide); delete svg.dataset.index; delete svg.dataset.scrubbing; } + /** @param {number} index */ + function preview(index) { + if (index === previewIndex) return; + + if (previewIndex !== undefined) highlight.clearPreview(previewIndex); + highlight.preview(index); + previewIndex = index; + } + + function clearPreview() { + if (previewIndex === undefined) return; + + highlight.clearPreview(previewIndex); + previewIndex = undefined; + } + /** * @param {ScrubberSeries[]} nextSeries * @param {number} nextHeight @@ -141,14 +160,25 @@ export function createScrubber(svg, readout, highlight) { function updateFromPointer(event) { const { left, width } = svg.getBoundingClientRect(); const x = ((event.clientX - left) / width) * VIEWBOX_WIDTH; + const index = Number( + /** @type {SVGElement} */ (event.target).dataset.series, + ); + if (Number.isInteger(index)) preview(index); + else clearPreview(); update(x / VIEWBOX_WIDTH); } svg.addEventListener("pointermove", updateFromPointer); - svg.addEventListener("pointerleave", hide); + svg.addEventListener("pointerleave", () => { + clearPreview(); + hide(); + }); svg.addEventListener("focus", () => update(1)); - svg.addEventListener("blur", hide); + svg.addEventListener("blur", () => { + clearPreview(); + hide(); + }); svg.addEventListener("keydown", (event) => { const current = Number(svg.dataset.index || stepCount); diff --git a/website_next/learn/charts/style.css b/website_next/learn/charts/style.css index 3cf0b9661..eb31c6f8a 100644 --- a/website_next/learn/charts/style.css +++ b/website_next/learn/charts/style.css @@ -183,7 +183,7 @@ main.learn { text-transform: inherit; cursor: pointer; - &:is(:hover, :focus-visible, [data-active]) { + &:is(:hover, :focus-visible, [data-active], [data-preview]) { color: var(--black); background: var(--color); diff --git a/website_next/learn/cohort-series.js b/website_next/learn/cohort-series.js index 397d04089..1f8fa7e0f 100644 --- a/website_next/learn/cohort-series.js +++ b/website_next/learn/cohort-series.js @@ -1,4 +1,3 @@ -import { createSeries } from "./charts/config.js"; import { colors } from "../utils/colors.js"; const palette = [ @@ -29,13 +28,11 @@ function colorAt(index) { /** @param {readonly { label: string, color?: ChartColor, metric: Metric }[]} items */ export function createCohortSeries(items) { - return createSeries( - items.map(({ label, color, metric }, index) => ({ - label, - color: color ?? colorAt(index), - metric, - })), - ); + return items.map(({ label, color, metric }, index) => ({ + label, + color: color ?? colorAt(index), + metric, + })); } /** diff --git a/website_next/routes.js b/website_next/routes.js index 8f56e65b6..4032691cc 100644 --- a/website_next/routes.js +++ b/website_next/routes.js @@ -3,17 +3,13 @@ import { createExplorePage } from "./explore/index.js"; import { createHomePage } from "./home/index.js"; import { createLearnPage } from "./learn/index.js"; -const pages = [ - { pathname: "/", createPage: createHomePage }, - { pathname: "/explore", createPage: createExplorePage }, - { pathname: "/learn", createPage: createLearnPage }, - { pathname: "/build", createPage: createBuildPage }, -]; - /** @type {Record HTMLElement>} */ -const routes = Object.fromEntries( - pages.map(({ pathname, createPage }) => [pathname, createPage]), -); +const routes = { + "/": createHomePage, + "/explore": createExplorePage, + "/learn": createLearnPage, + "/build": createBuildPage, +}; /** @param {string} pathname */ export function isRoute(pathname) {