diff --git a/next.config.ts b/next.config.ts index 1a329fe..14884e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,6 +50,26 @@ const nextConfig: NextConfig = { destination: "/docs/sankey-chart/static", permanent: true, }, + { + source: "/docs/scatter-chart", + destination: "/docs/scatter-chart/static", + permanent: true, + }, + { + source: "/docs/treemap-chart", + destination: "/docs/treemap-chart/static", + permanent: true, + }, + { + source: "/docs/funnel-chart", + destination: "/docs/funnel-chart/static", + permanent: true, + }, + { + source: "/docs/waterfall-chart", + destination: "/docs/waterfall-chart/static", + permanent: true, + }, // Some old projects redirects cached on google { source: "/docs/line-charts", diff --git a/registry.json b/registry.json index 9d885e5..c193c84 100644 --- a/registry.json +++ b/registry.json @@ -272,6 +272,95 @@ } ] }, + { + "name": "scatter-chart", + "description": "Scatter chart component for visualizing correlations between two variables", + "registryDependencies": [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/dot", + "@evilcharts/background" + ], + "dependencies": [ + "recharts", + "motion" + ], + "type": "registry:component", + "files": [ + { + "path": "src/registry/charts/scatter-chart.tsx", + "type": "registry:component", + "target": "components/evilcharts/charts/scatter-chart.tsx" + } + ] + }, + { + "name": "treemap-chart", + "description": "Treemap chart component for hierarchical category breakdowns", + "registryDependencies": [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/background" + ], + "dependencies": [ + "recharts", + "motion" + ], + "type": "registry:component", + "files": [ + { + "path": "src/registry/charts/treemap-chart.tsx", + "type": "registry:component", + "target": "components/evilcharts/charts/treemap-chart.tsx" + } + ] + }, + { + "name": "funnel-chart", + "description": "Funnel chart component for conversion and stage drop-off analysis", + "registryDependencies": [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/background" + ], + "dependencies": [ + "recharts", + "motion" + ], + "type": "registry:component", + "files": [ + { + "path": "src/registry/charts/funnel-chart.tsx", + "type": "registry:component", + "target": "components/evilcharts/charts/funnel-chart.tsx" + } + ] + }, + { + "name": "waterfall-chart", + "description": "Waterfall chart component for revenue bridges and variance walkthroughs", + "registryDependencies": [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/background" + ], + "dependencies": [ + "recharts", + "motion" + ], + "type": "registry:component", + "files": [ + { + "path": "src/registry/charts/waterfall-chart.tsx", + "type": "registry:component", + "target": "components/evilcharts/charts/waterfall-chart.tsx" + } + ] + }, { "name": "ex-area-chart", "registryDependencies": [ @@ -1455,6 +1544,175 @@ } ] }, + { + "name": "ex-scatter-chart", + "registryDependencies": [ + "@evilcharts/scatter-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-scatter-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-gradient-colors-scatter-chart", + "registryDependencies": [ + "@evilcharts/scatter-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-gradient-colors-scatter-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-loading-state-scatter-chart", + "registryDependencies": [ + "@evilcharts/scatter-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-loading-state-scatter-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-glowing-scatter-chart", + "registryDependencies": [ + "@evilcharts/scatter-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-glowing-scatter-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-treemap-chart", + "registryDependencies": [ + "@evilcharts/treemap-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-treemap-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-glowing-treemap-chart", + "registryDependencies": [ + "@evilcharts/treemap-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-glowing-treemap-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-loading-state-treemap-chart", + "registryDependencies": [ + "@evilcharts/treemap-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-loading-state-treemap-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-funnel-chart", + "registryDependencies": [ + "@evilcharts/funnel-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-funnel-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-glowing-funnel-chart", + "registryDependencies": [ + "@evilcharts/funnel-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-glowing-funnel-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-loading-state-funnel-chart", + "registryDependencies": [ + "@evilcharts/funnel-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-loading-state-funnel-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-waterfall-chart", + "registryDependencies": [ + "@evilcharts/waterfall-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-waterfall-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-glowing-waterfall-chart", + "registryDependencies": [ + "@evilcharts/waterfall-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-glowing-waterfall-chart.tsx", + "type": "registry:block" + } + ] + }, + { + "name": "ex-loading-state-waterfall-chart", + "registryDependencies": [ + "@evilcharts/waterfall-chart" + ], + "type": "registry:block", + "files": [ + { + "path": "src/registry/examples/ex-loading-state-waterfall-chart.tsx", + "type": "registry:block" + } + ] + }, { "name": "ex-sankey-chart", "registryDependencies": [ diff --git a/src/assets/icons/index.tsx b/src/assets/icons/index.tsx index 4230ef4..1883237 100644 --- a/src/assets/icons/index.tsx +++ b/src/assets/icons/index.tsx @@ -324,6 +324,118 @@ export function SankeyChartIcon({ ); } +export function ScatterChartIcon({ + fill = "currentColor", + secondaryfill, + width = "1em", + height = "1em", + ...props +}: IconProps) { + secondaryfill = secondaryfill || fill; + + return ( + + + + + + + + + + + + ); +} + +export function TreemapChartIcon({ + fill = "currentColor", + secondaryfill, + width = "1em", + height = "1em", + ...props +}: IconProps) { + secondaryfill = secondaryfill || fill; + + return ( + + + + + + + + + + ); +} + +export function FunnelChartIcon({ + fill = "currentColor", + secondaryfill, + width = "1em", + height = "1em", + ...props +}: IconProps) { + secondaryfill = secondaryfill || fill; + + return ( + + + + + + + + ); +} + +export function WaterfallChartIcon({ + fill = "currentColor", + secondaryfill, + width = "1em", + height = "1em", + ...props +}: IconProps) { + secondaryfill = secondaryfill || fill; + + return ( + + + + + + + + + + ); +} + export function ShapesIcon({ fill = "currentColor", secondaryfill, diff --git a/src/components/docs/mdx/components/showcase-grid.tsx b/src/components/docs/mdx/components/showcase-grid.tsx index b6edc1a..94fc5da 100644 --- a/src/components/docs/mdx/components/showcase-grid.tsx +++ b/src/components/docs/mdx/components/showcase-grid.tsx @@ -8,6 +8,10 @@ import { LinePreview } from "@/components/docs/svg-previews/line-preview"; import { AreaPreview } from "@/components/docs/svg-previews/area-preview"; import { PiePreview } from "@/components/docs/svg-previews/pie-preview"; import { BarPreview } from "@/components/docs/svg-previews/bar-preview"; +import { ScatterPreview } from "@/components/docs/svg-previews/scatter-preview"; +import { TreemapPreview } from "@/components/docs/svg-previews/treemap-preview"; +import { FunnelPreview } from "@/components/docs/svg-previews/funnel-preview"; +import { WaterfallPreview } from "@/components/docs/svg-previews/waterfall-preview"; import { Grid } from "@/components/docs/svg-previews/background-grid"; import Link from "next/link"; @@ -67,6 +71,30 @@ const CHARTS: Chart[] = [ Component: SankeyPreview, url: "/docs/sankey-chart", }, + { + name: "Scatter Chart", + description: "Explore correlations between two variables.", + Component: ScatterPreview, + url: "/docs/scatter-chart", + }, + { + name: "Treemap Chart", + description: "Break down categories by weighted area tiles.", + Component: TreemapPreview, + url: "/docs/treemap-chart", + }, + { + name: "Funnel Chart", + description: "Track conversion drop-off across stages.", + Component: FunnelPreview, + url: "/docs/funnel-chart", + }, + { + name: "Waterfall Chart", + description: "Bridge totals with increases and decreases.", + Component: WaterfallPreview, + url: "/docs/waterfall-chart", + }, ]; interface ShowcaseItemProps { diff --git a/src/components/docs/svg-previews/funnel-preview.tsx b/src/components/docs/svg-previews/funnel-preview.tsx new file mode 100644 index 0000000..3678eb5 --- /dev/null +++ b/src/components/docs/svg-previews/funnel-preview.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +export const FunnelPreview = () => ( + + + + + +); diff --git a/src/components/docs/svg-previews/scatter-preview.tsx b/src/components/docs/svg-previews/scatter-preview.tsx new file mode 100644 index 0000000..5422339 --- /dev/null +++ b/src/components/docs/svg-previews/scatter-preview.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +export const ScatterPreview = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/src/components/docs/svg-previews/treemap-preview.tsx b/src/components/docs/svg-previews/treemap-preview.tsx new file mode 100644 index 0000000..5ed0d4c --- /dev/null +++ b/src/components/docs/svg-previews/treemap-preview.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export const TreemapPreview = () => ( + + + + + + + +); diff --git a/src/components/docs/svg-previews/waterfall-preview.tsx b/src/components/docs/svg-previews/waterfall-preview.tsx new file mode 100644 index 0000000..f9967e2 --- /dev/null +++ b/src/components/docs/svg-previews/waterfall-preview.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export const WaterfallPreview = () => ( + + + + + + + +); diff --git a/src/content/docs/bar-chart/static.mdx b/src/content/docs/bar-chart/static.mdx index 4f1835c..19b5902 100644 --- a/src/content/docs/bar-chart/static.mdx +++ b/src/content/docs/bar-chart/static.mdx @@ -8,7 +8,8 @@ links: api: https://recharts.github.io/en-US/api/BarChart/ --- - + + ## Installation @@ -182,10 +183,9 @@ Below are some examples of the bar chart with different `variants`. Each `` ### Horizontal Layout - - Use `layout="horizontal"` on `` to display bars horizontally. In horizontal mode, the `` shows categories while the `` shows values — pass a `tickFormatter` to `` for category formatting. + Use `layout="horizontal"` on `` to display bars horizontally. Compose both `` (values) and `` (categories). See the preview at the top of this page. diff --git a/src/content/docs/funnel-chart/meta.json b/src/content/docs/funnel-chart/meta.json new file mode 100644 index 0000000..53fc185 --- /dev/null +++ b/src/content/docs/funnel-chart/meta.json @@ -0,0 +1 @@ +{ "title": "Funnel Chart", "pages": ["static"] } diff --git a/src/content/docs/funnel-chart/static.mdx b/src/content/docs/funnel-chart/static.mdx new file mode 100644 index 0000000..a4c31ca --- /dev/null +++ b/src/content/docs/funnel-chart/static.mdx @@ -0,0 +1,298 @@ +--- +title: Funnel Chart +description: Stage-based funnel visuals built atop composable EvilCharts primitives +image: /og/funnel-chart.png +links: + github: https://github.com/legions-developer/evilcharts/blob/main/src/registry/charts/funnel-chart.tsx + doc: https://recharts.github.io/en-US/examples/SimpleBarChart/ + api: https://recharts.github.io/en-US/api/BarChart/ +--- + + + +## Installation + + + + CLI + Manual + + + + + + + + Install the following dependencies: + + + + + + Copy and paste the following code snippets into your project. + + To use the chart, first create the folder `evilcharts` and a subfolder called `charts` inside your `components` directory. + Then, copy the following base funnel-chart code into a new file in that folder. + + + + + + + Now, Let's add the chart component to your project. + + These Components are required by the chart component to render the chart. Make a folder called `ui` inside the `evilcharts` folder and paste the code there. + + Below is main chart component. + + + + + + + Now, We need to add sub components. + + Create a file called `tooltip.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + Now, create another file called `legend.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + + + + +## Usage + +The funnel chart is composible. `` wraps a vertically laid out Recharts `` with custom trapezoid shapes per stage (`stageKey`) and measured values (`valueKey`). Compose ``, ``, ``, ``, ``, `` as needed, and set optional `backgroundVariant` on the chart root — Recharts has no standalone funnel primitive, but every native chart prop can pass through `chartProps` alongside the underlying BarChart escape hatch documented below. + +```tsx +import { + EvilFunnelChart, + Stages, + XAxis, + YAxis, + Grid, + Tooltip, + Legend, +} from "@/components/evilcharts/charts/funnel-chart"; +``` + +```tsx + + + + + + + + +``` + +### Interactive Selection + +Add `isClickable` to `` (plus `` when you want swatches clickable) then listen on `onSelectionChange` (`{ dataKey, value } | null`). `dataKey` matches rows’ `stageKey` string: + +```tsx + { + if (selection) { + console.log("Selected:", selection.dataKey, selection.value); + } else { + console.log("Deselected"); + } + }} +> + + + + + + +``` + +### Loading State + + + + + The funnel chart supports loading state. Pass `isLoading` to shimmer placeholder stages synthesized from deterministic loading rows while awaiting real funnel data (tune fictitious heights with `loadingStages`). + + + +## Examples + +### Glowing Stages + + + +## API Reference + +The chart is composed of several parts. The props below are grouped by the component they belong to. + +EvilFunnelChart + +The root container wrapping a vertical ``. Holds stage metadata, synthesized loading rows when `isLoading`, max-value scaling shared by funnel shapes, and selection state surfaced to ``/``. + + + + Keys must correspond to serialized `stageKey` values (`string`) so gradients + legend labels resolve per funnel stage color stop. + + + Rows with `stageKey` + numeric `valueKey` fields typed as literals for inference (`TData extends Record`). + + + Categorical funnel stage identifier surfaced on `` ticks and gradients. + + + Numeric throughput used to scale each trapezoid width plus tooltip payload. + + + ``, ``, ``, ``, ``, ``, plus optional root `backgroundVariant`, and escapes through forwarded Recharts props via `chartProps`. + + + Additional wrapper classes forwarded to ``. + + + Vertical gutter between stacked funnel polygons inside each category band. + + + Draws `` beneath the funnel (same pattern as bar and scatter charts). + + + Pre-select matching `stageKey` string (`null` clears). + + + Fires when clickable legend / stage polygons toggle visibility focus. + + + Swaps rendered rows with motion-shimmer placeholders while preserving axes + tooltip wiring. + + + Count of synthesized placeholder polygons while `isLoading` is active. + + + Native Recharts props forwarded to `` — sizing, responsiveness, syncing, brushes, margins, anything outside `data/layout` defaults we already encode. Refer to Recharts BarChart; there is no dedicated funnel primitive, so this escape hatch doubles as funnel tuning. + + + +Stages + +Configuration slot mirrored by funnel renderer — determines whether polygons listen for clicks / glow defs. + + + + Enables polygon click toggling. Selection dimming applies whenever a stage is selected (legend, default, or click). + + + Names matching `stageKey` values emitting glow filters akin to EvilBars. + + + +XAxis + +Mirrors numeric scale even though EvilFunnel hides ticks by default to keep silhouette clean (`hide` forwarding Recharts semantics). + + + + Default hides axis line / ticks consistent with EvilFunnels marketing aesthetic. + + + Forwarded verbatim to `` while loading states hide axes automatically. + + + Every remaining Recharts `XAxis` prop is forwarded. Read the Recharts XAxis documentation for available props. + + + +YAxis + +Shows ordered funnel stages (category axis for vertical funnel layout). + + + + Default tick gutter width accommodating stage labels before trapezoid math runs. + + + Drives categorical placement for each funnel row (`dataKey={stageKey}` internally). + + + Every remaining Recharts `YAxis` prop is forwarded. Read the Recharts YAxis documentation for available props. + + + +Grid + +Optional vertical-only dashed scaffolding for alignment reference. + + + + Dash pattern forwarded to ``. + + + Horizontal lines stay disabled intentionally; remaining grid props forwarded. Read the Recharts CartesianGrid documentation for nuance. + + + +Tooltip + +Disables Cartesian cursor crosshair (`cursor={false}`) for cleaner funnel hovering. + + + + Tooltip polish tier. + + + Tooltip radius. + + + Pre-highlights tooltip for a deterministic stage row while pointer idle. + + + +Legend + +Stage keyed legend syncing dimming + selection mirrored from ``. + + + + Legend marker motifs. + + + Horizontal alignment. + + + Vertical stacking vs chart canvas. + + + Legend entries toggle shared selection; dimming follows `selectedStage` even when `` is not clickable. + + diff --git a/src/content/docs/meta.json b/src/content/docs/meta.json index bda5cef..9032f63 100644 --- a/src/content/docs/meta.json +++ b/src/content/docs/meta.json @@ -12,6 +12,10 @@ "pie-chart", "radial-chart", "sankey-chart", + "scatter-chart", + "treemap-chart", + "funnel-chart", + "waterfall-chart", "ui/background", "ui/tooltip", "ui/legend", diff --git a/src/content/docs/scatter-chart/meta.json b/src/content/docs/scatter-chart/meta.json new file mode 100644 index 0000000..1bfbc60 --- /dev/null +++ b/src/content/docs/scatter-chart/meta.json @@ -0,0 +1 @@ +{ "title": "Scatter Chart", "pages": ["static"] } diff --git a/src/content/docs/scatter-chart/static.mdx b/src/content/docs/scatter-chart/static.mdx new file mode 100644 index 0000000..5bc0164 --- /dev/null +++ b/src/content/docs/scatter-chart/static.mdx @@ -0,0 +1,303 @@ +--- +title: Scatter Chart +description: Beautiful scatter charts for exploring correlations between two variables +image: /og/scatter-chart.png +links: + github: https://github.com/legions-developer/evilcharts/blob/main/src/registry/charts/scatter-chart.tsx + doc: https://recharts.github.io/en-US/examples/SimpleScatterChart/ + api: https://recharts.github.io/en-US/api/ScatterChart/ +--- + + + +## Installation + + + + CLI + Manual + + + + + + + + Install the following dependencies: + + + + + + Copy and paste the following code snippets into your project. + + To use the chart, first create the folder `evilcharts` and a subfolder called `charts` inside your `components` directory. + Then, copy the following base scatter-chart code into a new file in that folder. + + + + + + + Now, Let's add the chart component to your project. + + These Components are required by the chart component to render the chart. Make a folder called `ui` inside the `evilcharts` folder and paste the code there. + + Below is main chart component. + + + + + + + Now, We need to add sub components. + + Create a file called `tooltip.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + Now, create another file called `legend.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + Finally, create last file called `dot.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + + + + +## Usage + +The scatter chart is composible. `` is the container, and you compose only the parts you need — ``, ``, ``, ``, ``, and one or more `` — as its children. Each `` carries its own `data` array of `{ x, y }` points and can be styled independently with `isGlowing` and `isClickable`. + +```tsx +import { + EvilScatterChart, + Scatter, + XAxis, + YAxis, + Grid, + Tooltip, + Legend, + Dot, + ActiveDot, +} from "@/components/evilcharts/charts/scatter-chart"; +``` + +```tsx + + + + + + + + + + + + + + + +``` + +### Interactive Selection + +Add `isClickable` to any `` (and to ``) to make those series selectable. Use the `onSelectionChange` callback on `` to handle selection events: + +```tsx + { + if (selectedDataKey) { + console.log("Selected:", selectedDataKey); + } else { + console.log("Deselected"); + } + }} +> + + + + + + + +``` + +### Loading State + + + + + The scatter chart supports loading state. Pass `isLoading` to show an animated skeleton while data is being fetched. + + + +## Examples + +### Gradient Colors + + + +### Glowing Points + + + +## API Reference + +The chart is composed of several parts. The props below are grouped by the component they belong to. + +EvilScatterChart + +The root container. It owns the shared selection state and the loading skeleton. Everything visual is composed as its children. + + + + Configuration object that defines the chart's series. Each key should match a ``, with a corresponding color or color array. + + + The composed chart parts — ``, ``, ``, ``, ``, and one or more ``. + + + Additional CSS classes to apply to the chart container. + + + The data key that should be selected by default. + + + Callback fired when a series is selected or deselected — by clicking a clickable `` or `` entry. + + + Shows a loading skeleton animation when data is being fetched. + + + Number of points to display in the loading skeleton. + + + Optional background pattern drawn behind the chart. + + + Additional props forwarded to the underlying Recharts ScatterChart component. Read the Recharts ScatterChart documentation for available props. + + + +Scatter + +A single scatter series. Each `` is self-contained with its own point data and style definitions. + + + + The series key. Must exist in the chart `config`. + + + Array of point objects. Each point should include the keys referenced by `` and `` — typically `{ x, y }`. + + + Lets this series be selected by clicking it. When any series is selected, unselected series become semi-transparent. + + + Applies a soft outer glow to this series' points. + + + Optional `` and `` composition that styles point markers. + + + Escape hatch for raw props forwarded to the underlying Recharts Scatter component. + + + +Dot and ActiveDot + +Point markers composed inside a ``. `` is the resting marker; `` is the hovered marker. + + + + The visual style of the point marker. + + + +XAxis and YAxis + +The value axes. Both default to `type="number"` and forward every Recharts axis prop. They are hidden automatically while the chart is loading. + + + + The data key for the axis values — typically `"x"` or `"y"`. + + + Every other Recharts XAxis / YAxis prop is forwarded as-is. Read the Recharts XAxis and Recharts YAxis documentation for available props. + + + +Grid + +The background grid lines. Defaults to horizontal-only dashed lines and forwards every Recharts CartesianGrid prop. + + + + Every Recharts CartesianGrid prop is forwarded as-is. Read the Recharts CartesianGrid documentation for available props. + + + +Tooltip + +The hover tooltip. It reads the chart's selection state so its content dims unselected series. + + + + The visual style of the tooltip surface. + + + Controls the border-radius of the tooltip. + + + When set, the tooltip is visible by default at the specified data point index. + + + Whether the crosshair cursor follows the pointer on hover. + + + +Legend + +The series legend. When the chart is clickable, each entry toggles selection of its series. + + + + The visual style of the legend indicators. + + + Horizontal placement of the legend. + + + Vertical placement of the legend. + + + Lets each legend entry toggle selection of its series. + + diff --git a/src/content/docs/treemap-chart/meta.json b/src/content/docs/treemap-chart/meta.json new file mode 100644 index 0000000..1c29a6d --- /dev/null +++ b/src/content/docs/treemap-chart/meta.json @@ -0,0 +1 @@ +{ "title": "Treemap Chart", "pages": ["static"] } diff --git a/src/content/docs/treemap-chart/static.mdx b/src/content/docs/treemap-chart/static.mdx new file mode 100644 index 0000000..ba10b26 --- /dev/null +++ b/src/content/docs/treemap-chart/static.mdx @@ -0,0 +1,240 @@ +--- +title: Treemap Chart +description: Hierarchical treemaps sized by value — clear at a glance, beautiful by default +image: /og/treemap-chart.png +links: + github: https://github.com/legions-developer/evilcharts/blob/main/src/registry/charts/treemap-chart.tsx + doc: https://recharts.github.io/en-US/examples/SimpleTreemap/ + api: https://recharts.github.io/en-US/api/Treemap/ +--- + + + +## Installation + + + + CLI + Manual + + + + + + + + Install the following dependencies: + + + + + + Copy and paste the following code snippets into your project. + + To use the chart, first create the folder `evilcharts` and a subfolder called `charts` inside your `components` directory. + Then, copy the following base treemap-chart code into a new file in that folder. + + + + + + + Now, Let's add the chart component to your project. + + These Components are required by the chart component to render the chart. Make a folder called `ui` inside the `evilcharts` folder and paste the code there. + + Below is main chart component. + + + + + + + Now, We need to add sub components. + + Create a file called `tooltip.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + Now, create another file called `legend.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + + + + +## Usage + +The treemap chart is composible. `` is the container: you pass hierarchical or flat tile data (`TreemapNode[]`) plus a `chartConfig` whose keys match each tile’s display name (`nameKey`). Compose `` with behavior flags (`isClickable`, `glowingTiles`, `showLabels`), then `` and ``, optionally with `backgroundVariant` on the root — only the slots you need. + +```tsx +import { + EvilTreemapChart, + Tiles, + Tooltip, + Legend, +} from "@/components/evilcharts/charts/treemap-chart"; +``` + +```tsx + + + + + +``` + +### Interactive Selection + +Add `isClickable` on `` (and on ``) so clicks select a tile. Use `onSelectionChange` on `` — it receives `{ dataKey, value } | null`, where `dataKey` is the tile name from `nameKey` and `value` comes from `dataKey` (defaults to cumulative `size` for nested nodes): + +```tsx + { + if (selection) { + console.log("Selected:", selection.dataKey, selection.value); + } else { + console.log("Deselected"); + } + }} +> + + + + +``` + +### Loading State + + + + + The treemap chart supports loading state. Pass `isLoading` to show an animated skeleton while data is being fetched. + + + +## Examples + +### Glowing Tiles + + + +## API Reference + +The chart is composed of several parts. The props below are grouped by the component they belong to. + +EvilTreemapChart + +The root container. It owns hierarchical data (`TreemapNode[]`), shared tile selection state, loading skeleton behavior, gradient fills, and aspect ratio defaults. Compose visual slots as children. + + + + Configuration keyed by tile name (`nameKey`). Each key drives gradient colors and legend labels for that leaf or series. + + + Nested or flat hierarchical data; each node should expose `name` (or override with `nameKey`) and typically `size` (or override with `dataKey`) plus optional nested `children`. + + + Numeric field sized by each treemap rectangle. + + + String field used as the stable tile identifier and tooltip / legend identity. + + + Composed parts — ``, ``, ``, plus forwarded chart slots from Recharts controlled through `treemapProps`. + + + Additional CSS classes to apply to the chart container. + + + Treemap rectangle aspect ratio forwarded to Recharts ``. + + + Pixel stroke separating adjacent tiles (`stroke` stays tied to `--background`). + + + Draws `` behind the treemap rectangles (same pattern as bar and scatter charts). + + + Pre-selects one tile (`nameKey` value). + + + Fired after a clickable tile / legend toggle — `null` clears selection. + + + Displays the animated mosaic skeleton generated inside the SVG while data resolves. + + + Additional props forwarded to the underlying Recharts Treemap component (`data`, `dataKey`, `content`, gradients, strokes, naming keys, and sizing already owned by EvilTreemap internals). Read the Recharts Treemap documentation for available escape hatches. + + + +Tiles + +A configuration slot consumed by `` — renders nothing alone. Toggle click behavior, glowing filters, and in-tile labels for every rectangle. + + + + When true, tile clicks toggle selection. Dimming applies whenever a tile is selected (legend, default, or click). + + + Tile names (`nameKey` values) rendered with feathered glow filters sourced from defs. + + + Auto labels inside tiles large enough (> 36×20 px guard) sourced from chart config labels when present. + + + +Tooltip + +The hover tooltip. Hidden automatically while the chart is loading; uses `hideLabel`. + + + + Tooltip surface preset. + + + Tooltip border radius. + + + Forces an initial hovered tile index so the tooltip is visible immediately. + + + +Legend + +Tile legend keyed by each `config` series. Mirrors treemap gradients with optional click-to-select syncing with clickable tiles. + + + + Legend marker shape presets. + + + Horizontal legend alignment. + + + Vertical placement relative to the chart. + + + Legend entries toggle shared selection; dimming follows `selectedTile` even when `` is not clickable. + + diff --git a/src/content/docs/waterfall-chart/meta.json b/src/content/docs/waterfall-chart/meta.json new file mode 100644 index 0000000..d2b0d92 --- /dev/null +++ b/src/content/docs/waterfall-chart/meta.json @@ -0,0 +1 @@ +{ "title": "Waterfall Chart", "pages": ["static"] } diff --git a/src/content/docs/waterfall-chart/static.mdx b/src/content/docs/waterfall-chart/static.mdx new file mode 100644 index 0000000..952c53f --- /dev/null +++ b/src/content/docs/waterfall-chart/static.mdx @@ -0,0 +1,316 @@ +--- +title: Waterfall Chart +description: Bridges, deltas, and totals stacked as one composable EvilChart +image: /og/waterfall-chart.png +links: + github: https://github.com/legions-developer/evilcharts/blob/main/src/registry/charts/waterfall-chart.tsx + doc: https://recharts.github.io/en-US/examples/SimpleBarChart/ + api: https://recharts.github.io/en-US/api/BarChart/ +--- + + + +## Installation + + + + CLI + Manual + + + + + + + + Install the following dependencies: + + + + + + Copy and paste the following code snippets into your project. + + To use the chart, first create the folder `evilcharts` and a subfolder called `charts` inside your `components` directory. + Then, copy the following base waterfall-chart code into a new file in that folder. + + + + + + + Now, Let's add the chart component to your project. + + These Components are required by the chart component to render the chart. Make a folder called `ui` inside the `evilcharts` folder and paste the code there. + + Below is main chart component. + + + + + + + Now, We need to add sub components. + + Create a file called `tooltip.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + Now, create another file called `legend.tsx` inside the `evilcharts/ui` folder and paste the code there. + + + + + + + + + +## Usage + +The waterfall chart is composible on top of stacked `` geometries (transparent “base” + visible segment). Provide rows with semantic `WaterfallType` (`start`, `increase`, `decrease`, `total`) encoded at `typeKey` (defaults to `"type"`), display names (`nameKey`), deltas (`valueKey`), gradients via `chartConfig`. Compose ``, ``, ``, ``, ``, `` as needed plus optional `backgroundVariant` on the root — remaining behavior forwards into Recharts ``; see BarChart docs when extending through `chartProps`. + +```tsx +import { + EvilWaterfallChart, + Bars, + Grid, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/components/evilcharts/charts/waterfall-chart"; +``` + +```tsx + + + + + + + + +``` + +### Interactive Selection + +Set `isClickable` on `` and `` as needed — `onSelectionChange` carries `{ dataKey, value } | null`, where `dataKey` aligns with names from `nameKey` and values reflect stacking math (`start` resets running totals, totals span full magnitude, deltas offset previous balance): + +```tsx + { + if (selection) { + console.log("Selected:", selection.dataKey, selection.value); + } else { + console.log("Deselected"); + } + }} +> + + + + + + + +``` + +### Loading State + + + + + The waterfall chart supports loading state. Pass `isLoading` to shimmer placeholder bars synthesized from seeded loading rows (`loadingBars`) while real deltas resolve. + + + +## Examples + +### Glowing Bars + + + +## API Reference + +The chart is composed of several parts. The props below are grouped by the component they belong to. + +EvilWaterfallChart + +The root container bridging raw financial rows → cumulative stack-friendly records (`base` + `barValue`). Tracks selection highlighting, translucent reference line (`y={0}`), rounded rect edges, gradients, legends, glowing filters, shimmer skeleton overlays when `isLoading`. + + + + Keys correspond to categorical `nameKey` values powering gradients + legends. + + + Rows with `nameKey`, numeric `valueKey`, typed `WaterfallType` semantics at `typeKey`. + + + Display + identity column also fed to stacked `` gradient ids. + + + Contribution magnitudes interpreted per `WaterfallType`. + + + Selects categorical driver for bridging logic (`start`, `increase`, `decrease`, `total`). + + + Compose ``, ``, ``, ``, ``, ``, optional root `backgroundVariant`, and escapes through `chartProps`. + + + Extra classes forwarded to ``. + + + Corner rounding for waterfall rectangles. + + + Draws `` underneath the chart (same pattern as bar and scatter charts). + + + Pre-select waterfall slice by `nameKey` string (`null` clears). + + + Mirrors clicks on stacked segments + legend parity with ``. + + + Applies motion shimmering placeholders while withholding final geometry transitions. + + + Number of placeholder categories during loading sequences. + + + Native Recharts props forwarded to ``; refer to Recharts BarChart. + + + +Bars + +Configuration slot translating user intent (`isClickable`, `glowingBars`) into per-rect handlers + defs. + + + + Enables pointer toggling. Dimming applies whenever a bar is selected (legend, default, or click). + + + Subset of `nameKey` values receiving glow ``. + + + +Grid + +Cartesian scaffolding with sane defaults balancing dense financial labeling. + + + + Defaults to horizonal-only separators unless explicitly enabled for dual-axis overlays. + + + Dash forwarded to ``. + + + Forwarded verbatim; see CartesianGrid. + + + +XAxis + +Category axis keyed to `nameKey`, hidden during loading shimmer. + + + + Forwarded `` while automatically binding `dataKey={nameKey}`. + + + Comfortable breathing room for rotated tick labels without colliding stacking math. + + + Forwarded extras per Recharts XAxis. + + + +YAxis + +Numeric axis bridging positive / negative deltas with zero reference line synergy. + + + + Forwarded verbatim when not loading (`width` resolves to `'auto'` by default inside component). + + + Space between ticks + labels. + + + Additional props forwarded untouched; consult Recharts YAxis. + + + +Tooltip + +Selection-aware formatter with subdued crosshair fill (`muted` translucent band). + + + + Tooltip motif. + + + Tooltip radius presets. + + + Forces tooltip spotlight on categorical index zero while idle pointers. + + + +Legend + +Per-bridge legends mirroring clickable selection state. + + + + Legend marker motifs. + + + Horizontal alignment (Evil waterfall defaults tighter to chart gutter). + + + Vertical stacking. + + + Legend entries toggle shared selection; dimming follows `selectedBar` even when `` is not clickable. + + + +Waterfall data semantics + +`WaterfallType` strings drive preprocessing before bars render (`start`, `increase`, `decrease`, `total`). + + + + Determines how `` maps each row onto transparent base offsets vs visible segments — `total` resets running sums to anchored bars, decreases invert direction while preserving stacked geometry, increases extend running totals, `start` seeds initial balances from absolute magnitudes. + + diff --git a/src/globals/functions/getNavItemIcon.tsx b/src/globals/functions/getNavItemIcon.tsx index fc741a5..712d260 100644 --- a/src/globals/functions/getNavItemIcon.tsx +++ b/src/globals/functions/getNavItemIcon.tsx @@ -7,27 +7,47 @@ import { RadialChartIcon, RadarChartIcon, SankeyChartIcon, + ScatterChartIcon, + TreemapChartIcon, + FunnelChartIcon, + WaterfallChartIcon, } from "@/assets/icons"; +/** Normalizes fumadocs folder ids across versions (`root:area-chart` and `area-chart`). */ +function getChartSlug(tag?: string) { + if (!tag) return null; + + const slug = tag.includes(":") ? tag.split(":").at(-1) : tag; + return slug?.endsWith(".mdx") ? null : slug; +} + // Custom icons for each item in the sidebar of MDX files export function getNavItemIcon(tag?: string) { - switch (tag) { - case "root:area-chart": + switch (getChartSlug(tag)) { + case "area-chart": return ; - case "root:line-chart": + case "line-chart": return ; - case "root:bar-chart": + case "bar-chart": return ; - case "root:composed-chart": + case "composed-chart": return ; - case "root:pie-chart": + case "pie-chart": return ; - case "root:radial-chart": + case "radial-chart": return ; - case "root:radar-chart": + case "radar-chart": return ; - case "root:sankey-chart": + case "sankey-chart": return ; + case "scatter-chart": + return ; + case "treemap-chart": + return ; + case "funnel-chart": + return ; + case "waterfall-chart": + return ; default: return null; } diff --git a/src/registry/__index__.tsx b/src/registry/__index__.tsx index f8c6cda..cd9fb25 100644 --- a/src/registry/__index__.tsx +++ b/src/registry/__index__.tsx @@ -259,6 +259,78 @@ export const Index: Record = { categories: undefined, meta: undefined, }, + "scatter-chart": { + name: "scatter-chart", + description: "Scatter chart component for visualizing correlations between two variables", + type: "registry:component", + registryDependencies: ["@evilcharts/chart","@evilcharts/tooltip","@evilcharts/legend","@evilcharts/dot","@evilcharts/background"], + files: [{ + path: "@/registry/charts/scatter-chart.tsx", + type: "registry:component", + target: "components/evilcharts/charts/scatter-chart.tsx" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/charts/scatter-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "treemap-chart": { + name: "treemap-chart", + description: "Treemap chart component for hierarchical category breakdowns", + type: "registry:component", + registryDependencies: ["@evilcharts/chart","@evilcharts/tooltip","@evilcharts/legend","@evilcharts/background"], + files: [{ + path: "@/registry/charts/treemap-chart.tsx", + type: "registry:component", + target: "components/evilcharts/charts/treemap-chart.tsx" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/charts/treemap-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "funnel-chart": { + name: "funnel-chart", + description: "Funnel chart component for conversion and stage drop-off analysis", + type: "registry:component", + registryDependencies: ["@evilcharts/chart","@evilcharts/tooltip","@evilcharts/legend","@evilcharts/background"], + files: [{ + path: "@/registry/charts/funnel-chart.tsx", + type: "registry:component", + target: "components/evilcharts/charts/funnel-chart.tsx" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/charts/funnel-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "waterfall-chart": { + name: "waterfall-chart", + description: "Waterfall chart component for revenue bridges and variance walkthroughs", + type: "registry:component", + registryDependencies: ["@evilcharts/chart","@evilcharts/tooltip","@evilcharts/legend","@evilcharts/background"], + files: [{ + path: "@/registry/charts/waterfall-chart.tsx", + type: "registry:component", + target: "components/evilcharts/charts/waterfall-chart.tsx" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/charts/waterfall-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, "ex-area-chart": { name: "ex-area-chart", description: "", @@ -1897,6 +1969,240 @@ export const Index: Record = { categories: undefined, meta: undefined, }, + "ex-scatter-chart": { + name: "ex-scatter-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/scatter-chart"], + files: [{ + path: "@/registry/examples/ex-scatter-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-scatter-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-gradient-colors-scatter-chart": { + name: "ex-gradient-colors-scatter-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/scatter-chart"], + files: [{ + path: "@/registry/examples/ex-gradient-colors-scatter-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-gradient-colors-scatter-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-loading-state-scatter-chart": { + name: "ex-loading-state-scatter-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/scatter-chart"], + files: [{ + path: "@/registry/examples/ex-loading-state-scatter-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-loading-state-scatter-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-glowing-scatter-chart": { + name: "ex-glowing-scatter-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/scatter-chart"], + files: [{ + path: "@/registry/examples/ex-glowing-scatter-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-glowing-scatter-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-treemap-chart": { + name: "ex-treemap-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/treemap-chart"], + files: [{ + path: "@/registry/examples/ex-treemap-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-treemap-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-glowing-treemap-chart": { + name: "ex-glowing-treemap-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/treemap-chart"], + files: [{ + path: "@/registry/examples/ex-glowing-treemap-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-glowing-treemap-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-loading-state-treemap-chart": { + name: "ex-loading-state-treemap-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/treemap-chart"], + files: [{ + path: "@/registry/examples/ex-loading-state-treemap-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-loading-state-treemap-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-funnel-chart": { + name: "ex-funnel-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/funnel-chart"], + files: [{ + path: "@/registry/examples/ex-funnel-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-funnel-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-glowing-funnel-chart": { + name: "ex-glowing-funnel-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/funnel-chart"], + files: [{ + path: "@/registry/examples/ex-glowing-funnel-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-glowing-funnel-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-loading-state-funnel-chart": { + name: "ex-loading-state-funnel-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/funnel-chart"], + files: [{ + path: "@/registry/examples/ex-loading-state-funnel-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-loading-state-funnel-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-waterfall-chart": { + name: "ex-waterfall-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/waterfall-chart"], + files: [{ + path: "@/registry/examples/ex-waterfall-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-waterfall-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-glowing-waterfall-chart": { + name: "ex-glowing-waterfall-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/waterfall-chart"], + files: [{ + path: "@/registry/examples/ex-glowing-waterfall-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-glowing-waterfall-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, + "ex-loading-state-waterfall-chart": { + name: "ex-loading-state-waterfall-chart", + description: "", + type: "registry:block", + registryDependencies: ["@evilcharts/waterfall-chart"], + files: [{ + path: "@/registry/examples/ex-loading-state-waterfall-chart.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/examples/ex-loading-state-waterfall-chart.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, "ex-sankey-chart": { name: "ex-sankey-chart", description: "", diff --git a/src/registry/charts/funnel-chart.tsx b/src/registry/charts/funnel-chart.tsx new file mode 100644 index 0000000..e6b0e62 --- /dev/null +++ b/src/registry/charts/funnel-chart.tsx @@ -0,0 +1,566 @@ +"use client"; + +import { + type ChartConfig, + ChartContainer, + getColorsCount, + getLoadingData, + LoadingIndicator, +} from "@/registry/ui/chart"; +import { + ChartTooltip, + ChartTooltipContent, + type TooltipRoundness, + type TooltipVariant, +} from "@/registry/ui/tooltip"; +import { ChartLegend, ChartLegendContent, type ChartLegendVariant } from "@/registry/ui/legend"; +import { ChartBackground, type BackgroundVariant } from "@/registry/ui/background"; +import { + Children, + createContext, + isValidElement, + use, + useCallback, + useId, + useMemo, + useState, + type ComponentProps, + type FC, + type ReactElement, + type ReactNode, +} from "react"; +import { + BarChart as RechartsBarChart, + Bar as RechartsBar, + XAxis as RechartsXAxis, + YAxis as RechartsYAxis, + CartesianGrid, +} from "recharts"; +import { motion } from "motion/react"; + +const LOADING_STAGES = 5; +const LOADING_ANIMATION_DURATION = 2000; +const DEFAULT_STAGE_GAP = 2; + +type FunnelDatum = Record; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shared state for every part of the chart. Lifted into so that + * , , , and friends can read it without prop drilling. + */ +type FunnelChartContextValue = { + config: ChartConfig; + data: FunnelDatum[]; + stageKey: string; + valueKey: string; + chartId: string; + maxValue: number; + stageGap: number; + isLoading: boolean; + selectedStage: string | null; + selectStage: (stageName: string | null) => void; + isClickable: boolean; + glowingStages: string[]; +}; + +const FunnelChartContext = createContext(null); + +/** Reads the chart context, throwing a helpful error when used outside . */ +function useFunnelChart() { + const context = use(FunnelChartContext); + + if (!context) { + throw new Error( + "Funnel chart parts (, , …) must be used within ", + ); + } + + return context; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Root container +// ───────────────────────────────────────────────────────────────────────────── + +type EvilFunnelChartProps = { + config: ChartConfig; + data: TData[]; + stageKey: keyof TData & string; + valueKey: keyof TData & string; + children: ReactNode; + className?: string; + stageGap?: number; + backgroundVariant?: BackgroundVariant; + defaultSelectedStage?: string | null; + onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void; + isLoading?: boolean; + loadingStages?: number; + chartProps?: ComponentProps; +}; + +/** + * Root of the composible funnel chart. Owns the stage data, the shared context, + * and the loading skeleton. Everything visual is composed as children. + */ +export function EvilFunnelChart({ + config, + data, + stageKey, + valueKey, + children, + className, + stageGap = DEFAULT_STAGE_GAP, + backgroundVariant, + defaultSelectedStage = null, + onSelectionChange, + isLoading = false, + loadingStages = LOADING_STAGES, + chartProps, +}: EvilFunnelChartProps) { + const chartId = useId().replace(/:/g, ""); + const [selectedStage, setSelectedStage] = useState(defaultSelectedStage); + const stagesConfig = useMemo(() => resolveStagesConfig(children), [children]); + const chartParts = useMemo(() => resolveFunnelParts(children), [children]); + + const loadingData = useMemo( + () => + getLoadingData(loadingStages, 35, 95).map((row, index) => ({ + [stageKey]: `loading-${index}`, + [valueKey]: row.loading, + })), + [loadingStages, stageKey, valueKey], + ); + + const displayData = isLoading ? loadingData : data; + const maxValue = useMemo( + () => Math.max(...displayData.map((row) => Number(row[valueKey] ?? 0)), 1), + [displayData, valueKey], + ); + + const selectStage = useCallback( + (stageName: string | null) => { + setSelectedStage(stageName); + + if (stageName === null) { + onSelectionChange?.(null); + return; + } + + const row = data.find((item) => String(item[stageKey]) === stageName); + onSelectionChange?.( + row ? { dataKey: stageName, value: Number(row[valueKey] ?? 0) } : null, + ); + }, + [data, onSelectionChange, stageKey, valueKey], + ); + + const contextValue = useMemo( + () => ({ + config, + data: displayData, + stageKey, + valueKey, + chartId, + maxValue, + stageGap, + isLoading, + selectedStage, + selectStage, + isClickable: stagesConfig.isClickable, + glowingStages: stagesConfig.glowingStages, + }), + [ + config, + displayData, + stageKey, + valueKey, + chartId, + maxValue, + stageGap, + isLoading, + selectedStage, + selectStage, + stagesConfig, + ], + ); + + return ( + + + + + {backgroundVariant && } + {chartParts} + } + /> + + + {stagesConfig.glowingStages.length > 0 && ( + + )} + + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Composible parts +// ───────────────────────────────────────────────────────────────────────────── + +type StagesProps = { + isClickable?: boolean; + glowingStages?: string[]; +}; + +/** + * Configuration slot for funnel stage behavior. The root reads its props and + * wires them into the stage renderer — it renders nothing on its own. + */ +export const Stages: FC = () => null; + +type TooltipProps = { + variant?: TooltipVariant; + roundness?: TooltipRoundness; + defaultIndex?: number; +}; + +/** The hover tooltip. Hidden automatically while the chart is loading. */ +export function Tooltip({ variant, roundness, defaultIndex }: TooltipProps) { + const { isLoading, selectedStage, stageKey } = useFunnelChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +type LegendProps = { + variant?: ChartLegendVariant; + align?: "left" | "center" | "right"; + verticalAlign?: "top" | "middle" | "bottom"; + isClickable?: boolean; +}; + +/** + * The stage legend. When `isClickable` is set, each entry toggles selection of + * its stage, driving the shared selection state read by every stage. + */ +export function Legend({ + variant, + align = "center", + verticalAlign = "bottom", + isClickable = false, +}: LegendProps) { + const { isLoading, stageKey, selectedStage, selectStage } = useFunnelChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +type GridProps = ComponentProps; + +/** Optional dashed grid lines behind the funnel. */ +export function Grid({ strokeDasharray = "3 3", ...props }: GridProps) { + const { isLoading } = useFunnelChart(); + if (isLoading) return null; + return ; +} + +type XAxisProps = ComponentProps; + +/** The value axis. Hidden by default for a clean funnel silhouette. */ +export function XAxis({ hide = true, type = "number", ...props }: XAxisProps) { + const { isLoading } = useFunnelChart(); + if (isLoading) return null; + return ; +} + +type YAxisProps = ComponentProps; + +/** The stage axis. Lists each funnel stage top-to-bottom. */ +export function YAxis({ + type = "category", + tickLine = false, + axisLine = false, + width = 96, + ...props +}: YAxisProps) { + const { isLoading, stageKey } = useFunnelChart(); + if (isLoading) return null; + return ( + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Funnel shape +// ───────────────────────────────────────────────────────────────────────────── + +type FunnelShapeProps = { + x?: number; + y?: number; + width?: number; + height?: number; + payload?: FunnelDatum; + index?: number; + background?: { + x?: number | null; + y?: number | null; + width?: number | null; + height?: number | null; + }; +}; + +const FunnelStageShape = (props: FunnelShapeProps) => { + const { + config, + chartId, + data, + stageKey, + valueKey, + maxValue, + stageGap, + isLoading, + isClickable, + glowingStages, + selectedStage, + selectStage, + } = useFunnelChart(); + + const { x = 0, y = 0, width = 0, height = 0, payload, index = 0, background } = props; + if (!payload || height <= 0) return null; + + const stageName = String(payload[stageKey]); + const value = Number(payload[valueKey] ?? 0); + const nextValue = + index < data.length - 1 + ? Number(data[index + 1]?.[valueKey] ?? 0) + : Math.max(value * 0.65, 1); + + // Recharts sizes each bar by value (width grows with the stage count), which + // mis-centers trapezoids. The category background spans the full plot width — + // use it so every stage shares one vertical center line. + const plotX = background?.x ?? x; + const plotWidth = background?.width ?? width; + if (plotWidth == null || plotWidth <= 0) return null; + + const centerX = (plotX ?? 0) + plotWidth / 2; + const topWidth = (value / maxValue) * plotWidth; + const bottomWidth = (nextValue / maxValue) * plotWidth; + const isFirst = index === 0; + const isLast = index === data.length - 1; + const topY = y + (isFirst ? stageGap / 2 : 0); + const bottomY = y + height - (isLast ? stageGap / 2 : 0); + const topLeft = centerX - topWidth / 2; + const topRight = centerX + topWidth / 2; + const bottomLeft = centerX - bottomWidth / 2; + const bottomRight = centerX + bottomWidth / 2; + + const isGlowing = glowingStages.includes(stageName); + const isDimmed = selectedStage !== null && selectedStage !== stageName; + const fill = `url(#${chartId}-colors-${stageName})`; + const path = `M ${topLeft} ${topY} L ${topRight} ${topY} L ${bottomRight} ${bottomY} L ${bottomLeft} ${bottomY} Z`; + + const shape = ( + { + if (!isClickable || isLoading) return; + selectStage(selectedStage === stageName ? null : stageName); + }} + /> + ); + + if (isLoading) { + return ( + + + + ); + } + + return shape; +}; + +const StageColorGradients = ({ + chartId, + config, + data, + stageKey, +}: { + chartId: string; + config: ChartConfig; + data: FunnelDatum[]; + stageKey: string; +}) => ( + <> + {data.map((row) => { + const stageName = String(row[stageKey]); + const stageConfig = config[stageName]; + if (!stageConfig) return null; + + const colorsCount = getColorsCount(stageConfig); + + return ( + + {colorsCount === 1 ? ( + <> + + + + ) : ( + Array.from({ length: colorsCount }, (_, index) => { + const offset = `${(index / (colorsCount - 1)) * 100}%`; + return ( + + ); + }) + )} + + ); + })} + +); + +const StageGlowFilters = ({ + chartId, + glowingStages, +}: { + chartId: string; + glowingStages: string[]; +}) => ( + <> + {glowingStages.map((stageKey) => ( + + + + + + + + + ))} + +); + +const resolveStagesConfig = (children: ReactNode) => { + let isClickable = false; + let glowingStages: string[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child) || child.type !== Stages) return; + + const props = (child as ReactElement).props; + isClickable = props.isClickable ?? false; + glowingStages = props.glowingStages ?? []; + }); + + return { isClickable, glowingStages }; +}; + +const resolveFunnelParts = (children: ReactNode) => { + const parts: ReactNode[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.type === Stages) return; + parts.push(child); + }); + + return parts; +}; diff --git a/src/registry/charts/scatter-chart.tsx b/src/registry/charts/scatter-chart.tsx new file mode 100644 index 0000000..9261e77 --- /dev/null +++ b/src/registry/charts/scatter-chart.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { + type ChartConfig, + ChartContainer, + getColorsCount, + LoadingIndicator, +} from "@/registry/ui/chart"; +import { + ChartTooltip, + ChartTooltipContent, + type TooltipRoundness, + type TooltipVariant, +} from "@/registry/ui/tooltip"; +import { ChartLegend, ChartLegendContent, type ChartLegendVariant } from "@/registry/ui/legend"; +import { ChartBackground, type BackgroundVariant } from "@/registry/ui/background"; +import { ChartDot, type DotVariant } from "@/registry/ui/dot"; +import { + Children, + createContext, + isValidElement, + use, + useCallback, + useEffect, + useId, + useMemo, + useState, + type ComponentProps, + type FC, + type ReactElement, + type ReactNode, +} from "react"; +import { + CartesianGrid, + Scatter as RechartsScatter, + ScatterChart as RechartsScatterChart, + XAxis as RechartsXAxis, + YAxis as RechartsYAxis, + type ScatterPointItem, +} from "recharts"; + +const LOADING_POINTS = 12; +const LOADING_ANIMATION_DURATION = 1500; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shared state for every part of the chart. Lifted into so that + * , , , and friends can read it without prop drilling. + * Sub-components are composed freely — the provider is the single source of truth. + */ +type ScatterChartContextValue = { + config: ChartConfig; // colors + labels for every series + isLoading: boolean; // whether the chart shows its loading skeleton + selectedDataKey: string | null; // currently selected series, or null when none + selectDataKey: (dataKey: string | null) => void; // sets the selected series +}; + +const ScatterChartContext = createContext(null); + +/** Reads the chart context, throwing a helpful error when used outside . */ +function useScatterChart() { + const context = use(ScatterChartContext); + + if (!context) { + throw new Error( + "Scatter chart parts (, , …) must be used within ", + ); + } + + return context; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Root container +// ───────────────────────────────────────────────────────────────────────────── + +type EvilScatterChartProps> = { + config: TConfig; // series colors + labels + children: ReactNode; // composed parts — , , , … + className?: string; // extra classes for the chart container + chartProps?: ComponentProps; // escape hatch for the raw Recharts chart + backgroundVariant?: BackgroundVariant; // background pattern drawn behind the chart + defaultSelectedDataKey?: string | null; // series selected on first render + onSelectionChange?: (selectedDataKey: string | null) => void; // fires when the selected series changes + isLoading?: boolean; // shows the animated loading skeleton + loadingPoints?: number; // number of points in the loading skeleton +}; + +/** + * Root of the composible scatter chart. Owns the shared context and the loading skeleton. + * Everything visual — axes, grid, tooltip, legend, and the scatter series themselves — + * is composed as children, so a consumer renders exactly the parts they need. + */ +export function EvilScatterChart>({ + config, + children, + className, + chartProps, + backgroundVariant, + defaultSelectedDataKey = null, + onSelectionChange, + isLoading = false, + loadingPoints, +}: EvilScatterChartProps) { + const chartId = useId().replace(/:/g, ""); + const [selectedDataKey, setSelectedDataKey] = useState(defaultSelectedDataKey); + const loadingData = useLoadingData(isLoading, loadingPoints); + + const selectDataKey = useCallback( + (newSelectedDataKey: string | null) => { + setSelectedDataKey(newSelectedDataKey); + onSelectionChange?.(newSelectedDataKey); + }, + [onSelectionChange], + ); + + const contextValue = useMemo( + () => ({ + config, + isLoading, + selectedDataKey, + selectDataKey, + }), + [config, isLoading, selectedDataKey, selectDataKey], + ); + + return ( + + + + + {backgroundVariant && } + {children} + {isLoading && } + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Composible parts +// ───────────────────────────────────────────────────────────────────────────── + +type ScatterProps> = { + dataKey: string; // series key — must exist in the chart config + data: TPoint[]; // point rows rendered by this series + isGlowing?: boolean; // applies a soft outer glow to this series' points + isClickable?: boolean; // lets this series be selected by clicking it + children?: ReactNode; // optional and composition + scatterProps?: Omit, "data" | "dataKey" | "name">; // escape hatch for raw Recharts Scatter props +}; + +/** + * A single scatter series. Each is self-contained with its own point data and + * style definitions under a unique id, so multiple series can coexist without collisions. + * Compose and inside it to style point markers. + */ +export function Scatter>({ + dataKey, + data, + isGlowing = false, + isClickable = false, + children, + scatterProps, +}: ScatterProps) { + const { config, isLoading, selectedDataKey, selectDataKey } = useScatterChart(); + const id = useId().replace(/:/g, ""); + + if (isLoading) return null; + + const isSelected = selectedDataKey === null || selectedDataKey === dataKey; + const opacity = isClickable && !isSelected ? 0.25 : 1; + const { dotVariant, activeDotVariant } = resolveDots(children); + + const shape = (props: ScatterPointItem) => ( + + ); + + const activeShape = (props: ScatterPointItem) => ( + + ); + + return ( + <> + { + if (!isClickable) return; + selectDataKey(selectedDataKey === dataKey ? null : dataKey); + }} + {...scatterProps} + /> + + + {isGlowing && } + + + ); +} + +type DotProps = { + variant?: DotVariant; // visual style of the point marker +}; + +/** Configuration slot for the resting point marker composed inside a . */ +export const Dot: FC = () => null; + +/** Configuration slot for the hovered/active point marker composed inside a . */ +export const ActiveDot: FC = () => null; + +type XAxisProps = ComponentProps; + +/** + * The horizontal value axis. Defaults to `type="number"` and forwards every Recharts + * XAxis prop. Hidden automatically while the chart is loading. + */ +export function XAxis({ + type = "number", + tickLine = false, + axisLine = false, + tickMargin = 8, + ...props +}: XAxisProps) { + return ( + + ); +} + +type YAxisProps = ComponentProps; + +/** + * The vertical value axis. Defaults to `type="number"` and forwards every Recharts + * YAxis prop. Hidden automatically while the chart is loading. + */ +export function YAxis({ + type = "number", + tickLine = false, + axisLine = false, + tickMargin = 8, + width = "auto", + ...props +}: YAxisProps) { + return ( + + ); +} + +type GridProps = ComponentProps; + +/** The background grid lines. Defaults to horizontal-only dashed lines. */ +export function Grid({ vertical = false, strokeDasharray = "3 3", ...props }: GridProps) { + return ; +} + +type TooltipProps = { + variant?: TooltipVariant; // visual style of the tooltip surface + roundness?: TooltipRoundness; // border-radius of the tooltip + defaultIndex?: number; // keeps the tooltip open at this point index + cursor?: boolean; // whether the crosshair cursor follows the pointer +}; + +/** + * The hover tooltip. Reads the chart's selection state so its content dims unselected + * series. Hidden automatically while the chart is loading. + */ +export function Tooltip({ variant, roundness, defaultIndex, cursor = true }: TooltipProps) { + const { isLoading, selectedDataKey } = useScatterChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +type LegendProps = { + variant?: ChartLegendVariant; // visual style of the legend indicators + align?: "left" | "center" | "right"; // horizontal placement + verticalAlign?: "top" | "middle" | "bottom"; // vertical placement + isClickable?: boolean; // lets each entry toggle selection of its series +}; + +/** + * The series legend. When `isClickable` is set, each entry toggles selection of its + * series, driving the shared selection state read by every . + * Hidden automatically while the chart is loading. + */ +export function Legend({ + variant, + align = "right", + verticalAlign = "top", + isClickable = false, +}: LegendProps) { + const { isLoading, selectedDataKey, selectDataKey } = useScatterChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dot helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Pulls `` and `` out of a scatter's children into marker variants. */ +const resolveDots = (children: ReactNode) => { + let dotVariant: DotVariant = "default"; + let activeDotVariant: DotVariant | undefined; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + + if (child.type === Dot) { + dotVariant = (child as ReactElement).props.variant ?? "default"; + } + + if (child.type === ActiveDot) { + activeDotVariant = (child as ReactElement).props.variant; + } + }); + + return { dotVariant, activeDotVariant }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Style definitions +// ───────────────────────────────────────────────────────────────────────────── + +type StyleProps = { + id: string; + dataKey: string; + config: ChartConfig; +}; + +/** Horizontal color gradient for a scatter series' points. */ +const ColorGradient = ({ id, dataKey, config }: StyleProps) => { + const colorsCount = getColorsCount(config[dataKey] ?? {}); + + return ( + + {colorsCount === 1 ? ( + <> + + + + ) : ( + Array.from({ length: colorsCount }, (_, index) => { + const offset = `${(index / (colorsCount - 1)) * 100}%`; + return ( + + ); + }) + )} + + ); +}; + +/** Soft outer glow filter applied to a scatter series when `isGlowing` is set. */ +const GlowFilter = ({ id, dataKey }: Pick) => { + return ( + + + + + + + + + ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Loading skeleton +// ───────────────────────────────────────────────────────────────────────────── + +/** Builds a fresh set of randomized loading points for the skeleton scatter. */ +const generateLoadingData = (points: number) => { + return Array.from({ length: points }, () => ({ + x: 20 + Math.random() * 80, + y: 20 + Math.random() * 80, + })); +}; + +/** + * Hook that regenerates the loading skeleton data on a fixed interval, so the + * skeleton scatter keeps animating between shapes while the chart is loading. + */ +export function useLoadingData(isLoading: boolean, loadingPoints: number = LOADING_POINTS) { + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + if (!isLoading) return; + + const interval = setInterval(() => { + setRefreshKey((prev) => prev + 1); + }, LOADING_ANIMATION_DURATION); + + return () => clearInterval(interval); + }, [isLoading]); + + const loadingData = useMemo( + () => generateLoadingData(loadingPoints), + // refreshKey toggle triggers re-computation each animation cycle + // eslint-disable-next-line react-hooks/exhaustive-deps + [loadingPoints, refreshKey], + ); + + return loadingData; +} + +/** Animated placeholder points shown while real data is loading. */ +const LoadingScatter = ({ data }: { data: { x: number; y: number }[] }) => { + return ( + { + const { cx, cy } = props; + if (cx === undefined || cy === undefined) return <>; + return ; + }} + isAnimationActive + animationDuration={LOADING_ANIMATION_DURATION} + animationEasing="ease-in-out" + legendType="none" + tooltipType="none" + /> + ); +}; diff --git a/src/registry/charts/treemap-chart.tsx b/src/registry/charts/treemap-chart.tsx new file mode 100644 index 0000000..e5220c1 --- /dev/null +++ b/src/registry/charts/treemap-chart.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { + type ChartConfig, + ChartContainer, + getColorsCount, + LoadingIndicator, +} from "@/registry/ui/chart"; +import { ChartLegend, ChartLegendContent, type ChartLegendVariant } from "@/registry/ui/legend"; +import { + ChartTooltip, + ChartTooltipContent, + type TooltipRoundness, + type TooltipVariant, +} from "@/registry/ui/tooltip"; +import { ChartBackground, type BackgroundVariant } from "@/registry/ui/background"; +import { + Children, + createContext, + isValidElement, + use, + useCallback, + useId, + useMemo, + useState, + type ComponentProps, + type FC, + type ReactElement, + type ReactNode, +} from "react"; +import { Treemap as RechartsTreemap, type TreemapProps } from "recharts"; +import { motion } from "motion/react"; + +const LOADING_TILES = 8; +const LOADING_ANIMATION_DURATION = 2000; +const DEFAULT_ASPECT_RATIO = 4 / 3; +const DEFAULT_STROKE_WIDTH = 2; + +export type TreemapNode = { + name: string; + size?: number; + children?: TreemapNode[]; + [key: string]: unknown; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shared state for every part of the chart. Lifted into so that + * , , , and friends can read it without prop drilling. + */ +type TreemapChartContextValue = { + config: ChartConfig; + data: TreemapNode[]; + dataKey: string; + nameKey: string; + chartId: string; + aspectRatio: number; + strokeWidth: number; + isLoading: boolean; + selectedTile: string | null; + selectTile: (tileName: string | null) => void; + isClickable: boolean; + glowingTiles: string[]; + showLabels: boolean; +}; + +const TreemapChartContext = createContext(null); + +/** Reads the chart context, throwing a helpful error when used outside . */ +function useTreemapChart() { + const context = use(TreemapChartContext); + + if (!context) { + throw new Error( + "Treemap chart parts (, , …) must be used within ", + ); + } + + return context; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Root container +// ───────────────────────────────────────────────────────────────────────────── + +type EvilTreemapChartProps = { + config: ChartConfig; + data: TreemapNode[]; + dataKey?: string; + nameKey?: string; + children: ReactNode; + className?: string; + aspectRatio?: number; + strokeWidth?: number; + backgroundVariant?: BackgroundVariant; + defaultSelectedTile?: string | null; + onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void; + isLoading?: boolean; + treemapProps?: Omit; +}; + +/** + * Root of the composible treemap chart. Owns the hierarchical data, the shared + * context, and the loading skeleton. Everything visual is composed as children. + */ +export function EvilTreemapChart({ + config, + data, + dataKey = "size", + nameKey = "name", + children, + className, + aspectRatio = DEFAULT_ASPECT_RATIO, + strokeWidth = DEFAULT_STROKE_WIDTH, + backgroundVariant, + defaultSelectedTile = null, + onSelectionChange, + isLoading = false, + treemapProps, +}: EvilTreemapChartProps) { + const chartId = useId().replace(/:/g, ""); + const [selectedTile, setSelectedTile] = useState(defaultSelectedTile); + const tilesConfig = useMemo(() => resolveTilesConfig(children), [children]); + + const selectTile = useCallback( + (tileName: string | null) => { + setSelectedTile(tileName); + + if (tileName === null) { + onSelectionChange?.(null); + return; + } + + const value = findNodeValue(data, tileName, dataKey, nameKey); + onSelectionChange?.({ dataKey: tileName, value }); + }, + [data, dataKey, nameKey, onSelectionChange], + ); + + const contextValue = useMemo( + () => ({ + config, + data, + dataKey, + nameKey, + chartId, + aspectRatio, + strokeWidth, + isLoading, + selectedTile, + selectTile, + isClickable: tilesConfig.isClickable, + glowingTiles: tilesConfig.glowingTiles, + showLabels: tilesConfig.showLabels, + }), + [ + config, + data, + dataKey, + nameKey, + chartId, + aspectRatio, + strokeWidth, + isLoading, + selectedTile, + selectTile, + tilesConfig.isClickable, + tilesConfig.glowingTiles, + tilesConfig.showLabels, + ], + ); + + const chartParts = useMemo(() => resolveTreemapParts(children), [children]); + + return ( + + + + {backgroundVariant && } + {!isLoading ? ( + } + {...treemapProps} + > + {chartParts} + + + {tilesConfig.glowingTiles.length > 0 && ( + + )} + + + ) : ( + + )} + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Composible parts +// ───────────────────────────────────────────────────────────────────────────── + +type TilesProps = { + isClickable?: boolean; + glowingTiles?: string[]; + showLabels?: boolean; +}; + +/** + * Configuration slot for treemap tile behavior. The root reads its props and + * wires them into the tile renderer — it renders nothing on its own. + */ +export const Tiles: FC = () => null; + +type TooltipProps = { + variant?: TooltipVariant; + roundness?: TooltipRoundness; + defaultIndex?: number; +}; + +/** The hover tooltip. Hidden automatically while the chart is loading. */ +export function Tooltip({ variant, roundness, defaultIndex }: TooltipProps) { + const { isLoading, nameKey } = useTreemapChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +type LegendProps = { + variant?: ChartLegendVariant; + align?: "left" | "center" | "right"; + verticalAlign?: "top" | "middle" | "bottom"; + isClickable?: boolean; +}; + +/** + * The tile legend. When `isClickable` is set, each entry toggles selection of + * its tile, driving the shared selection state read by every tile. + */ +export function Legend({ + variant, + align = "center", + verticalAlign = "bottom", + isClickable = false, +}: LegendProps) { + const { isLoading, nameKey, selectedTile, selectTile } = useTreemapChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tile renderer +// ───────────────────────────────────────────────────────────────────────────── + +type TreemapContentProps = { + x?: number; + y?: number; + width?: number; + height?: number; + name?: string; + index?: number; + depth?: number; +}; + +const TreemapTileContent = (props: TreemapContentProps) => { + const { + config, + chartId, + isClickable, + glowingTiles, + showLabels, + selectedTile, + selectTile, + strokeWidth, + } = useTreemapChart(); + + const { x = 0, y = 0, width = 0, height = 0, name = "", depth = 0 } = props; + + if (width <= 0 || height <= 0 || depth === 0) return null; + + const tileKey = String(name); + const isGlowing = glowingTiles.includes(tileKey); + const isDimmed = selectedTile !== null && selectedTile !== tileKey; + const fill = `url(#${chartId}-colors-${tileKey})`; + + return ( + { + if (!isClickable) return; + selectTile(selectedTile === tileKey ? null : tileKey); + }} + > + + {showLabels && width > 36 && height > 20 && ( + + {config[tileKey]?.label ?? tileKey} + + )} + + ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Style definitions +// ───────────────────────────────────────────────────────────────────────────── + +const TileColorGradients = ({ chartId, config }: { chartId: string; config: ChartConfig }) => ( + <> + {Object.entries(config).map(([tileKey, tileConfig]) => { + const colorsCount = getColorsCount(tileConfig); + + return ( + + {colorsCount === 1 ? ( + <> + + + + ) : ( + Array.from({ length: colorsCount }, (_, index) => { + const offset = `${(index / (colorsCount - 1)) * 100}%`; + return ( + + ); + }) + )} + + ); + })} + +); + +const TileGlowFilters = ({ + chartId, + glowingTiles, +}: { + chartId: string; + glowingTiles: string[]; +}) => ( + <> + {glowingTiles.map((tileKey) => ( + + + + + + + + + ))} + +); + +// ───────────────────────────────────────────────────────────────────────────── +// Loading skeleton +// ───────────────────────────────────────────────────────────────────────────── + +const LOADING_TILE_LAYOUT = [ + { x: 0, y: 0, width: 55, height: 100 }, + { x: 55, y: 0, width: 45, height: 58 }, + { x: 55, y: 58, width: 22, height: 42 }, + { x: 77, y: 58, width: 23, height: 42 }, + { x: 0, y: 0, width: 30, height: 45 }, + { x: 30, y: 0, width: 25, height: 45 }, + { x: 0, y: 45, width: 55, height: 55 }, + { x: 55, y: 0, width: 45, height: 100 }, +]; + +const LoadingTreemap = () => ( + + {LOADING_TILE_LAYOUT.slice(0, LOADING_TILES).map((tile, index) => ( + + ))} + +); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const resolveTilesConfig = (children: ReactNode) => { + let isClickable = false; + let glowingTiles: string[] = []; + let showLabels = true; + + Children.forEach(children, (child) => { + if (!isValidElement(child) || child.type !== Tiles) return; + + const props = (child as ReactElement).props; + isClickable = props.isClickable ?? false; + glowingTiles = props.glowingTiles ?? []; + showLabels = props.showLabels ?? true; + }); + + return { isClickable, glowingTiles, showLabels }; +}; + +const resolveTreemapParts = (children: ReactNode) => { + const parts: ReactNode[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.type === Tiles) return; + parts.push(child); + }); + + return parts; +}; + +const findNodeValue = ( + nodes: TreemapNode[], + name: string, + dataKey: string, + nameKey: string, +): number => { + for (const node of nodes) { + if (String(node[nameKey]) === name) { + return Number(node[dataKey] ?? 0); + } + + if (node.children?.length) { + const nested = findNodeValue(node.children, name, dataKey, nameKey); + if (nested > 0) return nested; + } + } + + return 0; +}; diff --git a/src/registry/charts/waterfall-chart.tsx b/src/registry/charts/waterfall-chart.tsx new file mode 100644 index 0000000..357cd9a --- /dev/null +++ b/src/registry/charts/waterfall-chart.tsx @@ -0,0 +1,604 @@ +"use client"; + +import { + type ChartConfig, + ChartContainer, + getColorsCount, + getLoadingData, + LoadingIndicator, +} from "@/registry/ui/chart"; +import { + ChartTooltip, + ChartTooltipContent, + type TooltipRoundness, + type TooltipVariant, +} from "@/registry/ui/tooltip"; +import { ChartLegend, ChartLegendContent, type ChartLegendVariant } from "@/registry/ui/legend"; +import { ChartBackground, type BackgroundVariant } from "@/registry/ui/background"; +import { + Children, + createContext, + isValidElement, + use, + useCallback, + useId, + useMemo, + useState, + type ComponentProps, + type FC, + type ReactElement, + type ReactNode, +} from "react"; +import { + BarChart as RechartsBarChart, + Bar as RechartsBar, + XAxis as RechartsXAxis, + YAxis as RechartsYAxis, + CartesianGrid, + ReferenceLine, +} from "recharts"; +import { motion } from "motion/react"; + +const LOADING_STAGES = 5; +const LOADING_ANIMATION_DURATION = 2000; +const DEFAULT_STAGE_GAP = 6; +const BASE_STACK_ID = "evil-waterfall-base"; +const LOADING_BAR_COUNT = 6; + +export type WaterfallType = "start" | "increase" | "decrease" | "total"; + +type WaterfallDatum = Record & { + base?: number; + barValue?: number; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shared state for every part of the chart. Lifted into so that + * , , , and friends can read it without prop drilling. + */ +type WaterfallChartContextValue = { + config: ChartConfig; + data: WaterfallDatum[]; + nameKey: string; + valueKey: string; + typeKey: string; + chartId: string; + isLoading: boolean; + selectedBar: string | null; + selectBar: (barName: string | null) => void; + isClickable: boolean; + glowingBars: string[]; + barRadius: number; +}; + +const WaterfallChartContext = createContext(null); + +/** Reads the chart context, throwing a helpful error when used outside . */ +function useWaterfallChart() { + const context = use(WaterfallChartContext); + + if (!context) { + throw new Error( + "Waterfall chart parts (, , …) must be used within ", + ); + } + + return context; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Root container +// ───────────────────────────────────────────────────────────────────────────── + +type EvilWaterfallChartProps> = { + config: ChartConfig; + data: TData[]; + nameKey: keyof TData & string; + valueKey: keyof TData & string; + typeKey?: keyof TData & string; + children: ReactNode; + className?: string; + barRadius?: number; + backgroundVariant?: BackgroundVariant; + defaultSelectedBar?: string | null; + onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void; + isLoading?: boolean; + loadingBars?: number; + chartProps?: ComponentProps; +}; + +/** + * Root of the composible waterfall chart. Owns the bridge data, the shared context, + * and the loading skeleton. Everything visual is composed as children. + */ +export function EvilWaterfallChart>({ + config, + data, + nameKey, + valueKey, + typeKey = "type", + children, + className, + barRadius = 4, + backgroundVariant, + defaultSelectedBar = null, + onSelectionChange, + isLoading = false, + loadingBars = 6, + chartProps, +}: EvilWaterfallChartProps) { + const chartId = useId().replace(/:/g, ""); + const [selectedBar, setSelectedBar] = useState(defaultSelectedBar); + const barsConfig = useMemo(() => resolveBarsConfig(children), [children]); + const chartParts = useMemo(() => resolveWaterfallParts(children), [children]); + + const preparedData = useMemo( + () => (isLoading ? buildLoadingWaterfallData(loadingBars, nameKey, valueKey) : prepareWaterfallData(data, valueKey, typeKey)), + [data, isLoading, loadingBars, nameKey, valueKey, typeKey], + ); + + const selectBar = useCallback( + (barName: string | null) => { + setSelectedBar(barName); + + if (barName === null) { + onSelectionChange?.(null); + return; + } + + const row = preparedData.find((item) => String(item[nameKey]) === barName); + onSelectionChange?.( + row ? { dataKey: barName, value: Number(row[valueKey] ?? row.barValue ?? 0) } : null, + ); + }, + [preparedData, nameKey, onSelectionChange, valueKey], + ); + + const contextValue = useMemo( + () => ({ + config, + data: preparedData, + nameKey, + valueKey, + typeKey, + chartId, + isLoading, + selectedBar, + selectBar, + isClickable: barsConfig.isClickable, + glowingBars: barsConfig.glowingBars, + barRadius, + }), + [ + config, + preparedData, + nameKey, + valueKey, + typeKey, + chartId, + isLoading, + selectedBar, + selectBar, + barsConfig.isClickable, + barsConfig.glowingBars, + barRadius, + ], + ); + + return ( + + + + + {backgroundVariant && } + + {chartParts} + + } + /> + + + {barsConfig.glowingBars.length > 0 && ( + + )} + + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Composible parts +// ───────────────────────────────────────────────────────────────────────────── + +type BarsProps = { + isClickable?: boolean; + glowingBars?: string[]; +}; + +/** + * Configuration slot for waterfall bar behavior. The root reads its props and + * wires them into the bar renderer — it renders nothing on its own. + */ +export const Bars: FC = () => null; + +type TooltipProps = { + variant?: TooltipVariant; + roundness?: TooltipRoundness; + defaultIndex?: number; +}; + +/** The hover tooltip. Hidden automatically while the chart is loading. */ +export function Tooltip({ variant, roundness, defaultIndex }: TooltipProps) { + const { isLoading, selectedBar, nameKey } = useWaterfallChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +type LegendProps = { + variant?: ChartLegendVariant; + align?: "left" | "center" | "right"; + verticalAlign?: "top" | "middle" | "bottom"; + isClickable?: boolean; +}; + +/** + * The bar legend. When `isClickable` is set, each entry toggles selection of + * its bar, driving the shared selection state read by every bar. + */ +export function Legend({ + variant, + align = "right", + verticalAlign = "top", + isClickable = false, +}: LegendProps) { + const { isLoading, nameKey, selectedBar, selectBar } = useWaterfallChart(); + + if (isLoading) return null; + + return ( + + } + /> + ); +} + +type GridProps = ComponentProps; + +/** The background grid lines. Defaults to horizontal-only dashed lines. */ +export function Grid({ vertical = false, strokeDasharray = "3 3", ...props }: GridProps) { + const { isLoading } = useWaterfallChart(); + if (isLoading) return null; + return ; +} + +type XAxisProps = ComponentProps; + +/** The category axis listing each waterfall step. */ +export function XAxis({ + type = "category", + tickLine = false, + axisLine = false, + tickMargin = 8, + ...props +}: XAxisProps) { + const { isLoading, nameKey } = useWaterfallChart(); + if (isLoading) return null; + return ( + + ); +} + +type YAxisProps = ComponentProps; + +/** The value axis. Defaults to a numeric scale. */ +export function YAxis({ + type = "number", + tickLine = false, + axisLine = false, + tickMargin = 8, + width = "auto", + ...props +}: YAxisProps) { + const { isLoading } = useWaterfallChart(); + if (isLoading) return null; + return ( + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bar shape +// ───────────────────────────────────────────────────────────────────────────── + +type WaterfallBarShapeProps = { + x?: number; + y?: number; + width?: number; + height?: number; + payload?: WaterfallDatum; + index?: number; +}; + +const WaterfallBarShape = (props: WaterfallBarShapeProps) => { + const { + config, + chartId, + nameKey, + isLoading, + isClickable, + glowingBars, + selectedBar, + selectBar, + barRadius, + } = useWaterfallChart(); + + const { x = 0, y = 0, width = 0, height = 0, payload, index = 0 } = props; + if (!payload || width <= 0 || Math.abs(height) <= 0) return null; + + const barName = String(payload[nameKey]); + const isGlowing = glowingBars.includes(barName); + const isDimmed = selectedBar !== null && selectedBar !== barName; + const fill = `url(#${chartId}-colors-${barName})`; + const rectY = height < 0 ? y + height : y; + const rectHeight = Math.abs(height); + + const bar = ( + { + if (!isClickable || isLoading) return; + selectBar(selectedBar === barName ? null : barName); + }} + /> + ); + + if (isLoading) { + return ( + + ); + } + + return bar; +}; + +const BarColorGradients = ({ + chartId, + config, + data, + nameKey, +}: { + chartId: string; + config: ChartConfig; + data: WaterfallDatum[]; + nameKey: string; +}) => ( + <> + {data.map((row) => { + const barName = String(row[nameKey]); + const barConfig = config[barName]; + if (!barConfig) return null; + + const colorsCount = getColorsCount(barConfig); + + return ( + + {colorsCount === 1 ? ( + <> + + + + ) : ( + Array.from({ length: colorsCount }, (_, index) => { + const offset = `${(index / (colorsCount - 1)) * 100}%`; + return ( + + ); + }) + )} + + ); + })} + +); + +const BarGlowFilters = ({ + chartId, + glowingBars, +}: { + chartId: string; + glowingBars: string[]; +}) => ( + <> + {glowingBars.map((barName) => ( + + + + + + + + + ))} + +); + +// ───────────────────────────────────────────────────────────────────────────── +// Data helpers +// ───────────────────────────────────────────────────────────────────────────── + +const prepareWaterfallData = >( + data: TData[], + valueKey: string, + typeKey: string, +): WaterfallDatum[] => { + let running = 0; + + return data.map((row) => { + const value = Number(row[valueKey] ?? 0); + const type = String(row[typeKey] ?? "increase") as WaterfallType; + + if (type === "start") { + running = value; + return { ...row, base: 0, barValue: value }; + } + + if (type === "total") { + return { ...row, base: 0, barValue: value }; + } + + if (type === "decrease") { + running += value; + return { ...row, base: running, barValue: Math.abs(value) }; + } + + const base = running; + running += value; + return { ...row, base, barValue: value }; + }); +}; + +const buildLoadingWaterfallData = (count: number, nameKey: string, valueKey: string) => + getLoadingData(count, 20, 80).map((row, index) => ({ + [nameKey]: `loading-${index}`, + [valueKey]: row.loading, + type: "increase", + base: index * 12, + barValue: row.loading, + })); + +const resolveBarsConfig = (children: ReactNode) => { + let isClickable = false; + let glowingBars: string[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child) || child.type !== Bars) return; + + const props = (child as ReactElement).props; + isClickable = props.isClickable ?? false; + glowingBars = props.glowingBars ?? []; + }); + + return { isClickable, glowingBars }; +}; + +const resolveWaterfallParts = (children: ReactNode) => { + const parts: ReactNode[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.type === Bars) return; + parts.push(child); + }); + + return parts; +}; diff --git a/src/registry/examples/ex-funnel-chart.tsx b/src/registry/examples/ex-funnel-chart.tsx new file mode 100644 index 0000000..8404a86 --- /dev/null +++ b/src/registry/examples/ex-funnel-chart.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + EvilFunnelChart, + Stages, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/registry/charts/funnel-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const data = [ + { stage: "visitors", value: 10000 }, + { stage: "signups", value: 5200 }, + { stage: "trials", value: 2800 }, + { stage: "paid", value: 1200 }, +]; + +const chartConfig = { + visitors: { + label: "Visitors", + colors: { light: ["#3b82f6"], dark: ["#60a5fa"] }, + }, + signups: { + label: "Signups", + colors: { light: ["#10b981"], dark: ["#34d399"] }, + }, + trials: { + label: "Trials", + colors: { light: ["#f59e0b"], dark: ["#fbbf24"] }, + }, + paid: { + label: "Paid", + colors: { light: ["#be123c"], dark: ["#f43f5e"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleFunnelChart() { + return ( + + + + + + + + ); +} diff --git a/src/registry/examples/ex-glowing-funnel-chart.tsx b/src/registry/examples/ex-glowing-funnel-chart.tsx new file mode 100644 index 0000000..c51a502 --- /dev/null +++ b/src/registry/examples/ex-glowing-funnel-chart.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + EvilFunnelChart, + Stages, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/registry/charts/funnel-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const data = [ + { stage: "awareness", value: 12000 }, + { stage: "interest", value: 7600 }, + { stage: "consideration", value: 4100 }, + { stage: "purchase", value: 1800 }, +]; + +const chartConfig = { + awareness: { + label: "Awareness", + colors: { light: ["#1d4ed8", "#3b82f6"], dark: ["#3b82f6", "#60a5fa"] }, + }, + interest: { + label: "Interest", + colors: { light: ["#047857", "#10b981"], dark: ["#10b981", "#34d399"] }, + }, + consideration: { + label: "Consideration", + colors: { light: ["#b45309", "#f59e0b"], dark: ["#f59e0b", "#fbbf24"] }, + }, + purchase: { + label: "Purchase", + colors: { light: ["#be123c", "#f43f5e"], dark: ["#f43f5e", "#fb7185"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleFunnelChart() { + return ( + + + + + + + + ); +} diff --git a/src/registry/examples/ex-glowing-scatter-chart.tsx b/src/registry/examples/ex-glowing-scatter-chart.tsx new file mode 100644 index 0000000..cfc7d4d --- /dev/null +++ b/src/registry/examples/ex-glowing-scatter-chart.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + EvilScatterChart, + Scatter, + XAxis, + YAxis, + Grid, + Tooltip, + Legend, + Dot, + ActiveDot, +} from "@/registry/charts/scatter-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const desktopData = [ + { x: 120, y: 260 }, + { x: 180, y: 420 }, + { x: 240, y: 310 }, + { x: 320, y: 480 }, + { x: 390, y: 360 }, + { x: 450, y: 520 }, + { x: 510, y: 410 }, + { x: 580, y: 550 }, +]; + +const mobileData = [ + { x: 140, y: 180 }, + { x: 210, y: 290 }, + { x: 280, y: 220 }, + { x: 350, y: 340 }, + { x: 420, y: 270 }, + { x: 490, y: 380 }, + { x: 560, y: 300 }, + { x: 620, y: 430 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + colors: { + light: ["#047857"], + dark: ["#10b981"], + }, + }, + mobile: { + label: "Mobile", + colors: { + light: ["#be123c"], + dark: ["#f43f5e"], + }, + }, +} satisfies ChartConfig; + +export function EvilExampleScatterChart() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/src/registry/examples/ex-glowing-treemap-chart.tsx b/src/registry/examples/ex-glowing-treemap-chart.tsx new file mode 100644 index 0000000..2f1c4cc --- /dev/null +++ b/src/registry/examples/ex-glowing-treemap-chart.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { EvilTreemapChart, Tiles, Tooltip, Legend } from "@/registry/charts/treemap-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const data = [ + { name: "engineering", size: 380 }, + { name: "marketing", size: 260 }, + { name: "sales", size: 220 }, + { name: "support", size: 140 }, +]; + +const chartConfig = { + engineering: { + label: "Engineering", + colors: { light: ["#047857", "#10b981"], dark: ["#10b981", "#34d399"] }, + }, + marketing: { + label: "Marketing", + colors: { light: ["#be123c", "#f43f5e"], dark: ["#f43f5e", "#fb7185"] }, + }, + sales: { + label: "Sales", + colors: { light: ["#1d4ed8", "#3b82f6"], dark: ["#3b82f6", "#60a5fa"] }, + }, + support: { + label: "Support", + colors: { light: ["#7c3aed", "#8b5cf6"], dark: ["#8b5cf6", "#a78bfa"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleTreemapChart() { + return ( + + + + + + ); +} diff --git a/src/registry/examples/ex-glowing-waterfall-chart.tsx b/src/registry/examples/ex-glowing-waterfall-chart.tsx new file mode 100644 index 0000000..264c220 --- /dev/null +++ b/src/registry/examples/ex-glowing-waterfall-chart.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { + EvilWaterfallChart, + Bars, + Grid, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/registry/charts/waterfall-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const data = [ + { name: "q1", value: 80, type: "start" }, + { name: "expansion", value: 35, type: "increase" }, + { name: "churn", value: -12, type: "decrease" }, + { name: "upsell", value: 28, type: "increase" }, + { name: "q2", value: 131, type: "total" }, +]; + +const chartConfig = { + q1: { + label: "Q1", + colors: { light: ["#64748b"], dark: ["#94a3b8"] }, + }, + expansion: { + label: "Expansion", + colors: { light: ["#047857", "#10b981"], dark: ["#10b981", "#34d399"] }, + }, + churn: { + label: "Churn", + colors: { light: ["#be123c", "#f43f5e"], dark: ["#f43f5e", "#fb7185"] }, + }, + upsell: { + label: "Upsell", + colors: { light: ["#1d4ed8", "#3b82f6"], dark: ["#3b82f6", "#60a5fa"] }, + }, + q2: { + label: "Q2", + colors: { light: ["#7c3aed", "#8b5cf6"], dark: ["#8b5cf6", "#a78bfa"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleWaterfallChart() { + return ( + + + + + + + + + ); +} diff --git a/src/registry/examples/ex-gradient-colors-scatter-chart.tsx b/src/registry/examples/ex-gradient-colors-scatter-chart.tsx new file mode 100644 index 0000000..dd95e67 --- /dev/null +++ b/src/registry/examples/ex-gradient-colors-scatter-chart.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + EvilScatterChart, + Scatter, + XAxis, + YAxis, + Grid, + Tooltip, + Legend, + Dot, + ActiveDot, +} from "@/registry/charts/scatter-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const desktopData = [ + { x: 120, y: 260 }, + { x: 180, y: 420 }, + { x: 240, y: 310 }, + { x: 320, y: 480 }, + { x: 390, y: 360 }, + { x: 450, y: 520 }, + { x: 510, y: 410 }, + { x: 580, y: 550 }, +]; + +const mobileData = [ + { x: 140, y: 180 }, + { x: 210, y: 290 }, + { x: 280, y: 220 }, + { x: 350, y: 340 }, + { x: 420, y: 270 }, + { x: 490, y: 380 }, + { x: 560, y: 300 }, + { x: 620, y: 430 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + colors: { + light: ["#6366f1", "#a855f7", "#ec4899"], + dark: ["#818cf8", "#c084fc", "#f472b6"], + }, + }, + mobile: { + label: "Mobile", + colors: { + light: ["#14b8a6", "#06b6d4", "#3b82f6"], + dark: ["#2dd4bf", "#22d3ee", "#60a5fa"], + }, + }, +} satisfies ChartConfig; + +export function EvilExampleScatterChart() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/src/registry/examples/ex-horizontal-layout-bar-chart.tsx b/src/registry/examples/ex-horizontal-layout-bar-chart.tsx index c81712c..6c6a07b 100644 --- a/src/registry/examples/ex-horizontal-layout-bar-chart.tsx +++ b/src/registry/examples/ex-horizontal-layout-bar-chart.tsx @@ -1,23 +1,23 @@ "use client"; -import { EvilBarChart, Bar, YAxis, Grid, Tooltip, Legend } from "@/registry/charts/bar-chart"; +import { EvilBarChart, Bar, XAxis, YAxis, Grid, Tooltip, Legend } from "@/registry/charts/bar-chart"; import { type ChartConfig } from "@/registry/ui/chart"; const data = [ - { month: "January", desktop: 186 }, - { month: "February", desktop: 305 }, - { month: "March", desktop: 237 }, - { month: "April", desktop: 173 }, - { month: "May", desktop: 209 }, - { month: "June", desktop: 214 }, + { month: "January", desktop: 342 }, + { month: "February", desktop: 876 }, + { month: "March", desktop: 512 }, + { month: "April", desktop: 629 }, + { month: "May", desktop: 458 }, + { month: "June", desktop: 781 }, ]; const chartConfig = { desktop: { label: "Desktop", colors: { - light: ["#2563eb"], - dark: ["#3b82f6"], + light: ["#047857"], + dark: ["#10b981"], }, }, } satisfies ChartConfig; @@ -31,8 +31,11 @@ export function EvilExampleBarChart() { layout="horizontal" // [!code highlight] > + value.substring(0, 3)} // [!code highlight] /> diff --git a/src/registry/examples/ex-loading-state-funnel-chart.tsx b/src/registry/examples/ex-loading-state-funnel-chart.tsx new file mode 100644 index 0000000..cacb95e --- /dev/null +++ b/src/registry/examples/ex-loading-state-funnel-chart.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + EvilFunnelChart, + Stages, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/registry/charts/funnel-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const chartConfig = { + visitors: { + label: "Visitors", + colors: { light: ["#3b82f6"], dark: ["#60a5fa"] }, + }, + signups: { + label: "Signups", + colors: { light: ["#10b981"], dark: ["#34d399"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleFunnelChart() { + return ( + + + + + + + + ); +} diff --git a/src/registry/examples/ex-loading-state-scatter-chart.tsx b/src/registry/examples/ex-loading-state-scatter-chart.tsx new file mode 100644 index 0000000..acdcd07 --- /dev/null +++ b/src/registry/examples/ex-loading-state-scatter-chart.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { + EvilScatterChart, + Scatter, + XAxis, + YAxis, + Grid, + Tooltip, + Legend, +} from "@/registry/charts/scatter-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const chartConfig = { + desktop: { + label: "Desktop", + colors: { + light: ["#047857"], + dark: ["#10b981"], + }, + }, + mobile: { + label: "Mobile", + colors: { + light: ["#be123c"], + dark: ["#f43f5e"], + }, + }, +} satisfies ChartConfig; + +export function EvilExampleScatterChart() { + return ( + + + + + + + + + + ); +} diff --git a/src/registry/examples/ex-loading-state-treemap-chart.tsx b/src/registry/examples/ex-loading-state-treemap-chart.tsx new file mode 100644 index 0000000..9737e7c --- /dev/null +++ b/src/registry/examples/ex-loading-state-treemap-chart.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { EvilTreemapChart, Tiles, Tooltip, Legend } from "@/registry/charts/treemap-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const chartConfig = { + desktop: { + label: "Desktop", + colors: { light: ["#3b82f6"], dark: ["#60a5fa"] }, + }, + mobile: { + label: "Mobile", + colors: { light: ["#10b981"], dark: ["#34d399"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleTreemapChart() { + return ( + + + + + + ); +} diff --git a/src/registry/examples/ex-loading-state-waterfall-chart.tsx b/src/registry/examples/ex-loading-state-waterfall-chart.tsx new file mode 100644 index 0000000..3d40e31 --- /dev/null +++ b/src/registry/examples/ex-loading-state-waterfall-chart.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { + EvilWaterfallChart, + Bars, + Grid, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/registry/charts/waterfall-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const chartConfig = { + opening: { + label: "Opening", + colors: { light: ["#64748b"], dark: ["#94a3b8"] }, + }, + revenue: { + label: "Revenue", + colors: { light: ["#10b981"], dark: ["#34d399"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleWaterfallChart() { + return ( + + + + + + + + + ); +} diff --git a/src/registry/examples/ex-scatter-chart.tsx b/src/registry/examples/ex-scatter-chart.tsx new file mode 100644 index 0000000..e6599b8 --- /dev/null +++ b/src/registry/examples/ex-scatter-chart.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + EvilScatterChart, + Scatter, + XAxis, + YAxis, + Grid, + Tooltip, + Legend, + Dot, + ActiveDot, +} from "@/registry/charts/scatter-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const desktopData = [ + { x: 120, y: 260 }, + { x: 180, y: 420 }, + { x: 240, y: 310 }, + { x: 320, y: 480 }, + { x: 390, y: 360 }, + { x: 450, y: 520 }, + { x: 510, y: 410 }, + { x: 580, y: 550 }, +]; + +const mobileData = [ + { x: 140, y: 180 }, + { x: 210, y: 290 }, + { x: 280, y: 220 }, + { x: 350, y: 340 }, + { x: 420, y: 270 }, + { x: 490, y: 380 }, + { x: 560, y: 300 }, + { x: 620, y: 430 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + colors: { + light: ["#047857"], + dark: ["#10b981"], + }, + }, + mobile: { + label: "Mobile", + colors: { + light: ["#be123c"], + dark: ["#f43f5e"], + }, + }, +} satisfies ChartConfig; + +export function EvilExampleScatterChart() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/src/registry/examples/ex-treemap-chart.tsx b/src/registry/examples/ex-treemap-chart.tsx new file mode 100644 index 0000000..6094d09 --- /dev/null +++ b/src/registry/examples/ex-treemap-chart.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { EvilTreemapChart, Tiles, Tooltip, Legend } from "@/registry/charts/treemap-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const data = [ + { name: "desktop", size: 420 }, + { name: "mobile", size: 310 }, + { name: "tablet", size: 180 }, + { name: "other", size: 90 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + colors: { light: ["#3b82f6"], dark: ["#60a5fa"] }, + }, + mobile: { + label: "Mobile", + colors: { light: ["#10b981"], dark: ["#34d399"] }, + }, + tablet: { + label: "Tablet", + colors: { light: ["#f59e0b"], dark: ["#fbbf24"] }, + }, + other: { + label: "Other", + colors: { light: ["#8b5cf6"], dark: ["#a78bfa"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleTreemapChart() { + return ( + + + + + + ); +} diff --git a/src/registry/examples/ex-waterfall-chart.tsx b/src/registry/examples/ex-waterfall-chart.tsx new file mode 100644 index 0000000..a9737c5 --- /dev/null +++ b/src/registry/examples/ex-waterfall-chart.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { + EvilWaterfallChart, + Bars, + Grid, + XAxis, + YAxis, + Tooltip, + Legend, +} from "@/registry/charts/waterfall-chart"; +import { type ChartConfig } from "@/registry/ui/chart"; + +const data = [ + { name: "opening", value: 120, type: "start" }, + { name: "product-a", value: 45, type: "increase" }, + { name: "returns", value: -15, type: "decrease" }, + { name: "marketing", value: 20, type: "increase" }, + { name: "closing", value: 170, type: "total" }, +]; + +const chartConfig = { + opening: { + label: "Opening", + colors: { light: ["#64748b"], dark: ["#94a3b8"] }, + }, + "product-a": { + label: "Product A", + colors: { light: ["#10b981"], dark: ["#34d399"] }, + }, + returns: { + label: "Returns", + colors: { light: ["#f43f5e"], dark: ["#fb7185"] }, + }, + marketing: { + label: "Marketing", + colors: { light: ["#3b82f6"], dark: ["#60a5fa"] }, + }, + closing: { + label: "Closing", + colors: { light: ["#8b5cf6"], dark: ["#a78bfa"] }, + }, +} satisfies ChartConfig; + +export function EvilExampleWaterfallChart() { + return ( + + + + + + + + + ); +} diff --git a/src/registry/registry-chart.ts b/src/registry/registry-chart.ts index 680e6ab..be7984c 100644 --- a/src/registry/registry-chart.ts +++ b/src/registry/registry-chart.ts @@ -158,4 +158,81 @@ export const charts: Registry["items"] = [ }, ], }, + { + name: "scatter-chart", + description: "Scatter chart component for visualizing correlations between two variables", + registryDependencies: [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/dot", + "@evilcharts/background", + ], + dependencies: ["recharts", "motion"], + type: "registry:component", + files: [ + { + path: "charts/scatter-chart.tsx", + type: "registry:component", + target: TARGET_BASE_PATH + "/scatter-chart.tsx", + }, + ], + }, + { + name: "treemap-chart", + description: "Treemap chart component for hierarchical category breakdowns", + registryDependencies: [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/background", + ], + dependencies: ["recharts", "motion"], + type: "registry:component", + files: [ + { + path: "charts/treemap-chart.tsx", + type: "registry:component", + target: TARGET_BASE_PATH + "/treemap-chart.tsx", + }, + ], + }, + { + name: "funnel-chart", + description: "Funnel chart component for conversion and stage drop-off analysis", + registryDependencies: [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/background", + ], + dependencies: ["recharts", "motion"], + type: "registry:component", + files: [ + { + path: "charts/funnel-chart.tsx", + type: "registry:component", + target: TARGET_BASE_PATH + "/funnel-chart.tsx", + }, + ], + }, + { + name: "waterfall-chart", + description: "Waterfall chart component for revenue bridges and variance walkthroughs", + registryDependencies: [ + "@evilcharts/chart", + "@evilcharts/tooltip", + "@evilcharts/legend", + "@evilcharts/background", + ], + dependencies: ["recharts", "motion"], + type: "registry:component", + files: [ + { + path: "charts/waterfall-chart.tsx", + type: "registry:component", + target: TARGET_BASE_PATH + "/waterfall-chart.tsx", + }, + ], + }, ]; diff --git a/src/registry/registry-example.ts b/src/registry/registry-example.ts index 5c59395..ce04b42 100644 --- a/src/registry/registry-example.ts +++ b/src/registry/registry-example.ts @@ -1073,6 +1073,120 @@ export const examples: Registry["items"] = [ ], }, // ======================================== + // SCATTER CHART EXAMPLES + // ======================================== + // Base Scatter Chart + { + name: "ex-scatter-chart", + registryDependencies: ["@evilcharts/scatter-chart"], + type: "registry:block", + files: [ + { + path: "examples/ex-scatter-chart.tsx", + type: "registry:block", + }, + ], + }, + // Scatter Chart Gradient Colors + { + name: "ex-gradient-colors-scatter-chart", + registryDependencies: ["@evilcharts/scatter-chart"], + type: "registry:block", + files: [ + { + path: "examples/ex-gradient-colors-scatter-chart.tsx", + type: "registry:block", + }, + ], + }, + // Scatter Chart Loading State + { + name: "ex-loading-state-scatter-chart", + registryDependencies: ["@evilcharts/scatter-chart"], + type: "registry:block", + files: [ + { + path: "examples/ex-loading-state-scatter-chart.tsx", + type: "registry:block", + }, + ], + }, + // Scatter Chart Glowing + { + name: "ex-glowing-scatter-chart", + registryDependencies: ["@evilcharts/scatter-chart"], + type: "registry:block", + files: [ + { + path: "examples/ex-glowing-scatter-chart.tsx", + type: "registry:block", + }, + ], + }, + // ======================================== + // TREEMAP CHART EXAMPLES + // ======================================== + { + name: "ex-treemap-chart", + registryDependencies: ["@evilcharts/treemap-chart"], + type: "registry:block", + files: [{ path: "examples/ex-treemap-chart.tsx", type: "registry:block" }], + }, + { + name: "ex-glowing-treemap-chart", + registryDependencies: ["@evilcharts/treemap-chart"], + type: "registry:block", + files: [{ path: "examples/ex-glowing-treemap-chart.tsx", type: "registry:block" }], + }, + { + name: "ex-loading-state-treemap-chart", + registryDependencies: ["@evilcharts/treemap-chart"], + type: "registry:block", + files: [{ path: "examples/ex-loading-state-treemap-chart.tsx", type: "registry:block" }], + }, + // ======================================== + // FUNNEL CHART EXAMPLES + // ======================================== + { + name: "ex-funnel-chart", + registryDependencies: ["@evilcharts/funnel-chart"], + type: "registry:block", + files: [{ path: "examples/ex-funnel-chart.tsx", type: "registry:block" }], + }, + { + name: "ex-glowing-funnel-chart", + registryDependencies: ["@evilcharts/funnel-chart"], + type: "registry:block", + files: [{ path: "examples/ex-glowing-funnel-chart.tsx", type: "registry:block" }], + }, + { + name: "ex-loading-state-funnel-chart", + registryDependencies: ["@evilcharts/funnel-chart"], + type: "registry:block", + files: [{ path: "examples/ex-loading-state-funnel-chart.tsx", type: "registry:block" }], + }, + // ======================================== + // WATERFALL CHART EXAMPLES + // ======================================== + { + name: "ex-waterfall-chart", + registryDependencies: ["@evilcharts/waterfall-chart"], + type: "registry:block", + files: [{ path: "examples/ex-waterfall-chart.tsx", type: "registry:block" }], + }, + { + name: "ex-glowing-waterfall-chart", + registryDependencies: ["@evilcharts/waterfall-chart"], + type: "registry:block", + files: [{ path: "examples/ex-glowing-waterfall-chart.tsx", type: "registry:block" }], + }, + { + name: "ex-loading-state-waterfall-chart", + registryDependencies: ["@evilcharts/waterfall-chart"], + type: "registry:block", + files: [{ path: "examples/ex-loading-state-waterfall-chart.tsx", type: "registry:block" }], + }, + // ======================================== // SANKEY CHART EXAMPLES // ======================================== // Base Sankey Chart