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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- 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
|
+
}
|