import { useRef, useState, useSyncExternalStore } from 'react'; import { percent } from '#/lib/languages.js'; import { hasHue, onTheme } from '#/lib/theme.js'; /** Ticks across the whole scale. Each one is a share of the repository. */ const TICKS = 72; /** * How solid each language's ticks read, largest first. The gauge has no hue * to spend, so rank is what tells one run from the next: the language a * repository is mostly written in stands at full strength and each one after * it steps back. Past the last step every remaining language reads alike, * which by then is a tail too thin to tell apart anyway. */ const RANKS = [1, 0.78, 0.6, 0.46, 0.36, 0.28, 0.22]; const rank = (index) => RANKS[Math.min(index, RANKS.length - 1)]; /** * What a repository is written in, as a row of ticks read like a gauge: each * language holds the run of ticks its share earns, and the one under the * pointer names itself in a chip below. * * A language too small for a whole tick still gets one, so a listed language * is always visible. The chip is drawn over what follows rather than in the * flow, so the row keeps its height and nothing moves as the pointer crosses * it. * * The row answers the pointer, not the ticks: which language is under it * comes from how far across the row it is. Ticks are 2px with air between * them, so asking each tick would leave the gaps answering for nobody, and * crossing one run would blink the chip off and on again. */ export function LanguageBar({ languages }) { const [hovered, setHovered] = useState(null); const row = useRef(null); // A palette with hue to spend gives each language linguist's own colour. // One without steps each back by rank instead, which is the only way a // greyscale gauge can tell them apart. The palette is asked rather than // consulted about: its own key colour is the honest account of it. const byRank = useSyncExternalStore( onTheme, () => !hasHue(), () => true, ); // Hand out the scale, one language at a time, and let the last one take // whatever rounding left over so the row always ends full. Each tick // carries its own place in the row, which is what names it to React. /** @type {{id: number, language: {name: string, color: string}, index: number}[]} */ const ticks = []; languages.forEach((language, index) => { const last = index === languages.length - 1; const count = last ? TICKS - ticks.length : Math.max(1, Math.round((language.share / 100) * TICKS)); for (let tick = 0; tick < count; tick++) { ticks.push({ id: ticks.length, language, index }); } }); const words = languages .map((language) => `${language.name} ${percent(language.share)}`) .join(', '); /** Which language the pointer stands over, by how far across the row it is. */ const track = (event) => { const box = row.current?.getBoundingClientRect(); if (!box || box.width === 0) return; const across = (event.clientX - box.left) / box.width; const at = Math.floor(across * TICKS); setHovered( ticks[Math.min(ticks.length - 1, Math.max(0, at))]?.index ?? null, ); }; return (
{/* The ticks stand a few pixels tall, which is a hard thing to keep a pointer inside. The reach is taller than they are, and pulled back out of the layout so nothing below it moves. */} {/* biome-ignore lint/a11y/noStaticElementInteractions: the row inside carries the reading; this only widens the reach for a pointer */}
setHovered(null)} > {/* The ticks keep their own width and spread across whatever room the row has, which is what makes the scale read as an instrument rather than as a filled bar. */}
{ticks.map(({ id, language, index }) => (
{hovered !== null && ( {languages[hovered].name} {percent(languages[hovered].share)} )}
); }