{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-bar-halftone",
  "title": "Chart Bar Halftone",
  "description": "Bar chart whose fills are an ordered-dither halftone instead of a flat color — ink density tracks value as a second, redundant channel to height.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chart-bar-halftone/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChartBarHalftone — the dithered-chart family's bar chart. Bar fills are not\n// flat color: each bar is a plate stamped with the SAME 4x4 Bayer matrix used\n// by background-ascii-dither and ascii-engraving-contour elsewhere in this\n// suite (the family's shared ordered-dither ramp — 17 discrete ink levels,\n// 0 = empty, 16 = solid), so a bar's height is redundant with its own ink\n// density: cover one channel and the other still carries the value. Density\n// is the family's only value channel — pure var(--foreground) ink on\n// var(--background) paper, no --ns-accent in the data itself, exactly like\n// heatmap-year-stipple's choice. --ns-accent is reserved for keyboard focus,\n// same convention as every other component in the suite.\n//\n// Bars are SVG paths filled with `url(#pattern)`, one pattern per ink level,\n// so var(--foreground)/var(--background)/var(--border) resolve as ordinary\n// CSS custom properties on presentation attributes — no canvas, no\n// getComputedStyle, no theme MutationObserver: the browser's own cascade\n// repaints both themes correctly on toggle for free.\n// ---------------------------------------------------------------------------\n\nexport interface ChartBarHalftoneDatum {\n  label: string;\n  value: number;\n}\n\nexport interface ChartBarHalftoneProps {\n  /** the plotted bars, in order */\n  data?: ChartBarHalftoneDatum[];\n  /** chart title, used as the figure's accessible name and table caption */\n  title?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\n// 4x4 Bayer matrix — the family's shared dither constant, raw 0..15 ints\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];\nconst LEVELS = 16; // 17 discrete steps, 0..16\nconst CELL = 4; // px per dither cell, shared pattern tile is 4x4 cells\n\nconst BAR_W = 22; // <=24px per the mark spec\nconst SLOT_W = 58;\nconst PLOT_H = 220;\nconst TOP_PAD = 34; // room for the tip label above the tallest bar\nconst AXIS_H = 8;\nconst LABEL_H = 22;\nconst LEFT_PAD = 12;\nconst RIGHT_PAD = 12;\n\nfunction levelFor(norm: number): number {\n  return Math.round(Math.min(1, Math.max(0, norm)) * LEVELS);\n}\n\nfunction formatValue(v: number): string {\n  const abs = Math.abs(v);\n  if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\\.0$/, \"\")}M`;\n  if (abs >= 1_000) return `${(v / 1_000).toFixed(1).replace(/\\.0$/, \"\")}K`;\n  return Math.round(v).toLocaleString();\n}\n\n/** rounded-top, square-baseline bar path, per the mark spec */\nfunction barPath(x: number, yTop: number, yBase: number, w: number, r: number): string {\n  const rr = Math.min(r, w / 2, Math.max(0, yBase - yTop));\n  if (rr <= 0.01) return `M${x},${yBase} L${x},${yTop} L${x + w},${yTop} L${x + w},${yBase} Z`;\n  return [\n    `M${x},${yBase}`,\n    `L${x},${yTop + rr}`,\n    `Q${x},${yTop} ${x + rr},${yTop}`,\n    `L${x + w - rr},${yTop}`,\n    `Q${x + w},${yTop} ${x + w},${yTop + rr}`,\n    `L${x + w},${yBase}`,\n    `Z`,\n  ].join(\" \");\n}\n\nexport function ChartBarHalftone({ data = [], title = \"Chart\", className = \"\" }: ChartBarHalftoneProps) {\n  const uid = useId().replace(/[:]/g, \"\");\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [showTable, setShowTable] = useState(false);\n  const [entered, setEntered] = useState(false);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    if (mq.matches) {\n      setEntered(true);\n      return;\n    }\n    const id = requestAnimationFrame(() => setEntered(true));\n    return () => cancelAnimationFrame(id);\n  }, []);\n\n  const n = data.length;\n  const maxValue = useMemo(() => Math.max(1, ...data.map((d) => d.value)), [data]);\n  const viewW = LEFT_PAD + n * SLOT_W + RIGHT_PAD;\n  const viewH = TOP_PAD + PLOT_H + AXIS_H + LABEL_H;\n  const baseY = TOP_PAD + PLOT_H;\n\n  const bars = useMemo(\n    () =>\n      data.map((d, i) => {\n        const norm = d.value / maxValue;\n        const x = LEFT_PAD + i * SLOT_W + (SLOT_W - BAR_W) / 2;\n        const h = PLOT_H * norm;\n        const yTop = baseY - h;\n        return { ...d, index: i, x, yTop, level: levelFor(norm) };\n      }),\n    [data, maxValue, baseY]\n  );\n\n  const focusBar = (i: number) => {\n    if (i < 0 || i >= n) return;\n    setActiveIndex(i);\n    document.getElementById(`${uid}-hit-${i}`)?.focus();\n  };\n\n  const hovered = hoverIndex ?? null;\n\n  return (\n    <figure className={`ns-cbh inline-block ${className}`} aria-label={`${title}, bar chart`}>\n      <style>{CSS}</style>\n      <div className=\"flex items-center justify-between gap-3 pb-2\">\n        <span className=\"font-mono text-xs tracking-widest text-ns-muted\">{title.toUpperCase()}</span>\n        <button\n          type=\"button\"\n          onClick={() => setShowTable((s) => !s)}\n          className=\"ns-cbh-toggle rounded-sm border border-border px-2 py-1 font-mono text-[10px] tracking-widest text-ns-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          aria-pressed={showTable}\n        >\n          {showTable ? \"VIEW CHART\" : \"VIEW TABLE\"}\n        </button>\n      </div>\n\n      {showTable ? (\n        <table className=\"ns-cbh-table w-full border-collapse font-mono text-xs\">\n          <caption className=\"sr-only\">{title}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-left text-ns-muted\">\n                Category\n              </th>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-right text-ns-muted tabular-nums\">\n                Value\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {data.map((d) => (\n              <tr key={d.label}>\n                <td className=\"border-b border-border px-2 py-1.5 text-foreground\">{d.label}</td>\n                <td className=\"border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums\">\n                  {d.value.toLocaleString()}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      ) : (\n        <div className=\"relative\">\n          <svg\n            viewBox={`0 0 ${viewW} ${viewH}`}\n            width={viewW}\n            style={{ maxWidth: \"100%\" }}\n            focusable=\"false\"\n            role=\"presentation\"\n          >\n            <defs>\n              {Array.from({ length: LEVELS + 1 }, (_, level) => (\n                <pattern\n                  key={level}\n                  id={`${uid}-p${level}`}\n                  x={0}\n                  y={0}\n                  width={CELL * 4}\n                  height={CELL * 4}\n                  patternUnits=\"userSpaceOnUse\"\n                >\n                  {BAYER.map((b, i) =>\n                    b < level ? (\n                      <rect\n                        key={i}\n                        x={(i % 4) * CELL}\n                        y={Math.floor(i / 4) * CELL}\n                        width={CELL}\n                        height={CELL}\n                        fill=\"var(--foreground)\"\n                      />\n                    ) : null\n                  )}\n                </pattern>\n              ))}\n            </defs>\n\n            {/* gridlines — hairline, recessive, no tick labels since every bar is direct-labeled */}\n            <g aria-hidden=\"true\">\n              {[0, 0.25, 0.5, 0.75, 1].map((f) => (\n                <line\n                  key={f}\n                  x1={LEFT_PAD}\n                  x2={viewW - RIGHT_PAD}\n                  y1={baseY - PLOT_H * f}\n                  y2={baseY - PLOT_H * f}\n                  stroke=\"var(--border)\"\n                  strokeWidth={1}\n                />\n              ))}\n            </g>\n\n            {bars.map((b) => {\n              const isHover = hovered === b.index;\n              const path = barPath(b.x, b.yTop, baseY, BAR_W, 4);\n              const cx = b.x + BAR_W / 2;\n              return (\n                <g\n                  key={b.label}\n                  className=\"ns-cbh-bar\"\n                  aria-hidden=\"true\"\n                  style={\n                    {\n                      transformOrigin: `${cx}px ${baseY}px`,\n                      transform: entered ? \"scaleY(1)\" : \"scaleY(0)\",\n                      transitionDelay: `${b.index * 45}ms`,\n                    } as CSSProperties\n                  }\n                >\n                  <path d={path} fill={`url(#${uid}-p${b.level})`} opacity={isHover ? 1 : 0.92} />\n                  <text\n                    x={cx}\n                    y={b.yTop - 8}\n                    textAnchor=\"middle\"\n                    className=\"font-mono\"\n                    style={{ fontSize: 9.5, fill: \"var(--foreground)\" }}\n                  >\n                    {formatValue(b.value)}\n                  </text>\n                  <text\n                    x={cx}\n                    y={baseY + AXIS_H + LABEL_H - 7}\n                    textAnchor=\"middle\"\n                    className=\"font-mono\"\n                    style={{ fontSize: 9.5, fill: \"var(--ns-muted)\" }}\n                  >\n                    {b.label}\n                  </text>\n                </g>\n              );\n            })}\n\n            <line\n              x1={LEFT_PAD}\n              x2={viewW - RIGHT_PAD}\n              y1={baseY}\n              y2={baseY}\n              stroke=\"var(--border)\"\n              strokeWidth={1}\n              aria-hidden=\"true\"\n            />\n\n            {/* hit targets — real interactive elements, sized past the bar's own\n                painted pixels per the >=24px hit-area rule */}\n            {bars.map((b) => (\n              <rect\n                key={`hit-${b.label}`}\n                id={`${uid}-hit-${b.index}`}\n                role=\"button\"\n                tabIndex={activeIndex === b.index ? 0 : -1}\n                aria-label={`${b.label}: ${b.value.toLocaleString()}`}\n                x={b.x + BAR_W / 2 - SLOT_W / 2}\n                y={TOP_PAD - 12}\n                width={SLOT_W}\n                height={PLOT_H + 12}\n                fill=\"transparent\"\n                className=\"ns-cbh-hit\"\n                onPointerEnter={() => setHoverIndex(b.index)}\n                onPointerLeave={() => setHoverIndex((c) => (c === b.index ? null : c))}\n                onFocus={() => {\n                  setActiveIndex(b.index);\n                  setHoverIndex(b.index);\n                }}\n                onBlur={() => setHoverIndex((c) => (c === b.index ? null : c))}\n                onKeyDown={(e) => {\n                  if (e.key === \"ArrowLeft\") {\n                    e.preventDefault();\n                    focusBar(b.index - 1);\n                  } else if (e.key === \"ArrowRight\") {\n                    e.preventDefault();\n                    focusBar(b.index + 1);\n                  }\n                }}\n              />\n            ))}\n          </svg>\n\n          {hovered !== null && bars[hovered] && (\n            <div\n              aria-hidden=\"true\"\n              className=\"ns-cbh-tip pointer-events-none absolute z-10 rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] shadow-sm\"\n              style={{\n                left: `${((bars[hovered].x + BAR_W / 2) / viewW) * 100}%`,\n                top: `${(Math.max(0, bars[hovered].yTop - 34) / viewH) * 100}%`,\n                transform: \"translateX(-50%)\",\n              }}\n            >\n              <strong className=\"text-foreground\">{formatValue(bars[hovered].value)}</strong>{\" \"}\n              <span className=\"text-ns-muted\">{bars[hovered].label}</span>\n            </div>\n          )}\n        </div>\n      )}\n    </figure>\n  );\n}\n\nconst CSS = `\n.ns-cbh-bar { transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1); }\n.ns-cbh-hit { cursor: pointer; outline: none; }\n.ns-cbh-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n@media (prefers-reduced-motion: reduce) {\n  .ns-cbh-bar { transition: none; }\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/chart-bar-halftone.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "chart",
      "bar",
      "data-viz",
      "dither",
      "halftone",
      "svg",
      "ink"
    ],
    "instruction": "A dithered-chart-family bar chart: the first member alongside chart-donut-halftone, both stamped from the same 4x4 Bayer matrix already used by background-ascii-dither and ascii-engraving-contour elsewhere in this suite, so the aesthetic those two components established stays a single, shared constant rather than three independent reimplementations. Bars are thin (22px, under the 24px mark-spec cap), 4px-rounded at the data end and square at the baseline, and instead of a flat fill each bar is an SVG path filled with one of 17 precomputed patterns (levels 0-16): pattern N tiles the Bayer matrix's 16 cells at 4px each and inks every cell whose Bayer value is below N, so a bar's own ink density is a second, independently-legible encoding of its value — cover the bar's height and the halftone alone still tells you roughly how full it is. This is the family's colour decision made explicit: density is the only value channel here, pure var(--foreground) ink on var(--background) paper exactly like heatmap-year-stipple's precedent, with var(--ns-accent) reserved for keyboard focus only, never for data. Every fill, gridline, and border is a CSS custom property referenced directly as an SVG presentation-attribute value (fill=\"var(--foreground)\"), so both themes repaint correctly on toggle with no getComputedStyle call and no MutationObserver — unlike the canvas-based components in this family, plain SVG lets the browser's own cascade do that work. Hairline gridlines mark 0/25/50/75/100% with no tick labels, because every bar already carries a direct value label at its tip (a rounded compact figure, 1.2K style) per the mark spec's own rule that axis ticks are dropped once every value is labeled; a category label sits below the baseline. Each bar has an invisible hit rectangle sized to its full slot (wider than the painted bar, per the >=24px hit-area rule) carrying role=button, an aria-label of \"label: value\", and roving tabindex — ArrowLeft/ArrowRight move focus between bars, and hover or focus both raise a small value+label tooltip positioned above the bar without gating any information (the same fact is already in the aria-label and in the table view). A VIEW TABLE toggle swaps the chart for a real HTML table with the same data, the accessibility twin required for a continuous value scale. On mount, bars grow from the baseline on a 480ms ease-out transform, staggered 45ms per bar; prefers-reduced-motion skips the stagger and renders bars at full height immediately. Zero dependencies, pure SVG."
  },
  "type": "registry:ui"
}