astro-component-kit 0.3.1 → 0.4.1
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/README.md +133 -36
- package/package.json +17 -4
- package/src/components/index.ts +18 -202
- package/src/components/lib/Galaxy3D.astro +249 -137
- package/src/components/lib/app/AppSidebar.astro +249 -0
- package/src/components/lib/app/AppTopbar.astro +284 -0
- package/src/components/lib/app/ArrivalsBoard.astro +176 -0
- package/src/components/lib/app/AuthCard.astro +213 -0
- package/src/components/lib/app/DataTable.astro +346 -0
- package/src/components/lib/app/GuestProfileCard.astro +133 -0
- package/src/components/lib/app/HousekeepingBoard.astro +189 -0
- package/src/components/lib/app/PageHeader.astro +157 -0
- package/src/components/lib/app/PlanUsageCard.astro +112 -0
- package/src/components/lib/app/RoomRack.astro +207 -0
- package/src/components/lib/app/SettingsSection.astro +96 -0
- package/src/components/lib/app/SwitchRow.astro +71 -0
- package/src/components/lib/app/TeamMembers.astro +190 -0
- package/src/components/lib/app/app-icons.ts +52 -0
- package/src/components/lib/app/index.ts +19 -0
- package/src/components/lib/buttons/NeonBorderButton.astro +162 -0
- package/src/components/lib/buttons/RippleButton.astro +199 -0
- package/src/components/lib/buttons/SlideRevealButton.astro +159 -0
- package/src/components/lib/buttons/index.ts +4 -0
- package/src/components/lib/cards/GithubRepoCard.astro +176 -0
- package/src/components/lib/cards/PricingCompareMiniCard.astro +196 -0
- package/src/components/lib/cards/StatsTrendCard.astro +149 -0
- package/src/components/lib/cards/index.ts +3 -0
- package/src/components/lib/charts/ActivityRings.astro +105 -0
- package/src/components/lib/charts/AreaChart.astro +4 -4
- package/src/components/lib/charts/BarChart.astro +3 -3
- package/src/components/lib/charts/BoxPlotChart.astro +109 -0
- package/src/components/lib/charts/BubbleChart.astro +101 -0
- package/src/components/lib/charts/BulletChart.astro +160 -0
- package/src/components/lib/charts/BumpChart.astro +92 -0
- package/src/components/lib/charts/CandlestickChart.astro +105 -0
- package/src/components/lib/charts/ChartFrame.astro +373 -0
- package/src/components/lib/charts/DivergingBarChart.astro +98 -0
- package/src/components/lib/charts/DonutChart.astro +2 -2
- package/src/components/lib/charts/DumbbellChart.astro +92 -0
- package/src/components/lib/charts/ForecastLineChart.astro +125 -0
- package/src/components/lib/charts/FunnelChart.astro +122 -0
- package/src/components/lib/charts/GaugeChart.astro +111 -0
- package/src/components/lib/charts/GoalProgressChart.astro +97 -0
- package/src/components/lib/charts/GroupedBarChart.astro +125 -0
- package/src/components/lib/charts/HeatmapChart.astro +131 -0
- package/src/components/lib/charts/HistogramChart.astro +111 -0
- package/src/components/lib/charts/HorizontalBarChart.astro +155 -0
- package/src/components/lib/charts/KpiStatTile.astro +116 -0
- package/src/components/lib/charts/KpiTrendGrid.astro +60 -0
- package/src/components/lib/charts/LineChart.astro +160 -0
- package/src/components/lib/charts/LinearProgressGauge.astro +136 -0
- package/src/components/lib/charts/LollipopChart.astro +100 -0
- package/src/components/lib/charts/MeterGauge.astro +122 -0
- package/src/components/lib/charts/MetricDeltaTable.astro +125 -0
- package/src/components/lib/charts/PieChart.astro +135 -0
- package/src/components/lib/charts/PolarAreaChart.astro +85 -0
- package/src/components/lib/charts/PopulationPyramid.astro +122 -0
- package/src/components/lib/charts/PunchCardChart.astro +89 -0
- package/src/components/lib/charts/RadarChart.astro +94 -0
- package/src/components/lib/charts/RadarMetricChart.astro +168 -0
- package/src/components/lib/charts/RadialBarChart.astro +124 -0
- package/src/components/lib/charts/RangeAreaChart.astro +113 -0
- package/src/components/lib/charts/RetentionCohortTable.astro +126 -0
- package/src/components/lib/charts/SankeyChart.astro +128 -0
- package/src/components/lib/charts/ScatterPlot.astro +139 -0
- package/src/components/lib/charts/SegmentedBarChart.astro +119 -0
- package/src/components/lib/charts/SlopeChart.astro +105 -0
- package/src/components/lib/charts/SparklineChart.astro +87 -0
- package/src/components/lib/charts/StackedAreaChart.astro +130 -0
- package/src/components/lib/charts/StackedBarChart.astro +136 -0
- package/src/components/lib/charts/SunburstChart.astro +103 -0
- package/src/components/lib/charts/TreemapChart.astro +94 -0
- package/src/components/lib/charts/WaffleChart.astro +121 -0
- package/src/components/lib/charts/WaterfallChart.astro +118 -0
- package/src/components/lib/charts/chart-tooltip.ts +75 -0
- package/src/components/lib/charts/chart-utils.ts +271 -0
- package/src/components/lib/charts/index.ts +46 -0
- package/src/components/lib/data/ActivityHeatmapGrid.astro +157 -0
- package/src/components/lib/data/MetricComparisonRow.astro +109 -0
- package/src/components/lib/data/StatusTimelineFeed.astro +168 -0
- package/src/components/lib/data/index.ts +3 -0
- package/src/components/lib/ecommerce/CartDrawer.astro +483 -0
- package/src/components/lib/ecommerce/CartPageList.astro +290 -0
- package/src/components/lib/ecommerce/CartToast.astro +276 -0
- package/src/components/lib/ecommerce/CategoryTiles.astro +143 -0
- package/src/components/lib/ecommerce/CheckoutForm.astro +423 -0
- package/src/components/lib/ecommerce/CountdownBanner.astro +230 -0
- package/src/components/lib/ecommerce/FilterBar.astro +278 -0
- package/src/components/lib/ecommerce/FilterSidebar.astro +405 -0
- package/src/components/lib/ecommerce/LiveActivityToast.astro +213 -0
- package/src/components/lib/ecommerce/OrderSummary.astro +295 -0
- package/src/components/lib/ecommerce/ProductBuyBox.astro +247 -0
- package/src/components/lib/ecommerce/ProductCardMinimal.astro +229 -0
- package/src/components/lib/ecommerce/ProductGallery.astro +145 -0
- package/src/components/lib/ecommerce/ProductGrid.astro +118 -0
- package/src/components/lib/ecommerce/ProductMedia.astro +113 -0
- package/src/components/lib/ecommerce/ProductRating.astro +78 -0
- package/src/components/lib/ecommerce/ProductToolbar.astro +93 -0
- package/src/components/lib/ecommerce/PromoBar.astro +145 -0
- package/src/components/lib/ecommerce/StockIndicator.astro +127 -0
- package/src/components/lib/ecommerce/StoreHeader.astro +446 -0
- package/src/components/lib/ecommerce/StoreHero.astro +260 -0
- package/src/components/lib/ecommerce/StoreProductCard.astro +307 -0
- package/src/components/lib/ecommerce/TrustBadges.astro +101 -0
- package/src/components/lib/ecommerce/VariantPicker.astro +145 -0
- package/src/components/lib/ecommerce/cart-store.ts +151 -0
- package/src/components/lib/ecommerce/catalog-filter.ts +62 -0
- package/src/components/lib/ecommerce/index.ts +34 -0
- package/src/components/lib/ecommerce/product-icons.ts +28 -0
- package/src/components/lib/ecommerce/types.ts +70 -0
- package/src/components/lib/feedback/ProgressRingCounter.astro +118 -0
- package/src/components/lib/feedback/ToastNotificationGroup.astro +173 -0
- package/src/components/lib/feedback/index.ts +2 -0
- package/src/components/lib/forms/AnimatedInput.astro +4 -2
- package/src/components/lib/forms/CustomRadioGroup.astro +3 -1
- package/src/components/lib/forms/GlassInlineToggleGroup.astro +161 -0
- package/src/components/lib/forms/PasswordStrengthInput.astro +242 -0
- package/src/components/lib/forms/PhoneCountryInput.astro +145 -0
- package/src/components/lib/forms/TagsAutocompleteInput.astro +249 -0
- package/src/components/lib/forms/index.ts +5 -0
- package/src/components/lib/index.ts +2 -0
- package/src/components/lib/layout/BentoGrid3.astro +6 -4
- package/src/components/lib/layout/CenterFixedBox.astro +2 -2
- package/src/components/lib/layout/CenteredContainer.astro +4 -2
- package/src/components/lib/layout/ClusterHorizontal.astro +2 -2
- package/src/components/lib/layout/DiagonalSectionDivider.astro +69 -0
- package/src/components/lib/layout/FeatureHero.astro +4 -2
- package/src/components/lib/layout/FooterCenteredMinimal.astro +4 -2
- package/src/components/lib/layout/FullBleedSection.astro +2 -2
- package/src/components/lib/layout/GalleryGrid4.astro +2 -2
- package/src/components/lib/layout/HolyGrailGrid.astro +127 -0
- package/src/components/lib/layout/LandingSectionSplit.astro +5 -3
- package/src/components/lib/layout/MasonryGrid.astro +2 -2
- package/src/components/lib/layout/MinimalFooterGrid.astro +1 -1
- package/src/components/lib/layout/ScrollSnapSection.astro +1 -1
- package/src/components/lib/layout/SidebarLayoutWrapper.astro +4 -2
- package/src/components/lib/layout/SplitScreenLayout.astro +3 -3
- package/src/components/lib/layout/StepLayoutVertical.astro +1 -1
- package/src/components/lib/layout/StickySidebarWrapper.astro +4 -2
- package/src/components/lib/layout/index.ts +3 -1
- package/src/components/lib/navigation/QuickJumpDock.astro +125 -0
- package/src/components/lib/navigation/StepBreadcrumbBar.astro +151 -0
- package/src/components/lib/navigation/SubNavbarPills.astro +102 -0
- package/src/components/lib/navigation/index.ts +3 -0
- package/src/components/lib/overlays/CookiePreferencesDrawer.astro +226 -0
- package/src/components/lib/overlays/FloatingFeedbackWidget.astro +203 -0
- package/src/components/lib/overlays/ImageCompareSlider.astro +172 -0
- package/src/components/lib/overlays/index.ts +3 -0
- package/src/components/lib/sections/AgencyContactBrief.astro +194 -0
- package/src/components/lib/sections/AgencyFooterCta.astro +94 -0
- package/src/components/lib/sections/AgencyHeader.astro +161 -0
- package/src/components/lib/sections/AgencyHeroKinetic.astro +172 -0
- package/src/components/lib/sections/AgencyServicesList.astro +113 -0
- package/src/components/lib/sections/AgencyTeamGrid.astro +111 -0
- package/src/components/lib/sections/AgencyWorkGrid.astro +147 -0
- package/src/components/lib/sections/BlogGridSection.astro +133 -0
- package/src/components/lib/sections/ButtonsShowcase.astro +20 -18
- package/src/components/lib/sections/CardsShowcase.astro +28 -26
- package/src/components/lib/sections/CareersOpenings.astro +141 -0
- package/src/components/lib/sections/ChangelogSection.astro +151 -0
- package/src/components/lib/sections/ContactGlassForm.astro +10 -8
- package/src/components/lib/sections/ContactSplitMap.astro +141 -0
- package/src/components/lib/sections/CtaAppDownload.astro +13 -11
- package/src/components/lib/sections/CtaGlowBanner.astro +12 -10
- package/src/components/lib/sections/CtaNewsletterSection.astro +165 -0
- package/src/components/lib/sections/CtaSplitImage.astro +149 -0
- package/src/components/lib/sections/DataShowcase.astro +23 -21
- package/src/components/lib/sections/FaqAccordionSection.astro +8 -8
- package/src/components/lib/sections/FaqTwoColumn.astro +121 -0
- package/src/components/lib/sections/FeatureAlternatingRows.astro +192 -0
- package/src/components/lib/sections/FeatureBentoGrid.astro +13 -11
- package/src/components/lib/sections/FeatureIconGrid.astro +155 -0
- package/src/components/lib/sections/FeatureTabsShowcase.astro +182 -0
- package/src/components/lib/sections/FeatureTimelineFlow.astro +8 -6
- package/src/components/lib/sections/FeedbackShowcase.astro +15 -13
- package/src/components/lib/sections/FooterMegaSection.astro +157 -0
- package/src/components/lib/sections/FormsShowcase.astro +22 -20
- package/src/components/lib/sections/HeroDashboardPreview.astro +182 -0
- package/src/components/lib/sections/HeroGlass.astro +22 -20
- package/src/components/lib/sections/HeroGradient.astro +15 -13
- package/src/components/lib/sections/HeroMinimalCentered.astro +175 -0
- package/src/components/lib/sections/HeroShowcaseSplit.astro +306 -0
- package/src/components/lib/sections/HeroSplit.astro +13 -11
- package/src/components/lib/sections/HeroTerminal.astro +253 -0
- package/src/components/lib/sections/HeroWaitlist.astro +231 -0
- package/src/components/lib/sections/HowItWorksSteps.astro +180 -0
- package/src/components/lib/sections/IntegrationsGrid.astro +167 -0
- package/src/components/lib/sections/LayoutShowcase.astro +21 -19
- package/src/components/lib/sections/LogoCloudSection.astro +96 -0
- package/src/components/lib/sections/LogoWallGrid.astro +93 -0
- package/src/components/lib/sections/NavigationShowcase.astro +15 -13
- package/src/components/lib/sections/OverlaysShowcase.astro +21 -19
- package/src/components/lib/sections/PricingComparisonMatrix.astro +15 -13
- package/src/components/lib/sections/PricingSinglePlan.astro +159 -0
- package/src/components/lib/sections/PricingTierCards.astro +11 -9
- package/src/components/lib/sections/PricingToggleCards.astro +171 -0
- package/src/components/lib/sections/ShopCategoryHero.astro +103 -0
- package/src/components/lib/sections/ShopCollectionMosaic.astro +131 -0
- package/src/components/lib/sections/ShopFooterWordmark.astro +100 -0
- package/src/components/lib/sections/ShopHeaderEditorial.astro +396 -0
- package/src/components/lib/sections/ShopHeroEditorial.astro +151 -0
- package/src/components/lib/sections/ShopLookbook.astro +250 -0
- package/src/components/lib/sections/ShopNewsletterEditorial.astro +128 -0
- package/src/components/lib/sections/ShopProductRail.astro +150 -0
- package/src/components/lib/sections/ShopReviewsEditorial.astro +133 -0
- package/src/components/lib/sections/ShopValuesMarquee.astro +91 -0
- package/src/components/lib/sections/StatsCounterBand.astro +124 -0
- package/src/components/lib/sections/StatsDashboardSection.astro +85 -0
- package/src/components/lib/sections/StatsMetricShowcase.astro +9 -7
- package/src/components/lib/sections/TeamGridSection.astro +12 -10
- package/src/components/lib/sections/TestimonialCarousel.astro +167 -0
- package/src/components/lib/sections/TestimonialFeatured.astro +122 -0
- package/src/components/lib/sections/TestimonialGridSection.astro +188 -0
- package/src/components/lib/sections/TestimonialWallGlass.astro +11 -9
- package/src/components/lib/sections/TypographyShowcase.astro +10 -8
- package/src/components/lib/sections/index.ts +39 -0
- package/src/components/lib/templates/ecommerce/StoreCategoryPage.astro +160 -0
- package/src/components/lib/templates/ecommerce/StoreCheckoutPage.astro +98 -0
- package/src/components/lib/templates/ecommerce/StoreHomePage.astro +191 -0
- package/src/components/lib/templates/ecommerce/StoreLayout.astro +169 -0
- package/src/components/lib/templates/ecommerce/StoreProductPage.astro +165 -0
- package/src/components/lib/templates/ecommerce/data.ts +187 -0
- package/src/components/lib/templates/ecommerce/index.ts +14 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialBagPage.astro +52 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialCheckoutPage.astro +123 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialHomePage.astro +102 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialLayout.astro +216 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialProductPage.astro +205 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialShopPage.astro +112 -0
- package/src/components/lib/templates/ecommerce-editorial/data.ts +125 -0
- package/src/components/lib/templates/ecommerce-editorial/index.ts +16 -0
- package/src/components/lib/templates/saas-hotel/AppLayout.astro +83 -0
- package/src/components/lib/templates/saas-hotel/DashboardPage.astro +66 -0
- package/src/components/lib/templates/saas-hotel/GuestsPage.astro +65 -0
- package/src/components/lib/templates/saas-hotel/LandingPage.astro +213 -0
- package/src/components/lib/templates/saas-hotel/ReportsPage.astro +63 -0
- package/src/components/lib/templates/saas-hotel/ReservationsPage.astro +49 -0
- package/src/components/lib/templates/saas-hotel/RoomsPage.astro +48 -0
- package/src/components/lib/templates/saas-hotel/SettingsPage.astro +115 -0
- package/src/components/lib/templates/saas-hotel/SignInPage.astro +50 -0
- package/src/components/lib/templates/saas-hotel/data.ts +229 -0
- package/src/components/lib/templates/saas-hotel/index.ts +19 -0
- package/src/components/lib/templates/saas-hotel/lobby-theme.css +90 -0
- package/src/components/lib/typography/AnimatedCounterText.astro +102 -0
- package/src/components/lib/typography/CurvedPathHeading.astro +86 -0
- package/src/components/lib/typography/GradientTextMarquee.astro +105 -0
- package/src/components/lib/typography/HackerDecodeText.astro +85 -0
- package/src/components/lib/typography/index.ts +4 -0
- package/src/styles/global.css +113 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RangeAreaChart — Min/max band with an average line. Ideal for temperatures,
|
|
4
|
+
* latency percentiles or price ranges.
|
|
5
|
+
*
|
|
6
|
+
* @param {string[]} categories - X-axis labels.
|
|
7
|
+
* @param {number[]} min - Lower bound per period.
|
|
8
|
+
* @param {number[]} max - Upper bound per period.
|
|
9
|
+
* @param {number[]} avg - Central value per period.
|
|
10
|
+
* @param {string} bandLabel - Legend label for the band.
|
|
11
|
+
* @param {string} lineLabel - Legend label for the line.
|
|
12
|
+
* @param {string} suffix - Value suffix.
|
|
13
|
+
* @param {string} color - Hue for band and line.
|
|
14
|
+
* @param {string} variant - Card style (glass|solid|ghost).
|
|
15
|
+
* @param {string} size - Density (sm|md|lg).
|
|
16
|
+
*/
|
|
17
|
+
import ChartFrame from './ChartFrame.astro';
|
|
18
|
+
import { scaleLinear, niceTicks, bandPath, smoothPath, formatValue, type Point } from './chart-utils';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
title?: string;
|
|
22
|
+
subtitle?: string;
|
|
23
|
+
categories?: string[];
|
|
24
|
+
min?: number[];
|
|
25
|
+
max?: number[];
|
|
26
|
+
avg?: number[];
|
|
27
|
+
bandLabel?: string;
|
|
28
|
+
lineLabel?: string;
|
|
29
|
+
prefix?: string;
|
|
30
|
+
suffix?: string;
|
|
31
|
+
height?: number;
|
|
32
|
+
color?: string;
|
|
33
|
+
variant?: 'glass' | 'solid' | 'ghost';
|
|
34
|
+
size?: 'sm' | 'md' | 'lg';
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
title = 'API latency',
|
|
40
|
+
subtitle = 'p10–p90 band with median, last 10 days',
|
|
41
|
+
categories = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon', 'Tue', 'Wed'],
|
|
42
|
+
min = [42, 45, 40, 48, 52, 38, 36, 44, 47, 41],
|
|
43
|
+
max = [120, 135, 118, 160, 172, 98, 90, 126, 140, 115],
|
|
44
|
+
avg = [74, 80, 70, 92, 101, 62, 58, 78, 86, 72],
|
|
45
|
+
bandLabel = 'p10–p90',
|
|
46
|
+
lineLabel = 'Median',
|
|
47
|
+
prefix = '',
|
|
48
|
+
suffix = 'ms',
|
|
49
|
+
height = 260,
|
|
50
|
+
color = 'var(--chart-1, #3987e5)',
|
|
51
|
+
variant = 'glass',
|
|
52
|
+
size = 'md',
|
|
53
|
+
className = '',
|
|
54
|
+
} = Astro.props;
|
|
55
|
+
|
|
56
|
+
const W = 640;
|
|
57
|
+
const H = height;
|
|
58
|
+
const m = { t: 12, r: 16, b: 28, l: 48 };
|
|
59
|
+
const n = categories.length;
|
|
60
|
+
const ticks = niceTicks(Math.min(...min), Math.max(...max), 4);
|
|
61
|
+
const y = scaleLinear(ticks[0], ticks[ticks.length - 1], H - m.b, m.t);
|
|
62
|
+
const x = (i: number) => m.l + (n <= 1 ? 0 : (i / (n - 1)) * (W - m.l - m.r));
|
|
63
|
+
const step = n > 1 ? (W - m.l - m.r) / (n - 1) : W;
|
|
64
|
+
const upper: Point[] = max.map((v, i) => [x(i), y(v)]);
|
|
65
|
+
const lower: Point[] = min.map((v, i) => [x(i), y(v)]);
|
|
66
|
+
const mid: Point[] = avg.map((v, i) => [x(i), y(v)]);
|
|
67
|
+
const fmt = (v: number) => formatValue(v, prefix, suffix);
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
<ChartFrame
|
|
71
|
+
title={title}
|
|
72
|
+
subtitle={subtitle}
|
|
73
|
+
variant={variant}
|
|
74
|
+
size={size}
|
|
75
|
+
className={className}
|
|
76
|
+
legend={[{ label: bandLabel, color: `color-mix(in srgb, ${color} 35%, transparent)` }, { label: lineLabel, color }]}
|
|
77
|
+
table={{
|
|
78
|
+
headers: ['Period', 'Min', lineLabel, 'Max'],
|
|
79
|
+
rows: categories.map((c, i) => [c, fmt(min[i]), fmt(avg[i]), fmt(max[i])]),
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: range band chart`}>
|
|
83
|
+
{ticks.map((t) => (
|
|
84
|
+
<g>
|
|
85
|
+
<line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
|
|
86
|
+
<text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{fmt(t)}</text>
|
|
87
|
+
</g>
|
|
88
|
+
))}
|
|
89
|
+
{categories.map((c, i) => <text class="cf-label" x={x(i)} y={H - 8} text-anchor="middle">{c}</text>)}
|
|
90
|
+
|
|
91
|
+
<path d={bandPath(upper, lower)} fill={color} fill-opacity="0.18" aria-hidden="true" />
|
|
92
|
+
<path d={smoothPath(mid)} fill="none" stroke={color} stroke-width="2" aria-hidden="true" />
|
|
93
|
+
|
|
94
|
+
{categories.map((c, i) => (
|
|
95
|
+
<g class="cf-hit-group">
|
|
96
|
+
<line class="cf-cross" x1={x(i)} x2={x(i)} y1={y(max[i])} y2={y(min[i])} />
|
|
97
|
+
<circle class="cf-cross" cx={x(i)} cy={y(avg[i])} r="4.5" fill={color} stroke="#fff" stroke-width="1.5" />
|
|
98
|
+
<rect
|
|
99
|
+
class="cf-hit"
|
|
100
|
+
x={x(i) - step / 2}
|
|
101
|
+
y={m.t}
|
|
102
|
+
width={step}
|
|
103
|
+
height={H - m.t - m.b}
|
|
104
|
+
tabindex="0"
|
|
105
|
+
role="img"
|
|
106
|
+
aria-label={`${c}: min ${fmt(min[i])}, ${lineLabel} ${fmt(avg[i])}, max ${fmt(max[i])}`}
|
|
107
|
+
data-tip-title={c}
|
|
108
|
+
data-tip={`Max: ${fmt(max[i])}|${color}::${lineLabel}: ${fmt(avg[i])}|Min: ${fmt(min[i])}`}
|
|
109
|
+
/>
|
|
110
|
+
</g>
|
|
111
|
+
))}
|
|
112
|
+
</svg>
|
|
113
|
+
</ChartFrame>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RetentionCohortTable — Triangular cohort retention matrix. Each row is a
|
|
4
|
+
* signup cohort, each column a period since signup, shaded on a single-hue ramp.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<{cohort: string, size: number, retention: number[]}>} cohorts - Retention % per period (period 0 = 100).
|
|
7
|
+
* @param {string} periodLabel - Column prefix (e.g. 'Week', 'Month').
|
|
8
|
+
* @param {string} color - Ramp hue.
|
|
9
|
+
* @param {string} variant - Card style (glass|solid|ghost).
|
|
10
|
+
* @param {string} size - Density (sm|md|lg).
|
|
11
|
+
*/
|
|
12
|
+
import ChartFrame from './ChartFrame.astro';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
title?: string;
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
cohorts?: Array<{ cohort: string; size: number; retention: number[] }>;
|
|
18
|
+
periodLabel?: string;
|
|
19
|
+
color?: string;
|
|
20
|
+
variant?: 'glass' | 'solid' | 'ghost';
|
|
21
|
+
size?: 'sm' | 'md' | 'lg';
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
title = 'User retention',
|
|
27
|
+
subtitle = 'Monthly cohorts, % still active',
|
|
28
|
+
cohorts = [
|
|
29
|
+
{ cohort: 'May', size: 1240, retention: [100, 62, 48, 41, 37, 35] },
|
|
30
|
+
{ cohort: 'Jun', size: 1410, retention: [100, 65, 51, 44, 40] },
|
|
31
|
+
{ cohort: 'Jul', size: 1580, retention: [100, 68, 55, 47] },
|
|
32
|
+
{ cohort: 'Aug', size: 1720, retention: [100, 71, 57] },
|
|
33
|
+
{ cohort: 'Sep', size: 1890, retention: [100, 73] },
|
|
34
|
+
{ cohort: 'Oct', size: 2050, retention: [100] },
|
|
35
|
+
],
|
|
36
|
+
periodLabel = 'M',
|
|
37
|
+
color = 'var(--chart-3, #199e70)',
|
|
38
|
+
variant = 'glass',
|
|
39
|
+
size = 'md',
|
|
40
|
+
className = '',
|
|
41
|
+
} = Astro.props;
|
|
42
|
+
|
|
43
|
+
const periods = Math.max(...cohorts.map((c) => c.retention.length));
|
|
44
|
+
const cols = Array.from({ length: periods }, (_, i) => `${periodLabel}${i}`);
|
|
45
|
+
const shade = (v: number) => `color-mix(in srgb, ${color} ${Math.round(10 + (v / 100) * 85)}%, #141416)`;
|
|
46
|
+
const avg = cols.map((_, i) => {
|
|
47
|
+
const vals = cohorts.map((c) => c.retention[i]).filter((v) => v !== undefined);
|
|
48
|
+
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
|
49
|
+
});
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<ChartFrame
|
|
53
|
+
title={title}
|
|
54
|
+
subtitle={subtitle}
|
|
55
|
+
variant={variant}
|
|
56
|
+
size={size}
|
|
57
|
+
className={className}
|
|
58
|
+
>
|
|
59
|
+
<div class="rc">
|
|
60
|
+
<table class="rc__table">
|
|
61
|
+
<caption class="sr-only">{title}: retention percentage by cohort and period</caption>
|
|
62
|
+
<thead>
|
|
63
|
+
<tr>
|
|
64
|
+
<th scope="col">Cohort</th>
|
|
65
|
+
<th scope="col">Users</th>
|
|
66
|
+
{cols.map((c) => <th scope="col">{c}</th>)}
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
{cohorts.map((c) => (
|
|
71
|
+
<tr>
|
|
72
|
+
<th scope="row">{c.cohort}</th>
|
|
73
|
+
<td class="rc__size">{c.size.toLocaleString('en-US')}</td>
|
|
74
|
+
{cols.map((_, i) => {
|
|
75
|
+
const v = c.retention[i];
|
|
76
|
+
return v === undefined
|
|
77
|
+
? <td class="rc__empty" aria-label="no data yet"></td>
|
|
78
|
+
: <td class="rc__cell" style={`background:${shade(v)}`} data-strong={v > 55 ? '' : undefined}>{v}%</td>;
|
|
79
|
+
})}
|
|
80
|
+
</tr>
|
|
81
|
+
))}
|
|
82
|
+
</tbody>
|
|
83
|
+
<tfoot>
|
|
84
|
+
<tr>
|
|
85
|
+
<th scope="row">Average</th>
|
|
86
|
+
<td></td>
|
|
87
|
+
{avg.map((a) => <td class="rc__avg">{a.toFixed(0)}%</td>)}
|
|
88
|
+
</tr>
|
|
89
|
+
</tfoot>
|
|
90
|
+
</table>
|
|
91
|
+
</div>
|
|
92
|
+
</ChartFrame>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.rc { overflow-x: auto; }
|
|
96
|
+
.rc__table {
|
|
97
|
+
width: 100%;
|
|
98
|
+
border-collapse: separate;
|
|
99
|
+
border-spacing: 3px;
|
|
100
|
+
font-size: 0.75rem;
|
|
101
|
+
font-variant-numeric: tabular-nums;
|
|
102
|
+
}
|
|
103
|
+
.rc__table th {
|
|
104
|
+
padding: 0.4rem 0.5rem;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
color: var(--c-text-3, #8a8a8a);
|
|
107
|
+
text-align: center;
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
}
|
|
110
|
+
.rc__table tbody th, .rc__table tfoot th { text-align: left; color: var(--c-text-1, #fff); }
|
|
111
|
+
.rc__size { color: var(--c-text-2, #a3a3a3); text-align: right; padding-right: 0.6rem; }
|
|
112
|
+
.rc__cell {
|
|
113
|
+
min-width: 3rem;
|
|
114
|
+
padding: 0.55rem 0.4rem;
|
|
115
|
+
text-align: center;
|
|
116
|
+
border-radius: 4px;
|
|
117
|
+
color: rgb(var(--ink-rgb, 255 255 255) / 0.78);
|
|
118
|
+
}
|
|
119
|
+
.rc__cell[data-strong] { color: #fff; font-weight: 650; }
|
|
120
|
+
.rc__empty { background: rgb(var(--ink-rgb, 255 255 255) / 0.02); border-radius: 4px; }
|
|
121
|
+
.rc__avg { text-align: center; color: var(--c-text-1, #fff); font-weight: 600; padding-top: 0.5rem; }
|
|
122
|
+
.sr-only {
|
|
123
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
124
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SankeyChart — Two-column flow diagram: sources on the left, targets on the
|
|
4
|
+
* right, ribbons whose thickness encodes the flow volume.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<{source: string, target: string, value: number}>} flows - Links between nodes.
|
|
7
|
+
* @param {string} suffix - Value suffix.
|
|
8
|
+
* @param {number} height - SVG height in viewBox units (width is 640).
|
|
9
|
+
* @param {string[]} colors - Optional per-source colors.
|
|
10
|
+
* @param {string} variant - Card style (glass|solid|ghost).
|
|
11
|
+
* @param {string} size - Density (sm|md|lg).
|
|
12
|
+
*/
|
|
13
|
+
import ChartFrame from './ChartFrame.astro';
|
|
14
|
+
import { colorAt, formatValue, sum } from './chart-utils';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
title?: string;
|
|
18
|
+
subtitle?: string;
|
|
19
|
+
flows?: Array<{ source: string; target: string; value: number }>;
|
|
20
|
+
prefix?: string;
|
|
21
|
+
suffix?: string;
|
|
22
|
+
height?: number;
|
|
23
|
+
colors?: string[];
|
|
24
|
+
variant?: 'glass' | 'solid' | 'ghost';
|
|
25
|
+
size?: 'sm' | 'md' | 'lg';
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
title = 'Where signups come from',
|
|
31
|
+
subtitle = 'Acquisition channel → plan chosen',
|
|
32
|
+
flows = [
|
|
33
|
+
{ source: 'Organic', target: 'Free', value: 820 },
|
|
34
|
+
{ source: 'Organic', target: 'Pro', value: 310 },
|
|
35
|
+
{ source: 'Organic', target: 'Team', value: 90 },
|
|
36
|
+
{ source: 'Paid ads', target: 'Free', value: 410 },
|
|
37
|
+
{ source: 'Paid ads', target: 'Pro', value: 160 },
|
|
38
|
+
{ source: 'Referral', target: 'Pro', value: 220 },
|
|
39
|
+
{ source: 'Referral', target: 'Team', value: 140 },
|
|
40
|
+
{ source: 'Partners', target: 'Team', value: 120 },
|
|
41
|
+
{ source: 'Partners', target: 'Enterprise', value: 60 },
|
|
42
|
+
],
|
|
43
|
+
prefix = '',
|
|
44
|
+
suffix = '',
|
|
45
|
+
height = 320,
|
|
46
|
+
colors,
|
|
47
|
+
variant = 'glass',
|
|
48
|
+
size = 'md',
|
|
49
|
+
className = '',
|
|
50
|
+
} = Astro.props;
|
|
51
|
+
|
|
52
|
+
const W = 640;
|
|
53
|
+
const H = height;
|
|
54
|
+
const nodeW = 12;
|
|
55
|
+
const gap = 14;
|
|
56
|
+
const xL = 110;
|
|
57
|
+
const xR = W - 110 - nodeW;
|
|
58
|
+
const sources = [...new Set(flows.map((f) => f.source))];
|
|
59
|
+
const targets = [...new Set(flows.map((f) => f.target))];
|
|
60
|
+
const total = sum(flows.map((f) => f.value)) || 1;
|
|
61
|
+
const scaleFor = (count: number) => (H - gap * (count - 1)) / total;
|
|
62
|
+
const k = Math.min(scaleFor(sources.length), scaleFor(targets.length));
|
|
63
|
+
|
|
64
|
+
const layout = (names: string[], key: 'source' | 'target') => {
|
|
65
|
+
const used = names.reduce((a, nm) => a + sum(flows.filter((f) => f[key] === nm).map((f) => f.value)) * k, 0) + gap * (names.length - 1);
|
|
66
|
+
let y = (H - used) / 2;
|
|
67
|
+
return Object.fromEntries(names.map((nm) => {
|
|
68
|
+
const v = sum(flows.filter((f) => f[key] === nm).map((f) => f.value));
|
|
69
|
+
const node = { y, h: v * k, v, offset: 0 };
|
|
70
|
+
y += v * k + gap;
|
|
71
|
+
return [nm, node];
|
|
72
|
+
}));
|
|
73
|
+
};
|
|
74
|
+
const L = layout(sources, 'source');
|
|
75
|
+
const R = layout(targets, 'target');
|
|
76
|
+
const srcColor = Object.fromEntries(sources.map((s, i) => [s, colorAt(i, colors)]));
|
|
77
|
+
|
|
78
|
+
const links = flows.map((f) => {
|
|
79
|
+
const s = L[f.source];
|
|
80
|
+
const t = R[f.target];
|
|
81
|
+
const h = f.value * k;
|
|
82
|
+
const y0 = s.y + s.offset + h / 2;
|
|
83
|
+
const y1 = t.y + t.offset + h / 2;
|
|
84
|
+
s.offset += h;
|
|
85
|
+
t.offset += h;
|
|
86
|
+
const x0 = xL + nodeW;
|
|
87
|
+
const x1 = xR;
|
|
88
|
+
const mx = (x0 + x1) / 2;
|
|
89
|
+
return { ...f, h, d: `M${x0},${y0} C${mx},${y0} ${mx},${y1} ${x1},${y1}`, color: srcColor[f.source] };
|
|
90
|
+
});
|
|
91
|
+
const fmt = (v: number) => formatValue(v, prefix, suffix);
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
<ChartFrame
|
|
95
|
+
title={title}
|
|
96
|
+
subtitle={subtitle}
|
|
97
|
+
variant={variant}
|
|
98
|
+
size={size}
|
|
99
|
+
className={className}
|
|
100
|
+
table={{ headers: ['From', 'To', 'Value', 'Share'], rows: flows.map((f) => [f.source, f.target, fmt(f.value), `${((f.value / total) * 100).toFixed(1)}%`]) }}
|
|
101
|
+
>
|
|
102
|
+
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: flow from ${sources.length} sources to ${targets.length} targets`}>
|
|
103
|
+
{links.map((l) => (
|
|
104
|
+
<path class="cf-mark cf-dim sk__link" d={l.d} fill="none" stroke={l.color} stroke-opacity="0.38" stroke-width={Math.max(1, l.h)}
|
|
105
|
+
tabindex="0" role="img" aria-label={`${l.source} to ${l.target}: ${fmt(l.value)}`}
|
|
106
|
+
data-tip-title={`${l.source} → ${l.target}`} data-tip={`${l.color}::${fmt(l.value)} · ${((l.value / total) * 100).toFixed(1)}%`} />
|
|
107
|
+
))}
|
|
108
|
+
{sources.map((s) => (
|
|
109
|
+
<g>
|
|
110
|
+
<rect x={xL} y={L[s].y} width={nodeW} height={L[s].h} rx="3" fill={srcColor[s]} />
|
|
111
|
+
<text class="cf-label cf-label--strong" x={xL - 8} y={L[s].y + L[s].h / 2} text-anchor="end">{s}</text>
|
|
112
|
+
<text class="cf-label" x={xL - 8} y={L[s].y + L[s].h / 2 + 14} text-anchor="end">{fmt(L[s].v)}</text>
|
|
113
|
+
</g>
|
|
114
|
+
))}
|
|
115
|
+
{targets.map((t) => (
|
|
116
|
+
<g>
|
|
117
|
+
<rect x={xR} y={R[t].y} width={nodeW} height={R[t].h} rx="3" fill="var(--c-text-2, #a3a3a3)" />
|
|
118
|
+
<text class="cf-label cf-label--strong" x={xR + nodeW + 8} y={R[t].y + R[t].h / 2}>{t}</text>
|
|
119
|
+
<text class="cf-label" x={xR + nodeW + 8} y={R[t].y + R[t].h / 2 + 14}>{fmt(R[t].v)}</text>
|
|
120
|
+
</g>
|
|
121
|
+
))}
|
|
122
|
+
</svg>
|
|
123
|
+
</ChartFrame>
|
|
124
|
+
|
|
125
|
+
<style>
|
|
126
|
+
.sk__link { transition: stroke-opacity 0.2s ease; }
|
|
127
|
+
.sk__link:hover, .sk__link:focus-visible { stroke-opacity: 0.7; }
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ScatterPlot — X/Y relationship plot for up to 3 groups, with an optional
|
|
4
|
+
* least-squares trend line and per-point tooltips.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<{name: string, points: Array<{x: number, y: number, label?: string}>}>} groups - Point groups (max 3).
|
|
7
|
+
* @param {string} xLabel - X-axis title.
|
|
8
|
+
* @param {string} yLabel - Y-axis title.
|
|
9
|
+
* @param {boolean} trend - Draw a linear regression line across all points.
|
|
10
|
+
* @param {string[]} colors - Optional per-group colors.
|
|
11
|
+
* @param {string} variant - Card style (glass|solid|ghost).
|
|
12
|
+
* @param {string} size - Density (sm|md|lg).
|
|
13
|
+
*/
|
|
14
|
+
import ChartFrame from './ChartFrame.astro';
|
|
15
|
+
import { scaleLinear, niceTicks, colorAt, formatCompact } from './chart-utils';
|
|
16
|
+
|
|
17
|
+
interface Pt { x: number; y: number; label?: string }
|
|
18
|
+
interface Props {
|
|
19
|
+
title?: string;
|
|
20
|
+
subtitle?: string;
|
|
21
|
+
groups?: Array<{ name: string; points: Pt[] }>;
|
|
22
|
+
xLabel?: string;
|
|
23
|
+
yLabel?: string;
|
|
24
|
+
trend?: boolean;
|
|
25
|
+
height?: number;
|
|
26
|
+
colors?: string[];
|
|
27
|
+
variant?: 'glass' | 'solid' | 'ghost';
|
|
28
|
+
size?: 'sm' | 'md' | 'lg';
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const gen = (seed: number, n: number, slope: number, base: number, noise: number): Pt[] =>
|
|
33
|
+
Array.from({ length: n }, (_, i) => {
|
|
34
|
+
const r = Math.sin((i + 1) * seed) * 10000;
|
|
35
|
+
const j = r - Math.floor(r);
|
|
36
|
+
const xv = 5 + ((i * 37) % 95);
|
|
37
|
+
return { x: xv, y: Math.round(base + slope * xv + (j - 0.5) * noise) };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
title = 'Ad spend vs signups',
|
|
42
|
+
subtitle = 'Per campaign',
|
|
43
|
+
groups = [
|
|
44
|
+
{ name: 'Search', points: gen(3.1, 16, 2.1, 20, 60) },
|
|
45
|
+
{ name: 'Social', points: gen(7.7, 14, 1.3, 10, 70) },
|
|
46
|
+
{ name: 'Video', points: gen(5.3, 10, 0.8, 30, 50) },
|
|
47
|
+
],
|
|
48
|
+
xLabel = 'Spend ($k)',
|
|
49
|
+
yLabel = 'Signups',
|
|
50
|
+
trend = true,
|
|
51
|
+
height = 300,
|
|
52
|
+
colors,
|
|
53
|
+
variant = 'glass',
|
|
54
|
+
size = 'md',
|
|
55
|
+
className = '',
|
|
56
|
+
} = Astro.props;
|
|
57
|
+
|
|
58
|
+
const W = 640;
|
|
59
|
+
const H = height;
|
|
60
|
+
const m = { t: 12, r: 16, b: 40, l: 52 };
|
|
61
|
+
const all = groups.flatMap((g) => g.points);
|
|
62
|
+
const xt = niceTicks(Math.min(0, ...all.map((p) => p.x)), Math.max(...all.map((p) => p.x)), 5);
|
|
63
|
+
const yt = niceTicks(Math.min(0, ...all.map((p) => p.y)), Math.max(...all.map((p) => p.y)), 4);
|
|
64
|
+
const x = scaleLinear(xt[0], xt[xt.length - 1], m.l, W - m.r);
|
|
65
|
+
const y = scaleLinear(yt[0], yt[yt.length - 1], H - m.b, m.t);
|
|
66
|
+
|
|
67
|
+
// Ordinary least squares over all points.
|
|
68
|
+
const n = all.length;
|
|
69
|
+
const mx = all.reduce((a, p) => a + p.x, 0) / n;
|
|
70
|
+
const my = all.reduce((a, p) => a + p.y, 0) / n;
|
|
71
|
+
const slope = all.reduce((a, p) => a + (p.x - mx) * (p.y - my), 0) / (all.reduce((a, p) => a + (p.x - mx) ** 2, 0) || 1);
|
|
72
|
+
const icpt = my - slope * mx;
|
|
73
|
+
const r2 = (() => {
|
|
74
|
+
const ssTot = all.reduce((a, p) => a + (p.y - my) ** 2, 0) || 1;
|
|
75
|
+
const ssRes = all.reduce((a, p) => a + (p.y - (icpt + slope * p.x)) ** 2, 0);
|
|
76
|
+
return 1 - ssRes / ssTot;
|
|
77
|
+
})();
|
|
78
|
+
const shapes = ['circle', 'square', 'diamond'];
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
<ChartFrame
|
|
82
|
+
title={title}
|
|
83
|
+
subtitle={subtitle}
|
|
84
|
+
variant={variant}
|
|
85
|
+
size={size}
|
|
86
|
+
className={className}
|
|
87
|
+
legend={groups.slice(0, 3).map((g, i) => ({ label: g.name, color: colorAt(i, colors) }))}
|
|
88
|
+
table={{ headers: ['Group', xLabel, yLabel], rows: groups.flatMap((g) => g.points.map((p) => [p.label ? `${g.name} · ${p.label}` : g.name, p.x, p.y])) }}
|
|
89
|
+
>
|
|
90
|
+
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: scatter plot of ${n} points${trend ? `, trend R² ${r2.toFixed(2)}` : ''}`}>
|
|
91
|
+
{yt.map((t) => (
|
|
92
|
+
<g>
|
|
93
|
+
<line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
|
|
94
|
+
<text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{formatCompact(t)}</text>
|
|
95
|
+
</g>
|
|
96
|
+
))}
|
|
97
|
+
{xt.map((t) => (
|
|
98
|
+
<g>
|
|
99
|
+
<line class="cf-grid" x1={x(t)} x2={x(t)} y1={m.t} y2={H - m.b} />
|
|
100
|
+
<text class="cf-label" x={x(t)} y={H - m.b + 16} text-anchor="middle">{formatCompact(t)}</text>
|
|
101
|
+
</g>
|
|
102
|
+
))}
|
|
103
|
+
<text class="cf-label" x={(m.l + W - m.r) / 2} y={H - 4} text-anchor="middle">{xLabel}</text>
|
|
104
|
+
<text class="cf-label" transform={`translate(12 ${(m.t + H - m.b) / 2}) rotate(-90)`} text-anchor="middle">{yLabel}</text>
|
|
105
|
+
|
|
106
|
+
{trend && (
|
|
107
|
+
<g aria-hidden="true">
|
|
108
|
+
<line x1={x(xt[0])} y1={y(icpt + slope * xt[0])} x2={x(xt[xt.length - 1])} y2={y(icpt + slope * xt[xt.length - 1])}
|
|
109
|
+
stroke="var(--c-text-1, #fff)" stroke-opacity="0.45" stroke-width="1.5" />
|
|
110
|
+
<text class="cf-label" x={W - m.r} y={y(icpt + slope * xt[xt.length - 1]) - 8} text-anchor="end">R² {r2.toFixed(2)}</text>
|
|
111
|
+
</g>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{groups.slice(0, 3).map((g, gi) => {
|
|
115
|
+
const col = colorAt(gi, colors);
|
|
116
|
+
return g.points.map((p) => {
|
|
117
|
+
const px = x(p.x);
|
|
118
|
+
const py = y(p.y);
|
|
119
|
+
const common = {
|
|
120
|
+
class: 'cf-mark cf-dim',
|
|
121
|
+
fill: col,
|
|
122
|
+
'fill-opacity': 0.85,
|
|
123
|
+
stroke: 'var(--chart-surface, #0d0d0f)',
|
|
124
|
+
'stroke-width': 1.5,
|
|
125
|
+
tabindex: 0,
|
|
126
|
+
role: 'img' as const,
|
|
127
|
+
'aria-label': `${g.name}${p.label ? ` ${p.label}` : ''}: ${xLabel} ${p.x}, ${yLabel} ${p.y}`,
|
|
128
|
+
'data-tip-title': p.label ?? g.name,
|
|
129
|
+
'data-tip': `${col}::${g.name}|${xLabel}: ${p.x}|${yLabel}: ${p.y}`,
|
|
130
|
+
};
|
|
131
|
+
return shapes[gi] === 'square'
|
|
132
|
+
? <rect {...common} x={px - 5} y={py - 5} width="10" height="10" rx="2" />
|
|
133
|
+
: shapes[gi] === 'diamond'
|
|
134
|
+
? <path {...common} d={`M${px},${py - 6.5} L${px + 6.5},${py} L${px},${py + 6.5} L${px - 6.5},${py} Z`} />
|
|
135
|
+
: <circle {...common} cx={px} cy={py} r="5.5" />;
|
|
136
|
+
});
|
|
137
|
+
})}
|
|
138
|
+
</svg>
|
|
139
|
+
</ChartFrame>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SegmentedBarChart — A single tall part-to-whole bar with labelled segments
|
|
4
|
+
* and a breakdown list. A clearer alternative to a pie for ≤ 6 parts.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<{label: string, value: number}>} segments - Parts of the whole.
|
|
7
|
+
* @param {string} total - Optional formatted total for the header.
|
|
8
|
+
* @param {string} prefix - Value prefix.
|
|
9
|
+
* @param {string} suffix - Value suffix.
|
|
10
|
+
* @param {string[]} colors - Optional per-segment colors.
|
|
11
|
+
* @param {string} variant - Card style (glass|solid|ghost).
|
|
12
|
+
* @param {string} size - Density (sm|md|lg).
|
|
13
|
+
*/
|
|
14
|
+
import ChartFrame from './ChartFrame.astro';
|
|
15
|
+
import { colorAt, formatValue, sum } from './chart-utils';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
title?: string;
|
|
19
|
+
subtitle?: string;
|
|
20
|
+
segments?: Array<{ label: string; value: number }>;
|
|
21
|
+
total?: string;
|
|
22
|
+
prefix?: string;
|
|
23
|
+
suffix?: string;
|
|
24
|
+
colors?: string[];
|
|
25
|
+
variant?: 'glass' | 'solid' | 'ghost';
|
|
26
|
+
size?: 'sm' | 'md' | 'lg';
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
title = 'Storage usage',
|
|
32
|
+
subtitle = '512 GB plan',
|
|
33
|
+
segments = [
|
|
34
|
+
{ label: 'Media', value: 182 },
|
|
35
|
+
{ label: 'Backups', value: 96 },
|
|
36
|
+
{ label: 'Databases', value: 64 },
|
|
37
|
+
{ label: 'Logs', value: 38 },
|
|
38
|
+
{ label: 'Other', value: 22 },
|
|
39
|
+
],
|
|
40
|
+
total,
|
|
41
|
+
prefix = '',
|
|
42
|
+
suffix = ' GB',
|
|
43
|
+
colors,
|
|
44
|
+
variant = 'glass',
|
|
45
|
+
size = 'md',
|
|
46
|
+
className = '',
|
|
47
|
+
} = Astro.props;
|
|
48
|
+
|
|
49
|
+
const tot = sum(segments.map((s) => s.value)) || 1;
|
|
50
|
+
const fmt = (v: number) => formatValue(v, prefix, suffix, false);
|
|
51
|
+
const segs = segments.slice(0, 8).map((s, i) => ({ ...s, pct: (s.value / tot) * 100, color: colorAt(i, colors) }));
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<ChartFrame
|
|
55
|
+
title={title}
|
|
56
|
+
subtitle={subtitle}
|
|
57
|
+
value={total ?? fmt(tot)}
|
|
58
|
+
variant={variant}
|
|
59
|
+
size={size}
|
|
60
|
+
className={className}
|
|
61
|
+
table={{ headers: ['Segment', 'Value', 'Share'], rows: segs.map((s) => [s.label, fmt(s.value), `${s.pct.toFixed(1)}%`]) }}
|
|
62
|
+
>
|
|
63
|
+
<div class="sb__bar" role="list" aria-label={`${title} breakdown`}>
|
|
64
|
+
{segs.map((s) => (
|
|
65
|
+
<span class="sb__seg cf-mark" role="listitem" tabindex="0" style={`flex-grow:${s.value}; background:${s.color}`}
|
|
66
|
+
aria-label={`${s.label}: ${fmt(s.value)} (${s.pct.toFixed(1)}%)`}
|
|
67
|
+
data-tip-title={s.label} data-tip={`${s.color}::${fmt(s.value)} · ${s.pct.toFixed(1)}%`}>
|
|
68
|
+
{s.pct >= 9 && <span class="sb__pct">{s.pct.toFixed(0)}%</span>}
|
|
69
|
+
</span>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
<ul class="sb__list">
|
|
73
|
+
{segs.map((s) => (
|
|
74
|
+
<li>
|
|
75
|
+
<span class="sb__dot" style={`background:${s.color}`} aria-hidden="true"></span>
|
|
76
|
+
<span class="sb__name">{s.label}</span>
|
|
77
|
+
<span class="sb__val">{fmt(s.value)}</span>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ul>
|
|
81
|
+
</ChartFrame>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
.sb__bar {
|
|
85
|
+
display: flex;
|
|
86
|
+
gap: 2px;
|
|
87
|
+
height: 44px;
|
|
88
|
+
border-radius: 8px;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
}
|
|
91
|
+
.sb__seg {
|
|
92
|
+
flex-basis: 0;
|
|
93
|
+
min-width: 4px;
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
padding-left: 0.6rem;
|
|
97
|
+
transition: filter 0.2s ease;
|
|
98
|
+
}
|
|
99
|
+
.sb__seg:hover { filter: brightness(1.15); }
|
|
100
|
+
.sb__seg:focus-visible { outline: 2px solid var(--c-text-1, #fff); outline-offset: -2px; }
|
|
101
|
+
.sb__pct {
|
|
102
|
+
font-size: 0.75rem;
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
color: #fff;
|
|
105
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
|
106
|
+
}
|
|
107
|
+
.sb__list {
|
|
108
|
+
list-style: none;
|
|
109
|
+
margin: 1rem 0 0;
|
|
110
|
+
padding: 0;
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
113
|
+
gap: 0.6rem 1.25rem;
|
|
114
|
+
}
|
|
115
|
+
.sb__list li { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; }
|
|
116
|
+
.sb__dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
|
|
117
|
+
.sb__name { color: var(--c-text-2, #a3a3a3); }
|
|
118
|
+
.sb__val { margin-left: auto; color: var(--c-text-1, #fff); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
119
|
+
</style>
|