mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-09 10:08:13 -07:00
website: redesign part 13
This commit is contained in:
@@ -44,12 +44,21 @@ function createBarPathData(points, width) {
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
* @param {{ reversed: boolean }} options
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
export function renderBarPlot(group, loadedSeries, height, highlight, options) {
|
||||
export function renderBarPlot(
|
||||
group,
|
||||
loadedSeries,
|
||||
height,
|
||||
highlight,
|
||||
options,
|
||||
scale,
|
||||
) {
|
||||
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
|
||||
loadedSeries,
|
||||
height,
|
||||
options.reversed,
|
||||
scale,
|
||||
);
|
||||
|
||||
for (const index of stackIndexes) {
|
||||
|
||||
@@ -21,9 +21,10 @@ function createDotsPathData(points) {
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
export function renderDotsPlot(group, loadedSeries, height, highlight) {
|
||||
const plottedSeries = createLineSeries(loadedSeries, height);
|
||||
export function renderDotsPlot(group, loadedSeries, height, highlight, scale) {
|
||||
const plottedSeries = createLineSeries(loadedSeries, height, scale);
|
||||
|
||||
plottedSeries.forEach(({ color, points }, index) => {
|
||||
const path = createSvgElement("path");
|
||||
|
||||
@@ -39,6 +39,16 @@ export function createSeriesHighlight(items) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} index */
|
||||
function activateItem(index) {
|
||||
setActive(items[index], true);
|
||||
}
|
||||
|
||||
/** @param {number} index */
|
||||
function clearItem(index) {
|
||||
clearState(items[index]);
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
item.addEventListener("pointerenter", () => activate(index));
|
||||
item.addEventListener("pointerleave", clear);
|
||||
@@ -54,9 +64,9 @@ export function createSeriesHighlight(items) {
|
||||
seriesNodes[index].push(node);
|
||||
node.addEventListener("pointerenter", () => {
|
||||
scrollToItem(index);
|
||||
activate(index);
|
||||
activateItem(index);
|
||||
});
|
||||
node.addEventListener("pointerleave", clear);
|
||||
node.addEventListener("pointerleave", () => clearItem(index));
|
||||
}
|
||||
|
||||
function clearNodes() {
|
||||
|
||||
@@ -2,9 +2,14 @@ import { brk } from "../../utils/client.js";
|
||||
import { renderBarPlot } from "./bar/index.js";
|
||||
import { createFullscreenButton } from "./fullscreen.js";
|
||||
import { createSeriesHighlight } from "./highlight.js";
|
||||
import { onFirstIntersection } from "./intersection.js";
|
||||
import { onChartVisibility } from "./intersection.js";
|
||||
import { createLegend } from "./legend.js";
|
||||
import { renderLinePlot } from "./line/index.js";
|
||||
import {
|
||||
createScaleControl,
|
||||
getDefaultScale,
|
||||
saveScale,
|
||||
} from "./scale.js";
|
||||
import { createScrubber } from "./scrubber.js";
|
||||
import { renderDotsPlot } from "./dots/index.js";
|
||||
import { createSvgElement } from "./svg.js";
|
||||
@@ -27,6 +32,7 @@ import {
|
||||
} from "./viewbox.js";
|
||||
|
||||
/** @typedef {import("./legend.js").Readout} Readout */
|
||||
/** @typedef {import("./scale.js").ChartScale} ChartScale */
|
||||
/** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */
|
||||
/** @typedef {import("./views.js").ChartView} ChartView */
|
||||
|
||||
@@ -90,22 +96,33 @@ function createLoadedSeriesCache(chart) {
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
* @param {ChartScale} scale
|
||||
*/
|
||||
function renderPlot(view, group, loadedSeries, height, highlight) {
|
||||
function renderPlot(view, group, loadedSeries, height, highlight, scale) {
|
||||
switch (view) {
|
||||
case "line":
|
||||
return renderLinePlot(group, loadedSeries, height, highlight);
|
||||
return renderLinePlot(group, loadedSeries, height, highlight, scale);
|
||||
case "bar":
|
||||
case "bar-reversed":
|
||||
return renderBarPlot(group, loadedSeries, height, highlight, {
|
||||
reversed: view === "bar-reversed",
|
||||
});
|
||||
return renderBarPlot(
|
||||
group,
|
||||
loadedSeries,
|
||||
height,
|
||||
highlight,
|
||||
{ reversed: view === "bar-reversed" },
|
||||
scale,
|
||||
);
|
||||
case "dots":
|
||||
return renderDotsPlot(group, loadedSeries, height, highlight);
|
||||
return renderDotsPlot(group, loadedSeries, height, highlight, scale);
|
||||
default:
|
||||
return renderStackedPlot(group, loadedSeries, height, highlight, {
|
||||
reversed: view === "stacked-reversed",
|
||||
});
|
||||
return renderStackedPlot(
|
||||
group,
|
||||
loadedSeries,
|
||||
height,
|
||||
highlight,
|
||||
{ reversed: view === "stacked-reversed" },
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +133,7 @@ function renderPlot(view, group, loadedSeries, height, highlight) {
|
||||
* @param {HTMLElement} status
|
||||
* @param {Chart} chart
|
||||
* @param {() => ChartView} getView
|
||||
* @param {() => ChartScale} getScale
|
||||
* @param {() => TimeframeValue} getTimeframe
|
||||
*/
|
||||
function createChartRenderer(
|
||||
@@ -125,6 +143,7 @@ function createChartRenderer(
|
||||
status,
|
||||
chart,
|
||||
getView,
|
||||
getScale,
|
||||
getTimeframe,
|
||||
) {
|
||||
const group = createSvgElement("g");
|
||||
@@ -134,12 +153,14 @@ function createChartRenderer(
|
||||
let loadedSeries = [];
|
||||
/** @type {ReturnType<typeof createScrubber> | undefined} */
|
||||
let scrubber;
|
||||
const resizeObserver = new ResizeObserver(renderCurrent);
|
||||
let active = false;
|
||||
let loadId = 0;
|
||||
|
||||
svg.append(group);
|
||||
|
||||
function renderCurrent() {
|
||||
if (!loadedSeries.length) return;
|
||||
if (!active || !loadedSeries.length) return;
|
||||
|
||||
const height = getViewBoxHeight(svg);
|
||||
|
||||
@@ -148,7 +169,7 @@ function createChartRenderer(
|
||||
highlight.clearNodes();
|
||||
scrubber ??= createScrubber(svg, readout, highlight);
|
||||
scrubber.setSeries(
|
||||
renderPlot(getView(), group, loadedSeries, height, highlight),
|
||||
renderPlot(getView(), group, loadedSeries, height, highlight, getScale()),
|
||||
height,
|
||||
);
|
||||
}
|
||||
@@ -160,7 +181,7 @@ function createChartRenderer(
|
||||
try {
|
||||
const nextSeries = await getLoadedSeries(getTimeframe());
|
||||
|
||||
if (id !== loadId) return;
|
||||
if (id !== loadId || !active) return;
|
||||
|
||||
loadedSeries = nextSeries;
|
||||
renderCurrent();
|
||||
@@ -174,11 +195,31 @@ function createChartRenderer(
|
||||
}
|
||||
}
|
||||
|
||||
new ResizeObserver(renderCurrent).observe(svg);
|
||||
function resume() {
|
||||
if (active) return;
|
||||
|
||||
active = true;
|
||||
resizeObserver.observe(svg);
|
||||
void loadCurrent();
|
||||
}
|
||||
|
||||
function suspend() {
|
||||
if (!active) return;
|
||||
|
||||
active = false;
|
||||
loadId += 1;
|
||||
resizeObserver.disconnect();
|
||||
group.replaceChildren();
|
||||
highlight.clearNodes();
|
||||
scrubber?.clear();
|
||||
svg.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
return {
|
||||
loadCurrent,
|
||||
renderCurrent,
|
||||
resume,
|
||||
suspend,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,16 +228,19 @@ export function createChart(chart) {
|
||||
const figure = document.createElement("figure");
|
||||
const svg = createSvgElement("svg");
|
||||
const controls = document.createElement("footer");
|
||||
const chartControls = document.createElement("div");
|
||||
const timeControls = document.createElement("div");
|
||||
const status = document.createElement("p");
|
||||
const chartKey = chart.title;
|
||||
let currentTimeframe = getDefaultTimeframe(chartKey);
|
||||
let currentView = getDefaultView(chartKey);
|
||||
let currentScale = getDefaultScale(chartKey);
|
||||
const { legend, items, readout } = createLegend(chart);
|
||||
|
||||
figure.dataset.chart = "series";
|
||||
figure.dataset.timeframe = currentTimeframe;
|
||||
figure.dataset.view = currentView;
|
||||
figure.dataset.scale = currentScale;
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`,
|
||||
@@ -214,6 +258,7 @@ export function createChart(chart) {
|
||||
status,
|
||||
chart,
|
||||
() => currentView,
|
||||
() => currentScale,
|
||||
() => currentTimeframe,
|
||||
);
|
||||
const viewControl = createViewControl(currentView, (view) => {
|
||||
@@ -222,6 +267,12 @@ export function createChart(chart) {
|
||||
figure.dataset.view = view;
|
||||
renderer.renderCurrent();
|
||||
});
|
||||
const scaleControl = createScaleControl(currentScale, (scale) => {
|
||||
currentScale = scale;
|
||||
saveScale(chartKey, scale);
|
||||
figure.dataset.scale = scale;
|
||||
renderer.renderCurrent();
|
||||
});
|
||||
const timeframeControl = createTimeframeControl(
|
||||
currentTimeframe,
|
||||
(timeframe) => {
|
||||
@@ -231,10 +282,14 @@ export function createChart(chart) {
|
||||
void renderer.loadCurrent();
|
||||
},
|
||||
);
|
||||
chartControls.append(viewControl, scaleControl);
|
||||
timeControls.append(timeframeControl, createFullscreenButton(figure));
|
||||
controls.append(viewControl, timeControls);
|
||||
controls.append(chartControls, timeControls);
|
||||
figure.append(legend, svg, controls, status);
|
||||
onFirstIntersection(figure, () => void renderer.loadCurrent());
|
||||
onChartVisibility(figure, {
|
||||
show: renderer.resume,
|
||||
hide: renderer.suspend,
|
||||
});
|
||||
|
||||
return figure;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {() => void} callback
|
||||
* @param {{ show: () => void, hide: () => void }} lifecycle
|
||||
*/
|
||||
export function onFirstIntersection(element, callback) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries[0].isIntersecting) return;
|
||||
|
||||
observer.disconnect();
|
||||
callback();
|
||||
});
|
||||
export function onChartVisibility(element, lifecycle) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
lifecycle.show();
|
||||
} else {
|
||||
lifecycle.hide();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "800px 0px",
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { createLineSeries } from "./series.js";
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
export function renderLinePlot(group, loadedSeries, height, highlight) {
|
||||
const plottedSeries = createLineSeries(loadedSeries, height);
|
||||
export function renderLinePlot(group, loadedSeries, height, highlight, scale) {
|
||||
const plottedSeries = createLineSeries(loadedSeries, height, scale);
|
||||
|
||||
plottedSeries.forEach(({ color, points }, index) => {
|
||||
const path = createSvgElement("path");
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { VIEWBOX_WIDTH } from "../viewbox.js";
|
||||
import { scaleY } from "../scale.js";
|
||||
|
||||
/** @param {LoadedSeries[]} series */
|
||||
function createValueBounds(series) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let minPositive = Infinity;
|
||||
|
||||
for (const { entries } of series) {
|
||||
for (const { value } of entries) {
|
||||
min = Math.min(min, value);
|
||||
max = Math.max(max, value);
|
||||
if (value > 0) minPositive = Math.min(minPositive, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
return { min, max, minPositive };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ date: Date, value: number }[]} entries
|
||||
* @param {{ min: number, max: number }} bounds
|
||||
* @param {import("../scale.js").ScaleBounds} bounds
|
||||
* @param {number} height
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
function createPoints(entries, bounds, height) {
|
||||
function createPoints(entries, bounds, height, scale) {
|
||||
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
|
||||
const yScale =
|
||||
bounds.max === bounds.min ? 0 : height / (bounds.max - bounds.min);
|
||||
|
||||
return entries.map(({ date, value }, index) => ({
|
||||
date,
|
||||
value,
|
||||
x: index * xScale,
|
||||
y:
|
||||
bounds.max === bounds.min
|
||||
? height / 2
|
||||
: height - (value - bounds.min) * yScale,
|
||||
y: scaleY(value, bounds, height, scale),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
export function createLineSeries(loadedSeries, height) {
|
||||
export function createLineSeries(loadedSeries, height, scale) {
|
||||
const bounds = createValueBounds(loadedSeries);
|
||||
|
||||
return loadedSeries.map(({ series, color, entries }) => ({
|
||||
series,
|
||||
color,
|
||||
points: createPoints(entries, bounds, height),
|
||||
points: createPoints(entries, bounds, height, scale),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createRadioGroup } from "./radio.js";
|
||||
import { createChartStorage } from "./storage.js";
|
||||
|
||||
const storage = createChartStorage("scale");
|
||||
/** @type {ChartScale} */
|
||||
const defaultScale = "linear";
|
||||
/** @type {{ value: ChartScale, label: string }[]} */
|
||||
const scales = [
|
||||
{ value: "linear", label: "Lin" },
|
||||
{ value: "log", label: "Log" },
|
||||
];
|
||||
|
||||
/** @param {string} chartKey */
|
||||
export function getDefaultScale(chartKey) {
|
||||
const value = storage.get(chartKey);
|
||||
|
||||
return scales.find((scale) => scale.value === value)?.value ?? defaultScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} chartKey
|
||||
* @param {ChartScale} scale
|
||||
*/
|
||||
export function saveScale(chartKey, scale) {
|
||||
storage.set(chartKey, scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ChartScale} currentScale
|
||||
* @param {(scale: ChartScale) => void} onChange
|
||||
*/
|
||||
export function createScaleControl(currentScale, onChange) {
|
||||
return createRadioGroup({
|
||||
legend: "Scale",
|
||||
options: scales,
|
||||
currentValue: currentScale,
|
||||
onChange,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ScaleBounds
|
||||
* @property {number} min
|
||||
* @property {number} max
|
||||
* @property {number} minPositive
|
||||
*/
|
||||
|
||||
/** @typedef {"linear" | "log"} ChartScale */
|
||||
@@ -103,6 +103,14 @@ export function createScrubber(svg, readout, highlight) {
|
||||
update(1, false);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
series = [];
|
||||
markers = [];
|
||||
group.replaceChildren(guide);
|
||||
delete svg.dataset.index;
|
||||
delete svg.dataset.scrubbing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScrubberSeries[]} nextSeries
|
||||
* @param {number} nextHeight
|
||||
@@ -153,7 +161,7 @@ export function createScrubber(svg, readout, highlight) {
|
||||
}
|
||||
});
|
||||
|
||||
return { setSeries };
|
||||
return { clear, setSeries };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createStackedSeries } from "./series.js";
|
||||
* @param {number} height
|
||||
* @param {SeriesHighlight} highlight
|
||||
* @param {{ reversed: boolean }} options
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
export function renderStackedPlot(
|
||||
group,
|
||||
@@ -15,11 +16,13 @@ export function renderStackedPlot(
|
||||
height,
|
||||
highlight,
|
||||
options,
|
||||
scale,
|
||||
) {
|
||||
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
|
||||
loadedSeries,
|
||||
height,
|
||||
options.reversed,
|
||||
scale,
|
||||
);
|
||||
|
||||
for (const index of stackIndexes) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VIEWBOX_WIDTH } from "../viewbox.js";
|
||||
import { scaleY } from "../scale.js";
|
||||
|
||||
/**
|
||||
* @param {LoadedSeries[]} series
|
||||
@@ -9,6 +10,7 @@ function createStackBounds(series, stackIndexes, lineIndexes) {
|
||||
const length = series[0].entries.length;
|
||||
let min = 0;
|
||||
let max = 0;
|
||||
let minPositive = Infinity;
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
let negative = 0;
|
||||
@@ -23,27 +25,18 @@ function createStackBounds(series, stackIndexes, lineIndexes) {
|
||||
|
||||
min = Math.min(min, negative);
|
||||
max = Math.max(max, positive);
|
||||
if (positive > 0) minPositive = Math.min(minPositive, positive);
|
||||
|
||||
for (const seriesIndex of lineIndexes) {
|
||||
const value = series[seriesIndex].entries[index].value;
|
||||
|
||||
min = Math.min(min, value);
|
||||
max = Math.max(max, value);
|
||||
if (value > 0) minPositive = Math.min(minPositive, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {{ min: number, max: number }} bounds
|
||||
* @param {number} height
|
||||
*/
|
||||
function scaleY(value, bounds, height) {
|
||||
return bounds.max === bounds.min
|
||||
? height / 2
|
||||
: height - ((value - bounds.min) / (bounds.max - bounds.min)) * height;
|
||||
return { min, max, minPositive };
|
||||
}
|
||||
|
||||
/** @returns {StackedPoint[]} */
|
||||
@@ -55,8 +48,9 @@ function createStackedPoints() {
|
||||
* @param {LoadedSeries[]} loadedSeries
|
||||
* @param {number} height
|
||||
* @param {boolean} reversed
|
||||
* @param {import("../scale.js").ChartScale} scale
|
||||
*/
|
||||
export function createStackedSeries(loadedSeries, height, reversed) {
|
||||
export function createStackedSeries(loadedSeries, height, reversed, scale) {
|
||||
const indexes = loadedSeries.map((_, index) => index);
|
||||
const lineIndexes = indexes.filter(
|
||||
(index) => loadedSeries[index].series.role === "line",
|
||||
@@ -94,15 +88,15 @@ export function createStackedSeries(loadedSeries, height, reversed) {
|
||||
date,
|
||||
value,
|
||||
x,
|
||||
y: scaleY(end, bounds, height),
|
||||
y0: scaleY(start, bounds, height),
|
||||
y1: scaleY(end, bounds, height),
|
||||
y: scaleY(end, bounds, height, scale),
|
||||
y0: scaleY(start, bounds, height, scale),
|
||||
y1: scaleY(end, bounds, height, scale),
|
||||
});
|
||||
}
|
||||
|
||||
for (const seriesIndex of lineIndexes) {
|
||||
const { date, value } = loadedSeries[seriesIndex].entries[index];
|
||||
const y = scaleY(value, bounds, height);
|
||||
const y = scaleY(value, bounds, height, scale);
|
||||
|
||||
plottedSeries[seriesIndex].points.push({
|
||||
date,
|
||||
|
||||
@@ -222,6 +222,7 @@ main.learn {
|
||||
> output {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
min-height: 1em;
|
||||
color: var(--white);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
|
||||
Reference in New Issue
Block a user