import { Component, Input } from '@angular/core'; 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; } 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); } let acUid = 0; @Component({ selector: 'vsp-area-chart', template: ` @for (t of ticks; track $index) { {{ t.label }} } @if (labels) { @for (lb of labels; track $index; let i = $index) { @if (showLabel(i)) { {{ lb }} } } } @for (ln of lineEls; track $index) { @if (ln.area) { } } `, }) export class VspAreaChart { @Input() series: number[][] = []; @Input() labels?: string[]; @Input() width = 760; @Input() height = 260; @Input() color = 'var(--accent)'; @Input() color2 = 'var(--accent-2)'; @Input() dual = false; gid = 'ac' + ++acUid; padL = 38; padB = 26; padT = 12; padR = 8; get innerW(): number { return Math.max(10, this.width - this.padL - this.padR); } get innerH(): number { return this.height - this.padB - this.padT; } get s0(): number[] { return this.series[0] ?? []; } get s1(): number[] | undefined { return this.series[1]; } get max(): number { return Math.max(...(this.dual && this.s1 ? [...this.s0, ...this.s1] : this.s0), 0) * 1.12; } get rng(): number { return this.max || 1; } sx(i: number, len: number): number { return this.padL + (i / Math.max(1, len - 1)) * this.innerW; } sy(v: number): number { return this.padT + this.innerH - (v / this.rng) * this.innerH; } showLabel(i: number): boolean { return !!this.labels && i % Math.ceil(this.labels.length / 7) === 0; } get ticks(): { y: number; label: string }[] { return Array.from({ length: 5 }, (_, i) => ({ y: this.sy((this.max / 4) * i), label: niceNum(Math.round((this.max / 4) * i)), })); } get lineEls(): { d: string; area: string | null; stroke: string; dash: string | null; opacity: number; }[] { const sets = this.dual && this.s1 ? [this.s0, this.s1] : [this.s0]; return sets.map((arr, li) => { const pts: Pt[] = arr.map((v, i) => [this.sx(i, arr.length), this.sy(v)]); const last = pts[pts.length - 1]; const first = pts[0]; return { d: smoothPath(pts), area: li === 0 && first && last ? `${smoothPath(pts)} L ${last[0]} ${this.padT + this.innerH} L ${first[0]} ${this.padT + this.innerH} Z` : null, stroke: li === 0 ? this.color : this.color2, dash: li === 1 ? '5 5' : null, opacity: li === 1 ? 0.7 : 1, }; }); } } const BAR_PALETTE = [ 'var(--accent)', 'var(--accent-2)', 'var(--success)', 'var(--warning)', 'var(--danger)', ]; @Component({ selector: 'vsp-bar-chart', template: `
@for (g of gridEls; track $index) { {{ g.label }} } @for (b of bars; track $index) { } @for (cat of cats; track $index) { @if (cat.label != null) { {{ cat.label }} } } @if (hover != null) {
@if (labels && labels[hover] != null) {
{{ labels[hover] }}
} @for (row of tipRows; track $index) {
@if (row.label) { {{ row.label }} } {{ row.value }}
}
} @if (showLegend) {
@for (l of legendItems; track $index) { {{ l.label }} }
}
`, }) export class VspBarChart { @Input() data: number[] | number[][] = []; @Input() labels?: string[]; @Input() width = 620; @Input() height = 240; @Input() color?: string; @Input() colors?: string[]; @Input() seriesLabels?: string[]; @Input() horizontal = false; @Input() stacked = false; @Input() valueFormat?: (n: number) => string; @Input() showValues = false; hover: number | null = null; padB = 26; padT = 10; padR = 10; get series(): number[][] { return Array.isArray(this.data[0]) ? (this.data as number[][]) : [this.data as number[]]; } get nSeries(): number { return this.series.length; } get nCats(): number { return this.series.reduce((m, s) => Math.max(m, s.length), 0); } get padL(): number { return this.horizontal ? 60 : 34; } colorAt(s: number): string { return ( this.colors?.[s] ?? (s === 0 && this.color ? this.color : BAR_PALETTE[s % BAR_PALETTE.length]!) ); } fmt(n: number): string { return this.valueFormat ? this.valueFormat(n) : niceNum(n); } dim(c: string): string { return `color-mix(in oklab, ${c} 80%, transparent)`; } get max(): number { const m = this.stacked ? Math.max( ...Array.from({ length: this.nCats }, (_, i) => this.series.reduce((s, a) => s + (a[i] ?? 0), 0), ), 0, ) : Math.max(...this.series.flat(), 0); return (m || 1) * 1.15; } get valAxis(): number { return this.horizontal ? Math.max(10, this.width - this.padL - this.padR) : this.height - this.padB - this.padT; } get catAxis(): number { return this.horizontal ? this.height - this.padT - this.padB : Math.max(10, this.width - this.padL - this.padR); } get band(): number { return this.catAxis / this.nCats; } private rect(cs: number, thick: number, vStart: number, vLen: number) { if (this.horizontal) return { x: this.padL + (vStart / this.max) * this.valAxis, y: this.padT + cs, w: (vLen / this.max) * this.valAxis, h: thick, }; return { x: this.padL + cs, y: this.padT + this.valAxis - ((vStart + vLen) / this.max) * this.valAxis, w: thick, h: (vLen / this.max) * this.valAxis, }; } get bars(): { x: number; y: number; w: number; h: number; fill: string; i: number }[] { const out: { x: number; y: number; w: number; h: number; fill: string; i: number }[] = []; const band = this.band; for (let i = 0; i < this.nCats; i++) { if (this.stacked) { let acc = 0; this.series.forEach((arr, s) => { const v = arr[i] ?? 0; const r = this.rect(i * band + band * 0.18, band * 0.64, acc, v); out.push({ x: r.x, y: r.y, w: Math.max(0, r.w), h: Math.max(0, r.h), fill: this.colorAt(s), i, }); acc += v; }); } else { const sub = (band * 0.64) / this.nSeries; this.series.forEach((arr, s) => { const v = arr[i] ?? 0; const r = this.rect(i * band + band * 0.18 + s * sub, sub * 0.86, 0, v); out.push({ x: r.x, y: r.y, w: Math.max(0, r.w), h: Math.max(0, r.h), fill: this.colorAt(s), i, }); }); } } return out; } get gridEls(): { x1: number; x2: number; y1: number; y2: number; tx: number; ty: number; anchor: string; label: string; }[] { return [0, 0.25, 0.5, 0.75, 1].map((f) => { const p = this.horizontal ? this.padL + f * this.valAxis : this.padT + this.valAxis - f * this.valAxis; return { x1: this.horizontal ? p : this.padL, x2: this.horizontal ? p : this.padL + this.catAxis, y1: this.horizontal ? this.padT : p, y2: this.horizontal ? this.padT + this.catAxis : p, tx: this.horizontal ? p : this.padL - 8, ty: this.horizontal ? this.height - 8 : p + 3.5, anchor: this.horizontal ? 'middle' : 'end', label: this.fmt(Math.round(this.max * f)), }; }); } get cats(): { i: number; hx: number; hy: number; hw: number; hh: number; label: string | undefined; lx: number; ly: number; anchor: string; }[] { const band = this.band; return Array.from({ length: this.nCats }, (_, i) => { const center = i * band + band / 2; return { i, hx: this.horizontal ? this.padL : this.padL + i * band, hy: this.horizontal ? this.padT + i * band : this.padT, hw: this.horizontal ? this.valAxis : band, hh: this.horizontal ? band : this.valAxis, label: this.labels?.[i], lx: this.horizontal ? this.padL - 8 : this.padL + center, ly: this.horizontal ? this.padT + center + 3.5 : this.height - 8, anchor: this.horizontal ? 'end' : 'middle', }; }); } get tipLeft(): number { const band = this.band; return this.horizontal ? this.padL + 8 : this.padL + (this.hover ?? 0) * band + band / 2; } get tipTop(): number { const band = this.band; return this.horizontal ? this.padT + (this.hover ?? 0) * band + band / 2 : this.padT + 4; } get tipRows(): { color: string; label?: string; value: string }[] { const i = this.hover ?? 0; return this.series.map((arr, s) => ({ color: this.colorAt(s), label: this.seriesLabels?.[s], value: this.fmt(arr[i] ?? 0), })); } get showLegend(): boolean { return this.nSeries > 1 && !!this.seriesLabels; } get legendItems(): { color: string; label: string }[] { return (this.seriesLabels ?? []).map((label, s) => ({ color: this.colorAt(s), label })); } }