diff --git a/website_next/learn/charts/area/index.js b/website_next/chart/area/index.js similarity index 75% rename from website_next/learn/charts/area/index.js rename to website_next/chart/area/index.js index ab1ab3cad..43807e9f8 100644 --- a/website_next/learn/charts/area/index.js +++ b/website_next/chart/area/index.js @@ -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({ diff --git a/website_next/chart/area/style.css b/website_next/chart/area/style.css new file mode 100644 index 000000000..14df61226 --- /dev/null +++ b/website_next/chart/area/style.css @@ -0,0 +1,7 @@ +figure[data-chart="series"] { + path[data-chart="area"] { + fill: var(--color, var(--orange)); + fill-opacity: 0.5; + stroke: none; + } +} diff --git a/website_next/learn/charts/bar/index.js b/website_next/chart/bar/index.js similarity index 98% rename from website_next/learn/charts/bar/index.js rename to website_next/chart/bar/index.js index b836d7c7c..daa158ea6 100644 --- a/website_next/learn/charts/bar/index.js +++ b/website_next/chart/bar/index.js @@ -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, ); diff --git a/website_next/chart/bar/style.css b/website_next/chart/bar/style.css new file mode 100644 index 000000000..0b8243095 --- /dev/null +++ b/website_next/chart/bar/style.css @@ -0,0 +1,6 @@ +figure[data-chart="series"] { + path[data-chart="bar"] { + fill: var(--color, var(--orange)); + stroke: none; + } +} diff --git a/website_next/chart/constants.js b/website_next/chart/constants.js new file mode 100644 index 000000000..f99289182 --- /dev/null +++ b/website_next/chart/constants.js @@ -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, +}); diff --git a/website_next/chart/controls/style.css b/website_next/chart/controls/style.css new file mode 100644 index 000000000..950b8b162 --- /dev/null +++ b/website_next/chart/controls/style.css @@ -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; + } + } +} diff --git a/website_next/learn/charts/dots/index.js b/website_next/chart/dots/index.js similarity index 92% rename from website_next/learn/charts/dots/index.js rename to website_next/chart/dots/index.js index 7d92e39e3..ae1ecfcb0 100644 --- a/website_next/learn/charts/dots/index.js +++ b/website_next/chart/dots/index.js @@ -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) { diff --git a/website_next/chart/dots/style.css b/website_next/chart/dots/style.css new file mode 100644 index 000000000..be784efe0 --- /dev/null +++ b/website_next/chart/dots/style.css @@ -0,0 +1,6 @@ +figure[data-chart="series"] { + path[data-chart="dots"] { + fill: var(--color, var(--orange)); + stroke: none; + } +} diff --git a/website_next/learn/charts/format.js b/website_next/chart/format.js similarity index 100% rename from website_next/learn/charts/format.js rename to website_next/chart/format.js diff --git a/website_next/learn/charts/fullscreen.js b/website_next/chart/fullscreen.js similarity index 100% rename from website_next/learn/charts/fullscreen.js rename to website_next/chart/fullscreen.js diff --git a/website_next/learn/charts/highlight.js b/website_next/chart/highlight.js similarity index 74% rename from website_next/learn/charts/highlight.js rename to website_next/chart/highlight.js index a57b20de1..0889f1edb 100644 --- a/website_next/learn/charts/highlight.js +++ b/website_next/chart/highlight.js @@ -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); } diff --git a/website_next/learn/charts/index.js b/website_next/chart/index.js similarity index 99% rename from website_next/learn/charts/index.js rename to website_next/chart/index.js index 34943b56c..419d3317c 100644 --- a/website_next/learn/charts/index.js +++ b/website_next/chart/index.js @@ -35,6 +35,7 @@ export function createChart(chart, chartKey) { let renderer; figure.dataset.chart = "series"; + figure.dataset.chartLegend = ""; function mount() { if (renderer) return renderer; diff --git a/website_next/learn/charts/intersection.js b/website_next/chart/intersection.js similarity index 100% rename from website_next/learn/charts/intersection.js rename to website_next/chart/intersection.js diff --git a/website_next/learn/charts/legend/index.js b/website_next/chart/legend/index.js similarity index 72% rename from website_next/learn/charts/legend/index.js rename to website_next/chart/legend/index.js index a6888df9d..65aa408f5 100644 --- a/website_next/learn/charts/legend/index.js +++ b/website_next/chart/legend/index.js @@ -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 + */ diff --git a/website_next/chart/legend/style.css b/website_next/chart/legend/style.css new file mode 100644 index 000000000..fdfa56a82 --- /dev/null +++ b/website_next/chart/legend/style.css @@ -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; + } +} diff --git a/website_next/learn/charts/line/index.js b/website_next/chart/line/index.js similarity index 89% rename from website_next/learn/charts/line/index.js rename to website_next/chart/line/index.js index dc7409ae5..890317a30 100644 --- a/website_next/learn/charts/line/index.js +++ b/website_next/chart/line/index.js @@ -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) { diff --git a/website_next/chart/line/series.js b/website_next/chart/line/series.js new file mode 100644 index 000000000..5c2ee20b1 --- /dev/null +++ b/website_next/chart/line/series.js @@ -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) + ), + }; + }); +} diff --git a/website_next/chart/line/style.css b/website_next/chart/line/style.css new file mode 100644 index 000000000..2ad842fb1 --- /dev/null +++ b/website_next/chart/line/style.css @@ -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; + } +} diff --git a/website_next/learn/charts/loader.js b/website_next/chart/loader.js similarity index 96% rename from website_next/learn/charts/loader.js rename to website_next/chart/loader.js index 9241c4db9..d231d11ab 100644 --- a/website_next/learn/charts/loader.js +++ b/website_next/chart/loader.js @@ -1,4 +1,4 @@ -import { brk } from "../../utils/client.js"; +import { brk } from "../utils/client.js"; import { fetchTimeframe } from "./timeframes.js"; /** diff --git a/website_next/chart/marker.js b/website_next/chart/marker.js new file mode 100644 index 000000000..d4d866e16 --- /dev/null +++ b/website_next/chart/marker.js @@ -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; +} diff --git a/website_next/chart/marker/style.css b/website_next/chart/marker/style.css new file mode 100644 index 000000000..6e29ce62e --- /dev/null +++ b/website_next/chart/marker/style.css @@ -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; +} diff --git a/website_next/learn/charts/math.js b/website_next/chart/math.js similarity index 100% rename from website_next/learn/charts/math.js rename to website_next/chart/math.js diff --git a/website_next/learn/charts/order.js b/website_next/chart/order.js similarity index 100% rename from website_next/learn/charts/order.js rename to website_next/chart/order.js diff --git a/website_next/learn/charts/path.js b/website_next/chart/path.js similarity index 94% rename from website_next/learn/charts/path.js rename to website_next/chart/path.js index f225e0e8e..205a7af91 100644 --- a/website_next/learn/charts/path.js +++ b/website_next/chart/path.js @@ -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)) diff --git a/website_next/learn/charts/plot.js b/website_next/chart/plot.js similarity index 100% rename from website_next/learn/charts/plot.js rename to website_next/chart/plot.js diff --git a/website_next/learn/charts/radio.js b/website_next/chart/radio.js similarity index 100% rename from website_next/learn/charts/radio.js rename to website_next/chart/radio.js diff --git a/website_next/learn/charts/renderer.js b/website_next/chart/renderer.js similarity index 92% rename from website_next/learn/charts/renderer.js rename to website_next/chart/renderer.js index c57a584fb..aef679fac 100644 --- a/website_next/learn/charts/renderer.js +++ b/website_next/chart/renderer.js @@ -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, ); } diff --git a/website_next/learn/charts/scale.js b/website_next/chart/scale.js similarity index 100% rename from website_next/learn/charts/scale.js rename to website_next/chart/scale.js diff --git a/website_next/learn/charts/scrubber/index.js b/website_next/chart/scrubber/index.js similarity index 71% rename from website_next/learn/charts/scrubber/index.js rename to website_next/chart/scrubber/index.js index 5b021bc31..94090fed9 100644 --- a/website_next/learn/charts/scrubber/index.js +++ b/website_next/chart/scrubber/index.js @@ -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] */ diff --git a/website_next/chart/scrubber/style.css b/website_next/chart/scrubber/style.css new file mode 100644 index 000000000..c290ec87d --- /dev/null +++ b/website_next/chart/scrubber/style.css @@ -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); + } +} diff --git a/website_next/learn/charts/series-path.js b/website_next/chart/series-path.js similarity index 100% rename from website_next/learn/charts/series-path.js rename to website_next/chart/series-path.js diff --git a/website_next/learn/charts/setting.js b/website_next/chart/setting.js similarity index 100% rename from website_next/learn/charts/setting.js rename to website_next/chart/setting.js diff --git a/website_next/learn/charts/stacked/index.js b/website_next/chart/stacked/index.js similarity index 97% rename from website_next/learn/charts/stacked/index.js rename to website_next/chart/stacked/index.js index 9475e3d69..af22efb18 100644 --- a/website_next/learn/charts/stacked/index.js +++ b/website_next/chart/stacked/index.js @@ -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, ); diff --git a/website_next/learn/charts/stacked/series.js b/website_next/chart/stacked/series.js similarity index 58% rename from website_next/learn/charts/stacked/series.js rename to website_next/chart/stacked/series.js index 5aa0014ab..a777288d5 100644 --- a/website_next/learn/charts/stacked/series.js +++ b/website_next/chart/stacked/series.js @@ -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, diff --git a/website_next/chart/stacked/style.css b/website_next/chart/stacked/style.css new file mode 100644 index 000000000..0c9a1c1d9 --- /dev/null +++ b/website_next/chart/stacked/style.css @@ -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; + } +} diff --git a/website_next/learn/charts/storage.js b/website_next/chart/storage.js similarity index 100% rename from website_next/learn/charts/storage.js rename to website_next/chart/storage.js diff --git a/website_next/chart/style.css b/website_next/chart/style.css new file mode 100644 index 000000000..15d82ea4b --- /dev/null +++ b/website_next/chart/style.css @@ -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; + } + } +} diff --git a/website_next/learn/charts/svg.js b/website_next/chart/svg.js similarity index 100% rename from website_next/learn/charts/svg.js rename to website_next/chart/svg.js diff --git a/website_next/learn/charts/timeframes.js b/website_next/chart/timeframes.js similarity index 100% rename from website_next/learn/charts/timeframes.js rename to website_next/chart/timeframes.js diff --git a/website_next/learn/charts/types.d.ts b/website_next/chart/types.d.ts similarity index 69% rename from website_next/learn/charts/types.d.ts rename to website_next/chart/types.d.ts index d3cfea8bf..e58071525 100644 --- a/website_next/learn/charts/types.d.ts +++ b/website_next/chart/types.d.ts @@ -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 & { + 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; diff --git a/website_next/learn/charts/units.js b/website_next/chart/units.js similarity index 100% rename from website_next/learn/charts/units.js rename to website_next/chart/units.js diff --git a/website_next/chart/viewbox.js b/website_next/chart/viewbox.js new file mode 100644 index 000000000..79950e07c --- /dev/null +++ b/website_next/chart/viewbox.js @@ -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; +} diff --git a/website_next/learn/charts/views.js b/website_next/chart/views.js similarity index 100% rename from website_next/learn/charts/views.js rename to website_next/chart/views.js diff --git a/website_next/chart/xy/index.js b/website_next/chart/xy/index.js new file mode 100644 index 000000000..007a2c03b --- /dev/null +++ b/website_next/chart/xy/index.js @@ -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] + */ diff --git a/website_next/chart/xy/style.css b/website_next/chart/xy/style.css new file mode 100644 index 000000000..c62e2c152 --- /dev/null +++ b/website_next/chart/xy/style.css @@ -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; + } +} diff --git a/website_next/explore/block/fee-chart.js b/website_next/explore/block/fee-chart.js new file mode 100644 index 000000000..dd0103705 --- /dev/null +++ b/website_next/explore/block/fee-chart.js @@ -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 + */ diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js new file mode 100644 index 000000000..e07499db2 --- /dev/null +++ b/website_next/explore/block/index.js @@ -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>[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, + }); +} diff --git a/website_next/explore/block/style.css b/website_next/explore/block/style.css new file mode 100644 index 000000000..a0394be10 --- /dev/null +++ b/website_next/explore/block/style.css @@ -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; + } + } +} diff --git a/website_next/explore/chain/cube/style.css b/website_next/explore/chain/cube/style.css index da51a39d8..6926f374d 100644 --- a/website_next/explore/chain/cube/style.css +++ b/website_next/explore/chain/cube/style.css @@ -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% { diff --git a/website_next/explore/chain/index.js b/website_next/explore/chain/index.js index 994646f3c..d673d47bf 100644 --- a/website_next/explore/chain/index.js +++ b/website_next/explore/chain/index.js @@ -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>[number]} Block */ /** @typedef {Awaited>[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} */ 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(); } diff --git a/website_next/explore/chain/style.css b/website_next/explore/chain/style.css index 368d77e02..59afb4cfe 100644 --- a/website_next/explore/chain/style.css +++ b/website_next/explore/chain/style.css @@ -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); } } } diff --git a/website_next/explore/index.js b/website_next/explore/index.js index 5d0bf263d..a8db1487c 100644 --- a/website_next/explore/index.js +++ b/website_next/explore/index.js @@ -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); diff --git a/website_next/explore/style.css b/website_next/explore/style.css index b84aa3742..12986fad2 100644 --- a/website_next/explore/style.css +++ b/website_next/explore/style.css @@ -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%; + } +} diff --git a/website_next/index.html b/website_next/index.html index 2259d0655..0f42235d5 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -105,16 +105,19 @@ + - - - - - - - - - + + + + + + + + + + + diff --git a/website_next/learn/charts/area/style.css b/website_next/learn/charts/area/style.css deleted file mode 100644 index da877ff94..000000000 --- a/website_next/learn/charts/area/style.css +++ /dev/null @@ -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; - } - } -} diff --git a/website_next/learn/charts/bar/style.css b/website_next/learn/charts/bar/style.css deleted file mode 100644 index 7acecec4c..000000000 --- a/website_next/learn/charts/bar/style.css +++ /dev/null @@ -1,8 +0,0 @@ -main.learn { - figure[data-chart="series"] { - path[data-chart="bar"] { - fill: var(--color, var(--orange)); - stroke: none; - } - } -} diff --git a/website_next/learn/charts/controls/style.css b/website_next/learn/charts/controls/style.css deleted file mode 100644 index 9e8bd65bf..000000000 --- a/website_next/learn/charts/controls/style.css +++ /dev/null @@ -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; - } - } - } -} diff --git a/website_next/learn/charts/dots/style.css b/website_next/learn/charts/dots/style.css deleted file mode 100644 index f153fc370..000000000 --- a/website_next/learn/charts/dots/style.css +++ /dev/null @@ -1,8 +0,0 @@ -main.learn { - figure[data-chart="series"] { - path[data-chart="dots"] { - fill: var(--color, var(--orange)); - stroke: none; - } - } -} diff --git a/website_next/learn/charts/legend/style.css b/website_next/learn/charts/legend/style.css deleted file mode 100644 index 6907fd133..000000000 --- a/website_next/learn/charts/legend/style.css +++ /dev/null @@ -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; - } - } -} diff --git a/website_next/learn/charts/line/series.js b/website_next/learn/charts/line/series.js deleted file mode 100644 index 61e8279bc..000000000 --- a/website_next/learn/charts/line/series.js +++ /dev/null @@ -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), - })); -} diff --git a/website_next/learn/charts/line/style.css b/website_next/learn/charts/line/style.css deleted file mode 100644 index df2d345fa..000000000 --- a/website_next/learn/charts/line/style.css +++ /dev/null @@ -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; - } - } -} diff --git a/website_next/learn/charts/scrubber/style.css b/website_next/learn/charts/scrubber/style.css deleted file mode 100644 index 21c580801..000000000 --- a/website_next/learn/charts/scrubber/style.css +++ /dev/null @@ -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; - } - } -} diff --git a/website_next/learn/charts/stacked/style.css b/website_next/learn/charts/stacked/style.css deleted file mode 100644 index 41e5b0fe2..000000000 --- a/website_next/learn/charts/stacked/style.css +++ /dev/null @@ -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; - } - } -} diff --git a/website_next/learn/charts/style.css b/website_next/learn/charts/style.css deleted file mode 100644 index e9fd81503..000000000 --- a/website_next/learn/charts/style.css +++ /dev/null @@ -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; - } - } - } -} diff --git a/website_next/learn/charts/viewbox.js b/website_next/learn/charts/viewbox.js deleted file mode 100644 index a00537895..000000000 --- a/website_next/learn/charts/viewbox.js +++ /dev/null @@ -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; -} diff --git a/website_next/learn/data/sections/address-count.js b/website_next/learn/data/sections/address-count.js index 45c55b415..10a3c2f51 100644 --- a/website_next/learn/data/sections/address-count.js +++ b/website_next/learn/data/sections/address-count.js @@ -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"); diff --git a/website_next/learn/data/sections/capitalization.js b/website_next/learn/data/sections/capitalization.js index 06c8c2b29..297822c89 100644 --- a/website_next/learn/data/sections/capitalization.js +++ b/website_next/learn/data/sections/capitalization.js @@ -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"; diff --git a/website_next/learn/data/sections/capitalization/market.js b/website_next/learn/data/sections/capitalization/market.js index 5860cdb97..2a9b2a9a7 100644 --- a/website_next/learn/data/sections/capitalization/market.js +++ b/website_next/learn/data/sections/capitalization/market.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", diff --git a/website_next/learn/data/sections/capitalization/realized.js b/website_next/learn/data/sections/capitalization/realized.js index 44de80a9d..55f72bc8b 100644 --- a/website_next/learn/data/sections/capitalization/realized.js +++ b/website_next/learn/data/sections/capitalization/realized.js @@ -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", diff --git a/website_next/learn/data/sections/mining-pools.js b/website_next/learn/data/sections/mining-pools.js index 539c53a35..c776d0828 100644 --- a/website_next/learn/data/sections/mining-pools.js +++ b/website_next/learn/data/sections/mining-pools.js @@ -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"); diff --git a/website_next/learn/data/sections/supply.js b/website_next/learn/data/sections/supply.js index 5509be702..5d8547b80 100644 --- a/website_next/learn/data/sections/supply.js +++ b/website_next/learn/data/sections/supply.js @@ -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", diff --git a/website_next/learn/data/sections/utxo-set.js b/website_next/learn/data/sections/utxo-set.js index b5e5525d1..062c880a5 100644 --- a/website_next/learn/data/sections/utxo-set.js +++ b/website_next/learn/data/sections/utxo-set.js @@ -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"); diff --git a/website_next/learn/index.js b/website_next/learn/index.js index f01bd59cd..f2775cafc 100644 --- a/website_next/learn/index.js +++ b/website_next/learn/index.js @@ -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";