import { Box } from "@mantine/core"; import { overviewAccentGradients, type OverviewAccent, } from "@/components/overview/overview.styles"; /** * Tiny inline-SVG mini graphs shared by every KPI / stat card across the app. * No charting dependency — crisp and cheap to render in long card grids. * * - `MiniSparkline` (variant "area" | "line") draws a smooth decorative trend. * - `MiniRing` draws a circular percentage gauge. * * The sparkline series is deterministic (seeded from a string) so a given card * always renders the same shape — purely decorative, not a real time-series. */ /** Deterministic, gently-rising series seeded by a string (purely decorative). */ export function sparkHeights(seed: string, baseline: number, count = 9): number[] { let h = 2166136261; for (let i = 0; i < seed.length; i += 1) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } const out: number[] = []; let v = 0.35 + (baseline > 0 ? Math.min(0.35, baseline * 0.35) : 0.15); for (let i = 0; i < count; i += 1) { h = (Math.imul(h, 1103515245) + 12345) >>> 0; const delta = ((h % 1000) / 1000 - 0.42) * 0.28; v = Math.min(1, Math.max(0.22, v + delta)); out.push(v); } // Nudge the final point up so the series reads as an upward trend. out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15); return out; } /** Build a smooth (Catmull-Rom → bezier) path string through y-points in [0,1]. */ function smoothPath(values: number[], width: number, height: number, pad = 2): string { const n = values.length; if (n === 0) return ""; const innerW = width - pad * 2; const innerH = height - pad * 2; const pts = values.map((v, i) => ({ x: pad + (n === 1 ? 0 : (i / (n - 1)) * innerW), y: pad + (1 - v) * innerH, })); if (n === 1) return `M ${pts[0].x} ${pts[0].y}`; let d = `M ${pts[0].x} ${pts[0].y}`; for (let i = 0; i < n - 1; i += 1) { const p0 = pts[i - 1] ?? pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] ?? p2; const c1x = p1.x + (p2.x - p0.x) / 6; const c1y = p1.y + (p2.y - p0.y) / 6; const c2x = p2.x - (p3.x - p1.x) / 6; const c2y = p2.y - (p3.y - p1.y) / 6; d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`; } return d; } export type SparklineVariant = "area" | "line"; /** * Smooth decorative trend. `area` fills under the curve with an accent gradient; * `line` is a clean stroke with a highlighted dot at the latest point. */ export function MiniSparkline({ variant, accent = "default", seed, baseline = 0, height = 38, }: { variant: SparklineVariant; accent?: OverviewAccent; seed: string; baseline?: number; height?: number; }) { const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default; const values = sparkHeights(seed, baseline); const width = 120; const pad = 3; const line = smoothPath(values, width, height, pad); const last = values[values.length - 1]; const lastX = pad + ((values.length - 1) / (values.length - 1 || 1)) * (width - pad * 2); const lastY = pad + (1 - last) * (height - pad * 2); // Unique gradient id per seed+accent so multiple cards don't collide. const gid = `spark-${variant}-${accent}-${seed.replace(/[^a-zA-Z0-9]/g, "")}`; return ( {variant === "area" ? ( ) : null} {variant === "line" ? ( ) : null} ); } /** Circular percentage gauge with an accent stroke. */ export function MiniRing({ pct, accent = "default", size = 52, stroke = 5, children, }: { pct: number | null | undefined; accent?: OverviewAccent; size?: number; stroke?: number; children?: React.ReactNode; }) { const [, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default; const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null; const dash = clamped != null ? (clamped / 100) * circumference : 0; return ( {clamped != null ? ( ) : null} {children} ); }