{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "db-scale",
  "title": "dB Scale",
  "description": "Tick marks and labels for a decibel range, shared by meters and faders.",
  "registryDependencies": [
    "@audiocn/core",
    "@audiocn/use-audio-config"
  ],
  "files": [
    {
      "path": "components/ui/db-scale.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport type { ComponentProps, CSSProperties } from \"react\";\n\nimport { useAudioConfig } from \"@/hooks/use-audio-config\";\nimport { DEFAULT_MAX_DB, DEFAULT_MIN_DB, formatDb } from \"@/lib/audio/decibels\";\nimport { resolveTaper } from \"@/lib/audio/taper\";\nimport type { TaperInput } from \"@/lib/audio/taper\";\nimport type { Orientation, Taper } from \"@/lib/audio/types\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_TICKS = [12, 6, 0, -6, -12, -18, -24, -36, -48, -60, -72, -90];\nconst EDGE = 0.02;\n/** Space kept between two labels, in pixels. */\nconst LABEL_GAP = 3;\nconst MAJOR_STEP = 12;\n\n/**\n * Hides labels that would touch a more important one, so narrow scales stay\n * readable: 0 dB first, then the ends, then multiples of 12, then the rest.\n */\nexport const thinDbScaleLabels = (scale: HTMLElement) => {\n  const horizontal = scale.dataset.orientation !== \"vertical\";\n  const entries: { label: HTMLElement; rank: number; value: number }[] = [];\n  for (const tick of scale.querySelectorAll<HTMLElement>(\n    \"[data-slot=db-scale-tick]\"\n  )) {\n    const label = tick.querySelector<HTMLElement>(\"[data-slot=db-scale-label]\");\n    if (label) {\n      delete label.dataset.hidden;\n      entries.push({ label, rank: 3, value: Number(tick.dataset.value) });\n    }\n  }\n  const values = entries.map((entry) => entry.value);\n  const top = Math.max(...values);\n  const bottom = Math.min(...values);\n  for (const entry of entries) {\n    if (entry.value === 0) {\n      entry.rank = 0;\n    } else if (entry.value === top || entry.value === bottom) {\n      entry.rank = 1;\n    } else if (entry.value % MAJOR_STEP === 0) {\n      entry.rank = 2;\n    }\n  }\n  entries.sort((a, b) => a.rank - b.rank || b.value - a.value);\n\n  const kept: [number, number][] = [];\n  for (const { label } of entries) {\n    const rect = label.getBoundingClientRect();\n    // Not laid out, so there is nothing to compare.\n    if (rect.width === 0 && rect.height === 0) {\n      continue;\n    }\n    const start = horizontal ? rect.left : rect.top;\n    const end = horizontal ? rect.right : rect.bottom;\n    const collides = kept.some(\n      ([keptStart, keptEnd]) =>\n        start < keptEnd + LABEL_GAP && end > keptStart - LABEL_GAP\n    );\n    if (collides) {\n      label.dataset.hidden = \"\";\n    } else {\n      kept.push([start, end]);\n    }\n  }\n};\n\nconst defaultFormat = (db: number) =>\n  formatDb(db, { decimals: 0, unit: false });\n\ninterface DbScaleContextValue {\n  orientation: Orientation;\n  side: \"start\" | \"end\";\n  labels: boolean;\n  taper: Taper;\n  format: (db: number) => string;\n}\n\nconst DbScaleContext = createContext<DbScaleContextValue | null>(null);\n\nconst useDbScale = () => {\n  const context = useContext(DbScaleContext);\n  if (!context) {\n    throw new Error(\"DbScaleTick must be used inside DbScale.\");\n  }\n  return context;\n};\n\n/** Keeps labels at the ends of the scale inside it. */\nconst alignClass = (orientation: Orientation, position: number) => {\n  if (orientation === \"vertical\") {\n    if (position < EDGE) {\n      return \"translate-y-0\";\n    }\n    return position > 1 - EDGE ? \"translate-y-full\" : \"translate-y-1/2\";\n  }\n  if (position < EDGE) {\n    return \"translate-x-0\";\n  }\n  return position > 1 - EDGE ? \"-translate-x-full\" : \"-translate-x-1/2\";\n};\n\nconst markClass = (horizontal: boolean, major: boolean) => {\n  if (horizontal) {\n    return major ? \"h-1.5 w-px\" : \"h-1 w-px\";\n  }\n  return major ? \"h-px w-1.5\" : \"h-px w-1\";\n};\n\nexport interface DbScaleTickProps extends ComponentProps<\"div\"> {\n  value: number;\n  /** Major ticks are longer. Default true. */\n  major?: boolean;\n}\n\nexport const DbScaleTick = ({\n  value,\n  major = true,\n  className,\n  children,\n  style,\n  ...props\n}: DbScaleTickProps) => {\n  const { format, labels, orientation, side, taper } = useDbScale();\n  const position = taper.toPosition(value);\n  const horizontal = orientation === \"horizontal\";\n  const reversed = side === \"start\";\n\n  return (\n    <div\n      className={cn(\n        \"absolute flex items-center gap-0.5\",\n        horizontal\n          ? \"top-0 left-(--tick-position) h-full flex-col\"\n          : \"bottom-(--tick-position) left-0 w-full flex-row\",\n        reversed && (horizontal ? \"flex-col-reverse\" : \"flex-row-reverse\"),\n        alignClass(orientation, position),\n        className\n      )}\n      data-major={major ? \"\" : undefined}\n      data-slot=\"db-scale-tick\"\n      data-value={value}\n      style={\n        { \"--tick-position\": `${position * 100}%`, ...style } as CSSProperties\n      }\n      {...props}\n    >\n      <span\n        className={cn(\"bg-border shrink-0\", markClass(horizontal, major))}\n        data-slot=\"db-scale-mark\"\n      />\n      {labels ? (\n        <span\n          className={cn(\n            \"data-hidden:invisible\",\n            // Right-aligned so the last digits of a vertical scale line up.\n            !horizontal && \"flex-1 text-end\"\n          )}\n          data-slot=\"db-scale-label\"\n        >\n          {children ?? format(value)}\n        </span>\n      ) : null}\n    </div>\n  );\n};\n\nexport interface DbScaleProps extends ComponentProps<\"div\"> {\n  /** Bottom of the range. Default −60, or the surrounding meter's range. */\n  minDb?: number;\n  /** Top of the range. Default 0, or the surrounding meter's range. */\n  maxDb?: number;\n  /** Tick values. Default: common values inside the range. */\n  ticks?: number[];\n  /** Position law, so ticks line up with a fader or meter using the same taper. */\n  taper?: TaperInput;\n  orientation?: Orientation;\n  /** Which side of the tick marks the labels sit on. Default `end`. */\n  side?: \"start\" | \"end\";\n  /** Show labels. Default true. */\n  labels?: boolean;\n  format?: (db: number) => string;\n}\n\nexport const DbScale = ({\n  minDb: minDbProp,\n  maxDb: maxDbProp,\n  ticks,\n  taper = \"linear\",\n  orientation: orientationProp,\n  side = \"end\",\n  labels = true,\n  format = defaultFormat,\n  className,\n  children,\n  ref,\n  ...props\n}: DbScaleProps) => {\n  const config = useAudioConfig();\n  const minDb = minDbProp ?? config.minDb ?? DEFAULT_MIN_DB;\n  const maxDb = maxDbProp ?? config.maxDb ?? DEFAULT_MAX_DB;\n  const orientation = orientationProp ?? config.orientation ?? \"horizontal\";\n\n  const value = useMemo<DbScaleContextValue>(\n    () => ({\n      format,\n      labels,\n      orientation,\n      side,\n      taper: resolveTaper(taper, minDb, maxDb),\n    }),\n    [format, labels, maxDb, minDb, orientation, side, taper]\n  );\n\n  const values =\n    ticks ?? DEFAULT_TICKS.filter((tick) => tick >= minDb && tick <= maxDb);\n\n  const scaleRef = useRef<HTMLDivElement | null>(null);\n  const setScaleRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      scaleRef.current = node;\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref]\n  );\n\n  // Re-check label collisions when the scale resizes or its ticks change.\n  useLayoutEffect(() => {\n    const scale = scaleRef.current;\n    if (!(scale && labels)) {\n      return;\n    }\n    let cancelled = false;\n    const thin = () => thinDbScaleLabels(scale);\n    thin();\n    const resize =\n      typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(thin);\n    resize?.observe(scale);\n    // Only style changes: hiding a label must not trigger another pass.\n    const ticksChanged = new MutationObserver(thin);\n    ticksChanged.observe(scale, {\n      attributeFilter: [\"style\"],\n      characterData: true,\n      childList: true,\n      subtree: true,\n    });\n    const afterFonts = async () => {\n      await document.fonts?.ready;\n      if (!cancelled) {\n        thin();\n      }\n    };\n    afterFonts();\n    return () => {\n      cancelled = true;\n      resize?.disconnect();\n      ticksChanged.disconnect();\n    };\n  }, [labels]);\n\n  return (\n    <DbScaleContext.Provider value={value}>\n      <div\n        aria-hidden\n        className={cn(\n          \"text-muted-foreground relative shrink-0 text-[0.625rem] leading-none tabular-nums select-none\",\n          orientation === \"horizontal\" ? \"h-4 w-full\" : \"h-full w-7\",\n          className\n        )}\n        data-orientation={orientation}\n        data-side={side}\n        data-slot=\"db-scale\"\n        ref={setScaleRef}\n        {...props}\n      >\n        {children ??\n          values.map((tick) => <DbScaleTick key={tick} value={tick} />)}\n      </div>\n    </DbScaleContext.Provider>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}