website_next: snapshot

This commit is contained in:
nym21
2026-07-03 21:06:32 +02:00
parent e0a618837e
commit 10ef95497f
73 changed files with 2241 additions and 733 deletions
+58
View File
@@ -0,0 +1,58 @@
import { createAreaPathData, createLinePathData } from "../path.js";
import { appendSeriesPath } from "../series-path.js";
import { createOrderedIndexes } from "../order.js";
import { createLineSeries } from "../line/series.js";
import { getPlotBottom } from "../viewbox.js";
/**
* @param {ChartFrame} frame
* @param {ChartPoint[]} points
* @returns {StackedPoint[]}
*/
function createAreaPoints(frame, points) {
const bottom = getPlotBottom(frame);
return points.map((point) => ({
...point,
y0: bottom,
y1: point.y,
}));
}
/**
* @param {PlotContext} context
*/
export function renderAreaPlot({
group,
loadedSeries,
frame,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, frame, scale);
const indexes = createOrderedIndexes(plottedSeries.length, order);
for (const index of indexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "area",
color,
d: createAreaPathData(createAreaPoints(frame, points)),
});
appendSeriesPath({
group,
highlight,
index,
chart: "line",
color,
d: createLinePathData(points),
});
}
return plottedSeries;
}
+7
View File
@@ -0,0 +1,7 @@
figure[data-chart="series"] {
path[data-chart="area"] {
fill: var(--color, var(--orange));
fill-opacity: 0.5;
stroke: none;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { createLinePathData, formatCoordinate } from "../path.js";
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { createStackedSeries } from "../stacked/series.js";
import { clamp } from "../math.js";
import { appendSeriesPath } from "../series-path.js";
/** @param {StackedPoint[]} points */
function getBarWidth(points) {
return points.length > 1 ? (VIEWBOX_WIDTH / (points.length - 1)) * 0.8 : 1;
}
/**
* @param {StackedPoint[]} points
* @param {number} width
*/
function createBarPathData(points, width) {
return points
.map(({ x, y0, y1 }) => {
const left = clamp(x - width / 2, 0, VIEWBOX_WIDTH - width);
const right = left + width;
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
return (
`M${formatCoordinate(left)} ${formatCoordinate(top)}` +
`H${formatCoordinate(right)}V${formatCoordinate(bottom)}` +
`H${formatCoordinate(left)}Z`
);
})
.join(" ");
}
/**
* @param {PlotContext} context
*/
export function renderBarPlot({
group,
loadedSeries,
frame,
highlight,
scale,
order,
}) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
frame,
order,
scale,
);
for (const index of stackIndexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "bar",
color,
d: createBarPathData(points, getBarWidth(points)),
});
}
for (const index of lineIndexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "line",
color,
d: createLinePathData(points),
});
}
return plottedSeries;
}
+6
View File
@@ -0,0 +1,6 @@
figure[data-chart="series"] {
path[data-chart="bar"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
+19
View File
@@ -0,0 +1,19 @@
export const CHART_SIZE = /** @type {const} */ ({
width: 640,
fallbackHeight: 220,
});
export const CHART_MARKER = /** @type {const} */ ({
fallbackWidth: 84,
height: 20,
edgeOverflow: 8,
});
export const CHART_POINT = /** @type {const} */ ({
radius: 4,
});
export const CHART_FRAME = /** @type {const} */ ({
topGap: 16,
bottomPadding: 8,
});
+95
View File
@@ -0,0 +1,95 @@
figure[data-chart="series"] {
> footer {
> div {
display: flex;
flex-wrap: wrap;
gap: 0.125rem 0.5rem;
}
fieldset {
display: flex;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
text-transform: uppercase;
legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
label {
position: relative;
display: block;
cursor: pointer;
}
input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
span {
display: block;
}
label:has(:checked) span {
color: var(--black);
background: var(--gray);
}
}
button[data-chart="fullscreen"] {
border: 0;
background: none;
font: inherit;
line-height: inherit;
text-transform: uppercase;
cursor: pointer;
&[aria-pressed="true"] {
color: var(--black);
background: var(--green);
}
}
:is(label > span, button[data-chart="fullscreen"]) {
padding: 0.25rem;
border-radius: 0.25rem;
color: var(--gray);
}
@media (hover: hover) and (pointer: fine) {
:is(label:hover span, button[data-chart="fullscreen"]:hover) {
color: var(--black);
background: var(--white);
}
}
:is(label:active span, button[data-chart="fullscreen"]:active) {
color: var(--black);
background: var(--orange);
}
:is(label[data-press] span, button[data-chart="fullscreen"][data-press]) {
color: var(--black);
background: var(--white);
}
:is(
label:has(:focus-visible) span,
button[data-chart="fullscreen"]:focus-visible
) {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { formatCoordinate } from "../path.js";
import { appendSeriesPath } from "../series-path.js";
import { createOrderedIndexes } from "../order.js";
import { createLineSeries } from "../line/series.js";
const radius = 1;
/** @param {ChartPoint[]} points */
function createDotsPathData(points) {
return points
.map(
({ x, y }) =>
`M${formatCoordinate(x - radius)} ${formatCoordinate(y)}` +
`a${radius} ${radius} 0 1 0 ${radius * 2} 0` +
`a${radius} ${radius} 0 1 0 ${radius * -2} 0`,
)
.join(" ");
}
/**
* @param {PlotContext} context
*/
export function renderDotsPlot({
group,
loadedSeries,
frame,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, frame, scale);
const indexes = createOrderedIndexes(plottedSeries.length, order);
for (const index of indexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "dots",
color,
d: createDotsPathData(points),
});
}
return plottedSeries;
}
+6
View File
@@ -0,0 +1,6 @@
figure[data-chart="series"] {
path[data-chart="dots"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
+134
View File
@@ -0,0 +1,134 @@
const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"];
const compactBase = 1_000_000;
const compactStep = 1_000;
const compactMax = 1e27;
const maxLength = 7;
const tinyDigits = [3, 2, 1, 0];
const smallDigits = [2, 1, 0];
const mediumDigits = [1, 0];
const integerDigits = [0];
const numberFormats = createNumberFormats(true);
const ungroupedNumberFormats = createNumberFormats(false);
/** @param {boolean} useGrouping */
function createNumberFormats(useGrouping) {
return [0, 1, 2, 3].map(
(digits) =>
new Intl.NumberFormat("en-US", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
useGrouping,
}),
);
}
/**
* @param {number} value
* @param {number} digits
* @param {boolean} [useGrouping]
*/
function formatNumber(value, digits, useGrouping = true) {
return (useGrouping ? numberFormats : ungroupedNumberFormats)[digits].format(
value,
);
}
/** @param {string} value */
function parseFormattedNumber(value) {
return Number(value.replaceAll(",", ""));
}
/** @param {number} index */
function getCompactFactor(index) {
return compactBase * compactStep ** index;
}
/** @param {number} absolute */
function getCompactIndex(absolute) {
return Math.max(
0,
Math.min(
suffixes.length - 1,
Math.floor(Math.log10(absolute / compactBase) / 3),
),
);
}
/** @param {number} absolute */
function getPlainDigits(absolute) {
if (absolute < 10) return tinyDigits;
if (absolute < 1_000) return smallDigits;
if (absolute < 10_000) return mediumDigits;
return integerDigits;
}
/**
* @param {number} value
* @param {number} index
* @param {number} length
* @param {boolean} useGrouping
*/
function formatCompact(value, index, length, useGrouping) {
for (let suffixIndex = index; suffixIndex < suffixes.length; suffixIndex += 1) {
const suffix = suffixes[suffixIndex];
const scaled = value / getCompactFactor(suffixIndex);
for (let digits = 3; digits >= 0; digits -= 1) {
const formatted = formatNumber(scaled, digits, useGrouping);
if (
Math.abs(parseFormattedNumber(formatted)) >= compactStep &&
suffixIndex < suffixes.length - 1
) break;
if (`${formatted}${suffix}`.length <= length) {
return `${formatted}${suffix}`;
}
}
}
return "Inf.";
}
/**
* @param {number} value
* @param {number} length
* @param {boolean} useGrouping
*/
function formatPlain(value, length, useGrouping) {
const absolute = Math.abs(value);
for (const digit of getPlainDigits(absolute)) {
const formatted = formatNumber(value, digit, useGrouping);
if (formatted.length <= length) return formatted;
}
return formatCompact(value, 0, length, useGrouping);
}
/**
* @param {number} value
* @param {number} length
* @param {boolean} [useGrouping]
*/
function formatValue(value, length, useGrouping = true) {
if (value === 0) return "0";
const absolute = Math.abs(value);
if (absolute >= compactMax) return "Inf.";
if (absolute < compactBase) return formatPlain(value, length, useGrouping);
return formatCompact(value, getCompactIndex(absolute), length, useGrouping);
}
/** @param {number} value */
export function formatNumberValue(value) {
return formatValue(value, maxLength);
}
/** @param {number} value */
export function formatPercentValue(value) {
return `${formatValue(value, maxLength - 1, false)}%`;
}
+25
View File
@@ -0,0 +1,25 @@
/** @param {HTMLElement} target */
export function createFullscreenButton(target) {
const button = document.createElement("button");
function update() {
const active = document.fullscreenElement === target;
button.textContent = active ? "Exit" : "Full";
button.setAttribute("aria-pressed", active.toString());
}
button.type = "button";
button.dataset.chart = "fullscreen";
button.addEventListener("click", () => {
if (document.fullscreenElement === target) {
void document.exitFullscreen();
} else {
void target.requestFullscreen();
}
});
target.addEventListener("fullscreenchange", update);
update();
return button;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @param {(HTMLElement | null)[]} items
* @param {HTMLElement} menu
*/
export function createSeriesHighlight(items, menu) {
const seriesNodes = /** @type {SeriesNode[]} */ (items.map(() => []));
const noSeries = -1;
let previewedSeries = noSeries;
/** @param {number} index */
function scrollToItem(index) {
const item = items[index];
if (!item) return;
const margin = Number.parseFloat(getComputedStyle(menu).paddingLeft);
const itemRect = item.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
if (itemRect.left < menuRect.left + margin) {
menu.scrollBy({
left: itemRect.left - menuRect.left - margin,
behavior: "smooth",
});
} else if (itemRect.right > menuRect.right - margin) {
menu.scrollBy({
left: itemRect.right - menuRect.right + margin,
behavior: "smooth",
});
}
}
/** @param {number} index */
function highlightSeries(index) {
for (const [itemIndex, item] of items.entries()) {
if (!item) continue;
setActive(item, itemIndex === index);
}
seriesNodes.forEach((nodes, nodeIndex) => {
for (const node of nodes) {
setActive(node, nodeIndex === index);
}
});
}
function clearHighlight() {
for (const item of items) {
if (item) clearElementState(item);
}
for (const nodes of seriesNodes) {
for (const node of nodes) clearElementState(node);
}
}
function clearInteractionHighlight() {
clearPreview();
clearHighlight();
}
/** @param {number} index */
function previewSeries(index) {
if (index === previewedSeries) return;
clearPreview();
scrollToItem(index);
const item = items[index];
if (item) item.dataset.preview = "";
for (const node of seriesNodes[index]) {
node.dataset.preview = "";
node.parentNode?.appendChild(node);
}
previewedSeries = index;
}
function clearPreview() {
if (previewedSeries === noSeries) return;
const item = items[previewedSeries];
if (item) delete item.dataset.preview;
for (const node of seriesNodes[previewedSeries]) {
delete node.dataset.preview;
}
previewedSeries = noSeries;
}
items.forEach((item, index) => {
if (!item) return;
item.addEventListener("pointerenter", () => highlightSeries(index));
item.addEventListener("pointerleave", clearInteractionHighlight);
item.addEventListener("focus", () => highlightSeries(index));
item.addEventListener("blur", clearInteractionHighlight);
});
/**
* @param {SVGPathElement | SVGCircleElement} node
* @param {number} index
*/
function addNode(node, index) {
seriesNodes[index].push(node);
}
function clearNodes() {
clearInteractionHighlight();
for (const nodes of seriesNodes) {
nodes.length = 0;
}
}
return {
addNode,
clearPreview,
clearNodes,
preview: previewSeries,
};
}
/**
* @param {HTMLElement | SVGElement} element
* @param {boolean} active
*/
function setActive(element, active) {
if (active) {
element.dataset.active = "";
delete element.dataset.muted;
} else {
delete element.dataset.active;
element.dataset.muted = "";
}
}
/** @param {HTMLElement | SVGElement} element */
function clearElementState(element) {
delete element.dataset.active;
delete element.dataset.muted;
delete element.dataset.preview;
}
/** @typedef {(SVGPathElement | SVGCircleElement)[]} SeriesNode */
+134
View File
@@ -0,0 +1,134 @@
import { createFullscreenButton } from "./fullscreen.js";
import { onChartVisibility } from "./intersection.js";
import { createLegend } from "./legend/index.js";
import {
createOrderControl,
getDefaultOrder,
saveOrder,
} from "./order.js";
import { createChartRenderer } from "./renderer.js";
import {
createScaleControl,
getDefaultScale,
saveScale,
} from "./scale.js";
import { createSvgElement } from "./svg.js";
import {
createTimeframeControl,
getDefaultTimeframe,
saveTimeframe,
} from "./timeframes.js";
import {
createViewControl,
getDefaultView,
saveView,
} from "./views.js";
import { FALLBACK_VIEWBOX_HEIGHT, VIEWBOX_WIDTH } from "./viewbox.js";
/**
* @param {Chart} chart
* @param {string} chartKey
*/
export function createChart(chart, chartKey) {
const figure = document.createElement("figure");
/** @type {ReturnType<typeof createChartRenderer> | undefined} */
let renderer;
figure.dataset.chart = "series";
figure.dataset.chartLegend = "";
function mount() {
if (renderer) return renderer;
const plot = document.createElement("div");
const svg = createSvgElement("svg");
const controls = document.createElement("footer");
const chartControls = document.createElement("div");
const timeControls = document.createElement("div");
const status = document.createElement("p");
let currentTimeframe = getDefaultTimeframe(chartKey);
let currentView = getDefaultView(chartKey, chart.defaultType);
let currentScale = getDefaultScale(chartKey, chart.defaultScale);
let currentOrder = getDefaultOrder(chartKey);
const { legend, menu, items, readout } = createLegend(chart);
plot.dataset.chart = "plot";
figure.dataset.timeframe = currentTimeframe;
figure.dataset.view = currentView;
figure.dataset.scale = currentScale;
figure.dataset.order = currentOrder;
svg.setAttribute(
"viewBox",
`0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`,
);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", chart.title);
svg.setAttribute("tabindex", "0");
status.setAttribute("aria-live", "polite");
status.setAttribute("role", "status");
const nextRenderer = createChartRenderer({
svg,
readout,
menu,
items,
status,
chart,
getView: () => currentView,
getScale: () => currentScale,
getOrder: () => currentOrder,
getTimeframe: () => currentTimeframe,
});
/**
* @template {string} T
* @param {string} dataKey
* @param {(chartKey: string, value: T) => void} save
* @param {T} value
*/
function saveChartSetting(dataKey, save, value) {
save(chartKey, value);
figure.dataset[dataKey] = value;
}
const viewControl = createViewControl(currentView, (view) => {
currentView = view;
saveChartSetting("view", saveView, view);
nextRenderer.renderCurrent();
});
const scaleControl = createScaleControl(currentScale, (scale) => {
currentScale = scale;
saveChartSetting("scale", saveScale, scale);
nextRenderer.renderCurrent();
});
const orderControl = createOrderControl(currentOrder, (order) => {
currentOrder = order;
saveChartSetting("order", saveOrder, order);
nextRenderer.renderCurrent();
});
const timeframeControl = createTimeframeControl(
currentTimeframe,
(timeframe) => {
currentTimeframe = timeframe;
saveChartSetting("timeframe", saveTimeframe, timeframe);
void nextRenderer.loadCurrent();
},
);
chartControls.append(viewControl, scaleControl, orderControl);
timeControls.append(timeframeControl, createFullscreenButton(figure));
controls.append(chartControls, timeControls);
plot.append(svg, status);
figure.replaceChildren(legend, plot, controls);
renderer = nextRenderer;
return renderer;
}
onChartVisibility(figure, {
show: () => mount().resume(),
hide: () => renderer?.suspend(),
});
return figure;
}
+21
View File
@@ -0,0 +1,21 @@
const lifecycleByElement = new WeakMap();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const lifecycle = lifecycleByElement.get(entry.target);
lifecycle?.[entry.isIntersecting ? "show" : "hide"]();
}
},
{
rootMargin: "400px 0px",
},
);
/**
* @param {Element} element
* @param {{ show: () => void, hide: () => void }} lifecycle
*/
export function onChartVisibility(element, lifecycle) {
lifecycleByElement.set(element, lifecycle);
observer.observe(element);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* @param {LegendChart} chart
* @returns {{ legend: HTMLElement, menu: HTMLElement, items: (HTMLElement | null)[], readout: LegendReadout }}
*/
export function createLegend(chart) {
const legend = document.createElement("figcaption");
const header = document.createElement("header");
const title = document.createElement("h5");
const separator = document.createElement("span");
const unit = document.createElement("span");
const menu = document.createElement("menu");
const rows = chart.series.map((series) => {
if (series.hidden) return null;
const item = document.createElement("li");
const button = document.createElement("button");
const label = document.createElement("span");
const value = document.createElement("output");
button.type = "button";
button.setAttribute("aria-label", `Highlight ${series.label}`);
button.style.setProperty("--color", series.color());
label.append(series.label);
button.append(label, value);
item.append(button);
menu.append(item);
return { button, value };
});
const items = rows.map((row) => row?.button ?? null);
separator.dataset.chart = "separator";
separator.setAttribute("aria-hidden", "true");
separator.append("|");
unit.dataset.chart = "unit";
unit.setAttribute("aria-label", chart.unit.name);
unit.append(chart.unit.id);
title.append(chart.title, " ", separator, " ", unit);
header.append(title);
legend.append(header, menu);
return { legend, menu, items, readout: { rows } };
}
/**
* @typedef {Object} LegendChart
* @property {string} title
* @property {ChartUnit} unit
* @property {{ label: string, color: () => string, hidden?: boolean }[]} series
*/
+140
View File
@@ -0,0 +1,140 @@
figure[data-chart-legend] {
figcaption {
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
header {
display: block;
}
h5 {
margin: 0;
font-family: var(--font-mono);
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
span:is([data-chart="unit"], [data-chart="separator"]) {
color: var(--gray);
}
menu {
display: flex;
padding: 0.25rem 0 0.5rem;
overflow-x: auto;
list-style: none;
}
li {
flex: 0 0 auto;
}
button {
display: block;
min-width: 8.5ch;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
}
&[data-press] {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:is([data-active], [data-preview]) {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> span {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> output {
display: block;
margin-top: 0.25rem;
margin-left: auto;
width: 7ch;
min-height: 1em;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
}
}
&:fullscreen {
figcaption {
h5 {
color: var(--white);
font-family: var(--font-serif);
font-size: 2rem;
text-transform: none;
}
menu {
padding-bottom: 0.5rem;
}
}
}
svg [data-series][data-muted] {
opacity: 0.2;
}
svg [data-series][data-preview] {
opacity: 1;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { createOrderedIndexes } from "../order.js";
import { createLinePathData } from "../path.js";
import { appendSeriesPath } from "../series-path.js";
import { createLineSeries } from "./series.js";
/**
* @param {PlotContext} context
*/
export function renderLinePlot({
group,
loadedSeries,
frame,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, frame, scale);
const indexes = createOrderedIndexes(plottedSeries.length, order);
for (const index of indexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "line",
color,
d: createLinePathData(points),
});
}
return plottedSeries;
}
+79
View File
@@ -0,0 +1,79 @@
import { getPlotHeight, insetPlotY, VIEWBOX_WIDTH } from "../viewbox.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
/** @param {LoadedSeries[]} series */
function createValueBounds(series) {
const bounds = createBounds();
for (const { entries } of series) {
for (const { value } of entries) {
includeBoundValue(bounds, value);
}
}
return bounds;
}
/**
* @param {ChartEntry[]} entries
* @param {ScaleBounds} bounds
* @param {ChartFrame} frame
* @param {ChartScale} scale
* @returns {ChartPoint[]}
*/
function createPoints(entries, bounds, frame, scale) {
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
const plotHeight = getPlotHeight(frame);
return entries.map(({ date, value }, index) => ({
date,
value,
x: index * xScale,
y: insetPlotY(frame, scaleY(value, bounds, plotHeight, scale)),
}));
}
/**
* @param {ChartPoint[]} points
* @param {number} x
*/
function interpolateY(points, x) {
if (x <= points[0].x) return points[0].y;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (x > next.x) continue;
const span = next.x - previous.x;
const ratio = span ? (x - previous.x) / span : 0;
return previous.y + (next.y - previous.y) * ratio;
}
return points[points.length - 1].y;
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {ChartFrame} frame
* @param {ChartScale} scale
*/
export function createLineSeries(loadedSeries, frame, scale) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => {
const points = createPoints(entries, bounds, frame, scale);
return {
series,
color,
points,
hitTest: /** @type {PlottedSeries["hitTest"]} */ (
(_point, pointerX, pointerY) =>
Math.abs(interpolateY(points, pointerX) - pointerY)
),
};
});
}
+10
View File
@@ -0,0 +1,10 @@
figure[data-chart="series"] {
path[data-chart="line"] {
fill: none;
stroke: var(--color, var(--orange));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { brk } from "../utils/client.js";
import { fetchTimeframe } from "./timeframes.js";
/**
* @param {ChartResult} result
* @returns {ChartEntry[]}
*/
function createEntries(result) {
/** @type {ChartEntry[]} */
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;
};
}
+52
View File
@@ -0,0 +1,52 @@
import { CHART_MARKER, CHART_POINT, CHART_SIZE } from "./constants.js";
const VIEWBOX_WIDTH = CHART_SIZE.width;
/**
* @param {HTMLElement} marker
* @param {number} y
*/
export function sizeChartMarker(marker, y = 0) {
marker.style.top = `${y}px`;
marker.style.height = `${CHART_MARKER.height}px`;
}
/** @param {HTMLElement} marker */
function getMarkerWidth(marker) {
return marker.offsetWidth || CHART_MARKER.fallbackWidth;
}
/**
* @param {HTMLElement} marker
* @param {number} xValue
* @param {number} [viewWidth]
*/
export function positionChartMarker(marker, xValue, viewWidth = VIEWBOX_WIDTH) {
const parentWidth = marker.parentElement?.clientWidth || viewWidth;
const markerWidth = getMarkerWidth(marker);
const x = (xValue / viewWidth) * parentWidth;
const min = -CHART_MARKER.edgeOverflow;
const max = Math.max(
min,
parentWidth - markerWidth + CHART_MARKER.edgeOverflow,
);
const left = Math.min(Math.max(x - markerWidth / 2, min), max);
marker.style.left = `${left.toFixed(2)}px`;
}
/**
* @param {HTMLElement} marker
* @param {number} xValue
*/
export function layoutChartMarker(marker, xValue) {
sizeChartMarker(marker);
positionChartMarker(marker, xValue);
}
/** @param {number} viewWidth */
export function getChartPointRadius(viewWidth) {
return viewWidth
? (CHART_POINT.radius * CHART_SIZE.width) / viewWidth
: CHART_POINT.radius;
}
+17
View File
@@ -0,0 +1,17 @@
[data-chart="plot"] > [data-chart-marker] {
position: absolute;
z-index: 1;
display: grid;
place-items: center;
width: max-content;
padding-inline: 0.5rem;
box-sizing: border-box;
border-radius: 0.25rem;
color: var(--gray);
background: transparent;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-align: center;
pointer-events: none;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
+57
View File
@@ -0,0 +1,57 @@
import { createChartSetting } from "./setting.js";
export const orders = /** @type {const} */ ([
{ value: "ascending", label: "Asc" },
{ value: "descending", label: "Dsc" },
]);
const defaultOrder = "ascending";
const setting = createChartSetting({
storageKey: "order",
legend: "Order",
options: orders,
defaultValue: defaultOrder,
});
/** @param {string} chartKey */
export function getDefaultOrder(chartKey) {
return setting.get(chartKey);
}
/**
* @param {string} chartKey
* @param {ChartOrder} order
*/
export function saveOrder(chartKey, order) {
setting.save(chartKey, order);
}
/**
* @param {ChartOrder} currentOrder
* @param {(order: ChartOrder) => void} onChange
*/
export function createOrderControl(currentOrder, onChange) {
return setting.create(currentOrder, onChange);
}
/**
* @param {number[]} indexes
* @param {ChartOrder} order
*/
export function orderIndexes(indexes, order) {
const orderedIndexes = [...indexes];
if (order === "descending") orderedIndexes.reverse();
return orderedIndexes;
}
/**
* @param {number} length
* @param {ChartOrder} order
*/
export function createOrderedIndexes(length, order) {
return orderIndexes(
Array.from({ length }, (_, index) => index),
order,
);
}
+35
View File
@@ -0,0 +1,35 @@
/** @param {number} value */
export function formatCoordinate(value) {
return value.toFixed(2);
}
/**
* @param {string} command
* @param {number} x
* @param {number} y
*/
function createPathCommand(command, x, y) {
return `${command}${formatCoordinate(x)} ${formatCoordinate(y)}`;
}
/** @param {{ x: number, y: number }[]} points */
export function createLinePathData(points) {
return points
.map(({ x, y }, index) => createPathCommand(index ? "L" : "M", x, y))
.join(" ");
}
/** @param {StackedPoint[]} points */
export function createAreaPathData(points) {
const commands = points.map(({ x, y1 }, index) =>
createPathCommand(index ? "L" : "M", x, y1),
);
for (let index = points.length - 1; index >= 0; index -= 1) {
const { x, y0 } = points[index];
commands.push(createPathCommand("L", x, y0));
}
return `${commands.join(" ")} Z`;
}
+24
View File
@@ -0,0 +1,24 @@
import { renderAreaPlot } from "./area/index.js";
import { renderBarPlot } from "./bar/index.js";
import { renderDotsPlot } from "./dots/index.js";
import { renderLinePlot } from "./line/index.js";
import { renderStackedPlot } from "./stacked/index.js";
/**
* @param {ChartView} view
* @param {PlotContext} context
*/
export function renderPlot(view, context) {
switch (view) {
case "line":
return renderLinePlot(context);
case "area":
return renderAreaPlot(context);
case "bar":
return renderBarPlot(context);
case "dots":
return renderDotsPlot(context);
case "stacked":
return renderStackedPlot(context);
}
}
+40
View File
@@ -0,0 +1,40 @@
let groupId = 0;
/**
* @template {string} T
* @param {Object} args
* @param {string} args.legend
* @param {readonly { value: T, label: string }[]} args.options
* @param {T} args.currentValue
* @param {(value: T) => void} args.onChange
*/
export function createRadioGroup(args) {
const fieldset = document.createElement("fieldset");
const legend = document.createElement("legend");
const name = `chart-control-${(groupId += 1)}`;
legend.append(args.legend);
fieldset.append(legend);
fieldset.addEventListener("change", (event) => {
const input = /** @type {HTMLInputElement} */ (event.target);
args.onChange(/** @type {T} */ (input.value));
});
for (const option of args.options) {
const label = document.createElement("label");
const input = document.createElement("input");
const text = document.createElement("span");
input.type = "radio";
input.name = name;
input.value = option.value;
input.checked = option.value === args.currentValue;
text.append(option.label);
label.append(input, text);
fieldset.append(label);
}
return fieldset;
}
+132
View File
@@ -0,0 +1,132 @@
import { createSeriesHighlight } from "./highlight.js";
import { createSeriesLoader } from "./loader.js";
import { renderPlot } from "./plot.js";
import { createScrubber } from "./scrubber/index.js";
import { createSvgElement } from "./svg.js";
import { createChartFrame, VIEWBOX_WIDTH } from "./viewbox.js";
/**
* @param {Object} args
* @param {SVGSVGElement} args.svg
* @param {LegendReadout} args.readout
* @param {HTMLElement} args.menu
* @param {(HTMLElement | null)[]} args.items
* @param {HTMLElement} args.status
* @param {Chart} args.chart
* @param {() => ChartView} args.getView
* @param {() => ChartScale} args.getScale
* @param {() => ChartOrder} args.getOrder
* @param {() => TimeframeValue} args.getTimeframe
*/
export function createChartRenderer({
svg,
readout,
menu,
items,
status,
chart,
getView,
getScale,
getOrder,
getTimeframe,
}) {
const group = createSvgElement("g");
const highlight = createSeriesHighlight(items, menu);
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 clearStatus() {
status.textContent = "";
svg.removeAttribute("aria-busy");
}
/** @param {string} message */
function setStatus(message) {
status.textContent = message;
}
function renderCurrent() {
if (!active || !loadedSeries.length) return;
const frame = createChartFrame(svg);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${frame.height}`);
group.replaceChildren();
highlight.clearNodes();
scrubber ??= createScrubber(svg, readout, highlight, chart.unit.format);
scrubber.setSeries(
renderPlot(getView(), {
group,
loadedSeries,
frame,
highlight,
scale: getScale(),
order: getOrder(),
}),
frame,
);
}
async function loadCurrent() {
const id = (loadId += 1);
const loadingTimer = setTimeout(() => {
if (id === loadId && active) setStatus("Loading");
}, 250);
setStatus("");
svg.setAttribute("aria-busy", "true");
try {
const nextSeries = await loadSeries(getTimeframe());
if (id !== loadId || !active) return;
loadedSeries = nextSeries;
renderCurrent();
clearStatus();
} catch (error) {
if (id !== loadId) return;
console.error(error);
setStatus("Chart unavailable");
} finally {
clearTimeout(loadingTimer);
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();
clearStatus();
}
return {
loadCurrent,
renderCurrent,
resume,
suspend,
};
}
+81
View File
@@ -0,0 +1,81 @@
import { createChartSetting } from "./setting.js";
export const scales = /** @type {const} */ ([
{ value: "linear", label: "Lin" },
{ value: "log", label: "Log" },
]);
const defaultScale = "linear";
const setting = createChartSetting({
storageKey: "scale",
legend: "Scale",
options: scales,
defaultValue: defaultScale,
});
/**
* @param {string} chartKey
* @param {ChartScale} [fallback]
*/
export function getDefaultScale(chartKey, fallback = defaultScale) {
return setting.get(chartKey, fallback);
}
/**
* @param {string} chartKey
* @param {ChartScale} scale
*/
export function saveScale(chartKey, scale) {
setting.save(chartKey, scale);
}
/**
* @param {ChartScale} currentScale
* @param {(scale: ChartScale) => void} onChange
*/
export function createScaleControl(currentScale, onChange) {
return setting.create(currentScale, onChange);
}
export function createBounds() {
return {
min: Infinity,
max: -Infinity,
minPositive: Infinity,
};
}
/**
* @param {ScaleBounds} bounds
* @param {number} value
*/
export function includeBoundValue(bounds, value) {
bounds.min = Math.min(bounds.min, value);
bounds.max = Math.max(bounds.max, value);
if (value > 0) bounds.minPositive = Math.min(bounds.minPositive, value);
}
/**
* @param {number} value
* @param {ScaleBounds} bounds
* @param {number} height
* @param {ChartScale} scale
*/
export function scaleY(value, bounds, height, scale) {
if (bounds.max === bounds.min) return height / 2;
if (scale === "log") {
if (bounds.max <= bounds.minPositive) {
return value > 0 ? height / 2 : height;
}
const nextValue = Math.max(value, bounds.minPositive);
return (
height -
((Math.log10(nextValue) - Math.log10(bounds.minPositive)) /
(Math.log10(bounds.max) - Math.log10(bounds.minPositive))) *
height
);
}
return height - ((value - bounds.min) / (bounds.max - bounds.min)) * height;
}
+259
View File
@@ -0,0 +1,259 @@
import { clamp } from "../math.js";
import {
getChartPointRadius,
layoutChartMarker,
} from "../marker.js";
import { createSvgElement } from "../svg.js";
import { getPlotBottom, VIEWBOX_WIDTH } from "../viewbox.js";
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
/**
* @param {ScrubberSeries} series
* @param {number} step
*/
function getPointAtStep(series, step) {
return series.points[step];
}
/**
* @param {ScrubberSeries[]} series
* @param {ChartPoint[]} points
* @param {number} x
* @param {number} y
*/
function getClosestPointIndex(series, points, x, y) {
let closestIndex = 0;
let closestDistance = Infinity;
for (const [index, point] of points.entries()) {
const distance =
series[index].hitTest?.(point, x, y) ?? Math.abs(point.y - y);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
}
return closestIndex;
}
/**
* @param {LegendReadout} readout
* @param {ChartPoint[]} points
* @param {(value: number) => string} format
*/
function updateReadout(readout, points, format) {
readout.rows.forEach((row, index) => {
if (!row) return;
row.value.textContent = format(points[index].value);
});
}
/**
* @param {SVGSVGElement} svg
* @param {LegendReadout} readout
* @param {SeriesHighlight} highlight
* @param {(value: number) => string} format
*/
export function createScrubber(svg, readout, highlight, format) {
const group = createSvgElement("g");
const shade = createSvgElement("rect");
const guide = createSvgElement("line");
const plot = /** @type {HTMLElement} */ (svg.parentElement);
const dateMarker = document.createElement("div");
/** @type {ScrubberSeries[]} */
let series = [];
/** @type {SVGCircleElement[]} */
let markers = [];
/** @type {ChartFrame | undefined} */
let frame;
let stepCount = 0;
let currentStep = -1;
/** @type {ChartPoint[]} */
let currentPoints = [];
let rect = svg.getBoundingClientRect();
let pointerX = 0;
let pointerY = 0;
let pointerFrame = 0;
group.dataset.scrubber = "root";
shade.dataset.scrubber = "shade";
guide.dataset.scrubber = "guide";
dateMarker.dataset.chartMarker = "date";
group.append(shade, guide);
svg.append(group);
plot.append(dateMarker);
function measure() {
rect = svg.getBoundingClientRect();
}
/** @param {number} step */
function getPointsAtStep(step) {
return series.map((item) => getPointAtStep(item, step));
}
/**
* @param {number} ratio
* @param {number} [x]
* @param {number} [y]
* @param {boolean} [scrubbing]
*/
function update(ratio, x, y, scrubbing = true) {
if (!series.length || !frame) return;
const nextStep = Math.round(clamp(ratio, 0, 1) * stepCount);
if (nextStep !== currentStep) {
currentStep = nextStep;
currentPoints = getPointsAtStep(nextStep);
const stepX = currentPoints[0].x;
const xText = stepX.toFixed(2);
const plotBottom = getPlotBottom(frame);
svg.dataset.index = nextStep.toString();
shade.setAttribute("x", xText);
shade.setAttribute("y", "0");
shade.setAttribute("width", (VIEWBOX_WIDTH - stepX).toFixed(2));
shade.setAttribute("height", plotBottom.toString());
guide.setAttribute("x1", xText);
guide.setAttribute("x2", xText);
guide.setAttribute("y1", "0");
guide.setAttribute("y2", plotBottom.toString());
dateMarker.textContent = dateFormat.format(currentPoints[0].date);
dateMarker.setAttribute(
"aria-label",
`Date ${dateMarker.textContent}`,
);
layoutChartMarker(dateMarker, stepX);
updateReadout(readout, currentPoints, format);
markers.forEach((marker, index) => {
const point = currentPoints[index];
marker.setAttribute("cx", point.x.toFixed(2));
marker.setAttribute("cy", point.y.toFixed(2));
});
}
if (scrubbing) {
svg.dataset.scrubbing = "true";
} else {
delete svg.dataset.scrubbing;
}
if (x !== undefined && y !== undefined) {
highlight.preview(getClosestPointIndex(series, currentPoints, x, y));
}
}
function hide() {
update(1, undefined, undefined, false);
}
function cancelPointerUpdate() {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
}
function clear() {
cancelPointerUpdate();
series = [];
markers = [];
currentStep = -1;
currentPoints = [];
frame = undefined;
dateMarker.style.display = "none";
highlight.clearPreview();
group.replaceChildren(shade, guide);
delete svg.dataset.index;
delete svg.dataset.scrubbing;
}
/**
* @param {ScrubberSeries[]} nextSeries
* @param {ChartFrame} nextFrame
*/
function setSeries(nextSeries, nextFrame) {
series = nextSeries;
frame = nextFrame;
currentStep = -1;
stepCount = Math.max(...series.map(({ points }) => points.length - 1));
measure();
dateMarker.style.display = "";
const radius = getChartPointRadius(rect.width);
markers = series.map(({ color }, index) => {
const marker = createSvgElement("circle");
marker.dataset.series = index.toString();
marker.dataset.scrubber = "marker";
marker.style.setProperty("--color", color);
marker.setAttribute("r", radius.toString());
highlight.addNode(marker, index);
return marker;
});
group.replaceChildren(shade, guide, ...markers);
update(1, undefined, undefined, false);
}
/** @param {PointerEvent} event */
function updateFromPointer(event) {
pointerX = event.clientX;
pointerY = event.clientY;
if (pointerFrame) return;
pointerFrame = requestAnimationFrame(() => {
pointerFrame = 0;
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
const y = ((pointerY - rect.top) / rect.height) * (frame?.height ?? 0);
update(x / VIEWBOX_WIDTH, x, y);
});
}
svg.addEventListener("pointerenter", measure);
svg.addEventListener("pointermove", updateFromPointer);
svg.addEventListener("pointerleave", () => {
cancelPointerUpdate();
highlight.clearPreview();
hide();
});
svg.addEventListener("focus", () => update(1));
svg.addEventListener("blur", () => {
cancelPointerUpdate();
highlight.clearPreview();
hide();
});
svg.addEventListener("keydown", (event) => {
const current = Number(svg.dataset.index || stepCount);
if (event.key === "ArrowLeft") {
event.preventDefault();
update((current - 1) / stepCount);
}
if (event.key === "ArrowRight") {
event.preventDefault();
update((current + 1) / stepCount);
}
});
return { clear, setSeries };
}
/**
* @typedef {Object} ScrubberSeries
* @property {string} color
* @property {ChartPoint[]} points
* @property {PlottedSeries["hitTest"]} [hitTest]
*/
+39
View File
@@ -0,0 +1,39 @@
figure[data-chart="series"] {
[data-scrubber] {
opacity: 0;
pointer-events: none;
}
svg[data-scrubbing="true"] [data-scrubber] {
opacity: 1;
}
[data-scrubber="guide"] {
stroke: var(--white);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
}
[data-scrubber="shade"] {
fill: var(--black);
fill-opacity: 0.5;
}
[data-scrubber="marker"] {
fill: var(--black);
stroke: var(--color, var(--orange));
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
[data-scrubber="marker"][data-preview] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-width: 1.75;
}
svg[data-scrubbing="true"] ~ [data-chart-marker="date"] {
color: var(--black);
background: var(--white);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { createSvgElement } from "./svg.js";
/**
* @param {Object} args
* @param {SVGGElement} args.group
* @param {SeriesHighlight} args.highlight
* @param {number} args.index
* @param {string} args.chart
* @param {string} args.color
* @param {string} args.d
*/
export function appendSeriesPath(args) {
const path = createSvgElement("path");
path.dataset.chart = args.chart;
path.dataset.series = args.index.toString();
path.style.setProperty("--color", args.color);
path.setAttribute("d", args.d);
args.highlight.addNode(path, args.index);
args.group.append(path);
return path;
}
+50
View File
@@ -0,0 +1,50 @@
import { createRadioGroup } from "./radio.js";
import { createChartStorage } from "./storage.js";
/**
* @template {string} T
* @param {Object} config
* @param {string} config.storageKey
* @param {string} config.legend
* @param {readonly { value: T, label: string }[]} config.options
* @param {T} config.defaultValue
*/
export function createChartSetting(config) {
const storage = createChartStorage(config.storageKey);
return {
/**
* @param {string} chartKey
* @param {T} [fallback]
*/
get(chartKey, fallback = config.defaultValue) {
const value = storage.get(chartKey);
return (
config.options.find((option) => option.value === value)?.value ??
fallback
);
},
/**
* @param {string} chartKey
* @param {T} value
*/
save(chartKey, value) {
storage.set(chartKey, value);
},
/**
* @param {T} currentValue
* @param {(value: T) => void} onChange
*/
create(currentValue, onChange) {
return createRadioGroup({
legend: config.legend,
options: config.options,
currentValue,
onChange,
});
},
};
}
+48
View File
@@ -0,0 +1,48 @@
import { createAreaPathData, createLinePathData } from "../path.js";
import { appendSeriesPath } from "../series-path.js";
import { createStackedSeries } from "./series.js";
/**
* @param {PlotContext} context
*/
export function renderStackedPlot({
group,
loadedSeries,
frame,
highlight,
scale,
order,
}) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
frame,
order,
scale,
);
for (const index of stackIndexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "stacked",
color,
d: createAreaPathData(points),
});
}
for (const index of lineIndexes) {
const { color, points } = plottedSeries[index];
appendSeriesPath({
group,
highlight,
index,
chart: "line",
color,
d: createLinePathData(points),
});
}
return plottedSeries;
}
+162
View File
@@ -0,0 +1,162 @@
import { getPlotHeight, insetPlotY, VIEWBOX_WIDTH } from "../viewbox.js";
import { orderIndexes } from "../order.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
/**
* @param {LoadedSeries[]} series
* @param {number[]} stackOrder
* @param {number[]} lineIndexes
*/
function createStackBounds(series, stackOrder, lineIndexes) {
const bounds = createBounds();
const length = series[0].entries.length;
includeBoundValue(bounds, 0);
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
for (const seriesIndex of stackOrder) {
const value = series[seriesIndex].entries[index].value;
const end = value < 0 ? negative + value : positive + value;
if (value < 0) negative = end;
else positive = end;
includeBoundValue(bounds, end);
}
for (const seriesIndex of lineIndexes) {
const value = series[seriesIndex].entries[index].value;
includeBoundValue(bounds, value);
}
}
return bounds;
}
/**
* @param {StackedPoint[]} points
* @param {number} x
* @param {"y" | "y0" | "y1"} key
*/
function interpolateStackY(points, x, key) {
if (x <= points[0].x) return points[0][key];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (x > next.x) continue;
const span = next.x - previous.x;
const ratio = span ? (x - previous.x) / span : 0;
return previous[key] + (next[key] - previous[key]) * ratio;
}
return points[points.length - 1][key];
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {ChartFrame} frame
* @param {ChartOrder} order
* @param {ChartScale} scale
*/
export function createStackedSeries(loadedSeries, frame, order, scale) {
const indexes = loadedSeries.map((_, index) => index);
const lineIndexes = orderIndexes(
indexes.filter((index) => loadedSeries[index].series.role === "line"),
order,
);
const stackIndexes = orderIndexes(
indexes.filter((index) => loadedSeries[index].series.role !== "line"),
order,
);
const length = loadedSeries[0].entries.length;
const xScale = VIEWBOX_WIDTH / (length - 1);
const plotHeight = getPlotHeight(frame);
const plottedSeries = loadedSeries.map(({ series, color }) => ({
series,
color,
points: /** @type {StackedPoint[]} */ ([]),
hitTest: /** @type {StackedPlottedSeries["hitTest"]} */ (undefined),
}));
const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes);
for (const index of stackIndexes) {
const points = plottedSeries[index].points;
plottedSeries[index].hitTest = (_point, pointerX, pointerY) => {
if (!points.length) return Infinity;
const y0 = interpolateStackY(points, pointerX, "y0");
const y1 = interpolateStackY(points, pointerX, "y1");
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
return pointerY >= top && pointerY <= bottom
? 0
: Math.min(Math.abs(pointerY - top), Math.abs(pointerY - bottom));
};
}
for (const index of lineIndexes) {
const points = plottedSeries[index].points;
plottedSeries[index].hitTest = (_point, pointerX, pointerY) =>
Math.abs(interpolateStackY(points, pointerX, "y") - pointerY);
}
for (let index = 0; index < length; index += 1) {
let negative = 0;
let positive = 0;
const x = index * xScale;
for (const seriesIndex of stackIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const start = value < 0 ? negative : positive;
const end = start + value;
if (value < 0) negative = end;
else positive = end;
const y0 = insetPlotY(frame, scaleY(start, bounds, plotHeight, scale));
const y1 = insetPlotY(frame, scaleY(end, bounds, plotHeight, scale));
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y: y1,
y0,
y1,
});
}
for (const seriesIndex of lineIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const y = insetPlotY(frame, scaleY(value, bounds, plotHeight, scale));
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y,
y0: y,
y1: y,
});
}
}
return {
lineIndexes,
plottedSeries,
stackIndexes,
};
}
+9
View File
@@ -0,0 +1,9 @@
figure[data-chart="series"] {
path[data-chart="stacked"] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
+19
View File
@@ -0,0 +1,19 @@
/** @param {string} name */
export function createChartStorage(name) {
const prefix = `bitview:chart-${name}`;
return {
/** @param {string} chartKey */
get(chartKey) {
return localStorage.getItem(`${prefix}:${chartKey}`);
},
/**
* @param {string} chartKey
* @param {string} value
*/
set(chartKey, value) {
localStorage.setItem(`${prefix}:${chartKey}`, value);
},
};
}
+77
View File
@@ -0,0 +1,77 @@
figure[data-chart="series"] {
--chart-plot-height: 20rem;
--chart-reserved-ui-height: 6rem;
min-height: calc(
var(--chart-plot-height) + var(--chart-reserved-ui-height)
);
line-height: 1;
svg {
display: block;
width: 100%;
height: var(--chart-plot-height);
outline: 0;
cursor: crosshair;
overflow: visible;
touch-action: pan-y;
transition: opacity 150ms ease;
}
svg:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.25rem;
}
svg[aria-busy="true"] {
opacity: 0.25;
}
> div[data-chart="plot"] {
position: relative;
}
p[role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
margin: 0;
color: var(--white);
text-transform: uppercase;
pointer-events: none;
}
p[role="status"]:empty {
display: none;
}
> footer {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.5rem 0 0;
}
&:fullscreen {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--black);
> div[data-chart="plot"] {
flex: 1;
min-height: 0;
display: flex;
}
svg {
flex: 1;
height: auto;
min-height: 0;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* @template {keyof SVGElementTagNameMap} Name
* @param {Name} name
* @returns {SVGElementTagNameMap[Name]}
*/
export function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
+60
View File
@@ -0,0 +1,60 @@
import { createChartSetting } from "./setting.js";
export const timeframes = /** @type {const} */ ({
"1d": { index: "minute10", count: 144 },
"1w": { index: "hour1", count: 168 },
"1m": { index: "hour4", count: 186 },
"1y": { index: "day1", count: 366 },
"4y": { index: "day3", count: 488 },
"8y": { index: "week1", count: 418 },
all: { index: "week1" },
});
export const timeframeOptions = /** @type {const} */ ([
{ value: "1d", label: "1d" },
{ value: "1w", label: "1w" },
{ value: "1m", label: "1m" },
{ value: "1y", label: "1y" },
{ value: "4y", label: "4y" },
{ value: "8y", label: "8y" },
{ value: "all", label: "all" },
]);
const setting = createChartSetting({
storageKey: "timeframe",
legend: "Time",
options: timeframeOptions,
defaultValue: "all",
});
/** @param {string} chartKey */
export function getDefaultTimeframe(chartKey) {
return setting.get(chartKey);
}
/**
* @param {string} chartKey
* @param {TimeframeValue} timeframe
*/
export function saveTimeframe(chartKey, timeframe) {
setting.save(chartKey, timeframe);
}
/**
* @param {TimeframeValue} currentTimeframe
* @param {(timeframe: TimeframeValue) => void} onChange
*/
export function createTimeframeControl(currentTimeframe, onChange) {
return setting.create(currentTimeframe, onChange);
}
/**
* @param {TimeframeMetric} metric
* @param {TimeframeValue} timeframe
*/
export function fetchTimeframe(metric, timeframe) {
const config = timeframes[timeframe];
const endpoint = metric.by[config.index];
return "count" in config
? endpoint.last(config.count).fetch()
: endpoint.fetch();
}
+128
View File
@@ -0,0 +1,128 @@
import { brk } from "../../utils/client.js";
import { orders } from "./order.js";
import { scales } from "./scale.js";
import { timeframes, timeframeOptions } from "./timeframes.js";
import { views } from "./views.js";
declare global {
type ChartEntry = {
date: Date;
value: number;
};
type ChartMetric = (client: typeof brk) => TimeframeMetric;
type ChartOrder = (typeof orders)[number]["value"];
type ChartPoint = ChartEntry & {
x: number;
y: number;
};
type ChartResult = {
dateEntries(): Iterable<[Date, number | null | undefined]>;
};
type ChartScale = (typeof scales)[number]["value"];
type ChartSeries = {
label: string;
color: () => string;
role?: "line";
metric: ChartMetric;
};
type ChartUnit = {
id: string;
name: string;
format(value: number): string;
};
type ChartView = (typeof views)[number]["value"];
type Chart = {
title: string;
unit: ChartUnit;
defaultType?: ChartView;
defaultScale?: ChartScale;
series: ChartSeries[];
};
type ChartFrame = {
width: number;
height: number;
top: number;
bottom: number;
plotHeight: number;
};
type ChartFrameOptions = {
topPadding?: number;
bottomPadding?: number;
};
type LegendReadout = {
rows: ({ value: HTMLOutputElement } | null)[];
};
type LoadedSeries = {
series: ChartSeries;
color: string;
entries: ChartEntry[];
};
type PlotContext = {
group: SVGGElement;
loadedSeries: LoadedSeries[];
frame: ChartFrame;
highlight: SeriesHighlight;
scale: ChartScale;
order: ChartOrder;
};
type PlottedSeries = {
series: ChartSeries;
color: string;
points: ChartPoint[];
hitTest?: (
point: ChartPoint | StackedPoint,
pointerX: number,
pointerY: number,
) => number;
};
type ScaleBounds = {
min: number;
max: number;
minPositive: number;
};
type SeriesHighlight = {
addNode(
node: SVGPathElement | SVGCircleElement,
index: number,
): void;
clearPreview(): void;
clearNodes(): void;
preview(index: number): void;
};
type StackedPoint = ChartPoint & {
y0: number;
y1: number;
};
type StackedPlottedSeries = Omit<PlottedSeries, "points" | "hitTest"> & {
points: StackedPoint[];
hitTest?: PlottedSeries["hitTest"];
};
type XyPoint = {
x: number;
y: number;
value: number;
};
type XyPlottedSeries = {
points: XyPoint[];
value?: number | string;
};
type XySeries = {
label: string;
color: () => string;
kind: "line" | "point";
hidden?: boolean;
};
type TimeframeEndpoint = {
fetch(): Promise<ChartResult>;
last(count: number): { fetch(): Promise<ChartResult> };
};
type TimeframeIndex = (typeof timeframes)[TimeframeValue]["index"];
type TimeframeMetric = {
by: Record<TimeframeIndex, TimeframeEndpoint>;
};
type TimeframeValue = (typeof timeframeOptions)[number]["value"];
}
export {};
+10
View File
@@ -0,0 +1,10 @@
import { formatNumberValue, formatPercentValue } from "./format.js";
export const units = /** @type {const} */ ({
addresses: { id: "addresses", name: "Addresses", format: formatNumberValue },
blocks: { id: "blocks", name: "Blocks", format: formatNumberValue },
btc: { id: "btc", name: "Bitcoin", format: formatNumberValue },
percent: { id: "%", name: "Percent", format: formatPercentValue },
utxos: { id: "utxos", name: "UTXOs", format: formatNumberValue },
usd: { id: "usd", name: "US Dollars", format: formatNumberValue },
});
+68
View File
@@ -0,0 +1,68 @@
import { CHART_FRAME, CHART_MARKER, CHART_SIZE } from "./constants.js";
export const VIEWBOX_WIDTH = CHART_SIZE.width;
export const FALLBACK_VIEWBOX_HEIGHT = CHART_SIZE.fallbackHeight;
/**
* @param {SVGSVGElement} svg
* @param {number} [fallbackHeight]
*/
export function getViewBoxHeight(svg, fallbackHeight = FALLBACK_VIEWBOX_HEIGHT) {
const { width, height } = svg.getBoundingClientRect();
return width && height ? (VIEWBOX_WIDTH * height) / width : fallbackHeight;
}
/**
* @param {SVGSVGElement} svg
* @param {number} height
*/
function getViewBoxUnit(svg, height) {
return svg.clientHeight ? height / svg.clientHeight : 1;
}
/**
* @param {SVGSVGElement} svg
* @param {number} [fallbackHeight]
* @param {ChartFrameOptions} [options]
* @returns {ChartFrame}
*/
export function createChartFrame(
svg,
fallbackHeight = FALLBACK_VIEWBOX_HEIGHT,
options = {},
) {
const height = getViewBoxHeight(svg, fallbackHeight);
const unit = getViewBoxUnit(svg, height);
const topPadding =
options.topPadding ?? CHART_MARKER.height + CHART_FRAME.topGap;
const bottomPadding = options.bottomPadding ?? CHART_FRAME.bottomPadding;
const top = topPadding * unit;
const bottom = Math.max(top + 1, height - bottomPadding * unit);
return {
width: VIEWBOX_WIDTH,
height,
top,
bottom,
plotHeight: bottom - top,
};
}
/** @param {ChartFrame} frame */
export function getPlotHeight(frame) {
return frame.plotHeight;
}
/** @param {ChartFrame} frame */
export function getPlotBottom(frame) {
return frame.bottom;
}
/**
* @param {ChartFrame} frame
* @param {number} y
*/
export function insetPlotY(frame, y) {
return frame.top + y;
}
+40
View File
@@ -0,0 +1,40 @@
import { createChartSetting } from "./setting.js";
export const views = /** @type {const} */ ([
{ value: "line", label: "Line" },
{ value: "area", label: "Area" },
{ value: "stacked", label: "Stack" },
{ value: "bar", label: "Bars" },
{ value: "dots", label: "Dots" },
]);
const defaultView = "stacked";
const setting = createChartSetting({
storageKey: "view",
legend: "View",
options: views,
defaultValue: defaultView,
});
/**
* @param {string} chartKey
* @param {ChartView} [fallback]
*/
export function getDefaultView(chartKey, fallback = defaultView) {
return setting.get(chartKey, fallback);
}
/**
* @param {string} chartKey
* @param {ChartView} view
*/
export function saveView(chartKey, view) {
setting.save(chartKey, view);
}
/**
* @param {ChartView} currentView
* @param {(view: ChartView) => void} onChange
*/
export function createViewControl(currentView, onChange) {
return setting.create(currentView, onChange);
}
+272
View File
@@ -0,0 +1,272 @@
import { createSeriesHighlight } from "../highlight.js";
import { createLegend } from "../legend/index.js";
import { getChartPointRadius, layoutChartMarker } from "../marker.js";
import { createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createChartFrame, VIEWBOX_WIDTH } from "../viewbox.js";
/**
* @param {Object} args
* @param {string} args.title
* @param {ChartUnit} args.unit
* @param {string} args.ariaLabel
* @param {number} args.fallbackHeight
* @param {ChartFrameOptions} [args.gutter]
* @param {XySeries[]} args.series
* @param {(frame: ChartFrame) => XyPlottedSeries[]} args.plot
* @param {false | ((series: XySeries, plotted: XyPlottedSeries, point: XyPoint) => string)} [args.marker]
*/
export function createXyChart({
title,
unit,
ariaLabel,
fallbackHeight,
gutter,
series,
plot,
marker,
}) {
const frameOptions =
gutter ?? (marker === false ? { topPadding: 0 } : {});
const figure = document.createElement("figure");
const plotElement = document.createElement("div");
const svg = createSvgElement("svg");
const group = createSvgElement("g");
const guide = createSvgElement("line");
const markerElement = document.createElement("div");
const { legend, menu, items, readout } = createLegend({
title,
unit,
series,
});
const highlight = createSeriesHighlight(items, menu);
const resizeObserver = new ResizeObserver(render);
/** @type {XyPlottedSeries[]} */
let currentSeries = [];
/** @type {ChartFrame | undefined} */
let currentFrame;
let rect = svg.getBoundingClientRect();
let pointerX = 0;
let pointerY = 0;
let pointerFrame = 0;
figure.dataset.chart = "xy";
figure.dataset.chartLegend = "";
plotElement.dataset.chart = "plot";
guide.dataset.chart = "xy-guide";
markerElement.dataset.chartMarker = "xy";
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${fallbackHeight}`);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", ariaLabel);
svg.append(guide, group);
plotElement.append(svg, markerElement);
figure.append(legend, plotElement);
function measure() {
rect = svg.getBoundingClientRect();
}
function render() {
const frame = createChartFrame(svg, fallbackHeight, frameOptions);
const plottedSeries = plot(frame);
const radius = getChartPointRadius(svg.getBoundingClientRect().width);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${frame.height}`);
currentFrame = frame;
currentSeries = plottedSeries;
highlight.clearNodes();
group.replaceChildren();
hideMarker();
updateReadout(readout, unit, plottedSeries);
plottedSeries.forEach((plotted, index) => {
const item = series[index];
if (item.kind === "line") {
appendLine(group, highlight, item, plotted, index);
} else {
appendPoints(group, highlight, item, plotted, index, radius);
}
});
}
/** @param {PointerEvent} event */
function updateFromPointer(event) {
pointerX = event.clientX;
pointerY = event.clientY;
if (pointerFrame) return;
pointerFrame = requestAnimationFrame(() => {
pointerFrame = 0;
if (!currentFrame) return;
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
const y = ((pointerY - rect.top) / rect.height) * currentFrame.height;
const closest = findClosestPoint(series, currentSeries, x, y);
if (!closest) {
hideMarker();
return;
}
showMarker(closest, currentFrame);
});
}
/**
* @param {{ index: number, point: XyPoint }} closest
* @param {ChartFrame} frame
*/
function showMarker(closest, frame) {
const plotted = currentSeries[closest.index];
const item = series[closest.index];
guide.setAttribute("x1", closest.point.x.toFixed(2));
guide.setAttribute("x2", closest.point.x.toFixed(2));
guide.setAttribute("y1", "0");
guide.setAttribute("y2", frame.bottom.toString());
const text =
marker === false
? ""
: (marker?.(item, plotted, closest.point) ??
unit.format(closest.point.value));
markerElement.textContent = text;
markerElement.hidden = !text;
if (text) layoutChartMarker(markerElement, closest.point.x);
svg.dataset.xyHover = "true";
highlight.preview(closest.index);
}
function hideMarker() {
delete svg.dataset.xyHover;
markerElement.textContent = "";
markerElement.hidden = true;
highlight.clearPreview();
}
function disconnect() {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
resizeObserver.disconnect();
}
render();
requestAnimationFrame(render);
resizeObserver.observe(svg);
svg.addEventListener("pointerenter", measure);
svg.addEventListener("pointermove", updateFromPointer);
svg.addEventListener("pointerleave", () => {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
hideMarker();
});
figure.addEventListener("chart:destroy", disconnect, { once: true });
return figure;
}
/**
* @param {XySeries[]} series
* @param {XyPlottedSeries[]} plottedSeries
* @param {number} x
* @param {number} y
*/
function findClosestPoint(series, plottedSeries, x, y) {
/** @type {{ index: number, point: XyPoint } | null} */
let closest = null;
let closestDistance = Infinity;
plottedSeries.forEach((item, index) => {
if (series[index].hidden) return;
for (const point of item.points) {
const distance = Math.hypot(point.x - x, point.y - y);
if (distance < closestDistance) {
closest = { index, point };
closestDistance = distance;
}
}
});
return closest;
}
/**
* @param {LegendReadout} readout
* @param {ChartUnit} unit
* @param {XyPlottedSeries[]} plottedSeries
*/
function updateReadout(readout, unit, plottedSeries) {
readout.rows.forEach((row, index) => {
if (!row) return;
const value = plottedSeries[index]?.value;
row.value.textContent =
typeof value === "number" ? unit.format(value) : (value ?? "");
});
}
/**
* @param {SVGGElement} group
* @param {SeriesHighlight} highlight
* @param {XySeries} series
* @param {XyPlottedSeries} plotted
* @param {number} index
*/
function appendLine(group, highlight, series, plotted, index) {
const path = createSvgElement("path");
path.dataset.chart = "xy-line";
path.dataset.series = index.toString();
path.style.setProperty("--color", series.color());
path.setAttribute("d", createLinePathData(plotted.points));
highlight.addNode(path, index);
group.append(path);
}
/**
* @param {SVGGElement} group
* @param {SeriesHighlight} highlight
* @param {XySeries} series
* @param {XyPlottedSeries} plotted
* @param {number} index
* @param {number} radius
*/
function appendPoints(group, highlight, series, plotted, index, radius) {
for (const point of plotted.points) {
const circle = createSvgElement("circle");
circle.dataset.chart = "xy-point";
circle.dataset.series = index.toString();
circle.style.setProperty("--color", series.color());
circle.setAttribute("cx", point.x.toFixed(2));
circle.setAttribute("cy", point.y.toFixed(2));
circle.setAttribute("r", radius.toString());
highlight.addNode(circle, index);
group.append(circle);
}
}
/**
* @typedef {Object} XyPoint
* @property {number} x
* @property {number} y
* @property {number} value
*/
/**
* @typedef {Object} XyPlottedSeries
* @property {XyPoint[]} points
* @property {number | string} [value]
*/
/**
* @typedef {Object} XySeries
* @property {string} label
* @property {() => string} color
* @property {"line" | "point"} kind
* @property {boolean} [hidden]
*/
+52
View File
@@ -0,0 +1,52 @@
figure[data-chart="xy"] {
--chart-xy-height: 12rem;
min-width: 0;
margin: 0;
line-height: 1;
> [data-chart="plot"] {
position: relative;
}
svg {
display: block;
width: 100%;
height: var(--chart-xy-height);
overflow: visible;
}
[data-chart="xy-line"] {
fill: none;
stroke: var(--color, var(--gray));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.75;
vector-effect: non-scaling-stroke;
}
[data-chart="xy-point"] {
fill: var(--color, var(--white));
stroke: var(--black);
stroke-width: 1.75;
vector-effect: non-scaling-stroke;
}
[data-chart="xy-guide"] {
opacity: 0;
stroke: var(--white);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
svg[data-xy-hover="true"] [data-chart="xy-guide"] {
opacity: 1;
}
[data-chart-marker="xy"] {
color: var(--black);
background: var(--white);
text-transform: uppercase;
}
}