gauge

PreviousNext
Docs
aliimamui

Preview

Loading preview…
registry/default/components/gauge.tsx
"use client"

import { useEffect, useState } from "react"
import type { CSSProperties, SVGProps } from "react"
import { useMotionValue, useSpring } from "framer-motion"

import { cn } from "@/registry/default/lib/utils"

export interface GaugeProps extends Omit<SVGProps<SVGSVGElement>, "className"> {
  value: number
  size?: number | string
  gapPercent?: number
  strokeWidth?: number
  equal?: boolean
  showValue?: boolean
  showPercentage?: boolean
  primary?:
    | "danger"
    | "warning"
    | "success"
    | "info"
    | string
    | { [key: number]: string }
  secondary?:
    | "danger"
    | "warning"
    | "success"
    | "info"
    | string
    | { [key: number]: string }
  gradient?: boolean
  multiRing?: {
    enabled: boolean
    rings?: Array<{
      value: number
      color: string
      strokeWidth?: number
      opacity?: number
    }>
  }
  thresholds?: Array<{
    value: number
    color: string
    label?: string
  }>
  gaugeType?: "full" | "half" | "quarter"
  transition?: {
    length?: number
    step?: number
    delay?: number
  }
  className?:
    | string
    | {
        svgClassName?: string
        primaryClassName?: string
        secondaryClassName?: string
        textClassName?: string
        labelClassName?: string
      }
  label?: string
  unit?: string
  min?: number
  max?: number
  tickMarks?: boolean
  glowEffect?: boolean
}

export function Gauge({
  value,
  size = 150,
  gapPercent = 5,
  strokeWidth = 10,
  equal = false,
  showValue = true,
  showPercentage = false,
  primary,
  secondary,
  gradient = false,
  multiRing,
  thresholds,
  gaugeType = "full",
  transition = {
    length: 1000,
    step: 200,
    delay: 0,
  },
  className,
  label,
  unit = "%",
  min = 0,
  max = 100,
  tickMarks = false,
  glowEffect = false,
  ...props
}: GaugeProps) {
  const circleSize = 100
  const radius = circleSize / 2 - strokeWidth / 2
  const circumference = 2 * Math.PI * radius
  const percentToDegree = 360 / 100

  const offsetFactor = equal ? 0.5 : 0
  const offsetFactorSecondary = 1 - offsetFactor

  const { formattedValue: animatedValue, rawValue: animatedRawValue } =
    useNumberCounter({
      value,
      delay: (transition?.delay ?? 0) / 1000,
      decimalPlaces: value % 1 !== 0 ? 1 : 0,
    })

  const getGaugeConfig = () => {
    switch (gaugeType) {
      case "half":
        return {
          startAngle: -90,
          endAngle: 90,
          circumferenceFactor: 0.5,
          viewBox: `0 25 ${circleSize} 50`,
        }
      case "quarter":
        return {
          startAngle: 0,
          endAngle: 90,
          circumferenceFactor: 0.25,
          viewBox: `25 25 50 50`,
        }
      default:
        return {
          startAngle: -90,
          endAngle: 270,
          circumferenceFactor: 1,
          viewBox: `0 0 ${circleSize} ${circleSize}`,
        }
    }
  }

  // Use the animated raw value for circle calculations instead of the static value
  const strokePercent = animatedRawValue

  const gaugeConfig = getGaugeConfig()
  const adjustedCircumference = circumference * gaugeConfig.circumferenceFactor
  const adjustedPercentToPx = adjustedCircumference / 100

  const primaryStrokeDasharray = () => {
    if (
      offsetFactor > 0 &&
      strokePercent > 100 - gapPercent * 2 * offsetFactor
    ) {
      const subtract = -strokePercent + 100
      return `${Math.max(strokePercent * adjustedPercentToPx - subtract * adjustedPercentToPx, 0)} ${adjustedCircumference}`
    } else {
      const subtract = gapPercent * 2 * offsetFactor
      return `${Math.max(strokePercent * adjustedPercentToPx - subtract * adjustedPercentToPx, 0)} ${adjustedCircumference}`
    }
  }

  const secondaryStrokeDasharray = () => {
    if (
      offsetFactorSecondary < 1 &&
      strokePercent < gapPercent * 2 * offsetFactorSecondary
    ) {
      const subtract = strokePercent
      return `${Math.max((100 - strokePercent) * adjustedPercentToPx - subtract * adjustedPercentToPx, 0)} ${adjustedCircumference}`
    } else {
      const subtract = gapPercent * 2 * offsetFactorSecondary
      return `${Math.max((100 - strokePercent) * adjustedPercentToPx - subtract * adjustedPercentToPx, 0)} ${adjustedCircumference}`
    }
  }

  const primaryTransform = () => {
    if (
      offsetFactor > 0 &&
      strokePercent > 100 - gapPercent * 2 * offsetFactor
    ) {
      const add = 0.5 * (-strokePercent + 100)
      return `rotate(${-90 + add * percentToDegree}deg)`
    } else {
      const add = gapPercent * offsetFactor
      return `rotate(${-90 + add * percentToDegree}deg)`
    }
  }

  const secondaryTransform = () => {
    if (
      offsetFactorSecondary < 1 &&
      strokePercent < gapPercent * 2 * offsetFactorSecondary
    ) {
      const subtract = 0.5 * strokePercent
      return `rotate(${360 - 90 - subtract * percentToDegree}deg) scaleY(-1)`
    } else {
      const subtract = gapPercent * offsetFactorSecondary
      return `rotate(${360 - 90 - subtract * percentToDegree}deg) scaleY(-1)`
    }
  }

  const getColor = (colorProp: typeof primary, isSecondary = false) => {
    const defaultColors = isSecondary
      ? {
          danger: "#fecaca",
          warning: "#fde68a",
          info: "#bfdbfe",
          success: "#bbf7d0",
        }
      : {
          danger: "#dc2626",
          warning: "#f59e0b",
          info: "#3b82f6",
          success: "#22c55e",
        }

    if (!colorProp) {
      if (isSecondary) return "rgba(85, 85, 85, 0.2)"
      return strokePercent <= 25
        ? defaultColors.danger
        : strokePercent <= 50
          ? defaultColors.warning
          : strokePercent <= 75
            ? defaultColors.info
            : defaultColors.success
    }

    if (typeof colorProp === "string") {
      return defaultColors[colorProp as keyof typeof defaultColors] || colorProp
    }

    if (typeof colorProp === "object") {
      const keys = Object.keys(colorProp).sort((a, b) => Number(a) - Number(b))
      const checkValue = isSecondary ? 100 - strokePercent : strokePercent

      for (let i = 0; i < keys.length; i++) {
        const currentKey = Number(keys[i])
        const nextKey = Number(keys[i + 1])
        if (checkValue >= currentKey && (checkValue < nextKey || !nextKey)) {
          const color = colorProp[currentKey]
          return defaultColors[color as keyof typeof defaultColors] || color
        }
      }
    }

    return isSecondary ? "#e5e7eb" : "#3b82f6"
  }

  const primaryStroke = getColor(primary)
  const secondaryStroke = getColor(secondary, true)

  const primaryOpacity = () => {
    if (
      offsetFactor > 0 &&
      strokePercent < gapPercent * 2 * offsetFactor &&
      strokePercent < gapPercent * 2 * offsetFactorSecondary
    ) {
      return 0
    } else return 1
  }

  const secondaryOpacity = () => {
    if (
      (offsetFactor === 0 && strokePercent > 100 - gapPercent * 2) ||
      (offsetFactor > 0 &&
        strokePercent > 100 - gapPercent * 2 * offsetFactor &&
        strokePercent > 100 - gapPercent * 2 * offsetFactorSecondary)
    ) {
      return 0
    } else return 1
  }

  const circleStyles: CSSProperties = {
    strokeLinecap: "round",
    strokeLinejoin: "round",
    strokeDashoffset: 0,
    strokeWidth: strokeWidth,
    // Remove CSS transition since we're using animated values
    transformOrigin: "50% 50%",
    shapeRendering: "geometricPrecision",
  }

  const glowStyles = glowEffect
    ? {
        filter: `
        drop-shadow(0 0 2px ${primaryStroke}80)
        drop-shadow(0 0 6px ${primaryStroke}60)
        drop-shadow(0 0 12px ${primaryStroke}40)
        drop-shadow(0 0 20px ${primaryStroke}20)
      `,
      }
    : {}

  const generateTickMarks = () => {
    if (!tickMarks) return null
    const ticks = []
    const tickCount = 8

    for (let i = 0; i <= tickCount; i++) {
      const angle =
        (i / tickCount) * (gaugeConfig.endAngle - gaugeConfig.startAngle) +
        gaugeConfig.startAngle
      const tickRadius = radius - strokeWidth / 2
      const tickLength = 6

      const x1 =
        circleSize / 2 +
        (tickRadius - tickLength) * Math.cos((angle * Math.PI) / 180)
      const y1 =
        circleSize / 2 +
        (tickRadius - tickLength) * Math.sin((angle * Math.PI) / 180)
      const x2 = circleSize / 2 + tickRadius * Math.cos((angle * Math.PI) / 180)
      const y2 = circleSize / 2 + tickRadius * Math.sin((angle * Math.PI) / 180)

      ticks.push(
        <line
          key={i}
          x1={x1}
          y1={y1}
          x2={x2}
          y2={y2}
          stroke="currentColor"
          strokeWidth="1"
          opacity="0.3"
        />
      )
    }
    return ticks
  }

  return (
    <div className="relative inline-block">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox={`0 0 ${circleSize} ${circleSize}`}
        shapeRendering="crispEdges"
        width={size}
        height={size}
        style={{ userSelect: "none", ...glowStyles }}
        fill="none"
        className={cn(
          "",
          typeof className === "string" ? className : className?.svgClassName
        )}
        {...props}
      >
        {gradient && (
          <defs>
            <linearGradient
              id="primaryGradient"
              x1="0%"
              y1="0%"
              x2="100%"
              y2="0%"
            >
              <stop offset="0%" stopColor={primaryStroke} stopOpacity="0.3" />
              <stop offset="100%" stopColor={primaryStroke} stopOpacity="1" />
            </linearGradient>
          </defs>
        )}

        {generateTickMarks()}

        {multiRing?.enabled &&
          multiRing.rings?.map((ring, index) => (
            <circle
              key={`ring-${index}`}
              cx={circleSize / 2}
              cy={circleSize / 2}
              r={radius - (index + 1) * (strokeWidth + 2)}
              style={{
                ...circleStyles,
                strokeWidth: ring.strokeWidth || strokeWidth - 2,
                strokeDasharray: `${(ring.value / 100) * adjustedCircumference} ${adjustedCircumference}`,
                transform: primaryTransform(),
                stroke: ring.color,
                opacity: ring.opacity,
              }}
            />
          ))}

        <circle
          cx={circleSize / 2}
          cy={circleSize / 2}
          r={radius}
          style={{
            ...circleStyles,
            strokeDasharray: secondaryStrokeDasharray(),
            transform: secondaryTransform(),
            stroke: secondaryStroke,
            opacity: secondaryOpacity(),
          }}
          className={cn(
            "",
            typeof className === "object" && className?.secondaryClassName
          )}
        />

        <circle
          cx={circleSize / 2}
          cy={circleSize / 2}
          r={radius}
          style={{
            ...circleStyles,
            strokeDasharray: primaryStrokeDasharray(),
            transform: primaryTransform(),
            stroke: gradient ? "url(#primaryGradient)" : primaryStroke,
            opacity: primaryOpacity(),
          }}
          className={cn(
            "",
            typeof className === "object" && className?.primaryClassName
          )}
        />
        {thresholds?.map((threshold, index) => {
          const thresholdPercent = ((threshold.value - min) / (max - min)) * 100
          const angle =
            (thresholdPercent / 100) *
              (gaugeConfig.endAngle - gaugeConfig.startAngle) +
            gaugeConfig.startAngle
          const indicatorRadius = radius + strokeWidth / 2 + 5
          const x =
            circleSize / 2 + indicatorRadius * Math.cos((angle * Math.PI) / 180)
          const y =
            circleSize / 2 + indicatorRadius * Math.sin((angle * Math.PI) / 180)

          return (
            <circle
              key={`threshold-${index}`}
              cx={x}
              cy={y}
              r="2"
              fill={threshold.color}
            />
          )
        })}

        {showValue && (
          <g>
            <text
              x={circleSize / 2}
              y={circleSize / 2}
              textAnchor="middle"
              dominantBaseline="middle"
              alignmentBaseline="central"
              fill="currentColor"
              fontSize={30}
              fontWeight="700"
              className={cn(
                "font-bold",
                typeof className === "object" && className?.textClassName
              )}
              style={{ userSelect: "none" }}
            >
              {animatedValue}
              {showPercentage && unit}
            </text>
          </g>
        )}
        {label && (
          <text
            x={circleSize / 2}
            y={circleSize / 2 + 20}
            textAnchor="middle"
            dominantBaseline="middle"
            fontSize={8}
            fontWeight="400"
            className="fill-muted-foreground"
            style={{ userSelect: "none" }}
          >
            {label}
          </text>
        )}
      </svg>
    </div>
  )
}

// Hook version for use in SVG contexts - now returns both formatted text and raw animated value
export function useNumberCounter({
  value,
  direction = "up",
  delay = 0,
  decimalPlaces = 0,
}: {
  value: number
  direction?: "up" | "down"
  delay?: number
  decimalPlaces?: number
}) {
  const [displayValue, setDisplayValue] = useState(
    direction === "down" ? value : 0
  )
  const [rawValue, setRawValue] = useState(direction === "down" ? value : 0)
  const [isInView, setIsInView] = useState(false)

  const motionValue = useMotionValue(direction === "down" ? value : 0)
  const springValue = useSpring(motionValue, {
    damping: 60,
    stiffness: 100,
  })

  // Set initial display value
  useEffect(() => {
    const initialValue = direction === "down" ? value : 0
    setDisplayValue(initialValue)
    setRawValue(initialValue)
  }, [direction, value])

  // Simulate useInView for SVG context
  useEffect(() => {
    const timer = setTimeout(() => setIsInView(true), 100)
    return () => clearTimeout(timer)
  }, [])

  // Trigger animation after delay
  useEffect(() => {
    if (isInView) {
      const timeout = setTimeout(() => {
        motionValue.set(direction === "down" ? 0 : value)
      }, delay * 1000)
      return () => clearTimeout(timeout)
    }
  }, [motionValue, isInView, delay, value, direction])

  // Update display value when spring value changes
  useEffect(() => {
    const unsubscribe = springValue.on("change", (latest) => {
      const formattedValue = Number(latest.toFixed(decimalPlaces))
      setDisplayValue(formattedValue)
      setRawValue(latest) // Keep the raw animated value for circle animation
    })
    return unsubscribe
  }, [springValue, decimalPlaces])

  const formattedDisplayValue = Intl.NumberFormat("en-US", {
    minimumFractionDigits: decimalPlaces,
    maximumFractionDigits: decimalPlaces,
  }).format(displayValue)

  return {
    formattedValue: formattedDisplayValue,
    rawValue: rawValue,
  }
}

Installation

npx shadcn@latest add @aliimam/gauge

Usage

import { Gauge } from "@/components/ui/gauge"
<Gauge />