~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
139 lines · 5.4 KB Raw History
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 (
    <section aria-label="Languages" className="relative">
      {/* 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 */}
      <div
        className="-my-2 py-2"
        onMouseMove={track}
        onMouseLeave={() => 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. */}
        <div
          ref={row}
          role="img"
          aria-label={words}
          className="flex h-3 items-end justify-between"
        >
          {ticks.map(({ id, language, index }) => (
            <span
              key={id}
              aria-hidden="true"
              className="w-[2px] shrink-0 transition-[height]"
              style={{
                height: hovered === index ? '100%' : '60%',
                backgroundColor: byRank ? 'var(--foreground)' : language.color,
                opacity:
                  hovered === null || hovered === index
                    ? byRank
                      ? rank(index)
                      : 1
                    : byRank
                      ? rank(index) * 0.3
                      : 0.3,
              }}
            />
          ))}
        </div>
      </div>
      {hovered !== null && (
        <span className="pointer-events-none absolute top-4 left-0 z-10 flex items-center gap-1.5 border border-border bg-card px-2 py-1 text-[12px] shadow-sm">
          <span
            className="size-2 shrink-0"
            style={{
              backgroundColor: byRank
                ? 'var(--foreground)'
                : languages[hovered].color,
              opacity: byRank ? rank(hovered) : 1,
            }}
          />
          <span className="whitespace-nowrap font-semibold text-foreground">
            {languages[hovered].name}
          </span>
          <span className="text-faint tabular-nums">
            {percent(languages[hovered].share)}
          </span>
        </span>
      )}
    </section>
  );
}