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,378 @@
1
+ // SeriesChart — dependency-free SVG multi-dataset chart, ported from
2
+ // web-mojo's SeriesChart. line / bar / area, bars STACKED by default
3
+ // (stacked:'auto' → true for bar), grouped opt-out, legend, gridlines,
4
+ // nice Y ticks, thinned X labels, and Chart.js-style "index mode"
5
+ // crosshair tooltips (snap to nearest column, ghost dots, multi-row card).
6
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
7
+
8
+ export type ChartType = 'line' | 'bar' | 'area';
9
+
10
+ export interface Dataset {
11
+ label: string;
12
+ data: number[];
13
+ color?: string;
14
+ }
15
+
16
+ export interface SeriesChartProps {
17
+ labels: string[];
18
+ datasets: Dataset[];
19
+ chartType?: ChartType;
20
+ /** 'auto' (default) → stacked for bar, unstacked otherwise. */
21
+ stacked?: boolean | 'auto';
22
+ height?: number;
23
+ showLegend?: boolean;
24
+ showGrid?: boolean;
25
+ gridLines?: number;
26
+ showXLabels?: boolean;
27
+ showYLabels?: boolean;
28
+ /** Rendered in tooltips and Y ticks. */
29
+ valueFormatter?: (v: number) => string;
30
+ smoothing?: number; // 0 = straight segments
31
+ showDots?: boolean;
32
+ emptyText?: string;
33
+ }
34
+
35
+ export const CHART_PALETTE = [
36
+ '#4c8dff', '#4cc98a', '#d9a441', '#e07a7a',
37
+ '#a78bfa', '#4db8d4', '#f0883e', '#8a96a6',
38
+ ];
39
+
40
+ const PAD = { top: 10, right: 14, bottom: 26, left: 48 };
41
+
42
+ function niceNum(range: number, round: boolean): number {
43
+ const exp = Math.floor(Math.log10(range || 1));
44
+ const f = (range || 1) / Math.pow(10, exp);
45
+ let nf: number;
46
+ if (round) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10;
47
+ else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
48
+ return nf * Math.pow(10, exp);
49
+ }
50
+
51
+ /** Axis bounds on human-friendly round numbers. */
52
+ function niceScale(min: number, max: number, ticks: number) {
53
+ if (max === min) max = min + 1;
54
+ const range = niceNum(max - min, false);
55
+ const step = niceNum(range / Math.max(1, ticks - 1), true);
56
+ return {
57
+ min: Math.floor(min / step) * step,
58
+ max: Math.ceil(max / step) * step,
59
+ step,
60
+ };
61
+ }
62
+
63
+ function defaultFormat(v: number): string {
64
+ if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
65
+ if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
66
+ return Number.isInteger(v) ? String(v) : v.toFixed(1);
67
+ }
68
+
69
+ /** Catmull-Rom → cubic bezier, matching web-mojo's `smoothing` option. */
70
+ function smoothPath(pts: [number, number][], tension: number): string {
71
+ if (pts.length < 2) return '';
72
+ if (tension <= 0) return `M${pts.map(([x, y]) => `${x},${y}`).join('L')}`;
73
+ let d = `M${pts[0]![0]},${pts[0]![1]}`;
74
+ for (let i = 0; i < pts.length - 1; i++) {
75
+ const p0 = pts[i - 1] ?? pts[i]!;
76
+ const p1 = pts[i]!;
77
+ const p2 = pts[i + 1]!;
78
+ const p3 = pts[i + 2] ?? p2;
79
+ const c1x = p1[0] + ((p2[0] - p0[0]) / 6) * tension;
80
+ const c1y = p1[1] + ((p2[1] - p0[1]) / 6) * tension;
81
+ const c2x = p2[0] - ((p3[0] - p1[0]) / 6) * tension;
82
+ const c2y = p2[1] - ((p3[1] - p1[1]) / 6) * tension;
83
+ d += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
84
+ }
85
+ return d;
86
+ }
87
+
88
+ export function SeriesChart({
89
+ labels: labelsProp,
90
+ datasets: datasetsProp,
91
+ chartType = 'line',
92
+ stacked = 'auto',
93
+ height = 260,
94
+ showLegend = true,
95
+ showGrid = true,
96
+ gridLines = 5,
97
+ showXLabels = true,
98
+ showYLabels = true,
99
+ valueFormatter,
100
+ smoothing = 0.3,
101
+ showDots = true,
102
+ emptyText = 'No data for this range',
103
+ }: SeriesChartProps) {
104
+ // Malformed feed data must degrade to the empty state, not take down the
105
+ // route (a live-backend shape drift did exactly that once) — warn + empty,
106
+ // per the unknown-value rule.
107
+ const validShape = Array.isArray(labelsProp) && Array.isArray(datasetsProp);
108
+ if (!validShape) {
109
+ console.warn('SeriesChart: labels/datasets are not arrays — rendering empty state', { labels: labelsProp, datasets: datasetsProp });
110
+ }
111
+ const labels = validShape ? labelsProp : [];
112
+ const datasets = validShape ? datasetsProp : [];
113
+
114
+ const wrapRef = useRef<HTMLDivElement>(null);
115
+ const [width, setWidth] = useState(600);
116
+ const [hover, setHover] = useState<number | null>(null);
117
+ const [visible, setVisible] = useState<Set<string>>(() => new Set());
118
+
119
+ // Measure the container — real pixel geometry keeps text unscaled
120
+ // (a viewBox + preserveAspectRatio="none" would smear the labels).
121
+ useLayoutEffect(() => {
122
+ const node = wrapRef.current;
123
+ if (!node) return;
124
+ const ro = new ResizeObserver(([entry]) => {
125
+ const w = entry?.contentRect.width ?? 0;
126
+ if (w > 0) setWidth(w);
127
+ });
128
+ ro.observe(node);
129
+ return () => ro.disconnect();
130
+ }, []);
131
+
132
+ // Reset hidden series when the series set itself changes.
133
+ const seriesKey = datasets.map((d) => d.label).join('|');
134
+ useEffect(() => { setVisible(new Set()); }, [seriesKey]);
135
+
136
+ const fmt = valueFormatter ?? defaultFormat;
137
+ const shown = datasets.filter((d) => !visible.has(d.label));
138
+ const isStacked = stacked === 'auto' ? chartType === 'bar' : !!stacked;
139
+
140
+ const plotW = Math.max(40, width - PAD.left - PAD.right);
141
+ const plotH = Math.max(40, height - PAD.top - PAD.bottom);
142
+
143
+ const scale = useMemo(() => {
144
+ let max = 0;
145
+ let min = 0;
146
+ if (isStacked) {
147
+ for (let i = 0; i < labels.length; i++) {
148
+ let pos = 0;
149
+ let neg = 0;
150
+ for (const ds of shown) {
151
+ const v = ds.data[i] ?? 0;
152
+ if (v >= 0) pos += v; else neg += v;
153
+ }
154
+ max = Math.max(max, pos);
155
+ min = Math.min(min, neg);
156
+ }
157
+ } else {
158
+ for (const ds of shown) {
159
+ for (const v of ds.data) {
160
+ max = Math.max(max, v ?? 0);
161
+ min = Math.min(min, v ?? 0);
162
+ }
163
+ }
164
+ }
165
+ return niceScale(min, max, gridLines);
166
+ }, [labels.length, shown, isStacked, gridLines]);
167
+
168
+ const yOf = useCallback(
169
+ (v: number) => PAD.top + plotH - ((v - scale.min) / (scale.max - scale.min || 1)) * plotH,
170
+ [plotH, scale],
171
+ );
172
+
173
+ const n = labels.length;
174
+ const colW = n > 0 ? plotW / n : plotW;
175
+ // Line/area sample at column centers; bars fill the column.
176
+ const xCenter = useCallback((i: number) => PAD.left + colW * (i + 0.5), [colW]);
177
+
178
+ const ticks = useMemo(() => {
179
+ const out: number[] = [];
180
+ for (let v = scale.min; v <= scale.max + scale.step / 2; v += scale.step) out.push(v);
181
+ return out;
182
+ }, [scale]);
183
+
184
+ // Thin X labels to whatever fits — ~54px per label.
185
+ const labelStride = Math.max(1, Math.ceil((n * 54) / Math.max(plotW, 1)));
186
+
187
+ const colorOf = (ds: Dataset, i: number) => ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length]!;
188
+
189
+ const onMove = (e: React.MouseEvent<SVGRectElement>) => {
190
+ const rect = e.currentTarget.getBoundingClientRect();
191
+ const x = e.clientX - rect.left;
192
+ const idx = Math.min(n - 1, Math.max(0, Math.floor(x / (colW || 1))));
193
+ setHover(idx);
194
+ };
195
+
196
+ const hasData = n > 0 && datasets.length > 0;
197
+
198
+ return (
199
+ <div className="chart-wrap" ref={wrapRef}>
200
+ {showLegend && datasets.length > 0 && (
201
+ <div className="chart-legend">
202
+ {datasets.map((ds, i) => {
203
+ const off = visible.has(ds.label);
204
+ return (
205
+ <button
206
+ key={ds.label}
207
+ className={`legend-item${off ? ' legend-off' : ''}`}
208
+ onClick={() => setVisible((prev) => {
209
+ const next = new Set(prev);
210
+ if (next.has(ds.label)) next.delete(ds.label); else next.add(ds.label);
211
+ return next;
212
+ })}
213
+ title={off ? `Show ${ds.label}` : `Hide ${ds.label}`}
214
+ >
215
+ <span className="legend-swatch" style={{ background: colorOf(ds, i) }} />
216
+ {ds.label}
217
+ </button>
218
+ );
219
+ })}
220
+ </div>
221
+ )}
222
+
223
+ {!hasData ? (
224
+ <div className="chart-empty" style={{ height }}>{emptyText}</div>
225
+ ) : (
226
+ <div className="chart-svg-area" style={{ height }}>
227
+ <svg width={width} height={height} className="series-svg" role="img">
228
+ {/* gridlines + Y ticks */}
229
+ {showGrid && ticks.map((t) => (
230
+ <line
231
+ key={`g${t}`}
232
+ x1={PAD.left} x2={PAD.left + plotW}
233
+ y1={yOf(t)} y2={yOf(t)}
234
+ className={t === 0 && scale.min < 0 ? 'grid-line grid-zero' : 'grid-line'}
235
+ />
236
+ ))}
237
+ {showYLabels && ticks.map((t) => (
238
+ <text key={`yl${t}`} x={PAD.left - 8} y={yOf(t)} className="axis-label" textAnchor="end" dominantBaseline="middle">
239
+ {fmt(t)}
240
+ </text>
241
+ ))}
242
+
243
+ {/* bars */}
244
+ {chartType === 'bar' && labels.map((_, i) => {
245
+ const inner = colW * 0.72;
246
+ const groupN = Math.max(1, shown.length);
247
+ let posAcc = 0;
248
+ let negAcc = 0;
249
+ return (
250
+ <g key={`bar${i}`}>
251
+ {shown.map((ds, di) => {
252
+ const v = ds.data[i] ?? 0;
253
+ const color = colorOf(ds, datasets.indexOf(ds));
254
+ let x: number;
255
+ let w: number;
256
+ let y: number;
257
+ let h: number;
258
+ if (isStacked) {
259
+ x = PAD.left + colW * i + (colW - inner) / 2;
260
+ w = inner;
261
+ const base = v >= 0 ? posAcc : negAcc;
262
+ const top = base + v;
263
+ y = yOf(Math.max(base, top));
264
+ h = Math.abs(yOf(top) - yOf(base));
265
+ if (v >= 0) posAcc = top; else negAcc = top;
266
+ } else {
267
+ w = inner / groupN;
268
+ x = PAD.left + colW * i + (colW - inner) / 2 + w * di;
269
+ const zero = yOf(Math.max(0, scale.min));
270
+ y = Math.min(zero, yOf(v));
271
+ h = Math.abs(yOf(v) - zero);
272
+ }
273
+ return (
274
+ <rect
275
+ key={ds.label}
276
+ x={x} y={y}
277
+ width={Math.max(1, w - 1)} height={Math.max(0, h)}
278
+ fill={color}
279
+ className={`bar${hover === i ? ' bar-hot' : ''}`}
280
+ />
281
+ );
282
+ })}
283
+ </g>
284
+ );
285
+ })}
286
+
287
+ {/* area fills + lines */}
288
+ {chartType !== 'bar' && shown.map((ds) => {
289
+ const color = colorOf(ds, datasets.indexOf(ds));
290
+ const pts = ds.data.slice(0, n).map((v, i) => [xCenter(i), yOf(v ?? 0)] as [number, number]);
291
+ if (!pts.length) return null;
292
+ const path = smoothPath(pts, smoothing);
293
+ const zeroY = yOf(Math.max(0, scale.min));
294
+ return (
295
+ <g key={ds.label}>
296
+ {chartType === 'area' && (
297
+ <path
298
+ d={`${path}L${pts[pts.length - 1]![0]},${zeroY}L${pts[0]![0]},${zeroY}Z`}
299
+ fill={color}
300
+ opacity={0.16}
301
+ />
302
+ )}
303
+ <path d={path} fill="none" stroke={color} strokeWidth={2} className="series-line" />
304
+ {showDots && n <= 40 && pts.map(([x, y], i) => (
305
+ <circle key={i} cx={x} cy={y} r={hover === i ? 4 : 2.5} fill={color} className="series-dot" />
306
+ ))}
307
+ </g>
308
+ );
309
+ })}
310
+
311
+ {/* crosshair */}
312
+ {hover != null && (
313
+ <line
314
+ x1={xCenter(hover)} x2={xCenter(hover)}
315
+ y1={PAD.top} y2={PAD.top + plotH}
316
+ className="crosshair"
317
+ />
318
+ )}
319
+ {hover != null && chartType !== 'bar' && shown.map((ds) => {
320
+ const color = colorOf(ds, datasets.indexOf(ds));
321
+ return (
322
+ <circle
323
+ key={`h${ds.label}`}
324
+ cx={xCenter(hover)} cy={yOf(ds.data[hover] ?? 0)}
325
+ r={4} fill={color} stroke="var(--surface)" strokeWidth={1.5}
326
+ />
327
+ );
328
+ })}
329
+
330
+ {/* X labels */}
331
+ {showXLabels && labels.map((label, i) => (
332
+ i % labelStride === 0 ? (
333
+ <text key={`xl${i}`} x={xCenter(i)} y={height - 8} className="axis-label" textAnchor="middle">
334
+ {label}
335
+ </text>
336
+ ) : null
337
+ ))}
338
+
339
+ {/* hit area — one overlay drives index-mode hover */}
340
+ <rect
341
+ x={PAD.left} y={PAD.top} width={plotW} height={plotH}
342
+ fill="transparent"
343
+ onMouseMove={onMove}
344
+ onMouseLeave={() => setHover(null)}
345
+ />
346
+ </svg>
347
+
348
+ {hover != null && (
349
+ <div
350
+ className="chart-tip"
351
+ style={{
352
+ left: Math.min(Math.max(xCenter(hover), 90), width - 90),
353
+ top: PAD.top,
354
+ }}
355
+ >
356
+ <div className="chart-tip-head">{labels[hover]}</div>
357
+ {shown.map((ds) => (
358
+ <div key={ds.label} className="chart-tip-row">
359
+ <span className="legend-swatch" style={{ background: colorOf(ds, datasets.indexOf(ds)) }} />
360
+ <span className="chart-tip-label">{ds.label}</span>
361
+ <span className="chart-tip-val">{fmt(ds.data[hover] ?? 0)}</span>
362
+ </div>
363
+ ))}
364
+ {isStacked && shown.length > 1 && (
365
+ <div className="chart-tip-row chart-tip-total">
366
+ <span className="chart-tip-label">Total</span>
367
+ <span className="chart-tip-val">
368
+ {fmt(shown.reduce((sum, ds) => sum + (ds.data[hover!] ?? 0), 0))}
369
+ </span>
370
+ </div>
371
+ )}
372
+ </div>
373
+ )}
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
@@ -0,0 +1,149 @@
1
+ // Stats-summary and view-data dialogs for series charts — the React port of
2
+ // web-mojo MetricsChart's onActionShowStats / onActionShowDataTable (and the
3
+ // single-series twins on MetricsMiniChartWidget, which shared none of the
4
+ // code there; here both surfaces call these two functions). Native <dialog>
5
+ // via the awaitable modal manager in portal-mojo/ui.
6
+ import { modal } from '../ui/modal';
7
+ import {
8
+ GRANULARITY_NOUN,
9
+ buildCsv,
10
+ computeSeriesStats,
11
+ csvFilename,
12
+ defaultStatFormat,
13
+ toNumber,
14
+ type SeriesLike,
15
+ } from './stats';
16
+
17
+ export interface ChartDialogOptions {
18
+ /** Dialog title ("API Activity — Stats" / the data dialog's header). */
19
+ title: string;
20
+ labels: readonly string[];
21
+ datasets: readonly SeriesLike[];
22
+ /** Bucket noun for the "Hourly · 24 points" line (granularity value). */
23
+ granularity?: string;
24
+ /** Value formatter; default = int/2dp locale formatting. */
25
+ formatter?: (n: number) => string;
26
+ }
27
+
28
+ function pointsLine(granularity: string | undefined, points: number): string {
29
+ const noun = granularity ? GRANULARITY_NOUN[granularity] ?? granularity : null;
30
+ const pts = `${points} ${points === 1 ? 'point' : 'points'}`;
31
+ return noun ? `${noun} · ${pts}` : pts;
32
+ }
33
+
34
+ function EmptyBody({ onClose }: { onClose: () => void }) {
35
+ return (
36
+ <>
37
+ <div className="modal-message">No data to display.</div>
38
+ <div className="modal-actions">
39
+ <button className="btn" onClick={onClose}>Close</button>
40
+ </div>
41
+ </>
42
+ );
43
+ }
44
+
45
+ /** Per-series Latest / Min / Max / Avg / Median / Sum summary dialog. */
46
+ export function showSeriesStats(opts: ChartDialogOptions): Promise<unknown> {
47
+ const stats = computeSeriesStats(opts.datasets);
48
+ const fmt = opts.formatter ?? defaultStatFormat;
49
+ const cols = ['Latest', 'Min', 'Max', 'Avg', 'Median', 'Sum'] as const;
50
+ const pick = (s: (typeof stats)[number], col: (typeof cols)[number]): number =>
51
+ col === 'Latest' ? s.latest : col === 'Min' ? s.min : col === 'Max' ? s.max
52
+ : col === 'Avg' ? s.avg : col === 'Median' ? s.median : s.sum;
53
+
54
+ return modal.open((close) => (
55
+ <div className="modal-pad">
56
+ <h2 className="modal-title">{opts.title}</h2>
57
+ {stats.length === 0 ? (
58
+ <EmptyBody onClose={() => close(null)} />
59
+ ) : (
60
+ <>
61
+ <div className="chart-dialog-sub">{pointsLine(opts.granularity, stats[0]!.count)}</div>
62
+ <div className="chart-dialog-scroll">
63
+ <table className="chart-stats-table">
64
+ <thead>
65
+ <tr>
66
+ <th>Series</th>
67
+ {cols.map((c) => <th key={c} className="num">{c}</th>)}
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ {stats.map((s) => (
72
+ <tr key={s.label}>
73
+ <td>{s.label}</td>
74
+ {cols.map((c) => <td key={c} className="num">{fmt(pick(s, c))}</td>)}
75
+ </tr>
76
+ ))}
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ <div className="modal-actions">
81
+ <button className="btn" onClick={() => close(null)}>Close</button>
82
+ </div>
83
+ </>
84
+ )}
85
+ </div>
86
+ ), { size: 'md' });
87
+ }
88
+
89
+ /** Trigger a text-file download (the CSV button). */
90
+ function downloadText(text: string, filename: string, mime: string): void {
91
+ const blob = new Blob([text], { type: mime });
92
+ const url = URL.createObjectURL(blob);
93
+ const a = document.createElement('a');
94
+ a.href = url;
95
+ a.download = filename;
96
+ document.body.appendChild(a);
97
+ a.click();
98
+ a.remove();
99
+ URL.revokeObjectURL(url);
100
+ }
101
+
102
+ /** The raw series as a table: one row per label, one column per series, + CSV. */
103
+ export function showSeriesData(opts: ChartDialogOptions): Promise<unknown> {
104
+ const { labels, datasets } = opts;
105
+ const hasData = labels.length > 0 && datasets.length > 0;
106
+ const fmt = opts.formatter ?? defaultStatFormat;
107
+ const headers = ['Label', ...datasets.map((d) => d.label ?? '')];
108
+ const rows = labels.map((label, i) => [label, ...datasets.map((d) => toNumber(d.data?.[i]))] as const);
109
+
110
+ const downloadCsv = () => {
111
+ downloadText(buildCsv(headers, rows), csvFilename(opts.title), 'text/csv;charset=utf-8');
112
+ };
113
+
114
+ return modal.open((close) => (
115
+ <div className="modal-pad">
116
+ <h2 className="modal-title">{opts.title}</h2>
117
+ {!hasData ? (
118
+ <EmptyBody onClose={() => close(null)} />
119
+ ) : (
120
+ <>
121
+ <div className="chart-dialog-sub">{pointsLine(opts.granularity, labels.length)}</div>
122
+ <div className="chart-dialog-scroll chart-dialog-scroll-tall">
123
+ <table className="chart-data-table">
124
+ <thead>
125
+ <tr>
126
+ {headers.map((h, i) => <th key={i} className={i === 0 ? undefined : 'num'}>{h}</th>)}
127
+ </tr>
128
+ </thead>
129
+ <tbody>
130
+ {rows.map((row, ri) => (
131
+ <tr key={ri}>
132
+ <td>{row[0]}</td>
133
+ {row.slice(1).map((v, ci) => <td key={ci} className="num">{fmt(v as number)}</td>)}
134
+ </tr>
135
+ ))}
136
+ </tbody>
137
+ </table>
138
+ </div>
139
+ <div className="modal-actions">
140
+ <button className="btn" onClick={downloadCsv}>
141
+ <i className="bi bi-download" /> Download CSV
142
+ </button>
143
+ <button className="btn btn-primary" onClick={() => close(null)}>Close</button>
144
+ </div>
145
+ </>
146
+ )}
147
+ </div>
148
+ ), { size: 'lg' });
149
+ }
@@ -0,0 +1,144 @@
1
+ // exportChartPng — serialize a rendered SVG chart to a PNG download.
2
+ // Ported from web-mojo src/extensions/charts/exportChart.js (77 lines, read
3
+ // in full). Framework-free and not chart-specific: pass any element that is
4
+ // or contains an <svg>.
5
+ //
6
+ // Additions over the source, both needed because portal-mojo charts style
7
+ // through theme.css CLASSES (which a serialized clone loses):
8
+ // · computed styles are inlined onto the clone (fixed property list), so
9
+ // token-driven fills/strokes/fonts survive serialization;
10
+ // · the backdrop defaults to the nearest ancestor's real background color
11
+ // (dark theme exports stay dark) instead of hardcoded white; and the
12
+ // bitmap renders at 2x by default for crisp exports.
13
+ //
14
+ // ⚠️ Tainted-canvas caveat (carried from the source): if the SVG references
15
+ // cross-origin resources (external <image href>, remote fonts pulled in via
16
+ // <style>), the browser taints the canvas and `canvas.toDataURL` throws a
17
+ // SecurityError. portal-mojo's charts reference no external resources, so
18
+ // this only bites if you point the helper at an SVG that does — inline those
19
+ // resources before exporting.
20
+
21
+ export interface ExportChartPngOptions {
22
+ /** Download name. Default `chart-<timestamp>.png`. */
23
+ filename?: string;
24
+ /**
25
+ * Backdrop color. Default: the first non-transparent ancestor
26
+ * background (theme-correct), falling back to white. Pass null for a
27
+ * transparent PNG.
28
+ */
29
+ background?: string | null;
30
+ /** Bitmap scale factor (2 = retina-crisp). Default 2. */
31
+ scale?: number;
32
+ }
33
+
34
+ /** Anything that is, contains, or refs an SVG. */
35
+ export type ExportChartTarget =
36
+ | SVGSVGElement
37
+ | HTMLElement
38
+ | { current: SVGSVGElement | HTMLElement | null }
39
+ | null
40
+ | undefined;
41
+
42
+ /** The computed properties inlined onto the clone — the set the charts use. */
43
+ const INLINE_PROPS = [
44
+ 'fill', 'fill-opacity',
45
+ 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin',
46
+ 'opacity',
47
+ 'font-family', 'font-size', 'font-weight', 'letter-spacing',
48
+ 'text-anchor', 'dominant-baseline',
49
+ ] as const;
50
+
51
+ function resolveSvg(target: ExportChartTarget): SVGSVGElement | null {
52
+ const root = target != null && typeof target === 'object' && 'current' in target ? target.current : target;
53
+ if (!root) return null;
54
+ if (root instanceof SVGSVGElement) return root;
55
+ return root.querySelector?.('svg') ?? null;
56
+ }
57
+
58
+ /** Walk original + clone in lockstep and stamp computed styles inline. */
59
+ function inlineComputedStyles(original: SVGSVGElement, clone: SVGSVGElement): void {
60
+ const src: Element[] = [original, ...Array.from(original.querySelectorAll('*'))];
61
+ const dst: Element[] = [clone, ...Array.from(clone.querySelectorAll('*'))];
62
+ for (let i = 0; i < src.length && i < dst.length; i++) {
63
+ const from = src[i]!;
64
+ const to = dst[i]!;
65
+ if (!(to instanceof SVGElement) && !(to instanceof HTMLElement)) continue;
66
+ const computed = window.getComputedStyle(from);
67
+ const decl: string[] = [];
68
+ for (const prop of INLINE_PROPS) {
69
+ const v = computed.getPropertyValue(prop);
70
+ if (v) decl.push(`${prop}:${v}`);
71
+ }
72
+ if (decl.length) to.setAttribute('style', decl.join(';'));
73
+ }
74
+ }
75
+
76
+ /** First non-transparent ancestor background, so dark exports stay dark. */
77
+ function resolveBackdrop(svg: SVGSVGElement): string {
78
+ let node: Element | null = svg;
79
+ while (node) {
80
+ const bg = window.getComputedStyle(node).backgroundColor;
81
+ if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') return bg;
82
+ node = node.parentElement;
83
+ }
84
+ return '#ffffff';
85
+ }
86
+
87
+ export function exportChartPng(target: ExportChartTarget, opts: ExportChartPngOptions = {}): void {
88
+ const svg = resolveSvg(target);
89
+ if (!svg) {
90
+ console.warn('exportChartPng: no <svg> found in target');
91
+ return;
92
+ }
93
+
94
+ const rect = svg.getBoundingClientRect();
95
+ const vb = svg.getAttribute('viewBox')?.split(/\s+/).map(Number);
96
+ const width = vb && vb.length === 4 ? vb[2]! : Math.round(rect.width || 600);
97
+ const height = vb && vb.length === 4 ? vb[3]! : Math.round(rect.height || 400);
98
+
99
+ const clone = svg.cloneNode(true) as SVGSVGElement;
100
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
101
+ if (!clone.getAttribute('width')) clone.setAttribute('width', String(width));
102
+ if (!clone.getAttribute('height')) clone.setAttribute('height', String(height));
103
+ inlineComputedStyles(svg, clone);
104
+
105
+ // data: URL (unicode-safe base64) keeps the load same-origin.
106
+ const xml = new XMLSerializer().serializeToString(clone);
107
+ const svg64 = btoa(unescape(encodeURIComponent(xml)));
108
+ const dataUrl = `data:image/svg+xml;base64,${svg64}`;
109
+
110
+ const scale = opts.scale != null && Number.isFinite(opts.scale) && opts.scale > 0 ? opts.scale : 2;
111
+ const background = opts.background === undefined ? resolveBackdrop(svg) : opts.background;
112
+
113
+ const img = new Image();
114
+ img.crossOrigin = 'anonymous';
115
+ img.onload = () => {
116
+ const canvas = document.createElement('canvas');
117
+ canvas.width = Math.max(1, Math.round(width * scale));
118
+ canvas.height = Math.max(1, Math.round(height * scale));
119
+ const ctx = canvas.getContext('2d');
120
+ if (!ctx) {
121
+ console.warn('exportChartPng: could not get a 2d canvas context');
122
+ return;
123
+ }
124
+ if (background != null) {
125
+ ctx.fillStyle = background;
126
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
127
+ }
128
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
129
+
130
+ const png = canvas.toDataURL('image/png'); // throws here if tainted — see header note
131
+ const a = document.createElement('a');
132
+ a.href = png;
133
+ a.download = opts.filename ?? `chart-${Date.now()}.png`;
134
+ document.body.appendChild(a);
135
+ a.click();
136
+ a.remove();
137
+ };
138
+ img.onerror = (err) => {
139
+ console.error('exportChartPng: image load failed', err);
140
+ };
141
+ img.src = dataUrl;
142
+ }
143
+
144
+ export default exportChartPng;