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,649 @@
1
+ // WorldMap — dependency-free SVG geo map in the mission-control design
2
+ // language. A REBUILD, not a port: web-mojo drew its maps through
3
+ // MapLibreView.js, which injects `https://unpkg.com/maplibre-gl@4.7.1` as a
4
+ // runtime <script> (maplibre is not even in its package.json) and pulls
5
+ // basemap tiles and glyphs from `demotiles.maplibre.org`. Every admin
6
+ // deployment would fetch a third-party CDN and a demo tile server at page
7
+ // load — unacceptable independently of the no-library rule.
8
+ //
9
+ // What it covers is the surface the three consumers actually used:
10
+ // full-replace markers ({lng,lat,size,color,icon,popup}), a dblclick
11
+ // drill-down handle, fitBounds after drill-down, flyTo on mode switch,
12
+ // resize on tab activation, origin→country line sources with
13
+ // intensity-driven paint, and the interactive/scrollZoom/dragPan toggles.
14
+ // Deliberately NOT carried: pitch/bearing (cosmetic 3D with no consumer
15
+ // semantics — a fake-perspective SVG map would be dishonest), satellite and
16
+ // terrain styles, clustering, glyph rendering.
17
+ //
18
+ // Two source bugs are fixed here rather than reproduced: coordinate validity
19
+ // is `Number.isFinite` (the source's `if (!lng || !lat) return` dropped
20
+ // points on the equator and prime meridian), and tooltips are ReactNode
21
+ // slots (both consumers concatenated popup HTML from unescaped server
22
+ // values — `entry.region`, `ev.ip_address`, `ev.user.display_name`).
23
+ import {
24
+ useCallback,
25
+ useEffect,
26
+ useLayoutEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ type PointerEvent as ReactPointerEvent,
31
+ type ReactNode,
32
+ } from 'react';
33
+ import {
34
+ DEFAULT_BOUNDS,
35
+ DEFAULT_VIEW,
36
+ MAX_ZOOM,
37
+ MIN_ZOOM,
38
+ geoArcPath,
39
+ clampView,
40
+ equirectangular,
41
+ fitViewToPoints,
42
+ graticule,
43
+ isFinitePoint,
44
+ splitAntimeridian,
45
+ type GeoBounds,
46
+ type LatLng,
47
+ type Projection,
48
+ type WorldMapView,
49
+ } from './geo';
50
+ import {
51
+ landRings,
52
+ toneColor,
53
+ type WorldMapLand,
54
+ type WorldMapLegendItem,
55
+ type WorldMapMarker,
56
+ type WorldMapRoute,
57
+ } from './worldmap-data';
58
+
59
+ export interface WorldMapProps<T = unknown> {
60
+ /** Plotted points. Full replace, like the source's `updateMarkers`. */
61
+ markers?: readonly WorldMapMarker<T>[];
62
+ /** Origin→destination arcs. */
63
+ routes?: readonly WorldMapRoute[];
64
+ /**
65
+ * Basemap geometry. NOTHING is bundled: with `land` omitted the map draws
66
+ * an ocean field and a graticule, which is honest about what it knows.
67
+ * See docs/worldmap.md — shipping coastlines is an open decision, and
68
+ * this prop is the seam that makes it a drop-in when it is made.
69
+ */
70
+ land?: WorldMapLand | null;
71
+ height?: number;
72
+ /** Geographic crop. Default: world minus the polar caps. */
73
+ bounds?: GeoBounds;
74
+ /** Controlled viewport. A change tweens over `viewTransition` ms. */
75
+ view?: WorldMapView;
76
+ /** Uncontrolled initial viewport, and the target of a canvas double-click. */
77
+ defaultView?: WorldMapView;
78
+ onViewChange?: (view: WorldMapView) => void;
79
+ /**
80
+ * 'none' (default) matches both consumers' `autoFitBounds: false`.
81
+ * 'markers' re-fits whenever the marker set changes — the `fitBounds()`
82
+ * the drill-down flow called.
83
+ */
84
+ fit?: 'none' | 'markers';
85
+ /** Wheel zoom + drag pan. Default true (the source's master switch). */
86
+ interactive?: boolean;
87
+ showGraticule?: boolean;
88
+ showLegend?: boolean;
89
+ /** Explicit legend rows; omitted, they are derived from marker legendKeys. */
90
+ legend?: readonly WorldMapLegendItem[];
91
+ showTooltip?: boolean;
92
+ /** Replace the whole tooltip body. ReactNode, never an HTML string. */
93
+ renderTooltip?: (marker: WorldMapMarker<T>) => ReactNode;
94
+ onMarkerHover?: (marker: WorldMapMarker<T> | null) => void;
95
+ onMarkerClick?: (marker: WorldMapMarker<T>) => void;
96
+ onMarkerDoubleClick?: (marker: WorldMapMarker<T>) => void;
97
+ /** Route dash flow. Default true; `prefers-reduced-motion` still wins. */
98
+ animateRoutes?: boolean;
99
+ /** ms for a `view` change tween. Default 600 — flyTo({duration: 600}). */
100
+ viewTransition?: number;
101
+ loading?: boolean;
102
+ /** Footer status line — the source's `setStatus()`. */
103
+ status?: ReactNode;
104
+ emptyText?: string;
105
+ className?: string;
106
+ }
107
+
108
+ const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
109
+
110
+ const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
111
+
112
+ /** Single click is deferred this long so a double-click can cancel it. */
113
+ const CLICK_DEFER_MS = 250;
114
+
115
+ /** Below this drag distance a pointer sequence still counts as a click. */
116
+ const DRAG_SLOP_PX = 3;
117
+
118
+ export function WorldMap<T = unknown>({
119
+ markers: markersProp = [],
120
+ routes: routesProp = [],
121
+ land = null,
122
+ height = 320,
123
+ bounds = DEFAULT_BOUNDS,
124
+ view: viewProp,
125
+ defaultView,
126
+ onViewChange,
127
+ fit = 'none',
128
+ interactive = true,
129
+ showGraticule = true,
130
+ showLegend = true,
131
+ legend: legendProp,
132
+ showTooltip = true,
133
+ renderTooltip,
134
+ onMarkerHover,
135
+ onMarkerClick,
136
+ onMarkerDoubleClick,
137
+ animateRoutes = true,
138
+ viewTransition = 600,
139
+ loading = false,
140
+ status,
141
+ emptyText = 'No locations to plot',
142
+ className = '',
143
+ }: WorldMapProps<T>) {
144
+ // Malformed feed data degrades to the empty state, never takes the route
145
+ // down — the same rule SeriesChart follows.
146
+ const shapeOk = Array.isArray(markersProp) && Array.isArray(routesProp);
147
+ if (!shapeOk) {
148
+ console.warn('WorldMap: markers/routes are not arrays — rendering empty state', { markers: markersProp, routes: routesProp });
149
+ }
150
+ const markers = shapeOk ? markersProp : [];
151
+ const routes = shapeOk ? routesProp : [];
152
+
153
+ const wrapRef = useRef<HTMLDivElement>(null);
154
+ const areaRef = useRef<HTMLDivElement>(null);
155
+ const [width, setWidth] = useState(0);
156
+
157
+ // Real pixel geometry, measured — and the reason `map.resize()` (which
158
+ // both consumers had to call on tab activation) has no equivalent here.
159
+ useLayoutEffect(() => {
160
+ const node = wrapRef.current;
161
+ if (!node) return;
162
+ const ro = new ResizeObserver(([entry]) => {
163
+ const w = entry?.contentRect.width ?? 0;
164
+ if (w > 0) setWidth(w);
165
+ });
166
+ ro.observe(node);
167
+ return () => ro.disconnect();
168
+ }, []);
169
+
170
+ // ── Viewport: controlled-or-uncontrolled, with a rAF tween ────────
171
+ const controlled = viewProp !== undefined;
172
+ const homeView = defaultView ?? DEFAULT_VIEW;
173
+ const [innerView, setInnerView] = useState<WorldMapView>(homeView);
174
+ const targetView = controlled ? viewProp : innerView;
175
+
176
+ const [painted, setPaintedState] = useState<WorldMapView>(targetView);
177
+ const paintedRef = useRef<WorldMapView>(targetView);
178
+ const setPainted = useCallback((v: WorldMapView) => {
179
+ paintedRef.current = v;
180
+ setPaintedState(v);
181
+ }, []);
182
+
183
+ const size = useMemo(() => ({ width, height }), [width, height]);
184
+ const sizeRef = useRef(size);
185
+ sizeRef.current = size;
186
+ const targetRef = useRef(targetView);
187
+ targetRef.current = targetView;
188
+ // Gestures paint immediately; only programmatic view changes tween.
189
+ const immediateRef = useRef(false);
190
+ const firstPaintRef = useRef(true);
191
+
192
+ const commitView = useCallback((next: WorldMapView, immediate = false) => {
193
+ const clamped = clampView(next, bounds, sizeRef.current);
194
+ immediateRef.current = immediate;
195
+ if (!controlled) setInnerView(clamped);
196
+ else if (immediate) setPainted(clamped);
197
+ onViewChange?.(clamped);
198
+ }, [bounds, controlled, onViewChange, setPainted]);
199
+
200
+ const targetKey = `${targetView.lat}|${targetView.lng}|${targetView.zoom}`;
201
+ useEffect(() => {
202
+ const target = targetRef.current;
203
+ if (firstPaintRef.current || immediateRef.current || viewTransition <= 0) {
204
+ firstPaintRef.current = false;
205
+ immediateRef.current = false;
206
+ setPainted(target);
207
+ return;
208
+ }
209
+ const from = paintedRef.current;
210
+ const start = performance.now();
211
+ let raf = 0;
212
+ const step = (now: number) => {
213
+ const t = Math.min(1, (now - start) / viewTransition);
214
+ const e = easeOutCubic(t);
215
+ setPainted({
216
+ lat: from.lat + (target.lat - from.lat) * e,
217
+ lng: from.lng + (target.lng - from.lng) * e,
218
+ zoom: from.zoom + (target.zoom - from.zoom) * e,
219
+ });
220
+ if (t < 1) raf = requestAnimationFrame(step);
221
+ };
222
+ raf = requestAnimationFrame(step);
223
+ return () => cancelAnimationFrame(raf);
224
+ // targetKey IS the identity of `target`; refs carry the live values.
225
+ // eslint-disable-next-line react-hooks/exhaustive-deps
226
+ }, [targetKey, viewTransition, setPainted]);
227
+
228
+ const projection: Projection = useMemo(
229
+ () => equirectangular({ width: Math.max(1, width), height, bounds, view: painted }),
230
+ [width, height, bounds, painted],
231
+ );
232
+
233
+ // ── Marker triage: finite / plottable / off-bounds ────────────────
234
+ const markerKey = markers.map((m) => m.id ?? `${m.lat},${m.lng}`).join('|');
235
+ // The warn is AGGREGATED (one line per bad marker set, not per marker)
236
+ // and de-duplicated by this ref, so a consumer that hands over a fresh
237
+ // array every render does not get one warning per paint.
238
+ const warnedRef = useRef('');
239
+ const finiteMarkers = useMemo(() => {
240
+ const usable: WorldMapMarker<T>[] = [];
241
+ let dropped = 0;
242
+ for (const m of markers) {
243
+ if (isFinitePoint(m)) usable.push(m); else dropped += 1;
244
+ }
245
+ const signature = `${markerKey}:${dropped}`;
246
+ if (dropped > 0 && warnedRef.current !== signature) {
247
+ warnedRef.current = signature;
248
+ console.warn(`WorldMap: ${dropped} marker(s) skipped — lat/lng must be finite numbers (0,0 is valid data)`);
249
+ }
250
+ return usable;
251
+ }, [markers, markerKey]);
252
+
253
+ // ── Legend ────────────────────────────────────────────────────────
254
+ const legendItems: WorldMapLegendItem[] = useMemo(() => {
255
+ if (legendProp) return [...legendProp];
256
+ const seen = new Map<string, WorldMapLegendItem>();
257
+ for (const m of finiteMarkers) {
258
+ if (!m.legendKey || seen.has(m.legendKey)) continue;
259
+ seen.set(m.legendKey, { key: m.legendKey, label: m.legendKey, tone: m.tone });
260
+ }
261
+ return [...seen.values()];
262
+ }, [legendProp, finiteMarkers]);
263
+
264
+ const [hidden, setHidden] = useState<Set<string>>(() => new Set());
265
+ const legendKey = legendItems.map((l) => l.key).join('|');
266
+ useEffect(() => { setHidden(new Set()); }, [legendKey]);
267
+
268
+ const shownMarkers = useMemo(
269
+ () => finiteMarkers.filter((m) => !m.legendKey || !hidden.has(m.legendKey)),
270
+ [finiteMarkers, hidden],
271
+ );
272
+ const shownRoutes = useMemo(
273
+ () => routes.filter((r) => (!r.legendKey || !hidden.has(r.legendKey)) && isFinitePoint(r.from) && isFinitePoint(r.to)),
274
+ [routes, hidden],
275
+ );
276
+
277
+ // Off-bounds points are SKIPPED and COUNTED, never clamped to the edge:
278
+ // a marker pinned to the frame edge would lie about where the event was.
279
+ const plotted = shownMarkers.filter((m) => projection.contains(m));
280
+ const offBounds = shownMarkers.length - plotted.length;
281
+
282
+ // ── fit="markers" — the fitBounds() replacement ───────────────────
283
+ const fitKey = fit === 'markers' ? markerKey : '';
284
+ useEffect(() => {
285
+ if (fit !== 'markers' || width <= 0 || !finiteMarkers.length) return;
286
+ commitView(fitViewToPoints(finiteMarkers, {
287
+ size: sizeRef.current,
288
+ bounds,
289
+ fallback: targetRef.current,
290
+ }));
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
292
+ }, [fitKey, fit, width]);
293
+
294
+ // ── Pan / zoom ────────────────────────────────────────────────────
295
+ const dragRef = useRef<{ x: number; y: number; moved: number } | null>(null);
296
+ /** Distance of the gesture that just ended — outlives dragRef. */
297
+ const lastDragMoved = useRef(0);
298
+ const [dragging, setDragging] = useState(false);
299
+
300
+ const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
301
+ if (!interactive || e.button !== 0) return;
302
+ dragRef.current = { x: e.clientX, y: e.clientY, moved: 0 };
303
+ e.currentTarget.setPointerCapture(e.pointerId);
304
+ setDragging(true);
305
+ };
306
+ const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
307
+ const drag = dragRef.current;
308
+ if (!drag) return;
309
+ const dx = e.clientX - drag.x;
310
+ const dy = e.clientY - drag.y;
311
+ drag.moved += Math.abs(dx) + Math.abs(dy);
312
+ drag.x = e.clientX;
313
+ drag.y = e.clientY;
314
+ const current = paintedRef.current;
315
+ commitView({
316
+ lat: current.lat + dy / projection.scale,
317
+ lng: current.lng - dx / projection.scale,
318
+ zoom: current.zoom,
319
+ }, true);
320
+ };
321
+ const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
322
+ if (!dragRef.current) return;
323
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
324
+ lastDragMoved.current = dragRef.current.moved;
325
+ dragRef.current = null;
326
+ setDragging(false);
327
+ };
328
+
329
+ // Wheel must be a NON-passive native listener; React's synthetic wheel
330
+ // handler is passive, so preventDefault there only logs a warning while
331
+ // the page keeps scrolling under the cursor.
332
+ useEffect(() => {
333
+ const node = areaRef.current;
334
+ if (!node || !interactive) return;
335
+ const onWheel = (e: WheelEvent) => {
336
+ e.preventDefault();
337
+ const rect = node.getBoundingClientRect();
338
+ const px = e.clientX - rect.left;
339
+ const py = e.clientY - rect.top;
340
+ const current = paintedRef.current;
341
+ const anchor = equirectangular({
342
+ width: Math.max(1, rect.width),
343
+ height: rect.height,
344
+ bounds,
345
+ view: current,
346
+ }).invert({ x: px, y: py });
347
+ const nextZoom = clamp(current.zoom * Math.pow(0.999, e.deltaY), MIN_ZOOM, MAX_ZOOM);
348
+ const spanLng = Math.max(1e-9, bounds.east - bounds.west);
349
+ const spanLat = Math.max(1e-9, bounds.north - bounds.south);
350
+ const scale = Math.min(rect.width / spanLng, rect.height / spanLat) * nextZoom;
351
+ commitView({
352
+ lat: anchor.lat + (py - rect.height / 2) / scale,
353
+ lng: anchor.lng - (px - rect.width / 2) / scale,
354
+ zoom: nextZoom,
355
+ }, true);
356
+ };
357
+ node.addEventListener('wheel', onWheel, { passive: false });
358
+ return () => node.removeEventListener('wheel', onWheel);
359
+ }, [interactive, bounds, commitView]);
360
+
361
+ // ── Hover + click / double-click ──────────────────────────────────
362
+ const [hover, setHover] = useState<WorldMapMarker<T> | null>(null);
363
+ const mouseRef = useRef({ x: 0, y: 0 });
364
+ const tipRef = useRef<HTMLDivElement>(null);
365
+ const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
366
+ useEffect(() => () => { if (clickTimer.current) clearTimeout(clickTimer.current); }, []);
367
+
368
+ const placeTip = useCallback(() => {
369
+ const tip = tipRef.current;
370
+ const area = areaRef.current;
371
+ if (!tip || !area) return;
372
+ const tw = tip.offsetWidth;
373
+ const th = tip.offsetHeight;
374
+ let left = mouseRef.current.x - tw / 2;
375
+ let top = mouseRef.current.y - th - 12;
376
+ if (left < 0) left = 0;
377
+ if (left + tw > area.clientWidth) left = area.clientWidth - tw;
378
+ if (top < 0) top = mouseRef.current.y + 16;
379
+ if (top + th > area.clientHeight) top = area.clientHeight - th;
380
+ tip.style.left = `${left}px`;
381
+ tip.style.top = `${top}px`;
382
+ }, []);
383
+ useLayoutEffect(() => { if (hover) placeTip(); });
384
+
385
+ const trackMouse = (e: { clientX: number; clientY: number }) => {
386
+ const area = areaRef.current;
387
+ if (!area) return;
388
+ const rect = area.getBoundingClientRect();
389
+ mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
390
+ placeTip();
391
+ };
392
+
393
+ // In MapLibre the marker handler stop-propagated so canvas doubleClickZoom
394
+ // did not also fire. Here BOTH click and double-click are public, so a
395
+ // single click waits CLICK_DEFER_MS and a double-click cancels it.
396
+ const markerHandlers = (marker: WorldMapMarker<T>) => ({
397
+ onMouseEnter: (e: React.MouseEvent) => {
398
+ setHover(marker);
399
+ onMarkerHover?.(marker);
400
+ if (showTooltip) trackMouse(e);
401
+ },
402
+ onMouseMove: (e: React.MouseEvent) => { if (showTooltip) trackMouse(e); },
403
+ onMouseLeave: () => { setHover(null); onMarkerHover?.(null); },
404
+ onClick: (e: React.MouseEvent) => {
405
+ e.stopPropagation();
406
+ if (!onMarkerClick) return;
407
+ if (clickTimer.current) clearTimeout(clickTimer.current);
408
+ clickTimer.current = setTimeout(() => {
409
+ clickTimer.current = null;
410
+ onMarkerClick(marker);
411
+ }, onMarkerDoubleClick ? CLICK_DEFER_MS : 0);
412
+ },
413
+ onDoubleClick: (e: React.MouseEvent) => {
414
+ e.stopPropagation();
415
+ if (clickTimer.current) { clearTimeout(clickTimer.current); clickTimer.current = null; }
416
+ onMarkerDoubleClick?.(marker);
417
+ },
418
+ });
419
+
420
+ // Canvas double-click resets the view — it deliberately does NOT zoom,
421
+ // because double-click is the drill-down gesture on markers.
422
+ const onCanvasDoubleClick = () => {
423
+ // A pan that happened to end in a double-click must not also reset.
424
+ if (lastDragMoved.current > DRAG_SLOP_PX) { lastDragMoved.current = 0; return; }
425
+ commitView(homeView);
426
+ };
427
+
428
+ // ── Geometry for the drawn layers ─────────────────────────────────
429
+ const lines = useMemo(() => graticule(bounds), [bounds]);
430
+ const rings = useMemo(() => landRings(land), [land]);
431
+ const landPaths = useMemo(() => rings.map((ring) => ringPath(ring, projection)).filter(Boolean), [rings, projection]);
432
+
433
+ const routePaths = useMemo(() => shownRoutes.flatMap((route, i) => {
434
+ const intensity = clamp(Number(route.intensity) || 0, 0, 1);
435
+ return splitAntimeridian(route.from, route.to)
436
+ .map(([a, b]) => geoArcPath(projection, a, b))
437
+ .filter((d) => d.length > 0)
438
+ .map((d, seg) => ({
439
+ key: `${route.id ?? i}-${seg}`,
440
+ d,
441
+ // Source paint ramps, verbatim (MetricsCountryMapView.js:232-260):
442
+ // width 1.75→6, opacity 0.45→0.95, color teal→amber (now tokens).
443
+ stroke: toneColor(route.tone ?? 'scale', intensity),
444
+ strokeWidth: 1.75 + intensity * 4.25,
445
+ opacity: 0.45 + intensity * 0.5,
446
+ }));
447
+ }), [shownRoutes, projection]);
448
+
449
+ const measured = width > 0;
450
+ const nothingToShow = plotted.length === 0 && routePaths.length === 0;
451
+
452
+ return (
453
+ <div className={`worldmap${className ? ` ${className}` : ''}`} ref={wrapRef}>
454
+ {showLegend && legendItems.length > 0 && (
455
+ <div className="chart-legend">
456
+ {legendItems.map((item) => {
457
+ const off = hidden.has(item.key);
458
+ return (
459
+ <button
460
+ key={item.key}
461
+ className={`legend-item${off ? ' legend-off' : ''}`}
462
+ onClick={() => setHidden((prev) => {
463
+ const next = new Set(prev);
464
+ if (next.has(item.key)) next.delete(item.key); else next.add(item.key);
465
+ return next;
466
+ })}
467
+ title={off ? `Show ${item.label}` : `Hide ${item.label}`}
468
+ >
469
+ <span className="legend-swatch" style={{ background: item.color ?? toneColor(item.tone ?? 'accent', 1) }} />
470
+ {item.label}
471
+ </button>
472
+ );
473
+ })}
474
+ </div>
475
+ )}
476
+
477
+ <div
478
+ className={`worldmap-area${interactive ? ' worldmap-interactive' : ''}${dragging ? ' worldmap-dragging' : ''}`}
479
+ style={{ height }}
480
+ ref={areaRef}
481
+ onPointerDown={onPointerDown}
482
+ onPointerMove={onPointerMove}
483
+ onPointerUp={endDrag}
484
+ onPointerCancel={endDrag}
485
+ onDoubleClick={onCanvasDoubleClick}
486
+ >
487
+ {measured && (
488
+ <svg width={width} height={height} className="worldmap-svg" role="img" aria-label="World map">
489
+ <rect x={0} y={0} width={width} height={height} className="worldmap-ocean" />
490
+
491
+ {showGraticule && (
492
+ <g className="worldmap-graticule">
493
+ {lines.meridians.map((lng) => {
494
+ const top = projection.project({ lat: bounds.north, lng });
495
+ const bottom = projection.project({ lat: bounds.south, lng });
496
+ return (
497
+ <line
498
+ key={`m${lng}`}
499
+ x1={top.x} y1={top.y} x2={bottom.x} y2={bottom.y}
500
+ className={lng === 0 ? 'worldmap-grat-major' : 'worldmap-grat-line'}
501
+ />
502
+ );
503
+ })}
504
+ {lines.parallels.map((lat) => {
505
+ const left = projection.project({ lat, lng: bounds.west });
506
+ const right = projection.project({ lat, lng: bounds.east });
507
+ return (
508
+ <line
509
+ key={`p${lat}`}
510
+ x1={left.x} y1={left.y} x2={right.x} y2={right.y}
511
+ className={lat === 0 ? 'worldmap-grat-major' : 'worldmap-grat-line'}
512
+ />
513
+ );
514
+ })}
515
+ </g>
516
+ )}
517
+
518
+ {landPaths.length > 0 && (
519
+ <g className="worldmap-land">
520
+ {landPaths.map((d, i) => <path key={i} d={d} />)}
521
+ </g>
522
+ )}
523
+
524
+ <g className={`worldmap-routes${animateRoutes ? ' worldmap-routes-flow' : ''}`}>
525
+ {routePaths.map((r) => (
526
+ <path
527
+ key={r.key}
528
+ d={r.d}
529
+ className="worldmap-route"
530
+ stroke={r.stroke}
531
+ strokeWidth={r.strokeWidth}
532
+ opacity={r.opacity}
533
+ fill="none"
534
+ />
535
+ ))}
536
+ </g>
537
+
538
+ <g className="worldmap-markers">
539
+ {/* Largest first so small dots stay hoverable on top. */}
540
+ {[...plotted]
541
+ .sort((a, b) => (b.size ?? 14) - (a.size ?? 14))
542
+ .map((marker, i) => {
543
+ const { x, y } = projection.project(marker);
544
+ const r = Math.max(3, (marker.size ?? 14) / 2);
545
+ const color = toneColor(marker.tone ?? 'accent', marker.intensity ?? 0);
546
+ const key = marker.id ?? `${marker.lat},${marker.lng},${i}`;
547
+ return (
548
+ <circle
549
+ key={key}
550
+ cx={x} cy={y} r={r}
551
+ fill={color}
552
+ className={`worldmap-marker${isHovered(hover, marker) ? ' worldmap-marker-hot' : ''}`}
553
+ {...markerHandlers(marker)}
554
+ />
555
+ );
556
+ })}
557
+ </g>
558
+ </svg>
559
+ )}
560
+
561
+ {/* Icon glyphs ride an HTML layer: a bootstrap-icons class cannot
562
+ be rendered into SVG <text> without knowing its codepoint, and
563
+ injecting raw markup is not an option here. */}
564
+ {measured && plotted.some((m) => m.icon) && (
565
+ <div className="worldmap-icons">
566
+ {plotted.filter((m) => m.icon).map((marker, i) => {
567
+ const { x, y } = projection.project(marker);
568
+ return (
569
+ <i
570
+ key={marker.id ?? `icon${i}`}
571
+ className={`worldmap-icon ${marker.icon}`}
572
+ style={{ left: x, top: y, fontSize: Math.max(8, (marker.size ?? 14) * 0.46) }}
573
+ />
574
+ );
575
+ })}
576
+ </div>
577
+ )}
578
+
579
+ {measured && nothingToShow && !loading && (
580
+ <div className="worldmap-empty">{emptyText}</div>
581
+ )}
582
+
583
+ {loading && (
584
+ <div className="worldmap-loading"><span className="skel skel-block" /></div>
585
+ )}
586
+
587
+ {showTooltip && hover && (
588
+ <div className="chart-tip worldmap-tip" ref={tipRef}>
589
+ {renderTooltip ? renderTooltip(hover) : (
590
+ <>
591
+ <div className="chart-tip-head">{hover.label ?? '—'}</div>
592
+ {hover.value != null && (
593
+ <div className="chart-tip-row">
594
+ <span className="legend-swatch" style={{ background: toneColor(hover.tone ?? 'accent', hover.intensity ?? 0) }} />
595
+ <span className="chart-tip-label">Value</span>
596
+ <span className="chart-tip-val">{hover.value.toLocaleString()}</span>
597
+ </div>
598
+ )}
599
+ {hover.detail != null && <div className="worldmap-tip-detail">{hover.detail}</div>}
600
+ </>
601
+ )}
602
+ </div>
603
+ )}
604
+ </div>
605
+
606
+ {(status != null || offBounds > 0) && (
607
+ <div className="worldmap-foot">
608
+ {status != null && <span className="worldmap-foot-status">{status}</span>}
609
+ {offBounds > 0 && (
610
+ <span className="worldmap-foot-count" title="Widen the `bounds` prop to bring them into view">
611
+ {offBounds.toLocaleString()} point{offBounds === 1 ? '' : 's'} outside the map view
612
+ </span>
613
+ )}
614
+ </div>
615
+ )}
616
+ </div>
617
+ );
618
+ }
619
+
620
+ /**
621
+ * Hover match. Object identity first (the usual case), falling back to `id`
622
+ * so a consumer that rebuilds its marker array on every render does not lose
623
+ * the highlight mid-hover.
624
+ */
625
+ function isHovered<T>(hover: WorldMapMarker<T> | null, marker: WorldMapMarker<T>): boolean {
626
+ if (!hover) return false;
627
+ return hover === marker || (hover.id != null && hover.id === marker.id);
628
+ }
629
+
630
+ /**
631
+ * One land ring → an SVG path. A jump wider than 180° of longitude between
632
+ * consecutive vertices means the ring wrapped the antimeridian; the path
633
+ * BREAKS there (new subpath) instead of smearing a horizontal band across
634
+ * the whole map.
635
+ */
636
+ function ringPath(ring: readonly [number, number][], projection: Projection): string {
637
+ if (!Array.isArray(ring) || ring.length < 3) return '';
638
+ let d = '';
639
+ let prev: LatLng | null = null;
640
+ for (const position of ring) {
641
+ const point: LatLng = { lat: position[1], lng: position[0] };
642
+ if (!isFinitePoint(point)) { prev = null; continue; }
643
+ const { x, y } = projection.project(point);
644
+ const wrapped = prev != null && Math.abs(point.lng - prev.lng) > 180;
645
+ d += `${d && !wrapped ? 'L' : 'M'}${x.toFixed(2)},${y.toFixed(2)}`;
646
+ prev = point;
647
+ }
648
+ return d ? `${d}Z` : '';
649
+ }