{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dot-plot",
  "type": "registry:ui",
  "title": "Dot plot",
  "description": "A distribution drawn as stacks of dots, mirrored about a center line, so you see where most values fall. With a goal, dots inside it take a data hue and the rest stay gray; the median is a line, an outlier can be named, and the legend gives the shares in numbers.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/dot-plot.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/dot-plot 2026.11.03. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Dot plot: a distribution drawn as stacks of dots, one stack per bin along an axis, mirrored around a center\n * line. It answers \"where do most values fall, and how many are past a goal?\" without a number to read. Plain\n * SVG, no chart library. A sibling of chart.tsx: same measured width, same tooltip, same hidden table, same\n * one-tab-stop keyboard model, same touch scrub, same entrance rule.\n *\n * - Bins: they sit at a fixed 8px pitch, and a dot is 6px across with 2px of air, so the bin count follows the\n *   width (a phone gets fewer, wider bins). The axis is linear or log (durations from minutes to days read on a\n *   log axis). With a goal, the axis is piecewise at the goal so that a bin boundary lands exactly on it: no bin\n *   straddles the goal, the dashed line runs through the 2px gap between two columns, and the colors are exact.\n * - Dots: each bin stacks its dots from the center line outward, mirrored, so a stack of any size is symmetric\n *   about the line. A dot stands for `perDot` items, the smallest whole number that makes the tallest stack fit\n *   the height; a non-empty bin always keeps at least one dot, and the legend says what a dot is when perDot\n *   is above 1. The tooltip and the table always give the true count.\n * - Color (R7): with a goal, dots in bins at or under it take the hue's mark tone and dots past it take\n *   --litt-fg-subtle (a mark at 3:1 or more in both themes). Without a goal every dot takes the hue. The active bin\n *   turns to the hue's text tone (--litt-fg past the goal). The goal is a 1px dashed --litt-line-strong line with its\n *   label at its foot, the median a 2px line in the hue's text tone with its label above, and a marker a 10px\n *   dot with a 2px --litt-fg ring on the center line and an always-visible label in the tooltip's look.\n * - Anatomy: no gridlines, no area, no glow. Tick labels sit under the plot at the caption size, --litt-fg-subtle, tabular.\n *   A label that would touch the one before it is left out, and the first and last are kept inside the width.\n * - Drawn at the container's width, measured with a ResizeObserver, so text and hairlines stay crisp. The SVG is\n *   absolutely placed in a box of the final height, so it never holds its container wide: when the container\n *   shrinks (a phone turned upright) the plot shrinks with it. Before the\n *   first measure (and on the server) only the legend and the hidden table render, in a box of the final height.\n * - Marks: the pointer is one layer over the plot and the non-empty bin under it is the active one (on a phone\n *   a bin is narrower than a finger, so a touch scrubs, and the layer is touch-action: pan-y so a vertical drag\n *   still scrolls). For the keyboard and screen readers there is one visually hidden button per non-empty bin:\n *   one tab stop (roving tabindex), arrows, Home and End. A keyboard-focused bin gets a 2px focus ring around its\n *   stack, only when :focus-visible matches.\n * - Tooltip: the Tooltip's look (solid surface-3, shadow-litt-tooltip) above the stack, kept inside. Esc hides it\n *   without moving focus. A touch pins it until the next touch elsewhere. It repeats the button's name, so it\n *   is hidden from screen readers.\n * - Motion: one entrance on the responsive tier: the plot fades in by opacity (a dot squashed by scaleY would\n *   stop being a circle). Nothing else moves; new data redraws at once, and hover is a color change on the\n *   instant tier. Reduced motion is the hooks' job.\n * - Accessibility: the SVG is role=\"img\" with a summary (\"Reply times, 412 replies: median 1 h 16 m, 84% within\n *   the 4 h goal\"), and a visually hidden table lists each bin's range and count, then the median, the goal and\n *   the markers. The bin buttons are in a labelled group outside the image.\n */\nimport { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- types */\n\n/** The five data hues, in their fixed order (R7). */\nexport type DotPlotHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\nexport type DotPlotProps = {\n  /** The plot's name, in a few words: \"Reply times\". It labels the image, the bin group and the table, and starts the generated summary. */\n  label: string;\n  /** One number per item, in any order: every reply time, every build time. Values outside the domain land in the end bins. */\n  values: number[];\n  /** The axis: \"linear\", or \"log\" for values that run from minutes to days. A log axis needs values above zero. */\n  scale?: \"linear\" | \"log\";\n  /** The ends of the axis as [min, max]. Left out, it runs from the smallest value or marker to the largest. */\n  domain?: [min: number, max: number];\n  /** The labelled positions under the plot, such as `{ value: 60, label: \"1h\" }`. Left out, they come from the domain: decades on a log axis, round steps on a linear one. A label that would touch its neighbour is left out. */\n  ticks?: { value: number; label: string }[];\n  /** Turns a value into text for the tooltip, the summary, the table and the default ticks: `(v) => \\`${v} s\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** What the values count, said after the number in the tooltip and the legend: \"replies\". Pass [\"reply\", \"replies\"] to say the first for exactly 1. Leave it out when the label says it. */\n  unit?: string | [one: string, other: string];\n  /** Splits the dots at this value: dots at or under it take the hue, dots past it take the foreground's subtle tone. A 1px dashed line marks it and the legend gives both shares. Leave it out and every dot takes the hue. */\n  goal?: number;\n  /** The text at the goal line's foot, such as \"4 h goal\". Left out, it reads the goal and the word goal. */\n  goalLabel?: string;\n  /** The data hue of the dots (and of the median): blue, amber, teal, violet or rose. Use one hue for one meaning on a view. */\n  hue?: DotPlotHue;\n  /** Draws the median as a 2px line in the hue's text tone, labelled above it. */\n  median?: boolean;\n  /** Named single values, such as an outlier: a 10px dot with a 2px foreground ring on the center line, and its label always shown. */\n  markers?: { value: number; label: string }[];\n  /** Height of the plot in px, axis labels included. The legend sits under it. The width is the container's. */\n  height?: number;\n  /** Replaces the generated summary that names the image, such as \"Reply times, 412 replies: median 1 h 16 m, 84% within the 4 h goal\". */\n  summary?: string;\n  /** The legend's name for dots at or under the goal. */\n  inLabel?: string;\n  /** The legend's name for dots past the goal. */\n  outLabel?: string;\n  /** Plays the entrance on the responsive tier when the plot mounts. Remount with a new key to replay. */\n  entrance?: boolean;\n  /** Extra classes for the plot's box, such as a top margin. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- constants and helpers */\n\nconst PITCH = 8; // one bin, and one dot row: a 6px dot and 2px of air\nconst DOT_R = 3; // a 6px dot\nconst MARKER_R = 5; // a 10px dot\nconst RING_R = 8; // the ring's centreline: a 2px gap, then a 2px stroke\nconst RING_OUTER = 9;\nconst TOP = 20; // room above the plot for the median's label\nconst AXIS = 24; // room under the plot for the tick labels\nconst FOOT = 20; // room under the plot for the goal's label\nconst FIGURE = 7.2; // advance of a 12px tabular figure in Geist (letters run narrower)\nconst TICK_GAP = 12; // the least air between two tick labels\nconst NEAR = 4; // a focus ring sits this far from its stack\n\n/** Mark tone, text tone and line tone of each hue. Literal strings, so Tailwind sees them. */\nconst HUE: Record<DotPlotHue, { mark: string; text: string; line: string }> = {\n  blue: { mark: \"fill-litt-data-blue\", text: \"fill-litt-data-blue-text\", line: \"stroke-litt-data-blue-text\" },\n  amber: { mark: \"fill-litt-data-amber\", text: \"fill-litt-data-amber-text\", line: \"stroke-litt-data-amber-text\" },\n  teal: { mark: \"fill-litt-data-teal\", text: \"fill-litt-data-teal-text\", line: \"stroke-litt-data-teal-text\" },\n  violet: { mark: \"fill-litt-data-violet\", text: \"fill-litt-data-violet-text\", line: \"stroke-litt-data-violet-text\" },\n  rose: { mark: \"fill-litt-data-rose\", text: \"fill-litt-data-rose-text\", line: \"stroke-litt-data-rose-text\" },\n};\nconst OVER = { mark: \"fill-litt-fg-subtle\", text: \"fill-litt-fg\" };\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\n\nconst median = (values: number[]) => {\n  if (values.length === 0) return 0;\n  const s = [...values].sort((a, b) => a - b);\n  const mid = s.length >> 1;\n  return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n};\n\n/** Round steps across a linear domain: about four to six of them. */\nfunction niceTicks(min: number, max: number): number[] {\n  const range = max - min;\n  if (!(range > 0)) return [min];\n  const raw = range / 4;\n  const pow = 10 ** Math.floor(Math.log10(raw));\n  const step = [1, 2, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;\n  const first = Math.ceil(min / step - 1e-9);\n  const out: number[] = [];\n  for (let i = first; i * step <= max + 1e-9; i++) out.push(Number((i * step).toPrecision(12)));\n  return out;\n}\n\n/** Decades across a log domain, then 2s and 5s when there are fewer than three. */\nfunction logTicks(min: number, max: number): number[] {\n  const e0 = Math.floor(Math.log10(min));\n  const e1 = Math.ceil(Math.log10(max));\n  const within = (mults: number[]) => {\n    const out: number[] = [];\n    for (let e = e0; e <= e1; e++) for (const m of mults) out.push(Number((m * 10 ** e).toPrecision(12)));\n    return out.filter((v) => v >= min && v <= max);\n  };\n  const decades = within([1]);\n  return decades.length >= 3 ? decades : within([1, 2, 5]);\n}\n\n/* The axis. One continuous map from a value to x in 0..n*PITCH. With a goal inside the domain it has a knot\n   there, so that the goal sits exactly on a bin boundary (gk bins to its left). */\ntype Axis = {\n  n: number;\n  /** Bin boundary index of the goal, or null when the goal is not inside the domain. */\n  gk: number | null;\n  x: (value: number) => number;\n  value: (x: number) => number;\n  bin: (value: number) => number;\n};\n\nfunction makeAxis(scale: \"linear\" | \"log\", min: number, max: number, n: number, goal: number | undefined): Axis {\n  const log = scale === \"log\";\n  const t = log ? Math.log : (v: number) => v;\n  const u = log ? Math.exp : (v: number) => v;\n  const clamp = (v: number) => Math.min(max, Math.max(min, v));\n  const W = n * PITCH;\n  const tl = t(min);\n  const th = t(max);\n  const split = goal !== undefined && goal > min && goal < max && n >= 2;\n  const tg = split ? t(goal) : 0;\n  const gk = split ? Math.min(n - 1, Math.max(1, Math.round(((tg - tl) / (th - tl)) * n))) : null;\n  const gx = (gk ?? 0) * PITCH;\n  const x = (v: number) => {\n    const tv = t(clamp(v));\n    if (gk === null) return ((tv - tl) / (th - tl)) * W;\n    return tv <= tg ? ((tv - tl) / (tg - tl)) * gx : gx + ((tv - tg) / (th - tg)) * (W - gx);\n  };\n  const value = (px: number) => {\n    if (gk === null) return u(tl + (px / W) * (th - tl));\n    return px <= gx ? u(tl + (px / gx) * (tg - tl)) : u(tg + ((px - gx) / (W - gx)) * (th - tg));\n  };\n  const bin = (v: number) => {\n    const b = Math.max(0, Math.min(n - 1, Math.floor(x(v) / PITCH)));\n    return gk !== null && goal !== undefined && v <= goal ? Math.min(b, gk - 1) : b;\n  };\n  return { n, gk, x, value, bin };\n}\n\n/** The container's width, measured before paint and kept current. 0 until the first measure. */\nfunction useWidth() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setWidth(Math.floor(el.getBoundingClientRect().width));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\n/** Where a label of `w` px, centred on `x`, starts so that it stays inside 0..width. */\nconst startFor = (x: number, w: number, width: number) => Math.max(0, Math.min(width - w, x - w / 2));\n\n/* ----------------------------------------------------------------- the stacks */\n\n/** One bin's dots, mirrored about the center line. Memoised, so a hover redraws two bins and not all of them. */\nconst Stack = memo(function Stack({ cx, cy, d, cls }: { cx: number; cy: number; d: number; cls: string }) {\n  return (\n    <g className={\"transition-[fill] duration-(--litt-dur-instant) ease-litt \" + cls}>\n      {Array.from({ length: d }, (_, j) => (\n        <circle key={j} cx={cx} cy={cy + (j - (d - 1) / 2) * PITCH} r={DOT_R} />\n      ))}\n    </g>\n  );\n});\n\n/* ----------------------------------------------------------------- the plot */\n\nexport function DotPlot({\n  label,\n  values,\n  scale = \"linear\",\n  domain,\n  ticks,\n  formatValue,\n  unit,\n  goal,\n  goalLabel,\n  hue = \"blue\",\n  median: showMedian = true,\n  markers,\n  height = 160,\n  summary,\n  inLabel = \"Within goal\",\n  outLabel = \"Over\",\n  entrance = true,\n  className = \"\",\n}: DotPlotProps) {\n  const fmt = formatValue ?? groupDigits;\n  const unitFor = (v: number) => (Array.isArray(unit) ? (v === 1 ? unit[0] : unit[1]) : unit);\n  const withUnit = (v: number) => groupDigits(v) + (unit ? ` ${unitFor(v)}` : \"\");\n  const tones = HUE[hue];\n  const [boxRef, width] = useWidth();\n  const enter = useTransition(\"responsive\");\n\n  /* The data that does not depend on the width. */\n  const stats = useMemo(() => {\n    const usable = scale === \"log\" ? values.filter((v) => v > 0) : values;\n    const mk = (markers ?? []).filter((m) => (scale === \"log\" ? m.value > 0 : true));\n    const all = [...usable, ...mk.map((m) => m.value)];\n    let min = domain ? domain[0] : all.length ? Math.min(...all) : 0;\n    let max = domain ? domain[1] : all.length ? Math.max(...all) : 1;\n    if (scale === \"log\") min = Math.max(min, 1e-9);\n    if (!(max > min)) max = min + 1;\n    const total = usable.length;\n    const within = goal === undefined ? total : usable.filter((v) => v <= goal).length;\n    const pct = total > 0 ? Math.round((within / total) * 100) : 0;\n    return { usable, markers: mk, min, max, total, pct, med: median(usable) };\n  }, [values, markers, scale, domain, goal]);\n  const { min, max, total, med } = stats;\n  const goalText = goalLabel ?? (goal !== undefined ? `${fmt(goal)} goal` : \"\");\n\n  /* The geometry, which does. */\n  const g = useMemo(() => {\n    const n = Math.max(0, Math.floor(width / PITCH));\n    const x0 = Math.floor((width - n * PITCH) / 2);\n    const goalDrawn = goal !== undefined && goal > min && goal < max;\n    const foot = goalDrawn ? FOOT : 8;\n    const rows = Math.max(2, Math.floor((height - TOP - foot - AXIS) / PITCH));\n    const dotsH = rows * PITCH;\n    const top = height - AXIS - foot - dotsH; // the leftover goes above the plot\n    const axis = makeAxis(scale, min, max, Math.max(n, 1), goal);\n    const counts = new Array<number>(Math.max(n, 1)).fill(0);\n    for (const v of stats.usable) counts[axis.bin(v)]!++;\n    const peak = Math.max(0, ...counts);\n    const perDot = Math.max(1, Math.ceil(peak / rows));\n    const dots = counts.map((c) => (c === 0 ? 0 : Math.max(1, Math.round(c / perDot))));\n    const occupied = counts.flatMap((c, i) => (c > 0 && n > 0 ? [i] : []));\n    return { n, x0, goalDrawn, foot, rows, dotsH, top, bottom: top + dotsH, cy: top + dotsH / 2, axis, counts, perDot, dots, occupied };\n  }, [width, height, scale, min, max, goal, stats.usable]);\n\n  const rangeOf = (i: number) => {\n    const a = fmt(i === 0 ? min : g.axis.value(i * PITCH));\n    const b = fmt(i === g.n - 1 ? max : g.axis.value((i + 1) * PITCH));\n    return a === b ? a : `${a} to ${b}`;\n  };\n  const countOf = (i: number) => withUnit(g.counts[i]!);\n  const itemLabel = (i: number) => `${rangeOf(i)}: ${countOf(i)}`;\n  const over = (i: number) => goal !== undefined && (g.axis.gk !== null ? i >= g.axis.gk : goal < min);\n\n  const parts: string[] = [];\n  if (showMedian && total > 0) parts.push(`median ${fmt(med)}`);\n  if (goal !== undefined && total > 0) parts.push(`${stats.pct}% within the ${goalText}`);\n  const text = summary ?? `${label}, ${total} ${unit ? unitFor(total) : \"values\"}` + (parts.length ? `: ${parts.join(\", \")}` : \"\");\n\n  /* Which bin is active: a hovered one, else a focused one, else a pinned one (a tap). */\n  const [hover, setHover] = useState<number | null>(null);\n  const [focus, setFocus] = useState<number | null>(null);\n  const [pinned, setPinned] = useState<number | null>(null);\n  const [ring, setRing] = useState<number | null>(null);\n  const [dismissed, setDismissed] = useState(false);\n  const valid = (i: number | null) => (i !== null && (g.counts[i] ?? 0) > 0 ? i : null);\n  const active = dismissed ? null : valid(hover ?? focus ?? pinned);\n  const ringed = valid(ring);\n  useEffect(() => setDismissed(false), [hover, focus, pinned]);\n\n  const medianBin = total > 0 ? g.axis.bin(med) : 0;\n  const [tabStop, setTabStop] = useState<number | null>(null);\n  const stop = g.occupied.includes(tabStop ?? -1) ? tabStop! : g.occupied.includes(medianBin) ? medianBin : (g.occupied[0] ?? 0);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n\n  /* A tap pins the tooltip; the next touch anywhere else clears it. */\n  useEffect(() => {\n    if (pinned === null) return;\n    const away = (e: PointerEvent) => {\n      if (!boxRef.current?.contains(e.target as Node)) setPinned(null);\n    };\n    document.addEventListener(\"pointerdown\", away, true);\n    return () => document.removeEventListener(\"pointerdown\", away, true);\n  }, [pinned, boxRef]);\n\n  /* The bin under a pointer, or null outside the plot. */\n  const binAt = (clientX: number) => {\n    const box = boxRef.current?.getBoundingClientRect();\n    if (!box || g.n <= 0) return null;\n    const i = Math.floor((clientX - box.left - g.x0) / PITCH);\n    return i < 0 || i >= g.n ? null : i;\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const occ = g.occupied;\n    const pos = occ.findIndex((b) => buttons.current[b] === document.activeElement);\n    if (pos < 0) return;\n    const to =\n      e.key === \"ArrowRight\" || e.key === \"ArrowDown\"\n        ? occ[Math.min(occ.length - 1, pos + 1)]\n        : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\"\n          ? occ[Math.max(0, pos - 1)]\n          : e.key === \"Home\"\n            ? occ[0]\n            : e.key === \"End\"\n              ? occ[occ.length - 1]\n              : undefined;\n    if (to !== undefined) {\n      e.preventDefault();\n      buttons.current[to]?.focus();\n    } else if (e.key === \"Escape\" && active !== null) {\n      setDismissed(true);\n    }\n  };\n\n  const stackHalf = (i: number) => (g.dots[i]! * PITCH - (PITCH - 2 * DOT_R)) / 2;\n  const empty = total === 0;\n  const px = (v: number) => g.x0 + g.axis.x(v);\n  const shown = useMemo(() => {\n    /* Tick labels left to right; one that would touch the one before it is left out. */\n    const list = (ticks ?? (scale === \"log\" ? logTicks(min, max) : niceTicks(min, max)).map((value) => ({ value, label: fmt(value) })))\n      .filter((t) => t.value >= min && t.value <= max)\n      .map((t) => ({ ...t, w: t.label.length * FIGURE, x: g.x0 + g.axis.x(t.value) }))\n      .sort((a, b) => a.x - b.x);\n    const out: { key: number; label: string; left: number }[] = [];\n    let right = -Infinity;\n    for (const t of list) {\n      const left = startFor(t.x, t.w, width);\n      if (left < right + TICK_GAP) continue;\n      right = left + t.w;\n      out.push({ key: t.value, label: t.label, left });\n    }\n    return out;\n  }, [ticks, scale, min, max, fmt, g, width]);\n\n  const extras: [string, string][] = [];\n  if (showMedian && total > 0) extras.push([\"Median\", fmt(med)]);\n  if (goal !== undefined) extras.push([goalText || \"Goal\", fmt(goal)]);\n  for (const m of stats.markers) extras.push([m.label, fmt(m.value)]);\n\n  const noun = unit ? unitFor(g.perDot) : \"values\";\n\n  return (\n    <div className={\"w-full \" + className}>\n      <motion.div\n        ref={boxRef}\n        className=\"relative w-full\"\n        style={{ height }}\n        initial={entrance ? { opacity: 0 } : false}\n        animate={{ opacity: 1, transition: enter }}\n      >\n        {width > 0 && !empty && g.n > 0 && (\n          <>\n            <svg width={width} height={height} role=\"img\" aria-label={text} className=\"absolute top-0 left-0 block overflow-visible\">\n              <g aria-hidden=\"true\">\n                {g.occupied.map((i) => {\n                  const isOver = over(i);\n                  const lit = active === i;\n                  const cls = isOver ? (lit ? OVER.text : OVER.mark) : lit ? tones.text : tones.mark;\n                  return <Stack key={i} cx={g.x0 + i * PITCH + PITCH / 2} cy={g.cy} d={g.dots[i]!} cls={cls} />;\n                })}\n              </g>\n\n              <g aria-hidden=\"true\">\n                {g.goalDrawn && g.axis.gk !== null && (\n                  <line\n                    x1={g.x0 + g.axis.gk * PITCH + 0.5}\n                    x2={g.x0 + g.axis.gk * PITCH + 0.5}\n                    y1={g.top}\n                    y2={g.bottom}\n                    strokeWidth={1}\n                    strokeDasharray=\"4 4\"\n                    shapeRendering=\"crispEdges\"\n                    className=\"stroke-litt-line-strong\"\n                  />\n                )}\n                {showMedian && (\n                  <line\n                    x1={Math.round(px(med))}\n                    x2={Math.round(px(med))}\n                    y1={g.top}\n                    y2={g.bottom}\n                    strokeWidth={2}\n                    shapeRendering=\"crispEdges\"\n                    className={tones.line}\n                  />\n                )}\n                {stats.markers.map((m) => (\n                  <g key={m.label + m.value}>\n                    <circle cx={px(m.value)} cy={g.cy} r={MARKER_R} className={goal !== undefined && m.value > goal ? OVER.mark : tones.mark} />\n                    <circle cx={px(m.value)} cy={g.cy} r={RING_R} fill=\"none\" strokeWidth={2} className=\"stroke-litt-fg\" />\n                  </g>\n                ))}\n                <g className=\"text-litt-caption font-normal tabular-nums\">\n                  {showMedian && (\n                    <text\n                      x={startFor(px(med), \"Median\".length * FIGURE, width)}\n                      y={g.top - 10}\n                      dominantBaseline=\"central\"\n                      className={tones.text}\n                    >\n                      Median\n                    </text>\n                  )}\n                  {g.goalDrawn && goalText && (\n                    <text\n                      x={startFor(g.x0 + (g.axis.gk ?? 0) * PITCH, goalText.length * FIGURE, width)}\n                      y={g.bottom + g.foot / 2}\n                      dominantBaseline=\"central\"\n                      className=\"fill-litt-fg-subtle\"\n                    >\n                      {goalText}\n                    </text>\n                  )}\n                  {shown.map((t) => (\n                    <text key={t.key} x={t.left} y={g.bottom + g.foot + AXIS / 2} dominantBaseline=\"central\" className=\"fill-litt-fg-subtle\">\n                      {t.label}\n                    </text>\n                  ))}\n                </g>\n                {ringed !== null && (\n                  <rect\n                    x={g.x0 + ringed * PITCH + PITCH / 2 - DOT_R - NEAR}\n                    y={g.cy - stackHalf(ringed) - NEAR}\n                    width={2 * DOT_R + 2 * NEAR}\n                    height={2 * stackHalf(ringed) + 2 * NEAR}\n                    fill=\"none\"\n                    strokeWidth={2}\n                    className=\"stroke-litt-focus\"\n                  />\n                )}\n              </g>\n            </svg>\n\n            {stats.markers.map((m) => (\n              <MarkerLabel key={m.label + m.value} cx={px(m.value)} cy={g.cy} left={g.x0} right={g.x0 + g.n * PITCH} height={height}>\n                {m.label}\n              </MarkerLabel>\n            ))}\n\n            {/* One layer takes the pointer, so the target is the whole plot and not an 8px column. A bin on a phone is\n                narrower than a finger, so a touch scrubs: the bin under the finger is the pinned one. */}\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute inset-y-0 touch-pan-y select-none\"\n              style={{ left: g.x0, width: g.n * PITCH }}\n              onPointerMove={(e) => {\n                const i = binAt(e.clientX);\n                if (e.pointerType === \"touch\") {\n                  if (e.buttons) setPinned(i);\n                } else setHover(i);\n              }}\n              onPointerLeave={(e) => {\n                if (e.pointerType !== \"touch\") setHover(null);\n              }}\n              onPointerDown={(e) => {\n                if (e.pointerType !== \"touch\") return;\n                const i = binAt(e.clientX);\n                setPinned((p) => (p === i ? null : i));\n              }}\n            />\n\n            {/* Keyboard and screen readers get one hidden button per non-empty bin: a stop for the arrow keys, named by its range and count. */}\n            <div role=\"group\" aria-label={`${label}, bins`} onKeyDown={onKeyDown}>\n              {g.occupied.map((i) => (\n                <button\n                  key={i}\n                  ref={(el) => {\n                    buttons.current[i] = el;\n                  }}\n                  type=\"button\"\n                  tabIndex={i === stop ? 0 : -1}\n                  aria-label={itemLabel(i)}\n                  className=\"sr-only\"\n                  onFocus={(e) => {\n                    setFocus(i);\n                    setTabStop(i);\n                    setRing(e.currentTarget.matches(\":focus-visible\") ? i : null);\n                  }}\n                  onBlur={() => {\n                    setFocus((f) => (f === i ? null : f));\n                    setRing((r) => (r === i ? null : r));\n                  }}\n                />\n              ))}\n            </div>\n\n            <AnimatePresence>\n              {active !== null && (\n                <Tip key=\"tip\" cx={g.x0 + active * PITCH + PITCH / 2} markTop={g.cy - stackHalf(active) - NEAR} height={height} width={width}>\n                  <span className=\"text-litt-fg-muted\">{rangeOf(active)}</span>\n                  <span className=\"text-litt-caption font-normal tabular-nums\">{countOf(active)}</span>\n                </Tip>\n              )}\n            </AnimatePresence>\n          </>\n        )}\n      </motion.div>\n\n      {(goal !== undefined || (width > 0 && g.perDot > 1)) && !empty && (\n        <ul className=\"mt-4 flex flex-wrap items-center gap-x-6 gap-y-2 text-litt-small text-litt-fg-muted\">\n          {goal !== undefined && (\n            <>\n              <LegendItem swatch={tones.mark} name={inLabel} share={stats.pct} />\n              <LegendItem swatch={OVER.mark} name={outLabel} share={100 - stats.pct} />\n            </>\n          )}\n          {width > 0 && g.perDot > 1 && <li className=\"text-litt-fg-subtle\">{`Each dot is ${g.perDot} ${noun}`}</li>}\n        </ul>\n      )}\n\n      {/* A table will not shrink to sr-only's 1px, so a wrapper clips it; otherwise a long one widens a phone's page. */}\n      <div className=\"sr-only\">\n        <table className=\"[&_th]:font-normal\">\n          <caption>{label}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Range</th>\n              <th scope=\"col\">Count{unit ? ` (${unitFor(2)})` : \"\"}</th>\n            </tr>\n          </thead>\n          <tbody>\n            {g.occupied.map((i) => (\n              <tr key={i}>\n                <th scope=\"row\">{rangeOf(i)}</th>\n                <td>{g.counts[i]}</td>\n              </tr>\n            ))}\n          </tbody>\n          {extras.length > 0 && (\n            <tfoot>\n              {extras.map(([k, v]) => (\n                <tr key={k + v}>\n                  <th scope=\"row\">{k}</th>\n                  <td>{v}</td>\n                </tr>\n              ))}\n            </tfoot>\n          )}\n        </table>\n      </div>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- legend */\n\n/** A key, never a status: a 6px dot in the colour it names, the name, and the share in tabular figures. */\nfunction LegendItem({ swatch, name, share }: { swatch: string; name: string; share: number }) {\n  return (\n    <li className=\"flex items-center gap-2\">\n      <svg width={6} height={6} aria-hidden=\"true\" focusable=\"false\" className=\"shrink-0\">\n        <circle cx={3} cy={3} r={3} className={swatch} />\n      </svg>\n      {name}\n      <span className=\"text-litt-caption font-normal tabular-nums text-litt-fg\">{share}%</span>\n    </li>\n  );\n}\n\n/* ----------------------------------------------------------------- tooltip and marker labels */\n\nconst SOLID = \"rounded-litt-control bg-litt-surface-3 px-2 py-1.5 text-litt-small whitespace-nowrap text-litt-fg shadow-litt-tooltip inset-shadow-litt-highlight\";\n\nfunction Tip({ cx, markTop, height, width, children }: { cx: number; markTop: number; height: number; width: number; children: ReactNode }) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const ref = useRef<HTMLDivElement>(null);\n  const [w, setW] = useState(0);\n  const measure = useCallback(() => setW(ref.current?.offsetWidth ?? 0), []);\n  useLayoutEffect(measure);\n  const half = w / 2;\n  const left = Math.max(half, Math.min(width - half, cx));\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute z-10 -translate-x-1/2\" style={{ left, bottom: height - markTop + 8 }}>\n      <motion.div\n        ref={ref}\n        className={\"flex origin-bottom items-center gap-3 \" + SOLID}\n        initial={{ opacity: 0, scale: 0.98, y: travel / 4 }}\n        animate={{ opacity: 1, scale: 1, y: 0, transition: enter }}\n        exit={{ opacity: 0, scale: 0.98, transition: exit }}\n      >\n        {children}\n      </motion.div>\n    </div>\n  );\n}\n\n/** A marker's name, always shown in the tooltip's look above its ring and kept inside the plot. It does not move. */\nfunction MarkerLabel({ cx, cy, left: lo, right: hi, height, children }: { cx: number; cy: number; left: number; right: number; height: number; children: ReactNode }) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [w, setW] = useState(0);\n  const measure = useCallback(() => setW(ref.current?.offsetWidth ?? 0), []);\n  useLayoutEffect(measure);\n  const half = w / 2;\n  const left = hi - lo > w ? Math.max(lo + half, Math.min(hi - half, cx)) : (lo + hi) / 2;\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute -translate-x-1/2\" style={{ left, bottom: height - (cy - RING_OUTER - NEAR) }}>\n      <div ref={ref} className={SOLID}>\n        {children}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/dot-plot.tsx"
    }
  ],
  "docs": "Import DotPlot from components/litt/dot-plot. Pass a label, the values, a scale (log for durations) and a formatValue. Add a goal to split the dots, markers to name outliers. It measures its container, so give it a width. Each non-empty column is reachable with the arrow keys.",
  "meta": {
    "version": "2026.11.03",
    "released": "2026-11-03",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
