Files
task-stream/libs/web/tracker/presenters/src/lib/ui/series-chart.ts
Josep Piguillem 50d3ffd1dd feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:59:01 +02:00

622 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = <T>(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) {
<ul class="ts-chart__legend">
@for (s of legend(); track s.label) {
<li>
<span
class="ts-chart__key"
[class.ts-chart__key--area]="stacked()"
[style.--key]="s.color"
></span>
{{ s.label }}
</li>
}
</ul>
}
<div
class="ts-chart__plot"
tabindex="0"
role="img"
[attr.aria-label]="summary()"
(pointermove)="onPointer($event)"
(pointerleave)="hover.set(null)"
(keydown)="onKey($event)"
(focus)="hover.set(hover() ?? count() - 1)"
(blur)="hover.set(null)"
>
<svg [attr.width]="width()" [attr.height]="height()" aria-hidden="true">
@for (tick of ticks(); track tick) {
<line
class="ts-chart__grid"
[attr.x1]="left"
[attr.x2]="width() - right"
[attr.y1]="y(tick)"
[attr.y2]="y(tick)"
/>
<text
class="ts-chart__tick"
[attr.x]="left - 8"
[attr.y]="y(tick)"
text-anchor="end"
dominant-baseline="middle"
>
{{ tick }}
</text>
}
@for (index of xTicks(); track index) {
<text
class="ts-chart__tick"
[attr.x]="x(index)"
[attr.y]="height() - 8"
[attr.text-anchor]="anchor(index)"
>
{{ labels()[index] }}
</text>
}
@if (marker(); as m) {
<line
class="ts-chart__marker"
[attr.x1]="x(m.index)"
[attr.x2]="x(m.index)"
[attr.y1]="top - 4"
[attr.y2]="y(0)"
/>
<text
class="ts-chart__marker-label"
[attr.x]="x(m.index) + 4"
[attr.y]="top + 6"
>
{{ m.label }}
</text>
}
@for (shape of shapes(); track shape.label) {
@if (shape.fill) {
<path
[class]="stacked() ? 'ts-chart__band' : 'ts-chart__wash'"
[attr.d]="shape.fill"
[style.fill]="shape.color"
/>
}
@if (shape.line) {
<path
[class]="stacked() ? 'ts-chart__gap' : 'ts-chart__line'"
[class.ts-chart__line--dashed]="shape.dashed"
[attr.d]="shape.line"
[style.stroke]="stacked() ? null : shape.color"
/>
}
}
@for (label of endLabels(); track label.text) {
<text
class="ts-chart__end"
[attr.x]="width() - right + 4"
[attr.y]="label.y"
dominant-baseline="middle"
>
{{ label.text }}
</text>
}
@if (hover() !== null) {
<line
class="ts-chart__crosshair"
[attr.x1]="x(hover()!)"
[attr.x2]="x(hover()!)"
[attr.y1]="top"
[attr.y2]="y(0)"
/>
@for (dot of dots(); track dot.label) {
<circle
class="ts-chart__dot"
[attr.cx]="x(hover()!)"
[attr.cy]="dot.y"
r="4"
[style.fill]="dot.color"
/>
}
}
</svg>
@if (tooltip(); as tip) {
<div
class="ts-chart__tooltip"
[class.is-left]="tip.left"
[style.left.px]="tip.x"
>
<p class="ts-chart__tooltip-title">{{ tip.title }}</p>
@for (row of tip.rows; track row.label) {
<p class="ts-chart__tooltip-row">
<span
class="ts-chart__tooltip-key"
[style.--key]="row.color"
></span>
<strong>{{ row.value ?? '—' }}</strong> {{ row.label }}
</p>
}
@if (tip.total !== null) {
<p class="ts-chart__tooltip-row ts-chart__tooltip-total">
<strong>{{ tip.total }}</strong> en total
</p>
}
</div>
}
</div>
<details class="ts-chart__data">
<summary>Ver los datos</summary>
<div class="ts-chart__table">
<table>
<thead>
<tr>
<th scope="col">{{ axisLabel() }}</th>
@for (s of series(); track s.label) {
<th scope="col">{{ s.label }}</th>
}
</tr>
</thead>
<tbody>
@for (label of fullLabels(); track $index; let i = $index) {
<tr>
<th scope="row">{{ label }}</th>
@for (s of series(); track s.label) {
<td>{{ s.values[i] ?? '—' }}</td>
}
</tr>
}
</tbody>
</table>
</div>
</details>
`,
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<string[]>();
/** Long x labels (tooltip and table); defaults to `labels`. */
readonly longLabels = input<string[] | undefined>();
readonly series = input.required<ChartSeries[]>();
readonly stacked = input(false);
readonly marker = input<ChartMarker | null>(null);
/** What the chart shows, for screen readers. */
readonly summary = input.required<string>();
/** 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<number | null>(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<HTMLElement>);
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)));
}
}