{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "linked-bars",
  "title": "Linked Bars",
  "description": "A table and a 3D bar chart that share one highlight.",
  "dependencies": [
    "three",
    "@react-three/fiber"
  ],
  "devDependencies": [
    "@types/three"
  ],
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/gl-stage.json",
    "https://spotcheckui.com/r/use-gl-palette.json",
    "https://spotcheckui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/linked-bars.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useFrame, useThree, type ThreeEvent } from \"@react-three/fiber\"\nimport { BoxGeometry, Color, type InstancedMesh, MeshLambertMaterial, Object3D } from \"three\"\n\nimport { GlStage } from \"@/registry/spotcheck/components/spotcheck/gl-stage\"\nimport { useGlPalette } from \"@/registry/spotcheck/hooks/use-gl-palette\"\nimport { useReducedMotion } from \"@/registry/spotcheck/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\nconst SPACING = 0.9\nconst BAR_WIDTH = 0.7\nconst MAX_HEIGHT = 3.1\nconst RAISED = 1.12\nconst DIMMED = 0.45\nconst HEIGHT_EASE = 0.12\nconst SETTLED = 0.001\nconst TIP_OFFSET = 12\n// Below this width-to-height ratio the camera zooms out so the bars stay inside the canvas.\nconst FIT_ASPECT = 1.55\nconst PALETTE_TOKENS = [\"--ink\", \"--wash\", \"--accent-ink\"] as const\nconst CAMERA = { fov: 30, position: [8.5, 8, 10], near: 0.1, far: 100 } as const\nconst LOOK_AT: [number, number, number] = [0, 0.9, 0]\n\ntype Palette = Record<(typeof PALETTE_TOKENS)[number], string>\ntype Tip = { row: number; column: number; x: number; y: number }\n\n/** How tall a bar should stand, given which row is highlighted. */\nexport function barHeight(value: number, largest: number, row: number, highlight: number | null) {\n  const height = largest > 0 ? (value / largest) * MAX_HEIGHT : 0\n  if (highlight === null) return height\n  return height * (row === highlight ? RAISED : DIMMED)\n}\n\n/** Splits an instance id into the row and column of its bar. */\nexport function barAt(instanceId: number, columns: number) {\n  return { row: Math.floor(instanceId / columns), column: instanceId % columns }\n}\n\ntype BarsProps = {\n  values: number[][]\n  highlight: number | null\n  still: boolean\n  palette: Palette | null\n  onBarMove: (bar: ThreeEvent<PointerEvent>) => void\n  onBarLeave: () => void\n  onBarTap: (bar: ThreeEvent<MouseEvent>) => void\n}\n\nfunction Bars({ values, highlight, still, palette, onBarMove, onBarLeave, onBarTap }: BarsProps) {\n  const rows = values.length\n  const columns = values[0]?.length ?? 0\n  const count = rows * columns\n  const largest = Math.max(0, ...values.flat())\n  const mesh = React.useRef<InstancedMesh>(null)\n  const invalidate = useThree((state) => state.invalidate)\n  const camera = useThree((state) => state.camera)\n  const aspect = useThree((state) => state.viewport.aspect)\n\n  const [geometry, material, floor, floorMaterial] = React.useMemo(() => {\n    const box = new BoxGeometry(SPACING * BAR_WIDTH, 1, SPACING * BAR_WIDTH)\n    box.translate(0, 0.5, 0)\n    return [box, new MeshLambertMaterial({ color: 0xffffff }), new BoxGeometry(1, 0.04, 1), new MeshLambertMaterial()] as const\n  }, [])\n\n  const scratch = React.useMemo(\n    () => ({\n      heights: new Float32Array(count),\n      dummy: new Object3D(),\n      color: new Color(),\n      ink: new Color(),\n      accent: new Color(),\n      wash: new Color(),\n    }),\n    [count],\n  )\n\n  React.useEffect(() => {\n    return () => {\n      geometry.dispose()\n      material.dispose()\n      floor.dispose()\n      floorMaterial.dispose()\n    }\n  }, [floor, floorMaterial, geometry, material])\n\n  React.useEffect(() => {\n    if (!palette) return\n    scratch.ink.set(palette[\"--ink\"])\n    scratch.accent.set(palette[\"--accent-ink\"])\n    scratch.wash.set(palette[\"--wash\"])\n    floorMaterial.color.set(palette[\"--wash\"])\n    invalidate()\n  }, [floorMaterial, invalidate, palette, scratch])\n\n  React.useEffect(() => invalidate(), [highlight, invalidate, values])\n\n  React.useLayoutEffect(() => {\n    camera.zoom = Math.min(1, aspect / FIT_ASPECT)\n    camera.updateProjectionMatrix()\n    invalidate()\n  }, [aspect, camera, invalidate])\n\n  useFrame(() => {\n    const bars = mesh.current\n    if (!bars) return\n    const { heights, dummy, color, ink, accent, wash } = scratch\n    let moving = false\n\n    for (let row = 0, index = 0; row < rows; row++) {\n      for (let column = 0; column < columns; column++, index++) {\n        const value = values[row]?.[column] ?? 0\n        const target = barHeight(value, largest, row, highlight)\n        const current = heights[index] ?? 0\n        const next = still || Math.abs(target - current) < SETTLED ? target : current + (target - current) * HEIGHT_EASE\n        if (next !== target) moving = true\n        heights[index] = next\n\n        dummy.position.set((column - (columns - 1) / 2) * SPACING, 0, (row - (rows - 1) / 2) * SPACING)\n        dummy.scale.set(1, Math.max(0.01, next), 1)\n        dummy.updateMatrix()\n        bars.setMatrixAt(index, dummy.matrix)\n\n        color.copy(wash).lerp(ink, 0.35 + (largest > 0 ? value / largest : 0) * 0.65)\n        if (row === highlight) color.copy(accent)\n        else if (highlight !== null) color.lerp(wash, 0.6)\n        bars.setColorAt(index, color)\n      }\n    }\n    bars.instanceMatrix.needsUpdate = true\n    if (bars.instanceColor) bars.instanceColor.needsUpdate = true\n    // Raycasts test this sphere first, so it has to follow the bars as they grow.\n    bars.computeBoundingSphere()\n    if (moving) invalidate()\n  })\n\n  return (\n    <>\n      <hemisphereLight args={[0xffffff, 0x666666, 1.25]} />\n      <directionalLight position={[-4, 9, 5]} intensity={1.2} />\n      <mesh\n        geometry={floor}\n        material={floorMaterial}\n        position={[0, -0.03, 0]}\n        scale={[columns * SPACING + 0.4, 1, rows * SPACING + 0.4]}\n      />\n      <instancedMesh\n        key={count}\n        ref={mesh}\n        args={[geometry, material, count]}\n        frustumCulled={false}\n        onPointerMove={onBarMove}\n        onPointerOut={onBarLeave}\n        onClick={onBarTap}\n      />\n    </>\n  )\n}\n\ntype LinkedBarsProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Row names, in the same order as `values`. */\n  rows: string[]\n  /** Column names, in the same order as each row of `values`. */\n  columns: string[]\n  /** One array per row, holding one number per column. */\n  values: number[][]\n  /** Printed above the table, and names it for screen readers. */\n  title: string\n  /** Describes the chart for screen readers. */\n  label?: string\n  /** The highlighted row, when you control it. Use `null` for no highlight. */\n  highlight?: number | null\n  /** Runs when the highlighted row changes. */\n  onHighlightChange?: (row: number | null) => void\n  /** Formats each value. Pass it from a client component. */\n  format?: (value: number) => string\n}\n\nexport function LinkedBars({\n  rows,\n  columns,\n  values,\n  title,\n  label = \"3D bar chart of the table's values\",\n  highlight,\n  onHighlightChange,\n  format = (value) => String(value),\n  className,\n  ...props\n}: LinkedBarsProps) {\n  const host = React.useRef<HTMLDivElement>(null)\n  const frame = React.useRef<HTMLDivElement>(null)\n  const reduced = useReducedMotion()\n  const palette = useGlPalette(host, PALETTE_TOKENS)\n  const titleId = React.useId()\n\n  // Hover and focus come and go, while a tap stays until it's cleared.\n  const [hovered, setHovered] = React.useState<number | null>(null)\n  const [pinned, setPinned] = React.useState<number | null>(null)\n  const [tip, setTip] = React.useState<Tip | null>(null)\n  const own = hovered ?? pinned\n  const active = highlight !== undefined ? highlight : own\n\n  const reported = React.useRef(own)\n  React.useEffect(() => {\n    if (reported.current !== own) onHighlightChange?.(own)\n    reported.current = own\n  }, [onHighlightChange, own])\n\n  const tipFrom = (event: ThreeEvent<PointerEvent | MouseEvent>) => {\n    const box = frame.current?.getBoundingClientRect()\n    if (!box || event.instanceId === undefined) return null\n    const clamp = Math.min(event.nativeEvent.clientX - box.left + TIP_OFFSET, box.width / 2)\n    return { ...barAt(event.instanceId, columns.length), x: clamp, y: event.nativeEvent.clientY - box.top - TIP_OFFSET }\n  }\n\n  const onBarMove = (event: ThreeEvent<PointerEvent>) => {\n    event.stopPropagation()\n    if (event.nativeEvent.pointerType !== \"mouse\") return\n    const next = tipFrom(event)\n    if (!next) return\n    setTip(next)\n    setHovered(next.row)\n  }\n\n  const onBarLeave = () => {\n    setHovered(null)\n    setTip((current) => (pinned === null ? null : current))\n  }\n\n  const onBarTap = (event: ThreeEvent<MouseEvent>) => {\n    event.stopPropagation()\n    const next = tipFrom(event)\n    if (!next) return\n    setPinned(next.row)\n    setTip(next)\n  }\n\n  const clearPin = () => {\n    setPinned(null)\n    setTip(null)\n  }\n\n  return (\n    <div\n      className={cn(\n        \"border-ink grid min-w-0 border-(length:--rule) font-(family-name:--f-body) md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        tabIndex={0}\n        role=\"region\"\n        aria-labelledby={titleId}\n        className=\"spotcheck-focus border-ink order-2 min-w-0 overflow-x-auto border-t-(length:--rule) -outline-offset-2 md:order-1 md:border-t-0 md:border-r-(length:--rule)\"\n      >\n        <table className=\"w-full border-collapse font-(family-name:--f-mono) text-[11.5px] tabular-nums\">\n          <caption id={titleId} className=\"spotcheck-label px-2.5 pt-2.5 pb-1.5 text-left\">\n            {title}\n          </caption>\n          <thead>\n            <tr className=\"border-ink border-b-(length:--rule)\">\n              <th scope=\"col\" className=\"spotcheck-label bg-raise sticky left-0 px-2.5 py-1.5 text-left font-normal\">\n                <span className=\"sr-only\">Row</span>\n              </th>\n              {columns.map((column) => (\n                <th key={column} scope=\"col\" className=\"spotcheck-label px-1.5 py-1.5 text-right font-normal\">\n                  {column}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((row, index) => (\n              <tr\n                key={row}\n                data-active={active === index || undefined}\n                className=\"data-active:bg-ink data-active:text-paper border-b border-[color-mix(in_srgb,var(--ink)_18%,transparent)]\"\n                onPointerEnter={(event) => event.pointerType === \"mouse\" && setHovered(index)}\n                onPointerLeave={(event) => event.pointerType === \"mouse\" && setHovered(null)}\n              >\n                <th scope=\"row\" className=\"bg-raise sticky left-0 p-0 text-left in-data-active:bg-ink\">\n                  <button\n                    type=\"button\"\n                    aria-pressed={pinned === index}\n                    className=\"spotcheck-heading w-full cursor-pointer px-2.5 py-2 text-left text-[15px] leading-none outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--accent-ink)\"\n                    onFocus={(event) => event.currentTarget.matches(\":focus-visible\") && setHovered(index)}\n                    onBlur={() => setHovered(null)}\n                    onClick={() => {\n                      setPinned(pinned === index ? null : index)\n                      setTip(null)\n                    }}\n                  >\n                    {row}\n                  </button>\n                </th>\n                {columns.map((column, cell) => (\n                  <td\n                    key={column}\n                    data-cell={tip?.row === index && tip.column === cell ? \"\" : undefined}\n                    className=\"data-cell:text-accent-on-ink px-1.5 py-2 text-right\"\n                  >\n                    {format(values[index]?.[cell] ?? 0)}\n                  </td>\n                ))}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n\n      <div ref={frame} className=\"relative order-1 min-w-0 md:order-2\">\n        <GlStage\n          ref={host}\n          data-testid=\"linked-bars-chart\"\n          label={label}\n          animate={false}\n          camera={CAMERA}\n          lookAt={LOOK_AT}\n          className=\"h-75 md:h-full md:min-h-90\"\n          onPointerMissed={clearPin}\n        >\n          <Bars\n            values={values}\n            highlight={active}\n            still={reduced}\n            palette={palette}\n            onBarMove={onBarMove}\n            onBarLeave={onBarLeave}\n            onBarTap={onBarTap}\n          />\n        </GlStage>\n        {tip && (\n          <div\n            aria-hidden\n            data-testid=\"linked-bars-tip\"\n            className=\"bg-ink text-paper pointer-events-none absolute z-10 px-2 py-1 font-(family-name:--f-mono) text-[11.5px] whitespace-nowrap\"\n            style={{ left: tip.x, top: tip.y }}\n          >\n            {rows[tip.row]} · {columns[tip.column]} · {format(values[tip.row]?.[tip.column] ?? 0)}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "depth"
  ],
  "type": "registry:component"
}