{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-viewer",
  "title": "Medical image viewer",
  "description": "Scroll through CT or MRI slices with window and level, zoom and pan, and read DICOM files in the browser.",
  "dependencies": [
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "progress",
    "select",
    "slider",
    "toggle",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/base-nova/image-viewer/image-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\"\nimport { Button } from \"@/components/ui/button\"\nimport { Progress } from \"@/components/ui/progress\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { Slider } from \"@/components/ui/slider\"\nimport { Toggle } from \"@/components/ui/toggle\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/**\n * One image of a stack. Pass raw values, such as Hounsfield units, as `data`\n * in row-major order, or an image URL whose luminance (0 to 255) becomes the\n * value. Image URLs must allow CORS so their pixels can be read.\n */\nexport type ImageSlice =\n  | { src: string; label?: string }\n  | {\n      data: ArrayLike<number>\n      width: number\n      height: number\n      label?: string\n    }\n\n/** Display window: values from center - width / 2 to center + width / 2 map to black through white. */\nexport type ImageWindow = { center: number; width: number }\n\nexport type WindowPreset = ImageWindow & { name: string }\n\n/** Common CT windows in Hounsfield units. */\nexport const ctWindowPresets: readonly WindowPreset[] = [\n  { name: \"Brain\", center: 40, width: 80 },\n  { name: \"Soft tissue\", center: 50, width: 400 },\n  { name: \"Lung\", center: -600, width: 1500 },\n  { name: \"Bone\", center: 400, width: 1800 },\n]\n\nexport type ImageViewerHandle = {\n  setSlice: (index: number) => void\n  zoom: (factor: number) => void\n  reset: () => void\n}\n\nexport type ImageViewerTool = \"window\" | \"pan\"\n\nexport type ImageViewerProps = {\n  slices: readonly ImageSlice[]\n  /** Zero-based index of the shown slice, when controlled. */\n  slice?: number\n  defaultSlice?: number\n  onSliceChange?: (index: number) => void\n  window?: ImageWindow\n  /** Defaults to the first preset, or else the first slice's value range. */\n  defaultWindow?: ImageWindow\n  onWindowChange?: (window: ImageWindow) => void\n  /** Named windows offered in the toolbar, such as `ctWindowPresets`. */\n  presets?: readonly WindowPreset[]\n  /** Physical [row, column] spacing, as in DICOM Pixel Spacing, so pixels keep their true aspect. */\n  pixelSpacing?: readonly [number, number]\n  /** Unit shown after pixel values, such as \"HU\". */\n  unit?: string\n  /** Shows the toolbar and slice slider. Defaults to true. */\n  controls?: boolean\n  label?: string\n  className?: string\n  ref?: React.Ref<ImageViewerHandle>\n}\n\n/**\n * Maps a value to a gray level from 0 to 255 with the DICOM linear window\n * function (PS3.3 C.11.2.1.2).\n */\nexport function windowLevel(value: number, { center, width }: ImageWindow) {\n  const w = Math.max(width, 1)\n  const low = center - 0.5 - (w - 1) / 2\n  if (value <= low) return 0\n  if (value > center - 0.5 + (w - 1) / 2) return 255\n  return ((value - (center - 0.5)) / (w - 1) + 0.5) * 255\n}\n\ntype Pixels = {\n  values: ArrayLike<number>\n  width: number\n  height: number\n  min: number\n  max: number\n}\n\nfunction range(values: ArrayLike<number>) {\n  let min = Infinity\n  let max = -Infinity\n  for (let i = 0; i < values.length; i++) {\n    const value = values[i]!\n    if (value < min) min = value\n    if (value > max) max = value\n  }\n  return { min, max }\n}\n\nasync function decode(slice: ImageSlice): Promise<Pixels> {\n  if (\"data\" in slice) {\n    const { data, width, height } = slice\n    if (data.length !== width * height)\n      throw new Error(\n        `Slice data has ${data.length} values for a ${width} × ${height} image.`\n      )\n    return { values: data, width, height, ...range(data) }\n  }\n  const image = new Image()\n  image.crossOrigin = \"anonymous\"\n  image.src = slice.src\n  try {\n    await image.decode()\n  } catch {\n    throw new Error(`The image at ${slice.src} could not be loaded.`)\n  }\n  const { naturalWidth: width, naturalHeight: height } = image\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = width\n  canvas.height = height\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true })\n  if (!context) throw new Error(\"Canvas is unavailable in this browser.\")\n  context.drawImage(image, 0, 0)\n  const rgba = context.getImageData(0, 0, width, height).data\n  const values = new Uint8Array(width * height)\n  for (let i = 0; i < values.length; i++)\n    values[i] = Math.round(\n      0.299 * rgba[i * 4]! + 0.587 * rgba[i * 4 + 1]! + 0.114 * rgba[i * 4 + 2]!\n    )\n  return { values, width, height, ...range(values) }\n}\n\nconst MIN_ZOOM = 0.5\nconst MAX_ZOOM = 16\n// Slices ahead and behind the shown one to decode in the background.\nconst PRELOAD = 3\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max)\n}\n\nfunction formatValue(value: number) {\n  return Math.abs(value) >= 10 || Number.isInteger(value)\n    ? String(Math.round(value))\n    : value.toFixed(2)\n}\n\nconst iconProps = {\n  width: 16,\n  height: 16,\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n}\n\nfunction ToolbarButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string\n  onClick: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            aria-label={label}\n            onClick={onClick}\n          />\n        }\n      >\n        {children}\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\nfunction ToolToggle({\n  label,\n  pressed,\n  onPressedChange,\n  children,\n}: {\n  label: string\n  pressed: boolean\n  onPressedChange: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Toggle\n            aria-label={label}\n            pressed={pressed}\n            onPressedChange={onPressedChange}\n          />\n        }\n      >\n        {children}\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\nexport function ImageViewer({\n  slices,\n  slice: sliceProp,\n  defaultSlice = 0,\n  onSliceChange,\n  window: windowProp,\n  defaultWindow,\n  onWindowChange,\n  presets = [],\n  pixelSpacing,\n  unit,\n  controls = true,\n  label = \"Medical image\",\n  className,\n  ref,\n}: ImageViewerProps) {\n  const stage = React.useRef<HTMLDivElement>(null)\n  const canvas = React.useRef<HTMLCanvasElement>(null)\n  const instructions = React.useId()\n  const sliceLabel = React.useId()\n  const count = slices.length\n\n  const [ownSlice, setOwnSlice] = React.useState(defaultSlice)\n  const index = clamp(\n    Math.round(sliceProp ?? ownSlice),\n    0,\n    Math.max(count - 1, 0)\n  )\n  const changeSlice = React.useCallback(\n    (next: number) => {\n      const target = clamp(Math.round(next), 0, Math.max(count - 1, 0))\n      setOwnSlice(target)\n      if (target !== index) onSliceChange?.(target)\n    },\n    [count, index, onSliceChange]\n  )\n\n  // Decoded slices, keyed by the slice objects themselves so new data reloads.\n  const cache = React.useRef(new WeakMap<ImageSlice, Promise<Pixels>>())\n  const load = React.useCallback((slice: ImageSlice) => {\n    let pending = cache.current.get(slice)\n    if (!pending) {\n      pending = decode(slice)\n      pending.catch(() => cache.current.delete(slice))\n      cache.current.set(slice, pending)\n    }\n    return pending\n  }, [])\n  const [pixels, setPixels] = React.useState<Pixels | null>(null)\n  const [attempt, setAttempt] = React.useState(0)\n  const [failure, setFailure] = React.useState<{\n    slice: ImageSlice\n    attempt: number\n    message: string\n  } | null>(null)\n  const [autoWindow, setAutoWindow] = React.useState<ImageWindow | null>(null)\n  const current = slices[index]\n  const error =\n    failure && failure.slice === current && failure.attempt === attempt\n      ? failure.message\n      : null\n  React.useEffect(() => {\n    if (!current) return\n    let active = true\n    load(current).then(\n      (result) => {\n        if (!active) return\n        setPixels(result)\n        // Without a window or presets, show the first slice's full range.\n        setAutoWindow(\n          (previous) =>\n            previous ?? {\n              center: (result.min + result.max) / 2,\n              width: Math.max(result.max - result.min, 1),\n            }\n        )\n        for (let offset = 1; offset <= PRELOAD; offset++)\n          for (const neighbour of [\n            slices[index + offset],\n            slices[index - offset],\n          ])\n            if (neighbour) load(neighbour).catch(() => {})\n      },\n      (reason: unknown) => {\n        if (active)\n          setFailure({\n            slice: current,\n            attempt,\n            message: reason instanceof Error ? reason.message : String(reason),\n          })\n      }\n    )\n    return () => {\n      active = false\n    }\n  }, [current, slices, index, load, attempt])\n\n  const [ownWindow, setOwnWindow] = React.useState<ImageWindow | null>(\n    defaultWindow ?? presets[0] ?? null\n  )\n  const shownWindow = windowProp ?? ownWindow ?? autoWindow\n  const changeWindow = React.useCallback(\n    (next: ImageWindow) => {\n      const value = { center: next.center, width: Math.max(next.width, 1) }\n      setOwnWindow(value)\n      onWindowChange?.(value)\n    },\n    [onWindowChange]\n  )\n  const preset =\n    presets.find(\n      ({ center, width }) =>\n        shownWindow &&\n        Math.abs(center - shownWindow.center) < 1e-6 &&\n        Math.abs(width - shownWindow.width) < 1e-6\n    )?.name ?? null\n\n  const [view, setView] = React.useState({ zoom: 1, x: 0, y: 0 })\n  const [size, setSize] = React.useState({ width: 0, height: 0 })\n  const [tool, setTool] = React.useState<ImageViewerTool>(\"window\")\n  const [hover, setHover] = React.useState<{\n    x: number\n    y: number\n    value: number\n  } | null>(null)\n  const [interacted, setInteracted] = React.useState(false)\n\n  React.useEffect(() => {\n    const element = stage.current\n    if (!element) return\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry)\n        setSize({\n          width: entry.contentRect.width,\n          height: entry.contentRect.height,\n        })\n    })\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [])\n\n  // Physical size of a pixel relative to the smaller spacing.\n  const [rowSpacing, columnSpacing] = pixelSpacing ?? [1, 1]\n  const unitSpacing = Math.min(rowSpacing, columnSpacing)\n  const scaleX = columnSpacing / unitSpacing\n  const scaleY = rowSpacing / unitSpacing\n  const fit = pixels\n    ? Math.min(\n        size.width / (pixels.width * scaleX),\n        size.height / (pixels.height * scaleY)\n      )\n    : 1\n\n  // The windowed slice, rendered once per slice or window change.\n  const bitmap = React.useMemo(() => {\n    if (!pixels || !shownWindow || typeof document === \"undefined\") return null\n    const { values, width, height } = pixels\n    const element = document.createElement(\"canvas\")\n    element.width = width\n    element.height = height\n    const context = element.getContext(\"2d\")\n    if (!context) return null\n    const image = context.createImageData(width, height)\n    for (let i = 0; i < values.length; i++) {\n      const gray = windowLevel(values[i]!, shownWindow)\n      image.data[i * 4] = gray\n      image.data[i * 4 + 1] = gray\n      image.data[i * 4 + 2] = gray\n      image.data[i * 4 + 3] = 255\n    }\n    context.putImageData(image, 0, 0)\n    return element\n  }, [pixels, shownWindow])\n\n  React.useLayoutEffect(() => {\n    const element = canvas.current\n    if (!element) return\n    const ratio = globalThis.devicePixelRatio || 1\n    element.width = Math.round(size.width * ratio)\n    element.height = Math.round(size.height * ratio)\n    const context = element.getContext(\"2d\")\n    if (!context) return\n    context.setTransform(1, 0, 0, 1, 0, 0)\n    context.fillStyle = \"#000\"\n    context.fillRect(0, 0, element.width, element.height)\n    if (!bitmap) return\n    const scale = fit * view.zoom\n    context.setTransform(\n      ratio * scale * scaleX,\n      0,\n      0,\n      ratio * scale * scaleY,\n      ratio * (size.width / 2 + view.x),\n      ratio * (size.height / 2 + view.y)\n    )\n    // Show individual pixels once they are large enough to inspect.\n    context.imageSmoothingEnabled = scale * Math.min(scaleX, scaleY) < 8\n    context.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2)\n  }, [bitmap, size, fit, view, scaleX, scaleY])\n\n  // Image pixel under a point relative to the stage's top-left corner.\n  const pixelAt = (x: number, y: number) => {\n    if (!pixels) return null\n    const scale = fit * view.zoom\n    const column = Math.floor(\n      (x - size.width / 2 - view.x) / (scale * scaleX) + pixels.width / 2\n    )\n    const row = Math.floor(\n      (y - size.height / 2 - view.y) / (scale * scaleY) + pixels.height / 2\n    )\n    if (column < 0 || row < 0 || column >= pixels.width || row >= pixels.height)\n      return null\n    return {\n      x: column,\n      y: row,\n      value: pixels.values[row * pixels.width + column]!,\n    }\n  }\n\n  const zoomAt = React.useCallback(\n    (factor: number, x = 0, y = 0) =>\n      setView((previous) => {\n        const zoom = clamp(previous.zoom * factor, MIN_ZOOM, MAX_ZOOM)\n        const ratio = zoom / previous.zoom\n        return {\n          zoom,\n          x: x - (x - previous.x) * ratio,\n          y: y - (y - previous.y) * ratio,\n        }\n      }),\n    []\n  )\n  const reset = React.useCallback(() => setView({ zoom: 1, x: 0, y: 0 }), [])\n\n  // Wheel scrolls slices; a pinch or ctrl + wheel zooms at the pointer. React\n  // wheel listeners are passive, so this one is added directly.\n  const wheel = React.useRef({ changeSlice, index, zoomAt, delta: 0 })\n  React.useEffect(() => {\n    Object.assign(wheel.current, { changeSlice, index, zoomAt })\n  }, [changeSlice, index, zoomAt])\n  React.useEffect(() => {\n    const element = stage.current\n    if (!element) return\n    const onWheel = (event: WheelEvent) => {\n      event.preventDefault()\n      setInteracted(true)\n      const state = wheel.current\n      const pixels =\n        event.deltaMode === WheelEvent.DOM_DELTA_LINE\n          ? event.deltaY * 40\n          : event.deltaY\n      if (event.ctrlKey || event.metaKey) {\n        const box = element.getBoundingClientRect()\n        state.zoomAt(\n          Math.exp(-pixels / 200),\n          event.clientX - box.left - box.width / 2,\n          event.clientY - box.top - box.height / 2\n        )\n        return\n      }\n      state.delta += pixels\n      const steps = Math.trunc(state.delta / 40)\n      if (steps) {\n        state.delta -= steps * 40\n        state.changeSlice(state.index + steps)\n      }\n    }\n    element.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => element.removeEventListener(\"wheel\", onWheel)\n  }, [])\n\n  const pointers = React.useRef(new Map<number, { x: number; y: number }>())\n  const drag = React.useRef<{\n    mode: ImageViewerTool | \"pinch\"\n    window: ImageWindow | null\n    distance: number\n  } | null>(null)\n\n  const local = (event: React.PointerEvent) => {\n    const box = event.currentTarget.getBoundingClientRect()\n    return { x: event.clientX - box.left, y: event.clientY - box.top }\n  }\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    // Only the image itself drags; the toolbar and messages keep their own\n    // pointer handling.\n    if (\n      !pixels ||\n      event.target !== canvas.current ||\n      (event.pointerType === \"mouse\" && event.button > 1)\n    )\n      return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    pointers.current.set(event.pointerId, local(event))\n    setInteracted(true)\n    setHover(null)\n    const points = [...pointers.current.values()]\n    if (points.length === 2) {\n      drag.current = {\n        mode: \"pinch\",\n        window: null,\n        distance: Math.hypot(\n          points[0]!.x - points[1]!.x,\n          points[0]!.y - points[1]!.y\n        ),\n      }\n      return\n    }\n    drag.current = {\n      mode:\n        event.button === 1 || event.shiftKey || tool === \"pan\"\n          ? \"pan\"\n          : \"window\",\n      window: shownWindow,\n      distance: 0,\n    }\n  }\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const point = local(event)\n    const previous = pointers.current.get(event.pointerId)\n    if (!previous || !drag.current) {\n      if (event.pointerType === \"mouse\") setHover(pixelAt(point.x, point.y))\n      return\n    }\n    pointers.current.set(event.pointerId, point)\n    const dx = point.x - previous.x\n    const dy = point.y - previous.y\n    const state = drag.current\n    if (state.mode === \"pinch\") {\n      const points = [...pointers.current.values()]\n      if (points.length < 2) return\n      const distance = Math.hypot(\n        points[0]!.x - points[1]!.x,\n        points[0]!.y - points[1]!.y\n      )\n      if (state.distance > 0)\n        zoomAt(\n          distance / state.distance,\n          (points[0]!.x + points[1]!.x) / 2 - size.width / 2,\n          (points[0]!.y + points[1]!.y) / 2 - size.height / 2\n        )\n      state.distance = distance\n    } else if (state.mode === \"pan\") {\n      setView((view) => ({ ...view, x: view.x + dx, y: view.y + dy }))\n    } else if (state.window) {\n      // Right widens the window and down raises the level, in proportion to\n      // the current width so narrow and wide windows both feel responsive.\n      const step = Math.max(state.window.width, 1) / 200\n      state.window = {\n        center: state.window.center + dy * step,\n        width: state.window.width * Math.exp(dx / 200),\n      }\n      changeWindow(state.window)\n    }\n  }\n\n  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    pointers.current.delete(event.pointerId)\n    if (pointers.current.size === 0) drag.current = null\n    else if (drag.current?.mode === \"pinch\")\n      drag.current = { mode: \"pan\", window: null, distance: 0 }\n  }\n\n  React.useImperativeHandle(\n    ref,\n    () => ({ setSlice: changeSlice, zoom: (factor) => zoomAt(factor), reset }),\n    [changeSlice, zoomAt, reset]\n  )\n\n  const message =\n    count === 0\n      ? { title: \"No images\", text: \"This series has no slices to show.\" }\n      : error\n        ? {\n            title: \"The image couldn’t load\",\n            text: `${error} Check your connection and try again.`,\n          }\n        : null\n  const unitText = unit ? ` ${unit}` : \"\"\n  const name = current?.label\n\n  return (\n    <div\n      className={cn(\n        \"flex h-[28rem] w-full flex-col overflow-hidden rounded-xl border bg-background text-sm text-foreground\",\n        className\n      )}\n    >\n      <div\n        ref={stage}\n        role=\"region\"\n        aria-label={label}\n        aria-describedby={instructions}\n        tabIndex={0}\n        className=\"relative min-h-0 flex-1 touch-none overflow-hidden bg-black select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n        style={{\n          cursor: !pixels ? undefined : tool === \"pan\" ? \"grab\" : \"crosshair\",\n        }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onPointerLeave={() => setHover(null)}\n        onKeyDown={(event) => {\n          if (event.target !== event.currentTarget) return\n          const actions: Record<string, () => void> = {\n            ArrowUp: () => changeSlice(index - 1),\n            ArrowDown: () => changeSlice(index + 1),\n            PageUp: () => changeSlice(index - 10),\n            PageDown: () => changeSlice(index + 10),\n            Home: () => changeSlice(0),\n            End: () => changeSlice(count - 1),\n            \"+\": () => zoomAt(1.25),\n            \"=\": () => zoomAt(1.25),\n            \"-\": () => zoomAt(0.8),\n            \"0\": reset,\n          }\n          const action = actions[event.key]\n          if (!action) return\n          event.preventDefault()\n          setInteracted(true)\n          action()\n        }}\n      >\n        <p id={instructions} className=\"sr-only\">\n          Use the up and down arrow keys or scroll to move through slices, and\n          plus, minus and zero to zoom and reset. Drag to adjust the window, or\n          to pan when the pan tool is on.\n        </p>\n        <canvas\n          ref={canvas}\n          role=\"img\"\n          aria-label={\n            count\n              ? `${label}, slice ${index + 1} of ${count}${name ? `, ${name}` : \"\"}`\n              : label\n          }\n          className=\"absolute inset-0 size-full\"\n        />\n        {message ? (\n          <div className=\"absolute inset-0 flex items-center justify-center p-6\">\n            <Alert className=\"max-w-sm\">\n              <svg {...iconProps}>\n                <circle cx=\"12\" cy=\"12\" r=\"10\" />\n                <path d=\"M12 8v4M12 16h.01\" />\n              </svg>\n              <AlertTitle>{message.title}</AlertTitle>\n              <AlertDescription>\n                <p>{message.text}</p>\n                {error && (\n                  <Button\n                    variant=\"outline\"\n                    size=\"sm\"\n                    onClick={() => setAttempt((n) => n + 1)}\n                    className=\"mt-3 text-foreground\"\n                  >\n                    Try again\n                  </Button>\n                )}\n              </AlertDescription>\n            </Alert>\n          </div>\n        ) : !pixels ? (\n          <div\n            role=\"status\"\n            className=\"absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-white/80\"\n          >\n            <Progress\n              value={null}\n              aria-label=\"Loading image\"\n              className=\"w-40\"\n            />\n            <p>Loading image</p>\n          </div>\n        ) : null}\n        {pixels && (\n          <>\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute bottom-2 left-3 flex flex-col text-xs text-white/85 tabular-nums [text-shadow:0_1px_2px_#000]\"\n            >\n              <span>\n                {name ? `${name} · ` : \"\"}\n                {index + 1} / {count}\n              </span>\n              {hover && (\n                <span>\n                  {hover.x}, {hover.y} · {formatValue(hover.value)}\n                  {unitText}\n                </span>\n              )}\n              {shownWindow && (\n                <span>\n                  W {formatValue(shownWindow.width)} · L{\" \"}\n                  {formatValue(shownWindow.center)}\n                </span>\n              )}\n            </div>\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute right-3 bottom-2 text-xs text-white/85 tabular-nums [text-shadow:0_1px_2px_#000]\"\n            >\n              {Math.round(view.zoom * 100)}%\n            </span>\n            {!interacted && (\n              <p\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-x-0 bottom-16 hidden text-center text-xs text-white/70 [text-shadow:0_1px_2px_#000] sm:block\"\n              >\n                Scroll for slices · Drag to window · Ctrl + scroll to zoom\n              </p>\n            )}\n          </>\n        )}\n        {pixels && controls && (\n          <TooltipProvider>\n            <div\n              role=\"toolbar\"\n              aria-label=\"Image controls\"\n              className=\"absolute top-2 right-2 flex max-w-[calc(100%-1rem)] flex-wrap items-center justify-end gap-0.5 rounded-lg border bg-background/90 p-1 shadow-sm backdrop-blur\"\n            >\n              {presets.length > 0 && (\n                <Select\n                  value={preset}\n                  onValueChange={(next) => {\n                    const match = presets.find(({ name }) => name === next)\n                    if (match)\n                      changeWindow({ center: match.center, width: match.width })\n                  }}\n                >\n                  <SelectTrigger\n                    aria-label=\"Window preset\"\n                    className=\"border-transparent\"\n                  >\n                    <SelectValue placeholder=\"Custom\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {presets.map(({ name }) => (\n                      <SelectItem key={name} value={name}>\n                        {name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              )}\n              <ToolToggle\n                label=\"Window tool\"\n                pressed={tool === \"window\"}\n                onPressedChange={() => setTool(\"window\")}\n              >\n                <svg {...iconProps}>\n                  <circle cx=\"12\" cy=\"12\" r=\"9\" />\n                  <path d=\"M12 3a9 9 0 0 1 0 18Z\" fill=\"currentColor\" />\n                </svg>\n              </ToolToggle>\n              <ToolToggle\n                label=\"Pan tool\"\n                pressed={tool === \"pan\"}\n                onPressedChange={() => setTool(\"pan\")}\n              >\n                <svg {...iconProps}>\n                  <path d=\"M12 2v20M2 12h20M12 2l-3 3M12 2l3 3M12 22l-3-3M12 22l3-3M2 12l3-3M2 12l3 3M22 12l-3-3M22 12l-3 3\" />\n                </svg>\n              </ToolToggle>\n              <ToolbarButton label=\"Zoom in\" onClick={() => zoomAt(1.25)}>\n                <svg {...iconProps}>\n                  <path d=\"M5 12h14M12 5v14\" />\n                </svg>\n              </ToolbarButton>\n              <ToolbarButton label=\"Zoom out\" onClick={() => zoomAt(0.8)}>\n                <svg {...iconProps}>\n                  <path d=\"M5 12h14\" />\n                </svg>\n              </ToolbarButton>\n              <ToolbarButton label=\"Reset view\" onClick={reset}>\n                <svg {...iconProps}>\n                  <path d=\"M3 12a9 9 0 1 0 3-6.7L3 8\" />\n                  <path d=\"M3 3v5h5\" />\n                </svg>\n              </ToolbarButton>\n            </div>\n          </TooltipProvider>\n        )}\n      </div>\n      {controls && count > 1 && (\n        <div className=\"flex items-center gap-3 border-t px-3 py-2.5\">\n          <span id={sliceLabel} className=\"text-xs font-medium\">\n            Slice\n          </span>\n          <Slider\n            aria-labelledby={sliceLabel}\n            min={1}\n            max={count}\n            // An array, so shadcn's slider renders a single thumb.\n            value={[index + 1]}\n            onValueChange={(next) =>\n              changeSlice((Array.isArray(next) ? next[0]! : next) - 1)\n            }\n            className=\"flex-1\"\n          />\n          <span className=\"min-w-14 text-right text-xs text-muted-foreground tabular-nums\">\n            {index + 1} / {count}\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/base-nova/image-viewer/dicom.ts",
      "content": "// A small reader for uncompressed, single-frame grayscale DICOM files, such\n// as most CT and MR series. It is not a validated medical device and reads\n// only what the image viewer needs.\n\nexport type DicomImage = {\n  /** Modality values (Hounsfield units for CT): stored values after rescale slope and intercept. */\n  data: Float32Array\n  width: number\n  height: number\n  /** [row, column] spacing in millimetres, from Pixel Spacing. */\n  pixelSpacing?: [number, number]\n  /** The first Window Center and Width stored in the file. */\n  window?: { center: number; width: number }\n  /** Image Position (Patient), Slice Location and Instance Number, used to order a series. */\n  position?: [number, number, number]\n  sliceLocation?: number\n  instanceNumber?: number\n  modality?: string\n  label?: string\n}\n\nconst IMPLICIT_LITTLE = \"1.2.840.10008.1.2\"\nconst EXPLICIT_LITTLE = \"1.2.840.10008.1.2.1\"\n// Explicit VRs with a reserved two bytes and a four-byte length.\nconst LONG_VRS = new Set([\n  \"OB\",\n  \"OD\",\n  \"OF\",\n  \"OL\",\n  \"OV\",\n  \"OW\",\n  \"SQ\",\n  \"SV\",\n  \"UC\",\n  \"UN\",\n  \"UR\",\n  \"UT\",\n  \"UV\",\n])\nconst UNDEFINED = 0xffffffff\nconst ITEM = 0xfffee000\nconst ITEM_END = 0xfffee00d\nconst SEQUENCE_END = 0xfffee0dd\n\nconst tag = (group: number, element: number) => ((group << 16) | element) >>> 0\n\nconst TAGS = {\n  modality: tag(0x0008, 0x0060),\n  seriesDescription: tag(0x0008, 0x103e),\n  instanceNumber: tag(0x0020, 0x0013),\n  position: tag(0x0020, 0x0032),\n  sliceLocation: tag(0x0020, 0x1041),\n  samplesPerPixel: tag(0x0028, 0x0002),\n  photometric: tag(0x0028, 0x0004),\n  frames: tag(0x0028, 0x0008),\n  rows: tag(0x0028, 0x0010),\n  columns: tag(0x0028, 0x0011),\n  pixelSpacing: tag(0x0028, 0x0030),\n  bitsAllocated: tag(0x0028, 0x0100),\n  bitsStored: tag(0x0028, 0x0101),\n  pixelRepresentation: tag(0x0028, 0x0103),\n  windowCenter: tag(0x0028, 0x1050),\n  windowWidth: tag(0x0028, 0x1051),\n  intercept: tag(0x0028, 0x1052),\n  slope: tag(0x0028, 0x1053),\n  pixelData: tag(0x7fe0, 0x0010),\n}\n\ntype Element = { offset: number; length: number }\n\nfunction parseElements(view: DataView, start: number, explicit: boolean) {\n  const elements = new Map<number, Element>()\n  const text = (offset: number) =>\n    String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1))\n\n  // Returns the offset after the element at `offset`, recording top-level ones.\n  const read = (offset: number, record: boolean) => {\n    const id = tag(\n      view.getUint16(offset, true),\n      view.getUint16(offset + 2, true)\n    )\n    let length: number\n    let vr: string | null = null\n    let cursor = offset + 4\n    if (id === ITEM || id === ITEM_END || id === SEQUENCE_END) {\n      length = view.getUint32(cursor, true)\n      cursor += 4\n    } else if (explicit) {\n      vr = text(cursor)\n      if (LONG_VRS.has(vr)) {\n        length = view.getUint32(cursor + 4, true)\n        cursor += 8\n      } else {\n        length = view.getUint16(cursor + 2, true)\n        cursor += 4\n      }\n    } else {\n      length = view.getUint32(cursor, true)\n      cursor += 4\n    }\n    if (length === UNDEFINED) {\n      if (id === tag(0x7fe0, 0x0010))\n        throw new Error(\n          \"Compressed (encapsulated) pixel data is not supported.\"\n        )\n      cursor = skipUndefined(cursor)\n      return cursor\n    }\n    if (record) elements.set(id, { offset: cursor, length })\n    return cursor + length\n  }\n\n  // Skips an undefined-length sequence or item up to its delimiter.\n  const skipUndefined = (offset: number): number => {\n    let cursor = offset\n    while (cursor + 8 <= view.byteLength) {\n      const id = tag(\n        view.getUint16(cursor, true),\n        view.getUint16(cursor + 2, true)\n      )\n      if (id === SEQUENCE_END || id === ITEM_END) return cursor + 8\n      if (id === ITEM) {\n        const length = view.getUint32(cursor + 4, true)\n        cursor =\n          length === UNDEFINED ? skipUndefined(cursor + 8) : cursor + 8 + length\n        continue\n      }\n      cursor = read(cursor, false)\n    }\n    throw new Error(\"The file ends inside a sequence.\")\n  }\n\n  let cursor = start\n  while (cursor + 8 <= view.byteLength) {\n    cursor = read(cursor, true)\n  }\n  return elements\n}\n\n/** Reads one DICOM file (Part 10, with its 128-byte preamble) into raw values. */\nexport function parseDicom(buffer: ArrayBuffer): DicomImage {\n  const view = new DataView(buffer)\n  const bytes = new Uint8Array(buffer)\n  const magic = String.fromCharCode(...bytes.subarray(128, 132))\n  if (magic !== \"DICM\") throw new Error(\"This is not a DICOM Part 10 file.\")\n\n  const decoder = new TextDecoder(\"latin1\")\n  const decode = (offset: number, length: number) =>\n    decoder\n      .decode(bytes.subarray(offset, offset + length))\n      .replace(/[\\0\\s]+$/, \"\")\n      .trim()\n  // The meta group (0002) is always explicit little endian and states the\n  // transfer syntax of the dataset after it.\n  let syntax = EXPLICIT_LITTLE\n  let start = 132\n  while (start + 8 <= view.byteLength && view.getUint16(start, true) === 2) {\n    const vr = String.fromCharCode(bytes[start + 4]!, bytes[start + 5]!)\n    const long = LONG_VRS.has(vr)\n    const length = long\n      ? view.getUint32(start + 8, true)\n      : view.getUint16(start + 6, true)\n    const value = start + (long ? 12 : 8)\n    if (view.getUint16(start + 2, true) === 0x0010)\n      syntax = decode(value, length)\n    start = value + length\n  }\n  if (syntax !== IMPLICIT_LITTLE && syntax !== EXPLICIT_LITTLE)\n    throw new Error(\n      `Transfer syntax ${syntax} is not supported. Only uncompressed little endian files can be read.`\n    )\n  const elements = parseElements(view, start, syntax === EXPLICIT_LITTLE)\n\n  const string = (id: number) => {\n    const element = elements.get(id)\n    return element ? decode(element.offset, element.length) : undefined\n  }\n  const uint16 = (id: number) => {\n    const element = elements.get(id)\n    return element ? view.getUint16(element.offset, true) : undefined\n  }\n  const numbers = (id: number) =>\n    string(id)\n      ?.split(\"\\\\\")\n      .map(Number)\n      .filter((value) => Number.isFinite(value))\n\n  const width = uint16(TAGS.columns)\n  const height = uint16(TAGS.rows)\n  const bitsAllocated = uint16(TAGS.bitsAllocated) ?? 16\n  const signed = uint16(TAGS.pixelRepresentation) === 1\n  const pixelData = elements.get(TAGS.pixelData)\n  if (!width || !height || !pixelData) throw new Error(\"The file has no image.\")\n  if ((uint16(TAGS.samplesPerPixel) ?? 1) !== 1)\n    throw new Error(\"Only grayscale images are supported.\")\n  if (Number(string(TAGS.frames) ?? 1) > 1)\n    throw new Error(\"Multi-frame images are not supported.\")\n  if (bitsAllocated !== 8 && bitsAllocated !== 16)\n    throw new Error(`${bitsAllocated}-bit pixels are not supported.`)\n\n  const count = width * height\n  if (pixelData.length < count * (bitsAllocated / 8))\n    throw new Error(\"The pixel data is shorter than the image.\")\n  const bitsStored = uint16(TAGS.bitsStored) ?? bitsAllocated\n  const slope = numbers(TAGS.slope)?.[0] ?? 1\n  const intercept = numbers(TAGS.intercept)?.[0] ?? 0\n  const photometric = string(TAGS.photometric) ?? \"MONOCHROME2\"\n  if (photometric !== \"MONOCHROME1\" && photometric !== \"MONOCHROME2\")\n    throw new Error(`${photometric} images are not supported.`)\n  // MONOCHROME1 shows low values as white; negate so the viewer's black-to-\n  // white window keeps the same appearance.\n  const sign = photometric === \"MONOCHROME1\" ? -1 : 1\n  const data = new Float32Array(count)\n  const shift = 32 - bitsStored\n  for (let i = 0; i < count; i++) {\n    let stored =\n      bitsAllocated === 8\n        ? bytes[pixelData.offset + i]!\n        : view.getUint16(pixelData.offset + i * 2, true)\n    // Keep only the stored bits, sign-extending when they are signed.\n    stored = signed ? (stored << shift) >> shift : (stored << shift) >>> shift\n    data[i] = sign * (stored * slope + intercept)\n  }\n\n  const image: DicomImage = { data, width, height }\n  const spacing = numbers(TAGS.pixelSpacing)\n  if (spacing?.length === 2) image.pixelSpacing = [spacing[0]!, spacing[1]!]\n  const center = numbers(TAGS.windowCenter)?.[0]\n  const windowWidth = numbers(TAGS.windowWidth)?.[0]\n  if (center !== undefined && windowWidth !== undefined)\n    image.window = { center: sign * center, width: windowWidth }\n  const position = numbers(TAGS.position)\n  if (position?.length === 3)\n    image.position = [position[0]!, position[1]!, position[2]!]\n  const location = numbers(TAGS.sliceLocation)?.[0]\n  if (location !== undefined) image.sliceLocation = location\n  const instance = numbers(TAGS.instanceNumber)?.[0]\n  if (instance !== undefined) image.instanceNumber = instance\n  const modality = string(TAGS.modality)\n  if (modality) image.modality = modality\n  const description = string(TAGS.seriesDescription)\n  if (description) image.label = description\n  return image\n}\n\n/**\n * Orders the images of one series from head to foot (or by Slice Location,\n * then Instance Number) and returns props for the image viewer.\n */\nexport function dicomSeries(images: readonly DicomImage[]) {\n  const key = (image: DicomImage) =>\n    image.position\n      ? -image.position[2]\n      : image.sliceLocation !== undefined\n        ? -image.sliceLocation\n        : (image.instanceNumber ?? 0)\n  const slices = [...images].sort((a, b) => key(a) - key(b))\n  const first = slices[0]\n  return {\n    slices,\n    pixelSpacing: first?.pixelSpacing,\n    window: first?.window,\n    unit: first?.modality === \"CT\" ? \"HU\" : undefined,\n  }\n}\n",
      "type": "registry:ui",
      "target": "lib/dicom.ts"
    },
    {
      "path": "registry/base-nova/image-viewer/head-phantom.ts",
      "content": "// A synthetic head CT for trying the image viewer without patient data: the\n// 3D Shepp–Logan phantom (Kak and Slaney, Principles of Computerized\n// Tomographic Imaging, 1988), with each ellipsoid given an approximate\n// Hounsfield value. It is generated, so it carries no licence terms or\n// patient information.\n\ntype Ellipsoid = {\n  /** Semi-axes, centre and rotation about the vertical axis in degrees, in a -1 to 1 cube. */\n  axes: [number, number, number]\n  center: [number, number, number]\n  angle: number\n  /** Added to the value of everything inside, in Hounsfield units. */\n  value: number\n}\n\nconst ELLIPSOIDS: readonly Ellipsoid[] = [\n  // Skull, then the brain inside it.\n  { axes: [0.69, 0.92, 0.81], center: [0, 0, 0], angle: 0, value: 1800 },\n  {\n    axes: [0.6624, 0.874, 0.78],\n    center: [0, -0.0184, 0],\n    angle: 0,\n    value: -760,\n  },\n  // Ventricles.\n  { axes: [0.11, 0.31, 0.22], center: [0.22, 0, 0], angle: -18, value: -32 },\n  { axes: [0.16, 0.41, 0.28], center: [-0.22, 0, 0], angle: 18, value: -32 },\n  // Small lesions.\n  { axes: [0.21, 0.25, 0.41], center: [0, 0.35, -0.15], angle: 0, value: 22 },\n  { axes: [0.046, 0.046, 0.05], center: [0, 0.1, 0.25], angle: 0, value: 30 },\n  { axes: [0.046, 0.046, 0.05], center: [0, -0.1, 0.25], angle: 0, value: 30 },\n  {\n    axes: [0.046, 0.023, 0.05],\n    center: [-0.08, -0.605, 0],\n    angle: 0,\n    value: 30,\n  },\n  { axes: [0.023, 0.023, 0.02], center: [0, -0.606, 0], angle: 0, value: 30 },\n  {\n    axes: [0.023, 0.046, 0.02],\n    center: [0.06, -0.605, 0],\n    angle: 0,\n    value: 30,\n  },\n]\n\nconst AIR = -1000\n\nexport type HeadPhantomOptions = {\n  /** Pixels per side. Defaults to 128. */\n  size?: number\n  /** Number of axial slices. Defaults to 32. */\n  slices?: number\n  /** Standard deviation of the added noise in Hounsfield units. Defaults to 6. */\n  noise?: number\n}\n\n/**\n * Axial slices of the phantom from the top of the head down, as props for\n * the image viewer: raw values in Hounsfield units and their pixel spacing.\n */\nexport function createHeadPhantom({\n  size = 128,\n  slices = 32,\n  noise = 6,\n}: HeadPhantomOptions = {}) {\n  // A fixed seed keeps the noise the same on every render and in tests.\n  let seed = 0x2f6b\n  const random = () => {\n    seed = (seed * 1664525 + 1013904223) >>> 0\n    return seed / 2 ** 32\n  }\n  const gaussian = () =>\n    Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random())\n  const shapes = ELLIPSOIDS.map(({ axes, center, angle, value }) => ({\n    axes,\n    center,\n    cos: Math.cos((angle * Math.PI) / 180),\n    sin: Math.sin((angle * Math.PI) / 180),\n    value,\n  }))\n  const extent = 0.9\n  const result = []\n  for (let k = 0; k < slices; k++) {\n    // From near the vertex (z = 0.75) to below the skull base (z = -0.75).\n    const z = slices === 1 ? 0 : 0.75 - (1.5 * k) / (slices - 1)\n    const data = new Int16Array(size * size)\n    for (let row = 0; row < size; row++) {\n      // Rows run anterior (top of the image) to posterior.\n      const y = extent - (2 * extent * (row + 0.5)) / size\n      for (let column = 0; column < size; column++) {\n        const x = -extent + (2 * extent * (column + 0.5)) / size\n        let value = AIR\n        for (const shape of shapes) {\n          const dx = x - shape.center[0]\n          const dy = y - shape.center[1]\n          const dz = z - shape.center[2]\n          const u = (dx * shape.cos + dy * shape.sin) / shape.axes[0]\n          const v = (-dx * shape.sin + dy * shape.cos) / shape.axes[1]\n          const w = dz / shape.axes[2]\n          if (u * u + v * v + w * w <= 1) value += shape.value\n        }\n        data[row * size + column] = Math.round(\n          value + (value > AIR ? noise * gaussian() : 0)\n        )\n      }\n    }\n    result.push({\n      data,\n      width: size,\n      height: size,\n      label: `Axial ${k + 1}`,\n    })\n  }\n  // The phantom spans about 240 mm across.\n  const spacing = 240 / size\n  return {\n    slices: result,\n    pixelSpacing: [spacing, spacing] as [number, number],\n    unit: \"HU\",\n  }\n}\n",
      "type": "registry:ui",
      "target": "lib/head-phantom.ts"
    }
  ],
  "docs": "Medical image viewer docs: https://organui.com/docs/components/image-viewer. Needs one of shadcn's Base UI styles, such as base-nova: its controls use Base UI's render prop, so on a Radix style its buttons do nothing. The DICOM reader (lib/dicom.ts) handles uncompressed, single-frame grayscale files only; use a full DICOM toolkit for compressed, color or multi-frame images. Not a medical device.",
  "categories": [
    "imaging"
  ],
  "type": "registry:ui"
}