'use client'; import { Slot as SlotPrimitive } from '@radix-ui/react-slot'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import type { GaugeProps, GaugeContextValue, GaugeState } from './types'; const GAUGE_NAME = 'Gauge'; const INDICATOR_NAME = 'GaugeIndicator'; const TRACK_NAME = 'GaugeTrack'; const RANGE_NAME = 'GaugeRange'; const VALUE_TEXT_NAME = 'GaugeValueText'; const LABEL_NAME = 'GaugeLabel'; const DEFAULT_MAX = 100; const DEFAULT_START_ANGLE = 0; const DEFAULT_END_ANGLE = 360; function getGaugeState(value: number | undefined | null, maxValue: number): GaugeState { return value == null ? 'indeterminate' : value === maxValue ? 'complete' : 'loading'; } function getIsValidNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function getIsValidMaxNumber(max: unknown): max is number { return getIsValidNumber(max) && max > 0; } function getIsValidValueNumber(value: unknown, min: number, max: number): value is number { return getIsValidNumber(value) && value <= max && value >= min; } function getDefaultValueText(value: number, min: number, max: number): string { const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100; return Math.round(percentage).toString(); } function getNormalizedAngle(angle: number) { return ((angle % 360) + 360) % 360; } function polarToCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) { const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; return { x: centerX + radius * Math.cos(angleInRadians), y: centerY + radius * Math.sin(angleInRadians), }; } function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) { const angleDiff = endAngle - startAngle; if (Math.abs(angleDiff) >= 360) { const start = polarToCartesian(x, y, radius, startAngle); const mid = polarToCartesian(x, y, radius, startAngle + 180); return [ 'M', start.x, start.y, 'A', radius, radius, 0, 0, 1, mid.x, mid.y, 'A', radius, radius, 0, 0, 1, start.x, start.y, ].join(' '); } const start = polarToCartesian(x, y, radius, startAngle); const end = polarToCartesian(x, y, radius, endAngle); const largeArcFlag = angleDiff <= 180 ? '0' : '1'; return [ 'M', start.x, start.y, 'A', radius, radius, 0, largeArcFlag, 1, end.x, end.y, ].join(' '); } const GaugeContext = React.createContext(null); function useGaugeContext(consumerName: string) { const context = React.useContext(GaugeContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${GAUGE_NAME}\``); } return context; } export function Gauge(props: GaugeProps) { const { value: valueProp = null, getValueText = getDefaultValueText, min: minProp = 0, max: maxProp, size = 120, thickness = 8, startAngle = DEFAULT_START_ANGLE, endAngle = DEFAULT_END_ANGLE, asChild, className, ...rootProps } = props; if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) { if (process.env.NODE_ENV !== 'production') { console.error(`Invalid prop \`max\` of value \`${maxProp}\` supplied to \`${GAUGE_NAME}\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`); } } const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX; const min = getIsValidNumber(minProp) ? minProp : 0; const max = rawMax <= min ? min + 1 : rawMax; if (process.env.NODE_ENV !== 'production' && thickness >= size) { console.warn(`Gauge: thickness (${thickness}) should be less than size (${size}) for proper rendering.`); } if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) { if (process.env.NODE_ENV !== 'production') { console.error(`Invalid prop \`value\` of value \`${valueProp}\` supplied to \`${GAUGE_NAME}\`. The \`value\` prop must be a number between \`min\` and \`max\` (inclusive), or \`null\`/\`undefined\` for indeterminate state. The value will be clamped to the valid range.`); } } const value = getIsValidValueNumber(valueProp, min, max) ? valueProp : getIsValidNumber(valueProp) && valueProp > max ? max : getIsValidNumber(valueProp) && valueProp < min ? min : null; const valueText = getIsValidNumber(value) ? getValueText(value, min, max) : undefined; const state = getGaugeState(value, max); const radius = Math.max(0, (size - thickness) / 2); const center = size / 2; const angleDiff = Math.abs(endAngle - startAngle); const arcLength = (Math.min(angleDiff, 360) / 360) * (2 * Math.PI * radius); const percentage = getIsValidNumber(value) ? max === min ? 1 : (value - min) / (max - min) : null; const angleDiffDeg = Math.abs(endAngle - startAngle); const isFullCircle = angleDiffDeg >= 360; let arcCenterY = center; if (!isFullCircle) { const startRad = (startAngle * Math.PI) / 180; const endRad = (endAngle * Math.PI) / 180; const startY = center - radius * Math.cos(startRad); const endY = center - radius * Math.cos(endRad); let minY = Math.min(startY, endY); let maxY = Math.max(startY, endY); const normStart = getNormalizedAngle(startAngle); const normEnd = getNormalizedAngle(endAngle); const includesTop = normStart > normEnd ? normStart <= 270 || normEnd >= 270 : normStart <= 270 && normEnd >= 270; const includesBottom = normStart > normEnd ? normStart <= 90 || normEnd >= 90 : normStart <= 90 && normEnd >= 90; if (includesTop) minY = Math.min(minY, center - radius); if (includesBottom) maxY = Math.max(maxY, center + radius); arcCenterY = (minY + maxY) / 2; } const labelId = React.useId(); const valueTextId = React.useId(); const contextValue = React.useMemo( () => ({ value, valueText, max, min, state, radius, thickness, size, center, percentage, startAngle, endAngle, arcLength, arcCenterY, valueTextId, labelId, }), [value, valueText, max, min, state, radius, thickness, size, center, percentage, startAngle, endAngle, arcLength, arcCenterY, valueTextId, labelId] ); const RootPrimitive = asChild ? SlotPrimitive : 'div'; return ( ); } Gauge.displayName = GAUGE_NAME; export function GaugeIndicator(props: React.ComponentProps<'svg'>) { const { className, ...indicatorProps } = props; const { size, state, value, max, min, percentage } = useGaugeContext(INDICATOR_NAME); return ( ); } GaugeIndicator.displayName = INDICATOR_NAME; interface PathProps extends React.ComponentProps<'path'> {} export function GaugeTrack(props: PathProps) { const { className, ...trackProps } = props; const { center, radius, startAngle, endAngle, thickness, state } = useGaugeContext(TRACK_NAME); const pathData = describeArc(center, center, radius, startAngle, endAngle); return ( ); } GaugeTrack.displayName = TRACK_NAME; export function GaugeRange(props: PathProps) { const { className, ...rangeProps } = props; const { center, radius, startAngle, endAngle, value, max, min, state, thickness, arcLength, percentage, } = useGaugeContext(RANGE_NAME); const pathData = describeArc(center, center, radius, startAngle, endAngle); const strokeDasharray = arcLength; const strokeDashoffset = state === 'indeterminate' ? 0 : percentage !== null ? arcLength - percentage * arcLength : arcLength; return ( ); } GaugeRange.displayName = RANGE_NAME; interface DivProps extends React.ComponentProps<'div'> { asChild?: boolean; } export function GaugeValueText(props: DivProps) { const { asChild, className, children, style, ...valueTextProps } = props; const { valueTextId, state, arcCenterY, valueText } = useGaugeContext(VALUE_TEXT_NAME); const ValueTextPrimitive = asChild ? SlotPrimitive : 'div'; return ( {children ?? valueText} ); } GaugeValueText.displayName = VALUE_TEXT_NAME; export function GaugeLabel(props: DivProps) { const { asChild, className, ...labelProps } = props; const { labelId, state } = useGaugeContext(LABEL_NAME); const LabelPrimitive = asChild ? SlotPrimitive : 'div'; return ( ); } GaugeLabel.displayName = LABEL_NAME; export function GaugeCombined(props: GaugeProps) { return ( ); } GaugeCombined.displayName = 'GaugeCombined';