import { Component, Input } from '@angular/core'; function niceNum(n: number): string { if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M'; if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1) + 'k'; return String(n); } type Pt = [number, number]; function smoothPath(pts: Pt[]): string { if (pts.length < 2) return ''; let d = `M ${pts[0]![0]} ${pts[0]![1]}`; for (let i = 0; i < pts.length - 1; i++) { const [x0, y0] = pts[i]!; const [x1, y1] = pts[i + 1]!; const cx = (x0 + x1) / 2; d += ` C ${cx} ${y0} ${cx} ${y1} ${x1} ${y1}`; } return d; } let sparkUid = 0; @Component({ selector: 'vsp-sparkline', template: ` @if (fill) { } `, }) export class VspSparkline { @Input() data: number[] = []; @Input() color = 'var(--accent)'; @Input() w = 110; @Input() h = 34; @Input() fill = true; gid = 'spk' + ++sparkUid; get pts(): Pt[] { const min = Math.min(...this.data); const max = Math.max(...this.data); const rng = max - min || 1; return this.data.map((v, i) => [ (i / (this.data.length - 1)) * this.w, this.h - 3 - ((v - min) / rng) * (this.h - 6), ]); } get d(): string { return smoothPath(this.pts); } get areaD(): string { return `${this.d} L ${this.w} ${this.h} L 0 ${this.h} Z`; } get last(): Pt { return this.pts[this.pts.length - 1] ?? [0, 0]; } } export interface DonutDatum { label: string; value: number; color: string; } @Component({ selector: 'vsp-donut', template: `
@if (hover != null) {
{{ fmt(data[hover].value) }}
{{ data[hover].label }}
} @else if (centerLabel != null) {
{{ centerLabel }}
} @for (s of segs; track $index; let i = $index) { }
@for (d of data; track $index; let i = $index) {
{{ d.label }} {{ pct(d.value) }}%
}
`, }) export class VspDonut { @Input() data: DonutDatum[] = []; @Input() size = 168; @Input() thickness = 22; @Input() centerLabel?: string; @Input() valueFormat?: (n: number) => string; hover: number | null = null; fmt(n: number): string { return this.valueFormat ? this.valueFormat(n) : niceNum(n); } get total(): number { return this.data.reduce((s, d) => s + d.value, 0) || 1; } get r(): number { return (this.size - this.thickness) / 2; } get c(): number { return this.size / 2; } get circ(): number { return 2 * Math.PI * this.r; } get segs(): { color: string; dash: string; offset: number }[] { let acc = 0; return this.data.map((d) => { const len = (d.value / this.total) * this.circ; const seg = { color: d.color, dash: `${len - 2.5} ${this.circ - len + 2.5}`, offset: -acc }; acc += len; return seg; }); } pct(v: number): number { return Math.round((v / this.total) * 100); } } @Component({ selector: 'vsp-stat-card', imports: [VspSparkline], template: `
{{ label }}
@if (delta != null) { {{ delta }} }
{{ value }}
@if (spark) { }
`, }) export class VspStatCard { @Input() label?: string; @Input() value?: string; @Input() delta?: string; @Input() deltaDir: 'up' | 'down' = 'up'; @Input() spark?: number[]; @Input() sparkColor = 'var(--accent)'; get deltaCls(): string { return 'badge ' + (this.deltaDir === 'up' ? 'badge-pos' : 'badge-neg'); } get arrow(): string { return this.deltaDir === 'up' ? 'M12 19V5M5 12l7-7 7 7' : 'M12 5v14M5 12l7 7 7-7'; } }