{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fear-greed-gauge",
  "title": "Fear & Greed Gauge",
  "description": "Crypto Fear & Greed index dial with live data from alternative.me.",
  "dependencies": [
    "@number-flow/react",
    "framer-motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/voraui/fear-greed-gauge/index.ts",
      "content": "export { FearGreedGauge, type FearGreedGaugeProps } from \"./fear-greed-gauge\";\nexport { FearGreedGaugeSkeleton } from \"./components/skeleton\";\nexport type { FearGreedData } from \"./hooks/use-fear-greed\";\n",
      "type": "registry:lib",
      "target": "components/voraui/fear-greed-gauge/index.ts"
    },
    {
      "path": "registry/voraui/fear-greed-gauge/fear-greed-gauge.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\nimport { animate, useMotionValue } from \"framer-motion\";\nimport NumberFlow from \"@number-flow/react\";\nimport { cn } from \"@/lib/utils\";\nimport { FearGreedGaugeSkeleton } from \"./components/skeleton\";\nimport { useFearGreed, type FearGreedData } from \"./hooks/use-fear-greed\";\nimport {\n  DEFAULT_FEAR_GREED_BANDS,\n  GAUGE_CENTER_X,\n  GAUGE_CENTER_Y,\n  GRADIENT_STOPS,\n  WEDGE_GAP,\n  WEDGE_HUB_RADIUS,\n  WEDGE_INNER_RADIUS,\n  WEDGE_OUTER_RADIUS,\n  WEDGES_VIEWBOX_HEIGHT,\n  angleForValue,\n  arcPoint,\n  colorForValue,\n  describeArc,\n  describeWedge,\n  equalizedValue,\n  findFearGreedBand,\n} from \"./lib/fear-greed-bands\";\n\nexport interface FearGreedGaugeProps {\n  /** Provide your own data to bypass the bundled alternative.me fetcher. */\n  data?: FearGreedData;\n  /** \"gradient\" (default) smooth blend; \"minimal\" 5 discrete bands; \"ticks\" 100 tick marks; \"wedges\" CNN-style zone sectors. */\n  variant?: \"minimal\" | \"ticks\" | \"gradient\" | \"wedges\";\n  /** Spring the needle in from neutral on first render instead of snapping. */\n  animateOnLoad?: boolean;\n  className?: string;\n}\n\nconst TICKS_MAJOR_VALUES = [0, 25, 50, 75, 100];\nconst TICKS_FINE_VALUES = Array.from({ length: 100 }, (_, i) => i + 1);\n\nconst WEDGE_LABEL_RADIUS = (WEDGE_OUTER_RADIUS + WEDGE_INNER_RADIUS) / 2;\nconst WEDGE_BAND_SHARE = 100 / DEFAULT_FEAR_GREED_BANDS.length;\n// Equalized wedge corners are real band boundaries, so numbers sit at the corners.\nconst WEDGE_SCALE_NUMBER_VALUES = [0, 25, 45, 55, 75, 100];\n// Decorative dots at thirds between the corner numbers (display positions, not values).\nconst WEDGE_SCALE_DOT_DISPLAY_VALUES = DEFAULT_FEAR_GREED_BANDS.flatMap((_, i) => [\n  (i + 1 / 3) * WEDGE_BAND_SHARE,\n  (i + 2 / 3) * WEDGE_BAND_SHARE,\n]);\nconst WEDGE_SCALE_DOT_RADIUS = 48;\nconst WEDGE_SCALE_NUMBER_RADIUS = 56;\n\nfunction labelAnchor(value: number): \"start\" | \"middle\" | \"end\" {\n  if (value < 45) return \"start\";\n  if (value > 55) return \"end\";\n  return \"middle\";\n}\n\nexport function FearGreedGauge({\n  data,\n  variant = \"gradient\",\n  animateOnLoad = true,\n  className,\n}: FearGreedGaugeProps) {\n  const gradientId = useId();\n  const fetched = useFearGreed({ enabled: data === undefined });\n  const resolved = data ?? fetched.data;\n  const loading = data === undefined && fetched.loading;\n  const value = resolved?.value ?? null;\n  const label = resolved?.label ?? \"Unknown\";\n  const hasError = data === undefined && Boolean(fetched.error);\n  // The wedges dial is drawn in equalized space; remap the needle to match.\n  const dialValue = value !== null && variant === \"wedges\" ? equalizedValue(value) : value;\n  const needleRotation = dialValue !== null ? 90 - angleForValue(dialValue) : 0;\n  const activeBand = variant === \"wedges\" && value !== null ? findFearGreedBand(value) : null;\n\n  // Drive the SVG rotate() attribute imperatively: framer's transform pipeline\n  // drops 3-arg SVG rotate() and would pivot around the wrong origin.\n  const needleGroupRef = useRef<SVGGElement | null>(null);\n  const needleMV = useMotionValue(needleRotation);\n  useEffect(() => {\n    return needleMV.on(\"change\", (r) => {\n      needleGroupRef.current?.setAttribute(\"transform\", `rotate(${r} ${GAUGE_CENTER_X} ${GAUGE_CENTER_Y})`);\n    });\n  }, [needleMV]);\n  const hasLoadedOnceRef = useRef(false);\n  useEffect(() => {\n    const justLoaded = value !== null && !hasLoadedOnceRef.current;\n    if (value !== null) hasLoadedOnceRef.current = true;\n\n    if (justLoaded && !animateOnLoad) {\n      needleMV.set(needleRotation);\n      return;\n    }\n    const controls = animate(needleMV, needleRotation, { type: \"spring\", stiffness: 100 });\n    return controls.stop;\n  }, [needleRotation, value, animateOnLoad, needleMV]);\n\n  if (loading) {\n    return <FearGreedGaugeSkeleton variant={variant} className={className} />;\n  }\n\n  return (\n    <div className={cn(\"relative flex flex-col items-center\", className)}>\n      <div className=\"relative w-full max-w-[300px]\">\n        <svg\n          viewBox={variant === \"wedges\" ? `0 0 260 ${WEDGES_VIEWBOX_HEIGHT}` : \"0 0 260 158\"}\n          className=\"w-full\"\n          aria-hidden=\"true\"\n        >\n          {variant === \"minimal\" &&\n            DEFAULT_FEAR_GREED_BANDS.map((band) => (\n              <path\n                key={band.key}\n                d={describeArc(90, band.min, band.max)}\n                fill=\"none\"\n                stroke={band.color}\n                strokeWidth={12}\n                strokeLinecap=\"round\"\n              />\n            ))}\n\n          {variant === \"gradient\" && (\n            <>\n              <defs>\n                <linearGradient id={gradientId} x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"0%\">\n                  {GRADIENT_STOPS.map((stop) => (\n                    <stop key={stop.value} offset={`${stop.value}%`} stopColor={stop.color} />\n                  ))}\n                </linearGradient>\n              </defs>\n              <path\n                d={describeArc(90, 0, 100)}\n                fill=\"none\"\n                stroke={`url(#${gradientId})`}\n                strokeWidth={12}\n                strokeLinecap=\"round\"\n              />\n            </>\n          )}\n\n          {variant === \"ticks\" && (\n            <>\n              {TICKS_FINE_VALUES.map((v) => {\n                const inner = arcPoint(96, v);\n                const outer = arcPoint(103, v);\n                return (\n                  <line\n                    key={`fine-${v}`}\n                    x1={inner.x}\n                    y1={inner.y}\n                    x2={outer.x}\n                    y2={outer.y}\n                    strokeWidth={1.2}\n                    stroke={colorForValue(v)}\n                  />\n                );\n              })}\n              {TICKS_MAJOR_VALUES.map((v) => {\n                const inner = arcPoint(92, v);\n                const outer = arcPoint(107, v);\n                return (\n                  <line\n                    key={`major-${v}`}\n                    x1={inner.x}\n                    y1={inner.y}\n                    x2={outer.x}\n                    y2={outer.y}\n                    strokeWidth={2}\n                    stroke={colorForValue(v)}\n                  />\n                );\n              })}\n              {TICKS_MAJOR_VALUES.map((v) => {\n                const p = arcPoint(118, v);\n                // The horizontal 0/100 end labels overlap their ticks; push them down a line.\n                const isEdge = v === 0 || v === 100;\n                return (\n                  <text\n                    key={`num-${v}`}\n                    x={p.x}\n                    y={isEdge ? p.y + 14 : p.y}\n                    textAnchor={labelAnchor(v)}\n                    dominantBaseline=\"middle\"\n                    className=\"fill-muted-foreground text-[9px] font-medium tabular-nums\"\n                  >\n                    {v}\n                  </text>\n                );\n              })}\n            </>\n          )}\n\n          {variant === \"wedges\" && (\n            <>\n              {DEFAULT_FEAR_GREED_BANDS.map((band) => {\n                const isActive = activeBand?.key === band.key;\n                return (\n                  <path\n                    key={band.key}\n                    d={describeWedge(\n                      WEDGE_OUTER_RADIUS,\n                      WEDGE_INNER_RADIUS,\n                      equalizedValue(band.min) + WEDGE_GAP,\n                      equalizedValue(band.max) - WEDGE_GAP,\n                    )}\n                    fill={isActive ? band.color : undefined}\n                    fillOpacity={isActive ? 0.25 : undefined}\n                    stroke={isActive ? band.color : undefined}\n                    strokeWidth={isActive ? 1.5 : undefined}\n                    className={isActive ? undefined : \"fill-muted stroke-border\"}\n                  />\n                );\n              })}\n              {DEFAULT_FEAR_GREED_BANDS.map((band) => {\n                const mid = equalizedValue((band.min + band.max) / 2);\n                // textPath clips long labels; rotated text with stacked tspans doesn't.\n                const rotation = 90 - angleForValue(mid);\n                const p = arcPoint(WEDGE_LABEL_RADIUS, mid);\n                const words = band.label.toUpperCase().split(\" \");\n                return (\n                  <text\n                    key={band.key}\n                    textAnchor=\"middle\"\n                    dominantBaseline=\"central\"\n                    transform={`rotate(${rotation} ${p.x} ${p.y})`}\n                    className=\"fill-foreground text-[7.5px] font-extrabold uppercase tracking-wider\"\n                  >\n                    {words.map((word, i) => (\n                      <tspan key={word} x={p.x} y={p.y} dy={`${(i - (words.length - 1) / 2) * 1.3}em`}>\n                        {word}\n                      </tspan>\n                    ))}\n                  </text>\n                );\n              })}\n              {WEDGE_SCALE_DOT_DISPLAY_VALUES.map((d) => {\n                const p = arcPoint(WEDGE_SCALE_DOT_RADIUS, d);\n                return <circle key={`dot-${d}`} cx={p.x} cy={p.y} r={1} className=\"fill-muted-foreground/50\" />;\n              })}\n              {WEDGE_SCALE_NUMBER_VALUES.map((v) => {\n                const isEdge = v === 0 || v === 100;\n                const p = arcPoint(WEDGE_SCALE_NUMBER_RADIUS, equalizedValue(v));\n                return (\n                  <text\n                    key={`scale-${v}`}\n                    x={p.x}\n                    y={isEdge ? p.y - 3 : p.y}\n                    textAnchor={labelAnchor(equalizedValue(v))}\n                    dominantBaseline=\"middle\"\n                    className=\"fill-muted-foreground text-[8px] font-medium tabular-nums\"\n                  >\n                    {v}\n                  </text>\n                );\n              })}\n            </>\n          )}\n\n          {value !== null && (\n            <g ref={needleGroupRef} transform={`rotate(${needleMV.get()} ${GAUGE_CENTER_X} ${GAUGE_CENTER_Y})`}>\n              {variant === \"wedges\" ? (\n                <polygon\n                  points={`${GAUGE_CENTER_X - 4},${GAUGE_CENTER_Y - 58} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 4},${GAUGE_CENTER_Y - 58} ${GAUGE_CENTER_X + 4},${GAUGE_CENTER_Y + 20} ${GAUGE_CENTER_X - 4},${GAUGE_CENTER_Y + 20}`}\n                  fill={activeBand?.color}\n                  className={activeBand ? undefined : \"fill-foreground\"}\n                />\n              ) : (\n                <polygon\n                  points={`${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 72} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y + 15} ${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y + 15}`}\n                  className=\"fill-foreground\"\n                />\n              )}\n              {variant !== \"wedges\" && (\n                <>\n                  <circle cx={GAUGE_CENTER_X} cy={GAUGE_CENTER_Y} r={6} className=\"fill-foreground\" />\n                  <circle cx={GAUGE_CENTER_X} cy={GAUGE_CENTER_Y} r={2.5} className=\"fill-background\" />\n                </>\n              )}\n            </g>\n          )}\n\n          {variant === \"wedges\" && (\n            <circle\n              cx={GAUGE_CENTER_X}\n              cy={GAUGE_CENTER_Y}\n              r={WEDGE_HUB_RADIUS}\n              strokeWidth={1}\n              className=\"fill-background stroke-border\"\n            />\n          )}\n        </svg>\n        {variant === \"wedges\" && (\n          <div\n            className=\"absolute left-1/2 -translate-x-1/2 -translate-y-1/2 text-center\"\n            style={{ top: `${(GAUGE_CENTER_Y / WEDGES_VIEWBOX_HEIGHT) * 100}%` }}\n          >\n            <p className=\"text-3xl font-bold tabular-nums text-foreground\">\n              {value !== null ? <NumberFlow value={value} /> : \"-\"}\n              {/* The zone name only exists in the aria-hidden SVG; repeat it for screen readers. */}\n              {!hasError && <span className=\"sr-only\"> {label}</span>}\n            </p>\n          </div>\n        )}\n      </div>\n      {variant === \"wedges\" ? (\n        hasError && (\n          <p role=\"alert\" className=\"text-xs font-medium text-muted-foreground\">\n            Fear &amp; Greed data is unavailable.\n          </p>\n        )\n      ) : (\n        <div className=\"-mt-4 text-center\">\n          <p className=\"text-3xl font-bold tabular-nums text-foreground\">\n            {value !== null ? <NumberFlow value={value} /> : \"-\"}\n          </p>\n          {hasError ? (\n            <p role=\"alert\" className=\"text-xs font-medium text-muted-foreground\">\n              Fear &amp; Greed data is unavailable.\n            </p>\n          ) : (\n            <p className=\"text-xs font-medium text-muted-foreground\">{label}</p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/voraui/fear-greed-gauge/fear-greed-gauge.tsx"
    },
    {
      "path": "registry/voraui/fear-greed-gauge/components/skeleton.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_FEAR_GREED_BANDS,\n  GAUGE_CENTER_X,\n  GAUGE_CENTER_Y,\n  WEDGE_GAP,\n  WEDGE_HUB_RADIUS,\n  WEDGE_INNER_RADIUS,\n  WEDGE_OUTER_RADIUS,\n  WEDGES_VIEWBOX_HEIGHT,\n  describeArc,\n  describeWedge,\n} from \"../lib/fear-greed-bands\";\n\nexport interface FearGreedGaugeSkeletonProps {\n  /** Matches FearGreedGaugeProps[\"variant\"] so the ghost lines up with the real dial. */\n  variant?: \"minimal\" | \"ticks\" | \"gradient\" | \"wedges\";\n  className?: string;\n}\n\nconst WEDGE_COUNT = DEFAULT_FEAR_GREED_BANDS.length;\n\nexport function FearGreedGaugeSkeleton({ variant = \"gradient\", className }: FearGreedGaugeSkeletonProps) {\n  const isWedges = variant === \"wedges\";\n\n  return (\n    <div\n      role=\"status\"\n      className={cn(\"voraui-fear-greed-gauge-skeleton-shimmer relative flex flex-col items-center overflow-hidden\", className)}\n    >\n      <style href=\"voraui-fear-greed-gauge-skeleton\" precedence=\"low\">{`\n        @keyframes voraui-fear-greed-gauge-skeleton-shimmer {\n          from { transform: translateX(-100%); }\n          to { transform: translateX(100%); }\n        }\n        .voraui-fear-greed-gauge-skeleton-shimmer::after {\n          content: \"\";\n          position: absolute;\n          inset: 0;\n          background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);\n          animation: voraui-fear-greed-gauge-skeleton-shimmer 1.8s ease-in-out infinite;\n        }\n        @media (prefers-reduced-motion: reduce) {\n          .voraui-fear-greed-gauge-skeleton-shimmer::after {\n            animation: none;\n          }\n        }\n      `}</style>\n      <div className=\"relative w-full max-w-[300px]\">\n        <svg\n          viewBox={isWedges ? `0 0 260 ${WEDGES_VIEWBOX_HEIGHT}` : \"0 0 260 158\"}\n          className=\"w-full\"\n          aria-hidden=\"true\"\n        >\n          {isWedges ? (\n            Array.from({ length: WEDGE_COUNT }, (_, i) => {\n              const share = 100 / WEDGE_COUNT;\n              const from = i * share + WEDGE_GAP;\n              const to = (i + 1) * share - WEDGE_GAP;\n              return (\n                <path\n                  key={i}\n                  d={describeWedge(WEDGE_OUTER_RADIUS, WEDGE_INNER_RADIUS, from, to)}\n                  className=\"fill-muted\"\n                />\n              );\n            })\n          ) : (\n            <path\n              d={describeArc(90, 0, 100)}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={12}\n              strokeLinecap=\"round\"\n              className=\"text-muted\"\n            />\n          )}\n          <polygon\n            points={`${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 72} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y + 15} ${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y + 15}`}\n            className=\"fill-muted-foreground/40\"\n          />\n          {isWedges && (\n            <circle\n              cx={GAUGE_CENTER_X}\n              cy={GAUGE_CENTER_Y}\n              r={WEDGE_HUB_RADIUS}\n              strokeWidth={1}\n              className=\"fill-background stroke-border\"\n            />\n          )}\n        </svg>\n        {isWedges && (\n          <div\n            className=\"absolute left-1/2 -translate-x-1/2 -translate-y-1/2\"\n            style={{ top: `${(GAUGE_CENTER_Y / WEDGES_VIEWBOX_HEIGHT) * 100}%` }}\n          >\n            <div className=\"mx-auto h-9 w-14 rounded-md bg-muted\" />\n          </div>\n        )}\n      </div>\n      {!isWedges && (\n        <div className=\"-mt-4 flex flex-col items-center\">\n          <div className=\"h-9 w-16 rounded-md bg-muted\" />\n          <div className=\"h-4 w-20 rounded-full bg-muted\" />\n        </div>\n      )}\n      <span className=\"sr-only\">Loading Fear &amp; Greed Index</span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/voraui/fear-greed-gauge/components/skeleton.tsx"
    },
    {
      "path": "registry/voraui/fear-greed-gauge/hooks/use-fear-greed.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nexport interface FearGreedData {\n  /** 0-100 index value, or null when the upstream has no data. */\n  value: number | null;\n  /** Upstream classification, e.g. \"Fear\", \"Neutral\", \"Greed\". */\n  label: string;\n  /** Upstream unix-seconds timestamp string, if provided. */\n  updatedAt: string | null;\n}\n\nconst FEAR_GREED_URL = \"https://api.alternative.me/fng/?limit=1\";\n\nexport function parseFngResponse(raw: unknown): FearGreedData {\n  const rows = (raw as { data?: Array<Record<string, unknown>> } | null | undefined)?.data ?? [];\n  const row = rows[0];\n  if (!row) return { value: null, label: \"Unknown\", updatedAt: null };\n  const parsed = Number(row.value);\n  return {\n    value: Number.isFinite(parsed) ? parsed : null,\n    label: typeof row.value_classification === \"string\" ? row.value_classification : \"Neutral\",\n    updatedAt: typeof row.timestamp === \"string\" ? row.timestamp : null,\n  };\n}\n\nexport function useFearGreed(\n  options: { enabled?: boolean; refreshInterval?: number } = {},\n) {\n  const { enabled = true, refreshInterval = 600_000 } = options;\n  const [data, setData] = useState<FearGreedData | null>(null);\n  const [loading, setLoading] = useState(enabled);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!enabled) return;\n    let cancelled = false;\n\n    const load = async () => {\n      try {\n        const res = await fetch(FEAR_GREED_URL);\n        if (!res.ok) throw new Error(`Fear & Greed request failed: ${res.status}`);\n        const raw = await res.json();\n        if (cancelled) return;\n        setData(parseFngResponse(raw));\n        setError(null);\n        setLoading(false);\n      } catch (err) {\n        if (cancelled) return;\n        setError(err instanceof Error ? err.message : \"Fear & Greed request failed\");\n        setLoading(false);\n      }\n    };\n\n    load();\n    const timer = setInterval(load, refreshInterval);\n    return () => {\n      cancelled = true;\n      clearInterval(timer);\n    };\n  }, [enabled, refreshInterval]);\n\n  return { data, loading, error };\n}\n",
      "type": "registry:hook",
      "target": "components/voraui/fear-greed-gauge/hooks/use-fear-greed.ts"
    },
    {
      "path": "registry/voraui/fear-greed-gauge/lib/fear-greed-bands.ts",
      "content": "export interface FearGreedBand {\n  key: string;\n  label: string;\n  min: number;\n  max: number;\n  color: string;\n}\n\n/** Boundaries mirror alternative.me's value_classification thresholds. */\nexport const DEFAULT_FEAR_GREED_BANDS: FearGreedBand[] = [\n  { key: \"extreme-fear\", label: \"Extreme Fear\", min: 0, max: 24, color: \"#c0392b\" },\n  { key: \"fear\", label: \"Fear\", min: 25, max: 44, color: \"#e0672b\" },\n  { key: \"neutral\", label: \"Neutral\", min: 45, max: 55, color: \"#f0c929\" },\n  { key: \"greed\", label: \"Greed\", min: 56, max: 75, color: \"#4caf50\" },\n  { key: \"extreme-greed\", label: \"Extreme Greed\", min: 76, max: 100, color: \"#2e7d32\" },\n];\n\nexport const GAUGE_CENTER_X = 130;\nexport const GAUGE_CENTER_Y = 130;\n\n/** Angle in degrees for a 0-100 value along the top semicircle (180 at 0, 0 at 100). */\nexport function angleForValue(value: number): number {\n  const clamped = Math.min(Math.max(value, 0), 100);\n  return 180 - (clamped / 100) * 180;\n}\n\n/** Point at radius for a 0-100 value; rounded to 4 decimals to avoid hydration mismatches. */\nexport function arcPoint(radius: number, value: number): { x: number; y: number } {\n  const rad = (angleForValue(value) * Math.PI) / 180;\n  return {\n    x: Math.round((GAUGE_CENTER_X + radius * Math.cos(rad)) * 10000) / 10000,\n    y: Math.round((GAUGE_CENTER_Y - radius * Math.sin(rad)) * 10000) / 10000,\n  };\n}\n\n/** SVG arc path between two values along the top semicircle. */\nexport function describeArc(radius: number, fromValue: number, toValue: number): string {\n  const start = arcPoint(radius, fromValue);\n  const end = arcPoint(radius, toValue);\n  return `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;\n}\n\n/** SVG path for an annular wedge between two radii and two values. */\nexport function describeWedge(\n  outerRadius: number,\n  innerRadius: number,\n  fromValue: number,\n  toValue: number,\n): string {\n  const outerStart = arcPoint(outerRadius, fromValue);\n  const outerEnd = arcPoint(outerRadius, toValue);\n  const innerEnd = arcPoint(innerRadius, toValue);\n  const innerStart = arcPoint(innerRadius, fromValue);\n  return (\n    `M ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 0 1 ${outerEnd.x} ${outerEnd.y} ` +\n    `L ${innerEnd.x} ${innerEnd.y} A ${innerRadius} ${innerRadius} 0 0 0 ${innerStart.x} ${innerStart.y} Z`\n  );\n}\n\n/** Band for a 0-100 value; out-of-range clamps to the nearest edge band. */\nexport function findFearGreedBand(\n  value: number,\n  bands: FearGreedBand[] = DEFAULT_FEAR_GREED_BANDS,\n): FearGreedBand {\n  const clamped = Math.min(Math.max(value, 0), 100);\n  return bands.find((band) => clamped >= band.min && clamped <= band.max) ?? bands[bands.length - 1];\n}\n\n/** Remap a 0-100 value onto a CNN-style dial where every band gets an equal\n *  angular share; the needle, dots, and scale numbers all use this mapping. */\nexport function equalizedValue(\n  value: number,\n  bands: FearGreedBand[] = DEFAULT_FEAR_GREED_BANDS,\n): number {\n  const clamped = Math.min(Math.max(value, 0), 100);\n  const band = findFearGreedBand(clamped, bands);\n  const index = bands.indexOf(band);\n  const share = 100 / bands.length;\n  return index * share + ((clamped - band.min) / (band.max - band.min)) * share;\n}\n\nexport interface GradientStop {\n  value: number;\n  color: string;\n}\n\n/** Evenly-spaced band colors used to interpolate the per-tick gradient. */\nexport const GRADIENT_STOPS: GradientStop[] = [\n  { value: 0, color: \"#c0392b\" },\n  { value: 25, color: \"#e0672b\" },\n  { value: 50, color: \"#f0c929\" },\n  { value: 75, color: \"#4caf50\" },\n  { value: 100, color: \"#2e7d32\" },\n];\n\nfunction hexToRgb(hex: string): [number, number, number] {\n  const clean = hex.replace(\"#\", \"\");\n  const num = parseInt(clean, 16);\n  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n  return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, \"0\")).join(\"\")}`;\n}\n\n/** Interpolated hex color for a 0-100 value between GRADIENT_STOPS. */\nexport function colorForValue(value: number, stops: GradientStop[] = GRADIENT_STOPS): string {\n  const clamped = Math.min(Math.max(value, 0), 100);\n  for (let i = 0; i < stops.length - 1; i++) {\n    const from = stops[i];\n    const to = stops[i + 1];\n    if (clamped >= from.value && clamped <= to.value) {\n      const t = (clamped - from.value) / (to.value - from.value);\n      const [r1, g1, b1] = hexToRgb(from.color);\n      const [r2, g2, b2] = hexToRgb(to.color);\n      return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);\n    }\n  }\n  return stops[stops.length - 1].color;\n}\n\n// Wedge geometry, shared with the skeleton so the ghost dial lines up.\nexport const WEDGE_OUTER_RADIUS = 104;\nexport const WEDGE_INNER_RADIUS = 64;\nexport const WEDGE_GAP = 1.2;\nexport const WEDGE_HUB_RADIUS = 40;\n// Fits the hub circle plus margin; also sizes the centered value overlay.\nexport const WEDGES_VIEWBOX_HEIGHT = 180;\n",
      "type": "registry:lib",
      "target": "components/voraui/fear-greed-gauge/lib/fear-greed-bands.ts"
    }
  ],
  "type": "registry:block"
}