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: ``,
})
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 (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';
}
}