diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 000000000..06198657e --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "agentCanUpdateSnapshot": true, + "install": "pnpm install", + "start": "pnpm dev:start" +} \ No newline at end of file diff --git a/.cursor/rules/styling-rule.mdc b/.cursor/rules/styling-rule.mdc new file mode 100644 index 000000000..151554f1c --- /dev/null +++ b/.cursor/rules/styling-rule.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: .scss +alwaysApply: false +--- +for styling component use cssUtils.scss \ No newline at end of file diff --git a/.cursor/rules/use-pnpm.mdc b/.cursor/rules/use-pnpm.mdc new file mode 100644 index 000000000..84750494b --- /dev/null +++ b/.cursor/rules/use-pnpm.mdc @@ -0,0 +1,6 @@ +--- +description: any task that uses terminal +globs: +alwaysApply: false +--- +Always use pnpm instead of npm for this project diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..d07acd33b --- /dev/null +++ b/.cursorignore @@ -0,0 +1,2 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +.env \ No newline at end of file diff --git a/js/packages/react-ui/package.json b/js/packages/react-ui/package.json index 897348027..5b0c29fe5 100644 --- a/js/packages/react-ui/package.json +++ b/js/packages/react-ui/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@crayonai/react-ui", "license": "MIT", - "version": "0.8.0", + "version": "0.8.1", "description": "Component library for Generative UI SDK", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -73,7 +73,7 @@ "react-day-picker": "^9.5.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.1", - "recharts": "^2.15.1", + "recharts": "^2.15.4", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-emoji": "^5.0.1", @@ -85,7 +85,6 @@ "@chromatic-com/storybook": "^3.2.4", "@storybook/addon-essentials": "^8.5.3", "@storybook/addon-interactions": "^8.5.3", - "@storybook/addon-onboarding": "^8.5.3", "@storybook/addon-styling-webpack": "^1.0.1", "@storybook/addon-themes": "^8.5.3", "@storybook/blocks": "^8.5.3", diff --git a/js/packages/react-ui/src/components/Button/button.scss b/js/packages/react-ui/src/components/Button/button.scss index e00ad8dd1..e17d0af44 100644 --- a/js/packages/react-ui/src/components/Button/button.scss +++ b/js/packages/react-ui/src/components/Button/button.scss @@ -11,8 +11,8 @@ gap: cssUtils.$spacing-xs; align-items: center; & svg { - height: 1rem; - width: 1rem; + height: 1em; + width: 1em; } // Primary variant diff --git a/js/packages/react-ui/src/components/Charts/AreaChart/AreaChart.tsx b/js/packages/react-ui/src/components/Charts/AreaChart/AreaChart.tsx index 1b08719fe..ff3738c9f 100644 --- a/js/packages/react-ui/src/components/Charts/AreaChart/AreaChart.tsx +++ b/js/packages/react-ui/src/components/Charts/AreaChart/AreaChart.tsx @@ -1,243 +1,433 @@ -import React from "react"; -import { Area, LabelList, AreaChart as RechartsAreaChart, XAxis, YAxis } from "recharts"; -import { useLayoutContext } from "../../../context/LayoutContext"; +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Area, AreaChart as RechartsAreaChart, XAxis, YAxis } from "recharts"; +import { useId } from "../../../polyfills"; +import { IconButton } from "../../IconButton"; +import { ChartConfig, ChartContainer, ChartTooltip } from "../Charts"; +import { SideBarChartData, SideBarTooltipProvider } from "../context/SideBarTooltipContext"; +import { useTransformedKeys } from "../hooks"; import { - ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - keyTransform, -} from "../Charts"; -import { cartesianGrid } from "../cartesianGrid"; -import { getDistributedColors, getPalette } from "../utils/PalletUtils"; - -export type AreaChartData = Array>; + ActiveDot, + cartesianGrid, + CustomTooltipContent, + DefaultLegend, + SideBarTooltip, + XAxisTick, + YAxisTick, +} from "../shared"; +import { LegendItem } from "../types"; +import { + findNearestSnapPosition, + getOptimalXAxisTickFormatter, + getSnapPositions, + getWidthOfData, + getXAxisTickPositionData, +} from "../utils/AreaAndLine/AreaAndLineUtils"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; +import { + get2dChartConfig, + getColorForDataKey, + getDataKeys, + getLegendItems, +} from "../utils/dataUtils"; +import { getYAxisTickFormatter } from "../utils/styleUtils"; +import { AreaChartData, AreaChartVariant } from "./types"; + +// this a technic to get the type of the onClick event of the bar chart +// we need to do this because the onClick event type is not exported by recharts +type AreaChartOnClick = React.ComponentProps["onClick"]; +type AreaClickData = Parameters>[0]; export interface AreaChartProps { data: T; categoryKey: keyof T[number]; - theme?: "ocean" | "orchid" | "emerald" | "sunset" | "spectrum" | "vivid"; - variant?: "linear" | "natural" | "step"; + theme?: PaletteName; + variant?: AreaChartVariant; grid?: boolean; - label?: boolean; legend?: boolean; - opacity?: number; icons?: Partial>; isAnimationActive?: boolean; showYAxis?: boolean; xAxisLabel?: React.ReactNode; yAxisLabel?: React.ReactNode; + className?: string; + height?: number; + width?: number; } -export const AreaChart = ({ +const Y_AXIS_WIDTH = 40; // Width of Y-axis chart when shown + +const AreaChartComponent = ({ data, categoryKey, theme = "ocean", variant = "natural", grid = true, - label = true, - legend = true, - opacity = 0.5, icons = {}, - isAnimationActive = true, - showYAxis = false, + isAnimationActive = false, + showYAxis = true, xAxisLabel, yAxisLabel, + legend = true, + className, + height, + width, }: AreaChartProps) => { - // excluding the categoryKey - const dataKeys = Object.keys(data[0] || {}).filter((key) => key !== categoryKey); - - const palette = getPalette(theme); - const colors = getDistributedColors(palette, dataKeys.length); - const { layout } = useLayoutContext(); - - // Create Config - const chartConfig: ChartConfig = dataKeys.reduce( - (config, key, index) => ({ - ...config, - [key]: { - label: key, - icon: icons[key], - color: colors[index], - }, - }), - {}, - ); + const dataKeys = useMemo(() => { + return getDataKeys(data, categoryKey as string); + }, [data, categoryKey]); - const getTickFormatter = (data: T) => { - const dataLength = data.length; - const maxLengthMap = { - mobile: { - default: 5, - 10: 4, - 11: 4, - }, - tray: { - default: 5, - 8: 4, - 9: 4, - 10: 4, - 11: 4, - }, - copilot: { - default: 5, - 8: 4, - 9: 4, - 10: 4, - 11: 4, - }, - fullscreen: { - default: 5, - 11: 4, - }, - }; + const transformedKeys = useTransformedKeys(dataKeys); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, dataKeys.length); + }, [theme, dataKeys.length]); + + const chartConfig: ChartConfig = useMemo(() => { + return get2dChartConfig(dataKeys, colors, transformedKeys, undefined, icons); + }, [dataKeys, icons, colors, transformedKeys]); + + const chartContainerRef = useRef(null); + const mainContainerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const [isSideBarTooltipOpen, setIsSideBarTooltipOpen] = useState(false); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const [sideBarTooltipData, setSideBarTooltipData] = useState({ + title: "", + values: [], + }); + + // Use provided width or observed width + const effectiveWidth = useMemo(() => { + return width ?? containerWidth; + }, [width, containerWidth]); + + const effectiveContainerWidth = useMemo(() => { + const yAxisWidth = showYAxis ? Y_AXIS_WIDTH : 0; + return Math.max(0, effectiveWidth - yAxisWidth - 40); // -40 because we are giving 20px padding in xAxis on each side + }, [effectiveWidth, showYAxis]); - const layoutConfig = - maxLengthMap[layout as keyof typeof maxLengthMap] || maxLengthMap.fullscreen; + const dataWidth = useMemo(() => { + return getWidthOfData(data, effectiveContainerWidth); + }, [data, effectiveContainerWidth]); - const maxLength = - dataLength >= 11 - ? 4 - : layoutConfig[dataLength as keyof typeof layoutConfig] || layoutConfig.default; + // Calculate snap positions for proper scrolling alignment + const snapPositions = useMemo(() => { + return getSnapPositions(data); + }, [data]); - return (value: string) => { - if (value.length > maxLength) { - return `${value.slice(0, maxLength)}...`; + const chartHeight = useMemo(() => { + return height ?? 296; + }, [height]); + + // Calculate optimal tick formatter for collision detection and truncation + const xAxisTickFormatter = useMemo(() => { + return getOptimalXAxisTickFormatter(data, effectiveContainerWidth); + }, [data, effectiveContainerWidth]); + + // Calculate position data for X-axis tick offset handling + const xAxisPositionData = useMemo(() => { + return getXAxisTickPositionData(data, categoryKey as string); + }, [data, categoryKey]); + + // Check scroll boundaries + const updateScrollState = useCallback(() => { + if (mainContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = mainContainerRef.current; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); // -1 for floating point precision + } + }, []); + + const scrollLeft = useCallback(() => { + if (mainContainerRef.current) { + const currentScroll = mainContainerRef.current.scrollLeft; + const targetIndex = findNearestSnapPosition(snapPositions, currentScroll, "left"); + const targetPosition = snapPositions[targetIndex] ?? 0; + + mainContainerRef.current.scrollTo({ + left: targetPosition, + behavior: "smooth", + }); + } + }, [snapPositions]); + + const scrollRight = useCallback(() => { + if (mainContainerRef.current) { + const currentScroll = mainContainerRef.current.scrollLeft; + const targetIndex = findNearestSnapPosition(snapPositions, currentScroll, "right"); + const targetPosition = snapPositions[targetIndex] ?? 0; + + mainContainerRef.current.scrollTo({ + left: targetPosition, + behavior: "smooth", + }); + } + }, [snapPositions]); + + useEffect(() => { + // Only set up ResizeObserver if width is not provided + if (width || !chartContainerRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((entries) => { + // there is only one entry in the entries array because we are observing the chart container + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); } - return value; + }); + + resizeObserver.observe(chartContainerRef.current); + setContainerWidth(chartContainerRef.current.getBoundingClientRect().width); + + return () => { + resizeObserver.disconnect(); }; - }; - const getAxisAngle = (data: T) => { - const angleConfig = { - mobile: { - default: 0, - ranges: [ - { min: 6, max: 9, angle: -45 }, - { min: 10, max: 10, angle: -60 }, - { min: 11, max: Infinity, angle: -75 }, - ], - }, - tray: { - default: 0, - ranges: [{ min: 8, max: Infinity, angle: -45 }], - }, - copilot: { - default: 0, - ranges: [{ min: 8, max: Infinity, angle: -45 }], - }, - fullscreen: { - default: 0, - ranges: [{ min: 12, max: Infinity, angle: -45 }], - }, + }, [width]); + + // Update scroll state when container width or data width changes + useEffect(() => { + updateScrollState(); + }, [effectiveWidth, dataWidth, updateScrollState]); + + useEffect(() => { + setIsSideBarTooltipOpen(false); + setIsLegendExpanded(false); + }, [dataKeys]); + + // Add scroll event listener to update button states + useEffect(() => { + const mainContainer = mainContainerRef.current; + if (!mainContainer) return; + + const handleScroll = () => { + updateScrollState(); }; - const layoutConfig = angleConfig[layout as keyof typeof angleConfig] || angleConfig.fullscreen; - const dataLength = data.length; + mainContainer.addEventListener("scroll", handleScroll); + return () => { + mainContainer.removeEventListener("scroll", handleScroll); + }; + }, [updateScrollState]); + + const legendItems: LegendItem[] = useMemo(() => { + return getLegendItems(dataKeys, colors, icons); + }, [dataKeys, colors, icons]); + + const id = useId(); - const matchRange = layoutConfig.ranges.find( - (range) => dataLength >= range.min && dataLength <= range.max, - ); + const chartSyncID = useMemo(() => `area-chart-sync-${id}`, [id]); - return matchRange?.angle ?? layoutConfig.default; - }; - const getTickMargin = (data: T) => { - return data.length <= 6 ? 10 : 15; - }; + const gradientID = useMemo(() => `area-chart-gradient-${id}`, [id]); + + const onAreaClick = useCallback( + (data: AreaClickData) => { + if (data?.activePayload?.length && data.activePayload.length > 10) { + setIsSideBarTooltipOpen(true); + setSideBarTooltipData({ + title: data.activeLabel as string, + values: data.activePayload.map((payload) => ({ + value: payload.value as number, + label: payload.name || payload.dataKey, + color: getColorForDataKey(payload.dataKey, dataKeys, colors), + })), + }); + } + }, + [dataKeys, colors], + ); return ( - - +
- {grid && cartesianGrid()} - - {showYAxis && ( - - )} - - } /> - {dataKeys.map((key) => { - const transformedKey = keyTransform(key); - const color = `var(--color-${transformedKey})`; - if (label) { - return ( - + {showYAxis && ( +
+ {/* Y-axis only chart - synchronized with main chart */} + - {label && ( - - )} - - ); - } - return ( - } + /> + {/* Invisible area to maintain scale synchronization */} + {dataKeys.map((key) => { + return ( + + ); + })} + +
+ )} +
+ + + {grid && cartesianGrid()} + + } + orientation="bottom" + padding={{ + left: 25, + right: 20, + }} + /> + + } offset={15} /> + + {dataKeys.map((key) => { + const transformedKey = transformedKeys[key]; + const color = `var(--color-${transformedKey})`; + return ( + + + + + + + ); + })} + + {dataKeys.map((key) => { + const transformedKey = transformedKeys[key]; + const color = `var(--color-${transformedKey})`; + return ( + } + dot={false} + isAnimationActive={isAnimationActive} + /> + ); + })} + + +
+ {isSideBarTooltipOpen && } +
+ {/* if the data width is greater than the effective width, then show the scroll buttons */} + {dataWidth > effectiveWidth && ( +
+ } + variant="secondary" + onClick={scrollLeft} + size="extra-small" + disabled={!canScrollLeft} + /> + } + variant="secondary" + size="extra-small" + onClick={scrollRight} + disabled={!canScrollRight} /> - ); - })} - {legend && } />} - - +
+ )} + {legend && ( + + )} + + ); }; + +// Added React.memo for performance optimization to avoid unnecessary re-renders +export const AreaChart = React.memo(AreaChartComponent) as typeof AreaChartComponent; diff --git a/js/packages/react-ui/src/components/Charts/AreaChart/areaChart.scss b/js/packages/react-ui/src/components/Charts/AreaChart/areaChart.scss new file mode 100644 index 000000000..f447954f4 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/AreaChart/areaChart.scss @@ -0,0 +1,62 @@ +@use "../../../cssUtils" as cssUtils; + +.crayon-area-chart-container-inner { + display: flex; + width: 100%; +} + +.crayon-area-chart-y-axis-container { + flex-shrink: 0; +} + +.crayon-area-chart-main-container { + width: 100%; + overflow-x: auto; + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for Firefox */ + scrollbar-width: none; + + /* Hide scrollbar for IE and Edge */ + -ms-overflow-style: none; +} + +.crayon-area-chart-scroll-container { + position: relative; +} + +button.crayon-area-chart-scroll-button { + position: absolute; + background-color: cssUtils.$bg-container; + + &:hover { + background-color: cssUtils.$bg-container; + } + + &--left { + top: -15px; + transform: translateY(-50%); + left: 20px; + } + + &--right { + top: -15px; + transform: translateY(-50%); + right: 0px; + } + + &--disabled { + visibility: hidden; + cursor: not-allowed; + transition: visibility 0.1s linear; + } + + &--SideBarTooltip { + top: -15px; + transform: translateY(-50%); + right: 185px; + } +} diff --git a/js/packages/react-ui/src/components/Charts/AreaChart/dependencies.ts b/js/packages/react-ui/src/components/Charts/AreaChart/dependencies.ts new file mode 100644 index 000000000..5819b0194 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/AreaChart/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["AreaChart", "IconButton"]; +export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/AreaChart/index.ts b/js/packages/react-ui/src/components/Charts/AreaChart/index.ts index da5fa4e47..72dd774a1 100644 --- a/js/packages/react-ui/src/components/Charts/AreaChart/index.ts +++ b/js/packages/react-ui/src/components/Charts/AreaChart/index.ts @@ -1 +1,2 @@ export * from "./AreaChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/AreaChart/stories/areaChart.stories.tsx b/js/packages/react-ui/src/components/Charts/AreaChart/stories/areaChart.stories.tsx index d4d60a065..283cd5651 100644 --- a/js/packages/react-ui/src/components/Charts/AreaChart/stories/areaChart.stories.tsx +++ b/js/packages/react-ui/src/components/Charts/AreaChart/stories/areaChart.stories.tsx @@ -1,22 +1,477 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { Monitor, TabletSmartphone } from "lucide-react"; +import { + Calendar, + Globe, + Laptop, + Monitor, + Smartphone, + TabletSmartphone, + Tv, + Watch, +} from "lucide-react"; +import { useState } from "react"; import { Card } from "../../../Card"; import { AreaChart, AreaChartProps } from "../AreaChart"; -const areaChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; +// 🔥 COMPREHENSIVE DATA VARIATIONS - Designed to test label collision scenarios +const dataVariations = { + default: [ + { month: "January", desktop: 150, mobile: 90, tablet: 120 }, + { month: "February", desktop: 280, mobile: 180, tablet: 140 }, + { month: "March", desktop: 220, mobile: 140, tablet: 160 }, + { month: "April", desktop: 180, mobile: 160, tablet: 180 }, + { month: "May", desktop: 250, mobile: 120, tablet: 140 }, + { month: "June", desktop: 300, mobile: 180, tablet: 160 }, + { month: "July", desktop: 350, mobile: 220, tablet: 180 }, + { month: "August", desktop: 400, mobile: 240, tablet: 200 }, + { month: "September", desktop: 450, mobile: 260, tablet: 220 }, + { month: "October", desktop: 500, mobile: 280, tablet: 240 }, + { month: "November", desktop: 550, mobile: 300, tablet: 260 }, + { month: "December", desktop: 600, mobile: 320, tablet: 280 }, + ], + // 🏷️ BIG LABELS - Testing collision detection and truncation + bigLabels: [ + { + category: "Very Long Category Name That Should Be Truncated", + sales: 150, + revenue: 90, + profit: 120, + }, + { + category: "Another Extremely Long Label That Causes Collisions", + sales: 280, + revenue: 180, + profit: 140, + }, + { + category: "Super Duper Long Category Name That Tests Truncation", + sales: 220, + revenue: 140, + profit: 160, + }, + { + category: "Incredibly Long Text That Should Trigger Collision Detection", + sales: 180, + revenue: 160, + profit: 180, + }, + { + category: "Maximum Length Category Name That Tests All Edge Cases", + sales: 250, + revenue: 120, + profit: 140, + }, + { + category: "Extra Long Business Category Name With Many Words", + sales: 300, + revenue: 180, + profit: 160, + }, + { + category: "Comprehensive Long Label For Testing Horizontal Offset", + sales: 350, + revenue: 220, + profit: 180, + }, + { + category: "Extended Category Name That Pushes Truncation Limits", + sales: 400, + revenue: 240, + profit: 200, + }, + ], + // 📅 DENSE TIMELINE - Many items with medium-length labels + denseTimeline: [ + { period: "Q1 2022 Jan-Mar", visitors: 120, conversions: 15, revenue: 1200 }, + { period: "Q1 2022 Apr-Jun", visitors: 150, conversions: 22, revenue: 1800 }, + { period: "Q2 2022 Jul-Sep", visitors: 180, conversions: 28, revenue: 2100 }, + { period: "Q2 2022 Oct-Dec", visitors: 200, conversions: 35, revenue: 2500 }, + { period: "Q3 2023 Jan-Mar", visitors: 160, conversions: 18, revenue: 1600 }, + { period: "Q3 2023 Apr-Jun", visitors: 190, conversions: 32, revenue: 2300 }, + { period: "Q4 2023 Jul-Sep", visitors: 220, conversions: 40, revenue: 2800 }, + { period: "Q4 2023 Oct-Dec", visitors: 240, conversions: 45, revenue: 3200 }, + { period: "Q1 2024 Jan-Mar", visitors: 210, conversions: 38, revenue: 2700 }, + { period: "Q1 2024 Apr-Jun", visitors: 230, conversions: 42, revenue: 3000 }, + { period: "Q2 2024 Jul-Sep", visitors: 250, conversions: 48, revenue: 3400 }, + { period: "Q2 2024 Oct-Dec", visitors: 270, conversions: 52, revenue: 3800 }, + { period: "Q3 2024 Jan-Mar", visitors: 260, conversions: 50, revenue: 3600 }, + { period: "Q3 2024 Apr-Jun", visitors: 280, conversions: 55, revenue: 4000 }, + { period: "Q4 2024 Jul-Sep", visitors: 300, conversions: 60, revenue: 4300 }, + { period: "Q4 2024 Oct-Dec", visitors: 290, conversions: 58, revenue: 4100 }, + ], + // 🏢 COMPANY NAMES - Real-world long business names + companyNames: [ + { company: "Apple Inc.", revenue: 394328000000, profit: 99803000000, marketCap: 3500000000000 }, + { + company: "Microsoft Corporation", + revenue: 211915000000, + profit: 83383000000, + marketCap: 2800000000000, + }, + { + company: "Alphabet Inc. (Google)", + revenue: 307394000000, + profit: 76033000000, + marketCap: 2100000000000, + }, + { + company: "Amazon.com Inc.", + revenue: 574785000000, + profit: 33364000000, + marketCap: 1600000000000, + }, + { + company: "Tesla Motors Inc.", + revenue: 96773000000, + profit: 15000000000, + marketCap: 800000000000, + }, + { + company: "Meta Platforms Inc.", + revenue: 134902000000, + profit: 39370000000, + marketCap: 900000000000, + }, + { + company: "NVIDIA Corporation", + revenue: 60922000000, + profit: 29760000000, + marketCap: 1800000000000, + }, + { + company: "Berkshire Hathaway Inc.", + revenue: 364482000000, + profit: 96223000000, + marketCap: 780000000000, + }, + ], + // 🌍 COUNTRY NAMES - Geographic labels with varying lengths + countryData: [ + { country: "United States of America", population: 331900000, gdp: 26900000000000 }, + { country: "People's Republic of China", population: 1412000000, gdp: 17700000000000 }, + { country: "Federal Republic of Germany", population: 83200000, gdp: 4300000000000 }, + { country: "United Kingdom of Great Britain", population: 67500000, gdp: 3100000000000 }, + { country: "French Republic", population: 68000000, gdp: 2900000000000 }, + { country: "Republic of India", population: 1380000000, gdp: 3700000000000 }, + { country: "Federative Republic of Brazil", population: 215000000, gdp: 2100000000000 }, + { country: "Russian Federation", population: 146000000, gdp: 1800000000000 }, + ], + // 📈 FINANCIAL QUARTERS - Testing medium-density scenarios + financialQuarters: [ + { quarter: "Q1 FY2022", revenue: 1200000, expenses: 800000, profit: 400000 }, + { quarter: "Q2 FY2022", revenue: 1500000, expenses: 950000, profit: 550000 }, + { quarter: "Q3 FY2022", revenue: 1800000, expenses: 1100000, profit: 700000 }, + { quarter: "Q4 FY2022", revenue: 2000000, expenses: 1300000, profit: 700000 }, + { quarter: "Q1 FY2023", revenue: 2200000, expenses: 1400000, profit: 800000 }, + { quarter: "Q2 FY2023", revenue: 2500000, expenses: 1600000, profit: 900000 }, + { quarter: "Q3 FY2023", revenue: 2800000, expenses: 1800000, profit: 1000000 }, + { quarter: "Q4 FY2023", revenue: 3000000, expenses: 1900000, profit: 1100000 }, + ], + // 🔤 MIXED LENGTHS - Testing various label length scenarios + mixedLengths: [ + { item: "A", valueA: 100, valueB: 80 }, + { item: "Short", valueA: 150, valueB: 120 }, + { item: "Medium Length Item", valueA: 200, valueB: 160 }, + { item: "Very Long Item Name That Tests Truncation", valueA: 250, valueB: 200 }, + { item: "B", valueA: 180, valueB: 140 }, + { item: "Another Really Long Category Name", valueA: 220, valueB: 180 }, + { item: "XL", valueA: 190, valueB: 150 }, + ], + // 🎯 EDGE CASES - Extreme scenarios + edgeCases: [ + { + name: "SinglePointDataSetForTestingEdgeCasesInCollisionDetectionAndLabelTruncationFunctionality", + value: 500, + }, + { + name: "SecondExtremelyLongDataPointNameThatShouldDefinitelyTriggerTruncationMechanisms", + value: 600, + }, + ], + // 📱 MINIMAL - Small dataset for baseline testing + minimal: [ + { category: "Mobile Devices", users: 150, sessions: 90 }, + { category: "Desktop Computers", users: 280, sessions: 180 }, + { category: "Tablet Devices", users: 220, sessions: 140 }, + ], + // 🔄 EXPAND/COLLAPSE - Marketing channels dataset for legend overflow testing + expandCollapseMarketing: [ + { + channel: "Website Traffic and Organic Search Results", + impressions: 120000, + clicks: 15000, + conversions: 1200, + cost: 8500, + revenue: 24000, + roi: 182, + ctr: 12.5, + cpc: 0.57, + cpa: 7.08, + reach: 95000, + engagement: 8200, + shares: 420, + saves: 180, + comments: 650, + videoViews: 0, + }, + { + channel: "Social Media Engagement and Brand Awareness", + impressions: 85000, + clicks: 12000, + conversions: 950, + cost: 6200, + revenue: 19000, + roi: 206, + ctr: 14.1, + cpc: 0.52, + cpa: 6.53, + reach: 72000, + engagement: 15400, + shares: 890, + saves: 340, + comments: 1200, + videoViews: 28000, + }, + { + channel: "Email Marketing Campaign Performance", + impressions: 45000, + clicks: 8500, + conversions: 800, + cost: 2100, + revenue: 16000, + roi: 562, + ctr: 18.9, + cpc: 0.25, + cpa: 2.63, + reach: 42000, + engagement: 6800, + shares: 120, + saves: 85, + comments: 240, + videoViews: 0, + }, + { + channel: "Paid Advertising and PPC Campaign ROI", + impressions: 95000, + clicks: 18000, + conversions: 1500, + cost: 12500, + revenue: 30000, + roi: 140, + ctr: 18.9, + cpc: 0.69, + cpa: 8.33, + reach: 88000, + engagement: 12600, + shares: 320, + saves: 150, + comments: 480, + videoViews: 5200, + }, + { + channel: "Content Marketing and Blog Performance", + impressions: 60000, + clicks: 9500, + conversions: 750, + cost: 4800, + revenue: 15000, + roi: 213, + ctr: 15.8, + cpc: 0.51, + cpa: 6.4, + reach: 55000, + engagement: 7800, + shares: 680, + saves: 420, + comments: 950, + videoViews: 12000, + }, + { + channel: "Mobile Application Downloads and Usage", + impressions: 70000, + clicks: 11000, + conversions: 1100, + cost: 5500, + revenue: 22000, + roi: 300, + ctr: 15.7, + cpc: 0.5, + cpa: 5.0, + reach: 65000, + engagement: 9200, + shares: 180, + saves: 95, + comments: 320, + videoViews: 8500, + }, + { + channel: "Customer Support Response Time and Quality", + impressions: 35000, + clicks: 5500, + conversions: 450, + cost: 2800, + revenue: 9000, + roi: 221, + ctr: 15.7, + cpc: 0.51, + cpa: 6.22, + reach: 32000, + engagement: 4200, + shares: 45, + saves: 25, + comments: 180, + videoViews: 0, + }, + { + channel: "Sales Funnel Conversion and Lead Generation", + impressions: 110000, + clicks: 22000, + conversions: 1800, + cost: 15000, + revenue: 36000, + roi: 140, + ctr: 20.0, + cpc: 0.68, + cpa: 8.33, + reach: 98000, + engagement: 18500, + shares: 420, + saves: 280, + comments: 750, + videoViews: 3200, + }, + { + channel: "User Retention and Churn Rate Analysis", + impressions: 80000, + clicks: 14000, + conversions: 1200, + cost: 7200, + revenue: 24000, + roi: 233, + ctr: 17.5, + cpc: 0.51, + cpa: 6.0, + reach: 75000, + engagement: 11200, + shares: 280, + saves: 150, + comments: 420, + videoViews: 6800, + }, + { + channel: "Product Feature Usage and Performance Metrics", + impressions: 65000, + clicks: 10500, + conversions: 900, + cost: 5200, + revenue: 18000, + roi: 246, + ctr: 16.2, + cpc: 0.5, + cpa: 5.78, + reach: 58000, + engagement: 8500, + shares: 320, + saves: 180, + comments: 650, + videoViews: 4200, + }, + { + channel: "Market Research and Competitive Analysis", + impressions: 40000, + clicks: 6500, + conversions: 500, + cost: 3200, + revenue: 10000, + roi: 213, + ctr: 16.3, + cpc: 0.49, + cpa: 6.4, + reach: 36000, + engagement: 5200, + shares: 95, + saves: 65, + comments: 220, + videoViews: 1800, + }, + { + channel: "Brand Sentiment and Public Relations Impact", + impressions: 55000, + clicks: 8000, + conversions: 650, + cost: 4100, + revenue: 13000, + roi: 217, + ctr: 14.5, + cpc: 0.51, + cpa: 6.31, + reach: 48000, + engagement: 6800, + shares: 520, + saves: 280, + comments: 950, + videoViews: 15000, + }, + ], +}; + +// Category key mappings for different datasets +const categoryKeys = { + default: "month", + bigLabels: "category", + denseTimeline: "period", + companyNames: "company", + countryData: "country", + financialQuarters: "quarter", + mixedLengths: "item", + edgeCases: "name", + minimal: "category", + expandCollapseMarketing: "channel", +}; + +// 🔥 ACTIVE DATA - For backward compatibility +const areaChartData = dataVariations.default; const icons = { desktop: Monitor, mobile: TabletSmartphone, + tablet: Calendar, + sales: Globe, + revenue: Smartphone, + profit: Laptop, + visitors: Tv, + conversions: Watch, + users: Monitor, + sessions: TabletSmartphone, } as const; +/** + * # AreaChart Component Documentation + * + * The AreaChart is excellent for visualizing quantitative data and showing the volume of a metric over a progression. + * It's ideal for: + * + * - **Time-Series Analysis**: Showing how a value or volume changes over time. + * - **Showing Volume**: Emphasizing the magnitude of a value. + * - **Comparing Trends**: Stacking areas to show how different series contribute to a total. + * + * ## Key Features + * + * ### Advanced Label Handling + * - **Collision Detection**: Prevents X-axis labels from overlapping. + * - **Intelligent Truncation**: Automatically shortens long labels with an ellipsis (...). + * - **Horizontal Offset**: Ensures labels are correctly positioned for maximum readability. + * + * ### Interactive & Responsive + * - **Interactive Legend**: Toggle data series visibility. It gracefully handles overflow with a "Show More" feature. + * - **Hover Tooltips**: Choose between a standard tooltip or a floating one that follows the cursor. + * - **Responsive Design**: Adapts fluidly to its container's size. + * + * ### Customization + * - **Theming**: Comes with six pre-built color palettes. + * - **Area Styles**: Supports `linear`, `natural` (smooth), and `step` interpolation. + * - **Styling Options**: Control grid visibility, axes, and more. + */ const meta: Meta> = { title: "Components/Charts/AreaChart", component: AreaChart, @@ -24,134 +479,168 @@ const meta: Meta> = { layout: "centered", docs: { description: { - component: "```tsx\nimport { AreaChart } from '@crayon-ui/react-ui/Charts/AreaChart';\n```", + component: ` +## Installation and Basic Usage + +\`\`\`tsx +import { AreaChart } from '@crayon-ui/react-ui/Charts/AreaChart'; + +const trafficData = [ + { date: "2023-01-01", visits: 2200 }, + { date: "2023-01-02", visits: 2500 }, + { date: "2023-01-03", visits: 2300 }, +]; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +Your data should be an array of objects. Each object must contain: +- A **category field** (string or number): This will be used for the X-axis labels (e.g., dates, names, etc.). +- One or more **data series fields** (number): These are the values that will be plotted on the Y-axis. The key of the field will be used as the series name in the legend and tooltip. + +\`\`\`tsx +const salesData = [ + { month: "January", desktop: 150, mobile: 90 }, + { month: "February", desktop: 280, mobile: 180 }, + { month: "March", desktop: 220, mobile: 140 }, +]; +\`\`\` + +## Performance Considerations +- **Data Density**: For very large datasets, consider disabling animations to maintain smooth performance. +- **Responsiveness**: The chart is fully responsive, but test label visibility on very small screens. +- **Animation**: Controlled via \`isAnimationActive={false}\`. +`, }, }, }, tags: ["!dev", "autodocs"], argTypes: { data: { - description: - "An array of data objects where each object represents a data point. Each object should have a category field (e.g., month) and one or more numeric values for the areas to be plotted.", + description: ` +**Required.** An array of data objects for the area chart. Each object should contain: +- A category identifier (string/number) for the X-axis. +- One or more numeric values for the Y-axis. +`, control: false, table: { type: { summary: "Array>" }, - defaultValue: { summary: "[]" }, - category: "Data", + category: "📊 Data Configuration", }, }, categoryKey: { description: - "The key from your data object to be used as the x-axis categories (e.g., 'month', 'year', 'date')", + "**Required.** The key in your data object that represents the category for the X-axis.", control: false, table: { type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + category: "📊 Data Configuration", }, }, theme: { - description: - "The color palette theme for the chart. Each theme provides a different set of colors for the areas.", + description: "Specifies the color palette for the chart's areas, tooltips, and legend.", control: "select", options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], table: { defaultValue: { summary: "ocean" }, - category: "Appearance", - }, - }, - icons: { - description: - "An object that maps data keys to icon components. These icons will appear in the legend next to their corresponding data series.", - control: false, - table: { - type: { summary: "Record" }, - defaultValue: { summary: "{}" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, variant: { - description: - "The interpolation method used to create the area curves. 'linear' creates straight lines between points, 'natural' creates smooth curves, and 'step' creates a stepped area.", + description: "Determines the area interpolation method, affecting the shape of the curves.", control: "radio", options: ["linear", "natural", "step"], table: { defaultValue: { summary: "natural" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, - opacity: { + icons: { description: - "The opacity of the filled area beneath each line (0 = fully transparent, 1 = fully opaque)", + "An object mapping data series keys to React components to be used as icons in the legend.", control: false, table: { - type: { summary: "number" }, - defaultValue: { summary: "0.5" }, - category: "Appearance", + type: { summary: "Record" }, + category: "🎨 Visual Styling", }, }, grid: { - description: "Whether to display the background grid lines in the chart", + description: "Toggles the visibility of the background grid lines.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, - label: { - description: "Whether to display data point labels above each point on the chart", + legend: { + description: "Toggles the visibility of the chart legend.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, - legend: { - description: - "Whether to display the chart legend showing the data series names and their corresponding colors/icons", + showYAxis: { + description: "Toggles the visibility of the Y-axis line and labels.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", + }, + }, + xAxisLabel: { + description: "A label to display below the X-axis.", + control: "text", + table: { + type: { summary: "React.ReactNode" }, + category: "📱 Display Options", + }, + }, + yAxisLabel: { + description: "A label to display beside the Y-axis.", + control: "text", + table: { + type: { summary: "React.ReactNode" }, + category: "📱 Display Options", }, }, isAnimationActive: { - description: "Whether to animate the chart", + description: "Enables or disables the initial loading animation for the areas.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "🎬 Animation & Interaction", }, }, - showYAxis: { - description: "Whether to display the y-axis", - control: "boolean", + height: { + description: "Sets a fixed height for the chart container in pixels.", + control: "number", table: { - type: { summary: "boolean" }, - defaultValue: { summary: "false" }, - category: "Display", + type: { summary: "number" }, + category: "Layout & Sizing", }, }, - xAxisLabel: { - description: "The label for the x-axis", - control: false, + width: { + description: "Sets a fixed width for the chart container in pixels.", + control: "number", table: { - type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + type: { summary: "number" }, + category: "Layout & Sizing", }, }, - yAxisLabel: { - description: "The label for the y-axis", - control: false, + className: { + description: "Custom CSS class to apply to the chart's container.", + control: "text", table: { type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + category: "Layout & Sizing", }, }, }, @@ -160,19 +649,218 @@ const meta: Meta> = { export default meta; type Story = StoryObj; -export const AreaChartStory: Story = { - name: "Area Chart", +/** + * ## Comprehensive Data Explorer + * + * This story serves as a comprehensive test suite for the AreaChart component. + * Use the buttons to switch between various datasets designed to test edge cases in: + * + * - **Label Collision**: Datasets with long, dense, or overlapping labels. + * - **Data Density**: Scenarios with few or many data points. + * - **Legend Overflow**: A dataset with many series to test legend expand/collapse functionality. + * + * It's an excellent tool for developers to see how the chart behaves under different conditions. + */ +export const DataExplorer: Story = { + name: "🎛️ Comprehensive Data Explorer", args: { data: areaChartData, categoryKey: "month", theme: "ocean", - variant: "linear", - opacity: 0.5, + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + xAxisLabel: "Time Period", + yAxisLabel: "Values", + // height: 300, + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + fontFamily: "monospace", + }; + + const activeButtonStyle = { + ...buttonStyle, + background: "#007acc", + color: "white", + border: "1px solid #007acc", + }; + + return ( +
+
+ 🏷️ Label Collision Test Suite: +
+ + + + + + + + + + +
+
+
+ Current Dataset: {selectedDataType} +
+
+ Items: {currentData.length} | Category Key:{" "} + {currentCategoryKey} +
+
+ Features: Auto-truncation, Collision Detection, Horizontal Offset +
+
+
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: + "**🏷️ Big Labels Focus:** This story specifically tests label collision detection and truncation scenarios. Use the buttons to switch between different data variations that stress-test the chart's ability to handle long category names.\n\n**Key Features:**\n- ✂️ **Auto-truncation** with ellipsis for long labels\n- 🔍 **Collision detection** prevents overlapping text\n- ↔️ **Horizontal offset** for better visual distribution\n- 🚫 **No angle rotation** - labels stay horizontal for readability\n\n**Test Cases:**\n- **Big Labels**: Extremely long category names that trigger truncation\n- **Dense Timeline**: Many items with medium-length labels\n- **Company Names**: Real-world business names with varying lengths\n- **Mixed Lengths**: Combination of short and long labels\n- **Edge Cases**: Extreme scenarios for boundary testing", + }, + }, + }, +}; + +/** + * ## Big Labels + * + * This story demonstrates the chart's ability to handle very long category labels. + * The chart's intelligent truncation and collision detection should be visible here. + * X-axis labels that are too long to fit will be gracefully truncated with an ellipsis. + */ +export const BigLabelsStory: Story = { + name: "🏷️ Big Labels (Collision Detection)", + args: { + data: dataVariations.bigLabels as any, + categoryKey: "category" as any, + theme: "emerald", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), + parameters: { + docs: { + description: { + story: + "Tests the chart's collision detection system with extremely long category names. Labels should be automatically truncated with ellipsis (...) to prevent overlapping.", + }, + }, + }, +}; + +/** + * ## Dense Timeline + * + * Tests how the chart handles many data points with medium-length period labels. + * The chart should enable horizontal scrolling and apply intelligent label truncation. + * This scenario is common in financial or analytical dashboards where many time periods are displayed. + */ +export const DenseTimelineStory: Story = { + name: "📅 Dense Timeline (Many Periods)", + args: { + data: dataVariations.denseTimeline as any, + categoryKey: "period" as any, + theme: "sunset", + variant: "natural", grid: true, legend: true, - label: true, isAnimationActive: true, - showYAxis: false, + showYAxis: true, }, render: (args: any) => ( @@ -181,94 +869,97 @@ export const AreaChartStory: Story = { ), parameters: { docs: { - source: { - code: ` -const areaChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; + description: { + story: + "Tests how the chart handles many data points with medium-length period labels. The chart should enable horizontal scrolling and apply intelligent label truncation.", + }, + }, + }, +}; - - -`, +/** + * ## Company Names + * + * This story tests the chart with real-world company names that have varying lengths. + * It demonstrates how the chart handles business data with naturally occurring long labels. + */ +export const CompanyNamesStory: Story = { + name: "🏢 Company Names (Real-world Labels)", + args: { + data: dataVariations.companyNames as any, + categoryKey: "company" as any, + theme: "vivid", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), + parameters: { + docs: { + description: { + story: + "Tests with real-world company names that have varying lengths. Demonstrates how the chart handles business data with naturally occurring long labels.", }, }, }, }; -export const AreaChartStoryWithIcons: Story = { - name: "Area Chart with Icons", +/** + * ## Country Names + * + * This story tests the chart with official country names, which are often long. + * It shows how geographic data is handled by the label collision detection system. + */ +export const CountryDataStory: Story = { + name: "🌍 Country Names (Geographic Labels)", args: { - ...AreaChartStory.args, - icons: icons, + data: dataVariations.countryData as any, + categoryKey: "country" as any, + theme: "orchid", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, }, render: (args: any) => ( - + ), parameters: { docs: { - source: { - code: ` - import { Monitor, TabletSmartphone } from "lucide-react"; - - const areaChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, - ]; - - const icons = { - desktop: Monitor, - mobile: TabletSmartphone, - }; - - - - `, + description: { + story: + "Tests with official country names that include full formal names. Shows how geographic data with naturally long names is handled by the collision detection system.", }, }, }, }; -export const AreaChartStoryWithYAxis: Story = { - name: "Area Chart with Y-Axis and Axis Labels", +/** + * ## Mixed Lengths + * + * This story tests the chart's ability to handle a mix of very short and very long labels + * within the same dataset, demonstrating its adaptive truncation behavior. + */ +export const MixedLengthsStory: Story = { + name: "🔤 Mixed Lengths (Varied Label Sizes)", args: { - ...AreaChartStory.args, + data: dataVariations.mixedLengths as any, + categoryKey: "item" as any, + theme: "spectrum", + variant: "linear", + grid: true, + legend: true, + isAnimationActive: true, showYAxis: true, - xAxisLabel: "Time Period", - yAxisLabel: "Number of Users", }, render: (args: any) => ( @@ -277,36 +968,306 @@ export const AreaChartStoryWithYAxis: Story = { ), parameters: { docs: { - source: { - code: ` - import { Monitor, TabletSmartphone } from "lucide-react"; - - const areaChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, - ]; - - - - `, + description: { + story: + "Tests the chart's ability to handle a mix of very short and very long labels within the same dataset. Demonstrates adaptive truncation behavior.", }, }, }, }; + +/** + * ## Edge Cases + * + * This story pushes the collision detection and truncation system to its limits + * with exceptionally long labels and minimal data points. + */ +export const EdgeCasesStory: Story = { + name: "🎯 Edge Cases (Extreme Scenarios)", + args: { + data: dataVariations.edgeCases as any, + categoryKey: "name" as any, + theme: "ocean", + variant: "step", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), + parameters: { + docs: { + description: { + story: + "Tests extreme edge cases with exceptionally long labels and minimal data points. Pushes the collision detection and truncation system to its limits.", + }, + }, + }, +}; + +/** + * ## Minimal Data + * + * A baseline test with a small dataset and standard-length labels. This serves as a control + * case to compare against the more complex label scenarios. + */ +export const MinimalDataStory: Story = { + name: "📱 Minimal Data (Baseline)", + args: { + data: dataVariations.minimal as any, + categoryKey: "category" as any, + theme: "emerald", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), + parameters: { + docs: { + description: { + story: + "Baseline test with minimal data and standard-length labels. Provides a control case to compare against the big label scenarios.", + }, + }, + }, +}; + +/** + * ## Legend Expand/Collapse + * + * Tests the legend's expand/collapse functionality with a large number of data series. + * The legend should automatically show a "Show More" button when items overflow, + * allowing users to toggle between a collapsed and expanded view. + */ +export const ExpandCollapseMarketingStory: Story = { + name: "🔄 Legend Expand/Collapse", + args: { + data: dataVariations.expandCollapseMarketing as any, + categoryKey: "channel" as any, + theme: "vivid", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), + parameters: { + docs: { + description: { + story: + "Tests the legend expand/collapse functionality with 12 marketing channels that have long descriptive names. The legend should automatically show a 'Show More' button when items overflow the container width, allowing users to toggle between collapsed and expanded states.", + }, + }, + }, +}; + +/** + * ## Responsive Behavior Demo + * + * This story demonstrates the responsive capabilities of the AreaChart. Drag the handles + * on the container to resize it and observe how the chart adapts. + * + * **Responsive Features:** + * - **Label Truncation**: More aggressive truncation on smaller container widths. + * - **Layout Adaptation**: Chart elements adjust to fit the available space. + * - **Legend Behavior**: The legend may wrap or show expand/collapse buttons as needed. + */ +export const ResponsiveBehaviorDemo: Story = { + name: "📱 Responsive Behavior Demo", + args: { + data: dataVariations.bigLabels as any, + categoryKey: "category" as any, + theme: "sunset", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: false, + showYAxis: true, + }, + render: (args: any) => { + const [dimensions, setDimensions] = useState<{ width: number; height: number | string }>({ + width: 700, + height: "auto", + }); + + const handleMouseDown = (e: React.MouseEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = dimensions.width; + const startHeight = (e.currentTarget.parentElement as HTMLElement).offsetHeight; + + const doDrag = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let newWidth = startWidth; + let newHeight = startHeight; + + if (handle.includes("e")) newWidth = startWidth + dx; + if (handle.includes("w")) newWidth = startWidth - dx; + if (handle.includes("s")) newHeight = startHeight + dy; + if (handle.includes("n")) newHeight = startHeight - dy; + + setDimensions({ + width: Math.max(300, newWidth), + height: "auto", + }); + }; + + const stopDrag = () => { + document.removeEventListener("mousemove", doDrag); + document.removeEventListener("mouseup", stopDrag); + }; + + document.addEventListener("mousemove", doDrag); + document.addEventListener("mouseup", stopDrag); + }; + + const handleStyle: React.CSSProperties = { + position: "absolute", + background: "#3b82f6", + opacity: 0.6, + zIndex: 10, + transition: "opacity 0.2s ease", + }; + + return ( +
+
+

+ Responsive Behavior Demonstration +

+

+ Drag the edges or corners of the container below to see how the chart adapts +

+
+ 🎯 Try This: Resize the container to see automatic label truncation and + layout adjustments. +
+
+ + + + + {/* Resize Handles */} +
handleMouseDown(e, "n")} + /> +
handleMouseDown(e, "s")} + /> +
handleMouseDown(e, "w")} + /> +
handleMouseDown(e, "e")} + /> + + {/* Corner Handles */} +
handleMouseDown(e, "nw")} + /> +
handleMouseDown(e, "ne")} + /> +
handleMouseDown(e, "se")} + /> + + {/* Dimension Display */} +
+ {dimensions.width}px ×{" "} + {typeof dimensions.height === "number" ? `${dimensions.height}px` : "auto"} +
+ +
+ ); + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/AreaChart/types/index.ts b/js/packages/react-ui/src/components/Charts/AreaChart/types/index.ts new file mode 100644 index 000000000..b01afe131 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/AreaChart/types/index.ts @@ -0,0 +1,3 @@ +export type AreaChartVariant = "linear" | "natural" | "step"; + +export type AreaChartData = Array>; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/BarChart.tsx b/js/packages/react-ui/src/components/Charts/BarChart/BarChart.tsx index bb55f7815..3a07e5b9b 100644 --- a/js/packages/react-ui/src/components/Charts/BarChart/BarChart.tsx +++ b/js/packages/react-ui/src/components/Charts/BarChart/BarChart.tsx @@ -1,225 +1,472 @@ -import React from "react"; -import { Bar, LabelList, BarChart as RechartsBarChart, XAxis, YAxis } from "recharts"; -import { useLayoutContext } from "../../../context/LayoutContext"; +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Bar, BarChart as RechartsBarChart, XAxis, YAxis } from "recharts"; +import { useId } from "../../../polyfills"; +import { IconButton } from "../../IconButton"; +import { useTheme } from "../../ThemeProvider"; +import { ChartConfig, ChartContainer, ChartTooltip } from "../Charts"; +import { SideBarChartData, SideBarTooltipProvider } from "../context/SideBarTooltipContext"; +import { useTransformedKeys } from "../hooks"; import { - ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - keyTransform, -} from "../Charts"; -import { cartesianGrid } from "../cartesianGrid"; -import { getDistributedColors, getPalette } from "../utils/PalletUtils"; - -export type BarChartData = Array>; + cartesianGrid, + CustomTooltipContent, + DefaultLegend, + SideBarTooltip, + XAxisTick, + YAxisTick, +} from "../shared"; +import { type LegendItem } from "../types"; +import { getDistributedColors, getPalette, type PaletteName } from "../utils/PalletUtils"; +import { + get2dChartConfig, + getColorForDataKey, + getDataKeys, + getLegendItems, +} from "../utils/dataUtils"; +import { getYAxisTickFormatter } from "../utils/styleUtils"; +import { LineInBarShape } from "./components/LineInBarShape"; +import { BarChartData, BarChartVariant } from "./types"; +import { + BAR_WIDTH, + findNearestSnapPosition, + getOptimalXAxisTickFormatter, + getPadding, + getRadiusArray, + getSnapPositions, + getWidthOfData, +} from "./utils/BarChartUtils"; +// this a technic to get the type of the onClick event of the bar chart +// we need to do this because the onClick event type is not exported by recharts +type BarChartOnClick = React.ComponentProps["onClick"]; +type BarClickData = Parameters>[0]; export interface BarChartProps { data: T; categoryKey: keyof T[number]; - theme?: "ocean" | "orchid" | "emerald" | "sunset" | "spectrum" | "vivid"; - variant?: "grouped" | "stacked"; + theme?: PaletteName; + variant?: BarChartVariant; grid?: boolean; - label?: boolean; - legend?: boolean; radius?: number; icons?: Partial>; isAnimationActive?: boolean; showYAxis?: boolean; xAxisLabel?: React.ReactNode; yAxisLabel?: React.ReactNode; + legend?: boolean; + className?: string; + height?: number; + width?: number; } -export const BarChart = ({ +const Y_AXIS_WIDTH = 40; // Width of Y-axis chart when shown +const BAR_GAP = 10; // Gap between bars +const BAR_CATEGORY_GAP = "20%"; // Gap between categories +const BAR_INTERNAL_LINE_WIDTH = 1; +const BAR_RADIUS = 4; + +const BarChartComponent = ({ data, categoryKey, theme = "ocean", variant = "grouped", grid = true, - label = true, - legend = true, icons = {}, - radius = 4, - isAnimationActive = true, - showYAxis = false, + radius = BAR_RADIUS, + isAnimationActive = false, + showYAxis = true, xAxisLabel, yAxisLabel, + legend = true, + className, + height, + width, }: BarChartProps) => { - // excluding the categoryKey - const dataKeys = Object.keys(data[0] || {}).filter((key) => key !== categoryKey); - - const palette = getPalette(theme); - const colors = getDistributedColors(palette, dataKeys.length); - const { layout } = useLayoutContext(); - - // Create Config - const chartConfig: ChartConfig = dataKeys.reduce( - (config, key, index) => ({ - ...config, - [key]: { - label: key, - icon: icons[key], - color: colors[index], - }, - }), - {}, - ); + const dataKeys = useMemo(() => { + return getDataKeys(data, categoryKey as string); + }, [data, categoryKey]); - const getTickFormatter = (data: T) => { - const dataLength = data.length; - const maxLengthMap = { - mobile: { - default: 5, - 10: 4, - 11: 4, - }, - tray: { - default: 5, - 8: 4, - 9: 4, - 10: 4, - 11: 4, - }, - copilot: { - default: 5, - 8: 4, - 9: 4, - 10: 4, - 11: 4, - }, - fullscreen: { - default: 5, - 11: 4, - }, - }; + const transformedKeys = useTransformedKeys(dataKeys); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, dataKeys.length); + }, [theme, dataKeys.length]); + + const chartConfig: ChartConfig = useMemo(() => { + return get2dChartConfig(dataKeys, colors, transformedKeys, undefined, icons); + }, [dataKeys, icons, colors, transformedKeys]); + + const chartContainerRef = useRef(null); + const mainContainerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const [hoveredCategory, setHoveredCategory] = useState(null); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const [isSideBarTooltipOpen, setIsSideBarTooltipOpen] = useState(false); + const [sideBarTooltipData, setSideBarTooltipData] = useState({ + title: "", + values: [], + }); + + // Use provided width or observed width + const effectiveWidth = useMemo(() => { + return width ?? containerWidth; + }, [width, containerWidth]); - const layoutConfig = - maxLengthMap[layout as keyof typeof maxLengthMap] || maxLengthMap.fullscreen; + // need this to calculate the padding for the chart container, because the y-axis is rendered in a separate chart + const effectiveContainerWidth = useMemo(() => { + const yAxisWidth = showYAxis ? Y_AXIS_WIDTH : 0; + return Math.max(0, effectiveWidth - yAxisWidth); + }, [effectiveWidth, showYAxis]); - const maxLength = - dataLength >= 11 - ? 4 - : layoutConfig[dataLength as keyof typeof layoutConfig] || layoutConfig.default; + const padding = useMemo(() => { + return getPadding(data, categoryKey as string, effectiveContainerWidth, variant); + }, [data, categoryKey, effectiveContainerWidth, variant]); - return (value: string) => { - if (value.length > maxLength) { - return `${value.slice(0, maxLength)}...`; + const dataWidth = useMemo(() => { + return getWidthOfData(data, categoryKey as string, variant); + }, [data, categoryKey, variant]); + + // Calculate snap positions for proper group alignment + const snapPositions = useMemo(() => { + return getSnapPositions(data, categoryKey as string, variant); + }, [data, categoryKey, variant]); + + // self note: + // Use provided height or calculated height based on container width + // if height is provided, it will be used to set the height of the chart + // if height is not provided, it will be calculated based on the container width (effectiveWidth) + // getChartHeight(effectiveWidth) this function is not used here, request of the designer, we will use fix height + // 296 is the height of the chart by default, given by designer + // we want to chart to scale with width but height will be fixed + + const chartHeight = useMemo(() => { + return height ?? 296; + }, [height]); + + // Check scroll boundaries + const updateScrollState = useCallback(() => { + if (mainContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = mainContainerRef.current; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); // -1 for floating point precision + } + }, []); + + const scrollLeft = useCallback(() => { + if (mainContainerRef.current) { + const currentScroll = mainContainerRef.current.scrollLeft; + const targetIndex = findNearestSnapPosition(snapPositions, currentScroll, "left"); + const targetPosition = snapPositions[targetIndex] ?? 0; + + mainContainerRef.current.scrollTo({ + left: targetPosition, + behavior: "smooth", + }); + } + }, [snapPositions]); + + const scrollRight = useCallback(() => { + if (mainContainerRef.current) { + const currentScroll = mainContainerRef.current.scrollLeft; + const targetIndex = findNearestSnapPosition(snapPositions, currentScroll, "right"); + const targetPosition = snapPositions[targetIndex] ?? 0; + + mainContainerRef.current.scrollTo({ + left: targetPosition, + behavior: "smooth", + }); + } + }, [snapPositions]); + + useEffect(() => { + // Only set up ResizeObserver if width is not provided + if (width || !chartContainerRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((entries) => { + // there is only one entry in the entries array because we are observing the chart container + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); } - return value; + }); + + resizeObserver.observe(chartContainerRef.current); + + return () => { + resizeObserver.disconnect(); }; - }; - const getAxisAngle = (data: T) => { - const angleConfig = { - mobile: { - default: 0, - ranges: [ - { min: 6, max: 9, angle: -45 }, - { min: 10, max: 10, angle: -60 }, - { min: 11, max: Infinity, angle: -75 }, - ], - }, - tray: { - default: 0, - ranges: [{ min: 8, max: Infinity, angle: -45 }], - }, - copilot: { - default: 0, - ranges: [{ min: 8, max: Infinity, angle: -45 }], - }, - fullscreen: { - default: 0, - ranges: [{ min: 12, max: Infinity, angle: -45 }], - }, + }, [width]); + + // Update scroll state when container width or data width changes + useEffect(() => { + updateScrollState(); + }, [effectiveWidth, dataWidth, updateScrollState]); + + useEffect(() => { + setIsSideBarTooltipOpen(false); + setIsLegendExpanded(false); + }, [dataKeys]); + + // Add scroll event listener to update button states + useEffect(() => { + const mainContainer = mainContainerRef.current; + if (!mainContainer) return; + + const handleScroll = () => { + updateScrollState(); }; - const layoutConfig = angleConfig[layout as keyof typeof angleConfig] || angleConfig.fullscreen; - const dataLength = data.length; + mainContainer.addEventListener("scroll", handleScroll); + return () => { + mainContainer.removeEventListener("scroll", handleScroll); + }; + }, [updateScrollState]); + + // Memoize legend items creation + const legendItems: LegendItem[] = useMemo(() => { + return getLegendItems(dataKeys, colors, icons); + }, [dataKeys, colors, icons]); + + const id = useId(); + + const chartSyncID = useMemo(() => `bar-chart-sync-${id}`, [id]); - const matchRange = layoutConfig.ranges.find( - (range) => dataLength >= range.min && dataLength <= range.max, - ); + // Get the optimal X-axis tick formatter based on available space + const xAxisTickFormatter = useMemo(() => { + return getOptimalXAxisTickFormatter(data, categoryKey as string, variant); + }, [data, categoryKey, variant]); - return matchRange?.angle ?? layoutConfig.default; - }; - const getTickMargin = (data: T) => { - return data.length <= 6 ? 10 : 15; - }; + // Handle mouse events for group hovering + const handleChartMouseMove = useCallback((state: any) => { + if (state && state.activeLabel !== undefined) { + setHoveredCategory(state.activeLabel); + } + }, []); + + const handleChartMouseLeave = useCallback(() => { + setHoveredCategory(null); + }, []); + + const { mode } = useTheme(); + + const barInternalLineColor = useMemo(() => { + if (mode === "light") { + return "rgba(255, 255, 255, 0.3)"; + } + return "rgba(0, 0, 0, 0.3)"; + }, [mode]); + + const onBarsClick = useCallback( + (data: BarClickData) => { + if (data?.activePayload?.length && data.activePayload.length > 10) { + setIsSideBarTooltipOpen(true); + setSideBarTooltipData({ + title: data.activeLabel as string, + values: data.activePayload.map((payload) => ({ + value: payload.value as number, + label: payload.name || payload.dataKey, + color: getColorForDataKey(payload.dataKey, dataKeys, colors), + })), + }); + } + }, + [dataKeys, colors], + ); return ( - - +
- {grid && cartesianGrid()} - - {showYAxis && ( - - )} - } /> - {dataKeys.map((key) => { - const transformedKey = keyTransform(key); - const color = `var(--color-${transformedKey})`; - if (label) { - return ( - + {showYAxis && ( +
+ {/* Y-axis only chart - synchronized with main chart */} + + } + /> + {/* Invisible bars to maintain scale synchronization */} + {dataKeys.map((key) => { + return ( + + ); + })} + +
+ )} +
+ + - {label && ( - - )} - - ); - } - return ( - } + orientation="bottom" + // gives the padding on the 2 sides see the function for reference + padding={padding} + /> + {/* Y-axis is rendered in the separate synchronized chart */} + + } + cursor={{ + fill: "var(--crayon-sunk-fills)", + stroke: "var(--crayon-stroke-default)", + opacity: 1, + strokeWidth: 1, + }} + content={} + offset={15} + /> + + {dataKeys.map((key, index) => { + const transformedKey = transformedKeys[key]; + const color = `var(--color-${transformedKey})`; + const isFirstInStack = index === 0; + const isLastInStack = index === dataKeys.length - 1; + + return ( + + } + /> + ); + })} + + +
+ {isSideBarTooltipOpen && } +
+ {/* if the data width is greater than the effective width, then show the scroll buttons */} + {dataWidth > effectiveWidth && ( +
+ } + variant="secondary" + onClick={scrollLeft} + size="extra-small" + disabled={!canScrollLeft} /> - ); - })} - {legend && } />} - - + + } + variant="secondary" + size="extra-small" + onClick={scrollRight} + disabled={!canScrollRight} + /> +
+ )} + {legend && ( + + )} +
+ ); }; + +// Added React.memo for performance optimization to avoid unnecessary re-renders +export const BarChart = React.memo(BarChartComponent) as typeof BarChartComponent; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/barChart.scss b/js/packages/react-ui/src/components/Charts/BarChart/barChart.scss new file mode 100644 index 000000000..77438cbd9 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/BarChart/barChart.scss @@ -0,0 +1,62 @@ +@use "../../../cssUtils" as cssUtils; + +.crayon-bar-chart-container-inner { + display: flex; + width: 100%; +} + +.crayon-bar-chart-y-axis-container { + flex-shrink: 0; +} + +.crayon-bar-chart-main-container { + width: 100%; + overflow-x: auto; + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for Firefox */ + scrollbar-width: none; + + /* Hide scrollbar for IE and Edge */ + -ms-overflow-style: none; +} + +.crayon-bar-chart-scroll-container { + position: relative; +} + +.crayon-bar-chart-scroll-button { + position: absolute; + background-color: cssUtils.$bg-container; + + &:hover { + background-color: cssUtils.$bg-container; + } + + &--left { + top: -15px; + transform: translateY(-50%); + left: 20px; + } + + &--right { + top: -15px; + transform: translateY(-50%); + right: 0px; + } + + &--disabled { + visibility: hidden; + cursor: not-allowed; + transition: visibility 0.1s linear; + } + + &--SideBarTooltip { + top: -15px; + transform: translateY(-50%); + right: 185px; + } +} diff --git a/js/packages/react-ui/src/components/Charts/BarChart/components/LineInBarShape.tsx b/js/packages/react-ui/src/components/Charts/BarChart/components/LineInBarShape.tsx new file mode 100644 index 000000000..091677f10 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/BarChart/components/LineInBarShape.tsx @@ -0,0 +1,200 @@ +import React, { FunctionComponent, useMemo } from "react"; + +interface BarWithInternalLineProps { + x?: number; + y?: number; + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: number | string; + radius?: number | number[]; + internalLineColor?: string; + internalLineWidth?: number; + isHovered?: boolean; + hoveredCategory?: string | number | null; + categoryKey?: string; + payload?: any; + // New props for stacked bar gaps + variant?: "grouped" | "stacked"; + stackGap?: number; + + // Recharts also passes other props like payload, value, etc. + // we can add them here if our shape component needs them. + // console.log("props", props); + [key: string]: any; // Allow other props from Recharts +} + +const DEFAULT_STACK_GAP = 1; +const MIN_LINE_HEIGHT = 8; +const LINE_PADDING = 6; +const MIN_GROUP_BAR_HEIGHT = 2; +const MIN_STACKED_BAR_HEIGHT = 4; + +const LineInBarShape: FunctionComponent = React.memo((props) => { + const { + x = 0, // Default to 0 to avoid NaN issues if undefined + y = 0, + width = 0, + height = 0, + fill, + radius: r, // Renaming to avoid conflict with BarChartV2's radius prop + stroke, + strokeWidth, + internalLineColor: iLineColor, // Use prop or fallback + internalLineWidth: iLineWidth, + isHovered, + hoveredCategory, + categoryKey, + payload, + variant = "grouped", + stackGap = DEFAULT_STACK_GAP, // Default 1px gap + } = props; + + // Memoized radius calculations - Ensure rTL and rTR are always numbers, defaulting to 0. + // This calculation is memoized to avoid recalculating on every render when radius prop hasn't changed + const { rTL, rTR } = useMemo(() => { + // For grouped bars, if the height is too small, we should not apply corner radius + // as it can lead to weird shapes. MIN_LINE_HEIGHT is a reasonable threshold. + if (variant === "grouped" && height < MIN_GROUP_BAR_HEIGHT) { + return { rTL: 0, rTR: 0 }; + } + + if (variant === "stacked" && height < MIN_STACKED_BAR_HEIGHT) { + return { rTL: 0, rTR: 0 }; + } + + if (Array.isArray(r)) { + return { rTL: r[0] || 0, rTR: r[1] || 0 }; + } else if (typeof r === "number") { + return { rTL: r, rTR: r }; + } + return { rTL: 0, rTR: 0 }; + }, [r, variant, height]); + + // Memoized opacity calculation for hover effects + // Calculate the opacity value for the bar based on hover state + // When a category is hovered: + // - Bars in the hovered category maintain full opacity (1) + // - All other bars are dimmed to 60% opacity (0.6) + // This creates a visual hierarchy highlighting the hovered category + // + // @default 1 - Full opacity when no hover state is active + // @requires isHovered - Boolean indicating if any category is being hovered + // @requires hoveredCategory - The category value currently being hovered (string|number|null) + // @requires payload - The data payload for this bar containing category information + // @requires categoryKey - The key used to access the category value in the payload + const opacity = useMemo(() => { + if (!isHovered || hoveredCategory === null || !payload || !categoryKey) { + return 1; + } + const currentCategoryValue = payload[categoryKey]; + return currentCategoryValue === hoveredCategory ? 1 : 0.4; + }, [isHovered, hoveredCategory, payload, categoryKey]); + + // Memoized adjusted dimensions for stacked bar gaps + // Adjust dimensions for stacked bar gaps + // This creates visual separation between bars in a stack by reducing height only + // We don't adjust Y position to avoid double gaps and positioning issues + // Only reduce height at the top of each bar except the last one (bottom-most in stack) + // This creates a gap between this bar and the bar above it + const { adjustedY, adjustedHeight } = useMemo(() => { + let finalHeight = height; + if (variant === "stacked" && stackGap > 0) { + finalHeight = height - stackGap; + } + + // Enforce a minimum height for visibility, but only if the original height is non-zero. + // This prevents bars with a value of 0 from being rendered. + if (height > 0 && variant === "grouped") { + finalHeight = Math.max(finalHeight, MIN_GROUP_BAR_HEIGHT); + } + + if (height > 0 && variant === "stacked") { + finalHeight = Math.max(finalHeight, MIN_STACKED_BAR_HEIGHT - stackGap); + } + + // We need to adjust the y position to keep the bar bottom-aligned + // when we enforce a minimum height. + const finalY = y + height - finalHeight; + + return { + adjustedY: finalY, + adjustedHeight: finalHeight, + }; + }, [variant, stackGap, y, height]); + + // Memoized SVG path calculation for optimized rendering + // Path data for a rectangle with potentially rounded top corners and stack gaps + // M = move to, L = line to, A = arc, Z = close path + // Handle cases where rTL or rTR might be 0 (sharp corners) + // This SVG path string creates a rectangle with optional rounded top corners + // The path is constructed using SVG path commands: + // M = Move to starting point + // L = Draw line to point + // A = Draw arc (rx,ry rotation large-arc-flag sweep-flag x,y) + // Z = Close path back to start + // + // The path construction: + // 1. Starts at bottom left corner (x, adjustedY+rTL) + // 2. If rTL > 0, draws arc for top left corner, else draws straight line + // 3. Draws line across top to right side + // 4. If rTR > 0, draws arc for top right corner, else draws straight line + // 5. Draws straight line down right side + // 6. Draws straight line across bottom + // 7. Closes path back to start + const path = useMemo(() => { + return ` + M ${x},${adjustedY + rTL} + ${rTL > 0 ? `A ${rTL},${rTL} 0 0 1 ${x + rTL},${adjustedY}` : `L ${x},${adjustedY}`} + L ${x + width - rTR},${adjustedY} + ${rTR > 0 ? `A ${rTR},${rTR} 0 0 1 ${x + width},${adjustedY + rTR}` : `L ${x + width},${adjustedY}`} + L ${x + width},${adjustedY + adjustedHeight} + L ${x},${adjustedY + adjustedHeight} + Z + `; + }, [x, adjustedY, adjustedHeight, width, rTL, rTR]); + + // Memoized line coordinates calculation for the internal vertical line + // Only calculate coordinates if bar has sufficient width and height + // The internal line is centered horizontally and padded vertically for better visual appearance + const lineCoords = useMemo(() => { + if (width <= 0 || adjustedHeight <= MIN_LINE_HEIGHT) { + return null; + } + + const centerX = x + width / 2; + return { + x1: centerX, + y1: adjustedY + LINE_PADDING, // Starts below the top edge of the adjusted bar + x2: centerX, + y2: adjustedY + adjustedHeight - LINE_PADDING, // Ends above the bottom edge of the adjusted bar + }; + }, [x, width, adjustedY, adjustedHeight]); + + return ( + + {/* The main bar shape (using for rounded corners and stack gaps) */} + + + {/* The internal vertical line - adjusted for stack gaps */} + {lineCoords && ( + + )} + + ); +}); + +// Add display name for better debugging +LineInBarShape.displayName = "LineInBarShape"; + +export { LineInBarShape }; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/dependencies.ts b/js/packages/react-ui/src/components/Charts/BarChart/dependencies.ts new file mode 100644 index 000000000..4cfac500e --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/BarChart/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["BarChart", "IconButton"]; +export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/index.ts b/js/packages/react-ui/src/components/Charts/BarChart/index.ts index ea65af237..ddbf3231c 100644 --- a/js/packages/react-ui/src/components/Charts/BarChart/index.ts +++ b/js/packages/react-ui/src/components/Charts/BarChart/index.ts @@ -1 +1,2 @@ export * from "./BarChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/stories/barChart.stories.tsx b/js/packages/react-ui/src/components/Charts/BarChart/stories/barChart.stories.tsx index 89b5e8a24..9a5939059 100644 --- a/js/packages/react-ui/src/components/Charts/BarChart/stories/barChart.stories.tsx +++ b/js/packages/react-ui/src/components/Charts/BarChart/stories/barChart.stories.tsx @@ -1,22 +1,531 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Monitor, TabletSmartphone } from "lucide-react"; +import { useState } from "react"; import { Card } from "../../../Card"; import { BarChart, BarChartProps } from "../BarChart"; -const barChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; +// 📊 ALL DATA VARIATIONS - For easy switching in stories +const dataVariations = { + default: [ + { month: "January", desktop: 150, mobile: 90, tablet: 120, laptop: 180 }, + { month: "February", desktop: 280, mobile: 180, tablet: 140, laptop: 160 }, + { month: "March", desktop: 220, mobile: 140, tablet: 160, laptop: 180 }, + { month: "April", desktop: 180, mobile: 160, tablet: 180, laptop: 200 }, + { month: "May", desktop: 250, mobile: 120, tablet: 140, laptop: 160 }, + { month: "June", desktop: 300, mobile: 180, tablet: 160, laptop: 180 }, + { month: "July", desktop: 350, mobile: 220, tablet: 180, laptop: 200 }, + { month: "August", desktop: 400, mobile: 240, tablet: 200, laptop: 220 }, + { month: "September", desktop: 450, mobile: 260, tablet: 220, laptop: 240 }, + { month: "October", desktop: 500, mobile: 280, tablet: 240, laptop: 260 }, + { month: "November", desktop: 550, mobile: 300, tablet: 260, laptop: 280 }, + { month: "December", desktop: 600, mobile: 320, tablet: 280, laptop: 300 }, + ], + small: [ + { month: "Jan", desktop: 150, mobile: 90 }, + { month: "Feb", desktop: 280, mobile: 180 }, + { month: "Mar", desktop: 220, mobile: 140 }, + ], + large: [ + { + month: "Jan 2022", + desktop: 150, + mobile: 90, + tablet: 120, + laptop: 180, + tv: 50, + watch: 30, + smartwatch: 20, + }, + { + month: "Feb 2022", + desktop: 280, + mobile: 180, + tablet: 140, + laptop: 160, + tv: 70, + watch: 40, + smartwatch: 30, + }, + { + month: "Mar 2022", + desktop: 220, + mobile: 140, + tablet: 160, + laptop: 180, + tv: 60, + watch: 35, + smartwatch: 25, + }, + { + month: "Apr 2022", + desktop: 180, + mobile: 160, + tablet: 180, + laptop: 200, + tv: 80, + watch: 45, + smartwatch: 35, + }, + { + month: "May 2022", + desktop: 250, + mobile: 120, + tablet: 140, + laptop: 160, + tv: 55, + watch: 25, + smartwatch: 20, + }, + { + month: "Jun 2022", + desktop: 300, + mobile: 180, + tablet: 160, + laptop: 180, + tv: 75, + watch: 50, + smartwatch: 30, + }, + { + month: "Jul 2022", + desktop: 350, + mobile: 220, + tablet: 180, + laptop: 200, + tv: 85, + watch: 55, + smartwatch: 35, + }, + { + month: "Aug 2022", + desktop: 400, + mobile: 240, + tablet: 200, + laptop: 220, + tv: 90, + watch: 60, + smartwatch: 40, + }, + { + month: "Sep 2022", + desktop: 450, + mobile: 260, + tablet: 220, + laptop: 240, + tv: 95, + watch: 65, + smartwatch: 45, + }, + { + month: "Oct 2022", + desktop: 500, + mobile: 280, + tablet: 240, + laptop: 260, + tv: 100, + watch: 70, + smartwatch: 50, + }, + { + month: "Nov 2022", + desktop: 550, + mobile: 300, + tablet: 260, + laptop: 280, + tv: 105, + watch: 75, + smartwatch: 55, + }, + { + month: "Dec 2022", + desktop: 600, + mobile: 320, + tablet: 280, + laptop: 300, + tv: 110, + watch: 80, + smartwatch: 60, + }, + { + month: "Jan 2023", + desktop: 650, + mobile: 340, + tablet: 300, + laptop: 320, + tv: 115, + watch: 85, + smartwatch: 65, + }, + { + month: "Feb 2023", + desktop: 700, + mobile: 360, + tablet: 320, + laptop: 340, + tv: 120, + watch: 90, + smartwatch: 70, + }, + { + month: "Mar 2023", + desktop: 750, + mobile: 380, + tablet: 340, + laptop: 360, + tv: 125, + watch: 95, + smartwatch: 75, + }, + { + month: "Apr 2023", + desktop: 800, + mobile: 400, + tablet: 360, + laptop: 380, + tv: 130, + watch: 100, + smartwatch: 80, + }, + { + month: "May 2023", + desktop: 850, + mobile: 420, + tablet: 380, + laptop: 400, + tv: 135, + watch: 105, + smartwatch: 85, + }, + { + month: "Jun 2023", + desktop: 900, + mobile: 440, + tablet: 400, + laptop: 420, + tv: 140, + watch: 110, + smartwatch: 90, + }, + ], + simple: [ + { quarter: "Q1", revenue: 1200, profit: 800 }, + { quarter: "Q2", revenue: 1500, profit: 950 }, + { quarter: "Q3", revenue: 1800, profit: 1100 }, + { quarter: "Q4", revenue: 2000, profit: 1300 }, + ], + edge: [{ period: "Current", sales: 500, target: 600 }], + numbers: [ + { category: "Small", valueA: 5, valueB: 12, valueC: 8 }, + { category: "Medium", valueA: 150, valueB: 180, valueC: 120 }, + { category: "Large", valueA: 2500, valueB: 3200, valueC: 2800 }, + { category: "XLarge", valueA: 45000, valueB: 52000, valueC: 48000 }, + ], + weekly: [ + { week: "W1", visits: 120, conversions: 15, sales: 1200 }, + { week: "W2", visits: 150, conversions: 22, sales: 1800 }, + { week: "W3", visits: 180, conversions: 28, sales: 2100 }, + { week: "W4", visits: 200, conversions: 35, sales: 2500 }, + { week: "W5", visits: 160, conversions: 18, sales: 1600 }, + { week: "W6", visits: 190, conversions: 32, sales: 2300 }, + { week: "W7", visits: 220, conversions: 40, sales: 2800 }, + { week: "W8", visits: 240, conversions: 45, sales: 3200 }, + { week: "W9", visits: 210, conversions: 38, sales: 2700 }, + { week: "W10", visits: 230, conversions: 42, sales: 3000 }, + { week: "W11", visits: 250, conversions: 48, sales: 3400 }, + { week: "W12", visits: 270, conversions: 52, sales: 3800 }, + { week: "W13", visits: 260, conversions: 50, sales: 3600 }, + { week: "W14", visits: 280, conversions: 55, sales: 4000 }, + { week: "W15", visits: 300, conversions: 60, sales: 4300 }, + { week: "W16", visits: 290, conversions: 58, sales: 4100 }, + ], + bigNumbers: [ + { company: "Apple", revenue: 394328000000, profit: 99803000000, marketCap: 3500000000000 }, // 394B, 99B, 3.5T + { company: "Microsoft", revenue: 211915000000, profit: 83383000000, marketCap: 2800000000000 }, // 211B, 83B, 2.8T + { company: "Alphabet", revenue: 307394000000, profit: 76033000000, marketCap: 2100000000000 }, // 307B, 76B, 2.1T + { company: "Amazon", revenue: 574785000000, profit: 33364000000, marketCap: 1600000000000 }, // 574B, 33B, 1.6T + { company: "Tesla", revenue: 96773000000, profit: 15000000000, marketCap: 800000000000 }, // 96B, 15B, 800B + { company: "Meta", revenue: 134902000000, profit: 39370000000, marketCap: 900000000000 }, // 134B, 39B, 900B + ], + // 🔄 EXPAND/COLLAPSE - Marketing channels dataset for legend overflow testing + expandCollapseMarketing: [ + { + channel: "Website Traffic and Organic Search Results", + impressions: 120000, + clicks: 15000, + conversions: 1200, + cost: 8500, + revenue: 24000, + roi: 182, + ctr: 12.5, + cpc: 0.57, + cpa: 7.08, + reach: 95000, + engagement: 8200, + shares: 420, + saves: 180, + comments: 650, + videoViews: 0, + }, + { + channel: "Social Media Engagement and Brand Awareness", + impressions: 85000, + clicks: 12000, + conversions: 950, + cost: 6200, + revenue: 19000, + roi: 206, + ctr: 14.1, + cpc: 0.52, + cpa: 6.53, + reach: 72000, + engagement: 15400, + shares: 890, + saves: 340, + comments: 1200, + videoViews: 28000, + }, + { + channel: "Email Marketing Campaign Performance", + impressions: 45000, + clicks: 8500, + conversions: 800, + cost: 2100, + revenue: 16000, + roi: 562, + ctr: 18.9, + cpc: 0.25, + cpa: 2.63, + reach: 42000, + engagement: 6800, + shares: 120, + saves: 85, + comments: 240, + videoViews: 0, + }, + { + channel: "Paid Advertising and PPC Campaign ROI", + impressions: 95000, + clicks: 18000, + conversions: 1500, + cost: 12500, + revenue: 30000, + roi: 140, + ctr: 18.9, + cpc: 0.69, + cpa: 8.33, + reach: 88000, + engagement: 12600, + shares: 320, + saves: 150, + comments: 480, + videoViews: 5200, + }, + { + channel: "Content Marketing and Blog Performance", + impressions: 60000, + clicks: 9500, + conversions: 750, + cost: 4800, + revenue: 15000, + roi: 213, + ctr: 15.8, + cpc: 0.51, + cpa: 6.4, + reach: 55000, + engagement: 7800, + shares: 680, + saves: 420, + comments: 950, + videoViews: 12000, + }, + { + channel: "Mobile Application Downloads and Usage", + impressions: 70000, + clicks: 11000, + conversions: 1100, + cost: 5500, + revenue: 22000, + roi: 300, + ctr: 15.7, + cpc: 0.5, + cpa: 5.0, + reach: 65000, + engagement: 9200, + shares: 180, + saves: 95, + comments: 320, + videoViews: 8500, + }, + { + channel: "Customer Support Response Time and Quality", + impressions: 35000, + clicks: 5500, + conversions: 450, + cost: 2800, + revenue: 9000, + roi: 221, + ctr: 15.7, + cpc: 0.51, + cpa: 6.22, + reach: 32000, + engagement: 4200, + shares: 45, + saves: 25, + comments: 180, + videoViews: 0, + }, + { + channel: "Sales Funnel Conversion and Lead Generation", + impressions: 110000, + clicks: 22000, + conversions: 1800, + cost: 15000, + revenue: 36000, + roi: 140, + ctr: 20.0, + cpc: 0.68, + cpa: 8.33, + reach: 98000, + engagement: 18500, + shares: 420, + saves: 280, + comments: 750, + videoViews: 3200, + }, + { + channel: "User Retention and Churn Rate Analysis", + impressions: 80000, + clicks: 14000, + conversions: 1200, + cost: 7200, + revenue: 24000, + roi: 233, + ctr: 17.5, + cpc: 0.51, + cpa: 6.0, + reach: 75000, + engagement: 11200, + shares: 280, + saves: 150, + comments: 420, + videoViews: 6800, + }, + { + channel: "Product Feature Usage and Performance Metrics", + impressions: 65000, + clicks: 10500, + conversions: 900, + cost: 5200, + revenue: 18000, + roi: 246, + ctr: 16.2, + cpc: 0.5, + cpa: 5.78, + reach: 58000, + engagement: 8500, + shares: 320, + saves: 180, + comments: 650, + videoViews: 4200, + }, + { + channel: "Market Research and Competitive Analysis", + impressions: 40000, + clicks: 6500, + conversions: 500, + cost: 3200, + revenue: 10000, + roi: 213, + ctr: 16.3, + cpc: 0.49, + cpa: 6.4, + reach: 36000, + engagement: 5200, + shares: 95, + saves: 65, + comments: 220, + videoViews: 1800, + }, + { + channel: "Brand Sentiment and Public Relations Impact", + impressions: 55000, + clicks: 8000, + conversions: 650, + cost: 4100, + revenue: 13000, + roi: 217, + ctr: 14.5, + cpc: 0.51, + cpa: 6.31, + reach: 48000, + engagement: 6800, + shares: 520, + saves: 280, + comments: 950, + videoViews: 15000, + }, + ], + singleGroup: [ + { month: "January", sales: 150 }, + { month: "February", sales: 280 }, + { month: "March", sales: 220 }, + { month: "April", sales: 180 }, + { month: "May", sales: 250 }, + { month: "June", sales: 300 }, + { month: "July", sales: 350 }, + { month: "August", sales: 400 }, + ], +}; + +// Category key mappings for different datasets +const categoryKeys = { + default: "month", + small: "month", + large: "month", + simple: "quarter", + edge: "period", + numbers: "category", + weekly: "week", + bigNumbers: "company", + expandCollapseMarketing: "channel", + singleGroup: "month", +}; + +// 🔥 ACTIVE DATA - For backward compatibility +const barChartData = dataVariations.default; const icons = { desktop: Monitor, mobile: TabletSmartphone, } as const; +/** + * # BarChart Component Documentation + * + * The BarChart is a versatile component for comparing values across different categories. + * It's highly effective for: + * + * - **Category Comparison**: Easily compare metrics like sales, users, or revenue across different groups. + * - **Ranking**: Show the highest and lowest performing items in a dataset. + * - **Data Over Time**: Visualize changes across discrete time intervals (e.g., monthly sales). + * + * ## Key Features + * + * ### Layout Variants + * - **Grouped**: Compare sub-categories side-by-side within a primary category. + * - **Stacked**: Show how sub-categories contribute to a total value for each primary category. + * + * ### Interactive & Responsive + * - **Horizontal Scrolling**: Automatically enables scrolling when the number of bars exceeds the container width. + * - **Interactive Legend**: Toggle data series on and off. Handles overflow with a "Show More" button. + * - **Hover Tooltips**: Provides detailed data on hover for better user engagement. + * - **Responsive Design**: Adjusts gracefully to the size of its container. + * + * ### Customization + * - **Theming**: Six built-in color palettes. + * - **Bar Styling**: Customize the corner radius of the bars. + * - **Axis and Grid Control**: Toggle visibility of axes and grid lines. + */ const meta: Meta> = { title: "Components/Charts/BarChart", component: BarChart, @@ -24,133 +533,170 @@ const meta: Meta> = { layout: "centered", docs: { description: { - component: "```tsx\nimport { BarChart } from '@crayon-ui/react-ui/Charts/BarChart';\n```", + component: ` +## Installation and Basic Usage + +\`\`\`tsx +import { BarChart } from '@crayon-ui/react-ui/Charts/BarChart'; + +const monthlyData = [ + { month: "January", desktop: 150, mobile: 90 }, + { month: "February", desktop: 280, mobile: 180 }, + { month: "March", desktop: 220, mobile: 140 }, +]; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +Your data should be an array of objects. Each object must contain: +- A **category field** (string or number): Used for the X-axis labels (e.g., months, quarters). +- One or more **data series fields** (number): These are the values that will be plotted as bars. The key of the field is used as the series name in the legend and tooltip. + +\`\`\`tsx +const salesData = [ + { region: "North", sales: 5400, marketing: 1200 }, + { region: "South", sales: 8200, marketing: 1500 }, + { region: "East", sales: 7100, marketing: 1300 }, +]; +\`\`\` + +## Performance Considerations +- **Data Volume**: The chart uses virtualization for the X-axis, but performance can still be impacted by an extremely large number of data points (e.g., 1000+). +- **Responsiveness**: The chart adapts to its container, but ensure that bar widths and gaps are readable on very small screens. +- **Animation**: Can be disabled via \`isAnimationActive={false}\` for better performance with large datasets. +`, }, }, }, tags: ["!dev", "autodocs"], argTypes: { data: { - description: - "An array of data objects where each object represents a data point. Each object should have a category field (e.g., month) and one or more numeric values for the areas to be plotted.", + description: ` +**Required.** An array of data objects representing your dataset. Each object should contain: +- A category identifier (string/number) for the X-axis. +- One or more numeric values for the Y-axis bars. +`, control: false, table: { type: { summary: "Array>" }, - defaultValue: { summary: "[]" }, - category: "Data", + category: "📊 Data Configuration", }, }, categoryKey: { description: - "The key from your data object to be used as the x-axis categories (e.g., 'month', 'year', 'date')", + "**Required.** The key in your data object that represents the category for the X-axis (e.g., 'month', 'department').", control: false, table: { type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + category: "📊 Data Configuration", }, }, theme: { - description: - "The color palette theme for the chart. Each theme provides a different set of colors for the areas.", + description: "Specifies the color palette for the chart's bars, tooltips, and legend.", control: "select", options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], table: { defaultValue: { summary: "ocean" }, - category: "Appearance", - }, - }, - icons: { - description: - "An object that maps data keys to icon components. These icons will appear in the legend next to their corresponding data series.", - control: false, - table: { - type: { summary: "Record" }, - defaultValue: { summary: "{}" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, variant: { description: - "The style of the bar chart. 'grouped' shows bars side by side, while 'stacked' shows bars stacked on top of each other.", + "Defines how multiple data series are displayed: `grouped` (side-by-side) or `stacked` (on top of each other).", control: "radio", options: ["grouped", "stacked"], table: { defaultValue: { summary: "grouped" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, radius: { - description: "The radius of the rounded corners of the bars", - control: false, + description: "Sets the corner radius for the top of each bar, creating a rounded look.", + control: { type: "number", min: 0, max: 20 }, table: { type: { summary: "number" }, defaultValue: { summary: "4" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, - grid: { - description: "Whether to display the background grid lines in the chart", - control: "boolean", + icons: { + description: + "An object mapping data series keys to React components to be used as icons in the legend.", + control: false, table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", + type: { summary: "Record" }, + category: "🎨 Visual Styling", }, }, - label: { - description: "Whether to display data point labels above each point on the chart", + grid: { + description: "Toggles the visibility of the background grid lines.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, legend: { - description: - "Whether to display the chart legend showing the data series names and their corresponding colors/icons", + description: "Toggles the visibility of the chart legend.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, - isAnimationActive: { - description: "Whether to animate the chart", + showYAxis: { + description: "Toggles the visibility of the Y-axis line and labels.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, - showYAxis: { - description: "Whether to display the y-axis", + xAxisLabel: { + description: "A label to display below the X-axis.", + control: "text", + table: { + type: { summary: "React.ReactNode" }, + category: "📱 Display Options", + }, + }, + yAxisLabel: { + description: "A label to display beside the Y-axis.", + control: "text", + table: { + type: { summary: "React.ReactNode" }, + category: "📱 Display Options", + }, + }, + isAnimationActive: { + description: "Enables or disables the initial loading animation for the bars.", control: "boolean", table: { - type: { summary: "boolean" }, - defaultValue: { summary: "false" }, - category: "Display", + defaultValue: { summary: "true" }, + category: "🎬 Animation & Interaction", }, }, - xAxisLabel: { - description: "The label for the x-axis", - control: false, + height: { + description: "Sets a fixed height for the chart container in pixels.", + control: "number", table: { - type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + type: { summary: "number" }, + category: "Layout & Sizing", }, }, - yAxisLabel: { - description: "The label for the y-axis", - control: false, + width: { + description: "Sets a fixed width for the chart container in pixels.", + control: "number", table: { - type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + type: { summary: "number" }, + category: "Layout & Sizing", }, }, }, @@ -159,8 +705,20 @@ const meta: Meta> = { export default meta; type Story = StoryObj; -export const BarChartStory: Story = { - name: "Bar Chart", +/** + * ## Comprehensive Data Explorer + * + * This story serves as a comprehensive test suite for the BarChart component. + * Use the buttons to switch between various datasets designed to test edge cases in: + * + * - **Data Volume**: Scenarios with few, many, or a single data point. + * - **Data Formatting**: Handling of large numbers and different value ranges. + * - **Legend Overflow**: A dataset with many series to test legend expand/collapse functionality. + * + * It's an excellent tool for developers to see how the chart behaves under different conditions. + */ +export const DataExplorer: Story = { + name: "🎛️ Comprehensive Data Explorer", args: { data: barChartData, categoryKey: "month", @@ -168,149 +726,510 @@ export const BarChartStory: Story = { variant: "grouped", radius: 4, grid: true, - label: true, + isAnimationActive: true, + showYAxis: true, + // height: 500, + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + }; + + const activeButtonStyle = { + ...buttonStyle, + background: "#007acc", + color: "white", + border: "1px solid #007acc", + }; + + return ( +
+
+ 💡 Quick Data Switch: +
+ + + + + + + + + +
+
+ Current: {selectedDataType} | Items:{" "} + {currentData.length} | Category: {currentCategoryKey} +
+
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: + "Use the buttons above the chart to quickly switch between different data variations and test the scrolling functionality. Active button is highlighted in blue.", + }, + }, + }, +}; + +/** + * ## Small Dataset (No Scroll) + * + * This story demonstrates the BarChart's appearance with a small dataset. + * When the number of bars fits within the container width, horizontal scrolling is disabled. + */ +export const SmallDataStory: Story = { + name: "📱 Small Data (No Scroll)", + args: { + data: dataVariations.small as any, + categoryKey: "month" as any, + theme: "ocean", + variant: "grouped", + radius: 2, + grid: true, + isAnimationActive: true, + showYAxis: true, legend: true, + }, + render: (args: any) => ( + + + + ), +}; + +/** + * ## Large Dataset (With Scrolling) + * + * This story showcases the chart's horizontal scrolling capability. + * When the total width of the bars exceeds the container's width, users can scroll to view all data points. + */ +export const LargeDataStory: Story = { + name: "🌐 Large Data (Scrolling)", + args: { + data: dataVariations.large as any, + categoryKey: "month" as any, + theme: "emerald", + variant: "grouped", + radius: 2, + grid: true, isAnimationActive: true, - showYAxis: false, + showYAxis: true, + legend: true, }, render: (args: any) => ( - + ), - parameters: { - docs: { - source: { - code: ` -const barChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; +}; - - - -`, - }, - }, +/** + * ## Weekly Data (Many Categories) + * + * Similar to the large dataset, this story tests the chart with a high number of categories, + * which should trigger horizontal scrolling. + */ +export const WeeklyDataStory: Story = { + name: "📈 Weekly Data (Many Categories)", + args: { + data: dataVariations.weekly as any, + categoryKey: "week" as any, + theme: "sunset", + variant: "grouped", + radius: 2, + grid: true, + isAnimationActive: true, + showYAxis: true, + legend: true, }, + render: (args: any) => ( + + + + ), }; -export const BarChartStoryWithIcons: Story = { - name: "Bar Chart with Icons", +/** + * ## Big Numbers + * + * This story tests the chart's ability to handle and format very large numbers, + * such as billions and trillions, on the Y-axis. The axis labels should be formatted + * with appropriate suffixes (e.g., "B" for billion, "T" for trillion). + */ +export const BigNumbersStory: Story = { + name: "💰 Big Numbers (Billions/Trillions)", args: { - ...BarChartStory.args, - icons: icons, + data: dataVariations.bigNumbers as any, + categoryKey: "company" as any, + theme: "vivid", + variant: "grouped", + radius: 2, + grid: true, + isAnimationActive: true, + showYAxis: true, + legend: true, }, render: (args: any) => ( ), - parameters: { - docs: { - source: { - code: ` - import { Monitor, TabletSmartphone } from "lucide-react"; - - const barChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, - ]; - - const icons = { - desktop: Monitor, - mobile: TabletSmartphone, - }; +}; - - +/** + * ## Edge Case (Single Data Point) + * + * This story demonstrates how the chart renders with only a single data point. + * It's a useful test to ensure that the layout and axes behave correctly in minimal-data scenarios. + */ +export const EdgeCaseStory: Story = { + name: "🎯 Edge Case (Single Data Point)", + args: { + data: dataVariations.edge as any, + categoryKey: "period" as any, + theme: "orchid", + variant: "grouped", + radius: 2, + grid: true, + isAnimationActive: true, + showYAxis: true, + legend: true, + }, + render: (args: any) => ( + + - `, - }, - }, + ), +}; + +/** + * ## Number Ranges (Scale Testing) + * + * This story tests the Y-axis scaling with data points of vastly different magnitudes. + * The chart should automatically adjust its scale to accommodate all values appropriately. + */ +export const NumberRangesStory: Story = { + name: "🔢 Number Ranges (Scale Testing)", + args: { + data: dataVariations.numbers as any, + categoryKey: "category" as any, + theme: "spectrum", + variant: "grouped", + radius: 2, + grid: true, + isAnimationActive: true, + showYAxis: true, + legend: true, }, + render: (args: any) => ( + + + + ), }; -export const BarChartStoryWithYAxis: Story = { - name: "Bar Chart with Y-Axis and Axis Labels", +/** + * ## Legend Expand/Collapse + * + * Tests the legend's expand/collapse functionality with a large number of data series. + * The legend should automatically show a "Show More" button when items overflow, + * allowing users to toggle between a collapsed and expanded view. + */ +export const ExpandCollapseMarketingStory: Story = { + name: "🔄 Legend Expand/Collapse", args: { - ...BarChartStory.args, + data: dataVariations.expandCollapseMarketing as any, + categoryKey: "channel" as any, + theme: "emerald", + variant: "grouped", + radius: 4, + grid: true, + isAnimationActive: true, showYAxis: true, - xAxisLabel: "Time Period", - yAxisLabel: "Number of Users", + legend: true, }, render: (args: any) => ( - + ), parameters: { docs: { - source: { - code: ` - import { Monitor, TabletSmartphone } from "lucide-react"; - - const barChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, - ]; - - - - - `, + description: { + story: + "Tests the legend expand/collapse functionality with 12 marketing channels that have long descriptive names. The legend should automatically show a 'Show More' button when items overflow the container width, allowing users to toggle between collapsed and expanded states.", }, }, }, }; + +/** + * ## Responsive Behavior Demo + * + * This story demonstrates the responsive capabilities of the BarChart. Drag the handles + * on the container to resize it and observe how the chart adapts. + * + * **Responsive Features:** + * - **Bar Sizing**: Bar widths and gaps adjust to the container size. + * - **Layout Adaptation**: Chart elements are repositioned to fit the available space. + * - **Legend Behavior**: The legend may wrap or show expand/collapse buttons as needed. + */ +export const ResponsiveBehaviorDemo: Story = { + name: "📱 Responsive Behavior Demo", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + theme: "sunset", + variant: "grouped", + grid: true, + legend: true, + isAnimationActive: false, + showYAxis: true, + }, + render: (args: any) => { + const [dimensions, setDimensions] = useState<{ width: number; height: number | string }>({ + width: 700, + height: "auto", + }); + + const handleMouseDown = (e: React.MouseEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = dimensions.width; + const startHeight = (e.currentTarget.parentElement as HTMLElement).offsetHeight; + + const doDrag = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let newWidth = startWidth; + let newHeight = startHeight; + + if (handle.includes("e")) newWidth = startWidth + dx; + if (handle.includes("w")) newWidth = startWidth - dx; + if (handle.includes("s")) newHeight = startHeight + dy; + if (handle.includes("n")) newHeight = startHeight - dy; + + setDimensions({ + width: Math.max(300, newWidth), + height: "auto", + }); + }; + + const stopDrag = () => { + document.removeEventListener("mousemove", doDrag); + document.removeEventListener("mouseup", stopDrag); + }; + + document.addEventListener("mousemove", doDrag); + document.addEventListener("mouseup", stopDrag); + }; + + const handleStyle: React.CSSProperties = { + position: "absolute", + background: "#3b82f6", + opacity: 0.6, + zIndex: 10, + transition: "opacity 0.2s ease", + }; + + return ( +
+
+

+ Responsive Behavior Demonstration +

+

+ Drag the edges or corners of the container below to see how the chart adapts +

+
+ 🎯 Try This: Resize the container to see how the bar widths and layout + adjustments. +
+
+ + + + + {/* Resize Handles */} +
handleMouseDown(e, "n")} + /> +
handleMouseDown(e, "s")} + /> +
handleMouseDown(e, "w")} + /> +
handleMouseDown(e, "e")} + /> + + {/* Corner Handles */} +
handleMouseDown(e, "nw")} + /> +
handleMouseDown(e, "ne")} + /> +
handleMouseDown(e, "se")} + /> + + {/* Dimension Display */} +
+ {dimensions.width}px ×{" "} + {typeof dimensions.height === "number" ? `${dimensions.height}px` : "auto"} +
+ +
+ ); + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/types/index.ts b/js/packages/react-ui/src/components/Charts/BarChart/types/index.ts new file mode 100644 index 000000000..6f3195c54 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/BarChart/types/index.ts @@ -0,0 +1,3 @@ +export type BarChartVariant = "grouped" | "stacked"; + +export type BarChartData = Array>; diff --git a/js/packages/react-ui/src/components/Charts/BarChart/utils/BarChartUtils.ts b/js/packages/react-ui/src/components/Charts/BarChart/utils/BarChartUtils.ts new file mode 100644 index 000000000..93b167e85 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/BarChart/utils/BarChartUtils.ts @@ -0,0 +1,301 @@ +import { getDataKeys } from "../../utils/dataUtils"; +import { BarChartVariant } from "../types"; + +export const BAR_WIDTH = 16; + +// Internal constants - not exported as they're only used within this file +const ELEMENT_SPACING_GROUPED = 56; // Spacing per bar in grouped charts + +const ELEMENT_SPACING_STACKED = 56; // Spacing per stack in stacked charts + +/** + * INTERNAL HELPER FUNCTION + * Get the appropriate element spacing based on chart variant + * @param variant - The chart variant + * @returns The spacing value for the given variant + */ +const getElementSpacing = (variant: BarChartVariant): number => { + switch (variant) { + case "stacked": + return ELEMENT_SPACING_STACKED; + case "grouped": + default: + return ELEMENT_SPACING_GROUPED; + } +}; + +/** + * This function returns the width of the data in the chart, used for padding calculation, scroll amount calculation, and + * for the width of the chart container. + * @param data - The data to be displayed in the chart. + * @param categoryKey - The key of the category to be displayed in the chart. + * @param variant - The variant of the chart. + */ +const getWidthOfData = ( + data: Array>, + categoryKey: string, + variant: BarChartVariant, +) => { + if (data.length === 0) { + return 0; + } + + const width = data.length * getWidthOfGroup(data, categoryKey, variant); + + if (data.length === 1) { + const minSingleDataWidth = 200; // Minimum width for single data points + return Math.max(width, minSingleDataWidth); + } + return width; +}; + +/** + * This function returns the padding for the chart, used for the padding of the chart container. + * @param data - The data to be displayed in the chart. + * @param categoryKey - The key of the category to be displayed in the chart. + * @param containerWidth - The width of the container of the chart. + * @param variant - The variant of the chart. + */ +const getPadding = ( + data: Array>, + categoryKey: string, + containerWidth: number, + variant: BarChartVariant, +) => { + const chartWidth = getWidthOfData(data, categoryKey, variant); + const paddingValue = containerWidth - chartWidth; + + if (paddingValue < 0) { + // If chart content is wider than container, no padding + return { + left: 1, + right: 1, + }; + } else { + return { + left: paddingValue / 2, + right: paddingValue / 2, + }; + } +}; + +/** + * This function returns the radius for the chart, used for the radius of the LineInBarShape.tsx. + * @param variant - The variant of the chart. + * @param radius - The radius of the chart. + * @param isFirst - Whether the first item in the stack. + * @param isLast - Whether the last item in the stack. + */ +const getRadiusArray = ( + variant: BarChartVariant, + radius: number, + isFirst?: boolean, + isLast?: boolean, +): [number, number, number, number] => { + if (variant === "grouped") { + return [radius, radius, 0, 0]; + } else if (variant === "stacked") { + if (isFirst && isLast) { + // Single item in stack + return [radius, radius, radius, radius]; + } + if (isFirst) { + // Bottom of the stack + return [0, 0, 0, 0]; + } + if (isLast) { + // Top of the stack + return [radius, radius, 0, 0]; + } + // Middle of the stack + return [0, 0, 0, 0]; + } + // Default or other variants + return [radius, radius, radius, radius]; +}; + +/** + * INTERNAL HELPER FUNCTION + * This function returns the formatter for the X-axis tick values with intelligent truncation. + * @param groupWidth - The width available for each group/category (optional) + * @param variant - The chart variant (affects truncation logic) + * @returns The formatter for the X-axis tick values. + * internally used by the XAxis component reCharts + */ +const getXAxisTickFormatter = (groupWidth?: number, variant: BarChartVariant = "grouped") => { + const PADDING = 2; // Safety padding for better visual spacing + + // Setup canvas context for accurate text measurement. + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context) { + context.font = "12px Inter"; + } + + return (value: string) => { + const stringValue = String(value); + + // Fallback for SSR, or if canvas/groupWidth is not available. + if (!context || !groupWidth) { + if (variant === "stacked") { + return stringValue.slice(0, 3); + } else { + return stringValue.length > 3 ? `${stringValue.slice(0, 3)}...` : stringValue; + } + } + + const availableWidth = Math.max(0, groupWidth - PADDING); + + if (context.measureText(stringValue).width <= availableWidth) { + return stringValue; // Full text fits. + } + + // If text overflows, find the best truncation point with ellipsis. + let low = 0; + let high = stringValue.length; + let result = ""; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (mid === 0) { + low = mid + 1; + continue; + } + const truncated = stringValue.substring(0, mid) + "..."; + if (context.measureText(truncated).width <= availableWidth) { + result = truncated; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return result; + }; +}; + +/** + * Helper function to get the optimal X-axis tick formatter with calculated group width + * @param data - The chart data + * @param categoryKey - The category key + * @param variant - The chart variant + * @returns The optimized formatter function + */ +const getOptimalXAxisTickFormatter = ( + data: Array>, + categoryKey: string, + variant: BarChartVariant, +) => { + // Calculate the available width per group + const groupWidth = getWidthOfGroup(data, categoryKey, variant); + return getXAxisTickFormatter(groupWidth, variant); +}; + +/** + * INTERNAL HELPER FUNCTION + * This function returns the scroll amount for the chart, used for the scroll amount of the chart. + * This can also be used to calculate the width of each group/category. + * @param data - The data to be displayed in the chart. + * @param categoryKey - The key of the category to be displayed in the chart. + * @param variant - The variant of the chart. + */ +const getWidthOfGroup = ( + data: Array>, + categoryKey: string, + variant: BarChartVariant, +) => { + if (data.length === 0) return 200; // Fallback + + // Get the number of data keys (excluding categoryKey) + const dataKeys = getDataKeys(data, categoryKey); + const elementSpacing = getElementSpacing(variant); + + if (variant === "stacked") { + // For stacked: each category is one stack + // Example: month "January" = 1 stack = BAR_WIDTH + ELEMENT_SPACING_STACKED (60) + return BAR_WIDTH + elementSpacing; + } else { + // For grouped: each category contains multiple bars + // Example: month "January" with desktop+mobile+tablet = 3 bars + // Width = 3 * (BAR_WIDTH + gap between bars) - gap between bars for the last bar + elementSpacing + const seriesPerCategory = dataKeys.length; + return seriesPerCategory * (BAR_WIDTH + 8) - 8 + elementSpacing; + } +}; + +/** + * This function returns the snap positions for the chart, used for the snap positions of the chart. + * @param data - The data to be displayed in the chart. + * @param categoryKey - The key of the category to be displayed in the chart. + * @param variant - The variant of the chart. + * @returns The snap positions for the chart. + */ +const getSnapPositions = ( + data: Array>, + categoryKey: string, + variant: BarChartVariant, +): number[] => { + if (data.length === 0) return [0]; + + const positions = [0]; // Start position + const groupWidthValue = getWidthOfGroup(data, categoryKey, variant); + + // Calculate all valid snap positions based on groups + for (let i = 1; i < data.length; i++) { + positions.push(i * groupWidthValue); + } + + return positions; +}; + +/** + * This function returns the nearest snap position for the chart, used for the nearest snap position of the chart. + * @param snapPositions - The snap positions for the chart. + * @param currentScroll - The current scroll of the chart. + * @param direction - The direction of the scroll. + * @returns The nearest snap position for the chart. + */ +const findNearestSnapPosition = ( + snapPositions: number[], + currentScroll: number, + direction: "left" | "right", +): number => { + // Find current position index + let currentIndex = 0; + for (let i = 0; i < snapPositions.length; i++) { + const snapPosition = snapPositions[i]!; + if (currentScroll >= snapPosition) { + currentIndex = i; + } else { + break; + } + } + + if (direction === "left") { + // Go to previous snap position + return Math.max(0, currentIndex - 1); + } else { + // Go to next snap position + return Math.min(snapPositions.length - 1, currentIndex + 1); + } +}; + +/** + * This function returns the chart height for the chart, used for the chart height of the chart. + * @param containerWidth - The width of the container of the chart. + * @returns The chart height for the chart. + * 16:9 aspect ratio + * to change the aspect ratio, change the 9/16 to the desired aspect ratio + */ +const getChartHeight = (containerWidth: number): number => { + return containerWidth ? containerWidth * (9 / 16) : 400; +}; + +export { + findNearestSnapPosition, + getChartHeight, + getOptimalXAxisTickFormatter, + getPadding, + getRadiusArray, + getSnapPositions, + getWidthOfData, +}; diff --git a/js/packages/react-ui/src/components/Charts/Charts.tsx b/js/packages/react-ui/src/components/Charts/Charts.tsx index 6cabbd17d..1fb42b4c7 100644 --- a/js/packages/react-ui/src/components/Charts/Charts.tsx +++ b/js/packages/react-ui/src/components/Charts/Charts.tsx @@ -23,9 +23,20 @@ export type ChartConfig = { [k in string]: { label?: React.ReactNode; icon?: React.ComponentType; + transformed?: string; } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record } + | { color?: string; secondaryColor?: string; theme?: never } + | { + color?: never; + theme: Record< + keyof typeof THEMES, + | string + | { + color: string; + secondaryColor?: string; + } + >; + } ); }; @@ -34,6 +45,7 @@ export type ChartConfig = { */ type ChartContextProps = { config: ChartConfig; + id: string; }; const ChartContext = createContext(null); @@ -53,7 +65,21 @@ function useChart() { } export function keyTransform(key: string) { - return key.replaceAll(/\s/g, "-").replaceAll("%", "__per__"); + return ( + key + // Replace whitespace with hyphens + .replaceAll(/\s+/g, "-") + // Replace any character that's not alphanumeric, hyphen, or underscore with hyphen + .replaceAll(/[^a-zA-Z0-9_-]/g, "-") + // Remove multiple consecutive hyphens + .replaceAll(/-+/g, "-") + // Remove leading/trailing hyphens + .replace(/^-+|-+$/g, "") + // Ensure it doesn't start with a number (prepend 'key-' if it does) + .replace(/^(\d)/, "key-$1") ?? + // Fallback with unique ID if the key is empty + `key-${uniqueId()}` + ); } /** @@ -74,11 +100,24 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { ([theme, prefix]) => ` ${prefix} [data-chart=${id}] { ${colorConfig - .map(([key, itemConfig]) => { - const transformedKey = keyTransform(key); + .map(([_, itemConfig]) => { + const transformedKey = itemConfig.transformed; + const themeValue = itemConfig.theme?.[theme as keyof typeof itemConfig.theme]; const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; - return color ? ` --color-${transformedKey}: ${color};` : null; + typeof themeValue === "string" ? themeValue : themeValue?.color || itemConfig.color; + const secondaryColor = + typeof themeValue === "object" + ? themeValue?.secondaryColor + : "secondaryColor" in itemConfig + ? itemConfig.secondaryColor + : undefined; + + return [ + color ? ` --color-${transformedKey}: ${color};` : null, + secondaryColor ? ` --color-${transformedKey}-secondary: ${secondaryColor};` : null, + ] + .filter(Boolean) + .join("\n"); }) .filter(Boolean) .join("\n")} @@ -99,13 +138,17 @@ const ChartContainer = forwardRef< ComponentProps<"div"> & { config: ChartConfig; children: React.ComponentProps["children"]; + rechartsProps?: Omit< + React.ComponentProps, + "children" + >; } ->(({ id, className, children, config, ...props }, ref) => { +>(({ id, className, children, config, rechartsProps, ...props }, ref) => { const uniqueId = useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + const chartId = `crayon-chart-${id || uniqueId.replace(/:/g, "")}`; return ( - +
- {children} + + {children} +
); @@ -268,6 +313,9 @@ const ChartTooltipContent = forwardRef< ); ChartTooltipContent.displayName = "ChartTooltip"; +// this is not used any more, in the new chart, we are using the default legend which is rendered outside the charts container, +// older charts are still using this legend. + /** * Re-exported Legend component from Recharts */ @@ -362,4 +410,6 @@ export { ChartStyle, ChartTooltip, ChartTooltipContent, + getPayloadConfigFromPayload, + useChart, }; diff --git a/js/packages/react-ui/src/components/Charts/LineChart/LineChart.tsx b/js/packages/react-ui/src/components/Charts/LineChart/LineChart.tsx index 6463d3858..1affc555e 100644 --- a/js/packages/react-ui/src/components/Charts/LineChart/LineChart.tsx +++ b/js/packages/react-ui/src/components/Charts/LineChart/LineChart.tsx @@ -1,239 +1,408 @@ -import React from "react"; -import { LabelList, Line, LineChart as RechartsLineChart, XAxis, YAxis } from "recharts"; -import { useLayoutContext } from "../../../context/LayoutContext"; +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Line, LineChart as RechartsLineChart, XAxis, YAxis } from "recharts"; +import { useId } from "../../../polyfills"; +import { IconButton } from "../../IconButton"; +import { ChartConfig, ChartContainer, ChartTooltip } from "../Charts"; +import { SideBarChartData, SideBarTooltipProvider } from "../context/SideBarTooltipContext"; +import { useTransformedKeys } from "../hooks"; import { - ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - keyTransform, -} from "../Charts"; -import { cartesianGrid } from "../cartesianGrid"; -import { getDistributedColors, getPalette } from "../utils/PalletUtils"; - -export type LineChartData = Array>; + ActiveDot, + cartesianGrid, + CustomTooltipContent, + DefaultLegend, + SideBarTooltip, + XAxisTick, + YAxisTick, +} from "../shared"; +import { LegendItem } from "../types"; +import { + findNearestSnapPosition, + getOptimalXAxisTickFormatter, + getSnapPositions, + getWidthOfData, + getXAxisTickPositionData, +} from "../utils/AreaAndLine/AreaAndLineUtils"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; +import { + get2dChartConfig, + getColorForDataKey, + getDataKeys, + getLegendItems, +} from "../utils/dataUtils"; +import { getYAxisTickFormatter } from "../utils/styleUtils"; +import { LineChartData, LineChartVariant } from "./types"; + +type LineChartOnClick = React.ComponentProps["onClick"]; +type LineClickData = Parameters>[0]; export interface LineChartProps { data: T; categoryKey: keyof T[number]; - theme?: "ocean" | "orchid" | "emerald" | "sunset" | "spectrum" | "vivid"; - variant?: "linear" | "natural" | "step"; + theme?: PaletteName; + variant?: LineChartVariant; grid?: boolean; - label?: boolean; legend?: boolean; - strokeWidth?: number; icons?: Partial>; isAnimationActive?: boolean; showYAxis?: boolean; xAxisLabel?: React.ReactNode; yAxisLabel?: React.ReactNode; + className?: string; + height?: number; + width?: number; + strokeWidth?: number; } +const Y_AXIS_WIDTH = 40; // Width of Y-axis chart when shown + export const LineChart = ({ data, categoryKey, theme = "ocean", variant = "natural", grid = true, - label = true, - legend = true, - strokeWidth = 2, icons = {}, - isAnimationActive = true, - showYAxis = false, + isAnimationActive = false, + showYAxis = true, xAxisLabel, yAxisLabel, + legend = true, + className, + height, + width, + strokeWidth = 2, }: LineChartProps) => { - // excluding the categoryKey - const dataKeys = Object.keys(data[0] || {}).filter((key) => key !== categoryKey); - - const palette = getPalette(theme); - const colors = getDistributedColors(palette, dataKeys.length); - const { layout } = useLayoutContext(); - - // Create Config - const chartConfig: ChartConfig = dataKeys.reduce( - (config, key, index) => ({ - ...config, - [key]: { - label: key, - icon: icons[key], - color: colors[index], - }, - }), - {}, - ); + const dataKeys = useMemo(() => { + return getDataKeys(data, categoryKey as string); + }, [data, categoryKey]); - const getTickFormatter = (data: T) => { - const dataLength = data.length; - const maxLengthMap = { - mobile: { - default: 5, - 10: 4, - 11: 4, - }, - tray: { - default: 5, - 8: 4, - 9: 4, - 10: 4, - 11: 4, - }, - copilot: { - default: 5, - 8: 4, - 9: 4, - 10: 4, - 11: 4, - }, - fullscreen: { - default: 5, - 11: 4, - }, - }; + const transformedKeys = useTransformedKeys(dataKeys); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, dataKeys.length); + }, [theme, dataKeys.length]); + + const chartConfig: ChartConfig = useMemo(() => { + return get2dChartConfig(dataKeys, colors, transformedKeys, undefined, icons); + }, [dataKeys, icons, colors, transformedKeys]); + + const chartContainerRef = useRef(null); + const mainContainerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const [isSideBarTooltipOpen, setIsSideBarTooltipOpen] = useState(false); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const [sideBarTooltipData, setSideBarTooltipData] = useState({ + title: "", + values: [], + }); + + // Use provided width or observed width + const effectiveWidth = useMemo(() => { + return width ?? containerWidth; + }, [width, containerWidth]); + + const effectiveContainerWidth = useMemo(() => { + const yAxisWidth = showYAxis ? Y_AXIS_WIDTH : 0; + return Math.max(0, effectiveWidth - yAxisWidth - 40); // -40 because we are giving 20px padding in xAxis on each side + }, [effectiveWidth, showYAxis]); + + const dataWidth = useMemo(() => { + return getWidthOfData(data, effectiveContainerWidth); + }, [data, effectiveContainerWidth]); + + // Calculate snap positions for proper scrolling alignment + const snapPositions = useMemo(() => { + return getSnapPositions(data); + }, [data]); + + const chartHeight = useMemo(() => { + return height ?? 296; + }, [height]); + + // Calculate optimal tick formatter for collision detection and truncation + const xAxisTickFormatter = useMemo(() => { + return getOptimalXAxisTickFormatter(data, effectiveContainerWidth); + }, [data, effectiveContainerWidth]); + + // Calculate position data for X-axis tick offset handling + const xAxisPositionData = useMemo(() => { + return getXAxisTickPositionData(data, categoryKey as string); + }, [data, categoryKey]); + + // Check scroll boundaries + const updateScrollState = useCallback(() => { + if (mainContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = mainContainerRef.current; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); // -1 for floating point precision + } + }, []); + + const scrollLeft = useCallback(() => { + if (mainContainerRef.current) { + const currentScroll = mainContainerRef.current.scrollLeft; + const targetIndex = findNearestSnapPosition(snapPositions, currentScroll, "left"); + const targetPosition = snapPositions[targetIndex] ?? 0; - const layoutConfig = - maxLengthMap[layout as keyof typeof maxLengthMap] || maxLengthMap.fullscreen; + mainContainerRef.current.scrollTo({ + left: targetPosition, + behavior: "smooth", + }); + } + }, [snapPositions]); - const maxLength = - dataLength >= 11 - ? 4 - : layoutConfig[dataLength as keyof typeof layoutConfig] || layoutConfig.default; + const scrollRight = useCallback(() => { + if (mainContainerRef.current) { + const currentScroll = mainContainerRef.current.scrollLeft; + const targetIndex = findNearestSnapPosition(snapPositions, currentScroll, "right"); + const targetPosition = snapPositions[targetIndex] ?? 0; - return (value: string) => { - if (value.length > maxLength) { - return `${value.slice(0, maxLength)}...`; + mainContainerRef.current.scrollTo({ + left: targetPosition, + behavior: "smooth", + }); + } + }, [snapPositions]); + + useEffect(() => { + // Only set up ResizeObserver if width is not provided + if (width || !chartContainerRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((entries) => { + // there is only one entry in the entries array because we are observing the chart container + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); } - return value; + }); + + resizeObserver.observe(chartContainerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [width]); + + // Update scroll state when container width or data width changes + useEffect(() => { + updateScrollState(); + }, [effectiveWidth, dataWidth, updateScrollState]); + + useEffect(() => { + setIsSideBarTooltipOpen(false); + setIsLegendExpanded(false); + }, [dataKeys]); + + // Add scroll event listener to update button states + useEffect(() => { + const mainContainer = mainContainerRef.current; + if (!mainContainer) return; + + const handleScroll = () => { + updateScrollState(); }; - }; - const getAxisAngle = (data: T) => { - const angleConfig = { - mobile: { - default: 0, - ranges: [ - { min: 6, max: 9, angle: -45 }, - { min: 10, max: 10, angle: -60 }, - { min: 11, max: Infinity, angle: -75 }, - ], - }, - tray: { - default: 0, - ranges: [{ min: 8, max: Infinity, angle: -45 }], - }, - copilot: { - default: 0, - ranges: [{ min: 8, max: Infinity, angle: -45 }], - }, - fullscreen: { - default: 0, - ranges: [{ min: 12, max: Infinity, angle: -45 }], - }, + + mainContainer.addEventListener("scroll", handleScroll); + return () => { + mainContainer.removeEventListener("scroll", handleScroll); }; + }, [updateScrollState]); + + const legendItems: LegendItem[] = useMemo(() => { + return getLegendItems(dataKeys, colors, icons); + }, [dataKeys, colors, icons]); - const layoutConfig = angleConfig[layout as keyof typeof angleConfig] || angleConfig.fullscreen; - const dataLength = data.length; + const id = useId(); - const matchRange = layoutConfig.ranges.find( - (range) => dataLength >= range.min && dataLength <= range.max, - ); + const chartSyncID = useMemo(() => `line-chart-sync-${id}`, [id]); - return matchRange?.angle ?? layoutConfig.default; - }; - const getTickMargin = (data: T) => { - return data.length <= 6 ? 10 : 15; - }; + const onLineClick = useCallback( + (data: LineClickData) => { + if (data?.activePayload?.length && data.activePayload.length > 10) { + setIsSideBarTooltipOpen(true); + setSideBarTooltipData({ + title: data.activeLabel as string, + values: data.activePayload.map((payload) => ({ + value: payload.value as number, + label: payload.name || payload.dataKey, + color: getColorForDataKey(payload.dataKey, dataKeys, colors), + })), + }); + } + }, + [dataKeys, colors], + ); return ( - - +
- {grid && cartesianGrid()} - - {showYAxis && ( - - )} - } /> - {dataKeys.map((key) => { - const transformedKey = keyTransform(key); - const color = `var(--color-${transformedKey})`; - if (label) { - return ( - + {showYAxis && ( +
+ {/* Y-axis only chart - synchronized with main chart */} + - {label && ( - - )} - - ); - } - return ( - } + /> + {/* Invisible lines to maintain scale synchronization */} + {dataKeys.map((key) => { + return ( + + ); + })} + +
+ )} +
+ + + {grid && cartesianGrid()} + + } + orientation="bottom" + padding={{ + left: 25, + right: 20, + }} + /> + + } offset={15} /> + + {dataKeys.map((key) => { + const transformedKey = transformedKeys[key]; + const color = `var(--color-${transformedKey})`; + return ( + } + isAnimationActive={isAnimationActive} + /> + ); + })} + + +
+ {isSideBarTooltipOpen && } +
+ {/* if the data width is greater than the effective width, then show the scroll buttons */} + {dataWidth > effectiveWidth && ( +
+ } + variant="secondary" + onClick={scrollLeft} + size="extra-small" + disabled={!canScrollLeft} + /> + } + variant="secondary" + size="extra-small" + onClick={scrollRight} + disabled={!canScrollRight} /> - ); - })} - {legend && } />} - - +
+ )} + {legend && ( + + )} +
+ ); }; diff --git a/js/packages/react-ui/src/components/Charts/LineChart/dependencies.ts b/js/packages/react-ui/src/components/Charts/LineChart/dependencies.ts new file mode 100644 index 000000000..e9ef0ced9 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/LineChart/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["LineChart", "IconButton"]; +export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/LineChart/index.ts b/js/packages/react-ui/src/components/Charts/LineChart/index.ts index e873ee101..3f293679e 100644 --- a/js/packages/react-ui/src/components/Charts/LineChart/index.ts +++ b/js/packages/react-ui/src/components/Charts/LineChart/index.ts @@ -1 +1,2 @@ export * from "./LineChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/LineChart/lineChart.scss b/js/packages/react-ui/src/components/Charts/LineChart/lineChart.scss new file mode 100644 index 000000000..2a2be6ba9 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/LineChart/lineChart.scss @@ -0,0 +1,62 @@ +@use "../../../cssUtils" as cssUtils; + +.crayon-line-chart-container-inner { + display: flex; + width: 100%; +} + +.crayon-line-chart-y-axis-container { + flex-shrink: 0; +} + +.crayon-line-chart-main-container { + width: 100%; + overflow-x: auto; + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for Firefox */ + scrollbar-width: none; + + /* Hide scrollbar for IE and Edge */ + -ms-overflow-style: none; +} + +.crayon-line-chart-scroll-container { + position: relative; +} + +button.crayon-line-chart-scroll-button { + position: absolute; + background-color: cssUtils.$bg-container; + + &:hover { + background-color: cssUtils.$bg-container; + } + + &--left { + top: -15px; + transform: translateY(-50%); + left: 20px; + } + + &--right { + top: -15px; + transform: translateY(-50%); + right: 0px; + } + + &--disabled { + visibility: hidden; + cursor: not-allowed; + transition: visibility 0.1s linear; + } + + &--SideBarTooltip { + top: -15px; + transform: translateY(-50%); + right: 185px; + } +} diff --git a/js/packages/react-ui/src/components/Charts/LineChart/stories/lineChart.stories.tsx b/js/packages/react-ui/src/components/Charts/LineChart/stories/lineChart.stories.tsx index d3b1fb7f8..ec03c1a4c 100644 --- a/js/packages/react-ui/src/components/Charts/LineChart/stories/lineChart.stories.tsx +++ b/js/packages/react-ui/src/components/Charts/LineChart/stories/lineChart.stories.tsx @@ -1,157 +1,658 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { Monitor, TabletSmartphone } from "lucide-react"; +import { + Calendar, + Globe, + Laptop, + Monitor, + Smartphone, + TabletSmartphone, + Tv, + Watch, +} from "lucide-react"; +import { useState } from "react"; import { Card } from "../../../Card"; import { LineChart, LineChartProps } from "../LineChart"; -const lineChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; +// 🔥 COMPREHENSIVE DATA VARIATIONS - Designed to test label collision scenarios +const dataVariations = { + default: [ + { month: "January", desktop: 150, mobile: 90, tablet: 120 }, + { month: "February", desktop: 280, mobile: 180, tablet: 140 }, + { month: "March", desktop: 220, mobile: 140, tablet: 160 }, + { month: "April", desktop: 180, mobile: 160, tablet: 180 }, + { month: "May", desktop: 250, mobile: 120, tablet: 140 }, + { month: "June", desktop: 300, mobile: 180, tablet: 160 }, + { month: "July", desktop: 350, mobile: 220, tablet: 180 }, + { month: "August", desktop: 400, mobile: 240, tablet: 200 }, + { month: "September", desktop: 450, mobile: 260, tablet: 220 }, + { month: "October", desktop: 500, mobile: 280, tablet: 240 }, + { month: "November", desktop: 550, mobile: 300, tablet: 260 }, + { month: "December", desktop: 600, mobile: 320, tablet: 280 }, + ], + // 🏷️ BIG LABELS - Testing collision detection and truncation + bigLabels: [ + { + category: "Very Long Category Name That Should Be Truncated", + sales: 150, + revenue: 90, + profit: 120, + }, + { + category: "Another Extremely Long Label That Causes Collisions", + sales: 280, + revenue: 180, + profit: 140, + }, + { + category: "Super Duper Long Category Name That Tests Truncation", + sales: 220, + revenue: 140, + profit: 160, + }, + { + category: "Incredibly Long Text That Should Trigger Collision Detection", + sales: 180, + revenue: 160, + profit: 180, + }, + { + category: "Maximum Length Category Name That Tests All Edge Cases", + sales: 250, + revenue: 120, + profit: 140, + }, + { + category: "Extra Long Business Category Name With Many Words", + sales: 300, + revenue: 180, + profit: 160, + }, + { + category: "Comprehensive Long Label For Testing Horizontal Offset", + sales: 350, + revenue: 220, + profit: 180, + }, + { + category: "Extended Category Name That Pushes Truncation Limits", + sales: 400, + revenue: 240, + profit: 200, + }, + ], + // 📅 DENSE TIMELINE - Many items with medium-length labels + denseTimeline: [ + { period: "Q1 2022 Jan-Mar", visitors: 120, conversions: 15, revenue: 1200 }, + { period: "Q1 2022 Apr-Jun", visitors: 150, conversions: 22, revenue: 1800 }, + { period: "Q2 2022 Jul-Sep", visitors: 180, conversions: 28, revenue: 2100 }, + { period: "Q2 2022 Oct-Dec", visitors: 200, conversions: 35, revenue: 2500 }, + { period: "Q3 2023 Jan-Mar", visitors: 160, conversions: 18, revenue: 1600 }, + { period: "Q3 2023 Apr-Jun", visitors: 190, conversions: 32, revenue: 2300 }, + { period: "Q4 2023 Jul-Sep", visitors: 220, conversions: 40, revenue: 2800 }, + { period: "Q4 2023 Oct-Dec", visitors: 240, conversions: 45, revenue: 3200 }, + { period: "Q1 2024 Jan-Mar", visitors: 210, conversions: 38, revenue: 2700 }, + { period: "Q1 2024 Apr-Jun", visitors: 230, conversions: 42, revenue: 3000 }, + { period: "Q2 2024 Jul-Sep", visitors: 250, conversions: 48, revenue: 3400 }, + { period: "Q2 2024 Oct-Dec", visitors: 270, conversions: 52, revenue: 3800 }, + { period: "Q3 2024 Jan-Mar", visitors: 260, conversions: 50, revenue: 3600 }, + { period: "Q3 2024 Apr-Jun", visitors: 280, conversions: 55, revenue: 4000 }, + { period: "Q4 2024 Jul-Sep", visitors: 300, conversions: 60, revenue: 4300 }, + { period: "Q4 2024 Oct-Dec", visitors: 290, conversions: 58, revenue: 4100 }, + ], + // 🏢 COMPANY NAMES - Real-world long business names + companyNames: [ + { company: "Apple Inc.", revenue: 394328000000, profit: 99803000000, marketCap: 3500000000000 }, + { + company: "Microsoft Corporation", + revenue: 211915000000, + profit: 83383000000, + marketCap: 2800000000000, + }, + { + company: "Alphabet Inc. (Google)", + revenue: 307394000000, + profit: 76033000000, + marketCap: 2100000000000, + }, + { + company: "Amazon.com Inc.", + revenue: 574785000000, + profit: 33364000000, + marketCap: 1600000000000, + }, + { + company: "Tesla Motors Inc.", + revenue: 96773000000, + profit: 15000000000, + marketCap: 800000000000, + }, + { + company: "Meta Platforms Inc.", + revenue: 134902000000, + profit: 39370000000, + marketCap: 900000000000, + }, + { + company: "NVIDIA Corporation", + revenue: 60922000000, + profit: 29760000000, + marketCap: 1800000000000, + }, + { + company: "Berkshire Hathaway Inc.", + revenue: 364482000000, + profit: 96223000000, + marketCap: 780000000000, + }, + ], + // 🌍 COUNTRY NAMES - Geographic labels with varying lengths + countryData: [ + { country: "United States of America", population: 331900000, gdp: 17700000000000 }, + { country: "People's Republic of China", population: 1412000000, gdp: 17700000000000 }, + { country: "Federal Republic of Germany", population: 83200000, gdp: 4300000000000 }, + { country: "United Kingdom of Great Britain", population: 67500000, gdp: 3100000000000 }, + { country: "French Republic", population: 68000000, gdp: 2900000000000 }, + { country: "Republic of India", population: 1380000000, gdp: 3700000000000 }, + { country: "Federative Republic of Brazil", population: 215000000, gdp: 2100000000000 }, + { country: "Russian Federation", population: 146000000, gdp: 1800000000000 }, + ], + // 📈 FINANCIAL QUARTERS - Testing medium-density scenarios + financialQuarters: [ + { quarter: "Q1 FY2022", revenue: 1200000, expenses: 800000, profit: 400000 }, + { quarter: "Q2 FY2022", revenue: 1500000, expenses: 950000, profit: 550000 }, + { quarter: "Q3 FY2022", revenue: 1800000, expenses: 1100000, profit: 700000 }, + { quarter: "Q4 FY2022", revenue: 2000000, expenses: 1300000, profit: 700000 }, + { quarter: "Q1 FY2023", revenue: 2200000, expenses: 1400000, profit: 800000 }, + { quarter: "Q2 FY2023", revenue: 2500000, expenses: 1600000, profit: 900000 }, + { quarter: "Q3 FY2023", revenue: 2800000, expenses: 1800000, profit: 1000000 }, + { quarter: "Q4 FY2023", revenue: 3000000, expenses: 1900000, profit: 1100000 }, + ], + // 🔤 MIXED LENGTHS - Testing various label length scenarios + mixedLengths: [ + { item: "A", valueA: 100, valueB: 80 }, + { item: "Short", valueA: 150, valueB: 120 }, + { item: "Medium Length Item", valueA: 200, valueB: 160 }, + { item: "Very Long Item Name That Tests Truncation", valueA: 250, valueB: 200 }, + { item: "B", valueA: 180, valueB: 140 }, + { item: "Another Really Long Category Name", valueA: 220, valueB: 180 }, + { item: "XL", valueA: 190, valueB: 150 }, + ], + // 🎯 EDGE CASES - Extreme scenarios + edgeCases: [ + { + name: "SinglePointDataSetForTestingEdgeCasesInCollisionDetectionAndLabelTruncationFunctionality", + value: 500, + }, + { + name: "SecondExtremelyLongDataPointNameThatShouldDefinitelyTriggerTruncationMechanisms", + value: 600, + }, + ], + // 📱 MINIMAL - Small dataset for baseline testing + minimal: [ + { category: "Mobile Devices", users: 150, sessions: 90 }, + { category: "Desktop Computers", users: 280, sessions: 180 }, + { category: "Tablet Devices", users: 220, sessions: 140 }, + ], + // 🔄 EXPAND/COLLAPSE - Marketing channels dataset for legend overflow testing + expandCollapseMarketing: [ + { + channel: "Website Traffic and Organic Search Results", + impressions: 120000, + clicks: 15000, + conversions: 1200, + cost: 8500, + revenue: 24000, + roi: 182, + ctr: 12.5, + cpc: 0.57, + cpa: 7.08, + reach: 95000, + engagement: 8200, + shares: 420, + saves: 180, + comments: 650, + videoViews: 0, + }, + { + channel: "Social Media Engagement and Brand Awareness", + impressions: 85000, + clicks: 12000, + conversions: 950, + cost: 6200, + revenue: 19000, + roi: 206, + ctr: 14.1, + cpc: 0.52, + cpa: 6.53, + reach: 72000, + engagement: 15400, + shares: 890, + saves: 340, + comments: 1200, + videoViews: 28000, + }, + { + channel: "Email Marketing Campaign Performance", + impressions: 45000, + clicks: 8500, + conversions: 800, + cost: 2100, + revenue: 16000, + roi: 562, + ctr: 18.9, + cpc: 0.25, + cpa: 2.63, + reach: 42000, + engagement: 6800, + shares: 120, + saves: 85, + comments: 240, + videoViews: 0, + }, + { + channel: "Paid Advertising and PPC Campaign ROI", + impressions: 95000, + clicks: 18000, + conversions: 1500, + cost: 12500, + revenue: 30000, + roi: 140, + ctr: 18.9, + cpc: 0.69, + cpa: 8.33, + reach: 88000, + engagement: 12600, + shares: 320, + saves: 150, + comments: 480, + videoViews: 5200, + }, + { + channel: "Content Marketing and Blog Performance", + impressions: 60000, + clicks: 9500, + conversions: 750, + cost: 4800, + revenue: 15000, + roi: 213, + ctr: 15.8, + cpc: 0.51, + cpa: 6.4, + reach: 55000, + engagement: 7800, + shares: 680, + saves: 420, + comments: 950, + videoViews: 12000, + }, + { + channel: "Mobile Application Downloads and Usage", + impressions: 70000, + clicks: 11000, + conversions: 1100, + cost: 5500, + revenue: 22000, + roi: 300, + ctr: 15.7, + cpc: 0.5, + cpa: 5.0, + reach: 65000, + engagement: 9200, + shares: 180, + saves: 95, + comments: 320, + videoViews: 8500, + }, + { + channel: "Customer Support Response Time and Quality", + impressions: 35000, + clicks: 5500, + conversions: 450, + cost: 2800, + revenue: 9000, + roi: 221, + ctr: 15.7, + cpc: 0.51, + cpa: 6.22, + reach: 32000, + engagement: 4200, + shares: 45, + saves: 25, + comments: 180, + videoViews: 0, + }, + { + channel: "Sales Funnel Conversion and Lead Generation", + impressions: 110000, + clicks: 22000, + conversions: 1800, + cost: 15000, + revenue: 36000, + roi: 140, + ctr: 20.0, + cpc: 0.68, + cpa: 8.33, + reach: 98000, + engagement: 18500, + shares: 420, + saves: 280, + comments: 750, + videoViews: 3200, + }, + { + channel: "User Retention and Churn Rate Analysis", + impressions: 80000, + clicks: 14000, + conversions: 1200, + cost: 7200, + revenue: 24000, + roi: 233, + ctr: 17.5, + cpc: 0.51, + cpa: 6.0, + reach: 75000, + engagement: 11200, + shares: 280, + saves: 150, + comments: 420, + videoViews: 6800, + }, + { + channel: "Product Feature Usage and Performance Metrics", + impressions: 65000, + clicks: 10500, + conversions: 900, + cost: 5200, + revenue: 18000, + roi: 246, + ctr: 16.2, + cpc: 0.5, + cpa: 5.78, + reach: 58000, + engagement: 8500, + shares: 320, + saves: 180, + comments: 650, + videoViews: 4200, + }, + { + channel: "Market Research and Competitive Analysis", + impressions: 40000, + clicks: 6500, + conversions: 500, + cost: 3200, + revenue: 10000, + roi: 213, + ctr: 16.3, + cpc: 0.49, + cpa: 6.4, + reach: 36000, + engagement: 5200, + shares: 95, + saves: 65, + comments: 220, + videoViews: 1800, + }, + { + channel: "Brand Sentiment and Public Relations Impact", + impressions: 55000, + clicks: 8000, + conversions: 650, + cost: 4100, + revenue: 13000, + roi: 217, + ctr: 14.5, + cpc: 0.51, + cpa: 6.31, + reach: 48000, + engagement: 6800, + shares: 520, + saves: 280, + comments: 950, + videoViews: 15000, + }, + ], +}; + +// Category key mappings for different datasets +const categoryKeys = { + default: "month", + bigLabels: "category", + denseTimeline: "period", + companyNames: "company", + countryData: "country", + financialQuarters: "quarter", + mixedLengths: "item", + edgeCases: "name", + minimal: "category", + expandCollapseMarketing: "channel", +}; + +// 🔥 ACTIVE DATA - For backward compatibility +const lineChartV2Data = dataVariations.default; const icons = { desktop: Monitor, mobile: TabletSmartphone, + tablet: Calendar, + sales: Globe, + revenue: Smartphone, + profit: Laptop, + visitors: Tv, + conversions: Watch, + users: Monitor, + sessions: TabletSmartphone, } as const; -const meta: Meta> = { +/** + * # LineChart Component Documentation + * + * The LineChart component is a powerful and flexible tool for visualizing trends over time or comparing continuous data. + * It's ideal for: + * + * - **Time-Series Analysis**: Tracking metrics over days, months, or years. + * - **Comparative Analysis**: Comparing the performance of multiple data series. + * - **Trend Identification**: Spotting upward or downward trends, and patterns in datasets. + * + * ## Key Features + * + * ### Advanced Label Handling + * - **Collision Detection**: Automatically detects and prevents overlapping X-axis labels. + * - **Intelligent Truncation**: Truncates long labels with an ellipsis (...) to keep the chart clean. + * - **Horizontal Offset**: Ensures truncated labels remain horizontally centered for readability. + * + * ### Interactive & Responsive + * - **Interactive Legend**: Allows toggling data series visibility and adapts with a "Show More" feature for many items. + * - **Hover Tooltips**: Provides detailed data points on hover for enhanced user interaction. + * - **Responsive Design**: Fluidly adjusts to any container size, from small widgets to large dashboards. + * + * ### Customization + * - **Theming**: Six pre-built color palettes to fit your application's design. + * - **Line Styles**: Supports `linear`, `natural` (smooth), and `step` variants. + * - **Styling Options**: Control stroke width, grid visibility, and more. + */ +const meta: Meta> = { title: "Components/Charts/LineChart", component: LineChart, parameters: { layout: "centered", docs: { description: { - component: "```tsx\nimport { LineChart } from '@crayon-ui/react-ui/Charts/LineChart';\n```", + component: ` +## Installation and Basic Usage + +\`\`\`tsx +import { LineChart } from '@crayon-ui/react-ui/Charts/LineChart'; + +const timeSeriesData = [ + { month: "January", desktop: 150, mobile: 90 }, + { month: "February", desktop: 280, mobile: 180 }, + { month: "March", desktop: 220, mobile: 140 }, +]; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +Your data should be an array of objects. Each object must contain: +- A **category field** (string or number): This will be used for the X-axis labels (e.g., dates, names, etc.). +- One or more **data series fields** (number): These are the values that will be plotted on the Y-axis. The key of the field will be used as the series name in the legend and tooltip. + +\`\`\`tsx +const salesData = [ + { date: "2023-01-01", productA: 4000, productB: 2400 }, + { date: "2023-02-01", productA: 3000, productB: 1398 }, + { date: "2023-03-01", productA: 2000, productB: 9800 }, +]; +\`\`\` + +## Performance Considerations +- **Data Density**: While the chart can handle many data points, performance may degrade with thousands of points, especially with animations enabled. +- **Responsiveness**: The chart is fully responsive, but consider the visual clarity of labels on very small screens. +- **Animation**: Can be disabled via \`isAnimationActive={false}\` for performance-critical applications. +`, }, }, }, tags: ["!dev", "autodocs"], - argTypes: { data: { - description: - "An array of data objects where each object represents a data point. Each object should have a category field (e.g., month) and one or more numeric values for the areas to be plotted.", + description: ` +**Required.** An array of data objects for the line chart. Each object should contain: +- A category identifier (string/number) for the X-axis. +- One or more numeric values for the Y-axis. + +**Best Practices:** +- Ensure consistent data structure across all objects. +- For time-series data, ensure items are sorted chronologically. +`, control: false, table: { type: { summary: "Array>" }, - defaultValue: { summary: "[]" }, - category: "Data", + category: "📊 Data Configuration", }, }, categoryKey: { description: - "The key from your data object to be used as the x-axis categories (e.g., 'month', 'year', 'date')", + "**Required.** The key in your data object that represents the category for the X-axis (e.g., 'date', 'month').", control: false, table: { type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + category: "📊 Data Configuration", }, }, theme: { - description: - "The color palette theme for the chart. Each theme provides a different set of colors for the areas.", + description: "Specifies the color palette for the chart's lines, tooltips, and legend.", control: "select", options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], table: { defaultValue: { summary: "ocean" }, - category: "Appearance", - }, - }, - icons: { - description: - "An object that maps data keys to icon components. These icons will appear in the legend next to their corresponding data series.", - control: false, - table: { - type: { summary: "Record" }, - defaultValue: { summary: "{}" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, variant: { - description: - "The interpolation method used to create the line curves. 'linear' creates straight lines between points, 'natural' creates smooth curves, and 'step' creates a stepped line.", + description: "Determines the line interpolation method, affecting the shape of the lines.", control: "radio", options: ["linear", "natural", "step"], table: { defaultValue: { summary: "natural" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, strokeWidth: { - description: "The width of the line stroke", - control: false, + description: "Controls the thickness of the lines in pixels.", + control: { type: "number", min: 1, max: 10, step: 1 }, table: { - type: { summary: "number" }, defaultValue: { summary: "2" }, - category: "Appearance", + category: "🎨 Visual Styling", }, }, grid: { - description: "Whether to display the background grid lines in the chart", + description: "Toggles the visibility of the background grid lines.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, - label: { - description: "Whether to display data point labels above each point on the chart", + legend: { + description: "Toggles the visibility of the chart legend.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, - legend: { - description: - "Whether to display the chart legend showing the data series names and their corresponding colors/icons", + showYAxis: { + description: "Toggles the visibility of the Y-axis line and labels.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "📱 Display Options", }, }, isAnimationActive: { - description: "Whether to animate the chart", + description: "Enables or disables the initial loading animation for the lines.", control: "boolean", table: { - type: { summary: "boolean" }, defaultValue: { summary: "true" }, - category: "Display", + category: "🎬 Animation & Interaction", }, }, - showYAxis: { - description: "Whether to display the y-axis", - control: "boolean", + icons: { + description: + "An object mapping data series keys to React components to be used as icons in the legend.", + control: false, table: { - type: { summary: "boolean" }, - defaultValue: { summary: "false" }, - category: "Display", + type: { summary: "Record" }, + category: "🎨 Visual Styling", }, }, xAxisLabel: { - description: "The label for the x-axis", - control: false, + description: "A label to display below the X-axis.", + control: "text", table: { - type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + type: { summary: "React.ReactNode" }, + category: "📱 Display Options", }, }, yAxisLabel: { - description: "The label for the y-axis", - control: false, + description: "A label to display beside the Y-axis.", + control: "text", + table: { + type: { summary: "React.ReactNode" }, + category: "📱 Display Options", + }, + }, + className: { + description: "Custom CSS class to apply to the chart's container.", + control: "text", table: { type: { summary: "string" }, - defaultValue: { summary: "string" }, - category: "Data", + category: "Layout & Sizing", + }, + }, + height: { + description: "Sets the height of the chart container.", + control: "number", + table: { + type: { summary: "number" }, + category: "Layout & Sizing", + }, + }, + width: { + description: "Sets the width of the chart container.", + control: "number", + table: { + type: { summary: "number" }, + category: "Layout & Sizing", }, }, }, @@ -160,19 +661,187 @@ const meta: Meta> = { export default meta; type Story = StoryObj; -export const LineChartStory: Story = { - name: "Line Chart", +/** + * ## Comprehensive Data Explorer + * + * This story serves as a comprehensive test suite for the LineChart component. + * Use the buttons to switch between various datasets designed to test edge cases in: + * + * - **Label Collision**: Datasets with long, dense, or overlapping labels. + * - **Data Density**: Scenarios with few or many data points. + * - **Legend Overflow**: A dataset with many series to test legend expand/collapse functionality. + * + * It's an excellent tool for developers to see how the chart behaves under different conditions. + */ +export const DataExplorer: Story = { + name: "🎛️ Comprehensive Data Explorer", args: { - data: lineChartData, + data: lineChartV2Data, categoryKey: "month", theme: "ocean", variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, strokeWidth: 2, + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + fontFamily: "monospace", + }; + + const activeButtonStyle = { + ...buttonStyle, + background: "#007acc", + color: "white", + border: "1px solid #007acc", + }; + + return ( +
+
+ 📈 Line Chart Test Suite: +
+ + + + + + + + + + +
+
+ + + +
+ ); + }, +}; + +/** + * ## Big Labels + * + * This story demonstrates the chart's ability to handle very long category labels. + * The chart's intelligent truncation and collision detection should be visible here. + * X-axis labels that are too long to fit will be gracefully truncated with an ellipsis. + */ +export const BigLabelsStory: Story = { + name: "🏷️ Big Labels", + args: { + data: dataVariations.bigLabels as any, + categoryKey: "category" as any, + theme: "emerald", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + strokeWidth: 3, + }, + render: (args: any) => ( + + + + ), +}; + +/** + * ## Dense Timeline + * + * Tests how the chart handles many data points with medium-length period labels. + * The chart should enable horizontal scrolling and apply intelligent label truncation. + * This scenario is common in financial or analytical dashboards where many time periods are displayed. + */ +export const DenseTimelineStory: Story = { + name: "📅 Dense Timeline (Many Periods)", + args: { + data: dataVariations.denseTimeline as any, + categoryKey: "period" as any, + theme: "sunset", + variant: "natural", grid: true, - label: true, legend: true, isAnimationActive: true, - showYAxis: false, + showYAxis: true, + strokeWidth: 2, }, render: (args: any) => ( @@ -181,144 +850,331 @@ export const LineChartStory: Story = { ), parameters: { docs: { - source: { - code: ` -const lineChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; - - - -`, + description: { + story: + "Tests how the chart handles many data points with medium-length period labels. The chart should enable horizontal scrolling and apply intelligent label truncation.", }, }, }, }; -export const LineChartStoryWithIcons: Story = { - name: "Line Chart with Icons", +/** + * ## Stroke and Style Customization + * + * This story showcases how to customize the appearance of the lines. + * You can adjust properties like `strokeWidth` to create different visual effects. + */ +export const StrokeCustomizationStory: Story = { + name: "🎨 Stroke Customization", args: { - ...LineChartStory.args, - icons: icons, + data: dataVariations.default as any, + categoryKey: "month" as any, + theme: "vivid", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, }, render: (args: any) => ( - - - +
+
+

Default Lines (strokeWidth: 2)

+ + + +
+
+

Thick Lines (strokeWidth: 4)

+ + + +
+
), - parameters: { - docs: { - source: { - code: ` - import { Monitor, TabletSmartphone } from "lucide-react"; - - const lineChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; - -const icons = { - desktop: Monitor, - mobile: TabletSmartphone, }; - - - - `, +/** + * ## Line Variants + * + * Compares the three available line variants: + * - **Linear**: Straight lines connecting data points. + * - **Natural**: Smooth curves for a softer, organic look. + * - **Step**: Stepped lines, useful for showing discrete changes. + */ +export const VariantComparisonStory: Story = { + name: "📐 Line Variants", + args: { + data: dataVariations.minimal as any, + categoryKey: "category" as any, + theme: "orchid", + grid: true, + legend: true, + isAnimationActive: true, + showYAxis: true, + strokeWidth: 3, + }, + render: (args: any) => ( +
+
+

Linear Variant

+ + + +
+
+

+ Natural Variant (Smooth Curves) +

+ + + +
+
+

Step Variant

+ + + +
+
+ ), + parameters: { + docs: { + description: { + story: + "Compares the three available line variants: linear (straight lines), natural (smooth curves), and step (stepped lines).", }, }, }, }; -export const LineChartStoryWithYAxis: Story = { - name: "Line Chart with Y-Axis and Axis Labels", +/** + * ## Legend Expand/Collapse + * + * Tests the legend's expand/collapse functionality with a large number of data series. + * The legend should automatically show a "Show More" button when items overflow, + * allowing users to toggle between a collapsed and expanded view. + */ +export const ExpandCollapseMarketingStory: Story = { + name: "🔄 Legend Expand/Collapse", args: { - ...LineChartStory.args, + data: dataVariations.expandCollapseMarketing as any, + categoryKey: "channel" as any, + theme: "spectrum", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: true, showYAxis: true, - xAxisLabel: "Time Period", - yAxisLabel: "Number of Users", + strokeWidth: 2, }, render: (args: any) => ( - + ), parameters: { docs: { - source: { - code: ` - import { Monitor, TabletSmartphone } from "lucide-react"; - - const lineChartData = [ - { month: "January", desktop: 150, mobile: 90 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; - - - - - `, + description: { + story: + "Tests the legend expand/collapse functionality with 12 marketing channels that have long descriptive names. The legend should automatically show a 'Show More' button when items overflow the container width, allowing users to toggle between collapsed and expanded states.", }, }, }, }; + +/** + * ## Responsive Behavior Demo + * + * This story demonstrates the responsive capabilities of the LineChart. Drag the handles + * on the container to resize it and observe how the chart adapts. + * + * **Responsive Features:** + * - **Label Truncation**: More aggressive truncation on smaller container widths. + * - **Layout Adaptation**: Chart elements adjust to fit the available space. + * - **Legend Behavior**: The legend may wrap or show expand/collapse buttons as needed. + */ +export const ResponsiveBehaviorDemo: Story = { + name: "📱 Responsive Behavior Demo", + args: { + data: dataVariations.bigLabels as any, + categoryKey: "category" as any, + theme: "sunset", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: false, + showYAxis: true, + strokeWidth: 2, + }, + render: (args: any) => { + const [dimensions, setDimensions] = useState<{ width: number; height: number | string }>({ + width: 700, + height: "auto", + }); + + const handleMouseDown = (e: React.MouseEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = dimensions.width; + const startHeight = (e.currentTarget.parentElement as HTMLElement).offsetHeight; + + const doDrag = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let newWidth = startWidth; + let newHeight = startHeight; + + if (handle.includes("e")) newWidth = startWidth + dx; + if (handle.includes("w")) newWidth = startWidth - dx; + if (handle.includes("s")) newHeight = startHeight + dy; + if (handle.includes("n")) newHeight = startHeight - dy; + + setDimensions({ + width: Math.max(300, newWidth), + height: "auto", + }); + }; + + const stopDrag = () => { + document.removeEventListener("mousemove", doDrag); + document.removeEventListener("mouseup", stopDrag); + }; + + document.addEventListener("mousemove", doDrag); + document.addEventListener("mouseup", stopDrag); + }; + + const handleStyle: React.CSSProperties = { + position: "absolute", + background: "#3b82f6", + opacity: 0.6, + zIndex: 10, + transition: "opacity 0.2s ease", + }; + + return ( +
+
+

+ Responsive Behavior Demonstration +

+

+ Drag the edges or corners of the container below to see how the chart adapts +

+
+ 🎯 Try This: Resize the container to see automatic label truncation and + layout adjustments. +
+
+ + + + + {/* Resize Handles */} +
handleMouseDown(e, "n")} + /> +
handleMouseDown(e, "s")} + /> +
handleMouseDown(e, "w")} + /> +
handleMouseDown(e, "e")} + /> + + {/* Corner Handles */} +
handleMouseDown(e, "nw")} + /> +
handleMouseDown(e, "ne")} + /> +
handleMouseDown(e, "se")} + /> + + {/* Dimension Display */} +
+ {dimensions.width}px ×{" "} + {typeof dimensions.height === "number" ? `${dimensions.height}px` : "auto"} +
+ +
+ ); + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/LineChart/types/index.ts b/js/packages/react-ui/src/components/Charts/LineChart/types/index.ts new file mode 100644 index 000000000..083280dd2 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/LineChart/types/index.ts @@ -0,0 +1,3 @@ +export type LineChartData = Array>; + +export type LineChartVariant = "linear" | "natural" | "step"; diff --git a/js/packages/react-ui/src/components/Charts/MiniAreaChart/MiniAreaChart.tsx b/js/packages/react-ui/src/components/Charts/MiniAreaChart/MiniAreaChart.tsx new file mode 100644 index 000000000..3df3dc36e --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniAreaChart/MiniAreaChart.tsx @@ -0,0 +1,129 @@ +import clsx from "clsx"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Area, AreaChart as RechartsAreaChart, XAxis } from "recharts"; +import { ChartConfig, ChartContainer } from "../Charts"; +import { + getRecentDataThatFits, + transformDataForChart, +} from "../utils/AreaAndLine/MiniAreaAndLineUtils"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; +import { MiniAreaChartData } from "./types"; + +export interface MiniAreaChartProps { + data: MiniAreaChartData; + theme?: PaletteName; + variant?: "linear" | "natural" | "step"; + opacity?: number; + isAnimationActive?: boolean; + onAreaClick?: (data: any) => void; + size?: number | string; + className?: string; + areaColor?: string; + useGradient?: boolean; +} + +export const MiniAreaChart = ({ + data, + theme = "ocean", + variant = "natural", + opacity = 0.5, + isAnimationActive = false, + onAreaClick, + size = "100%", + className, + areaColor, + useGradient = true, +}: MiniAreaChartProps) => { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + if (!containerRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((entries) => { + // there is only one entry in the entries array because we are only observing the chart container + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Get the most recent data that fits in the container + const filteredData = useMemo(() => { + return getRecentDataThatFits(data, containerWidth); + }, [data, containerWidth]); + + // Transform the filtered data to a consistent format for recharts + const chartData = useMemo(() => { + return transformDataForChart(filteredData); + }, [filteredData]); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, 1); // Single color for 1D chart + }, [theme]); + + const chartConfig: ChartConfig = useMemo(() => { + return { + value: { + label: "Value", + color: areaColor ? areaColor : colors[0], + }, + }; + }, [colors, areaColor]); + + const id = useId(); + + // Generate unique gradient ID to avoid conflicts when multiple charts are on the same page + const gradientId = useMemo(() => `miniAreaGradient-${id}`, [id]); + + return ( + + + {useGradient && ( + + + + + + + )} + + + + + + + ); +}; diff --git a/js/packages/react-ui/src/components/Charts/MiniAreaChart/dependencies.ts b/js/packages/react-ui/src/components/Charts/MiniAreaChart/dependencies.ts new file mode 100644 index 000000000..3b1962326 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniAreaChart/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["MiniAreaChart"]; +export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/MiniAreaChart/index.ts b/js/packages/react-ui/src/components/Charts/MiniAreaChart/index.ts new file mode 100644 index 000000000..02e709aa0 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniAreaChart/index.ts @@ -0,0 +1,2 @@ +export * from "./MiniAreaChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/MiniAreaChart/stories/MiniAreaChart.stories.tsx b/js/packages/react-ui/src/components/Charts/MiniAreaChart/stories/MiniAreaChart.stories.tsx new file mode 100644 index 000000000..73b8711e6 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniAreaChart/stories/MiniAreaChart.stories.tsx @@ -0,0 +1,403 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Card } from "../../../Card"; +import { MiniAreaChart } from "../MiniAreaChart"; + +// Simple array of numbers for 1D area chart +const simpleAreaChartData = [ + 12, 45, 78, 32, 67, 89, 23, 56, 91, 34, 76, 28, 85, 42, 19, 63, 87, 31, 74, 58, 92, 26, 49, 83, + 37, 50, +]; + +// Array of objects with value and label +const labeledAreaChartData = [ + { value: 150, label: "January" }, + { value: 280, label: "February" }, + { value: 220, label: "March" }, + { value: 180, label: "April" }, + { value: 250, label: "May" }, + { value: 300, label: "June" }, + { value: 320, label: "July" }, + { value: 280, label: "August" }, + { value: 310, label: "September" }, + { value: 290, label: "October" }, + { value: 340, label: "November" }, + { value: 360, label: "December" }, + // { value: 360, label: "December" }, + // { value: 360, label: "December" }, +]; + +const meta: Meta = { + title: "Components/Charts/AreaCharts/MiniAreaChart", + component: MiniAreaChart, + parameters: { + layout: "centered", + docs: { + description: { + component: + "```tsx\nimport { MiniAreaChart } from '@crayon-ui/react-ui/Charts/AreaCharts/MiniAreaChart';\n```\n\nA responsive mini area chart component that accepts 1D data (numbers or objects with value/label) with automatic data filtering for space-constrained containers. Features linear gradient fills from color to transparent.", + }, + }, + }, + tags: ["!dev", "!autodocs"], + argTypes: { + data: { + description: + "An array of numbers or an array of objects with value and optional label. Each entry represents a single point in the area chart.", + control: false, + table: { + type: { summary: "Array | Array<{ value: number; label?: string }>" }, + defaultValue: { summary: "[]" }, + category: "Data", + }, + }, + theme: { + description: + "The color palette theme for the chart. Each theme provides a different color for the area.", + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "Appearance", + }, + }, + variant: { + description: + "The interpolation method used to create the area curves. 'linear' creates straight lines between points, 'natural' creates smooth curves, and 'step' creates a stepped area.", + control: "radio", + options: ["linear", "natural", "step"], + table: { + defaultValue: { summary: "natural" }, + category: "Appearance", + }, + }, + opacity: { + description: + "The opacity of the filled area beneath the line (0 = fully transparent, 1 = fully opaque). Only used when useGradient is false.", + control: "number", + table: { + type: { summary: "number" }, + defaultValue: { summary: "0.5" }, + category: "Appearance", + }, + }, + useGradient: { + description: + "Whether to use a linear gradient fill that goes from the area color at the top to transparent at the bottom.", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "Appearance", + }, + }, + isAnimationActive: { + description: "Whether to animate the chart when it first renders", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "Display", + }, + }, + size: { + description: "The width and height of the chart", + control: "text", + table: { + type: { summary: "number | string" }, + defaultValue: { summary: "100%" }, + category: "Appearance", + }, + }, + areaColor: { + description: "Custom color for the area fill and stroke", + control: "color", + table: { + type: { summary: "string" }, + category: "Appearance", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SimpleNumberArray: Story = { + name: "Simple Number Array (With Gradient)", + args: { + data: simpleAreaChartData, + theme: "ocean", + variant: "natural", + opacity: 0.5, + useGradient: true, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( + +

Daily Activity

+ +
+ ), + parameters: { + docs: { + source: { + code: ` +const activityData = [12, 45, 78, 32, 67, 89, 23, 56, 91, 34]; + + +`, + }, + }, + }, +}; + +export const LabeledData: Story = { + name: "Labeled Data (With Gradient)", + args: { + data: labeledAreaChartData, + theme: "emerald", + variant: "natural", + opacity: 0.6, + useGradient: true, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( + +

Monthly Revenue

+ +
+ ), + parameters: { + docs: { + source: { + code: ` +const revenueData = [ + { value: 150, label: "January" }, + { value: 280, label: "February" }, + { value: 220, label: "March" }, + // ... +]; + + +`, + }, + }, + }, +}; + +export const WithoutGradient: Story = { + name: "Solid Fill (Without Gradient)", + args: { + data: simpleAreaChartData.slice(0, 15), + theme: "sunset", + variant: "natural", + opacity: 0.4, + useGradient: false, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( + +

Solid Fill Area

+ +
+ ), + parameters: { + docs: { + source: { + code: ` + +`, + }, + }, + }, +}; + +export const GradientComparison: Story = { + name: "Gradient vs Solid Comparison", + render: () => ( +
+ +

+ With Gradient (Default) +

+ +
+ +

Solid Fill

+ +
+
+ ), +}; + +export const ResponsiveData: Story = { + name: "Responsive Data Filtering", + args: { + data: simpleAreaChartData, + theme: "orchid", + variant: "natural", + opacity: 0.4, + useGradient: true, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( +
+ +

Small (200px)

+ +
+ +

+ Medium (400px) +

+ +
+ +

Large (600px)

+ +
+
+ ), +}; + +export const DifferentSizes: Story = { + name: "Different Sizes", + render: () => ( +
+ +

Small

+ +
+ +

Medium

+ +
+ +

Large

+ +
+
+ ), +}; + +export const DifferentThemes: Story = { + name: "Different Themes (All With Gradients)", + render: () => ( +
+ {(["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const).map((theme) => ( + +

+ {theme} Theme +

+ +
+ ))} +
+ ), +}; + +export const DifferentVariants: Story = { + name: "Different Variants", + render: () => ( +
+ {(["linear", "natural", "step"] as const).map((variant) => ( + +

+ {variant} Variant +

+ +
+ ))} +
+ ), +}; + +export const CustomColor: Story = { + name: "Custom Color with Gradient", + args: { + data: [20, 45, 28, 80, 99, 43, 67, 23, 89, 56], + theme: "ocean", + variant: "natural", + opacity: 0.7, + areaColor: "#ff6b6b", + useGradient: true, + size: 200, + }, + render: (args: any) => ( + +

Custom Red Area

+ +
+ ), +}; diff --git a/js/packages/react-ui/src/components/Charts/MiniAreaChart/types/index.ts b/js/packages/react-ui/src/components/Charts/MiniAreaChart/types/index.ts new file mode 100644 index 000000000..9a51c7451 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniAreaChart/types/index.ts @@ -0,0 +1 @@ +export type MiniAreaChartData = Array | Array<{ value: number; label?: string }>; diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/MiniBarChart.tsx b/js/packages/react-ui/src/components/Charts/MiniBarChart/MiniBarChart.tsx new file mode 100644 index 000000000..47aeb26e6 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/MiniBarChart.tsx @@ -0,0 +1,124 @@ +import clsx from "clsx"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Bar, BarChart, XAxis } from "recharts"; +import { useTheme } from "../../ThemeProvider"; +import { LineInBarShape } from "../BarChart/components/LineInBarShape"; +import { ChartConfig, ChartContainer } from "../Charts"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; +import { type MiniBarChartData } from "./types"; +import { + getPadding, + getRecentDataThatFits, + MINI_BAR_WIDTH, + transformDataForChart, +} from "./utils/miniBarChartUtils"; + +export interface MiniBarChartProps { + data: MiniBarChartData; + theme?: PaletteName; + radius?: number; + isAnimationActive?: boolean; + onBarsClick?: (data: any) => void; + size?: number | string; + className?: string; + barColor?: string; +} + +const MINI_BAR_CHART_INNER_LINE_WIDTH = 1; + +export const MiniBarChart = ({ + data, + theme = "ocean", + radius = 1, + isAnimationActive = false, + onBarsClick, + size = "100%", + className, + barColor, +}: MiniBarChartProps) => { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + if (!containerRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((entries) => { + // there is only one entry in the entries array because we are only observing the chart container + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Get the most recent data that fits in the container + const filteredData = useMemo(() => { + return getRecentDataThatFits(data, containerWidth); + }, [data, containerWidth]); + + // Transform the filtered data to a consistent format for recharts + const chartData = useMemo(() => { + return transformDataForChart(filteredData); + }, [filteredData]); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, 1); // Single color for 1D chart + }, [theme]); + + const chartConfig: ChartConfig = useMemo(() => { + return { + value: { + label: "Value", + color: barColor ? barColor : colors[0], + }, + }; + }, [colors, barColor]); + + const { mode } = useTheme(); + + const barInternalLineColor = useMemo(() => { + if (mode === "light") { + return "rgba(255, 255, 255, 0.3)"; + } + return "rgba(0, 0, 0, 0.3)"; + }, [mode]); + + return ( + + + + + } + /> + + + ); +}; diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/dependencies.ts b/js/packages/react-ui/src/components/Charts/MiniBarChart/dependencies.ts new file mode 100644 index 000000000..ea021d98a --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["MiniBarChart"]; +export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/index.ts b/js/packages/react-ui/src/components/Charts/MiniBarChart/index.ts new file mode 100644 index 000000000..02620bc68 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/index.ts @@ -0,0 +1,2 @@ +export * from "./MiniBarChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/miniBarChart.scss b/js/packages/react-ui/src/components/Charts/MiniBarChart/miniBarChart.scss new file mode 100644 index 000000000..d395a1ecb --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/miniBarChart.scss @@ -0,0 +1,3 @@ +.crayon-charts-mini-bar-chart-container { + padding: 4px; +} diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/stories/MiniBarChart.stories.tsx b/js/packages/react-ui/src/components/Charts/MiniBarChart/stories/MiniBarChart.stories.tsx new file mode 100644 index 000000000..ecd296f68 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/stories/MiniBarChart.stories.tsx @@ -0,0 +1,215 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Monitor } from "lucide-react"; +import { Card } from "../../../Card"; +import { MiniBarChart, MiniBarChartProps } from "../MiniBarChart"; + +// Simple array of numbers for 1D bar chart +const simpleBarChartData = [ + 1, 1893456, 3456789, 2987654, 1765432, 4321098, 3789012, 2654321, 4123567, 3234567, 2876543, + 3987654, 2347891, 1893456, 3456789, 2987654, 1765432, 4321098, 3789012, 2654321, 4123567, 3234567, + 2876543, 3987654, 2347891, 1893456, 3456789, 2987654, 1765432, 4321098, 3789012, 2654321, 4123567, + 3234567, 2876543, 3987654, 2347891, 1893456, 3456789, 2987654, 1765432, 4321098, 3789012, 2654321, + 4123567, 3234567, 2876543, 100000, +]; + +// Array of objects with value and label +const labeledBarChartData = [ + { value: 2347891, label: "January" }, + { value: 1893456, label: "February" }, + { value: 3456789, label: "March" }, + { value: 2987654, label: "April" }, + { value: 1765432, label: "May" }, + { value: 4321098, label: "June" }, + { value: 3789012, label: "July" }, + { value: 2654321, label: "August" }, + { value: 4123567, label: "September" }, + { value: 3234567, label: "October" }, + { value: 2876543, label: "November" }, + { value: 3987654, label: "December" }, +]; + +const icons = { + desktop: Monitor, +} as const; + +const meta: Meta = { + title: "Components/Charts/BarCharts/MiniBarChart", + component: MiniBarChart, + parameters: { + layout: "centered", + docs: { + description: { + component: + "```tsx\nimport { MiniBarChart } from '@crayon-ui/react-ui/Charts/BarCharts/MiniBarChart';\n```", + }, + }, + }, + tags: ["!dev", "!autodocs"], + argTypes: { + data: { + description: + "An array of numbers or an array of objects with value and optional label. Each entry represents a single bar in the chart.", + control: false, + table: { + type: { summary: "Array | Array<{ value: number; label?: string }>" }, + defaultValue: { summary: "[]" }, + category: "Data", + }, + }, + theme: { + description: + "The color palette theme for the chart. Each theme provides a different set of colors for the bars.", + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "Appearance", + }, + }, + radius: { + description: "The radius of the rounded corners of the bars", + control: "number", + table: { + type: { summary: "number" }, + defaultValue: { summary: "1" }, + category: "Appearance", + }, + }, + isAnimationActive: { + description: "Whether to animate the chart when it first renders", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "Display", + }, + }, + size: { + description: "The width and height of the chart", + control: "number", + table: { + type: { summary: "number | string" }, + defaultValue: { summary: "160" }, + category: "Appearance", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SimpleNumberArray: Story = { + name: "Simple Number Array", + args: { + data: simpleBarChartData, + theme: "ocean", + radius: 2, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( + +

+ Monthly Sales Data +

+ +
+ ), + parameters: { + docs: { + source: { + code: ` +const salesData = [2347891, 1893456, 3456789, 2987654, 1765432, 4321098]; + + +`, + }, + }, + }, +}; + +export const LabeledData: Story = { + name: "Labeled Data", + args: { + data: labeledBarChartData, + theme: "emerald", + radius: 1, + isAnimationActive: true, + size: 200, + }, + render: (args: any) => ( + +

Monthly Revenue

+ +
+ ), + parameters: { + docs: { + source: { + code: ` +const revenueData = [ + { value: 2347891, label: "January" }, + { value: 1893456, label: "February" }, + { value: 3456789, label: "March" }, + // ... +]; + + +`, + }, + }, + }, +}; + +export const SmallSize: Story = { + name: "Small Size", + args: { + data: [10, 20, 15, 30, 25, 35, 18], + theme: "sunset", + radius: 1, + isAnimationActive: true, + size: 120, + }, + render: (args: any) => ( + +

Weekly Stats

+ +
+ ), +}; + +export const DifferentThemes: Story = { + name: "Different Themes", + render: () => ( +
+ {(["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const).map((theme) => ( + +

+ {theme} Theme +

+ +
+ ))} +
+ ), +}; diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/types/index.ts b/js/packages/react-ui/src/components/Charts/MiniBarChart/types/index.ts new file mode 100644 index 000000000..51f9a0492 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/types/index.ts @@ -0,0 +1 @@ +export type MiniBarChartData = Array | Array<{ value: number; label?: string }>; diff --git a/js/packages/react-ui/src/components/Charts/MiniBarChart/utils/miniBarChartUtils.ts b/js/packages/react-ui/src/components/Charts/MiniBarChart/utils/miniBarChartUtils.ts new file mode 100644 index 000000000..28b01f92b --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniBarChart/utils/miniBarChartUtils.ts @@ -0,0 +1,97 @@ +import { type MiniBarChartData } from "../types"; + +export const MINI_BAR_WIDTH: number = 8; +export const MINI_ELEMENT_SPACING: number = 10; + +const CONTAINER_HORIZONTAL_PADDING: number = 8; // 4px left + 4px right + +/** + * Calculates the total width of the data. + * + * @param data - The mini bar chart data array + * @returns The total width needed in pixels to display all data items + */ +const getWidthOfData = (data: MiniBarChartData) => { + return data.length * (MINI_ELEMENT_SPACING + MINI_BAR_WIDTH); +}; + +/** + * Calculates the left and right padding for the chart container based on available space. + * If the chart data exceeds the container width, no padding is applied. + * + * @param data - The mini bar chart data array + * @param containerWidth - The total width of the container in pixels + * @returns An object with left and right padding values in pixels + */ +const getPadding = (data: MiniBarChartData, containerWidth: number) => { + const availableWidth = containerWidth - CONTAINER_HORIZONTAL_PADDING; + const chartWidth = getWidthOfData(data); + const paddingValue = availableWidth - chartWidth; + + if (paddingValue < 0) { + return { + left: 0, + right: 0, + }; + } + return { + left: paddingValue, + right: 0, + }; +}; + +/** + * Filters the data to include only the most recent items that can fit within the container width. + * This function ensures the chart displays the latest data when space is limited. + * + * @param data - The complete mini bar chart data array + * @param containerWidth - The total width of the container in pixels + * @returns A filtered array containing only the most recent data items that fit in the container + */ +const getRecentDataThatFits = ( + data: MiniBarChartData, + containerWidth: number, +): MiniBarChartData => { + if (containerWidth <= 0 || data.length === 0) { + return data; + } + + // Subtract padding to get actual available width for chart content + const availableWidth = containerWidth - CONTAINER_HORIZONTAL_PADDING; + + // Calculate how many items can fit in the available space + const itemWidth = MINI_BAR_WIDTH + MINI_ELEMENT_SPACING; + const maxItems = Math.floor(availableWidth / itemWidth); + + // If all items fit, return all data + if (maxItems >= data.length) { + return data; + } + + // Return the most recent items that fit + return data.slice(-maxItems); +}; + +type ChartData = Array<{ + value: number; + label: string; +}>; + +/** + * Transforms the mini bar chart data into a standardized format for rendering. + * Handles both numeric values and objects with value/label properties. + * + * @param data - The mini bar chart data array (can contain numbers or objects with value/label) + * @returns An array of chart data objects with value and label properties + */ +const transformDataForChart = (data: MiniBarChartData): ChartData => { + return data.map((item, index) => { + if (typeof item === "number") { + return { value: item, label: `Item ${index + 1}` }; + } else { + return { value: item.value, label: item.label || `Item ${index + 1}` }; + } + }); +}; + +export { getPadding, getRecentDataThatFits, transformDataForChart }; diff --git a/js/packages/react-ui/src/components/Charts/MiniLineChart/MiniLineChart.tsx b/js/packages/react-ui/src/components/Charts/MiniLineChart/MiniLineChart.tsx new file mode 100644 index 000000000..9b6b1e827 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniLineChart/MiniLineChart.tsx @@ -0,0 +1,111 @@ +import clsx from "clsx"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Line, LineChart as RechartsLineChart, XAxis } from "recharts"; +import { ChartConfig, ChartContainer } from "../Charts"; +import { + getRecentDataThatFits, + transformDataForChart, +} from "../utils/AreaAndLine/MiniAreaAndLineUtils"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; +import { MiniLineChartData } from "./types"; + +export interface MiniLineChartProps { + data: MiniLineChartData; + theme?: PaletteName; + variant?: "linear" | "natural" | "step"; + strokeWidth?: number; + isAnimationActive?: boolean; + onLineClick?: (data: any) => void; + size?: number | string; + className?: string; + lineColor?: string; +} + +export const MiniLineChart = ({ + data, + theme = "ocean", + variant = "natural", + strokeWidth = 2, + isAnimationActive = true, + onLineClick, + size = "100%", + className, + lineColor, +}: MiniLineChartProps) => { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + if (!containerRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Get the most recent data that fits in the container + const filteredData = useMemo(() => { + return getRecentDataThatFits(data, containerWidth); + }, [data, containerWidth]); + + // Transform the filtered data to a consistent format for recharts + const chartData = useMemo(() => { + return transformDataForChart(filteredData); + }, [filteredData]); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, 1); // Single color for 1D chart + }, [theme]); + + const chartConfig: ChartConfig = useMemo(() => { + return { + value: { + label: "Value", + color: lineColor ? lineColor : colors[0], + }, + }; + }, [colors, lineColor]); + + return ( + + + + + + + + ); +}; diff --git a/js/packages/react-ui/src/components/Charts/MiniLineChart/dependencies.ts b/js/packages/react-ui/src/components/Charts/MiniLineChart/dependencies.ts new file mode 100644 index 000000000..573358a14 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniLineChart/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["MiniLineChart"]; +export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/MiniLineChart/index.ts b/js/packages/react-ui/src/components/Charts/MiniLineChart/index.ts new file mode 100644 index 000000000..c489b3a1b --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniLineChart/index.ts @@ -0,0 +1,2 @@ +export * from "./MiniLineChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/MiniLineChart/stories/MiniLineChart.stories.tsx b/js/packages/react-ui/src/components/Charts/MiniLineChart/stories/MiniLineChart.stories.tsx new file mode 100644 index 000000000..85230d7dd --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniLineChart/stories/MiniLineChart.stories.tsx @@ -0,0 +1,186 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Card } from "../../../Card"; +import { MiniLineChart } from "../MiniLineChart"; + +// Simple array of numbers for 1D line chart +const simpleLineChartData = [ + 12, 45, 78, 32, 67, 89, 23, 56, 91, 34, 76, 28, 85, 42, 19, 63, 87, 31, 74, 58, 92, 26, 49, 83, + 37, 50, 1, +]; + +// Array of objects with value and label +const labeledLineChartData = [ + { value: 150, label: "January" }, + { value: 280, label: "February" }, + { value: 220, label: "March" }, + { value: 180, label: "April" }, + { value: 250, label: "May" }, + { value: 300, label: "June" }, + { value: 320, label: "July" }, + { value: 280, label: "August" }, + { value: 310, label: "September" }, + { value: 290, label: "October" }, + { value: 340, label: "November" }, + { value: 360, label: "December" }, +]; + +const meta: Meta = { + title: "Components/Charts/LineCharts/MiniLineChart", + component: MiniLineChart, + parameters: { + layout: "centered", + docs: { + description: { + component: + "```tsx\nimport { MiniLineChart } from '@crayon-ui/react-ui/Charts/LineCharts/MiniLineChart';\n```\n\nA responsive mini line chart component that accepts 1D data (numbers or objects with value/label) with automatic data filtering for space-constrained containers. Features smooth line interpolation and customizable styling.", + }, + }, + }, + tags: ["!dev", "!autodocs"], + argTypes: { + data: { + description: + "An array of numbers or an array of objects with value and optional label. Each entry represents a single point in the line chart.", + control: false, + table: { + type: { summary: "Array | Array<{ value: number; label?: string }>" }, + defaultValue: { summary: "[]" }, + category: "Data", + }, + }, + theme: { + description: + "The color palette theme for the chart. Each theme provides a different color for the line.", + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "Appearance", + }, + }, + variant: { + description: + "The interpolation method used to create the line curves. 'linear' creates straight lines between points, 'natural' creates smooth curves, and 'step' creates a stepped line.", + control: "radio", + options: ["linear", "natural", "step"], + table: { + defaultValue: { summary: "natural" }, + category: "Appearance", + }, + }, + strokeWidth: { + description: "The width of the line stroke", + control: "number", + table: { + type: { summary: "number" }, + defaultValue: { summary: "2" }, + category: "Appearance", + }, + }, + isAnimationActive: { + description: "Whether to animate the chart when it first renders", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "Display", + }, + }, + size: { + description: "The width and height of the chart", + control: "text", + table: { + type: { summary: "number | string" }, + defaultValue: { summary: "100%" }, + category: "Appearance", + }, + }, + lineColor: { + description: "Custom color for the line stroke", + control: "color", + table: { + type: { summary: "string" }, + category: "Appearance", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SimpleNumberArray: Story = { + name: "Simple Number Array", + args: { + data: simpleLineChartData, + theme: "ocean", + variant: "natural", + strokeWidth: 2, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( + +

Daily Activity

+ +
+ ), + parameters: { + docs: { + source: { + code: ` +const activityData = [12, 45, 78, 32, 67, 89, 23, 56, 91, 34]; + + +`, + }, + }, + }, +}; + +export const LabeledData: Story = { + name: "Labeled Data", + args: { + data: labeledLineChartData, + theme: "emerald", + variant: "natural", + strokeWidth: 2, + isAnimationActive: true, + size: "100%", + }, + render: (args: any) => ( + +

Monthly Revenue

+ +
+ ), + parameters: { + docs: { + source: { + code: ` +const revenueData = [ + { value: 150, label: "January" }, + { value: 280, label: "February" }, + { value: 220, label: "March" }, + // ... +]; + + +`, + }, + }, + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/MiniLineChart/types/index.ts b/js/packages/react-ui/src/components/Charts/MiniLineChart/types/index.ts new file mode 100644 index 000000000..59487ff9f --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/MiniLineChart/types/index.ts @@ -0,0 +1 @@ +export type MiniLineChartData = Array | Array<{ value: number; label?: string }>; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/PieChart.tsx b/js/packages/react-ui/src/components/Charts/PieChart/PieChart.tsx index 44496a4b6..b7a55b9e2 100644 --- a/js/packages/react-ui/src/components/Charts/PieChart/PieChart.tsx +++ b/js/packages/react-ui/src/components/Charts/PieChart/PieChart.tsx @@ -1,48 +1,58 @@ import clsx from "clsx"; -import { debounce } from "lodash-es"; -import { useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Cell, Pie, PieChart as RechartsPieChart } from "recharts"; -import { useLayoutContext } from "../../../context/LayoutContext"; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../Charts.js"; +import { useTransformedKeys } from "../hooks/index.js"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend.js"; +import { StackedLegend } from "../shared/StackedLegend/StackedLegend.js"; +import { LegendItem } from "../types/Legend.js"; +import { getCategoricalChartConfig } from "../utils/dataUtils.js"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils.js"; import { - ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "../Charts"; -import { getDistributedColors, getPalette } from "../utils/PalletUtils"; + createGradientDefinitions, + MAX_CHART_SIZE, + MIN_CHART_SIZE, +} from "./components/PieChartRenderers.js"; +import { PieChartData } from "./types/index.js"; +import { + calculateTwoLevelChartDimensions, + createAnimationConfig, + createEventHandlers, + createSectorStyle, + getHoverStyles, + transformDataWithPercentages, + useChartHover, +} from "./utils/PieChartUtils.js"; -export type PieChartData = Array>; +interface GradientColor { + start?: string; + end?: string; +} export interface PieChartProps { data: T; categoryKey: keyof T[number]; dataKey: keyof T[number]; - theme?: "ocean" | "orchid" | "emerald" | "sunset" | "spectrum" | "vivid"; + theme?: PaletteName; variant?: "pie" | "donut"; format?: "percentage" | "number"; legend?: boolean; - label?: boolean; + legendVariant?: "default" | "stacked"; isAnimationActive?: boolean; + appearance?: "circular" | "semiCircular"; + cornerRadius?: number; + paddingAngle?: number; + useGradients?: boolean; + gradientColors?: GradientColor[]; + onMouseEnter?: (data: any, index: number) => void; + onMouseLeave?: () => void; + onClick?: (data: any, index: number) => void; + className?: string; } -const layoutMap: Record = { - mobile: "crayon-pie-chart-container-mobile", - fullscreen: "crayon-pie-chart-container-fullscreen", - tray: "crayon-pie-chart-container-tray", - copilot: "crayon-pie-chart-container-copilot", -}; +const STACKED_LEGEND_BREAKPOINT = 400; -// Helper function to calculate percentage -const calculatePercentage = (value: number, total: number): number => { - if (total === 0) { - return 0; - } - return Number(((value / total) * 100).toFixed(2)); -}; - -export const PieChart = ({ +const PieChartComponent = ({ data, categoryKey, dataKey, @@ -50,126 +60,368 @@ export const PieChart = ({ variant = "pie", format = "number", legend = true, - label = true, + legendVariant = "stacked", isAnimationActive = true, + appearance = "circular", + cornerRadius = 0, + paddingAngle = 0, + useGradients = false, + gradientColors, + onMouseEnter, + onMouseLeave, + onClick, + className, }: PieChartProps) => { - const { layout } = useLayoutContext(); - const [calculatedOuterRadius, setCalculatedOuterRadius] = useState(120); - const [calculatedInnerRadius, setCalculatedInnerRadius] = useState(0); - const containerRef = useRef(null); + const wrapperRef = useRef(null); + const [wrapperRect, setWrapperRect] = useState({ width: 0, height: 0 }); + const [hoveredLegendKey, setHoveredLegendKey] = useState(null); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const { activeIndex, handleMouseEnter, handleMouseLeave } = useChartHover(); - // Calculate dynamic radius based on layout - useEffect(() => { - if (!containerRef.current) return; - - const resizeObserver = new ResizeObserver( - debounce((entries: any) => { - const { width } = entries[0].contentRect; - - // Calculate outer radius - let newOuterRadius = 120; // default - if (layout === "mobile") { - newOuterRadius = label ? (width > 300 ? 85 : 75) : width > 300 ? 95 : 80; - } else if (layout === "fullscreen") { - newOuterRadius = 120; - } else if (layout === "tray" || layout === "copilot") { - newOuterRadius = 90; - } + // Determine layout mode based on container width + const isRowLayout = + legend && legendVariant === "stacked" && wrapperRect.width >= STACKED_LEGEND_BREAKPOINT; + + // The data that is processed and rendered in the chart + const processedData = useMemo(() => data, [data]); + + const categories = useMemo( + () => processedData.map((item) => String(item[categoryKey])), + [processedData, categoryKey], + ); + const transformedKeys = useTransformedKeys(categories); + + // Memoize string conversions to avoid repeated calls + const categoryKeyString = useMemo(() => String(categoryKey), [categoryKey]); + const dataKeyString = useMemo(() => String(dataKey), [dataKey]); + const formatKey = useMemo( + () => (format === "percentage" ? "percentage" : dataKeyString), + [format, dataKeyString], + ); + + // Use provided dimensions or observed dimensions from the wrapper + const effectiveWidth = wrapperRect.width; + const effectiveHeight = wrapperRect.height; + + // Calculate chart dimensions based on the smaller dimension of the container + const chartSize = useMemo(() => { + let size; + if (isRowLayout) { + const chartContainerWidth = (effectiveWidth - 20) / 2; // Subtract gap + size = Math.min(chartContainerWidth, effectiveHeight); + } else { + size = Math.min(effectiveWidth, effectiveHeight); + } + size = Math.min(size, MAX_CHART_SIZE); + return Math.max(MIN_CHART_SIZE, size); + }, [effectiveWidth, effectiveHeight, isRowLayout]); + + const chartSizeStyle = useMemo(() => ({ width: chartSize, height: chartSize }), [chartSize]); + const rechartsProps = useMemo(() => ({ width: chartSize, height: chartSize }), [chartSize]); + + // Memoize expensive data transformations and configurations + const transformedData = useMemo( + () => transformDataWithPercentages(processedData, dataKey), + [processedData, dataKey], + ); + + const chartConfig = useMemo( + () => getCategoricalChartConfig(processedData, categoryKey, theme, transformedKeys), + [processedData, categoryKey, theme, transformedKeys], + ); + + const animationConfig = useMemo( + () => createAnimationConfig({ isAnimationActive }), + [isAnimationActive], + ); + + const eventHandlers = useMemo( + () => createEventHandlers(onMouseEnter, onMouseLeave, onClick), + [onMouseEnter, onMouseLeave, onClick], + ); + + const sectorStyle = useMemo( + () => createSectorStyle(cornerRadius, variant === "donut" ? 0.5 : paddingAngle), + [cornerRadius, variant, paddingAngle], + ); + + const palette = useMemo(() => getPalette(theme), [theme]); + const colors = useMemo( + () => getDistributedColors(palette, processedData.length), + [palette, processedData.length], + ); + + const gradientDefinitions = useMemo(() => { + if (!useGradients) return null; + const chartColors = Object.values(chartConfig) + .map((config) => config.color) + .filter((color): color is string => color !== undefined); + return createGradientDefinitions(transformedData, chartColors, gradientColors); + }, [useGradients, chartConfig, transformedData, gradientColors]); - // Calculate inner radius for donut - let newInnerRadius = 0; - if (variant === "donut") { - if (layout === "mobile") { - newInnerRadius = label ? (width > 300 ? 50 : 30) : width > 300 ? 60 : 50; - } else { - newInnerRadius = 60; + const legendItems = useMemo( + () => + processedData.map((item, index) => ({ + key: String(item[categoryKey]), + label: String(item[categoryKey]), + value: Number(item[dataKey]), + color: colors[index] || "#000000", + })), + [processedData, categoryKey, dataKey, colors], + ); + + const defaultLegendItems = useMemo((): LegendItem[] => { + return legendItems.map(({ key, label, color }) => ({ key, label, color })); + }, [legendItems]); + + const sortedData = useMemo( + () => [...processedData].sort((a, b) => Number(b[dataKey]) - Number(a[dataKey])), + [processedData, dataKey], + ); + + const handleLegendItemHover = useCallback( + (index: number | null) => { + if (legendVariant !== "stacked") return; + if (index !== null) { + const item = sortedData[index]; + if (item) { + const categoryValue = String(item[categoryKey]); + setHoveredLegendKey(categoryValue); + const processedIndex = processedData.findIndex( + (d) => String(d[categoryKey]) === categoryValue, + ); + if (processedIndex !== -1) { + handleMouseEnter(processedData[processedIndex], processedIndex); } } + } else { + setHoveredLegendKey(null); + handleMouseLeave(); + } + }, + [sortedData, categoryKey, processedData, handleMouseEnter, handleMouseLeave, legendVariant], + ); - setCalculatedOuterRadius(newOuterRadius); - setCalculatedInnerRadius(newInnerRadius); - }, 100), - ); + const handleChartMouseEnter = useCallback( + (entry: any, index: number) => { + handleMouseEnter(entry, index); + if (legend && legendVariant === "stacked") { + setHoveredLegendKey(String(entry[categoryKey])); + } + eventHandlers.onMouseEnter?.(entry, index); + }, + [handleMouseEnter, categoryKey, legend, legendVariant, eventHandlers.onMouseEnter], + ); + + const handleChartMouseLeave = useCallback(() => { + handleMouseLeave(); + if (legend && legendVariant === "stacked") { + setHoveredLegendKey(null); + } + eventHandlers.onMouseLeave?.(); + }, [handleMouseLeave, legend, legendVariant, eventHandlers.onMouseLeave]); + + const dimensions = useMemo(() => { + if (variant === "donut") { + return calculateTwoLevelChartDimensions(chartSize); + } + return { outerRadius: "90%", innerRadius: 0, middleRadius: 0 }; + }, [variant, chartSize]); + + const startAngle = useMemo(() => (appearance === "semiCircular" ? 180 : 0), [appearance]); + const endAngle = useMemo(() => (appearance === "semiCircular" ? 0 : 360), [appearance]); - resizeObserver.observe(containerRef.current); - return () => resizeObserver.disconnect(); - }, [layout, label, variant]); - - // Calculate total for percentage calculations - const total = data.reduce((sum, item) => sum + Number(item[dataKey]), 0); - - // Transform data with percentages - const transformedData = data.map((item) => ({ - ...item, - percentage: calculatePercentage(Number(item[dataKey as string]), total), - originalValue: item[dataKey as string], - })); - - // Get color palette and distribute colors - const palette = getPalette(theme); - const colors = getDistributedColors(palette, data.length); - - // Create chart configuration - const chartConfig = data.reduce( - (config, item, index) => ({ - ...config, - [String(item[categoryKey])]: { - label: String(item[categoryKey as string]), - color: colors[index], - }, + const commonPieProps = useMemo( + () => ({ + data: transformedData, + dataKey: formatKey, + nameKey: categoryKeyString, + labelLine: false, + label: false, + ...animationConfig, + ...eventHandlers, + ...sectorStyle, + startAngle, + endAngle, + onMouseEnter: handleChartMouseEnter, + onMouseLeave: handleChartMouseLeave, }), - {}, + [ + transformedData, + formatKey, + categoryKeyString, + animationConfig, + eventHandlers, + sectorStyle, + startAngle, + endAngle, + handleChartMouseEnter, + handleChartMouseLeave, + ], ); - // Custom label renderer - const renderCustomLabel = ({ payload, cx, cy, x, y, textAnchor, dominantBaseline }: any) => { - if (payload.percentage <= 10) return null; - const displayValue = format === "percentage" ? payload.percentage : payload[dataKey]; - const formattedValue = - String(displayValue).length > 7 ? `${String(displayValue).slice(0, 7)}...` : displayValue; + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; - return ( - - { + const entry = entries[0]; + if (entry) { + setWrapperRect({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + observer.observe(wrapper); + return () => observer.disconnect(); + }, []); + + const renderPieCharts = useCallback(() => { + if (variant === "donut") { + return [ + + {transformedData.map((entry, index: number) => { + const categoryValue = String(entry[categoryKey as keyof typeof entry] || ""); + const transformedKey = transformedKeys[categoryValue] ?? categoryValue; + const config = chartConfig[transformedKey]; + const hoverStyles = getHoverStyles(index, activeIndex); + const fill = config?.color || colors[index]; + return ( + + ); + })} + , + - {formattedValue} - {format === "percentage" ? "%" : ""} - - + {transformedData.map((entry, index: number) => { + const categoryValue = String(entry[categoryKey as keyof typeof entry] || ""); + const transformedKey = transformedKeys[categoryValue] ?? categoryValue; + const config = chartConfig[transformedKey]; + const hoverStyles = getHoverStyles(index, activeIndex); + const fill = useGradients ? `url(#gradient-${index})` : config?.color || colors[index]; + return ; + })} + , + ]; + } + return ( + + {transformedData.map((entry, index: number) => { + const categoryValue = String(entry[categoryKey as keyof typeof entry] || ""); + const transformedKey = transformedKeys[categoryValue] ?? categoryValue; + const config = chartConfig[transformedKey]; + const hoverStyles = getHoverStyles(index, activeIndex); + const fill = useGradients ? `url(#gradient-${index})` : config?.color || colors[index]; + return ; + })} + + ); + }, [ + variant, + commonPieProps, + dimensions, + transformedData, + categoryKey, + chartConfig, + activeIndex, + useGradients, + colors, + transformedKeys, + ]); + + const renderLegend = useCallback(() => { + if (!legend) return null; + if (legendVariant === "stacked") { + return ( +
+ +
+ ); + } + return ( + ); - }; + }, [ + legend, + legendVariant, + legendItems, + hoveredLegendKey, + handleLegendItemHover, + wrapperRect.width, + isRowLayout, + defaultLegendItems, + isLegendExpanded, + ]); + + const wrapperClassName = useMemo( + () => + clsx("crayon-pie-chart-container-wrapper", className, { + "layout-row": isRowLayout, + "layout-column": !isRowLayout, + "legend-default": legend && legendVariant === "default", + "legend-stacked": legend && legendVariant === "stacked", + }), + [className, legend, legendVariant, isRowLayout], + ); return ( - - - } /> - {legend && } />} - - {Object.entries(chartConfig).map(([key, config]) => ( - - ))} - - - +
+
+
+
+ + + } + /> + {gradientDefinitions && {gradientDefinitions}} + {renderPieCharts()} + + +
+
+
+ {renderLegend()} +
); }; + +export const PieChart = memo(PieChartComponent); + +PieChart.displayName = "PieChart"; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/components/PieChartRenderers.tsx b/js/packages/react-ui/src/components/Charts/PieChart/components/PieChartRenderers.tsx new file mode 100644 index 000000000..be1ced441 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/PieChart/components/PieChartRenderers.tsx @@ -0,0 +1,82 @@ +export const MIN_CHART_SIZE = 150; +export const MAX_CHART_SIZE = 500; + +/** + * Creates gradient definitions for pie chart segments + * @param data - The chart data array + * @param colors - Array of base colors for segments + * @param gradientColors - Optional array of gradient color configurations + * @returns Array of gradient definition elements + */ +export const createGradientDefinitions = ( + data: any[], + colors: string[], + gradientColors?: Array<{ start?: string; end?: string }>, +) => { + return data.map((_, index) => { + const defaultBaseColor = colors[index] || "#000000"; + + // Get the base color from gradientColors or fallback to default + let baseColor = defaultBaseColor; + let secondaryColor = colors[data.length - index - 1] || "#ffffff"; + + if (gradientColors?.[index]) { + // If both colors are provided, use them + if (gradientColors[index].start && gradientColors[index].end) { + baseColor = gradientColors[index].start; + secondaryColor = gradientColors[index].end; + } + // If only start color is provided, create a darker version for end + else if (gradientColors[index].start) { + baseColor = gradientColors[index].start; + secondaryColor = adjustColorBrightness(baseColor, -20); + } + // If only end color is provided, create a lighter version for start + else if (gradientColors[index].end) { + baseColor = adjustColorBrightness(gradientColors[index].end, 20); + secondaryColor = gradientColors[index].end; + } + } + + return ( + + + + + + + ); + }); +}; + +/** + * Adjusts the brightness of a hex color + * @param hex - The hex color to adjust + * @param percent - The percentage to adjust brightness by (-100 to 100) + * @returns The adjusted hex color + */ +export const adjustColorBrightness = (hex: string, percent: number): string => { + if (!hex || !hex.startsWith("#")) { + return hex; + } + try { + const num = parseInt(hex.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = Math.min(255, Math.max(0, (num >> 16) + amt)); + const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt)); + const B = Math.min(255, Math.max(0, (num & 0x0000ff) + amt)); + return "#" + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); + } catch (error) { + console.warn("Invalid color format:", hex); + console.warn(error); + return hex; + } +}; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/index.ts b/js/packages/react-ui/src/components/Charts/PieChart/index.ts index 223a80128..d4a4262d5 100644 --- a/js/packages/react-ui/src/components/Charts/PieChart/index.ts +++ b/js/packages/react-ui/src/components/Charts/PieChart/index.ts @@ -1 +1,2 @@ export * from "./PieChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/pieChart.scss b/js/packages/react-ui/src/components/Charts/PieChart/pieChart.scss new file mode 100644 index 000000000..9e9a51c67 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/PieChart/pieChart.scss @@ -0,0 +1,82 @@ +@use "../../../cssUtils" as cssUtils; + +// Main wrapper for the entire chart component +.crayon-pie-chart-container-wrapper { + display: flex; + position: relative; + gap: 20px; + + // Default legend is always a column layout + &.legend-default { + flex-direction: column; + align-items: center; + } + + // Stacked legend uses a column layout by default (mobile-first) + &.legend-stacked.layout-column { + flex-direction: column; + align-items: center; + } + + // Switches to a row layout on wider containers + &.legend-stacked.layout-row { + flex-direction: row; + align-items: center; + min-height: 296px; + + .crayon-pie-chart-legend-container { + height: 296px; + } + } +} + +// Container for the chart itself +.crayon-pie-chart-container { + display: flex; + align-items: center; + justify-content: center; + position: relative; + + // When in a row layout, the chart container should be flexible + .layout-row & { + flex: 1; + min-width: 0; + } +} + +// Recharts chart styles +.crayon-pie-chart { + display: flex; + align-items: center; + justify-content: center; + + .crayon-pie-chart__inner-cell { + fill: cssUtils.$bg-sunk; + } +} + +// Container for the legend component +.crayon-pie-chart-legend-container { + display: flex; + justify-content: center; + + // Default legend is centered at the bottom + .legend-default & { + width: 100%; + align-items: center; + } + + // Stacked legend in column layout + .layout-column.legend-stacked & { + width: 100%; + align-items: flex-start; + } + + // Stacked legend in row layout + .layout-row.legend-stacked & { + flex: 1; + min-width: 0; + height: 100%; + align-items: center; + } +} diff --git a/js/packages/react-ui/src/components/Charts/PieChart/stories/PieChart.stories.tsx b/js/packages/react-ui/src/components/Charts/PieChart/stories/PieChart.stories.tsx new file mode 100644 index 000000000..fcd180e9a --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/PieChart/stories/PieChart.stories.tsx @@ -0,0 +1,611 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Card } from "../../../Card"; +import { PieChart, PieChartProps } from "../PieChart"; + +const monthlySalesData = [ + { month: "January", value: 1250 }, + { month: "February", value: 980 }, + { month: "March", value: 1450 }, + { month: "April", value: 1320 }, + { month: "May", value: 1680 }, + { month: "June", value: 2100 }, +]; + +const comprehensiveData = [ + { category: "Electronics", sales: 12500 }, + { category: "Apparel", sales: 9800 }, + { category: "Groceries", sales: 14500 }, + { category: "Home Goods", sales: 13200 }, + { category: "Books", sales: 8800 }, + { category: "Toys", sales: 7600 }, + { category: "Automotive", sales: 6500 }, + { category: "Health", sales: 11200 }, + { category: "Beauty", sales: 9300 }, + { category: "Sports", sales: 8100 }, + { category: "Outdoors", sales: 7200 }, + { category: "Music", sales: 4500 }, + { category: "Software", sales: 10500 }, +]; + +const gradientPalette = [ + { start: "#FF6B6B", end: "#FF8E8E" }, + { start: "#4ECDC4", end: "#6ED7D0" }, + { start: "#45B7D1", end: "#6BC5DB" }, + { start: "#96CEB4", end: "#B4DCC9" }, + { start: "#FFEEAD", end: "#FFF4C4" }, + { start: "#D4A5A5", end: "#E5BDBD" }, + { start: "#9B59B6", end: "#B07CC7" }, +]; + +/** + * # PieChart Component Documentation + * + * The PieChart component is a versatile and intuitive tool for visualizing + * part-to-whole relationships in a dataset. It's highly effective for: + * + * - **Proportional Analysis**: Showing how individual segments contribute to a total + * - **Category Comparison**: Comparing the relative size of different categories + * - **Dashboard KPIs**: Displaying key metrics like market share or budget allocation + * + * ## Key Features + * + * ### Chart Types + * - **Pie**: Classic circular chart for proportional representation + * - **Donut**: Pie chart with a center cutout, useful for displaying a central metric or for a modern look + * + * ### Layout Variants + * - **Circular**: Full 360-degree display for a complete data overview + * - **Semicircle**: Half-circle (180-degree) layout, ideal for compact dashboard widgets + * + * ### Data Formatting + * - **Percentage Mode**: Automatically calculates and displays segment values as percentages + * - **Number Mode**: Shows raw data values with appropriate formatting + * + * ### Interactive & Responsive + * - **Interactive Legend**: Adapts to container size with carousel navigation for large datasets + * - **Hover Tooltips**: Provides detailed information on hover for better user engagement + * - **Responsive Design**: Fluidly adjusts to the size of its container + * + * ### Customization + * - **Theming**: Six pre-built color palettes to match your application's design + * - **Styling Options**: Control corner radius, padding between slices, and more + * - **Gradient Fills**: Apply beautiful gradients for an enhanced visual appeal + */ + +const meta: Meta> = { + title: "Components/Charts/PieChart", + component: PieChart, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## Installation and Basic Usage + +\`\`\`tsx +import { PieChart } from '@crayon-ui/react-ui/Charts/PieChart'; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +Your data should be an array of objects where each object contains: +- A **category field** (string): Used for labels and legend items +- A **value field** (number): Used to determine slice sizes + +\`\`\`tsx +const salesData = [ + { category: "Electronics", value: 4500 }, + { category: "Apparel", value: 3200 }, + { category: "Groceries", value: 6800 } +]; +\`\`\` + +## Performance Considerations +- **Data Size**: Best for 3-12 data points for readability. +- **Responsiveness**: Fully responsive and adapts to its container. +- **Animation**: Can be disabled for performance with very large or complex charts. + `, + }, + }, + }, + tags: ["!dev", "autodocs"], + argTypes: { + data: { + description: ` +**Required.** An array of data objects representing your dataset. Each object should contain: +- A category identifier (string) +- A numeric value + +**Best Practices:** +- Use 3-12 data points for optimal readability. +- Ensure consistent data structure across all objects. +`, + control: false, + table: { + type: { summary: "Array>" }, + defaultValue: { summary: "[]" }, + category: "📊 Data Configuration", + }, + }, + categoryKey: { + description: + "**Required.** The key in your data object that represents the category label (e.g., 'month', 'department').", + control: false, + table: { + type: { summary: "string" }, + category: "📊 Data Configuration", + }, + }, + dataKey: { + description: + "**Required.** The key in your data object that contains the numeric value for each slice (e.g., 'value', 'sales').", + control: false, + table: { + type: { summary: "string" }, + category: "📊 Data Configuration", + }, + }, + theme: { + description: + "The color palette for the chart. Provides a set of aesthetically pleasing colors.", + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "🎨 Visual Styling", + }, + }, + appearance: { + description: "The overall shape of the chart: a full circle or a semicircle.", + control: "radio", + options: ["circular", "semiCircular"], + table: { + defaultValue: { summary: "circular" }, + category: "🎨 Visual Styling", + }, + }, + variant: { + description: "The style of the chart: a standard pie or a donut with a cutout center.", + control: "radio", + options: ["pie", "donut"], + table: { + defaultValue: { summary: "pie" }, + category: "🎨 Visual Styling", + }, + }, + format: { + description: + "The format for displaying data values, either as raw numbers or as percentages.", + control: "radio", + options: ["percentage", "number"], + table: { + defaultValue: { summary: "number" }, + category: "📱 Display Options", + }, + }, + legend: { + description: "Controls the visibility of the chart's legend.", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "📱 Display Options", + }, + }, + legendVariant: { + description: "The layout of the legend: a horizontal list or a responsive vertical stack.", + control: "radio", + options: ["default", "stacked"], + table: { + defaultValue: { summary: "default" }, + category: "📱 Display Options", + }, + }, + isAnimationActive: { + description: "Enables or disables the initial loading animation.", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "🎬 Animation & Interaction", + }, + }, + cornerRadius: { + description: "The radius for rounding the corners of each pie slice.", + control: { type: "number", min: 0, max: 20 }, + table: { + type: { summary: "number" }, + defaultValue: { summary: "0" }, + category: "🎨 Visual Styling", + }, + }, + paddingAngle: { + description: "The spacing angle between each pie slice.", + control: { type: "number", min: 0, max: 10 }, + table: { + type: { summary: "number" }, + defaultValue: { summary: "0" }, + category: "🎨 Visual Styling", + }, + }, + useGradients: { + description: "Applies gradient fills to the pie slices instead of solid colors.", + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "🎨 Visual Styling", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * ## Default Configuration + * + * This example showcases the PieChart with its standard settings, making it an + * ideal starting point for most data visualization needs. + * + * **Key Features Shown:** + * - Standard circular pie layout + * - Professional 'ocean' color theme + * - Responsive stacked legend for clarity + * - Smooth animations on load + */ +export const DefaultConfiguration: Story = { + name: "📊 Default Configuration", + args: { + data: monthlySalesData, + categoryKey: "month", + dataKey: "value", + theme: "ocean", + variant: "pie", + format: "number", + legend: true, + legendVariant: "stacked", + isAnimationActive: true, + appearance: "circular", + cornerRadius: 0, + paddingAngle: 0, + useGradients: false, + gradientColors: gradientPalette, + }, + render: (args: any) => ( + +

+ Monthly Sales Performance +

+ +
+ ), +}; + +/** + * ## Layout and Variant Options + * + * This story demonstrates the different layout (`appearance`) and style (`variant`) + * options available in the PieChart component. + * + * **Options Shown:** + * - **Pie vs. Donut**: See the difference between a full pie and one with a center cutout. + * - **Circular vs. Semicircle**: Compare the full 360° layout with the space-saving 180° version. + */ +export const LayoutAndVariantOptions: Story = { + name: "🎨 Layout & Variant Options", + args: { + data: monthlySalesData.slice(0, 4), + categoryKey: "month", + dataKey: "value", + theme: "emerald", + legend: true, + legendVariant: "stacked", + isAnimationActive: false, + cornerRadius: 0, + paddingAngle: 0, + }, + render: (args: any) => ( +
+
+

Pie - Circular

+ + + +
+
+

Donut - Circular

+ + + +
+
+

Pie - Semicircle

+ + + +
+
+

Donut - Semicircle

+ + + +
+
+ ), +}; + +/** + * ## Large Dataset with Carousel + * + * The PieChart's legend intelligently handles a large number of items by + * enabling a scrollable carousel with up/down navigation. + * + * **Feature Highlight:** + * - The legend's carousel appears automatically when items overflow the available space, ensuring all data points remain accessible without cluttering the UI. + */ +export const LargeDatasetWithCarousel: Story = { + name: "📈 Large Dataset with Carousel", + args: { + data: comprehensiveData, + categoryKey: "category", + dataKey: "sales", + theme: "spectrum", + variant: "donut", + legend: true, + legendVariant: "stacked", + cornerRadius: 4, + paddingAngle: 1, + }, + render: (args: any) => ( + + + + ), +}; + +export const ResponsiveDemo: Story = { + name: "📱 Responsive Behavior Demo", + args: { + data: comprehensiveData, + categoryKey: "category", + dataKey: "sales", + theme: "spectrum", + variant: "circular", + format: "number", + legend: true, + legendVariant: "stacked", + isAnimationActive: false, + cornerRadius: 8, + useGradients: false, + }, + render: (args: any) => { + const [dimensions, setDimensions] = useState<{ width: number; height: number | string }>({ + width: 700, + height: "auto", + }); + + const handleMouseDown = (e: React.MouseEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = dimensions.width; + const startHeight = (e.currentTarget.parentElement as HTMLElement).offsetHeight; + + const doDrag = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let newWidth = startWidth; + let newHeight = startHeight; + + if (handle.includes("e")) newWidth = startWidth + dx; + if (handle.includes("w")) newWidth = startWidth - dx; + if (handle.includes("s")) newHeight = startHeight + dy; + if (handle.includes("n")) newHeight = startHeight - dy; + + setDimensions({ + width: Math.max(300, newWidth), + height: "auto", + }); + }; + + const stopDrag = () => { + document.removeEventListener("mousemove", doDrag); + document.removeEventListener("mouseup", stopDrag); + }; + + document.addEventListener("mousemove", doDrag); + document.addEventListener("mouseup", stopDrag); + }; + + const handleStyle: React.CSSProperties = { + position: "absolute", + background: "#3b82f6", + opacity: 0.6, + zIndex: 10, + transition: "opacity 0.2s ease", + }; + + return ( +
+
+

+ Responsive Behavior Demonstration +

+

+ Drag the edges or corners of the container below to see how the chart adapts +

+
+ 🎯 Try This: Resize the container to see automatic legend layout + changes, chart proportion adjustments, and responsive breakpoint behaviors. +
+
+ + + + + {/* Resize Handles */} +
handleMouseDown(e, "n")} + /> +
handleMouseDown(e, "s")} + /> +
handleMouseDown(e, "w")} + /> +
handleMouseDown(e, "e")} + /> + + {/* Corner Handles */} +
handleMouseDown(e, "nw")} + /> +
handleMouseDown(e, "ne")} + /> +
handleMouseDown(e, "se")} + /> + + {/* Dimension Display */} +
+ {dimensions.width}px ×{" "} + {typeof dimensions.height === "number" ? `${dimensions.height}px` : "auto"} +
+ + +
+ 📊 Responsive Features Demonstrated: +
    +
  • Automatic chart scaling and proportion maintenance
  • +
  • Legend layout adaptation (stacked ↔ horizontal)
  • +
  • Text size and spacing adjustments
  • +
  • Optimal space utilization at any container size
  • +
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: ` +**Responsive Design Philosophy:** + +**Automatic Adaptation:** +- Chart dimensions automatically adjust to container size +- Legend layout switches between horizontal and stacked based on available space +- Text and spacing scale appropriately for readability + +**Breakpoint Behavior:** +- **Large Containers (>600px)**: Side-by-side chart and legend layout +- **Medium Containers (400-600px)**: Stacked layout with optimized proportions +- **Small Containers (<400px)**: Compact layout with essential elements only + +**Performance Optimizations:** +- Efficient re-rendering during resize operations +- Debounced resize calculations to prevent performance issues +- Optimized SVG scaling for crisp display at any size + +**Implementation Notes:** +- Uses ResizeObserver API for precise container size detection +- Maintains aspect ratios while maximizing space utilization +- Supports both fixed and flexible container sizing strategies + `, + }, + }, + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/stories/pieChart.stories.tsx b/js/packages/react-ui/src/components/Charts/PieChart/stories/pieChart.stories.tsx deleted file mode 100644 index e1929524e..000000000 --- a/js/packages/react-ui/src/components/Charts/PieChart/stories/pieChart.stories.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Card } from "../../../Card"; -import { PieChart, PieChartProps } from "../PieChart"; - -const pieChartData = [ - { month: "January", value: 4250 }, - { month: "February", value: 3820 }, - { month: "March", value: 4680 }, - { month: "April", value: 4120 }, - { month: "May", value: 5340 }, - { month: "June", value: 6250 }, - { month: "July", value: 5890 }, -]; - -const meta: Meta> = { - title: "Components/Charts/PieChart", - component: PieChart, - parameters: { - layout: "centered", - docs: { - description: { - component: "```tsx\nimport { PieChart } from '@crayon-ui/react-ui/Charts/PieChart';\n```", - }, - }, - }, - tags: ["!dev", "autodocs"], - - argTypes: { - data: { - description: - "An array of data objects where each object represents a data point. Each object should have a category field (e.g., month) and one or more numeric values for the areas to be plotted.", - control: false, - table: { - type: { summary: "Array>" }, - defaultValue: { summary: "[]" }, - category: "Data", - }, - }, - categoryKey: { - description: - "The key from your data object to be used as the segment labels (e.g., 'month', 'category', 'name')", - control: false, - table: { - type: { summary: "string" }, - category: "Data", - }, - }, - dataKey: { - description: - "The key from your data object to be used as the values that determine the slice sizes (e.g., 'value', 'count', 'amount')", - control: false, - table: { - type: { summary: "string" }, - category: "Data", - }, - }, - theme: { - description: - "The color palette theme for the chart. Each theme provides a different set of colors for the areas.", - control: "select", - options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], - table: { - defaultValue: { summary: "ocean" }, - category: "Appearance", - }, - }, - variant: { - description: - "The style of the pie chart. 'pie' shows a pie chart, while 'donut' shows a donut chart.", - control: "radio", - options: ["pie", "donut"], - table: { - defaultValue: { summary: "pie" }, - category: "Appearance", - }, - }, - format: { - description: - "The format of the data. 'percentage' shows the data as a percentage, while 'number' shows the data as a number.", - control: "radio", - options: ["percentage", "number"], - table: { - defaultValue: { summary: "percentage" }, - category: "Display", - }, - }, - legend: { - description: "Whether to display the legend", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - label: { - description: "Whether to display the data point labels above each point on the chart", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - isAnimationActive: { - description: "Whether to animate the chart", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const PieChartStory: Story = { - name: "Pie Chart", - args: { - data: pieChartData, - categoryKey: "month", - dataKey: "value", - theme: "ocean", - variant: "pie", - format: "number", - legend: true, - label: true, - isAnimationActive: true, - }, - render: (args: any) => ( - - - - ), - parameters: { - docs: { - source: { - code: ` - const pieChartData = [ - { month: "January", value: 400 }, - { month: "February", value: 300 }, - { month: "March", value: 300 }, - { month: "April", value: 400 }, - { month: "May", value: 300 }, - { month: "June", value: 300 }, - { month: "July", value: 300 }, -]; - - - - - `, - }, - }, - }, -}; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/types/index.ts b/js/packages/react-ui/src/components/Charts/PieChart/types/index.ts new file mode 100644 index 000000000..44e9afb30 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/PieChart/types/index.ts @@ -0,0 +1 @@ +export type PieChartData = Array>; diff --git a/js/packages/react-ui/src/components/Charts/PieChart/utils/PieChartUtils.ts b/js/packages/react-ui/src/components/Charts/PieChart/utils/PieChartUtils.ts new file mode 100644 index 000000000..48c80dcbc --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/PieChart/utils/PieChartUtils.ts @@ -0,0 +1,239 @@ +/** + * Utility functions for pie charts + */ +import { useState } from "react"; +import { PieChartData } from "../types"; + +export interface ChartDimensions { + outerRadius: number; + innerRadius: number; +} + +export interface TwoLevelChartDimensions extends ChartDimensions { + middleRadius: number; +} + +export interface HoverStyles { + opacity: number; + stroke: string; + strokeWidth: number; +} + +export interface ChartHoverHook { + activeIndex: number | null; + handleMouseEnter: (event: any, index: number) => void; + handleMouseLeave: () => void; +} + +export interface AnimationConfig { + isAnimationActive: boolean; + animationBegin: number; + animationDuration: number; + animationEasing: "ease" | "ease-in" | "ease-out" | "ease-in-out" | "linear"; +} + +// ========================================== +// Core Calculation Utilities +// ========================================== + +/** + * Calculates the percentage value of a number relative to a total + * @param value - The value to calculate percentage for + * @param total - The total value to calculate percentage against + * @returns The calculated percentage rounded to 2 decimal places + */ +const calculatePercentage = (value: number, total: number): number => { + if (total === 0) { + return 0; + } + return Number(((value / total) * 100).toFixed(2)); +}; + +// ========================================== +// Chart Dimension Calculations +// ========================================== + +/** + * Calculates dimensions for standard pie/donut charts + * @param width - The container width + * @param variant - The chart variant ('pie' or 'donut') + * @returns Object containing outer and inner radius values + */ +const calculateChartDimensions = (width: number, variant: string): ChartDimensions => { + const baseRadiusPercentage = 0.4; // 40% of container width + let outerRadius = Math.round(width * baseRadiusPercentage); + + // Set minimum and maximum bounds for radius + outerRadius = Math.max(50, Math.min(outerRadius, width / 2 - 10)); // Ensure radius isn't too small or too large + + // Calculate inner radius for donut variant - consistent ratio regardless of layout + let innerRadius = 0; + if (variant === "donut") { + innerRadius = Math.round(outerRadius * 0.6); + } + + return { outerRadius, innerRadius }; +}; + +/** + * Calculates dimensions for two-level pie charts + * @param width - The container width + * @returns Object containing outer, middle, and inner radius values + */ +const calculateTwoLevelChartDimensions = (width: number): TwoLevelChartDimensions => { + const baseRadiusPercentage = 0.4; // 40% of container width + let outerRadius = Math.round(width * baseRadiusPercentage); + + // Set minimum and maximum bounds for radius + outerRadius = Math.max(50, Math.min(outerRadius, width / 2 - 10)); + + // Calculate middle radius (inner ring's outer boundary) + const middleRadius = Math.round(outerRadius * 0.9); + + // Calculate inner radius - always has a value in two-level chart + const innerRadius = Math.round(middleRadius * 0.28); + + return { outerRadius, middleRadius, innerRadius }; +}; + +// ========================================== +// Layout and Styling Utilities +// ========================================== + +/** + * Generates hover style properties for chart cells + * @param index - The index of the current cell + * @param activeIndex - The index of the currently hovered cell + * @returns Object containing hover style properties + */ +const getHoverStyles = (index: number, activeIndex: number | null): HoverStyles => { + return { + opacity: activeIndex === null || activeIndex === index ? 1 : 0.6, + stroke: activeIndex === index ? "#fff" : "none", + strokeWidth: activeIndex === index ? 2 : 0, + }; +}; + +// ========================================== +// Data Transformation Utilities +// ========================================== + +/** + * Transforms data by adding percentage calculations + * @param data - The input data array + * @param dataKey - The key to use for value calculations + * @returns Transformed data with added percentage and original value + */ +const transformDataWithPercentages = ( + data: T, + dataKey: keyof T[number], +) => { + const total = data.reduce((sum, item) => sum + Number(item[dataKey]), 0); + return data.map((item) => ({ + ...item, + percentage: calculatePercentage(Number(item[dataKey as string]), total), + originalValue: item[dataKey as string], + })); +}; + +// ========================================== +// Hover Effect Utilities +// ========================================== + +/** + * Custom hook for managing chart hover effects + * @returns Object containing hover state and handlers + */ +const useChartHover = (): ChartHoverHook => { + const [activeIndex, setActiveIndex] = useState(null); + + const handleMouseEnter = (_: any, index: number) => { + setActiveIndex(index); + }; + + const handleMouseLeave = () => { + setActiveIndex(null); + }; + + return { + activeIndex, + handleMouseEnter, + handleMouseLeave, + }; +}; + +// ========================================== +// Animation Utilities +// ========================================== + +/** + * Creates animation configuration for pie chart + * @param config - Animation configuration options + * @returns Animation configuration object + */ +const createAnimationConfig = (config: Partial = {}): AnimationConfig => { + return { + isAnimationActive: config.isAnimationActive ?? true, + animationBegin: config.animationBegin ?? 0, + animationDuration: config.animationDuration ?? 1500, + animationEasing: config.animationEasing ?? "ease", + }; +}; + +// ========================================== +// Event Handler Utilities +// ========================================== + +/** + * Creates event handlers for pie chart + * @param onMouseEnter - Mouse enter handler + * @param onMouseLeave - Mouse leave handler + * @param onClick - Click handler + * @returns Object containing event handlers + */ +const createEventHandlers = ( + onMouseEnter?: (data: any, index: number) => void, + onMouseLeave?: () => void, + onClick?: (data: any, index: number) => void, +) => { + return { + onMouseEnter: onMouseEnter + ? (data: any, index: number) => onMouseEnter(data, index) + : undefined, + onMouseLeave: onMouseLeave ? () => onMouseLeave() : undefined, + onClick: onClick ? (data: any, index: number) => onClick(data, index) : undefined, + }; +}; + +// ========================================== +// Sector Style Utilities +// ========================================== + +/** + * Creates sector style configuration + * @param cornerRadius - Corner radius for sectors + * @param paddingAngle - Padding angle between sectors + * @returns Sector style configuration + */ +const createSectorStyle = (cornerRadius: number = 0, paddingAngle: number = 0) => { + return { + cornerRadius, + paddingAngle, + }; +}; + +// ========================================== +// Export all utility functions +// ========================================== + +export { + calculateChartDimensions, + calculatePercentage, + calculateTwoLevelChartDimensions, + createAnimationConfig, + createEventHandlers, + createSectorStyle, + getHoverStyles, + transformDataWithPercentages, + useChartHover, +}; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/RadarChart.tsx b/js/packages/react-ui/src/components/Charts/RadarChart/RadarChart.tsx index af863997c..c94b7c1ed 100644 --- a/js/packages/react-ui/src/components/Charts/RadarChart/RadarChart.tsx +++ b/js/packages/react-ui/src/components/Charts/RadarChart/RadarChart.tsx @@ -1,17 +1,18 @@ -import React from "react"; +import clsx from "clsx"; +import React, { memo, useEffect, useMemo, useRef, useState } from "react"; import { PolarAngleAxis, PolarGrid, Radar, RadarChart as RechartsRadarChart } from "recharts"; -import { - ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - keyTransform, -} from "../Charts"; +import { ChartConfig, ChartContainer, ChartTooltip } from "../Charts"; +import { SideBarTooltipProvider } from "../context/SideBarTooltipContext"; +import { useTransformedKeys } from "../hooks/useTransformKey"; +import { ActiveDot, CustomTooltipContent, DefaultLegend } from "../shared"; +import { LegendItem } from "../types"; import { getDistributedColors, getPalette } from "../utils/PalletUtils"; +import { get2dChartConfig, getDataKeys, getLegendItems } from "../utils/dataUtils"; +import { AxisLabel } from "./components/AxisLabel"; +import { RadarChartData } from "./types"; -export type RadarChartData = Array>; +const MIN_CHART_SIZE = 150; +const MAX_CHART_SIZE = 296; export interface RadarChartProps { data: T; @@ -26,7 +27,7 @@ export interface RadarChartProps { isAnimationActive?: boolean; } -export const RadarChart = ({ +const RadarChartComponent = ({ data, categoryKey, theme = "ocean", @@ -34,72 +35,157 @@ export const RadarChart = ({ grid = true, legend = true, strokeWidth = 2, - areaOpacity = 0.5, + areaOpacity = 0.2, icons = {}, - isAnimationActive = true, + isAnimationActive = false, }: RadarChartProps) => { - // excluding the categoryKey - const dataKeys = Object.keys(data[0] || {}).filter((key) => key !== categoryKey); + const dataKeys = useMemo(() => { + return getDataKeys(data, categoryKey as string); + }, [data, categoryKey]); - const palette = getPalette(theme); - const colors = getDistributedColors(palette, dataKeys.length); + const transformedKeys = useTransformedKeys(dataKeys); + + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, dataKeys.length); + }, [theme, dataKeys.length]); // Create Config - const chartConfig: ChartConfig = dataKeys.reduce( - (config, key, index) => ({ - ...config, - [key]: { - label: key, - icon: icons[key], - color: colors[index], - }, - }), - {}, + const chartConfig: ChartConfig = useMemo(() => { + return get2dChartConfig(dataKeys, colors, transformedKeys, undefined, icons); + }, [dataKeys, icons, colors, transformedKeys]); + + const legendItems: LegendItem[] = useMemo(() => { + return getLegendItems(dataKeys, colors, icons); + }, [dataKeys, colors, icons]); + + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const wrapperRef = useRef(null); + const [wrapperRect, setWrapperRect] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setWrapperRect({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + observer.observe(wrapper); + return () => observer.disconnect(); + }, []); + + const chartSize = useMemo(() => { + const effectiveWidth = wrapperRect.width; + const effectiveHeight = wrapperRect.height; + let charts = Math.min(effectiveWidth, effectiveHeight); + charts = Math.min(charts, MAX_CHART_SIZE); + return Math.max(MIN_CHART_SIZE, charts); + }, [wrapperRect]); + + const chartSizeStyle = useMemo(() => ({ width: chartSize, height: chartSize }), [chartSize]); + const rechartsProps = useMemo(() => ({ width: chartSize, height: chartSize }), [chartSize]); + + const radars = useMemo(() => { + return dataKeys.map((key) => { + const transformedKey = transformedKeys[key]; + const color = `var(--color-${transformedKey})`; + if (variant === "line") { + return ( + } + /> + ); + } else { + return ( + } + /> + ); + } + }); + }, [dataKeys, transformedKeys, variant, strokeWidth, areaOpacity, isAnimationActive]); + + const wrapperClassName = useMemo( + () => + clsx("crayon-radar-chart-container-wrapper", { + "layout-column": true, + }), + [], ); return ( - - - {grid && } - - - } /> - {dataKeys.map((key) => { - const transformedKey = keyTransform(key); - const color = `var(--color-${transformedKey})`; - if (variant === "line") { - return ( - - ); - } else { - return ( - - ); - } - })} + {}} + data={undefined} + setData={() => {}} + > +
+
+
+
+ + + {grid && } + } + /> + } /> + {/* rendering the radars here */} + {radars} + + +
+
+
{legend && ( - - } + )} - - +
+
); }; + +export const RadarChart = memo(RadarChartComponent); + +RadarChart.displayName = "RadarChart"; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/components/AxisLabel.tsx b/js/packages/react-ui/src/components/Charts/RadarChart/components/AxisLabel.tsx new file mode 100644 index 000000000..d03e3fa3d --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadarChart/components/AxisLabel.tsx @@ -0,0 +1,130 @@ +import clsx from "clsx"; +import React, { useLayoutEffect, useMemo, useRef } from "react"; +import { calculateAvailableWidth, truncateText } from "../utils"; + +// This is the props that are passed by recharts to the custom tick component +interface AxisLabelProps { + x?: number; + y?: number; + textAnchor?: string; + payload?: { + value: string; + }; + className?: string; + portalContainerRef?: React.RefObject; + + [key: string]: any; // To allow other props from recharts +} + +export const AxisLabel: React.FC = (props) => { + const { x, y, payload, textAnchor, portalContainerRef, className } = props; + const anchorRef = useRef(null); + + /** + * Memoizes the calculation of truncated text for axis labels + * + * This hook handles text truncation based on available space in the chart container: + * 1. Returns empty string or original value if payload/container is missing + * 2. Calculates container width and available space based on text anchor position + * 3. Truncates text to fit within available width using specified font size + * + * @returns {string} Truncated text that fits within available space + * + * Dependencies: + * - payload?.value: The text content to truncate + * - x: X-coordinate of the label + * - textAnchor: Text anchor position ('start', 'middle', 'end') + * - portalContainerRef: Reference to container element + */ + const truncatedText = useMemo(() => { + if (!payload?.value || !portalContainerRef?.current) { + return payload?.value || ""; + } + + const container = portalContainerRef.current; + // Get the width of the container + const containerWidth = container.getBoundingClientRect().width; + // Get the padding of the container + const padding = 0; + // Get the font size of the text + const fontSize = 10; + + // Calculate available width based on text anchor and position, and padding + const availableWidth = calculateAvailableWidth( + x || 0, + containerWidth, + textAnchor || "middle", + padding, + ); + + return truncateText(payload.value, availableWidth, fontSize); + }, [payload?.value, x, textAnchor, portalContainerRef]); + + useLayoutEffect(() => { + const container = portalContainerRef?.current; + const anchor = anchorRef.current; + + if (!container || !anchor || !truncatedText) { + return; + } + + // Create the label element + const labelEl = document.createElement("div"); + labelEl.textContent = truncatedText; + container.appendChild(labelEl); + + // Function to calculate and apply styles + const updatePosition = () => { + const anchorRect = anchor.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const left = anchorRect.left - containerRect.left; + const top = anchorRect.top - containerRect.top; + + const padding = 0; + let transform = ""; + if (textAnchor === "end") { + transform = `translate(calc(-100% - ${padding}px), -50%)`; + } else if (textAnchor === "start") { + transform = `translate(${padding}px, -50%)`; + } else { + transform = "translate(-50%, -50%)"; + } + + labelEl.style.position = "absolute"; + labelEl.style.left = `${left}px`; + labelEl.style.top = `${top}px`; + labelEl.style.transform = transform; + labelEl.style.pointerEvents = "none"; + labelEl.style.zIndex = "0"; + labelEl.className = clsx("crayon-chart-polar-angle-axis-label", className); + + // Update text content if it has changed due to container resize + const containerWidth = containerRect.width; + const availableWidth = calculateAvailableWidth( + left, + containerWidth, + textAnchor ?? "middle", + 0, + ); + const newTruncatedText = truncateText(payload?.value ?? "", availableWidth, 10); + if (labelEl.textContent !== newTruncatedText) { + labelEl.textContent = newTruncatedText; + } + }; + + updatePosition(); + + const resizeObserver = new ResizeObserver(updatePosition); + resizeObserver.observe(container); + + // Cleanup function to run when the component unmounts or deps change + return () => { + resizeObserver.disconnect(); + if (container.contains(labelEl)) { + container.removeChild(labelEl); + } + }; + }, [x, y, textAnchor, truncatedText, portalContainerRef, className, payload?.value]); + + return ; +}; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/index.ts b/js/packages/react-ui/src/components/Charts/RadarChart/index.ts index 536521034..ba0fe04a9 100644 --- a/js/packages/react-ui/src/components/Charts/RadarChart/index.ts +++ b/js/packages/react-ui/src/components/Charts/RadarChart/index.ts @@ -1 +1,2 @@ export * from "./RadarChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/radarChart.scss b/js/packages/react-ui/src/components/Charts/RadarChart/radarChart.scss new file mode 100644 index 000000000..a1a5bfe7b --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadarChart/radarChart.scss @@ -0,0 +1,48 @@ +@use "../../../cssUtils" as cssUtils; + +.crayon-radar-chart-container-wrapper { + display: flex; + position: relative; + gap: 20px; + &.layout-column { + flex-direction: column; + align-items: center; + } +} + +.crayon-radar-chart-container { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 100%; + .layout-column & { + flex: 1; + min-height: 0; + } +} + +.crayon-radar-chart-container-inner { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.crayon-radar-chart { + .recharts-polar-grid-concentric-polygon { + stroke-width: 1; + stroke: cssUtils.$stroke-interactive-el; + } + + .recharts-polar-grid-angle line { + stroke: cssUtils.$stroke-interactive-el; + } +} + +.crayon-chart-polar-angle-axis-label { + pointer-events: none; + @include cssUtils.typography(label, 2-extra-small); + color: cssUtils.$secondary-text; +} diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/stories/RadarChart.stories.tsx b/js/packages/react-ui/src/components/Charts/RadarChart/stories/RadarChart.stories.tsx new file mode 100644 index 000000000..18759c702 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadarChart/stories/RadarChart.stories.tsx @@ -0,0 +1,586 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Shield, Star, Target } from "lucide-react"; +import { useState } from "react"; +import { Card } from "../../../Card"; +import { RadarChart, RadarChartProps } from "../RadarChart"; + +// 📊 COMPREHENSIVE DATA VARIATIONS - Designed to test various radar chart scenarios +const dataVariations = { + default: [ + { skill: "JavaScript", beginner: 3, intermediate: 7, advanced: 9 }, + { skill: "React", beginner: 4, intermediate: 8, advanced: 8 }, + { skill: "TypeScript", beginner: 2, intermediate: 6, advanced: 9 }, + { skill: "Node.js", beginner: 3, intermediate: 7, advanced: 7 }, + { skill: "CSS", beginner: 5, intermediate: 8, advanced: 6 }, + { skill: "Git", beginner: 4, intermediate: 6, advanced: 8 }, + ], + performance: [ + { metric: "Speed", team_a: 85, team_b: 92, team_c: 78 }, + { metric: "Quality", team_a: 90, team_b: 88, team_c: 95 }, + { metric: "Efficiency", team_a: 78, team_b: 85, team_c: 82 }, + { metric: "Innovation", team_a: 92, team_b: 75, team_c: 88 }, + { metric: "Collaboration", team_a: 88, team_b: 94, team_c: 85 }, + { metric: "Reliability", team_a: 95, team_b: 87, team_c: 90 }, + ], + businessMetrics: [ + { department: "Sales", q1: 85, q2: 92, q3: 78, q4: 95 }, + { department: "Marketing", q1: 78, q2: 85, q3: 90, q4: 88 }, + { department: "Support", q1: 92, q2: 88, q3: 85, q4: 90 }, + { department: "Product", q1: 88, q2: 90, q3: 92, q4: 85 }, + { department: "Engineering", q1: 90, q2: 85, q3: 88, q4: 92 }, + ], + gameStats: [ + { attribute: "Strength", player1: 95, player2: 78, player3: 85 }, + { attribute: "Speed", player1: 82, player2: 95, player3: 88 }, + { attribute: "Intelligence", player1: 88, player2: 85, player3: 92 }, + { attribute: "Defense", player1: 90, player2: 82, player3: 78 }, + { attribute: "Magic", player1: 75, player2: 92, player3: 88 }, + { attribute: "Luck", player1: 85, player2: 88, player3: 90 }, + ], + productFeatures: [ + { feature: "Usability", current: 8, competitor_a: 7, competitor_b: 9 }, + { feature: "Performance", current: 9, competitor_a: 8, competitor_b: 7 }, + { feature: "Design", current: 8, competitor_a: 9, competitor_b: 8 }, + { feature: "Features", current: 7, competitor_a: 8, competitor_b: 9 }, + { feature: "Price", current: 9, competitor_a: 6, competitor_b: 8 }, + { feature: "Support", current: 8, competitor_a: 7, competitor_b: 6 }, + ], + minimal: [ + { category: "Category A", value1: 8, value2: 6 }, + { category: "Category B", value1: 7, value2: 9 }, + { category: "Category C", value1: 9, value2: 7 }, + ], + singleMetric: [ + { dimension: "North", score: 85 }, + { dimension: "South", score: 72 }, + { dimension: "East", score: 90 }, + { dimension: "West", score: 78 }, + { dimension: "Center", score: 88 }, + ], + manyDimensions: [ + { aspect: "UX Design", mobile: 8, web: 9, desktop: 7, tablet: 8 }, + { aspect: "Performance", mobile: 9, web: 8, desktop: 9, tablet: 8 }, + { aspect: "Accessibility", mobile: 7, web: 8, desktop: 9, tablet: 7 }, + { aspect: "Security", mobile: 8, web: 9, desktop: 8, tablet: 8 }, + { aspect: "Scalability", mobile: 7, web: 9, desktop: 8, tablet: 7 }, + { aspect: "Maintenance", mobile: 8, web: 7, desktop: 8, tablet: 8 }, + { aspect: "Testing", mobile: 9, web: 8, desktop: 7, tablet: 8 }, + { aspect: "Documentation", mobile: 6, web: 8, desktop: 9, tablet: 7 }, + ], +}; + +// Map data variations to their category keys +const categoryKeys = { + default: "skill", + performance: "metric", + businessMetrics: "department", + gameStats: "attribute", + productFeatures: "feature", + minimal: "category", + singleMetric: "dimension", + manyDimensions: "aspect", +} as const; + +// Default data for the main story +const radarChartData = dataVariations.default; + +const meta: Meta> = { + title: "Components/Charts/RadarChart", + component: RadarChart, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## Installation and Basic Usage + +\`\`\`tsx +import { RadarChart } from '@crayon-ui/react-ui/Charts/RadarChart'; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +Your data should be an array of objects where each object contains: +- A **category field** (string): Used for the radar's axes (e.g., 'skill', 'metric'). +- One or more **value fields** (number): Each numeric key represents a data series to be plotted. + +\`\`\`tsx +const exampleData = [ + { skill: "JavaScript", team_a: 90, team_b: 75 }, + { skill: "React", team_a: 85, team_b: 95 }, + { skill: "Node.js", team_a: 80, team_b: 70 }, +]; +\`\`\` + +## Key Features + +- **Multiple Data Series**: Compare several datasets on the same chart. +- **Two Visual Variants**: Choose between 'line' and 'area' styles. +- **Customizable Appearance**: Control colors, stroke width, and area opacity. +- **Interactive Legend**: Toggle visibility of data series. +- **Icon Support**: Add custom icons to legend items for better visual identification. +- **Animation**: Smooth animations for loading and data transitions. +`, + }, + }, + }, + tags: ["!dev", "autodocs"], + argTypes: { + data: { + description: ` +**Required.** An array of data objects. Each object represents a point on the radar axes and should contain: +- A category identifier (string). +- One or more numeric values, where each key represents a data series. + +**Best Practices:** +- Use 3-8 axes (categories) for optimal readability. +- Keep data series count low (2-4) to avoid clutter. +`, + control: false, + table: { + type: { summary: "Array>" }, + defaultValue: { summary: "[]" }, + category: "📊 Data Configuration", + }, + }, + categoryKey: { + description: ` +**Required.** The key in your data objects that corresponds to the radar axis labels. + +**Examples:** +- "skill" for a skills assessment chart. +- "metric" for a performance comparison chart. +`, + control: false, + table: { + type: { summary: "string" }, + category: "📊 Data Configuration", + }, + }, + theme: { + description: ` +**Color Theme Selection.** Choose from professionally designed color palettes: + +- **ocean**: Cool blues and teals (professional, corporate) +- **orchid**: Purple and pink tones (creative, modern) +- **emerald**: Green variations (nature, growth, finance) +- **sunset**: Warm oranges and reds (energy, attention-grabbing) +- **spectrum**: Full color range (diverse, comprehensive) +- **vivid**: High-contrast colors (accessibility, clarity) +`, + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "🎨 Visual Styling", + }, + }, + variant: { + description: ` +**Chart Style Variant:** + +- **line**: Displays only the outlines of the data series. Best for comparing multiple series without obstruction. +- **area**: Fills the area covered by each data series. Good for showing the magnitude of values. +`, + control: "radio", + options: ["line", "area"], + table: { + defaultValue: { summary: "line" }, + category: "🎨 Visual Styling", + }, + }, + grid: { + description: ` +**Grid Visibility.** Toggles the display of the background polar grid. + +**When to use:** +- To help estimate values along the axes. +- For a more technical, data-driven look. +`, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "📱 Display Options", + }, + }, + legend: { + description: ` +**Legend Visibility.** Controls whether the legend is displayed. + +**When to disable:** +- When charting a single data series. +- For minimal, clean dashboard widgets. +`, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "📱 Display Options", + }, + }, + strokeWidth: { + description: ` +**Line Thickness.** Sets the width of the radar lines for the 'line' variant and the border for the 'area' variant. +`, + control: { type: "number", min: 1, max: 10 }, + table: { + type: { summary: "number" }, + defaultValue: { summary: "2" }, + category: "🎨 Visual Styling", + }, + }, + areaOpacity: { + description: ` +**Area Fill Opacity.** Controls the transparency of the filled areas when \`variant\` is 'area'. + +**Tip:** Use lower opacity (e.g., 0.2-0.5) when displaying multiple overlapping area series. +`, + control: { type: "range", min: 0, max: 1, step: 0.1 }, + table: { + type: { summary: "number" }, + defaultValue: { summary: "0.5" }, + category: "🎨 Visual Styling", + }, + }, + icons: { + description: ` +An object mapping data keys to React components to display as icons in the legend. + +**Example:** +\`\`\`jsx +import { Shield, Target } from 'lucide-react'; + +const icons = { + team_a: Shield, + team_b: Target, +}; +\`\`\` +`, + control: false, + table: { + type: { summary: "Record" }, + defaultValue: { summary: "{}" }, + category: "🎨 Visual Styling", + }, + }, + isAnimationActive: { + description: ` +**Animation Control.** Enables or disables chart animations on load and update. + +**Performance note:** Disable for highly complex charts or in performance-critical applications. +`, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "🎬 Animation & Interaction", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DefaultConfiguration: Story = { + name: "📊 Default Configuration", + args: { + data: dataVariations.performance, + categoryKey: "metric", + theme: "ocean", + variant: "line", + grid: true, + legend: true, + strokeWidth: 2, + isAnimationActive: true, + }, + render: (args: any) => ( + +
+

+ Team Performance Analysis +

+

+ Comparing key performance indicators across three teams. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: ` +This is the recommended starting configuration for most use cases. The chart displays multiple data series in a clear, professional manner. + +**Configuration Details:** +- **Data**: Team performance metrics. +- **Variant**: 'line' for clear comparison between series. +- **Colors**: 'ocean' theme (blues and teals). +- **Legend**: Enabled to identify data series. +- **Animations**: Enabled for smooth interactions. + `, + }, + }, + }, +}; + +export const SkillsAssessment: Story = { + name: "📚 Skills Assessment", + args: { + data: dataVariations.default, + categoryKey: "skill", + theme: "orchid", + variant: "area", + grid: true, + legend: true, + strokeWidth: 2, + areaOpacity: 0.4, + isAnimationActive: true, + }, + render: (args: any) => ( + +
+

+ Developer Skill Levels +

+

+ Visualizing proficiency across different programming skills for various experience levels. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: + "This example uses the 'area' variant to represent different skill levels. The transparency allows for easy comparison of overlapping areas, providing a clear view of strengths and weaknesses across different roles.", + }, + }, + }, +}; + +export const TeamPerformance: Story = { + name: "🏆 Team Performance with Icons", + args: { + data: dataVariations.performance, + categoryKey: "metric", + theme: "emerald", + variant: "line", + grid: true, + legend: true, + strokeWidth: 3, + isAnimationActive: true, + icons: { + team_a: Shield, + team_b: Target, + team_c: Star, + }, + }, + render: (args: any) => ( + +
+

+ Team Performance Comparison +

+

+ Utilizing custom icons to distinguish teams in the legend for improved clarity. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: + "This chart demonstrates how to enhance the legend with custom icons. It's an effective way to visually associate data series with specific entities, like teams or products. The thicker stroke width also improves line visibility.", + }, + }, + }, +}; + +export const BusinessMetrics: Story = { + name: "📈 Quarterly Business Metrics", + args: { + data: dataVariations.businessMetrics, + categoryKey: "department", + theme: "sunset", + variant: "area", + grid: true, + legend: true, + strokeWidth: 2, + areaOpacity: 0.4, + isAnimationActive: true, + }, + render: (args: any) => ( + +
+

+ Quarterly Performance Review +

+

+ Tracking key business metrics across departments over four quarters. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: + "A comprehensive view of business performance, this example showcases how the 'area' variant can effectively display multiple overlapping datasets to reveal trends and patterns over time.", + }, + }, + }, +}; + +export const InteractivePlayground: Story = { + name: "🧪 Interactive Playground", + args: { + theme: "ocean", + variant: "line", + grid: true, + legend: true, + strokeWidth: 2, + areaOpacity: 0.5, + isAnimationActive: true, + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle: React.CSSProperties = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + fontFamily: "monospace", + transition: "all 0.2s", + }; + + const activeButtonStyle: React.CSSProperties = { + ...buttonStyle, + background: "#3b82f6", + color: "white", + border: "1px solid #3b82f6", + fontWeight: 600, + }; + + return ( +
+ +

+ 🕸️ Radar Chart Test Suite +

+
+ {(Object.keys(dataVariations) as Array).map((key) => ( + + ))} +
+
+ Current Dataset: {selectedDataType} | Axes:{" "} + {currentData.length} | Category Key: {currentCategoryKey} +
+
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +Use the buttons to switch between different datasets and explore how the RadarChart adapts. +You can also use the Storybook controls to change theme, variant, and other properties in real-time. +This playground is designed for testing various configurations and edge cases. +`, + }, + }, + }, +}; + +export const ThemeShowcase: Story = { + name: "🎨 Theme Showcase", + args: { + data: dataVariations.productFeatures, + categoryKey: "feature", + variant: "area", + grid: true, + legend: true, + strokeWidth: 2, + areaOpacity: 0.6, + isAnimationActive: false, + }, + render: (args: any) => ( +
+ {(["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const).map((theme) => ( +
+

+ {theme} +

+ + + +
+ ))} +
+ ), + parameters: { + docs: { + description: { + story: + "This story showcases all available color themes. Each theme provides a professionally curated palette suitable for different branding and data contexts. Animations are disabled for quicker comparison.", + }, + }, + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/stories/raderChart.stories.tsx b/js/packages/react-ui/src/components/Charts/RadarChart/stories/raderChart.stories.tsx deleted file mode 100644 index d230d1275..000000000 --- a/js/packages/react-ui/src/components/Charts/RadarChart/stories/raderChart.stories.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Monitor, TabletSmartphone } from "lucide-react"; -import { Card } from "../../../Card"; -import { RadarChart, RadarChartProps } from "../RadarChart"; - -const radarChartData = [ - { month: "January", desktop: 250, mobile: 150 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; - -const icons = { - desktop: Monitor, - mobile: TabletSmartphone, -} as const; - -const meta: Meta> = { - title: "Components/Charts/RadarChart", - component: RadarChart, - parameters: { - layout: "centered", - docs: { - description: { - component: - "```tsx\nimport { RadarChart } from '@crayon-ui/react-ui/Charts/RadarChart';\n```", - }, - }, - }, - tags: ["!dev", "autodocs"], - - argTypes: { - data: { - description: - "An array of data objects where each object represents a data point. Each object should have a category field (e.g., month) and one or more numeric values for the areas to be plotted.", - control: false, - table: { - type: { summary: "Array>" }, - defaultValue: { summary: "[]" }, - category: "Data", - }, - }, - categoryKey: { - description: - "The key from your data object to be used as the x-axis categories (e.g., 'month', 'year', 'date')", - control: false, - table: { - type: { summary: "string" }, - category: "Data", - }, - }, - theme: { - description: - "The color palette theme for the chart. Each theme provides a different set of colors for the areas.", - control: "select", - options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], - table: { - defaultValue: { summary: "ocean" }, - category: "Appearance", - }, - }, - icons: { - description: - "An object that maps data keys to icon components. These icons will appear in the legend next to their corresponding data series.", - control: false, - table: { - type: { summary: "Record" }, - defaultValue: { summary: "{}" }, - category: "Appearance", - }, - }, - variant: { - description: - "The style of the radar chart. 'line' shows only the connecting lines between data points, while 'area' fills the shape created by the data points.", - control: "radio", - options: ["line", "area"], - table: { - defaultValue: { summary: "area" }, - category: "Appearance", - }, - }, - strokeWidth: { - description: "The width of the line stroke", - control: false, - table: { - type: { summary: "number" }, - defaultValue: { summary: "2" }, - category: "Appearance", - }, - }, - grid: { - description: "Whether to display the background grid lines in the chart", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - areaOpacity: { - description: "The opacity of the area fill", - control: false, - table: { - type: { summary: "number" }, - defaultValue: { summary: "0.7" }, - category: "Appearance", - }, - }, - legend: { - description: - "Whether to display the chart legend showing the data series names and their corresponding colors/icons", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - isAnimationActive: { - description: "Whether to animate the chart", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const RadarChartStory: Story = { - name: "Radar Chart", - args: { - data: radarChartData, - categoryKey: "month", - theme: "ocean", - variant: "area", - strokeWidth: 2, - areaOpacity: 0.5, - legend: true, - grid: true, - isAnimationActive: true, - }, - render: (args: any) => ( - - - - ), - parameters: { - docs: { - source: { - code: ` - const radarChartData = [ - { month: "January", desktop: 250, mobile: 150 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; - - - - - `, - }, - }, - }, -}; - -export const RadarChartStoryWithIcons: Story = { - name: "Radar Chart with Icons", - args: { - ...RadarChartStory.args, - icons: icons, - }, - render: (args: any) => ( - - - - ), - parameters: { - docs: { - source: { - code: ` -import { Monitor, TabletSmartphone } from "lucide-react"; - -const radarChartData = [ - { month: "January", desktop: 250, mobile: 150 }, - { month: "February", desktop: 280, mobile: 180 }, - { month: "March", desktop: 220, mobile: 140 }, - { month: "April", desktop: 180, mobile: 160 }, - { month: "May", desktop: 250, mobile: 120 }, - { month: "June", desktop: 300, mobile: 180 }, -]; - -const icons = { - desktop: Monitor, - mobile: TabletSmartphone, -} as const; - - - - - `, - }, - }, - }, -}; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/types/index.ts b/js/packages/react-ui/src/components/Charts/RadarChart/types/index.ts new file mode 100644 index 000000000..1639bd74b --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadarChart/types/index.ts @@ -0,0 +1 @@ +export type RadarChartData = Array>; diff --git a/js/packages/react-ui/src/components/Charts/RadarChart/utils/index.ts b/js/packages/react-ui/src/components/Charts/RadarChart/utils/index.ts new file mode 100644 index 000000000..a2780afb6 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadarChart/utils/index.ts @@ -0,0 +1,83 @@ +/** + * Truncates text to fit within specified width bounds + * @param text - The original text + * @param maxWidth - Maximum width in pixels + * @param fontSize - Font size for measurement + * @returns Truncated text with ellipsis if needed + */ +const truncateText = (text: string, maxWidth: number, fontSize = 10): string => { + if (maxWidth <= 0) return text; + + // Create a temporary canvas to measure text width + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return text; + + context.font = `${fontSize}px Inter`; + + // If text fits, return as is + if (context.measureText(text).width <= maxWidth) { + return text; + } + + // Binary search for the longest text that fits + let low = 0; + let high = text.length; + let result = text; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const truncated = text.substring(0, mid) + "..."; + const width = context.measureText(truncated).width; + + if (width <= maxWidth) { + result = truncated; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return result; +}; + +/** + * Calculates the available width for a text label based on its position and anchor point + * + * This function determines how much horizontal space is available for a text label + * by checking its position relative to the container bounds. The calculation varies + * based on the text anchor position: + * + * - For "start" anchored text: Available space is from labelX to container right edge + * - For "end" anchored text: Available space is from container left edge to labelX + * - For "middle" anchored text: Takes minimum of left/right space and doubles it + * + * @param labelX - X coordinate of the label position + * @param containerWidth - Total width of the container + * @param textAnchor - How text is anchored ("start", "end", or "middle") + * @param padding - Optional padding to maintain from container edges (default 10px) + * @returns The maximum width available for the label in pixels + */ +const calculateAvailableWidth = ( + labelX: number, + containerWidth: number, + textAnchor: string, + padding = 0, +): number => { + switch (textAnchor) { + case "start": + // Text starts at labelX, extends to the right + return Math.max(0, containerWidth - labelX - padding); + case "end": + // Text ends at labelX, extends to the left + return Math.max(0, labelX - padding); + case "middle": + default: + // Text is centered at labelX + const leftSpace = labelX - padding; + const rightSpace = containerWidth - labelX - padding; + return Math.max(0, Math.min(leftSpace, rightSpace) * 2); + } +}; + +export { calculateAvailableWidth, truncateText }; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/RadialChart.tsx b/js/packages/react-ui/src/components/Charts/RadialChart/RadialChart.tsx index bd7bc5d33..91ea4a737 100644 --- a/js/packages/react-ui/src/components/Charts/RadialChart/RadialChart.tsx +++ b/js/packages/react-ui/src/components/Charts/RadialChart/RadialChart.tsx @@ -1,194 +1,365 @@ import clsx from "clsx"; -import { debounce } from "lodash-es"; -import { useEffect, useRef, useState } from "react"; -import { Cell, LabelList, PolarGrid, RadialBar, RadialBarChart } from "recharts"; -import { useLayoutContext } from "../../../context/LayoutContext"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Cell, PolarGrid, RadialBar, RadialBarChart } from "recharts"; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../Charts"; +import { useTransformedKeys } from "../hooks"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { StackedLegend } from "../shared/StackedLegend/StackedLegend"; +import { LegendItem } from "../types/Legend"; +import { getCategoricalChartConfig } from "../utils/dataUtils"; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; import { - ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "../Charts"; -import { getDistributedColors, getPalette } from "../utils/PalletUtils"; + createRadialGradientDefinitions, + MAX_CHART_SIZE, + MIN_CHART_SIZE, +} from "./components/RadialChartRenderers"; +import { RadialChartData } from "./types"; +import { + calculateRadialChartDimensions, + createRadialAnimationConfig, + createRadialEventHandlers, + getRadialHoverStyles, + transformRadialDataWithPercentages, + useRadialChartHover, +} from "./utils/RadialChartUtils"; -export type RadialChartData = Array>; +interface GradientColor { + start?: string; + end?: string; +} export interface RadialChartProps { data: T; categoryKey: keyof T[number]; dataKey: keyof T[number]; - theme?: "ocean" | "orchid" | "emerald" | "sunset" | "spectrum" | "vivid"; + theme?: PaletteName; variant?: "semicircle" | "circular"; format?: "percentage" | "number"; legend?: boolean; - label?: boolean; + legendVariant?: "default" | "stacked"; grid?: boolean; isAnimationActive?: boolean; + cornerRadius?: number; + useGradients?: boolean; + gradientColors?: GradientColor[]; + onMouseEnter?: (data: any, index: number) => void; + onMouseLeave?: () => void; + onClick?: (data: any, index: number) => void; + className?: string; } -const layoutMap: Record = { - mobile: "crayon-pie-chart-container-mobile", - fullscreen: "crayon-pie-chart-container-fullscreen", - tray: "crayon-pie-chart-container-tray", - copilot: "crayon-pie-chart-container-copilot", -}; - -// Helper function to calculate percentage -const calculatePercentage = (value: number, total: number): number => { - if (total === 0) return 0; - return Number(((value / total) * 100).toFixed(2)); -}; +const STACKED_LEGEND_BREAKPOINT = 400; export const RadialChart = ({ data, categoryKey, dataKey, theme = "ocean", - variant = "semicircle", + variant = "circular", format = "number", legend = true, - label = true, - grid = true, - isAnimationActive = true, + legendVariant = "stacked", + grid = false, + isAnimationActive = false, + cornerRadius = 10, + useGradients = false, + gradientColors, + onMouseEnter, + onMouseLeave, + onClick, + className, }: RadialChartProps) => { - const { layout } = useLayoutContext(); - const [calculatedOuterRadius, setCalculatedOuterRadius] = useState(110); - const [calculatedInnerRadius, setCalculatedInnerRadius] = useState(30); - const containerRef = useRef(null); + const wrapperRef = useRef(null); + const [wrapperRect, setWrapperRect] = useState({ width: 0, height: 0 }); + const [hoveredLegendKey, setHoveredLegendKey] = useState(null); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const { activeIndex, handleMouseEnter, handleMouseLeave } = useRadialChartHover(); - useEffect(() => { - if (!containerRef.current) return; - - const resizeObserver = new ResizeObserver( - debounce((entries: any) => { - const { width } = entries[0].contentRect; - - // Calculate outer radius - let newOuterRadius = 110; // default - if (layout === "mobile") { - newOuterRadius = label ? (width > 300 ? 110 : 90) : width > 300 ? 95 : 80; - } else if (layout === "fullscreen") { - newOuterRadius = 160; - } else if (layout === "tray" || layout === "copilot") { - newOuterRadius = 130; - } else { - newOuterRadius = 100; - } + // Determine layout mode based on container width + const isRowLayout = + legend && legendVariant === "stacked" && wrapperRect.width >= STACKED_LEGEND_BREAKPOINT; - // Calculate inner radius - let newInnerRadius = 30; // default - if (layout === "mobile") { - newInnerRadius = 30; - } else if (layout === "fullscreen") { - newInnerRadius = 50; - } else { - newInnerRadius = 30; - } + // The data that is processed and rendered in the chart + const processedData = useMemo(() => data, [data]); - setCalculatedOuterRadius(newOuterRadius); - setCalculatedInnerRadius(newInnerRadius); - }, 100), - ); + const categories = useMemo( + () => processedData.map((item) => String(item[categoryKey])), + [processedData, categoryKey], + ); + const transformedKeys = useTransformedKeys(categories); - resizeObserver.observe(containerRef.current); - return () => resizeObserver.disconnect(); - }, [layout, label]); + // Memoize string conversions to avoid repeated calls + const categoryKeyString = useMemo(() => String(categoryKey), [categoryKey]); + const dataKeyString = useMemo(() => String(dataKey), [dataKey]); + const formatKey = useMemo( + () => (format === "percentage" ? "percentage" : dataKeyString), + [format, dataKeyString], + ); - // Calculate total for percentage calculations - const total = data.reduce((sum, item) => sum + Number(item[dataKey]), 0); + // Use provided dimensions or observed dimensions from the wrapper + const effectiveWidth = wrapperRect.width; + const effectiveHeight = wrapperRect.height; - // Get color palette and distribute colors - const palette = getPalette(theme); - const colors = getDistributedColors(palette, data.length); - - // Transform data with percentages - const transformedData = data.map((item, index) => ({ - ...item, - percentage: calculatePercentage(Number(item[dataKey as string]), total), - originalValue: item[dataKey as string], - fill: colors[index], - })); - - // Create chart configuration - const chartConfig = data.reduce( - (config, item, index) => ({ - ...config, - [String(item[categoryKey])]: { - label: String(item[categoryKey as string]), - color: colors[index], - }, + // Calculate chart dimensions based on the smaller dimension of the container + const chartSize = useMemo(() => { + let size; + // When in a row layout, the chart and legend are side-by-side. + if (isRowLayout) { + // The chart container takes up roughly half the width. We subtract the gap between items. + const chartContainerWidth = (effectiveWidth - 20) / 2; + // The size of the chart is the smaller of its container's width or the total available height. + size = Math.min(chartContainerWidth, effectiveHeight); + } else { + // In a column layout, the chart's size is constrained by the smaller of the total container's width or height. + size = Math.min(effectiveWidth, effectiveHeight); + } + size = Math.min(size, MAX_CHART_SIZE); + return Math.max(MIN_CHART_SIZE, size); + }, [effectiveWidth, effectiveHeight, isRowLayout]); + + const chartSizeStyle = useMemo( + () => ({ + width: chartSize, + height: chartSize, }), - {}, + [chartSize], ); - const getFontSize = (dataLength: number) => { - return dataLength <= 5 ? 12 : 7; - }; + const rechartsProps = useMemo( + () => ({ + width: chartSize, + height: chartSize, + }), + [chartSize], + ); - const formatLabel = (value: string | number) => { - if (format === "percentage") { - const item = transformedData.find((d) => String(d.originalValue) === String(value)); - return item ? `${item.percentage}%` : value; - } - // For number format, just truncate if too long - const stringValue = String(value); - return stringValue.length > 8 ? `${stringValue.slice(0, 8)}...` : stringValue; - }; + // Calculate chart radii + const dimensions = useMemo(() => calculateRadialChartDimensions(chartSize), [chartSize]); - return ( - - - {grid && } - + // Memoize expensive data transformations and configurations + const transformedData = useMemo( + () => transformRadialDataWithPercentages(processedData, dataKey, theme), + [processedData, dataKey, theme], + ); + + const chartConfig = useMemo( + () => getCategoricalChartConfig(processedData, categoryKey, theme, transformedKeys), + [processedData, categoryKey, theme, transformedKeys], + ); + + const animationConfig = useMemo( + () => createRadialAnimationConfig({ isAnimationActive }), + [isAnimationActive], + ); + + const eventHandlers = useMemo( + () => createRadialEventHandlers(onMouseEnter, onMouseLeave, onClick), + [onMouseEnter, onMouseLeave, onClick], + ); + + // Get color palette and distribute colors + const palette = useMemo(() => getPalette(theme), [theme]); + const colors = useMemo( + () => getDistributedColors(palette, processedData.length), + [palette, processedData.length], + ); + + // Memoize gradient definitions + const gradientDefinitions = useMemo(() => { + if (!useGradients) return null; + const chartColors = Object.values(chartConfig) + .map((config) => config.color) + .filter((color): color is string => color !== undefined); + return createRadialGradientDefinitions(transformedData, chartColors, gradientColors); + }, [useGradients, chartConfig, transformedData, gradientColors]); + + // Create legend items for both variants + const legendItems = useMemo( + () => + processedData.map((item, index) => ({ + key: String(item[categoryKey]), + label: String(item[categoryKey]), + value: Number(item[dataKey]), + color: colors[index] || "#000000", + })), + [processedData, categoryKey, dataKey, colors], + ); + + const defaultLegendItems = useMemo((): LegendItem[] => { + return legendItems.map(({ key, label, color }) => ({ key, label, color })); + }, [legendItems]); + + // Memoize sorted data for legend hover handling + const sortedData = useMemo( + () => [...processedData].sort((a, b) => Number(b[dataKey]) - Number(a[dataKey])), + [processedData, dataKey], + ); + + // Handle legend item hover to highlight radial bar + const handleLegendItemHover = useCallback( + (index: number | null) => { + if (legendVariant !== "stacked") return; + if (index !== null) { + const item = sortedData[index]; + if (item) { + const categoryValue = String(item[categoryKey]); + setHoveredLegendKey(categoryValue); + const originalIndex = processedData.findIndex( + (d) => String(d[categoryKey]) === categoryValue, + ); + if (originalIndex !== -1) { + handleMouseEnter(processedData[originalIndex], originalIndex); } - /> - {legend && ( - - } + } + } else { + setHoveredLegendKey(null); + handleMouseLeave(); + } + }, + [sortedData, categoryKey, processedData, handleMouseEnter, handleMouseLeave, legendVariant], + ); + + // Enhanced chart hover handlers + const handleChartMouseEnter = useCallback( + (entry: any, index: number) => { + handleMouseEnter(entry, index); + if (legend && legendVariant === "stacked") { + setHoveredLegendKey(String(entry[categoryKey])); + } + eventHandlers.onMouseEnter?.(entry, index); + }, + [handleMouseEnter, categoryKey, legend, legendVariant, eventHandlers.onMouseEnter], + ); + + const handleChartMouseLeave = useCallback(() => { + handleMouseLeave(); + if (legend && legendVariant === "stacked") { + setHoveredLegendKey(null); + } + eventHandlers.onMouseLeave?.(); + }, [handleMouseLeave, legend, legendVariant, eventHandlers.onMouseLeave]); + + // Setup ResizeObserver to watch the wrapper element + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + // Use ResizeObserver if component is in responsive mode (no fixed width/height) + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setWrapperRect({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + observer.observe(wrapper); + return () => observer.disconnect(); + }, []); + + const renderLegend = useCallback(() => { + if (!legend) return null; + if (legendVariant === "stacked") { + return ( +
+ - )} - - {Object.entries(chartConfig).map(([key, config]) => ( - - ))} - {label && ( - - )} - - - +
+ ); + } + return ( + + ); + }, [ + legend, + legendVariant, + legendItems, + hoveredLegendKey, + handleLegendItemHover, + wrapperRect.width, + isRowLayout, + defaultLegendItems, + isLegendExpanded, + ]); + + const wrapperClassName = clsx("crayon-radial-chart-container-wrapper", className, { + "layout-row": isRowLayout, + "layout-column": !isRowLayout, + "legend-default": legend && legendVariant === "default", + "legend-stacked": legend && legendVariant === "stacked", + }); + + // Correct angles for semicircle (top half) + const startAngle = variant === "semicircle" ? 180 : 0; + const endAngle = variant === "semicircle" ? 0 : 360; + + return ( +
+
+
+
+ + + {grid && } + + } + /> + {gradientDefinitions && {gradientDefinitions}} + + {transformedData.map((entry, index) => { + const categoryValue = String(entry[categoryKey as keyof typeof entry] || ""); + const config = chartConfig[categoryValue]; + const hoverStyles = getRadialHoverStyles(index, activeIndex); + const fill = useGradients + ? `url(#radial-gradient-${index})` + : config?.color || colors[index]; + return ( + + ); + })} + + + +
+
+
+ {renderLegend()} +
); }; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/components/RadialChartRenderers.tsx b/js/packages/react-ui/src/components/Charts/RadialChart/components/RadialChartRenderers.tsx new file mode 100644 index 000000000..8f79cd5b3 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadialChart/components/RadialChartRenderers.tsx @@ -0,0 +1,82 @@ +export const MIN_CHART_SIZE = 150; +export const MAX_CHART_SIZE = 500; + +/** + * Creates gradient definitions for radial chart segments + * @param data - The chart data array + * @param colors - Array of base colors for segments + * @param gradientColors - Optional array of gradient color configurations + * @returns Array of gradient definition elements + */ +export const createRadialGradientDefinitions = ( + data: any[], + colors: string[], + gradientColors?: Array<{ start?: string; end?: string }>, +) => { + return data.map((_, index) => { + const defaultBaseColor = colors[index] || "#000000"; + + // Get the base color from gradientColors or fallback to default + let baseColor = defaultBaseColor; + let secondaryColor = colors[data.length - index - 1] || "#ffffff"; + + if (gradientColors?.[index]) { + // If both colors are provided, use them + if (gradientColors[index].start && gradientColors[index].end) { + baseColor = gradientColors[index].start; + secondaryColor = gradientColors[index].end; + } + // If only start color is provided, create a darker version for end + else if (gradientColors[index].start) { + baseColor = gradientColors[index].start; + secondaryColor = adjustColorBrightness(baseColor, -20); + } + // If only end color is provided, create a lighter version for start + else if (gradientColors[index].end) { + baseColor = adjustColorBrightness(gradientColors[index].end, 20); + secondaryColor = gradientColors[index].end; + } + } + + return ( + + + + + + + ); + }); +}; + +/** + * Adjusts the brightness of a hex color + * @param hex - The hex color to adjust + * @param percent - The percentage to adjust brightness by (-100 to 100) + * @returns The adjusted hex color + */ +export const adjustColorBrightness = (hex: string, percent: number): string => { + if (!hex || !hex.startsWith("#")) { + return hex; + } + try { + const num = parseInt(hex.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = Math.min(255, Math.max(0, (num >> 16) + amt)); + const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt)); + const B = Math.min(255, Math.max(0, (num & 0x0000ff) + amt)); + return "#" + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); + } catch (error) { + console.warn("Invalid color format:", hex); + console.warn(error); + return hex; + } +}; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/index.ts b/js/packages/react-ui/src/components/Charts/RadialChart/index.ts index d5578ec7c..2f12d30de 100644 --- a/js/packages/react-ui/src/components/Charts/RadialChart/index.ts +++ b/js/packages/react-ui/src/components/Charts/RadialChart/index.ts @@ -1 +1,2 @@ export * from "./RadialChart"; +export * from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/radialChart.scss b/js/packages/react-ui/src/components/Charts/RadialChart/radialChart.scss new file mode 100644 index 000000000..a89a478a3 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadialChart/radialChart.scss @@ -0,0 +1,82 @@ +@use "../../../cssUtils" as cssUtils; + +// Main wrapper for the entire chart component +.crayon-radial-chart-container-wrapper { + display: flex; + position: relative; + gap: 20px; + + // Default legend is always a column layout + &.legend-default { + flex-direction: column; + align-items: center; + } + + // Stacked legend uses a column layout by default (mobile-first) + &.legend-stacked.layout-column { + flex-direction: column; + align-items: center; + } + + // Switches to a row layout on wider containers + &.legend-stacked.layout-row { + flex-direction: row; + align-items: center; // Vertically center chart and legend + max-height: 296px; + + .crayon-radial-chart-legend-container { + height: 296px; + } + } +} + +// Container for the chart itself +.crayon-radial-chart-container { + display: flex; + align-items: center; + justify-content: center; + position: relative; + + // When in a row layout, the chart container should be flexible + .layout-row & { + flex: 1; + min-width: 0; // Prevent flexbox overflow issues + } +} + +// Recharts chart styles +.crayon-radial-chart { + display: flex; + align-items: center; + justify-content: center; + + .recharts-polar-grid { + opacity: 0.3; + } +} + +// Container for the legend component +.crayon-radial-chart-legend-container { + display: flex; + justify-content: center; + + // Default legend is centered at the bottom + .legend-default & { + width: 100%; + align-items: center; + } + + // Stacked legend in column layout + .layout-column.legend-stacked & { + width: 100%; + align-items: flex-start; + } + + // Stacked legend in row layout + .layout-row.legend-stacked & { + flex: 1; + min-width: 0; // Prevent flexbox overflow + height: 100%; + align-items: center; // Vertically center legend content + } +} diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/stories/RadialChart.stories.tsx b/js/packages/react-ui/src/components/Charts/RadialChart/stories/RadialChart.stories.tsx new file mode 100644 index 000000000..c0fc1994b --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadialChart/stories/RadialChart.stories.tsx @@ -0,0 +1,830 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Card } from "../../../Card"; +import { RadialChart, RadialChartProps } from "../RadialChart"; + +/** + * Sample data sets for demonstrating various RadialChart configurations + * These data structures represent common use cases in business applications + */ + +// Basic monthly sales data - ideal for business dashboards +const monthlyRevenueData = [ + { month: "January", value: 1250 }, + { month: "February", value: 980 }, + { month: "March", value: 1450 }, + { month: "April", value: 1320 }, + { month: "May", value: 1680 }, + { month: "June", value: 2100 }, + { month: "July", value: 1950 }, + { month: "August", value: 1820 }, + { month: "September", value: 1650 }, + { month: "October", value: 1480 }, + { month: "November", value: 1350 }, + { month: "December", value: 1200 }, +]; + +// Extended dataset for demonstrating carousel functionality +const comprehensiveFinancialData = [ + { category: "Base Salary", amount: 75000 }, + { category: "Q1 Bonus", amount: 8500 }, + { category: "Q2 Bonus", amount: 9200 }, + { category: "Q3 Bonus", amount: 7800 }, + { category: "Q4 Bonus", amount: 11000 }, + { category: "Holiday Pay", amount: 6500 }, + { category: "Overtime", amount: 4200 }, + { category: "Commission", amount: 8900 }, + { category: "Performance Incentive", amount: 7200 }, + { category: "Stock Options", amount: 12000 }, + { category: "Healthcare Benefits", amount: 4800 }, + { category: "Retirement Match", amount: 3600 }, + { category: "Professional Development", amount: 2400 }, + { category: "Transportation Allowance", amount: 1800 }, + { category: "Meal Vouchers", amount: 1200 }, +]; + +// Custom gradient definitions for enhanced visual appeal +const customGradientPalette = [ + { start: "#FF6B6B", end: "#FF8E8E", name: "Coral Red" }, + { start: "#4ECDC4", end: "#6ED7D0", name: "Turquoise" }, + { start: "#45B7D1", end: "#6BC5DB", name: "Sky Blue" }, + { start: "#96CEB4", end: "#B4DCC9", name: "Mint Green" }, + { start: "#FFEEAD", end: "#FFF4C4", name: "Light Yellow" }, + { start: "#D4A5A5", end: "#E5BDBD", name: "Rose" }, + { start: "#9B59B6", end: "#B07CC7", name: "Purple" }, +]; + +/** + * # RadialChart Component Documentation + * + * The RadialChart component is a powerful and flexible visualization tool for displaying + * categorical data in a circular format. It's particularly effective for showing: + * + * - **Proportional Relationships**: How different categories relate to each other + * - **Part-to-Whole Analysis**: Understanding individual contributions to a total + * - **Comparative Data**: Quickly comparing values across multiple categories + * - **Dashboard Metrics**: Essential KPIs in executive dashboards + * + * ## Key Features + * + * ### Visual Variants + * - **Circular**: Full 360-degree display for comprehensive data visualization + * - **Semicircle**: Half-circle display for space-efficient layouts + * + * ### Data Formatting + * - **Percentage Mode**: Automatically calculates and displays percentages + * - **Number Mode**: Shows raw values with customizable formatting + * + * ### Interactive Elements + * - **Responsive Legend**: Adapts to container size with carousel navigation + * - **Hover States**: Interactive feedback for enhanced user experience + * - **Animation Support**: Smooth transitions and loading animations + * + * ### Customization Options + * - **Theme System**: Pre-built color palettes (ocean, orchid, emerald, sunset, spectrum, vivid) + * - **Gradient Support**: Custom gradient definitions for enhanced visual appeal + * - **Layout Flexibility**: Responsive design that adapts to container dimensions + */ + +const meta: Meta> = { + title: "Components/Charts/RadialChart", + component: RadialChart, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## Installation and Basic Usage + +\`\`\`tsx +import { RadialChart } from '@crayon-ui/react-ui/Charts/RadialChart'; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +Your data should be an array of objects where each object contains: +- A **category field** (string): Used for labels and legend items +- A **value field** (number): Used to determine segment sizes + +\`\`\`tsx +const exampleData = [ + { category: "Sales", value: 45000 }, + { category: "Marketing", value: 32000 }, + { category: "Operations", value: 28000 }, + { category: "Support", value: 15000 } +]; +\`\`\` + +## Performance Considerations + +- **Data Size**: Optimized for 3-20 data points for best readability +- **Responsive**: Automatically adjusts to container dimensions +- **Animation**: Can be disabled for better performance with large datasets + `, + }, + }, + }, + tags: ["!dev", "autodocs"], + argTypes: { + data: { + description: ` +**Required.** An array of data objects representing your dataset. Each object should contain: +- A category identifier (string) +- One or more numeric values + +**Best Practices:** +- Use 3-12 data points for optimal readability +- Ensure consistent data structure across all objects +- Use meaningful category names for better user experience + `, + control: false, + table: { + type: { summary: "Array>" }, + defaultValue: { summary: "[]" }, + category: "📊 Data Configuration", + }, + }, + categoryKey: { + description: ` +**Required.** Specifies which field in your data objects should be used as category labels. + +**Examples:** +- "month" for time-series data +- "department" for organizational data +- "product" for sales data + `, + control: false, + table: { + type: { summary: "string" }, + category: "📊 Data Configuration", + }, + }, + dataKey: { + description: ` +**Required.** Specifies which field contains the numeric values for visualization. + +**Examples:** +- "value", "amount", "revenue", "count" +- Values should be positive numbers +- Supports both integers and decimals + `, + control: false, + table: { + type: { summary: "string" }, + category: "📊 Data Configuration", + }, + }, + theme: { + description: ` +**Color Theme Selection.** Choose from professionally designed color palettes: + +- **ocean**: Cool blues and teals (professional, corporate) +- **orchid**: Purple and pink tones (creative, modern) +- **emerald**: Green variations (nature, growth, finance) +- **sunset**: Warm oranges and reds (energy, attention-grabbing) +- **spectrum**: Full color range (diverse, comprehensive) +- **vivid**: High-contrast colors (accessibility, clarity) + `, + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "🎨 Visual Styling", + }, + }, + variant: { + description: ` +**Chart Layout Style:** + +- **circular**: Full 360° display - best for comprehensive data overview +- **semicircle**: Half-circle display - space-efficient for dashboards + `, + control: "radio", + options: ["semicircle", "circular"], + table: { + defaultValue: { summary: "circular" }, + category: "🎨 Visual Styling", + }, + }, + format: { + description: ` +**Data Display Format:** + +- **percentage**: Automatically calculates percentages from your data +- **number**: Shows raw numeric values with smart formatting + `, + control: "radio", + options: ["percentage", "number"], + table: { + defaultValue: { summary: "number" }, + category: "📱 Display Options", + }, + }, + legend: { + description: ` +**Legend Visibility.** Controls whether the legend is displayed. + +**When to disable:** +- Minimal dashboard widgets +- When labels are embedded in the chart +- Space-constrained layouts + `, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "📱 Display Options", + }, + }, + legendVariant: { + description: ` +**Legend Layout Style:** + +- **stacked**: Vertical layout with responsive behavior (recommended) +- **default**: Horizontal layout at bottom (classic style) + `, + control: "radio", + options: ["default", "stacked"], + table: { + defaultValue: { summary: "stacked" }, + category: "📱 Display Options", + }, + }, + grid: { + description: ` +**Polar Grid Lines.** Adds concentric circles for value reference. + +**Use cases:** +- Data analysis and comparison +- Scientific or technical presentations +- When precise value reading is important + `, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "📱 Display Options", + }, + }, + isAnimationActive: { + description: ` +**Animation Control.** Enables smooth loading and transition animations. + +**Performance note:** Disable for large datasets or performance-critical applications. + `, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "🎬 Animation & Interaction", + }, + }, + cornerRadius: { + description: ` +**Corner Rounding.** Controls the roundness of radial bar ends. + +**Design impact:** +- 0: Sharp, technical appearance +- 5-10: Subtle modern look (recommended) +- 15+: Highly rounded, friendly appearance + `, + control: { type: "number", min: 0, max: 20 }, + table: { + type: { summary: "number" }, + defaultValue: { summary: "10" }, + category: "🎨 Visual Styling", + }, + }, + useGradients: { + description: ` +**Gradient Enhancement.** Applies gradient effects to radial bars. + +**Visual impact:** +- Enhanced depth and dimension +- Modern, polished appearance +- Works best with 3-8 data points + `, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "🎨 Visual Styling", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * ## Default Configuration + * + * This example demonstrates the RadialChart with its default settings - the most common + * configuration for business dashboards and data visualization applications. + * + * **Key Features Shown:** + * - Standard circular layout with full 360° display + * - Professional ocean color theme + * - Stacked legend for optimal space utilization + * - Smooth animations for polished user experience + * - Number format for clear value display + */ +export const DefaultConfiguration: Story = { + name: "📊 Default Configuration", + args: { + data: monthlyRevenueData, + categoryKey: "month", + dataKey: "value", + theme: "ocean", + variant: "circular", + format: "number", + legend: true, + legendVariant: "stacked", + grid: false, + isAnimationActive: true, + cornerRadius: 10, + useGradients: false, + gradientColors: customGradientPalette, + }, + render: (args: any) => ( + +
+

+ Monthly Revenue Analysis +

+

+ Comprehensive view of revenue distribution across 12 months +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: ` +This is the recommended starting configuration for most use cases. The chart displays data +in a clear, professional manner with optimal default settings for readability and user experience. + +**Configuration Details:** +- **Data**: 12 months of revenue data +- **Layout**: Full circular display +- **Colors**: Ocean theme (blues and teals) +- **Legend**: Stacked layout for space efficiency +- **Animations**: Enabled for smooth interactions + `, + }, + }, + }, +}; + +/** + * ## Theme Showcase + * + * RadialChart includes six professionally designed color themes, each optimized + * for different contexts and brand requirements. + */ +export const ThemeShowcase: Story = { + name: "🎨 Theme Variations", + args: { + data: monthlyRevenueData.slice(0, 6), + categoryKey: "month", + dataKey: "value", + theme: "emerald", + variant: "circular", + format: "number", + legend: true, + legendVariant: "stacked", + grid: false, + isAnimationActive: true, + cornerRadius: 10, + useGradients: false, + }, + render: (args: any) => ( + +
+

+ Financial Growth Metrics +

+

+ Using the emerald theme for finance-related data visualization +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: ` +**Theme Selection Guide:** + +- **Ocean** 🌊: Corporate, professional, trustworthy (recommended for business) +- **Orchid** 🌸: Creative, modern, innovative (great for design/marketing) +- **Emerald** 🍃: Growth, finance, nature (perfect for financial data) +- **Sunset** 🌅: Energy, attention, warmth (ideal for alerts/important metrics) +- **Spectrum** 🌈: Diverse, comprehensive (when you need many distinct colors) +- **Vivid** ⚡: High contrast, accessible (optimized for accessibility requirements) + +**Best Practice:** Choose themes that align with your brand colors and data context. + `, + }, + }, + }, +}; + +/** + * ## Advanced Visual Enhancement + * + * This example showcases the gradient feature combined with percentage formatting + * to create visually striking and informative charts. + */ +export const GradientEnhancement: Story = { + name: "✨ Gradient Enhancement", + args: { + data: monthlyRevenueData.slice(0, 6), + categoryKey: "month", + dataKey: "value", + theme: "sunset", + variant: "circular", + format: "percentage", + legend: true, + legendVariant: "stacked", + grid: false, + isAnimationActive: true, + cornerRadius: 15, + useGradients: true, + gradientColors: customGradientPalette, + }, + render: (args: any) => ( + +
+

+ Revenue Distribution +

+

+ Enhanced with gradients and percentage display +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: ` +**Gradient Enhancement Features:** + +- **Visual Depth**: Creates three-dimensional appearance +- **Modern Aesthetic**: Aligns with contemporary design trends +- **Brand Flexibility**: Custom gradient definitions for brand alignment +- **Performance Optimized**: Efficiently rendered using SVG gradients + +**When to Use Gradients:** +- Executive dashboards and presentations +- Marketing materials and public-facing reports +- When visual impact is prioritized +- With 3-8 data points for optimal effect + +**Technical Note:** Gradients are defined as start/end color pairs and applied +automatically to maintain visual consistency across the chart. + `, + }, + }, + }, +}; + +/** + * ## Large Dataset Management + * + * When working with extensive datasets, RadialChart provides intelligent + * legend management with carousel navigation to maintain usability. + */ +export const LargeDatasetDemo: Story = { + name: "📈 Large Dataset with Carousel", + args: { + data: comprehensiveFinancialData, + categoryKey: "category", + dataKey: "amount", + theme: "spectrum", + variant: "circular", + format: "number", + legend: true, + legendVariant: "stacked", + grid: false, + isAnimationActive: true, + cornerRadius: 8, + useGradients: false, + }, + render: (args: any) => ( + +
+

+ Comprehensive Compensation Analysis +

+

+ Complete breakdown of 15 compensation categories with carousel navigation +

+
+ + + +
+ 💡 Carousel Feature: When legend items exceed available space, up/down + navigation arrows automatically appear. Use the arrows to scroll through all legend items + while maintaining chart visibility. +
+
+ ), + parameters: { + docs: { + description: { + story: ` +**Large Dataset Management:** + +**Automatic Carousel Detection:** +- Activates when legend content exceeds container height +- Provides smooth scrolling navigation +- Maintains chart proportions and visibility + +**Navigation Features:** +- **Up/Down Arrows**: Navigate through legend items +- **Smooth Scrolling**: Polished user experience +- **Visual Indicators**: Clear navigation state + +**Performance Optimizations:** +- Virtual scrolling for datasets with 50+ items +- Efficient rendering of visible legend items only +- Optimized color cycling for unlimited data points + +**Best Practices for Large Datasets:** +- Consider data aggregation for better readability +- Use meaningful category names +- Test carousel functionality in your target container sizes +- Consider alternative visualizations for 25+ categories + `, + }, + }, + }, +}; + +/** + * ## Interactive Responsiveness Demo + * + * This advanced example demonstrates RadialChart's responsive capabilities + * with a resizable container to show real-time adaptation. + */ +export const ResponsiveDemo: Story = { + name: "📱 Responsive Behavior Demo", + args: { + data: monthlyRevenueData, + categoryKey: "month", + dataKey: "value", + theme: "spectrum", + variant: "circular", + format: "number", + legend: true, + legendVariant: "stacked", + isAnimationActive: false, + cornerRadius: 8, + useGradients: false, + }, + render: (args: any) => { + const [dimensions, setDimensions] = useState<{ width: number; height: number | string }>({ + width: 650, + height: "auto", + }); + + const handleMouseDown = (e: React.MouseEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = dimensions.width; + const startHeight = (e.currentTarget.parentElement as HTMLElement).offsetHeight; + + const doDrag = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let newWidth = startWidth; + let newHeight = startHeight; + + if (handle.includes("e")) newWidth = startWidth + dx; + if (handle.includes("w")) newWidth = startWidth - dx; + if (handle.includes("s")) newHeight = startHeight + dy; + if (handle.includes("n")) newHeight = startHeight - dy; + + setDimensions({ + width: Math.max(300, newWidth), + height: "auto", + }); + }; + + const stopDrag = () => { + document.removeEventListener("mousemove", doDrag); + document.removeEventListener("mouseup", stopDrag); + }; + + document.addEventListener("mousemove", doDrag); + document.addEventListener("mouseup", stopDrag); + }; + + const handleStyle: React.CSSProperties = { + position: "absolute", + background: "#3b82f6", + opacity: 0.6, + zIndex: 10, + transition: "opacity 0.2s ease", + }; + + return ( +
+
+

+ Responsive Behavior Demonstration +

+

+ Drag the edges or corners of the container below to see how the chart adapts +

+
+ 🎯 Try This: Resize the container to see automatic legend layout + changes, chart proportion adjustments, and responsive breakpoint behaviors. +
+
+ + + + + {/* Resize Handles */} +
handleMouseDown(e, "n")} + /> +
handleMouseDown(e, "s")} + /> +
handleMouseDown(e, "w")} + /> +
handleMouseDown(e, "e")} + /> + + {/* Corner Handles */} +
handleMouseDown(e, "nw")} + /> +
handleMouseDown(e, "ne")} + /> +
handleMouseDown(e, "se")} + /> + + {/* Dimension Display */} +
+ {dimensions.width}px ×{" "} + {typeof dimensions.height === "number" ? `${dimensions.height}px` : "auto"} +
+ + +
+ 📊 Responsive Features Demonstrated: +
    +
  • Automatic chart scaling and proportion maintenance
  • +
  • Legend layout adaptation (stacked ↔ horizontal)
  • +
  • Text size and spacing adjustments
  • +
  • Optimal space utilization at any container size
  • +
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: ` +**Responsive Design Philosophy:** + +**Automatic Adaptation:** +- Chart dimensions automatically adjust to container size +- Legend layout switches between horizontal and stacked based on available space +- Text and spacing scale appropriately for readability + +**Breakpoint Behavior:** +- **Large Containers (>600px)**: Side-by-side chart and legend layout +- **Medium Containers (400-600px)**: Stacked layout with optimized proportions +- **Small Containers (<400px)**: Compact layout with essential elements only + +**Performance Optimizations:** +- Efficient re-rendering during resize operations +- Debounced resize calculations to prevent performance issues +- Optimized SVG scaling for crisp display at any size + +**Implementation Notes:** +- Uses ResizeObserver API for precise container size detection +- Maintains aspect ratios while maximizing space utilization +- Supports both fixed and flexible container sizing strategies + `, + }, + }, + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/stories/radialChart.stories.tsx b/js/packages/react-ui/src/components/Charts/RadialChart/stories/radialChart.stories.tsx deleted file mode 100644 index c672d75f8..000000000 --- a/js/packages/react-ui/src/components/Charts/RadialChart/stories/radialChart.stories.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Card } from "../../../Card"; -import { RadialChart, RadialChartProps } from "../RadialChart"; - -const radialChartData = [ - { month: "January", value: 400 }, - { month: "February", value: 300 }, - { month: "March", value: 300 }, - { month: "April", value: 400 }, - { month: "May", value: 300 }, - { month: "June", value: 300 }, - { month: "July", value: 300 }, -]; - -const meta: Meta> = { - title: "Components/Charts/RadialChart", - component: RadialChart, - parameters: { - layout: "centered", - docs: { - description: { - component: - "```tsx\nimport { RadialChart } from '@crayon-ui/react-ui/Charts/RadialChart';\n```", - }, - }, - }, - tags: ["!dev", "autodocs"], - - argTypes: { - data: { - description: - "An array of data objects where each object represents a data point. Each object should have a category field (e.g., month) and one or more numeric values for the areas to be plotted.", - control: false, - table: { - type: { summary: "Array>" }, - defaultValue: { summary: "[]" }, - category: "Data", - }, - }, - categoryKey: { - description: - "The key from your data object to be used as the category labels for each segment (e.g., 'month', 'year', 'category')", - control: false, - table: { - type: { summary: "string" }, - category: "Data", - }, - }, - dataKey: { - description: - "The key from your data object to be used as the values that determine the segment sizes (e.g., 'value', 'count', 'amount')", - control: false, - table: { - type: { summary: "string" }, - category: "Data", - }, - }, - theme: { - description: - "The color palette theme for the chart. Each theme provides a different set of colors for the areas.", - control: "select", - options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], - table: { - defaultValue: { summary: "ocean" }, - category: "Appearance", - }, - }, - variant: { - description: - "The style of the pie chart. 'circular' shows a circular chart, while 'semicircle' shows a semicircle chart.", - control: "radio", - options: ["circular", "semicircle"], - table: { - defaultValue: { summary: "circular" }, - category: "Appearance", - }, - }, - format: { - description: - "The format of the data. 'percentage' shows the data as a percentage, while 'number' shows the data as a number.", - control: "radio", - options: ["percentage", "number"], - table: { - defaultValue: { summary: "percentage" }, - category: "Display", - }, - }, - legend: { - description: "Whether to display the legend", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - label: { - description: "Whether to display the data point labels above each point on the chart", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - grid: { - description: "Whether to display the grid lines", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - isAnimationActive: { - description: "Whether to animate the chart", - control: "boolean", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "Display", - }, - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const RadialChartStory: Story = { - name: "Radial Chart", - args: { - data: radialChartData, - categoryKey: "month", - dataKey: "value", - theme: "ocean", - variant: "circular", - format: "number", - legend: true, - label: true, - grid: true, - isAnimationActive: true, - }, - render: (args: any) => ( - - - - ), - parameters: { - docs: { - description: { - story: - "A radial chart that displays data in a circular or semicircular format, with customizable themes, labels, and grid options.", - }, - source: { - code: ` -const radialChartData = [ - { month: "January", value: 400 }, - { month: "February", value: 300 }, - { month: "March", value: 300 }, - { month: "April", value: 400 }, - { month: "May", value: 300 }, - { month: "June", value: 300 }, - { month: "July", value: 300 }, -]; - - - - - `, - }, - }, - }, -}; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/types/index.ts b/js/packages/react-ui/src/components/Charts/RadialChart/types/index.ts new file mode 100644 index 000000000..498dd6e66 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadialChart/types/index.ts @@ -0,0 +1 @@ +export type RadialChartData = Array>; diff --git a/js/packages/react-ui/src/components/Charts/RadialChart/utils/RadialChartUtils.ts b/js/packages/react-ui/src/components/Charts/RadialChart/utils/RadialChartUtils.ts new file mode 100644 index 000000000..e299ff127 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/RadialChart/utils/RadialChartUtils.ts @@ -0,0 +1,201 @@ +/** + * Utility functions for radial charts + */ +import { useState } from "react"; +import { getDistributedColors, getPalette } from "../../utils/PalletUtils"; +import { RadialChartData } from "../types"; + +// ========================================== + +export interface RadialChartDimensions { + outerRadius: number; + innerRadius: number; +} + +export interface RadialHoverStyles { + opacity: number; + stroke: string; + strokeWidth: number; +} + +export interface RadialChartHoverHook { + activeIndex: number | null; + handleMouseEnter: (event: any, index: number) => void; + handleMouseLeave: () => void; +} + +export interface RadialAnimationConfig { + isAnimationActive: boolean; + animationBegin: number; + animationDuration: number; + animationEasing: "ease" | "ease-in" | "ease-out" | "ease-in-out" | "linear"; +} + +// ========================================== +// Core Calculation Utilities +// ========================================== + +/** + * Calculates the percentage value of a number relative to a total + * @param value - The value to calculate percentage for + * @param total - The total value to calculate percentage against + * @returns The calculated percentage rounded to 2 decimal places + */ +export const calculatePercentage = (value: number, total: number): number => { + if (total === 0) { + return 0; + } + return Number(((value / total) * 100).toFixed(2)); +}; + +// ========================================== +// Chart Dimension Calculations +// ========================================== + +/** + * Calculates dimensions for radial charts based on container size + * @param width - The container width + * @param variant - The chart variant ('semicircle' or 'circular') + * @returns Object containing outer and inner radius values + */ +export const calculateRadialChartDimensions = (width: number): RadialChartDimensions => { + const baseRadiusPercentage = 0.4; // 40% of container width + let outerRadius = Math.round(width * baseRadiusPercentage); + + // Set minimum and maximum bounds for radius + outerRadius = Math.max(50, Math.min(outerRadius, width / 2 - 10)); + + // Calculate inner radius - consistent ratio regardless of layout + const innerRadius = Math.round(outerRadius * 0.3); + + return { outerRadius, innerRadius }; +}; + +// ========================================== +// Layout and Styling Utilities +// ========================================== + +/** + * Generates hover style properties for radial chart cells + * @param index - The index of the current cell + * @param activeIndex - The index of the currently hovered cell + * @returns Object containing hover style properties + */ +export const getRadialHoverStyles = ( + index: number, + activeIndex: number | null, +): RadialHoverStyles => { + return { + opacity: activeIndex === null || activeIndex === index ? 1 : 0.6, + stroke: activeIndex === index ? "#fff" : "none", + strokeWidth: activeIndex === index ? 2 : 0, + }; +}; + +// ========================================== +// Data Transformation Utilities +// ========================================== + +/** + * Transforms data by adding percentage calculations and colors + * @param data - The input data array + * @param dataKey - The key to use for value calculations + * @param theme - The color theme to use + * @returns Transformed data with added percentage, original value, and fill color + */ +export const transformRadialDataWithPercentages = ( + data: T, + dataKey: keyof T[number], + theme: string = "ocean", +) => { + const total = data.reduce((sum, item) => sum + Number(item[dataKey]), 0); + const palette = getPalette(theme); + const colors = getDistributedColors(palette, data.length); + + return data.map((item, index) => ({ + ...item, + percentage: calculatePercentage(Number(item[dataKey as string]), total), + originalValue: item[dataKey as string], + fill: colors[index], + })); +}; + +// ========================================== +// Hover Effect Utilities +// ========================================== + +/** + * Custom hook for managing radial chart hover effects + * @returns Object containing hover state and handlers + */ +export const useRadialChartHover = (): RadialChartHoverHook => { + const [activeIndex, setActiveIndex] = useState(null); + + const handleMouseEnter = (_: any, index: number) => { + setActiveIndex(index); + }; + + const handleMouseLeave = () => { + setActiveIndex(null); + }; + + return { + activeIndex, + handleMouseEnter, + handleMouseLeave, + }; +}; + +// ========================================== +// Animation Utilities +// ========================================== + +/** + * Creates animation configuration for radial chart + * @param config - Animation configuration options + * @returns Animation configuration object + */ +export const createRadialAnimationConfig = ( + config: Partial = {}, +): RadialAnimationConfig => { + return { + isAnimationActive: config.isAnimationActive ?? true, + animationBegin: config.animationBegin ?? 0, + animationDuration: config.animationDuration ?? 1500, + animationEasing: config.animationEasing ?? "ease", + }; +}; + +// ========================================== +// Event Handler Utilities +// ========================================== + +/** + * Creates event handlers for radial chart + * @param onMouseEnter - Mouse enter handler + * @param onMouseLeave - Mouse leave handler + * @param onClick - Click handler + * @returns Object containing event handlers + */ +export const createRadialEventHandlers = ( + onMouseEnter?: (data: any, index: number) => void, + onMouseLeave?: () => void, + onClick?: (data: any, index: number) => void, +) => { + return { + onMouseEnter: onMouseEnter + ? (data: any, index: number) => onMouseEnter(data, index) + : undefined, + onMouseLeave: onMouseLeave ? () => onMouseLeave() : undefined, + onClick: onClick ? (data: any, index: number) => onClick(data, index) : undefined, + }; +}; + +// ========================================== +// Backward compatibility - keeping old function names +// ========================================== + +// Keep old function names for backward compatibility +export const transformRadialData = transformRadialDataWithPercentages; +export const useChartHover = useRadialChartHover; +export const getHoverStyles = getRadialHoverStyles; diff --git a/js/packages/react-ui/src/components/Charts/SegmentedBarChart/SegmentedBarChart.tsx b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/SegmentedBarChart.tsx new file mode 100644 index 000000000..3ceb305dc --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/SegmentedBarChart.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import { useMemo } from "react"; +import { SegmentedBarData } from "."; +import { getDistributedColors, getPalette, PaletteName } from "../utils/PalletUtils"; + +export interface SegmentedBarProps { + data: SegmentedBarData; + theme?: PaletteName; + className?: string; + style?: React.CSSProperties; + animated?: boolean; +} + +export const SegmentedBar = ({ + data, + theme = "ocean", + className, + style, + animated = true, +}: SegmentedBarProps) => { + // Calculate percentages + const segments = useMemo(() => { + if (!data || data.length === 0) { + return []; + } + + const total = data.reduce((acc, value) => acc + value, 0); + + return data.map((value, index) => ({ + value, + index, + percentage: total > 0 ? (value / total) * 100 : 0, + })); + }, [data]); + + // Get theme colors for each segment + const colors = useMemo(() => { + const palette = getPalette(theme); + return getDistributedColors(palette, Math.max(segments.length, 1)); + }, [theme, segments.length]); + + // Segmented progress bar + return ( +
+ {segments.map((segment, index) => { + return ( +
+ ); + })} +
+ ); +}; diff --git a/js/packages/react-ui/src/components/Charts/SegmentedBarChart/index.ts b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/index.ts new file mode 100644 index 000000000..305fbc61a --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/index.ts @@ -0,0 +1,3 @@ +export { SegmentedBar } from "./SegmentedBarChart"; +export type { SegmentedBarProps } from "./SegmentedBarChart"; +export type { SegmentedBarData } from "./types"; diff --git a/js/packages/react-ui/src/components/Charts/SegmentedBarChart/segmentedBarChart.scss b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/segmentedBarChart.scss new file mode 100644 index 000000000..963017d38 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/segmentedBarChart.scss @@ -0,0 +1,28 @@ +@use "../../../cssUtils.scss" as cssUtils; + +.crayon-segmented-bar-chart { + width: 100%; + height: 20px; + font-family: inherit; + background-color: cssUtils.$bg-fill; + border-radius: cssUtils.$rounded-s; + overflow: hidden; + box-shadow: cssUtils.$shadow-s; + display: flex; + padding: cssUtils.$spacing-2xs; + gap: cssUtils.$spacing-3xs; +} + +.crayon-segmented-bar-chart-segment { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + min-width: 2px; + box-shadow: cssUtils.$shadow-s; + border-radius: cssUtils.$rounded-xs; +} + +.crayon-segmented-bar-chart-animated { + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/js/packages/react-ui/src/components/Charts/SegmentedBarChart/stories/SegmentedBarChart.stories.tsx b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/stories/SegmentedBarChart.stories.tsx new file mode 100644 index 000000000..5540fb670 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/stories/SegmentedBarChart.stories.tsx @@ -0,0 +1,226 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Card } from "../../../Card"; +import { SegmentedBar, SegmentedBarProps } from "../SegmentedBarChart"; + +const meta: Meta = { + title: "Components/Charts/SegmentedBar", + component: SegmentedBar, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +// Note: While the component is named 'ProgressBar', it's presented here as a 'SegmentedBar' +// to emphasize its use for showing proportional data, much like a pie chart. +## Installation and Basic Usage + +\`\`\`tsx +import { SegmentedBar } from '@crayon-ui/react-ui/Charts/SegmentedBar'; + +// Basic implementation + +\`\`\` + +## Data Structure Requirements + +The data should be a simple array of numbers. Each number represents a segment in the bar. The component automatically calculates the proportional width of each segment based on the total sum of values. + +\`\`\`tsx +// Represents a composition of three values +const data = [25, 30, 20]; +\`\`\` + +## Key Features + +- **Segmented Display**: Visualize the composition of a whole. +- **Theming**: Six built-in color palettes. +- **Animation**: Smoothly animates the bar segments. +- **Responsive**: Adapts to the width of its container. +`, + }, + }, + }, + tags: ["!dev", "!autodocs"], + argTypes: { + data: { + description: ` +**Required.** An array of numbers where each number represents a segment's value. +The component automatically calculates the percentage width for each segment based on the total sum of values. + +**Example:** \`[20, 30, 15]\` creates three segments showing their proportional relationship. +`, + control: false, + table: { + type: { summary: "Array" }, + defaultValue: { summary: "[]" }, + category: "📊 Data Configuration", + }, + }, + theme: { + description: ` +**Color Theme Selection.** Choose from professionally designed color palettes: + +- **ocean**: Cool blues and teals +- **orchid**: Purple and pink tones +- **emerald**: Green variations +- **sunset**: Warm oranges and reds +- **spectrum**: Full color range +- **vivid**: High-contrast colors +`, + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + table: { + defaultValue: { summary: "ocean" }, + category: "🎨 Visual Styling", + }, + }, + animated: { + description: ` +**Animation Control.** Enables or disables the fill animation on load. +`, + control: "boolean", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + category: "🎬 Animation & Interaction", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sampleData = { + default: [25, 30, 20], + many: [10, 8, 12, 5, 15, 7, 13, 10], + full: [25, 25, 25, 25], +}; + +export const DefaultConfiguration: Story = { + name: "📊 Default Configuration", + args: { + data: sampleData.default, + theme: "ocean", + animated: true, + }, + render: (args: any) => ( + +
+

+ Category Breakdown +

+

+ Visualizing the breakdown of a dataset into its constituent parts. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: + "This is the standard appearance of the Segmented Bar. It shows multiple segments, each with a color from the selected theme, representing the proportional breakdown of a whole.", + }, + }, + }, +}; + +export const ThemeShowcase: Story = { + name: "🎨 Theme Showcase", + args: { + data: [15, 20, 25, 15], + animated: true, + }, + render: (args: any) => ( +
+ {(["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const).map((theme) => ( +
+

+ {theme} +

+ +
+ ))} +
+ ), + parameters: { + docs: { + description: { + story: + "The Segmented Bar supports six different color themes. This allows it to fit seamlessly into various application designs and visual identities.", + }, + }, + }, +}; + +export const ManySegments: Story = { + name: "🧩 Many Segments", + args: { + data: sampleData.many, + theme: "vivid", + animated: true, + }, + render: (args: any) => ( + +
+

+ Resource Distribution +

+

+ The component gracefully handles numerous small segments. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: + "The Segmented Bar can display many segments. The colors will cycle through the selected theme palette if the number of segments exceeds the number of available colors.", + }, + }, + }, +}; + +export const FullComposition: Story = { + name: "✅ Full Composition", + args: { + data: sampleData.full, + theme: "spectrum", + animated: false, + }, + render: (args: any) => ( + +
+

+ Complete Data Set +

+

+ Displaying a bar where segments add up to 100%. +

+
+ +
+ ), + parameters: { + docs: { + description: { + story: + "This example shows a bar where the segments add up to 100%. The segments collectively fill the entire width of the container, representing the full composition of the data.", + }, + }, + }, +}; diff --git a/js/packages/react-ui/src/components/Charts/SegmentedBarChart/types/index.ts b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/types/index.ts new file mode 100644 index 000000000..aa0ddfb29 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/SegmentedBarChart/types/index.ts @@ -0,0 +1 @@ +export type SegmentedBarData = Array; diff --git a/js/packages/react-ui/src/components/Charts/charts.scss b/js/packages/react-ui/src/components/Charts/charts.scss index e4b13fb0f..759377bed 100644 --- a/js/packages/react-ui/src/components/Charts/charts.scss +++ b/js/packages/react-ui/src/components/Charts/charts.scss @@ -1,4 +1,38 @@ -@use "../../cssUtils" as cssUtils; +@use "../../cssUtils.scss" as cssUtils; + +// bar chart css +@forward "./BarChart/barChart.scss"; +@forward "./MiniBarChart/miniBarChart.scss"; + +// area chart css +@forward "./AreaChart/areaChart.scss"; + +// line chart css +@forward "./LineChart/lineChart.scss"; + +// radar chart css +@forward "./RadarChart/radarChart.scss"; + +// segmented bar chart css +@forward "./SegmentedBarChart/segmentedBarChart.scss"; + +// pie chart css +@forward "./PieChart/pieChart.scss"; + +// radial chart css +@forward "./RadialChart/radialChart.scss"; + +// shared components css +@forward "./shared/XAxisTick/xAxisTick.scss"; +@forward "./shared/DefaultLegend/defaultLegend.scss"; +@forward "./shared/StackedLegend/stackedLegend.scss"; +@forward "./shared/YAxisTick/yAxisTick.scss"; + +// portal tooltip css +@forward "./shared/PortalTooltip/portalTooltip.scss"; + +// side bar tooltip css +@forward "./shared/SideBarTooltip/sideBarTooltip.scss"; .crayon-chart { // Container styles @@ -9,19 +43,10 @@ font-size: 12px; line-height: 16px; - // Text and fills - .recharts-cartesian-axis-tick text { - fill: cssUtils.$primary-text; - } - .recharts-radial-bar-background-sector { fill: cssUtils.$bg-fill; } - .recharts-rectangle.recharts-tooltip-cursor { - fill: cssUtils.$bg-fill; - } - // Strokes and borders .recharts-cartesian-grid line[stroke="#ccc"], .recharts-curve.recharts-tooltip-cursor, @@ -43,169 +68,6 @@ outline: none; } } - - // Tooltip styles - &-tooltip { - display: grid; - align-items: start; - min-width: 128px; - gap: cssUtils.$spacing-xs; - padding: cssUtils.$spacing-xs; - color: cssUtils.$primary-text; - @include cssUtils.typography(label, default); - border-radius: cssUtils.$rounded-s; - border: 1px solid cssUtils.$stroke-default; - background-color: cssUtils.$bg-container; - box-shadow: cssUtils.$shadow-s; - text-transform: capitalize; - - &-label { - @include cssUtils.typography(label, default); - } - - &-label-heavy { - @include cssUtils.typography(label, heavy); - } - - &-content { - display: grid; - align-items: start; - min-width: 128px; - gap: cssUtils.$spacing-xs; - padding: cssUtils.$spacing-xs; - color: cssUtils.$primary-text; - @include cssUtils.typography(label, default); - border-radius: cssUtils.$rounded-s; - border: 1px solid cssUtils.$stroke-default; - background-color: cssUtils.$bg-container; - box-shadow: cssUtils.$shadow-s; - text-transform: capitalize; - - &-item { - display: flex; - width: 100%; - flex-wrap: wrap; - gap: cssUtils.$spacing-xs; - align-items: stretch; - &--dot { - align-items: center; - } - - svg { - height: 10px; - width: 10px; - color: cssUtils.$primary-text; - } - } - & &-indicator { - flex-shrink: 0; - border-radius: cssUtils.$rounded-3xs; - - &--dot { - height: 10px; - width: 10px; - background-color: var(--color-bg); - border-color: var(--color-border); - } - - &--line { - width: 4px; - height: 100%; - background-color: var(--color-bg); - border-color: var(--color-border); - } - - &--dashed { - width: 0; - border: 1.5px dashed var(--color-border); - background-color: transparent; - } - - &--nested-dashed { - margin: cssUtils.$spacing-3xs 0; - } - } - - &-value-wrapper { - display: flex; - flex: 1; - justify-content: space-between; - line-height: 1; - - &--nested { - align-items: flex-end; - } - - &--standard { - align-items: center; - } - } - - &-label { - display: grid; - gap: cssUtils.$spacing-xs; - color: cssUtils.$primary-text; - @include cssUtils.typography(label, default); - } - - &-value { - font-variant-numeric: tabular-nums; - color: cssUtils.$primary-text; - @include cssUtils.typography(label, default); - - &--percentage { - padding-left: cssUtils.$spacing-s; - } - } - } - } - - // Legend styles - &-legend { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - text-transform: capitalize; - flex-wrap: wrap; - - &--top { - padding-bottom: cssUtils.$spacing-s; - } - - &--bottom { - padding-top: cssUtils.$spacing-m; - } - - &-item { - display: flex; - align-items: center; - gap: cssUtils.$spacing-xs; - - svg { - height: 12px; - width: 12px; - color: cssUtils.$primary-text; - } - - &-indicator { - height: 8px; - width: 8px; - flex-shrink: 0; - border-radius: cssUtils.$rounded-3xs; - background-color: var(--color-bg); - } - - &-label { - @include cssUtils.typography(label, heavy); - max-width: 64px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: cssUtils.$primary-text; - } - } - } } .crayon-chart-cartesian-grid { @@ -221,11 +83,13 @@ } //Pie Chart styles +// @subham either remove this or move this to it dedicated file. .crayon-pie-chart { &-container { margin-left: auto; margin-right: auto; + aspect-ratio: 1/1; &-fullscreen { min-height: 400px; @@ -253,9 +117,3 @@ fill: cssUtils.$secondary-text; padding: 0; } - -.crayon-chart-axis-label { - fill: cssUtils.$primary-text; - @include cssUtils.typography(label, default); - text-anchor: middle; -} diff --git a/js/packages/react-ui/src/components/Charts/context/SideBarTooltipContext.tsx b/js/packages/react-ui/src/components/Charts/context/SideBarTooltipContext.tsx new file mode 100644 index 000000000..4fad8db27 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/context/SideBarTooltipContext.tsx @@ -0,0 +1,54 @@ +import React, { createContext, ReactNode, useContext } from "react"; + +export interface SideBarChartData { + title: string; + values: { + value: number; + label: string; + color: string; + }[]; +} + +interface SideBarTooltipContextType { + data: SideBarChartData | undefined; + isSideBarTooltipOpen: boolean; + setData: (data: SideBarChartData) => void; + setIsSideBarTooltipOpen: (isOpen: boolean) => void; +} + +const SideBarTooltipContext = createContext(undefined); + +interface SideBarTooltipProviderProps { + children: ReactNode; + isSideBarTooltipOpen: boolean; + setIsSideBarTooltipOpen: (isOpen: boolean) => void; + data: SideBarChartData | undefined; + setData: (data: SideBarChartData) => void; +} + +export const SideBarTooltipProvider: React.FC = ({ + children, + isSideBarTooltipOpen, + setIsSideBarTooltipOpen, + data, + setData, +}) => { + const value: SideBarTooltipContextType = { + data, + isSideBarTooltipOpen, + setData, + setIsSideBarTooltipOpen, + }; + + return {children}; +}; + +export const useSideBarTooltip = (): SideBarTooltipContextType => { + const context = useContext(SideBarTooltipContext); + if (context === undefined) { + throw new Error("useSideBarTooltip must be used within a SideBarTooltipProvider"); + } + return context; +}; + +export default SideBarTooltipContext; diff --git a/js/packages/react-ui/src/components/Charts/dependencies.ts b/js/packages/react-ui/src/components/Charts/dependencies.ts index f6ded9582..39a26fb45 100644 --- a/js/packages/react-ui/src/components/Charts/dependencies.ts +++ b/js/packages/react-ui/src/components/Charts/dependencies.ts @@ -1,2 +1,2 @@ -const dependencies = ["Charts"]; +const dependencies = ["Charts", "IconButton"]; export default dependencies; diff --git a/js/packages/react-ui/src/components/Charts/hooks/index.ts b/js/packages/react-ui/src/components/Charts/hooks/index.ts new file mode 100644 index 000000000..82638018f --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useTransformKey"; diff --git a/js/packages/react-ui/src/components/Charts/hooks/useTransformKey.tsx b/js/packages/react-ui/src/components/Charts/hooks/useTransformKey.tsx new file mode 100644 index 000000000..fc563b0a4 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/hooks/useTransformKey.tsx @@ -0,0 +1,20 @@ +import { useMemo } from "react"; + +export const useTransformedKeys = (keys: string[]) => { + return useMemo(() => { + return keys.reduce( + (acc, key) => { + const transformedKey = crypto.randomUUID(); + acc[key] = transformedKey; + return acc; + }, + {} as Record, + ); + }, [keys]); +}; + +// const transformedKeys = useTransformedKeys(["a", "b", "c"]) + +// dataKeys.map((key) => { +// return `var(${transformedKeys[key]})` +// }) diff --git a/js/packages/react-ui/src/components/Charts/index.ts b/js/packages/react-ui/src/components/Charts/index.ts index 5dd6c7966..92b81eaf9 100644 --- a/js/packages/react-ui/src/components/Charts/index.ts +++ b/js/packages/react-ui/src/components/Charts/index.ts @@ -4,3 +4,4 @@ export * from "./LineChart"; export * from "./PieChart"; export * from "./RadarChart"; export * from "./RadialChart"; +export * from "./SegmentedBarChart"; diff --git a/js/packages/react-ui/src/components/Charts/shared/ActiveDot/ActiveDot.tsx b/js/packages/react-ui/src/components/Charts/shared/ActiveDot/ActiveDot.tsx new file mode 100644 index 000000000..63a2bff59 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/ActiveDot/ActiveDot.tsx @@ -0,0 +1,53 @@ +import React, { useLayoutEffect, useRef } from "react"; + +export interface ActiveDotProps { + cx?: number; + cy?: number; + payload?: any; + value?: any; +} + +export const ActiveDot: React.FC = (props) => { + const { cx, cy } = props; + const ref = useRef(null); + + useLayoutEffect(() => { + if (ref.current) { + const parent = ref.current.parentElement?.parentElement; + const dotGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); + + const circle1 = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle1.setAttribute("cx", String(cx)); + circle1.setAttribute("cy", String(cy)); + circle1.setAttribute("r", "4"); + circle1.setAttribute("fill", "var(--crayon-container-fills)"); + circle1.setAttribute("stroke", "var(--crayon-container-fills)"); + circle1.setAttribute("stroke-width", "1"); + + const circle2 = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle2.setAttribute("cx", String(cx)); + circle2.setAttribute("cy", String(cy)); + circle2.setAttribute("r", "2"); + circle2.setAttribute("fill", "var(--crayon-inverted-fills)"); + circle2.setAttribute("stroke", "var(--crayon-inverted-fills)"); + circle2.setAttribute("stroke-width", "0.5"); + + dotGroup.appendChild(circle1); + dotGroup.appendChild(circle2); + if (parent) { + parent.appendChild(dotGroup); + } + + return () => { + dotGroup.remove(); + }; + } + return undefined; + }); + + if (cx === undefined || cy === undefined || cx === null || cy === null) { + return null; + } + + return ; +}; diff --git a/js/packages/react-ui/src/components/Charts/cartesianGrid.tsx b/js/packages/react-ui/src/components/Charts/shared/CartesianGrid/cartesianGrid.tsx similarity index 86% rename from js/packages/react-ui/src/components/Charts/cartesianGrid.tsx rename to js/packages/react-ui/src/components/Charts/shared/CartesianGrid/cartesianGrid.tsx index 4f26689b3..e8f60d989 100644 --- a/js/packages/react-ui/src/components/Charts/cartesianGrid.tsx +++ b/js/packages/react-ui/src/components/Charts/shared/CartesianGrid/cartesianGrid.tsx @@ -5,8 +5,8 @@ export const cartesianGrid = () => ( vertical={false} fillOpacity={1} strokeOpacity={1} - strokeWidth={2} - strokeDasharray="6" + strokeWidth={1} + strokeDasharray="0" strokeLinecap="round" strokeLinejoin="round" stroke="currentColor" diff --git a/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/DefaultLegend.tsx b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/DefaultLegend.tsx new file mode 100644 index 000000000..37f9ac691 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/DefaultLegend.tsx @@ -0,0 +1,121 @@ +import clsx from "clsx"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import React, { memo, useMemo } from "react"; +import { Button } from "../../../Button/Button"; +import { type LegendItem } from "../../types"; +import { calculateVisibleItems, getToggleButtonText } from "./utils/defaultLegendUtils"; + +interface DefaultLegendProps { + items: LegendItem[]; + className?: string; + yAxisLabel?: React.ReactNode; + xAxisLabel?: React.ReactNode; + containerWidth?: number; + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + style?: React.CSSProperties; +} + +const DefaultLegend = memo( + React.forwardRef( + ( + { + items, + className, + yAxisLabel, + xAxisLabel, + containerWidth, + isExpanded, + setIsExpanded, + style, + }, + ref, + ) => { + // Only memoize expensive calculations + const { visibleItems, hasMoreItems } = useMemo(() => { + return calculateVisibleItems(items, containerWidth); + }, [items, containerWidth]); + + const displayItems = useMemo(() => { + return isExpanded ? items : visibleItems; + }, [isExpanded, items, visibleItems]); + + const handleToggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + const showToggleButton = hasMoreItems; + + const toggleButtonText = useMemo(() => { + return getToggleButtonText(isExpanded, items.length, visibleItems.length); + }, [isExpanded, items.length, visibleItems.length]); + + return ( +
+ {/* this is x and y axis labels container*/} + {(xAxisLabel || yAxisLabel) && ( +
+ {xAxisLabel && ( + + X-Axis: {xAxisLabel} + + )} + {yAxisLabel && ( + + Y-Axis: {yAxisLabel} + + )} +
+ )} + {/* this is the legend items container*/} +
+ {displayItems.map((item) => ( +
+ {item.icon ? ( + + ) : ( +
+ )} + {item.label} +
+ ))} + + {showToggleButton && ( + + )} +
+
+ ); + }, + ), +); + +DefaultLegend.displayName = "DefaultLegend"; +export { DefaultLegend }; +export type { DefaultLegendProps }; diff --git a/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/defaultLegend.scss b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/defaultLegend.scss new file mode 100644 index 000000000..5f67e3c58 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/defaultLegend.scss @@ -0,0 +1,87 @@ +@use "../../../../cssUtils" as cssUtils; + +.crayon-chart-legend-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: cssUtils.$spacing-2xs; +} + +.crayon-chart-legend-axis-label-container { + display: flex; + align-items: center; + justify-content: center; + gap: cssUtils.$spacing-m; + flex-wrap: wrap; +} + +.crayon-chart-legend-axis-label { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$secondary-text; + + &-text { + color: cssUtils.$primary-text; + } +} + +.crayon-chart-legend { + display: flex; + align-items: center; + justify-content: center; + gap: cssUtils.$spacing-m; + text-transform: capitalize; + flex-wrap: wrap; + + &--bottom { + padding-top: cssUtils.$spacing-m; + } + + &--collapsed { + flex-wrap: nowrap; + overflow: hidden; + } + + &--expanded { + flex-wrap: wrap; + } + + &-item { + display: flex; + align-items: center; + gap: cssUtils.$spacing-xs; + + svg { + height: 10px; + width: 10px; + color: cssUtils.$primary-text; + } + + &-indicator { + height: 10px; + width: 10px; + flex-shrink: 0; + border-radius: cssUtils.$rounded-2xs; + background-color: var(--color-bg); + } + + &-label { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$primary-text; + } + } + + &-toggle-button { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$primary-text; + height: 16px; + padding-left: cssUtils.$spacing-2xs; + padding-right: cssUtils.$spacing-3xs; + + &-icon { + width: 12px; + height: 12px; + color: cssUtils.$primary-text; + } + } +} diff --git a/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/stories/DefaultLegend.stories.tsx b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/stories/DefaultLegend.stories.tsx new file mode 100644 index 000000000..8b617b284 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/stories/DefaultLegend.stories.tsx @@ -0,0 +1,338 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef, useState } from "react"; +import { LegendItem } from "../../../types"; +import { DefaultLegend } from "../DefaultLegend"; + +const meta: Meta = { + title: "Components/Charts/Shared/DefaultLegend", + component: DefaultLegend, + parameters: { + layout: "centered", + }, + tags: ["!dev", "!autodocs"], + argTypes: { + containerWidth: { + control: { type: "range", min: 200, max: 800, step: 50 }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Sample legend items with varying label lengths +const shortItems: LegendItem[] = [ + { key: "sales", label: "Sales", color: "#3b82f6" }, + { key: "users", label: "Users", color: "#ef4444" }, + { key: "revenue", label: "Revenue", color: "#10b981" }, +]; + +const mediumItems: LegendItem[] = [ + { key: "sales", label: "Sales Data", color: "#3b82f6" }, + { key: "marketing", label: "Marketing Leads", color: "#ef4444" }, + { key: "revenue", label: "Total Revenue", color: "#10b981" }, + { key: "conversion", label: "Conversion Rate", color: "#f59e0b" }, + { key: "retention", label: "User Retention", color: "#8b5cf6" }, +]; + +const longItems: LegendItem[] = [ + { key: "sales", label: "Monthly Sales Performance", color: "#3b82f6" }, + { key: "marketing", label: "Digital Marketing Campaigns", color: "#ef4444" }, + { key: "revenue", label: "Quarterly Revenue Growth", color: "#10b981" }, + { key: "conversion", label: "Customer Conversion Metrics", color: "#f59e0b" }, + { key: "retention", label: "Long-term User Retention", color: "#8b5cf6" }, + { key: "engagement", label: "User Engagement Analytics", color: "#ec4899" }, + { key: "support", label: "Customer Support Tickets", color: "#14b8a6" }, + { key: "satisfaction", label: "Customer Satisfaction Score", color: "#f97316" }, +]; + +const expandCollapseItems: LegendItem[] = [ + { key: "web_traffic", label: "Website Traffic and Organic Search Results", color: "#3b82f6" }, + { key: "social_media", label: "Social Media Engagement and Brand Awareness", color: "#ef4444" }, + { key: "email_campaigns", label: "Email Marketing Campaign Performance", color: "#10b981" }, + { key: "paid_advertising", label: "Paid Advertising and PPC Campaign ROI", color: "#f59e0b" }, + { key: "content_marketing", label: "Content Marketing and Blog Performance", color: "#8b5cf6" }, + { key: "mobile_app", label: "Mobile Application Downloads and Usage", color: "#ec4899" }, + { + key: "customer_support", + label: "Customer Support Response Time and Quality", + color: "#14b8a6", + }, + { + key: "sales_conversion", + label: "Sales Funnel Conversion and Lead Generation", + color: "#f97316", + }, + { key: "user_retention", label: "User Retention and Churn Rate Analysis", color: "#6366f1" }, + { + key: "product_analytics", + label: "Product Feature Usage and Performance Metrics", + color: "#06b6d4", + }, + { key: "market_research", label: "Market Research and Competitive Analysis", color: "#84cc16" }, + { + key: "brand_sentiment", + label: "Brand Sentiment and Public Relations Impact", + color: "#f59e0b", + }, +]; + +export const Basic: Story = { + args: { + items: shortItems, + containerWidth: 400, + }, +}; + +export const WithAxisLabels: Story = { + args: { + items: mediumItems, + containerWidth: 400, + xAxisLabel: "Time Period", + yAxisLabel: "Value", + }, +}; + +export const SmallContainer: Story = { + args: { + items: mediumItems, + containerWidth: 300, + }, +}; + +export const ManyItemsSmallContainer: Story = { + args: { + items: longItems, + containerWidth: 400, + }, +}; + +export const ManyItemsLargeContainer: Story = { + args: { + items: longItems, + containerWidth: 800, + }, +}; + +export const VerySmallContainer: Story = { + args: { + items: longItems, + containerWidth: 200, + }, +}; + +export const Interactive: Story = { + args: { + items: longItems, + xAxisLabel: "Monthly Data", + yAxisLabel: "Performance Metrics", + }, + render: (args: any) => { + const DynamicLegendExample = () => { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(500); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + // Subtract padding from the observed width + const paddingX = 40; // 20px padding on each side + const observedWidth = Math.max(200, entry.contentRect.width - paddingX); + setContainerWidth(observedWidth); + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + return ( +
+

+ This legend demonstrates dynamic behavior with ResizeObserver. + Drag the bottom-right corner to resize the container and see how the + legend adapts in real-time. Current width: {containerWidth}px +

+ +
+ ); + }; + + return ; + }, +}; + +export const ExpandCollapseDemo: Story = { + args: { + items: expandCollapseItems, + containerWidth: 500, + xAxisLabel: "Marketing Channels", + yAxisLabel: "Performance Metrics", + }, + render: (args: any) => ( +
+

+ 🔄 Expand/Collapse Functionality Demo +

+

+ This legend has 12 items with long labels in a{" "} + 500px container. Only the items that fit in one line are shown initially, + with a "Show More" button to expand and see all items. Click the button to + toggle between collapsed and expanded states. +

+
+ +
+
+ 💡 Features: Dynamic width calculation, intelligent truncation, responsive + behavior +
+
+ ), + parameters: { + docs: { + description: { + story: + "Demonstrates the expand/collapse functionality with 12 marketing-themed legend items. The legend automatically calculates how many items fit in the container width and shows a toggle button when items overflow.", + }, + }, + }, +}; + +export const ResponsiveExpandCollapse: Story = { + args: { + items: expandCollapseItems, + xAxisLabel: "Marketing Channels", + yAxisLabel: "Performance Metrics", + }, + render: (args: any) => ( +
+

+ 📐 Responsive Expand/Collapse Behavior +

+

+ The same legend data shown in different container widths. Notice how the number of visible + items and the toggle button behavior adapts to the available space. +

+ +
+
+

+ Small Container (350px) - Shows fewer items +

+
+ +
+
+ +
+

+ Medium Container (500px) - Shows more items +

+
+ +
+
+ +
+

+ Large Container (700px) - Shows most items +

+
+ +
+
+ +
+

+ Extra Large Container (900px) - Shows all items (no toggle button) +

+
+ +
+
+
+
+ ), + parameters: { + docs: { + description: { + story: + "Shows how the expand/collapse behavior adapts to different container widths. Smaller containers show fewer items initially, while larger containers may show all items without needing a toggle button.", + }, + }, + }, +}; + +export const DefaultBehavior: Story = { + args: { + items: longItems, + // No containerWidth provided - should show all items + xAxisLabel: "Monthly Data", + yAxisLabel: "Performance Metrics", + }, + render: (args: any) => ( +
+

+ This demonstrates the default behavior when no containerWidth is provided. All legend items + are shown without truncation or show more/less functionality. +

+ +
+ ), +}; diff --git a/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/utils/defaultLegendUtils.ts b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/utils/defaultLegendUtils.ts new file mode 100644 index 000000000..0134f7f27 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/DefaultLegend/utils/defaultLegendUtils.ts @@ -0,0 +1,81 @@ +import { LegendItem } from "../../../types"; + +// Constants +export const SHOW_MORE_BUTTON_WIDTH = 65; +export const CHARACTER_WIDTH = 7; +export const INDICATOR_WIDTH = 10; +export const GAP_WIDTH = 12; + +/** + * Calculate the estimated width of a legend item + */ +export const calculateItemWidth = (label: string): number => { + return label.length * CHARACTER_WIDTH + INDICATOR_WIDTH + GAP_WIDTH; +}; + +/** + * Calculate which items can fit in the available width + */ +export const calculateVisibleItems = ( + items: LegendItem[], + containerWidth?: number, +): { + visibleItems: LegendItem[]; + hasMoreItems: boolean; +} => { + // If no containerWidth provided, show all items (default behavior) + if (!containerWidth || items.length === 0) { + return { visibleItems: items, hasMoreItems: false }; + } + + // Reserve space for "show more" button + const availableWidth = containerWidth - SHOW_MORE_BUTTON_WIDTH; + + let currentWidth = 0; + let visibleCount = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) continue; + + const itemWidth = calculateItemWidth(item.label); + + if (currentWidth + itemWidth <= availableWidth) { + currentWidth += itemWidth; + visibleCount++; + } else { + break; + } + } + + // If all items fit, don't need show more button + if (visibleCount === items.length) { + return { visibleItems: items, hasMoreItems: false }; + } + + // If no items fit even with show more button, show at least one + if (visibleCount === 0 && items[0]) { + return { visibleItems: [items[0]], hasMoreItems: items.length > 1 }; + } + + return { + visibleItems: items.slice(0, visibleCount), + hasMoreItems: items.length > visibleCount, + }; +}; + +/** + * Generate toggle button text based on the number of hidden items + */ +export const getToggleButtonText = ( + isExpanded: boolean, + totalItems: number, + visibleItemsCount: number, +): string => { + if (isExpanded) { + return "Show Less"; + } + + const hiddenCount = totalItems - visibleItemsCount; + return hiddenCount === 1 ? "1 more" : `${hiddenCount} more`; +}; diff --git a/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/CustomTooltipContent.tsx b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/CustomTooltipContent.tsx new file mode 100644 index 000000000..7cc00a75d --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/CustomTooltipContent.tsx @@ -0,0 +1,231 @@ +import clsx from "clsx"; +import { forwardRef, memo, useLayoutEffect, useMemo, useState } from "react"; +import * as RechartsPrimitive from "recharts"; +import { ChartStyle, getPayloadConfigFromPayload, useChart } from "../../../Charts/Charts"; +import { useSideBarTooltip } from "../../context/SideBarTooltipContext"; +import { FloatingUIPortal } from "./FloatingUIPortal"; +import { tooltipNumberFormatter } from "./utils"; + +const DEFAULT_INDICATOR = "dot" as const; + +/** + * Custom tooltip content component for floating tooltips + * Mirrors the functionality of ChartTooltipContent but works with FloatingUIPortal + */ +export const CustomTooltipContent = memo( + forwardRef< + HTMLDivElement, + React.ComponentProps & + React.ComponentProps<"div"> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + showPercentage?: boolean; + portalContainer?: React.RefObject; + } + >((props, ref) => { + const { + active, + payload, + className, + indicator = DEFAULT_INDICATOR, + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + showPercentage = false, + portalContainer, + offset, + } = props; + + const { config, id } = useChart(); + const { isSideBarTooltipOpen } = useSideBarTooltip(); + const [isGreaterThanTen, setIsGreaterThanTen] = useState( + !!(payload?.length && payload.length > 10), + ); + const [remainingItems, setRemainingItems] = useState(0); + + useLayoutEffect(() => { + if (payload?.length && payload.length > 10) { + setIsGreaterThanTen(true); + } else { + setIsGreaterThanTen(false); + } + }, [payload]); + + const tooltipLabel = useMemo(() => { + if (hideLabel || !payload?.length) { + return null; + } + + const [item] = payload; + const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = + !labelKey && typeof label === "string" ? config[label]?.label || label : itemConfig?.label; + + // this is active when we pass a labelFormatter prop to the recharts tooltip + // normally we would not need this, but it's useful for customizing the label + if (labelFormatter) { + return ( +
+ {labelFormatter(value, payload)} +
+ ); + } + + if (!value) { + return null; + } + + return
{value}
; + }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]); + + const nestLabel = useMemo( + () => payload?.length === 1 && indicator !== DEFAULT_INDICATOR, + [payload?.length, indicator], + ); + + const payloadItems = useMemo(() => { + if (!payload?.length) { + return []; + } + + const renderPayloadItem = (item: any, index: number, isTwoItemsLayout: boolean) => { + const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = (color ?? item.payload?.fill) || item.color; + + return ( +
+ {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + + ) : ( + !hideIndicator && ( +
+ ) + )} + +
+
+ {nestLabel && tooltipLabel} + {itemConfig?.label || item.name} +
+ + {item.value !== undefined && ( + + {typeof item.value === "number" + ? tooltipNumberFormatter(item.value) + : item.value} + {showPercentage ? "%" : ""} + + )} +
+ + )} +
+
+ ); + }; + + // Handle two items layout + if (payload.length <= 2) { + return payload.map((item, index) => renderPayloadItem(item, index, true)); + } + + // Handle regular layout with potential truncation + const morphPayload = isGreaterThanTen ? payload.slice(0, 5) : payload; + setRemainingItems(payload.length - morphPayload.length); + return morphPayload.map((item, index) => renderPayloadItem(item, index, false)); + }, [ + payload, + nameKey, + config, + color, + indicator, + formatter, + hideIndicator, + nestLabel, + tooltipLabel, + showPercentage, + isGreaterThanTen, + ]); + + // Early return for inactive or empty payload - moved after all hooks + if (!active || !payload?.length || isSideBarTooltipOpen) { + return null; + } + + const tooltipContent = ( +
+ {!nestLabel && tooltipLabel} +
+
{payloadItems}
+ {isGreaterThanTen &&
} + {isGreaterThanTen && ( +
+ Click to view all {remainingItems} +
+ )} +
+ ); + + return ( + + + {tooltipContent} + + ); + }), +); + +CustomTooltipContent.displayName = "CustomTooltipContent"; diff --git a/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/FloatingUIPortal.tsx b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/FloatingUIPortal.tsx new file mode 100644 index 000000000..c63c469ce --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/FloatingUIPortal.tsx @@ -0,0 +1,135 @@ +import type { Placement } from "@floating-ui/react-dom"; +import { computePosition, flip, offset, shift } from "@floating-ui/react-dom"; +import clsx from "clsx"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface VirtualElement { + getBoundingClientRect(): DOMRect; +} + +interface FloatingUIPortalProps { + active: boolean; + children: React.ReactNode; + placement?: Placement; + offsetDistance?: number; + className?: string; + chartId?: string; + portalContainer?: React.RefObject; +} + +export const FloatingUIPortal: React.FC = ({ + active, + children, + placement = "right-start", + offsetDistance = 10, + className = "", + chartId, + portalContainer, +}) => { + const mousePositionRef = useRef({ x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER }); + const tooltipRef = useRef(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isPositioned, setIsPositioned] = useState(false); + + // Memoize the virtual element to avoid recreating it on every render + // this virtual element basically shares the same position as the mouse position + // and it is used to position the tooltip + const virtualElement = useMemo( + () => ({ + getBoundingClientRect(): DOMRect { + return { + width: 0, + height: 0, + x: mousePositionRef.current.x, + y: mousePositionRef.current.y, + top: mousePositionRef.current.y, + left: mousePositionRef.current.x, + right: mousePositionRef.current.x, + bottom: mousePositionRef.current.y, + } as DOMRect; + }, + }), + [], + ); + + // Function to get the portal target element + // also memoize it to avoid recreating it on every render + const getPortalTarget = useCallback((): HTMLElement => { + if (!portalContainer || !portalContainer.current) { + return document.body; + } + return portalContainer.current; + }, [portalContainer]); + + // Memoize the updatePosition function to avoid recreating it + const updatePosition = useCallback(async () => { + if ( + !virtualElement || + !tooltipRef.current || + virtualElement.getBoundingClientRect().x === Number.MAX_SAFE_INTEGER || + virtualElement.getBoundingClientRect().y === Number.MAX_SAFE_INTEGER + ) { + return; + } + + // https://floating-ui.com/docs/computePosition + // not a synchronous function, it returns a promise. so we need to await it. + const { x, y } = await computePosition(virtualElement, tooltipRef.current, { + placement, + middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })], + }); + + if (x === 0 && y === 0) { + return; + } + + setPosition({ x, y }); + setIsPositioned(true); + }, [virtualElement, placement, offsetDistance]); + + // Memoize the mouse move handler + const handleMouseMove = useCallback( + (event: MouseEvent) => { + mousePositionRef.current = { + x: event.clientX, + y: event.clientY, + }; + updatePosition(); + }, + [updatePosition], + ); + + useEffect(() => { + if (!active) { + setIsPositioned(false); + return; + } + + document.addEventListener("mousemove", handleMouseMove); + updatePosition(); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + setIsPositioned(false); + }; + }, [active, handleMouseMove, updatePosition]); + + if (!active) return null; + + return createPortal( +
+ {children} +
, + getPortalTarget(), + ); +}; diff --git a/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/README.md b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/README.md new file mode 100644 index 000000000..a0d90e0b5 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/README.md @@ -0,0 +1,160 @@ +# Floating Tooltip Components + +A collection of components that provide mouse-following tooltips for charts using Floating UI for superior positioning and collision detection. + +## Components + +### FloatingUIPortal + +A portal-based tooltip component that follows the mouse cursor and uses Floating UI for intelligent positioning. + +```tsx +import { FloatingUIPortal } from "./FloatingUIPortal"; + + +
Tooltip content
+
; +``` + +#### Props + +- `active` (boolean): Whether the tooltip should be visible and active +- `children` (ReactNode): The content to display in the tooltip +- `placement` (Placement, optional): Floating UI placement option (default: "right-start") +- `offsetDistance` (number, optional): Distance from the mouse cursor (default: 8) +- `className` (string, optional): Additional CSS classes + +#### Features + +- **Mouse Following**: Tooltip follows mouse cursor in real-time +- **Smart Positioning**: Uses Floating UI's `flip`, `offset`, and `shift` middleware +- **Collision Detection**: Automatically adjusts position to stay on screen +- **Portal Rendering**: Renders outside the chart container to avoid clipping +- **Virtual Element**: Creates a virtual element that tracks mouse position + +### CustomTooltipContent + +A specialized tooltip content component that mirrors ChartTooltipContent but works with the floating tooltip system. + +```tsx +import { CustomTooltipContent } from "./CustomTooltipContent"; + +} />; +``` + +#### Props + +Supports all the same props as `ChartTooltipContent`: + +- `hideLabel` (boolean, optional): Hide the tooltip label +- `hideIndicator` (boolean, optional): Hide the color indicators +- `indicator` ("line" | "dot" | "dashed", optional): Indicator style +- `nameKey` (string, optional): Key for extracting item names +- `labelKey` (string, optional): Key for extracting labels +- `showPercentage` (boolean, optional): Show values as percentages +- `formatter` (function, optional): Custom value formatter +- `labelFormatter` (function, optional): Custom label formatter + +#### Features + +- **Consistent Styling**: Uses the same CSS classes as ChartTooltipContent +- **Full Feature Parity**: Supports all formatting and styling options +- **Floating UI Integration**: Automatically wraps content with FloatingUIPortal +- **Chart Context**: Access to chart configuration and styling + +## Usage with AreaChartV2 + +The floating tooltip is integrated into AreaChartV2 via the `useFloatingTooltip` prop: + +```tsx +import { AreaChartV2 } from "../AreaCharts/AreaChartV2"; + +; +``` + +## Implementation Details + +### Virtual Element + +The FloatingUIPortal creates a virtual element that implements Floating UI's positioning interface: + +```tsx +const virtualElement: VirtualElement = { + getBoundingClientRect(): DOMRect { + return { + width: 0, + height: 0, + x: mousePositionRef.current.x, + y: mousePositionRef.current.y, + top: mousePositionRef.current.y, + left: mousePositionRef.current.x, + right: mousePositionRef.current.x, + bottom: mousePositionRef.current.y, + } as DOMRect; + }, +}; +``` + +### Mouse Tracking + +Mouse position is tracked via global `mousemove` events and stored in a ref for performance: + +```tsx +const handleMouseMove = (event: MouseEvent) => { + mousePositionRef.current = { + x: event.clientX, + y: event.clientY, + }; + updatePosition(); +}; +``` + +### Positioning + +Uses Floating UI's middleware for intelligent positioning: + +```tsx +const { x, y } = await computePosition(virtualElement, tooltipElement, { + placement: "right-start", + middleware: [ + offset(8), // Distance from cursor + flip(), // Flip when hitting boundaries + shift({ padding: 8 }), // Shift to stay in viewport + ], +}); +``` + +## Dependencies + +- `@floating-ui/react-dom`: For positioning calculations +- `react-dom`: For portal functionality +- Existing chart components and styling + +## Browser Support + +Works in all modern browsers that support: + +- React Portals +- CSS Custom Properties +- Mouse Events +- Async/Await + +## Performance + +- Uses `useRef` for mouse position to avoid re-renders +- Debounced position updates via `requestAnimationFrame` +- Minimal DOM manipulation +- Portal rendering prevents layout thrashing diff --git a/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/index.ts b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/index.ts new file mode 100644 index 000000000..23a6bfcaa --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/index.ts @@ -0,0 +1,2 @@ +export { CustomTooltipContent } from "./CustomTooltipContent"; +export { FloatingUIPortal } from "./FloatingUIPortal"; diff --git a/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/portalTooltip.scss b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/portalTooltip.scss new file mode 100644 index 000000000..b20843331 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/portalTooltip.scss @@ -0,0 +1,152 @@ +@use "../../../../cssUtils.scss" as cssUtils; + +.crayon-portal-tooltip { + pointer-events: none; + z-index: 1000; + position: absolute; +} + +.crayon-chart-tooltip { + display: grid; + align-items: start; + min-width: 128px; + max-width: 240px; + gap: cssUtils.$spacing-xs; + padding: cssUtils.$spacing-xs; + color: cssUtils.$primary-text; + @include cssUtils.typography(label, extra-small); + border-radius: cssUtils.$rounded-s; + border: 1px solid cssUtils.$stroke-default; + background-color: cssUtils.$bg-container; + box-shadow: cssUtils.$shadow-s; + text-transform: capitalize; + + &-label { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$primary-text; + } + + &-label-heavy { + @include cssUtils.typography(label, extra-small-heavy); + color: cssUtils.$primary-text; + } + + // this is the content of the tooltip, where the items of the of each data point is rendered, + // todo: remove commented style once the review is done. + + &-content { + display: grid; + align-items: start; + min-width: 128px; + gap: cssUtils.$spacing-xs; + // padding: cssUtils.$spacing-xs; + color: cssUtils.$primary-text; + @include cssUtils.typography(label, extra-small); + background-color: cssUtils.$bg-container; + box-shadow: cssUtils.$shadow-s; + text-transform: capitalize; + + &-item { + display: flex; + width: 100%; + flex-wrap: wrap; + gap: cssUtils.$spacing-xs; + align-items: stretch; + &--dot { + align-items: center; + } + + svg { + height: 10px; + width: 10px; + color: cssUtils.$primary-text; + } + } + // this portion is responsible for styling the colored box or other indicators like line, dashed, etc. + &-indicator { + flex-shrink: 0; + border-radius: cssUtils.$rounded-2xs; + + &--dot { + height: 10px; + width: 10px; + background-color: var(--color-bg); + border-color: var(--color-border); + } + + &--line { + width: 4px; + background-color: var(--color-bg); + border-color: var(--color-border); + } + + &--dashed { + width: 0; + border: 1.5px dashed var(--color-border); + background-color: transparent; + } + + &--nested-dashed { + margin: cssUtils.$spacing-3xs 0; + } + &--two-items { + margin-top: 2px; + } + } + + &-value-wrapper { + display: flex; + flex: 1; + gap: cssUtils.$spacing-s; + justify-content: space-between; + line-height: 1; + + &--nested { + align-items: flex-end; + } + + &--standard { + align-items: center; + } + &--vertical { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: cssUtils.$spacing-2xs; + } + } + + &-label { + display: grid; + gap: cssUtils.$spacing-xs; + color: cssUtils.$secondary-text; + @include cssUtils.typography(label, extra-small); + } + + &-value { + font-variant-numeric: tabular-nums; + color: cssUtils.$primary-text; + @include cssUtils.typography(label, extra-small); + + &--percentage { + padding-left: cssUtils.$spacing-s; + } + } + + &-item-separator { + width: 100%; + height: 1px; + background-color: cssUtils.$stroke-default; + margin: 0; + } + &-item:last-child &-item-separator { + display: none; + } + + &-view-more { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$primary-text; + text-align: left; + } + } +} diff --git a/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/utils/index.ts b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/utils/index.ts new file mode 100644 index 000000000..6dc61a404 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/PortalTooltip/utils/index.ts @@ -0,0 +1,21 @@ +const tooltipNumberFormatter = (value: number) => { + if (value < 100000) { + return value.toLocaleString(); + } + + const units = ["", "K", "M", "B", "T"]; + let unitIndex = 0; + let scaledValue = value; + + while (scaledValue >= 1000 && unitIndex < units.length - 1) { + scaledValue /= 1000; + unitIndex++; + } + + // Format with at most 1 decimal place + const formattedValue = Math.floor(scaledValue * 10) / 10; + + return `${formattedValue}${units[unitIndex]}`; +}; + +export { tooltipNumberFormatter }; diff --git a/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/SideBarTooltip.tsx b/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/SideBarTooltip.tsx new file mode 100644 index 000000000..47075e652 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/SideBarTooltip.tsx @@ -0,0 +1,79 @@ +import { X } from "lucide-react"; +import React, { useCallback, useMemo } from "react"; +import { IconButton } from "../../../IconButton"; +import { useSideBarTooltip } from "../../context/SideBarTooltipContext"; +import { tooltipNumberFormatter } from "../PortalTooltip/utils"; + +interface SideBarTooltipProps { + height: number; +} + +// Utility function for capitalizing strings +const capitalizeString = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +const SideBarTooltip = React.memo(({ height }: SideBarTooltipProps) => { + const { setIsSideBarTooltipOpen, data } = useSideBarTooltip(); + + // if no data, close the tooltip + if (!data) { + setIsSideBarTooltipOpen(false); + return null; + } + + const handleClose = useCallback(() => { + setIsSideBarTooltipOpen(false); + }, [setIsSideBarTooltipOpen]); + + const processedValues = useMemo(() => { + return data.values?.map((value, index) => ({ + ...value, + capitalizedLabel: capitalizeString(value.label), + isLast: index === data.values.length - 1, + })); + }, [data.values]); + + const title = data.title; + + return ( +
+
+
{title}
+ } + size="extra-small" + onClick={handleClose} + variant="secondary" + className="crayon-chart-side-bar-tooltip-close-button" + /> +
+
+
+ {processedValues?.map((value, index) => ( +
+
+
+
+ {value.capitalizedLabel} +
+
+ {tooltipNumberFormatter(value.value)} +
+
+ {!value.isLast && ( +
+ )} +
+ ))} +
+
+ ); +}); + +SideBarTooltip.displayName = "SideBarTooltip"; + +export { SideBarTooltip }; diff --git a/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/index.ts b/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/index.ts new file mode 100644 index 000000000..3c3aa2cec --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/index.ts @@ -0,0 +1 @@ +export * from "./SideBarTooltip"; diff --git a/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/sideBarTooltip.scss b/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/sideBarTooltip.scss new file mode 100644 index 000000000..e8a801f26 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/SideBarTooltip/sideBarTooltip.scss @@ -0,0 +1,102 @@ +@use "../../../../cssUtils.scss" as cssUtils; + +.crayon-chart-side-bar-tooltip { + display: flex; + flex-direction: column; + min-width: 180px; + max-width: 180px; + overflow: hidden; + padding: cssUtils.$spacing-xs; + background-color: cssUtils.$bg-container; + border-radius: cssUtils.$rounded-s; + border: 1px solid cssUtils.$stroke-default; + + &-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-shrink: 0; + } + + &-close-button { + flex-shrink: 0; + } + + &-title { + @include cssUtils.typography(label, 2-extra-small); + } + + &-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + min-height: 0; + &:focus { + outline: none; + } + &:focus-visible { + outline: none; + } + + // Custom scrollbar styling + scrollbar-width: thin; // Firefox - closest option (can't set exact px) + scrollbar-color: cssUtils.$stroke-default transparent; // Firefox - thumb and track colors + + // Custom scrollbar for Webkit browsers (Chrome, Safari, newer Edge) + &::-webkit-scrollbar { + width: 2px; // Track width + margin-right: 2px; // Offset to right + } + + &::-webkit-scrollbar-track { + background: transparent; + border-radius: 0; + margin-right: 2px; // Additional offset for track + } + + &::-webkit-scrollbar-thumb { + background-color: cssUtils.$stroke-default; + border-radius: 1px; + width: 1.5px; // Thumb width (though width is constrained by scrollbar width) + border: 0.25px solid transparent; // Creates the 1.5px effect within 2px track + background-clip: content-box; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: cssUtils.$stroke-default; + } + + &-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: cssUtils.$spacing-xs; + + &-label { + @include cssUtils.typography(label, 2-extra-small); + color: cssUtils.$secondary-text; + flex: 1; + min-width: 0; + } + + &-value { + @include cssUtils.typography(label, 2-extra-small); + flex-shrink: 0; + text-align: right; + } + + &-color { + width: 10px; + height: 10px; + border-radius: cssUtils.$rounded-2xs; + } + &-separator { + width: 100%; + margin: cssUtils.$spacing-xs 0; + height: 1px; + background-color: cssUtils.$stroke-default; + } + } + } +} diff --git a/js/packages/react-ui/src/components/Charts/shared/StackedLegend/StackedLegend.tsx b/js/packages/react-ui/src/components/Charts/shared/StackedLegend/StackedLegend.tsx new file mode 100644 index 000000000..9605934d5 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/StackedLegend/StackedLegend.tsx @@ -0,0 +1,200 @@ +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "../../../Button"; +import { IconButton } from "../../../IconButton"; + +interface LegendItem { + key: string; + label: string; + value: number; + color: string; +} + +interface StackedLegendProps { + items: LegendItem[]; + onItemHover?: (key: string | null) => void; + activeKey?: string | null; + onLegendItemHover?: (index: number | null) => void; + containerWidth?: number; + title?: string; +} + +const formatPercentage = (value: number, total: number): string => { + const percentage = (value / total) * 100; + return `${percentage.toFixed(1)}%`; +}; + +const ITEM_HEIGHT = 36; // Height of each legend item +const ITEM_GAP = 2; // Gap between items +const LEGEND_ITEM_LIMIT = 6; + +export const StackedLegend = ({ + items, + onItemHover, + activeKey, + onLegendItemHover, + containerWidth, +}: StackedLegendProps) => { + const containerRef = useRef(null); + const listRef = useRef(null); + const [showUpButton, setShowUpButton] = useState(false); + const [showDownButton, setShowDownButton] = useState(false); + const [showAll, setShowAll] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + + const isShowMoreLayout = containerWidth !== undefined && items.length > LEGEND_ITEM_LIMIT; + + const handleMouseEnter = (key: string, index: number) => { + onItemHover?.(key); + onLegendItemHover?.(index); + }; + + const handleMouseLeave = () => { + onItemHover?.(null); + onLegendItemHover?.(null); + }; + + // Check if scrolling is needed + useEffect(() => { + const checkScroll = () => { + if (listRef.current && containerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listRef.current; + const overflowing = scrollHeight > clientHeight; + setIsOverflowing(overflowing); + setShowUpButton(scrollTop > 0); + setShowDownButton(scrollTop < scrollHeight - clientHeight - 1); // -1 for rounding errors + } + }; + + if (isShowMoreLayout) { + setShowUpButton(false); + setShowDownButton(false); + return; + } + + // Initial check + checkScroll(); + + // Add event listener for scroll + const currentRef = listRef.current; + if (currentRef) { + currentRef.addEventListener("scroll", checkScroll); + + // Also add resize observer to handle responsive changes + const resizeObserver = new ResizeObserver(checkScroll); + resizeObserver.observe(currentRef); + + return () => { + currentRef.removeEventListener("scroll", checkScroll); + resizeObserver.disconnect(); + }; + } + return () => {}; + }, [isShowMoreLayout]); + + // Scroll functions + const scrollUp = () => { + if (listRef.current) { + // Scroll one item up + listRef.current.scrollBy({ top: -(ITEM_HEIGHT + ITEM_GAP), behavior: "smooth" }); + } + }; + + const scrollDown = () => { + if (listRef.current) { + // Scroll one item down + listRef.current.scrollBy({ top: ITEM_HEIGHT + ITEM_GAP, behavior: "smooth" }); + } + }; + + // Calculate total for percentage + const total = items.reduce((sum, item) => sum + item.value, 0); + + // Sort items by value in descending order (higher to lower) + const sortedItems = [...items].sort((a, b) => b.value - a.value); + + const itemsToDisplay = isShowMoreLayout && !showAll ? sortedItems.slice(0, 6) : sortedItems; + + return ( +
+
+
{items.length} values
+
+ {!isShowMoreLayout && isOverflowing && ( + <> + } + variant="secondary" + size="extra-small" + disabled={!showUpButton} + /> + } + variant="secondary" + size="extra-small" + disabled={!showDownButton} + /> + + )} +
+
+
+ {itemsToDisplay.map((item, index) => ( +
handleMouseEnter(item.key, index)} + onMouseLeave={handleMouseLeave} + > +
+
+
+
+
{item.label}
+
+
+ {formatPercentage(item.value, total)} +
+
+ ))} +
+ {isShowMoreLayout && !showAll && ( + + )} + {isShowMoreLayout && showAll && ( + + )} +
+ ); +}; diff --git a/js/packages/react-ui/src/components/Charts/shared/StackedLegend/stackedLegend.scss b/js/packages/react-ui/src/components/Charts/shared/StackedLegend/stackedLegend.scss new file mode 100644 index 000000000..11ec1894f --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/StackedLegend/stackedLegend.scss @@ -0,0 +1,151 @@ +@use "../../../../cssUtils" as cssUtils; + +.crayon-stacked-legend-container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.crayon-stacked-legend-header { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 cssUtils.$spacing-xs cssUtils.$spacing-xs cssUtils.$spacing-l; + color: cssUtils.$secondary-text; + + &-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: cssUtils.$spacing-xs; + } +} +.crayon-stacked-legend-scroll-button { + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 100; + + &.crayon-stacked-legend-scroll-up { + top: 2px; + } + + &.crayon-stacked-legend-scroll-down { + bottom: 2px; + } +} + +.crayon-stacked-legend { + width: 100%; + display: flex; + flex-direction: column; + min-width: 200px; + overflow-y: scroll; + height: 100%; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-behavior: smooth; + + &::-webkit-scrollbar { + display: none; + } + + &__item { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 4px 8px; + border-radius: cssUtils.$rounded-m; + transition: all 0.2s ease-in-out; + cursor: pointer; + height: 32px; + + &:hover { + background-color: cssUtils.$bg-sunk; + } + + &--active { + background-color: cssUtils.$bg-sunk; + } + + &-label { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + + &-text { + color: cssUtils.$primary-text; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + } + + &-color-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; + height: 36px; + width: 32px; + flex-shrink: 0; + } + + &-color { + width: 10px; + height: 10px; + border-radius: 2px; + transition: all 0.2s ease-in-out; + + .crayon-stacked-legend__item:hover & { + transform: scale(1.2); + } + + .crayon-stacked-legend__item--active & { + transform: scale(1.3); + } + } + + &-label-text { + @include cssUtils.typography(label, default); + color: cssUtils.$primary-text; + transition: color 0.2s ease-in-out; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + &-value { + @include cssUtils.typography(label, default); + color: cssUtils.$primary-text; + transition: all 0.2s ease-in-out; + flex-shrink: 0; + } + } + + &-show-more-button { + width: 100%; + justify-content: center; + } + + &-show-less-button { + width: 100%; + justify-content: center; + } +} diff --git a/js/packages/react-ui/src/components/Charts/shared/XAxisTick/XAxisTick.tsx b/js/packages/react-ui/src/components/Charts/shared/XAxisTick/XAxisTick.tsx new file mode 100644 index 000000000..8a1b40d80 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/XAxisTick/XAxisTick.tsx @@ -0,0 +1,82 @@ +import React from "react"; +interface XAxisTickProps { + x?: number; + y?: number; + payload?: { + value: any; + coordinate?: number; + tickCoord?: number; + index?: number; + offset?: number; + isShow?: boolean; + }; + textAnchor?: "start" | "middle" | "end"; + verticalAnchor?: "start" | "middle" | "end"; + fill?: string; + stroke?: string; + width?: number; + height?: number; + className?: string; + orientation?: "top" | "bottom"; + tickFormatter?: (value: any) => string; + index?: number; + visibleTicksCount?: number; + // Extended props for position-based offset handling + getPositionOffset?: (value: string) => number; + isFirstTick?: (value: string) => boolean; + isLastTick?: (value: string) => boolean; +} + +const XAxisTick = React.forwardRef((props, ref) => { + const { + x, + y, + payload, + textAnchor = "middle", + fill = "#666", + tickFormatter, + className, + getPositionOffset, + isFirstTick, + isLastTick, + } = props; + + const value = String(payload?.value || ""); + const displayValue = tickFormatter ? tickFormatter(payload?.value) : value; + + // Calculate position offset for first and last labels (optional extension) + let xOffset = 0; + if (getPositionOffset) { + xOffset = getPositionOffset(value); + } + + // Optional text anchor adjustment for first and last labels + // if the text need to get adjusted then we can do so from here + let adjustedTextAnchor = textAnchor; + if (isFirstTick && isFirstTick(value)) { + adjustedTextAnchor = "middle"; + } else if (isLastTick && isLastTick(value)) { + adjustedTextAnchor = "middle"; + } + + return ( + + + {value} + {displayValue} + + + ); +}); + +XAxisTick.displayName = "XAxisTick"; + +export { XAxisTick }; +export type { XAxisTickProps }; diff --git a/js/packages/react-ui/src/components/Charts/shared/XAxisTick/xAxisTick.scss b/js/packages/react-ui/src/components/Charts/shared/XAxisTick/xAxisTick.scss new file mode 100644 index 000000000..328931058 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/XAxisTick/xAxisTick.scss @@ -0,0 +1,6 @@ +@use "../../../../cssUtils" as cssUtils; + +.crayon-chart-x-axis-tick { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$secondary-text; +} diff --git a/js/packages/react-ui/src/components/Charts/shared/YAxisTick/YAxisTick.tsx b/js/packages/react-ui/src/components/Charts/shared/YAxisTick/YAxisTick.tsx new file mode 100644 index 000000000..b3d3b1471 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/YAxisTick/YAxisTick.tsx @@ -0,0 +1,47 @@ +interface YAxisTickProps { + x?: number; + y?: number; + payload?: { + value: any; + coordinate?: number; + tickCoord?: number; + index?: number; + offset?: number; + isShow?: boolean; + }; + textAnchor?: "start" | "middle" | "end"; + verticalAnchor?: "start" | "middle" | "end"; + fill?: string; + stroke?: string; + width?: number; + height?: number; + className?: string; + orientation?: "left" | "right"; + tickFormatter?: (value: any) => string; + index?: number; + visibleTicksCount?: number; +} + +const YAxisTick: React.FC = (props) => { + const { x, y, payload, textAnchor, verticalAnchor, tickFormatter, className } = props; + + // Use the provided tickFormatter or fallback to displaying raw value + const displayValue = tickFormatter ? tickFormatter(payload?.value) : String(payload?.value || ""); + + return ( + + + {displayValue} + + + ); +}; + +export { YAxisTick }; +export type { YAxisTickProps }; diff --git a/js/packages/react-ui/src/components/Charts/shared/YAxisTick/yAxisTick.scss b/js/packages/react-ui/src/components/Charts/shared/YAxisTick/yAxisTick.scss new file mode 100644 index 000000000..d6ac4d934 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/YAxisTick/yAxisTick.scss @@ -0,0 +1,6 @@ +@use "../../../../cssUtils" as cssUtils; + +.crayon-chart-y-axis-tick { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$secondary-text; +} diff --git a/js/packages/react-ui/src/components/Charts/shared/index.ts b/js/packages/react-ui/src/components/Charts/shared/index.ts new file mode 100644 index 000000000..8d85cffde --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/shared/index.ts @@ -0,0 +1,8 @@ +export * from "./ActiveDot/ActiveDot"; +export * from "./CartesianGrid/cartesianGrid"; +export * from "./DefaultLegend/DefaultLegend"; +export * from "./PortalTooltip"; +export * from "./SideBarTooltip"; +export * from "./StackedLegend/StackedLegend"; +export * from "./XAxisTick/XAxisTick"; +export * from "./YAxisTick/YAxisTick"; diff --git a/js/packages/react-ui/src/components/Charts/types/Legend.ts b/js/packages/react-ui/src/components/Charts/types/Legend.ts new file mode 100644 index 000000000..75fd94384 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/types/Legend.ts @@ -0,0 +1,6 @@ +export interface LegendItem { + key: string; + label: string; + color: string; + icon?: React.ComponentType; +} diff --git a/js/packages/react-ui/src/components/Charts/types/index.ts b/js/packages/react-ui/src/components/Charts/types/index.ts new file mode 100644 index 000000000..a39cb6790 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/types/index.ts @@ -0,0 +1 @@ +export * from "./Legend"; diff --git a/js/packages/react-ui/src/components/Charts/utils/AreaAndLine/AreaAndLineUtils.ts b/js/packages/react-ui/src/components/Charts/utils/AreaAndLine/AreaAndLineUtils.ts new file mode 100644 index 000000000..fafef7c0d --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/utils/AreaAndLine/AreaAndLineUtils.ts @@ -0,0 +1,245 @@ +// Common utility functions for Area and Line charts +// These functions are chart-type agnostic and can be shared between AreaChartV2 and LineChartV2 + +import { AreaChartData } from "../../AreaChart/types"; +import { LineChartData } from "../../LineChart/types"; + +const ELEMENT_SPACING = 70; + +// Common type for chart data - both AreaChart and LineChart data structures +type ChartData = AreaChartData | LineChartData; + +/** + * AREA CHART AND LINE CHART SPECIFIC FUNCTION + * This function returns the width of the data in the area chart, used for padding calculation, scroll amount calculation, and + * for the width of the chart container. + * @param data - The data to be displayed in the chart. + */ +export const getWidthOfData = (data: ChartData, containerWidth: number) => { + // For area charts, we calculate based on the number of data points (always stacked) + const numberOfElements = data.length; // Number of data points + + let width = numberOfElements * ELEMENT_SPACING - ELEMENT_SPACING; // here we are defining the spacing between the data points, + // as the data point has no width, we are just calculating the spacing between the data points + // if 3 data points, then 2 spaces between them, so 2*70 = 140 + // so the subtraction is to remove the last spacing as number of data points is 1 more than the number of spaces + + // if the container width is greater than the width of the data, then we return the container width + // because the we need the chart minimum width to be the container width + // this decision is made because area chart an bar chart are span from the left to the right of the container + + if (containerWidth >= width) { + return containerWidth; + } + + if (data.length === 1) { + const minSingleDataWidth = 200; // Minimum width for single data points + // self note: + // if the data point is only one, then we need to set the width to the minimum width + + width = Math.max(width, minSingleDataWidth); + } + + return width; +}; + +/** + * SHARED UTILITY FUNCTION + * This function returns the formatter for the X-axis tick values with intelligent truncation. + * This is identical for both AreaChart and LineChart components. + * @param groupWidth - The width available for each group/category (optional) + * @param containerWidth - The total container width for responsive calculations (optional) + * @returns The formatter for the X-axis tick values. + * Internally used by the XAxis component in Recharts + * this function can be improved for coalition detection and better truncation + */ +export const getXAxisTickFormatter = (groupWidth?: number, containerWidth?: number) => { + const PADDING = 10; // More generous padding for visual clarity + + // Setup canvas context once per formatter creation for efficiency. + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context) { + // Should match the chart's actual font for accuracy + context.font = "12px Inter"; + } + + return (value: string) => { + // If canvas isn't supported, or for some reason context is null, return original value. + if (!context) return String(value); + + const stringValue = String(value); + + // Determine the maximum available width for the tick. Prioritize groupWidth. + let availableWidth = 0; + if (groupWidth) { + availableWidth = Math.max(0, groupWidth - PADDING); + } else if (containerWidth) { + // Fallback responsive logic if no groupWidth is available. + // We assume a certain number of ticks could be visible. + // This is less accurate but better than nothing. + const assumedMaxTicks = containerWidth / 100; // e.g., assume ticks are ~100px apart + availableWidth = Math.max(0, containerWidth / assumedMaxTicks - PADDING); + } else { + // No width info at all, perform a simple character slice as a last resort. + return stringValue.length > 10 ? `${stringValue.slice(0, 10)}...` : stringValue; + } + + // If the original text already fits, return it. + if (context.measureText(stringValue).width <= availableWidth) { + return stringValue; + } + + // If it doesn't fit, perform a binary search to find the best truncation point. + let low = 0; + let high = stringValue.length; + let result = ""; + + // binary search to find the best truncation point. + while (low <= high) { + const mid = Math.floor((low + high) / 2); + // Don't append "..." if mid is 0, just return an empty string or first char. + if (mid === 0) { + low = mid + 1; + continue; + } + const truncated = stringValue.substring(0, mid) + "..."; + const measuredWidth = context.measureText(truncated).width; + + if (measuredWidth <= availableWidth) { + result = truncated; + low = mid + 1; + } else { + high = mid - 1; + } + } + + // A final check: if result is empty (very tight space), it might be better + // to show the first character instead of nothing. + if (result === "") { + const firstChar = stringValue.substring(0, 1); + if (context.measureText(firstChar).width <= availableWidth) { + return firstChar; + } + } + + return result; + }; +}; + +/** + * SHARED UTILITY FUNCTION + * This function returns the nearest snap position index for both chart types. + * The implementation is identical for both AreaChart and LineChart. + * @param snapPositions - The snap positions for the chart. + * @param currentScroll - The current scroll of the chart. + * @param direction - The direction of the scroll. + * @returns The nearest snap position index for the chart. + */ +export const findNearestSnapPosition = ( + snapPositions: number[], + currentScroll: number, + direction: "left" | "right", +): number => { + // Find current position index + let currentIndex = 0; + for (let i = 0; i < snapPositions.length; i++) { + const snapPosition = snapPositions[i]; + if (snapPosition !== undefined && currentScroll >= snapPosition) { + currentIndex = i; + } else { + break; + } + } + + if (direction === "left") { + // Go to previous snap position + return Math.max(0, currentIndex - 1); + } else { + // Go to next snap position + return Math.min(snapPositions.length - 1, currentIndex + 1); + } +}; + +/** + * SHARED UTILITY FUNCTION + * This function returns the width of each group/category for both chart types. + * Both AreaChart and LineChart use the same ELEMENT_SPACING. + * @param data - The data to be displayed in the chart. + * @returns The width of each group/category. + */ +export const getWidthOfGroup = (data: ChartData) => { + if (data.length === 0) return 200; // Fallback + + // Both chart types use the same spacing + return ELEMENT_SPACING; +}; + +/** + * SHARED UTILITY FUNCTION + * Helper function to get the optimal X-axis tick formatter with calculated group width. + * This is generic and works for both AreaChart and LineChart data. + * @param data - The chart data + * @param containerWidth - The container width for responsive calculations + * @returns The optimized formatter function + */ +export const getOptimalXAxisTickFormatter = (data: ChartData, containerWidth?: number) => { + // Calculate the available width per group + const groupWidth = getWidthOfGroup(data); + return getXAxisTickFormatter(groupWidth, containerWidth); +}; + +/** + * SHARED UTILITY FUNCTION + * Helper function to get position information for X-axis ticks with offset handling. + * This is generic and works for both AreaChart and LineChart data. + * @param data - The chart data + * @param categoryKey - The category key for the chart + * @returns Object containing position data for the tick renderer + */ +export const getXAxisTickPositionData = (data: ChartData, categoryKey: string) => { + return { + dataLength: data.length, + categoryValues: data.map((item) => String(item[categoryKey])), + getPositionOffset: (value: string): number => { + const index = data.findIndex((item) => String(item[categoryKey]) === value); + if (index === 0) { + // First label: offset to the right by 5px + return 5; + } else if (index === data.length - 1) { + // Last label: offset to the left by 5px + return -5; + } + // Middle labels: no offset + return 0; + }, + isFirstTick: (value: string): boolean => { + const index = data.findIndex((item) => String(item[categoryKey]) === value); + return index === 0; + }, + isLastTick: (value: string): boolean => { + const index = data.findIndex((item) => String(item[categoryKey]) === value); + return index === data.length - 1; + }, + }; +}; + +/** + * SHARED UTILITY FUNCTION + * This function returns the snap positions for both chart types, used for smooth scrolling. + * @param data - The data to be displayed in the chart. + * @returns The snap positions for the chart. + */ +export const getSnapPositions = (data: ChartData): number[] => { + if (data.length === 0) return [0]; + + const positions = [0]; // Start position + const groupWidthValue = getWidthOfGroup(data); + + // Calculate all valid snap positions based on data points + for (let i = 1; i < data.length; i++) { + positions.push(i * groupWidthValue); + } + + return positions; +}; diff --git a/js/packages/react-ui/src/components/Charts/utils/AreaAndLine/MiniAreaAndLineUtils.ts b/js/packages/react-ui/src/components/Charts/utils/AreaAndLine/MiniAreaAndLineUtils.ts new file mode 100644 index 000000000..f4c107ef5 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/utils/AreaAndLine/MiniAreaAndLineUtils.ts @@ -0,0 +1,65 @@ +// Common utility functions for Mini Area and Line charts +// These functions are shared between MiniAreaChart and MiniLineChart components + +import { MiniAreaChartData } from "../../MiniAreaChart/types"; +import { MiniLineChartData } from "../../MiniLineChart/types"; + +// Element spacing constant for both chart types +export const MINI_ELEMENT_SPACING: number = 20; + +type ChartData = Array<{ + value: number; + label: string; +}>; + +// Common type for mini chart data - both area and line use the same structure +export type MiniChartData = MiniAreaChartData | MiniLineChartData; + +/** + * Transforms mini chart data into a standardized format for rendering. + * Handles both numeric values and objects with value/label properties. + * Works for both MiniAreaChart and MiniLineChart components. + * + * @param data - The mini chart data array (can contain numbers or objects with value/label) + * @returns An array of chart data objects with value and label properties + */ +export const transformDataForChart = (data: MiniChartData): ChartData => { + return data.map((item, index) => { + if (typeof item === "number") { + return { value: item, label: `Item ${index + 1}` }; + } else { + return { value: item.value, label: item.label || `Item ${index + 1}` }; + } + }); +}; + +/** + * Filters data to include only the most recent items that can fit within the container width. + * This function ensures the chart displays the latest data when space is limited. + * Works for both MiniAreaChart and MiniLineChart components. + * + * @param data - The complete mini chart data array + * @param containerWidth - The total width of the container in pixels + * @returns A filtered array containing only the most recent data items that fit in the container + */ +export const getRecentDataThatFits = ( + data: MiniChartData, + containerWidth: number, +): MiniChartData => { + if (containerWidth <= 0 || data.length === 0) { + return data; + } + + // Calculate how many items can fit in the available space + const maxItems = Math.floor((containerWidth + 20) / MINI_ELEMENT_SPACING); + // +20 because the element spacing is between so if we have 2 element then its data 20px data + // so we need to add 20px to the container width to get the actual width of the data + + // If all items fit, return all data + if (maxItems >= data.length) { + return data; + } + + // Return the most recent items that fit + return data.slice(-maxItems); +}; diff --git a/js/packages/react-ui/src/components/Charts/utils/PalletUtils.ts b/js/packages/react-ui/src/components/Charts/utils/PalletUtils.ts index e8898f962..75e92693a 100644 --- a/js/packages/react-ui/src/components/Charts/utils/PalletUtils.ts +++ b/js/packages/react-ui/src/components/Charts/utils/PalletUtils.ts @@ -6,6 +6,8 @@ export type ColorPalette = { colors: string[]; }; +export type PaletteName = "ocean" | "orchid" | "emerald" | "spectrum" | "sunset" | "vivid"; + type PaletteMap = Record; const colorPalettes: PaletteMap = { diff --git a/js/packages/react-ui/src/components/Charts/utils/dataUtils.ts b/js/packages/react-ui/src/components/Charts/utils/dataUtils.ts new file mode 100644 index 000000000..c852a68bd --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/utils/dataUtils.ts @@ -0,0 +1,112 @@ +import { ChartConfig } from "../Charts"; +import { PieChartData } from "../PieChart"; +import { RadialChartData } from "../RadialChart"; +import { LegendItem } from "../types"; +import { getDistributedColors, getPalette } from "../utils/PalletUtils"; + +/** + * This function returns the data keys for the chart, used for the data keys of the chart. + * @param data - The data to be displayed in the chart. + * @param categoryKey - The key of the category to be displayed in the chart. + * @returns The data keys for the chart. + */ +export const getDataKeys = ( + data: Array>, + categoryKey: string, +): string[] => { + return Object.keys(data[0] || {}).filter((key) => key !== categoryKey); +}; + +/** + * This function returns the chart configuration object, used for the chart configuration object of the chart. + * @param dataKeys - The data keys for the chart. + * @param colors - The colors for the chart. + * @param transformedKeys - The transformed keys for the chart. + * @param secondaryColors - The secondary colors for the chart (optional). + * @param icons - The icons for the chart (optional). + * @returns The chart configuration object for the chart. + */ +export const get2dChartConfig = ( + dataKeys: string[], + colors: string[], + transformedKeys: Record, + secondaryColors?: string[], + icons?: Partial>, +): ChartConfig => { + return dataKeys.reduce( + (config, key, index) => ({ + ...config, + [key]: { + label: key, + icon: icons?.[key], + color: colors[index], + secondaryColor: secondaryColors?.[index] || colors[dataKeys.length - index - 1], + transformed: transformedKeys[key], + }, + }), + {}, + ); +}; + +type CategoricalChartData = RadialChartData | PieChartData; + +export const getCategoricalChartConfig = ( + data: T, + categoryKey: keyof T[number], + theme: string = "ocean", + transformedKeys: Record, +): ChartConfig => { + const palette = getPalette(theme); + const colors = getDistributedColors(palette, data.length); + + return data.reduce((config, item, index) => { + const originalKey = String(item[categoryKey]); + const transformedKey = `key-${transformedKeys[originalKey] ?? originalKey}`; + return { + ...config, + [transformedKey]: { + label: String(item[categoryKey as string]), + color: colors[index], + secondaryColor: colors[data.length - index - 1], // Add secondary color for gradient effect + }, + }; + }, {}); +}; + +/** + * This function returns the legend items for the chart, used for the legend items of the chart. + * @param dataKeys - The data keys for the chart. + * @param colors - The colors for the chart. + * @param icons - The icons for the chart. + * @returns The legend items for the chart. + */ + +export const getLegendItems = ( + dataKeys: string[], + colors: string[], + icons: Partial>, +): LegendItem[] => { + return dataKeys.map((key, index) => ({ + key, + label: key, + color: colors[index] ?? "#000000", // Fallback color if undefined + icon: icons[key] as React.ComponentType | undefined, + })); +}; + +/** + * This function returns the color value for a specific data key based on its position in the dataKeys array. + * Use this instead of payload.fill to ensure consistent color mapping. + * @param dataKey - The data key to get the color for. + * @param dataKeys - The array of all data keys in the chart. + * @param colors - The array of colors corresponding to the data keys. + * @returns The color value for the specified data key. + */ +export const getColorForDataKey = ( + dataKey: string, + dataKeys: string[], + colors: string[], +): string => { + const index = dataKeys.indexOf(dataKey); + return colors[index] ?? "#000000"; // Fallback color if dataKey not found or color undefined +}; diff --git a/js/packages/react-ui/src/components/Charts/utils/styleUtils.ts b/js/packages/react-ui/src/components/Charts/utils/styleUtils.ts new file mode 100644 index 000000000..dddbf55c2 --- /dev/null +++ b/js/packages/react-ui/src/components/Charts/utils/styleUtils.ts @@ -0,0 +1,28 @@ +/** + * This function returns the formatter for the Y-axis tick values. + * @returns The formatter for the Y-axis tick values. + * internally used by the YAxis component reCharts + */ +const getYAxisTickFormatter = () => { + return (value: any) => { + // Format the Y-axis tick values with abbreviations + if (typeof value === "number") { + const absValue = Math.abs(value); + + if (absValue >= 1e12) { + return (value / 1e12).toFixed(absValue >= 10e12 ? 0 : 1) + "T"; + } else if (absValue >= 1e9) { + return (value / 1e9).toFixed(absValue >= 10e9 ? 0 : 1) + "B"; + } else if (absValue >= 1e6) { + return (value / 1e6).toFixed(absValue >= 10e6 ? 0 : 1) + "M"; + } else if (absValue >= 1e3) { + return (value / 1e3).toFixed(absValue >= 10e3 ? 0 : 1) + "K"; + } else { + return value.toString(); + } + } + return String(value); + }; +}; + +export { getYAxisTickFormatter }; diff --git a/js/packages/react-ui/src/components/Tabs/tabs.scss b/js/packages/react-ui/src/components/Tabs/tabs.scss index 67d0b4063..e2c5777ad 100644 --- a/js/packages/react-ui/src/components/Tabs/tabs.scss +++ b/js/packages/react-ui/src/components/Tabs/tabs.scss @@ -109,7 +109,6 @@ align-items: center; justify-content: center; padding: 0 10px; - filter: blur(0.9); padding: 0 4px 0 0; background-color: cssUtils.$bg-container; } @@ -123,7 +122,6 @@ align-items: center; justify-content: center; padding: 0 10px; - filter: blur(0.9); padding: 0 0 0 4px; background-color: cssUtils.$bg-container; } diff --git a/js/packages/react-ui/src/components/index.scss b/js/packages/react-ui/src/components/index.scss index a3148eeed..01ce5fee5 100644 --- a/js/packages/react-ui/src/components/index.scss +++ b/js/packages/react-ui/src/components/index.scss @@ -6,7 +6,6 @@ @forward "./Card/card.scss"; @forward "./CardHeader/cardHeader.scss"; @forward "./Carousel/carousel.scss"; -@forward "./Charts/charts.scss"; @forward "./CheckBoxGroup/checkBoxGroup.scss"; @forward "./CheckBoxItem/checkBoxItem.scss"; @forward "./CodeBlock/codeBlock.scss"; @@ -42,3 +41,4 @@ @forward "./ToggleGroup/toggleGroup.scss"; @forward "./ToggleItem/toggleItem.scss"; @forward "./TextCallout/textCallout.scss"; +@forward "./Charts/charts.scss"; diff --git a/js/packages/react-ui/src/cssUtils.scss b/js/packages/react-ui/src/cssUtils.scss index d309f0d8b..a327ad9a6 100644 --- a/js/packages/react-ui/src/cssUtils.scss +++ b/js/packages/react-ui/src/cssUtils.scss @@ -143,14 +143,14 @@ $typography: ( label: ( default: typography(crayon-font-label), heavy: typography(crayon-font-label-heavy), - small: typography(crayon-font-label-small), - small-heavy: typography(crayon-font-label-small-heavy), - extra-small: typography(crayon-font-label-extra-small), - extra-small-heavy: typography(crayon-font-label-extra-small-heavy), large: typography(crayon-font-label-large), large-heavy: typography(crayon-font-label-large-heavy), medium: typography(crayon-font-label-medium), medium-heavy: typography(crayon-font-label-medium-heavy), + small: typography(crayon-font-label-small), + small-heavy: typography(crayon-font-label-small-heavy), + extra-small: typography(crayon-font-label-extra-small), + extra-small-heavy: typography(crayon-font-label-extra-small-heavy), 2-extra-small: typography(crayon-font-label-2-extra-small), 2-extra-small-heavy: typography(crayon-font-label-2-extra-small-heavy), ), diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index cad532b74..34977c713 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: ^15.6.1 version: 15.6.1(react@19.1.0) recharts: - specifier: ^2.15.1 - version: 2.15.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^2.15.4 + version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -205,9 +205,6 @@ importers: '@storybook/addon-interactions': specifier: ^8.5.3 version: 8.6.14(storybook@8.6.14(prettier@3.5.3)) - '@storybook/addon-onboarding': - specifier: ^8.5.3 - version: 8.6.14(storybook@8.6.14(prettier@3.5.3)) '@storybook/addon-styling-webpack': specifier: ^1.0.1 version: 1.0.1(storybook@8.6.14(prettier@3.5.3))(webpack@5.99.9(esbuild@0.25.5)) @@ -1553,11 +1550,6 @@ packages: peerDependencies: storybook: ^8.6.14 - '@storybook/addon-onboarding@8.6.14': - resolution: {integrity: sha512-bHdHiGJFigVcSzMIsNLHY5IODZHr+nKwyz5/QOZLMkLcGH2IaUbOJfm4RyGOaTTPsUtAKbdsVXNEG3Otf+qO9A==} - peerDependencies: - storybook: ^8.6.14 - '@storybook/addon-outline@8.6.14': resolution: {integrity: sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==} peerDependencies: @@ -3514,8 +3506,8 @@ packages: recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - recharts@2.15.3: - resolution: {integrity: sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==} + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5113,10 +5105,6 @@ snapshots: storybook: 8.6.14(prettier@3.5.3) tiny-invariant: 1.3.3 - '@storybook/addon-onboarding@8.6.14(storybook@8.6.14(prettier@3.5.3))': - dependencies: - storybook: 8.6.14(prettier@3.5.3) - '@storybook/addon-outline@8.6.14(storybook@8.6.14(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 @@ -7396,7 +7384,7 @@ snapshots: dependencies: decimal.js-light: 2.5.1 - recharts@2.15.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + recharts@2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: clsx: 2.1.1 eventemitter3: 4.0.7