mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
194 lines
6.0 KiB
TypeScript
194 lines
6.0 KiB
TypeScript
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 (
|
|
<Box style={{ width: "100%" }}>
|
|
<svg
|
|
width="100%"
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
preserveAspectRatio="none"
|
|
style={{ display: "block", overflow: "visible" }}
|
|
>
|
|
<defs>
|
|
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={deep} stopOpacity={variant === "area" ? 0.32 : 0} />
|
|
<stop offset="100%" stopColor={light} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
{variant === "area" ? (
|
|
<path
|
|
d={`${line} L ${width - pad} ${height - pad} L ${pad} ${height - pad} Z`}
|
|
fill={`url(#${gid})`}
|
|
stroke="none"
|
|
/>
|
|
) : null}
|
|
<path
|
|
d={line}
|
|
fill="none"
|
|
stroke={deep}
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
{variant === "line" ? (
|
|
<circle cx={lastX} cy={lastY} r={3} fill={deep} stroke="white" strokeWidth={1.5} />
|
|
) : null}
|
|
</svg>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
|
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="var(--mantine-color-gray-2)"
|
|
strokeWidth={stroke}
|
|
/>
|
|
{clamped != null ? (
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={deep}
|
|
strokeWidth={stroke}
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${dash} ${circumference}`}
|
|
style={{ transition: "stroke-dasharray 400ms ease" }}
|
|
/>
|
|
) : null}
|
|
</svg>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: deep,
|
|
}}
|
|
>
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|