@tolle_/tolle-ui 21.1.1 → 21.2.0

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.
Files changed (128) hide show
  1. package/fesm2022/tolle-ui.mjs +30099 -5285
  2. package/fesm2022/tolle-ui.mjs.map +1 -1
  3. package/package.json +17 -5
  4. package/preset.js +35 -1
  5. package/registry/docs-content.json +11486 -0
  6. package/registry/llms-full.txt +4067 -0
  7. package/registry/llms.txt +127 -0
  8. package/registry/manifest.json +12656 -0
  9. package/registry/r/accordion-item.json +21 -0
  10. package/registry/r/accordion.json +23 -0
  11. package/registry/r/alert-dialog-dynamic.json +31 -0
  12. package/registry/r/alert-dialog.json +26 -0
  13. package/registry/r/alert.json +23 -0
  14. package/registry/r/aspect-ratio.json +21 -0
  15. package/registry/r/attachment.json +23 -0
  16. package/registry/r/avatar-fallback.json +16 -0
  17. package/registry/r/avatar.json +23 -0
  18. package/registry/r/badge.json +23 -0
  19. package/registry/r/bar-list.json +21 -0
  20. package/registry/r/breadcrumb-item.json +16 -0
  21. package/registry/r/breadcrumb-link.json +16 -0
  22. package/registry/r/breadcrumb-separator.json +16 -0
  23. package/registry/r/breadcrumb.json +21 -0
  24. package/registry/r/bubble.json +23 -0
  25. package/registry/r/button-group.json +21 -0
  26. package/registry/r/button.json +23 -0
  27. package/registry/r/calendar.json +23 -0
  28. package/registry/r/card.json +21 -0
  29. package/registry/r/carousel.json +18 -0
  30. package/registry/r/category-bar.json +21 -0
  31. package/registry/r/chain-of-thought.json +23 -0
  32. package/registry/r/chart-pie.json +30 -0
  33. package/registry/r/chart-spark.json +30 -0
  34. package/registry/r/chart.json +28 -0
  35. package/registry/r/checkbox.json +23 -0
  36. package/registry/r/checkpoint.json +23 -0
  37. package/registry/r/collapsible.json +21 -0
  38. package/registry/r/combobox.json +31 -0
  39. package/registry/r/command-dialog.json +21 -0
  40. package/registry/r/command.json +26 -0
  41. package/registry/r/confirmation.json +23 -0
  42. package/registry/r/context-menu-trigger.json +23 -0
  43. package/registry/r/context-menu.json +28 -0
  44. package/registry/r/context.json +26 -0
  45. package/registry/r/conversation.json +23 -0
  46. package/registry/r/country-selector.json +31 -0
  47. package/registry/r/data-table.json +38 -0
  48. package/registry/r/date-picker.json +27 -0
  49. package/registry/r/date-range-picker.json +32 -0
  50. package/registry/r/date-time-picker.json +28 -0
  51. package/registry/r/direction.json +16 -0
  52. package/registry/r/dropdown-item.json +21 -0
  53. package/registry/r/dropdown-label.json +16 -0
  54. package/registry/r/dropdown-menu.json +16 -0
  55. package/registry/r/dropdown-separator.json +16 -0
  56. package/registry/r/dropdown-trigger.json +20 -0
  57. package/registry/r/empty-state.json +23 -0
  58. package/registry/r/field.json +23 -0
  59. package/registry/r/hover-card.json +23 -0
  60. package/registry/r/inline-citation.json +26 -0
  61. package/registry/r/input-group.json +23 -0
  62. package/registry/r/input.json +21 -0
  63. package/registry/r/item.json +23 -0
  64. package/registry/r/kbd.json +23 -0
  65. package/registry/r/label.json +21 -0
  66. package/registry/r/marker.json +23 -0
  67. package/registry/r/masked-input.json +21 -0
  68. package/registry/r/menubar.json +24 -0
  69. package/registry/r/message-scroller.json +26 -0
  70. package/registry/r/message.json +23 -0
  71. package/registry/r/modal.json +36 -0
  72. package/registry/r/model-selector.json +32 -0
  73. package/registry/r/multi-select.json +33 -0
  74. package/registry/r/native-select.json +23 -0
  75. package/registry/r/navigation-menu.json +24 -0
  76. package/registry/r/otp-group.json +16 -0
  77. package/registry/r/otp-slot.json +21 -0
  78. package/registry/r/otp.json +21 -0
  79. package/registry/r/pagination.json +33 -0
  80. package/registry/r/phone-number-input.json +34 -0
  81. package/registry/r/plan.json +23 -0
  82. package/registry/r/popover-content.json +21 -0
  83. package/registry/r/popover.json +18 -0
  84. package/registry/r/progress-circle.json +21 -0
  85. package/registry/r/progress.json +21 -0
  86. package/registry/r/prompt-input.json +23 -0
  87. package/registry/r/queue.json +23 -0
  88. package/registry/r/radio-group.json +26 -0
  89. package/registry/r/radio-item.json +26 -0
  90. package/registry/r/range-calendar.json +28 -0
  91. package/registry/r/reasoning.json +23 -0
  92. package/registry/r/resizable-panel-item.json +23 -0
  93. package/registry/r/resizable-panel.json +23 -0
  94. package/registry/r/resizable.json +21 -0
  95. package/registry/r/scroll-area.json +21 -0
  96. package/registry/r/segment.json +21 -0
  97. package/registry/r/select-group.json +21 -0
  98. package/registry/r/select-item.json +26 -0
  99. package/registry/r/select-separator.json +21 -0
  100. package/registry/r/select.json +32 -0
  101. package/registry/r/separator.json +21 -0
  102. package/registry/r/sheet-wrapper.json +31 -0
  103. package/registry/r/sheet.json +21 -0
  104. package/registry/r/shimmer.json +23 -0
  105. package/registry/r/sidebar.json +21 -0
  106. package/registry/r/skeleton.json +23 -0
  107. package/registry/r/slider.json +21 -0
  108. package/registry/r/sources.json +23 -0
  109. package/registry/r/spinner.json +23 -0
  110. package/registry/r/suggestion.json +23 -0
  111. package/registry/r/switch.json +23 -0
  112. package/registry/r/table.json +21 -0
  113. package/registry/r/tabs.json +23 -0
  114. package/registry/r/tag-input.json +25 -0
  115. package/registry/r/task.json +23 -0
  116. package/registry/r/textarea.json +21 -0
  117. package/registry/r/time-picker.json +24 -0
  118. package/registry/r/toaster.json +26 -0
  119. package/registry/r/toggle-group.json +25 -0
  120. package/registry/r/toggle.json +23 -0
  121. package/registry/r/tolle-cell.json +16 -0
  122. package/registry/r/tool.json +23 -0
  123. package/registry/r/tooltip.json +23 -0
  124. package/registry/r/tracker.json +25 -0
  125. package/registry/r/typography.json +23 -0
  126. package/registry/registry.json +2545 -0
  127. package/theme.css +230 -10
  128. package/types/tolle-ui.d.ts +7433 -823
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "calendar",
3
+ "type": "registry:ui",
4
+ "title": "Calendar",
5
+ "description": "Calendar component.",
6
+ "category": "date-time",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "date-fns"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "calendar.component.ts",
14
+ "target": "ui/calendar.component.ts",
15
+ "content": "import {\n Component,\n Input,\n OnInit,\n forwardRef,\n Output,\n EventEmitter,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport {\n addDays,\n addMonths,\n subMonths,\n startOfMonth,\n endOfMonth,\n startOfWeek,\n endOfWeek,\n eachDayOfInterval,\n isSameMonth,\n isSameDay,\n isToday,\n setMonth,\n setYear,\n addYears,\n subYears,\n isBefore,\n startOfDay,\n format,\n} from 'date-fns';\nimport { cn } from './utils/cn';\n\nexport type CalendarMode = 'date' | 'month' | 'year';\n\n@Component({\n selector: 'tolle-calendar',\n styles: [':host { display: block; }'],\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => CalendarComponent),\n multi: true,\n },\n ],\n template: `\n <div\n [class]=\"\n cn(\n 'inline-block min-w-fit text-popover-foreground',\n bordered ? 'rounded-md border bg-background p-3 shadow-sm' : '',\n class\n )\n \">\n <!-- Header with Navigation -->\n <div class=\"flex items-center justify-between gap-2 pb-4 pt-1\">\n <!-- View Selector -->\n <div class=\"flex items-center gap-1\">\n @if (mode !== 'year') {\n <button\n type=\"button\"\n (click)=\"setView('month')\"\n [class]=\"\n cn(\n 'rounded px-2 py-1 text-sm font-semibold transition-colors',\n currentView === 'month'\n ? 'bg-secondary text-secondary-foreground'\n : 'hover:bg-accent hover:text-accent-foreground'\n )\n \">\n {{ formatMonthYear(viewDate, 'month') }}\n </button>\n }\n\n <button\n type=\"button\"\n (click)=\"setView('year')\"\n [class]=\"\n cn(\n 'rounded px-2 py-1 text-sm font-semibold transition-colors',\n currentView === 'year'\n ? 'bg-secondary text-secondary-foreground'\n : 'hover:bg-accent hover:text-accent-foreground'\n )\n \">\n {{ formatMonthYear(viewDate, 'year') }}\n </button>\n </div>\n\n <!-- Navigation Buttons -->\n <div class=\"flex items-center space-x-1\">\n <button type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n <button type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n </div>\n </div>\n\n <!-- DATE MODE -->\n @if (currentView === 'date' && mode === 'date') {\n <div class=\"space-y-2 duration-200 animate-in fade-in zoom-in-95\">\n <div class=\"grid w-full grid-cols-7 gap-1\">\n @for (day of weekDays; track day) {\n <span class=\"w-9 text-center text-[0.8rem] font-normal text-muted-foreground\">\n {{ day }}\n </span>\n }\n </div>\n <div role=\"grid\" class=\"grid w-full grid-cols-7 gap-1\" (keydown)=\"onGridKeydown($event)\">\n @for (date of daysInMonth; track date) {\n <button\n type=\"button\"\n role=\"gridcell\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"date | date: 'fullDate'\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date)\">\n {{ formatDate(date, 'day') }}\n </button>\n }\n </div>\n </div>\n }\n\n <!-- MONTH SELECTOR (for date mode and month mode) -->\n @if (currentView === 'month') {\n <div class=\"grid w-64 grid-cols-3 gap-2 duration-200 animate-in fade-in zoom-in-95\">\n @for (month of months; track month; let i = $index) {\n <button type=\"button\" (click)=\"selectMonth(i)\" [class]=\"getMonthClass(i)\">\n {{ month }}\n </button>\n }\n </div>\n }\n\n <!-- YEAR SELECTOR (for date mode and year mode) -->\n @if (currentView === 'year') {\n <div class=\"grid w-64 grid-cols-4 gap-2 duration-200 animate-in fade-in zoom-in-95\">\n @for (year of years; track year) {\n <button type=\"button\" (click)=\"selectYear(year)\" [class]=\"getYearClass(year)\">\n {{ year }}\n </button>\n }\n </div>\n }\n\n <!-- Quick Actions -->\n @if (showQuickActions) {\n <div class=\"mt-3 border-t pt-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <button\n type=\"button\"\n (click)=\"selectToday()\"\n [class]=\"quickActionBtnClass\"\n [disabled]=\"isTodayDisabled()\">\n Today\n </button>\n <button type=\"button\" (click)=\"clear()\" [class]=\"quickActionBtnClass\">Clear</button>\n </div>\n </div>\n }\n </div>\n `,\n})\nexport class CalendarComponent implements OnInit, ControlValueAccessor {\n @Input() class = '';\n /** Renders the calendar's own border, background and shadow. Set `false` when\n * nesting inside a card/popover that already provides the chrome. @default true */\n @Input() bordered = true;\n @Input() mode: CalendarMode = 'date';\n @Input() disablePastDates = false;\n @Input() showQuickActions = true;\n @Input() minDate?: Date;\n @Input() maxDate?: Date;\n @Input() formatMonthFn?: (date: Date) => string;\n @Input() formatYearFn?: (date: Date) => string;\n @Input() formatDateFn?: (date: Date) => string;\n\n @Output() dateSelect = new EventEmitter<Date | null>();\n\n currentView: 'date' | 'month' | 'year' = 'date';\n viewDate: Date = new Date();\n selectedDate: Date | null = null;\n\n weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n daysInMonth: Date[] = [];\n months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n years: number[] = [];\n yearRangeStart: number;\n\n navBtnClass = cn(\n 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 border border-input rounded-md flex items-center justify-center hover:bg-accent hover:text-accent-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n quickActionBtnClass = cn(\n 'px-3 py-1.5 text-sm rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors'\n );\n\n onTouched: () => void = () => {};\n onChange: (value: any) => void = () => {};\n\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef) {\n this.yearRangeStart = new Date().getFullYear() - 6;\n }\n\n ngOnInit() {\n // Initialize based on mode\n if (this.mode === 'month') {\n this.currentView = 'month';\n } else if (this.mode === 'year') {\n this.currentView = 'year';\n }\n\n this.generateDays();\n this.generateYears();\n }\n\n // Format helpers\n formatMonthYear(date: Date, type: 'month' | 'year'): string {\n if (type === 'month' && this.formatMonthFn) {\n return this.formatMonthFn(date);\n }\n if (type === 'year' && this.formatYearFn) {\n return this.formatYearFn(date);\n }\n return type === 'month' ? format(date, 'MMMM') : format(date, 'yyyy');\n }\n\n formatDate(date: Date, type: 'day' | 'month' | 'year'): string {\n if (type === 'day' && this.formatDateFn) {\n return this.formatDateFn(date);\n }\n return format(date, type === 'day' ? 'd' : type === 'month' ? 'MMM' : 'yyyy');\n }\n\n generateDays() {\n if (this.mode !== 'date') return;\n\n const start = startOfWeek(startOfMonth(this.viewDate));\n const end = endOfWeek(endOfMonth(this.viewDate));\n this.daysInMonth = eachDayOfInterval({ start, end });\n }\n\n generateYears() {\n const currentYear = this.viewDate.getFullYear();\n\n if (this.mode === 'year') {\n // For year picker, show a 12-year grid\n this.years = Array.from({ length: 12 }, (_, i) => this.yearRangeStart + i);\n } else {\n // For date mode year selector, show 16 years centered on current\n this.years = Array.from({ length: 16 }, (_, i) => currentYear - 6 + i);\n }\n }\n\n setView(view: 'date' | 'month' | 'year') {\n this.currentView = view;\n if (view === 'year') {\n this.generateYears();\n }\n }\n\n prev() {\n if (this.mode === 'date') {\n if (this.currentView === 'date') {\n this.viewDate = subMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = subYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n }\n } else if (this.mode === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n } else if (this.mode === 'year') {\n this.yearRangeStart -= 12;\n this.generateYears();\n } else if (this.mode === 'month-year') {\n if (this.currentView === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n } else {\n this.yearRangeStart -= 12;\n this.generateYears();\n }\n }\n }\n\n next() {\n if (this.mode === 'date') {\n if (this.currentView === 'date') {\n this.viewDate = addMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = addYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n }\n } else if (this.mode === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n } else if (this.mode === 'year') {\n this.yearRangeStart += 12;\n this.generateYears();\n } else if (this.mode === 'month-year') {\n if (this.currentView === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n } else {\n this.yearRangeStart += 12;\n this.generateYears();\n }\n }\n }\n\n prevYears() {\n this.yearRangeStart -= 12;\n this.generateYears();\n }\n\n nextYears() {\n this.yearRangeStart += 12;\n this.generateYears();\n }\n\n selectDate(date: Date) {\n if (this.isDateDisabled(date)) return;\n\n this.selectedDate = date;\n this.onChange(date);\n this.onTouched();\n this.dateSelect.emit(date);\n }\n\n /**\n * Grid keyboard navigation for the day view (react-day-picker semantics).\n * Arrows move by day/week, Home/End to week edges, PageUp/PageDown by month.\n * Enter/Space are left to the native day <button> (which selects on click and\n * does not scroll the page).\n */\n onGridKeydown(event: KeyboardEvent) {\n if (this.currentView !== 'date' || this.mode !== 'date') return;\n\n const gridEl = event.currentTarget as HTMLElement;\n const buttons = Array.from(gridEl.querySelectorAll('button')) as HTMLElement[];\n const idx = buttons.indexOf(event.target as HTMLElement);\n if (idx < 0) return;\n\n const current = this.daysInMonth[idx];\n if (!current) return;\n\n let next: Date | null = null;\n switch (event.key) {\n case 'ArrowLeft':\n next = addDays(current, -1);\n break;\n case 'ArrowRight':\n next = addDays(current, 1);\n break;\n case 'ArrowUp':\n next = addDays(current, -7);\n break;\n case 'ArrowDown':\n next = addDays(current, 7);\n break;\n case 'Home':\n next = startOfWeek(current);\n break;\n case 'End':\n next = endOfWeek(current);\n break;\n case 'PageUp':\n next = subMonths(current, 1);\n break;\n case 'PageDown':\n next = addMonths(current, 1);\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.focusDay(next, gridEl);\n }\n\n /**\n * Moves focus to the day button matching `date`. If the day is not present in\n * the current grid, the view advances/retreats to its month, regenerates and\n * then focuses it.\n */\n private focusDay(date: Date, gridEl: HTMLElement) {\n let idx = this.daysInMonth.findIndex(d => isSameDay(d, date));\n if (idx < 0) {\n this.viewDate = startOfMonth(date);\n this.generateDays();\n this.cdr.detectChanges(); // re-render the grid synchronously\n idx = this.daysInMonth.findIndex(d => isSameDay(d, date));\n }\n if (idx < 0) return;\n\n const buttons = Array.from(gridEl.querySelectorAll('button')) as HTMLElement[];\n buttons[idx]?.focus();\n }\n\n selectMonth(monthIndex: number) {\n if (this.mode === 'date') {\n this.viewDate = setMonth(this.viewDate, monthIndex);\n this.currentView = 'date';\n this.generateDays();\n } else if (this.mode === 'month') {\n this.viewDate = setMonth(this.viewDate, monthIndex);\n this.selectedDate = this.viewDate;\n this.onChange(this.viewDate);\n this.onTouched();\n this.dateSelect.emit(this.viewDate);\n }\n }\n\n selectYear(year: number) {\n if (this.mode === 'date') {\n this.viewDate = setYear(this.viewDate, year);\n this.currentView = 'date';\n this.generateDays();\n } else if (this.mode === 'year' || this.mode === 'month') {\n this.viewDate = setYear(this.viewDate, year);\n this.selectedDate = this.viewDate;\n this.onChange(this.viewDate);\n this.onTouched();\n this.dateSelect.emit(this.viewDate);\n }\n }\n\n isSelected(date: Date): boolean {\n return !!(this.selectedDate && isSameDay(date, this.selectedDate));\n }\n\n getDayClass(date: Date) {\n const isSelected = this.selectedDate && isSameDay(date, this.selectedDate);\n const isTodayDate = isToday(date);\n const isOutside = !isSameMonth(date, this.viewDate);\n const isDisabled = this.isDateDisabled(date);\n\n return cn(\n 'h-9 w-9 p-0 font-normal text-sm rounded-md transition-all flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n !isSelected && !isDisabled && 'hover:bg-accent hover:text-accent-foreground',\n isSelected &&\n 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n !isSelected && isTodayDate && 'bg-accent text-accent-foreground',\n (isOutside || isDisabled) && 'text-muted-foreground opacity-50',\n isDisabled && 'cursor-not-allowed'\n );\n }\n\n getMonthClass(monthIndex: number) {\n const isSelected =\n this.selectedDate &&\n this.selectedDate.getMonth() === monthIndex &&\n this.selectedDate.getFullYear() === this.viewDate.getFullYear();\n const isCurrent =\n new Date().getMonth() === monthIndex &&\n new Date().getFullYear() === this.viewDate.getFullYear();\n\n return cn(\n 'text-sm py-2.5 rounded-md transition-colors',\n isSelected\n ? 'bg-primary text-primary-foreground hover:bg-primary'\n : isCurrent\n ? 'border border-primary/30 text-primary'\n : 'hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n getYearClass(year: number) {\n const isSelected = this.selectedDate && this.selectedDate.getFullYear() === year;\n const isCurrent = new Date().getFullYear() === year;\n\n return cn(\n 'text-sm py-2 rounded-md transition-colors',\n isSelected\n ? 'bg-primary text-primary-foreground hover:bg-primary'\n : isCurrent\n ? 'border border-primary/30 text-primary'\n : 'hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n isDateDisabled(date: Date): boolean {\n if (this.disablePastDates && isBefore(date, startOfDay(new Date()))) {\n return true;\n }\n if (this.minDate && isBefore(date, this.minDate)) {\n return true;\n }\n return !!(this.maxDate && isBefore(this.maxDate, date));\n }\n\n isTodayDisabled(): boolean {\n return this.isDateDisabled(new Date());\n }\n\n selectToday() {\n if (!this.isTodayDisabled()) {\n this.selectDate(new Date());\n }\n }\n\n clear() {\n this.selectedDate = null;\n this.onChange(null);\n this.onTouched();\n this.dateSelect.emit(null);\n }\n\n // CVA Implementation\n writeValue(obj: any): void {\n if (obj) {\n const date = new Date(obj);\n if (!isNaN(date.getTime())) {\n this.selectedDate = date;\n this.viewDate = date;\n this.generateDays();\n this.generateYears();\n }\n }\n }\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "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"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "card",
3
+ "type": "registry:ui",
4
+ "title": "Card",
5
+ "description": "Card component.",
6
+ "category": "layout",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "card.component.ts",
12
+ "target": "ui/card.component.ts",
13
+ "content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-card',\n styles: [':host { display: block; }'],\n imports: [],\n // Card owns the vertical rhythm: `flex-col` + `gap-6` spaces the sections and\n // `py-6` gives top/bottom padding. Sub-sections only add horizontal padding\n // (`px-6`). This means a card renders correctly with ANY combination of\n // header / content / footer — including content-only — with no `pt-0` hacks.\n template: `\n <div\n [class]=\"\n cn(\n 'flex flex-col gap-6 rounded-lg border border-border bg-card py-6 text-card-foreground shadow-sm',\n class\n )\n \">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CardComponent {\n @Input() class = '';\n protected cn = cn;\n}\n\n@Component({\n selector: 'tolle-card-header',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `<div [class]=\"cn('flex flex-col gap-1.5 px-6', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class CardHeaderComponent {\n @Input() class = '';\n protected cn = cn;\n}\n\n@Component({\n selector: 'tolle-card-title',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `<h3 [class]=\"cn('font-semibold leading-none tracking-tight', class)\">\n <ng-content></ng-content>\n </h3>`,\n})\nexport class CardTitleComponent {\n @Input() class = '';\n protected cn = cn;\n}\n\n@Component({\n selector: 'tolle-card-description',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `<p [class]=\"cn('text-sm text-muted-foreground', class)\"><ng-content></ng-content></p>`,\n})\nexport class CardDescriptionComponent {\n @Input() class = '';\n protected cn = cn;\n}\n\n@Component({\n selector: 'tolle-card-content',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `<div [class]=\"cn('px-6', class)\"><ng-content></ng-content></div>`,\n})\nexport class CardContentComponent {\n @Input() class = '';\n protected cn = cn;\n}\n\n@Component({\n selector: 'tolle-card-footer',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `<div [class]=\"cn('flex items-center px-6', class)\"><ng-content></ng-content></div>`,\n})\nexport class CardFooterComponent {\n @Input() class = '';\n protected cn = cn;\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,18 @@
1
+ {
2
+ "name": "carousel",
3
+ "type": "registry:ui",
4
+ "title": "Carousel",
5
+ "description": "Carousel component.",
6
+ "category": "media",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "embla-carousel"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "carousel/carousel.component.ts",
14
+ "target": "ui/carousel/carousel.component.ts",
15
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n ViewChild,\n AfterViewInit,\n OnDestroy,\n OnInit,\n OnChanges,\n ContentChild,\n forwardRef,\n Injectable,\n Directive,\n HostListener,\n inject,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport EmblaCarousel, {\n EmblaCarouselType,\n EmblaOptionsType,\n EmblaPluginType,\n} from 'embla-carousel';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class CarouselContext {\n api$ = new Subject<EmblaCarouselType>();\n api?: EmblaCarouselType;\n orientation: 'horizontal' | 'vertical' = 'horizontal';\n canScrollPrev = false;\n canScrollNext = false;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n setApi(api: EmblaCarouselType) {\n this.api = api;\n this.api$.next(api);\n this.updateState();\n\n api.on('select', () => this.updateState());\n api.on('reInit', () => this.updateState());\n }\n\n updateState() {\n if (!this.api) return;\n this.canScrollPrev = this.api.canScrollPrev();\n this.canScrollNext = this.api.canScrollNext();\n this.cdr.detectChanges();\n }\n\n scrollPrev() {\n this.api?.scrollPrev();\n }\n\n scrollNext() {\n this.api?.scrollNext();\n }\n}\n\n@Component({\n selector: 'tolle-carousel',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [CarouselContext],\n template: `\n <div\n class=\"relative\"\n role=\"region\"\n aria-roledescription=\"carousel\"\n tabindex=\"0\"\n (keydown)=\"onKeydown($event)\"\n [class.flex-col]=\"orientation === 'vertical'\">\n <ng-content></ng-content>\n </div>\n `,\n host: {\n '[class.flex]': 'orientation === \"vertical\"',\n },\n})\nexport class CarouselComponent implements AfterViewInit, OnDestroy {\n @Input() opts: EmblaOptionsType = {};\n @Input() plugins: EmblaPluginType[] = [];\n @Input() orientation: 'horizontal' | 'vertical' = 'horizontal';\n\n @Output() api = new EventEmitter<EmblaCarouselType>();\n\n private carouselContext = inject(CarouselContext);\n private emblaApi?: EmblaCarouselType;\n\n @ViewChild('container', { static: false }) containerRef?: ElementRef;\n\n constructor() {}\n\n ngOnInit() {\n this.carouselContext.orientation = this.orientation;\n }\n\n ngOnChanges() {\n this.carouselContext.orientation = this.orientation;\n }\n\n ngAfterViewInit() {\n // We expect tolleCarouselContent to provide the viewport element\n }\n\n onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowLeft') {\n event.preventDefault();\n this.carouselContext.scrollPrev();\n } else if (event.key === 'ArrowRight') {\n event.preventDefault();\n this.carouselContext.scrollNext();\n }\n }\n\n initApi(viewport: HTMLElement) {\n this.emblaApi = EmblaCarousel(\n viewport,\n {\n ...this.opts,\n axis: this.orientation === 'horizontal' ? 'x' : 'y',\n },\n this.plugins\n );\n\n this.carouselContext.setApi(this.emblaApi);\n this.api.emit(this.emblaApi);\n }\n\n ngOnDestroy() {\n this.emblaApi?.destroy();\n }\n}\n\n@Directive({\n selector: '[tolleCarouselContent]',\n standalone: true,\n host: {\n class: 'overflow-hidden',\n },\n})\nexport class CarouselContentDirective implements AfterViewInit {\n private carousel = inject(CarouselComponent);\n private el = inject(ElementRef);\n\n ngAfterViewInit() {\n this.carousel.initApi(this.el.nativeElement);\n }\n}\n\n@Directive({\n selector: '[tolleCarouselContainer]',\n standalone: true,\n host: {\n '[class]':\n 'carouselContext.orientation === \"horizontal\" ? \"flex -ml-4\" : \"flex flex-col -mt-4\"',\n },\n})\nexport class CarouselContainerDirective {\n carouselContext = inject(CarouselContext);\n}\n\n@Directive({\n selector: '[tolleCarouselItem]',\n standalone: true,\n host: {\n role: 'group',\n 'aria-roledescription': 'slide',\n '[class]':\n 'carouselContext.orientation === \"horizontal\" ? \"min-w-0 shrink-0 grow-0 basis-full pl-4\" : \"min-w-0 shrink-0 grow-0 basis-full pt-4\"',\n },\n})\nexport class CarouselItemDirective {\n carouselContext = inject(CarouselContext);\n}\n\n@Directive({\n selector: '[tolleCarouselPrevious]',\n standalone: true,\n host: {\n 'aria-label': 'Previous slide',\n '[attr.disabled]': '!carouselContext.canScrollPrev ? true : null',\n '(click)': 'carouselContext.scrollPrev()',\n },\n})\nexport class CarouselPreviousDirective {\n carouselContext = inject(CarouselContext);\n}\n\n@Directive({\n selector: '[tolleCarouselNext]',\n standalone: true,\n host: {\n 'aria-label': 'Next slide',\n '[attr.disabled]': '!carouselContext.canScrollNext ? true : null',\n '(click)': 'carouselContext.scrollNext()',\n },\n})\nexport class CarouselNextDirective {\n carouselContext = inject(CarouselContext);\n}\n"
16
+ }
17
+ ]
18
+ }
@@ -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 {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\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 imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative flex w-full gap-0.5\">\n @for (segment of segments; track $index; let i = $index) {\n <div\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\"></div>\n }\n\n @if (clampedMarker !== null) {\n <span\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\"></span>\n }\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\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(\n s => (s.label ? s.label + ': ' : '') + s.percent.toFixed(0) + '%'\n );\n const marker =\n this.clampedMarker !== null\n ? ', current position ' + this.clampedMarker.toFixed(0) + '%'\n : '';\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
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "chain-of-thought",
3
+ "type": "registry:ui",
4
+ "title": "Chain Of Thought",
5
+ "description": "Chain Of Thought component with size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "chain-of-thought.component.ts",
14
+ "target": "ui/chain-of-thought.component.ts",
15
+ "content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n Output,\n inject,\n} from '@angular/core';\n\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/** Progress of a single reasoning step. */\nexport type ChainOfThoughtStepStatus = 'pending' | 'active' | 'complete';\n\nconst chainOfThoughtVariants = cva('w-full text-sm', {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\nexport type ChainOfThoughtProps = VariantProps<typeof chainOfThoughtVariants>;\n\nconst chainOfThoughtStepVariants = cva(\n // The rail is hidden on the final step so the line stops at the last marker\n // instead of trailing off the bottom of the list.\n 'relative flex gap-3 pb-4 last:pb-0 [&:last-child_.tolle-cot-rail]:hidden',\n {\n variants: {\n status: {\n pending: 'text-muted-foreground',\n active: 'text-foreground',\n complete: 'text-muted-foreground',\n },\n },\n defaultVariants: { status: 'complete' },\n }\n);\n\nconst chainOfThoughtMarkerVariants = cva(\n 'relative z-[1] flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[11px]',\n {\n variants: {\n status: {\n pending: 'border-border bg-background text-muted-foreground',\n active: 'border-info bg-info/10 text-info',\n complete: 'border-success/50 bg-success/10 text-success',\n },\n },\n defaultVariants: { status: 'complete' },\n }\n);\n\nexport type ChainOfThoughtStepProps = VariantProps<typeof chainOfThoughtStepVariants>;\n\n/**\n * Coordinates a `tolle-chain-of-thought` and its header/content children.\n *\n * The children are `OnPush` and receive none of this state as inputs, so it\n * travels as observables and each subscriber calls `markForCheck()`.\n */\n@Injectable()\nexport class ChainOfThoughtService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the reasoning trace is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n get open(): boolean {\n return this.openSource.value;\n }\n\n setOpen(open: boolean): void {\n if (this.openSource.value !== open) this.openSource.next(open);\n }\n\n toggle(): void {\n this.openSource.next(!this.openSource.value);\n }\n}\n\n/**\n * A collapsible reasoning trace — the numbered steps an agent worked through\n * before answering, connected by a vertical rail.\n *\n * ```html\n * <tolle-chain-of-thought [open]=\"true\">\n * <tolle-chain-of-thought-header label=\"Thought for 4s\"></tolle-chain-of-thought-header>\n * <tolle-chain-of-thought-content>\n * <tolle-chain-of-thought-step label=\"Searched the docs\" status=\"complete\" icon=\"ri-search-line\">\n * <tolle-chain-of-thought-search-results>\n * <tolle-chain-of-thought-search-result>tailwind.config</tolle-chain-of-thought-search-result>\n * </tolle-chain-of-thought-search-results>\n * </tolle-chain-of-thought-step>\n * </tolle-chain-of-thought-content>\n * </tolle-chain-of-thought>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-chain-of-thought',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [ChainOfThoughtService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class ChainOfThoughtComponent implements OnInit, OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Whether the reasoning steps are expanded. @default false */\n @Input() open = false;\n /** Text scale of the whole trace. @default 'default' */\n @Input() size: ChainOfThoughtProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trace via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new expanded state whenever the trace opens or closes. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ChainOfThoughtService);\n private readonly sub = new Subscription();\n\n ngOnInit(): void {\n this.service.setOpen(this.open);\n // skip(1) drops the replayed initial value so no `openChange` fires on init.\n this.sub.add(this.service.open$.pipe(skip(1)).subscribe(open => this.openChange.emit(open)));\n }\n\n ngOnChanges(): void {\n this.service.setOpen(this.open);\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 ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn(chainOfThoughtVariants({ size: this.size }), this.class);\n }\n}\n\n/** Clickable header that expands and collapses the reasoning steps. */\n@Component({\n selector: 'tolle-chain-of-thought-header',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"toggle()\">\n <i class=\"ri-brain-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <span class=\"truncate\">{{ label }}</span>\n <ng-content></ng-content>\n <i\n [class]=\"isOpen ? 'ri-arrow-down-s-line shrink-0' : 'ri-arrow-right-s-line shrink-0'\"\n aria-hidden=\"true\"></i>\n </button>\n `,\n})\nexport class ChainOfThoughtHeaderComponent 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 the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Text shown in the header. @default 'Chain of Thought' */\n @Input() label = 'Chain of Thought';\n /** Extra Tailwind classes merged onto the header via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ChainOfThoughtService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.open$.subscribe(open => {\n this.isOpen = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n 'flex w-full items-center gap-2 rounded-md py-1 text-sm font-medium text-muted-foreground outline-none transition-colors',\n 'hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n toggle(): void {\n this.service.toggle();\n }\n}\n\n/** Holds the steps; rendered only while the trace is expanded. */\n@Component({\n selector: 'tolle-chain-of-thought-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (isOpen) {\n <div data-state=\"open\" [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class ChainOfThoughtContentComponent 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 the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ChainOfThoughtService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.open$.subscribe(open => {\n this.isOpen = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn('mt-2 flex flex-col', this.class);\n }\n}\n\n/** One reasoning step: a marker on the rail, a label, and any projected detail. */\n@Component({\n selector: 'tolle-chain-of-thought-step',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { '[class]': 'computedClass', '[attr.data-status]': 'status' },\n template: `\n <span\n class=\"tolle-cot-rail absolute bottom-0 left-3 top-6 w-px -translate-x-1/2 bg-border\"\n aria-hidden=\"true\"></span>\n <span [class]=\"markerClass\">\n <i [class]=\"icon\" aria-hidden=\"true\"></i>\n </span>\n <div class=\"min-w-0 flex-1 pt-0.5\">\n <p [class]=\"labelClass\">{{ label }}</p>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ChainOfThoughtStepComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\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 /** Text describing what happened in this step. @default '' */\n @Input() label = '';\n /** Progress of this step; drives the marker colour. @default 'complete' */\n @Input() status: ChainOfThoughtStepStatus = 'complete';\n /** Remixicon class shown inside the step marker. @default 'ri-checkbox-blank-circle-fill' */\n @Input() icon = 'ri-checkbox-blank-circle-fill';\n /** Extra Tailwind classes merged onto the step via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(chainOfThoughtStepVariants({ status: this.status }), this.class);\n }\n\n get markerClass() {\n return cn(chainOfThoughtMarkerVariants({ status: this.status }));\n }\n\n get labelClass() {\n return cn(\n 'truncate text-sm',\n this.status === 'active' ? 'font-medium text-foreground' : 'text-muted-foreground'\n );\n }\n}\n\n/** Wraps the result chips produced by a search step. */\n@Component({\n selector: 'tolle-chain-of-thought-search-results',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('mt-1.5 flex flex-wrap items-center gap-1', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class ChainOfThoughtSearchResultsComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\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 /** Extra Tailwind classes merged onto the results row via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** A single search result rendered as a small chip. */\n@Component({\n selector: 'tolle-chain-of-thought-search-result',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span [class]=\"computedClass\">\n @if (icon) {\n <i [class]=\"icon + ' shrink-0'\" aria-hidden=\"true\"></i>\n }\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ChainOfThoughtSearchResultComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\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 /** Optional remixicon class shown before the label. @default '' */\n @Input() icon = '';\n /** Extra Tailwind classes merged onto the chip via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(\n 'inline-flex max-w-full items-center gap-1 truncate rounded-full border border-border bg-muted px-2 py-0.5 text-xs text-muted-foreground',\n this.class\n );\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "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"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "chart-pie",
3
+ "type": "registry:ui",
4
+ "title": "Chart Pie",
5
+ "description": "Chart Pie component with variant options.",
6
+ "category": "data",
7
+ "registryDependencies": [
8
+ "chart"
9
+ ],
10
+ "dependencies": [
11
+ "class-variance-authority"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "chart-pie.component.ts",
16
+ "target": "ui/chart-pie.component.ts",
17
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { ChartService, ChartColorScale } from './chart.service';\nimport { ChartTableComponent } from './chart.component';\n\nlet chartPieId = 0;\n\n/** One resolved slice: its geometry, its paint, and the numbers behind it. */\nexport interface ChartPieSlice {\n label: string;\n value: number;\n percent: number;\n color: string;\n /** The arc path, already padded for the surface gap. */\n d: string;\n /** Mid-angle point on the outer edge, used to place the tooltip. */\n anchorX: number;\n anchorY: number;\n}\n\n/** Converts an angle measured clockwise from 12 o'clock into svg coordinates. */\nexport function polarPoint(cx: number, cy: number, r: number, angle: number): [number, number] {\n return [cx + r * Math.sin(angle), cy - r * Math.cos(angle)];\n}\n\n/**\n * Builds one slice. `innerRadius` above zero produces a donut segment; a span\n * covering the full circle is drawn as two half arcs, because an SVG arc whose\n * start and end points coincide renders nothing at all.\n */\nexport function arcPath(\n cx: number,\n cy: number,\n outer: number,\n inner: number,\n start: number,\n end: number\n): string {\n const span = end - start;\n if (span <= 0) return '';\n\n if (span >= Math.PI * 2 - 1e-6) {\n const mid = start + Math.PI;\n return (\n arcPath(cx, cy, outer, inner, start, mid) + ' ' + arcPath(cx, cy, outer, inner, mid, end)\n );\n }\n\n const large = span > Math.PI ? 1 : 0;\n const [ox0, oy0] = polarPoint(cx, cy, outer, start);\n const [ox1, oy1] = polarPoint(cx, cy, outer, end);\n\n if (inner > 0) {\n const [ix1, iy1] = polarPoint(cx, cy, inner, end);\n const [ix0, iy0] = polarPoint(cx, cy, inner, start);\n return (\n 'M ' +\n ox0 +\n ' ' +\n oy0 +\n ' A ' +\n outer +\n ' ' +\n outer +\n ' 0 ' +\n large +\n ' 1 ' +\n ox1 +\n ' ' +\n oy1 +\n ' L ' +\n ix1 +\n ' ' +\n iy1 +\n ' A ' +\n inner +\n ' ' +\n inner +\n ' 0 ' +\n large +\n ' 0 ' +\n ix0 +\n ' ' +\n iy0 +\n ' Z'\n );\n }\n\n return (\n 'M ' +\n cx +\n ' ' +\n cy +\n ' L ' +\n ox0 +\n ' ' +\n oy0 +\n ' A ' +\n outer +\n ' ' +\n outer +\n ' 0 ' +\n large +\n ' 1 ' +\n ox1 +\n ' ' +\n oy1 +\n ' Z'\n );\n}\n\nconst chartPieVariants = cva('relative w-full', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-lg border border-border bg-background p-4',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type ChartPieProps = VariantProps<typeof chartPieVariants>;\n\n/**\n * A pie or donut drawn from hand-written SVG arcs.\n *\n * Slices are separated by a real 2px angular gap rather than a stroke, so what\n * divides them is surface rather than added ink. Colour is assigned by slice\n * label, so re-sorting or filtering the data never repaints a slice, and a\n * sixth slice falls back to a neutral rather than reusing `--chart-1`.\n *\n * Renders its own legend at two or more slices, a per-slice hover tooltip, and\n * the same accessible `tolle-chart-table` fallback as `tolle-chart`.\n * @new\n */\n@Component({\n selector: 'tolle-chart-pie',\n styles: [':host { display: block; }'],\n imports: [ChartTableComponent],\n providers: [ChartService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative w-full\">\n <svg\n role=\"img\"\n width=\"100%\"\n [attr.height]=\"size\"\n [attr.viewBox]=\"viewBox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.aria-describedby]=\"tableId\"\n class=\"block w-full\"\n (pointerleave)=\"onLeave()\">\n <title>{{ ariaLabel }}</title>\n <desc>{{ description || ariaLabel }}</desc>\n\n @for (slice of slices; track trackSlice(i, slice); let i = $index) {\n <svg:path\n [attr.d]=\"slice.d\"\n [attr.fill]=\"slice.color\"\n [attr.fill-opacity]=\"activeIndex === i ? 1 : 0.85\"\n (pointerenter)=\"onEnter(i)\"></svg:path>\n }\n </svg>\n\n @if (activeSlice) {\n <div\n role=\"status\"\n aria-live=\"polite\"\n class=\"pointer-events-none absolute z-10 min-w-[8rem] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md\"\n [style.left.px]=\"tooltipLeft\"\n [style.top.px]=\"tooltipTop\">\n <div class=\"flex items-center gap-2 text-xs\">\n <span\n class=\"h-2 w-2 shrink-0 rounded-[2px]\"\n [style.background]=\"activeSlice.color\"></span>\n <span class=\"text-muted-foreground\">{{ activeSlice.label }}</span>\n <span class=\"ml-auto font-medium tabular-nums text-foreground\">\n {{ formatValue(activeSlice.value) }}\n </span>\n </div>\n <p class=\"mt-0.5 text-right text-xs tabular-nums text-muted-foreground\">\n {{ formatPercent(activeSlice.percent) }}\n </p>\n </div>\n }\n </div>\n\n @if (slices.length > 1) {\n <ul class=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-2 text-xs\">\n @for (slice of slices; track trackSlice($index, slice)) {\n <li class=\"flex items-center gap-1.5\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"slice.color\"></span>\n <span class=\"text-muted-foreground\">{{ slice.label }}</span>\n </li>\n }\n </ul>\n }\n\n <tolle-chart-table\n [tableId]=\"tableId\"\n [visible]=\"showTable\"\n [xHeader]=\"labelHeader\"\n [caption]=\"ariaLabel || 'Chart data'\"></tolle-chart-table>\n </div>\n `,\n})\nexport class ChartPieComponent implements OnChanges {\n /** Rows to plot, one slice each. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Row key holding each slice's numeric value. @default 'value' */\n @Input() valueKey = 'value';\n /** Row key holding each slice's label. @default 'label' */\n @Input() labelKey = 'label';\n /** Cuts a hole in the middle, making it a donut. @default false */\n @Input() donut = false;\n /** Hole size as a fraction of the outer radius, used when `donut` is set. @default 0.6 */\n @Input() innerRadius = 0.6;\n /** Width and height of the square plot in px. @default 240 */\n @Input() size = 240;\n /** Angular gap between slices, expressed in px of arc at the outer edge. @default 2 */\n @Input() gap = 2;\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 /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Column header the table fallback gives the label column. @default 'Category' */\n @Input() labelHeader = 'Category';\n /** Column header the table fallback gives the value column. @default 'Value' */\n @Input() valueHeader = 'Value';\n /** Visual treatment of the chart frame. @default 'default' */\n @Input() variant: ChartPieProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the hovered slice index, or null when the pointer leaves. */\n @Output() activeIndexChange = new EventEmitter<number | null>();\n\n private readonly cdr = inject(ChangeDetectorRef);\n /** Only used to feed the shared table fallback; the pie owns its own colours. */\n private readonly tableSource = inject(ChartService);\n private readonly colors = new ChartColorScale();\n\n /** Id of the table fallback, referenced by the svg's `aria-describedby`. */\n readonly tableId = 'chart-pie-table-' + chartPieId++;\n\n /** Index of the hovered slice, or null. */\n activeIndex: number | null = null;\n\n ngOnChanges(): void {\n // Reserve palette slots in data order, so a slice keeps its colour when the\n // data is later re-sorted or filtered.\n this.colors.register(this.labels);\n this.tableSource.configure({\n data: this.data ?? [],\n xKey: this.labelKey,\n series: [{ key: this.valueKey, label: this.valueHeader }],\n });\n this._slices = this.computeSlices();\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 private get labels(): string[] {\n return (this.data ?? []).map(row => {\n const raw = row?.[this.labelKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n get viewBox(): string {\n return '0 0 ' + this.size + ' ' + this.size;\n }\n\n private get outerRadius(): number {\n return this.size / 2;\n }\n\n /**\n * Resolved slices in data order, each already padded for the surface gap.\n *\n * Computed once per input change, NOT per read. Three template bindings read\n * this each pass; when it rebuilt the array every time, `*ngFor` saw fresh\n * identities and tore down every `<path>` on every change-detection run.\n * Destroying the node under the pointer re-fires `pointerenter`, which calls\n * `markForCheck`, which schedules another pass — the chart locked the page up.\n */\n get slices(): ChartPieSlice[] {\n return this._slices;\n }\n\n private _slices: ChartPieSlice[] = [];\n\n /** Identity for `*ngFor`, so a redraw patches attributes instead of replacing nodes. */\n trackSlice = (_: number, slice: ChartPieSlice): string => slice.label;\n\n private computeSlices(): ChartPieSlice[] {\n const rows = this.data ?? [];\n const values = rows.map(row => {\n const num = Number(row?.[this.valueKey]);\n // A pie divides a whole; a negative share has no meaning in one.\n return Number.isFinite(num) && num > 0 ? num : 0;\n });\n const total = values.reduce((sum, v) => sum + v, 0);\n if (total <= 0) return [];\n\n const cx = this.size / 2;\n const cy = this.size / 2;\n const outer = this.outerRadius;\n const inner = this.donut ? outer * Math.min(0.95, Math.max(0, this.innerRadius)) : 0;\n // Turn a px gap at the rim into the angle that subtends it.\n const pad = outer > 0 ? this.gap / outer : 0;\n\n const out: ChartPieSlice[] = [];\n let cursor = 0;\n\n for (let i = 0; i < rows.length; i++) {\n const value = values[i];\n if (value <= 0) continue;\n\n const span = (value / total) * Math.PI * 2;\n const start = cursor;\n const end = cursor + span;\n cursor = end;\n\n // Never let the gap eat a slice: a sliver keeps a hairline of itself.\n const usable = Math.max(span * 0.2, span - pad);\n const inset = (span - usable) / 2;\n const label = this.labels[i];\n const mid = start + span / 2;\n const [anchorX, anchorY] = polarPoint(cx, cy, (outer + inner) / 2, mid);\n\n out.push({\n label,\n value,\n percent: value / total,\n color: this.colors.colorFor(label),\n d: arcPath(cx, cy, outer, inner, start + inset, end - inset),\n anchorX,\n anchorY,\n });\n }\n\n return out;\n }\n\n get activeSlice(): ChartPieSlice | null {\n if (this.activeIndex === null) return null;\n return this.slices[this.activeIndex] ?? null;\n }\n\n get tooltipLeft(): number {\n const slice = this.activeSlice;\n if (!slice) return 0;\n // Flip toward the centre rather than let the readout leave the frame.\n return slice.anchorX > this.size / 2\n ? Math.max(0, slice.anchorX - 140)\n : Math.min(this.size, slice.anchorX + 8);\n }\n\n get tooltipTop(): number {\n return this.activeSlice ? this.activeSlice.anchorY : 0;\n }\n\n formatValue(value: number): string {\n return value.toLocaleString('en-US');\n }\n\n formatPercent(percent: number): string {\n return (percent * 100).toFixed(1) + '%';\n }\n\n onEnter(index: number): void {\n if (this.activeIndex === index) return;\n this.activeIndex = index;\n this.activeIndexChange.emit(index);\n this.cdr.markForCheck();\n }\n\n onLeave(): void {\n if (this.activeIndex === null) return;\n this.activeIndex = null;\n this.activeIndexChange.emit(null);\n this.cdr.markForCheck();\n }\n\n get computedClass() {\n return cn(chartPieVariants({ variant: this.variant }), this.class);\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
+ }
@@ -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 {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\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 imports: [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 @if (type === 'line') {\n <svg:g tolle-chart-line [seriesKey]=\"valueKey\" [curve]=\"curve\"></svg:g>\n }\n @if (type === 'area') {\n <svg:g tolle-chart-area [seriesKey]=\"valueKey\" [curve]=\"curve\"></svg:g>\n }\n @if (type === 'bar') {\n <svg:g tolle-chart-bar [seriesKey]=\"valueKey\" [maxWidth]=\"maxBarWidth\"></svg:g>\n }\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
+ }