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