website_next: snapshot

This commit is contained in:
nym21
2026-07-03 21:06:32 +02:00
parent e0a618837e
commit 10ef95497f
73 changed files with 2241 additions and 733 deletions
@@ -2,16 +2,19 @@ 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 {number} height
* @param {ChartFrame} frame
* @param {ChartPoint[]} points
* @returns {StackedPoint[]}
*/
function createAreaPoints(height, points) {
function createAreaPoints(frame, points) {
const bottom = getPlotBottom(frame);
return points.map((point) => ({
...point,
y0: height,
y0: bottom,
y1: point.y,
}));
}
@@ -22,12 +25,12 @@ function createAreaPoints(height, points) {
export function renderAreaPlot({
group,
loadedSeries,
height,
frame,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, height, scale);
const plottedSeries = createLineSeries(loadedSeries, frame, scale);
const indexes = createOrderedIndexes(plottedSeries.length, order);
for (const index of indexes) {
@@ -38,7 +41,7 @@ export function renderAreaPlot({
index,
chart: "area",
color,
d: createAreaPathData(createAreaPoints(height, points)),
d: createAreaPathData(createAreaPoints(frame, points)),
});
appendSeriesPath({
+7
View File
@@ -0,0 +1,7 @@
figure[data-chart="series"] {
path[data-chart="area"] {
fill: var(--color, var(--orange));
fill-opacity: 0.5;
stroke: none;
}
}
@@ -36,14 +36,14 @@ function createBarPathData(points, width) {
export function renderBarPlot({
group,
loadedSeries,
height,
frame,
highlight,
scale,
order,
}) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
frame,
order,
scale,
);
+6
View File
@@ -0,0 +1,6 @@
figure[data-chart="series"] {
path[data-chart="bar"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
+19
View File
@@ -0,0 +1,19 @@
export const CHART_SIZE = /** @type {const} */ ({
width: 640,
fallbackHeight: 220,
});
export const CHART_MARKER = /** @type {const} */ ({
fallbackWidth: 84,
height: 20,
edgeOverflow: 8,
});
export const CHART_POINT = /** @type {const} */ ({
radius: 4,
});
export const CHART_FRAME = /** @type {const} */ ({
topGap: 16,
bottomPadding: 8,
});
+95
View File
@@ -0,0 +1,95 @@
figure[data-chart="series"] {
> footer {
> div {
display: flex;
flex-wrap: wrap;
gap: 0.125rem 0.5rem;
}
fieldset {
display: flex;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
text-transform: uppercase;
legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
label {
position: relative;
display: block;
cursor: pointer;
}
input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
span {
display: block;
}
label:has(:checked) span {
color: var(--black);
background: var(--gray);
}
}
button[data-chart="fullscreen"] {
border: 0;
background: none;
font: inherit;
line-height: inherit;
text-transform: uppercase;
cursor: pointer;
&[aria-pressed="true"] {
color: var(--black);
background: var(--green);
}
}
:is(label > span, button[data-chart="fullscreen"]) {
padding: 0.25rem;
border-radius: 0.25rem;
color: var(--gray);
}
@media (hover: hover) and (pointer: fine) {
:is(label:hover span, button[data-chart="fullscreen"]:hover) {
color: var(--black);
background: var(--white);
}
}
:is(label:active span, button[data-chart="fullscreen"]:active) {
color: var(--black);
background: var(--orange);
}
:is(label[data-press] span, button[data-chart="fullscreen"][data-press]) {
color: var(--black);
background: var(--white);
}
:is(
label:has(:focus-visible) span,
button[data-chart="fullscreen"]:focus-visible
) {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
}
@@ -23,12 +23,12 @@ function createDotsPathData(points) {
export function renderDotsPlot({
group,
loadedSeries,
height,
frame,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, height, scale);
const plottedSeries = createLineSeries(loadedSeries, frame, scale);
const indexes = createOrderedIndexes(plottedSeries.length, order);
for (const index of indexes) {
+6
View File
@@ -0,0 +1,6 @@
figure[data-chart="series"] {
path[data-chart="dots"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
@@ -1,17 +1,19 @@
/**
* @param {HTMLElement[]} items
* @param {(HTMLElement | null)[]} items
* @param {HTMLElement} menu
*/
export function createSeriesHighlight(items, menu) {
const seriesNodes = /** @type {SeriesNode[]} */ (items.map(() => []));
const noSeries = -1;
let selectedSeries = noSeries;
let previewedSeries = noSeries;
/** @param {number} index */
function scrollToItem(index) {
const item = items[index];
if (!item) return;
const margin = Number.parseFloat(getComputedStyle(menu).paddingLeft);
const itemRect = items[index].getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
if (itemRect.left < menuRect.left + margin) {
@@ -30,6 +32,7 @@ export function createSeriesHighlight(items, menu) {
/** @param {number} index */
function highlightSeries(index) {
for (const [itemIndex, item] of items.entries()) {
if (!item) continue;
setActive(item, itemIndex === index);
}
@@ -41,38 +44,18 @@ export function createSeriesHighlight(items, menu) {
}
function clearHighlight() {
for (const item of items) clearElementState(item);
for (const item of items) {
if (item) clearElementState(item);
}
for (const nodes of seriesNodes) {
for (const node of nodes) clearElementState(node);
}
}
function restoreSelectedHighlight() {
if (selectedSeries === noSeries) {
clearHighlight();
} else {
highlightSeries(selectedSeries);
}
}
function clearInteractionHighlight() {
clearPreview();
restoreSelectedHighlight();
}
/** @param {number} index */
function selectSeries(index) {
selectedSeries = index;
items.forEach((item, itemIndex) => {
item.setAttribute(
"aria-pressed",
(itemIndex === selectedSeries).toString(),
);
});
restoreSelectedHighlight();
clearHighlight();
}
/** @param {number} index */
@@ -81,26 +64,32 @@ export function createSeriesHighlight(items, menu) {
clearPreview();
scrollToItem(index);
items[index].dataset.preview = "";
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;
delete items[previewedSeries].dataset.preview;
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) => {
item.setAttribute("aria-pressed", "false");
if (!item) return;
item.addEventListener("pointerenter", () => highlightSeries(index));
item.addEventListener("pointerleave", clearInteractionHighlight);
item.addEventListener("focus", () => highlightSeries(index));
item.addEventListener("blur", clearInteractionHighlight);
item.addEventListener("click", () => {
selectSeries(selectedSeries === index ? noSeries : index);
});
});
/**
@@ -108,7 +97,6 @@ export function createSeriesHighlight(items, menu) {
* @param {number} index
*/
function addNode(node, index) {
if (selectedSeries !== noSeries) setActive(node, index === selectedSeries);
seriesNodes[index].push(node);
}
@@ -35,6 +35,7 @@ export function createChart(chart, chartKey) {
let renderer;
figure.dataset.chart = "series";
figure.dataset.chartLegend = "";
function mount() {
if (renderer) return renderer;
@@ -1,6 +1,6 @@
/**
* @param {Chart} chart
* @returns {{ legend: HTMLElement, menu: HTMLElement, items: HTMLElement[], readout: LegendReadout }}
* @param {LegendChart} chart
* @returns {{ legend: HTMLElement, menu: HTMLElement, items: (HTMLElement | null)[], readout: LegendReadout }}
*/
export function createLegend(chart) {
const legend = document.createElement("figcaption");
@@ -8,9 +8,10 @@ export function createLegend(chart) {
const title = document.createElement("h5");
const separator = document.createElement("span");
const unit = document.createElement("span");
const time = document.createElement("time");
const menu = document.createElement("menu");
const rows = chart.series.map((series) => {
if (series.hidden) return null;
const item = document.createElement("li");
const button = document.createElement("button");
const label = document.createElement("span");
@@ -26,7 +27,7 @@ export function createLegend(chart) {
return { button, value };
});
const items = rows.map(({ button }) => button);
const items = rows.map((row) => row?.button ?? null);
separator.dataset.chart = "separator";
separator.setAttribute("aria-hidden", "true");
@@ -36,8 +37,14 @@ export function createLegend(chart) {
unit.append(chart.unit.id);
title.append(chart.title, " ", separator, " ", unit);
header.append(title);
header.append(time);
legend.append(header, menu);
return { legend, menu, items, readout: { time, rows } };
return { legend, menu, items, readout: { rows } };
}
/**
* @typedef {Object} LegendChart
* @property {string} title
* @property {ChartUnit} unit
* @property {{ label: string, color: () => string, hidden?: boolean }[]} series
*/
+140
View File
@@ -0,0 +1,140 @@
figure[data-chart-legend] {
figcaption {
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
header {
display: block;
}
h5 {
margin: 0;
font-family: var(--font-mono);
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
span:is([data-chart="unit"], [data-chart="separator"]) {
color: var(--gray);
}
menu {
display: flex;
padding: 0.25rem 0 0.5rem;
overflow-x: auto;
list-style: none;
}
li {
flex: 0 0 auto;
}
button {
display: block;
min-width: 8.5ch;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
}
&[data-press] {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:is([data-active], [data-preview]) {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> span {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> output {
display: block;
margin-top: 0.25rem;
margin-left: auto;
width: 7ch;
min-height: 1em;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
}
}
&:fullscreen {
figcaption {
h5 {
color: var(--white);
font-family: var(--font-serif);
font-size: 2rem;
text-transform: none;
}
menu {
padding-bottom: 0.5rem;
}
}
}
svg [data-series][data-muted] {
opacity: 0.2;
}
svg [data-series][data-preview] {
opacity: 1;
}
}
@@ -9,12 +9,12 @@ import { createLineSeries } from "./series.js";
export function renderLinePlot({
group,
loadedSeries,
height,
frame,
highlight,
scale,
order,
}) {
const plottedSeries = createLineSeries(loadedSeries, height, scale);
const plottedSeries = createLineSeries(loadedSeries, frame, scale);
const indexes = createOrderedIndexes(plottedSeries.length, order);
for (const index of indexes) {
+79
View File
@@ -0,0 +1,79 @@
import { getPlotHeight, insetPlotY, VIEWBOX_WIDTH } from "../viewbox.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
/** @param {LoadedSeries[]} series */
function createValueBounds(series) {
const bounds = createBounds();
for (const { entries } of series) {
for (const { value } of entries) {
includeBoundValue(bounds, value);
}
}
return bounds;
}
/**
* @param {ChartEntry[]} entries
* @param {ScaleBounds} bounds
* @param {ChartFrame} frame
* @param {ChartScale} scale
* @returns {ChartPoint[]}
*/
function createPoints(entries, bounds, frame, scale) {
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
const plotHeight = getPlotHeight(frame);
return entries.map(({ date, value }, index) => ({
date,
value,
x: index * xScale,
y: insetPlotY(frame, scaleY(value, bounds, plotHeight, scale)),
}));
}
/**
* @param {ChartPoint[]} points
* @param {number} x
*/
function interpolateY(points, x) {
if (x <= points[0].x) return points[0].y;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (x > next.x) continue;
const span = next.x - previous.x;
const ratio = span ? (x - previous.x) / span : 0;
return previous.y + (next.y - previous.y) * ratio;
}
return points[points.length - 1].y;
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {ChartFrame} frame
* @param {ChartScale} scale
*/
export function createLineSeries(loadedSeries, frame, scale) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => {
const points = createPoints(entries, bounds, frame, scale);
return {
series,
color,
points,
hitTest: /** @type {PlottedSeries["hitTest"]} */ (
(_point, pointerX, pointerY) =>
Math.abs(interpolateY(points, pointerX) - pointerY)
),
};
});
}
+10
View File
@@ -0,0 +1,10 @@
figure[data-chart="series"] {
path[data-chart="line"] {
fill: none;
stroke: var(--color, var(--orange));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
@@ -1,4 +1,4 @@
import { brk } from "../../utils/client.js";
import { brk } from "../utils/client.js";
import { fetchTimeframe } from "./timeframes.js";
/**
+52
View File
@@ -0,0 +1,52 @@
import { CHART_MARKER, CHART_POINT, CHART_SIZE } from "./constants.js";
const VIEWBOX_WIDTH = CHART_SIZE.width;
/**
* @param {HTMLElement} marker
* @param {number} y
*/
export function sizeChartMarker(marker, y = 0) {
marker.style.top = `${y}px`;
marker.style.height = `${CHART_MARKER.height}px`;
}
/** @param {HTMLElement} marker */
function getMarkerWidth(marker) {
return marker.offsetWidth || CHART_MARKER.fallbackWidth;
}
/**
* @param {HTMLElement} marker
* @param {number} xValue
* @param {number} [viewWidth]
*/
export function positionChartMarker(marker, xValue, viewWidth = VIEWBOX_WIDTH) {
const parentWidth = marker.parentElement?.clientWidth || viewWidth;
const markerWidth = getMarkerWidth(marker);
const x = (xValue / viewWidth) * parentWidth;
const min = -CHART_MARKER.edgeOverflow;
const max = Math.max(
min,
parentWidth - markerWidth + CHART_MARKER.edgeOverflow,
);
const left = Math.min(Math.max(x - markerWidth / 2, min), max);
marker.style.left = `${left.toFixed(2)}px`;
}
/**
* @param {HTMLElement} marker
* @param {number} xValue
*/
export function layoutChartMarker(marker, xValue) {
sizeChartMarker(marker);
positionChartMarker(marker, xValue);
}
/** @param {number} viewWidth */
export function getChartPointRadius(viewWidth) {
return viewWidth
? (CHART_POINT.radius * CHART_SIZE.width) / viewWidth
: CHART_POINT.radius;
}
+17
View File
@@ -0,0 +1,17 @@
[data-chart="plot"] > [data-chart-marker] {
position: absolute;
z-index: 1;
display: grid;
place-items: center;
width: max-content;
padding-inline: 0.5rem;
box-sizing: border-box;
border-radius: 0.25rem;
color: var(--gray);
background: transparent;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-align: center;
pointer-events: none;
}
@@ -12,7 +12,7 @@ function createPathCommand(command, x, y) {
return `${command}${formatCoordinate(x)} ${formatCoordinate(y)}`;
}
/** @param {ChartPoint[]} points */
/** @param {{ x: number, y: number }[]} points */
export function createLinePathData(points) {
return points
.map(({ x, y }, index) => createPathCommand(index ? "L" : "M", x, y))
@@ -3,14 +3,14 @@ import { createSeriesLoader } from "./loader.js";
import { renderPlot } from "./plot.js";
import { createScrubber } from "./scrubber/index.js";
import { createSvgElement } from "./svg.js";
import { getViewBoxHeight, VIEWBOX_WIDTH } from "./viewbox.js";
import { createChartFrame, VIEWBOX_WIDTH } from "./viewbox.js";
/**
* @param {Object} args
* @param {SVGSVGElement} args.svg
* @param {LegendReadout} args.readout
* @param {HTMLElement} args.menu
* @param {HTMLElement[]} args.items
* @param {(HTMLElement | null)[]} args.items
* @param {HTMLElement} args.status
* @param {Chart} args.chart
* @param {() => ChartView} args.getView
@@ -56,9 +56,9 @@ export function createChartRenderer({
function renderCurrent() {
if (!active || !loadedSeries.length) return;
const height = getViewBoxHeight(svg);
const frame = createChartFrame(svg);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${height}`);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${frame.height}`);
group.replaceChildren();
highlight.clearNodes();
scrubber ??= createScrubber(svg, readout, highlight, chart.unit.format);
@@ -66,12 +66,12 @@ export function createChartRenderer({
renderPlot(getView(), {
group,
loadedSeries,
height,
frame,
highlight,
scale: getScale(),
order: getOrder(),
}),
height,
frame,
);
}
@@ -1,6 +1,10 @@
import { clamp } from "../math.js";
import {
getChartPointRadius,
layoutChartMarker,
} from "../marker.js";
import { createSvgElement } from "../svg.js";
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { getPlotBottom, VIEWBOX_WIDTH } from "../viewbox.js";
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
@@ -8,13 +12,6 @@ const dateFormat = new Intl.DateTimeFormat("en-US", {
year: "numeric",
});
const markerRadiusPx = 4;
/** @param {number} width */
function getMarkerRadiusInViewBox(width) {
return width ? (markerRadiusPx * VIEWBOX_WIDTH) / width : markerRadiusPx;
}
/**
* @param {ScrubberSeries} series
* @param {number} step
@@ -24,15 +21,18 @@ function getPointAtStep(series, step) {
}
/**
* @param {ScrubberSeries[]} series
* @param {ChartPoint[]} points
* @param {number} x
* @param {number} y
*/
function getClosestPointIndex(points, y) {
function getClosestPointIndex(series, points, x, y) {
let closestIndex = 0;
let closestDistance = Infinity;
for (const [index, point] of points.entries()) {
const distance = Math.abs(point.y - y);
const distance =
series[index].hitTest?.(point, x, y) ?? Math.abs(point.y - y);
if (distance < closestDistance) {
closestIndex = index;
@@ -43,25 +43,15 @@ function getClosestPointIndex(points, y) {
return closestIndex;
}
/**
* @param {HTMLTimeElement} time
* @param {Date} date
*/
function updateTime(time, date) {
time.textContent = dateFormat.format(date);
time.dateTime = date.toISOString().slice(0, 10);
}
/**
* @param {LegendReadout} readout
* @param {ChartPoint[]} points
* @param {(value: number) => string} format
*/
function updateReadout(readout, points, format) {
updateTime(readout.time, points[0].date);
readout.rows.forEach(({ value }, index) => {
value.textContent = format(points[index].value);
readout.rows.forEach((row, index) => {
if (!row) return;
row.value.textContent = format(points[index].value);
});
}
@@ -75,11 +65,14 @@ 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 = [];
let height = 0;
/** @type {ChartFrame | undefined} */
let frame;
let stepCount = 0;
let currentStep = -1;
/** @type {ChartPoint[]} */
@@ -92,8 +85,10 @@ export function createScrubber(svg, readout, highlight, format) {
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();
@@ -106,11 +101,12 @@ export function createScrubber(svg, readout, highlight, format) {
/**
* @param {number} ratio
* @param {number} [x]
* @param {number} [y]
* @param {boolean} [scrubbing]
*/
function update(ratio, y, scrubbing = true) {
if (!series.length) return;
function update(ratio, x, y, scrubbing = true) {
if (!series.length || !frame) return;
const nextStep = Math.round(clamp(ratio, 0, 1) * stepCount);
@@ -118,18 +114,25 @@ export function createScrubber(svg, readout, highlight, format) {
currentStep = nextStep;
currentPoints = getPointsAtStep(nextStep);
const x = currentPoints[0].x;
const xText = x.toFixed(2);
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 - x).toFixed(2));
shade.setAttribute("height", height.toString());
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", height.toString());
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) => {
@@ -146,13 +149,13 @@ export function createScrubber(svg, readout, highlight, format) {
delete svg.dataset.scrubbing;
}
if (y !== undefined) {
highlight.preview(getClosestPointIndex(currentPoints, y));
if (x !== undefined && y !== undefined) {
highlight.preview(getClosestPointIndex(series, currentPoints, x, y));
}
}
function hide() {
update(1, undefined, false);
update(1, undefined, undefined, false);
}
function cancelPointerUpdate() {
@@ -166,6 +169,8 @@ export function createScrubber(svg, readout, highlight, format) {
markers = [];
currentStep = -1;
currentPoints = [];
frame = undefined;
dateMarker.style.display = "none";
highlight.clearPreview();
group.replaceChildren(shade, guide);
delete svg.dataset.index;
@@ -174,15 +179,16 @@ export function createScrubber(svg, readout, highlight, format) {
/**
* @param {ScrubberSeries[]} nextSeries
* @param {number} nextHeight
* @param {ChartFrame} nextFrame
*/
function setSeries(nextSeries, nextHeight) {
function setSeries(nextSeries, nextFrame) {
series = nextSeries;
height = nextHeight;
frame = nextFrame;
currentStep = -1;
stepCount = Math.max(...series.map(({ points }) => points.length - 1));
measure();
const radius = getMarkerRadiusInViewBox(rect.width);
dateMarker.style.display = "";
const radius = getChartPointRadius(rect.width);
markers = series.map(({ color }, index) => {
const marker = createSvgElement("circle");
@@ -196,7 +202,7 @@ export function createScrubber(svg, readout, highlight, format) {
});
group.replaceChildren(shade, guide, ...markers);
update(1, undefined, false);
update(1, undefined, undefined, false);
}
/** @param {PointerEvent} event */
@@ -209,9 +215,9 @@ export function createScrubber(svg, readout, highlight, format) {
pointerFrame = 0;
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
const y = ((pointerY - rect.top) / rect.height) * height;
const y = ((pointerY - rect.top) / rect.height) * (frame?.height ?? 0);
update(x / VIEWBOX_WIDTH, y);
update(x / VIEWBOX_WIDTH, x, y);
});
}
@@ -249,4 +255,5 @@ export function createScrubber(svg, readout, highlight, format) {
* @typedef {Object} ScrubberSeries
* @property {string} color
* @property {ChartPoint[]} points
* @property {PlottedSeries["hitTest"]} [hitTest]
*/
+39
View File
@@ -0,0 +1,39 @@
figure[data-chart="series"] {
[data-scrubber] {
opacity: 0;
pointer-events: none;
}
svg[data-scrubbing="true"] [data-scrubber] {
opacity: 1;
}
[data-scrubber="guide"] {
stroke: var(--white);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
}
[data-scrubber="shade"] {
fill: var(--black);
fill-opacity: 0.5;
}
[data-scrubber="marker"] {
fill: var(--black);
stroke: var(--color, var(--orange));
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
[data-scrubber="marker"][data-preview] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-width: 1.75;
}
svg[data-scrubbing="true"] ~ [data-chart-marker="date"] {
color: var(--black);
background: var(--white);
}
}
@@ -8,14 +8,14 @@ import { createStackedSeries } from "./series.js";
export function renderStackedPlot({
group,
loadedSeries,
height,
frame,
highlight,
scale,
order,
}) {
const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries(
loadedSeries,
height,
frame,
order,
scale,
);
@@ -1,4 +1,4 @@
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { getPlotHeight, insetPlotY, VIEWBOX_WIDTH } from "../viewbox.js";
import { orderIndexes } from "../order.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
@@ -37,13 +37,36 @@ function createStackBounds(series, stackOrder, lineIndexes) {
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 {number} height
* @param {ChartFrame} frame
* @param {ChartOrder} order
* @param {ChartScale} scale
*/
export function createStackedSeries(loadedSeries, height, order, 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"),
@@ -56,14 +79,40 @@ export function createStackedSeries(loadedSeries, height, order, scale) {
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;
@@ -77,8 +126,8 @@ export function createStackedSeries(loadedSeries, height, order, scale) {
if (value < 0) negative = end;
else positive = end;
const y0 = scaleY(start, bounds, height, scale);
const y1 = scaleY(end, bounds, height, scale);
const y0 = insetPlotY(frame, scaleY(start, bounds, plotHeight, scale));
const y1 = insetPlotY(frame, scaleY(end, bounds, plotHeight, scale));
plottedSeries[seriesIndex].points.push({
date,
@@ -92,7 +141,7 @@ export function createStackedSeries(loadedSeries, height, order, scale) {
for (const seriesIndex of lineIndexes) {
const { date, value } = loadedSeries[seriesIndex].entries[index];
const y = scaleY(value, bounds, height, scale);
const y = insetPlotY(frame, scaleY(value, bounds, plotHeight, scale));
plottedSeries[seriesIndex].points.push({
date,
+9
View File
@@ -0,0 +1,9 @@
figure[data-chart="series"] {
path[data-chart="stacked"] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
+77
View File
@@ -0,0 +1,77 @@
figure[data-chart="series"] {
--chart-plot-height: 20rem;
--chart-reserved-ui-height: 6rem;
min-height: calc(
var(--chart-plot-height) + var(--chart-reserved-ui-height)
);
line-height: 1;
svg {
display: block;
width: 100%;
height: var(--chart-plot-height);
outline: 0;
cursor: crosshair;
overflow: visible;
touch-action: pan-y;
transition: opacity 150ms ease;
}
svg:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.25rem;
}
svg[aria-busy="true"] {
opacity: 0.25;
}
> div[data-chart="plot"] {
position: relative;
}
p[role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
margin: 0;
color: var(--white);
text-transform: uppercase;
pointer-events: none;
}
p[role="status"]:empty {
display: none;
}
> footer {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.5rem 0 0;
}
&:fullscreen {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--black);
> div[data-chart="plot"] {
flex: 1;
min-height: 0;
display: flex;
}
svg {
flex: 1;
height: auto;
min-height: 0;
}
}
}
@@ -38,10 +38,20 @@ declare global {
defaultScale?: ChartScale;
series: ChartSeries[];
};
type ChartFrame = {
width: number;
height: number;
top: number;
bottom: number;
plotHeight: number;
};
type ChartFrameOptions = {
topPadding?: number;
bottomPadding?: number;
};
type LegendReadout = {
time: HTMLTimeElement;
rows: { value: HTMLOutputElement }[];
rows: ({ value: HTMLOutputElement } | null)[];
};
type LoadedSeries = {
series: ChartSeries;
@@ -51,11 +61,21 @@ declare global {
type PlotContext = {
group: SVGGElement;
loadedSeries: LoadedSeries[];
height: number;
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;
@@ -74,6 +94,25 @@ declare global {
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>;
+68
View File
@@ -0,0 +1,68 @@
import { CHART_FRAME, CHART_MARKER, CHART_SIZE } from "./constants.js";
export const VIEWBOX_WIDTH = CHART_SIZE.width;
export const FALLBACK_VIEWBOX_HEIGHT = CHART_SIZE.fallbackHeight;
/**
* @param {SVGSVGElement} svg
* @param {number} [fallbackHeight]
*/
export function getViewBoxHeight(svg, fallbackHeight = FALLBACK_VIEWBOX_HEIGHT) {
const { width, height } = svg.getBoundingClientRect();
return width && height ? (VIEWBOX_WIDTH * height) / width : fallbackHeight;
}
/**
* @param {SVGSVGElement} svg
* @param {number} height
*/
function getViewBoxUnit(svg, height) {
return svg.clientHeight ? height / svg.clientHeight : 1;
}
/**
* @param {SVGSVGElement} svg
* @param {number} [fallbackHeight]
* @param {ChartFrameOptions} [options]
* @returns {ChartFrame}
*/
export function createChartFrame(
svg,
fallbackHeight = FALLBACK_VIEWBOX_HEIGHT,
options = {},
) {
const height = getViewBoxHeight(svg, fallbackHeight);
const unit = getViewBoxUnit(svg, height);
const topPadding =
options.topPadding ?? CHART_MARKER.height + CHART_FRAME.topGap;
const bottomPadding = options.bottomPadding ?? CHART_FRAME.bottomPadding;
const top = topPadding * unit;
const bottom = Math.max(top + 1, height - bottomPadding * unit);
return {
width: VIEWBOX_WIDTH,
height,
top,
bottom,
plotHeight: bottom - top,
};
}
/** @param {ChartFrame} frame */
export function getPlotHeight(frame) {
return frame.plotHeight;
}
/** @param {ChartFrame} frame */
export function getPlotBottom(frame) {
return frame.bottom;
}
/**
* @param {ChartFrame} frame
* @param {number} y
*/
export function insetPlotY(frame, y) {
return frame.top + y;
}
+272
View File
@@ -0,0 +1,272 @@
import { createSeriesHighlight } from "../highlight.js";
import { createLegend } from "../legend/index.js";
import { getChartPointRadius, layoutChartMarker } from "../marker.js";
import { createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createChartFrame, VIEWBOX_WIDTH } from "../viewbox.js";
/**
* @param {Object} args
* @param {string} args.title
* @param {ChartUnit} args.unit
* @param {string} args.ariaLabel
* @param {number} args.fallbackHeight
* @param {ChartFrameOptions} [args.gutter]
* @param {XySeries[]} args.series
* @param {(frame: ChartFrame) => XyPlottedSeries[]} args.plot
* @param {false | ((series: XySeries, plotted: XyPlottedSeries, point: XyPoint) => string)} [args.marker]
*/
export function createXyChart({
title,
unit,
ariaLabel,
fallbackHeight,
gutter,
series,
plot,
marker,
}) {
const frameOptions =
gutter ?? (marker === false ? { topPadding: 0 } : {});
const figure = document.createElement("figure");
const plotElement = document.createElement("div");
const svg = createSvgElement("svg");
const group = createSvgElement("g");
const guide = createSvgElement("line");
const markerElement = document.createElement("div");
const { legend, menu, items, readout } = createLegend({
title,
unit,
series,
});
const highlight = createSeriesHighlight(items, menu);
const resizeObserver = new ResizeObserver(render);
/** @type {XyPlottedSeries[]} */
let currentSeries = [];
/** @type {ChartFrame | undefined} */
let currentFrame;
let rect = svg.getBoundingClientRect();
let pointerX = 0;
let pointerY = 0;
let pointerFrame = 0;
figure.dataset.chart = "xy";
figure.dataset.chartLegend = "";
plotElement.dataset.chart = "plot";
guide.dataset.chart = "xy-guide";
markerElement.dataset.chartMarker = "xy";
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${fallbackHeight}`);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", ariaLabel);
svg.append(guide, group);
plotElement.append(svg, markerElement);
figure.append(legend, plotElement);
function measure() {
rect = svg.getBoundingClientRect();
}
function render() {
const frame = createChartFrame(svg, fallbackHeight, frameOptions);
const plottedSeries = plot(frame);
const radius = getChartPointRadius(svg.getBoundingClientRect().width);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${frame.height}`);
currentFrame = frame;
currentSeries = plottedSeries;
highlight.clearNodes();
group.replaceChildren();
hideMarker();
updateReadout(readout, unit, plottedSeries);
plottedSeries.forEach((plotted, index) => {
const item = series[index];
if (item.kind === "line") {
appendLine(group, highlight, item, plotted, index);
} else {
appendPoints(group, highlight, item, plotted, index, radius);
}
});
}
/** @param {PointerEvent} event */
function updateFromPointer(event) {
pointerX = event.clientX;
pointerY = event.clientY;
if (pointerFrame) return;
pointerFrame = requestAnimationFrame(() => {
pointerFrame = 0;
if (!currentFrame) return;
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
const y = ((pointerY - rect.top) / rect.height) * currentFrame.height;
const closest = findClosestPoint(series, currentSeries, x, y);
if (!closest) {
hideMarker();
return;
}
showMarker(closest, currentFrame);
});
}
/**
* @param {{ index: number, point: XyPoint }} closest
* @param {ChartFrame} frame
*/
function showMarker(closest, frame) {
const plotted = currentSeries[closest.index];
const item = series[closest.index];
guide.setAttribute("x1", closest.point.x.toFixed(2));
guide.setAttribute("x2", closest.point.x.toFixed(2));
guide.setAttribute("y1", "0");
guide.setAttribute("y2", frame.bottom.toString());
const text =
marker === false
? ""
: (marker?.(item, plotted, closest.point) ??
unit.format(closest.point.value));
markerElement.textContent = text;
markerElement.hidden = !text;
if (text) layoutChartMarker(markerElement, closest.point.x);
svg.dataset.xyHover = "true";
highlight.preview(closest.index);
}
function hideMarker() {
delete svg.dataset.xyHover;
markerElement.textContent = "";
markerElement.hidden = true;
highlight.clearPreview();
}
function disconnect() {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
resizeObserver.disconnect();
}
render();
requestAnimationFrame(render);
resizeObserver.observe(svg);
svg.addEventListener("pointerenter", measure);
svg.addEventListener("pointermove", updateFromPointer);
svg.addEventListener("pointerleave", () => {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
hideMarker();
});
figure.addEventListener("chart:destroy", disconnect, { once: true });
return figure;
}
/**
* @param {XySeries[]} series
* @param {XyPlottedSeries[]} plottedSeries
* @param {number} x
* @param {number} y
*/
function findClosestPoint(series, plottedSeries, x, y) {
/** @type {{ index: number, point: XyPoint } | null} */
let closest = null;
let closestDistance = Infinity;
plottedSeries.forEach((item, index) => {
if (series[index].hidden) return;
for (const point of item.points) {
const distance = Math.hypot(point.x - x, point.y - y);
if (distance < closestDistance) {
closest = { index, point };
closestDistance = distance;
}
}
});
return closest;
}
/**
* @param {LegendReadout} readout
* @param {ChartUnit} unit
* @param {XyPlottedSeries[]} plottedSeries
*/
function updateReadout(readout, unit, plottedSeries) {
readout.rows.forEach((row, index) => {
if (!row) return;
const value = plottedSeries[index]?.value;
row.value.textContent =
typeof value === "number" ? unit.format(value) : (value ?? "");
});
}
/**
* @param {SVGGElement} group
* @param {SeriesHighlight} highlight
* @param {XySeries} series
* @param {XyPlottedSeries} plotted
* @param {number} index
*/
function appendLine(group, highlight, series, plotted, index) {
const path = createSvgElement("path");
path.dataset.chart = "xy-line";
path.dataset.series = index.toString();
path.style.setProperty("--color", series.color());
path.setAttribute("d", createLinePathData(plotted.points));
highlight.addNode(path, index);
group.append(path);
}
/**
* @param {SVGGElement} group
* @param {SeriesHighlight} highlight
* @param {XySeries} series
* @param {XyPlottedSeries} plotted
* @param {number} index
* @param {number} radius
*/
function appendPoints(group, highlight, series, plotted, index, radius) {
for (const point of plotted.points) {
const circle = createSvgElement("circle");
circle.dataset.chart = "xy-point";
circle.dataset.series = index.toString();
circle.style.setProperty("--color", series.color());
circle.setAttribute("cx", point.x.toFixed(2));
circle.setAttribute("cy", point.y.toFixed(2));
circle.setAttribute("r", radius.toString());
highlight.addNode(circle, index);
group.append(circle);
}
}
/**
* @typedef {Object} XyPoint
* @property {number} x
* @property {number} y
* @property {number} value
*/
/**
* @typedef {Object} XyPlottedSeries
* @property {XyPoint[]} points
* @property {number | string} [value]
*/
/**
* @typedef {Object} XySeries
* @property {string} label
* @property {() => string} color
* @property {"line" | "point"} kind
* @property {boolean} [hidden]
*/
+52
View File
@@ -0,0 +1,52 @@
figure[data-chart="xy"] {
--chart-xy-height: 12rem;
min-width: 0;
margin: 0;
line-height: 1;
> [data-chart="plot"] {
position: relative;
}
svg {
display: block;
width: 100%;
height: var(--chart-xy-height);
overflow: visible;
}
[data-chart="xy-line"] {
fill: none;
stroke: var(--color, var(--gray));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.75;
vector-effect: non-scaling-stroke;
}
[data-chart="xy-point"] {
fill: var(--color, var(--white));
stroke: var(--black);
stroke-width: 1.75;
vector-effect: non-scaling-stroke;
}
[data-chart="xy-guide"] {
opacity: 0;
stroke: var(--white);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
svg[data-xy-hover="true"] [data-chart="xy-guide"] {
opacity: 1;
}
[data-chart-marker="xy"] {
color: var(--black);
background: var(--white);
text-transform: uppercase;
}
}
+191
View File
@@ -0,0 +1,191 @@
import { createXyChart } from "../../chart/xy/index.js";
import { getPlotHeight, insetPlotY } from "../../chart/viewbox.js";
export const FEE_PERCENTILE_LABELS = /** @type {const} */ ([
"min",
"10%",
"25%",
"50%",
"75%",
"90%",
"max",
]);
const FEE_PERCENTILE_COLORS = /** @type {const} */ ([
"var(--cyan)",
"var(--blue)",
"var(--violet)",
"var(--white)",
"var(--yellow)",
"var(--orange)",
"var(--red)",
]);
const VIEWBOX_HEIGHT = 180;
const FEE_AVERAGE_COLOR = "var(--green)";
/** @param {number} value */
function scaleFeeRate(value) {
return Math.log10(value + 1);
}
/**
* @param {number[]} values
* @param {number} averageRate
* @returns {FeeEntry[]}
*/
function createEntries(values, averageRate) {
return [
...values.map((value, index) => ({
label: FEE_PERCENTILE_LABELS[index],
value,
color: FEE_PERCENTILE_COLORS[index],
pointIndex: index,
priority: 0,
})),
{
label: "avg",
value: averageRate,
color: FEE_AVERAGE_COLOR,
pointIndex: null,
priority: 1,
},
].sort((a, b) => a.value - b.value || a.priority - b.priority);
}
/**
* @param {FeeEntry[]} entries
* @returns {XySeries[]}
*/
function createSeries(entries) {
return [
{
label: "range",
color: () => "var(--gray)",
kind: /** @type {const} */ ("line"),
hidden: true,
},
...entries.map((entry) => ({
label: entry.label,
color: () => entry.color,
kind: /** @type {const} */ ("point"),
})),
];
}
/**
* @param {readonly number[]} values
* @param {ChartFrame} frame
* @returns {{ x: number, y: number, value: number }[]}
*/
function createPoints(values, frame) {
const scaledValues = values.map(scaleFeeRate);
const min = Math.min(...scaledValues);
const max = Math.max(...scaledValues);
const span = max - min;
const plotHeight = getPlotHeight(frame);
const xScale = frame.width / (scaledValues.length - 1);
return scaledValues.map((value, index) => ({
x: xScale * index,
y: span
? insetPlotY(frame, (1 - (value - min) / span) * plotHeight)
: insetPlotY(frame, plotHeight / 2),
value: values[index],
}));
}
/**
* @param {number[]} values
* @param {{ x: number, y: number, value: number }[]} points
* @param {number} target
*/
function interpolatePoint(values, points, target) {
const scaledValues = values.map(scaleFeeRate);
const scaledTarget = scaleFeeRate(target);
if (scaledTarget <= scaledValues[0]) {
return { ...points[0], value: target };
}
for (let index = 1; index < scaledValues.length; index += 1) {
if (scaledTarget > scaledValues[index]) continue;
const previousValue = scaledValues[index - 1];
const nextValue = scaledValues[index];
const previousPoint = points[index - 1];
const nextPoint = points[index];
const span = nextValue - previousValue;
const ratio = span ? (scaledTarget - previousValue) / span : 0;
return {
x: previousPoint.x + (nextPoint.x - previousPoint.x) * ratio,
y: previousPoint.y + (nextPoint.y - previousPoint.y) * ratio,
value: target,
};
}
return { ...points[points.length - 1], value: target };
}
/**
* @param {number[]} values
* @param {FeeEntry[]} entries
* @param {ChartFrame} frame
* @returns {XyPlottedSeries[]}
*/
function plotSeries(values, entries, frame) {
const points = createPoints(values, frame);
return [
{ points },
...entries.map((entry) => {
const point =
entry.pointIndex === null
? interpolatePoint(values, points, entry.value)
: points[entry.pointIndex];
return {
points: [point],
value: entry.value,
};
}),
];
}
/**
* @param {number[]} values
* @param {number} averageRate
* @param {(value: number) => string} formatRate
*/
export function createFeeChart(values, averageRate, formatRate) {
const entries = createEntries(values, averageRate);
const figure = createXyChart({
title: "Percentiles",
unit: {
id: "sat/vB",
name: "satoshis per virtual byte",
format: formatRate,
},
ariaLabel: `Fee rate percentiles from ${formatRate(
values[0],
)} to ${formatRate(values[values.length - 1])} sat/vB`,
fallbackHeight: VIEWBOX_HEIGHT,
series: createSeries(entries),
plot: (frame) => plotSeries(values, entries, frame),
marker: false,
});
figure.dataset.feeChart = "";
return figure;
}
/**
* @typedef {Object} FeeEntry
* @property {string} label
* @property {number} value
* @property {string} color
* @property {number | null} pointIndex
* @property {number} priority
*/
+232
View File
@@ -0,0 +1,232 @@
import { brk } from "../../utils/client.js";
import { createFeeChart } from "./fee-chart.js";
const SATS_PER_BTC = 100_000_000;
/** @typedef {Awaited<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
/** @param {number} sats */
function formatBtc(sats) {
return `${(sats / SATS_PER_BTC).toFixed(8)} BTC`;
}
/** @param {number} bytes */
function formatBytes(bytes) {
return bytes >= 1_000_000
? `${(bytes / 1_000_000).toFixed(2)} MB`
: `${bytes.toLocaleString()} B`;
}
/** @param {number} rate */
function formatFeeRate(rate) {
if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`;
if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`;
if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`;
if (rate >= 100) return Math.round(rate).toLocaleString();
if (rate >= 10) return rate.toFixed(1);
return rate.toFixed(2);
}
/** @param {number} unixSeconds */
function formatDateTime(unixSeconds) {
return new Date(unixSeconds * 1_000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "medium",
});
}
/** @param {number} height */
function createHeightElement(height) {
const element = document.createElement("span");
const prefix = document.createElement("span");
const value = document.createElement("span");
prefix.classList.add("dim");
prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`;
value.textContent = String(height);
element.append(prefix, value);
return element;
}
/** @param {number} height */
function createTitle(height) {
const label = document.createElement("span");
const value = document.createElement("span");
label.classList.add("title-label");
value.classList.add("title-height");
label.textContent = "Block";
value.append(createHeightElement(height));
return [label, value];
}
/** @param {string} value */
function code(value) {
const element = document.createElement("code");
element.textContent = value;
return element;
}
/** @param {(string | Node | null)[]} values */
function joinValues(values) {
const fragment = document.createDocumentFragment();
let added = false;
for (const value of values) {
if (value == null || value === "") continue;
if (added) fragment.append(" · ");
fragment.append(value);
added = true;
}
return added ? fragment : null;
}
/** @param {string[]} values */
function joinText(values) {
return values.filter(Boolean).join(", ") || null;
}
/**
* @param {string} term
* @param {string | Node | null | undefined} value
*/
function createRow(term, value) {
if (value == null || value === "") return null;
const row = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
dd.append(value);
row.append(dt, dd);
return row;
}
/** @param {string} title */
function groupName(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}
/**
* @param {HTMLElement} parent
* @param {string} title
* @param {[string, string | Node | null | undefined][]} rows
* @param {Node[]} [children]
*/
function appendGroup(parent, title, rows, children = []) {
const visibleRows = rows.flatMap(([term, value]) => {
const row = createRow(term, value);
return row ? [row] : [];
});
if (!visibleRows.length && !children.length) return;
const section = document.createElement("section");
const heading = document.createElement("h2");
section.dataset.group = groupName(title);
heading.textContent = title;
section.append(heading, ...children);
if (visibleRows.length) {
const list = document.createElement("dl");
list.append(...visibleRows);
section.append(list);
}
parent.append(section);
}
export function createBlockDetails() {
const element = document.createElement("section");
const header = document.createElement("header");
const title = document.createElement("h1");
const summary = document.createElement("p");
const content = document.createElement("div");
element.id = "block-details";
element.hidden = true;
header.append(title, summary);
element.append(header, content);
/** @param {Block} block */
function update(block) {
const extras = block.extras;
element.hidden = false;
title.replaceChildren(...createTitle(block.height));
summary.replaceChildren(
joinValues([
extras.pool.name,
formatDateTime(block.timestamp),
`${block.txCount.toLocaleString()} txs`,
]) ?? "",
);
for (const chart of content.querySelectorAll("[data-fee-chart]")) {
chart.dispatchEvent(new Event("chart:destroy"));
}
content.textContent = "";
appendGroup(content, "Overview", [
["Hash", code(block.id)],
["Previous", code(block.previousblockhash)],
["Merkle root", code(block.merkleRoot)],
["Timestamp", formatDateTime(block.timestamp)],
["Median time", formatDateTime(block.mediantime)],
["Version", `0x${block.version.toString(16)}`],
["Bits", `0x${block.bits.toString(16)}`],
["Nonce", block.nonce.toLocaleString()],
["Difficulty", block.difficulty.toLocaleString()],
["Stale", block.stale ? "yes" : null],
]);
appendGroup(content, "Mining", [
["Pool", extras.pool.name],
["Pool slug", extras.pool.slug],
["Miner names", joinText(extras.pool.minerNames ?? [])],
["Reward", formatBtc(extras.reward)],
["Total fees", formatBtc(extras.totalFees)],
["Price", `$${extras.price.toLocaleString()}`],
["Coinbase address", extras.coinbaseAddress ?? null],
["Coinbase addresses", joinText(extras.coinbaseAddresses)],
["Coinbase signature", extras.coinbaseSignatureAscii || null],
]);
appendGroup(content, "Transactions", [
["Count", block.txCount.toLocaleString()],
["Inputs", extras.totalInputs.toLocaleString()],
["Outputs", extras.totalOutputs.toLocaleString()],
["Input amount", formatBtc(extras.totalInputAmt)],
["Output amount", formatBtc(extras.totalOutputAmt)],
["UTXO set change", extras.utxoSetChange.toLocaleString()],
["UTXO set size", extras.utxoSetSize.toLocaleString()],
["SegWit transactions", extras.segwitTotalTxs.toLocaleString()],
]);
appendGroup(content, "Fees", [], [
createFeeChart(extras.feeRange, extras.avgFeeRate, formatFeeRate),
]);
appendGroup(content, "Size", [
["Size", formatBytes(block.size)],
["Weight", `${(block.weight / 1_000_000).toFixed(2)} MWU`],
["Virtual size", `${extras.virtualSize.toLocaleString()} vB`],
["Average tx size", formatBytes(extras.avgTxSize)],
["SegWit size", formatBytes(extras.segwitTotalSize)],
["SegWit weight", `${extras.segwitTotalWeight.toLocaleString()} WU`],
]);
}
return /** @type {const} */ ({
element,
update,
});
}
+184
View File
@@ -0,0 +1,184 @@
#block-details {
min-width: 0;
height: 100%;
min-height: 0;
overflow-y: auto;
padding: calc(var(--page-x) + 2.5rem) var(--page-x) var(--page-x) 0;
color: var(--white);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
scrollbar-width: none;
.dim {
opacity: 0.5;
}
:is(h1, h2, p, dl, dd) {
margin: 0;
}
> header {
display: grid;
gap: 0.5rem;
padding-bottom: 1.25rem;
h1 {
display: flex;
flex-wrap: wrap;
gap: 0.35em;
align-items: baseline;
min-width: 0;
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
}
.title-label {
font-family: var(--font-serif);
font-size: 2.5rem;
font-style: italic;
line-height: 0.9;
text-transform: lowercase;
}
.title-height {
color: var(--gray);
font-size: var(--font-size-lg);
line-height: var(--line-height-lg);
}
p {
color: var(--gray);
}
}
> div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
section {
display: grid;
align-content: start;
gap: 0.75rem;
min-width: 0;
border: 1px solid color-mix(in oklch, var(--gray) 18%, transparent);
border-radius: 0.5rem;
padding: 1rem;
&[data-group="overview"] {
grid-column: 1 / -1;
}
&[data-group="mining"] {
border-color: color-mix(in oklch, var(--orange) 34%, transparent);
h2 {
color: var(--orange);
}
}
&[data-group="transactions"] {
border-color: color-mix(in oklch, var(--cyan) 28%, transparent);
h2 {
color: var(--cyan);
}
}
&[data-group="fees"] {
border-color: color-mix(in oklch, var(--green) 28%, transparent);
h2 {
color: var(--green);
}
figure[data-fee-chart] {
--chart-xy-height: 7.5rem;
}
}
&[data-group="size"] {
border-color: color-mix(in oklch, var(--blue) 28%, transparent);
h2 {
color: var(--blue);
}
}
}
h2 {
color: var(--gray);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 450;
line-height: var(--line-height-xs);
text-transform: uppercase;
}
dl {
display: grid;
> div {
display: grid;
grid-template-columns: minmax(6rem, 0.36fr) minmax(0, 1fr);
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid
color-mix(in oklch, var(--gray) 12%, transparent);
&:first-child {
padding-top: 0;
}
&:last-child {
padding-bottom: 0;
border-bottom: 0;
}
}
}
dt {
color: var(--gray);
}
dd {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
}
code {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
}
}
@media (max-width: 48rem) {
#block-details {
min-width: 0;
height: auto;
padding: 1rem var(--page-x) var(--page-x);
> div {
grid-template-columns: minmax(0, 1fr);
}
section[data-group="overview"] {
grid-column: auto;
}
dl > div {
grid-template-columns: minmax(0, 1fr);
gap: 0.15rem;
}
dd {
text-align: left;
}
}
}
+43 -30
View File
@@ -4,6 +4,10 @@
--cube-empty-alpha: 0.4;
--face-step: 0.033;
&:not(.loading) .cube[data-enter] {
animation: confirmed-cube-enter 180ms ease-out both;
}
.cube {
--cube-width: calc(var(--cube-size) * 2 * var(--iso-scale));
--cube-height: calc(var(--cube-size) * 2);
@@ -24,6 +28,16 @@
--face-bottom: oklch(
from var(--cube-face-base) calc(l - var(--face-step) * 3) c h
);
--state-face-top: var(--face-color-base);
--state-face-right: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 2) c h
);
--state-face-left: oklch(
from var(--face-color-base) calc(l - var(--face-step)) c h
);
--state-face-bottom: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 3) c h
);
--is-full: round(down, var(--fill), 1);
--is-empty: round(down, calc(1 - var(--fill)), 1);
@@ -47,16 +61,10 @@
&:is(button):hover {
color: var(--background-color);
--face-color-base: var(--inv-border-color);
--face-top: var(--face-color-base);
--face-right: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 2) c h
);
--face-left: oklch(
from var(--face-color-base) calc(l - var(--face-step)) c h
);
--face-bottom: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 3) c h
);
--face-top: var(--state-face-top);
--face-right: var(--state-face-right);
--face-left: var(--state-face-left);
--face-bottom: var(--state-face-bottom);
}
}
@@ -64,37 +72,30 @@
&.selected {
color: var(--black);
--face-color-base: var(--orange);
--face-top: var(--face-color-base);
--face-right: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 2) c h
);
--face-left: oklch(
from var(--face-color-base) calc(l - var(--face-step)) c h
);
--face-bottom: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 3) c h
);
}
&[data-press]:not(.selected) {
color: var(--background-color);
--face-color-base: var(--inv-border-color);
--face-top: var(--face-color-base);
--face-right: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 2) c h
);
--face-left: oklch(
from var(--face-color-base) calc(l - var(--face-step)) c h
);
--face-bottom: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 3) c h
);
}
&:is(button):active,
&.selected,
&[data-press]:not(.selected) {
--face-top: var(--state-face-top);
--face-right: var(--state-face-right);
--face-left: var(--state-face-left);
--face-bottom: var(--state-face-bottom);
}
&.projected {
animation: projected-cube-pulse 4s ease-in-out infinite;
}
&[data-placeholder] {
visibility: hidden;
}
&.skeleton .face-text {
visibility: hidden;
}
@@ -235,6 +236,18 @@
}
}
@keyframes confirmed-cube-enter {
from {
opacity: 0;
scale: 0.98;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes projected-cube-pulse {
0%,
100% {
+372 -82
View File
@@ -2,7 +2,9 @@ import { brk } from "../../utils/client.js";
import { isPlainLeftClick } from "../../utils/event.js";
import { createCubeButton, createCubeDiv } from "./cube/index.js";
const LOOKAHEAD = 15;
const BLOCK_BATCH_SIZE = 15;
const EDGE_LOAD_DISTANCE = 50;
const OLDER_RESERVE_VIEWPORTS = 6;
const POLL_INTERVAL = 1_000;
const PROJECTED_LIMIT = 8;
const TARGET_BLOCK_SECONDS = 600;
@@ -24,6 +26,7 @@ const MONTHS = /** @type {const} */ ([
/** @typedef {Awaited<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
/** @typedef {Awaited<ReturnType<typeof brk.getMempoolBlocks>>[number]} MempoolBlock */
/** @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch */
/** @param {number} rate */
function formatFeeRate(rate) {
@@ -42,7 +45,6 @@ function createHeightElement(height) {
const value = document.createElement("span");
prefix.classList.add("dim");
prefix.style.userSelect = "none";
prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`;
value.textContent = String(height);
container.append(prefix, value);
@@ -113,16 +115,19 @@ function createEdgeButton(className, label, mobileLabel, title, handler) {
return button;
}
export function createChain() {
/**
* @param {{ onSelect?: (block: Block) => void }} [options]
*/
export function createChain({ onSelect = () => {} } = {}) {
const element = document.createElement("div");
const scrollElement = document.createElement("div");
const blocksElement = document.createElement("div");
const tipButton = createEdgeButton("tip", "↑", "←", "Jump to chain tip", () => {
void goToCube(null);
jumpToTip();
});
element.id = "chain";
tipButton.hidden = true;
setTipVisible(false);
scrollElement.classList.add("scroll");
blocksElement.classList.add("blocks");
scrollElement.append(blocksElement);
@@ -130,9 +135,8 @@ export function createChain() {
/** @type {HTMLButtonElement | null} */
let selectedCube = null;
/** @type {IntersectionObserver | undefined} */
let olderEdgeObserver;
/** @type {HTMLButtonElement | null} */
let tipCube = null;
/** @type {Map<string, Block>} */
const blocksByHash = new Map();
@@ -143,15 +147,23 @@ export function createChain() {
let active = false;
let newestHeight = -1;
let oldestHeight = Infinity;
let oldestReservedHeight = -1;
let newestTimestamp = 0;
let loadingOlder = false;
let hydratingOlder = false;
let loadingNewer = false;
let polling = false;
let reachedTip = false;
let olderGeneration = 0;
/** @type {OlderBatch[]} */
const olderBatches = [];
/** @type {number | undefined} */
let pollId;
/** @type {number | undefined} */
let jumpTimeout;
let tipSyncFrame = 0;
let jumping = false;
/** @type {AbortController} */
let controller = new AbortController();
@@ -164,9 +176,9 @@ export function createChain() {
const attribute = typeof hashOrHeight === "number" ? "height" : "hash";
return /** @type {HTMLButtonElement | null} */ (
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
);
return /** @type {HTMLButtonElement | null} */ (
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
);
}
function firstProjectedCube() {
@@ -188,7 +200,87 @@ export function createChain() {
selectedCube = null;
}
/** @param {HTMLButtonElement} cube @param {{ scroll?: "smooth" | "instant" }} [options] */
function updateTipCube() {
tipCube?.removeAttribute("data-tip");
tipCube = newestConfirmedCube();
tipCube?.setAttribute("data-tip", "");
}
function jumpToTip() {
if (!tipCube || jumping) return;
jumping = true;
element.classList.add("jumping");
element.addEventListener("transitionend", finishJumpToTip);
jumpTimeout = window.setTimeout(
finishJumpToTip,
transitionMs(element, "opacity") + 50,
);
}
/** @param {Event} [event] */
function finishJumpToTip(event) {
if (
event instanceof TransitionEvent &&
(event.target !== element || event.propertyName !== "opacity")
) {
return;
}
if (tipCube) selectCube(tipCube, { scroll: "instant" });
cancelJump();
}
function cancelJump() {
if (jumpTimeout !== undefined) {
window.clearTimeout(jumpTimeout);
jumpTimeout = undefined;
}
element.removeEventListener("transitionend", finishJumpToTip);
element.classList.remove("jumping");
jumping = false;
}
/**
* @param {Element} element
* @param {string} property
*/
function transitionMs(element, property) {
const style = getComputedStyle(element);
const properties = style.transitionProperty.split(",").map((part) => {
return part.trim();
});
const durations = parseCssTimes(style.transitionDuration);
const delays = parseCssTimes(style.transitionDelay);
const index = properties.findIndex((part) => {
return part === property || part === "all";
});
if (index < 0) return 0;
const duration = durations[index] ?? durations.at(-1) ?? 0;
const delay = delays[index] ?? delays.at(-1) ?? 0;
return duration + delay;
}
/** @param {string} value */
function parseCssTimes(value) {
return value.split(",").map((part) => {
const time = part.trim();
const amount = Number.parseFloat(time);
return time.endsWith("ms") ? amount : amount * 1_000;
});
}
/**
* @param {HTMLButtonElement} cube
* @param {{ scroll?: "smooth" | "instant" }} [options]
*/
function selectCube(cube, { scroll } = {}) {
if (cube !== selectedCube) {
deselectCube();
@@ -196,36 +288,133 @@ export function createChain() {
cube.classList.add("selected");
}
const hash = cube.dataset.hash;
const block = hash ? blocksByHash.get(hash) : undefined;
if (block) onSelect(block);
if (scroll) {
cube.scrollIntoView({
behavior: scroll,
block: "center",
inline: "center",
});
scrollToElement(cube, scroll);
scheduleTipVisibilitySync();
}
}
/**
* @param {Element} target
* @param {"smooth" | "instant"} behavior
*/
function scrollToElement(target, behavior) {
target.scrollIntoView({
behavior,
block: "center",
inline: "center",
});
}
/**
* @param {Element | null | undefined} anchor
* @param {DOMRect | undefined} anchorRect
*/
function preserveScrollPosition(anchor, anchorRect) {
if (!anchor || !anchorRect) return;
const rect = anchor.getBoundingClientRect();
scrollElement.scrollTop += rect.top - anchorRect.top;
scrollElement.scrollLeft += rect.left - anchorRect.left;
}
function isHorizontal() {
return getComputedStyle(blocksElement).flexDirection.startsWith("row");
}
/** @param {boolean} horizontal */
function olderRemaining(horizontal) {
return horizontal
? scrollElement.scrollWidth -
scrollElement.clientWidth -
scrollElement.scrollLeft
: scrollElement.scrollHeight -
scrollElement.clientHeight -
scrollElement.scrollTop;
}
/** @param {boolean} horizontal */
function olderRunway(horizontal) {
return (
(horizontal ? scrollElement.clientWidth : scrollElement.clientHeight) *
OLDER_RESERVE_VIEWPORTS
);
}
/** @param {number} [delta] */
function reserveOlderRunway(delta = 0) {
if (!active || oldestReservedHeight <= 0) return;
const horizontal = isHorizontal();
const runway = olderRunway(horizontal) + delta;
let remaining = olderRemaining(horizontal);
while (remaining < runway) {
if (!reserveOlderBatch()) return;
remaining = olderRemaining(horizontal);
}
}
function clear() {
newestHeight = -1;
oldestHeight = Infinity;
oldestReservedHeight = -1;
newestTimestamp = 0;
loadingOlder = false;
hydratingOlder = false;
loadingNewer = false;
reachedTip = false;
olderGeneration++;
selectedCube = null;
tipCube = null;
blocksByHash.clear();
blocksElement.textContent = "";
projectedCubes.length = 0;
tipButton.hidden = true;
olderEdgeObserver?.disconnect();
olderBatches.length = 0;
setTipVisible(false);
}
function observeOldestEdge() {
olderEdgeObserver?.disconnect();
/**
* @param {Element | null} anchor
* @param {number} count
*/
function prependOlderPlaceholders(anchor, count) {
const fragment = document.createDocumentFragment();
const placeholders = /** @type {HTMLElement[]} */ ([]);
const oldest = blocksElement.firstElementChild;
if (oldest) olderEdgeObserver?.observe(oldest);
for (let i = 0; i < count; i++) {
const cube = document.createElement("div");
cube.classList.add("cube");
cube.dataset.placeholder = "";
placeholders.push(cube);
fragment.append(cube);
}
blocksElement.insertBefore(fragment, anchor);
return placeholders;
}
function reserveOlderBatch() {
if (!active || oldestReservedHeight <= 0) return false;
const anchor = blocksElement.firstElementChild;
const count = Math.min(BLOCK_BATCH_SIZE, oldestReservedHeight);
const startHeight = oldestReservedHeight - 1;
const placeholders = prependOlderPlaceholders(anchor, count);
if (!placeholders.length) return false;
oldestReservedHeight -= placeholders.length;
olderBatches.push({ generation: olderGeneration, startHeight, placeholders });
void hydrateOlderBatches();
return true;
}
/** @param {Block[]} blocks */
@@ -239,7 +428,7 @@ export function createChain() {
const block = blocks[i];
if (block.height > newestHeight) {
appendConfirmed(createConfirmedCube(block));
appendConfirmed(createEnteringConfirmedCube(block));
} else {
blocksByHash.set(block.id, block);
}
@@ -247,13 +436,10 @@ export function createChain() {
newestHeight = Math.max(newestHeight, blocks[0].height);
newestTimestamp = blocks[0].timestamp;
updateTipCube();
refreshProjected();
if (anchor && anchorRect) {
const rect = anchor.getBoundingClientRect();
scrollElement.scrollTop += rect.top - anchorRect.top;
scrollElement.scrollLeft += rect.left - anchorRect.left;
}
preserveScrollPosition(anchor, anchorRect);
syncTipVisibility();
@@ -269,13 +455,17 @@ export function createChain() {
clear();
for (const block of blocks) prependConfirmed(createConfirmedCube(block));
for (const block of blocks) {
prependConfirmed(createEnteringConfirmedCube(block));
}
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
oldestReservedHeight = oldestHeight;
newestTimestamp = blocks[0].timestamp;
reachedTip = height == null;
observeOldestEdge();
updateTipCube();
reserveOlderRunway();
if (reachedTip) await pollProjected();
else await loadNewer();
@@ -362,26 +552,65 @@ export function createChain() {
}
}
async function loadOlder() {
if (!active || loadingOlder || oldestHeight <= 0) return;
async function hydrateOlderBatches() {
if (hydratingOlder) return;
loadingOlder = true;
const generation = olderGeneration;
hydratingOlder = true;
try {
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1, {
while (
active &&
generation === olderGeneration &&
olderBatches[0]?.generation === generation
) {
await hydrateOlderBatch(olderBatches[0]);
if (olderBatches[0]?.generation === generation) olderBatches.shift();
}
} finally {
if (generation === olderGeneration) hydratingOlder = false;
}
}
/** @param {OlderBatch} batch */
async function hydrateOlderBatch(batch) {
try {
const blocks = await brk.getBlocksV1FromHeight(batch.startHeight, {
signal: controller.signal,
});
for (const block of blocks) prependConfirmed(createConfirmedCube(block));
if (!batch.placeholders.some((placeholder) => placeholder.isConnected)) {
return;
}
const cubes = [...blocks].reverse().map(createEnteringConfirmedCube);
for (let i = 0; i < batch.placeholders.length; i++) {
const cube = cubes[i];
if (cube) batch.placeholders[i].replaceWith(cube);
else batch.placeholders[i].remove();
}
for (const cube of cubes) setConfirmedInterval(cube);
const next = cubes.at(-1)?.nextElementSibling;
if (next instanceof HTMLElement) setConfirmedInterval(next);
if (blocks.length) {
oldestHeight = blocks[blocks.length - 1].height;
observeOldestEdge();
} else {
oldestReservedHeight = oldestHeight;
}
reserveOlderRunway();
} catch (error) {
if (!controller.signal.aborted) console.error("explore older:", error);
} finally {
loadingOlder = false;
if (!controller.signal.aborted) {
for (const placeholder of batch.placeholders) placeholder.remove();
oldestReservedHeight = oldestHeight;
console.error("explore older:", error);
}
}
}
@@ -393,7 +622,7 @@ export function createChain() {
try {
const prevNewest = newestHeight;
const blocks = await brk.getBlocksV1FromHeight(
newestHeight + LOOKAHEAD,
newestHeight + BLOCK_BATCH_SIZE,
{ signal: controller.signal },
);
@@ -408,6 +637,27 @@ export function createChain() {
}
}
/** @param {HTMLElement} cube */
function markCubeEntering(cube) {
cube.dataset.enter = "";
cube.addEventListener(
"animationend",
() => {
cube.removeAttribute("data-enter");
},
{ once: true },
);
}
/** @param {Block} block */
function createEnteringConfirmedCube(block) {
const cube = createConfirmedCube(block);
markCubeEntering(cube);
return cube;
}
/** @param {Block} block */
function createConfirmedCube(block) {
const { pool, medianFee, feeRange, virtualSize } = block.extras;
@@ -467,7 +717,7 @@ export function createChain() {
/** @param {HTMLElement} cube */
function setConfirmedInterval(cube) {
const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling);
if (!prev) return;
if (!prev?.dataset.timestamp) return;
cube.style.setProperty(
"--block-interval",
@@ -514,6 +764,7 @@ export function createChain() {
updateProjectedCube(projectedCubes[i], blocks[i]);
}
updateTipCube();
refreshProjected();
}
@@ -577,6 +828,7 @@ export function createChain() {
const now = Math.floor(Date.now() / 1_000);
const elapsed = Math.max(0, now - newestTimestamp);
const updateLayout = !tipButton.hasAttribute("data-visible");
for (let i = 0; i < projectedCubes.length; i++) {
const cube = projectedCubes[i];
@@ -584,7 +836,10 @@ export function createChain() {
const timestamp = now + i * TARGET_BLOCK_SECONDS;
const [hh, mm] = formatHHMM(timestamp);
cube.element.style.setProperty("--block-interval", String(interval));
if (updateLayout) {
cube.element.style.setProperty("--block-interval", String(interval));
}
cube.parts.date.nodeValue = formatShortDate(timestamp);
cube.parts.hh.nodeValue = hh;
cube.parts.mm.nodeValue = mm;
@@ -600,70 +855,104 @@ export function createChain() {
});
}
/** @param {boolean} visible */
function setTipVisible(visible) {
tipButton.toggleAttribute("data-visible", visible);
tipButton.setAttribute("aria-hidden", String(!visible));
tipButton.tabIndex = visible ? 0 : -1;
}
function syncTipVisibility() {
if (!reachedTip || newestHeight < 0) {
tipButton.hidden = true;
if (!reachedTip || newestHeight < 0 || !tipCube) {
setTipVisible(false);
return;
}
const visibleHeight = findVisibleConfirmedHeight();
tipButton.hidden =
visibleHeight == null ||
newestHeight - visibleHeight <= TIP_BLOCK_THRESHOLD;
if (projectedCubes.some(({ element }) => isElementVisible(element))) {
setTipVisible(false);
return;
}
setTipVisible(
visibleHeight != null
? newestHeight - visibleHeight > TIP_BLOCK_THRESHOLD
: !isElementVisible(tipCube),
);
}
/** @param {Element} element */
function distanceFromViewport(element) {
const viewport = scrollElement.getBoundingClientRect();
const rect = element.getBoundingClientRect();
const horizontal = isHorizontal();
if (horizontal) {
if (rect.left > viewport.right) return rect.left - viewport.right;
if (rect.right < viewport.left) return viewport.left - rect.right;
return 0;
}
if (rect.top > viewport.bottom) return rect.top - viewport.bottom;
if (rect.bottom < viewport.top) return viewport.top - rect.bottom;
return 0;
}
/** @param {Element} element */
function isElementVisible(element) {
return distanceFromViewport(element) === 0;
}
function shouldLoadNewer() {
const cube = newestConfirmedCube();
return cube != null && distanceFromViewport(cube) <= EDGE_LOAD_DISTANCE;
}
function findVisibleConfirmedHeight() {
const viewport = scrollElement.getBoundingClientRect();
const horizontal = getComputedStyle(blocksElement).flexDirection.startsWith(
"row",
);
const viewportStart = horizontal ? viewport.left : viewport.top;
const viewportEnd = horizontal ? viewport.right : viewport.bottom;
const target = (viewportStart + viewportEnd) / 2;
const x = (viewport.left + viewport.right) / 2;
const y = (viewport.top + viewport.bottom) / 2;
let closestHeight = null;
let closestDistance = Infinity;
for (const element of document.elementsFromPoint(x, y)) {
const cube = element.closest(".cube[data-height]");
for (const element of blocksElement.children) {
if (
!(element instanceof HTMLElement) ||
element.classList.contains("projected")
cube instanceof HTMLElement &&
blocksElement.contains(cube) &&
!cube.classList.contains("projected")
) {
continue;
return Number(cube.dataset.height);
}
const rect = element.getBoundingClientRect();
const start = horizontal ? rect.left : rect.top;
const end = horizontal ? rect.right : rect.bottom;
if (end < viewportStart || start > viewportEnd) continue;
const distance = Math.abs((start + end) / 2 - target);
if (distance >= closestDistance) continue;
closestDistance = distance;
closestHeight = Number(element.dataset.height);
}
return closestHeight;
return null;
}
olderEdgeObserver = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) void loadOlder();
/** @param {WheelEvent} event */
function olderWheelDelta(event) {
return Math.max(
0,
isHorizontal() ? Math.max(event.deltaX, event.deltaY) : event.deltaY,
);
}
scrollElement.addEventListener(
"wheel",
(event) => {
reserveOlderRunway(olderWheelDelta(event));
},
{ root: scrollElement },
{ passive: true },
);
scrollElement.addEventListener(
"scroll",
() => {
scheduleTipVisibilitySync();
reserveOlderRunway();
if (reachedTip || loadingNewer) return;
if (scrollElement.scrollTop <= 50 && scrollElement.scrollLeft <= 50) {
void loadNewer();
}
if (shouldLoadNewer()) void loadNewer();
},
{ passive: true },
);
@@ -694,6 +983,7 @@ export function createChain() {
tipSyncFrame = 0;
}
cancelJump();
controller.abort();
}
+21 -23
View File
@@ -11,7 +11,6 @@
position: relative;
display: grid;
flex: 1;
min-width: 0;
height: 100%;
min-height: 0;
@@ -19,8 +18,10 @@
opacity: 1;
transition: opacity 200ms ease;
&.loading {
&.loading,
&.jumping {
opacity: 0;
pointer-events: none;
}
.dim {
@@ -98,32 +99,29 @@
.edge {
position: absolute;
top: var(--main-padding);
left: calc(var(--main-padding) / 2);
top: calc(var(--main-padding) + 2.5rem);
left: calc(var(--cube-size) * var(--iso-scale));
z-index: 1;
width: auto;
height: auto;
width: 1.5rem;
height: 1.5rem;
translate: -50% 0;
border-radius: 999rem;
padding: 0.375rem 0.625rem;
color: var(--black);
background: var(--white);
font-size: var(--font-size-xs);
padding: 0;
font-size: var(--font-size-base);
font-weight: 500;
line-height: 1;
letter-spacing: 0;
opacity: 0;
scale: 0.85;
pointer-events: none;
transition:
opacity 150ms ease,
scale 150ms ease;
@media (hover: hover) and (pointer: fine) {
&:hover {
background: var(--gray);
}
}
&:active {
background: var(--orange);
}
&[data-press] {
background: var(--gray);
&[data-visible] {
opacity: 1;
scale: 1;
pointer-events: auto;
}
}
}
@@ -170,7 +168,7 @@
&::before {
content: attr(data-mobile-label);
font-size: var(--font-size-xs);
font-size: var(--font-size-base);
}
}
}
+6 -2
View File
@@ -1,11 +1,15 @@
import { createBlockDetails } from "./block/index.js";
import { createChain } from "./chain/index.js";
export function createExplorePage() {
const main = document.createElement("main");
const chain = createChain();
const blockDetails = createBlockDetails();
const chain = createChain({
onSelect: blockDetails.update,
});
main.className = "explore";
main.append(chain.element);
main.append(chain.element, blockDetails.element);
const syncChain = () => chain.setActive(!main.hidden && !document.hidden);
+18 -1
View File
@@ -1,6 +1,23 @@
main.explore {
display: flex;
--explore-max-width: 80rem;
--explore-gap: 2rem;
--chain-width: calc(4.5rem * sqrt(3));
display: grid;
grid-template-columns: var(--chain-width) minmax(0, 1fr);
gap: var(--explore-gap);
width: min(100%, var(--explore-max-width));
height: 100dvh;
margin-inline: auto;
overflow: hidden;
padding: 0;
}
@media (max-width: 48rem) {
main.explore {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 0;
width: 100%;
}
}
+12 -9
View File
@@ -105,16 +105,19 @@
<link rel="stylesheet" href="/explore/style.css" />
<link rel="stylesheet" href="/explore/chain/cube/style.css" />
<link rel="stylesheet" href="/explore/chain/style.css" />
<link rel="stylesheet" href="/explore/block/style.css" />
<link rel="stylesheet" href="/learn/style.css" />
<link rel="stylesheet" href="/learn/charts/style.css" />
<link rel="stylesheet" href="/learn/charts/controls/style.css" />
<link rel="stylesheet" href="/learn/charts/legend/style.css" />
<link rel="stylesheet" href="/learn/charts/scrubber/style.css" />
<link rel="stylesheet" href="/learn/charts/area/style.css" />
<link rel="stylesheet" href="/learn/charts/bar/style.css" />
<link rel="stylesheet" href="/learn/charts/line/style.css" />
<link rel="stylesheet" href="/learn/charts/dots/style.css" />
<link rel="stylesheet" href="/learn/charts/stacked/style.css" />
<link rel="stylesheet" href="/chart/style.css" />
<link rel="stylesheet" href="/chart/marker/style.css" />
<link rel="stylesheet" href="/chart/controls/style.css" />
<link rel="stylesheet" href="/chart/legend/style.css" />
<link rel="stylesheet" href="/chart/scrubber/style.css" />
<link rel="stylesheet" href="/chart/area/style.css" />
<link rel="stylesheet" href="/chart/bar/style.css" />
<link rel="stylesheet" href="/chart/line/style.css" />
<link rel="stylesheet" href="/chart/dots/style.css" />
<link rel="stylesheet" href="/chart/stacked/style.css" />
<link rel="stylesheet" href="/chart/xy/style.css" />
<link rel="stylesheet" href="/learn/contents/style.css" />
<link rel="stylesheet" href="/build/style.css" />
<link rel="stylesheet" href="/wallets/style.css" />
-9
View File
@@ -1,9 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="area"] {
fill: var(--color, var(--orange));
fill-opacity: 0.5;
stroke: none;
}
}
}
-8
View File
@@ -1,8 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="bar"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
@@ -1,97 +0,0 @@
main.learn {
figure[data-chart="series"] {
> footer {
> div {
display: flex;
flex-wrap: wrap;
gap: 0.125rem 0.5rem;
}
fieldset {
display: flex;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
text-transform: uppercase;
legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
label {
position: relative;
display: block;
cursor: pointer;
}
input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
span {
display: block;
}
label:has(:checked) span {
color: var(--black);
background: var(--gray);
}
}
button[data-chart="fullscreen"] {
border: 0;
background: none;
font: inherit;
line-height: inherit;
text-transform: uppercase;
cursor: pointer;
&[aria-pressed="true"] {
color: var(--black);
background: var(--green);
}
}
:is(label > span, button[data-chart="fullscreen"]) {
padding: 0.25rem;
border-radius: 0.25rem;
color: var(--gray);
}
@media (hover: hover) and (pointer: fine) {
:is(label:hover span, button[data-chart="fullscreen"]:hover) {
color: var(--black);
background: var(--white);
}
}
:is(label:active span, button[data-chart="fullscreen"]:active) {
color: var(--black);
background: var(--orange);
}
:is(label[data-press] span, button[data-chart="fullscreen"][data-press]) {
color: var(--black);
background: var(--white);
}
:is(
label:has(:focus-visible) span,
button[data-chart="fullscreen"]:focus-visible
) {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
}
}
}
-8
View File
@@ -1,8 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="dots"] {
fill: var(--color, var(--orange));
stroke: none;
}
}
}
-153
View File
@@ -1,153 +0,0 @@
main.learn {
figure[data-chart="series"] {
figcaption {
text-transform: uppercase;
header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
h5 {
margin: 0;
font-family: var(--font-mono);
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
time {
color: var(--gray);
}
span:is([data-chart="unit"], [data-chart="separator"]) {
color: var(--gray);
}
menu {
--shadow-size: 1rem;
display: flex;
margin-inline: calc(-1 * var(--shadow-size));
padding: 0 var(--shadow-size);
padding-bottom: 1rem;
padding-top: 0.25rem;
overflow-x: auto;
list-style: none;
mask-image: linear-gradient(
to right,
transparent,
black var(--shadow-size),
black calc(100% - var(--shadow-size)),
transparent
);
}
li {
flex: 0 0 auto;
}
button {
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: none;
font: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
}
&[data-press] {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:is(:focus-visible, [data-active], [data-preview]) {
color: var(--black);
background: var(--color);
span,
output {
color: inherit;
}
}
&:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.125rem;
}
&[data-muted] {
opacity: 0.35;
}
> span {
display: block;
color: var(--color);
text-align: left;
&::before {
content: "";
display: inline-block;
width: 0.5em;
height: 0.5em;
margin-right: 0.35em;
margin-bottom: 0.1rem;
border-radius: 50%;
background: currentColor;
}
}
> output {
display: block;
margin-top: 0.25rem;
margin-left: auto;
width: 7ch;
min-height: 1em;
color: var(--white);
font-variant-numeric: tabular-nums;
text-align: right;
}
}
}
&:fullscreen {
figcaption {
h5 {
color: var(--white);
font-family: var(--font-serif);
font-size: 2rem;
text-transform: none;
}
menu {
padding-bottom: 0.5rem;
}
}
}
svg [data-series][data-muted] {
opacity: 0.2;
}
}
}
-48
View File
@@ -1,48 +0,0 @@
import { VIEWBOX_WIDTH } from "../viewbox.js";
import { createBounds, includeBoundValue, scaleY } from "../scale.js";
/** @param {LoadedSeries[]} series */
function createValueBounds(series) {
const bounds = createBounds();
for (const { entries } of series) {
for (const { value } of entries) {
includeBoundValue(bounds, value);
}
}
return bounds;
}
/**
* @param {ChartEntry[]} entries
* @param {ScaleBounds} bounds
* @param {number} height
* @param {ChartScale} scale
* @returns {ChartPoint[]}
*/
function createPoints(entries, bounds, height, scale) {
const xScale = VIEWBOX_WIDTH / (entries.length - 1);
return entries.map(({ date, value }, index) => ({
date,
value,
x: index * xScale,
y: scaleY(value, bounds, height, scale),
}));
}
/**
* @param {LoadedSeries[]} loadedSeries
* @param {number} height
* @param {ChartScale} scale
*/
export function createLineSeries(loadedSeries, height, scale) {
const bounds = createValueBounds(loadedSeries);
return loadedSeries.map(({ series, color, entries }) => ({
series,
color,
points: createPoints(entries, bounds, height, scale),
}));
}
-12
View File
@@ -1,12 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="line"] {
fill: none;
stroke: var(--color, var(--orange));
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
@@ -1,30 +0,0 @@
main.learn {
figure[data-chart="series"] {
[data-scrubber] {
opacity: 0;
pointer-events: none;
}
svg[data-scrubbing="true"] [data-scrubber] {
opacity: 1;
}
[data-scrubber="guide"] {
stroke: var(--white);
stroke-dasharray: 2 4;
vector-effect: non-scaling-stroke;
}
[data-scrubber="shade"] {
fill: var(--black);
fill-opacity: 0.5;
}
[data-scrubber="marker"] {
fill: var(--black);
stroke: var(--color, var(--orange));
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
@@ -1,11 +0,0 @@
main.learn {
figure[data-chart="series"] {
path[data-chart="stacked"] {
fill: var(--color, var(--orange));
stroke: var(--black);
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
}
}
-79
View File
@@ -1,79 +0,0 @@
main.learn {
figure[data-chart="series"] {
--chart-plot-height: 20rem;
--chart-reserved-ui-height: 6rem;
min-height: calc(
var(--chart-plot-height) + var(--chart-reserved-ui-height)
);
line-height: 1;
svg {
display: block;
width: 100%;
height: var(--chart-plot-height);
outline: 0;
cursor: crosshair;
overflow: visible;
touch-action: pan-y;
transition: opacity 150ms ease;
}
svg:focus-visible {
outline: 1px solid var(--orange);
outline-offset: 0.25rem;
}
svg[aria-busy="true"] {
opacity: 0.25;
}
> div[data-chart="plot"] {
position: relative;
}
p[role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
margin: 0;
color: var(--white);
text-transform: uppercase;
pointer-events: none;
}
p[role="status"]:empty {
display: none;
}
> footer {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.5rem 0 0;
}
&:fullscreen {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--black);
> div[data-chart="plot"] {
flex: 1;
min-height: 0;
display: flex;
}
svg {
flex: 1;
height: auto;
min-height: 0;
}
}
}
}
-11
View File
@@ -1,11 +0,0 @@
export const VIEWBOX_WIDTH = 640;
export const FALLBACK_VIEWBOX_HEIGHT = 220;
/** @param {SVGSVGElement} svg */
export function getViewBoxHeight(svg) {
const { width, height } = svg.getBoundingClientRect();
return width && height
? (VIEWBOX_WIDTH * height) / width
: FALLBACK_VIEWBOX_HEIGHT;
}
@@ -13,7 +13,7 @@ import {
stateSeries,
typeSeries,
} from "../address-count.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
const line = /** @type {const} */ ("line");
@@ -1,5 +1,5 @@
import { capitalizationSeries } from "../capitalization.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
import { marketCapSection } from "./capitalization/market.js";
import { realizedCapSection } from "./capitalization/realized.js";
@@ -9,7 +9,7 @@ import {
marketCapTypeSeries,
marketCapUtxoBalanceSeries,
} from "../../capitalization.js";
import { units } from "../../../charts/units.js";
import { units } from "../../../../chart/units.js";
export const marketCapSection = {
title: "Market Cap",
@@ -9,7 +9,7 @@ import {
realizedCapTypeSeries,
realizedCapUtxoBalanceSeries,
} from "../../capitalization.js";
import { units } from "../../../charts/units.js";
import { units } from "../../../../chart/units.js";
export const realizedCapSection = {
title: "Realized Cap",
@@ -10,7 +10,7 @@ import {
majorPoolRewardsSeries,
minorPools,
} from "../mining-pools.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
const line = /** @type {const} */ ("line");
+1 -1
View File
@@ -13,7 +13,7 @@ import {
circulatingSupplySeries,
supplyProfitabilitySeries,
} from "../supply.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
export const supplySection = {
title: "Supply",
+1 -1
View File
@@ -17,7 +17,7 @@ import {
totalSeries,
typeSeries,
} from "../utxo-set.js";
import { units } from "../../charts/units.js";
import { units } from "../../../chart/units.js";
const line = /** @type {const} */ ("line");
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContents } from "./contents/index.js";
import { sections } from "./data/index.js";
import { createChart } from "./charts/index.js";
import { createChart } from "../chart/index.js";
import { initSectionDetails } from "./details.js";
import { initHashLinks } from "./hash-links.js";
import { initScrollSpy } from "./scroll-spy.js";