import { afterNextRender, ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, input, signal, } from '@angular/core'; export interface ChartSeries { label: string; /** null: no value there (the line breaks; e.g. days still to come). */ values: (number | null)[]; /** CSS colour (a `--ts-viz-*` token). */ color: string; /** Wash the area under a line (~10%); ignored when stacked. */ area?: boolean; /** Dashed line, for references such as an ideal pace. */ dashed?: boolean; } export interface ChartMarker { index: number; label: string; } /** Last item (the web target predates Array.prototype.at). */ export const last = (list: readonly T[]): T | undefined => list[list.length - 1]; const PAD = { top: 16, right: 20, bottom: 30, left: 40 }; /** 0 → max in 3-5 clean steps (1, 2, 5 × 10ⁿ). */ function niceTicks(max: number): number[] { if (max <= 0) return [0, 1]; const raw = max / 4; const power = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? power * 10; const ticks: number[] = []; for (let v = 0; v < max + step; v += step) { ticks.push(Math.round(v * 1000) / 1000); if (v >= max) break; } return ticks; } /** * Lines or stacked areas over a shared x axis, drawn in SVG with the theme's * tokens (so dark mode is its own palette). Crosshair + one tooltip with every * series, keyboard (← →) as well as pointer, legend for 2+ series and a data * table so nothing depends on hovering or on colour alone. */ @Component({ selector: 'ts-series-chart', changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (series().length > 1) { }
Ver los datos
@for (s of series(); track s.label) { } @for (label of fullLabels(); track $index; let i = $index) { @for (s of series(); track s.label) { } }
{{ axisLabel() }}{{ s.label }}
{{ label }}{{ s.values[i] ?? '—' }}
`, styles: ` :host { display: grid; gap: 0.75rem; min-width: 0; } .ts-chart__legend { display: flex; flex-wrap: wrap; gap: 0.375rem 1rem; margin: 0; padding: 0; list-style: none; font-size: 0.8125rem; color: var(--ts-ink-soft); li { display: inline-flex; align-items: center; gap: 0.4375rem; } } .ts-chart__key { width: 1rem; height: 2px; border-radius: 1px; background: var(--key); } .ts-chart__key--area { width: 0.75rem; height: 0.75rem; border-radius: 3px; } .ts-chart__plot { position: relative; border-radius: 0.5rem; outline: none; touch-action: pan-y; &:focus-visible { box-shadow: 0 0 0 2px var(--ts-brand); } } svg { display: block; overflow: visible; font-family: var(--ts-font-mono); } .ts-chart__grid { stroke: var(--ts-line); stroke-width: 1; shape-rendering: crispEdges; } .ts-chart__tick { fill: var(--ts-muted); font-size: 0.6875rem; font-variant-numeric: tabular-nums; } .ts-chart__line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } .ts-chart__line--dashed { stroke-width: 1.5; stroke-dasharray: 5 4; } .ts-chart__wash { fill-opacity: 0.1; } /* The 2px surface gap between stacked bands. */ .ts-chart__gap { fill: none; stroke: var(--ts-surface); stroke-width: 2; stroke-linejoin: round; } .ts-chart__end { fill: var(--ts-ink-soft); font-size: 0.75rem; font-weight: 600; } .ts-chart__marker { stroke: var(--ts-ink-soft); stroke-width: 1; } .ts-chart__marker-label { fill: var(--ts-ink-soft); font-size: 0.6875rem; font-weight: 600; } .ts-chart__crosshair { stroke: var(--ts-ink-soft); stroke-width: 1; shape-rendering: crispEdges; } .ts-chart__dot { stroke: var(--ts-surface); stroke-width: 2; } .ts-chart__tooltip { position: absolute; top: 0; z-index: 2; min-width: 9rem; margin-left: 0.75rem; padding: 0.5rem 0.625rem; border-radius: 0.5rem; background: var(--ts-surface-raised); border: 1px solid var(--ts-line-strong); box-shadow: 0 8px 24px -12px rgb(0 0 0 / 35%); pointer-events: none; font-size: 0.75rem; color: var(--ts-ink-soft); &.is-left { margin-left: 0; transform: translateX(calc(-100% - 0.75rem)); } p { margin: 0; } strong { color: var(--ts-ink); font-variant-numeric: tabular-nums; } } .ts-chart__tooltip-title { margin-bottom: 0.25rem !important; font-weight: 600; color: var(--ts-muted); } .ts-chart__tooltip-row { display: flex; align-items: center; gap: 0.375rem; line-height: 1.6; } .ts-chart__tooltip-key { width: 0.625rem; height: 2px; border-radius: 1px; background: var(--key); } .ts-chart__tooltip-total { margin-top: 0.25rem !important; padding-top: 0.25rem; border-top: 1px solid var(--ts-line); } .ts-chart__data summary { width: fit-content; cursor: pointer; font-size: 0.75rem; color: var(--ts-muted); } .ts-chart__table { max-height: 16rem; margin-top: 0.5rem; overflow: auto; } table { width: 100%; border-collapse: collapse; font-size: 0.75rem; font-variant-numeric: tabular-nums; } th, td { padding: 0.25rem 0.5rem; border-bottom: 1px solid var(--ts-line); text-align: right; white-space: nowrap; } th:first-child { text-align: left; font-weight: 500; } thead th { color: var(--ts-muted); font-weight: 600; } `, }) export class SeriesChartComponent { /** Short x labels (axis). */ readonly labels = input.required(); /** Long x labels (tooltip and table); defaults to `labels`. */ readonly longLabels = input(); readonly series = input.required(); readonly stacked = input(false); readonly marker = input(null); /** What the chart shows, for screen readers. */ readonly summary = input.required(); /** Header of the table's first column. */ readonly axisLabel = input('Fecha'); readonly height = input(240); protected readonly left = PAD.left; protected readonly right = PAD.right; protected readonly top = PAD.top; protected readonly width = signal(640); protected readonly hover = signal(null); protected readonly count = computed(() => this.labels().length); /** Stacked: top band first, as the reader sees them. */ protected readonly legend = computed(() => this.stacked() ? [...this.series()].reverse() : this.series(), ); protected readonly fullLabels = computed( () => this.longLabels() ?? this.labels(), ); /** Lower/upper bound of every series at every x (stacked: cumulative). */ private readonly bands = computed(() => { let base = new Array(this.count()).fill(0) as number[]; return this.series().map((s) => { const lower = this.stacked() ? base : base.map(() => 0); // Stacked bands have no gaps: a missing value adds nothing. const upper: (number | null)[] = s.values.map((v, i) => v === null && !this.stacked() ? null : (this.stacked() ? lower[i] : 0) + (v ?? 0), ); if (this.stacked()) base = upper as number[]; return { ...s, lower, upper }; }); }); protected readonly ticks = computed(() => niceTicks( Math.max( 0, ...this.bands().flatMap((b) => b.upper.filter((v): v is number => v !== null), ), ), ), ); private readonly max = computed(() => last(this.ticks()) || 1); protected x(index: number): number { const plot = this.width() - PAD.left - PAD.right; return this.count() <= 1 ? PAD.left + plot / 2 : PAD.left + (index * plot) / (this.count() - 1); } protected y(value: number): number { const plot = this.height() - PAD.top - PAD.bottom; return PAD.top + plot - (value / this.max()) * plot; } protected anchor(index: number): string { if (this.count() <= 1) return 'middle'; return index === 0 ? 'start' : index === this.count() - 1 ? 'end' : 'middle'; } /** At most one label every ~90px, always the first and last. */ protected readonly xTicks = computed(() => { const n = this.count(); if (n === 0) return []; const slots = Math.max( 2, Math.floor((this.width() - PAD.left - PAD.right) / 90), ); const step = Math.max(1, Math.ceil((n - 1) / (slots - 1))); const ticks: number[] = []; for (let i = 0; i < n; i += step) ticks.push(i); if (last(ticks) !== n - 1) { if (n - 1 - (last(ticks) ?? 0) < step / 2) ticks.pop(); ticks.push(n - 1); } return ticks; }); protected readonly shapes = computed(() => this.bands().map((band) => { const gaps = band.upper.some((v) => v === null); // A gap starts a new stroke ("M") where the values come back. const line = band.upper .map((v, i) => v === null ? null : `${i === 0 || band.upper[i - 1] === null ? 'M' : 'L'}${this.x(i)},${this.y(v)}`, ) .filter(Boolean) .join(''); const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v ?? 0)}`); let fill: string | null = null; if (!gaps && (this.stacked() || band.area)) { const floor = band.lower .map((v, i) => `${this.x(i)},${this.y(v)}`) .reverse(); fill = points.length ? `M${points.join('L')}L${floor.join('L')}Z` : null; } return { label: band.label, color: band.color, dashed: !!band.dashed, line, fill, }; }), ); /** Last value at the line end, only while labels keep 14px apart. */ protected readonly endLabels = computed(() => { if (this.stacked() || this.count() === 0) return []; const labels = this.bands() .map((band) => { const value = last(band.upper.filter((v) => v !== null)) ?? 0; return { text: String(value), y: this.y(value) }; }) .sort((a, b) => a.y - b.y); const apart = labels.every( (l, i) => i === 0 || l.y - labels[i - 1].y >= 14, ); return apart ? labels : []; }); protected readonly dots = computed(() => { const index = this.hover(); if (index === null) return []; return this.bands() .filter((band) => band.upper[index] !== null) .map((band) => ({ label: band.label, color: band.color, y: this.y(band.upper[index] ?? 0), })); }); protected readonly tooltip = computed(() => { const index = this.hover(); if (index === null || index >= this.count()) return null; const rows = this.series() .map((s) => ({ label: s.label, color: s.color, value: s.values[index] })) // Stacked: same order as the bands, top first. .reverse(); if (!this.stacked()) rows.reverse(); const x = this.x(index); return { title: this.fullLabels()[index], rows, total: this.stacked() ? rows.reduce((sum, r) => sum + (r.value ?? 0), 0) : null, x, left: x > this.width() * 0.6, }; }); constructor() { const host = inject(ElementRef); const destroyRef = inject(DestroyRef); afterNextRender(() => { const observer = new ResizeObserver(([entry]) => this.width.set(Math.max(240, Math.floor(entry.contentRect.width))), ); observer.observe(host.nativeElement); destroyRef.onDestroy(() => observer.disconnect()); }); } /** The crosshair snaps to the nearest x. */ protected onPointer(event: PointerEvent): void { const n = this.count(); if (n === 0) return; const box = (event.currentTarget as HTMLElement).getBoundingClientRect(); const plot = this.width() - PAD.left - PAD.right; const ratio = (event.clientX - box.left - PAD.left) / plot; this.hover.set(Math.min(n - 1, Math.max(0, Math.round(ratio * (n - 1))))); } protected onKey(event: KeyboardEvent): void { const n = this.count(); const current = this.hover() ?? n - 1; const next = event.key === 'ArrowLeft' ? current - 1 : event.key === 'ArrowRight' ? current + 1 : event.key === 'Home' ? 0 : event.key === 'End' ? n - 1 : null; if (next === null) return; event.preventDefault(); this.hover.set(Math.min(n - 1, Math.max(0, next))); } }