website: redesign part 15

This commit is contained in:
nym21
2026-06-07 01:38:38 +02:00
parent 9fc45625ad
commit f599115f6c
10 changed files with 282 additions and 263 deletions
+1 -3
View File
@@ -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;
-17
View File
@@ -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 */
+10 -9
View File
@@ -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
*/
+8 -213
View File
@@ -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<LoadedSeries[]>}
*/
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<LoadedSeries[]> | 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<typeof createScrubber> | 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 */
+60
View File
@@ -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<LoadedSeries[]> | 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 */
+159
View File
@@ -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<typeof createScrubber> | 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 */
+32 -2
View File
@@ -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);
+1 -1
View File
@@ -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);
+5 -8
View File
@@ -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,
}));
}
/**
+6 -10
View File
@@ -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<string, () => 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) {