portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,283 @@
1
+ // MiniChart — the compact single-series sparkline (line / bar), ported from
2
+ // web-mojo src/extensions/charts/MiniChart.js (674 lines, read in full).
3
+ // Carries: area fill, curve smoothing, hard crops (minValue/maxValue), bar
4
+ // soft bounds (softMin/softMax expand-only targets), the zero-crossing-aware
5
+ // x-axis, per-bucket hover tooltip + crosshair + bar highlight, dots, and
6
+ // the all-zero "alive, just zero" dashed baseline. Draw-in animation uses
7
+ // pathLength-normalized CSS (no getTotalLength measuring).
8
+ //
9
+ // Deviations, deliberate: geometry comes from a measured container width
10
+ // (ResizeObserver — the SeriesChart pattern) instead of the source's
11
+ // stretched viewBox; colors default to theme tokens; the tooltip formatter
12
+ // is a typed function (never a DataFormatter pipe string).
13
+ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
14
+ import { toNumber } from './stats';
15
+
16
+ export type MiniChartType = 'line' | 'bar';
17
+
18
+ export interface MiniChartProps {
19
+ data: readonly number[];
20
+ /** Per-bucket tooltip labels (index-aligned with data). */
21
+ labels?: readonly string[];
22
+ /** 'line' | 'bar'. Unknown values warn and fall back to 'line'. */
23
+ chartType?: MiniChartType;
24
+ /** px, or 'fill' to take the container's height (uniform-height cards). */
25
+ height?: number | 'fill';
26
+ /** Stroke / bar color — any CSS color incl. var() tokens. */
27
+ color?: string;
28
+ /** Area fill under the line. Default: `color` at 0.12 opacity. */
29
+ fillColor?: string;
30
+ fill?: boolean;
31
+ strokeWidth?: number;
32
+ barGap?: number;
33
+ /** 0 = straight segments; the source's cubic smoothing. */
34
+ smoothing?: number;
35
+ padding?: number;
36
+ /** Hard axis crops (both chart types). */
37
+ minValue?: number;
38
+ maxValue?: number;
39
+ /** Bar-only soft bounds: normalize to these, expand if data exceeds. */
40
+ softMin?: number;
41
+ softMax?: number;
42
+ showDots?: boolean;
43
+ dotRadius?: number;
44
+ showTooltip?: boolean;
45
+ valueFormatter?: (v: number) => string;
46
+ showCrosshair?: boolean;
47
+ showXAxis?: boolean;
48
+ animate?: boolean;
49
+ className?: string;
50
+ }
51
+
52
+ /** The source's simpler horizontal-control-point smoothing (not Catmull-Rom). */
53
+ function smoothPath(points: readonly { x: number; y: number }[], smoothing: number): string {
54
+ if (points.length === 0) return '';
55
+ if (points.length < 2 || smoothing <= 0) {
56
+ return `M ${points.map((p) => `${p.x},${p.y}`).join(' L ')}`;
57
+ }
58
+ let path = `M ${points[0]!.x},${points[0]!.y}`;
59
+ for (let i = 0; i < points.length - 1; i++) {
60
+ const cur = points[i]!;
61
+ const next = points[i + 1]!;
62
+ const cp1x = cur.x + (next.x - cur.x) * smoothing;
63
+ const cp2x = next.x - (next.x - cur.x) * smoothing;
64
+ path += ` C ${cp1x},${cur.y} ${cp2x},${next.y} ${next.x},${next.y}`;
65
+ }
66
+ return path;
67
+ }
68
+
69
+ export function MiniChart({
70
+ data: dataProp,
71
+ labels,
72
+ chartType = 'line',
73
+ height: heightProp = 48,
74
+ color = 'var(--accent)',
75
+ fillColor,
76
+ fill = true,
77
+ strokeWidth = 2,
78
+ barGap = 2,
79
+ smoothing = 0.3,
80
+ padding = 2,
81
+ minValue,
82
+ maxValue,
83
+ softMin,
84
+ softMax,
85
+ showDots = false,
86
+ dotRadius = 2,
87
+ showTooltip = true,
88
+ valueFormatter,
89
+ showCrosshair = true,
90
+ showXAxis = false,
91
+ animate = true,
92
+ className = '',
93
+ }: MiniChartProps) {
94
+ // Malformed feed → warn + empty (unknown-value rule; never crash).
95
+ const validShape = Array.isArray(dataProp);
96
+ if (!validShape) {
97
+ console.warn('MiniChart: data is not an array — rendering empty', { data: dataProp });
98
+ }
99
+ let type: MiniChartType = chartType;
100
+ if (type !== 'line' && type !== 'bar') {
101
+ console.warn(`MiniChart: unknown chartType "${String(chartType)}" — falling back to "line"`);
102
+ type = 'line';
103
+ }
104
+ const values = useMemo(() => (validShape ? dataProp.map(toNumber) : []), [validShape, dataProp]);
105
+
106
+ const wrapRef = useRef<HTMLDivElement>(null);
107
+ const [width, setWidth] = useState(120);
108
+ const [fillH, setFillH] = useState(72);
109
+ const [hover, setHover] = useState<number | null>(null);
110
+
111
+ useLayoutEffect(() => {
112
+ const node = wrapRef.current;
113
+ if (!node) return;
114
+ const ro = new ResizeObserver(([entry]) => {
115
+ const w = entry?.contentRect.width ?? 0;
116
+ if (w > 0) setWidth(w);
117
+ // 'fill' cards take their height from the box too, so a chart can
118
+ // grow into a stretched card instead of leaving a void under it.
119
+ const h = entry?.contentRect.height ?? 0;
120
+ if (h > 0) setFillH(h);
121
+ });
122
+ ro.observe(node);
123
+ return () => ro.disconnect();
124
+ }, []);
125
+
126
+ // Resolve 'fill' to the measured box height.
127
+ const height = heightProp === 'fill' ? fillH : heightProp;
128
+
129
+ const n = values.length;
130
+ const dataMin = n ? Math.min(...values) : 0;
131
+ const dataMax = n ? Math.max(...values) : 0;
132
+
133
+ // Line bounds (source calculateBounds): hard crops win; zero-range pads.
134
+ let lineMin = minValue ?? dataMin;
135
+ let lineMax = maxValue ?? dataMax;
136
+ if (lineMax - lineMin === 0) {
137
+ if (type === 'bar' && lineMin === 0) lineMax = 1;
138
+ else { lineMin -= 1; lineMax += 1; }
139
+ }
140
+
141
+ const drawTop = padding;
142
+ const drawBottom = height - padding;
143
+ const drawH = drawBottom - drawTop;
144
+ const xStep = (width - padding * 2) / (n - 1 || 1);
145
+ const points = values.map((v, i) => ({
146
+ x: padding + i * xStep,
147
+ y: drawBottom - ((v - lineMin) / (lineMax - lineMin)) * drawH,
148
+ }));
149
+
150
+ // Bar bounds (source renderBar): always include zero; soft bounds expand.
151
+ const barLo = minValue !== undefined ? minValue : Math.min(0, softMin ?? 0, dataMin);
152
+ const barHiRaw = maxValue !== undefined ? maxValue : Math.max(0, softMax ?? 0, dataMax);
153
+ const barHi = barHiRaw === barLo ? barLo + 1 : barHiRaw;
154
+ const yScale = drawH / (barHi - barLo);
155
+ const yBase = Math.max(drawTop, Math.min(drawBottom, drawBottom - (0 - barLo) * yScale));
156
+
157
+ const allZero = n > 0 && dataMin === 0 && dataMax === 0;
158
+ const callerBounds = minValue !== undefined || maxValue !== undefined || softMin !== undefined || softMax !== undefined;
159
+ const barsAsBaseline = type === 'bar' && allZero && !callerBounds;
160
+
161
+ // X-axis at zero when the data crosses it, else at the bottom (source).
162
+ let axisY = drawBottom;
163
+ if (lineMin <= 0 && lineMax >= 0) {
164
+ axisY = drawBottom - ((0 - lineMin) / (lineMax - lineMin)) * drawH;
165
+ }
166
+
167
+ const barW = n > 0 ? Math.max(1, (width - padding * 2 - barGap * (n - 1)) / n) : 0;
168
+ const fmt = valueFormatter ?? ((v: number) => v.toLocaleString());
169
+ const fillPaint = fillColor ?? color;
170
+ const fillOpacity = fillColor ? 1 : 0.12;
171
+
172
+ const colW = n > 0 ? width / n : width;
173
+ const onMove = (e: React.MouseEvent<SVGRectElement>) => {
174
+ const rect = e.currentTarget.getBoundingClientRect();
175
+ const x = e.clientX - rect.left;
176
+ setHover(Math.min(n - 1, Math.max(0, Math.floor(x / (colW || 1)))));
177
+ };
178
+
179
+ // Re-run the draw-in whenever the series identity changes (source parity:
180
+ // every setData re-animated).
181
+ const dataKey = useMemo(() => values.join('|'), [values]);
182
+
183
+ const linePath = smoothPath(points, smoothing);
184
+ const areaPath = points.length
185
+ ? `${linePath} L ${points[points.length - 1]!.x},${drawBottom} L ${points[0]!.x},${drawBottom} Z`
186
+ : '';
187
+
188
+ const tipX = hover != null ? Math.min(Math.max(colW * (hover + 0.5), 40), Math.max(width - 40, 40)) : 0;
189
+
190
+ return (
191
+ <div
192
+ ref={wrapRef}
193
+ className={`minichart${className ? ` ${className}` : ''}`}
194
+ style={{ height: heightProp === 'fill' ? '100%' : height }}
195
+ onMouseLeave={() => setHover(null)}
196
+ >
197
+ <svg width={width} height={height} className="minichart-svg" role="img" key={animate ? dataKey : 'static'}>
198
+ {showXAxis && n > 0 && (
199
+ <line
200
+ x1={padding} x2={width - padding} y1={axisY} y2={axisY}
201
+ stroke={color} strokeWidth={1} strokeOpacity={0.5} strokeDasharray="2,2"
202
+ />
203
+ )}
204
+
205
+ {n > 0 && type === 'line' && (
206
+ <>
207
+ {fill && (
208
+ <path d={areaPath} fill={fillPaint} fillOpacity={fillOpacity} stroke="none" />
209
+ )}
210
+ <path
211
+ d={linePath}
212
+ fill="none"
213
+ stroke={color}
214
+ strokeWidth={strokeWidth}
215
+ strokeLinecap="round"
216
+ strokeLinejoin="round"
217
+ pathLength={1}
218
+ className={animate ? 'minichart-draw' : undefined}
219
+ />
220
+ {showDots && points.map((p, i) => (
221
+ <circle key={i} cx={p.x} cy={p.y} r={hover === i ? dotRadius + 1 : dotRadius} fill={color} />
222
+ ))}
223
+ </>
224
+ )}
225
+
226
+ {n > 0 && type === 'bar' && (barsAsBaseline ? (
227
+ // All-zero, no caller bounds: dashed baseline says "alive,
228
+ // just zero" instead of looking broken (source behavior).
229
+ <line
230
+ x1={padding} x2={width - padding} y1={drawBottom} y2={drawBottom}
231
+ stroke={color} strokeWidth={1} strokeOpacity={0.4} strokeDasharray="2,2"
232
+ />
233
+ ) : (
234
+ values.map((v, i) => {
235
+ const yRaw = drawBottom - (v - barLo) * yScale;
236
+ const yVal = Math.max(drawTop, Math.min(drawBottom, yRaw));
237
+ const top = Math.min(yVal, yBase);
238
+ const h = Math.max(0, Math.abs(yVal - yBase));
239
+ return (
240
+ <rect
241
+ key={i}
242
+ x={points[i]!.x - barW / 2}
243
+ y={top}
244
+ width={barW}
245
+ height={h}
246
+ rx={1}
247
+ fill={color}
248
+ opacity={hover === i ? 0.7 : 1}
249
+ className={animate ? 'minichart-grow' : undefined}
250
+ style={animate ? { animationDelay: `${i * 20}ms` } : undefined}
251
+ />
252
+ );
253
+ })
254
+ ))}
255
+
256
+ {showCrosshair && hover != null && (
257
+ <line
258
+ x1={colW * (hover + 0.5)} x2={colW * (hover + 0.5)}
259
+ y1={0} y2={height}
260
+ className="minichart-crosshair"
261
+ />
262
+ )}
263
+
264
+ {/* One overlay drives index hover (SeriesChart pattern). */}
265
+ {n > 0 && (showTooltip || showCrosshair) && (
266
+ <rect
267
+ x={0} y={0} width={width} height={height}
268
+ fill="transparent"
269
+ onMouseMove={onMove}
270
+ onMouseLeave={() => setHover(null)}
271
+ />
272
+ )}
273
+ </svg>
274
+
275
+ {showTooltip && hover != null && n > 0 && (
276
+ <div className="minichart-tip" style={{ left: tipX }}>
277
+ {labels?.[hover] != null && <div className="minichart-tip-label">{labels[hover]}</div>}
278
+ <strong>{fmt(values[hover] ?? 0)}</strong>
279
+ </div>
280
+ )}
281
+ </div>
282
+ );
283
+ }
@@ -0,0 +1,354 @@
1
+ // PieChart — native SVG pie / doughnut, ported from web-mojo
2
+ // src/extensions/charts/PieChart.js (546 lines, read in full). No chart
3
+ // library. Carries: the three input shapes (array of {label,value,color?} /
4
+ // Chart.js {labels,datasets} / object map), optional endpoint fetch through
5
+ // the one client boundary, the golden-angle color generator, cutout +
6
+ // center label/sub-label (static or computed), right/bottom legend, slice
7
+ // edge labels with percentages, clamped tooltip, slice click, the
8
+ // full-circle single-segment special case, and the label-keyed arc tween.
9
+ //
10
+ // Deviations, deliberate:
11
+ // · default palette is CHART_PALETTE (the mission-control set SeriesChart
12
+ // uses) instead of web-mojo's Chart.js-era hexes; overflow indices go to
13
+ // the golden-angle generator exactly as in the source;
14
+ // · valueFormatter is a typed function (never a DataFormatter pipe string);
15
+ // · malformed data warns and renders the empty state (the source silently
16
+ // rendered nothing);
17
+ // · endpoint errors warn + empty state; TanStack owns retry/caching.
18
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
19
+ import { useQuery } from '@tanstack/react-query';
20
+ import { mojoCall } from '../client/client';
21
+ import type { Params } from '../client/types';
22
+ import { CHART_PALETTE } from './SeriesChart';
23
+ import {
24
+ arcPath,
25
+ buildPieGeometry,
26
+ goldenAngleColor,
27
+ interpolateSegments,
28
+ parsePieInput,
29
+ type PieInput,
30
+ type PieSegment,
31
+ } from './pie-math';
32
+
33
+ /** The center-label context ({total, segments} — source ctx shape). */
34
+ export interface PieCenterContext {
35
+ total: number;
36
+ segments: readonly PieSegment[];
37
+ }
38
+
39
+ export type PieCenterLabel = string | number | ((ctx: PieCenterContext) => string | number);
40
+
41
+ export interface PieChartProps {
42
+ /** One of the three shapes. Ignored while `endpoint` is set. */
43
+ data?: PieInput | null;
44
+ /** GET this endpoint through the client boundary; its `data` feeds the pie. */
45
+ endpoint?: string;
46
+ /** Wire params for the endpoint fetch. */
47
+ params?: Params;
48
+ width?: number;
49
+ height?: number;
50
+ /** 0 = solid pie; 0..1 = inner-hole fraction (doughnut). */
51
+ cutout?: number;
52
+ /** Doughnut center text (needs cutout > 0). String or ({total, segments}) => string. */
53
+ centerLabel?: PieCenterLabel | null;
54
+ /** Smaller uppercase line under the center label. */
55
+ centerSubLabel?: PieCenterLabel | null;
56
+ /** Per-index colors; overflow falls to `colorGenerator`. Default CHART_PALETTE. */
57
+ colors?: string[];
58
+ colorGenerator?: (i: number) => string;
59
+ showLegend?: boolean;
60
+ /** 'right' | 'bottom' | 'none'. Unknown values warn and fall back to 'right'. */
61
+ legendPosition?: 'right' | 'bottom' | 'none';
62
+ /** Slice-edge labels outside the arc. */
63
+ showLabels?: boolean;
64
+ /** Append "(12.5%)" to edge labels; legend always shows percentages. */
65
+ showPercentages?: boolean;
66
+ showTooltip?: boolean;
67
+ animate?: boolean;
68
+ animationDuration?: number;
69
+ valueFormatter?: (v: number) => string;
70
+ onSliceClick?: (e: { label: string; value: number; pct: number; index: number }) => void;
71
+ emptyText?: string;
72
+ className?: string;
73
+ }
74
+
75
+ const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
76
+
77
+ export function PieChart({
78
+ data: dataProp = null,
79
+ endpoint,
80
+ params,
81
+ width = 200,
82
+ height = 200,
83
+ cutout = 0,
84
+ centerLabel = null,
85
+ centerSubLabel = null,
86
+ colors,
87
+ colorGenerator = goldenAngleColor,
88
+ showLegend = true,
89
+ legendPosition = 'right',
90
+ showLabels = false,
91
+ showPercentages = true,
92
+ showTooltip = true,
93
+ animate = true,
94
+ animationDuration = 300,
95
+ valueFormatter,
96
+ onSliceClick,
97
+ emptyText = 'No data',
98
+ className = '',
99
+ }: PieChartProps) {
100
+ let legendPos: 'right' | 'bottom' | 'none' = legendPosition;
101
+ if (legendPos !== 'right' && legendPos !== 'bottom' && legendPos !== 'none') {
102
+ console.warn(`PieChart: unknown legendPosition "${String(legendPosition)}" — falling back to "right"`);
103
+ legendPos = 'right';
104
+ }
105
+ const legendOn = showLegend && legendPos !== 'none';
106
+
107
+ // ── Endpoint fetch (optional) through the one client boundary ─────
108
+ const query = useQuery({
109
+ queryKey: [endpoint, 'pie', params ?? {}],
110
+ queryFn: () => mojoCall(endpoint!, { params }),
111
+ enabled: !!endpoint,
112
+ });
113
+ useEffect(() => {
114
+ if (query.isError) {
115
+ console.warn(`PieChart: endpoint fetch failed (${endpoint}) — rendering empty state`, query.error);
116
+ }
117
+ }, [query.isError, query.error, endpoint]);
118
+
119
+ // Source heuristic carried over: some endpoints nest the payload one
120
+ // level deeper (`data.data`) — prefer it when present.
121
+ const raw: unknown = endpoint
122
+ ? (query.data ? ((query.data.data as { data?: unknown } | null)?.data ?? query.data.data) : null)
123
+ : dataProp;
124
+
125
+ // ── Normalize + color + geometry ──────────────────────────────────
126
+ const palette = colors && colors.length ? colors : CHART_PALETTE;
127
+ const target = useMemo(() => {
128
+ const slices = parsePieInput(raw);
129
+ const total = slices.reduce((s, d) => s + d.value, 0);
130
+ const colored = slices.map((s, i) => ({
131
+ label: s.label,
132
+ value: s.value,
133
+ pct: total > 0 ? (s.value / total) * 100 : 0,
134
+ color: s.color ?? palette[i] ?? colorGenerator(i),
135
+ }));
136
+ return buildPieGeometry(colored);
137
+ }, [raw, palette, colorGenerator]);
138
+ const total = useMemo(() => target.reduce((s, seg) => s + seg.value, 0), [target]);
139
+
140
+ // ── Tween (label-keyed, rAF, cancelled on change/unmount) ─────────
141
+ const [painted, setPainted] = useState<readonly PieSegment[]>(target);
142
+ const prevRef = useRef<readonly PieSegment[]>(target);
143
+ const firstPaint = useRef(true);
144
+ const targetKey = useMemo(
145
+ () => target.map((s) => `${s.label}:${s.value}`).join('|'),
146
+ [target],
147
+ );
148
+ useEffect(() => {
149
+ // First paint is direct (source: onAfterRender painted without animation).
150
+ if (firstPaint.current || !animate || animationDuration <= 0) {
151
+ firstPaint.current = false;
152
+ prevRef.current = target;
153
+ setPainted(target);
154
+ return;
155
+ }
156
+ const prev = prevRef.current;
157
+ const start = performance.now();
158
+ let raf = 0;
159
+ const step = (now: number) => {
160
+ const t = Math.min(1, (now - start) / animationDuration);
161
+ setPainted(interpolateSegments(prev, target, easeOutCubic(t)));
162
+ if (t < 1) raf = requestAnimationFrame(step);
163
+ else prevRef.current = target;
164
+ };
165
+ raf = requestAnimationFrame(step);
166
+ return () => cancelAnimationFrame(raf);
167
+ // targetKey is the identity of `target`; the ref carries the old one.
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [targetKey, animate, animationDuration]);
170
+
171
+ // ── Tooltip (clamped inside the svg area, source _moveTooltip) ────
172
+ const [hover, setHover] = useState<number | null>(null);
173
+ // Legend hover highlights a slice but must not summon the mouse-anchored
174
+ // tooltip (it would render at a stale position) — arc hover only.
175
+ const [tipOn, setTipOn] = useState(false);
176
+ const areaRef = useRef<HTMLDivElement>(null);
177
+ const tipRef = useRef<HTMLDivElement>(null);
178
+ const mouseRef = useRef({ x: 0, y: 0 });
179
+ const placeTip = () => {
180
+ const tip = tipRef.current;
181
+ const area = areaRef.current;
182
+ if (!tip || !area) return;
183
+ const aw = area.clientWidth;
184
+ const ah = area.clientHeight;
185
+ const tw = tip.offsetWidth;
186
+ const th = tip.offsetHeight;
187
+ let left = mouseRef.current.x - tw / 2;
188
+ let top = mouseRef.current.y - th - 8;
189
+ if (left < 0) left = 0;
190
+ if (left + tw > aw) left = aw - tw;
191
+ if (top < 0) top = mouseRef.current.y + 12;
192
+ if (top + th > ah) top = ah - th;
193
+ tip.style.left = `${left}px`;
194
+ tip.style.top = `${top}px`;
195
+ };
196
+ useLayoutEffect(() => { if (hover != null) placeTip(); });
197
+ const trackMouse = (e: React.MouseEvent) => {
198
+ const area = areaRef.current;
199
+ if (!area) return;
200
+ const rect = area.getBoundingClientRect();
201
+ mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
202
+ placeTip();
203
+ };
204
+
205
+ const fmt = valueFormatter ?? ((v: number) => v.toLocaleString());
206
+
207
+ const cx = width / 2;
208
+ const cy = height / 2;
209
+ const outerR = Math.min(cx, cy) - 4;
210
+ const innerR = cutout > 0 ? outerR * Math.min(1, Math.max(0, cutout)) : 0;
211
+
212
+ const visible = painted.filter((s) => Math.abs(s.endAngle - s.startAngle) > 1e-6);
213
+ const fullCircle = visible.length === 1
214
+ && Math.abs(Math.abs(visible[0]!.endAngle - visible[0]!.startAngle) - Math.PI * 2) < 1e-3;
215
+ const hasData = target.length > 0 && total > 0;
216
+
217
+ const centerCtx: PieCenterContext = { total, segments: target };
218
+ const resolveCenter = (v: PieCenterLabel | null): string | null => {
219
+ if (v == null) return null;
220
+ return String(typeof v === 'function' ? v(centerCtx) : v);
221
+ };
222
+ const centerMain = resolveCenter(centerLabel);
223
+ const centerSub = resolveCenter(centerSubLabel);
224
+
225
+ const sliceHandlers = (i: number) => ({
226
+ onMouseEnter: (e: React.MouseEvent) => { setHover(i); if (showTooltip) { setTipOn(true); trackMouse(e); } },
227
+ onMouseMove: (e: React.MouseEvent) => { if (showTooltip) trackMouse(e); },
228
+ onMouseLeave: () => { setHover(null); setTipOn(false); },
229
+ onClick: () => {
230
+ const seg = target[i];
231
+ if (seg && onSliceClick) onSliceClick({ label: seg.label, value: seg.value, pct: seg.pct, index: i });
232
+ },
233
+ });
234
+
235
+ const hovered = hover != null ? target[hover] : null;
236
+
237
+ return (
238
+ <div className={`pie-chart pie-layout-${legendPos === 'bottom' ? 'bottom' : 'right'}${className ? ` ${className}` : ''}`}>
239
+ <div className="pie-svg-area" style={{ width, height }} ref={areaRef}>
240
+ {!hasData ? (
241
+ <div className="pie-empty">{endpoint && query.isPending ? <span className="skel skel-block" /> : emptyText}</div>
242
+ ) : (
243
+ <svg
244
+ className="pie-svg"
245
+ viewBox={`0 0 ${width} ${height}`}
246
+ width="100%"
247
+ height="100%"
248
+ preserveAspectRatio="xMidYMid meet"
249
+ role="img"
250
+ >
251
+ {fullCircle ? (
252
+ // Single-segment special case: SVG arcs cannot draw a
253
+ // full 360° — a circle element does (source parity).
254
+ innerR > 0 ? (
255
+ <circle
256
+ cx={cx} cy={cy} r={(outerR + innerR) / 2}
257
+ fill="none" stroke={visible[0]!.color} strokeWidth={outerR - innerR}
258
+ className="pie-segment"
259
+ {...sliceHandlers(painted.indexOf(visible[0]!))}
260
+ />
261
+ ) : (
262
+ <circle
263
+ cx={cx} cy={cy} r={outerR} fill={visible[0]!.color}
264
+ className="pie-segment"
265
+ {...sliceHandlers(painted.indexOf(visible[0]!))}
266
+ />
267
+ )
268
+ ) : (
269
+ painted.map((seg, i) => {
270
+ if (seg.endAngle - seg.startAngle <= 0) return null;
271
+ return (
272
+ <path
273
+ key={seg.label || i}
274
+ d={arcPath(cx, cy, outerR, innerR, seg)}
275
+ fill={seg.color}
276
+ stroke="var(--surface)"
277
+ strokeWidth={1.5}
278
+ className={`pie-segment${hover === i ? ' pie-segment-hot' : ''}`}
279
+ {...sliceHandlers(i)}
280
+ />
281
+ );
282
+ })
283
+ )}
284
+
285
+ {/* Slice-edge labels */}
286
+ {showLabels && !fullCircle && painted.map((seg, i) => {
287
+ const arc = seg.endAngle - seg.startAngle;
288
+ if (arc <= 0.05) return null;
289
+ const mid = (seg.startAngle + seg.endAngle) / 2;
290
+ const lx = cx + (outerR + 12) * Math.cos(mid);
291
+ const ly = cy + (outerR + 12) * Math.sin(mid);
292
+ return (
293
+ <text
294
+ key={`l${seg.label || i}`}
295
+ x={lx} y={ly}
296
+ textAnchor={lx < cx ? 'end' : 'start'}
297
+ className="pie-edge-label"
298
+ >
299
+ {showPercentages ? `${seg.label} (${seg.pct.toFixed(1)}%)` : seg.label}
300
+ </text>
301
+ );
302
+ })}
303
+
304
+ {/* Doughnut center label(s) */}
305
+ {innerR > 0 && centerMain != null && (
306
+ <text
307
+ x={cx} y={centerSub != null ? cy - 2 : cy + 6}
308
+ textAnchor="middle" dominantBaseline="middle"
309
+ className="pie-center-label"
310
+ >
311
+ {centerMain}
312
+ </text>
313
+ )}
314
+ {innerR > 0 && centerSub != null && (
315
+ <text
316
+ x={cx} y={cy + 16}
317
+ textAnchor="middle" dominantBaseline="middle"
318
+ className="pie-center-sub"
319
+ >
320
+ {centerSub.toUpperCase()}
321
+ </text>
322
+ )}
323
+ </svg>
324
+ )}
325
+
326
+ {showTooltip && tipOn && hovered && (
327
+ <div className="pie-tip" ref={tipRef}>
328
+ <strong>{hovered.label}</strong>
329
+ <span>{fmt(hovered.value)} ({hovered.pct.toFixed(1)}%)</span>
330
+ </div>
331
+ )}
332
+ </div>
333
+
334
+ {legendOn && hasData && (
335
+ <div className="pie-legend">
336
+ {target.map((seg, i) => (
337
+ <div
338
+ key={seg.label || i}
339
+ className={`pie-legend-item${hover === i ? ' pie-legend-hot' : ''}`}
340
+ onMouseEnter={() => setHover(i)}
341
+ onMouseLeave={() => setHover(null)}
342
+ >
343
+ {/* Color as a style property, never string-interpolated markup —
344
+ the value can come from the API (source note carried over). */}
345
+ <span className="pie-legend-swatch" style={{ background: seg.color }} />
346
+ <span className="pie-legend-label">{seg.label}</span>
347
+ <span className="pie-legend-value">{seg.pct.toFixed(1)}%</span>
348
+ </div>
349
+ ))}
350
+ </div>
351
+ )}
352
+ </div>
353
+ );
354
+ }