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,283 @@
|
|
|
1
|
+
// MiniChart — the compact single-series sparkline (line / bar), ported from
|
|
2
|
+
// web-mojo src/extensions/charts/MiniChart.js (674 lines, read in full).
|
|
3
|
+
// Carries: area fill, curve smoothing, hard crops (minValue/maxValue), bar
|
|
4
|
+
// soft bounds (softMin/softMax expand-only targets), the zero-crossing-aware
|
|
5
|
+
// x-axis, per-bucket hover tooltip + crosshair + bar highlight, dots, and
|
|
6
|
+
// the all-zero "alive, just zero" dashed baseline. Draw-in animation uses
|
|
7
|
+
// pathLength-normalized CSS (no getTotalLength measuring).
|
|
8
|
+
//
|
|
9
|
+
// Deviations, deliberate: geometry comes from a measured container width
|
|
10
|
+
// (ResizeObserver — the SeriesChart pattern) instead of the source's
|
|
11
|
+
// stretched viewBox; colors default to theme tokens; the tooltip formatter
|
|
12
|
+
// is a typed function (never a DataFormatter pipe string).
|
|
13
|
+
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import { toNumber } from './stats';
|
|
15
|
+
|
|
16
|
+
export type MiniChartType = 'line' | 'bar';
|
|
17
|
+
|
|
18
|
+
export interface MiniChartProps {
|
|
19
|
+
data: readonly number[];
|
|
20
|
+
/** Per-bucket tooltip labels (index-aligned with data). */
|
|
21
|
+
labels?: readonly string[];
|
|
22
|
+
/** 'line' | 'bar'. Unknown values warn and fall back to 'line'. */
|
|
23
|
+
chartType?: MiniChartType;
|
|
24
|
+
/** px, or 'fill' to take the container's height (uniform-height cards). */
|
|
25
|
+
height?: number | 'fill';
|
|
26
|
+
/** Stroke / bar color — any CSS color incl. var() tokens. */
|
|
27
|
+
color?: string;
|
|
28
|
+
/** Area fill under the line. Default: `color` at 0.12 opacity. */
|
|
29
|
+
fillColor?: string;
|
|
30
|
+
fill?: boolean;
|
|
31
|
+
strokeWidth?: number;
|
|
32
|
+
barGap?: number;
|
|
33
|
+
/** 0 = straight segments; the source's cubic smoothing. */
|
|
34
|
+
smoothing?: number;
|
|
35
|
+
padding?: number;
|
|
36
|
+
/** Hard axis crops (both chart types). */
|
|
37
|
+
minValue?: number;
|
|
38
|
+
maxValue?: number;
|
|
39
|
+
/** Bar-only soft bounds: normalize to these, expand if data exceeds. */
|
|
40
|
+
softMin?: number;
|
|
41
|
+
softMax?: number;
|
|
42
|
+
showDots?: boolean;
|
|
43
|
+
dotRadius?: number;
|
|
44
|
+
showTooltip?: boolean;
|
|
45
|
+
valueFormatter?: (v: number) => string;
|
|
46
|
+
showCrosshair?: boolean;
|
|
47
|
+
showXAxis?: boolean;
|
|
48
|
+
animate?: boolean;
|
|
49
|
+
className?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The source's simpler horizontal-control-point smoothing (not Catmull-Rom). */
|
|
53
|
+
function smoothPath(points: readonly { x: number; y: number }[], smoothing: number): string {
|
|
54
|
+
if (points.length === 0) return '';
|
|
55
|
+
if (points.length < 2 || smoothing <= 0) {
|
|
56
|
+
return `M ${points.map((p) => `${p.x},${p.y}`).join(' L ')}`;
|
|
57
|
+
}
|
|
58
|
+
let path = `M ${points[0]!.x},${points[0]!.y}`;
|
|
59
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
60
|
+
const cur = points[i]!;
|
|
61
|
+
const next = points[i + 1]!;
|
|
62
|
+
const cp1x = cur.x + (next.x - cur.x) * smoothing;
|
|
63
|
+
const cp2x = next.x - (next.x - cur.x) * smoothing;
|
|
64
|
+
path += ` C ${cp1x},${cur.y} ${cp2x},${next.y} ${next.x},${next.y}`;
|
|
65
|
+
}
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function MiniChart({
|
|
70
|
+
data: dataProp,
|
|
71
|
+
labels,
|
|
72
|
+
chartType = 'line',
|
|
73
|
+
height: heightProp = 48,
|
|
74
|
+
color = 'var(--accent)',
|
|
75
|
+
fillColor,
|
|
76
|
+
fill = true,
|
|
77
|
+
strokeWidth = 2,
|
|
78
|
+
barGap = 2,
|
|
79
|
+
smoothing = 0.3,
|
|
80
|
+
padding = 2,
|
|
81
|
+
minValue,
|
|
82
|
+
maxValue,
|
|
83
|
+
softMin,
|
|
84
|
+
softMax,
|
|
85
|
+
showDots = false,
|
|
86
|
+
dotRadius = 2,
|
|
87
|
+
showTooltip = true,
|
|
88
|
+
valueFormatter,
|
|
89
|
+
showCrosshair = true,
|
|
90
|
+
showXAxis = false,
|
|
91
|
+
animate = true,
|
|
92
|
+
className = '',
|
|
93
|
+
}: MiniChartProps) {
|
|
94
|
+
// Malformed feed → warn + empty (unknown-value rule; never crash).
|
|
95
|
+
const validShape = Array.isArray(dataProp);
|
|
96
|
+
if (!validShape) {
|
|
97
|
+
console.warn('MiniChart: data is not an array — rendering empty', { data: dataProp });
|
|
98
|
+
}
|
|
99
|
+
let type: MiniChartType = chartType;
|
|
100
|
+
if (type !== 'line' && type !== 'bar') {
|
|
101
|
+
console.warn(`MiniChart: unknown chartType "${String(chartType)}" — falling back to "line"`);
|
|
102
|
+
type = 'line';
|
|
103
|
+
}
|
|
104
|
+
const values = useMemo(() => (validShape ? dataProp.map(toNumber) : []), [validShape, dataProp]);
|
|
105
|
+
|
|
106
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
const [width, setWidth] = useState(120);
|
|
108
|
+
const [fillH, setFillH] = useState(72);
|
|
109
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
110
|
+
|
|
111
|
+
useLayoutEffect(() => {
|
|
112
|
+
const node = wrapRef.current;
|
|
113
|
+
if (!node) return;
|
|
114
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
115
|
+
const w = entry?.contentRect.width ?? 0;
|
|
116
|
+
if (w > 0) setWidth(w);
|
|
117
|
+
// 'fill' cards take their height from the box too, so a chart can
|
|
118
|
+
// grow into a stretched card instead of leaving a void under it.
|
|
119
|
+
const h = entry?.contentRect.height ?? 0;
|
|
120
|
+
if (h > 0) setFillH(h);
|
|
121
|
+
});
|
|
122
|
+
ro.observe(node);
|
|
123
|
+
return () => ro.disconnect();
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
// Resolve 'fill' to the measured box height.
|
|
127
|
+
const height = heightProp === 'fill' ? fillH : heightProp;
|
|
128
|
+
|
|
129
|
+
const n = values.length;
|
|
130
|
+
const dataMin = n ? Math.min(...values) : 0;
|
|
131
|
+
const dataMax = n ? Math.max(...values) : 0;
|
|
132
|
+
|
|
133
|
+
// Line bounds (source calculateBounds): hard crops win; zero-range pads.
|
|
134
|
+
let lineMin = minValue ?? dataMin;
|
|
135
|
+
let lineMax = maxValue ?? dataMax;
|
|
136
|
+
if (lineMax - lineMin === 0) {
|
|
137
|
+
if (type === 'bar' && lineMin === 0) lineMax = 1;
|
|
138
|
+
else { lineMin -= 1; lineMax += 1; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const drawTop = padding;
|
|
142
|
+
const drawBottom = height - padding;
|
|
143
|
+
const drawH = drawBottom - drawTop;
|
|
144
|
+
const xStep = (width - padding * 2) / (n - 1 || 1);
|
|
145
|
+
const points = values.map((v, i) => ({
|
|
146
|
+
x: padding + i * xStep,
|
|
147
|
+
y: drawBottom - ((v - lineMin) / (lineMax - lineMin)) * drawH,
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
// Bar bounds (source renderBar): always include zero; soft bounds expand.
|
|
151
|
+
const barLo = minValue !== undefined ? minValue : Math.min(0, softMin ?? 0, dataMin);
|
|
152
|
+
const barHiRaw = maxValue !== undefined ? maxValue : Math.max(0, softMax ?? 0, dataMax);
|
|
153
|
+
const barHi = barHiRaw === barLo ? barLo + 1 : barHiRaw;
|
|
154
|
+
const yScale = drawH / (barHi - barLo);
|
|
155
|
+
const yBase = Math.max(drawTop, Math.min(drawBottom, drawBottom - (0 - barLo) * yScale));
|
|
156
|
+
|
|
157
|
+
const allZero = n > 0 && dataMin === 0 && dataMax === 0;
|
|
158
|
+
const callerBounds = minValue !== undefined || maxValue !== undefined || softMin !== undefined || softMax !== undefined;
|
|
159
|
+
const barsAsBaseline = type === 'bar' && allZero && !callerBounds;
|
|
160
|
+
|
|
161
|
+
// X-axis at zero when the data crosses it, else at the bottom (source).
|
|
162
|
+
let axisY = drawBottom;
|
|
163
|
+
if (lineMin <= 0 && lineMax >= 0) {
|
|
164
|
+
axisY = drawBottom - ((0 - lineMin) / (lineMax - lineMin)) * drawH;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const barW = n > 0 ? Math.max(1, (width - padding * 2 - barGap * (n - 1)) / n) : 0;
|
|
168
|
+
const fmt = valueFormatter ?? ((v: number) => v.toLocaleString());
|
|
169
|
+
const fillPaint = fillColor ?? color;
|
|
170
|
+
const fillOpacity = fillColor ? 1 : 0.12;
|
|
171
|
+
|
|
172
|
+
const colW = n > 0 ? width / n : width;
|
|
173
|
+
const onMove = (e: React.MouseEvent<SVGRectElement>) => {
|
|
174
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
175
|
+
const x = e.clientX - rect.left;
|
|
176
|
+
setHover(Math.min(n - 1, Math.max(0, Math.floor(x / (colW || 1)))));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Re-run the draw-in whenever the series identity changes (source parity:
|
|
180
|
+
// every setData re-animated).
|
|
181
|
+
const dataKey = useMemo(() => values.join('|'), [values]);
|
|
182
|
+
|
|
183
|
+
const linePath = smoothPath(points, smoothing);
|
|
184
|
+
const areaPath = points.length
|
|
185
|
+
? `${linePath} L ${points[points.length - 1]!.x},${drawBottom} L ${points[0]!.x},${drawBottom} Z`
|
|
186
|
+
: '';
|
|
187
|
+
|
|
188
|
+
const tipX = hover != null ? Math.min(Math.max(colW * (hover + 0.5), 40), Math.max(width - 40, 40)) : 0;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
ref={wrapRef}
|
|
193
|
+
className={`minichart${className ? ` ${className}` : ''}`}
|
|
194
|
+
style={{ height: heightProp === 'fill' ? '100%' : height }}
|
|
195
|
+
onMouseLeave={() => setHover(null)}
|
|
196
|
+
>
|
|
197
|
+
<svg width={width} height={height} className="minichart-svg" role="img" key={animate ? dataKey : 'static'}>
|
|
198
|
+
{showXAxis && n > 0 && (
|
|
199
|
+
<line
|
|
200
|
+
x1={padding} x2={width - padding} y1={axisY} y2={axisY}
|
|
201
|
+
stroke={color} strokeWidth={1} strokeOpacity={0.5} strokeDasharray="2,2"
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{n > 0 && type === 'line' && (
|
|
206
|
+
<>
|
|
207
|
+
{fill && (
|
|
208
|
+
<path d={areaPath} fill={fillPaint} fillOpacity={fillOpacity} stroke="none" />
|
|
209
|
+
)}
|
|
210
|
+
<path
|
|
211
|
+
d={linePath}
|
|
212
|
+
fill="none"
|
|
213
|
+
stroke={color}
|
|
214
|
+
strokeWidth={strokeWidth}
|
|
215
|
+
strokeLinecap="round"
|
|
216
|
+
strokeLinejoin="round"
|
|
217
|
+
pathLength={1}
|
|
218
|
+
className={animate ? 'minichart-draw' : undefined}
|
|
219
|
+
/>
|
|
220
|
+
{showDots && points.map((p, i) => (
|
|
221
|
+
<circle key={i} cx={p.x} cy={p.y} r={hover === i ? dotRadius + 1 : dotRadius} fill={color} />
|
|
222
|
+
))}
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{n > 0 && type === 'bar' && (barsAsBaseline ? (
|
|
227
|
+
// All-zero, no caller bounds: dashed baseline says "alive,
|
|
228
|
+
// just zero" instead of looking broken (source behavior).
|
|
229
|
+
<line
|
|
230
|
+
x1={padding} x2={width - padding} y1={drawBottom} y2={drawBottom}
|
|
231
|
+
stroke={color} strokeWidth={1} strokeOpacity={0.4} strokeDasharray="2,2"
|
|
232
|
+
/>
|
|
233
|
+
) : (
|
|
234
|
+
values.map((v, i) => {
|
|
235
|
+
const yRaw = drawBottom - (v - barLo) * yScale;
|
|
236
|
+
const yVal = Math.max(drawTop, Math.min(drawBottom, yRaw));
|
|
237
|
+
const top = Math.min(yVal, yBase);
|
|
238
|
+
const h = Math.max(0, Math.abs(yVal - yBase));
|
|
239
|
+
return (
|
|
240
|
+
<rect
|
|
241
|
+
key={i}
|
|
242
|
+
x={points[i]!.x - barW / 2}
|
|
243
|
+
y={top}
|
|
244
|
+
width={barW}
|
|
245
|
+
height={h}
|
|
246
|
+
rx={1}
|
|
247
|
+
fill={color}
|
|
248
|
+
opacity={hover === i ? 0.7 : 1}
|
|
249
|
+
className={animate ? 'minichart-grow' : undefined}
|
|
250
|
+
style={animate ? { animationDelay: `${i * 20}ms` } : undefined}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
})
|
|
254
|
+
))}
|
|
255
|
+
|
|
256
|
+
{showCrosshair && hover != null && (
|
|
257
|
+
<line
|
|
258
|
+
x1={colW * (hover + 0.5)} x2={colW * (hover + 0.5)}
|
|
259
|
+
y1={0} y2={height}
|
|
260
|
+
className="minichart-crosshair"
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* One overlay drives index hover (SeriesChart pattern). */}
|
|
265
|
+
{n > 0 && (showTooltip || showCrosshair) && (
|
|
266
|
+
<rect
|
|
267
|
+
x={0} y={0} width={width} height={height}
|
|
268
|
+
fill="transparent"
|
|
269
|
+
onMouseMove={onMove}
|
|
270
|
+
onMouseLeave={() => setHover(null)}
|
|
271
|
+
/>
|
|
272
|
+
)}
|
|
273
|
+
</svg>
|
|
274
|
+
|
|
275
|
+
{showTooltip && hover != null && n > 0 && (
|
|
276
|
+
<div className="minichart-tip" style={{ left: tipX }}>
|
|
277
|
+
{labels?.[hover] != null && <div className="minichart-tip-label">{labels[hover]}</div>}
|
|
278
|
+
<strong>{fmt(values[hover] ?? 0)}</strong>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// PieChart — native SVG pie / doughnut, ported from web-mojo
|
|
2
|
+
// src/extensions/charts/PieChart.js (546 lines, read in full). No chart
|
|
3
|
+
// library. Carries: the three input shapes (array of {label,value,color?} /
|
|
4
|
+
// Chart.js {labels,datasets} / object map), optional endpoint fetch through
|
|
5
|
+
// the one client boundary, the golden-angle color generator, cutout +
|
|
6
|
+
// center label/sub-label (static or computed), right/bottom legend, slice
|
|
7
|
+
// edge labels with percentages, clamped tooltip, slice click, the
|
|
8
|
+
// full-circle single-segment special case, and the label-keyed arc tween.
|
|
9
|
+
//
|
|
10
|
+
// Deviations, deliberate:
|
|
11
|
+
// · default palette is CHART_PALETTE (the mission-control set SeriesChart
|
|
12
|
+
// uses) instead of web-mojo's Chart.js-era hexes; overflow indices go to
|
|
13
|
+
// the golden-angle generator exactly as in the source;
|
|
14
|
+
// · valueFormatter is a typed function (never a DataFormatter pipe string);
|
|
15
|
+
// · malformed data warns and renders the empty state (the source silently
|
|
16
|
+
// rendered nothing);
|
|
17
|
+
// · endpoint errors warn + empty state; TanStack owns retry/caching.
|
|
18
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
19
|
+
import { useQuery } from '@tanstack/react-query';
|
|
20
|
+
import { mojoCall } from '../client/client';
|
|
21
|
+
import type { Params } from '../client/types';
|
|
22
|
+
import { CHART_PALETTE } from './SeriesChart';
|
|
23
|
+
import {
|
|
24
|
+
arcPath,
|
|
25
|
+
buildPieGeometry,
|
|
26
|
+
goldenAngleColor,
|
|
27
|
+
interpolateSegments,
|
|
28
|
+
parsePieInput,
|
|
29
|
+
type PieInput,
|
|
30
|
+
type PieSegment,
|
|
31
|
+
} from './pie-math';
|
|
32
|
+
|
|
33
|
+
/** The center-label context ({total, segments} — source ctx shape). */
|
|
34
|
+
export interface PieCenterContext {
|
|
35
|
+
total: number;
|
|
36
|
+
segments: readonly PieSegment[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PieCenterLabel = string | number | ((ctx: PieCenterContext) => string | number);
|
|
40
|
+
|
|
41
|
+
export interface PieChartProps {
|
|
42
|
+
/** One of the three shapes. Ignored while `endpoint` is set. */
|
|
43
|
+
data?: PieInput | null;
|
|
44
|
+
/** GET this endpoint through the client boundary; its `data` feeds the pie. */
|
|
45
|
+
endpoint?: string;
|
|
46
|
+
/** Wire params for the endpoint fetch. */
|
|
47
|
+
params?: Params;
|
|
48
|
+
width?: number;
|
|
49
|
+
height?: number;
|
|
50
|
+
/** 0 = solid pie; 0..1 = inner-hole fraction (doughnut). */
|
|
51
|
+
cutout?: number;
|
|
52
|
+
/** Doughnut center text (needs cutout > 0). String or ({total, segments}) => string. */
|
|
53
|
+
centerLabel?: PieCenterLabel | null;
|
|
54
|
+
/** Smaller uppercase line under the center label. */
|
|
55
|
+
centerSubLabel?: PieCenterLabel | null;
|
|
56
|
+
/** Per-index colors; overflow falls to `colorGenerator`. Default CHART_PALETTE. */
|
|
57
|
+
colors?: string[];
|
|
58
|
+
colorGenerator?: (i: number) => string;
|
|
59
|
+
showLegend?: boolean;
|
|
60
|
+
/** 'right' | 'bottom' | 'none'. Unknown values warn and fall back to 'right'. */
|
|
61
|
+
legendPosition?: 'right' | 'bottom' | 'none';
|
|
62
|
+
/** Slice-edge labels outside the arc. */
|
|
63
|
+
showLabels?: boolean;
|
|
64
|
+
/** Append "(12.5%)" to edge labels; legend always shows percentages. */
|
|
65
|
+
showPercentages?: boolean;
|
|
66
|
+
showTooltip?: boolean;
|
|
67
|
+
animate?: boolean;
|
|
68
|
+
animationDuration?: number;
|
|
69
|
+
valueFormatter?: (v: number) => string;
|
|
70
|
+
onSliceClick?: (e: { label: string; value: number; pct: number; index: number }) => void;
|
|
71
|
+
emptyText?: string;
|
|
72
|
+
className?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
|
|
76
|
+
|
|
77
|
+
export function PieChart({
|
|
78
|
+
data: dataProp = null,
|
|
79
|
+
endpoint,
|
|
80
|
+
params,
|
|
81
|
+
width = 200,
|
|
82
|
+
height = 200,
|
|
83
|
+
cutout = 0,
|
|
84
|
+
centerLabel = null,
|
|
85
|
+
centerSubLabel = null,
|
|
86
|
+
colors,
|
|
87
|
+
colorGenerator = goldenAngleColor,
|
|
88
|
+
showLegend = true,
|
|
89
|
+
legendPosition = 'right',
|
|
90
|
+
showLabels = false,
|
|
91
|
+
showPercentages = true,
|
|
92
|
+
showTooltip = true,
|
|
93
|
+
animate = true,
|
|
94
|
+
animationDuration = 300,
|
|
95
|
+
valueFormatter,
|
|
96
|
+
onSliceClick,
|
|
97
|
+
emptyText = 'No data',
|
|
98
|
+
className = '',
|
|
99
|
+
}: PieChartProps) {
|
|
100
|
+
let legendPos: 'right' | 'bottom' | 'none' = legendPosition;
|
|
101
|
+
if (legendPos !== 'right' && legendPos !== 'bottom' && legendPos !== 'none') {
|
|
102
|
+
console.warn(`PieChart: unknown legendPosition "${String(legendPosition)}" — falling back to "right"`);
|
|
103
|
+
legendPos = 'right';
|
|
104
|
+
}
|
|
105
|
+
const legendOn = showLegend && legendPos !== 'none';
|
|
106
|
+
|
|
107
|
+
// ── Endpoint fetch (optional) through the one client boundary ─────
|
|
108
|
+
const query = useQuery({
|
|
109
|
+
queryKey: [endpoint, 'pie', params ?? {}],
|
|
110
|
+
queryFn: () => mojoCall(endpoint!, { params }),
|
|
111
|
+
enabled: !!endpoint,
|
|
112
|
+
});
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (query.isError) {
|
|
115
|
+
console.warn(`PieChart: endpoint fetch failed (${endpoint}) — rendering empty state`, query.error);
|
|
116
|
+
}
|
|
117
|
+
}, [query.isError, query.error, endpoint]);
|
|
118
|
+
|
|
119
|
+
// Source heuristic carried over: some endpoints nest the payload one
|
|
120
|
+
// level deeper (`data.data`) — prefer it when present.
|
|
121
|
+
const raw: unknown = endpoint
|
|
122
|
+
? (query.data ? ((query.data.data as { data?: unknown } | null)?.data ?? query.data.data) : null)
|
|
123
|
+
: dataProp;
|
|
124
|
+
|
|
125
|
+
// ── Normalize + color + geometry ──────────────────────────────────
|
|
126
|
+
const palette = colors && colors.length ? colors : CHART_PALETTE;
|
|
127
|
+
const target = useMemo(() => {
|
|
128
|
+
const slices = parsePieInput(raw);
|
|
129
|
+
const total = slices.reduce((s, d) => s + d.value, 0);
|
|
130
|
+
const colored = slices.map((s, i) => ({
|
|
131
|
+
label: s.label,
|
|
132
|
+
value: s.value,
|
|
133
|
+
pct: total > 0 ? (s.value / total) * 100 : 0,
|
|
134
|
+
color: s.color ?? palette[i] ?? colorGenerator(i),
|
|
135
|
+
}));
|
|
136
|
+
return buildPieGeometry(colored);
|
|
137
|
+
}, [raw, palette, colorGenerator]);
|
|
138
|
+
const total = useMemo(() => target.reduce((s, seg) => s + seg.value, 0), [target]);
|
|
139
|
+
|
|
140
|
+
// ── Tween (label-keyed, rAF, cancelled on change/unmount) ─────────
|
|
141
|
+
const [painted, setPainted] = useState<readonly PieSegment[]>(target);
|
|
142
|
+
const prevRef = useRef<readonly PieSegment[]>(target);
|
|
143
|
+
const firstPaint = useRef(true);
|
|
144
|
+
const targetKey = useMemo(
|
|
145
|
+
() => target.map((s) => `${s.label}:${s.value}`).join('|'),
|
|
146
|
+
[target],
|
|
147
|
+
);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
// First paint is direct (source: onAfterRender painted without animation).
|
|
150
|
+
if (firstPaint.current || !animate || animationDuration <= 0) {
|
|
151
|
+
firstPaint.current = false;
|
|
152
|
+
prevRef.current = target;
|
|
153
|
+
setPainted(target);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const prev = prevRef.current;
|
|
157
|
+
const start = performance.now();
|
|
158
|
+
let raf = 0;
|
|
159
|
+
const step = (now: number) => {
|
|
160
|
+
const t = Math.min(1, (now - start) / animationDuration);
|
|
161
|
+
setPainted(interpolateSegments(prev, target, easeOutCubic(t)));
|
|
162
|
+
if (t < 1) raf = requestAnimationFrame(step);
|
|
163
|
+
else prevRef.current = target;
|
|
164
|
+
};
|
|
165
|
+
raf = requestAnimationFrame(step);
|
|
166
|
+
return () => cancelAnimationFrame(raf);
|
|
167
|
+
// targetKey is the identity of `target`; the ref carries the old one.
|
|
168
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
+
}, [targetKey, animate, animationDuration]);
|
|
170
|
+
|
|
171
|
+
// ── Tooltip (clamped inside the svg area, source _moveTooltip) ────
|
|
172
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
173
|
+
// Legend hover highlights a slice but must not summon the mouse-anchored
|
|
174
|
+
// tooltip (it would render at a stale position) — arc hover only.
|
|
175
|
+
const [tipOn, setTipOn] = useState(false);
|
|
176
|
+
const areaRef = useRef<HTMLDivElement>(null);
|
|
177
|
+
const tipRef = useRef<HTMLDivElement>(null);
|
|
178
|
+
const mouseRef = useRef({ x: 0, y: 0 });
|
|
179
|
+
const placeTip = () => {
|
|
180
|
+
const tip = tipRef.current;
|
|
181
|
+
const area = areaRef.current;
|
|
182
|
+
if (!tip || !area) return;
|
|
183
|
+
const aw = area.clientWidth;
|
|
184
|
+
const ah = area.clientHeight;
|
|
185
|
+
const tw = tip.offsetWidth;
|
|
186
|
+
const th = tip.offsetHeight;
|
|
187
|
+
let left = mouseRef.current.x - tw / 2;
|
|
188
|
+
let top = mouseRef.current.y - th - 8;
|
|
189
|
+
if (left < 0) left = 0;
|
|
190
|
+
if (left + tw > aw) left = aw - tw;
|
|
191
|
+
if (top < 0) top = mouseRef.current.y + 12;
|
|
192
|
+
if (top + th > ah) top = ah - th;
|
|
193
|
+
tip.style.left = `${left}px`;
|
|
194
|
+
tip.style.top = `${top}px`;
|
|
195
|
+
};
|
|
196
|
+
useLayoutEffect(() => { if (hover != null) placeTip(); });
|
|
197
|
+
const trackMouse = (e: React.MouseEvent) => {
|
|
198
|
+
const area = areaRef.current;
|
|
199
|
+
if (!area) return;
|
|
200
|
+
const rect = area.getBoundingClientRect();
|
|
201
|
+
mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
202
|
+
placeTip();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const fmt = valueFormatter ?? ((v: number) => v.toLocaleString());
|
|
206
|
+
|
|
207
|
+
const cx = width / 2;
|
|
208
|
+
const cy = height / 2;
|
|
209
|
+
const outerR = Math.min(cx, cy) - 4;
|
|
210
|
+
const innerR = cutout > 0 ? outerR * Math.min(1, Math.max(0, cutout)) : 0;
|
|
211
|
+
|
|
212
|
+
const visible = painted.filter((s) => Math.abs(s.endAngle - s.startAngle) > 1e-6);
|
|
213
|
+
const fullCircle = visible.length === 1
|
|
214
|
+
&& Math.abs(Math.abs(visible[0]!.endAngle - visible[0]!.startAngle) - Math.PI * 2) < 1e-3;
|
|
215
|
+
const hasData = target.length > 0 && total > 0;
|
|
216
|
+
|
|
217
|
+
const centerCtx: PieCenterContext = { total, segments: target };
|
|
218
|
+
const resolveCenter = (v: PieCenterLabel | null): string | null => {
|
|
219
|
+
if (v == null) return null;
|
|
220
|
+
return String(typeof v === 'function' ? v(centerCtx) : v);
|
|
221
|
+
};
|
|
222
|
+
const centerMain = resolveCenter(centerLabel);
|
|
223
|
+
const centerSub = resolveCenter(centerSubLabel);
|
|
224
|
+
|
|
225
|
+
const sliceHandlers = (i: number) => ({
|
|
226
|
+
onMouseEnter: (e: React.MouseEvent) => { setHover(i); if (showTooltip) { setTipOn(true); trackMouse(e); } },
|
|
227
|
+
onMouseMove: (e: React.MouseEvent) => { if (showTooltip) trackMouse(e); },
|
|
228
|
+
onMouseLeave: () => { setHover(null); setTipOn(false); },
|
|
229
|
+
onClick: () => {
|
|
230
|
+
const seg = target[i];
|
|
231
|
+
if (seg && onSliceClick) onSliceClick({ label: seg.label, value: seg.value, pct: seg.pct, index: i });
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const hovered = hover != null ? target[hover] : null;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className={`pie-chart pie-layout-${legendPos === 'bottom' ? 'bottom' : 'right'}${className ? ` ${className}` : ''}`}>
|
|
239
|
+
<div className="pie-svg-area" style={{ width, height }} ref={areaRef}>
|
|
240
|
+
{!hasData ? (
|
|
241
|
+
<div className="pie-empty">{endpoint && query.isPending ? <span className="skel skel-block" /> : emptyText}</div>
|
|
242
|
+
) : (
|
|
243
|
+
<svg
|
|
244
|
+
className="pie-svg"
|
|
245
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
246
|
+
width="100%"
|
|
247
|
+
height="100%"
|
|
248
|
+
preserveAspectRatio="xMidYMid meet"
|
|
249
|
+
role="img"
|
|
250
|
+
>
|
|
251
|
+
{fullCircle ? (
|
|
252
|
+
// Single-segment special case: SVG arcs cannot draw a
|
|
253
|
+
// full 360° — a circle element does (source parity).
|
|
254
|
+
innerR > 0 ? (
|
|
255
|
+
<circle
|
|
256
|
+
cx={cx} cy={cy} r={(outerR + innerR) / 2}
|
|
257
|
+
fill="none" stroke={visible[0]!.color} strokeWidth={outerR - innerR}
|
|
258
|
+
className="pie-segment"
|
|
259
|
+
{...sliceHandlers(painted.indexOf(visible[0]!))}
|
|
260
|
+
/>
|
|
261
|
+
) : (
|
|
262
|
+
<circle
|
|
263
|
+
cx={cx} cy={cy} r={outerR} fill={visible[0]!.color}
|
|
264
|
+
className="pie-segment"
|
|
265
|
+
{...sliceHandlers(painted.indexOf(visible[0]!))}
|
|
266
|
+
/>
|
|
267
|
+
)
|
|
268
|
+
) : (
|
|
269
|
+
painted.map((seg, i) => {
|
|
270
|
+
if (seg.endAngle - seg.startAngle <= 0) return null;
|
|
271
|
+
return (
|
|
272
|
+
<path
|
|
273
|
+
key={seg.label || i}
|
|
274
|
+
d={arcPath(cx, cy, outerR, innerR, seg)}
|
|
275
|
+
fill={seg.color}
|
|
276
|
+
stroke="var(--surface)"
|
|
277
|
+
strokeWidth={1.5}
|
|
278
|
+
className={`pie-segment${hover === i ? ' pie-segment-hot' : ''}`}
|
|
279
|
+
{...sliceHandlers(i)}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
})
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Slice-edge labels */}
|
|
286
|
+
{showLabels && !fullCircle && painted.map((seg, i) => {
|
|
287
|
+
const arc = seg.endAngle - seg.startAngle;
|
|
288
|
+
if (arc <= 0.05) return null;
|
|
289
|
+
const mid = (seg.startAngle + seg.endAngle) / 2;
|
|
290
|
+
const lx = cx + (outerR + 12) * Math.cos(mid);
|
|
291
|
+
const ly = cy + (outerR + 12) * Math.sin(mid);
|
|
292
|
+
return (
|
|
293
|
+
<text
|
|
294
|
+
key={`l${seg.label || i}`}
|
|
295
|
+
x={lx} y={ly}
|
|
296
|
+
textAnchor={lx < cx ? 'end' : 'start'}
|
|
297
|
+
className="pie-edge-label"
|
|
298
|
+
>
|
|
299
|
+
{showPercentages ? `${seg.label} (${seg.pct.toFixed(1)}%)` : seg.label}
|
|
300
|
+
</text>
|
|
301
|
+
);
|
|
302
|
+
})}
|
|
303
|
+
|
|
304
|
+
{/* Doughnut center label(s) */}
|
|
305
|
+
{innerR > 0 && centerMain != null && (
|
|
306
|
+
<text
|
|
307
|
+
x={cx} y={centerSub != null ? cy - 2 : cy + 6}
|
|
308
|
+
textAnchor="middle" dominantBaseline="middle"
|
|
309
|
+
className="pie-center-label"
|
|
310
|
+
>
|
|
311
|
+
{centerMain}
|
|
312
|
+
</text>
|
|
313
|
+
)}
|
|
314
|
+
{innerR > 0 && centerSub != null && (
|
|
315
|
+
<text
|
|
316
|
+
x={cx} y={cy + 16}
|
|
317
|
+
textAnchor="middle" dominantBaseline="middle"
|
|
318
|
+
className="pie-center-sub"
|
|
319
|
+
>
|
|
320
|
+
{centerSub.toUpperCase()}
|
|
321
|
+
</text>
|
|
322
|
+
)}
|
|
323
|
+
</svg>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{showTooltip && tipOn && hovered && (
|
|
327
|
+
<div className="pie-tip" ref={tipRef}>
|
|
328
|
+
<strong>{hovered.label}</strong>
|
|
329
|
+
<span>{fmt(hovered.value)} ({hovered.pct.toFixed(1)}%)</span>
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{legendOn && hasData && (
|
|
335
|
+
<div className="pie-legend">
|
|
336
|
+
{target.map((seg, i) => (
|
|
337
|
+
<div
|
|
338
|
+
key={seg.label || i}
|
|
339
|
+
className={`pie-legend-item${hover === i ? ' pie-legend-hot' : ''}`}
|
|
340
|
+
onMouseEnter={() => setHover(i)}
|
|
341
|
+
onMouseLeave={() => setHover(null)}
|
|
342
|
+
>
|
|
343
|
+
{/* Color as a style property, never string-interpolated markup —
|
|
344
|
+
the value can come from the API (source note carried over). */}
|
|
345
|
+
<span className="pie-legend-swatch" style={{ background: seg.color }} />
|
|
346
|
+
<span className="pie-legend-label">{seg.label}</span>
|
|
347
|
+
<span className="pie-legend-value">{seg.pct.toFixed(1)}%</span>
|
|
348
|
+
</div>
|
|
349
|
+
))}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|