Vendor SEO chart kit and shell media

This commit is contained in:
DCCONSTRUCTIONS 2026-06-29 23:34:11 +03:00
parent 942373563d
commit 95eea435ac
271 changed files with 40659 additions and 7 deletions

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 uixmat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,192 @@
![hero](https://github.com/bklit/bklit-ui/blob/main/apps/web/app/opengraph-image.png?raw=true)
<br />
<p align="center">
<img alt="vercel-oss 2" src="https://github.com/user-attachments/assets/e6cd5d4c-7707-4271-96cb-10122d193048" />
</p>
</br>
<p align="center">
A collection of Open Source charts and utility components that you can customize and extend.
<br />
<br />
<a href="https://github.com/bklit/bklit-ui"><img src="https://www.shieldcn.dev/github/stars/bklit/bklit-ui.svg?variant=secondary&size=xs&theme=zinc" alt="GitHub Stars" /></a>
<a href="https://github.com/bklit/bklit-ui/actions"><img src="https://www.shieldcn.dev/github/ci/bklit/bklit-ui.svg?variant=secondary&size=xs&theme=zinc" alt="CI" /></a>
<a href="https://discord.gg/75s4frfE8X"><img src="https://www.shieldcn.dev/discord/members/75s4frfE8X.svg?variant=secondary&size=xs&theme=zinc" alt="Discord Members" /></a>
<a href="https://x.com/uixmat"><img src="https://www.shieldcn.dev/x/follow/uixmat.svg?variant=branded&size=xs&theme=zinc" alt="X Follow" /></a>
<a href="https://vercel.com/oss"><img src="https://shieldcn.dev/badge/Vercel_OSS_Program_Member.svg?variant=branded&size=xs&theme=zinc&logo=vercel" alt="Vercel OSS Program Member" /></a>
</p>
</p>
## Premium Sponsors
Bklit UI is supported by our premium sponsors.
<br />
<a href="https://openpanel.dev">
<img alt="OpenPanel" src="https://github.com/user-attachments/assets/279ef3ca-ed28-4a75-bac7-3fb90db4954d" />
</a>
**[OpenPanel](https://openpanel.dev)** — OpenPanel is an open source analytics platform that combines web analytics and product analytics in one privacy-first tool. Track pageviews, events, funnels, retention, and user journeys — all without cookies.
## Charts
<table>
<tr>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/area-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/area-chart.svg" alt="Area Chart" width="280" />
</a>
<br />
<strong>Area Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/area-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=area-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/bar-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/bar-chart.svg" alt="Bar Chart" width="280" />
</a>
<br />
<strong>Bar Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/bar-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=bar-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/candlestick-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/candlestick-chart.svg" alt="Candlestick Chart" width="280" />
</a>
<br />
<strong>Candlestick Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/candlestick-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=candlestick-chart">Studio</a>
</td>
</tr>
<tr>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/choropleth-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/choropleth-chart.svg" alt="Choropleth Chart" width="280" />
</a>
<br />
<strong>Choropleth Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/choropleth-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=choropleth-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/composed-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/composed-chart.svg" alt="Composed Chart" width="280" />
</a>
<br />
<strong>Composed Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/composed-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=composed-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/funnel-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/funnel-chart.svg" alt="Funnel Chart" width="280" />
</a>
<br />
<strong>Funnel Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/funnel-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=funnel-chart">Studio</a>
</td>
</tr>
<tr>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/gauge-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/gauge-chart.svg" alt="Gauge" width="280" />
</a>
<br />
<strong>Gauge</strong><br />
<a href="https://ui.bklit.com/docs/components/gauge-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=gauge-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/line-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/line-chart.svg" alt="Line Chart" width="280" />
</a>
<br />
<strong>Line Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/line-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=line-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/live-line-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/live-line-chart.svg" alt="Live Line Chart" width="280" />
</a>
<br />
<strong>Live Line Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/live-line-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=live-line-chart">Studio</a>
</td>
</tr>
<tr>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/pie-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/pie-chart.svg" alt="Pie Chart" width="280" />
</a>
<br />
<strong>Pie Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/pie-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=pie-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/radar-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/radar-chart.svg" alt="Radar Chart" width="280" />
</a>
<br />
<strong>Radar Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/radar-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=radar-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/ring-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/ring-chart.svg" alt="Ring Chart" width="280" />
</a>
<br />
<strong>Ring Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/ring-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=ring-chart">Studio</a>
</td>
</tr>
<tr>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/scatter-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/scatter-chart.svg" alt="Scatter Chart" width="280" />
</a>
<br />
<strong>Scatter Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/scatter-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=scatter-chart">Studio</a>
</td>
<td align="center" valign="top" width="33%">
<a href="https://ui.bklit.com/docs/components/sankey-chart">
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/sankey-chart.svg" alt="Sankey Chart" width="280" />
</a>
<br />
<strong>Sankey Chart</strong><br />
<a href="https://ui.bklit.com/docs/components/sankey-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=sankey-chart">Studio</a>
</td>
<td></td>
</tr>
</table>
## Studio
Studio is Bklit's interactive chart playground. Pick a chart, tweak styling and animation controls in real time, then copy the generated React code or export a registry JSON for your project.
<p align="center">
<a href="https://www.youtube.com/watch?v=I3PLqHImYbE"><img src="https://img.youtube.com/vi/I3PLqHImYbE/maxresdefault.jpg" alt="Bklit Studio" width="720" /></a>
</p>
[Open Studio →](https://ui.bklit.com/studio)
## Getting Started
Bklit UI is a shadcn registry. Initialize shadcn in your project, then add a chart:
```bash
npx shadcn@latest init
npx shadcn@latest add @bklit/line-chart
```
See [Installation](https://ui.bklit.com/docs/installation) for registry setup and all components.
## Repo activity
![Alt](https://repobeats.axiom.co/api/embed/c591b93fd9e7bfa8f4dc8bddb716699615ee5fde.svg "Repobeats analytics image")
## License
**Chart components** (`packages/ui`, shadcn registry) are [MIT licensed](./LICENSE).
**Bklit Studio** (`packages/studio`, hosted at [ui.bklit.com/studio](https://ui.bklit.com/studio))
is proprietary. See [LICENSE-STUDIO.md](./LICENSE-STUDIO.md). You may not reuse,
resell, or redistribute Studio without written permission.

View File

@ -0,0 +1,35 @@
# Bklit UI Vendor Snapshot
Source: https://github.com/bklit/bklit-ui
Commit: 86149e76b509b661aca26af95461b18952eda646
Snapshot date: 2026-06-28
Included:
- `LICENSE`
- `README.md`
- `packages/ui`
- `apps/web/public/svgs`
Excluded:
- `.git`
- `node_modules`
- `apps/studio`
- `packages/studio`
License notes:
- The root Bklit UI repository is MIT licensed.
- Bklit Studio has a separate restrictive license and is intentionally not vendored here.
- Treat this directory as a local source/reference snapshot. Do not import it into the runtime directly until the selected chart components and their dependencies are copied into the app layer intentionally.
Candidate SEO module uses:
- `funnel-chart`: pipeline/readiness from context review to market demand, SEO plan, rewrite, validation.
- `bar-chart`: top clusters, pages, intents, and demand buckets.
- `scatter-chart`: opportunity map by demand, relevance, confidence, and implementation effort.
- `sankey-chart`: context/intent to keyword/page/field flow for dev-mode explainability.
- `gauge-chart` or `ring-chart`: compact quality/readiness scores.
- `area-chart`, `line-chart`, `composed-chart`: historical demand, forecast, and validation trend screens after time-series data exists.
- `heatmap-chart`: keyword/page coverage matrix if the table becomes too dense.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 995 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1005 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1000 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 675 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1001 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1018 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1005 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 994 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1014 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 988 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 500 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 988 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 499 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1005 KiB

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
}
}

View File

@ -0,0 +1,2 @@
// biome-ignore lint/performance/noBarrelFile: ESLint config re-export
export { config as default } from "@bklitui/eslint-config/react-internal";

View File

@ -0,0 +1,81 @@
{
"name": "@bklitui/ui",
"version": "0.0.0",
"license": "MIT",
"private": true,
"sideEffects": false,
"exports": {
"./charts": "./src/charts/index.ts",
"./lib/utils": "./src/lib/utils.ts",
"./components/shimmering-text": "./src/components/shimmering-text.tsx",
"./button": "./src/button.tsx",
"./card": "./src/card.tsx",
"./code": "./src/code.tsx",
"./chart-grid": "./src/chart-grid.tsx",
"./chart-marker": "./src/chart-marker.tsx",
"./line-chart": "./src/line-chart.tsx",
"./tooltip": "./src/tooltip.tsx",
"./*": "./src/*.tsx"
},
"scripts": {
"lint": "eslint . --max-warnings 0",
"check-types": "tsc --noEmit",
"test": "node scripts/run-tests.mjs",
"registry:build": "node scripts/verify-registry-files.mjs && node scripts/verify-registry-deps.mjs && node scripts/generate-v0-examples.mjs && node scripts/generate-block-registry.mjs && shadcn build --output ../../apps/web/public/r && cp registry.json ../../apps/web/public/r/registry.json",
"registry:verify": "node scripts/verify-registry-files.mjs && node scripts/verify-registry-deps.mjs && node scripts/verify-registry-sync.mjs"
},
"devDependencies": {
"@bklitui/eslint-config": "workspace:*",
"@bklitui/typescript-config": "workspace:*",
"@types/d3-array": "^3.2.1",
"@types/d3-geo": "^3.1.0",
"@types/d3-sankey": "^0.12.5",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.6",
"@types/geojson": "^7946.0.16",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"eslint": "^9.39.1",
"shadcn": "^2.5.0",
"tailwindcss": "^4.1.8",
"tsx": "^4.19.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@bklitui/icons": "workspace:*",
"@base-ui/react": "^1.0.0-alpha.8",
"@number-flow/react": "^0.5.4",
"@visx/brush": "3.12.0",
"@visx/curve": "4.0.1-alpha.0",
"@visx/event": "4.0.1-alpha.0",
"@visx/geo": "4.0.1-alpha.0",
"@visx/gradient": "4.0.1-alpha.0",
"@visx/grid": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/heatmap": "4.0.1-alpha.0",
"@visx/pattern": "4.0.1-alpha.0",
"@visx/responsive": "4.0.1-alpha.0",
"@visx/sankey": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/shape": "4.0.1-alpha.0",
"@visx/zoom": "4.0.1-alpha.0",
"clsx": "^2.1.1",
"d3-array": "3.2.4",
"d3-geo": "3.1.0",
"d3-scale": "4.0.2",
"d3-shape": "^3.2.0",
"motion": "^12.27.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-use-measure": "^2.1.7",
"tailwind-merge": "^2.6.0",
"topojson-client": "3.1.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,97 @@
"use client";
import {
Area,
AreaChart,
ChartStatFlow,
LinearGradient,
} from "@/components/charts";
import { curveCardinal } from "@visx/curve";
import { useState } from "react";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { revenueSeries, revenueStats } from "../data/revenue-series";
import {
StatCardChart,
statCardLabelClassName,
statCardValueClassName,
} from "./stat-card-chart";
import {
formatStatCardMonth,
StatCardHoverBridge,
type StatCardHoverState,
} from "./stat-card-hover-bridge";
import { TrendBadge } from "./trend-badge";
export function StatCardArea() {
const [hover, setHover] = useState<StatCardHoverState>({
value: null,
label: null,
trend: null,
});
const displayValue = hover.value ?? revenueStats.average;
const displayLabel = hover.label ?? "Avg";
const displayTrend = hover.trend ?? revenueStats.trend;
return (
<Card className="w-full gap-0 py-0">
<CardHeader className="px-4 py-3">
<CardTitle>Total Revenue</CardTitle>
<CardAction>
<TrendBadge value={displayTrend} />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3 px-4 pt-2 pb-3">
<ChartStatFlow
formatOptions={{
currency: "USD",
maximumFractionDigits: 0,
style: "currency",
}}
label={displayLabel}
labelClassName={statCardLabelClassName}
value={displayValue}
valueClassName={statCardValueClassName}
/>
<StatCardChart size="md">
<AreaChart
aspectRatio="2.5 / 1"
className="w-full"
data={revenueSeries}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<StatCardHoverBridge
dataKey="value"
formatLabel={formatStatCardMonth}
onHoverChange={setHover}
/>
<LinearGradient
from="var(--chart-1)"
fromOpacity={0.45}
id="stat-card-area-fill"
to="var(--chart-1)"
toOpacity={0}
/>
<Area
curve={curveCardinal.tension(0.65)}
dataKey="value"
fill="url(#stat-card-area-fill)"
fillOpacity={1}
gradientToOpacity={0}
showHighlight
stroke="var(--chart-1)"
strokeWidth={2}
/>
</AreaChart>
</StatCardChart>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface StatCardHoverState {
value: number | null;
label: string | null;
trend: number | null;
}
export const statCardValueClassName =
"text-3xl font-semibold leading-none tracking-tight";
export const statCardLabelClassName = "mt-0 text-xs";
export const statCardChartHeights = {
sm: "[--stat-card-chart-h:96px]",
md: "[--stat-card-chart-h:190px]",
lg: "[--stat-card-chart-h:420px]",
} as const;
/** Bleeds charts edge-to-edge inside stat card content padding. */
export function StatCardChart({
children,
className,
size = "sm",
}: {
children: ReactNode;
className?: string;
size?: keyof typeof statCardChartHeights;
}) {
return (
<div
className={cn(
"relative -mx-4 -mb-3 overflow-hidden",
"[&_.relative.w-full]:aspect-auto! [&_.relative.w-full]:h-[var(--stat-card-chart-h)]!",
statCardChartHeights[size],
className
)}
>
{children}
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useChart } from "@/components/charts";
import { useEffect } from "react";
import type { StatCardHoverState } from "./stat-card-chart";
export type { StatCardHoverState } from "./stat-card-chart";
export function formatStatCardMonth(date: Date) {
return date.toLocaleDateString("en-US", { month: "short" });
}
export function formatStatCardWeekday(date: Date) {
return date.toLocaleDateString("en-US", { weekday: "long" });
}
function parsePointDate(raw: unknown): Date | null {
if (raw instanceof Date) {
return raw;
}
if (typeof raw === "string") {
return new Date(raw);
}
return null;
}
function computePeriodTrend(
data: Record<string, unknown>[],
index: number,
dataKey: string
): number | null {
if (index <= 0) {
return null;
}
const current = data[index]?.[dataKey];
const previous = data[index - 1]?.[dataKey];
if (
typeof current !== "number" ||
typeof previous !== "number" ||
previous === 0
) {
return null;
}
return ((current - previous) / previous) * 100;
}
/** Syncs hovered chart values, labels, and trend into stat card UI. */
export function StatCardHoverBridge({
dataKey,
dateKey = "date",
formatLabel,
onHoverChange,
}: {
dataKey: string;
dateKey?: string;
formatLabel: (date: Date) => string;
onHoverChange: (state: StatCardHoverState) => void;
}) {
const { data, tooltipData } = useChart();
useEffect(() => {
if (!tooltipData?.point) {
onHoverChange({ value: null, label: null, trend: null });
return;
}
const raw = tooltipData.point[dataKey];
const value = typeof raw === "number" ? raw : null;
const date = parsePointDate(tooltipData.point[dateKey]);
const label = date ? formatLabel(date) : null;
const trend = computePeriodTrend(data, tooltipData.index, dataKey);
onHoverChange({ value, label, trend });
}, [data, dataKey, dateKey, formatLabel, onHoverChange, tooltipData]);
return null;
}

View File

@ -0,0 +1,38 @@
"use client";
import { CentralIcon } from "@central-icons-react/all";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function TrendBadge({
value,
className,
}: {
value: number;
className?: string;
}) {
const positive = value >= 0;
return (
<Badge
className={cn(
positive &&
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
className
)}
variant={positive ? "outline" : "destructive"}
>
<CentralIcon
className="size-3"
data-icon="inline-start"
fill="outlined"
join="round"
name={positive ? "IconArrowUp" : "IconArrowDown"}
radius="0"
stroke="1.5"
/>
{positive ? "+" : ""}
{value.toFixed(1)}%
</Badge>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface StatCardHoverState {
value: number | null;
label: string | null;
trend: number | null;
}
export const statCardValueClassName =
"text-3xl font-semibold leading-none tracking-tight";
export const statCardLabelClassName = "mt-0 text-xs";
export const statCardChartHeights = {
sm: "[--stat-card-chart-h:96px]",
md: "[--stat-card-chart-h:190px]",
lg: "[--stat-card-chart-h:420px]",
} as const;
/** Bleeds charts edge-to-edge inside stat card content padding. */
export function StatCardChart({
children,
className,
size = "sm",
}: {
children: ReactNode;
className?: string;
size?: keyof typeof statCardChartHeights;
}) {
return (
<div
className={cn(
"relative -mx-4 -mb-3 overflow-hidden",
"[&_.relative.w-full]:aspect-auto! [&_.relative.w-full]:h-[var(--stat-card-chart-h)]!",
statCardChartHeights[size],
className
)}
>
{children}
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { useChoropleth } from "@/components/charts";
import { useEffect } from "react";
import { computeVisitorTrend, getVisitorValue } from "../data/visitors";
import type { StatCardHoverState } from "./stat-card-chart";
/** Syncs hovered choropleth feature into stat card NumberFlow and trend badge. */
export function StatCardChoroplethHoverBridge({
onHoverChange,
}: {
onHoverChange: (state: StatCardHoverState) => void;
}) {
const { tooltipData } = useChoropleth();
useEffect(() => {
if (!tooltipData?.feature) {
onHoverChange({ value: null, label: null, trend: null });
return;
}
const feature = tooltipData.feature;
const label = (feature.properties?.name as string | undefined) ?? "Unknown";
const visitors = getVisitorValue(feature);
const value = visitors ?? 0;
const trend = visitors === undefined ? null : computeVisitorTrend(visitors);
onHoverChange({ value, label, trend });
}, [onHoverChange, tooltipData]);
return null;
}

View File

@ -0,0 +1,91 @@
"use client";
import type { ChoroplethFeature } from "@/components/charts";
import {
ChartStatFlow,
ChoroplethChart,
ChoroplethFeatureComponent,
ChoroplethTooltip,
} from "@/components/charts";
import { useState } from "react";
import { useWorldDataStandalone } from "@/lib/use-world-data";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
getVisitorColor,
getVisitorValue,
visitorStats,
} from "../data/visitors";
import {
StatCardChart,
type StatCardHoverState,
statCardLabelClassName,
statCardValueClassName,
} from "./stat-card-chart";
import { StatCardChoroplethHoverBridge } from "./stat-card-choropleth-hover-bridge";
import { TrendBadge } from "./trend-badge";
export function StatCardChoropleth() {
const { worldData, isLoading } = useWorldDataStandalone();
const [hover, setHover] = useState<StatCardHoverState>({
value: null,
label: null,
trend: null,
});
const displayValue = hover.value ?? visitorStats.total;
const displayLabel = hover.label ?? "Total";
const displayTrend = hover.trend ?? visitorStats.trend;
return (
<Card className="relative w-full gap-0 overflow-hidden py-0">
<CardHeader className="pointer-events-none absolute inset-x-0 top-0 z-10 grid auto-rows-min grid-cols-[1fr_auto] items-start gap-1 border-0 bg-gradient-to-b from-45% from-card to-transparent px-4 py-3 pb-10 shadow-none ring-0">
<div className="flex flex-col gap-0.5">
<CardTitle>Unique Visitors</CardTitle>
<ChartStatFlow
label={displayLabel}
labelClassName={statCardLabelClassName}
value={displayValue}
valueClassName={statCardValueClassName}
/>
</div>
<CardAction>
<TrendBadge value={displayTrend} />
</CardAction>
</CardHeader>
<CardContent className="p-0">
{isLoading || !worldData ? (
<StatCardChart className="mx-0 mb-0 min-h-[420px]" size="lg">
<div className="flex h-full min-h-[420px] items-center justify-center text-muted-foreground text-xs">
Loading map
</div>
</StatCardChart>
) : (
<StatCardChart className="mx-0 mb-0 min-h-[420px]" size="lg">
<ChoroplethChart
aspectRatio="2.5 / 1"
className="min-h-[420px] w-full"
data={worldData}
>
<StatCardChoroplethHoverBridge onHoverChange={setHover} />
<ChoroplethFeatureComponent
getFeatureColor={(feature: ChoroplethFeature) =>
getVisitorColor(feature)
}
/>
<ChoroplethTooltip
getFeatureValue={getVisitorValue}
valueLabel="Visitors"
/>
</ChoroplethChart>
</StatCardChart>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,38 @@
"use client";
import { CentralIcon } from "@central-icons-react/all";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function TrendBadge({
value,
className,
}: {
value: number;
className?: string;
}) {
const positive = value >= 0;
return (
<Badge
className={cn(
positive &&
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
className
)}
variant={positive ? "outline" : "destructive"}
>
<CentralIcon
className="size-3"
data-icon="inline-start"
fill="outlined"
join="round"
name={positive ? "IconArrowUp" : "IconArrowDown"}
radius="0"
stroke="1.5"
/>
{positive ? "+" : ""}
{value.toFixed(1)}%
</Badge>
);
}

View File

@ -0,0 +1,143 @@
"use client";
import type { FeatureCollection, Geometry } from "geojson";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { feature } from "topojson-client";
import type { GeometryCollection, Topology } from "topojson-specification";
interface CountryProperties {
name: string;
[key: string]: unknown;
}
interface WorldTopology extends Topology {
objects: {
[key: string]: GeometryCollection<CountryProperties>;
};
}
const WORLD_DATA_URL =
"https://raw.githubusercontent.com/subyfly/topojson/refs/heads/master/world-countries.json";
// Global cache to avoid refetching across component mounts
let globalWorldDataCache: FeatureCollection<
Geometry,
CountryProperties
> | null = null;
let globalFetchPromise: Promise<FeatureCollection<
Geometry,
CountryProperties
> | null> | null = null;
function fetchWorldData(): Promise<FeatureCollection<
Geometry,
CountryProperties
> | null> {
// Return cached data if available
if (globalWorldDataCache) {
return Promise.resolve(globalWorldDataCache);
}
// Return existing promise if fetch is in progress
if (globalFetchPromise) {
return globalFetchPromise;
}
// Start new fetch
globalFetchPromise = (async () => {
try {
const response = await fetch(WORLD_DATA_URL);
const topology = (await response.json()) as WorldTopology;
const objectKey = Object.keys(topology.objects)[0];
if (!objectKey) {
throw new Error("No objects found in topology");
}
const geoObject = topology.objects[objectKey];
if (!geoObject) {
throw new Error("Object not found in topology");
}
const geojson = feature(
topology,
geoObject
) as unknown as FeatureCollection<Geometry, CountryProperties>;
globalWorldDataCache = geojson;
return geojson;
} catch (error) {
console.error("Failed to fetch world data:", error);
return null;
}
})();
return globalFetchPromise;
}
interface WorldDataContextValue {
worldData: FeatureCollection<Geometry, CountryProperties> | null;
isLoading: boolean;
}
const WorldDataContext = createContext<WorldDataContextValue>({
worldData: null,
isLoading: true,
});
export function WorldDataProvider({ children }: { children: ReactNode }) {
const [worldData, setWorldData] = useState<FeatureCollection<
Geometry,
CountryProperties
> | null>(globalWorldDataCache);
const [isLoading, setIsLoading] = useState(!globalWorldDataCache);
useEffect(() => {
if (globalWorldDataCache) {
setWorldData(globalWorldDataCache);
setIsLoading(false);
return;
}
fetchWorldData().then((data) => {
setWorldData(data);
setIsLoading(false);
});
}, []);
return (
<WorldDataContext.Provider value={{ worldData, isLoading }}>
{children}
</WorldDataContext.Provider>
);
}
export function useWorldData() {
return useContext(WorldDataContext);
}
// Standalone hook for components that don't have the provider
export function useWorldDataStandalone() {
const [worldData, setWorldData] = useState<FeatureCollection<
Geometry,
CountryProperties
> | null>(globalWorldDataCache);
const [isLoading, setIsLoading] = useState(!globalWorldDataCache);
useEffect(() => {
if (globalWorldDataCache) {
setWorldData(globalWorldDataCache);
setIsLoading(false);
return;
}
fetchWorldData().then((data) => {
setWorldData(data);
setIsLoading(false);
});
}, []);
return { worldData, isLoading };
}

View File

@ -0,0 +1,45 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface StatCardHoverState {
value: number | null;
label: string | null;
trend: number | null;
}
export const statCardValueClassName =
"text-3xl font-semibold leading-none tracking-tight";
export const statCardLabelClassName = "mt-0 text-xs";
export const statCardChartHeights = {
sm: "[--stat-card-chart-h:96px]",
md: "[--stat-card-chart-h:190px]",
lg: "[--stat-card-chart-h:420px]",
} as const;
/** Bleeds charts edge-to-edge inside stat card content padding. */
export function StatCardChart({
children,
className,
size = "sm",
}: {
children: ReactNode;
className?: string;
size?: keyof typeof statCardChartHeights;
}) {
return (
<div
className={cn(
"relative -mx-4 -mb-3 overflow-hidden",
"[&_.relative.w-full]:aspect-auto! [&_.relative.w-full]:h-[var(--stat-card-chart-h)]!",
statCardChartHeights[size],
className
)}
>
{children}
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useChart } from "@/components/charts";
import { useEffect } from "react";
import type { StatCardHoverState } from "./stat-card-chart";
export type { StatCardHoverState } from "./stat-card-chart";
export function formatStatCardMonth(date: Date) {
return date.toLocaleDateString("en-US", { month: "short" });
}
export function formatStatCardWeekday(date: Date) {
return date.toLocaleDateString("en-US", { weekday: "long" });
}
function parsePointDate(raw: unknown): Date | null {
if (raw instanceof Date) {
return raw;
}
if (typeof raw === "string") {
return new Date(raw);
}
return null;
}
function computePeriodTrend(
data: Record<string, unknown>[],
index: number,
dataKey: string
): number | null {
if (index <= 0) {
return null;
}
const current = data[index]?.[dataKey];
const previous = data[index - 1]?.[dataKey];
if (
typeof current !== "number" ||
typeof previous !== "number" ||
previous === 0
) {
return null;
}
return ((current - previous) / previous) * 100;
}
/** Syncs hovered chart values, labels, and trend into stat card UI. */
export function StatCardHoverBridge({
dataKey,
dateKey = "date",
formatLabel,
onHoverChange,
}: {
dataKey: string;
dateKey?: string;
formatLabel: (date: Date) => string;
onHoverChange: (state: StatCardHoverState) => void;
}) {
const { data, tooltipData } = useChart();
useEffect(() => {
if (!tooltipData?.point) {
onHoverChange({ value: null, label: null, trend: null });
return;
}
const raw = tooltipData.point[dataKey];
const value = typeof raw === "number" ? raw : null;
const date = parsePointDate(tooltipData.point[dateKey]);
const label = date ? formatLabel(date) : null;
const trend = computePeriodTrend(data, tooltipData.index, dataKey);
onHoverChange({ value, label, trend });
}, [data, dataKey, dateKey, formatLabel, onHoverChange, tooltipData]);
return null;
}

View File

@ -0,0 +1,80 @@
"use client";
import { ChartStatFlow, Line, LineChart } from "@/components/charts";
import { curveBasis } from "@visx/curve";
import { useState } from "react";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { sessionsSeries, sessionsStats } from "../data/sessions-series";
import {
StatCardChart,
statCardLabelClassName,
statCardValueClassName,
} from "./stat-card-chart";
import {
formatStatCardWeekday,
StatCardHoverBridge,
type StatCardHoverState,
} from "./stat-card-hover-bridge";
import { TrendBadge } from "./trend-badge";
export function StatCardLine() {
const [hover, setHover] = useState<StatCardHoverState>({
value: null,
label: null,
trend: null,
});
const average = Math.round(sessionsStats.average);
const displayValue = hover.value === null ? average : Math.round(hover.value);
const displayLabel = hover.label ?? "Avg";
const displayTrend = hover.trend ?? sessionsStats.trend;
return (
<Card className="w-full gap-0 py-0">
<CardHeader className="px-4 py-3">
<CardTitle>Active Sessions</CardTitle>
<CardAction>
<TrendBadge value={displayTrend} />
</CardAction>
</CardHeader>
<CardContent className="px-4 pt-2 pb-3">
<StatCardChart size="md">
<div className="pointer-events-none absolute right-4 bottom-4 z-10 flex flex-col items-end text-right">
<ChartStatFlow
label={displayLabel}
labelClassName={statCardLabelClassName}
value={displayValue}
valueClassName={statCardValueClassName}
/>
</div>
<LineChart
aspectRatio="2.5 / 1"
className="w-full"
data={sessionsSeries}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<StatCardHoverBridge
dataKey="value"
formatLabel={formatStatCardWeekday}
onHoverChange={setHover}
/>
<Line
curve={curveBasis}
dataKey="value"
showHighlight
stroke="var(--chart-3)"
strokeWidth={2.5}
/>
</LineChart>
</StatCardChart>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,38 @@
"use client";
import { CentralIcon } from "@central-icons-react/all";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function TrendBadge({
value,
className,
}: {
value: number;
className?: string;
}) {
const positive = value >= 0;
return (
<Badge
className={cn(
positive &&
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
className
)}
variant={positive ? "outline" : "destructive"}
>
<CentralIcon
className="size-3"
data-icon="inline-start"
fill="outlined"
join="round"
name={positive ? "IconArrowUp" : "IconArrowDown"}
radius="0"
stroke="1.5"
/>
{positive ? "+" : ""}
{value.toFixed(1)}%
</Badge>
);
}

View File

@ -0,0 +1,6 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { AreaChart } from "./area-chart";
export { Area } from "./area";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,30 @@
"use client"
// In your app (monorepo/npm): import { AreaChart, Area, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
import { AreaChart, Area, Grid, XAxis, ChartTooltip } from "@/components/charts"
import { curveNatural } from "@visx/curve";
const chartData = [
{ date: new Date("2024-01-01"), desktop: 186 },
{ date: new Date("2024-02-01"), desktop: 305 },
{ date: new Date("2024-03-01"), desktop: 237 },
{ date: new Date("2024-04-01"), desktop: 73 },
{ date: new Date("2024-05-01"), desktop: 209 },
{ date: new Date("2024-06-01"), desktop: 214 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<AreaChart data={chartData} animationDuration={1100}>
<Grid horizontal />
<Area dataKey="desktop" curve={curveNatural} strokeWidth={2.5} fillOpacity={0.4} />
<XAxis />
<ChartTooltip />
</AreaChart>
</div>
</main>
)
}

View File

@ -0,0 +1,6 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { BarChart } from "./bar-chart";
export { Bar } from "./bar";
export { BarXAxis } from "./bar-x-axis";
export { Grid } from "./grid";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,29 @@
"use client"
// In your app (monorepo/npm): import { BarChart, Bar, BarXAxis, Grid, ChartTooltip } from "@bklitui/ui/charts"
import { BarChart, Bar, BarXAxis, Grid, ChartTooltip } from "@/components/charts"
const data = [
{ month: "Jan", revenue: 12000, profit: 4500 },
{ month: "Feb", revenue: 15500, profit: 5200 },
{ month: "Mar", revenue: 11000, profit: 3800 },
{ month: "Apr", revenue: 18500, profit: 7100 },
{ month: "May", revenue: 16800, profit: 5400 },
{ month: "Jun", revenue: 21200, profit: 8800 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<BarChart data={data} xDataKey="month">
<Grid horizontal />
<Bar dataKey="revenue" fill="var(--chart-line-primary)" lineCap="round" />
<Bar dataKey="profit" fill="var(--chart-line-secondary)" lineCap="round" />
<BarXAxis />
<ChartTooltip />
</BarChart>
</div>
</main>
)
}

View File

@ -0,0 +1,7 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { CandlestickChart } from "./candlestick-chart";
export { Candlestick } from "./candlestick";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { YAxis } from "./y-axis";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,27 @@
"use client"
// In your app (monorepo/npm): import { CandlestickChart, Candlestick, Grid, XAxis, YAxis, ChartTooltip } from "@bklitui/ui/charts"
import { CandlestickChart, Candlestick, Grid, XAxis, YAxis, ChartTooltip } from "@/components/charts"
const ohlcData = [
{ date: new Date("2024-01-02"), open: 100, high: 108, low: 98, close: 105 },
{ date: new Date("2024-01-03"), open: 105, high: 110, low: 102, close: 103 },
{ date: new Date("2024-01-04"), open: 103, high: 112, low: 101, close: 110 },
{ date: new Date("2024-01-05"), open: 110, high: 115, low: 107, close: 108 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<CandlestickChart data={ohlcData}>
<Grid horizontal vertical />
<Candlestick />
<XAxis />
<YAxis />
<ChartTooltip />
</CandlestickChart>
</div>
</main>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "./choropleth";

View File

@ -0,0 +1,23 @@
"use client"
// In your app (monorepo/npm): import { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "@bklitui/ui/charts"
import { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "@/components/charts"
const features = [
{ id: "US", name: "United States", value: 120 },
{ id: "CA", name: "Canada", value: 45 },
{ id: "GB", name: "United Kingdom", value: 62 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<ChoroplethChart data={features} aspectRatio="2 / 1">
<ChoroplethFeatureComponent />
<ChoroplethTooltip />
</ChoroplethChart>
</div>
</main>
)
}

View File

@ -0,0 +1,8 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { ComposedChart } from "./composed-chart";
export { SeriesBar } from "./series-bar";
export { Area } from "./area";
export { Line } from "./line";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,30 @@
"use client"
// In your app (monorepo/npm): import { ComposedChart, SeriesBar, Area, Line, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
import { ComposedChart, SeriesBar, Area, Line, Grid, XAxis, ChartTooltip } from "@/components/charts"
import { curveNatural } from "@visx/curve";
const chartData = [
{ date: new Date("2024-01-01"), revenue: 4200, runRate: 3800 },
{ date: new Date("2024-02-01"), revenue: 5100, runRate: 4600 },
{ date: new Date("2024-03-01"), revenue: 4800, runRate: 5200 },
{ date: new Date("2024-04-01"), revenue: 5500, runRate: 5000 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<ComposedChart data={chartData}>
<Grid horizontal />
<SeriesBar dataKey="revenue" fill="var(--chart-1)" />
<Area dataKey="runRate" curve={curveNatural} fill="var(--chart-4)" fillOpacity={0.35} />
<Line dataKey="runRate" curve={curveNatural} stroke="var(--chart-2)" />
<XAxis />
<ChartTooltip />
</ComposedChart>
</div>
</main>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { FunnelChart } from "./funnel-chart";

View File

@ -0,0 +1,21 @@
"use client"
// In your app (monorepo/npm): import { FunnelChart } from "@bklitui/ui/charts"
import { FunnelChart } from "@/components/charts"
const funnelData = [
{ label: "Visitors", value: 12000 },
{ label: "Signups", value: 4800 },
{ label: "Activated", value: 2100 },
{ label: "Paid", value: 840 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<FunnelChart data={funnelData} aspectRatio="2 / 1" />
</div>
</main>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { Gauge } from "./gauge";

View File

@ -0,0 +1,22 @@
"use client"
// In your app (monorepo/npm): import { Gauge } from "@bklitui/ui/charts"
import { Gauge } from "@/components/charts"
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<Gauge
value={72}
centerValue={72}
totalNotches={40}
defaultLabel="Score"
formatOptions={{ style: "percent" }}
/>
</div>
</main>
)
}

View File

@ -0,0 +1,11 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export {
HeatmapCells,
HeatmapChart,
HeatmapInteractionBoundary,
HeatmapInteractionProvider,
HeatmapLegend,
HeatmapTooltip,
HeatmapXAxis,
HeatmapYAxis,
} from "./heatmap";

View File

@ -0,0 +1,53 @@
"use client"
// In your app (monorepo/npm): import { HeatmapCells, HeatmapChart, HeatmapInteractionBoundary, HeatmapInteractionProvider, HeatmapLegend, HeatmapTooltip, HeatmapXAxis, HeatmapYAxis } from "@bklitui/ui/charts"
import { HeatmapCells, HeatmapChart, HeatmapInteractionBoundary, HeatmapInteractionProvider, HeatmapLegend, HeatmapTooltip, HeatmapXAxis, HeatmapYAxis } from "@/components/charts"
const data = [
{
bin: 0,
bins: [
{ bin: 0, count: 2, date: new Date(2024, 0, 1) },
{ bin: 1, count: 0, date: new Date(2024, 0, 2) },
{ bin: 2, count: 3, date: new Date(2024, 0, 3) },
{ bin: 3, count: 1, date: new Date(2024, 0, 4) },
{ bin: 4, count: 4, date: new Date(2024, 0, 5) },
{ bin: 5, count: 0, date: new Date(2024, 0, 6) },
{ bin: 6, count: 1, date: new Date(2024, 0, 7) },
],
},
{
bin: 1,
bins: [
{ bin: 0, count: 1, date: new Date(2024, 0, 8) },
{ bin: 1, count: 2, date: new Date(2024, 0, 9) },
{ bin: 2, count: 0, date: new Date(2024, 0, 10) },
{ bin: 3, count: 3, date: new Date(2024, 0, 11) },
{ bin: 4, count: 2, date: new Date(2024, 0, 12) },
{ bin: 5, count: 1, date: new Date(2024, 0, 13) },
{ bin: 6, count: 0, date: new Date(2024, 0, 14) },
],
},
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<HeatmapInteractionProvider>
<HeatmapInteractionBoundary>
<div className="flex w-full flex-col items-stretch gap-3">
<HeatmapChart className="w-full" data={data} layout="fluid">
<HeatmapCells />
<HeatmapXAxis />
<HeatmapYAxis />
<HeatmapTooltip />
</HeatmapChart>
<HeatmapLegend />
</div>
</HeatmapInteractionBoundary>
</HeatmapInteractionProvider>
</div>
</main>
)
}

View File

@ -0,0 +1,6 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { LineChart } from "./line-chart";
export { Line } from "./line";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,30 @@
"use client"
// In your app (monorepo/npm): import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@/components/charts"
import { curveNatural } from "@visx/curve";
const chartData = [
{ date: new Date("2024-01-01"), users: 1200 },
{ date: new Date("2024-02-01"), users: 1350 },
{ date: new Date("2024-03-01"), users: 1100 },
{ date: new Date("2024-04-01"), users: 1450 },
{ date: new Date("2024-05-01"), users: 1380 },
{ date: new Date("2024-06-01"), users: 1520 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<LineChart data={chartData}>
<Grid horizontal />
<Line dataKey="users" curve={curveNatural} stroke="var(--chart-line-primary)" />
<XAxis />
<ChartTooltip />
</LineChart>
</div>
</main>
)
}

View File

@ -0,0 +1,6 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { LiveLineChart } from "./live-line-chart";
export { LiveLine } from "./live-line";
export { LiveXAxis } from "./live-x-axis";
export { LiveYAxis } from "./live-y-axis";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,32 @@
"use client"
// In your app (monorepo/npm): import { LiveLineChart, LiveLine, LiveXAxis, LiveYAxis, ChartTooltip } from "@bklitui/ui/charts"
import { LiveLineChart, LiveLine, LiveXAxis, LiveYAxis, ChartTooltip } from "@/components/charts"
import { curveNatural } from "@visx/curve";
const initialData = Array.from({ length: 24 }, (_, i) => ({
time: Date.now() - (23 - i) * 60_000,
value: 50 + Math.sin(i / 3) * 20,
}));
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<LiveLineChart
data={initialData}
interval={1000}
maxPoints={24}
xDataKey="time"
yDataKey="value"
>
<LiveLine curve={curveNatural} stroke="var(--chart-line-primary)" />
<LiveXAxis />
<LiveYAxis />
<ChartTooltip />
</LiveLineChart>
</div>
</main>
)
}

View File

@ -0,0 +1,4 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { PieChart } from "./pie-chart";
export { PieSlice } from "./pie-slice";
export { PieCenter } from "./pie-center";

View File

@ -0,0 +1,26 @@
"use client"
// In your app (monorepo/npm): import { PieChart, PieSlice, PieCenter } from "@bklitui/ui/charts"
import { PieChart, PieSlice, PieCenter } from "@/components/charts"
const pieData = [
{ label: "Direct", value: 320 },
{ label: "Organic", value: 280 },
{ label: "Referral", value: 190 },
{ label: "Social", value: 140 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<PieChart data={pieData} size={280}>
{pieData.map((item, i) => (
<PieSlice index={i} key={item.label} />
))}
<PieCenter defaultLabel="Traffic" />
</PieChart>
</div>
</main>
)
}

View File

@ -0,0 +1,13 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { LineChart } from "./line-chart";
export { Line } from "./line";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";
export { ProfitLossLine } from "./profit-loss-line";
export { ProfitLossLegend } from "./profit-loss-legend";
export { ProfitLossLegendHoverProvider } from "./profit-loss-legend-hover";
export {
profitLossColor,
resolveProfitLossTooltipLabel,
} from "./profit-loss-line";

View File

@ -0,0 +1,72 @@
"use client"
// In your app (monorepo/npm): import from "@bklitui/ui/charts"
import {
ChartTooltip,
Grid,
Line,
LineChart,
ProfitLossLegend,
ProfitLossLegendHoverProvider,
ProfitLossLine,
profitLossColor,
resolveProfitLossTooltipLabel,
XAxis,
} from "@/components/charts"
import { curveLinear } from "@visx/curve";
import { useState } from "react";
const chartData = [
{ date: new Date("2024-01-01"), pnl: 420 },
{ date: new Date("2024-01-05"), pnl: 180 },
{ date: new Date("2024-01-10"), pnl: -240 },
{ date: new Date("2024-01-15"), pnl: -90 },
{ date: new Date("2024-01-20"), pnl: 310 },
{ date: new Date("2024-01-25"), pnl: 520 },
];
export default function Component() {
const [legendHoveredIndex, setLegendHoveredIndex] = useState<number | null>(
null
);
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="flex w-full max-w-3xl flex-col gap-2">
<ProfitLossLegend
align="center"
hoveredIndex={legendHoveredIndex}
onHoverChange={setLegendHoveredIndex}
/>
<LineChart data={chartData}>
<Grid highlightRowValues={[0]} horizontal />
<Line
curve={curveLinear}
dataKey="pnl"
fadeEdges={false}
showHighlight={false}
stroke="transparent"
strokeWidth={0}
/>
<ProfitLossLegendHoverProvider hoveredIndex={legendHoveredIndex}>
<ProfitLossLine dataKey="pnl" />
</ProfitLossLegendHoverProvider>
<XAxis />
<ChartTooltip
indicatorColor={(point) => profitLossColor((point.pnl as number) ?? 0)}
rows={(point) => {
const value = (point.pnl as number) ?? 0;
return [
{
color: profitLossColor(value),
label: resolveProfitLossTooltipLabel(""),
value,
},
];
}}
/>
</LineChart>
</div>
</main>
)
}

View File

@ -0,0 +1,6 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { RadarChart } from "./radar-chart";
export { RadarGrid } from "./radar-grid";
export { RadarAxis } from "./radar-axis";
export { RadarLabels } from "./radar-labels";
export { RadarArea } from "./radar-area";

View File

@ -0,0 +1,32 @@
"use client"
// In your app (monorepo/npm): import { RadarChart, RadarGrid, RadarAxis, RadarLabels, RadarArea } from "@bklitui/ui/charts"
import { RadarChart, RadarGrid, RadarAxis, RadarLabels, RadarArea } from "@/components/charts"
const metrics = [
{ key: "speed", label: "Speed" },
{ key: "reliability", label: "Reliability" },
{ key: "comfort", label: "Comfort" },
{ key: "safety", label: "Safety" },
{ key: "efficiency", label: "Efficiency" },
];
const data = [
{ id: "a", speed: 80, reliability: 70, comfort: 60, safety: 90, efficiency: 75 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<RadarChart data={data} metrics={metrics} size={320}>
<RadarGrid />
<RadarAxis />
<RadarLabels fontSize={10} offset={16} />
{data.map((row, i) => (
<RadarArea key={row.id} index={i} fill="var(--chart-line-primary)" fillOpacity={0.35} />
))}
</RadarChart>
</div>
</main>
)
}

View File

@ -0,0 +1,4 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { RingChart } from "./ring-chart";
export { Ring } from "./ring";
export { RingCenter } from "./ring-center";

View File

@ -0,0 +1,26 @@
"use client"
// In your app (monorepo/npm): import { RingChart, Ring, RingCenter } from "@bklitui/ui/charts"
import { RingChart, Ring, RingCenter } from "@/components/charts"
const ringData = [
{ label: "Email", value: 42 },
{ label: "Social", value: 28 },
{ label: "Direct", value: 18 },
{ label: "Other", value: 12 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<RingChart data={ringData} size={280} strokeWidth={14}>
{ringData.map((item, i) => (
<Ring index={i} key={item.label} />
))}
<RingCenter defaultLabel="Channels" />
</RingChart>
</div>
</main>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "./sankey";

View File

@ -0,0 +1,34 @@
"use client"
// In your app (monorepo/npm): import { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "@bklitui/ui/charts"
import { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "@/components/charts"
const data = {
nodes: [
{ name: "Ads" },
{ name: "Organic" },
{ name: "Landing" },
{ name: "Product" },
{ name: "Checkout" },
],
links: [
{ source: 0, target: 2, value: 40 },
{ source: 1, target: 2, value: 30 },
{ source: 2, target: 3, value: 50 },
{ source: 3, target: 4, value: 35 },
],
};
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<SankeyChart data={data} aspectRatio="16 / 9">
<SankeyLink />
<SankeyNode />
<SankeyTooltip />
</SankeyChart>
</div>
</main>
)
}

View File

@ -0,0 +1,6 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { ScatterChart } from "./scatter-chart";
export { Scatter } from "./scatter";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";

View File

@ -0,0 +1,29 @@
"use client"
// In your app (monorepo/npm): import { ScatterChart, Scatter, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
import { ScatterChart, Scatter, Grid, XAxis, ChartTooltip } from "@/components/charts"
const chartData = [
{ date: new Date("2024-01-01"), sessions: 420, conversions: 28 },
{ date: new Date("2024-02-01"), sessions: 510, conversions: 34 },
{ date: new Date("2024-03-01"), sessions: 390, conversions: 22 },
{ date: new Date("2024-04-01"), sessions: 580, conversions: 41 },
{ date: new Date("2024-05-01"), sessions: 620, conversions: 38 },
{ date: new Date("2024-06-01"), sessions: 710, conversions: 52 },
];
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<ScatterChart data={chartData}>
<Grid horizontal />
<Scatter dataKey="sessions" />
<Scatter dataKey="conversions" />
<XAxis />
<ChartTooltip />
</ScatterChart>
</div>
</main>
)
}

View File

@ -0,0 +1,6 @@
/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */
export { AreaChart } from "./area-chart";
export { Area } from "./area";
export { ChartStatFlow } from "./chart-stat-flow";
export { useChart } from "./chart-context";
export { LinearGradient } from "@visx/gradient";

View File

@ -0,0 +1,13 @@
"use client";
import { StatCardArea } from "@/components/stat-card-area";
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-md">
<StatCardArea />
</div>
</main>
);
}

View File

@ -0,0 +1,3 @@
/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */
export { type ChoroplethFeature, ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip, useChoropleth } from "./choropleth";
export { ChartStatFlow } from "./chart-stat-flow";

View File

@ -0,0 +1,13 @@
"use client";
import { StatCardChoropleth } from "@/components/stat-card-choropleth";
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
<StatCardChoropleth />
</div>
</main>
);
}

View File

@ -0,0 +1,5 @@
/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */
export { LineChart } from "./line-chart";
export { Line } from "./line";
export { ChartStatFlow } from "./chart-stat-flow";
export { useChart } from "./chart-context";

View File

@ -0,0 +1,13 @@
"use client";
import { StatCardLine } from "@/components/stat-card-line";
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-md">
<StatCardLine />
</div>
</main>
);
}

View File

@ -0,0 +1,328 @@
/**
* Syncs stat-card blocks from apps/web/blocks into registry sources and registry.json
* (shadcn add + Open in v0). Run from packages/ui after generate-v0-examples.mjs.
*/
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const uiRoot = join(__dirname, "..");
const webBlocksRoot = join(uiRoot, "../../apps/web/blocks");
const registryBlocksRoot = join(uiRoot, "registry/blocks");
const examplesDir = join(uiRoot, "registry/examples");
const registryPath = join(uiRoot, "registry.json");
const TREND_BADGE_REGISTRY = `"use client";
import { CentralIcon } from "@central-icons-react/all";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function TrendBadge({
value,
className,
}: {
value: number;
className?: string;
}) {
const positive = value >= 0;
return (
<Badge
className={cn(
positive &&
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
className
)}
variant={positive ? "outline" : "destructive"}
>
<CentralIcon
className="size-3"
data-icon="inline-start"
fill="outlined"
join="round"
name={positive ? "IconArrowUp" : "IconArrowDown"}
radius="0"
stroke="1.5"
/>
{positive ? "+" : ""}
{value.toFixed(1)}%
</Badge>
);
}
`;
const BLOCK_CHART_INDEX = {
"stat-card-area-01": `export { AreaChart } from "./area-chart";
export { Area } from "./area";
export { ChartStatFlow } from "./chart-stat-flow";
export { useChart } from "./chart-context";
export { LinearGradient } from "@visx/gradient";`,
"stat-card-line-01": `export { LineChart } from "./line-chart";
export { Line } from "./line";
export { ChartStatFlow } from "./chart-stat-flow";
export { useChart } from "./chart-context";`,
"stat-card-choropleth-01": `export { type ChoroplethFeature, ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip, useChoropleth } from "./choropleth";
export { ChartStatFlow } from "./chart-stat-flow";`,
};
/** @type {Record<string, { title: string, description: string, previewMaxWidth: string, registryDependencies: string[], dependencies: string[], files: string[], extraFiles?: { path: string, target: string, content?: string }[] }>} */
const BLOCKS = {
"stat-card-area-01": {
title: "Stat Card Area",
description:
"Revenue stat card with gradient area sparkline, NumberFlow, and trend badge",
previewMaxWidth: "max-w-md",
registryDependencies: [
"@bklit/area-chart",
"@bklit/chart-stat-flow",
"card",
"badge",
],
dependencies: [
"@visx/curve@4.0.1-alpha.0",
"@visx/gradient@4.0.1-alpha.0",
"@number-flow/react",
"@central-icons-react/all",
],
files: [
"components/stat-card-area.tsx",
"components/stat-card-chart.tsx",
"components/stat-card-hover-bridge.tsx",
"components/trend-badge.tsx",
"data/revenue-series.ts",
],
},
"stat-card-line-01": {
title: "Stat Card Line",
description:
"Sessions stat card with line sparkline, overlaid NumberFlow, and trend badge",
previewMaxWidth: "max-w-md",
registryDependencies: [
"@bklit/line-chart",
"@bklit/chart-stat-flow",
"card",
"badge",
],
dependencies: [
"@visx/curve@4.0.1-alpha.0",
"@number-flow/react",
"@central-icons-react/all",
],
files: [
"components/stat-card-line.tsx",
"components/stat-card-chart.tsx",
"components/stat-card-hover-bridge.tsx",
"components/trend-badge.tsx",
"data/sessions-series.ts",
],
},
"stat-card-choropleth-01": {
title: "Stat Card Choropleth",
description:
"Visitor map stat card with choropleth sparkline, NumberFlow, and trend badge",
previewMaxWidth: "max-w-3xl",
registryDependencies: [
"@bklit/choropleth-chart",
"@bklit/chart-stat-flow",
"card",
"badge",
],
dependencies: [
"@number-flow/react",
"@types/geojson",
"@types/topojson-specification",
"@central-icons-react/all",
"topojson-client",
],
files: [
"components/stat-card-choropleth.tsx",
"components/stat-card-choropleth-hover-bridge.tsx",
"components/stat-card-chart.tsx",
"components/trend-badge.tsx",
"data/visitors.ts",
"lib/use-world-data.tsx",
],
},
};
const COMPONENT_EXPORTS = {
"stat-card-area-01": "StatCardArea",
"stat-card-line-01": "StatCardLine",
"stat-card-choropleth-01": "StatCardChoropleth",
};
function transformForRegistry(content, filePath) {
if (filePath.endsWith("trend-badge.tsx")) {
return TREND_BADGE_REGISTRY;
}
return content
.replaceAll("@bklitui/ui/charts", "@/components/charts")
.replaceAll("@/components/docs/use-world-data", "@/lib/use-world-data");
}
function copyBlockFile(blockId, relativePath) {
const sourcePath = join(webBlocksRoot, blockId, "files", relativePath);
const targetPath = join(registryBlocksRoot, blockId, relativePath);
if (relativePath === "lib/use-world-data.tsx") {
const sharedSource = join(
uiRoot,
"../../apps/web/components/docs/use-world-data.tsx"
);
mkdirSync(dirname(targetPath), { recursive: true });
cpSync(sharedSource, targetPath);
return;
}
if (!existsSync(sourcePath)) {
throw new Error(`Block source not found: ${sourcePath}`);
}
mkdirSync(dirname(targetPath), { recursive: true });
const content = transformForRegistry(
readFileSync(sourcePath, "utf8"),
relativePath
);
writeFileSync(targetPath, content);
}
function blockRegistryFiles(blockId, relativePaths) {
return relativePaths.map((relativePath) => ({
path: `registry/blocks/${blockId}/${relativePath}`,
type: relativePath.endsWith(".ts") ? "registry:lib" : "registry:component",
target: relativePath,
}));
}
function blockChartIndexFile(blockId) {
if (!BLOCK_CHART_INDEX[blockId]) {
return null;
}
return {
path: `registry/examples/${blockId}-index.ts`,
type: "registry:lib",
target: "components/charts/index.ts",
};
}
const chartStatFlowItem = {
name: "chart-stat-flow",
type: "registry:component",
title: "Chart Stat Flow",
description: "Animated stat value and label for chart overlays",
registryDependencies: ["@bklit/utils"],
dependencies: ["@number-flow/react", "motion"],
files: [
{
path: "src/charts/chart-stat-flow.tsx",
type: "registry:component",
target: "components/charts/chart-stat-flow.tsx",
},
],
};
mkdirSync(examplesDir, { recursive: true });
mkdirSync(registryBlocksRoot, { recursive: true });
for (const [blockId, spec] of Object.entries(BLOCKS)) {
for (const relativePath of spec.files) {
copyBlockFile(blockId, relativePath);
}
const exportName = COMPONENT_EXPORTS[blockId];
const mainComponentPath = spec.files.find((file) =>
file.startsWith("components/stat-card-")
);
const importPath = `@/components/${mainComponentPath
?.replace("components/", "")
.replace(".tsx", "")}`;
const exampleContent = `"use client";
import { ${exportName} } from "${importPath}";
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full ${spec.previewMaxWidth}">
<${exportName} />
</div>
</main>
);
}
`;
writeFileSync(join(examplesDir, `${blockId}.tsx`), exampleContent);
const chartIndex = BLOCK_CHART_INDEX[blockId];
if (chartIndex) {
writeFileSync(
join(examplesDir, `${blockId}-index.ts`),
`/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */\n${chartIndex}\n`
);
}
}
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
registry.items = registry.items.filter(
(item) =>
item.name !== "chart-stat-flow" && !item.name.startsWith("stat-card-")
);
registry.items.push(chartStatFlowItem);
for (const [blockId, spec] of Object.entries(BLOCKS)) {
const blockFiles = blockRegistryFiles(blockId, spec.files);
const chartIndexFile = blockChartIndexFile(blockId);
if (chartIndexFile) {
blockFiles.push(chartIndexFile);
}
registry.items.push({
name: blockId,
type: "registry:block",
title: spec.title,
description: spec.description,
registryDependencies: spec.registryDependencies,
dependencies: spec.dependencies,
files: blockFiles,
});
const exampleFiles = [
{
path: `registry/examples/${blockId}.tsx`,
type: "registry:page",
target: "app/page.tsx",
},
];
if (chartIndexFile) {
exampleFiles.push(chartIndexFile);
}
registry.items.push({
name: `${blockId}-example`,
type: "registry:example",
title: `${spec.title} Example`,
description: `${spec.description} — demo for Open in v0`,
registryDependencies: [`@bklit/${blockId}`],
dependencies: spec.dependencies,
files: exampleFiles,
});
}
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
console.log(
`Synced ${Object.keys(BLOCKS).length} stat-card blocks into registry (${Object.keys(BLOCKS).length * 2 + 1} items).`
);

View File

@ -0,0 +1,520 @@
/**
* Generates registry example entries for Open in v0 (registry:example + registry:page).
* Run from packages/ui: node scripts/generate-v0-examples.mjs
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const examplesDir = join(root, "registry/examples");
/** Per-chart barrel for installed @/components/charts/* paths in v0. */
const CHART_INDEX_EXPORTS = {
"area-chart": `export { AreaChart } from "./area-chart";
export { Area } from "./area";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";`,
"bar-chart": `export { BarChart } from "./bar-chart";
export { Bar } from "./bar";
export { BarXAxis } from "./bar-x-axis";
export { Grid } from "./grid";
export { ChartTooltip } from "./tooltip";`,
"line-chart": `export { LineChart } from "./line-chart";
export { Line } from "./line";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";`,
"scatter-chart": `export { ScatterChart } from "./scatter-chart";
export { Scatter } from "./scatter";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";`,
"pie-chart": `export { PieChart } from "./pie-chart";
export { PieSlice } from "./pie-slice";
export { PieCenter } from "./pie-center";`,
"gauge-chart": `export { Gauge } from "./gauge";`,
"heatmap-chart": `export {
HeatmapCells,
HeatmapChart,
HeatmapInteractionBoundary,
HeatmapInteractionProvider,
HeatmapLegend,
HeatmapTooltip,
HeatmapXAxis,
HeatmapYAxis,
} from "./heatmap";`,
"ring-chart": `export { RingChart } from "./ring-chart";
export { Ring } from "./ring";
export { RingCenter } from "./ring-center";`,
"radar-chart": `export { RadarChart } from "./radar-chart";
export { RadarGrid } from "./radar-grid";
export { RadarAxis } from "./radar-axis";
export { RadarLabels } from "./radar-labels";
export { RadarArea } from "./radar-area";`,
"composed-chart": `export { ComposedChart } from "./composed-chart";
export { SeriesBar } from "./series-bar";
export { Area } from "./area";
export { Line } from "./line";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { ChartTooltip } from "./tooltip";`,
"funnel-chart": `export { FunnelChart } from "./funnel-chart";`,
"sankey-chart": `export { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "./sankey";`,
"candlestick-chart": `export { CandlestickChart } from "./candlestick-chart";
export { Candlestick } from "./candlestick";
export { Grid } from "./grid";
export { XAxis } from "./x-axis";
export { YAxis } from "./y-axis";
export { ChartTooltip } from "./tooltip";`,
"choropleth-chart": `export { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "./choropleth";`,
"live-line-chart": `export { LiveLineChart } from "./live-line-chart";
export { LiveLine } from "./live-line";
export { LiveXAxis } from "./live-x-axis";
export { LiveYAxis } from "./live-y-axis";
export { ChartTooltip } from "./tooltip";`,
};
/** @type {Record<string, { registryDependencies: string[], dependencies: string[], importFrom: string, data: string, body: string }>} */
const EXAMPLES = {
"area-chart": {
registryDependencies: [
"@bklit/area-chart",
"@bklit/grid",
"@bklit/x-axis",
"@bklit/chart-tooltip",
],
dependencies: [
"@visx/curve@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom: "AreaChart, Area, Grid, XAxis, ChartTooltip",
data: `const chartData = [
{ date: new Date("2024-01-01"), desktop: 186 },
{ date: new Date("2024-02-01"), desktop: 305 },
{ date: new Date("2024-03-01"), desktop: 237 },
{ date: new Date("2024-04-01"), desktop: 73 },
{ date: new Date("2024-05-01"), desktop: 209 },
{ date: new Date("2024-06-01"), desktop: 214 },
];`,
body: `<AreaChart data={chartData} animationDuration={1100}>
<Grid horizontal />
<Area dataKey="desktop" curve={curveNatural} strokeWidth={2.5} fillOpacity={0.4} />
<XAxis />
<ChartTooltip />
</AreaChart>`,
extraImports: `import { curveNatural } from "@visx/curve";`,
},
"bar-chart": {
registryDependencies: [
"@bklit/bar-chart",
"@bklit/grid",
"@bklit/chart-tooltip",
],
dependencies: [
"@visx/gradient@4.0.1-alpha.0",
"@visx/pattern@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom: "BarChart, Bar, BarXAxis, Grid, ChartTooltip",
data: `const data = [
{ month: "Jan", revenue: 12000, profit: 4500 },
{ month: "Feb", revenue: 15500, profit: 5200 },
{ month: "Mar", revenue: 11000, profit: 3800 },
{ month: "Apr", revenue: 18500, profit: 7100 },
{ month: "May", revenue: 16800, profit: 5400 },
{ month: "Jun", revenue: 21200, profit: 8800 },
];`,
body: `<BarChart data={data} xDataKey="month">
<Grid horizontal />
<Bar dataKey="revenue" fill="var(--chart-line-primary)" lineCap="round" />
<Bar dataKey="profit" fill="var(--chart-line-secondary)" lineCap="round" />
<BarXAxis />
<ChartTooltip />
</BarChart>`,
},
"line-chart": {
registryDependencies: [
"@bklit/line-chart",
"@bklit/grid",
"@bklit/x-axis",
"@bklit/chart-tooltip",
],
dependencies: [
"@visx/curve@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom: "LineChart, Line, Grid, XAxis, ChartTooltip",
data: `const chartData = [
{ date: new Date("2024-01-01"), users: 1200 },
{ date: new Date("2024-02-01"), users: 1350 },
{ date: new Date("2024-03-01"), users: 1100 },
{ date: new Date("2024-04-01"), users: 1450 },
{ date: new Date("2024-05-01"), users: 1380 },
{ date: new Date("2024-06-01"), users: 1520 },
];`,
body: `<LineChart data={chartData}>
<Grid horizontal />
<Line dataKey="users" curve={curveNatural} stroke="var(--chart-line-primary)" />
<XAxis />
<ChartTooltip />
</LineChart>`,
extraImports: `import { curveNatural } from "@visx/curve";`,
},
"scatter-chart": {
registryDependencies: [
"@bklit/scatter-chart",
"@bklit/grid",
"@bklit/x-axis",
"@bklit/chart-tooltip",
],
dependencies: ["d3-scale", "d3-array", "motion", "react-use-measure"],
importFrom: "ScatterChart, Scatter, Grid, XAxis, ChartTooltip",
data: `const chartData = [
{ date: new Date("2024-01-01"), sessions: 420, conversions: 28 },
{ date: new Date("2024-02-01"), sessions: 510, conversions: 34 },
{ date: new Date("2024-03-01"), sessions: 390, conversions: 22 },
{ date: new Date("2024-04-01"), sessions: 580, conversions: 41 },
{ date: new Date("2024-05-01"), sessions: 620, conversions: 38 },
{ date: new Date("2024-06-01"), sessions: 710, conversions: 52 },
];`,
body: `<ScatterChart data={chartData}>
<Grid horizontal />
<Scatter dataKey="sessions" />
<Scatter dataKey="conversions" />
<XAxis />
<ChartTooltip />
</ScatterChart>`,
},
"pie-chart": {
registryDependencies: ["@bklit/pie-chart"],
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
importFrom: "PieChart, PieSlice, PieCenter",
data: `const pieData = [
{ label: "Direct", value: 320 },
{ label: "Organic", value: 280 },
{ label: "Referral", value: 190 },
{ label: "Social", value: 140 },
];`,
body: `<PieChart data={pieData} size={280}>
{pieData.map((item, i) => (
<PieSlice index={i} key={item.label} />
))}
<PieCenter defaultLabel="Traffic" />
</PieChart>`,
},
"gauge-chart": {
registryDependencies: ["@bklit/gauge-chart"],
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
importFrom: "Gauge",
data: "",
body: `<Gauge
value={72}
centerValue={72}
totalNotches={40}
defaultLabel="Score"
formatOptions={{ style: "percent" }}
/>`,
},
"heatmap-chart": {
registryDependencies: ["@bklit/heatmap-chart"],
dependencies: [
"@visx/heatmap@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom:
"HeatmapCells, HeatmapChart, HeatmapInteractionBoundary, HeatmapInteractionProvider, HeatmapLegend, HeatmapTooltip, HeatmapXAxis, HeatmapYAxis",
data: `const data = [
{
bin: 0,
bins: [
{ bin: 0, count: 2, date: new Date(2024, 0, 1) },
{ bin: 1, count: 0, date: new Date(2024, 0, 2) },
{ bin: 2, count: 3, date: new Date(2024, 0, 3) },
{ bin: 3, count: 1, date: new Date(2024, 0, 4) },
{ bin: 4, count: 4, date: new Date(2024, 0, 5) },
{ bin: 5, count: 0, date: new Date(2024, 0, 6) },
{ bin: 6, count: 1, date: new Date(2024, 0, 7) },
],
},
{
bin: 1,
bins: [
{ bin: 0, count: 1, date: new Date(2024, 0, 8) },
{ bin: 1, count: 2, date: new Date(2024, 0, 9) },
{ bin: 2, count: 0, date: new Date(2024, 0, 10) },
{ bin: 3, count: 3, date: new Date(2024, 0, 11) },
{ bin: 4, count: 2, date: new Date(2024, 0, 12) },
{ bin: 5, count: 1, date: new Date(2024, 0, 13) },
{ bin: 6, count: 0, date: new Date(2024, 0, 14) },
],
},
];`,
body: `<HeatmapInteractionProvider>
<HeatmapInteractionBoundary>
<div className="flex w-full flex-col items-stretch gap-3">
<HeatmapChart className="w-full" data={data} layout="fluid">
<HeatmapCells />
<HeatmapXAxis />
<HeatmapYAxis />
<HeatmapTooltip />
</HeatmapChart>
<HeatmapLegend />
</div>
</HeatmapInteractionBoundary>
</HeatmapInteractionProvider>`,
},
"ring-chart": {
registryDependencies: ["@bklit/ring-chart"],
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
importFrom: "RingChart, Ring, RingCenter",
data: `const ringData = [
{ label: "Email", value: 42 },
{ label: "Social", value: 28 },
{ label: "Direct", value: 18 },
{ label: "Other", value: 12 },
];`,
body: `<RingChart data={ringData} size={280} strokeWidth={14}>
{ringData.map((item, i) => (
<Ring index={i} key={item.label} />
))}
<RingCenter defaultLabel="Channels" />
</RingChart>`,
},
"radar-chart": {
registryDependencies: ["@bklit/radar-chart"],
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
importFrom: "RadarChart, RadarGrid, RadarAxis, RadarLabels, RadarArea",
data: `const metrics = [
{ key: "speed", label: "Speed" },
{ key: "reliability", label: "Reliability" },
{ key: "comfort", label: "Comfort" },
{ key: "safety", label: "Safety" },
{ key: "efficiency", label: "Efficiency" },
];
const data = [
{ id: "a", speed: 80, reliability: 70, comfort: 60, safety: 90, efficiency: 75 },
];`,
body: `<RadarChart data={data} metrics={metrics} size={320}>
<RadarGrid />
<RadarAxis />
<RadarLabels fontSize={10} offset={16} />
{data.map((row, i) => (
<RadarArea key={row.id} index={i} fill="var(--chart-line-primary)" fillOpacity={0.35} />
))}
</RadarChart>`,
},
"composed-chart": {
registryDependencies: [
"@bklit/composed-chart",
"@bklit/grid",
"@bklit/x-axis",
"@bklit/chart-tooltip",
],
dependencies: [
"@visx/curve@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom:
"ComposedChart, SeriesBar, Area, Line, Grid, XAxis, ChartTooltip",
data: `const chartData = [
{ date: new Date("2024-01-01"), revenue: 4200, runRate: 3800 },
{ date: new Date("2024-02-01"), revenue: 5100, runRate: 4600 },
{ date: new Date("2024-03-01"), revenue: 4800, runRate: 5200 },
{ date: new Date("2024-04-01"), revenue: 5500, runRate: 5000 },
];`,
body: `<ComposedChart data={chartData}>
<Grid horizontal />
<SeriesBar dataKey="revenue" fill="var(--chart-1)" />
<Area dataKey="runRate" curve={curveNatural} fill="var(--chart-4)" fillOpacity={0.35} />
<Line dataKey="runRate" curve={curveNatural} stroke="var(--chart-2)" />
<XAxis />
<ChartTooltip />
</ComposedChart>`,
extraImports: `import { curveNatural } from "@visx/curve";`,
},
"funnel-chart": {
registryDependencies: ["@bklit/funnel-chart"],
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
importFrom: "FunnelChart",
data: `const funnelData = [
{ label: "Visitors", value: 12000 },
{ label: "Signups", value: 4800 },
{ label: "Activated", value: 2100 },
{ label: "Paid", value: 840 },
];`,
body: `<FunnelChart data={funnelData} aspectRatio="2 / 1" />`,
},
"sankey-chart": {
registryDependencies: ["@bklit/sankey-chart"],
dependencies: [
"@visx/sankey@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom: "SankeyChart, SankeyLink, SankeyNode, SankeyTooltip",
data: `const data = {
nodes: [
{ name: "Ads" },
{ name: "Organic" },
{ name: "Landing" },
{ name: "Product" },
{ name: "Checkout" },
],
links: [
{ source: 0, target: 2, value: 40 },
{ source: 1, target: 2, value: 30 },
{ source: 2, target: 3, value: 50 },
{ source: 3, target: 4, value: 35 },
],
};`,
body: `<SankeyChart data={data} aspectRatio="16 / 9">
<SankeyLink />
<SankeyNode />
<SankeyTooltip />
</SankeyChart>`,
},
"candlestick-chart": {
registryDependencies: [
"@bklit/candlestick-chart",
"@bklit/grid",
"@bklit/x-axis",
"@bklit/y-axis",
"@bklit/chart-tooltip",
],
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
importFrom:
"CandlestickChart, Candlestick, Grid, XAxis, YAxis, ChartTooltip",
data: `const ohlcData = [
{ date: new Date("2024-01-02"), open: 100, high: 108, low: 98, close: 105 },
{ date: new Date("2024-01-03"), open: 105, high: 110, low: 102, close: 103 },
{ date: new Date("2024-01-04"), open: 103, high: 112, low: 101, close: 110 },
{ date: new Date("2024-01-05"), open: 110, high: 115, low: 107, close: 108 },
];`,
body: `<CandlestickChart data={ohlcData}>
<Grid horizontal vertical />
<Candlestick />
<XAxis />
<YAxis />
<ChartTooltip />
</CandlestickChart>`,
},
"choropleth-chart": {
registryDependencies: ["@bklit/choropleth-chart"],
dependencies: ["@visx/geo@4.0.1-alpha.0", "d3-geo", "topojson-client"],
importFrom:
"ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip",
data: `const features = [
{ id: "US", name: "United States", value: 120 },
{ id: "CA", name: "Canada", value: 45 },
{ id: "GB", name: "United Kingdom", value: 62 },
];`,
body: `<ChoroplethChart data={features} aspectRatio="2 / 1">
<ChoroplethFeatureComponent />
<ChoroplethTooltip />
</ChoroplethChart>`,
},
"live-line-chart": {
registryDependencies: ["@bklit/live-line-chart", "@bklit/chart-tooltip"],
dependencies: [
"@visx/curve@4.0.1-alpha.0",
"@visx/shape@4.0.1-alpha.0",
"motion",
],
importFrom: "LiveLineChart, LiveLine, LiveXAxis, LiveYAxis, ChartTooltip",
data: `const initialData = Array.from({ length: 24 }, (_, i) => ({
time: Date.now() - (23 - i) * 60_000,
value: 50 + Math.sin(i / 3) * 20,
}));`,
body: `<LiveLineChart
data={initialData}
interval={1000}
maxPoints={24}
xDataKey="time"
yDataKey="value"
>
<LiveLine curve={curveNatural} stroke="var(--chart-line-primary)" />
<LiveXAxis />
<LiveYAxis />
<ChartTooltip />
</LiveLineChart>`,
extraImports: `import { curveNatural } from "@visx/curve";`,
},
};
mkdirSync(examplesDir, { recursive: true });
for (const [slug, spec] of Object.entries(EXAMPLES)) {
const extra = spec.extraImports ? `${spec.extraImports}\n\n` : "";
const content = `"use client"
// In your app (monorepo/npm): import { ${spec.importFrom} } from "@bklitui/ui/charts"
import { ${spec.importFrom} } from "@/components/charts"
${extra}${spec.data}
export default function Component() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-3xl">
${spec.body}
</div>
</main>
)
}
`;
writeFileSync(join(examplesDir, `${slug}.tsx`), content);
const indexExports = CHART_INDEX_EXPORTS[slug];
if (indexExports) {
writeFileSync(
join(examplesDir, `${slug}-index.ts`),
`// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install\n${indexExports}\n`
);
}
}
const registryPath = join(root, "registry.json");
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
registry.items = registry.items.filter(
(item) => !item.name.endsWith("-example")
);
const exampleItems = Object.entries(EXAMPLES).map(([slug, spec]) => {
const files = [
{
path: `registry/examples/${slug}.tsx`,
type: "registry:page",
target: "app/page.tsx",
},
];
if (CHART_INDEX_EXPORTS[slug]) {
files.push({
path: `registry/examples/${slug}-index.ts`,
type: "registry:lib",
target: "components/charts/index.ts",
});
}
return {
name: `${slug}-example`,
type: "registry:example",
title: `${slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} Example`,
description: `Composable ${slug} demo for Open in v0`,
registryDependencies: spec.registryDependencies,
dependencies: spec.dependencies,
files,
};
});
registry.items.push(...exampleItems);
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
console.log(`Wrote ${Object.keys(EXAMPLES).length} v0 example registry items`);

View File

@ -0,0 +1,24 @@
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const testDir = join(packageRoot, "src/charts/__tests__");
const tests = readdirSync(testDir)
.filter((name) => name.endsWith(".test.ts"))
.map((name) => join(testDir, name));
if (tests.length === 0) {
console.error(`No test files found in ${testDir}`);
process.exit(1);
}
const result = spawnSync(
process.execPath,
["--import", "tsx", "--test", ...tests],
{ stdio: "inherit", cwd: packageRoot }
);
process.exit(result.status ?? 1);

View File

@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* Verifies registry item `dependencies` cover all npm imports in the item's
* file tree plus transitive @bklit registryDependencies (shadcn / v0 install closure).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uiRoot = path.join(__dirname, "..");
const registryPath = path.join(uiRoot, "registry.json");
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
const byName = Object.fromEntries(
registry.items.map((item) => [item.name, item])
);
const importRe =
/(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g;
const DEP_VERSION_SUFFIX_RE = /@[\d.]+(-[\w.]+)?$/;
const BKLIT_REGISTRY_DEP_RE = /^@bklit\/(.+)$/;
function getPackageName(spec) {
if (spec.startsWith(".") || spec.startsWith("@/")) {
return null;
}
if (spec.startsWith("@")) {
const parts = spec.split("/");
return `${parts[0]}/${parts[1]}`;
}
return spec.split("/")[0];
}
function normalizeDep(dep) {
return dep.replace(DEP_VERSION_SUFFIX_RE, "");
}
/** Import specifiers that are satisfied by @types/* registry dependencies. */
const TYPES_ALIASES = {
geojson: ["geojson", "@types/geojson"],
};
function isDeclared(pkg, declared) {
if (declared.has(pkg)) {
return true;
}
const aliases = TYPES_ALIASES[pkg];
if (aliases) {
return aliases.some((name) => declared.has(name));
}
return false;
}
function scanFiles(files) {
const used = new Set();
for (const file of files ?? []) {
const fullPath = path.join(uiRoot, file.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`Registry file not found: ${file.path}`);
}
const content = fs.readFileSync(fullPath, "utf8");
importRe.lastIndex = 0;
let match = importRe.exec(content);
while (match) {
const pkg = getPackageName(match[1]);
if (pkg && pkg !== "react" && pkg !== "react-dom") {
used.add(pkg);
}
match = importRe.exec(content);
}
}
return used;
}
function getRegistryDepName(dep) {
const m = dep.match(BKLIT_REGISTRY_DEP_RE);
return m ? m[1] : null;
}
function collectDeclared(name, visited = new Set()) {
const declared = new Set();
const visit = (itemName) => {
if (visited.has(itemName)) {
return;
}
visited.add(itemName);
const item = byName[itemName];
if (!item) {
return;
}
for (const dep of item.dependencies ?? []) {
declared.add(normalizeDep(dep));
}
for (const regDep of item.registryDependencies ?? []) {
const nested = getRegistryDepName(regDep);
if (nested) {
visit(nested);
}
}
};
visit(name);
return declared;
}
function collectUsed(name, visited = new Set()) {
const used = new Set();
const visit = (itemName) => {
if (visited.has(itemName)) {
return;
}
visited.add(itemName);
const item = byName[itemName];
if (!item) {
return;
}
for (const pkg of scanFiles(item.files)) {
used.add(pkg);
}
for (const regDep of item.registryDependencies ?? []) {
const nested = getRegistryDepName(regDep);
if (nested) {
visit(nested);
}
}
};
visit(name);
return used;
}
const chartItems = registry.items.filter(
(item) =>
item.type === "registry:component" &&
(item.name.endsWith("-chart") ||
item.name === "chart-context" ||
item.name === "chart-animation")
);
const utilityItems = registry.items.filter((item) =>
["grid", "x-axis", "y-axis", "chart-tooltip", "legend", "markers"].includes(
item.name
)
);
const failures = [];
for (const item of [...chartItems, ...utilityItems]) {
const declared = collectDeclared(item.name);
const used = collectUsed(item.name);
const missing = [...used].filter((pkg) => !isDeclared(pkg, declared)).sort();
if (missing.length > 0) {
failures.push({ name: item.name, missing });
}
}
if (failures.length > 0) {
console.error("Registry dependency closure check failed:\n");
for (const { name, missing } of failures) {
console.error(` ${name}: missing ${missing.join(", ")}`);
}
console.error(
"\nAdd missing packages to `dependencies` in packages/ui/registry.json (on the item or a registryDependency)."
);
process.exit(1);
}
console.log(
`Registry dependency closure OK (${chartItems.length + utilityItems.length} items checked).`
);

View File

@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Verifies every installable registry item ships all local `./` imports in its
* transitive file closure (item files + registryDependencies).
*
* Also rejects CommonJS `require("react-dom")` in registry source files it
* breaks Vite/ESM consumers even though Next.js/webpack may tolerate it.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uiRoot = path.join(__dirname, "..");
const registryPath = path.join(uiRoot, "registry.json");
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
const byName = Object.fromEntries(
registry.items.map((item) => [item.name, item])
);
const importRe =
/(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?['"](\.[^'"]+)['"]/g;
const BKLIT_REGISTRY_DEP_RE = /^@bklit\/(.+)$/;
const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
function resolveRelativeImport(fromFilePath, spec) {
const dir = path.dirname(fromFilePath);
const base = path.normalize(path.join(dir, spec));
const candidates = [
base,
...RESOLVE_EXTENSIONS.map((ext) => base + ext),
...RESOLVE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)),
];
for (const candidate of candidates) {
const fullPath = path.join(uiRoot, candidate);
if (fs.existsSync(fullPath)) {
return candidate;
}
}
return null;
}
function collectItemFiles(itemName, visited = new Set()) {
const files = new Set();
const visit = (name) => {
if (visited.has(name)) {
return;
}
visited.add(name);
const item = byName[name];
if (!item) {
return;
}
for (const file of item.files ?? []) {
files.add(file.path);
}
for (const regDep of item.registryDependencies ?? []) {
const nested = regDep.match(BKLIT_REGISTRY_DEP_RE)?.[1];
if (nested) {
visit(nested);
}
}
};
visit(itemName);
return files;
}
function scanRelativeImports(filePath) {
// v0/shadcn example barrels re-export co-installed chart files from the
// target `components/charts/` directory — not from registry source paths.
if (
filePath.startsWith("registry/examples/") &&
filePath.endsWith("-index.ts")
) {
return { imports: [], content: "" };
}
const fullPath = path.join(uiRoot, filePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Registry file not found: ${filePath}`);
}
const content = fs.readFileSync(fullPath, "utf8");
const imports = [];
importRe.lastIndex = 0;
let match = importRe.exec(content);
while (match) {
imports.push(match[1]);
match = importRe.exec(content);
}
return { imports, content };
}
const INSTALLABLE_TYPES = new Set([
"registry:component",
"registry:lib",
"registry:block",
]);
const installableItems = registry.items.filter((item) =>
INSTALLABLE_TYPES.has(item.type)
);
const closureFailures = [];
const requireFailures = [];
for (const item of installableItems) {
const closure = collectItemFiles(item.name);
const missing = new Map();
for (const filePath of closure) {
const { imports, content } = scanRelativeImports(filePath);
if (content.includes('require("react-dom")')) {
requireFailures.push({ item: item.name, file: filePath });
}
for (const spec of imports) {
const resolved = resolveRelativeImport(filePath, spec);
if (!resolved) {
const key = `${filePath} -> ${spec}`;
if (!missing.has(key)) {
missing.set(key, "missing on disk");
}
continue;
}
if (!closure.has(resolved)) {
const key = `${filePath} -> ${spec} (${resolved})`;
if (!missing.has(key)) {
missing.set(key, "not in install closure");
}
}
}
}
if (missing.size > 0) {
closureFailures.push({ name: item.name, missing: [...missing.entries()] });
}
}
let failed = false;
if (closureFailures.length > 0) {
failed = true;
console.error("Registry local import closure check failed:\n");
for (const { name, missing } of closureFailures) {
console.error(` ${name}:`);
for (const [ref, reason] of missing) {
console.error(` - ${ref} (${reason})`);
}
console.error("");
}
console.error(
"Add missing files to packages/ui/registry.json (on the item or a registryDependency)."
);
}
if (requireFailures.length > 0) {
failed = true;
console.error('Registry source must not use require("react-dom"):\n');
for (const { item, file } of requireFailures) {
console.error(` ${item}: ${file}`);
}
console.error(
'\nUse `import { createPortal } from "react-dom"` in "use client" components instead.'
);
}
if (failed) {
process.exit(1);
}
console.log(
`Registry file closure OK (${installableItems.length} installable items checked).`
);

View File

@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* Ensures shadcn registry output matches chart source when packages/ui/src/charts changes.
* Run via `pnpm registry:verify` (locally or in CI).
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "../../..");
const CHARTS_SRC = "packages/ui/src/charts";
const REGISTRY_PATHS = [
"apps/web/public/r",
"packages/ui/registry.json",
"packages/ui/registry/examples",
"packages/ui/registry/blocks",
];
/** Build scripts may reformat these; compare parsed JSON, not raw text. */
const SEMANTIC_JSON_PATHS = [
"packages/ui/registry.json",
"apps/web/public/r/registry.json",
];
function run(cmd, { inherit = false } = {}) {
return execSync(cmd, {
cwd: repoRoot,
encoding: "utf8",
stdio: inherit ? "inherit" : "pipe",
});
}
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(join(repoRoot, relativePath), "utf8"));
}
function jsonEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function resolveDiffRange() {
if (process.env.REGISTRY_SYNC_DIFF_RANGE) {
return process.env.REGISTRY_SYNC_DIFF_RANGE;
}
if (process.env.GITHUB_EVENT_NAME === "pull_request") {
const base = process.env.GITHUB_BASE_REF || "main";
return `origin/${base}...HEAD`;
}
if (
process.env.GITHUB_EVENT_NAME === "push" &&
process.env.GITHUB_EVENT_BEFORE &&
process.env.GITHUB_SHA
) {
return `${process.env.GITHUB_EVENT_BEFORE}...${process.env.GITHUB_SHA}`;
}
return "origin/main...HEAD";
}
function chartsChanged(diffRange) {
const changed = run(
`git diff --name-only ${diffRange} -- ${CHARTS_SRC}`
).trim();
return changed.length > 0;
}
function listDirtyRegistryFiles() {
const excludeArgs = SEMANTIC_JSON_PATHS.map((p) => `':!${p}'`).join(" ");
const paths = REGISTRY_PATHS.join(" ");
const status = run(
`git status --porcelain -- ${paths} ${excludeArgs}`
).trim();
if (!status) {
return [];
}
return status.split("\n").filter(Boolean);
}
function listSemanticRegistryDrift(beforeByPath) {
const drift = [];
for (const relativePath of SEMANTIC_JSON_PATHS) {
const after = readJson(relativePath);
if (!jsonEqual(beforeByPath[relativePath], after)) {
drift.push(relativePath);
}
}
return drift;
}
function main() {
const diffRange = resolveDiffRange();
if (!chartsChanged(diffRange)) {
console.log(
`No changes in ${CHARTS_SRC} (${diffRange}); registry sync check skipped.`
);
process.exit(0);
}
console.log(
`Chart source changed (${diffRange}); rebuilding registry to verify sync...`
);
const registryBefore = Object.fromEntries(
SEMANTIC_JSON_PATHS.map((relativePath) => [
relativePath,
readJson(relativePath),
])
);
run("pnpm registry:build", { inherit: true });
const semanticDrift = listSemanticRegistryDrift(registryBefore);
const dirty = listDirtyRegistryFiles();
const failures = [
...semanticDrift.map((path) => `${path} (content changed)`),
...dirty,
];
if (failures.length > 0) {
console.error("\nRegistry is out of sync with chart source changes.\n");
console.error(
"Run `pnpm registry:build` from the repo root and commit the updated registry files:\n"
);
for (const line of failures) {
console.error(` ${line}`);
}
process.exit(1);
}
console.log("Registry is in sync with chart source.");
}
main();

View File

@ -0,0 +1,21 @@
"use client";
import type { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
className?: string;
appName: string;
}
export const Button = ({ children, className, appName }: ButtonProps) => {
return (
<button
className={className}
onClick={() => console.log(`Hello from your ${appName} app!`)}
type="button"
>
{children}
</button>
);
};

View File

@ -0,0 +1,27 @@
import type { JSX } from "react";
export function Card({
className,
title,
children,
href,
}: {
className?: string;
title: string;
children: React.ReactNode;
href: string;
}): JSX.Element {
return (
<a
className={className}
href={`${href}?utm_source=create-turbo&utm_medium=basic&utm_campaign=create-turbo"`}
rel="noopener noreferrer"
target="_blank"
>
<h2>
{title} <span>-&gt;</span>
</h2>
<p>{children}</p>
</a>
);
}

View File

@ -0,0 +1,116 @@
"use client";
import { GridColumns, GridRows } from "@visx/grid";
import type { scaleLinear, scaleTime } from "@visx/scale";
import { useId } from "react";
type ScaleLinear<Output, _Input = number> = ReturnType<
typeof scaleLinear<Output>
>;
type ScaleTime<Output, _Input = Date | number> = ReturnType<
typeof scaleTime<Output>
>;
export interface ChartGridProps {
/** Width of the grid area */
width: number;
/** Height of the grid area */
height: number;
/** X scale (time scale) */
xScale: ScaleTime<number, number>;
/** Y scale (linear scale) */
yScale: ScaleLinear<number, number>;
/** Show horizontal grid lines. Default: true */
showRows?: boolean;
/** Show vertical grid lines. Default: false */
showColumns?: boolean;
/** Number of horizontal grid lines. Default: 5 */
numTicksRows?: number;
/** Number of vertical grid lines. Default: 10 */
numTicksColumns?: number;
/** Grid line stroke color. Default: var(--chart-grid) */
stroke?: string;
/** Grid line stroke opacity. Default: 1 */
strokeOpacity?: number;
/** Grid line stroke width. Default: 1 */
strokeWidth?: number;
/** Grid line dash array. Default: "4,4" for dashed lines */
strokeDasharray?: string;
/** Enable horizontal fade effect on grid rows. Default: true */
fadeRows?: boolean;
}
export function ChartGrid({
width,
height,
xScale,
yScale,
showRows = true,
showColumns = false,
numTicksRows = 5,
numTicksColumns = 10,
stroke = "var(--chart-grid)",
strokeOpacity = 1,
strokeWidth = 1,
strokeDasharray = "4,4",
fadeRows = true,
}: ChartGridProps) {
const uniqueId = useId();
const maskId = `chart-grid-rows-fade-${uniqueId}`;
const gradientId = `${maskId}-gradient`;
return (
<g className="chart-grid">
{/* Gradient mask for horizontal grid lines - fades at both ends */}
{showRows && fadeRows && (
<defs>
<linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
</linearGradient>
<mask id={maskId}>
<rect
fill={`url(#${gradientId})`}
height={height}
width={width}
x="0"
y="0"
/>
</mask>
</defs>
)}
{showRows && (
<g mask={fadeRows ? `url(#${maskId})` : undefined}>
<GridRows
numTicks={numTicksRows}
scale={yScale}
stroke={stroke}
strokeDasharray={strokeDasharray}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
width={width}
/>
</g>
)}
{showColumns && (
<GridColumns
height={height}
numTicks={numTicksColumns}
scale={xScale}
stroke={stroke}
strokeDasharray={strokeDasharray}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
/>
)}
</g>
);
}
export default ChartGrid;

View File

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
clipRevealTransition,
DEFAULT_CHART_ENTER_TRANSITION,
} from "../animation";
describe("clipRevealTransition", () => {
it("preserves explicit tween ease", () => {
const tween = {
type: "tween" as const,
duration: 0.9,
ease: [0.1, 0.2, 0.3, 0.4] as [number, number, number, number],
};
assert.deepEqual(clipRevealTransition(tween), tween);
});
it("applies default ease when tween omits ease", () => {
const result = clipRevealTransition({
type: "tween",
duration: 0.9,
});
assert.equal(result.type, "tween");
assert.equal(result.duration, 0.9);
assert.deepEqual(result.ease, DEFAULT_CHART_ENTER_TRANSITION.ease);
});
it("converts spring to tween for svg width reveal", () => {
const result = clipRevealTransition({
type: "spring",
duration: 1.2,
bounce: 0.5,
});
assert.equal(result.type, "tween");
assert.equal(result.duration, 1.2);
assert.deepEqual(result.ease, DEFAULT_CHART_ENTER_TRANSITION.ease);
});
});

View File

@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
BAR_DEPTH_MAX_PX,
BAR_DEPTH_PERSPECTIVE_RATIO,
barDepthAndRise,
barDepthMaxDepth,
} from "../bar-depth-geometry";
describe("barDepthMaxDepth", () => {
it("caps at BAR_DEPTH_MAX_PX when the gap is wide", () => {
// step 100, band 90 → gap 10, gap-1=9; band*0.22=19.8 → min(19.8, 9, 7) = 7
assert.equal(barDepthMaxDepth(100, 90), BAR_DEPTH_MAX_PX);
});
it("is bounded by the inter-bar gap on dense charts", () => {
// step 50, band 48 → gap 2, gap-1=1; band*0.22=10.56 → min(10.56, 1, 7) = 1
assert.equal(barDepthMaxDepth(50, 48), 1);
});
it("is 0 when there is no gap between bars", () => {
assert.equal(barDepthMaxDepth(50, 50), 0);
});
});
describe("barDepthAndRise", () => {
it("returns no depth for a dead-center bar (absOffset 0)", () => {
assert.deepEqual(barDepthAndRise(0, 100, 7), {
depth: 0,
perspectiveRise: 0,
});
});
it("scales depth with offset, capped by maxDepth at the edge", () => {
assert.equal(barDepthAndRise(1, 100, 7).depth, 7);
assert.equal(barDepthAndRise(0.5, 100, 7).depth, 3.5);
});
it("caps depth by the bar height so short bars stay proportional", () => {
// naturalHeight 4 < maxDepth 7 → depth capped at 4
assert.equal(barDepthAndRise(1, 4, 7).depth, 4);
});
it("derives the rise from depth via the perspective ratio", () => {
const { depth, perspectiveRise } = barDepthAndRise(0.5, 100, 7);
assert.equal(perspectiveRise, depth * BAR_DEPTH_PERSPECTIVE_RATIO);
});
it("clamps out-of-range inputs defensively", () => {
assert.equal(barDepthAndRise(2, 100, 7).depth, 7); // absOffset clamped to 1
assert.equal(barDepthAndRise(-1, 100, 7).depth, 0); // clamped to 0
assert.equal(barDepthAndRise(1, -5, 7).depth, 0); // negative height → 0
});
});

View File

@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
hmsTimeFmt,
intFmt,
shortDateFmt,
weekdayDateFmt,
} from "../chart-formatters";
const sampleDates = [
new Date(2025, 0, 5, 9, 8, 7),
new Date(2024, 11, 31, 23, 59, 59),
new Date(2026, 6, 4, 12, 0, 0),
];
const sampleNumbers = [0, 42, 1234, 1_234_567, -9876.5];
describe("chart-formatters", () => {
describe("shortDateFmt", () => {
for (const date of sampleDates) {
it(`matches toLocaleDateString for ${date.toISOString()}`, () => {
assert.equal(
shortDateFmt.format(date),
date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
);
});
}
});
describe("weekdayDateFmt", () => {
for (const date of sampleDates) {
it(`matches toLocaleDateString for ${date.toISOString()}`, () => {
assert.equal(
weekdayDateFmt.format(date),
date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
);
});
}
});
describe("hmsTimeFmt", () => {
for (const date of sampleDates) {
it(`matches toLocaleTimeString for ${date.toISOString()}`, () => {
assert.equal(
hmsTimeFmt.format(date),
date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
);
});
}
});
describe("intFmt", () => {
for (const value of sampleNumbers) {
it(`matches toLocaleString for ${value}`, () => {
assert.equal(intFmt(value), value.toLocaleString("en-US"));
});
}
it("is a reusable formatter function", () => {
const formatValue = intFmt;
assert.equal(formatValue(1000), (1000).toLocaleString("en-US"));
});
});
});

View File

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
decimateOhlcData,
decimateTimeSeries,
maxRenderPointsForWidth,
} from "../decimate-time-series";
describe("decimateTimeSeries", () => {
it("returns the original array when under the point budget", () => {
const data = [{ v: 1 }, { v: 2 }, { v: 3 }];
assert.equal(decimateTimeSeries(data, 10), data);
});
it("always keeps the first and last points", () => {
const data = Array.from({ length: 100 }, (_, i) => ({ v: i }));
const sampled = decimateTimeSeries(data, 20, ["v"]);
assert.equal(sampled[0]?.v, 0);
assert.equal(sampled.at(-1)?.v, 99);
assert.equal(sampled.length, 20);
});
it("preserves spikes in the series", () => {
const data = Array.from({ length: 50 }, (_, i) => ({
v: i === 25 ? 1000 : i,
}));
const sampled = decimateTimeSeries(data, 10, ["v"]);
assert(sampled.some((point) => point.v === 1000));
});
});
describe("decimateOhlcData", () => {
it("preserves bucket high/low extremes", () => {
const data = Array.from({ length: 100 }, (_, i) => ({
date: new Date(i),
open: i,
high: i === 50 ? 999 : i + 5,
low: i === 50 ? 1 : i - 5,
close: i + 1,
}));
const sampled = decimateOhlcData(data, 10);
assert(sampled.some((row) => row.high === 999));
assert(sampled.some((row) => row.low === 1));
assert.equal(sampled.length, 10);
});
});
describe("maxRenderPointsForWidth", () => {
it("returns at least 64 points", () => {
assert.equal(maxRenderPointsForWidth(10), 64);
});
it("scales with chart width", () => {
assert.equal(maxRenderPointsForWidth(400), 600);
});
});

View File

@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { computeSegmentBounds } from "../highlight-segment-bounds";
// Data points at t = 0,10,20,30 ms; a linear pixel xScale (1px per ms) keeps the
// arithmetic obvious — the band is the pixel x-range one data point either side.
const data = [{ t: 0 }, { t: 10 }, { t: 20 }, { t: 30 }];
const xAccessor = (d: Record<string, unknown>) => new Date(d.t as number);
const xScale = (value: Date) => value.getTime();
describe("computeSegmentBounds", () => {
it("is inactive for empty data", () => {
assert.deepEqual(
computeSegmentBounds([], xScale, xAccessor, { index: 1 }, null),
{ x: 0, width: 0, isActive: false }
);
});
it("is inactive with no hover and no selection", () => {
assert.equal(
computeSegmentBounds(data, xScale, xAccessor, null, null).isActive,
false
);
});
it("spans one data point either side of the hovered index", () => {
// idx 1 → [t 0, t 20] → x 0, width 20
assert.deepEqual(
computeSegmentBounds(data, xScale, xAccessor, { index: 1 }, null),
{ x: 0, width: 20, isActive: true }
);
});
it("clamps the start at the first index", () => {
// idx 0 → [t 0, t 10] → x 0, width 10
assert.deepEqual(
computeSegmentBounds(data, xScale, xAccessor, { index: 0 }, null),
{ x: 0, width: 10, isActive: true }
);
});
it("clamps the end at the last index", () => {
// idx 3 (last) → [t 20, t 30] → x 20, width 10
assert.deepEqual(
computeSegmentBounds(data, xScale, xAccessor, { index: 3 }, null),
{ x: 20, width: 10, isActive: true }
);
});
it("uses the dragged pixel range for an active selection (takes priority)", () => {
const bounds = computeSegmentBounds(
data,
xScale,
xAccessor,
{ index: 1 }, // ignored — selection wins
{ active: true, startX: 5, endX: 25 }
);
assert.deepEqual(bounds, { x: 5, width: 20, isActive: true });
});
it("normalizes a reversed (right-to-left) selection drag", () => {
const bounds = computeSegmentBounds(data, xScale, xAccessor, null, {
active: true,
startX: 25,
endX: 5,
});
assert.deepEqual(bounds, { x: 5, width: 20, isActive: true });
});
});

View File

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { resolveLineLoadingPulseMode } from "../line-loading-pulse";
describe("resolveLineLoadingPulseMode", () => {
it("maps loading phases to pulse modes", () => {
assert.equal(resolveLineLoadingPulseMode("loading"), "loop");
assert.equal(resolveLineLoadingPulseMode("exiting"), "exit");
assert.equal(resolveLineLoadingPulseMode("revealingLoading"), "enter");
});
it("returns null for non-loading phases", () => {
assert.equal(resolveLineLoadingPulseMode("ready"), null);
assert.equal(resolveLineLoadingPulseMode("revealing"), null);
assert.equal(resolveLineLoadingPulseMode("exitingReady"), null);
});
});

View File

@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getSkeletonHeights } from "../loading-sweep";
describe("getSkeletonHeights", () => {
it("returns the requested number of heights", () => {
assert.equal(getSkeletonHeights(12).length, 12);
assert.equal(getSkeletonHeights(0).length, 0);
});
it("is deterministic for the same (count, seed) — SSR-safe", () => {
assert.deepEqual(getSkeletonHeights(8, 3), getSkeletonHeights(8, 3));
});
it("re-rolls when the seed changes", () => {
assert.notDeepEqual(getSkeletonHeights(8, 0), getSkeletonHeights(8, 1));
});
it("stays within the default [20, 80) range", () => {
for (const h of getSkeletonHeights(50, 7)) {
assert.ok(h >= 20 && h < 80, `height ${h} out of default range`);
}
});
it("respects a custom range", () => {
for (const h of getSkeletonHeights(50, 2, 40, 60)) {
assert.ok(h >= 40 && h < 60, `height ${h} out of [40, 60)`);
}
});
});

View File

@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { splitProfitLossSegments } from "../profit-loss-segments";
const xAccessor = (d: Record<string, unknown>) => d.date as Date;
describe("splitProfitLossSegments", () => {
it("returns empty array for no data", () => {
assert.deepEqual(
splitProfitLossSegments({
data: [],
dataKey: "pnl",
xAccessor,
}),
[]
);
});
it("splits at zero crossings with interpolated crossing points", () => {
const data = [
{ date: new Date("2024-01-01"), pnl: 100 },
{ date: new Date("2024-01-02"), pnl: -50 },
{ date: new Date("2024-01-03"), pnl: -20 },
];
const segments = splitProfitLossSegments({
data,
dataKey: "pnl",
xAccessor,
});
assert.equal(segments.length, 2);
assert.equal(segments[0]?.isPositive, true);
assert.equal(segments[1]?.isPositive, false);
assert.equal(segments[0]?.data.at(-1)?.pnl, 0);
assert.equal(segments[1]?.data[0]?.pnl, 0);
});
it("keeps a single segment when values stay on one side of zero", () => {
const data = [
{ date: new Date("2024-01-01"), pnl: 10 },
{ date: new Date("2024-01-02"), pnl: 20 },
];
const segments = splitProfitLossSegments({
data,
dataKey: "pnl",
xAccessor,
});
assert.equal(segments.length, 1);
assert.equal(segments[0]?.isPositive, true);
assert.equal(segments[0]?.data.length, 2);
});
});

View File

@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildHorizontalTangentBezierPath,
buildProjectionPath,
} from "../projection-utils";
const sourceData = Array.from({ length: 6 }, (_, index) => ({
date: new Date(2024, 0, index + 1),
desktop: 100 + index * 10,
}));
describe("buildProjectionPath", () => {
it("extends auto projections from the last point to the horizon", () => {
const path = buildProjectionPath({
sourceData,
seriesKey: "desktop",
mode: "auto",
autoMethod: "linearRegression",
horizonPoints: 3,
});
assert.equal(path.length, 2);
assert.equal(path[0]?.value, 150);
assert.ok((path.at(-1)?.value ?? 0) > 150);
});
it("builds a target projection to an end value", () => {
const path = buildProjectionPath({
sourceData,
seriesKey: "desktop",
mode: "target",
horizonPoints: 3,
endValue: 220,
});
assert.equal(path.length, 2);
assert.equal(path[0]?.value, 150);
assert.equal(path[1]?.value, 220);
});
});
const HORIZONTAL_TANGENT_BEZIER_PATH = /^M 0,100 C 90,100 110,50 200,50$/;
describe("buildHorizontalTangentBezierPath", () => {
it("uses horizontal control points at start and end y", () => {
const path = buildHorizontalTangentBezierPath(0, 100, 200, 50);
assert.match(path, HORIZONTAL_TANGENT_BEZIER_PATH);
});
});

View File

@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
computeReferenceAreaRect,
resolveReferenceDataRange,
} from "../reference-area-geometry";
const innerWidth = 400;
const innerHeight = 200;
const xScale = (date: Date) => date.getTime() / 10;
const yScale = (value: number) => innerHeight - value;
function baseOptions(
overrides: Partial<Parameters<typeof computeReferenceAreaRect>[0]> = {}
) {
return {
innerWidth,
innerHeight,
xScale,
yScale,
...overrides,
};
}
describe("computeReferenceAreaRect", () => {
it("maps a full-width horizontal band between y1 and y2", () => {
const rect = computeReferenceAreaRect(baseOptions({ y1: 40, y2: 80 }));
assert.deepEqual(rect, { x: 0, y: 120, width: 400, height: 40 });
});
it("maps a partial x-range when x1 and x2 are set", () => {
const rect = computeReferenceAreaRect(
baseOptions({
x1: new Date(1000),
x2: new Date(2000),
y1: 50,
y2: 100,
})
);
assert.deepEqual(rect, { x: 100, y: 100, width: 100, height: 50 });
});
it("clamps to the plot when ifOverflow is hidden", () => {
const rect = computeReferenceAreaRect(
baseOptions({
y1: 150,
y2: 250,
ifOverflow: "hidden",
})
);
assert.deepEqual(rect, { x: 0, y: 0, width: 400, height: 50 });
});
it("returns null when discard and partly outside the plot", () => {
const rect = computeReferenceAreaRect(
baseOptions({
y1: 150,
y2: 250,
ifOverflow: "discard",
})
);
assert.equal(rect, null);
});
it("does not clamp when ifOverflow is visible", () => {
const rect = computeReferenceAreaRect(
baseOptions({
y1: 150,
y2: 250,
ifOverflow: "visible",
})
);
assert.deepEqual(rect, { x: 0, y: -50, width: 400, height: 100 });
});
it("returns null for zero plot size", () => {
assert.equal(
computeReferenceAreaRect(
baseOptions({ innerWidth: 0, innerHeight: 200, y1: 10, y2: 20 })
),
null
);
});
});
describe("resolveReferenceDataRange", () => {
it("returns inclusive bounds between y1 and y2", () => {
assert.deepEqual(resolveReferenceDataRange(160, 220, [0, 300]), [160, 220]);
});
it("extends to domain edges when y bounds are omitted", () => {
assert.deepEqual(
resolveReferenceDataRange(undefined, 220, [0, 300]),
[0, 220]
);
assert.deepEqual(
resolveReferenceDataRange(160, undefined, [0, 300]),
[160, 300]
);
});
});

View File

@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
computeSeriesBarRevealClipPadding,
computeSeriesBarWidth,
} from "../series-bar-layout";
describe("computeSeriesBarWidth", () => {
it("caps grouped bar width to the slot", () => {
const width = computeSeriesBarWidth({
innerWidth: 400,
dataLength: 12,
columnWidth: 400 / 11,
seriesCount: 2,
composedBarGap: 4,
});
assert.ok(width >= 2);
assert.ok(width <= 40);
});
});
describe("computeSeriesBarRevealClipPadding", () => {
it("uses half the bar width for a single stacked column", () => {
assert.equal(
computeSeriesBarRevealClipPadding({
barWidth: 24,
seriesCount: 3,
stacked: true,
}),
12
);
});
it("uses half the group width for grouped bars", () => {
assert.equal(
computeSeriesBarRevealClipPadding({
barWidth: 20,
seriesCount: 2,
gap: 4,
}),
22
);
});
});

View File

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
domainsEqual,
isReferenceAreaVisiblePhase,
mergeYDomainRecords,
niceYDomain,
shouldTweenYDomain,
} from "../y-domain-utils";
describe("shouldTweenYDomain", () => {
it("skips tween when both endpoints move less than 2% of span", () => {
assert.equal(shouldTweenYDomain([0, 100], [1, 101]), false);
});
it("tweens when min endpoint shifts enough", () => {
assert.equal(shouldTweenYDomain([0, 100], [5, 100]), true);
});
it("tweens when max endpoint shifts enough", () => {
assert.equal(shouldTweenYDomain([0, 100], [0, 110]), true);
});
});
describe("niceYDomain", () => {
it("expands raw domain to nice tick boundaries", () => {
const [min, max] = niceYDomain([13, 87]);
assert.ok(min <= 13);
assert.ok(max >= 87);
});
});
describe("mergeYDomainRecords", () => {
it("normalizes axis ids and merges maps", () => {
const merged = mergeYDomainRecords({ left: [0, 100] }, { right: [10, 50] });
assert.deepEqual(merged.left, [0, 100]);
assert.deepEqual(merged.right, [10, 50]);
});
});
describe("domainsEqual", () => {
it("returns true when axis domains match", () => {
assert.equal(domainsEqual({ left: [0, 100] }, { left: [0, 100] }), true);
});
it("returns false when any endpoint differs", () => {
assert.equal(domainsEqual({ left: [0, 100] }, { left: [0, 110] }), false);
});
});
describe("isReferenceAreaVisiblePhase", () => {
it("shows reference areas during ready reveal phases", () => {
assert.equal(isReferenceAreaVisiblePhase("ready"), true);
assert.equal(isReferenceAreaVisiblePhase("revealing"), true);
assert.equal(isReferenceAreaVisiblePhase("gridTweenReady"), true);
});
it("hides reference areas during loading and exit phases", () => {
assert.equal(isReferenceAreaVisiblePhase("loading"), false);
assert.equal(isReferenceAreaVisiblePhase("exiting"), false);
assert.equal(isReferenceAreaVisiblePhase("exitingReady"), false);
assert.equal(isReferenceAreaVisiblePhase("gridTweenLoading"), false);
assert.equal(isReferenceAreaVisiblePhase("revealingLoading"), false);
});
});

View File

@ -0,0 +1,36 @@
import type { Transition } from "motion/react";
/** Default clip-reveal easing for cartesian charts. */
export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)";
export const DEFAULT_ANIMATION_DURATION_MS = 1100;
/** Default enter transition — matches the original line chart reveal. */
export const DEFAULT_CHART_ENTER_TRANSITION: Transition = {
type: "tween",
duration: DEFAULT_ANIMATION_DURATION_MS / 1000,
ease: [0.85, 0, 0.15, 1],
};
/**
* Clip-path width reveal must use tween spring does not reliably animate SVG width.
*/
export function clipRevealTransition(enterTransition?: Transition): Transition {
if (enterTransition?.type === "tween") {
return {
...enterTransition,
ease: enterTransition.ease ?? DEFAULT_CHART_ENTER_TRANSITION.ease,
};
}
const duration =
typeof enterTransition?.duration === "number"
? enterTransition.duration
: DEFAULT_ANIMATION_DURATION_MS / 1000;
return {
type: "tween",
duration,
ease: DEFAULT_CHART_ENTER_TRANSITION.ease,
};
}

View File

@ -0,0 +1,116 @@
"use client";
import { curveNatural } from "@visx/curve";
import { useMemo } from "react";
import { Area } from "./area";
import { AreaChart } from "./area-chart";
import type { Margin } from "./chart-context";
import type { LoadingStyle } from "./chart-phase";
import {
DEFAULT_SKELETON_DATA_KEY,
DEFAULT_SKELETON_POINT_COUNT,
generateChartSkeletonData,
} from "./generate-chart-skeleton-data";
import { Grid } from "./grid";
const LOADING_DATA_KEY = DEFAULT_SKELETON_DATA_KEY;
const DEFAULT_LOADING_STROKE = "var(--foreground)";
const DEFAULT_LOADING_GRID_STROKE =
"color-mix(in oklch, var(--chart-grid) 50%, transparent)";
const DEFAULT_LOADING_GRID_SHIMMER_STROKE =
"color-mix(in oklch, var(--foreground) 68%, transparent)";
const DEFAULT_LOADING_STROKE_OPACITY = 0.5;
export interface AreaChartLoadingProps {
/** Chart margins */
margin?: Partial<Margin>;
/** Stroke color for the animated loading segment. */
stroke?: string;
/** Stroke opacity for the animated loading segment. Default: 0.5 */
strokeOpacity?: number;
/** Grid line stroke (color and opacity via color-mix or oklch alpha). */
gridStroke?: string;
/** Shimmer band stroke (color and opacity via color-mix or oklch alpha). */
gridShimmerStroke?: string;
/** Animate a shimmer band across grid lines. Default: true */
gridShimmer?: boolean;
/** Shimmer band width in pixels. Default: 140 */
gridShimmerLength?: number;
/** Shimmer speed multiplier (higher = faster). Default: 1 */
gridShimmerSpeed?: number;
/** Match shimmer loop to the loading line pulse (cycle + inter-loop pause). */
gridShimmerSync?: boolean;
/** Loading animation: `"pulse"` (default traveling pulse) or `"sweep"` (a
* diagonal shimmer across the skeleton area). Default: `"pulse"`. */
loadingStyle?: LoadingStyle;
/** Centered shimmer label text. Default: "Loading" */
label?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
}
export function AreaChartLoading({
margin,
stroke = DEFAULT_LOADING_STROKE,
strokeOpacity = DEFAULT_LOADING_STROKE_OPACITY,
gridStroke = DEFAULT_LOADING_GRID_STROKE,
gridShimmerStroke = DEFAULT_LOADING_GRID_SHIMMER_STROKE,
gridShimmer = true,
gridShimmerLength,
gridShimmerSpeed,
gridShimmerSync = false,
loadingStyle = "pulse",
label = "Loading",
aspectRatio = "2 / 1",
className = "",
}: AreaChartLoadingProps) {
const data = useMemo(
() =>
generateChartSkeletonData({
dataKey: DEFAULT_SKELETON_DATA_KEY,
pointCount: DEFAULT_SKELETON_POINT_COUNT,
}),
[]
);
return (
<AreaChart
animationDuration={0}
aspectRatio={aspectRatio}
className={className}
data={data}
loadingLabel={label}
margin={margin}
status="loading"
>
<Grid
horizontal
shimmer={loadingStyle === "sweep" ? false : gridShimmer}
shimmerLength={gridShimmerLength}
shimmerSpeed={gridShimmerSpeed}
shimmerStroke={gridShimmerStroke}
shimmerSync={gridShimmerSync}
stroke={gridStroke}
/>
<Area
curve={curveNatural}
dataKey={LOADING_DATA_KEY}
fadeEdges={false}
fill="transparent"
fillOpacity={0}
loading
loadingStroke={stroke}
loadingStrokeOpacity={strokeOpacity}
loadingStyle={loadingStyle}
showHighlight={false}
showLine
stroke="transparent"
strokeWidth={2}
/>
</AreaChart>
);
}
export default AreaChartLoading;

View File

@ -0,0 +1,272 @@
"use client";
import { ParentSize } from "@visx/responsive";
import type { Transition } from "motion/react";
import {
Children,
type CSSProperties,
isValidElement,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { Area, type AreaProps } from "./area";
import type { LineConfig, Margin } from "./chart-context";
import { ChartLoadingLabel } from "./chart-loading-label";
import {
type ChartPhase,
type ChartStatus,
DEFAULT_CHART_STATUS,
DEFAULT_Y_DOMAIN_TWEEN_MS,
resolveRestingChartPhase,
} from "./chart-phase";
import { PatternArea } from "./pattern-area";
import { TimeSeriesChartInner } from "./time-series-chart-shell";
export interface AreaChartProps {
/** Data array - each item should have a date field and numeric values */
data: Record<string, unknown>[];
/** Key in data for the x-axis (date). Default: "date" */
xDataKey?: string;
/** Chart margins */
margin?: Partial<Margin>;
/** Animation duration in milliseconds. Default: 1100 */
animationDuration?: number;
/** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */
animationEasing?: string;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers reveal replay when it changes. */
revealSignature?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Loading vs ready — drives chart phase and loading chrome. Default: `"ready"`. */
status?: ChartStatus;
/** Centered shimmer label while loading. */
loadingLabel?: string;
/** Animate y-domain over this duration (ms) on status transitions. Default: 500. */
yDomainTweenDuration?: number;
/** Animate y-domain when status or target domain changes. Default: true */
yDomainTween?: boolean;
/** Visible x-domain for brush zoom. */
xDomain?: [Date, Date];
/** Full dataset length for x-scale padding when `xDomain` is set. */
xDomainSlotCount?: number;
/** Tween y-domain when brush changes the visible x-range. Default: false */
tweenYDomainOnXDomainChange?: boolean;
/** Inline container styles (e.g. fixed height for brush strip). */
style?: CSSProperties;
/** Fires when the internal chart phase changes (e.g. OG capture readiness). */
onPhaseChange?: (phase: ChartPhase) => void;
/** Child components (Area, Grid, ChartTooltip, etc.) */
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
function extractAreaConfigs(children: ReactNode): LineConfig[] {
const configs: LineConfig[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
const childType = child.type as {
displayName?: string;
name?: string;
};
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
const props = child.props as AreaProps | undefined;
const isPatternArea =
componentName === "PatternArea" || child.type === PatternArea;
const isAreaComponent =
componentName === "Area" ||
child.type === Area ||
(props &&
typeof props.dataKey === "string" &&
props.dataKey.length > 0 &&
!isPatternArea);
if (isAreaComponent && props?.dataKey) {
configs.push({
dataKey: props.dataKey,
stroke: props.stroke || props.fill || "var(--chart-line-primary)",
strokeWidth: props.strokeWidth || 2,
yAxisId: props.yAxisId,
});
}
});
return configs;
}
interface ChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
animationEasing?: string;
enterTransition?: Transition;
revealSignature?: string;
chartStatus: ChartStatus;
loadingLabel?: string;
yDomainTweenDuration: number;
yDomainTween: boolean;
xDomain?: [Date, Date];
xDomainSlotCount?: number;
tweenYDomainOnXDomainChange?: boolean;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
onPhaseChange: (phase: ChartPhase) => void;
}
function ChartInner({
width,
height,
data,
xDataKey,
margin,
animationDuration,
animationEasing,
enterTransition,
revealSignature,
chartStatus,
loadingLabel,
yDomainTweenDuration,
yDomainTween,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange,
children,
containerRef,
onPhaseChange,
}: ChartInnerProps) {
const lines = useMemo(() => extractAreaConfigs(children), [children]);
return (
<TimeSeriesChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
chartStatus={chartStatus}
clipPathId="chart-area-grow-clip"
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
lines={lines}
loadingLabel={loadingLabel}
margin={margin}
onPhaseChange={onPhaseChange}
revealSignature={revealSignature}
tweenYDomainOnXDomainChange={tweenYDomainOnXDomainChange}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
{children}
</TimeSeriesChartInner>
);
}
export function AreaChart({
data,
xDataKey = "date",
margin: marginProp,
animationDuration = 1100,
animationEasing,
enterTransition,
revealSignature,
aspectRatio = "2 / 1",
className = "",
status = DEFAULT_CHART_STATUS,
loadingLabel,
yDomainTweenDuration = DEFAULT_Y_DOMAIN_TWEEN_MS,
yDomainTween = true,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange = false,
style,
onPhaseChange,
children,
}: AreaChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
const [chartPhase, setChartPhase] = useState<ChartPhase>(() =>
resolveRestingChartPhase(status)
);
const handlePhaseChange = useCallback(
(phase: ChartPhase) => {
setChartPhase(phase);
onPhaseChange?.(phase);
},
[onPhaseChange]
);
const showLoadingLabel = Boolean(
loadingLabel?.trim() &&
(chartPhase === "loading" ||
chartPhase === "exiting" ||
chartPhase === "gridTweenReady" ||
chartPhase === "revealingLoading")
);
return (
<div
className={cn("relative w-full", className)}
ref={containerRef}
style={{ aspectRatio, touchAction: "none", ...style }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
chartStatus={status}
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
loadingLabel={loadingLabel}
margin={margin}
onPhaseChange={handlePhaseChange}
revealSignature={revealSignature}
tweenYDomainOnXDomainChange={tweenYDomainOnXDomainChange}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
{children}
</ChartInner>
)}
</ParentSize>
{showLoadingLabel ? (
<ChartLoadingLabel
exiting={chartPhase !== "loading"}
text={loadingLabel}
/>
) : null}
</div>
);
}
export { Area, type AreaProps } from "./area";
export default AreaChart;

View File

@ -0,0 +1,113 @@
import {
type FadeEdges,
fadeGradientStops,
resolveFadeSides,
viewportFadeGradientAttrs,
} from "./fade-edges";
interface AreaGradientDefsProps {
gradientId: string;
strokeGradientId: string;
edgeMaskId: string;
edgeGradientId: string;
fill: string;
fillOpacity: number;
gradientToOpacity: number;
/** 01: where the bottom stop sits (1 = full-height gradient). */
gradientSpan?: number;
resolvedStroke: string;
isPatternFill: boolean;
fadeEdges: FadeEdges;
innerWidth: number;
innerHeight: number;
}
export function AreaGradientDefs({
gradientId,
strokeGradientId,
edgeMaskId,
edgeGradientId,
fill,
fillOpacity,
gradientToOpacity,
gradientSpan = 1,
resolvedStroke,
isPatternFill,
fadeEdges,
innerWidth,
innerHeight,
}: AreaGradientDefsProps) {
const sides = resolveFadeSides(fadeEdges);
// Stroke gradient mirrors the area's edge fade so the line doesn't pop in
// past the faded fill. Skip emitting it when neither edge fades — the line
// can then paint a solid stroke instead of an unnecessary url(#...) ref.
const strokeStops = sides.any ? fadeGradientStops(sides) : null;
const showEdgeMask = sides.any && !isPatternFill;
const edgeStops = showEdgeMask ? fadeGradientStops(sides) : null;
const span = Math.min(1, Math.max(0.01, gradientSpan));
const midOffset = `${span * 100}%`;
return (
<defs>
{isPatternFill ? null : (
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: fill, stopOpacity: fillOpacity }}
/>
<stop
offset={midOffset}
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
{span < 1 ? (
<stop
offset="100%"
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
) : null}
</linearGradient>
)}
{strokeStops ? (
<linearGradient
id={strokeGradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{strokeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: resolvedStroke, stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
) : null}
{edgeStops ? (
<>
<linearGradient
id={edgeGradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{edgeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: "white", stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
<mask id={edgeMaskId}>
<rect
fill={`url(#${edgeGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</>
) : null}
</defs>
);
}

View File

@ -0,0 +1,377 @@
"use client";
import { curveMonotoneX } from "@visx/curve";
import { AreaClosed, LinePath } from "@visx/shape";
// CurveFactory type - simplified version compatible with visx
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AreaGradientDefs } from "./area-gradient-defs";
import { chartCssVars, useChartStable, useYScale } from "./chart-context";
import type { ChartPhase, LoadingStyle } from "./chart-phase";
import { type FadeEdges, resolveFadeSides } from "./fade-edges";
import {
type LineLoadingPulseMode,
LineLoadingPulseStroke,
resolveLineLoadingPulseMode,
} from "./line-loading-pulse";
import { LINE_LOADING_LOOP_PAUSE_MS } from "./line-loading-timing";
import { LineLoadingSweep } from "./loading-sweep";
import {
resolveDashTailBounds,
usePathStrokeMetrics,
} from "./path-stroke-utils";
import { SeriesDashTailOverlay } from "./series-dash-tail-overlay";
import { SeriesHighlightLayer } from "./series-highlight-layer";
import { SeriesHoverDim } from "./series-hover-dim";
import { SeriesMarkers } from "./series-markers";
import type { SeriesPointMarkerStyle } from "./series-point-marker";
export interface AreaProps {
/** Key in data to use for y values */
dataKey: string;
/** Y-scale group id (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
/** Fill color for the area gradient start. Default: var(--chart-line-primary) */
fill?: string;
/** Fill opacity at the top of the area. Default: 0.4 */
fillOpacity?: number;
/** Stroke color for the line. Default: same as fill */
stroke?: string;
/** Stroke width. Default: 2 */
strokeWidth?: number;
/** Curve function. Default: curveMonotoneX */
curve?: CurveFactory;
/** Whether to animate the area. Default: true */
animate?: boolean;
/** Whether to show the stroke line. Default: true */
showLine?: boolean;
/** Whether to show highlight segment on hover. Default: true */
showHighlight?: boolean;
/** Gradient opacity at bottom (0 = fully transparent). Default: 0 */
gradientToOpacity?: number;
/**
* Vertical extent of the fill gradient (01). `1` fades across the full
* height; lower values compress the gradient toward the top.
*/
gradientSpan?: number;
/**
* Fade the area fill (and stroke) toward transparent at the chart edges.
* - `true` fades both edges, `false` disables the fade entirely.
* - `"left"` / `"right"` fades only that side useful when the opposite
* edge butts up against another element you don't want to fade into.
* Default: false
*/
fadeEdges?: FadeEdges;
/** Render scatter-style circle markers at each data point. Default: false */
showMarkers?: boolean;
/** Marker styling (same options as Scatter). */
markers?: SeriesPointMarkerStyle;
/**
* Data index from which the line stroke becomes dashed (inclusive).
* Useful for projecting incomplete periods, e.g. dashed from yesterday through today.
*/
dashFromIndex?: number;
/** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */
dashArray?: string;
/** Pulse stroke color while chart is loading. Default: var(--foreground) */
loadingStroke?: string;
/** Pulse stroke opacity while chart is loading. Default: 0.5 */
loadingStrokeOpacity?: number;
/**
* Show the loading pulse overlay. Default: follows chart loading phase.
* Set `false` to disable even during loading.
*/
loading?: boolean;
/** Override pulse animation mode (loop / exit / enter). */
loadingPulseMode?: LineLoadingPulseMode;
/**
* Loading animation while the chart is in loading status: the default
* traveling `"pulse"`, or a diagonal `"sweep"` shimmer across the skeleton
* area. Default: `"pulse"`.
*/
loadingStyle?: LoadingStyle;
}
function useAreaLoadingPulseState(
chartPhase: ChartPhase,
loading: boolean | undefined,
loadingPulseMode: LineLoadingPulseMode | undefined,
notifyLoadingPulseComplete?: () => void
) {
const phasePulseMode = resolveLineLoadingPulseMode(chartPhase);
const pulseMode =
loading === false
? null
: (loadingPulseMode ?? (loading === true ? "loop" : phasePulseMode));
const showLoadingPulse = pulseMode != null;
const showSeriesContent =
chartPhase === "revealing" ||
chartPhase === "ready" ||
chartPhase === "exitingReady";
const [pulseEpoch, setPulseEpoch] = useState(0);
const handleLoadingPulseComplete = useCallback(() => {
if (pulseMode === "loop") {
window.setTimeout(() => {
setPulseEpoch((epoch) => epoch + 1);
}, LINE_LOADING_LOOP_PAUSE_MS);
return;
}
notifyLoadingPulseComplete?.();
}, [notifyLoadingPulseComplete, pulseMode]);
return {
handleLoadingPulseComplete,
pulseMode,
pulseEpoch,
showLoadingPulse,
showSeriesContent,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: mirrors Line series layout (fill, stroke, dash, markers, pulse)
export function Area({
dataKey,
yAxisId,
fill = chartCssVars.linePrimary,
fillOpacity = 0.4,
stroke,
strokeWidth = 2,
curve = curveMonotoneX,
animate = true,
showLine = true,
showHighlight = true,
gradientToOpacity = 0,
gradientSpan = 1,
fadeEdges = false,
showMarkers = false,
markers,
dashFromIndex,
dashArray = "6,4",
loading,
loadingStroke = chartCssVars.foreground,
loadingStrokeOpacity = 0.5,
loadingPulseMode,
loadingStyle = "pulse",
}: AreaProps) {
// Stable slice only: hover state lives inside `<SeriesHoverDim>` and
// `<SeriesHighlightLayer>` so this component (and its expensive
// <SeriesDashTailOverlay> child) does not re-render on cursor motion.
// The reveal-clip is now a single shared clipPath at the chart-shell
// level (`time-series-chart-shell.tsx`); we no longer render a per-area
// `<ChartRevealClip>` or read `revealEpoch` here.
const {
data,
renderData,
xScale,
innerHeight,
innerWidth,
xAccessor,
lines,
chartPhase,
notifyLoadingPulseComplete,
} = useChartStable();
const yScale = useYScale(yAxisId);
const {
handleLoadingPulseComplete,
pulseMode,
pulseEpoch,
showLoadingPulse,
showSeriesContent,
} = useAreaLoadingPulseState(
chartPhase,
loading,
loadingPulseMode,
notifyLoadingPulseComplete
);
const seriesIndex = useMemo(() => {
const index = lines.findIndex((line) => line.dataKey === dataKey);
return index >= 0 ? index : 0;
}, [lines, dataKey]);
const pathRef = useRef<SVGPathElement>(null);
const { pathLength, pathD } = usePathStrokeMetrics(pathRef, [
renderData,
innerWidth,
dashFromIndex,
showLine,
showSeriesContent,
showLoadingPulse,
]);
// Unique IDs for this area
const uniqueId = useId();
const gradientId = `area-gradient-${dataKey}-${uniqueId}`;
const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`;
const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`;
const edgeGradientId = `${edgeMaskId}-gradient`;
const isPatternFill = fill.startsWith("url(");
const showAreaFill = isPatternFill || fillOpacity > 0;
const areaFill = isPatternFill ? fill : `url(#${gradientId})`;
// Resolved stroke color (defaults to fill; pattern URLs need a real color)
const resolvedStroke =
stroke || (isPatternFill ? chartCssVars.linePrimary : fill);
const getY = useCallback(
(d: Record<string, unknown>) => {
const value = d[dataKey];
return typeof value === "number" ? (yScale(value) ?? 0) : 0;
},
[dataKey, yScale]
);
const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length);
// The stroke gradient is only emitted when at least one edge fades, so fall
// back to the resolved solid color otherwise — avoids an invalid url(#...).
const fadeSides = resolveFadeSides(fadeEdges);
const useViewportEdgeFade = fadeSides.any && !isPatternFill;
let strokePaint = resolvedStroke;
if (!useViewportEdgeFade && fadeSides.any) {
strokePaint = `url(#${strokeGradientId})`;
}
const highlightEnabled =
showHighlight && showLine && !showLoadingPulse && showSeriesContent;
const showSeriesStroke = showSeriesContent && showLine;
let visibleStroke = "transparent";
if (showSeriesStroke && !hasDashTail) {
visibleStroke = strokePaint;
}
const shouldMeasurePath = showLine && (showSeriesContent || showLoadingPulse);
const seriesLayers = (
<>
{showSeriesContent && showAreaFill ? (
<AreaClosed
curve={curve}
data={renderData}
fill={areaFill}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
yScale={yScale}
/>
) : null}
{shouldMeasurePath ? (
<>
<LinePath
curve={curve}
data={renderData}
innerRef={pathRef}
stroke={visibleStroke}
strokeLinecap="round"
strokeWidth={strokeWidth}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
/>
{showSeriesStroke ? (
<SeriesDashTailOverlay
dashArray={dashArray}
dashFromIndex={dashFromIndex}
data={data}
innerHeight={innerHeight}
innerWidth={innerWidth}
pathD={pathD}
pathLength={pathLength}
stroke={strokePaint}
strokeWidth={strokeWidth}
xAccessor={xAccessor}
xScale={xScale}
/>
) : null}
</>
) : null}
</>
);
// Sweep style owns all loading modes (loop + the exit/enter transitions),
// drawing its own silhouette; the pulse covers the default style.
const sweepLoading =
showLoadingPulse && innerWidth > 0 && loadingStyle === "sweep";
const pulseLoading = showLoadingPulse && innerWidth > 0 && !sweepLoading;
return (
<>
<AreaGradientDefs
edgeGradientId={edgeGradientId}
edgeMaskId={edgeMaskId}
fadeEdges={fadeEdges}
fill={fill}
fillOpacity={fillOpacity}
gradientId={gradientId}
gradientSpan={gradientSpan}
gradientToOpacity={gradientToOpacity}
innerHeight={innerHeight}
innerWidth={innerWidth}
isPatternFill={isPatternFill}
resolvedStroke={resolvedStroke}
strokeGradientId={strokeGradientId}
/>
<SeriesHoverDim
dimOpacity={0.6}
enabled={showHighlight}
seriesIndex={seriesIndex}
>
{useViewportEdgeFade ? (
<g mask={`url(#${edgeMaskId})`}>{seriesLayers}</g>
) : (
seriesLayers
)}
</SeriesHoverDim>
{/* Highlight segment on hover — isolated hover subscriber. */}
<SeriesHighlightLayer
enabled={highlightEnabled}
height={innerHeight}
pathRef={pathRef}
stroke={resolvedStroke}
strokeWidth={strokeWidth}
/>
{showMarkers && showSeriesContent ? (
<SeriesMarkers
animate={animate}
dataKey={dataKey}
{...markers}
fill={markers?.fill ?? resolvedStroke}
stroke={markers?.stroke ?? markers?.fill ?? resolvedStroke}
/>
) : null}
{sweepLoading ? (
<LineLoadingSweep
curve={curve}
key="loading-sweep"
mode={pulseMode ?? "loop"}
onTransitionComplete={handleLoadingPulseComplete}
stroke={loadingStroke}
strokeOpacity={loadingStrokeOpacity}
strokeWidth={strokeWidth}
withArea
/>
) : null}
{pulseLoading && pathD ? (
<LineLoadingPulseStroke
key="loading-pulse"
loopEpoch={pulseEpoch}
mode={pulseMode ?? undefined}
onCycleComplete={handleLoadingPulseComplete}
pathD={pathD}
stroke={loadingStroke}
strokeOpacity={loadingStrokeOpacity}
strokeWidth={strokeWidth}
/>
) : null}
</>
);
}
Area.displayName = "Area";
export default Area;

View File

@ -0,0 +1,219 @@
"use client";
import { motion } from "motion/react";
import { useId, useMemo } from "react";
import { useChartStable } from "./chart-context";
import {
type PatternPresetId,
type PatternPresetOptions,
renderPatternPreset,
} from "./pattern-preset";
export type BackgroundPatternPreset = PatternPresetId;
export interface BackgroundProps extends PatternPresetOptions {
/** Pattern preset. `"none"` renders nothing. */
pattern?: BackgroundPatternPreset;
/** Pattern stroke color. Default: `var(--chart-grid)` */
color?: string;
/** Apply the pattern texture to the plot area. Default: true */
showFill?: boolean;
/** Pattern fill opacity. Default: 1 */
opacity?: number;
/** Fade pattern at the left and right chart edges. Default: true */
fadeHorizontal?: boolean;
/** Fade pattern at the top and bottom chart edges. Default: true */
fadeVertical?: boolean;
/** Horizontal fade zone as % of plot width per edge. Default: 10 */
fadeHorizontalLength?: number;
/** Vertical fade zone as % of plot height per edge. Default: 10 */
fadeVerticalLength?: number;
}
const BACKGROUND_ENTER_FADE_MS = 420;
function clampFadeLength(length: number): number {
return Math.min(45, Math.max(0, length));
}
function fadeMaskStops(lengthPercent: number): Array<{
offset: string;
opacity: number;
}> {
const edge = clampFadeLength(lengthPercent);
return [
{ offset: "0%", opacity: 0 },
{ offset: `${edge}%`, opacity: 1 },
{ offset: `${100 - edge}%`, opacity: 1 },
{ offset: "100%", opacity: 0 },
];
}
/** Plot-area pattern fill for charts without a grid. Renders behind series layers. */
export function Background({
pattern = "diagonal",
color = "var(--chart-grid)",
scale = 1,
strokeWidth,
radius,
complement,
fill,
dotFill,
tileBackground,
showFill = true,
opacity = 1,
fadeHorizontal = true,
fadeVertical = true,
fadeHorizontalLength = 10,
fadeVerticalLength = 10,
}: BackgroundProps) {
const { innerWidth, innerHeight, isLoaded, enterTransition } =
useChartStable();
const uniqueId = useId();
const patternId = `chart-background-${uniqueId.replace(/:/g, "")}`;
const hStops = useMemo(
() => fadeMaskStops(fadeHorizontalLength),
[fadeHorizontalLength]
);
const vStops = useMemo(
() => fadeMaskStops(fadeVerticalLength),
[fadeVerticalLength]
);
if (pattern === "none" || !showFill || innerWidth <= 0 || innerHeight <= 0) {
return null;
}
const patternNode = renderPatternPreset(pattern, patternId, {
color,
scale,
strokeWidth,
radius,
complement,
fill,
dotFill,
tileBackground,
});
if (!patternNode) {
return null;
}
const fadeMask = fadeHorizontal || fadeVertical;
const hMaskId = `chart-background-fade-h-${uniqueId.replace(/:/g, "")}`;
const hGradientId = `${hMaskId}-gradient`;
const vMaskId = `chart-background-fade-v-${uniqueId.replace(/:/g, "")}`;
const vGradientId = `${vMaskId}-gradient`;
const combinedMaskId = `chart-background-fade-${uniqueId.replace(/:/g, "")}`;
let maskRef: string | undefined;
if (fadeHorizontal && fadeVertical) {
maskRef = `url(#${combinedMaskId})`;
} else if (fadeHorizontal) {
maskRef = `url(#${hMaskId})`;
} else if (fadeVertical) {
maskRef = `url(#${vMaskId})`;
}
const targetOpacity = opacity;
const revealOpacity = isLoaded ? targetOpacity : 0;
const enterFade =
enterTransition && typeof enterTransition === "object"
? enterTransition
: { duration: BACKGROUND_ENTER_FADE_MS / 1000, ease: "easeOut" as const };
return (
// biome-ignore lint/a11y/noAriaHiddenOnFocusable: decorative plot-area pattern layer
<g aria-hidden="true" className="chart-background">
{fadeMask ? (
<defs>
{fadeHorizontal ? (
<>
<linearGradient
id={hGradientId}
x1="0%"
x2="100%"
y1="0%"
y2="0%"
>
{hStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: "white", stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
<mask id={hMaskId}>
<rect
fill={`url(#${hGradientId})`}
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
</mask>
</>
) : null}
{fadeVertical ? (
<>
<linearGradient
id={vGradientId}
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
{vStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: "white", stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
<mask id={vMaskId}>
<rect
fill={`url(#${vGradientId})`}
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
</mask>
</>
) : null}
{fadeHorizontal && fadeVertical ? (
<mask id={combinedMaskId}>
<g mask={`url(#${hMaskId})`}>
<rect
fill={`url(#${vGradientId})`}
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
</g>
</mask>
) : null}
</defs>
) : null}
<defs>{patternNode}</defs>
<motion.rect
animate={{ opacity: revealOpacity }}
fill={`url(#${patternId})`}
height={innerHeight}
initial={{ opacity: 0 }}
mask={maskRef}
transition={isLoaded ? enterFade : { duration: 0 }}
width={innerWidth}
x={0}
y={0}
/>
</g>
);
}
Background.displayName = "Background";
export default Background;

View File

@ -0,0 +1,38 @@
"use client";
import { BarChart } from "./bar-chart";
import type { Margin } from "./chart-context";
const EMPTY_DATA: Record<string, unknown>[] = [];
export interface BarChartLoadingProps {
/** Chart margins. */
margin?: Partial<Margin>;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container. */
className?: string;
}
/**
* Turnkey loading skeleton for bar charts, a thin shortcut for
* `<BarChart status="loading" />`. Renders shimmer-swept placeholder bars while
* data is fetching; swap in a real `<BarChart>` once it resolves.
*/
export function BarChartLoading({
margin,
aspectRatio = "2 / 1",
className = "",
}: BarChartLoadingProps) {
return (
<BarChart
aspectRatio={aspectRatio}
className={className}
data={EMPTY_DATA}
margin={margin}
status="loading"
/>
);
}
export default BarChartLoading;

Some files were not shown because too many files have changed in this diff Show More