"use client"; import { useLayoutEffect, useRef } from "react"; import { cn } from "@/lib/utils"; // One figure in the row above a chart: a small uppercase label, and whatever // says the number underneath it. // // Moved here, unchanged, from `gas-use-card.tsx` once a second card wanted the // same row - the same move `niceDomain`/`axisTicks` and `subscribeToNothing` // made, and for the same reason. These two cards sit one above the other on the // Home page, so "the same shape" is not a nicety here: a stat row whose label // size or baseline gap drifted by a couple of pixels between them would read as // a rendering fault. // // Deliberately not in `components/ui/`, which is where shadcn's primitives live // and where anything added is implicitly app-wide. This is the header of a // chart card, and both of the cards that want it are dives. // // `detail` sits beside the figure when the enclosing `ChartStatRow` finds room // for it, and on its own line under the figure otherwise. It is a grid cell // rather than a flex item so that, stacked, the stat's width is the wider of // the two lines rather than their sum - which is what lets a row of three fit // at phone width. export function ChartStat({ label, children, detail, }: { label: string; children: React.ReactNode; detail?: React.ReactNode; }) { const figure = (
{children}
); return (
{label}
{detail ? (
{figure}
{detail}
) : (
{figure}
)}
); } // The flex row of `ChartStat`s, which decides where their details go. // // A detail goes inline only when that changes nothing else about the row: no // stat moves to another line and none wraps inside itself. That depends on the // rendered widths of every figure in the row, so it is measured rather than // guessed at with a breakpoint - by laying the row out both ways, before paint, // on every render and every width change. // // Top-aligned, so the labels stay level whichever way a detail lands: a stacked // one makes its stat a line taller than the rest. export function ChartStatRow({ className, children, }: { className?: string; children: React.ReactNode; }) { const ref = useRef(null); useLayoutEffect(() => { const row = ref.current; if (row) placeDetails(row); }); useLayoutEffect(() => { const row = ref.current; if (!row) return; let width = row.clientWidth; // Only a width change can change the answer; the height change the answer // itself causes must not set this off again. const observer = new ResizeObserver(() => { if (row.clientWidth === width) return; width = row.clientWidth; placeDetails(row); }); observer.observe(row); return () => observer.disconnect(); }, []); return (
{children}
); } function placeDetails(row: HTMLElement) { const stats = Array.from( row.querySelectorAll(":scope > [data-chart-stat]"), ); if (!stats.some((stat) => stat.dataset.chartStat === "detail")) return; row.dataset.detail = "stacked"; const stacked = stats.map(measure); row.dataset.detail = "inline"; const inline = stats.map(measure); const fits = stats.every((stat, index) => { const before = stacked[index]; const after = inline[index]; if (after.line !== before.line || after.overflows) return false; // A stat with a detail must have saved its line; one without must be // exactly as tall as it was, or something in it wrapped. return stat.dataset.chartStat === "detail" ? after.height < before.height : after.height === before.height; }); row.dataset.detail = fits ? "inline" : "stacked"; function measure(stat: HTMLElement, index: number) { return { // A flex-wrap item starts a new line exactly when it lands no further // right than the item before it, whatever the row's alignment. line: stats .slice(1, index + 1) .filter((item, i) => item.offsetLeft <= stats[i].offsetLeft).length, height: stat.offsetHeight, overflows: stat.scrollWidth > stat.clientWidth, }; } }