mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-24 17:38:09 -07:00
website_next: snapshot
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
main.learn {
|
||||
figure[data-chart="series"] {
|
||||
path[data-chart="dots"] {
|
||||
fill: var(--color, var(--orange));
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
-89
@@ -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 {};
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user