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,173 @@
|
|
|
1
|
+
# TimePicker
|
|
2
|
+
|
|
3
|
+
The stepper time input, ported from web-mojo `forms/inputs/TimePicker.js`
|
|
4
|
+
(648 lines) — the source's locked **variant B**: per unit a column of
|
|
5
|
+
▲ / value input / ▼ / label, with direct numeric typing on the value. Built
|
|
6
|
+
on the shared [`Popover`](popover.md) and the `parseTime`/`formatTime`/
|
|
7
|
+
`compareTime`/`ianaOffset` half of [`dateFns`](calendar.md). No wheels (the
|
|
8
|
+
source deferred those to a future mobile mode), no library.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { TimePicker } from 'portal-mojo/ui';
|
|
12
|
+
import type { TimeValue, TimeChangeEvent, TimeOutputFormat } from 'portal-mojo/ui';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Value shapes (the contract)
|
|
16
|
+
|
|
17
|
+
Storage is **always canonical 24h `HH:MM` internally** — `format` only
|
|
18
|
+
decides what the panel and trigger render. `outputFormat` decides what
|
|
19
|
+
leaves:
|
|
20
|
+
|
|
21
|
+
| `outputFormat` | with a zone | without a zone | empty |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| `'iso'` (default) | `'14:30-07:00'` | `'14:30'` | `''` |
|
|
24
|
+
| `'iana'` (legacy) | `'14:30 America/Los_Angeles'` | `'14:30'` | `''` |
|
|
25
|
+
| `'object'` | `{time: '14:30', timezone: 'America/Los_Angeles'}` | `'14:30'` (string!) | `null` |
|
|
26
|
+
|
|
27
|
+
`'object'` only produces an object when the zone picker is enabled —
|
|
28
|
+
otherwise it degrades to the `'iso'` string, exactly as the source did.
|
|
29
|
+
|
|
30
|
+
**Everything above parses back in**, plus two more shapes the source
|
|
31
|
+
accepted (`_parseInitial`, ported line for line):
|
|
32
|
+
|
|
33
|
+
- 12h text — `'2:30 PM'` (an AM/PM tail belongs to the time, not the zone)
|
|
34
|
+
- ISO offsets in every spelling — `'14:30Z'` → zone `+00:00`,
|
|
35
|
+
`'14:30+0530'` → `+05:30`, `'14:30-07:00'` → `-07:00`
|
|
36
|
+
- IANA tails — `'14:30 America/New_York'`
|
|
37
|
+
- bare `'14:30'` / `'9:05'`
|
|
38
|
+
|
|
39
|
+
Invalid text parses to `null` (empty picker) — `parseTime` never throws.
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
| Prop | Notes |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `value` | `string \| {time, timezone} \| null` — see above. Omit entirely for an uncontrolled picker. |
|
|
46
|
+
| `onChange` | `({value, formatted, oldValue})` — fires on **commit only**, and only when the serialized string actually changed |
|
|
47
|
+
| `format` | `'24h'` (default) \| `'12h'`. Unknown values warn + fall back |
|
|
48
|
+
| `step` | minute stepping increment, ≥1 (default 1). Hours always step by 1 |
|
|
49
|
+
| `min` / `max` | time strings (`"09:00"`, `"5:30 pm"` — `parseTime` parses them); clamped on **every** commit path |
|
|
50
|
+
| `placeholder` | default `'HH:MM'` / `'h:mm AM/PM'` |
|
|
51
|
+
| `disabled` / `readOnly` / `required` / `invalid` | trigger states; `required` and `readOnly` also suppress the clear ✕ |
|
|
52
|
+
| `inline` | render the panel in place — no trigger, no popover |
|
|
53
|
+
| `showFooter` | `false` → the embeddable footerless panel (DateTimePicker's time column) |
|
|
54
|
+
| `timezone` | `true` enables the zone picker; a `string[]` enables it **and is the zone list** |
|
|
55
|
+
| `timezones` | zone list when `timezone` is `true` (default: Intl's full list) |
|
|
56
|
+
| `outputFormat` | `'iso'` \| `'iana'` \| `'object'`. Unknown values warn + fall back |
|
|
57
|
+
| `renderTimezoneSelect` | the zone-picker slot — see below |
|
|
58
|
+
| `name` | renders a hidden input carrying the serialized string (native-form use) |
|
|
59
|
+
| `className` | appended to the root |
|
|
60
|
+
|
|
61
|
+
`TimeChangeEvent.oldValue` is always the **previous canonical serialized
|
|
62
|
+
string** (`''` when empty), never the object form — it is the diffing key,
|
|
63
|
+
not a mirror of `value`.
|
|
64
|
+
|
|
65
|
+
## The timezone seam
|
|
66
|
+
|
|
67
|
+
`TimePicker` owns every bit of the zone's value and serialization logic but
|
|
68
|
+
renders the picker itself through a slot:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
<TimePicker
|
|
72
|
+
timezone
|
|
73
|
+
renderTimezoneSelect={({ value, onChange, timezones, disabled }) => (
|
|
74
|
+
<TimezoneSelect value={value} onChange={onChange} timezones={timezones} disabled={disabled} />
|
|
75
|
+
)}
|
|
76
|
+
/>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Without the prop it falls back to a plain token-styled `<select>` built from
|
|
80
|
+
`Intl.supportedValuesOf('timeZone')` (curated ~50-zone list + a `console.warn`
|
|
81
|
+
when Intl can't supply one), so the feature is complete standalone. Swapping
|
|
82
|
+
in the real `TimezoneSelect` changes nothing about the value pipeline — the
|
|
83
|
+
slot only renders. A zone the list doesn't contain still renders as its own
|
|
84
|
+
option (never "render nothing") with a warn.
|
|
85
|
+
|
|
86
|
+
## Behavior invariants (from source, keep them true)
|
|
87
|
+
|
|
88
|
+
- **Commit-only pipeline.** Typing into a column writes a *draft*, which can
|
|
89
|
+
never be read as the value. `onChange` fires on: stepper click, ArrowUp/
|
|
90
|
+
Down, Enter (which commits by blurring), blur, AM/PM, Now, Set-on-empty,
|
|
91
|
+
clear, zone pick. Re-committing the same value is silent.
|
|
92
|
+
- **Column typing.** Digits only (the keydown blocks the rest; ⌘/^/⌥
|
|
93
|
+
shortcuts pass through — a fix over source, which ate ⌘A/⌘V). Focus
|
|
94
|
+
selects the whole field; `maxLength` is 2; a blur clamps — 12h hours
|
|
95
|
+
1–12, 24h hours 0–23, minutes 0–59 — and an empty field reverts to the
|
|
96
|
+
displayed value.
|
|
97
|
+
- **12h hour math** is the fiddly part: **12 AM = 0**, **12 PM = 12**, and a
|
|
98
|
+
typed hour maps through the *current* AM/PM state (PM + `3` → `15:00`).
|
|
99
|
+
AM/PM on an empty picker seeds `00:00` / `12:00`.
|
|
100
|
+
- **Minute stepping honors `step`** via total-minutes math and wraps across
|
|
101
|
+
midnight in BOTH directions (`23:45` +15 → `00:00`; `00:00` −15 →
|
|
102
|
+
`23:45`). Hours wrap 0–23. Typing is not constrained to `step`.
|
|
103
|
+
- **`min`/`max` clamp is a snap, not a block** — the stepper still moves, it
|
|
104
|
+
just lands on the bound.
|
|
105
|
+
- **Now** takes the local wall clock; **Set** fills `00:00` if the picker is
|
|
106
|
+
empty and closes the popover.
|
|
107
|
+
- The trigger is bi-clock icon + formatted text + a clear ✕ that appears on
|
|
108
|
+
hover/focus (suppressed by `required`/`disabled`/`readOnly`). Clicking it
|
|
109
|
+
on an empty picker is a no-op.
|
|
110
|
+
|
|
111
|
+
## Deviations from source (deliberate)
|
|
112
|
+
|
|
113
|
+
1. **A zone change emits.** web-mojo updated its hidden input but never fired
|
|
114
|
+
`change` on a zone pick (`_syncOutputs()` called with no `oldStored`).
|
|
115
|
+
`onChange` is the only channel here — staying silent would strand the
|
|
116
|
+
owner's value.
|
|
117
|
+
2. **`Now` and the AM/PM-on-empty seed are clamped** like every other commit
|
|
118
|
+
path. The source skipped `_clampToBounds` on both, so `Now` outside
|
|
119
|
+
business hours produced an out-of-bounds value.
|
|
120
|
+
3. **An already-offset zone passes through** `'iso'` serialization
|
|
121
|
+
(`'14:30-07:00'` re-saves as `'14:30-07:00'`). `ianaOffset('-07:00')`
|
|
122
|
+
returns `null`, so the source silently dropped the offset on re-save.
|
|
123
|
+
4. **The clear ✕ is a sibling of the trigger**, not nested inside it (the
|
|
124
|
+
source produced a `<button>` inside a `<button>`). The wrapper carries the
|
|
125
|
+
hover reveal, so the pointer sitting on the ✕ keeps it visible.
|
|
126
|
+
5. `disabled`/`readOnly` are enforced at the commit boundary, so a locked
|
|
127
|
+
`inline` panel is inert (the source only guarded the popover, which a
|
|
128
|
+
locked picker could never open anyway).
|
|
129
|
+
6. The dead `autoApply` option (destructured, never read in source) is not
|
|
130
|
+
carried. There is no `hasError()` stub either — pass `invalid` instead.
|
|
131
|
+
|
|
132
|
+
## State ownership (read this before wiring a form)
|
|
133
|
+
|
|
134
|
+
The picker holds its **own committed state**, seeded from `value` and
|
|
135
|
+
re-seeded whenever the `value` prop changes to something that is not an echo
|
|
136
|
+
of its own last emit — the same shape [`Calendar`](calendar.md) uses. Write
|
|
137
|
+
`event.value` straight back into `value` and the two stay in lockstep; omit
|
|
138
|
+
`value` entirely and it behaves as an uncontrolled picker.
|
|
139
|
+
|
|
140
|
+
Why not derive purely from props: `'iso'` output carries only the **offset**,
|
|
141
|
+
so re-parsing our own echo would silently degrade
|
|
142
|
+
`'America/Los_Angeles'` → `'-07:00'`. The zone therefore lives in component
|
|
143
|
+
state, and the echo guard is what protects it. Corollary: **a rejected write
|
|
144
|
+
does not revert the panel** — if you need to refuse a value, write the value
|
|
145
|
+
you *do* want (a second prop change re-seeds).
|
|
146
|
+
|
|
147
|
+
One more source-parity quirk: enabling `timezone` seeds the browser's local
|
|
148
|
+
zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `'UTC'` on failure)
|
|
149
|
+
**without emitting**. The owner learns the zone on the first real commit.
|
|
150
|
+
|
|
151
|
+
## Styling
|
|
152
|
+
|
|
153
|
+
`apps/portal/src/theme/timepicker.css` — `mojo-time-*` anatomy only
|
|
154
|
+
(`trigger` / `trigger-text` / `trigger-clear` / `popover-inner` /
|
|
155
|
+
`picker-inline` / `stepper*` / `ampm*` / `tz-host` / `foot`), tokens only,
|
|
156
|
+
both themes. The trigger wears the house `.input` look so it lines up in a
|
|
157
|
+
form grid; the popover shell (`.mojo-popover`) supplies background, border
|
|
158
|
+
and shadow, and `.mojo-time-popover-inner` brings the padding
|
|
159
|
+
(`.has-tz` widens it to 300px for IANA labels).
|
|
160
|
+
|
|
161
|
+
## Pitfalls
|
|
162
|
+
|
|
163
|
+
- Don't read the column inputs to get the value — they may hold an
|
|
164
|
+
uncommitted draft. `onChange`/`value` is the only value channel.
|
|
165
|
+
- `'object'` output is `null` when empty, `''` for every other
|
|
166
|
+
`outputFormat`. Check for both if you switch formats at runtime.
|
|
167
|
+
- `min`/`max` are wall-clock times, not datetimes: a range that crosses
|
|
168
|
+
midnight (`22:00`–`02:00`) is not expressible — `compareTime` is linear.
|
|
169
|
+
- `ianaOffset` is evaluated at **serialization time against `new Date()`**,
|
|
170
|
+
so a DST-straddling value serializes with today's offset. Store the IANA
|
|
171
|
+
name (`'iana'` / `'object'`) when the date matters.
|
|
172
|
+
- django-mojo datetimes are epoch **seconds** — that is a DateTimePicker
|
|
173
|
+
concern; this component only speaks wall-clock time strings.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# TimezoneSelect — the IANA zone picker
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
TimezoneSelect, localTimezone, resolveTimezone, FALLBACK_TIMEZONES,
|
|
6
|
+
type TimezoneChangeEvent, type TimezoneSelectProps,
|
|
7
|
+
} from 'portal-mojo/ui';
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
A searchable timezone field: the house [`ComboBox`](combobox.md) with
|
|
11
|
+
`allowCustom: false`, options built from the engine's IANA zone list, each
|
|
12
|
+
label annotated with the zone's **current** UTC offset. Ported from
|
|
13
|
+
web-mojo `src/core/forms/inputs/TimezoneSelect.js`. Used standalone and
|
|
14
|
+
embedded by the time pickers (#1269 TimePicker, DateTimePicker).
|
|
15
|
+
|
|
16
|
+
## API
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
<TimezoneSelect
|
|
20
|
+
value={zone} // committed IANA string | null
|
|
21
|
+
onChange={({value, oldValue}) => …} // COMMIT only, real changes only
|
|
22
|
+
timezones={['UTC', 'Europe/London']} // fixed list overrides the engine
|
|
23
|
+
defaultToLocal // default true — empty displays the local zone
|
|
24
|
+
name="timezone" // renders a hidden input (native form posts)
|
|
25
|
+
placeholder disabled required maxSuggestions id className
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
interface TimezoneChangeEvent { value: string; oldValue: string | null }
|
|
29
|
+
|
|
30
|
+
localTimezone(): string // 'UTC' when the engine can't say
|
|
31
|
+
resolveTimezone(value, { timezones?, defaultToLocal? }) // → the EFFECTIVE zone (getFormValue)
|
|
32
|
+
FALLBACK_TIMEZONES: readonly string[] // the curated 48
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`maxSuggestions` defaults to the **whole list** (source parity — web-mojo's
|
|
36
|
+
combobox scrolled every zone and never capped); pass a number to trim.
|
|
37
|
+
|
|
38
|
+
## The zone list
|
|
39
|
+
|
|
40
|
+
1. `timezones` prop, in the order given, when it is a non-empty array.
|
|
41
|
+
2. else `Intl.supportedValuesOf('timeZone')` — ~418 zones, resolved once per
|
|
42
|
+
session and cached. `UTC` is prepended when absent: V8 omits it, and a
|
|
43
|
+
django-mojo portal whose picker cannot choose UTC is broken (a deliberate
|
|
44
|
+
one-line deviation from source; the curated list leads with UTC too).
|
|
45
|
+
3. else `FALLBACK_TIMEZONES` (the curated 48, verbatim from web-mojo) with
|
|
46
|
+
ONE module-level `console.warn` — once per page load, not per instance.
|
|
47
|
+
|
|
48
|
+
**The committed zone is always in the list**, prepended when the resolved
|
|
49
|
+
list lacks it. This is not a nicety — V8 reports ICU-canonical ids
|
|
50
|
+
(`Asia/Calcutta`, `Europe/Kiev`, `Asia/Saigon`, `America/Godthab`,
|
|
51
|
+
`Asia/Rangoon`, `America/Buenos_Aires`) while Python/`zoneinfo` — and
|
|
52
|
+
therefore django-mojo — stores the modern IANA primaries (`Asia/Kolkata`,
|
|
53
|
+
`Europe/Kyiv`, …). Without the prepend, a stored `Asia/Kolkata` rendered
|
|
54
|
+
unlabelled and could not be re-selected: the user had to change the zone to
|
|
55
|
+
save the record. Engines accept the aliases as *input*, so those entries get
|
|
56
|
+
a proper offset label.
|
|
57
|
+
|
|
58
|
+
## Labels: offset annotation
|
|
59
|
+
|
|
60
|
+
`America/New_York (UTC−05:00)` — `dateFns.ianaOffset` does the
|
|
61
|
+
`formatToParts` / `timeZoneName: 'shortOffset'` work (`GMT`/`UTC` → `+00:00`,
|
|
62
|
+
hours zero-padded, minutes defaulted), and the sign is rewritten to a
|
|
63
|
+
**Unicode minus** (U+2212) for negatives. A zone the engine rejects labels as
|
|
64
|
+
its bare name — never nothing.
|
|
65
|
+
|
|
66
|
+
**Labels recompute on open.** web-mojo built them once at mount, so a
|
|
67
|
+
long-lived picker kept showing pre-DST offsets. Here every gesture that can
|
|
68
|
+
open the list (focus, pointer-down, ArrowDown on the wrapper) stamps a new
|
|
69
|
+
reference instant, keyed into the label `useMemo`; the stamp coalesces to one
|
|
70
|
+
update per second, because a single open is several DOM events and a second
|
|
71
|
+
is far finer than any DST transition. Full relabel of ~420 zones is ~10ms,
|
|
72
|
+
paid on open only.
|
|
73
|
+
|
|
74
|
+
## Values: commit-only, and the local default
|
|
75
|
+
|
|
76
|
+
`onChange` fires on a **commit** — clicking an option, Enter on the
|
|
77
|
+
highlighted one, or blurring onto an exact zone name — and only when the zone
|
|
78
|
+
actually changed. `allowCustom` is `false`, so typed filter text is a draft
|
|
79
|
+
that reverts: it can never become a value. This is the bug that mattered —
|
|
80
|
+
web-mojo's ComboBox emitted per keystroke, so fragments like `"Amer"` reached
|
|
81
|
+
the picker's value and FormView's 300ms autosave saved them.
|
|
82
|
+
|
|
83
|
+
The field is **fully controlled** (one pipeline, no internal value state).
|
|
84
|
+
The default is a *display* resolution, not a commit:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
effective = value || (defaultToLocal ? defaultZone : null)
|
|
88
|
+
defaultZone = localTimezone(), except: a caller-supplied `timezones` list
|
|
89
|
+
that lacks the local zone falls back to its FIRST entry
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `getFormValue` semantics = `resolveTimezone(value, {timezones, defaultToLocal})`
|
|
93
|
+
→ a plain IANA string, or `null` (never the offset label). The `name` prop's
|
|
94
|
+
hidden input posts exactly that.
|
|
95
|
+
- An empty `value` therefore *displays* the local zone while the owner's state
|
|
96
|
+
is still `null`. That is deliberate: emitting a commit on mount would mark a
|
|
97
|
+
form dirty and trip inline autosave for a zone the user never chose. Seed
|
|
98
|
+
state with `localTimezone()` when you need state and display identical from
|
|
99
|
+
the first paint.
|
|
100
|
+
- `oldValue` is the previously **displayed** (effective) zone, so a default
|
|
101
|
+
that was never committed still reports honestly.
|
|
102
|
+
|
|
103
|
+
## Embedding (TimePicker / DateTimePicker)
|
|
104
|
+
|
|
105
|
+
The mount seam is `{ value, onChange, timezones?, disabled?, name?, placeholder? }`
|
|
106
|
+
— exactly what web-mojo's TimePicker/DateTimePicker passed
|
|
107
|
+
(`timezone: true | string[]` collapses to `timezones` at the call site; a
|
|
108
|
+
`timezone` prop of `true` means "show the picker", the array form means "show
|
|
109
|
+
it with this fixed list"). Hosts render the offset-annotated field inline
|
|
110
|
+
under their time strip.
|
|
111
|
+
|
|
112
|
+
Until the field-type registry (#1278) wires this in, the time-picker port may
|
|
113
|
+
be rendering a plain `<select>` fallback in that slot; swapping it for
|
|
114
|
+
`<TimezoneSelect>` is a drop-in — value in, value out, same IANA string.
|
|
115
|
+
|
|
116
|
+
## Invariants / fixed web-mojo bugs (do not reintroduce)
|
|
117
|
+
|
|
118
|
+
- **Commit-only value** — inherited by construction from ComboBox plus
|
|
119
|
+
`allowCustom: false`; there is no per-keystroke path to `onChange`.
|
|
120
|
+
- **Offsets are recomputed, not frozen at mount** (DST correctness).
|
|
121
|
+
- **One warn, once** — the no-Intl fallback warns at module level, not per
|
|
122
|
+
instance and never per render.
|
|
123
|
+
- The value is always a plain IANA string; the offset lives only in the
|
|
124
|
+
label. Never post or store the label.
|
|
125
|
+
|
|
126
|
+
## Pitfalls
|
|
127
|
+
|
|
128
|
+
- `Intl.supportedValuesOf('timeZone')` does **not** contain `GMT`, and (in
|
|
129
|
+
V8) not `UTC` either — hence the prepend. Other aliases (`US/Eastern`,
|
|
130
|
+
`Etc/GMT+5`) are not browsable either: they work as a *value* (prepended,
|
|
131
|
+
labelled) but a user cannot discover them. Pass `timezones` if a deployment
|
|
132
|
+
needs them listed.
|
|
133
|
+
- A zone the engine cannot resolve **at all** (`ianaOffset` → null, e.g. the
|
|
134
|
+
fallback list's bogus `America/Honolulu`) warns once per instance/value and
|
|
135
|
+
is displayed verbatim, unlabelled — house rule 4's signal that stored data
|
|
136
|
+
is wrong. An alias the engine *accepts* never warns.
|
|
137
|
+
- `FALLBACK_TIMEZONES` is verbatim from source, warts included:
|
|
138
|
+
`America/Honolulu` is not a real IANA zone (`Pacific/Honolulu` is, and is
|
|
139
|
+
also in the list). It labels bare instead of throwing. The list is only
|
|
140
|
+
reachable on engines without `Intl.supportedValuesOf`.
|
|
141
|
+
- An empty `timezones={[]}` means "no override" (source parity), not "no
|
|
142
|
+
zones".
|
|
143
|
+
- No stylesheet ships: the wrapper `.tz-select` is an unstyled hook and every
|
|
144
|
+
pixel comes from `apps/portal/src/theme/combobox.css`, which consuming apps
|
|
145
|
+
must include.
|
|
146
|
+
- The dropdown is ComboBox's locally-positioned list — inside
|
|
147
|
+
`overflow:hidden` containers it clips (MERGE-WIRE: Popover primitive #1271).
|
package/docs/worldmap.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# WorldMap — dependency-free geo map
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
WorldMap,
|
|
6
|
+
// country data
|
|
7
|
+
COUNTRY_CENTROIDS, COUNTRY_OPTIONS, getCountryCentroid, countryName,
|
|
8
|
+
// binding helpers
|
|
9
|
+
countrySeriesToMarkers, countryCodeFromSlug, scaleMarkerSize,
|
|
10
|
+
toneColor, landRings, MAP_TONES,
|
|
11
|
+
// basemap geometry — lazily loaded; see "The `land` seam"
|
|
12
|
+
useWorldLand,
|
|
13
|
+
// projection math
|
|
14
|
+
equirectangular, fitViewToPoints, clampView, splitAntimeridian,
|
|
15
|
+
geoArcPath, graticule, isFinitePoint,
|
|
16
|
+
DEFAULT_BOUNDS, DEFAULT_VIEW, MIN_ZOOM, MAX_ZOOM,
|
|
17
|
+
} from 'portal-mojo/charts';
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
One map for every "where in the world did this happen" surface: login
|
|
21
|
+
locations (#1291), geofence policy (#1287), the dashboard geography panel
|
|
22
|
+
(#1292). Demos: Develop → Components → Display → **WorldMap** and **WorldMap
|
|
23
|
+
routes**.
|
|
24
|
+
|
|
25
|
+
## Why this is a REBUILD, not a port
|
|
26
|
+
|
|
27
|
+
web-mojo drew all of its maps through `src/extensions/map/MapLibreView.js`,
|
|
28
|
+
which injects `https://unpkg.com/maplibre-gl@4.7.1` as a runtime `<script>`
|
|
29
|
+
(maplibre is not in its `package.json` at all) and takes basemap tiles and
|
|
30
|
+
glyphs from `demotiles.maplibre.org` — MapLibre's explicitly-demo server.
|
|
31
|
+
Porting that verbatim would make every admin deployment fetch a third-party
|
|
32
|
+
CDN and a demo tile server at page load, which is unacceptable for the
|
|
33
|
+
IP-restricted / separate-origin admin deployments independently of the
|
|
34
|
+
no-library rule. The rebuild covers exactly the MapLibre surface the
|
|
35
|
+
consumers used and nothing more.
|
|
36
|
+
|
|
37
|
+
## Invariants
|
|
38
|
+
|
|
39
|
+
1. **Presentational only.** `WorldMap` fetches NOTHING — no tiles, no CDN, no
|
|
40
|
+
`fetch`, no `useQuery`. Consumers own their TanStack queries and pass
|
|
41
|
+
`markers` / `routes` / `status` / `loading`. `verify-worldmap.mjs` asserts
|
|
42
|
+
the absence of every network affordance in the source.
|
|
43
|
+
2. **Tokens, never hexes.** Every color is a `var(--…)` string resolved by
|
|
44
|
+
the browser, so a theme flip needs no re-render.
|
|
45
|
+
3. **ReactNode tooltips, never HTML strings.** No `dangerouslySetInnerHTML`.
|
|
46
|
+
Both web-mojo consumers concatenated popup HTML out of unescaped server
|
|
47
|
+
values (`entry.region`, `ev.ip_address`, `ev.user.display_name`);
|
|
48
|
+
`label` + `detail?: ReactNode` + `renderTooltip` replace that.
|
|
49
|
+
4. **Coordinate validity is `Number.isFinite`.** `{lat: 0, lng: 0}` is real
|
|
50
|
+
data and plots. The source's `if (!lng || !lat) return`
|
|
51
|
+
(`MapLibreView.js:223`) dropped the equator and the prime meridian.
|
|
52
|
+
5. **Off-bounds points are skipped and COUNTED**, surfaced in the footer as
|
|
53
|
+
"N points outside the map view". Clamping to the frame edge would lie
|
|
54
|
+
about where the event happened; dropping them silently is the
|
|
55
|
+
silent-failure anti-pattern on the do-not-recreate list.
|
|
56
|
+
6. **Unknown values fall back WITH a `console.warn`** — an unknown tone
|
|
57
|
+
becomes `accent`, an unsupported land geometry is skipped, non-array
|
|
58
|
+
`markers`/`routes` render the empty state.
|
|
59
|
+
|
|
60
|
+
## `<WorldMap>`
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
<WorldMap<CountryTotal>
|
|
64
|
+
markers={markers} // WorldMapMarker<T>[] — full replace
|
|
65
|
+
routes={routes} // WorldMapRoute[]
|
|
66
|
+
land={null} // injectable geometry; see below
|
|
67
|
+
height={320}
|
|
68
|
+
bounds={DEFAULT_BOUNDS}
|
|
69
|
+
view={view} onViewChange={setView} // controlled…
|
|
70
|
+
defaultView={DEFAULT_VIEW} // …or uncontrolled
|
|
71
|
+
fit="none" // 'none' | 'markers'
|
|
72
|
+
interactive // wheel zoom + drag pan
|
|
73
|
+
showGraticule showLegend showTooltip
|
|
74
|
+
legend={[{ key: 'Failed', label: 'Failed', tone: 'bad' }]}
|
|
75
|
+
renderTooltip={(m) => <>…</>}
|
|
76
|
+
onMarkerHover={(m) => …} onMarkerClick={(m) => …} onMarkerDoubleClick={(m) => …}
|
|
77
|
+
animateRoutes viewTransition={600}
|
|
78
|
+
loading={query.isPending}
|
|
79
|
+
status={`${n} logins plotted`}
|
|
80
|
+
emptyText="No locations to plot"
|
|
81
|
+
/>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| prop | type | default | notes |
|
|
85
|
+
|---|---|---|---|
|
|
86
|
+
| `markers` | `readonly WorldMapMarker<T>[]` | `[]` | Full replace, like `updateMarkers` |
|
|
87
|
+
| `routes` | `readonly WorldMapRoute[]` | `[]` | Origin→destination arcs |
|
|
88
|
+
| `land` | `WorldMapLand \| null` | `null` | Basemap geometry. Feed it `useWorldLand()`; `null` draws the graticule fallback |
|
|
89
|
+
| `height` | `number` | `320` | Width is measured (ResizeObserver) |
|
|
90
|
+
| `bounds` | `GeoBounds` | `{west:-180,east:180,south:-58,north:84}` | The geographic crop |
|
|
91
|
+
| `view` / `defaultView` | `WorldMapView` | `DEFAULT_VIEW` | Controlled vs uncontrolled |
|
|
92
|
+
| `onViewChange` | `(view) => void` | — | Fires for gestures, `fit`, and resets |
|
|
93
|
+
| `fit` | `'none' \| 'markers'` | `'none'` | `'none'` = the source's `autoFitBounds: false` |
|
|
94
|
+
| `interactive` | `boolean` | `true` | Wheel zoom + drag pan; markers stay live either way |
|
|
95
|
+
| `showGraticule` / `showLegend` / `showTooltip` | `boolean` | `true` | |
|
|
96
|
+
| `legend` | `readonly WorldMapLegendItem[]` | derived | Falls back to the markers' `legendKey`s |
|
|
97
|
+
| `renderTooltip` | `(m) => ReactNode` | — | Replaces the whole tooltip body |
|
|
98
|
+
| `onMarkerHover/Click/DoubleClick` | `(m) => void` | — | See the click-deferral note |
|
|
99
|
+
| `animateRoutes` | `boolean` | `true` | `prefers-reduced-motion` still wins |
|
|
100
|
+
| `viewTransition` | `number` | `600` | ms; the `flyTo({duration: 600})` equivalent |
|
|
101
|
+
| `loading` / `status` / `emptyText` / `className` | | | `status` is the source's `setStatus()` |
|
|
102
|
+
|
|
103
|
+
### `WorldMapMarker<T>`
|
|
104
|
+
|
|
105
|
+
`{ id?, lat, lng, size?, tone?, intensity?, icon?, label?, value?, detail?,
|
|
106
|
+
legendKey?, data? }`
|
|
107
|
+
|
|
108
|
+
`data` is the typed payload every handler receives — the reason drill-down
|
|
109
|
+
does not need the `_countryCode` / `_isRegion` underscore fields web-mojo
|
|
110
|
+
smuggled through the marker object. `icon` is a bootstrap-icons class drawn
|
|
111
|
+
inside the dot (it rides an HTML overlay layer; an icon class cannot be put
|
|
112
|
+
into SVG `<text>` without knowing its codepoint).
|
|
113
|
+
|
|
114
|
+
### `WorldMapRoute`
|
|
115
|
+
|
|
116
|
+
`{ id?, from, to, intensity?, tone?, label?, legendKey? }`
|
|
117
|
+
|
|
118
|
+
Paint follows the source ramps verbatim (`MetricsCountryMapView.js:232-260`):
|
|
119
|
+
width `1.75 → 6`, opacity `0.45 → 0.95`, color teal→amber — now
|
|
120
|
+
`toneColor('scale', intensity)`.
|
|
121
|
+
|
|
122
|
+
### Tones
|
|
123
|
+
|
|
124
|
+
| tone | token | used for |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `ok` | `--ok` | successful logins |
|
|
127
|
+
| `bad` | `--bad` | failed logins |
|
|
128
|
+
| `warn` | `--warn` | suspicious / MFA-required |
|
|
129
|
+
| `info` | `--info` | informational |
|
|
130
|
+
| `accent` | `--accent` | route origin, default |
|
|
131
|
+
| `mute` | `--mute` | neutral / unclassified |
|
|
132
|
+
| `scale` | `color-mix(--ok → --warn)` | the value ramp |
|
|
133
|
+
|
|
134
|
+
**There is deliberately no login-tone helper here.** One shipped briefly
|
|
135
|
+
(`loginEventTone` / `LOGIN_EVENT_TONES`, ported from
|
|
136
|
+
`LoginLocationMapView.js:31-41`) and was **removed 2026-08-06**: it keyed off a
|
|
137
|
+
login "event type", and django-mojo's `UserLoginEvent` has no such field, so
|
|
138
|
+
every real row fell through to grey — which is exactly why web-mojo's login map
|
|
139
|
+
rendered entirely grey for years. Tone login rows with `loginRiskTone(row)` from
|
|
140
|
+
`portal-mojo/admin` (`admin/security/devices`): danger on `is_new_country`,
|
|
141
|
+
warning on `is_new_region`, otherwise success. `verify-worldmap.mjs` asserts the
|
|
142
|
+
old names stay gone.
|
|
143
|
+
|
|
144
|
+
## Projection contract
|
|
145
|
+
|
|
146
|
+
`equirectangular({width, height, bounds, view})` → `{project, invert, scale,
|
|
147
|
+
contains, size, bounds, view}`.
|
|
148
|
+
|
|
149
|
+
- **Equirectangular (plate carrée)**: `lat`/`lng` map linearly to `y`/`x`, so
|
|
150
|
+
`invert` is exact — that is what makes a graticule-only map honest.
|
|
151
|
+
- **`zoom` is a fit multiple, not a tile level.** At `zoom: 1` the whole
|
|
152
|
+
`bounds` box fits the frame; the scale is `min(width/spanLng,
|
|
153
|
+
height/spanLat)` in BOTH axes, so a mismatched container letterboxes rather
|
|
154
|
+
than stretching. Non-uniform fitting would turn markers into ellipses.
|
|
155
|
+
Range: `MIN_ZOOM` 1 … `MAX_ZOOM` 12.
|
|
156
|
+
- **`invert`** exists for zoom-toward-the-cursor and for any consumer that
|
|
157
|
+
needs "what is under this pixel".
|
|
158
|
+
- **`DEFAULT_BOUNDS`** crops to `south: -58, north: 84` — the framing web-mojo
|
|
159
|
+
approximated with `center [10, 20], zoom 1.3`. Antarctica (lat −77) is
|
|
160
|
+
outside it on purpose; widen `bounds` if a consumer needs it.
|
|
161
|
+
- `fitViewToPoints(points, {size, bounds, padding = 50, maxZoom, fallback})`
|
|
162
|
+
replaces `fitBounds()`. Zero points returns `fallback`; ONE point recenters
|
|
163
|
+
but KEEPS the current zoom (a single-point bbox has zero span).
|
|
164
|
+
- `clampView(view, bounds, size)` clamps zoom and pans so the map can never
|
|
165
|
+
leave the frame.
|
|
166
|
+
- `splitAntimeridian(from, to)` returns 2 segments when the short path wraps
|
|
167
|
+
past ±180°, 1 otherwise — without it a Reston→Tokyo arc runs backwards
|
|
168
|
+
across the entire map.
|
|
169
|
+
- `geoArcPath(projection, from, to, curvature = 0.22)` is a quadratic bezier
|
|
170
|
+
whose control point is perpendicular to the chord. **Named `geoArcPath`,
|
|
171
|
+
not `arcPath`,** because `charts/pie-math` already exports `arcPath`.
|
|
172
|
+
|
|
173
|
+
## `countrySeriesToMarkers` and the two metrics key shapes
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const markers = countrySeriesToMarkers(response.data, {
|
|
177
|
+
maxCountries: 12, tone: 'scale', metricLabel: 'events',
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Two key shapes exist in the wild and ONE rule covers both — uppercase the
|
|
182
|
+
last `:`-separated segment, accept it only if it is 2 characters AND a known
|
|
183
|
+
country:
|
|
184
|
+
|
|
185
|
+
| shape | source | example |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| bare ISO2 | `MetricsCountryMapView.js:107-118` | `US` |
|
|
188
|
+
| namespaced slug | `GeographyPanel.js:232-243` | `incident_events:country:US`, `firewall:blocks:country:US`, `bouncer:blocks:country:US` |
|
|
189
|
+
|
|
190
|
+
`api_calls` and `ZZ` therefore both return `null`, and rows summing to zero
|
|
191
|
+
are dropped. Results are sorted descending, cut to `maxCountries`, joined to
|
|
192
|
+
`COUNTRY_CENTROIDS`, and sized by `scaleMarkerSize` (default range
|
|
193
|
+
`[18, 42]` = the source's `18 + intensity × 24`; `LoginLocationMapView` used
|
|
194
|
+
`× 26`, i.e. `[18, 44]`).
|
|
195
|
+
|
|
196
|
+
## The `land` seam — SETTLED 2026-08-06
|
|
197
|
+
|
|
198
|
+
Ian's call: **embed the outlines, no library.** `WORLD_LAND` in
|
|
199
|
+
`charts/worldmap/world-land.ts` is Natural Earth 1:110m Admin 0 Countries —
|
|
200
|
+
**public domain**, no attribution required — decoded from the `world-atlas`
|
|
201
|
+
TopoJSON build, rounded to 1 decimal and stripped of sub-visible islets.
|
|
202
|
+
173 countries, 10,443 positions, ~143 KB raw / **~40 KB gzipped**.
|
|
203
|
+
|
|
204
|
+
It is generated **once and checked in**. There is no runtime dependency, no
|
|
205
|
+
CDN fetch and no tile server — which is the whole point: web-mojo pulled
|
|
206
|
+
`maplibre-gl` from unpkg at page load and drew on MapLibre's *demo* tile
|
|
207
|
+
server, so its admin map could not work on an IP-restricted or offline
|
|
208
|
+
deployment. Ours can.
|
|
209
|
+
|
|
210
|
+
**Reach it through `useWorldLand()` — never a static import.**
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { WorldMap, useWorldLand } from 'portal-mojo/charts';
|
|
214
|
+
|
|
215
|
+
const land = useWorldLand(); // or useWorldLand(hasCoords) to skip the fetch
|
|
216
|
+
<WorldMap land={land} markers={markers} />
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`world-land.ts` is deliberately **not re-exported from the charts barrel**, and
|
|
220
|
+
the hook reaches it with a dynamic `import()`. That is what makes Rollup emit it
|
|
221
|
+
as its own chunk. Measured on `apps/portal`: static export put the coordinates
|
|
222
|
+
in the main bundle (1,472 kB / 426 kB gzipped); behind the hook the main bundle
|
|
223
|
+
is 1,328 kB / 386 kB and `world-land-*.js` is a separate 144 kB / **39.8 kB
|
|
224
|
+
gzipped** chunk fetched only when a map first renders. `verify-admin-devices`
|
|
225
|
+
asserts the barrel never re-exports it.
|
|
226
|
+
|
|
227
|
+
The hook caches at module scope, so later maps in a session get the geometry on
|
|
228
|
+
first render and concurrent mounts share one request. It returns `null` until
|
|
229
|
+
resolved — not a special case, since a null `land` is just the graticule
|
|
230
|
+
fallback, so the map is fully readable before the coastlines arrive. A failed
|
|
231
|
+
chunk fetch warns and leaves the fallback rather than blanking the map.
|
|
232
|
+
|
|
233
|
+
Surfaces using it today: `LoginLocationMap` and the GeoIP dossier's mini-map.
|
|
234
|
+
|
|
235
|
+
Rendering correctly **without** it remains a hard requirement — pass
|
|
236
|
+
`land={null}` (the default) and the ocean + graticule fallback carries the
|
|
237
|
+
markers, legend, tooltips and status line on its own. Both showcase demos keep
|
|
238
|
+
a toggle so the no-geometry case stays visible.
|
|
239
|
+
|
|
240
|
+
Regenerating (only if you want different precision or a different source):
|
|
241
|
+
decode the TopoJSON, de-quantize with its `transform`, round, and drop outer
|
|
242
|
+
rings under 0.5 square degrees. The file header records the exact parameters.
|
|
243
|
+
Do not hand-edit it.
|
|
244
|
+
|
|
245
|
+
Precision note: 1:110m is a *coarse* source by design. At the map's rendered
|
|
246
|
+
scale one pixel is roughly 0.36° of longitude, so 0.1° rounding is sub-pixel;
|
|
247
|
+
at the maximum 12× zoom the coarseness of the source itself shows before the
|
|
248
|
+
rounding does. If you ever need street-level fidelity, that is a different
|
|
249
|
+
dataset and a different conversation — not a rounding tweak.
|
|
250
|
+
|
|
251
|
+
`land` accepts a `FeatureCollection`, a bare feature array, or a single
|
|
252
|
+
`Polygon`/`MultiPolygon`. Unsupported geometry types are skipped with a
|
|
253
|
+
`console.warn` and the rest still draw (`landRings` is the exported
|
|
254
|
+
normalizer). A ring whose consecutive vertices jump more than 180° of
|
|
255
|
+
longitude BREAKS into a new subpath instead of smearing a band across the
|
|
256
|
+
map.
|
|
257
|
+
|
|
258
|
+
## Pitfalls
|
|
259
|
+
|
|
260
|
+
- **Double-click is drill-down, so canvas double-click does NOT zoom.** With
|
|
261
|
+
both `onMarkerClick` and `onMarkerDoubleClick` wired, a single click is
|
|
262
|
+
deferred `250ms` and cancelled by a double-click (in MapLibre the marker
|
|
263
|
+
handler `stopPropagation`'d against `doubleClickZoom`). Double-clicking the
|
|
264
|
+
canvas resets to `defaultView`.
|
|
265
|
+
- **Off-bounds markers do not render.** Check the footer count before
|
|
266
|
+
concluding the data is missing; widen `bounds` to bring them in.
|
|
267
|
+
- **The centroid keys were deduped.** The source table has 249 rows but 244
|
|
268
|
+
distinct ISO2 keys (`BQ`×3, `ES`×2, `TF`×3), and JS last-write-wins made
|
|
269
|
+
**`ES` resolve to "Canarias" rather than Spain** — so web-mojo's geofence
|
|
270
|
+
picker listed a Spanish island group where Spain belonged, and Spanish
|
|
271
|
+
metrics plotted in the Canaries. Duplicate keys are also a hard TS error
|
|
272
|
+
(TS1117). Here the primary territory keeps the key (`BQ`→Bonaire,
|
|
273
|
+
`ES`→Spain, `TF`→French Southern Territories) and the 5 dropped rows are
|
|
274
|
+
recorded with their coordinates in the module header.
|
|
275
|
+
- **`LoginLocationMapView`'s docstring says `size=500`; the code sends
|
|
276
|
+
`size: 1000`** (`:208`). Consumers should follow the code.
|
|
277
|
+
- **`map.resize()` has no equivalent** — `ResizeObserver` covers the
|
|
278
|
+
tab-activation and drawer-opening cases that forced it. A container at
|
|
279
|
+
width 0 defers rendering until the first non-zero measurement.
|
|
280
|
+
- **`pitch` / `bearing` are dropped.** Cosmetic 3D tilt with no consumer
|
|
281
|
+
semantics; a fake-perspective SVG map would be dishonest.
|
|
282
|
+
- **Route animation is an ADDITION.** web-mojo's routes were static
|
|
283
|
+
LineStrings; only color/width/opacity interpolated on intensity. It is
|
|
284
|
+
therefore opt-out (`animateRoutes={false}`) and lives inside
|
|
285
|
+
`@media (prefers-reduced-motion: no-preference)`.
|
|
286
|
+
- **`fit="markers"` pushes through the same channel as a gesture** — it sets
|
|
287
|
+
internal state when uncontrolled and calls `onViewChange` when controlled.
|
|
288
|
+
A controlled consumer must write that value back for the fit to stick.
|
|
289
|
+
|
|
290
|
+
## Verification
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
node scripts/verify-worldmap.mjs # or: npm run verify:worldmap
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Headless assertions over the projection (corner mapping, `project`∘`invert`
|
|
297
|
+
round-trip, clamping, fitting, antimeridian splitting), the centroid table
|
|
298
|
+
(244 unique keys, coordinate ranges, `ES` = Spain), the binding helpers
|
|
299
|
+
(slug parsing, sizing, sorting/top-N/drops), tone fallbacks and their warns,
|
|
300
|
+
the land normalizer, and the source invariants above (no network, no map
|
|
301
|
+
library, no HTML-string tooltips, tokens-only CSS, and the two theme copies
|
|
302
|
+
staying byte-identical).
|