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
-55
View File
@@ -1,55 +0,0 @@
import { createAreaPathData, createLinePathData } from "../path.js";
import { appendSeriesPath } from "../series-path.js";
import { createOrderedIndexes } from "../order.js";
import { createLineSeries } from "../line/series.js";
/**
* @param {number} height
* @param {ChartPoint[]} points
* @returns {StackedPoint[]}
*/
function createAreaPoints(height, points) {
return points.map((point) => ({
...point,
y0: height,
y1: point.y,
}));
}
/**
* @param {PlotContext} context
*/
export function renderAreaPlot({
group,
loadedSeries,
height,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, height, 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(height, points)),
});
appendSeriesPath({
group,
highlight,
index,
chart: "line",
color,
d: createLinePathData(points),
});
}
return plottedSeries;
}
-9
View File
@@ -1,9 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="area"] {
fill: var(--color, var(--orange));
fill-opacity: 0.5;
stroke: none;
}
}
}
-76
View File
@@ -1,76 +0,0 @@
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,
height,
highlight,
scale,
order,
}) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
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;
}
-8
View File
@@ -1,8 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="bar"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
@@ -1,97 +0,0 @@
main.learn {
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
@@ -1,47 +0,0 @@
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,
height,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, height, 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;
}
-8
View File
@@ -1,8 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="dots"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
-134
View File
@@ -1,134 +0,0 @@
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
@@ -1,25 +0,0 @@
/** @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;
}
-152
View File
@@ -1,152 +0,0 @@
/**
* @param {HTMLElement[]} items
* @param {HTMLElement} menu
*/
export function createSeriesHighlight(items, menu) {
const seriesNodes = /** @type {SeriesNode[]} */ (items.map(() => []));
const noSeries = -1;
let selectedSeries = noSeries;
let previewedSeries = noSeries;
/** @param {number} index */
function scrollToItem(index) {
const margin = Number.parseFloat(getComputedStyle(menu).paddingLeft);
const itemRect = items[index].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()) {
setActive(item, itemIndex === index);
}
seriesNodes.forEach((nodes, nodeIndex) => {
for (const node of nodes) {
setActive(node, nodeIndex === index);
}
});
}
function clearHighlight() {
for (const item of items) clearElementState(item);
for (const nodes of seriesNodes) {
for (const node of nodes) clearElementState(node);
}
}
function restoreSelectedHighlight() {
if (selectedSeries === noSeries) {
clearHighlight();
} else {
highlightSeries(selectedSeries);
}
}
function clearInteractionHighlight() {
clearPreview();
restoreSelectedHighlight();
}
/** @param {number} index */
function selectSeries(index) {
selectedSeries = index;
items.forEach((item, itemIndex) => {
item.setAttribute(
"aria-pressed",
(itemIndex === selectedSeries).toString(),
);
});
restoreSelectedHighlight();
}
/** @param {number} index */
function previewSeries(index) {
if (index === previewedSeries) return;
clearPreview();
scrollToItem(index);
items[index].dataset.preview = "";
previewedSeries = index;
}
function clearPreview() {
if (previewedSeries === noSeries) return;
delete items[previewedSeries].dataset.preview;
previewedSeries = noSeries;
}
items.forEach((item, index) => {
item.setAttribute("aria-pressed", "false");
item.addEventListener("pointerenter", () => highlightSeries(index));
item.addEventListener("pointerleave", clearInteractionHighlight);
item.addEventListener("focus", () => highlightSeries(index));
item.addEventListener("blur", clearInteractionHighlight);
item.addEventListener("click", () => {
selectSeries(selectedSeries === index ? noSeries : index);
});
});
/**
* @param {SVGPathElement | SVGCircleElement} node
* @param {number} index
*/
function addNode(node, index) {
if (selectedSeries !== noSeries) setActive(node, index === selectedSeries);
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 */
-133
View File
@@ -1,133 +0,0 @@
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";
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
@@ -1,21 +0,0 @@
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);
}
-43
View File
@@ -1,43 +0,0 @@
/**
* @param {Chart} chart
* @returns {{ legend: HTMLElement, menu: HTMLElement, items: HTMLElement[], 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 time = document.createElement("time");
const menu = document.createElement("menu");
const rows = chart.series.map((series) => {
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(({ button }) => button);
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);
header.append(time);
legend.append(header, menu);
return { legend, menu, items, readout: { time, rows } };
}
-153
View File
@@ -1,153 +0,0 @@
main.learn {
figure[data-chart="series"] {
figcaption {
text-transform: uppercase;
header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
h5 {
margin: 0;
font-family: var(--font-mono);
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
time {
color: var(--gray);
}
span:is([data-chart="unit"], [data-chart="separator"]) {
color: var(--gray);
}
menu {
--shadow-size: 1rem;
display: flex;
margin-inline: calc(-1 * var(--shadow-size));
padding: 0 var(--shadow-size);
padding-bottom: 1rem;
padding-top: 0.25rem;
overflow-x: auto;
list-style: none;
mask-image: linear-gradient(
to right,
transparent,
black var(--shadow-size),
black calc(100% - var(--shadow-size)),
transparent
);
}
li {
flex: 0 0 auto;
}
button {
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(:focus-visible, [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;
}
}
}
-33
View File
@@ -1,33 +0,0 @@
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,
height,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, height, 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;
}
-48
View File
@@ -1,48 +0,0 @@
import { 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 {number} height
* @param {ChartScale} scale
* @returns {ChartPoint[]}
*/
function createPoints(entries, bounds, height, scale) {
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
return entries.map(({ date, value }, index) => ({
date,
value,
x: index * xScale,
y: scaleY(value, bounds, height, scale),
}));
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {ChartScale} scale
*/
export function createLineSeries(loadedSeries, height, scale) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => ({
series,
color,
points: createPoints(entries, bounds, height, scale),
}));
}
-12
View File
@@ -1,12 +0,0 @@
main.learn {
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
@@ -1,55 +0,0 @@
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;
};
}
-8
View File
@@ -1,8 +0,0 @@
/**
* @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
@@ -1,57 +0,0 @@
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
@@ -1,35 +0,0 @@
/** @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 {ChartPoint[]} 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
@@ -1,24 +0,0 @@
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
@@ -1,40 +0,0 @@
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
@@ -1,132 +0,0 @@
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 { getViewBoxHeight, VIEWBOX_WIDTH } from "./viewbox.js";
/**
* @param {Object} args
* @param {SVGSVGElement} args.svg
* @param {LegendReadout} args.readout
* @param {HTMLElement} args.menu
* @param {HTMLElement[]} 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 height = getViewBoxHeight(svg);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`);
group.replaceChildren();
highlight.clearNodes();
scrubber ??= createScrubber(svg, readout, highlight, chart.unit.format);
scrubber.setSeries(
renderPlot(getView(), {
group,
loadedSeries,
height,
highlight,
scale: getScale(),
order: getOrder(),
}),
height,
);
}
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
@@ -1,81 +0,0 @@
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;
}
-252
View File
@@ -1,252 +0,0 @@
import { clamp } from "../math.js";
import { createSvgElement } from "../svg.js";
import { VIEWBOX_WIDTH } from "../viewbox.js";
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const markerRadiusPx = 4;
/** @param {number} width */
function getMarkerRadiusInViewBox(width) {
return width ? (markerRadiusPx * VIEWBOX_WIDTH) / width : markerRadiusPx;
}
/**
* @param {ScrubberSeries} series
* @param {number} step
*/
function getPointAtStep(series, step) {
return series.points[step];
}
/**
* @param {ChartPoint[]} points
* @param {number} y
*/
function getClosestPointIndex(points, y) {
let closestIndex = 0;
let closestDistance = Infinity;
for (const [index, point] of points.entries()) {
const distance = Math.abs(point.y - y);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
}
return closestIndex;
}
/**
* @param {HTMLTimeElement} time
* @param {Date} date
*/
function updateTime(time, date) {
time.textContent = dateFormat.format(date);
time.dateTime = date.toISOString().slice(0, 10);
}
/**
* @param {LegendReadout} readout
* @param {ChartPoint[]} points
* @param {(value: number) => string} format
*/
function updateReadout(readout, points, format) {
updateTime(readout.time, points[0].date);
readout.rows.forEach(({ value }, index) => {
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");
/** @type {ScrubberSeries[]} */
let series = [];
/** @type {SVGCircleElement[]} */
let markers = [];
let height = 0;
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";
group.append(shade, guide);
svg.append(group);
function measure() {
rect = svg.getBoundingClientRect();
}
/** @param {number} step */
function getPointsAtStep(step) {
return series.map((item) => getPointAtStep(item, step));
}
/**
* @param {number} ratio
* @param {number} [y]
* @param {boolean} [scrubbing]
*/
function update(ratio, y, scrubbing = true) {
if (!series.length) return;
const nextStep = Math.round(clamp(ratio, 0, 1) * stepCount);
if (nextStep !== currentStep) {
currentStep = nextStep;
currentPoints = getPointsAtStep(nextStep);
const x = currentPoints[0].x;
const xText = x.toFixed(2);
svg.dataset.index = nextStep.toString();
shade.setAttribute("x", xText);
shade.setAttribute("y", "0");
shade.setAttribute("width", (VIEWBOX_WIDTH - x).toFixed(2));
shade.setAttribute("height", height.toString());
guide.setAttribute("x1", xText);
guide.setAttribute("x2", xText);
guide.setAttribute("y1", "0");
guide.setAttribute("y2", height.toString());
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 (y !== undefined) {
highlight.preview(getClosestPointIndex(currentPoints, y));
}
}
function hide() {
update(1, undefined, false);
}
function cancelPointerUpdate() {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
}
function clear() {
cancelPointerUpdate();
series = [];
markers = [];
currentStep = -1;
currentPoints = [];
highlight.clearPreview();
group.replaceChildren(shade, guide);
delete svg.dataset.index;
delete svg.dataset.scrubbing;
}
/**
* @param {ScrubberSeries[]} nextSeries
* @param {number} nextHeight
*/
function setSeries(nextSeries, nextHeight) {
series = nextSeries;
height = nextHeight;
currentStep = -1;
stepCount = Math.max(...series.map(({ points }) => points.length - 1));
measure();
const radius = getMarkerRadiusInViewBox(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, 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) * height;
update(x / VIEWBOX_WIDTH, 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
*/
@@ -1,30 +0,0 @@
main.learn {
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;
}
}
}
-23
View File
@@ -1,23 +0,0 @@
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
@@ -1,50 +0,0 @@
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,
});
},
};
}
@@ -1,48 +0,0 @@
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,
height,
highlight,
scale,
order,
}) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
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;
}
-113
View File
@@ -1,113 +0,0 @@
import { 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 {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {ChartOrder} order
* @param {ChartScale} scale
*/
export function createStackedSeries(loadedSeries, height, 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 plottedSeries = loadedSeries.map(({ series, color }) => ({
series,
color,
points: /** @type {StackedPoint[]} */ ([]),
}));
const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes);
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 = scaleY(start, bounds, height, scale);
const y1 = scaleY(end, bounds, height, 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 = scaleY(value, bounds, height, scale);
plottedSeries[seriesIndex].points.push({
date,
value,
x,
y,
y0: y,
y1: y,
});
}
}
return {
lineIndexes,
plottedSeries,
stackIndexes,
};
}
@@ -1,11 +0,0 @@
main.learn {
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
@@ -1,19 +0,0 @@
/** @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);
},
};
}
-79
View File
@@ -1,79 +0,0 @@
main.learn {
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
@@ -1,10 +0,0 @@
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
@@ -1,60 +0,0 @@
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();
}
-89
View File
@@ -1,89 +0,0 @@
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 LegendReadout = {
time: HTMLTimeElement;
rows: { value: HTMLOutputElement }[];
};
type LoadedSeries = {
series: ChartSeries;
color: string;
entries: ChartEntry[];
};
type PlotContext = {
group: SVGGElement;
loadedSeries: LoadedSeries[];
height: number;
highlight: SeriesHighlight;
scale: ChartScale;
order: ChartOrder;
};
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 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
@@ -1,10 +0,0 @@
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 },
});
-11
View File
@@ -1,11 +0,0 @@
export const VIEWBOX_WIDTH = 640;
export const FALLBACK_VIEWBOX_HEIGHT = 220;
/** @param {SVGSVGElement} svg */
export function getViewBoxHeight(svg) {
const { width, height } = svg.getBoundingClientRect();
return width && height
? (VIEWBOX_WIDTH * height) / width
: FALLBACK_VIEWBOX_HEIGHT;
}
-40
View File
@@ -1,40 +0,0 @@
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);
}
@@ -13,7 +13,7 @@ import {
stateSeries,
typeSeries,
} from "../address-count.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
const line = /** @type {const} */ ("line");
@@ -1,5 +1,5 @@
import { capitalizationSeries } from "../capitalization.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
import { marketCapSection } from "./capitalization/market.js";
import { realizedCapSection } from "./capitalization/realized.js";
@@ -9,7 +9,7 @@ import {
marketCapTypeSeries,
marketCapUtxoBalanceSeries,
} from "../../capitalization.js";
import { units } from "../../../charts/units.js";
import { units } from "../../../../chart/units.js";
export const marketCapSection = {
title: "Market Cap",
@@ -9,7 +9,7 @@ import {
realizedCapTypeSeries,
realizedCapUtxoBalanceSeries,
} from "../../capitalization.js";
import { units } from "../../../charts/units.js";
import { units } from "../../../../chart/units.js";
export const realizedCapSection = {
title: "Realized Cap",
@@ -10,7 +10,7 @@ import {
majorPoolRewardsSeries,
minorPools,
} from "../mining-pools.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
const line = /** @type {const} */ ("line");
+1 -1
View File
@@ -13,7 +13,7 @@ import {
circulatingSupplySeries,
supplyProfitabilitySeries,
} from "../supply.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
export const supplySection = {
title: "Supply",
+1 -1
View File
@@ -17,7 +17,7 @@ import {
totalSeries,
typeSeries,
} from "../utxo-set.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
const line = /** @type {const} */ ("line");
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContents } from "./contents/index.js";
import { sections } from "./data/index.js";
import { createChart } from "./charts/index.js";
import { createChart } from "../chart/index.js";
import { initSectionDetails } from "./details.js";
import { initHashLinks } from "./hash-links.js";
import { initScrollSpy } from "./scroll-spy.js";