@tolle_/tolle-ui 18.3.0 → 18.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "chart-spark",
3
+ "type": "registry:ui",
4
+ "title": "Chart Spark",
5
+ "description": "Chart Spark component.",
6
+ "category": "data",
7
+ "registryDependencies": [
8
+ "chart"
9
+ ],
10
+ "dependencies": [
11
+ "class-variance-authority"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "chart-spark.component.ts",
16
+ "target": "ui/chart-spark.component.ts",
17
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport {\n ChartComponent,\n ChartLineComponent,\n ChartAreaComponent,\n ChartBarComponent,\n type ChartMargin,\n type ChartSeries,\n} from './chart.component';\n\n/**\n * A minimal inline chart: one series, no grid, no axes, no legend — sized to\n * sit next to a number in a table cell or a stat card. A thin composition\n * over `tolle-chart`; it draws nothing of its own and inherits `--chart-1`\n * the same way any other single-series chart in this library does.\n * @new\n */\n@Component({\n selector: 'tolle-chart-spark',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, ChartComponent, ChartLineComponent, ChartAreaComponent, ChartBarComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <tolle-chart\n [class]=\"class\"\n [data]=\"data\"\n [series]=\"series\"\n [xKey]=\"xKey\"\n [height]=\"height\"\n [margin]=\"margin\"\n [hover]=\"hover\"\n [showTable]=\"showTable\"\n [ariaLabel]=\"ariaLabel\"\n [description]=\"description\"\n >\n <svg:g *ngIf=\"type === 'line'\" tolle-chart-line [seriesKey]=\"valueKey\" [curve]=\"curve\"></svg:g>\n <svg:g *ngIf=\"type === 'area'\" tolle-chart-area [seriesKey]=\"valueKey\" [curve]=\"curve\"></svg:g>\n <svg:g *ngIf=\"type === 'bar'\" tolle-chart-bar [seriesKey]=\"valueKey\" [maxWidth]=\"maxBarWidth\"></svg:g>\n </tolle-chart>\n `,\n})\nexport class ChartSparkComponent implements OnChanges {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Rows to plot, in x order. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Row key holding each row's numeric value. @default 'value' */\n @Input() valueKey = 'value';\n /** Row key holding each row's x position. Only matters once `hover` is on. @default '' */\n @Input() xKey = '';\n /** Which mark to draw. @default 'line' */\n @Input() type: 'line' | 'area' | 'bar' = 'line';\n /** Interpolation for line/area. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Largest bar thickness in px, used when `type` is 'bar'. @default 6 */\n @Input() maxBarWidth = 6;\n /** Height in px. @default 32 */\n @Input() height = 32;\n /** Renders the crosshair, hit layer and tooltip. @default false */\n @Input() hover = false;\n /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Accessible name, used as the svg <title>. @default '' */\n @Input() ariaLabel = '';\n /** Longer summary used as the svg <desc>. Falls back to `ariaLabel`. @default '' */\n @Input() description = '';\n /** Space reserved around the plot — a sparkline has no axis labels to fit. @default { top: 2, right: 2, bottom: 2, left: 2 } */\n @Input() margin: ChartMargin = { top: 2, right: 2, bottom: 2, left: 2 };\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Always exactly one series, reading `valueKey` off each row. */\n get series(): ChartSeries[] {\n return [{ key: this.valueKey, label: this.ariaLabel || this.valueKey }];\n }\n}\n"
18
+ },
19
+ {
20
+ "path": "chart.service.ts",
21
+ "target": "ui/chart.service.ts",
22
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** One plotted series: which key to read off each row, and how to name it. */\nexport interface ChartSeries {\n key: string;\n label: string;\n}\n\n/** Space reserved around the plot area for axes and labels. */\nexport interface ChartMargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** A y-axis tick: its data value, its resolved svg y, and its formatted label. */\nexport interface ChartTick {\n value: number;\n y: number;\n label: string;\n}\n\n/** A horizontal slice of the plot belonging to one x position. */\nexport interface ChartBand {\n start: number;\n centre: number;\n width: number;\n}\n\n/** How x positions are resolved: discrete bands (bars) or points (line/area). */\nexport type ChartScaleMode = 'point' | 'band';\n\n/**\n * Which physical axis carries the category vs the value. Only the band scale\n * (bars) supports 'horizontal' — line/area stay point-scale and vertical,\n * same as Tremor never offers a horizontal line/area chart either.\n */\nexport type ChartOrientation = 'vertical' | 'horizontal';\n\n/** Number of chart palette steps that exist. A 6th series does not get a colour. */\nexport const CHART_COLOR_LIMIT = 5;\n\n/**\n * The paint used for a series past `CHART_COLOR_LIMIT`.\n *\n * The palette is validated for lightness, chroma and colour-blind separation at\n * exactly five steps; generating a sixth would land outside that validation, and\n * wrapping back to step 1 would make two series share an identity. Both are\n * worse than dropping to a neutral, so the overflow is explicitly uncoloured.\n */\nexport const CHART_OVERFLOW_COLOR = 'rgb(var(--muted-foreground))';\n\n/**\n * Hands out palette steps by entity key rather than by position.\n *\n * A key keeps its slot for the lifetime of the scale, which is what makes\n * colour follow the entity: filtering a series out and back in, or removing an\n * earlier one, must never repaint the survivors. Past the fifth distinct key\n * the scale stops colouring rather than inventing a step or wrapping around.\n */\nexport class ChartColorScale {\n private readonly slots = new Map<string, number>();\n private next = 0;\n\n /** Reserves slots for `keys` in order, leaving already-known keys alone. */\n register(keys: string[]): void {\n for (const key of keys) {\n if (!this.slots.has(key)) this.slots.set(key, this.next++);\n }\n }\n\n /** Paint for `key`, registering it on first sight. */\n colorFor(key: string): string {\n let slot = this.slots.get(key);\n if (slot === undefined) {\n slot = this.next++;\n this.slots.set(key, slot);\n }\n if (slot >= CHART_COLOR_LIMIT) return CHART_OVERFLOW_COLOR;\n return 'rgb(var(--chart-' + (slot + 1) + '))';\n }\n\n /** True when `key` fell past the palette and renders neutral. */\n isOverflow(key: string): boolean {\n const slot = this.slots.get(key);\n return slot !== undefined && slot >= CHART_COLOR_LIMIT;\n }\n\n /** Slot index assigned to `key`, or undefined when it is unknown. */\n slotOf(key: string): number | undefined {\n return this.slots.get(key);\n }\n}\n\n/**\n * Owns layout, scales and colour assignment for one `tolle-chart`, so every\n * child draws into the same coordinate space. Renders nothing itself.\n *\n * Provided on `ChartComponent`, so each chart gets its own instance.\n */\n@Injectable()\nexport class ChartService {\n /** Rows being plotted, in x order. */\n data: Record<string, any>[] = [];\n /** Series definitions, in the order they should take palette steps. */\n series: ChartSeries[] = [];\n /** Row key holding the x category/label. */\n xKey = '';\n /** Whether marks stack on a shared baseline rather than sitting side by side. */\n stacked = false;\n /** Full svg width in px, tracked from the container's ResizeObserver. */\n width = 0;\n /** Full svg height in px. */\n height = 260;\n /** Space reserved around the plot area. */\n margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 40 };\n /** Fraction of a band's step spent on padding, split either side. */\n bandPadding = 0.2;\n /**\n * Which physical axis is the category axis. Bars flip this via `configure()`;\n * every other geometry getter branches on it internally, defaulting to the\n * exact vertical formula when unset, so existing vertical charts are unaffected.\n */\n orientation: ChartOrientation = 'vertical';\n\n private scaleMode: ChartScaleMode = 'point';\n private zeroRequired = false;\n\n private domainMin = 0;\n private domainMax = 1;\n private tickValues: number[] = [];\n private tickStep = 1;\n\n /** Palette assignment, keyed by series identity. */\n private readonly colors = new ChartColorScale();\n\n private readonly layoutSubject = new BehaviorSubject<number>(0);\n /** Emits a version number whenever scales, dimensions or data change. */\n readonly layout$ = this.layoutSubject.asObservable();\n\n private readonly activeIndexSubject = new BehaviorSubject<number | null>(null);\n /** Index of the x position under the pointer, or null when not hovering. */\n readonly activeIndex$ = this.activeIndexSubject.asObservable();\n\n private layoutVersion = 0;\n private emitScheduled = false;\n\n // ---------------------------------------------------------------- geometry\n\n get plotLeft(): number {\n return this.margin.left;\n }\n\n get plotTop(): number {\n return this.margin.top;\n }\n\n get plotWidth(): number {\n return Math.max(0, this.width - this.margin.left - this.margin.right);\n }\n\n get plotHeight(): number {\n return Math.max(0, this.height - this.margin.top - this.margin.bottom);\n }\n\n get plotRight(): number {\n return this.plotLeft + this.plotWidth;\n }\n\n get plotBottom(): number {\n return this.plotTop + this.plotHeight;\n }\n\n /** Number of x positions. */\n get count(): number {\n return this.data.length;\n }\n\n /** How x is currently resolved. Bars flip this to 'band'. */\n get mode(): ChartScaleMode {\n return this.scaleMode;\n }\n\n /** Resolved y ticks, with svg coordinates and formatted labels. */\n get ticks(): ChartTick[] {\n return this.tickValues.map((value) => ({\n value,\n y: this.yFor(value),\n label: this.formatValue(value),\n }));\n }\n\n /** Current y domain as [min, max]. */\n get domain(): [number, number] {\n return [this.domainMin, this.domainMax];\n }\n\n /** The x-axis category labels, one per row. */\n get xLabels(): string[] {\n return this.data.map((row) => {\n const raw = row?.[this.xKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n // ------------------------------------------------------------ registration\n\n /**\n * Replaces the chart's configuration and recomputes the scales.\n * Called by the container on input changes and on resize.\n */\n configure(config: Partial<Pick<ChartService,\n 'data' | 'series' | 'xKey' | 'stacked' | 'width' | 'height' | 'margin' | 'bandPadding' | 'orientation'>>): void {\n Object.assign(this, config);\n if (config.series) this.syncColorSlots(config.series);\n this.recompute();\n }\n\n /** Switches x to a band scale. Bars call this; it also forces zero into the domain. */\n useBandScale(): void {\n if (this.scaleMode === 'band' && this.zeroRequired) return;\n this.scaleMode = 'band';\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Forces zero into the y domain, for marks anchored to a baseline. */\n requireZero(): void {\n if (this.zeroRequired) return;\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Reserves palette slots in series order, leaving known keys untouched. */\n private syncColorSlots(series: ChartSeries[]): void {\n this.colors.register(series.map((item) => item.key));\n }\n\n /**\n * Paint for a series, keyed by identity rather than by index in the visible\n * array. Past the fifth distinct key this returns the neutral overflow colour.\n */\n colorFor(seriesKey: string): string {\n return this.colors.colorFor(seriesKey);\n }\n\n /** True when this series fell past the palette and renders neutral. */\n isOverflowSeries(seriesKey: string): boolean {\n return this.colors.isOverflow(seriesKey);\n }\n\n // -------------------------------------------------------------- the scales\n\n /** Reads a series value off a row, or null when it is missing/non-numeric. */\n valueAt(seriesKey: string, index: number): number | null {\n const raw = this.data[index]?.[seriesKey];\n if (raw == null || raw === '') return null;\n const num = Number(raw);\n return Number.isFinite(num) ? num : null;\n }\n\n /** Sum of every series below `seriesKey` at `index`, for stacked marks. */\n stackBase(seriesKey: string, index: number): number {\n if (!this.stacked) return 0;\n let base = 0;\n for (const item of this.series) {\n if (item.key === seriesKey) break;\n base += this.valueAt(item.key, index) ?? 0;\n }\n return base;\n }\n\n /** Sum of every series at `index`. */\n stackTotal(index: number): number {\n let total = 0;\n for (const item of this.series) total += this.valueAt(item.key, index) ?? 0;\n return total;\n }\n\n /**\n * Maps a data value to a position along the *value* axis — svg y when\n * vertical (svg y grows downward, so the domain maximum lands on `plotTop`),\n * svg x when horizontal (the domain maximum lands on `plotRight`).\n */\n yFor(value: number): number {\n const span = this.domainMax - this.domainMin;\n if (this.orientation === 'horizontal') {\n if (span <= 0) return this.plotLeft;\n const ratio = (value - this.domainMin) / span;\n return this.plotLeft + ratio * this.plotWidth;\n }\n if (span <= 0) return this.plotBottom;\n const ratio = (value - this.domainMin) / span;\n return this.plotBottom - ratio * this.plotHeight;\n }\n\n /** The value-axis position of the zero line, clamped into the plot. */\n get baselineY(): number {\n const zero = Math.min(Math.max(0, this.domainMin), this.domainMax);\n return this.yFor(zero);\n }\n\n /** Length of the plot along the category axis: `plotHeight` when horizontal, else `plotWidth`. */\n private get categoryAxisLength(): number {\n return this.orientation === 'horizontal' ? this.plotHeight : this.plotWidth;\n }\n\n /** Origin of the plot along the category axis: `plotTop` when horizontal, else `plotLeft`. */\n private get categoryAxisOrigin(): number {\n return this.orientation === 'horizontal' ? this.plotTop : this.plotLeft;\n }\n\n /** Width of one band, excluding its padding. Meaningful in band mode. */\n get bandWidth(): number {\n if (this.count === 0) return 0;\n return this.bandStep * (1 - this.bandPadding);\n }\n\n /** Distance between adjacent band starts. Bands tile the plot exactly. */\n get bandStep(): number {\n if (this.count === 0) return 0;\n return this.categoryAxisLength / this.count;\n }\n\n /**\n * Geometry of the band at `index`, along the category axis — x when\n * vertical (today's meaning), y when horizontal.\n */\n bandFor(index: number): ChartBand {\n const step = this.bandStep;\n const width = this.bandWidth;\n const start = this.categoryAxisOrigin + index * step + (step - width) / 2;\n return { start, centre: start + width / 2, width };\n }\n\n /** x of the data point at `index` on the point scale. */\n pointX(index: number): number {\n if (this.count === 0) return this.plotLeft;\n if (this.count === 1) return this.plotLeft + this.plotWidth / 2;\n return this.plotLeft + (index * this.plotWidth) / (this.count - 1);\n }\n\n /** x of `index` under whichever scale the chart is currently using. */\n xFor(index: number): number {\n return this.scaleMode === 'band' ? this.bandFor(index).centre : this.pointX(index);\n }\n\n /**\n * The pointer catchment area for `index` — deliberately wider than the mark.\n * In band mode it is the whole band step; on the point scale it runs to the\n * midpoints of the neighbouring points, so the pointer only has to be nearest.\n */\n hitBandFor(index: number): ChartBand {\n if (this.scaleMode === 'band') {\n const step = this.bandStep;\n const start = this.categoryAxisOrigin + index * step;\n return { start, centre: start + step / 2, width: step };\n }\n const x = this.pointX(index);\n const prev = index > 0 ? this.pointX(index - 1) : this.plotLeft - (this.plotRight - this.plotLeft);\n const next =\n index < this.count - 1 ? this.pointX(index + 1) : this.plotRight + (this.plotRight - this.plotLeft);\n const start = Math.max(this.plotLeft, (prev + x) / 2);\n const end = Math.min(this.plotRight, (x + next) / 2);\n return { start, centre: x, width: Math.max(0, end - start) };\n }\n\n // --------------------------------------------------------------- hovering\n\n /** Index currently under the pointer, or null. */\n get activeIndex(): number | null {\n return this.activeIndexSubject.value;\n }\n\n setActiveIndex(index: number | null): void {\n if (this.activeIndexSubject.value === index) return;\n this.activeIndexSubject.next(index);\n }\n\n // ----------------------------------------------------------------- domains\n\n private recompute(): void {\n const values: number[] = [];\n\n if (this.stacked) {\n for (let i = 0; i < this.count; i++) values.push(this.stackTotal(i));\n // A stack still has to show its own floor when a series goes negative.\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null && v < 0) values.push(v);\n }\n }\n } else {\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null) values.push(v);\n }\n }\n }\n\n if (this.zeroRequired) values.push(0);\n\n const [min, max, step, ticks] = niceScale(values);\n this.domainMin = min;\n this.domainMax = max;\n this.tickStep = step;\n this.tickValues = ticks;\n\n // Only tell subscribers when something they would draw differently actually\n // changed. `configure()` is called from ngOnChanges, and an app binding\n // `[data]=\"items.filter(...)\"` hands us a fresh array reference on every\n // change-detection pass — an ordinary thing to write. Emitting on every call\n // made subscribers markForCheck, which scheduled another pass, which called\n // configure again: the cycle never closed and the page locked up.\n const signature = this.layoutSignature(values);\n if (signature === this.lastSignature) return;\n this.lastSignature = signature;\n\n this.scheduleEmit();\n }\n\n /** Everything a subscriber would render differently, flattened for comparison. */\n private lastSignature = '';\n\n private layoutSignature(values: number[]): string {\n return [\n this.count,\n this.xKey,\n this.stacked,\n this.width,\n this.height,\n this.bandPadding,\n this.scaleMode,\n this.orientation,\n this.zeroRequired,\n this.margin.top, this.margin.right, this.margin.bottom, this.margin.left,\n this.domainMin, this.domainMax, this.tickStep,\n this.tickValues.join(','),\n this.series.map((s) => s.key + ':' + s.label).join(','),\n this.xLabels.join('\u0001'),\n values.join(','),\n ].join('\u0000');\n }\n\n /**\n * Coalesces layout notifications into a microtask, so several children\n * registering in the same change-detection pass produce one emission after\n * they have all settled rather than one each mid-pass.\n */\n private scheduleEmit(): void {\n this.layoutVersion++;\n if (this.emitScheduled) return;\n this.emitScheduled = true;\n queueMicrotask(() => {\n this.emitScheduled = false;\n this.layoutSubject.next(this.layoutVersion);\n });\n }\n\n /** Formats a value for a tick label at the current tick step's precision. */\n formatValue(value: number): string {\n const decimals = decimalsFor(this.tickStep);\n return value.toLocaleString('en-US', {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n });\n }\n\n // ------------------------------------------------------------------- paths\n\n /** Straight-segment path through the series' defined points. */\n linePath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), false);\n }\n\n /** Monotone-cubic path through the series' defined points. */\n smoothPath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), true);\n }\n\n /**\n * Filled-area path: the series' line, closed back along its baseline. When\n * stacked, the floor is the top of the series below rather than zero.\n */\n areaPath(seriesKey: string, smooth: boolean): string {\n const points = this.pointsFor(seriesKey);\n if (points.length === 0) return '';\n\n const top = buildPath(points, smooth);\n const floor = points\n .map((p) => {\n const base = this.stacked ? this.stackBase(seriesKey, p.index) : 0;\n return { x: p.x, y: this.yFor(base), index: p.index };\n })\n .reverse();\n const bottom = buildPath(floor, smooth);\n // Re-enter the reversed floor with a line, then close.\n return top + ' L ' + floor[0].x + ' ' + floor[0].y + ' ' + bottom.replace(/^M/, 'L') + ' Z';\n }\n\n /** Resolved svg points for a series, skipping rows where it has no value. */\n pointsFor(seriesKey: string): { x: number; y: number; index: number }[] {\n const points: { x: number; y: number; index: number }[] = [];\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(seriesKey, i);\n if (v == null) continue;\n const stacked = this.stacked ? this.stackBase(seriesKey, i) + v : v;\n points.push({ x: this.xFor(i), y: this.yFor(stacked), index: i });\n }\n return points;\n }\n}\n\n// ------------------------------------------------------------- pure helpers\n\n/** Decimal places worth showing for a given tick step. */\nexport function decimalsFor(step: number): number {\n if (!Number.isFinite(step) || step <= 0) return 0;\n return Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));\n}\n\n/**\n * Rounds a raw step up to the next 1/2/5 x 10^n.\n *\n * Dividing the range by a tick count instead would produce steps like 19.4 and\n * so axis labels like 19.4 / 38.8 / 58.2, which nobody can read a value off.\n */\nexport function niceStep(rawStep: number): number {\n if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));\n const normalised = rawStep / magnitude;\n let multiplier: number;\n if (normalised <= 1) multiplier = 1;\n else if (normalised <= 2) multiplier = 2;\n else if (normalised <= 5) multiplier = 5;\n else multiplier = 10;\n return multiplier * magnitude;\n}\n\n/**\n * Builds a readable y scale over `values`: returns `[min, max, step, ticks]`\n * with the domain snapped outward to whole steps.\n *\n * Degenerate inputs (empty, all-identical) still produce a usable one-unit\n * domain rather than a zero-height plot.\n */\nexport function niceScale(values: number[], targetTicks = 5): [number, number, number, number[]] {\n const finite = values.filter((v) => Number.isFinite(v));\n\n let min = finite.length ? Math.min(...finite) : 0;\n let max = finite.length ? Math.max(...finite) : 1;\n\n if (min === max) {\n // A flat or single-point domain has no span to divide; open it to one unit\n // around the value so the mark sits on a real axis instead of on the floor.\n if (min === 0) {\n min = 0;\n max = 1;\n } else if (min > 0) {\n min = Math.min(0, min);\n max = max === 0 ? 1 : max;\n if (min === max) max = min + 1;\n } else {\n max = Math.max(0, max);\n if (min === max) min = max - 1;\n }\n }\n\n const step = niceStep((max - min) / targetTicks);\n // Nudge before rounding outward: 100/20 lands a hair above 5 in binary\n // floating point, which would otherwise add a phantom tick at 120.\n const epsilon = 1e-9;\n const niceMin = Math.floor(min / step + epsilon) * step;\n const niceMax = Math.ceil(max / step - epsilon) * step;\n const decimals = decimalsFor(step);\n\n const ticks: number[] = [];\n const total = Math.round((niceMax - niceMin) / step);\n for (let i = 0; i <= total; i++) {\n ticks.push(Number((niceMin + i * step).toFixed(decimals)));\n }\n\n return [Number(niceMin.toFixed(decimals)), Number(niceMax.toFixed(decimals)), step, ticks];\n}\n\n/**\n * Monotone-cubic tangents (Fritsch-Carlson). Limiting each tangent to the\n * neighbouring secants is what stops the curve overshooting: a plain\n * Catmull-Rom spline bulges past the data and invents peaks that were never\n * measured, which on a chart is a false statement about the numbers.\n */\nexport function monotoneTangents(xs: number[], ys: number[]): number[] {\n const n = xs.length;\n if (n < 2) return new Array(n).fill(0);\n\n const secants: number[] = [];\n for (let i = 0; i < n - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n secants.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);\n }\n\n const m: number[] = new Array(n);\n m[0] = secants[0];\n m[n - 1] = secants[n - 2];\n for (let i = 1; i < n - 1; i++) m[i] = (secants[i - 1] + secants[i]) / 2;\n\n for (let i = 0; i < n - 1; i++) {\n if (secants[i] === 0) {\n // Flat segment: both ends must be flat or the curve leaves the data range.\n m[i] = 0;\n m[i + 1] = 0;\n continue;\n }\n const a = m[i] / secants[i];\n const b = m[i + 1] / secants[i];\n // Negative ratios mean the tangent points away from the secant.\n if (a < 0) m[i] = 0;\n if (b < 0) m[i + 1] = 0;\n const sum = a * a + b * b;\n if (sum > 9) {\n const t = 3 / Math.sqrt(sum);\n m[i] = t * a * secants[i];\n m[i + 1] = t * b * secants[i];\n }\n }\n\n return m;\n}\n\n/** Turns resolved points into an svg path, optionally monotone-smoothed. */\nexport function buildPath(points: { x: number; y: number }[], smooth: boolean): string {\n if (points.length === 0) return '';\n if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y;\n\n const head = 'M ' + points[0].x + ' ' + points[0].y;\n if (!smooth) {\n return head + points.slice(1).map((p) => ' L ' + p.x + ' ' + p.y).join('');\n }\n\n const xs = points.map((p) => p.x);\n const ys = points.map((p) => p.y);\n const m = monotoneTangents(xs, ys);\n\n let d = head;\n for (let i = 0; i < points.length - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n const c1x = xs[i] + dx / 3;\n const c1y = ys[i] + (m[i] * dx) / 3;\n const c2x = xs[i + 1] - dx / 3;\n const c2y = ys[i + 1] - (m[i + 1] * dx) / 3;\n d += ' C ' + c1x + ' ' + c1y + ' ' + c2x + ' ' + c2y + ' ' + xs[i + 1] + ' ' + ys[i + 1];\n }\n return d;\n}\n"
23
+ },
24
+ {
25
+ "path": "utils/cn.ts",
26
+ "target": "ui/utils/cn.ts",
27
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
28
+ }
29
+ ]
30
+ }
@@ -12,12 +12,12 @@
12
12
  {
13
13
  "path": "chart.component.ts",
14
14
  "target": "ui/chart.component.ts",
15
- "content": "import { Component, Directive, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef, ViewChild, OnInit, OnChanges, OnDestroy, AfterViewInit, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { ChartService, type ChartSeries, type ChartMargin } from './chart.service';\n\nexport type { ChartSeries, ChartMargin, ChartTick, ChartBand } from './chart.service';\n\nlet chartId = 0;\n\n/**\n * Base for every chart part that has to redraw when the shared scales change.\n * Parts subscribe to the service rather than reading the container component,\n * so an OnPush child still updates when the container's inputs or width change.\n */\n@Directive()\nexport abstract class ChartChild implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook subclasses keep\n * rendering the class they were born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n protected readonly chart = inject(ChartService);\n protected readonly cdr = inject(ChangeDetectorRef);\n protected readonly subscriptions = new Subscription();\n\n /**\n * Identity for the mark `*ngFor`s. The collections behind them are rebuilt on\n * every read, so without this `*ngFor` sees new identities each pass and\n * replaces every node instead of patching it. Marks are positional, so the\n * index IS the identity.\n */\n trackByIndex = (index: number): number => index;\n\n ngOnInit(): void {\n this.register();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** Hook for parts that need to influence the shared scales before first paint. */\n protected register(): void {}\n}\n\n/**\n * The floating readout for the hovered x position. Lists every series at that\n * x, so the pointer never has to land on a particular line to get a value.\n * Rendered automatically by `tolle-chart` when `hover` is on.\n */\n@Component({\n selector: 'tolle-chart-tooltip',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n *ngIf=\"index !== null\"\n role=\"status\"\n aria-live=\"polite\"\n [class]=\"computedClass\"\n [style.left.px]=\"left\"\n [style.top.px]=\"top\"\n >\n <p class=\"mb-1 text-xs font-medium text-foreground\">{{ xLabel }}</p>\n <div *ngFor=\"let row of rows; trackBy: trackByIndex\" class=\"flex items-center gap-2 text-xs\">\n <span class=\"h-0.5 w-3 shrink-0 rounded-full\" [style.background]=\"row.color\"></span>\n <span class=\"text-muted-foreground\">{{ row.label }}</span>\n <span class=\"ml-auto font-medium tabular-nums text-foreground\">{{ row.value }}</span>\n </div>\n </div>\n `,\n})\nexport class ChartTooltipComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Distance in px the tooltip sits from the crosshair. @default 12 */\n @Input() offset = 12;\n /** Assumed tooltip width in px, used to flip it near the right edge. @default 160 */\n @Input() estimatedWidth = 160;\n /** Extra Tailwind classes merged onto the tooltip via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n /** Row index the tooltip is describing, or null when nothing is hovered. */\n index: number | null = null;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n this.index = index;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get xLabel(): string {\n return this.index === null ? '' : this.chart.xLabels[this.index] ?? '';\n }\n\n /** One row per series: colour key, label, and the formatted value. */\n get rows(): { label: string; value: string; color: string }[] {\n const index = this.index;\n if (index === null) return [];\n return this.chart.series.map((item) => {\n const value = this.chart.valueAt(item.key, index);\n return {\n label: item.label,\n value: value == null ? '—' : this.chart.formatValue(value),\n color: this.chart.colorFor(item.key),\n };\n });\n }\n\n get left(): number {\n if (this.index === null) return 0;\n const x = this.chart.xFor(this.index);\n // Flip to the left of the crosshair rather than overflow the container.\n if (x + this.offset + this.estimatedWidth > this.chart.width) {\n return Math.max(0, x - this.offset - this.estimatedWidth);\n }\n return x + this.offset;\n }\n\n get top(): number {\n return this.chart.plotTop;\n }\n\n get computedClass() {\n return cn(\n 'pointer-events-none absolute z-10 min-w-[8rem] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md',\n this.class\n );\n }\n}\n\nconst chartLegendVariants = cva('flex flex-wrap items-center gap-x-4 gap-y-1 pt-2', {\n variants: {\n align: { start: 'justify-start', center: 'justify-center', end: 'justify-end' },\n size: { sm: 'text-[11px]', default: 'text-xs' },\n },\n defaultVariants: { align: 'center', size: 'default' },\n});\n\nexport type ChartLegendProps = VariantProps<typeof chartLegendVariants>;\n\n/**\n * Swatch and label per series. Rendered automatically by `tolle-chart` whenever\n * there are two or more series, so identity is never carried by colour alone.\n * A single series gets no legend — the chart's own label already names it.\n */\n@Component({\n selector: 'tolle-chart-legend',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"computedClass\">\n <li *ngFor=\"let entry of entries; trackBy: trackByIndex\" class=\"flex items-center gap-1.5\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"entry.color\"></span>\n <span class=\"text-muted-foreground\">{{ entry.label }}</span>\n </li>\n </ul>\n `,\n})\nexport class ChartLegendComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Horizontal alignment of the legend row. @default 'center' */\n @Input() align: ChartLegendProps['align'] = 'center';\n /** Text size of the legend. @default 'default' */\n @Input() size: ChartLegendProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the legend via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One entry per series, coloured by series identity. */\n get entries(): { label: string; color: string }[] {\n return this.chart.series.map((item) => ({\n label: item.label,\n color: this.chart.colorFor(item.key),\n }));\n }\n\n get computedClass() {\n return cn(chartLegendVariants({ align: this.align, size: this.size }), this.class);\n }\n}\n\nconst chartTableVariants = cva('w-full text-left text-xs', {\n variants: {\n visible: {\n true: 'mt-4 border-collapse',\n false: 'sr-only',\n },\n },\n defaultVariants: { visible: false },\n});\n\nexport type ChartTableProps = VariantProps<typeof chartTableVariants>;\n\n/**\n * The chart's data as a real `<table>`. Visually hidden by default and pointed\n * at by the svg's `aria-describedby`, so every value the chart encodes in\n * colour stays reachable without seeing or hovering it. Set `visible` to show it.\n */\n@Component({\n selector: 'tolle-chart-table',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [attr.id]=\"tableId || null\">\n <table [class]=\"computedClass\">\n <caption class=\"sr-only\">{{ caption }}</caption>\n <thead>\n <tr>\n <th scope=\"col\" class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\">\n {{ xHeader }}\n </th>\n <th\n *ngFor=\"let item of chart.series; trackBy: trackByIndex\"\n scope=\"col\"\n class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\"\n >\n {{ item.label }}\n </th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let row of rows; trackBy: trackByIndex\">\n <th scope=\"row\" class=\"border-b border-border px-2 py-1 font-normal text-foreground\">\n {{ row.label }}\n </th>\n <td\n *ngFor=\"let cell of row.cells; trackBy: trackByIndex\"\n class=\"border-b border-border px-2 py-1 tabular-nums text-foreground\"\n >\n {{ cell }}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n `,\n})\nexport class ChartTableComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Shows the table instead of hiding it from sighted readers. @default false */\n @Input() visible = false;\n /** Id applied to the wrapper, for the chart's `aria-describedby`. @default '' */\n @Input() tableId = '';\n /** Column header for the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Accessible caption for the table. @default 'Chart data' */\n @Input() caption = 'Chart data';\n /** Extra Tailwind classes merged onto the table via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One row per x position, with a formatted cell per series. */\n get rows(): { label: string; cells: string[] }[] {\n return this.chart.xLabels.map((label, i) => ({\n label,\n cells: this.chart.series.map((item) => {\n const value = this.chart.valueAt(item.key, i);\n return value == null ? '—' : this.chart.formatValue(value);\n }),\n }));\n }\n\n get computedClass() {\n return cn(chartTableVariants({ visible: this.visible }), this.class);\n }\n}\n\nconst chartVariants = cva('relative w-full', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-lg border border-border bg-background p-4',\n },\n density: {\n default: '',\n compact: 'text-xs',\n },\n },\n defaultVariants: { variant: 'default', density: 'default' },\n});\n\nexport type ChartProps = VariantProps<typeof chartVariants>;\n\n/**\n * Container for a hand-rolled SVG chart. Owns the responsive `<svg>`, the\n * shared scales (via the `ChartService` it provides), the hover layer, the\n * legend and the accessible table fallback.\n *\n * Marks and axes are projected as SVG children. They use attribute selectors on\n * `svg:g` rather than element selectors, because a custom element is created in\n * the HTML namespace and an HTML element inside an `<svg>` never renders its\n * SVG subtree:\n *\n * ```html\n * <tolle-chart [data]=\"rows\" [series]=\"series\" xKey=\"month\" ariaLabel=\"Revenue\">\n * <svg:g tolle-chart-grid></svg:g>\n * <svg:g tolle-chart-y-axis></svg:g>\n * <svg:g tolle-chart-x-axis></svg:g>\n * <svg:g tolle-chart-line seriesKey=\"revenue\" curve=\"smooth\"></svg:g>\n * </tolle-chart>\n * ```\n *\n * There is deliberately no secondary y-axis: two measures on two scales in one\n * frame let the author choose where the lines cross, so the reader sees a\n * relationship the data does not contain. Use two charts, small multiples, or\n * index both series to a common base instead.\n * @new\n */\n@Component({\n selector: 'tolle-chart',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, ChartTooltipComponent, ChartLegendComponent, ChartTableComponent],\n providers: [ChartService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative w-full\" #plot>\n <svg\n role=\"img\"\n width=\"100%\"\n [attr.height]=\"height\"\n [attr.viewBox]=\"viewBox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.aria-describedby]=\"tableId\"\n class=\"block w-full overflow-visible\"\n (pointerleave)=\"onPointerLeave()\"\n >\n <title>{{ ariaLabel }}</title>\n <desc>{{ description || ariaLabel }}</desc>\n\n <svg:g>\n <ng-content></ng-content>\n </svg:g>\n\n <svg:line\n *ngIf=\"crosshairX !== null\"\n [attr.x1]=\"crosshairX\"\n [attr.x2]=\"crosshairX\"\n [attr.y1]=\"chart.plotTop\"\n [attr.y2]=\"chart.plotBottom\"\n class=\"pointer-events-none stroke-border\"\n stroke-width=\"1\"\n ></svg:line>\n\n <svg:rect\n *ngFor=\"let band of hitBands; let i = index; trackBy: trackByIndex\"\n [attr.x]=\"band.start\"\n [attr.y]=\"chart.plotTop\"\n [attr.width]=\"band.width\"\n [attr.height]=\"chart.plotHeight\"\n fill=\"transparent\"\n (pointerenter)=\"onEnterBand(i)\"\n ></svg:rect>\n </svg>\n\n <tolle-chart-tooltip *ngIf=\"hover\"></tolle-chart-tooltip>\n </div>\n\n <tolle-chart-legend *ngIf=\"series.length > 1\"></tolle-chart-legend>\n\n <tolle-chart-table [tableId]=\"tableId\" [visible]=\"showTable\" [xHeader]=\"xHeader\"></tolle-chart-table>\n </div>\n `,\n})\nexport class ChartComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Rows to plot, in x order. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Series to draw, in the order they take palette steps `--chart-1`..`--chart-5`. @default [] */\n @Input() series: ChartSeries[] = [];\n /** Row key holding each row's x category label. @default '' */\n @Input() xKey = '';\n /** Height of the chart in px. @default 260 */\n @Input() height = 260;\n /** Space reserved around the plot for axis labels. @default { top: 8, right: 8, bottom: 24, left: 44 } */\n @Input() margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 44 };\n /** Accessible name for the chart, used as the svg `<title>`. @default '' */\n @Input() ariaLabel = '';\n /** Longer summary used as the svg `<desc>`. Falls back to `ariaLabel`. @default '' */\n @Input() description = '';\n /** Stacks marks on a shared baseline instead of grouping them. @default false */\n @Input() stacked = false;\n /** Renders the crosshair, hit layer and shared tooltip. @default true */\n @Input() hover = true;\n /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Column header the table fallback gives the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Visual treatment of the chart frame. @default 'default' */\n @Input() variant: ChartProps['variant'] = 'default';\n /** Text density of the chart chrome. @default 'default' */\n @Input() density: ChartProps['density'] = 'default';\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the hovered row index, or null when the pointer leaves. */\n @Output() activeIndexChange = new EventEmitter<number | null>();\n\n @ViewChild('plot') plotRef?: ElementRef<HTMLElement>;\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n private resizeObserver?: ResizeObserver;\n\n /** Width used until the container has been measured. */\n static readonly fallbackWidth = 600;\n\n /** Id of the table fallback, referenced by the svg's `aria-describedby`. */\n readonly tableId = 'chart-table-' + chartId++;\n\n /** X of the crosshair, or null when nothing is hovered or the scale is banded. */\n crosshairX: number | null = null;\n\n ngOnInit(): void {\n this.push();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n // Bars carry their own hover state; a crosshair belongs to point scales.\n this.crosshairX =\n index != null && this.chart.mode === 'point' ? this.chart.xFor(index) : null;\n this.activeIndexChange.emit(index);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnChanges(): void {\n this.push();\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n ngAfterViewInit(): void {\n const host = this.plotRef?.nativeElement;\n if (!host) return;\n this.measure(host);\n\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(() => {\n this.measure(host);\n this.cdr.markForCheck();\n });\n this.resizeObserver.observe(host);\n }\n }\n\n ngOnDestroy(): void {\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n this.subscriptions.unsubscribe();\n }\n\n private measure(host: HTMLElement): void {\n const width = host.clientWidth || ChartComponent.fallbackWidth;\n if (width === this.chart.width) return;\n this.chart.configure({ width });\n }\n\n private push(): void {\n this.chart.configure({\n data: this.data ?? [],\n series: this.series ?? [],\n xKey: this.xKey,\n stacked: this.stacked,\n height: this.height,\n margin: this.margin,\n width: this.chart.width || ChartComponent.fallbackWidth,\n });\n }\n\n get viewBox(): string {\n return '0 0 ' + (this.chart.width || ChartComponent.fallbackWidth) + ' ' + this.height;\n }\n\n /**\n * One catchment rect per x position, deliberately wider than the marks it\n * covers — readers aim at a category, never at a 2px line or an 8px dot.\n */\n get hitBands() {\n if (!this.hover) return [];\n return this.data.map((_, i) => this.chart.hitBandFor(i));\n }\n\n onEnterBand(index: number): void {\n this.chart.setActiveIndex(index);\n }\n\n onPointerLeave(): void {\n this.chart.setActiveIndex(null);\n }\n\n get computedClass() {\n return cn(chartVariants({ variant: this.variant, density: this.density }), this.class);\n }\n}\n\n/** Horizontal rules at the y ticks, with optional vertical rules at the x positions. */\n@Component({\n selector: 'svg:g[tolle-chart-grid]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:line\n *ngFor=\"let tick of chart.ticks; trackBy: trackByIndex\"\n [attr.x1]=\"chart.plotLeft\"\n [attr.x2]=\"chart.plotRight\"\n [attr.y1]=\"tick.y\"\n [attr.y2]=\"tick.y\"\n [class]=\"lineClass\"\n stroke-width=\"1\"\n ></svg:line>\n <svg:line\n *ngFor=\"let x of verticalXs; trackBy: trackByIndex\"\n [attr.x1]=\"x\"\n [attr.x2]=\"x\"\n [attr.y1]=\"chart.plotTop\"\n [attr.y2]=\"chart.plotBottom\"\n [class]=\"lineClass\"\n stroke-width=\"1\"\n ></svg:line>\n `,\n})\nexport class ChartGridComponent extends ChartChild {\n /** Also draws a rule at every x position. @default false */\n @Input() vertical = false;\n /** Extra Tailwind classes merged onto each rule via `cn()` (last-wins). */\n @Input() class = '';\n\n get verticalXs(): number[] {\n if (!this.vertical) return [];\n return this.chart.xLabels.map((_, i) => this.chart.xFor(i));\n }\n\n get lineClass(): string {\n // Recessive by design: a grid is a reading aid, never a mark.\n return cn('stroke-border opacity-60', this.class);\n }\n}\n\n/** X-axis tick labels, thinned so they never collide. */\n@Component({\n selector: 'svg:g[tolle-chart-x-axis]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:text\n *ngFor=\"let tick of visibleTicks; trackBy: trackByIndex\"\n [attr.x]=\"tick.x\"\n [attr.y]=\"labelY\"\n text-anchor=\"middle\"\n dominant-baseline=\"hanging\"\n [class]=\"labelClass\"\n >{{ tick.label }}</svg:text>\n `,\n})\nexport class ChartXAxisComponent extends ChartChild {\n /** Approximate px per character, used to decide when labels would collide. @default 7 */\n @Input() charWidth = 7;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n /**\n * Draw every nth label. Derived from the widest label and the space each one\n * gets, so a dense axis thins out instead of overprinting itself.\n */\n get stride(): number {\n const labels = this.chart.xLabels;\n if (labels.length === 0) return 1;\n const widest = labels.reduce((max, label) => Math.max(max, label.length), 0);\n const needed = widest * this.charWidth + 8;\n const perLabel = this.chart.plotWidth / labels.length;\n if (perLabel <= 0) return 1;\n return Math.max(1, Math.ceil(needed / perLabel));\n }\n\n get visibleTicks(): { x: number; label: string }[] {\n const stride = this.stride;\n return this.chart.xLabels\n .map((label, i) => ({ label, x: this.chart.xFor(i), i }))\n .filter((tick) => tick.i % stride === 0);\n }\n\n get labelY(): number {\n return this.chart.plotBottom + 8;\n }\n\n get labelClass(): string {\n // Text wears a text token, never the series colour.\n return cn('fill-muted-foreground text-xs', this.class);\n }\n}\n\n/** Y-axis tick labels at the nice tick values. */\n@Component({\n selector: 'svg:g[tolle-chart-y-axis]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:text\n *ngFor=\"let tick of chart.ticks; trackBy: trackByIndex\"\n [attr.x]=\"labelX\"\n [attr.y]=\"tick.y\"\n text-anchor=\"end\"\n dominant-baseline=\"middle\"\n [class]=\"labelClass\"\n >{{ tick.label }}</svg:text>\n `,\n})\nexport class ChartYAxisComponent extends ChartChild {\n /** Gap in px between the labels and the plot edge. @default 8 */\n @Input() offset = 8;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n get labelX(): number {\n return this.chart.plotLeft - this.offset;\n }\n\n get labelClass(): string {\n return cn('fill-muted-foreground text-xs tabular-nums', this.class);\n }\n}\n\n/** A line mark for one series, optionally monotone-smoothed and dotted. */\n@Component({\n selector: 'svg:g[tolle-chart-line]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"path\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"\n ></svg:path>\n <svg:circle\n *ngFor=\"let point of dots; trackBy: trackByIndex\"\n [attr.cx]=\"point.x\"\n [attr.cy]=\"point.y\"\n [attr.r]=\"dotRadius\"\n [attr.fill]=\"color\"\n class=\"stroke-background\"\n stroke-width=\"2\"\n ></svg:circle>\n `,\n})\nexport class ChartLineComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points; smooth is monotone cubic, so it never overshoots. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Draws a marker at every point. @default false */\n @Input() showDots = false;\n /** Marker radius in px; 4 gives the 8px minimum diameter. @default 4 */\n @Input() dotRadius = 4;\n /** Extra Tailwind classes merged onto the path via `cn()` (last-wins). */\n @Input() class = '';\n\n get path(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n get dots(): { x: number; y: number; index: number }[] {\n return this.showDots ? this.chart.pointsFor(this.seriesKey) : [];\n }\n}\n\n/** A filled area for one series: a low-opacity wash under a 2px stroke. */\n@Component({\n selector: 'svg:g[tolle-chart-area]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"areaPath\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"fillOpacity\"\n stroke=\"none\"\n ></svg:path>\n <svg:path\n [attr.d]=\"linePath\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"\n ></svg:path>\n `,\n})\nexport class ChartAreaComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Opacity of the fill — a wash, never a saturated block. @default 0.1 */\n @Input() fillOpacity = 0.1;\n /** Extra Tailwind classes merged onto the stroke via `cn()` (last-wins). */\n @Input() class = '';\n\n /** An area is anchored to a baseline, so zero has to be in the domain. */\n protected override register(): void {\n this.chart.requireZero();\n }\n\n get areaPath(): string {\n return this.chart.areaPath(this.seriesKey, this.curve === 'smooth');\n }\n\n get linePath(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n}\n\n/**\n * Builds a bar whose data end is rounded and whose baseline end stays square.\n * Handles both directions, so a negative value rounds downward.\n */\nexport function barPath(\n x: number,\n width: number,\n yBase: number,\n yValue: number,\n radius: number\n): string {\n const height = Math.abs(yBase - yValue);\n const r = Math.max(0, Math.min(radius, width / 2, height));\n const right = x + width;\n // Which way the corner curves depends on which side of the baseline we are.\n const sign = yValue <= yBase ? 1 : -1;\n const corner = yValue + sign * r;\n\n return (\n 'M ' + x + ' ' + yBase +\n ' L ' + x + ' ' + corner +\n ' Q ' + x + ' ' + yValue + ' ' + (x + r) + ' ' + yValue +\n ' L ' + (right - r) + ' ' + yValue +\n ' Q ' + right + ' ' + yValue + ' ' + right + ' ' + corner +\n ' L ' + right + ' ' + yBase + ' Z'\n );\n}\n\n/**\n * A bar mark for one series. Grouped beside its siblings by default, stacked\n * when the container's `stacked` is set. The data end carries a 4px radius, the\n * baseline end stays square, and neighbours are separated by surface, not ink.\n */\n@Component({\n selector: 'svg:g[tolle-chart-bar]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n *ngFor=\"let bar of bars; trackBy: trackByIndex\"\n [attr.d]=\"bar.d\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"bar.active ? 1 : 0.85\"\n ></svg:path>\n `,\n})\nexport class ChartBarComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Corner radius on the data end in px. @default 4 */\n @Input() radius = 4;\n /** Largest bar thickness in px; a wider band becomes air, not a fatter bar. @default 24 */\n @Input() maxWidth = 24;\n /** Gap in px between adjacent bars and between stacked segments. @default 2 */\n @Input() gap = 2;\n /** Extra Tailwind classes merged onto each bar via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Index currently hovered, so the bar under the pointer can lift. */\n activeIndex: number | null = null;\n\n /** Bars need a band scale and a zero baseline; both belong to the whole chart. */\n protected override register(): void {\n this.chart.useBandScale();\n }\n\n override ngOnInit(): void {\n super.ngOnInit();\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n this.activeIndex = index;\n this.cdr.markForCheck();\n })\n );\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n /** Geometry for every bar this series contributes. */\n get bars(): {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] {\n const out: {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] = [];\n const seriesCount = Math.max(1, this.chart.series.length);\n const found = this.chart.series.findIndex((item) => item.key === this.seriesKey);\n const slotIndex = found < 0 ? 0 : found;\n\n for (let i = 0; i < this.chart.count; i++) {\n const value = this.chart.valueAt(this.seriesKey, i);\n if (value == null) continue;\n\n const band = this.chart.bandFor(i);\n let x: number;\n let width: number;\n\n if (this.chart.stacked) {\n width = Math.min(this.maxWidth, band.width);\n x = band.centre - width / 2;\n } else {\n const slot = band.width / seriesCount;\n width = Math.min(this.maxWidth, Math.max(1, slot - this.gap));\n x = band.start + slot * slotIndex + (slot - width) / 2;\n }\n\n const base = this.chart.stacked ? this.chart.stackBase(this.seriesKey, i) : 0;\n const yBase = this.chart.yFor(base);\n let yValue = this.chart.yFor(base + value);\n\n // The gap between stacked segments comes off the data end, so what\n // separates them is surface showing through rather than a stroke.\n if (this.chart.stacked && Math.abs(yBase - yValue) > this.gap) {\n yValue += value >= 0 ? this.gap : -this.gap;\n }\n\n out.push({\n d: barPath(x, width, yBase, yValue, this.radius),\n active: this.activeIndex === i,\n x,\n width,\n yBase,\n yValue,\n });\n }\n return out;\n }\n}\n"
15
+ "content": "import { Component, Directive, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef, ViewChild, OnInit, OnChanges, OnDestroy, AfterViewInit, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { ChartService, type ChartSeries, type ChartMargin, type ChartOrientation } from './chart.service';\n\nexport type { ChartSeries, ChartMargin, ChartTick, ChartBand, ChartOrientation } from './chart.service';\n\nlet chartId = 0;\n\n/**\n * Base for every chart part that has to redraw when the shared scales change.\n * Parts subscribe to the service rather than reading the container component,\n * so an OnPush child still updates when the container's inputs or width change.\n */\n@Directive()\nexport abstract class ChartChild implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook subclasses keep\n * rendering the class they were born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n protected readonly chart = inject(ChartService);\n protected readonly cdr = inject(ChangeDetectorRef);\n protected readonly subscriptions = new Subscription();\n\n /**\n * Identity for the mark `*ngFor`s. The collections behind them are rebuilt on\n * every read, so without this `*ngFor` sees new identities each pass and\n * replaces every node instead of patching it. Marks are positional, so the\n * index IS the identity.\n */\n trackByIndex = (index: number): number => index;\n\n ngOnInit(): void {\n this.register();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** Hook for parts that need to influence the shared scales before first paint. */\n protected register(): void {}\n}\n\n/**\n * The floating readout for the hovered x position. Lists every series at that\n * x, so the pointer never has to land on a particular line to get a value.\n * Rendered automatically by `tolle-chart` when `hover` is on.\n */\n@Component({\n selector: 'tolle-chart-tooltip',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n *ngIf=\"index !== null\"\n role=\"status\"\n aria-live=\"polite\"\n [class]=\"computedClass\"\n [style.left.px]=\"left\"\n [style.top.px]=\"top\"\n >\n <p class=\"mb-1 text-xs font-medium text-foreground\">{{ xLabel }}</p>\n <div *ngFor=\"let row of rows; trackBy: trackByIndex\" class=\"flex items-center gap-2 text-xs\">\n <span class=\"h-0.5 w-3 shrink-0 rounded-full\" [style.background]=\"row.color\"></span>\n <span class=\"text-muted-foreground\">{{ row.label }}</span>\n <span class=\"ml-auto font-medium tabular-nums text-foreground\">{{ row.value }}</span>\n </div>\n </div>\n `,\n})\nexport class ChartTooltipComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Distance in px the tooltip sits from the crosshair. @default 12 */\n @Input() offset = 12;\n /** Assumed tooltip width in px, used to flip it near the right edge. @default 160 */\n @Input() estimatedWidth = 160;\n /** Extra Tailwind classes merged onto the tooltip via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n /** Row index the tooltip is describing, or null when nothing is hovered. */\n index: number | null = null;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n this.index = index;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get xLabel(): string {\n return this.index === null ? '' : this.chart.xLabels[this.index] ?? '';\n }\n\n /** One row per series: colour key, label, and the formatted value. */\n get rows(): { label: string; value: string; color: string }[] {\n const index = this.index;\n if (index === null) return [];\n return this.chart.series.map((item) => {\n const value = this.chart.valueAt(item.key, index);\n return {\n label: item.label,\n value: value == null ? '—' : this.chart.formatValue(value),\n color: this.chart.colorFor(item.key),\n };\n });\n }\n\n get left(): number {\n if (this.index === null) return 0;\n const x = this.chart.xFor(this.index);\n // Flip to the left of the crosshair rather than overflow the container.\n if (x + this.offset + this.estimatedWidth > this.chart.width) {\n return Math.max(0, x - this.offset - this.estimatedWidth);\n }\n return x + this.offset;\n }\n\n get top(): number {\n return this.chart.plotTop;\n }\n\n get computedClass() {\n return cn(\n 'pointer-events-none absolute z-10 min-w-[8rem] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md',\n this.class\n );\n }\n}\n\nconst chartLegendVariants = cva('flex flex-wrap items-center gap-x-4 gap-y-1 pt-2', {\n variants: {\n align: { start: 'justify-start', center: 'justify-center', end: 'justify-end' },\n size: { sm: 'text-[11px]', default: 'text-xs' },\n },\n defaultVariants: { align: 'center', size: 'default' },\n});\n\nexport type ChartLegendProps = VariantProps<typeof chartLegendVariants>;\n\n/**\n * Swatch and label per series. Rendered automatically by `tolle-chart` whenever\n * there are two or more series, so identity is never carried by colour alone.\n * A single series gets no legend — the chart's own label already names it.\n */\n@Component({\n selector: 'tolle-chart-legend',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"computedClass\">\n <li *ngFor=\"let entry of entries; trackBy: trackByIndex\" class=\"flex items-center gap-1.5\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"entry.color\"></span>\n <span class=\"text-muted-foreground\">{{ entry.label }}</span>\n </li>\n </ul>\n `,\n})\nexport class ChartLegendComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Horizontal alignment of the legend row. @default 'center' */\n @Input() align: ChartLegendProps['align'] = 'center';\n /** Text size of the legend. @default 'default' */\n @Input() size: ChartLegendProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the legend via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One entry per series, coloured by series identity. */\n get entries(): { label: string; color: string }[] {\n return this.chart.series.map((item) => ({\n label: item.label,\n color: this.chart.colorFor(item.key),\n }));\n }\n\n get computedClass() {\n return cn(chartLegendVariants({ align: this.align, size: this.size }), this.class);\n }\n}\n\nconst chartTableVariants = cva('w-full text-left text-xs', {\n variants: {\n visible: {\n true: 'mt-4 border-collapse',\n false: 'sr-only',\n },\n },\n defaultVariants: { visible: false },\n});\n\nexport type ChartTableProps = VariantProps<typeof chartTableVariants>;\n\n/**\n * The chart's data as a real `<table>`. Visually hidden by default and pointed\n * at by the svg's `aria-describedby`, so every value the chart encodes in\n * colour stays reachable without seeing or hovering it. Set `visible` to show it.\n */\n@Component({\n selector: 'tolle-chart-table',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [attr.id]=\"tableId || null\">\n <table [class]=\"computedClass\">\n <caption class=\"sr-only\">{{ caption }}</caption>\n <thead>\n <tr>\n <th scope=\"col\" class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\">\n {{ xHeader }}\n </th>\n <th\n *ngFor=\"let item of chart.series; trackBy: trackByIndex\"\n scope=\"col\"\n class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\"\n >\n {{ item.label }}\n </th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let row of rows; trackBy: trackByIndex\">\n <th scope=\"row\" class=\"border-b border-border px-2 py-1 font-normal text-foreground\">\n {{ row.label }}\n </th>\n <td\n *ngFor=\"let cell of row.cells; trackBy: trackByIndex\"\n class=\"border-b border-border px-2 py-1 tabular-nums text-foreground\"\n >\n {{ cell }}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n `,\n})\nexport class ChartTableComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Shows the table instead of hiding it from sighted readers. @default false */\n @Input() visible = false;\n /** Id applied to the wrapper, for the chart's `aria-describedby`. @default '' */\n @Input() tableId = '';\n /** Column header for the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Accessible caption for the table. @default 'Chart data' */\n @Input() caption = 'Chart data';\n /** Extra Tailwind classes merged onto the table via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One row per x position, with a formatted cell per series. */\n get rows(): { label: string; cells: string[] }[] {\n return this.chart.xLabels.map((label, i) => ({\n label,\n cells: this.chart.series.map((item) => {\n const value = this.chart.valueAt(item.key, i);\n return value == null ? '—' : this.chart.formatValue(value);\n }),\n }));\n }\n\n get computedClass() {\n return cn(chartTableVariants({ visible: this.visible }), this.class);\n }\n}\n\nconst chartVariants = cva('relative w-full', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-lg border border-border bg-background p-4',\n },\n density: {\n default: '',\n compact: 'text-xs',\n },\n },\n defaultVariants: { variant: 'default', density: 'default' },\n});\n\nexport type ChartProps = VariantProps<typeof chartVariants>;\n\n/**\n * Container for a hand-rolled SVG chart. Owns the responsive `<svg>`, the\n * shared scales (via the `ChartService` it provides), the hover layer, the\n * legend and the accessible table fallback.\n *\n * Marks and axes are projected as SVG children. They use attribute selectors on\n * `svg:g` rather than element selectors, because a custom element is created in\n * the HTML namespace and an HTML element inside an `<svg>` never renders its\n * SVG subtree:\n *\n * ```html\n * <tolle-chart [data]=\"rows\" [series]=\"series\" xKey=\"month\" ariaLabel=\"Revenue\">\n * <svg:g tolle-chart-grid></svg:g>\n * <svg:g tolle-chart-y-axis></svg:g>\n * <svg:g tolle-chart-x-axis></svg:g>\n * <svg:g tolle-chart-line seriesKey=\"revenue\" curve=\"smooth\"></svg:g>\n * </tolle-chart>\n * ```\n *\n * There is deliberately no secondary y-axis: two measures on two scales in one\n * frame let the author choose where the lines cross, so the reader sees a\n * relationship the data does not contain. Use two charts, small multiples, or\n * index both series to a common base instead.\n * @new\n */\n@Component({\n selector: 'tolle-chart',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, ChartTooltipComponent, ChartLegendComponent, ChartTableComponent],\n providers: [ChartService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative w-full\" #plot>\n <svg\n role=\"img\"\n width=\"100%\"\n [attr.height]=\"height\"\n [attr.viewBox]=\"viewBox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.aria-describedby]=\"tableId\"\n class=\"block w-full overflow-visible\"\n (pointerleave)=\"onPointerLeave()\"\n >\n <title>{{ ariaLabel }}</title>\n <desc>{{ description || ariaLabel }}</desc>\n\n <svg:g>\n <ng-content></ng-content>\n </svg:g>\n\n <svg:line\n *ngIf=\"crosshairX !== null\"\n [attr.x1]=\"crosshairX\"\n [attr.x2]=\"crosshairX\"\n [attr.y1]=\"chart.plotTop\"\n [attr.y2]=\"chart.plotBottom\"\n class=\"pointer-events-none stroke-border\"\n stroke-width=\"1\"\n ></svg:line>\n\n <svg:rect\n *ngFor=\"let rect of hitRects; let i = index; trackBy: trackByIndex\"\n [attr.x]=\"rect.x\"\n [attr.y]=\"rect.y\"\n [attr.width]=\"rect.width\"\n [attr.height]=\"rect.height\"\n fill=\"transparent\"\n (pointerenter)=\"onEnterBand(i)\"\n ></svg:rect>\n </svg>\n\n <tolle-chart-tooltip *ngIf=\"hover\"></tolle-chart-tooltip>\n </div>\n\n <tolle-chart-legend *ngIf=\"series.length > 1\"></tolle-chart-legend>\n\n <tolle-chart-table [tableId]=\"tableId\" [visible]=\"showTable\" [xHeader]=\"xHeader\"></tolle-chart-table>\n </div>\n `,\n})\nexport class ChartComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Rows to plot, in x order. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Series to draw, in the order they take palette steps `--chart-1`..`--chart-5`. @default [] */\n @Input() series: ChartSeries[] = [];\n /** Row key holding each row's x category label. @default '' */\n @Input() xKey = '';\n /** Height of the chart in px. @default 260 */\n @Input() height = 260;\n /** Space reserved around the plot for axis labels. @default { top: 8, right: 8, bottom: 24, left: 44 } */\n @Input() margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 44 };\n /** Accessible name for the chart, used as the svg `<title>`. @default '' */\n @Input() ariaLabel = '';\n /** Longer summary used as the svg `<desc>`. Falls back to `ariaLabel`. @default '' */\n @Input() description = '';\n /** Stacks marks on a shared baseline instead of grouping them. @default false */\n @Input() stacked = false;\n /**\n * Which physical axis carries the category vs the value. Only bars support\n * 'horizontal' — line/area marks stay vertical regardless. @default 'vertical'\n */\n @Input() orientation: ChartOrientation = 'vertical';\n /** Renders the crosshair, hit layer and shared tooltip. @default true */\n @Input() hover = true;\n /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Column header the table fallback gives the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Visual treatment of the chart frame. @default 'default' */\n @Input() variant: ChartProps['variant'] = 'default';\n /** Text density of the chart chrome. @default 'default' */\n @Input() density: ChartProps['density'] = 'default';\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the hovered row index, or null when the pointer leaves. */\n @Output() activeIndexChange = new EventEmitter<number | null>();\n\n @ViewChild('plot') plotRef?: ElementRef<HTMLElement>;\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n private resizeObserver?: ResizeObserver;\n\n /** Width used until the container has been measured. */\n static readonly fallbackWidth = 600;\n\n /** Id of the table fallback, referenced by the svg's `aria-describedby`. */\n readonly tableId = 'chart-table-' + chartId++;\n\n /** X of the crosshair, or null when nothing is hovered or the scale is banded. */\n crosshairX: number | null = null;\n\n ngOnInit(): void {\n this.push();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n // Bars carry their own hover state; a crosshair belongs to point scales.\n this.crosshairX =\n index != null && this.chart.mode === 'point' ? this.chart.xFor(index) : null;\n this.activeIndexChange.emit(index);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnChanges(): void {\n this.push();\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n ngAfterViewInit(): void {\n const host = this.plotRef?.nativeElement;\n if (!host) return;\n this.measure(host);\n\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(() => {\n this.measure(host);\n this.cdr.markForCheck();\n });\n this.resizeObserver.observe(host);\n }\n }\n\n ngOnDestroy(): void {\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n this.subscriptions.unsubscribe();\n }\n\n private measure(host: HTMLElement): void {\n const width = host.clientWidth || ChartComponent.fallbackWidth;\n if (width === this.chart.width) return;\n this.chart.configure({ width });\n }\n\n private push(): void {\n this.chart.configure({\n data: this.data ?? [],\n series: this.series ?? [],\n xKey: this.xKey,\n stacked: this.stacked,\n orientation: this.orientation,\n height: this.height,\n margin: this.margin,\n width: this.chart.width || ChartComponent.fallbackWidth,\n });\n }\n\n get viewBox(): string {\n return '0 0 ' + (this.chart.width || ChartComponent.fallbackWidth) + ' ' + this.height;\n }\n\n /**\n * One catchment rect per x position, deliberately wider than the marks it\n * covers — readers aim at a category, never at a 2px line or an 8px dot.\n * Spans the full plot in the cross direction: full height in a vertical\n * chart, full width in a horizontal one.\n */\n get hitRects(): { x: number; y: number; width: number; height: number }[] {\n if (!this.hover) return [];\n const horizontal = this.chart.orientation === 'horizontal';\n return this.data.map((_, i) => {\n const band = this.chart.hitBandFor(i);\n return horizontal\n ? { x: this.chart.plotLeft, y: band.start, width: this.chart.plotWidth, height: band.width }\n : { x: band.start, y: this.chart.plotTop, width: band.width, height: this.chart.plotHeight };\n });\n }\n\n onEnterBand(index: number): void {\n this.chart.setActiveIndex(index);\n }\n\n onPointerLeave(): void {\n this.chart.setActiveIndex(null);\n }\n\n get computedClass() {\n return cn(chartVariants({ variant: this.variant, density: this.density }), this.class);\n }\n}\n\n/**\n * Rules at the value ticks, with optional rules at the category positions.\n * Orientation-aware: in a horizontal chart the value rules run vertically and\n * the (optional) category rules run horizontally — the transpose of a\n * vertical chart's grid.\n */\n@Component({\n selector: 'svg:g[tolle-chart-grid]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:line\n *ngFor=\"let line of valueLines; trackBy: trackByIndex\"\n [attr.x1]=\"line.x1\"\n [attr.x2]=\"line.x2\"\n [attr.y1]=\"line.y1\"\n [attr.y2]=\"line.y2\"\n [class]=\"lineClass\"\n stroke-width=\"1\"\n ></svg:line>\n <svg:line\n *ngFor=\"let line of categoryLines; trackBy: trackByIndex\"\n [attr.x1]=\"line.x1\"\n [attr.x2]=\"line.x2\"\n [attr.y1]=\"line.y1\"\n [attr.y2]=\"line.y2\"\n [class]=\"lineClass\"\n stroke-width=\"1\"\n ></svg:line>\n `,\n})\nexport class ChartGridComponent extends ChartChild {\n /** Also draws a rule at every category position. @default false */\n @Input() vertical = false;\n /** Extra Tailwind classes merged onto each rule via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Rules at each value tick, spanning the plot in the cross direction. */\n get valueLines(): { x1: number; y1: number; x2: number; y2: number }[] {\n const horizontal = this.chart.orientation === 'horizontal';\n return this.chart.ticks.map((tick) =>\n horizontal\n ? { x1: tick.y, x2: tick.y, y1: this.chart.plotTop, y2: this.chart.plotBottom }\n : { x1: this.chart.plotLeft, x2: this.chart.plotRight, y1: tick.y, y2: tick.y }\n );\n }\n\n /** Rules at each category position, only when `vertical` is set. */\n get categoryLines(): { x1: number; y1: number; x2: number; y2: number }[] {\n if (!this.vertical) return [];\n const horizontal = this.chart.orientation === 'horizontal';\n return this.chart.xLabels.map((_, i) => {\n const pos = this.chart.xFor(i);\n return horizontal\n ? { x1: this.chart.plotLeft, x2: this.chart.plotRight, y1: pos, y2: pos }\n : { x1: pos, x2: pos, y1: this.chart.plotTop, y2: this.chart.plotBottom };\n });\n }\n\n get lineClass(): string {\n // Recessive by design: a grid is a reading aid, never a mark.\n return cn('stroke-border opacity-60', this.class);\n }\n}\n\n/** X-axis tick labels, thinned so they never collide. */\n@Component({\n selector: 'svg:g[tolle-chart-x-axis]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:text\n *ngFor=\"let tick of visibleTicks; trackBy: trackByIndex\"\n [attr.x]=\"tick.x\"\n [attr.y]=\"labelY\"\n text-anchor=\"middle\"\n dominant-baseline=\"hanging\"\n [class]=\"labelClass\"\n >{{ tick.label }}</svg:text>\n `,\n})\nexport class ChartXAxisComponent extends ChartChild {\n /**\n * Approximate px per character, used to decide when category labels would\n * collide. Only consulted in the vertical orientation. @default 7\n */\n @Input() charWidth = 7;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n /**\n * Draw every nth label. Derived from the widest label and the space each one\n * gets, so a dense axis thins out instead of overprinting itself.\n */\n get stride(): number {\n const labels = this.chart.xLabels;\n if (labels.length === 0) return 1;\n const widest = labels.reduce((max, label) => Math.max(max, label.length), 0);\n const needed = widest * this.charWidth + 8;\n const perLabel = this.chart.plotWidth / labels.length;\n if (perLabel <= 0) return 1;\n return Math.max(1, Math.ceil(needed / perLabel));\n }\n\n /**\n * Vertical orientation: category labels, thinned so they never collide\n * (today's behaviour). Horizontal orientation: this is the bottom axis'\n * physical position, which a horizontal chart uses for its value ticks.\n */\n get visibleTicks(): { x: number; label: string }[] {\n if (this.chart.orientation === 'horizontal') {\n return this.chart.ticks.map((tick) => ({ x: tick.y, label: tick.label }));\n }\n const stride = this.stride;\n return this.chart.xLabels\n .map((label, i) => ({ label, x: this.chart.xFor(i), i }))\n .filter((tick) => tick.i % stride === 0);\n }\n\n get labelY(): number {\n return this.chart.plotBottom + 8;\n }\n\n get labelClass(): string {\n // Text wears a text token, never the series colour.\n return cn('fill-muted-foreground text-xs', this.class);\n }\n}\n\n/** Y-axis tick labels at the nice tick values. */\n@Component({\n selector: 'svg:g[tolle-chart-y-axis]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:text\n *ngFor=\"let tick of visibleTicks; trackBy: trackByIndex\"\n [attr.x]=\"labelX\"\n [attr.y]=\"tick.y\"\n text-anchor=\"end\"\n dominant-baseline=\"middle\"\n [class]=\"labelClass\"\n >{{ tick.label }}</svg:text>\n `,\n})\nexport class ChartYAxisComponent extends ChartChild {\n /** Gap in px between the labels and the plot edge. @default 8 */\n @Input() offset = 8;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n get labelX(): number {\n return this.chart.plotLeft - this.offset;\n }\n\n /**\n * Vertical orientation: value ticks (today's behaviour). Horizontal\n * orientation: this is the left axis' physical position, which a\n * horizontal chart uses for its category labels.\n */\n get visibleTicks(): { y: number; label: string }[] {\n if (this.chart.orientation === 'horizontal') {\n return this.chart.xLabels.map((label, i) => ({ label, y: this.chart.xFor(i) }));\n }\n return this.chart.ticks;\n }\n\n get labelClass(): string {\n return cn('fill-muted-foreground text-xs tabular-nums', this.class);\n }\n}\n\n/** A line mark for one series, optionally monotone-smoothed and dotted. */\n@Component({\n selector: 'svg:g[tolle-chart-line]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"path\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"\n ></svg:path>\n <svg:circle\n *ngFor=\"let point of dots; trackBy: trackByIndex\"\n [attr.cx]=\"point.x\"\n [attr.cy]=\"point.y\"\n [attr.r]=\"dotRadius\"\n [attr.fill]=\"color\"\n class=\"stroke-background\"\n stroke-width=\"2\"\n ></svg:circle>\n `,\n})\nexport class ChartLineComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points; smooth is monotone cubic, so it never overshoots. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Draws a marker at every point. @default false */\n @Input() showDots = false;\n /** Marker radius in px; 4 gives the 8px minimum diameter. @default 4 */\n @Input() dotRadius = 4;\n /** Extra Tailwind classes merged onto the path via `cn()` (last-wins). */\n @Input() class = '';\n\n get path(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n get dots(): { x: number; y: number; index: number }[] {\n return this.showDots ? this.chart.pointsFor(this.seriesKey) : [];\n }\n}\n\n/** A filled area for one series: a low-opacity wash under a 2px stroke. */\n@Component({\n selector: 'svg:g[tolle-chart-area]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"areaPath\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"fillOpacity\"\n stroke=\"none\"\n ></svg:path>\n <svg:path\n [attr.d]=\"linePath\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"\n ></svg:path>\n `,\n})\nexport class ChartAreaComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Opacity of the fill — a wash, never a saturated block. @default 0.1 */\n @Input() fillOpacity = 0.1;\n /** Extra Tailwind classes merged onto the stroke via `cn()` (last-wins). */\n @Input() class = '';\n\n /** An area is anchored to a baseline, so zero has to be in the domain. */\n protected override register(): void {\n this.chart.requireZero();\n }\n\n get areaPath(): string {\n return this.chart.areaPath(this.seriesKey, this.curve === 'smooth');\n }\n\n get linePath(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n}\n\n/**\n * Builds a bar whose data end is rounded and whose baseline end stays square.\n * Handles both directions, so a negative value rounds downward.\n */\nexport function barPath(\n x: number,\n width: number,\n yBase: number,\n yValue: number,\n radius: number\n): string {\n const height = Math.abs(yBase - yValue);\n const r = Math.max(0, Math.min(radius, width / 2, height));\n const right = x + width;\n // Which way the corner curves depends on which side of the baseline we are.\n const sign = yValue <= yBase ? 1 : -1;\n const corner = yValue + sign * r;\n\n return (\n 'M ' + x + ' ' + yBase +\n ' L ' + x + ' ' + corner +\n ' Q ' + x + ' ' + yValue + ' ' + (x + r) + ' ' + yValue +\n ' L ' + (right - r) + ' ' + yValue +\n ' Q ' + right + ' ' + yValue + ' ' + right + ' ' + corner +\n ' L ' + right + ' ' + yBase + ' Z'\n );\n}\n\n/**\n * `barPath()` transposed for horizontal bars: fixed vertical extent\n * `[y, y+height]`, variable horizontal extent `[xBase, xValue]`, rounded at\n * the `xValue` (data) end.\n */\nexport function barPathHorizontal(\n y: number,\n height: number,\n xBase: number,\n xValue: number,\n radius: number\n): string {\n const width = Math.abs(xBase - xValue);\n const r = Math.max(0, Math.min(radius, height / 2, width));\n const bottom = y + height;\n const sign = xValue >= xBase ? 1 : -1;\n const corner = xValue - sign * r;\n\n return (\n 'M ' + xBase + ' ' + y +\n ' L ' + corner + ' ' + y +\n ' Q ' + xValue + ' ' + y + ' ' + xValue + ' ' + (y + r) +\n ' L ' + xValue + ' ' + (bottom - r) +\n ' Q ' + xValue + ' ' + bottom + ' ' + corner + ' ' + bottom +\n ' L ' + xBase + ' ' + bottom + ' Z'\n );\n}\n\n/**\n * A bar mark for one series. Grouped beside its siblings by default, stacked\n * when the container's `stacked` is set. The data end carries a 4px radius, the\n * baseline end stays square, and neighbours are separated by surface, not ink.\n */\n@Component({\n selector: 'svg:g[tolle-chart-bar]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n *ngFor=\"let bar of bars; trackBy: trackByIndex\"\n [attr.d]=\"bar.d\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"bar.active ? 1 : 0.85\"\n ></svg:path>\n `,\n})\nexport class ChartBarComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Corner radius on the data end in px. @default 4 */\n @Input() radius = 4;\n /** Largest bar thickness in px; a wider band becomes air, not a fatter bar. @default 24 */\n @Input() maxWidth = 24;\n /** Gap in px between adjacent bars and between stacked segments. @default 2 */\n @Input() gap = 2;\n /** Extra Tailwind classes merged onto each bar via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Index currently hovered, so the bar under the pointer can lift. */\n activeIndex: number | null = null;\n\n /** Bars need a band scale and a zero baseline; both belong to the whole chart. */\n protected override register(): void {\n this.chart.useBandScale();\n }\n\n override ngOnInit(): void {\n super.ngOnInit();\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n this.activeIndex = index;\n this.cdr.markForCheck();\n })\n );\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n /**\n * Geometry for every bar this series contributes. `x`/`width` are the bar's\n * footprint on the category axis and `yBase`/`yValue` its extent on the\n * value axis — physical x/y when vertical (the original meaning), but\n * transposed (x holds a vertical footprint, yBase/yValue hold horizontal\n * positions) when the chart is horizontal, same as `ChartService.yFor()`.\n */\n get bars(): {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] {\n const out: {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] = [];\n const seriesCount = Math.max(1, this.chart.series.length);\n const found = this.chart.series.findIndex((item) => item.key === this.seriesKey);\n const slotIndex = found < 0 ? 0 : found;\n const horizontal = this.chart.orientation === 'horizontal';\n\n for (let i = 0; i < this.chart.count; i++) {\n const value = this.chart.valueAt(this.seriesKey, i);\n if (value == null) continue;\n\n const band = this.chart.bandFor(i);\n let x: number;\n let width: number;\n\n if (this.chart.stacked) {\n width = Math.min(this.maxWidth, band.width);\n x = band.centre - width / 2;\n } else {\n const slot = band.width / seriesCount;\n width = Math.min(this.maxWidth, Math.max(1, slot - this.gap));\n x = band.start + slot * slotIndex + (slot - width) / 2;\n }\n\n const base = this.chart.stacked ? this.chart.stackBase(this.seriesKey, i) : 0;\n const yBase = this.chart.yFor(base);\n let yValue = this.chart.yFor(base + value);\n\n // The gap between stacked segments comes off the data end, so what\n // separates them is surface showing through rather than a stroke. Signed\n // from the resolved positions (not the raw value) so it holds under\n // both orientations, whose value axis runs in opposite directions.\n if (this.chart.stacked && Math.abs(yBase - yValue) > this.gap) {\n yValue += Math.sign(yBase - yValue) * this.gap;\n }\n\n out.push({\n d: horizontal\n ? barPathHorizontal(x, width, yBase, yValue, this.radius)\n : barPath(x, width, yBase, yValue, this.radius),\n active: this.activeIndex === i,\n x,\n width,\n yBase,\n yValue,\n });\n }\n return out;\n }\n}\n"
16
16
  },
17
17
  {
18
18
  "path": "chart.service.ts",
19
19
  "target": "ui/chart.service.ts",
20
- "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** One plotted series: which key to read off each row, and how to name it. */\nexport interface ChartSeries {\n key: string;\n label: string;\n}\n\n/** Space reserved around the plot area for axes and labels. */\nexport interface ChartMargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** A y-axis tick: its data value, its resolved svg y, and its formatted label. */\nexport interface ChartTick {\n value: number;\n y: number;\n label: string;\n}\n\n/** A horizontal slice of the plot belonging to one x position. */\nexport interface ChartBand {\n start: number;\n centre: number;\n width: number;\n}\n\n/** How x positions are resolved: discrete bands (bars) or points (line/area). */\nexport type ChartScaleMode = 'point' | 'band';\n\n/** Number of chart palette steps that exist. A 6th series does not get a colour. */\nexport const CHART_COLOR_LIMIT = 5;\n\n/**\n * The paint used for a series past `CHART_COLOR_LIMIT`.\n *\n * The palette is validated for lightness, chroma and colour-blind separation at\n * exactly five steps; generating a sixth would land outside that validation, and\n * wrapping back to step 1 would make two series share an identity. Both are\n * worse than dropping to a neutral, so the overflow is explicitly uncoloured.\n */\nexport const CHART_OVERFLOW_COLOR = 'rgb(var(--muted-foreground))';\n\n/**\n * Hands out palette steps by entity key rather than by position.\n *\n * A key keeps its slot for the lifetime of the scale, which is what makes\n * colour follow the entity: filtering a series out and back in, or removing an\n * earlier one, must never repaint the survivors. Past the fifth distinct key\n * the scale stops colouring rather than inventing a step or wrapping around.\n */\nexport class ChartColorScale {\n private readonly slots = new Map<string, number>();\n private next = 0;\n\n /** Reserves slots for `keys` in order, leaving already-known keys alone. */\n register(keys: string[]): void {\n for (const key of keys) {\n if (!this.slots.has(key)) this.slots.set(key, this.next++);\n }\n }\n\n /** Paint for `key`, registering it on first sight. */\n colorFor(key: string): string {\n let slot = this.slots.get(key);\n if (slot === undefined) {\n slot = this.next++;\n this.slots.set(key, slot);\n }\n if (slot >= CHART_COLOR_LIMIT) return CHART_OVERFLOW_COLOR;\n return 'rgb(var(--chart-' + (slot + 1) + '))';\n }\n\n /** True when `key` fell past the palette and renders neutral. */\n isOverflow(key: string): boolean {\n const slot = this.slots.get(key);\n return slot !== undefined && slot >= CHART_COLOR_LIMIT;\n }\n\n /** Slot index assigned to `key`, or undefined when it is unknown. */\n slotOf(key: string): number | undefined {\n return this.slots.get(key);\n }\n}\n\n/**\n * Owns layout, scales and colour assignment for one `tolle-chart`, so every\n * child draws into the same coordinate space. Renders nothing itself.\n *\n * Provided on `ChartComponent`, so each chart gets its own instance.\n */\n@Injectable()\nexport class ChartService {\n /** Rows being plotted, in x order. */\n data: Record<string, any>[] = [];\n /** Series definitions, in the order they should take palette steps. */\n series: ChartSeries[] = [];\n /** Row key holding the x category/label. */\n xKey = '';\n /** Whether marks stack on a shared baseline rather than sitting side by side. */\n stacked = false;\n /** Full svg width in px, tracked from the container's ResizeObserver. */\n width = 0;\n /** Full svg height in px. */\n height = 260;\n /** Space reserved around the plot area. */\n margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 40 };\n /** Fraction of a band's step spent on padding, split either side. */\n bandPadding = 0.2;\n\n private scaleMode: ChartScaleMode = 'point';\n private zeroRequired = false;\n\n private domainMin = 0;\n private domainMax = 1;\n private tickValues: number[] = [];\n private tickStep = 1;\n\n /** Palette assignment, keyed by series identity. */\n private readonly colors = new ChartColorScale();\n\n private readonly layoutSubject = new BehaviorSubject<number>(0);\n /** Emits a version number whenever scales, dimensions or data change. */\n readonly layout$ = this.layoutSubject.asObservable();\n\n private readonly activeIndexSubject = new BehaviorSubject<number | null>(null);\n /** Index of the x position under the pointer, or null when not hovering. */\n readonly activeIndex$ = this.activeIndexSubject.asObservable();\n\n private layoutVersion = 0;\n private emitScheduled = false;\n\n // ---------------------------------------------------------------- geometry\n\n get plotLeft(): number {\n return this.margin.left;\n }\n\n get plotTop(): number {\n return this.margin.top;\n }\n\n get plotWidth(): number {\n return Math.max(0, this.width - this.margin.left - this.margin.right);\n }\n\n get plotHeight(): number {\n return Math.max(0, this.height - this.margin.top - this.margin.bottom);\n }\n\n get plotRight(): number {\n return this.plotLeft + this.plotWidth;\n }\n\n get plotBottom(): number {\n return this.plotTop + this.plotHeight;\n }\n\n /** Number of x positions. */\n get count(): number {\n return this.data.length;\n }\n\n /** How x is currently resolved. Bars flip this to 'band'. */\n get mode(): ChartScaleMode {\n return this.scaleMode;\n }\n\n /** Resolved y ticks, with svg coordinates and formatted labels. */\n get ticks(): ChartTick[] {\n return this.tickValues.map((value) => ({\n value,\n y: this.yFor(value),\n label: this.formatValue(value),\n }));\n }\n\n /** Current y domain as [min, max]. */\n get domain(): [number, number] {\n return [this.domainMin, this.domainMax];\n }\n\n /** The x-axis category labels, one per row. */\n get xLabels(): string[] {\n return this.data.map((row) => {\n const raw = row?.[this.xKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n // ------------------------------------------------------------ registration\n\n /**\n * Replaces the chart's configuration and recomputes the scales.\n * Called by the container on input changes and on resize.\n */\n configure(config: Partial<Pick<ChartService,\n 'data' | 'series' | 'xKey' | 'stacked' | 'width' | 'height' | 'margin' | 'bandPadding'>>): void {\n Object.assign(this, config);\n if (config.series) this.syncColorSlots(config.series);\n this.recompute();\n }\n\n /** Switches x to a band scale. Bars call this; it also forces zero into the domain. */\n useBandScale(): void {\n if (this.scaleMode === 'band' && this.zeroRequired) return;\n this.scaleMode = 'band';\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Forces zero into the y domain, for marks anchored to a baseline. */\n requireZero(): void {\n if (this.zeroRequired) return;\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Reserves palette slots in series order, leaving known keys untouched. */\n private syncColorSlots(series: ChartSeries[]): void {\n this.colors.register(series.map((item) => item.key));\n }\n\n /**\n * Paint for a series, keyed by identity rather than by index in the visible\n * array. Past the fifth distinct key this returns the neutral overflow colour.\n */\n colorFor(seriesKey: string): string {\n return this.colors.colorFor(seriesKey);\n }\n\n /** True when this series fell past the palette and renders neutral. */\n isOverflowSeries(seriesKey: string): boolean {\n return this.colors.isOverflow(seriesKey);\n }\n\n // -------------------------------------------------------------- the scales\n\n /** Reads a series value off a row, or null when it is missing/non-numeric. */\n valueAt(seriesKey: string, index: number): number | null {\n const raw = this.data[index]?.[seriesKey];\n if (raw == null || raw === '') return null;\n const num = Number(raw);\n return Number.isFinite(num) ? num : null;\n }\n\n /** Sum of every series below `seriesKey` at `index`, for stacked marks. */\n stackBase(seriesKey: string, index: number): number {\n if (!this.stacked) return 0;\n let base = 0;\n for (const item of this.series) {\n if (item.key === seriesKey) break;\n base += this.valueAt(item.key, index) ?? 0;\n }\n return base;\n }\n\n /** Sum of every series at `index`. */\n stackTotal(index: number): number {\n let total = 0;\n for (const item of this.series) total += this.valueAt(item.key, index) ?? 0;\n return total;\n }\n\n /**\n * Maps a data value to an svg y. SVG y grows downward, so the domain maximum\n * lands on `plotTop` and the minimum on `plotBottom`.\n */\n yFor(value: number): number {\n const span = this.domainMax - this.domainMin;\n if (span <= 0) return this.plotBottom;\n const ratio = (value - this.domainMin) / span;\n return this.plotBottom - ratio * this.plotHeight;\n }\n\n /** The svg y of the zero line, clamped into the plot. */\n get baselineY(): number {\n const zero = Math.min(Math.max(0, this.domainMin), this.domainMax);\n return this.yFor(zero);\n }\n\n /** Width of one band, excluding its padding. Meaningful in band mode. */\n get bandWidth(): number {\n if (this.count === 0) return 0;\n return this.bandStep * (1 - this.bandPadding);\n }\n\n /** Distance between adjacent band starts. Bands tile the plot exactly. */\n get bandStep(): number {\n if (this.count === 0) return 0;\n return this.plotWidth / this.count;\n }\n\n /** Geometry of the band at `index`. */\n bandFor(index: number): ChartBand {\n const step = this.bandStep;\n const width = this.bandWidth;\n const start = this.plotLeft + index * step + (step - width) / 2;\n return { start, centre: start + width / 2, width };\n }\n\n /** x of the data point at `index` on the point scale. */\n pointX(index: number): number {\n if (this.count === 0) return this.plotLeft;\n if (this.count === 1) return this.plotLeft + this.plotWidth / 2;\n return this.plotLeft + (index * this.plotWidth) / (this.count - 1);\n }\n\n /** x of `index` under whichever scale the chart is currently using. */\n xFor(index: number): number {\n return this.scaleMode === 'band' ? this.bandFor(index).centre : this.pointX(index);\n }\n\n /**\n * The pointer catchment area for `index` — deliberately wider than the mark.\n * In band mode it is the whole band step; on the point scale it runs to the\n * midpoints of the neighbouring points, so the pointer only has to be nearest.\n */\n hitBandFor(index: number): ChartBand {\n if (this.scaleMode === 'band') {\n const step = this.bandStep;\n const start = this.plotLeft + index * step;\n return { start, centre: start + step / 2, width: step };\n }\n const x = this.pointX(index);\n const prev = index > 0 ? this.pointX(index - 1) : this.plotLeft - (this.plotRight - this.plotLeft);\n const next =\n index < this.count - 1 ? this.pointX(index + 1) : this.plotRight + (this.plotRight - this.plotLeft);\n const start = Math.max(this.plotLeft, (prev + x) / 2);\n const end = Math.min(this.plotRight, (x + next) / 2);\n return { start, centre: x, width: Math.max(0, end - start) };\n }\n\n // --------------------------------------------------------------- hovering\n\n /** Index currently under the pointer, or null. */\n get activeIndex(): number | null {\n return this.activeIndexSubject.value;\n }\n\n setActiveIndex(index: number | null): void {\n if (this.activeIndexSubject.value === index) return;\n this.activeIndexSubject.next(index);\n }\n\n // ----------------------------------------------------------------- domains\n\n private recompute(): void {\n const values: number[] = [];\n\n if (this.stacked) {\n for (let i = 0; i < this.count; i++) values.push(this.stackTotal(i));\n // A stack still has to show its own floor when a series goes negative.\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null && v < 0) values.push(v);\n }\n }\n } else {\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null) values.push(v);\n }\n }\n }\n\n if (this.zeroRequired) values.push(0);\n\n const [min, max, step, ticks] = niceScale(values);\n this.domainMin = min;\n this.domainMax = max;\n this.tickStep = step;\n this.tickValues = ticks;\n\n // Only tell subscribers when something they would draw differently actually\n // changed. `configure()` is called from ngOnChanges, and an app binding\n // `[data]=\"items.filter(...)\"` hands us a fresh array reference on every\n // change-detection pass — an ordinary thing to write. Emitting on every call\n // made subscribers markForCheck, which scheduled another pass, which called\n // configure again: the cycle never closed and the page locked up.\n const signature = this.layoutSignature(values);\n if (signature === this.lastSignature) return;\n this.lastSignature = signature;\n\n this.scheduleEmit();\n }\n\n /** Everything a subscriber would render differently, flattened for comparison. */\n private lastSignature = '';\n\n private layoutSignature(values: number[]): string {\n return [\n this.count,\n this.xKey,\n this.stacked,\n this.width,\n this.height,\n this.bandPadding,\n this.scaleMode,\n this.zeroRequired,\n this.margin.top, this.margin.right, this.margin.bottom, this.margin.left,\n this.domainMin, this.domainMax, this.tickStep,\n this.tickValues.join(','),\n this.series.map((s) => s.key + ':' + s.label).join(','),\n this.xLabels.join('\u0001'),\n values.join(','),\n ].join('\u0000');\n }\n\n /**\n * Coalesces layout notifications into a microtask, so several children\n * registering in the same change-detection pass produce one emission after\n * they have all settled rather than one each mid-pass.\n */\n private scheduleEmit(): void {\n this.layoutVersion++;\n if (this.emitScheduled) return;\n this.emitScheduled = true;\n queueMicrotask(() => {\n this.emitScheduled = false;\n this.layoutSubject.next(this.layoutVersion);\n });\n }\n\n /** Formats a value for a tick label at the current tick step's precision. */\n formatValue(value: number): string {\n const decimals = decimalsFor(this.tickStep);\n return value.toLocaleString('en-US', {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n });\n }\n\n // ------------------------------------------------------------------- paths\n\n /** Straight-segment path through the series' defined points. */\n linePath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), false);\n }\n\n /** Monotone-cubic path through the series' defined points. */\n smoothPath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), true);\n }\n\n /**\n * Filled-area path: the series' line, closed back along its baseline. When\n * stacked, the floor is the top of the series below rather than zero.\n */\n areaPath(seriesKey: string, smooth: boolean): string {\n const points = this.pointsFor(seriesKey);\n if (points.length === 0) return '';\n\n const top = buildPath(points, smooth);\n const floor = points\n .map((p) => {\n const base = this.stacked ? this.stackBase(seriesKey, p.index) : 0;\n return { x: p.x, y: this.yFor(base), index: p.index };\n })\n .reverse();\n const bottom = buildPath(floor, smooth);\n // Re-enter the reversed floor with a line, then close.\n return top + ' L ' + floor[0].x + ' ' + floor[0].y + ' ' + bottom.replace(/^M/, 'L') + ' Z';\n }\n\n /** Resolved svg points for a series, skipping rows where it has no value. */\n pointsFor(seriesKey: string): { x: number; y: number; index: number }[] {\n const points: { x: number; y: number; index: number }[] = [];\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(seriesKey, i);\n if (v == null) continue;\n const stacked = this.stacked ? this.stackBase(seriesKey, i) + v : v;\n points.push({ x: this.xFor(i), y: this.yFor(stacked), index: i });\n }\n return points;\n }\n}\n\n// ------------------------------------------------------------- pure helpers\n\n/** Decimal places worth showing for a given tick step. */\nexport function decimalsFor(step: number): number {\n if (!Number.isFinite(step) || step <= 0) return 0;\n return Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));\n}\n\n/**\n * Rounds a raw step up to the next 1/2/5 x 10^n.\n *\n * Dividing the range by a tick count instead would produce steps like 19.4 and\n * so axis labels like 19.4 / 38.8 / 58.2, which nobody can read a value off.\n */\nexport function niceStep(rawStep: number): number {\n if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));\n const normalised = rawStep / magnitude;\n let multiplier: number;\n if (normalised <= 1) multiplier = 1;\n else if (normalised <= 2) multiplier = 2;\n else if (normalised <= 5) multiplier = 5;\n else multiplier = 10;\n return multiplier * magnitude;\n}\n\n/**\n * Builds a readable y scale over `values`: returns `[min, max, step, ticks]`\n * with the domain snapped outward to whole steps.\n *\n * Degenerate inputs (empty, all-identical) still produce a usable one-unit\n * domain rather than a zero-height plot.\n */\nexport function niceScale(values: number[], targetTicks = 5): [number, number, number, number[]] {\n const finite = values.filter((v) => Number.isFinite(v));\n\n let min = finite.length ? Math.min(...finite) : 0;\n let max = finite.length ? Math.max(...finite) : 1;\n\n if (min === max) {\n // A flat or single-point domain has no span to divide; open it to one unit\n // around the value so the mark sits on a real axis instead of on the floor.\n if (min === 0) {\n min = 0;\n max = 1;\n } else if (min > 0) {\n min = Math.min(0, min);\n max = max === 0 ? 1 : max;\n if (min === max) max = min + 1;\n } else {\n max = Math.max(0, max);\n if (min === max) min = max - 1;\n }\n }\n\n const step = niceStep((max - min) / targetTicks);\n // Nudge before rounding outward: 100/20 lands a hair above 5 in binary\n // floating point, which would otherwise add a phantom tick at 120.\n const epsilon = 1e-9;\n const niceMin = Math.floor(min / step + epsilon) * step;\n const niceMax = Math.ceil(max / step - epsilon) * step;\n const decimals = decimalsFor(step);\n\n const ticks: number[] = [];\n const total = Math.round((niceMax - niceMin) / step);\n for (let i = 0; i <= total; i++) {\n ticks.push(Number((niceMin + i * step).toFixed(decimals)));\n }\n\n return [Number(niceMin.toFixed(decimals)), Number(niceMax.toFixed(decimals)), step, ticks];\n}\n\n/**\n * Monotone-cubic tangents (Fritsch-Carlson). Limiting each tangent to the\n * neighbouring secants is what stops the curve overshooting: a plain\n * Catmull-Rom spline bulges past the data and invents peaks that were never\n * measured, which on a chart is a false statement about the numbers.\n */\nexport function monotoneTangents(xs: number[], ys: number[]): number[] {\n const n = xs.length;\n if (n < 2) return new Array(n).fill(0);\n\n const secants: number[] = [];\n for (let i = 0; i < n - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n secants.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);\n }\n\n const m: number[] = new Array(n);\n m[0] = secants[0];\n m[n - 1] = secants[n - 2];\n for (let i = 1; i < n - 1; i++) m[i] = (secants[i - 1] + secants[i]) / 2;\n\n for (let i = 0; i < n - 1; i++) {\n if (secants[i] === 0) {\n // Flat segment: both ends must be flat or the curve leaves the data range.\n m[i] = 0;\n m[i + 1] = 0;\n continue;\n }\n const a = m[i] / secants[i];\n const b = m[i + 1] / secants[i];\n // Negative ratios mean the tangent points away from the secant.\n if (a < 0) m[i] = 0;\n if (b < 0) m[i + 1] = 0;\n const sum = a * a + b * b;\n if (sum > 9) {\n const t = 3 / Math.sqrt(sum);\n m[i] = t * a * secants[i];\n m[i + 1] = t * b * secants[i];\n }\n }\n\n return m;\n}\n\n/** Turns resolved points into an svg path, optionally monotone-smoothed. */\nexport function buildPath(points: { x: number; y: number }[], smooth: boolean): string {\n if (points.length === 0) return '';\n if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y;\n\n const head = 'M ' + points[0].x + ' ' + points[0].y;\n if (!smooth) {\n return head + points.slice(1).map((p) => ' L ' + p.x + ' ' + p.y).join('');\n }\n\n const xs = points.map((p) => p.x);\n const ys = points.map((p) => p.y);\n const m = monotoneTangents(xs, ys);\n\n let d = head;\n for (let i = 0; i < points.length - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n const c1x = xs[i] + dx / 3;\n const c1y = ys[i] + (m[i] * dx) / 3;\n const c2x = xs[i + 1] - dx / 3;\n const c2y = ys[i + 1] - (m[i + 1] * dx) / 3;\n d += ' C ' + c1x + ' ' + c1y + ' ' + c2x + ' ' + c2y + ' ' + xs[i + 1] + ' ' + ys[i + 1];\n }\n return d;\n}\n"
20
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** One plotted series: which key to read off each row, and how to name it. */\nexport interface ChartSeries {\n key: string;\n label: string;\n}\n\n/** Space reserved around the plot area for axes and labels. */\nexport interface ChartMargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** A y-axis tick: its data value, its resolved svg y, and its formatted label. */\nexport interface ChartTick {\n value: number;\n y: number;\n label: string;\n}\n\n/** A horizontal slice of the plot belonging to one x position. */\nexport interface ChartBand {\n start: number;\n centre: number;\n width: number;\n}\n\n/** How x positions are resolved: discrete bands (bars) or points (line/area). */\nexport type ChartScaleMode = 'point' | 'band';\n\n/**\n * Which physical axis carries the category vs the value. Only the band scale\n * (bars) supports 'horizontal' — line/area stay point-scale and vertical,\n * same as Tremor never offers a horizontal line/area chart either.\n */\nexport type ChartOrientation = 'vertical' | 'horizontal';\n\n/** Number of chart palette steps that exist. A 6th series does not get a colour. */\nexport const CHART_COLOR_LIMIT = 5;\n\n/**\n * The paint used for a series past `CHART_COLOR_LIMIT`.\n *\n * The palette is validated for lightness, chroma and colour-blind separation at\n * exactly five steps; generating a sixth would land outside that validation, and\n * wrapping back to step 1 would make two series share an identity. Both are\n * worse than dropping to a neutral, so the overflow is explicitly uncoloured.\n */\nexport const CHART_OVERFLOW_COLOR = 'rgb(var(--muted-foreground))';\n\n/**\n * Hands out palette steps by entity key rather than by position.\n *\n * A key keeps its slot for the lifetime of the scale, which is what makes\n * colour follow the entity: filtering a series out and back in, or removing an\n * earlier one, must never repaint the survivors. Past the fifth distinct key\n * the scale stops colouring rather than inventing a step or wrapping around.\n */\nexport class ChartColorScale {\n private readonly slots = new Map<string, number>();\n private next = 0;\n\n /** Reserves slots for `keys` in order, leaving already-known keys alone. */\n register(keys: string[]): void {\n for (const key of keys) {\n if (!this.slots.has(key)) this.slots.set(key, this.next++);\n }\n }\n\n /** Paint for `key`, registering it on first sight. */\n colorFor(key: string): string {\n let slot = this.slots.get(key);\n if (slot === undefined) {\n slot = this.next++;\n this.slots.set(key, slot);\n }\n if (slot >= CHART_COLOR_LIMIT) return CHART_OVERFLOW_COLOR;\n return 'rgb(var(--chart-' + (slot + 1) + '))';\n }\n\n /** True when `key` fell past the palette and renders neutral. */\n isOverflow(key: string): boolean {\n const slot = this.slots.get(key);\n return slot !== undefined && slot >= CHART_COLOR_LIMIT;\n }\n\n /** Slot index assigned to `key`, or undefined when it is unknown. */\n slotOf(key: string): number | undefined {\n return this.slots.get(key);\n }\n}\n\n/**\n * Owns layout, scales and colour assignment for one `tolle-chart`, so every\n * child draws into the same coordinate space. Renders nothing itself.\n *\n * Provided on `ChartComponent`, so each chart gets its own instance.\n */\n@Injectable()\nexport class ChartService {\n /** Rows being plotted, in x order. */\n data: Record<string, any>[] = [];\n /** Series definitions, in the order they should take palette steps. */\n series: ChartSeries[] = [];\n /** Row key holding the x category/label. */\n xKey = '';\n /** Whether marks stack on a shared baseline rather than sitting side by side. */\n stacked = false;\n /** Full svg width in px, tracked from the container's ResizeObserver. */\n width = 0;\n /** Full svg height in px. */\n height = 260;\n /** Space reserved around the plot area. */\n margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 40 };\n /** Fraction of a band's step spent on padding, split either side. */\n bandPadding = 0.2;\n /**\n * Which physical axis is the category axis. Bars flip this via `configure()`;\n * every other geometry getter branches on it internally, defaulting to the\n * exact vertical formula when unset, so existing vertical charts are unaffected.\n */\n orientation: ChartOrientation = 'vertical';\n\n private scaleMode: ChartScaleMode = 'point';\n private zeroRequired = false;\n\n private domainMin = 0;\n private domainMax = 1;\n private tickValues: number[] = [];\n private tickStep = 1;\n\n /** Palette assignment, keyed by series identity. */\n private readonly colors = new ChartColorScale();\n\n private readonly layoutSubject = new BehaviorSubject<number>(0);\n /** Emits a version number whenever scales, dimensions or data change. */\n readonly layout$ = this.layoutSubject.asObservable();\n\n private readonly activeIndexSubject = new BehaviorSubject<number | null>(null);\n /** Index of the x position under the pointer, or null when not hovering. */\n readonly activeIndex$ = this.activeIndexSubject.asObservable();\n\n private layoutVersion = 0;\n private emitScheduled = false;\n\n // ---------------------------------------------------------------- geometry\n\n get plotLeft(): number {\n return this.margin.left;\n }\n\n get plotTop(): number {\n return this.margin.top;\n }\n\n get plotWidth(): number {\n return Math.max(0, this.width - this.margin.left - this.margin.right);\n }\n\n get plotHeight(): number {\n return Math.max(0, this.height - this.margin.top - this.margin.bottom);\n }\n\n get plotRight(): number {\n return this.plotLeft + this.plotWidth;\n }\n\n get plotBottom(): number {\n return this.plotTop + this.plotHeight;\n }\n\n /** Number of x positions. */\n get count(): number {\n return this.data.length;\n }\n\n /** How x is currently resolved. Bars flip this to 'band'. */\n get mode(): ChartScaleMode {\n return this.scaleMode;\n }\n\n /** Resolved y ticks, with svg coordinates and formatted labels. */\n get ticks(): ChartTick[] {\n return this.tickValues.map((value) => ({\n value,\n y: this.yFor(value),\n label: this.formatValue(value),\n }));\n }\n\n /** Current y domain as [min, max]. */\n get domain(): [number, number] {\n return [this.domainMin, this.domainMax];\n }\n\n /** The x-axis category labels, one per row. */\n get xLabels(): string[] {\n return this.data.map((row) => {\n const raw = row?.[this.xKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n // ------------------------------------------------------------ registration\n\n /**\n * Replaces the chart's configuration and recomputes the scales.\n * Called by the container on input changes and on resize.\n */\n configure(config: Partial<Pick<ChartService,\n 'data' | 'series' | 'xKey' | 'stacked' | 'width' | 'height' | 'margin' | 'bandPadding' | 'orientation'>>): void {\n Object.assign(this, config);\n if (config.series) this.syncColorSlots(config.series);\n this.recompute();\n }\n\n /** Switches x to a band scale. Bars call this; it also forces zero into the domain. */\n useBandScale(): void {\n if (this.scaleMode === 'band' && this.zeroRequired) return;\n this.scaleMode = 'band';\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Forces zero into the y domain, for marks anchored to a baseline. */\n requireZero(): void {\n if (this.zeroRequired) return;\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Reserves palette slots in series order, leaving known keys untouched. */\n private syncColorSlots(series: ChartSeries[]): void {\n this.colors.register(series.map((item) => item.key));\n }\n\n /**\n * Paint for a series, keyed by identity rather than by index in the visible\n * array. Past the fifth distinct key this returns the neutral overflow colour.\n */\n colorFor(seriesKey: string): string {\n return this.colors.colorFor(seriesKey);\n }\n\n /** True when this series fell past the palette and renders neutral. */\n isOverflowSeries(seriesKey: string): boolean {\n return this.colors.isOverflow(seriesKey);\n }\n\n // -------------------------------------------------------------- the scales\n\n /** Reads a series value off a row, or null when it is missing/non-numeric. */\n valueAt(seriesKey: string, index: number): number | null {\n const raw = this.data[index]?.[seriesKey];\n if (raw == null || raw === '') return null;\n const num = Number(raw);\n return Number.isFinite(num) ? num : null;\n }\n\n /** Sum of every series below `seriesKey` at `index`, for stacked marks. */\n stackBase(seriesKey: string, index: number): number {\n if (!this.stacked) return 0;\n let base = 0;\n for (const item of this.series) {\n if (item.key === seriesKey) break;\n base += this.valueAt(item.key, index) ?? 0;\n }\n return base;\n }\n\n /** Sum of every series at `index`. */\n stackTotal(index: number): number {\n let total = 0;\n for (const item of this.series) total += this.valueAt(item.key, index) ?? 0;\n return total;\n }\n\n /**\n * Maps a data value to a position along the *value* axis — svg y when\n * vertical (svg y grows downward, so the domain maximum lands on `plotTop`),\n * svg x when horizontal (the domain maximum lands on `plotRight`).\n */\n yFor(value: number): number {\n const span = this.domainMax - this.domainMin;\n if (this.orientation === 'horizontal') {\n if (span <= 0) return this.plotLeft;\n const ratio = (value - this.domainMin) / span;\n return this.plotLeft + ratio * this.plotWidth;\n }\n if (span <= 0) return this.plotBottom;\n const ratio = (value - this.domainMin) / span;\n return this.plotBottom - ratio * this.plotHeight;\n }\n\n /** The value-axis position of the zero line, clamped into the plot. */\n get baselineY(): number {\n const zero = Math.min(Math.max(0, this.domainMin), this.domainMax);\n return this.yFor(zero);\n }\n\n /** Length of the plot along the category axis: `plotHeight` when horizontal, else `plotWidth`. */\n private get categoryAxisLength(): number {\n return this.orientation === 'horizontal' ? this.plotHeight : this.plotWidth;\n }\n\n /** Origin of the plot along the category axis: `plotTop` when horizontal, else `plotLeft`. */\n private get categoryAxisOrigin(): number {\n return this.orientation === 'horizontal' ? this.plotTop : this.plotLeft;\n }\n\n /** Width of one band, excluding its padding. Meaningful in band mode. */\n get bandWidth(): number {\n if (this.count === 0) return 0;\n return this.bandStep * (1 - this.bandPadding);\n }\n\n /** Distance between adjacent band starts. Bands tile the plot exactly. */\n get bandStep(): number {\n if (this.count === 0) return 0;\n return this.categoryAxisLength / this.count;\n }\n\n /**\n * Geometry of the band at `index`, along the category axis — x when\n * vertical (today's meaning), y when horizontal.\n */\n bandFor(index: number): ChartBand {\n const step = this.bandStep;\n const width = this.bandWidth;\n const start = this.categoryAxisOrigin + index * step + (step - width) / 2;\n return { start, centre: start + width / 2, width };\n }\n\n /** x of the data point at `index` on the point scale. */\n pointX(index: number): number {\n if (this.count === 0) return this.plotLeft;\n if (this.count === 1) return this.plotLeft + this.plotWidth / 2;\n return this.plotLeft + (index * this.plotWidth) / (this.count - 1);\n }\n\n /** x of `index` under whichever scale the chart is currently using. */\n xFor(index: number): number {\n return this.scaleMode === 'band' ? this.bandFor(index).centre : this.pointX(index);\n }\n\n /**\n * The pointer catchment area for `index` — deliberately wider than the mark.\n * In band mode it is the whole band step; on the point scale it runs to the\n * midpoints of the neighbouring points, so the pointer only has to be nearest.\n */\n hitBandFor(index: number): ChartBand {\n if (this.scaleMode === 'band') {\n const step = this.bandStep;\n const start = this.categoryAxisOrigin + index * step;\n return { start, centre: start + step / 2, width: step };\n }\n const x = this.pointX(index);\n const prev = index > 0 ? this.pointX(index - 1) : this.plotLeft - (this.plotRight - this.plotLeft);\n const next =\n index < this.count - 1 ? this.pointX(index + 1) : this.plotRight + (this.plotRight - this.plotLeft);\n const start = Math.max(this.plotLeft, (prev + x) / 2);\n const end = Math.min(this.plotRight, (x + next) / 2);\n return { start, centre: x, width: Math.max(0, end - start) };\n }\n\n // --------------------------------------------------------------- hovering\n\n /** Index currently under the pointer, or null. */\n get activeIndex(): number | null {\n return this.activeIndexSubject.value;\n }\n\n setActiveIndex(index: number | null): void {\n if (this.activeIndexSubject.value === index) return;\n this.activeIndexSubject.next(index);\n }\n\n // ----------------------------------------------------------------- domains\n\n private recompute(): void {\n const values: number[] = [];\n\n if (this.stacked) {\n for (let i = 0; i < this.count; i++) values.push(this.stackTotal(i));\n // A stack still has to show its own floor when a series goes negative.\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null && v < 0) values.push(v);\n }\n }\n } else {\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null) values.push(v);\n }\n }\n }\n\n if (this.zeroRequired) values.push(0);\n\n const [min, max, step, ticks] = niceScale(values);\n this.domainMin = min;\n this.domainMax = max;\n this.tickStep = step;\n this.tickValues = ticks;\n\n // Only tell subscribers when something they would draw differently actually\n // changed. `configure()` is called from ngOnChanges, and an app binding\n // `[data]=\"items.filter(...)\"` hands us a fresh array reference on every\n // change-detection pass — an ordinary thing to write. Emitting on every call\n // made subscribers markForCheck, which scheduled another pass, which called\n // configure again: the cycle never closed and the page locked up.\n const signature = this.layoutSignature(values);\n if (signature === this.lastSignature) return;\n this.lastSignature = signature;\n\n this.scheduleEmit();\n }\n\n /** Everything a subscriber would render differently, flattened for comparison. */\n private lastSignature = '';\n\n private layoutSignature(values: number[]): string {\n return [\n this.count,\n this.xKey,\n this.stacked,\n this.width,\n this.height,\n this.bandPadding,\n this.scaleMode,\n this.orientation,\n this.zeroRequired,\n this.margin.top, this.margin.right, this.margin.bottom, this.margin.left,\n this.domainMin, this.domainMax, this.tickStep,\n this.tickValues.join(','),\n this.series.map((s) => s.key + ':' + s.label).join(','),\n this.xLabels.join('\u0001'),\n values.join(','),\n ].join('\u0000');\n }\n\n /**\n * Coalesces layout notifications into a microtask, so several children\n * registering in the same change-detection pass produce one emission after\n * they have all settled rather than one each mid-pass.\n */\n private scheduleEmit(): void {\n this.layoutVersion++;\n if (this.emitScheduled) return;\n this.emitScheduled = true;\n queueMicrotask(() => {\n this.emitScheduled = false;\n this.layoutSubject.next(this.layoutVersion);\n });\n }\n\n /** Formats a value for a tick label at the current tick step's precision. */\n formatValue(value: number): string {\n const decimals = decimalsFor(this.tickStep);\n return value.toLocaleString('en-US', {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n });\n }\n\n // ------------------------------------------------------------------- paths\n\n /** Straight-segment path through the series' defined points. */\n linePath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), false);\n }\n\n /** Monotone-cubic path through the series' defined points. */\n smoothPath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), true);\n }\n\n /**\n * Filled-area path: the series' line, closed back along its baseline. When\n * stacked, the floor is the top of the series below rather than zero.\n */\n areaPath(seriesKey: string, smooth: boolean): string {\n const points = this.pointsFor(seriesKey);\n if (points.length === 0) return '';\n\n const top = buildPath(points, smooth);\n const floor = points\n .map((p) => {\n const base = this.stacked ? this.stackBase(seriesKey, p.index) : 0;\n return { x: p.x, y: this.yFor(base), index: p.index };\n })\n .reverse();\n const bottom = buildPath(floor, smooth);\n // Re-enter the reversed floor with a line, then close.\n return top + ' L ' + floor[0].x + ' ' + floor[0].y + ' ' + bottom.replace(/^M/, 'L') + ' Z';\n }\n\n /** Resolved svg points for a series, skipping rows where it has no value. */\n pointsFor(seriesKey: string): { x: number; y: number; index: number }[] {\n const points: { x: number; y: number; index: number }[] = [];\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(seriesKey, i);\n if (v == null) continue;\n const stacked = this.stacked ? this.stackBase(seriesKey, i) + v : v;\n points.push({ x: this.xFor(i), y: this.yFor(stacked), index: i });\n }\n return points;\n }\n}\n\n// ------------------------------------------------------------- pure helpers\n\n/** Decimal places worth showing for a given tick step. */\nexport function decimalsFor(step: number): number {\n if (!Number.isFinite(step) || step <= 0) return 0;\n return Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));\n}\n\n/**\n * Rounds a raw step up to the next 1/2/5 x 10^n.\n *\n * Dividing the range by a tick count instead would produce steps like 19.4 and\n * so axis labels like 19.4 / 38.8 / 58.2, which nobody can read a value off.\n */\nexport function niceStep(rawStep: number): number {\n if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));\n const normalised = rawStep / magnitude;\n let multiplier: number;\n if (normalised <= 1) multiplier = 1;\n else if (normalised <= 2) multiplier = 2;\n else if (normalised <= 5) multiplier = 5;\n else multiplier = 10;\n return multiplier * magnitude;\n}\n\n/**\n * Builds a readable y scale over `values`: returns `[min, max, step, ticks]`\n * with the domain snapped outward to whole steps.\n *\n * Degenerate inputs (empty, all-identical) still produce a usable one-unit\n * domain rather than a zero-height plot.\n */\nexport function niceScale(values: number[], targetTicks = 5): [number, number, number, number[]] {\n const finite = values.filter((v) => Number.isFinite(v));\n\n let min = finite.length ? Math.min(...finite) : 0;\n let max = finite.length ? Math.max(...finite) : 1;\n\n if (min === max) {\n // A flat or single-point domain has no span to divide; open it to one unit\n // around the value so the mark sits on a real axis instead of on the floor.\n if (min === 0) {\n min = 0;\n max = 1;\n } else if (min > 0) {\n min = Math.min(0, min);\n max = max === 0 ? 1 : max;\n if (min === max) max = min + 1;\n } else {\n max = Math.max(0, max);\n if (min === max) min = max - 1;\n }\n }\n\n const step = niceStep((max - min) / targetTicks);\n // Nudge before rounding outward: 100/20 lands a hair above 5 in binary\n // floating point, which would otherwise add a phantom tick at 120.\n const epsilon = 1e-9;\n const niceMin = Math.floor(min / step + epsilon) * step;\n const niceMax = Math.ceil(max / step - epsilon) * step;\n const decimals = decimalsFor(step);\n\n const ticks: number[] = [];\n const total = Math.round((niceMax - niceMin) / step);\n for (let i = 0; i <= total; i++) {\n ticks.push(Number((niceMin + i * step).toFixed(decimals)));\n }\n\n return [Number(niceMin.toFixed(decimals)), Number(niceMax.toFixed(decimals)), step, ticks];\n}\n\n/**\n * Monotone-cubic tangents (Fritsch-Carlson). Limiting each tangent to the\n * neighbouring secants is what stops the curve overshooting: a plain\n * Catmull-Rom spline bulges past the data and invents peaks that were never\n * measured, which on a chart is a false statement about the numbers.\n */\nexport function monotoneTangents(xs: number[], ys: number[]): number[] {\n const n = xs.length;\n if (n < 2) return new Array(n).fill(0);\n\n const secants: number[] = [];\n for (let i = 0; i < n - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n secants.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);\n }\n\n const m: number[] = new Array(n);\n m[0] = secants[0];\n m[n - 1] = secants[n - 2];\n for (let i = 1; i < n - 1; i++) m[i] = (secants[i - 1] + secants[i]) / 2;\n\n for (let i = 0; i < n - 1; i++) {\n if (secants[i] === 0) {\n // Flat segment: both ends must be flat or the curve leaves the data range.\n m[i] = 0;\n m[i + 1] = 0;\n continue;\n }\n const a = m[i] / secants[i];\n const b = m[i + 1] / secants[i];\n // Negative ratios mean the tangent points away from the secant.\n if (a < 0) m[i] = 0;\n if (b < 0) m[i + 1] = 0;\n const sum = a * a + b * b;\n if (sum > 9) {\n const t = 3 / Math.sqrt(sum);\n m[i] = t * a * secants[i];\n m[i + 1] = t * b * secants[i];\n }\n }\n\n return m;\n}\n\n/** Turns resolved points into an svg path, optionally monotone-smoothed. */\nexport function buildPath(points: { x: number; y: number }[], smooth: boolean): string {\n if (points.length === 0) return '';\n if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y;\n\n const head = 'M ' + points[0].x + ' ' + points[0].y;\n if (!smooth) {\n return head + points.slice(1).map((p) => ' L ' + p.x + ' ' + p.y).join('');\n }\n\n const xs = points.map((p) => p.x);\n const ys = points.map((p) => p.y);\n const m = monotoneTangents(xs, ys);\n\n let d = head;\n for (let i = 0; i < points.length - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n const c1x = xs[i] + dx / 3;\n const c1y = ys[i] + (m[i] * dx) / 3;\n const c2x = xs[i + 1] - dx / 3;\n const c2y = ys[i + 1] - (m[i + 1] * dx) / 3;\n d += ' C ' + c1x + ' ' + c1y + ' ' + c2x + ' ' + c2y + ' ' + xs[i + 1] + ' ' + ys[i + 1];\n }\n return d;\n}\n"
21
21
  },
22
22
  {
23
23
  "path": "utils/cn.ts",
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "progress-circle",
3
+ "type": "registry:ui",
4
+ "title": "Progress Circle",
5
+ "description": "Progress Circle component.",
6
+ "category": "feedback",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "progress-circle.component.ts",
12
+ "target": "ui/progress-circle.component.ts",
13
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n/**\n * Radial counterpart to `tolle-progress`: a ring that fills clockwise from\n * 12 o'clock. Project content to label the centre — a percentage, an icon,\n * whatever the caller wants; the ring renders nothing there on its own.\n * @new\n */\n@Component({\n selector: 'tolle-progress-circle',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\"\n >\n <svg [attr.width]=\"size\" [attr.height]=\"size\" [attr.viewBox]=\"viewBox\" class=\"block\">\n <svg:circle\n [attr.cx]=\"center\"\n [attr.cy]=\"center\"\n [attr.r]=\"radius\"\n fill=\"none\"\n [attr.stroke-width]=\"strokeWidth\"\n class=\"stroke-primary/20\"\n ></svg:circle>\n <svg:circle\n [attr.cx]=\"center\"\n [attr.cy]=\"center\"\n [attr.r]=\"radius\"\n fill=\"none\"\n [attr.stroke-width]=\"strokeWidth\"\n stroke-linecap=\"round\"\n [attr.stroke-dasharray]=\"circumference\"\n [attr.stroke-dashoffset]=\"dashOffset\"\n [attr.transform]=\"'rotate(-90 ' + center + ' ' + center + ')'\"\n class=\"stroke-primary transition-[stroke-dashoffset] duration-300 ease-in-out\"\n ></svg:circle>\n </svg>\n <div class=\"absolute inset-0 flex items-center justify-center text-sm font-medium tabular-nums text-foreground\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class ProgressCircleComponent implements OnChanges {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Percent complete, 0-100. @default 0 */\n @Input() value: number | null = 0;\n /** Diameter of the ring in px. @default 120 */\n @Input() size = 120;\n /** Thickness of the ring in px. @default 8 */\n @Input() strokeWidth = 8;\n /** Extra Tailwind classes merged onto the ring via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get viewBox(): string {\n return '0 0 ' + this.size + ' ' + this.size;\n }\n\n get center(): number {\n return this.size / 2;\n }\n\n /** Leaves half the stroke width as margin so the ring never clips the frame. */\n get radius(): number {\n return Math.max(0, (this.size - this.strokeWidth) / 2);\n }\n\n get circumference(): number {\n return 2 * Math.PI * this.radius;\n }\n\n get dashOffset(): number {\n return this.circumference * (1 - this.clampedValue / 100);\n }\n\n get computedClass() {\n return cn('relative inline-flex shrink-0 items-center justify-center', this.class);\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "tracker",
3
+ "type": "registry:ui",
4
+ "title": "Tracker",
5
+ "description": "Tracker component.",
6
+ "category": "components",
7
+ "registryDependencies": [
8
+ "tooltip"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "tracker.component.ts",
16
+ "target": "ui/tracker.component.ts",
17
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { TooltipDirective } from './tooltip.directive';\n\n/** Health of one period in the strip. Maps to the theme's semantic status tokens, not the chart palette. */\nexport type TrackerStatus = 'success' | 'warning' | 'error' | 'neutral';\n\n/** One block: its health, an optional per-block colour override, and the tooltip naming it. */\nexport interface TrackerBlock {\n status?: TrackerStatus;\n color?: string;\n tooltip?: string;\n}\n\nconst STATUS_COLOR: Record<TrackerStatus, string> = {\n success: 'rgb(var(--success))',\n warning: 'rgb(var(--warning))',\n error: 'rgb(var(--destructive))',\n neutral: 'rgb(var(--muted))',\n};\n\n/**\n * A row of equal-width blocks, one per period — an uptime or activity strip.\n * Each block is a real `<button>` so its tooltip is reachable by keyboard and\n * has a screen-reader name of its own, not just a hover affordance.\n * @new\n */\n@Component({\n selector: 'tolle-tracker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, TooltipDirective],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" role=\"group\" [attr.aria-label]=\"ariaLabel || null\">\n <button\n *ngFor=\"let block of data; let i = index; trackBy: trackByIndex\"\n type=\"button\"\n class=\"flex-1 rounded-[2px] transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n [style.height.px]=\"blockHeight\"\n [style.background]=\"colorFor(block)\"\n [tolleTooltip]=\"block.tooltip || ''\"\n [attr.aria-label]=\"block.tooltip || 'Period ' + (i + 1)\"\n ></button>\n </div>\n `,\n})\nexport class TrackerComponent implements OnChanges {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** One block per period, in chronological order. @default [] */\n @Input() data: TrackerBlock[] = [];\n /** Height of each block in px. @default 32 */\n @Input() blockHeight = 32;\n /** Accessible name for the whole strip. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the strip via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n colorFor(block: TrackerBlock): string {\n return block.color || STATUS_COLOR[block.status ?? 'neutral'];\n }\n\n get computedClass() {\n return cn('flex w-full items-stretch gap-0.5', this.class);\n }\n}\n"
18
+ },
19
+ {
20
+ "path": "utils/cn.ts",
21
+ "target": "ui/utils/cn.ts",
22
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
23
+ }
24
+ ]
25
+ }
@@ -213,6 +213,26 @@
213
213
  "class-variance-authority"
214
214
  ]
215
215
  },
216
+ {
217
+ "name": "bar-list",
218
+ "type": "registry:ui",
219
+ "title": "Bar List",
220
+ "description": "Bar List component.",
221
+ "category": "components",
222
+ "isNew": true,
223
+ "files": [
224
+ {
225
+ "path": "bar-list.component.ts",
226
+ "type": "registry:ui"
227
+ },
228
+ {
229
+ "path": "utils/cn.ts",
230
+ "type": "registry:ui"
231
+ }
232
+ ],
233
+ "registryDependencies": [],
234
+ "dependencies": []
235
+ },
216
236
  {
217
237
  "name": "breadcrumb",
218
238
  "type": "registry:ui",
@@ -396,6 +416,26 @@
396
416
  "embla-carousel"
397
417
  ]
398
418
  },
419
+ {
420
+ "name": "category-bar",
421
+ "type": "registry:ui",
422
+ "title": "Category Bar",
423
+ "description": "Category Bar component.",
424
+ "category": "components",
425
+ "isNew": true,
426
+ "files": [
427
+ {
428
+ "path": "category-bar.component.ts",
429
+ "type": "registry:ui"
430
+ },
431
+ {
432
+ "path": "utils/cn.ts",
433
+ "type": "registry:ui"
434
+ }
435
+ ],
436
+ "registryDependencies": [],
437
+ "dependencies": []
438
+ },
399
439
  {
400
440
  "name": "chain-of-thought",
401
441
  "type": "registry:ui",
@@ -472,6 +512,34 @@
472
512
  "class-variance-authority"
473
513
  ]
474
514
  },
515
+ {
516
+ "name": "chart-spark",
517
+ "type": "registry:ui",
518
+ "title": "Chart Spark",
519
+ "description": "Chart Spark component.",
520
+ "category": "data",
521
+ "isNew": true,
522
+ "files": [
523
+ {
524
+ "path": "chart-spark.component.ts",
525
+ "type": "registry:ui"
526
+ },
527
+ {
528
+ "path": "chart.service.ts",
529
+ "type": "registry:ui"
530
+ },
531
+ {
532
+ "path": "utils/cn.ts",
533
+ "type": "registry:ui"
534
+ }
535
+ ],
536
+ "registryDependencies": [
537
+ "chart"
538
+ ],
539
+ "dependencies": [
540
+ "class-variance-authority"
541
+ ]
542
+ },
475
543
  {
476
544
  "name": "checkbox",
477
545
  "type": "registry:ui",
@@ -1595,6 +1663,26 @@
1595
1663
  "registryDependencies": [],
1596
1664
  "dependencies": []
1597
1665
  },
1666
+ {
1667
+ "name": "progress-circle",
1668
+ "type": "registry:ui",
1669
+ "title": "Progress Circle",
1670
+ "description": "Progress Circle component.",
1671
+ "category": "feedback",
1672
+ "isNew": true,
1673
+ "files": [
1674
+ {
1675
+ "path": "progress-circle.component.ts",
1676
+ "type": "registry:ui"
1677
+ },
1678
+ {
1679
+ "path": "utils/cn.ts",
1680
+ "type": "registry:ui"
1681
+ }
1682
+ ],
1683
+ "registryDependencies": [],
1684
+ "dependencies": []
1685
+ },
1598
1686
  {
1599
1687
  "name": "prompt-input",
1600
1688
  "type": "registry:ui",
@@ -2407,6 +2495,30 @@
2407
2495
  "@floating-ui/dom"
2408
2496
  ]
2409
2497
  },
2498
+ {
2499
+ "name": "tracker",
2500
+ "type": "registry:ui",
2501
+ "title": "Tracker",
2502
+ "description": "Tracker component.",
2503
+ "category": "components",
2504
+ "isNew": true,
2505
+ "files": [
2506
+ {
2507
+ "path": "tracker.component.ts",
2508
+ "type": "registry:ui"
2509
+ },
2510
+ {
2511
+ "path": "utils/cn.ts",
2512
+ "type": "registry:ui"
2513
+ }
2514
+ ],
2515
+ "registryDependencies": [
2516
+ "tooltip"
2517
+ ],
2518
+ "dependencies": [
2519
+ "@floating-ui/dom"
2520
+ ]
2521
+ },
2410
2522
  {
2411
2523
  "name": "typography",
2412
2524
  "type": "registry:ui",