@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@tolle_/tolle-ui",
3
- "version": "18.3.0",
3
+ "version": "18.3.1",
4
4
  "registry": "r/{name}.json",
5
5
  "components": [
6
6
  {
@@ -860,6 +860,62 @@
860
860
  }
861
861
  ]
862
862
  },
863
+ {
864
+ "name": "bar-list",
865
+ "title": "Bar List",
866
+ "description": "Bar List component.",
867
+ "category": "components",
868
+ "selector": "tolle-bar-list",
869
+ "isNew": true,
870
+ "import": "import { BarListComponent } from '@tolle_/tolle-ui';",
871
+ "install": "npx @tolle_/cli add bar-list",
872
+ "registryDependencies": [],
873
+ "dependencies": [],
874
+ "files": [
875
+ "bar-list.component.ts",
876
+ "utils/cn.ts"
877
+ ],
878
+ "components": [
879
+ {
880
+ "className": "BarListComponent",
881
+ "selector": "tolle-bar-list",
882
+ "kind": "component",
883
+ "inputs": [
884
+ {
885
+ "name": "data",
886
+ "type": "Record<string, any>[]",
887
+ "default": "[]",
888
+ "description": "Rows to rank, in the order they should be drawn.",
889
+ "required": false
890
+ },
891
+ {
892
+ "name": "labelKey",
893
+ "type": "string",
894
+ "default": "'label'",
895
+ "description": "Row key holding each row's label.",
896
+ "required": false
897
+ },
898
+ {
899
+ "name": "valueKey",
900
+ "type": "string",
901
+ "default": "'value'",
902
+ "description": "Row key holding each row's numeric value.",
903
+ "required": false
904
+ },
905
+ {
906
+ "name": "class",
907
+ "type": "string",
908
+ "default": "''",
909
+ "description": "Extra Tailwind classes merged onto the list via `cn()` (last-wins).",
910
+ "required": false
911
+ }
912
+ ],
913
+ "outputs": [],
914
+ "slots": [],
915
+ "variants": []
916
+ }
917
+ ]
918
+ },
863
919
  {
864
920
  "name": "breadcrumb",
865
921
  "title": "Breadcrumb",
@@ -1671,6 +1727,76 @@
1671
1727
  }
1672
1728
  ]
1673
1729
  },
1730
+ {
1731
+ "name": "category-bar",
1732
+ "title": "Category Bar",
1733
+ "description": "Category Bar component.",
1734
+ "category": "components",
1735
+ "selector": "tolle-category-bar",
1736
+ "isNew": true,
1737
+ "import": "import { CategoryBarComponent } from '@tolle_/tolle-ui';",
1738
+ "install": "npx @tolle_/cli add category-bar",
1739
+ "registryDependencies": [],
1740
+ "dependencies": [],
1741
+ "files": [
1742
+ "category-bar.component.ts",
1743
+ "utils/cn.ts"
1744
+ ],
1745
+ "components": [
1746
+ {
1747
+ "className": "CategoryBarComponent",
1748
+ "selector": "tolle-category-bar",
1749
+ "kind": "component",
1750
+ "inputs": [
1751
+ {
1752
+ "name": "values",
1753
+ "type": "number[]",
1754
+ "default": "[]",
1755
+ "description": "Width of each segment. Values that are not finite and greater than zero are dropped.",
1756
+ "required": false
1757
+ },
1758
+ {
1759
+ "name": "labels",
1760
+ "type": "string[]",
1761
+ "default": "[]",
1762
+ "description": "Name for each segment, used in the accessible summary and as a title tooltip.",
1763
+ "required": false
1764
+ },
1765
+ {
1766
+ "name": "colors",
1767
+ "type": "string[]",
1768
+ "default": "[]",
1769
+ "description": "Paint for each segment, by index. Falls back to the chart palette --chart-1 through --chart-5.",
1770
+ "required": false
1771
+ },
1772
+ {
1773
+ "name": "markerValue",
1774
+ "type": "number | null",
1775
+ "default": "null",
1776
+ "description": "Position of the pointer, 0-100 on the same scale the segments sum to. Omit to draw no pointer.",
1777
+ "required": false
1778
+ },
1779
+ {
1780
+ "name": "ariaLabel",
1781
+ "type": "string",
1782
+ "default": "''",
1783
+ "description": "Accessible name, prefixed onto the sr-only summary of segments and marker.",
1784
+ "required": false
1785
+ },
1786
+ {
1787
+ "name": "class",
1788
+ "type": "string",
1789
+ "default": "''",
1790
+ "description": "Extra Tailwind classes merged onto the bar via `cn()` (last-wins).",
1791
+ "required": false
1792
+ }
1793
+ ],
1794
+ "outputs": [],
1795
+ "slots": [],
1796
+ "variants": []
1797
+ }
1798
+ ]
1799
+ },
1674
1800
  {
1675
1801
  "name": "chain-of-thought",
1676
1802
  "title": "Chain Of Thought",
@@ -2094,6 +2220,13 @@
2094
2220
  "description": "Stacks marks on a shared baseline instead of grouping them.",
2095
2221
  "required": false
2096
2222
  },
2223
+ {
2224
+ "name": "orientation",
2225
+ "type": "ChartOrientation",
2226
+ "default": "'vertical'",
2227
+ "description": "Which physical axis carries the category vs the value. Only bars support\n'horizontal' — line/area marks stay vertical regardless.",
2228
+ "required": false
2229
+ },
2097
2230
  {
2098
2231
  "name": "hover",
2099
2232
  "type": "boolean",
@@ -2177,7 +2310,7 @@
2177
2310
  "name": "vertical",
2178
2311
  "type": "boolean",
2179
2312
  "default": "false",
2180
- "description": "Also draws a rule at every x position.",
2313
+ "description": "Also draws a rule at every category position.",
2181
2314
  "required": false
2182
2315
  },
2183
2316
  {
@@ -2201,7 +2334,7 @@
2201
2334
  "name": "charWidth",
2202
2335
  "type": "number",
2203
2336
  "default": "7",
2204
- "description": "Approximate px per character, used to decide when labels would collide.",
2337
+ "description": "Approximate px per character, used to decide when category labels would\ncollide. Only consulted in the vertical orientation.",
2205
2338
  "required": false
2206
2339
  },
2207
2340
  {
@@ -2516,6 +2649,130 @@
2516
2649
  }
2517
2650
  ]
2518
2651
  },
2652
+ {
2653
+ "name": "chart-spark",
2654
+ "title": "Chart Spark",
2655
+ "description": "Chart Spark component.",
2656
+ "category": "data",
2657
+ "selector": "tolle-chart-spark",
2658
+ "isNew": true,
2659
+ "import": "import { ChartSparkComponent } from '@tolle_/tolle-ui';",
2660
+ "install": "npx @tolle_/cli add chart-spark",
2661
+ "registryDependencies": [
2662
+ "chart"
2663
+ ],
2664
+ "dependencies": [
2665
+ "class-variance-authority"
2666
+ ],
2667
+ "files": [
2668
+ "chart-spark.component.ts",
2669
+ "chart.service.ts",
2670
+ "utils/cn.ts"
2671
+ ],
2672
+ "components": [
2673
+ {
2674
+ "className": "ChartSparkComponent",
2675
+ "selector": "tolle-chart-spark",
2676
+ "kind": "component",
2677
+ "inputs": [
2678
+ {
2679
+ "name": "data",
2680
+ "type": "Record<string, any>[]",
2681
+ "default": "[]",
2682
+ "description": "Rows to plot, in x order.",
2683
+ "required": false
2684
+ },
2685
+ {
2686
+ "name": "valueKey",
2687
+ "type": "string",
2688
+ "default": "'value'",
2689
+ "description": "Row key holding each row's numeric value.",
2690
+ "required": false
2691
+ },
2692
+ {
2693
+ "name": "xKey",
2694
+ "type": "string",
2695
+ "default": "''",
2696
+ "description": "Row key holding each row's x position. Only matters once `hover` is on.",
2697
+ "required": false
2698
+ },
2699
+ {
2700
+ "name": "type",
2701
+ "type": "'line' | 'area' | 'bar'",
2702
+ "default": "'line'",
2703
+ "description": "Which mark to draw.",
2704
+ "required": false
2705
+ },
2706
+ {
2707
+ "name": "curve",
2708
+ "type": "'linear' | 'smooth'",
2709
+ "default": "'linear'",
2710
+ "description": "Interpolation for line/area.",
2711
+ "required": false
2712
+ },
2713
+ {
2714
+ "name": "maxBarWidth",
2715
+ "type": "number",
2716
+ "default": "6",
2717
+ "description": "Largest bar thickness in px, used when `type` is 'bar'.",
2718
+ "required": false
2719
+ },
2720
+ {
2721
+ "name": "height",
2722
+ "type": "number",
2723
+ "default": "32",
2724
+ "description": "Height in px.",
2725
+ "required": false
2726
+ },
2727
+ {
2728
+ "name": "hover",
2729
+ "type": "boolean",
2730
+ "default": "false",
2731
+ "description": "Renders the crosshair, hit layer and tooltip.",
2732
+ "required": false
2733
+ },
2734
+ {
2735
+ "name": "showTable",
2736
+ "type": "boolean",
2737
+ "default": "false",
2738
+ "description": "Shows the data table instead of leaving it visually hidden.",
2739
+ "required": false
2740
+ },
2741
+ {
2742
+ "name": "ariaLabel",
2743
+ "type": "string",
2744
+ "default": "''",
2745
+ "description": "Accessible name, used as the svg <title>.",
2746
+ "required": false
2747
+ },
2748
+ {
2749
+ "name": "description",
2750
+ "type": "string",
2751
+ "default": "''",
2752
+ "description": "Longer summary used as the svg <desc>. Falls back to `ariaLabel`.",
2753
+ "required": false
2754
+ },
2755
+ {
2756
+ "name": "margin",
2757
+ "type": "ChartMargin",
2758
+ "default": "{ top: 2, right: 2, bottom: 2, left: 2 }",
2759
+ "description": "Space reserved around the plot — a sparkline has no axis labels to fit.",
2760
+ "required": false
2761
+ },
2762
+ {
2763
+ "name": "class",
2764
+ "type": "string",
2765
+ "default": "''",
2766
+ "description": "Extra Tailwind classes merged onto the chart via `cn()` (last-wins).",
2767
+ "required": false
2768
+ }
2769
+ ],
2770
+ "outputs": [],
2771
+ "slots": [],
2772
+ "variants": []
2773
+ }
2774
+ ]
2775
+ },
2519
2776
  {
2520
2777
  "name": "checkbox",
2521
2778
  "title": "Checkbox",
@@ -8501,6 +8758,66 @@
8501
8758
  }
8502
8759
  ]
8503
8760
  },
8761
+ {
8762
+ "name": "progress-circle",
8763
+ "title": "Progress Circle",
8764
+ "description": "Progress Circle component.",
8765
+ "category": "feedback",
8766
+ "selector": "tolle-progress-circle",
8767
+ "isNew": true,
8768
+ "import": "import { ProgressCircleComponent } from '@tolle_/tolle-ui';",
8769
+ "install": "npx @tolle_/cli add progress-circle",
8770
+ "registryDependencies": [],
8771
+ "dependencies": [],
8772
+ "files": [
8773
+ "progress-circle.component.ts",
8774
+ "utils/cn.ts"
8775
+ ],
8776
+ "components": [
8777
+ {
8778
+ "className": "ProgressCircleComponent",
8779
+ "selector": "tolle-progress-circle",
8780
+ "kind": "component",
8781
+ "inputs": [
8782
+ {
8783
+ "name": "value",
8784
+ "type": "number | null",
8785
+ "default": "0",
8786
+ "description": "Percent complete, 0-100.",
8787
+ "required": false
8788
+ },
8789
+ {
8790
+ "name": "size",
8791
+ "type": "number",
8792
+ "default": "120",
8793
+ "description": "Diameter of the ring in px.",
8794
+ "required": false
8795
+ },
8796
+ {
8797
+ "name": "strokeWidth",
8798
+ "type": "number",
8799
+ "default": "8",
8800
+ "description": "Thickness of the ring in px.",
8801
+ "required": false
8802
+ },
8803
+ {
8804
+ "name": "class",
8805
+ "type": "string",
8806
+ "default": "''",
8807
+ "description": "Extra Tailwind classes merged onto the ring via `cn()` (last-wins).",
8808
+ "required": false
8809
+ }
8810
+ ],
8811
+ "outputs": [],
8812
+ "slots": [
8813
+ {
8814
+ "name": ""
8815
+ }
8816
+ ],
8817
+ "variants": []
8818
+ }
8819
+ ]
8820
+ },
8504
8821
  {
8505
8822
  "name": "prompt-input",
8506
8823
  "title": "Prompt Input",
@@ -12209,6 +12526,66 @@
12209
12526
  }
12210
12527
  ]
12211
12528
  },
12529
+ {
12530
+ "name": "tracker",
12531
+ "title": "Tracker",
12532
+ "description": "Tracker component.",
12533
+ "category": "components",
12534
+ "selector": "tolle-tracker",
12535
+ "isNew": true,
12536
+ "import": "import { TrackerComponent } from '@tolle_/tolle-ui';",
12537
+ "install": "npx @tolle_/cli add tracker",
12538
+ "registryDependencies": [
12539
+ "tooltip"
12540
+ ],
12541
+ "dependencies": [
12542
+ "@floating-ui/dom"
12543
+ ],
12544
+ "files": [
12545
+ "tracker.component.ts",
12546
+ "utils/cn.ts"
12547
+ ],
12548
+ "components": [
12549
+ {
12550
+ "className": "TrackerComponent",
12551
+ "selector": "tolle-tracker",
12552
+ "kind": "component",
12553
+ "inputs": [
12554
+ {
12555
+ "name": "data",
12556
+ "type": "TrackerBlock[]",
12557
+ "default": "[]",
12558
+ "description": "One block per period, in chronological order.",
12559
+ "required": false
12560
+ },
12561
+ {
12562
+ "name": "blockHeight",
12563
+ "type": "number",
12564
+ "default": "32",
12565
+ "description": "Height of each block in px.",
12566
+ "required": false
12567
+ },
12568
+ {
12569
+ "name": "ariaLabel",
12570
+ "type": "string",
12571
+ "default": "''",
12572
+ "description": "Accessible name for the whole strip.",
12573
+ "required": false
12574
+ },
12575
+ {
12576
+ "name": "class",
12577
+ "type": "string",
12578
+ "default": "''",
12579
+ "description": "Extra Tailwind classes merged onto the strip via `cn()` (last-wins).",
12580
+ "required": false
12581
+ }
12582
+ ],
12583
+ "outputs": [],
12584
+ "slots": [],
12585
+ "variants": []
12586
+ }
12587
+ ]
12588
+ },
12212
12589
  {
12213
12590
  "name": "typography",
12214
12591
  "title": "Typography",
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "bar-list",
3
+ "type": "registry:ui",
4
+ "title": "Bar List",
5
+ "description": "Bar List component.",
6
+ "category": "components",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "bar-list.component.ts",
12
+ "target": "ui/bar-list.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/** One resolved row: its label, its value, and its share of the list's largest value. */\nexport interface BarListRow {\n label: string;\n value: number;\n percent: number;\n formattedValue: string;\n}\n\n/**\n * A ranked list of horizontal bars — label left, value right, bar width\n * scaled to each row's share of the list's largest value. Unlike a chart\n * series or a category bar's segments, every bar is the same accent: rank is\n * carried by length, not by a categorical palette.\n * @new\n */\n@Component({\n selector: 'tolle-bar-list',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div\n *ngFor=\"let row of rows; trackBy: trackByIndex\"\n class=\"relative flex items-center overflow-hidden rounded-sm\"\n >\n <div class=\"absolute inset-y-0 left-0 rounded-sm bg-primary/15\" [style.width.%]=\"row.percent\"></div>\n <span class=\"relative z-10 truncate px-2 py-1.5 text-sm text-foreground\">{{ row.label }}</span>\n <span class=\"relative z-10 ml-auto shrink-0 px-2 py-1.5 text-sm font-medium tabular-nums text-foreground\">\n {{ row.formattedValue }}\n </span>\n </div>\n </div>\n `,\n})\nexport class BarListComponent 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 /** Rows to rank, in the order they should be drawn. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Row key holding each row's label. @default 'label' */\n @Input() labelKey = 'label';\n /** Row key holding each row's numeric value. @default 'value' */\n @Input() valueKey = 'value';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n get rows(): BarListRow[] {\n const rows = this.data ?? [];\n const values = rows.map((row) => {\n const num = Number(row?.[this.valueKey]);\n return Number.isFinite(num) ? num : 0;\n });\n const max = values.reduce((m, v) => Math.max(m, v), 0);\n\n return rows.map((row, i) => {\n const raw = row?.[this.labelKey];\n const value = values[i];\n return {\n label: raw == null ? '' : String(raw),\n value,\n percent: max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0,\n formattedValue: value.toLocaleString('en-US'),\n };\n });\n }\n\n get computedClass() {\n return cn('flex w-full flex-col gap-1', 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,21 @@
1
+ {
2
+ "name": "category-bar",
3
+ "type": "registry:ui",
4
+ "title": "Category Bar",
5
+ "description": "Category Bar component.",
6
+ "category": "components",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "category-bar.component.ts",
12
+ "target": "ui/category-bar.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/** One resolved segment: its share of the bar's width, its paint, and the numbers behind it. */\nexport interface CategoryBarSegment {\n label: string;\n value: number;\n percent: number;\n color: string;\n}\n\n/**\n * A single bar split into proportional, labelled ranges — for showing where\n * one value falls against a scale made of categories (a credit score inside\n * \"Poor / Fair / Good / Excellent\", latency inside \"Fast / OK / Slow\").\n *\n * Segments take the chart palette by position, since a category bar has no\n * stable per-render identity to key colour off the way a chart series does —\n * pass `colors` to override.\n * @new\n */\n@Component({\n selector: 'tolle-category-bar',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative flex w-full gap-0.5\">\n <div\n *ngFor=\"let segment of segments; let i = index; trackBy: trackByIndex\"\n class=\"h-2 first:rounded-l-full last:rounded-r-full\"\n [style.width.%]=\"segment.percent\"\n [style.background]=\"segment.color\"\n [attr.title]=\"segment.label || null\"\n ></div>\n\n <span\n *ngIf=\"clampedMarker !== null\"\n class=\"absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-foreground shadow\"\n [style.left.%]=\"clampedMarker\"\n ></span>\n </div>\n\n <p class=\"sr-only\">{{ summary }}</p>\n </div>\n `,\n})\nexport class CategoryBarComponent 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 /** Width of each segment. Values that are not finite and greater than zero are dropped. @default [] */\n @Input() values: number[] = [];\n /** Name for each segment, used in the accessible summary and as a title tooltip. @default [] */\n @Input() labels: string[] = [];\n /** Paint for each segment, by index. Falls back to the chart palette --chart-1 through --chart-5. @default [] */\n @Input() colors: string[] = [];\n /** Position of the pointer, 0-100 on the same scale the segments sum to. Omit to draw no pointer. @default null */\n @Input() markerValue: number | null = null;\n /** Accessible name, prefixed onto the sr-only summary of segments and marker. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the bar via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n private get total(): number {\n return this.values.reduce((sum, v) => (Number.isFinite(v) && v > 0 ? sum + v : sum), 0);\n }\n\n get segments(): CategoryBarSegment[] {\n const total = this.total;\n if (total <= 0) return [];\n\n return this.values\n .map((value, i) => ({ value, i }))\n .filter(({ value }) => Number.isFinite(value) && value > 0)\n .map(({ value, i }) => ({\n label: this.labels[i] ?? '',\n value,\n percent: (value / total) * 100,\n color: this.colors[i] || 'rgb(var(--chart-' + ((i % 5) + 1) + '))',\n }));\n }\n\n get clampedMarker(): number | null {\n if (this.markerValue == null || !Number.isFinite(this.markerValue)) return null;\n return Math.min(100, Math.max(0, this.markerValue));\n }\n\n get summary(): string {\n const parts = this.segments.map((s) => (s.label ? s.label + ': ' : '') + s.percent.toFixed(0) + '%');\n const marker = this.clampedMarker !== null ? ', current position ' + this.clampedMarker.toFixed(0) + '%' : '';\n const prefix = this.ariaLabel ? this.ariaLabel + '. ' : '';\n return prefix + parts.join(', ') + marker;\n }\n\n get computedClass() {\n return cn('w-full', 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
+ }
@@ -19,7 +19,7 @@
19
19
  {
20
20
  "path": "chart.service.ts",
21
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/** 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"
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
23
  },
24
24
  {
25
25
  "path": "utils/cn.ts",