website_next: snapshot

This commit is contained in:
nym21
2026-07-03 11:29:13 +02:00
parent 5bd1f0b625
commit e0a618837e
18 changed files with 1385 additions and 33 deletions
+2
View File
@@ -18,6 +18,8 @@ ALWAYS
- light (memory)
- KISS
- DRY
- lean
- YAGNI
- very well organized
- contained
- colocated
+63
View File
@@ -0,0 +1,63 @@
/**
* @param {number} [fill]
*/
export function createCubeButton(fill = 1) {
const element = document.createElement("button");
element.type = "button";
return { element, ...populateCube(element, fill) };
}
/**
* @param {number} [fill]
*/
export function createCubeDiv(fill = 1) {
const element = document.createElement("div");
return { element, ...populateCube(element, fill) };
}
/**
* @param {HTMLElement} element
* @param {number} fill
*/
function populateCube(element, fill) {
const topFace = createFace("face-text", "top");
const rightFace = createFace("face-text", "right");
const leftFace = createFace("face-text", "left");
element.classList.add("cube");
element.style.setProperty("--fill", String(fill));
element.append(
createFace("glass", "bottom"),
createFace("glass", "rear-right"),
createFace("glass", "rear-left"),
createFace("liquid", "bottom"),
createFace("liquid", "rear-right"),
createFace("liquid", "rear-left"),
createFace("liquid", "right"),
createFace("liquid", "left"),
createFace("liquid", "top"),
createFace("glass", "right"),
createFace("glass", "left"),
createFace("glass", "top"),
rightFace,
leftFace,
topFace,
);
return { topFace, rightFace, leftFace };
}
/**
* @param {string} role
* @param {string} position
*/
function createFace(role, position) {
const element = document.createElement("div");
element.className = `face ${role} ${position}`;
return element;
}
+247
View File
@@ -0,0 +1,247 @@
#chain {
--cube-size: 4.5rem;
--iso-scale: calc(sqrt(3) / 2);
--cube-empty-alpha: 0.4;
--face-step: 0.033;
.cube {
--cube-width: calc(var(--cube-size) * 2 * var(--iso-scale));
--cube-height: calc(var(--cube-size) * 2);
--cube-face-base: var(--border-color);
--face-top: light-dark(
var(--cube-face-base),
oklch(from var(--cube-face-base) calc(l + var(--face-step) * 2) c h)
);
--face-right: light-dark(
oklch(from var(--cube-face-base) calc(l - var(--face-step) * 2) c h),
var(--cube-face-base)
);
--face-left: light-dark(
oklch(from var(--cube-face-base) calc(l - var(--face-step)) c h),
oklch(from var(--cube-face-base) calc(l + var(--face-step)) c h)
);
--face-bottom: oklch(
from var(--cube-face-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);
display: block;
position: relative;
flex-shrink: 0;
width: var(--cube-width);
height: var(--cube-height);
border-radius: 0;
padding: 0;
overflow: visible;
color: var(--color);
background: transparent;
text-transform: none;
-webkit-user-select: none;
user-select: none;
pointer-events: none;
@media (hover: hover) and (pointer: fine) {
&: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
);
}
}
&:is(button):active,
&.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
);
}
&.projected {
animation: projected-cube-pulse 4s ease-in-out infinite;
}
&.skeleton .face-text {
visibility: hidden;
}
.face {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: var(--cube-size);
height: var(--cube-size);
transform-origin: 0 0;
transform: translateY(50%) var(--face-orient)
translate(
calc(var(--cube-size) * var(--face-x)),
calc(var(--cube-size) * var(--face-y))
)
scale(var(--face-scale-x, 1), var(--face-scale-y));
pointer-events: auto;
}
.liquid {
--face-scale-y: calc(var(--iso-scale) * var(--fill));
--face-stack-shift: calc(var(--iso-scale) * (1 - var(--fill)));
background: var(--face-color);
opacity: calc(1 - var(--is-empty));
}
.glass {
--face-scale-y: calc(var(--iso-scale) * (1 - var(--fill)));
--face-stack-shift: 0;
background: oklch(from var(--face-color) l c h / var(--cube-empty-alpha));
}
.face-text {
--face-scale-y: var(--iso-scale);
--face-stack-shift: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.1rem;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 450;
line-height: var(--line-height-base);
text-align: center;
pointer-events: none;
&.top {
justify-content: center;
text-transform: uppercase;
}
&.right {
justify-content: space-between;
}
&.left {
justify-content: center;
}
p {
margin: 0;
}
}
.top,
.bottom {
--face-orient: rotate(30deg) skewX(-30deg);
--face-scale-y: var(--iso-scale);
}
.right,
.rear-left {
--face-orient: rotate(-30deg) skewX(-30deg);
}
.left,
.rear-right {
--face-orient: rotate(30deg) skewX(30deg);
}
.top,
.rear-right {
--face-y: calc(var(--face-stack-shift) - var(--iso-scale));
}
.left,
.rear-left {
--face-y: var(--face-stack-shift);
}
.right {
--face-y: calc(var(--face-stack-shift) + var(--iso-scale));
}
.bottom {
--face-y: 0;
}
.top {
--face-color: var(--face-top);
--face-x: 0;
}
.bottom {
--face-color: var(--face-bottom);
--face-x: 1;
}
.right {
--face-color: var(--face-right);
--face-x: 1;
}
.left {
--face-color: var(--face-left);
--face-x: 0;
}
.rear-right {
--face-color: var(--face-left);
--face-x: 1;
}
.rear-left {
--face-color: var(--face-top);
--face-scale-x: -1;
--face-x: 1;
}
.liquid.top {
--face-x: calc(1 - var(--fill));
}
}
}
@keyframes projected-cube-pulse {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(2.5);
}
}
+704
View File
@@ -0,0 +1,704 @@
import { brk } from "../../utils/client.js";
import { isPlainLeftClick } from "../../utils/event.js";
import { createCubeButton, createCubeDiv } from "./cube/index.js";
const LOOKAHEAD = 15;
const POLL_INTERVAL = 1_000;
const PROJECTED_LIMIT = 8;
const TARGET_BLOCK_SECONDS = 600;
const TIP_BLOCK_THRESHOLD = 10;
const MONTHS = /** @type {const} */ ([
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]);
/** @typedef {Awaited<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
/** @typedef {Awaited<ReturnType<typeof brk.getMempoolBlocks>>[number]} MempoolBlock */
/** @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} height */
function createHeightElement(height) {
const container = document.createElement("span");
const prefix = document.createElement("span");
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);
return container;
}
/** @param {HTMLElement} element @param {() => void} handler */
function onPlainClick(element, handler) {
element.addEventListener("click", (event) => {
if (!(event instanceof MouseEvent) || !isPlainLeftClick(event)) return;
event.preventDefault();
handler();
});
}
/** @param {string} text @param {string} [className] */
function span(text, className) {
const element = document.createElement("span");
if (className) element.classList.add(className);
element.textContent = text;
return element;
}
/** @param {string} name */
function poolSlug(name) {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
/** @param {number} unixSeconds */
function formatShortDate(unixSeconds) {
const date = new Date(unixSeconds * 1_000);
return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
}
/** @param {number} unixSeconds */
function formatHHMM(unixSeconds) {
const date = new Date(unixSeconds * 1_000);
return [
String(date.getHours()).padStart(2, "0"),
String(date.getMinutes()).padStart(2, "0"),
];
}
/**
* @param {"tip"} className
* @param {string} label
* @param {string} mobileLabel
* @param {string} title
* @param {() => void} handler
*/
function createEdgeButton(className, label, mobileLabel, title, handler) {
const button = document.createElement("button");
button.classList.add("edge", className);
button.type = "button";
button.title = title;
button.ariaLabel = title;
button.dataset.mobileLabel = mobileLabel;
button.textContent = label;
onPlainClick(button, handler);
return button;
}
export function createChain() {
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);
});
element.id = "chain";
tipButton.hidden = true;
scrollElement.classList.add("scroll");
blocksElement.classList.add("blocks");
scrollElement.append(blocksElement);
element.append(tipButton, scrollElement);
/** @type {HTMLButtonElement | null} */
let selectedCube = null;
/** @type {IntersectionObserver | undefined} */
let olderEdgeObserver;
/** @type {Map<string, Block>} */
const blocksByHash = new Map();
/** @type {ReturnType<typeof createProjectedCube>[]} */
const projectedCubes = [];
let active = false;
let newestHeight = -1;
let oldestHeight = Infinity;
let newestTimestamp = 0;
let loadingOlder = false;
let loadingNewer = false;
let polling = false;
let reachedTip = false;
/** @type {number | undefined} */
let pollId;
let tipSyncFrame = 0;
/** @type {AbortController} */
let controller = new AbortController();
/** @param {string | number | null | undefined} hashOrHeight */
function findCube(hashOrHeight) {
if (hashOrHeight == null) {
return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null;
}
const attribute = typeof hashOrHeight === "number" ? "height" : "hash";
return /** @type {HTMLButtonElement | null} */ (
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
);
}
function firstProjectedCube() {
return projectedCubes[0]?.element ?? null;
}
function newestConfirmedCube() {
const firstProjected = firstProjectedCube();
return /** @type {HTMLButtonElement | null} */ (
firstProjected
? firstProjected.previousElementSibling
: blocksElement.lastElementChild
);
}
function deselectCube() {
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = null;
}
/** @param {HTMLButtonElement} cube @param {{ scroll?: "smooth" | "instant" }} [options] */
function selectCube(cube, { scroll } = {}) {
if (cube !== selectedCube) {
deselectCube();
selectedCube = cube;
cube.classList.add("selected");
}
if (scroll) {
cube.scrollIntoView({
behavior: scroll,
block: "center",
inline: "center",
});
scheduleTipVisibilitySync();
}
}
function clear() {
newestHeight = -1;
oldestHeight = Infinity;
newestTimestamp = 0;
loadingOlder = false;
loadingNewer = false;
reachedTip = false;
selectedCube = null;
blocksByHash.clear();
blocksElement.textContent = "";
projectedCubes.length = 0;
tipButton.hidden = true;
olderEdgeObserver?.disconnect();
}
function observeOldestEdge() {
olderEdgeObserver?.disconnect();
const oldest = blocksElement.firstElementChild;
if (oldest) olderEdgeObserver?.observe(oldest);
}
/** @param {Block[]} blocks */
function appendNewerBlocks(blocks) {
if (!blocks.length) return false;
const anchor = newestConfirmedCube();
const anchorRect = anchor?.getBoundingClientRect();
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block.height > newestHeight) {
appendConfirmed(createConfirmedCube(block));
} else {
blocksByHash.set(block.id, block);
}
}
newestHeight = Math.max(newestHeight, blocks[0].height);
newestTimestamp = blocks[0].timestamp;
refreshProjected();
if (anchor && anchorRect) {
const rect = anchor.getBoundingClientRect();
scrollElement.scrollTop += rect.top - anchorRect.top;
scrollElement.scrollLeft += rect.left - anchorRect.left;
}
syncTipVisibility();
return true;
}
/** @param {number | null} [height] */
async function loadInitial(height) {
const blocks =
height != null
? await brk.getBlocksV1FromHeight(height, { signal: controller.signal })
: await brk.getBlocksV1({ signal: controller.signal });
clear();
for (const block of blocks) prependConfirmed(createConfirmedCube(block));
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
newestTimestamp = blocks[0].timestamp;
reachedTip = height == null;
observeOldestEdge();
if (reachedTip) await pollProjected();
else await loadNewer();
return blocks[0].id;
}
/** @param {string | number | null | undefined} hashOrHeight */
async function resolveHeight(hashOrHeight) {
if (typeof hashOrHeight === "number") return hashOrHeight;
if (typeof hashOrHeight === "string") {
const cached = blocksByHash.get(hashOrHeight);
if (cached) return cached.height;
const block = await brk.getBlockV1(hashOrHeight, {
signal: controller.signal,
});
blocksByHash.set(hashOrHeight, block);
return block.height;
}
return null;
}
/** @param {string | number | null | undefined} [hashOrHeight] */
async function goToCube(hashOrHeight) {
if (!active) return;
if (hashOrHeight === "tip") hashOrHeight = null;
if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) {
hashOrHeight = Number(hashOrHeight);
}
const existing = findCube(hashOrHeight);
if (existing) {
selectCube(existing, { scroll: "smooth" });
return;
}
for (const cube of blocksElement.children) {
if (!cube.classList.contains("projected")) cube.classList.add("skeleton");
}
element.classList.add("loading");
try {
const height = await resolveHeight(hashOrHeight);
const startHash = await loadInitial(height);
const cube = findCube(startHash);
if (cube) selectCube(cube, { scroll: "instant" });
} catch (error) {
if (!controller.signal.aborted) {
console.error("explore chain load:", error);
}
} finally {
element.classList.remove("loading");
}
}
async function pollProjected() {
try {
renderProjected(
await brk.getMempoolBlocks({ signal: controller.signal }),
);
} catch (error) {
if (!controller.signal.aborted) console.error("explore mempool:", error);
}
}
async function poll() {
if (!active || !reachedTip || polling) return;
polling = true;
await pollProjected();
try {
appendNewerBlocks(await brk.getBlocksV1({ signal: controller.signal }));
} catch (error) {
if (!controller.signal.aborted) console.error("explore chain poll:", error);
} finally {
polling = false;
}
}
async function loadOlder() {
if (!active || loadingOlder || oldestHeight <= 0) return;
loadingOlder = true;
try {
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1, {
signal: controller.signal,
});
for (const block of blocks) prependConfirmed(createConfirmedCube(block));
if (blocks.length) {
oldestHeight = blocks[blocks.length - 1].height;
observeOldestEdge();
}
} catch (error) {
if (!controller.signal.aborted) console.error("explore older:", error);
} finally {
loadingOlder = false;
}
}
async function loadNewer() {
if (!active || loadingNewer || newestHeight === -1 || reachedTip) return;
loadingNewer = true;
try {
const prevNewest = newestHeight;
const blocks = await brk.getBlocksV1FromHeight(
newestHeight + LOOKAHEAD,
{ signal: controller.signal },
);
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) {
reachedTip = true;
await pollProjected();
}
} catch (error) {
if (!controller.signal.aborted) console.error("explore newer:", error);
} finally {
loadingNewer = false;
}
}
/** @param {Block} block */
function createConfirmedCube(block) {
const { pool, medianFee, feeRange, virtualSize } = block.extras;
const cube = createCubeButton(Math.min(1, virtualSize / 1_000_000));
cube.element.dataset.hash = block.id;
cube.element.dataset.height = String(block.height);
cube.element.dataset.timestamp = String(block.timestamp);
cube.element.title = `Block ${block.height.toLocaleString()}`;
blocksByHash.set(block.id, block);
onPlainClick(cube.element, () => selectCube(cube.element));
const date = document.createElement("p");
const time = document.createElement("p");
const [hh, mm] = formatHHMM(block.timestamp);
date.textContent = formatShortDate(block.timestamp);
time.append(hh, span(":", "dim"), mm);
cube.topFace.append(date, time);
const height = document.createElement("p");
height.classList.add("height");
height.append(createHeightElement(block.height));
const poolElement = document.createElement("div");
const logo = document.createElement("img");
const name = document.createElement("span");
poolElement.classList.add("pool");
logo.src = `/assets/pools/${poolSlug(pool.name)}.svg`;
logo.alt = "";
logo.onerror = () => {
logo.onerror = null;
logo.src = "/assets/pools/default.svg";
};
name.textContent = pool.name.replace(/\s+(Pool|USA)$/i, "").trim();
poolElement.append(logo, name);
cube.rightFace.append(height, poolElement);
const fees = document.createElement("div");
const median = document.createElement("p");
const range = document.createElement("p");
const unit = document.createElement("p");
fees.classList.add("fees");
median.append(span("~", "dim"), formatFeeRate(medianFee));
range.append(
formatFeeRate(feeRange[0]),
span("-", "dim"),
formatFeeRate(feeRange[6]),
);
unit.classList.add("dim");
unit.textContent = "sat/vB";
fees.append(median, range, unit);
cube.leftFace.append(fees);
return cube.element;
}
/** @param {HTMLElement} cube */
function setConfirmedInterval(cube) {
const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling);
if (!prev) return;
cube.style.setProperty(
"--block-interval",
String(
Math.max(
0,
Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp),
),
),
);
}
/** @param {HTMLButtonElement} cube */
function prependConfirmed(cube) {
const oldFirst = /** @type {HTMLElement | null} */ (
blocksElement.firstElementChild
);
blocksElement.insertBefore(cube, oldFirst);
if (oldFirst) setConfirmedInterval(oldFirst);
}
/** @param {HTMLButtonElement} cube */
function appendConfirmed(cube) {
blocksElement.insertBefore(cube, firstProjectedCube());
setConfirmedInterval(cube);
}
/** @param {MempoolBlock[]} blocks */
function renderProjected(blocks) {
const want = Math.min(blocks.length, PROJECTED_LIMIT);
while (projectedCubes.length > want) {
projectedCubes.pop()?.element.remove();
}
while (projectedCubes.length < want) {
const cube = createProjectedCube();
projectedCubes.push(cube);
blocksElement.append(cube.element);
}
for (let i = 0; i < want; i++) {
updateProjectedCube(projectedCubes[i], blocks[i]);
}
refreshProjected();
}
function createProjectedCube() {
const cube = createCubeDiv();
const date = document.createTextNode("");
const hh = document.createTextNode("");
const mm = document.createTextNode("");
const txs = document.createTextNode("");
const txsUnit = document.createTextNode("");
const median = document.createTextNode("");
const rangeLo = document.createTextNode("");
const rangeHi = document.createTextNode("");
const dateElement = document.createElement("p");
const timeElement = document.createElement("p");
const txsElement = document.createElement("p");
const txsUnitElement = document.createElement("p");
const medianElement = document.createElement("p");
const rangeElement = document.createElement("p");
const unitElement = document.createElement("p");
cube.element.classList.add("projected");
dateElement.append(date);
timeElement.append(hh, span(":", "dim"), mm);
cube.topFace.append(dateElement, timeElement);
txsElement.append(txs);
txsUnitElement.classList.add("dim");
txsUnitElement.append(txsUnit);
cube.rightFace.append(txsElement, txsUnitElement);
medianElement.append(span("~", "dim"), median);
rangeElement.append(rangeLo, span("-", "dim"), rangeHi);
unitElement.classList.add("dim");
unitElement.textContent = "sat/vB";
cube.leftFace.append(medianElement, rangeElement, unitElement);
return {
...cube,
parts: { date, hh, mm, txs, txsUnit, median, rangeLo, rangeHi },
};
}
/** @param {ReturnType<typeof createProjectedCube>} cube @param {MempoolBlock} block */
function updateProjectedCube(cube, block) {
cube.element.style.setProperty(
"--fill",
String(Math.min(1, block.blockVSize / 1_000_000)),
);
cube.parts.txs.nodeValue = block.nTx.toLocaleString();
cube.parts.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs";
cube.parts.median.nodeValue = formatFeeRate(block.medianFee);
cube.parts.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]);
cube.parts.rangeHi.nodeValue = formatFeeRate(block.feeRange[6]);
}
function refreshProjected() {
if (!projectedCubes.length || !newestTimestamp) return;
const now = Math.floor(Date.now() / 1_000);
const elapsed = Math.max(0, now - newestTimestamp);
for (let i = 0; i < projectedCubes.length; i++) {
const cube = projectedCubes[i];
const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
const timestamp = now + i * TARGET_BLOCK_SECONDS;
const [hh, mm] = formatHHMM(timestamp);
cube.element.style.setProperty("--block-interval", String(interval));
cube.parts.date.nodeValue = formatShortDate(timestamp);
cube.parts.hh.nodeValue = hh;
cube.parts.mm.nodeValue = mm;
}
}
function scheduleTipVisibilitySync() {
if (tipSyncFrame) return;
tipSyncFrame = window.requestAnimationFrame(() => {
tipSyncFrame = 0;
syncTipVisibility();
});
}
function syncTipVisibility() {
if (!reachedTip || newestHeight < 0) {
tipButton.hidden = true;
return;
}
const visibleHeight = findVisibleConfirmedHeight();
tipButton.hidden =
visibleHeight == null ||
newestHeight - visibleHeight <= TIP_BLOCK_THRESHOLD;
}
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;
let closestHeight = null;
let closestDistance = Infinity;
for (const element of blocksElement.children) {
if (
!(element instanceof HTMLElement) ||
element.classList.contains("projected")
) {
continue;
}
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;
}
olderEdgeObserver = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) void loadOlder();
},
{ root: scrollElement },
);
scrollElement.addEventListener(
"scroll",
() => {
scheduleTipVisibilitySync();
if (reachedTip || loadingNewer) return;
if (scrollElement.scrollTop <= 50 && scrollElement.scrollLeft <= 50) {
void loadNewer();
}
},
{ passive: true },
);
/** @param {boolean} nextActive */
function setActive(nextActive) {
if (active === nextActive) return;
active = nextActive;
if (active) {
controller = new AbortController();
if (newestHeight === -1) void goToCube(null);
else void poll();
pollId = window.setInterval(() => void poll(), POLL_INTERVAL);
return;
}
if (pollId !== undefined) {
window.clearInterval(pollId);
pollId = undefined;
}
if (tipSyncFrame) {
window.cancelAnimationFrame(tipSyncFrame);
tipSyncFrame = 0;
}
controller.abort();
}
return /** @type {const} */ ({
element,
setActive,
});
}
+177
View File
@@ -0,0 +1,177 @@
#chain {
--background-color: var(--black);
--color: var(--white);
--border-color: oklch(20% 0 0);
--inv-border-color: oklch(90% 0 0);
--min-gap: calc(var(--cube-size) * -1);
--max-gap: calc(var(--cube-size) * 6);
--min-block-interval: 0;
--max-block-interval: 10800;
--main-padding: var(--page-x);
position: relative;
display: grid;
flex: 1;
min-width: 0;
height: 100%;
min-height: 0;
overflow: hidden;
opacity: 1;
transition: opacity 200ms ease;
&.loading {
opacity: 0;
}
.dim {
opacity: 0.5;
}
.scroll {
height: 100%;
padding: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.blocks {
display: flex;
flex-direction: column-reverse;
min-height: 100%;
}
.cube {
--cube-fall-off: pow(
clamp(
0,
(var(--block-interval, 600) - var(--min-block-interval)) /
(var(--max-block-interval) - var(--min-block-interval)),
1
),
0.7
);
--block-gap: calc(
var(--min-gap) + var(--cube-fall-off) *
(var(--max-gap) - var(--min-gap))
);
& + & {
margin-bottom: var(--block-gap);
}
.face-text .height {
font-size: var(--font-size-sm);
font-weight: normal;
}
.face-text .fees {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.face-text .pool {
display: flex;
align-items: center;
justify-content: center;
gap: 0.1em;
width: 100%;
img {
width: 1.25em;
height: 1.25em;
flex-shrink: 0;
}
span {
min-width: 0;
overflow: hidden;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.edge {
position: absolute;
top: var(--main-padding);
left: calc(var(--main-padding) / 2);
z-index: 1;
width: auto;
height: auto;
border-radius: 999rem;
padding: 0.375rem 0.625rem;
color: var(--black);
background: var(--white);
font-size: var(--font-size-xs);
font-weight: 500;
line-height: 1;
letter-spacing: 0;
@media (hover: hover) and (pointer: fine) {
&:hover {
background: var(--gray);
}
}
&:active {
background: var(--orange);
}
&[data-press] {
background: var(--gray);
}
}
}
@media (max-width: 48rem) {
#chain {
--min-gap: 0rem;
grid-template-columns: minmax(0, 1fr);
height: 14rem;
min-height: 14rem;
.scroll {
display: grid;
align-items: center;
grid-column: 1;
grid-row: 1;
padding: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
touch-action: pan-x;
}
.blocks {
flex-direction: row-reverse;
align-items: center;
width: max-content;
min-height: 0;
}
.cube {
& + & {
margin-right: var(--block-gap);
margin-bottom: 0;
}
}
.edge {
top: 50%;
left: 0.75rem;
translate: 0 -50%;
font-size: 0;
&::before {
content: attr(data-mobile-label);
font-size: var(--font-size-xs);
}
}
}
}
+15 -3
View File
@@ -1,8 +1,20 @@
import { createChain } from "./chain/index.js";
export function createExplorePage() {
const main = document.createElement("main");
const chain = createChain();
main.className = "explore";
const title = document.createElement("h1");
title.append("Explore");
main.append(title);
main.append(chain.element);
const syncChain = () => chain.setActive(!main.hidden && !document.hidden);
main.addEventListener("pageactive", syncChain);
document.addEventListener("visibilitychange", syncChain);
new MutationObserver(syncChain).observe(main, {
attributes: true,
attributeFilter: ["hidden"],
});
return main;
}
+4 -3
View File
@@ -1,5 +1,6 @@
main.explore {
display: grid;
place-items: center;
font-size: 4rem;
display: flex;
height: 100dvh;
overflow: hidden;
padding: 0;
}
+9 -2
View File
@@ -19,8 +19,10 @@ body {
text-decoration: none;
text-transform: lowercase;
&:hover {
opacity: 1;
@media (hover: hover) and (pointer: fine) {
&:hover {
opacity: 1;
}
}
&:active {
@@ -28,6 +30,11 @@ body {
--color: var(--orange);
}
&[data-press] {
opacity: 1;
--color: var(--white);
}
> span {
display: inline-grid;
padding: 0.2rem 0.3rem;
+2
View File
@@ -103,6 +103,8 @@
<link rel="stylesheet" href="/home/style.css" />
<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="/learn/style.css" />
<link rel="stylesheet" href="/learn/charts/style.css" />
<link rel="stylesheet" href="/learn/charts/controls/style.css" />
+11 -4
View File
@@ -42,7 +42,7 @@ main.learn {
display: block;
}
label:has(:checked):not(:hover) span {
label:has(:checked) span {
color: var(--black);
background: var(--gray);
}
@@ -68,9 +68,11 @@ main.learn {
color: var(--gray);
}
:is(label:hover span, button[data-chart="fullscreen"]:hover) {
color: var(--black);
background: var(--white);
@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) {
@@ -78,6 +80,11 @@ main.learn {
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
+23 -1
View File
@@ -60,7 +60,29 @@ main.learn {
text-transform: inherit;
cursor: pointer;
&:is(:hover, :focus-visible, [data-active], [data-preview]) {
@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);
+12 -3
View File
@@ -49,6 +49,8 @@ main.learn {
margin-inline: 0 1rem;
padding: 0.25rem;
padding-inline-start: calc(var(--line-width) + var(--line-gap));
-webkit-user-select: none;
user-select: none;
&::after {
content: "";
@@ -66,15 +68,22 @@ main.learn {
color: var(--white);
}
&:hover {
color: var(--black);
background-color: var(--white);
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background-color: var(--white);
}
}
&:active {
color: var(--black);
background-color: var(--orange);
}
&[data-press] {
color: var(--black);
background-color: var(--white);
}
}
ol ol {
+20 -7
View File
@@ -115,19 +115,32 @@ main.learn {
text-decoration: none;
}
&:hover::before {
opacity: 0.5;
}
@media (hover: hover) and (pointer: fine) {
&:hover::before {
opacity: 0.5;
}
&:hover {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.125em;
&:hover {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.125em;
}
}
&:active {
color: var(--orange);
}
&[data-press]::before {
opacity: 0.5;
}
&[data-press] {
color: var(--white);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.125em;
}
}
}
}
+1
View File
@@ -1,5 +1,6 @@
import { createHeader } from "./header/index.js";
import { createRoutePage, normalizePath, resolvePath } from "./routes.js";
import "./utils/press.js";
import { getEventAnchor, isPlainLeftClick } from "./utils/event.js";
import { revealPage, transitionPage } from "./utils/transition.js";
+31 -5
View File
@@ -12,6 +12,17 @@ body > main {
color: var(--white);
}
a,
button,
input,
label,
select,
summary,
textarea {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
button,
input,
select,
@@ -41,16 +52,24 @@ button,
background: var(--button-background);
text-decoration: none;
text-transform: uppercase;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
&:hover {
background: var(--button-hover-background);
@media (hover: hover) and (pointer: fine) {
&:hover {
background: var(--button-hover-background);
}
}
&:active {
background: var(--button-active-background);
}
&[data-press] {
background: var(--button-hover-background);
}
&:disabled {
opacity: 0.5;
cursor: progress;
@@ -64,9 +83,11 @@ textarea {
color: var(--field-color);
background: transparent;
&:hover {
border-color: var(--field-color);
color: var(--field-color);
@media (hover: hover) and (pointer: fine) {
&:hover {
border-color: var(--field-color);
color: var(--field-color);
}
}
&:active {
@@ -74,6 +95,11 @@ textarea {
color: var(--field-active-color);
}
&[data-press] {
border-color: var(--field-color);
color: var(--field-color);
}
&[aria-invalid="true"] {
border-color: var(--red);
color: var(--red);
+34
View File
@@ -0,0 +1,34 @@
const PRESS_SELECTOR = [
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"label",
"select:not(:disabled)",
"summary",
"textarea:not(:disabled)",
].join(",");
/** @type {Element | null} */
let pressedElement = null;
function clearPress() {
pressedElement?.removeAttribute("data-press");
pressedElement = null;
}
document.addEventListener(
"pointerdown",
(event) => {
if (event.pointerType === "mouse") return;
if (!(event.target instanceof Element)) return;
clearPress();
pressedElement = event.target.closest(PRESS_SELECTOR);
pressedElement?.setAttribute("data-press", "");
},
{ passive: true },
);
document.addEventListener("pointerup", clearPress, { passive: true });
document.addEventListener("pointercancel", clearPress, { passive: true });
window.addEventListener("blur", clearPress);
+8 -1
View File
@@ -39,7 +39,14 @@ main.wallets {
color: inherit;
}
&:is(:hover, :focus-visible, :active):not(.holding) {
@media (hover: hover) and (pointer: fine) {
&:hover:not(.holding) {
color: var(--red);
background: transparent;
}
}
&:is(:focus-visible, :active, [data-press]):not(.holding) {
color: var(--red);
background: transparent;
}
+22 -4
View File
@@ -25,9 +25,11 @@ main.wallets {
font-size: var(--font-size-base);
}
&:hover {
color: var(--black);
background: var(--white);
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--black);
background: var(--white);
}
}
&:active {
@@ -35,6 +37,11 @@ main.wallets {
background: var(--orange);
}
&[data-press] {
color: var(--black);
background: var(--white);
}
&[aria-pressed="true"] {
color: var(--black);
background: var(--white);
@@ -44,11 +51,22 @@ main.wallets {
color: color-mix(in oklch, var(--gray) 76%, transparent);
background: transparent;
&:hover,
@media (hover: hover) and (pointer: fine) {
&:hover {
color: var(--red);
background: transparent;
}
}
&:active {
color: var(--red);
background: transparent;
}
&[data-press] {
color: var(--red);
background: transparent;
}
}
}
}