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