mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-16 21:48:10 -07:00
website_next: snapshot
This commit is contained in:
@@ -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
|
||||
*/
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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% {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user