// Adapted from jalcoui (MIT) — github.com/jal-co/ui // // Generic inline SVG sparkline. Pure SVG, no charting deps. Color resolves // through the theme palette (semantic token → hex), so the stroke and area // fill follow the active preset. // // Requires mounted by the host app (no nested providers here). 'use client'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { useThemeColor, alpha } from '@djangocfg/ui-core/styles/palette'; import type { SparklineDatum, SparklineProps } from './types'; function toValue(d: SparklineDatum): number { return typeof d === 'number' ? d : d.value; } interface Stats { values: number[]; min: number; max: number; range: number; avg: number; } function computeStats(data: SparklineDatum[]): Stats { const values = data.map(toValue); const max = Math.max(...values, 1); const min = Math.min(...values); const range = max - min || 1; const total = values.reduce((s, v) => s + v, 0); const avg = values.length > 0 ? total / values.length : 0; return { values, min, max, range, avg }; } function buildLinePath( values: number[], width: number, height: number, padding: number, min: number, range: number, ): string { if (values.length === 0) return ''; const drawHeight = height - padding * 2; const drawWidth = width - padding * 2; const denom = values.length - 1 || 1; return values .map((v, i) => { const x = padding + (i / denom) * drawWidth; const y = padding + drawHeight - ((v - min) / range) * drawHeight; return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`; }) .join(' '); } function buildAreaPath( linePath: string, width: number, height: number, padding: number, ): string { if (!linePath) return ''; const lastX = width - padding; const firstX = padding; const bottom = height - padding; return `${linePath} L ${lastX.toFixed(2)} ${bottom.toFixed(2)} L ${firstX.toFixed(2)} ${bottom.toFixed(2)} Z`; } function getEndpoint( values: number[], width: number, height: number, padding: number, min: number, range: number, ): { x: number; y: number } | null { if (values.length === 0) return null; const drawHeight = height - padding * 2; const lastV = values[values.length - 1]; return { x: width - padding, y: padding + drawHeight - ((lastV - min) / range) * drawHeight, }; } function getBaselineY( avg: number, height: number, padding: number, min: number, range: number, ): number { const drawHeight = height - padding * 2; return padding + drawHeight - ((avg - min) / range) * drawHeight; } export function Sparkline({ data, variant = 'line', color = 'primary', width = 120, height = 32, strokeWidth = 1.5, showEndpoint = true, showBaseline = false, ariaLabel, className, ...rest }: SparklineProps) { const stroke = useThemeColor(color); const fill = alpha(stroke, 0.15); const baselineStroke = alpha(stroke, 0.4); if (data.length === 0) return null; const stats = computeStats(data); const padding = variant === 'bar' ? 2 : 2 + strokeWidth; const linePath = variant !== 'bar' ? buildLinePath(stats.values, width, height, padding, stats.min, stats.range) : ''; const areaPath = variant === 'area' ? buildAreaPath(linePath, width, height, padding) : ''; const endpoint = showEndpoint && variant !== 'bar' ? getEndpoint(stats.values, width, height, padding, stats.min, stats.range) : null; const baselineY = showBaseline ? getBaselineY(stats.avg, height, padding, stats.min, stats.range) : null; return (
{baselineY != null && ( )} {variant === 'bar' && (() => { const drawHeight = height - padding * 2; const drawWidth = width - padding * 2; const gap = 1; const barWidth = Math.max( 1, (drawWidth - gap * (stats.values.length - 1)) / stats.values.length, ); const denom = stats.values.length - 1 || 1; return stats.values.map((v, i) => { const barHeight = Math.max(1, ((v - stats.min) / stats.range) * drawHeight); const x = padding + i * (barWidth + gap); const y = padding + drawHeight - barHeight; return ( ); }); })()} {variant === 'area' && areaPath && } {variant !== 'bar' && linePath && ( )} {endpoint && ( )}
); }