@wakastellar/ui 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +71 -8
  2. package/dist/cli/commands/add.d.ts +7 -0
  3. package/dist/cli/commands/init.d.ts +6 -0
  4. package/dist/cli/commands/list.d.ts +5 -0
  5. package/dist/cli/commands/search.d.ts +1 -0
  6. package/dist/cli/index.cjs +6014 -0
  7. package/dist/cli/index.d.ts +1 -0
  8. package/dist/cli/utils/config.d.ts +29 -0
  9. package/dist/cli/utils/logger.d.ts +20 -0
  10. package/dist/cli/utils/registry.d.ts +23 -0
  11. package/package.json +14 -3
  12. package/src/blocks/activity-timeline/index.tsx +586 -0
  13. package/src/blocks/calendar-view/index.tsx +756 -0
  14. package/src/blocks/chat/index.tsx +1018 -0
  15. package/src/blocks/chat/widget.tsx +504 -0
  16. package/src/blocks/dashboard/index.tsx +522 -0
  17. package/src/blocks/empty-states/index.tsx +452 -0
  18. package/src/blocks/error-pages/index.tsx +426 -0
  19. package/src/blocks/faq/index.tsx +479 -0
  20. package/src/blocks/file-manager/index.tsx +890 -0
  21. package/src/blocks/footer/index.tsx +133 -0
  22. package/src/blocks/header/index.tsx +357 -0
  23. package/src/blocks/headtab/index.tsx +139 -0
  24. package/src/blocks/i18n-editor/index.tsx +1016 -0
  25. package/src/blocks/index.ts +80 -0
  26. package/src/blocks/kanban-board/index.tsx +779 -0
  27. package/src/blocks/landing/index.tsx +677 -0
  28. package/src/blocks/language-selector/index.tsx +88 -0
  29. package/src/blocks/layout/index.tsx +159 -0
  30. package/src/blocks/login/index.tsx +339 -0
  31. package/src/blocks/login/types.ts +131 -0
  32. package/src/blocks/pricing/index.tsx +564 -0
  33. package/src/blocks/profile/index.tsx +746 -0
  34. package/src/blocks/settings/index.tsx +558 -0
  35. package/src/blocks/sidebar/index.tsx +713 -0
  36. package/src/blocks/theme-creator-block/index.tsx +835 -0
  37. package/src/blocks/user-management/index.tsx +1037 -0
  38. package/src/blocks/wizard/index.tsx +719 -0
  39. package/src/components/DataTable/DataTable.tsx +406 -0
  40. package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
  41. package/src/components/DataTable/DataTableBody.tsx +216 -0
  42. package/src/components/DataTable/DataTableCell.tsx +172 -0
  43. package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
  44. package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
  45. package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
  46. package/src/components/DataTable/DataTableEditCell.tsx +279 -0
  47. package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
  48. package/src/components/DataTable/DataTableFilters.tsx +535 -0
  49. package/src/components/DataTable/DataTableGrouping.tsx +147 -0
  50. package/src/components/DataTable/DataTableHeader.tsx +172 -0
  51. package/src/components/DataTable/DataTablePagination.tsx +125 -0
  52. package/src/components/DataTable/DataTableSelection.tsx +269 -0
  53. package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
  54. package/src/components/DataTable/DataTableToolbar.tsx +262 -0
  55. package/src/components/DataTable/README.md +446 -0
  56. package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
  57. package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
  58. package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
  59. package/src/components/DataTable/examples/EditExample.tsx +166 -0
  60. package/src/components/DataTable/formatters/index.ts +335 -0
  61. package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
  62. package/src/components/DataTable/hooks/useDataTable.ts +145 -0
  63. package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
  64. package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
  65. package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
  66. package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
  67. package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
  68. package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
  69. package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
  70. package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
  71. package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
  72. package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
  73. package/src/components/DataTable/index.ts +81 -0
  74. package/src/components/DataTable/services/IndexedDBService.ts +504 -0
  75. package/src/components/DataTable/templates/index.tsx +803 -0
  76. package/src/components/DataTable/types.ts +504 -0
  77. package/src/components/DataTable/utils.ts +164 -0
  78. package/src/components/DataTable/workers/exportWorker.ts +213 -0
  79. package/src/components/accordion/index.tsx +61 -0
  80. package/src/components/alert/index.tsx +61 -0
  81. package/src/components/alert-dialog/index.tsx +146 -0
  82. package/src/components/aspect-ratio/index.tsx +12 -0
  83. package/src/components/avatar/index.tsx +54 -0
  84. package/src/components/badge/Badge.stories.tsx +64 -0
  85. package/src/components/badge/index.tsx +38 -0
  86. package/src/components/button/Button.stories.tsx +173 -0
  87. package/src/components/button/index.tsx +56 -0
  88. package/src/components/calendar/index.tsx +73 -0
  89. package/src/components/card/index.tsx +78 -0
  90. package/src/components/checkbox/index.tsx +34 -0
  91. package/src/components/code/index.tsx +229 -0
  92. package/src/components/collapsible/index.tsx +16 -0
  93. package/src/components/command/index.tsx +162 -0
  94. package/src/components/context-menu/index.tsx +204 -0
  95. package/src/components/dialog/index.tsx +126 -0
  96. package/src/components/dropdown-menu/index.tsx +204 -0
  97. package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
  98. package/src/components/error-boundary/index.ts +7 -0
  99. package/src/components/form/index.tsx +183 -0
  100. package/src/components/hover-card/index.tsx +33 -0
  101. package/src/components/index.ts +368 -0
  102. package/src/components/input/Input.stories.tsx +100 -0
  103. package/src/components/input/index.tsx +27 -0
  104. package/src/components/input-otp/index.tsx +277 -0
  105. package/src/components/label/index.tsx +30 -0
  106. package/src/components/language-selector/index.tsx +341 -0
  107. package/src/components/menubar/index.tsx +240 -0
  108. package/src/components/navigation-menu/index.tsx +134 -0
  109. package/src/components/popover/index.tsx +35 -0
  110. package/src/components/progress/index.tsx +32 -0
  111. package/src/components/radio-group/index.tsx +48 -0
  112. package/src/components/scroll-area/index.tsx +52 -0
  113. package/src/components/select/index.tsx +164 -0
  114. package/src/components/separator/index.tsx +35 -0
  115. package/src/components/sheet/index.tsx +147 -0
  116. package/src/components/skeleton/index.tsx +22 -0
  117. package/src/components/slider/index.tsx +32 -0
  118. package/src/components/switch/index.tsx +33 -0
  119. package/src/components/table/index.tsx +117 -0
  120. package/src/components/tabs/index.tsx +59 -0
  121. package/src/components/textarea/index.tsx +30 -0
  122. package/src/components/theme-selector/index.tsx +327 -0
  123. package/src/components/toast/index.tsx +133 -0
  124. package/src/components/toaster/index.tsx +34 -0
  125. package/src/components/toggle/index.tsx +49 -0
  126. package/src/components/tooltip/index.tsx +34 -0
  127. package/src/components/typography/index.tsx +276 -0
  128. package/src/components/waka-3d-pie-chart/index.tsx +486 -0
  129. package/src/components/waka-achievement-unlock/index.tsx +716 -0
  130. package/src/components/waka-activity-feed/index.tsx +686 -0
  131. package/src/components/waka-address-autocomplete/index.tsx +1202 -0
  132. package/src/components/waka-admincrumb/index.tsx +349 -0
  133. package/src/components/waka-alert-stack/index.tsx +827 -0
  134. package/src/components/waka-allocation-matrix/index.tsx +1278 -0
  135. package/src/components/waka-approval-chain/index.tsx +766 -0
  136. package/src/components/waka-audit-log/index.tsx +1475 -0
  137. package/src/components/waka-autocomplete/index.tsx +358 -0
  138. package/src/components/waka-badge-showcase/index.tsx +704 -0
  139. package/src/components/waka-barcode/index.tsx +260 -0
  140. package/src/components/waka-biometric-prompt/index.tsx +765 -0
  141. package/src/components/waka-bottom-sheet/index.tsx +495 -0
  142. package/src/components/waka-breadcrumb/index.tsx +376 -0
  143. package/src/components/waka-breadcrumb-path/index.tsx +513 -0
  144. package/src/components/waka-budget-burn/index.tsx +1234 -0
  145. package/src/components/waka-capacity-planner/index.tsx +1107 -0
  146. package/src/components/waka-carousel/index.tsx +893 -0
  147. package/src/components/waka-cart-summary/index.tsx +1055 -0
  148. package/src/components/waka-challenge-timer/index.tsx +1044 -0
  149. package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
  150. package/src/components/waka-charts/WakaBarChart.tsx +222 -0
  151. package/src/components/waka-charts/WakaChart.tsx +124 -0
  152. package/src/components/waka-charts/WakaLineChart.tsx +219 -0
  153. package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
  154. package/src/components/waka-charts/WakaPieChart.tsx +214 -0
  155. package/src/components/waka-charts/WakaSparkline.tsx +229 -0
  156. package/src/components/waka-charts/dataTableHelpers.ts +109 -0
  157. package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
  158. package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
  159. package/src/components/waka-charts/index.ts +90 -0
  160. package/src/components/waka-charts/types.ts +330 -0
  161. package/src/components/waka-chat-bubble/index.tsx +1060 -0
  162. package/src/components/waka-checklist/index.tsx +1067 -0
  163. package/src/components/waka-checkout-stepper/index.tsx +976 -0
  164. package/src/components/waka-cohort-table/index.tsx +1011 -0
  165. package/src/components/waka-color-picker/index.tsx +447 -0
  166. package/src/components/waka-combo-counter/index.tsx +864 -0
  167. package/src/components/waka-combobox/index.tsx +497 -0
  168. package/src/components/waka-command-bar/index.tsx +403 -0
  169. package/src/components/waka-compare-period/index.tsx +1230 -0
  170. package/src/components/waka-connection-matrix/index.tsx +1053 -0
  171. package/src/components/waka-contribution-graph/index.tsx +552 -0
  172. package/src/components/waka-cost-breakdown/index.tsx +1065 -0
  173. package/src/components/waka-coupon-input/index.tsx +592 -0
  174. package/src/components/waka-credit-card-input/index.tsx +982 -0
  175. package/src/components/waka-daily-reward/index.tsx +762 -0
  176. package/src/components/waka-date-range-picker/index.tsx +378 -0
  177. package/src/components/waka-datetime-picker/index.tsx +793 -0
  178. package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
  179. package/src/components/waka-deployment-lane/index.tsx +673 -0
  180. package/src/components/waka-device-trust/index.tsx +1259 -0
  181. package/src/components/waka-dock/index.tsx +285 -0
  182. package/src/components/waka-drawer/index.tsx +319 -0
  183. package/src/components/waka-empty-state/index.tsx +545 -0
  184. package/src/components/waka-error-shake/index.tsx +398 -0
  185. package/src/components/waka-feature-announcement/index.tsx +991 -0
  186. package/src/components/waka-file-upload/index.tsx +437 -0
  187. package/src/components/waka-floating-nav/index.tsx +413 -0
  188. package/src/components/waka-flow-diagram/index.tsx +508 -0
  189. package/src/components/waka-funnel-chart/index.tsx +823 -0
  190. package/src/components/waka-glow-card/index.tsx +246 -0
  191. package/src/components/waka-goal-progress/index.tsx +1025 -0
  192. package/src/components/waka-haptic-button/index.tsx +388 -0
  193. package/src/components/waka-health-pulse/index.tsx +451 -0
  194. package/src/components/waka-heatmap/index.tsx +1026 -0
  195. package/src/components/waka-hotspot/index.tsx +682 -0
  196. package/src/components/waka-image/index.tsx +373 -0
  197. package/src/components/waka-incident-timeline/index.tsx +686 -0
  198. package/src/components/waka-invoice-preview/index.tsx +829 -0
  199. package/src/components/waka-kanban/index.tsx +646 -0
  200. package/src/components/waka-kpi-dashboard/index.tsx +755 -0
  201. package/src/components/waka-leaderboard/index.tsx +746 -0
  202. package/src/components/waka-level-progress/index.tsx +665 -0
  203. package/src/components/waka-liquid-button/index.tsx +520 -0
  204. package/src/components/waka-loading-orbit/index.tsx +478 -0
  205. package/src/components/waka-loot-box/index.tsx +1091 -0
  206. package/src/components/waka-magic-link/index.tsx +321 -0
  207. package/src/components/waka-magnetic-button/index.tsx +567 -0
  208. package/src/components/waka-mention-input/index.tsx +953 -0
  209. package/src/components/waka-metric-sparkline/index.tsx +627 -0
  210. package/src/components/waka-milestone-road/index.tsx +1064 -0
  211. package/src/components/waka-modal/index.tsx +374 -0
  212. package/src/components/waka-morph-button/index.tsx +495 -0
  213. package/src/components/waka-network-topology/index.tsx +801 -0
  214. package/src/components/waka-notifications/index.tsx +414 -0
  215. package/src/components/waka-number-input/index.tsx +373 -0
  216. package/src/components/waka-orbital-menu/index.tsx +445 -0
  217. package/src/components/waka-order-tracker/index.tsx +1041 -0
  218. package/src/components/waka-pagination/index.tsx +393 -0
  219. package/src/components/waka-password-strength/index.tsx +824 -0
  220. package/src/components/waka-payment-method-picker/index.tsx +715 -0
  221. package/src/components/waka-permission-matrix/index.tsx +1302 -0
  222. package/src/components/waka-phone-input/index.tsx +801 -0
  223. package/src/components/waka-pipeline-view/index.tsx +604 -0
  224. package/src/components/waka-player-card/index.tsx +691 -0
  225. package/src/components/waka-points-popup/index.tsx +366 -0
  226. package/src/components/waka-power-up/index.tsx +1155 -0
  227. package/src/components/waka-presence-indicator/index.tsx +1181 -0
  228. package/src/components/waka-pricing-table/index.tsx +755 -0
  229. package/src/components/waka-product-card/index.tsx +786 -0
  230. package/src/components/waka-progress-onboarding/index.tsx +878 -0
  231. package/src/components/waka-pull-to-refresh/index.tsx +451 -0
  232. package/src/components/waka-qrcode/index.tsx +232 -0
  233. package/src/components/waka-quest-card/index.tsx +1275 -0
  234. package/src/components/waka-quota-bar/index.tsx +693 -0
  235. package/src/components/waka-radar-score/index.tsx +512 -0
  236. package/src/components/waka-rank-badge/index.tsx +813 -0
  237. package/src/components/waka-rating-input/index.tsx +560 -0
  238. package/src/components/waka-reaction-picker/index.tsx +1062 -0
  239. package/src/components/waka-region-map/index.tsx +730 -0
  240. package/src/components/waka-resource-gauge/index.tsx +654 -0
  241. package/src/components/waka-resource-pool/index.tsx +1035 -0
  242. package/src/components/waka-rich-text-editor/index.tsx +594 -0
  243. package/src/components/waka-rollback-slider/index.tsx +891 -0
  244. package/src/components/waka-sankey-diagram/index.tsx +1032 -0
  245. package/src/components/waka-schedule-picker/index.tsx +1060 -0
  246. package/src/components/waka-scratch-card/index.tsx +914 -0
  247. package/src/components/waka-season-pass/index.tsx +886 -0
  248. package/src/components/waka-security-score/index.tsx +1126 -0
  249. package/src/components/waka-segmented-control/index.tsx +238 -0
  250. package/src/components/waka-server-rack/index.tsx +764 -0
  251. package/src/components/waka-session-manager/index.tsx +815 -0
  252. package/src/components/waka-signature-pad/index.tsx +744 -0
  253. package/src/components/waka-skeleton-wave/index.tsx +454 -0
  254. package/src/components/waka-skill-tree/index.tsx +1031 -0
  255. package/src/components/waka-sla-tracker/index.tsx +798 -0
  256. package/src/components/waka-slider-range/index.tsx +765 -0
  257. package/src/components/waka-spin-wheel/index.tsx +671 -0
  258. package/src/components/waka-spinner/index.tsx +284 -0
  259. package/src/components/waka-spotlight/index.tsx +410 -0
  260. package/src/components/waka-stat/index.tsx +428 -0
  261. package/src/components/waka-stats-hexagon/index.tsx +824 -0
  262. package/src/components/waka-status-matrix/index.tsx +565 -0
  263. package/src/components/waka-stepper/index.tsx +489 -0
  264. package/src/components/waka-streak-counter/index.tsx +334 -0
  265. package/src/components/waka-success-explosion/index.tsx +453 -0
  266. package/src/components/waka-swipe-card/index.tsx +574 -0
  267. package/src/components/waka-tabs-morph/index.tsx +509 -0
  268. package/src/components/waka-tag-input/index.tsx +877 -0
  269. package/src/components/waka-team-banner/index.tsx +1183 -0
  270. package/src/components/waka-terminal-output/index.tsx +836 -0
  271. package/src/components/waka-theme-creator/index.tsx +762 -0
  272. package/src/components/waka-theme-manager/index.tsx +654 -0
  273. package/src/components/waka-thread-view/index.tsx +874 -0
  274. package/src/components/waka-tilt-card/index.tsx +250 -0
  275. package/src/components/waka-time-picker/index.tsx +479 -0
  276. package/src/components/waka-timeline/index.tsx +385 -0
  277. package/src/components/waka-tooltip-tour/index.tsx +855 -0
  278. package/src/components/waka-tour-guide/index.tsx +920 -0
  279. package/src/components/waka-tournament-bracket/index.tsx +1276 -0
  280. package/src/components/waka-tree/index.tsx +557 -0
  281. package/src/components/waka-treemap-chart/index.tsx +1031 -0
  282. package/src/components/waka-two-factor-setup/index.tsx +995 -0
  283. package/src/components/waka-typewriter/index.tsx +566 -0
  284. package/src/components/waka-typing-indicator/index.tsx +649 -0
  285. package/src/components/waka-versus-card/index.tsx +1026 -0
  286. package/src/components/waka-video/index.tsx +557 -0
  287. package/src/components/waka-video-call/index.tsx +1087 -0
  288. package/src/components/waka-virtual-list/index.tsx +327 -0
  289. package/src/components/waka-voice-message/index.tsx +1019 -0
  290. package/src/components/waka-welcome-modal/index.tsx +790 -0
  291. package/src/components/waka-xp-bar/index.tsx +799 -0
@@ -0,0 +1,1065 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../utils/cn"
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ export interface CostItem {
11
+ /** Unique identifier */
12
+ id: string
13
+ /** Display name */
14
+ name: string
15
+ /** Cost value */
16
+ value: number
17
+ /** Custom color */
18
+ color?: string
19
+ /** Child items for hierarchical data */
20
+ children?: CostItem[]
21
+ /** Percentage change from previous period */
22
+ change?: number
23
+ }
24
+
25
+ export interface WakaCostBreakdownProps {
26
+ /** Hierarchical cost data */
27
+ data: CostItem[]
28
+ /** Visualization variant */
29
+ variant?: "treemap" | "sunburst"
30
+ /** Currency symbol or code */
31
+ currency?: string
32
+ /** Show legend with percentages */
33
+ showLegend?: boolean
34
+ /** Show comparison indicators */
35
+ showComparison?: boolean
36
+ /** Callback when an item is clicked */
37
+ onItemClick?: (id: string, path: string[]) => void
38
+ /** Custom value formatter */
39
+ formatValue?: (value: number) => string
40
+ /** Enable animations */
41
+ animated?: boolean
42
+ /** Additional CSS classes */
43
+ className?: string
44
+ }
45
+
46
+ // ============================================================================
47
+ // Default Colors
48
+ // ============================================================================
49
+
50
+ const defaultColors = [
51
+ "#3b82f6", // blue
52
+ "#ef4444", // red
53
+ "#22c55e", // green
54
+ "#f59e0b", // amber
55
+ "#8b5cf6", // violet
56
+ "#ec4899", // pink
57
+ "#14b8a6", // teal
58
+ "#f97316", // orange
59
+ "#6366f1", // indigo
60
+ "#84cc16", // lime
61
+ ]
62
+
63
+ // ============================================================================
64
+ // Utility Functions
65
+ // ============================================================================
66
+
67
+ function assignColors(items: CostItem[], startIndex = 0): CostItem[] {
68
+ return items.map((item, index) => ({
69
+ ...item,
70
+ color: item.color || defaultColors[(startIndex + index) % defaultColors.length],
71
+ children: item.children ? assignColors(item.children, startIndex + index) : undefined,
72
+ }))
73
+ }
74
+
75
+ function calculateTotal(items: CostItem[]): number {
76
+ return items.reduce((sum, item) => {
77
+ if (item.children && item.children.length > 0) {
78
+ return sum + calculateTotal(item.children)
79
+ }
80
+ return sum + item.value
81
+ }, 0)
82
+ }
83
+
84
+ function getItemTotal(item: CostItem): number {
85
+ if (item.children && item.children.length > 0) {
86
+ return calculateTotal(item.children)
87
+ }
88
+ return item.value
89
+ }
90
+
91
+ function flattenItems(items: CostItem[], path: string[] = []): Array<{ item: CostItem; path: string[] }> {
92
+ const result: Array<{ item: CostItem; path: string[] }> = []
93
+
94
+ items.forEach((item) => {
95
+ const currentPath = [...path, item.id]
96
+ result.push({ item, path: currentPath })
97
+ if (item.children) {
98
+ result.push(...flattenItems(item.children, currentPath))
99
+ }
100
+ })
101
+
102
+ return result
103
+ }
104
+
105
+ function darkenColor(hex: string, amount: number): string {
106
+ const num = parseInt(hex.slice(1), 16)
107
+ const r = Math.max(0, (num >> 16) - Math.round(255 * amount))
108
+ const g = Math.max(0, ((num >> 8) & 0x00ff) - Math.round(255 * amount))
109
+ const b = Math.max(0, (num & 0x0000ff) - Math.round(255 * amount))
110
+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`
111
+ }
112
+
113
+ function lightenColor(hex: string, amount: number): string {
114
+ const num = parseInt(hex.slice(1), 16)
115
+ const r = Math.min(255, (num >> 16) + Math.round(255 * amount))
116
+ const g = Math.min(255, ((num >> 8) & 0x00ff) + Math.round(255 * amount))
117
+ const b = Math.min(255, (num & 0x0000ff) + Math.round(255 * amount))
118
+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`
119
+ }
120
+
121
+ // ============================================================================
122
+ // Treemap Layout Algorithm (Squarified)
123
+ // ============================================================================
124
+
125
+ interface TreemapRect {
126
+ x: number
127
+ y: number
128
+ width: number
129
+ height: number
130
+ item: CostItem
131
+ depth: number
132
+ }
133
+
134
+ function squarify(
135
+ items: CostItem[],
136
+ rect: { x: number; y: number; width: number; height: number },
137
+ depth: number = 0
138
+ ): TreemapRect[] {
139
+ const total = items.reduce((sum, item) => sum + getItemTotal(item), 0)
140
+ if (total === 0 || items.length === 0) return []
141
+
142
+ const sortedItems = [...items].sort((a, b) => getItemTotal(b) - getItemTotal(a))
143
+ const rects: TreemapRect[] = []
144
+
145
+ let currentRect = { ...rect }
146
+ let remainingItems = sortedItems
147
+
148
+ while (remainingItems.length > 0) {
149
+ const { width, height } = currentRect
150
+ const isHorizontal = width >= height
151
+ const side = isHorizontal ? height : width
152
+
153
+ // Find best row
154
+ let rowItems: CostItem[] = []
155
+ let rowSum = 0
156
+ let bestRatio = Infinity
157
+
158
+ for (let i = 0; i < remainingItems.length; i++) {
159
+ const testItems = remainingItems.slice(0, i + 1)
160
+ const testSum = testItems.reduce((sum, item) => sum + getItemTotal(item), 0)
161
+ const rowRatio = testSum / total
162
+ const rowLength = isHorizontal ? width * rowRatio : height * rowRatio
163
+
164
+ // Calculate worst aspect ratio in this row
165
+ let worstRatio = 0
166
+ testItems.forEach((item) => {
167
+ const itemRatio = getItemTotal(item) / testSum
168
+ const itemSize = side * itemRatio
169
+ const aspectRatio = Math.max(rowLength / itemSize, itemSize / rowLength)
170
+ worstRatio = Math.max(worstRatio, aspectRatio)
171
+ })
172
+
173
+ if (worstRatio <= bestRatio) {
174
+ bestRatio = worstRatio
175
+ rowItems = testItems
176
+ rowSum = testSum
177
+ } else {
178
+ break
179
+ }
180
+ }
181
+
182
+ // Layout the row
183
+ const rowRatio = rowSum / total
184
+ const rowLength = isHorizontal ? width * rowRatio : height * rowRatio
185
+ let offset = 0
186
+
187
+ rowItems.forEach((item) => {
188
+ const itemRatio = getItemTotal(item) / rowSum
189
+ const itemSize = side * itemRatio
190
+
191
+ const itemRect: TreemapRect = isHorizontal
192
+ ? {
193
+ x: currentRect.x,
194
+ y: currentRect.y + offset,
195
+ width: rowLength,
196
+ height: itemSize,
197
+ item,
198
+ depth,
199
+ }
200
+ : {
201
+ x: currentRect.x + offset,
202
+ y: currentRect.y,
203
+ width: itemSize,
204
+ height: rowLength,
205
+ item,
206
+ depth,
207
+ }
208
+
209
+ rects.push(itemRect)
210
+ offset += itemSize
211
+ })
212
+
213
+ // Update remaining area
214
+ if (isHorizontal) {
215
+ currentRect = {
216
+ x: currentRect.x + rowLength,
217
+ y: currentRect.y,
218
+ width: width - rowLength,
219
+ height,
220
+ }
221
+ } else {
222
+ currentRect = {
223
+ x: currentRect.x,
224
+ y: currentRect.y + rowLength,
225
+ width,
226
+ height: height - rowLength,
227
+ }
228
+ }
229
+
230
+ remainingItems = remainingItems.slice(rowItems.length)
231
+ total === rowSum && (remainingItems = [])
232
+ }
233
+
234
+ return rects
235
+ }
236
+
237
+ // ============================================================================
238
+ // Sunburst Layout
239
+ // ============================================================================
240
+
241
+ interface SunburstArc {
242
+ startAngle: number
243
+ endAngle: number
244
+ innerRadius: number
245
+ outerRadius: number
246
+ item: CostItem
247
+ depth: number
248
+ parentColor?: string
249
+ }
250
+
251
+ function createSunburstArcs(
252
+ items: CostItem[],
253
+ startAngle: number,
254
+ endAngle: number,
255
+ innerRadius: number,
256
+ outerRadius: number,
257
+ depth: number = 0,
258
+ parentColor?: string
259
+ ): SunburstArc[] {
260
+ const total = items.reduce((sum, item) => sum + getItemTotal(item), 0)
261
+ if (total === 0) return []
262
+
263
+ const arcs: SunburstArc[] = []
264
+ let currentAngle = startAngle
265
+ const ringWidth = (outerRadius - innerRadius) / 3 // 3 levels max depth
266
+
267
+ items.forEach((item) => {
268
+ const itemValue = getItemTotal(item)
269
+ const angleSpan = ((endAngle - startAngle) * itemValue) / total
270
+ const itemEndAngle = currentAngle + angleSpan
271
+
272
+ arcs.push({
273
+ startAngle: currentAngle,
274
+ endAngle: itemEndAngle,
275
+ innerRadius,
276
+ outerRadius: innerRadius + ringWidth,
277
+ item,
278
+ depth,
279
+ parentColor,
280
+ })
281
+
282
+ // Add children arcs
283
+ if (item.children && item.children.length > 0) {
284
+ arcs.push(
285
+ ...createSunburstArcs(
286
+ item.children,
287
+ currentAngle,
288
+ itemEndAngle,
289
+ innerRadius + ringWidth,
290
+ outerRadius,
291
+ depth + 1,
292
+ item.color
293
+ )
294
+ )
295
+ }
296
+
297
+ currentAngle = itemEndAngle
298
+ })
299
+
300
+ return arcs
301
+ }
302
+
303
+ function describeArc(
304
+ cx: number,
305
+ cy: number,
306
+ innerRadius: number,
307
+ outerRadius: number,
308
+ startAngle: number,
309
+ endAngle: number
310
+ ): string {
311
+ const startRad = ((startAngle - 90) * Math.PI) / 180
312
+ const endRad = ((endAngle - 90) * Math.PI) / 180
313
+
314
+ const x1Outer = cx + outerRadius * Math.cos(startRad)
315
+ const y1Outer = cy + outerRadius * Math.sin(startRad)
316
+ const x2Outer = cx + outerRadius * Math.cos(endRad)
317
+ const y2Outer = cy + outerRadius * Math.sin(endRad)
318
+
319
+ const x1Inner = cx + innerRadius * Math.cos(endRad)
320
+ const y1Inner = cy + innerRadius * Math.sin(endRad)
321
+ const x2Inner = cx + innerRadius * Math.cos(startRad)
322
+ const y2Inner = cy + innerRadius * Math.sin(startRad)
323
+
324
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0
325
+
326
+ if (innerRadius === 0) {
327
+ return `M ${cx} ${cy} L ${x1Outer} ${y1Outer} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${x2Outer} ${y2Outer} Z`
328
+ }
329
+
330
+ return `M ${x1Outer} ${y1Outer} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${x2Outer} ${y2Outer} L ${x1Inner} ${y1Inner} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x2Inner} ${y2Inner} Z`
331
+ }
332
+
333
+ // ============================================================================
334
+ // Breadcrumb Component
335
+ // ============================================================================
336
+
337
+ interface BreadcrumbProps {
338
+ path: Array<{ id: string; name: string; color?: string }>
339
+ onNavigate: (index: number) => void
340
+ }
341
+
342
+ function Breadcrumb({ path, onNavigate }: BreadcrumbProps) {
343
+ return (
344
+ <nav className="flex items-center gap-1 text-sm mb-4 flex-wrap">
345
+ <button
346
+ onClick={() => onNavigate(-1)}
347
+ className={cn(
348
+ "px-2 py-1 rounded-md transition-colors",
349
+ "hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary",
350
+ "text-muted-foreground hover:text-foreground"
351
+ )}
352
+ >
353
+ All
354
+ </button>
355
+ {path.map((item, index) => (
356
+ <React.Fragment key={item.id}>
357
+ <span className="text-muted-foreground">/</span>
358
+ <button
359
+ onClick={() => onNavigate(index)}
360
+ className={cn(
361
+ "px-2 py-1 rounded-md transition-colors flex items-center gap-2",
362
+ "hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary",
363
+ index === path.length - 1 ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground"
364
+ )}
365
+ >
366
+ {item.color && (
367
+ <span
368
+ className="w-2 h-2 rounded-full"
369
+ style={{ backgroundColor: item.color }}
370
+ />
371
+ )}
372
+ {item.name}
373
+ </button>
374
+ </React.Fragment>
375
+ ))}
376
+ </nav>
377
+ )
378
+ }
379
+
380
+ // ============================================================================
381
+ // Tooltip Component
382
+ // ============================================================================
383
+
384
+ interface TooltipProps {
385
+ item: CostItem
386
+ position: { x: number; y: number }
387
+ total: number
388
+ formatValue: (value: number) => string
389
+ showComparison: boolean
390
+ }
391
+
392
+ function Tooltip({ item, position, total, formatValue, showComparison }: TooltipProps) {
393
+ const itemTotal = getItemTotal(item)
394
+ const percentage = ((itemTotal / total) * 100).toFixed(1)
395
+
396
+ return (
397
+ <div
398
+ className={cn(
399
+ "absolute z-50 px-3 py-2 text-sm rounded-lg shadow-lg",
400
+ "bg-foreground text-background",
401
+ "animate-in fade-in-0 zoom-in-95 duration-100",
402
+ "pointer-events-none min-w-[140px]"
403
+ )}
404
+ style={{
405
+ left: position.x,
406
+ top: position.y - 70,
407
+ transform: "translateX(-50%)",
408
+ }}
409
+ >
410
+ <div className="font-semibold mb-1">{item.name}</div>
411
+ <div className="flex justify-between gap-4">
412
+ <span className="opacity-70">Value:</span>
413
+ <span className="font-medium">{formatValue(itemTotal)}</span>
414
+ </div>
415
+ <div className="flex justify-between gap-4">
416
+ <span className="opacity-70">Share:</span>
417
+ <span>{percentage}%</span>
418
+ </div>
419
+ {showComparison && item.change !== undefined && (
420
+ <div className="flex justify-between gap-4 mt-1 pt-1 border-t border-background/20">
421
+ <span className="opacity-70">Change:</span>
422
+ <span
423
+ className={cn(
424
+ "font-medium",
425
+ item.change > 0 && "text-red-300",
426
+ item.change < 0 && "text-green-300"
427
+ )}
428
+ >
429
+ {item.change > 0 ? "+" : ""}
430
+ {item.change.toFixed(1)}%
431
+ </span>
432
+ </div>
433
+ )}
434
+ {item.children && item.children.length > 0 && (
435
+ <div className="text-xs opacity-70 mt-1 pt-1 border-t border-background/20">
436
+ Click to drill down ({item.children.length} items)
437
+ </div>
438
+ )}
439
+ <div className="absolute left-1/2 -translate-x-1/2 -bottom-1.5 w-3 h-3 rotate-45 bg-foreground" />
440
+ </div>
441
+ )
442
+ }
443
+
444
+ // ============================================================================
445
+ // Legend Component
446
+ // ============================================================================
447
+
448
+ interface LegendProps {
449
+ items: CostItem[]
450
+ total: number
451
+ formatValue: (value: number) => string
452
+ hoveredId: string | null
453
+ onHover: (id: string | null) => void
454
+ onClick: (item: CostItem) => void
455
+ showComparison: boolean
456
+ }
457
+
458
+ function Legend({ items, total, formatValue, hoveredId, onHover, onClick, showComparison }: LegendProps) {
459
+ return (
460
+ <div className="flex flex-col gap-1">
461
+ {items.map((item) => {
462
+ const itemTotal = getItemTotal(item)
463
+ const percentage = ((itemTotal / total) * 100).toFixed(1)
464
+ const isHovered = hoveredId === item.id
465
+ const isOtherHovered = hoveredId !== null && hoveredId !== item.id
466
+
467
+ return (
468
+ <button
469
+ key={item.id}
470
+ className={cn(
471
+ "flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-left",
472
+ "hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary",
473
+ isHovered && "bg-muted",
474
+ isOtherHovered && "opacity-50"
475
+ )}
476
+ onMouseEnter={() => onHover(item.id)}
477
+ onMouseLeave={() => onHover(null)}
478
+ onClick={() => onClick(item)}
479
+ >
480
+ <span
481
+ className="w-3 h-3 rounded flex-shrink-0"
482
+ style={{ backgroundColor: item.color }}
483
+ />
484
+ <span className="flex-1 truncate text-sm">{item.name}</span>
485
+ <div className="flex flex-col items-end text-xs">
486
+ <span className="font-medium">{formatValue(itemTotal)}</span>
487
+ <span className="text-muted-foreground">{percentage}%</span>
488
+ </div>
489
+ {showComparison && item.change !== undefined && (
490
+ <span
491
+ className={cn(
492
+ "text-xs font-medium px-1.5 py-0.5 rounded",
493
+ item.change > 0 && "text-red-600 bg-red-100",
494
+ item.change < 0 && "text-green-600 bg-green-100",
495
+ item.change === 0 && "text-muted-foreground bg-muted"
496
+ )}
497
+ >
498
+ {item.change > 0 ? "+" : ""}
499
+ {item.change.toFixed(1)}%
500
+ </span>
501
+ )}
502
+ {item.children && item.children.length > 0 && (
503
+ <svg
504
+ className="w-4 h-4 text-muted-foreground"
505
+ fill="none"
506
+ viewBox="0 0 24 24"
507
+ stroke="currentColor"
508
+ >
509
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
510
+ </svg>
511
+ )}
512
+ </button>
513
+ )
514
+ })}
515
+ </div>
516
+ )
517
+ }
518
+
519
+ // ============================================================================
520
+ // Treemap Visualization
521
+ // ============================================================================
522
+
523
+ interface TreemapProps {
524
+ data: CostItem[]
525
+ width: number
526
+ height: number
527
+ onItemClick: (item: CostItem) => void
528
+ hoveredId: string | null
529
+ onHover: (id: string | null) => void
530
+ formatValue: (value: number) => string
531
+ showComparison: boolean
532
+ animated: boolean
533
+ total: number
534
+ }
535
+
536
+ function Treemap({
537
+ data,
538
+ width,
539
+ height,
540
+ onItemClick,
541
+ hoveredId,
542
+ onHover,
543
+ formatValue,
544
+ showComparison,
545
+ animated,
546
+ total,
547
+ }: TreemapProps) {
548
+ const [tooltipData, setTooltipData] = React.useState<{
549
+ item: CostItem
550
+ position: { x: number; y: number }
551
+ } | null>(null)
552
+
553
+ const containerRef = React.useRef<HTMLDivElement>(null)
554
+
555
+ const rects = React.useMemo(
556
+ () => squarify(data, { x: 0, y: 0, width, height }, 0),
557
+ [data, width, height]
558
+ )
559
+
560
+ const handleMouseMove = (e: React.MouseEvent, rect: TreemapRect) => {
561
+ if (containerRef.current) {
562
+ const containerRect = containerRef.current.getBoundingClientRect()
563
+ setTooltipData({
564
+ item: rect.item,
565
+ position: {
566
+ x: e.clientX - containerRect.left,
567
+ y: e.clientY - containerRect.top,
568
+ },
569
+ })
570
+ }
571
+ onHover(rect.item.id)
572
+ }
573
+
574
+ return (
575
+ <div ref={containerRef} className="relative" style={{ width, height }}>
576
+ <svg width={width} height={height} className="overflow-visible">
577
+ {rects.map((rect, index) => {
578
+ const isHovered = hoveredId === rect.item.id
579
+ const isOtherHovered = hoveredId !== null && hoveredId !== rect.item.id
580
+ const hasChildren = rect.item.children && rect.item.children.length > 0
581
+ const itemTotal = getItemTotal(rect.item)
582
+ const percentage = ((itemTotal / total) * 100).toFixed(1)
583
+
584
+ // Calculate if text fits
585
+ const minWidthForText = 60
586
+ const minHeightForText = 40
587
+ const showText = rect.width >= minWidthForText && rect.height >= minHeightForText
588
+ const showValue = rect.width >= 80 && rect.height >= 50
589
+
590
+ return (
591
+ <g
592
+ key={rect.item.id}
593
+ className={cn(
594
+ "cursor-pointer",
595
+ animated && "transition-all duration-300"
596
+ )}
597
+ style={{
598
+ opacity: isOtherHovered ? 0.5 : 1,
599
+ transform: isHovered ? "scale(1.02)" : "scale(1)",
600
+ transformOrigin: `${rect.x + rect.width / 2}px ${rect.y + rect.height / 2}px`,
601
+ }}
602
+ onMouseMove={(e) => handleMouseMove(e, rect)}
603
+ onMouseLeave={() => {
604
+ setTooltipData(null)
605
+ onHover(null)
606
+ }}
607
+ onClick={() => onItemClick(rect.item)}
608
+ >
609
+ <rect
610
+ x={rect.x + 1}
611
+ y={rect.y + 1}
612
+ width={Math.max(0, rect.width - 2)}
613
+ height={Math.max(0, rect.height - 2)}
614
+ fill={rect.item.color}
615
+ rx="4"
616
+ className={cn(
617
+ animated && "transition-all duration-200",
618
+ isHovered && "brightness-110"
619
+ )}
620
+ style={{
621
+ filter: isHovered ? "brightness(1.1)" : undefined,
622
+ }}
623
+ />
624
+ {showText && (
625
+ <>
626
+ <text
627
+ x={rect.x + rect.width / 2}
628
+ y={rect.y + rect.height / 2 - (showValue ? 8 : 0)}
629
+ textAnchor="middle"
630
+ dominantBaseline="middle"
631
+ fill="white"
632
+ className="text-xs font-medium pointer-events-none"
633
+ style={{
634
+ textShadow: "0 1px 2px rgba(0,0,0,0.5)",
635
+ }}
636
+ >
637
+ {rect.item.name.length > Math.floor(rect.width / 8)
638
+ ? rect.item.name.slice(0, Math.floor(rect.width / 8)) + "..."
639
+ : rect.item.name}
640
+ </text>
641
+ {showValue && (
642
+ <text
643
+ x={rect.x + rect.width / 2}
644
+ y={rect.y + rect.height / 2 + 10}
645
+ textAnchor="middle"
646
+ dominantBaseline="middle"
647
+ fill="white"
648
+ className="text-[10px] pointer-events-none opacity-80"
649
+ style={{
650
+ textShadow: "0 1px 2px rgba(0,0,0,0.5)",
651
+ }}
652
+ >
653
+ {percentage}%
654
+ </text>
655
+ )}
656
+ </>
657
+ )}
658
+ {hasChildren && rect.width >= 24 && rect.height >= 24 && (
659
+ <circle
660
+ cx={rect.x + rect.width - 12}
661
+ cy={rect.y + 12}
662
+ r="8"
663
+ fill="rgba(255,255,255,0.3)"
664
+ className="pointer-events-none"
665
+ />
666
+ )}
667
+ </g>
668
+ )
669
+ })}
670
+ </svg>
671
+
672
+ {tooltipData && (
673
+ <Tooltip
674
+ item={tooltipData.item}
675
+ position={tooltipData.position}
676
+ total={total}
677
+ formatValue={formatValue}
678
+ showComparison={showComparison}
679
+ />
680
+ )}
681
+ </div>
682
+ )
683
+ }
684
+
685
+ // ============================================================================
686
+ // Sunburst Visualization
687
+ // ============================================================================
688
+
689
+ interface SunburstProps {
690
+ data: CostItem[]
691
+ size: number
692
+ onItemClick: (item: CostItem) => void
693
+ hoveredId: string | null
694
+ onHover: (id: string | null) => void
695
+ formatValue: (value: number) => string
696
+ showComparison: boolean
697
+ animated: boolean
698
+ total: number
699
+ }
700
+
701
+ function Sunburst({
702
+ data,
703
+ size,
704
+ onItemClick,
705
+ hoveredId,
706
+ onHover,
707
+ formatValue,
708
+ showComparison,
709
+ animated,
710
+ total,
711
+ }: SunburstProps) {
712
+ const [tooltipData, setTooltipData] = React.useState<{
713
+ item: CostItem
714
+ position: { x: number; y: number }
715
+ } | null>(null)
716
+
717
+ const containerRef = React.useRef<HTMLDivElement>(null)
718
+ const cx = size / 2
719
+ const cy = size / 2
720
+ const outerRadius = size / 2 - 20
721
+ const innerRadius = 50
722
+
723
+ const arcs = React.useMemo(
724
+ () => createSunburstArcs(data, 0, 360, innerRadius, outerRadius, 0),
725
+ [data, innerRadius, outerRadius]
726
+ )
727
+
728
+ const handleMouseMove = (e: React.MouseEvent, arc: SunburstArc) => {
729
+ if (containerRef.current) {
730
+ const containerRect = containerRef.current.getBoundingClientRect()
731
+ setTooltipData({
732
+ item: arc.item,
733
+ position: {
734
+ x: e.clientX - containerRect.left,
735
+ y: e.clientY - containerRect.top,
736
+ },
737
+ })
738
+ }
739
+ onHover(arc.item.id)
740
+ }
741
+
742
+ // Get color for arc based on depth
743
+ const getArcColor = (arc: SunburstArc) => {
744
+ if (arc.depth === 0) return arc.item.color || defaultColors[0]
745
+ const baseColor = arc.parentColor || arc.item.color || defaultColors[0]
746
+ return lightenColor(baseColor, arc.depth * 0.15)
747
+ }
748
+
749
+ return (
750
+ <div ref={containerRef} className="relative" style={{ width: size, height: size }}>
751
+ <svg width={size} height={size} className="overflow-visible">
752
+ {/* Center circle with total */}
753
+ <circle
754
+ cx={cx}
755
+ cy={cy}
756
+ r={innerRadius - 5}
757
+ fill="none"
758
+ stroke="currentColor"
759
+ strokeWidth="1"
760
+ className="opacity-10"
761
+ />
762
+
763
+ {/* Arcs */}
764
+ {arcs.map((arc, index) => {
765
+ const isHovered = hoveredId === arc.item.id
766
+ const isOtherHovered = hoveredId !== null && hoveredId !== arc.item.id
767
+ const hasChildren = arc.item.children && arc.item.children.length > 0
768
+ const color = getArcColor(arc)
769
+
770
+ return (
771
+ <path
772
+ key={`${arc.item.id}-${arc.depth}-${index}`}
773
+ d={describeArc(cx, cy, arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle)}
774
+ fill={color}
775
+ stroke="white"
776
+ strokeWidth="1"
777
+ className={cn(
778
+ "cursor-pointer",
779
+ animated && "transition-all duration-200"
780
+ )}
781
+ style={{
782
+ opacity: isOtherHovered ? 0.5 : 1,
783
+ filter: isHovered ? "brightness(1.15)" : undefined,
784
+ transform: isHovered ? `scale(1.02)` : "scale(1)",
785
+ transformOrigin: `${cx}px ${cy}px`,
786
+ }}
787
+ onMouseMove={(e) => handleMouseMove(e, arc)}
788
+ onMouseLeave={() => {
789
+ setTooltipData(null)
790
+ onHover(null)
791
+ }}
792
+ onClick={() => onItemClick(arc.item)}
793
+ />
794
+ )
795
+ })}
796
+
797
+ {/* Center text */}
798
+ <text
799
+ x={cx}
800
+ y={cy - 8}
801
+ textAnchor="middle"
802
+ dominantBaseline="middle"
803
+ className="text-xs font-medium fill-muted-foreground"
804
+ >
805
+ Total
806
+ </text>
807
+ <text
808
+ x={cx}
809
+ y={cy + 10}
810
+ textAnchor="middle"
811
+ dominantBaseline="middle"
812
+ className="text-sm font-bold fill-foreground"
813
+ >
814
+ {formatValue(total)}
815
+ </text>
816
+ </svg>
817
+
818
+ {tooltipData && (
819
+ <Tooltip
820
+ item={tooltipData.item}
821
+ position={tooltipData.position}
822
+ total={total}
823
+ formatValue={formatValue}
824
+ showComparison={showComparison}
825
+ />
826
+ )}
827
+ </div>
828
+ )
829
+ }
830
+
831
+ // ============================================================================
832
+ // Main Component
833
+ // ============================================================================
834
+
835
+ export function WakaCostBreakdown({
836
+ data,
837
+ variant = "treemap",
838
+ currency = "$",
839
+ showLegend = true,
840
+ showComparison = false,
841
+ onItemClick,
842
+ formatValue,
843
+ animated = true,
844
+ className,
845
+ }: WakaCostBreakdownProps) {
846
+ const [currentPath, setCurrentPath] = React.useState<Array<{ id: string; name: string; color?: string }>>([])
847
+ const [hoveredId, setHoveredId] = React.useState<string | null>(null)
848
+
849
+ // Assign colors to data
850
+ const coloredData = React.useMemo(() => assignColors(data), [data])
851
+
852
+ // Get current display data based on drill-down path
853
+ const currentData = React.useMemo(() => {
854
+ let items = coloredData
855
+ for (const pathItem of currentPath) {
856
+ const found = items.find((item) => item.id === pathItem.id)
857
+ if (found?.children) {
858
+ items = found.children
859
+ } else {
860
+ break
861
+ }
862
+ }
863
+ return items
864
+ }, [coloredData, currentPath])
865
+
866
+ const total = React.useMemo(() => calculateTotal(currentData), [currentData])
867
+
868
+ const defaultFormatValue = React.useCallback(
869
+ (value: number) => {
870
+ if (value >= 1000000) {
871
+ return `${currency}${(value / 1000000).toFixed(1)}M`
872
+ }
873
+ if (value >= 1000) {
874
+ return `${currency}${(value / 1000).toFixed(1)}K`
875
+ }
876
+ return `${currency}${value.toFixed(2)}`
877
+ },
878
+ [currency]
879
+ )
880
+
881
+ const valueFormatter = formatValue || defaultFormatValue
882
+
883
+ const handleItemClick = React.useCallback(
884
+ (item: CostItem) => {
885
+ const path = [...currentPath.map((p) => p.id), item.id]
886
+ onItemClick?.(item.id, path)
887
+
888
+ // Drill down if has children
889
+ if (item.children && item.children.length > 0) {
890
+ setCurrentPath([...currentPath, { id: item.id, name: item.name, color: item.color }])
891
+ }
892
+ },
893
+ [currentPath, onItemClick]
894
+ )
895
+
896
+ const handleBreadcrumbNavigate = React.useCallback((index: number) => {
897
+ if (index === -1) {
898
+ setCurrentPath([])
899
+ } else {
900
+ setCurrentPath(currentPath.slice(0, index + 1))
901
+ }
902
+ }, [currentPath])
903
+
904
+ // Calculate full total for comparison
905
+ const fullTotal = React.useMemo(() => calculateTotal(coloredData), [coloredData])
906
+
907
+ return (
908
+ <div className={cn("flex flex-col", className)}>
909
+ {/* Breadcrumb */}
910
+ {currentPath.length > 0 && (
911
+ <Breadcrumb path={currentPath} onNavigate={handleBreadcrumbNavigate} />
912
+ )}
913
+
914
+ {/* Header with total */}
915
+ <div className="flex items-center justify-between mb-4">
916
+ <div>
917
+ <div className="text-sm text-muted-foreground">
918
+ {currentPath.length > 0 ? "Category Total" : "Total Cost"}
919
+ </div>
920
+ <div className="text-2xl font-bold">{valueFormatter(total)}</div>
921
+ {currentPath.length > 0 && (
922
+ <div className="text-xs text-muted-foreground">
923
+ {((total / fullTotal) * 100).toFixed(1)}% of total
924
+ </div>
925
+ )}
926
+ </div>
927
+
928
+ {showComparison && (
929
+ <div className="text-right">
930
+ <div className="text-sm text-muted-foreground">vs. Previous Period</div>
931
+ <div className="flex items-center gap-2 justify-end">
932
+ {(() => {
933
+ const totalChange = currentData.reduce((sum, item) => sum + (item.change || 0) * getItemTotal(item), 0) / total
934
+ return (
935
+ <span
936
+ className={cn(
937
+ "text-lg font-semibold",
938
+ totalChange > 0 && "text-red-600",
939
+ totalChange < 0 && "text-green-600",
940
+ totalChange === 0 && "text-muted-foreground"
941
+ )}
942
+ >
943
+ {totalChange > 0 ? "+" : ""}
944
+ {totalChange.toFixed(1)}%
945
+ </span>
946
+ )
947
+ })()}
948
+ </div>
949
+ </div>
950
+ )}
951
+ </div>
952
+
953
+ {/* Visualization and Legend */}
954
+ <div className={cn("flex gap-6", showLegend ? "flex-row" : "flex-col")}>
955
+ {/* Visualization */}
956
+ <div className="flex-1 min-w-0">
957
+ {variant === "treemap" ? (
958
+ <Treemap
959
+ data={currentData}
960
+ width={400}
961
+ height={300}
962
+ onItemClick={handleItemClick}
963
+ hoveredId={hoveredId}
964
+ onHover={setHoveredId}
965
+ formatValue={valueFormatter}
966
+ showComparison={showComparison}
967
+ animated={animated}
968
+ total={total}
969
+ />
970
+ ) : (
971
+ <Sunburst
972
+ data={currentData}
973
+ size={350}
974
+ onItemClick={handleItemClick}
975
+ hoveredId={hoveredId}
976
+ onHover={setHoveredId}
977
+ formatValue={valueFormatter}
978
+ showComparison={showComparison}
979
+ animated={animated}
980
+ total={total}
981
+ />
982
+ )}
983
+ </div>
984
+
985
+ {/* Legend */}
986
+ {showLegend && (
987
+ <div className="w-64 flex-shrink-0">
988
+ <div className="text-sm font-medium mb-2 text-muted-foreground">
989
+ {currentPath.length > 0 ? "Subcategories" : "Categories"}
990
+ </div>
991
+ <Legend
992
+ items={currentData}
993
+ total={total}
994
+ formatValue={valueFormatter}
995
+ hoveredId={hoveredId}
996
+ onHover={setHoveredId}
997
+ onClick={handleItemClick}
998
+ showComparison={showComparison}
999
+ />
1000
+ </div>
1001
+ )}
1002
+ </div>
1003
+ </div>
1004
+ )
1005
+ }
1006
+
1007
+ // ============================================================================
1008
+ // Hook for cost breakdown
1009
+ // ============================================================================
1010
+
1011
+ export interface UseCostBreakdownOptions {
1012
+ data: CostItem[]
1013
+ onDrillDown?: (path: string[]) => void
1014
+ }
1015
+
1016
+ export function useCostBreakdown({ data, onDrillDown }: UseCostBreakdownOptions) {
1017
+ const [path, setPath] = React.useState<string[]>([])
1018
+ const [selectedItem, setSelectedItem] = React.useState<string | null>(null)
1019
+
1020
+ const drillDown = React.useCallback(
1021
+ (itemId: string) => {
1022
+ const newPath = [...path, itemId]
1023
+ setPath(newPath)
1024
+ onDrillDown?.(newPath)
1025
+ },
1026
+ [path, onDrillDown]
1027
+ )
1028
+
1029
+ const drillUp = React.useCallback(() => {
1030
+ const newPath = path.slice(0, -1)
1031
+ setPath(newPath)
1032
+ onDrillDown?.(newPath)
1033
+ }, [path, onDrillDown])
1034
+
1035
+ const reset = React.useCallback(() => {
1036
+ setPath([])
1037
+ setSelectedItem(null)
1038
+ onDrillDown?.([])
1039
+ }, [onDrillDown])
1040
+
1041
+ const getCurrentData = React.useCallback(() => {
1042
+ let items = data
1043
+ for (const id of path) {
1044
+ const found = items.find((item) => item.id === id)
1045
+ if (found?.children) {
1046
+ items = found.children
1047
+ } else {
1048
+ break
1049
+ }
1050
+ }
1051
+ return items
1052
+ }, [data, path])
1053
+
1054
+ return {
1055
+ path,
1056
+ selectedItem,
1057
+ setSelectedItem,
1058
+ drillDown,
1059
+ drillUp,
1060
+ reset,
1061
+ getCurrentData,
1062
+ }
1063
+ }
1064
+
1065
+ export default WakaCostBreakdown