diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index 6139a942f..e0ae84a67 100644 --- a/apps/docs/docgen.config.js +++ b/apps/docs/docgen.config.js @@ -74,7 +74,9 @@ module.exports = { 'chart/area/AreaChart', 'chart/bar/BarChart', 'chart/CartesianChart', + 'chart/ChartTooltip', 'chart/DonutChart', + 'chart/legend/Legend', 'chart/line/LineChart', 'chart/pie/PieChart', 'chart/PolarChart', diff --git a/apps/docs/docs/components/graphs/ChartTooltip/_webExamples.mdx b/apps/docs/docs/components/graphs/ChartTooltip/_webExamples.mdx new file mode 100644 index 000000000..d07431ca1 --- /dev/null +++ b/apps/docs/docs/components/graphs/ChartTooltip/_webExamples.mdx @@ -0,0 +1,404 @@ +ChartTooltip displays series data at the current scrubber position in a floating tooltip. It automatically shows all series values, supports custom formatting, and positions itself near the cursor. + +## Basic Usage + +Add ChartTooltip as a child of any chart with `enableScrubbing` to display values when hovering. The tooltip label defaults to the x-axis value. + +```jsx live +function BasicTooltip() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const revenue = [120, 150, 180, 165, 190, 210]; + const expenses = [80, 95, 110, 105, 120, 130]; + + return ( + + + + + ); +} +``` + +## Value Formatter + +Use the `valueFormatter` prop to customize how values are displayed. The formatter receives the numeric value and should return a string or ReactNode. + +```jsx live +function ValueFormatterTooltip() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const sales = [45000, 52000, 48000, 61000, 55000, 67000]; + const profit = [12000, 15000, 11000, 18000, 14000, 21000]; + + const currencyFormatter = useCallback( + (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + + + + ); +} +``` + +## Custom Label + +Use the `label` prop to customize the tooltip header. It can be a static string, ReactNode, or a function that receives the data index. + +```jsx live +function CustomLabelTooltip() { + const dates = ['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06']; + const displayDates = [ + 'January 2024', + 'February 2024', + 'March 2024', + 'April 2024', + 'May 2024', + 'June 2024', + ]; + const temperature = [32, 35, 45, 58, 68, 78]; + const humidity = [65, 60, 55, 50, 55, 60]; + + return ( + + + ( + + {displayDates[dataIndex]} + + )} + /> + + ); +} +``` + +## Filtered Series + +Use the `seriesIds` prop to show only specific series in the tooltip. This is useful when you want to hide certain series from the tooltip while still displaying them on the chart. + +```jsx live +function FilteredSeriesTooltip() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const primary = [100, 120, 140, 130, 150, 170]; + const secondary = [60, 70, 80, 75, 85, 95]; + const tertiary = [30, 35, 40, 38, 42, 48]; + + return ( + + + + + ); +} +``` + +## With Bar Chart + +ChartTooltip works with bar charts and other chart types. + +```jsx live +function BarChartTooltip() { + const categories = ['Q1', 'Q2', 'Q3', 'Q4']; + const revenue = [250, 320, 280, 410]; + const costs = [180, 220, 200, 280]; + + const numberFormatter = useCallback((value) => `$${value}k`, []); + + return ( + + + + + + + ); +} +``` + +## Stacked Area Tooltip + +When used with stacked charts, ChartTooltip shows individual series values rather than cumulative values. + +```jsx live +function StackedAreaTooltip() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug']; + const desktop = [4000, 4200, 3800, 4500, 4800, 5200, 5000, 5500]; + const mobile = [2400, 2800, 3000, 3200, 3500, 3800, 4000, 4200]; + const tablet = [1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]; + + const numberFormatter = useCallback((value) => value.toLocaleString(), []); + + return ( + + + + + ); +} +``` + +## Custom Value Display + +The `valueFormatter` can return a ReactNode for rich value display, including icons, colors, and additional information. + +```jsx live +function CustomValueDisplayTooltip() { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const steps = [8500, 12000, 7200, 9800, 11500, 15000, 6500]; + const goal = 10000; + + const stepsFormatter = useCallback((value) => { + const percentage = Math.round((value / goal) * 100); + const isGoalMet = value >= goal; + return ( + + + {value.toLocaleString()} steps + + + ({percentage}%) + + + ); + }, []); + + return ( + + + + + ); +} +``` + +## Multi-Axis Tooltip + +ChartTooltip works with charts that have multiple y-axes, showing values from all series. + +```jsx live +function MultiAxisTooltip() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const revenue = [455, 520, 380, 455, 285, 235]; + const profitMargin = [23, 20, 16, 38, 12, 9]; + + return ( + + + `$${value}k`} + /> + `${value}%`} + /> + + { + return value < 100 ? `${value}%` : `$${value}k`; + }} + /> + + ); +} +``` + +## With Legend + +ChartTooltip can be combined with Legend to provide both persistent series identification and on-hover details. + +```jsx live +function TooltipWithLegend() { + const precipitationData = [ + { + id: 'northeast', + label: 'Northeast', + data: [5.14, 1.53, 5.73, 4.29, 3.78, 3.92, 4.19, 5.54, 2.03, 1.42, 2.95, 3.89], + color: 'rgb(var(--blue40))', + }, + { + id: 'upperMidwest', + label: 'Upper Midwest', + data: [1.44, 0.49, 2.16, 3.67, 5.44, 6.21, 4.02, 3.67, 0.92, 1.47, 3.05, 1.48], + color: 'rgb(var(--green40))', + }, + { + id: 'southwest', + label: 'Southwest', + data: [1.12, 1.5, 1.52, 0.75, 0.76, 1.27, 1.44, 2.01, 0.62, 1.08, 1.23, 0.25], + color: 'rgb(var(--purple40))', + }, + ]; + + const xAxisData = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + return ( + + + 2024 Precipitation by Climate Region + + + + `${value} in`} /> + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/ChartTooltip/_webPropsTable.mdx b/apps/docs/docs/components/graphs/ChartTooltip/_webPropsTable.mdx new file mode 100644 index 000000000..e17542521 --- /dev/null +++ b/apps/docs/docs/components/graphs/ChartTooltip/_webPropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import webPropsData from ':docgen/web-visualization/chart/ChartTooltip/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/ChartTooltip/index.mdx b/apps/docs/docs/components/graphs/ChartTooltip/index.mdx new file mode 100644 index 000000000..8e8549f09 --- /dev/null +++ b/apps/docs/docs/components/graphs/ChartTooltip/index.mdx @@ -0,0 +1,27 @@ +--- +id: chartTooltip +title: ChartTooltip +platform_switcher_options: { web: true, mobile: false } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/ChartTooltip/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import webMetadata from './webMetadata.json'; + + + + } + webExamples={} + webExamplesToc={webExamplesToc} + webPropsToc={webPropsToc} + /> + diff --git a/apps/docs/docs/components/graphs/ChartTooltip/webMetadata.json b/apps/docs/docs/components/graphs/ChartTooltip/webMetadata.json new file mode 100644 index 000000000..6efbcabee --- /dev/null +++ b/apps/docs/docs/components/graphs/ChartTooltip/webMetadata.json @@ -0,0 +1,29 @@ +{ + "import": "import { ChartTooltip } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/ChartTooltip.tsx", + "description": "A tooltip component for displaying series data at the current scrubber position. Supports custom labels, value formatting, and series filtering.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/graphs/CartesianChart/" + }, + { + "label": "Legend", + "url": "/components/graphs/Legend/" + }, + { + "label": "LineChart", + "url": "/components/graphs/LineChart/" + }, + { + "label": "Scrubber", + "url": "/components/graphs/Scrubber/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx b/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx new file mode 100644 index 000000000..128fc0019 --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx @@ -0,0 +1,296 @@ +Legend displays series information for charts, showing labels and color indicators for each data series. It can be positioned around the chart and supports custom shapes and item components. + +## Basic Usage + +Use the `legend` prop on chart components to enable a default legend, or pass a `Legend` component for customization. + +```tsx +function BasicLegend() { + const theme = useTheme(); + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + return ( + + + + ); +} +``` + +## Shape Variants + +Legend supports different shape variants: `pill`, `circle`, `square`, and `squircle`. Set the shape on each series using `legendShape`. + +```tsx +function ShapeVariants() { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + } + legendPosition="bottom" + series={[ + { + id: 'pill', + label: 'Pill', + data: [120, 150, 130, 170, 160, 190], + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'pill', + }, + { + id: 'circle', + label: 'Circle', + data: [80, 110, 95, 125, 115, 140], + color: `rgb(${theme.spectrum.green40})`, + legendShape: 'circle', + }, + { + id: 'square', + label: 'Square', + data: [60, 85, 70, 100, 90, 115], + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'square', + }, + { + id: 'squircle', + label: 'Squircle', + data: [40, 60, 50, 75, 65, 85], + color: `rgb(${theme.spectrum.purple40})`, + legendShape: 'squircle', + }, + ]} + width="100%" + xAxis={{ data: months }} + /> + + ); +} +``` + +## Layout Direction + +Use the `direction` prop to arrange legend items in a row or column. + +```tsx +function LayoutDirection() { + const theme = useTheme(); + + const series = [ + { + id: 'stocks', + data: 45, + label: 'Stocks', + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'circle' as const, + }, + { + id: 'bonds', + data: 25, + label: 'Bonds', + color: `rgb(${theme.spectrum.green40})`, + legendShape: 'circle' as const, + }, + { + id: 'realEstate', + data: 15, + label: 'Real Estate', + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'circle' as const, + }, + { + id: 'commodities', + data: 10, + label: 'Commodities', + color: `rgb(${theme.spectrum.purple40})`, + legendShape: 'circle' as const, + }, + { + id: 'cash', + data: 5, + label: 'Cash', + color: `rgb(${theme.spectrum.gray40})`, + legendShape: 'circle' as const, + }, + ]; + + return ( + + Portfolio Allocation + } + legendPosition="right" + series={series} + width={300} + /> + + ); +} +``` + +## Donut Chart Legend + +Legend works with donut and pie charts to show segment information. + +```tsx +function DonutChartLegend() { + const theme = useTheme(); + + const series = [ + { + id: 'completed', + data: 68, + label: 'Completed', + color: theme.color.fgPositive, + legendShape: 'squircle' as const, + }, + { + id: 'inProgress', + data: 22, + label: 'In Progress', + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'squircle' as const, + }, + { + id: 'pending', + data: 10, + label: 'Pending', + color: `rgb(${theme.spectrum.gray40})`, + legendShape: 'squircle' as const, + }, + ]; + + return ( + + Task Status + + + ); +} +``` + +## Custom Legend Item + +Use `ItemComponent` to render custom legend items with additional information or interactions. + +```tsx +function CustomLegendItem() { + const theme = useTheme(); + + const CustomItem = memo(function CustomItem({ label, color, shape }) { + return ( + + + {label} + 100% + + ); + }); + + return ( + + Custom Legend Item + } + legendPosition="bottom" + series={[ + { + id: 'btc', + data: 60, + label: 'Bitcoin', + color: `rgb(${theme.spectrum.orange40})`, + }, + { + id: 'eth', + data: 30, + label: 'Ethereum', + color: `rgb(${theme.spectrum.purple40})`, + }, + { + id: 'other', + data: 10, + label: 'Other', + color: `rgb(${theme.spectrum.gray40})`, + }, + ]} + width={150} + /> + + ); +} +``` + +## DefaultLegendShape + +You can use `DefaultLegendShape` directly to create custom legend layouts or displays. + +```tsx +function DefaultShapes() { + const theme = useTheme(); + + const spectrumColors = [ + 'blue40', + 'green40', + 'orange40', + 'yellow40', + 'gray40', + 'indigo40', + 'pink40', + 'purple40', + 'red40', + 'teal40', + ]; + + const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square']; + + return ( + + {shapes.map((shape) => ( + + {spectrumColors.map((color) => ( + + + + ))} + + ))} + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx b/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx new file mode 100644 index 000000000..60808a42b --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import mobilePropsData from ':docgen/mobile-visualization/chart/legend/Legend/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx new file mode 100644 index 000000000..2c97726e6 --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx @@ -0,0 +1,655 @@ +Legend displays series information for charts, showing labels and color indicators for each data series. It can be positioned around the chart and supports custom shapes and item components. + +## Basic Usage + +Use the `legend` prop on chart components to enable a default legend, or pass a `Legend` component for customization. + +```jsx live +function BasicLegend() { + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const numberFormatter = useCallback( + (value) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + + + ); +} +``` + +## Legend Position + +Use `legendPosition` to place the legend at different positions around the chart. You can also customize alignment using the `justifyContent` prop on Legend. + +```jsx live +function LegendPosition() { + return ( + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: [455, 520, 380, 455, 285, 235], + yAxisId: 'revenue', + color: 'rgb(var(--yellow40))', + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: [23, 20, 16, 38, 12, 9], + yAxisId: 'profitMargin', + color: 'var(--color-fgPositive)', + legendShape: 'squircle', + }, + ]} + xAxis={{ + data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + scaleType: 'band', + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + ); +} +``` + +## Shape Variants + +Legend supports different shape variants: `pill`, `circle`, `square`, and `squircle`. Set the shape on each series using `legendShape`. + +```jsx live +function ShapeVariants() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +} +``` + +## Layout Direction + +Use the `direction` prop to arrange legend items in a row or column. + +```jsx live +function LayoutDirection() { + const series = [ + { + id: 'stocks', + data: 45, + label: 'Stocks', + color: 'rgb(var(--blue40))', + legendShape: 'circle', + }, + { + id: 'bonds', + data: 25, + label: 'Bonds', + color: 'rgb(var(--green40))', + legendShape: 'circle', + }, + { + id: 'realEstate', + data: 15, + label: 'Real Estate', + color: 'rgb(var(--orange40))', + legendShape: 'circle', + }, + { + id: 'commodities', + data: 10, + label: 'Commodities', + color: 'rgb(var(--purple40))', + legendShape: 'circle', + }, + { + id: 'cash', + data: 5, + label: 'Cash', + color: 'rgb(var(--gray40))', + legendShape: 'circle', + }, + ]; + + return ( + + + Portfolio Allocation + + + + ); +} +``` + +## Custom Legend Item + +Use `ItemComponent` to render custom legend items with additional information or interactions. + +```jsx live +function CustomLegendItem() { + const timeLabels = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const series = [ + { + id: 'candidate-a', + label: 'Candidate A', + data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38], + color: 'rgb(var(--blue40))', + legendShape: 'circle', + }, + { + id: 'candidate-b', + label: 'Candidate B', + data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35], + color: 'rgb(var(--orange40))', + legendShape: 'circle', + }, + { + id: 'candidate-c', + label: 'Candidate C', + data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27], + color: 'rgb(var(--gray40))', + legendShape: 'circle', + }, + ]; + + const ValueLegendItem = memo(function ValueLegendItem({ seriesId, label, color, shape }) { + const { scrubberPosition } = useScrubberContext(); + const { series, dataLength } = useCartesianChartContext(); + + const dataIndex = scrubberPosition ?? dataLength - 1; + + const seriesData = series.find((s) => s.id === seriesId); + const rawValue = seriesData?.data?.[dataIndex]; + + const formattedValue = + rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue)}%`; + + return ( + + + {label} + + {formattedValue} + + + ); + }); + + return ( + + + Election Polls + + } + legendPosition="top" + series={series} + xAxis={{ + data: timeLabels, + }} + yAxis={{ + domain: { max: 100, min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `${value}%`, + }} + > + + + + ); +} +``` + +## Interactive Legend + +Create interactive legends by using custom item components with state management. + +```jsx live +function InteractiveLegend() { + const [emphasizedId, setEmphasizedId] = useState(null); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'revenue', + label: 'Revenue', + data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350], + baseColor: '--blue', + }, + { + id: 'expenses', + label: 'Expenses', + data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195], + baseColor: '--orange', + }, + { + id: 'profit', + label: 'Profit', + data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155], + baseColor: '--green', + }, + ], + [], + ); + + const handleToggle = useCallback((seriesId) => { + setEmphasizedId((prev) => (prev === seriesId ? null : seriesId)); + }, []); + + const ChipLegendItem = memo(function ChipLegendItem({ seriesId, label }) { + const isEmphasized = emphasizedId === seriesId; + const config = seriesConfig.find((s) => s.id === seriesId); + const baseColor = config?.baseColor ?? '--gray'; + + return ( + handleToggle(seriesId)} + style={{ + backgroundColor: `rgb(var(${baseColor}10))`, + borderWidth: 0, + color: 'var(--color-fg)', + outlineColor: `rgb(var(${baseColor}50))`, + }} + > + + + {label} + + + ); + }); + + const series = useMemo(() => { + return seriesConfig.map((config) => { + const isEmphasized = emphasizedId === config.id; + const isDimmed = emphasizedId !== null && !isEmphasized; + + return { + id: config.id, + label: config.label, + data: config.data, + color: `rgb(var(${config.baseColor}40))`, + opacity: isDimmed ? 0.3 : 1, + }; + }); + }, [emphasizedId, seriesConfig]); + + return ( + + + Financial Overview + + } + legendPosition="top" + series={series} + xAxis={{ + data: months, + }} + yAxis={{ + domain: { min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `$${value}k`, + }} + /> + + ); +} +``` + +## Donut Chart Legend + +Legend works with donut and pie charts to show segment information. + +```jsx live +function DonutChartLegend() { + const series = [ + { + id: 'completed', + data: 68, + label: 'Completed', + color: 'var(--color-fgPositive)', + legendShape: 'squircle', + }, + { + id: 'inProgress', + data: 22, + label: 'In Progress', + color: 'rgb(var(--blue40))', + legendShape: 'squircle', + }, + { + id: 'pending', + data: 10, + label: 'Pending', + color: 'rgb(var(--gray40))', + legendShape: 'squircle', + }, + ]; + + return ( + + + Task Status + + + + ); +} +``` + +## Custom Legend Shapes + +You can pass a custom ReactNode as `legendShape` for fully custom indicators. + +```jsx live +function CustomLegendShapes () { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Actual revenue (first 9 months) + const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null]; + + // Forecasted revenue (last 3 months) + const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680]; + + const numberFormatter = useCallback( + (value: number) => + `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`, + [], + ); + + // Pattern settings for dotted fill + const patternSize = 4; + const dotSize = 1; + const patternId = useId(); + const maskId = useId(); + const legendPatternId = useId(); + + // Custom legend indicator that matches the dotted bar pattern + const DottedLegendIndicator = ( + + + + + + + + + + + + + + + ); + + // Custom bar component that renders bars with dotted pattern fill + const DottedBarComponent = memo((props) => { + const { dataX, x, y } = props; + // Create unique IDs per bar so patterns are scoped to each bar + const uniqueMaskId = `${maskId}-${dataX}`; + const uniquePatternId = `${patternId}-${dataX}`; + return ( + <> + + {/* Pattern positioned relative to this bar's origin */} + + + + + + + + + + + + + ); + }); + + return ( + + ); +}; +``` diff --git a/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx b/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx new file mode 100644 index 000000000..7b21df026 --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import webPropsData from ':docgen/web-visualization/chart/legend/Legend/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/Legend/index.mdx b/apps/docs/docs/components/graphs/Legend/index.mdx new file mode 100644 index 000000000..4969ce0b7 --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/index.mdx @@ -0,0 +1,35 @@ +--- +id: legend +title: Legend +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/legend/Legend/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/legend/Legend/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/graphs/Legend/mobileMetadata.json b/apps/docs/docs/components/graphs/Legend/mobileMetadata.json new file mode 100644 index 000000000..55826f201 --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/mobileMetadata.json @@ -0,0 +1,37 @@ +{ + "import": "import { Legend } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/legend/Legend.tsx", + "description": "A legend component for displaying series information in charts. Supports customizable shapes, layouts, and custom item components.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/graphs/CartesianChart/" + }, + { + "label": "DonutChart", + "url": "/components/graphs/DonutChart/" + }, + { + "label": "LineChart", + "url": "/components/graphs/LineChart/" + }, + { + "label": "PieChart", + "url": "/components/graphs/PieChart/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/Legend/webMetadata.json b/apps/docs/docs/components/graphs/Legend/webMetadata.json new file mode 100644 index 000000000..9c9246ff4 --- /dev/null +++ b/apps/docs/docs/components/graphs/Legend/webMetadata.json @@ -0,0 +1,33 @@ +{ + "import": "import { Legend } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/legend/Legend.tsx", + "description": "A legend component for displaying series information in charts. Supports customizable shapes, layouts, and custom item components.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/graphs/CartesianChart/" + }, + { + "label": "ChartTooltip", + "url": "/components/graphs/ChartTooltip/" + }, + { + "label": "DonutChart", + "url": "/components/graphs/DonutChart/" + }, + { + "label": "LineChart", + "url": "/components/graphs/LineChart/" + }, + { + "label": "PieChart", + "url": "/components/graphs/PieChart/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index ce48fdc1a..e0c5a8c2c 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -603,11 +603,21 @@ const sidebars: SidebarsConfig = { id: 'components/graphs/CartesianChart/cartesianChart', label: 'CartesianChart', }, + { + type: 'doc', + id: 'components/graphs/ChartTooltip/chartTooltip', + label: 'ChartTooltip', + }, { type: 'doc', id: 'components/graphs/DonutChart/donutChart', label: 'DonutChart', }, + { + type: 'doc', + id: 'components/graphs/Legend/legend', + label: 'Legend', + }, { type: 'doc', id: 'components/graphs/LineChart/lineChart', diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index 293096742..0b3492e3a 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -303,6 +303,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 293096742..0b3492e3a 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -303,6 +303,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 1d3e957d2..c6c312594 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -1,11 +1,12 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; -import { type StyleProp, type View, type ViewStyle } from 'react-native'; +import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; +import { type LayoutChangeEvent, type StyleProp, type View, type ViewStyle } from 'react-native'; import type { Rect } from '@coinbase/cds-common/types'; import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; +import { Legend } from './legend/Legend'; import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; @@ -28,17 +29,23 @@ import { useTotalAxisPadding, } from './utils'; -const ChartCanvas = memo( - ({ children, style }: { children: React.ReactNode; style?: StyleProp }) => { - const ContextBridge = useChartContextBridge(); +type ChartCanvasProps = { + children: React.ReactNode; + style?: StyleProp; + onLayout?: (event: LayoutChangeEvent) => void; +}; - return ( - - {children} - - ); - }, -); +const ChartCanvas = memo(({ children, style, onLayout }: ChartCanvasProps) => { + const ContextBridge = useChartContextBridge(); + + return ( + + {children} + + ); +}); + +export type LegendPosition = 'top' | 'bottom' | 'left' | 'right'; export type CartesianChartBaseProps = Omit & Pick & { @@ -64,6 +71,18 @@ export type CartesianChartBaseProps = Omit & * Inset around the entire chart (outside the axes). */ inset?: number | Partial; + /** + * Whether to show a legend, or a custom legend element. + * When `true`, renders the default Legend component. + * When a ReactNode, renders the provided element. + * @default false + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; }; export type CartesianChartProps = CartesianChartBaseProps & @@ -97,6 +116,11 @@ export type CartesianChartProps = CartesianChartBaseProps & * Custom styles for the chart canvas element. */ chart?: StyleProp; + /** + * Custom styles for the legend element. + * @note not used when legend is a ReactNode. + */ + legend?: StyleProp; }; }; @@ -112,6 +136,8 @@ export const CartesianChart = memo( yAxis: yAxisConfigProp, inset, onScrubberPositionChange, + legend, + legendPosition = 'bottom', width = '100%', height = '100%', style, @@ -386,8 +412,11 @@ export const CartesianChart = memo( return Skia.TypefaceFontProvider.Make(); }, [fontProviderProp]); + const chartRef = useRef(null); + const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, @@ -407,6 +436,7 @@ export const CartesianChart = memo( registerAxis, unregisterAxis, getAxisBounds, + ref: chartRef, }), [ series, @@ -428,12 +458,28 @@ export const CartesianChart = memo( registerAxis, unregisterAxis, getAxisBounds, + chartRef, ], ); - const rootStyles = useMemo(() => { - return [style, styles?.root]; - }, [style, styles?.root]); + const isVerticalLegend = legendPosition === 'top' || legendPosition === 'bottom'; + const isLegendBefore = legendPosition === 'top' || legendPosition === 'left'; + + const legendElement = useMemo(() => { + if (!legend) return; + if (typeof legend !== 'boolean') return legend; + return ( + + ); + }, [legend, isVerticalLegend, styles?.legend]); + + const rootStyles = useMemo(() => { + return [ + { flexDirection: isVerticalLegend ? 'column' : 'row' } as ViewStyle, + style as ViewStyle, + styles?.root as ViewStyle, + ].filter(Boolean); + }, [isVerticalLegend, style, styles?.root]); return ( @@ -443,17 +489,29 @@ export const CartesianChart = memo( onScrubberPositionChange={onScrubberPositionChange} > { + chartRef.current = node; + if (ref) { + if (typeof ref === 'function') { + ref(node); + } else { + ref.current = node; + } + } + }} accessibilityLiveRegion="polite" accessibilityRole="image" collapsable={collapsable} height={height} - onLayout={onContainerLayout} style={rootStyles} width={width} {...props} > - {children} + {isLegendBefore && legendElement} + + {children} + + {!isLegendBefore && legendElement} diff --git a/packages/mobile-visualization/src/chart/PolarChart.tsx b/packages/mobile-visualization/src/chart/PolarChart.tsx index b87aea4a1..ddaf8508c 100644 --- a/packages/mobile-visualization/src/chart/PolarChart.tsx +++ b/packages/mobile-visualization/src/chart/PolarChart.tsx @@ -1,12 +1,14 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; -import { type StyleProp, type View, type ViewStyle } from 'react-native'; +import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; +import { type LayoutChangeEvent, type StyleProp, type View, type ViewStyle } from 'react-native'; import type { Rect } from '@coinbase/cds-common/types'; import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; +import { Legend } from './legend/Legend'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; +import type { LegendPosition } from './CartesianChart'; import { useChartContextBridge } from './ChartContextBridge'; import { PolarChartProvider } from './ChartProvider'; import { @@ -28,17 +30,21 @@ import { type RadialAxisConfigProps, } from './utils'; -const ChartCanvas = memo( - ({ children, style }: { children: React.ReactNode; style?: StyleProp }) => { - const ContextBridge = useChartContextBridge(); +type ChartCanvasProps = { + children: React.ReactNode; + style?: StyleProp; + onLayout?: (event: LayoutChangeEvent) => void; +}; - return ( - - {children} - - ); - }, -); +const ChartCanvas = memo(({ children, style, onLayout }: ChartCanvasProps) => { + const ContextBridge = useChartContextBridge(); + + return ( + + {children} + + ); +}); export type PolarChartBaseProps = Omit & { /** @@ -113,6 +119,18 @@ export type PolarChartBaseProps = Omit & { * Inset around the entire chart (outside the drawing area). */ inset?: number | Partial; + /** + * Whether to show a legend, or a custom legend element. + * When `true`, renders the default Legend component. + * When a ReactNode, renders the provided element. + * @default false + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; }; export type PolarChartProps = PolarChartBaseProps & @@ -145,6 +163,11 @@ export type PolarChartProps = PolarChartBaseProps & * Custom styles for the chart canvas element. */ chart?: StyleProp; + /** + * Custom styles for the legend element. + * @note not used when legend is a ReactNode. + */ + legend?: StyleProp; }; }; @@ -162,6 +185,8 @@ export const PolarChart = memo( angularAxis, radialAxis, inset: insetInput, + legend, + legendPosition = 'bottom', width = '100%', height = '100%', style, @@ -370,8 +395,11 @@ export const PolarChart = memo( return Skia.TypefaceFontProvider.Make(); }, [fontProviderProp]); + const chartRef = useRef(null); + const contextValue: PolarChartContextValue = useMemo( () => ({ + type: 'polar', series: series ?? [], getSeries, getSeriesData, @@ -389,6 +417,7 @@ export const PolarChart = memo( getAngularSerializableScale, getRadialSerializableScale, dataLength, + ref: chartRef, }), [ series, @@ -408,27 +437,55 @@ export const PolarChart = memo( getAngularSerializableScale, getRadialSerializableScale, dataLength, + chartRef, ], ); - const rootStyles = useMemo(() => { - return [style, styles?.root]; - }, [style, styles?.root]); + const isVerticalLegend = legendPosition === 'top' || legendPosition === 'bottom'; + const isLegendBefore = legendPosition === 'top' || legendPosition === 'left'; + + const legendElement = useMemo(() => { + if (!legend) return; + if (typeof legend !== 'boolean') return legend; + return ( + + ); + }, [legend, isVerticalLegend, styles?.legend]); + + const rootStyles = useMemo(() => { + return [ + { flexDirection: isVerticalLegend ? 'column' : 'row' } as ViewStyle, + style as ViewStyle, + styles?.root as ViewStyle, + ].filter(Boolean); + }, [isVerticalLegend, style, styles?.root]); return ( { + chartRef.current = node; + if (ref) { + if (typeof ref === 'function') { + ref(node); + } else { + ref.current = node; + } + } + }} accessibilityLiveRegion="polite" accessibilityRole="image" collapsable={collapsable} height={height} - onLayout={onContainerLayout} style={rootStyles} width={width} {...props} > - {children} + {isLegendBefore && legendElement} + + {children} + + {!isLegendBefore && legendElement} ); diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx index c6e8ae052..becb330a4 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx @@ -6,6 +6,10 @@ import { getBarPath, type Transition } from '../utils'; import { DefaultBar } from './DefaultBar'; export type BarBaseProps = { + /** + * The series ID this bar belongs to. + */ + seriesId?: string; /** * X coordinate of the bar (left edge). */ @@ -99,6 +103,7 @@ export type BarComponent = React.FC; */ export const Bar = memo( ({ + seriesId, x, y, width, @@ -129,9 +134,7 @@ export const Bar = memo( const effectiveOriginY = originY ?? y + height; - if (!barPath) { - return null; - } + if (!barPath) return; // Always use the BarComponent for rendering return ( @@ -146,6 +149,7 @@ export const Bar = memo( originY={effectiveOriginY} roundBottom={roundBottom} roundTop={roundTop} + seriesId={seriesId} stroke={stroke} strokeWidth={strokeWidth} transition={transition} diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx index a47211d11..20e8d2d0b 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -9,13 +9,13 @@ import { } from '../CartesianChart'; import { type CartesianAxisConfigProps, - type CartesianSeries, defaultChartInset, defaultStackId, getChartInset, } from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; +import type { BarSeries } from './BarStack'; export type BarChartBaseProps = Omit & Pick< @@ -36,7 +36,7 @@ export type BarChartBaseProps = Omit; + series?: Array; /** * Whether to stack the areas on top of each other. * When true, each series builds cumulative values on top of the previous series. diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index e5698e490..7d551589d 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -7,11 +7,21 @@ import type { CartesianSeries, ChartScaleFunction, Transition } from '../utils'; import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; -import { Bar, type BarProps } from './Bar'; +import { Bar, type BarComponent, type BarProps } from './Bar'; import { DefaultBarStack } from './DefaultBarStack'; const EPSILON = 1e-4; +/** + * Extended series type that includes bar-specific properties. + */ +export type BarSeries = CartesianSeries & { + /** + * Custom component to render bars for this series. + */ + BarComponent?: BarComponent; +}; + export type BarStackBaseProps = Pick< BarProps, 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' @@ -19,7 +29,7 @@ export type BarStackBaseProps = Pick< /** * Array of series configurations that belong to this stack. */ - series: CartesianSeries[]; + series: BarSeries[]; /** * The category index for this stack. */ @@ -180,6 +190,7 @@ export const BarStack = memo( roundTop?: boolean; roundBottom?: boolean; shouldApplyGap?: boolean; + BarComponent?: BarComponent; }> = []; // Track how many bars we've stacked in each direction for gap calculation @@ -262,6 +273,7 @@ export const BarStack = memo( width, height, dataY: value, // Store the actual data value + BarComponent: s.BarComponent, fill: barFill, // Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding roundTop: roundBaseline || Math.abs(barTop - baseline) >= EPSILON, @@ -676,6 +688,7 @@ export const BarStack = memo( originY={baseline} roundBottom={bar.roundBottom} roundTop={bar.roundTop} + seriesId={bar.seriesId} stroke={defaultStroke} strokeWidth={defaultStrokeWidth} transition={transition} diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index f2e887573..a7e276c42 100644 --- a/packages/mobile-visualization/src/chart/index.ts +++ b/packages/mobile-visualization/src/chart/index.ts @@ -7,6 +7,7 @@ export * from './ChartContextBridge'; export * from './ChartProvider'; export * from './DonutChart'; export * from './gradient/index'; +export * from './legend/index'; export * from './line/index'; export * from './Path'; export * from './PeriodSelector'; diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendItem.tsx b/packages/mobile-visualization/src/chart/legend/DefaultLegendItem.tsx new file mode 100644 index 000000000..12c8a6b10 --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/DefaultLegendItem.tsx @@ -0,0 +1,106 @@ +import { memo } from 'react'; +import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; +import type { SharedProps } from '@coinbase/cds-common/types'; +import { Box, HStack, type HStackProps } from '@coinbase/cds-mobile/layout'; +import { TextLabel2 } from '@coinbase/cds-mobile/typography'; + +import type { Series } from '../utils'; + +import { DefaultLegendShape, type LegendShapeComponent } from './DefaultLegendShape'; + +const styles = StyleSheet.create({ + shapeWrapper: { + width: 10, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, + legendItem: { + alignItems: 'center', + }, +}); + +export type LegendItemBaseProps = Omit & + SharedProps & { + /** + * Id of the series. + */ + seriesId: string; + /** + * Display label for the legend item. + * Can be a string or a custom ReactNode. + * If a ReactNode is provided, it replaces the default Text component. + */ + label: React.ReactNode; + /** + * Color associated with the series. + * This is a raw string color value (e.g. 'rgb(...)' or hex). + */ + color?: string; + /** + * Shape to display in the legend. + */ + shape?: Series['legendShape']; + /** + * Custom component to render the legend shape. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + }; + +export type LegendItemProps = LegendItemBaseProps & { + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: StyleProp; + /** + * Custom styles for the shape wrapper element. + */ + shapeWrapper?: StyleProp; + /** + * Custom styles for the shape element. + */ + shape?: StyleProp; + /** + * Custom styles for the label element. + * @note not applied when label is a ReactNode. + */ + label?: StyleProp; + }; +}; + +export type LegendItemComponent = React.FC; + +export const DefaultLegendItem = memo(function DefaultLegendItem({ + label, + color, + shape, + ShapeComponent = DefaultLegendShape, + gap = 1, + style, + styles: stylesProp, + testID, + ...props +}) { + return ( + + + + + {typeof label === 'string' ? ( + {label} + ) : ( + label + )} + + ); +}); diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx new file mode 100644 index 000000000..c2a7f03c9 --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx @@ -0,0 +1,82 @@ +import { memo } from 'react'; +import { StyleSheet, type ViewStyle } from 'react-native'; +import type { SharedProps } from '@coinbase/cds-common/types'; +import { useTheme } from '@coinbase/cds-mobile'; +import { Box, type BoxProps } from '@coinbase/cds-mobile/layout'; + +import type { LegendShape, LegendShapeVariant } from '../utils/chart'; + +const styles = StyleSheet.create({ + pill: { + width: 6, + height: 24, + borderRadius: 3, + }, + circle: { + width: 10, + height: 10, + borderRadius: 5, + }, + square: { + width: 10, + height: 10, + }, + squircle: { + width: 10, + height: 10, + borderRadius: 2, + }, +}); + +const stylesByVariant: Record = { + pill: styles.pill, + circle: styles.circle, + square: styles.square, + squircle: styles.squircle, +}; + +const isVariantShape = (shape: LegendShape): shape is LegendShapeVariant => + typeof shape === 'string' && shape in stylesByVariant; + +export type LegendShapeBaseProps = SharedProps & { + /** + * Color of the legend shape. + * @default theme.color.fgPrimary + */ + color?: string; + /** + * Shape to display. Can be a preset shape or a custom ReactNode. + * @default 'circle' + */ + shape?: LegendShape; +}; + +export type LegendShapeProps = Omit & LegendShapeBaseProps; + +export type LegendShapeComponent = React.FC; + +/** + * Default shape component for chart legends. + * Renders a colored shape (pill, circle, square, or squircle) or a custom ReactNode. + */ +export const DefaultLegendShape = memo(function DefaultLegendShape({ + color, + shape = 'circle', + style, + testID, + ...props +}) { + const theme = useTheme(); + + if (!isVariantShape(shape)) return shape; + + const variantStyle = stylesByVariant[shape]; + + return ( + + ); +}); diff --git a/packages/mobile-visualization/src/chart/legend/Legend.tsx b/packages/mobile-visualization/src/chart/legend/Legend.tsx new file mode 100644 index 000000000..c9fc11d4e --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/Legend.tsx @@ -0,0 +1,116 @@ +import { forwardRef, memo, useMemo } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; +import type { SharedProps } from '@coinbase/cds-common/types'; +import { Box, type BoxProps } from '@coinbase/cds-mobile/layout'; + +import { useChartContext } from '../ChartProvider'; + +import { DefaultLegendItem, type LegendItemComponent } from './DefaultLegendItem'; +import type { LegendShapeComponent } from './DefaultLegendShape'; + +export type LegendBaseProps = SharedProps & { + /** + * Array of series IDs to display in the legend. + * By default, all series will be displayed. + */ + seriesIds?: string[]; + /** + * Custom component to render each legend item. + * @default DefaultLegendItem + */ + ItemComponent?: LegendItemComponent; + /** + * Custom component to render the legend shape within each item. + * Only used when ItemComponent is not provided or is DefaultLegendItem. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; +}; + +export type LegendProps = BoxProps & + LegendBaseProps & { + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: StyleProp; + /** + * Custom styles for each item element. + */ + item?: StyleProp; + /** + * Custom styles for the shape wrapper element within each item. + */ + itemShapeWrapper?: StyleProp; + /** + * Custom styles for the shape element within each item. + */ + itemShape?: StyleProp; + /** + * Custom styles for the label element within each item. + * @note not applied when label is a ReactNode. + */ + itemLabel?: StyleProp; + }; + }; + +export const Legend = memo( + forwardRef(function Legend( + { + flexDirection = 'row', + justifyContent = 'center', + alignItems = flexDirection === 'row' ? 'center' : 'flex-start', + flexWrap = 'wrap', + gap = 1, + seriesIds, + ItemComponent = DefaultLegendItem, + ShapeComponent, + style, + styles, + ...props + }, + ref, + ) { + const { series } = useChartContext(); + + const filteredSeries = useMemo(() => { + if (seriesIds === undefined) return series; + return series.filter((s) => seriesIds.includes(s.id)); + }, [series, seriesIds]); + + if (filteredSeries.length === 0) return null; + + return ( + + {filteredSeries.map((s) => ( + + ))} + + ); + }), +); diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx new file mode 100644 index 000000000..5648eb4bb --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -0,0 +1,343 @@ +import { memo, useCallback, useMemo, useState } from 'react'; +import { useTheme } from '@coinbase/cds-mobile'; +import { IconButton } from '@coinbase/cds-mobile/buttons'; +import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; +import { TextLabel1, TextLabel2 } from '@coinbase/cds-mobile/typography'; +import { Text } from '@coinbase/cds-mobile/typography/Text'; + +import { DonutChart } from '../../DonutChart'; +import { LineChart } from '../../line'; +import { PieChart } from '../../pie'; +import type { LegendShapeVariant } from '../../utils/chart'; +import type { LegendItemProps } from '../DefaultLegendItem'; +import { DefaultLegendShape } from '../DefaultLegendShape'; +import { Legend } from '../Legend'; + +const spectrumColors = [ + 'blue40', + 'green40', + 'orange40', + 'yellow40', + 'gray40', + 'indigo40', + 'pink40', + 'purple40', + 'red40', + 'teal40', +]; + +const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square']; + +const Shapes = () => { + const theme = useTheme(); + + return ( + + {shapes.map((shape) => ( + + {spectrumColors.map((color) => ( + + + + ))} + + ))} + + ); +}; + +const BasicLegend = () => { + const theme = useTheme(); + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + return ( + + + + ); +}; + +const ShapeVariants = () => { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + } + legendPosition="bottom" + series={[ + { + id: 'pill', + label: 'Pill', + data: [120, 150, 130, 170, 160, 190], + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'pill', + }, + { + id: 'circle', + label: 'Circle', + data: [80, 110, 95, 125, 115, 140], + color: `rgb(${theme.spectrum.green40})`, + legendShape: 'circle', + }, + { + id: 'square', + label: 'Square', + data: [60, 85, 70, 100, 90, 115], + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'square', + }, + { + id: 'squircle', + label: 'Squircle', + data: [40, 60, 50, 75, 65, 85], + color: `rgb(${theme.spectrum.purple40})`, + legendShape: 'squircle', + }, + ]} + width="100%" + xAxis={{ data: months }} + /> + + ); +}; + +const PieChartLegend = () => { + const theme = useTheme(); + + const series = [ + { + id: 'stocks', + data: 45, + label: 'Stocks', + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'circle' as const, + }, + { + id: 'bonds', + data: 25, + label: 'Bonds', + color: `rgb(${theme.spectrum.green40})`, + legendShape: 'circle' as const, + }, + { + id: 'realEstate', + data: 15, + label: 'Real Estate', + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'circle' as const, + }, + { + id: 'commodities', + data: 10, + label: 'Commodities', + color: `rgb(${theme.spectrum.purple40})`, + legendShape: 'circle' as const, + }, + { + id: 'cash', + data: 5, + label: 'Cash', + color: `rgb(${theme.spectrum.gray40})`, + legendShape: 'circle' as const, + }, + ]; + + return ( + + Portfolio Allocation + } + legendPosition="right" + series={series} + width={300} + /> + + ); +}; + +const DonutChartLegend = () => { + const theme = useTheme(); + + const series = [ + { + id: 'completed', + data: 68, + label: 'Completed', + color: theme.color.fgPositive, + legendShape: 'squircle' as const, + }, + { + id: 'inProgress', + data: 22, + label: 'In Progress', + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'squircle' as const, + }, + { + id: 'pending', + data: 10, + label: 'Pending', + color: `rgb(${theme.spectrum.gray40})`, + legendShape: 'squircle' as const, + }, + ]; + + return ( + + Task Status + + + ); +}; + +const CustomLegendItem = () => { + const theme = useTheme(); + + const CustomItem = memo(function CustomItem({ label, color, shape }) { + return ( + + + {label} + 100% + + ); + }); + + return ( + + Custom Legend Item + } + legendPosition="bottom" + series={[ + { + id: 'btc', + data: 60, + label: 'Bitcoin', + color: `rgb(${theme.spectrum.orange40})`, + }, + { + id: 'eth', + data: 30, + label: 'Ethereum', + color: `rgb(${theme.spectrum.purple40})`, + }, + { + id: 'other', + data: 10, + label: 'Other', + color: `rgb(${theme.spectrum.gray40})`, + }, + ]} + width={150} + /> + + ); +}; + +type ExampleItem = { + title: string; + component: React.ReactNode; +}; + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { title: 'Shapes', component: }, + { title: 'Basic Legend', component: }, + { title: 'Shape Variants', component: }, + { title: 'Pie Chart Legend', component: }, + { title: 'Donut Chart Legend', component: }, + { title: 'Custom Legend Item', component: }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + const isFirstExample = currentIndex === 0; + const isLastExample = currentIndex === examples.length - 1; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1)); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + + {currentExample.component} + + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/legend/index.ts b/packages/mobile-visualization/src/chart/legend/index.ts new file mode 100644 index 000000000..ba2be3549 --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/index.ts @@ -0,0 +1,3 @@ +export * from './DefaultLegendItem'; +export * from './DefaultLegendShape'; +export * from './Legend'; diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx index 6f3234bea..2cccdabba 100644 --- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx @@ -19,12 +19,9 @@ export type DefaultScrubberBeaconLabelProps = ScrubberBeaconLabelProps & */ export const DefaultScrubberBeaconLabel = memo( ({ - background, color, elevated = true, - borderRadius = 4, font = 'label1', - verticalAlignment = 'middle', inset = { left: labelHorizontalInset, right: labelHorizontalInset, @@ -38,13 +35,10 @@ export const DefaultScrubberBeaconLabel = memo( return ( {label} diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx index 89d619b2d..b3e0b4bf8 100644 --- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx @@ -146,6 +146,10 @@ export type ScrubberBaseProps = Pick * By default, all series will be highlighted. */ seriesIds?: string[]; + /** + * Hides the beacon labels while keeping the line label visible (if provided). + */ + hideBeaconLabels?: boolean; /** * Hides the scrubber line. * @note This hides Scrubber's ReferenceLine including the label. @@ -211,6 +215,7 @@ export const Scrubber = memo( ( { seriesIds, + hideBeaconLabels, hideLine, label, lineStroke, @@ -380,7 +385,7 @@ export const Scrubber = memo( seriesIds={filteredSeriesIds} transitions={beaconTransitions} /> - {beaconLabels.length > 0 && ( + {!hideBeaconLabels && beaconLabels.length > 0 && ( ): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; +export type LegendShapeVariant = 'circle' | 'square' | 'squircle' | 'pill'; + +export type LegendShape = LegendShapeVariant | React.ReactNode; + export type Series = { /** * Id of the series. @@ -32,6 +36,12 @@ export type Series = { * Color for the series. */ color?: string; + /** + * Legend shape for this series. + * Can be a LegendShapeVariant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; }; export type CartesianSeries = Series & { diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index 0c49f3fdc..56ceb6c4c 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -1,4 +1,5 @@ -import { createContext, useContext } from 'react'; +import { createContext, type RefObject, useContext } from 'react'; +import type { View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import type { Rect } from '@coinbase/cds-common/types'; import type { SkTypefaceFontProvider } from '@shopify/react-native-skia'; @@ -8,9 +9,23 @@ import type { CartesianSeries, PolarSeries, Series } from './chart'; import type { ChartScaleFunction, SerializableScale } from './scale'; /** - * Context value for charts. + * Chart context type discriminator. + */ +export type ChartType = 'cartesian' | 'polar'; + +/** + * Base context value for all chart types. */ export type ChartContextValue = { + /** + * The type of chart. + */ + type: ChartType; + /** + * The series data for the chart. + * Contains common series properties (id, label, color, legendShape). + */ + series: Series[]; /** * Whether to animate the chart. */ @@ -40,13 +55,21 @@ export type ChartContextValue = { * Length of the data domain. */ dataLength: number; + /** + * Reference to the chart's root element (SVG on web, Canvas on mobile). + */ + ref?: RefObject; }; /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = ChartContextValue & { +export type CartesianChartContextValue = Omit & { + /** + * The type of chart. + */ + type: 'cartesian'; /** * The series data for the chart. */ @@ -112,7 +135,11 @@ export type CartesianChartContextValue = ChartContextValue & { * Context value for Polar (Angular/Radial) coordinate charts. * Contains axis-specific methods and properties for polar coordinate systems. */ -export type PolarChartContextValue = ChartContextValue & { +export type PolarChartContextValue = Omit & { + /** + * The type of chart. + */ + type: 'polar'; /** * The series data for the chart. */ diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index 293096742..0b3492e3a 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -303,6 +303,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts index 293096742..0b3492e3a 100644 --- a/packages/ui-mobile-visreg/src/routes.ts +++ b/packages/ui-mobile-visreg/src/routes.ts @@ -303,6 +303,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index 02aaba956..ed4cb11d4 100644 --- a/packages/web-visualization/package.json +++ b/packages/web-visualization/package.json @@ -42,6 +42,7 @@ "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-utils": "workspace:^", "@coinbase/cds-web": "workspace:^", + "@floating-ui/react-dom": "^2.1.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index cfd5a89dc..167fb6d0d 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -5,6 +5,7 @@ import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout'; import { css } from '@linaria/core'; +import { Legend } from './legend/Legend'; import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; import { CartesianChartProvider } from './ChartProvider'; import { @@ -25,7 +26,11 @@ import { useTotalAxisPadding, } from './utils'; -const focusStylesCss = css` +const rootCss = css` + display: flex; + overflow: hidden; +`; +const focusCss = css` &:focus { outline: none; } @@ -34,6 +39,19 @@ const focusStylesCss = css` outline-offset: 2px; } `; +const verticalCss = css` + flex-direction: column; +`; +const horizontalCss = css` + flex-direction: row; +`; +const chartContainerCss = css` + flex: 1; + min-height: 0; + min-width: 0; +`; + +export type LegendPosition = 'top' | 'bottom' | 'left' | 'right'; export type CartesianChartBaseProps = BoxBaseProps & Pick & { @@ -61,6 +79,17 @@ export type CartesianChartBaseProps = BoxBaseProps & * Inset around the entire chart (outside the axes). */ inset?: number | Partial; + /** + * Whether to show a legend, or a custom legend element. + * When `true`, renders the default Legend component. + * When a ReactNode, renders the provided element. + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; }; export type CartesianChartProps = Omit, 'title'> & @@ -81,6 +110,11 @@ export type CartesianChartProps = Omit, 'title'> & * Custom class name for the chart SVG element. */ chart?: string; + /** + * Custom class name for the legend element. + * @note not used when legend is a ReactNode. + */ + legend?: string; }; /** * Custom styles for the root element. @@ -98,6 +132,11 @@ export type CartesianChartProps = Omit, 'title'> & * Custom styles for the chart SVG element. */ chart?: React.CSSProperties; + /** + * Custom styles for the legend element. + * @note not used when legend is a ReactNode. + */ + legend?: React.CSSProperties; }; }; @@ -113,6 +152,8 @@ export const CartesianChart = memo( inset, enableScrubbing, onScrubberPositionChange, + legend, + legendPosition = 'bottom', width = '100%', height = '100%', className, @@ -124,7 +165,7 @@ export const CartesianChart = memo( ref, ) => { const { observe, width: chartWidth, height: chartHeight } = useDimensions(); - const svgRef = useRef(null); + const chartRef = useRef(null); const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); @@ -353,6 +394,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, @@ -368,6 +410,7 @@ export const CartesianChart = memo( registerAxis, unregisterAxis, getAxisBounds, + ref: chartRef, }), [ series, @@ -385,12 +428,35 @@ export const CartesianChart = memo( registerAxis, unregisterAxis, getAxisBounds, + chartRef, ], ); + const isVerticalLegend = useMemo( + () => legendPosition === 'top' || legendPosition === 'bottom', + [legendPosition], + ); + const isLegendBefore = useMemo( + () => legendPosition === 'top' || legendPosition === 'left', + [legendPosition], + ); + + const legendElement = useMemo(() => { + if (!legend) return; + if (typeof legend !== 'boolean') return legend; + return ( + + ); + }, [legend, isVerticalLegend, classNames?.legend, styles?.legend]); + const rootClassNames = useMemo( - () => cx(className, classNames?.root), - [className, classNames], + () => + cx(rootCss, isVerticalLegend ? verticalCss : horizontalCss, className, classNames?.root), + [className, classNames, isVerticalLegend], ); const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); @@ -399,22 +465,21 @@ export const CartesianChart = memo( { - observe(node as unknown as HTMLElement); - }} className={rootClassNames} height={height} style={rootStyles} width={width} {...props} > + {isLegendBefore && legendElement} { const svgElement = node as unknown as SVGSVGElement; - svgRef.current = svgElement; + chartRef.current = svgElement; + observe(node as unknown as HTMLElement); + // Forward the ref to the user if (ref) { if (typeof ref === 'function') { @@ -426,7 +491,7 @@ export const CartesianChart = memo( }} aria-live="polite" as="svg" - className={cx(enableScrubbing && focusStylesCss, classNames?.chart)} + className={cx(chartContainerCss, enableScrubbing && focusCss, classNames?.chart)} height="100%" style={styles?.chart} tabIndex={enableScrubbing ? 0 : undefined} @@ -434,6 +499,7 @@ export const CartesianChart = memo( > {children} + {!isLegendBefore && legendElement} diff --git a/packages/web-visualization/src/chart/ChartTooltip.tsx b/packages/web-visualization/src/chart/ChartTooltip.tsx new file mode 100644 index 000000000..eef34d80d --- /dev/null +++ b/packages/web-visualization/src/chart/ChartTooltip.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useMemo } from 'react'; +import { + Divider, + VStack, + type VStackBaseProps, + type VStackDefaultElement, + type VStackProps, +} from '@coinbase/cds-web/layout'; +import { Portal } from '@coinbase/cds-web/overlays/Portal'; +import { tooltipContainerId } from '@coinbase/cds-web/overlays/PortalProvider'; +import { Text } from '@coinbase/cds-web/typography'; +import { flip, offset, shift, useFloating, type VirtualElement } from '@floating-ui/react-dom'; + +import type { LegendShapeComponent } from './legend/DefaultLegendShape'; +import { useCartesianChartContext } from './ChartProvider'; +import { type ChartTooltipItemComponent, DefaultChartTooltipItem } from './DefaultChartTooltipItem'; +import { useScrubberContext } from './utils'; + +export type ChartTooltipBaseProps = VStackBaseProps & { + /** + * Label text displayed at the top of the tooltip. + * Can be a static string, a custom ReactNode, or a function that receives the current dataIndex. + * If not provided, defaults to the x-axis data value at the current index. + * If null is returned, the label is omitted. + */ + label?: React.ReactNode | ((dataIndex: number) => React.ReactNode); + /** + * Array of series IDs to include in the tooltip. + * By default, all series will be included. + */ + seriesIds?: string[]; + /** + * Formatter function for series values. + * Receives the numeric series value and should return a ReactNode. + * String results will automatically be wrapped in Text with font="label2". + */ + valueFormatter?: (value: number) => React.ReactNode; + /** + * Custom component to render each tooltip item. + * @default DefaultChartTooltipItem + */ + ItemComponent?: ChartTooltipItemComponent; + /** + * Custom component to render the legend shape within each item. + * Only used when ItemComponent is DefaultChartTooltipItem. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; +}; + +export type ChartTooltipProps = VStackProps & + ChartTooltipBaseProps & { + /** + * Custom class names for the component parts. + */ + classNames?: { + /** + * Custom class name for the root element. + */ + root?: string; + /** + * Custom class name for the label element. + * @note not applied when label is a ReactNode. + */ + label?: string; + /** + * Custom class name for the divider element. + */ + divider?: string; + /** + * Custom class name for each item element. + */ + item?: string; + /** + * Custom class name for the legend item element within each item. + */ + itemLegendItem?: string; + /** + * Custom class name for the value element within each item. + */ + itemValue?: string; + /** + * Custom class name for the shape wrapper element within each item. + */ + itemShapeWrapper?: string; + /** + * Custom class name for the shape element within each item. + */ + itemShape?: string; + /** + * Custom class name for the label element within each item. + * @note not applied when label is a ReactNode. + */ + itemLabel?: string; + }; + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: React.CSSProperties; + /** + * Custom styles for the label element. + * @note not applied when label is a ReactNode. + */ + label?: React.CSSProperties; + /** + * Custom styles for the divider element. + */ + divider?: React.CSSProperties; + /** + * Custom styles for each item element. + */ + item?: React.CSSProperties; + /** + * Custom styles for the legend item element within each item. + */ + itemLegendItem?: React.CSSProperties; + /** + * Custom styles for the value element within each item. + */ + itemValue?: React.CSSProperties; + /** + * Custom styles for the shape wrapper element within each item. + */ + itemShapeWrapper?: React.CSSProperties; + /** + * Custom styles for the shape element within each item. + */ + itemShape?: React.CSSProperties; + /** + * Custom styles for the label element within each item. + * @note not applied when label is a ReactNode. + */ + itemLabel?: React.CSSProperties; + }; + }; + +export const ChartTooltip = ({ + label, + seriesIds, + valueFormatter, + ItemComponent = DefaultChartTooltipItem, + ShapeComponent, + background = 'bgElevation2', + borderRadius = 400, + elevation = 2, + gap = 1, + minWidth = 320, + paddingX = 2, + paddingY = 1.5, + className, + classNames, + style, + styles, + ...props +}: ChartTooltipProps) => { + const { ref, series, getXAxis } = useCartesianChartContext(); + const { scrubberPosition, enableScrubbing } = useScrubberContext(); + + const isTooltipVisible = enableScrubbing && scrubberPosition !== undefined; + + const { refs, floatingStyles } = useFloating({ + open: isTooltipVisible, + placement: 'bottom-start', + middleware: [ + offset(({ placement }) => { + const mainAxis = placement.includes('bottom') ? 16 : 8; + const crossAxis = placement.includes('start') ? 16 : -8; + + return { mainAxis, crossAxis }; + }), + flip({ + fallbackPlacements: ['top-start', 'bottom-end', 'top-end'], + }), + shift({ padding: 8 }), + ], + }); + + useEffect(() => { + const element = ref?.current; + if (!element || !enableScrubbing) return; + + const handleMouseMove = (event: Event) => { + const { clientX, clientY } = event as MouseEvent; + const virtualEl: VirtualElement = { + getBoundingClientRect() { + return { + width: 0, + height: 0, + x: clientX, + y: clientY, + left: clientX, + right: clientX, + top: clientY, + bottom: clientY, + } as DOMRect; + }, + }; + refs.setReference(virtualEl); + }; + + element.addEventListener('mousemove', handleMouseMove); + + return () => element.removeEventListener('mousemove', handleMouseMove); + }, [enableScrubbing, refs, ref]); + + const filteredSeries = useMemo(() => { + if (seriesIds === undefined) return series; + return series.filter((s) => seriesIds.includes(s.id)); + }, [series, seriesIds]); + + const resolvedLabel = useMemo(() => { + if (scrubberPosition === undefined) return; + + let resolved: React.ReactNode; + if (label !== undefined) { + resolved = typeof label === 'function' ? label(scrubberPosition) : label; + } else { + // Default to x-axis data value + const xAxis = getXAxis(); + if (xAxis?.data && xAxis.data[scrubberPosition] !== undefined) { + resolved = xAxis.data[scrubberPosition]; + } else { + resolved = scrubberPosition; + } + } + return resolved; + }, [scrubberPosition, label, getXAxis]); + + if (!isTooltipVisible) return; + + return ( + + + {resolvedLabel && + (typeof resolvedLabel === 'string' || typeof resolvedLabel === 'number' ? ( + + {resolvedLabel} + + ) : ( + resolvedLabel + ))} + {resolvedLabel && filteredSeries.length > 0 && ( + + )} + {filteredSeries.length > 0 && + filteredSeries.map((s) => ( + + ))} + + + ); +}; diff --git a/packages/web-visualization/src/chart/DefaultChartTooltipItem.tsx b/packages/web-visualization/src/chart/DefaultChartTooltipItem.tsx new file mode 100644 index 000000000..6ee446095 --- /dev/null +++ b/packages/web-visualization/src/chart/DefaultChartTooltipItem.tsx @@ -0,0 +1,191 @@ +import { memo, useMemo } from 'react'; +import type { SharedProps } from '@coinbase/cds-common/types'; +import { + HStack, + type HStackBaseProps, + type HStackDefaultElement, + type HStackProps, +} from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { DefaultLegendItem, type LegendItemComponent } from './legend/DefaultLegendItem'; +import type { LegendShapeComponent } from './legend/DefaultLegendShape'; +import { useCartesianChartContext } from './ChartProvider'; +import type { CartesianSeries } from './utils'; + +export type ChartTooltipItemBaseProps = Omit & + SharedProps & { + /** + * The series to display. + */ + series: CartesianSeries; + /** + * The current scrubber position (data index). + */ + scrubberPosition: number; + /** + * Formatter function for series values. + * Receives the numeric series value and should return a ReactNode. + * String results will automatically be wrapped in Text. + */ + valueFormatter?: (value: number) => React.ReactNode; + /** + * Custom component to render the legend item (shape + label). + * @default DefaultLegendItem + */ + LegendItemComponent?: LegendItemComponent; + /** + * Custom component to render the legend shape. + * Only used when LegendItemComponent is DefaultLegendItem. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + }; + +export type ChartTooltipItemProps = Omit, 'children'> & + ChartTooltipItemBaseProps & { + /** + * Custom class names for the component parts. + */ + classNames?: { + /** + * Custom class name for the root element. + */ + root?: string; + /** + * Custom class name for the legend item element. + */ + legendItem?: string; + /** + * Custom class name for the value element. + * @note not applied when value is a ReactNode. + */ + value?: string; + /** + * Custom class name for the shape wrapper element. + */ + shapeWrapper?: string; + /** + * Custom class name for the shape element. + */ + shape?: string; + /** + * Custom class name for the label element. + * @note not applied when label is a ReactNode. + */ + label?: string; + }; + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: React.CSSProperties; + /** + * Custom styles for the legend item element. + */ + legendItem?: React.CSSProperties; + /** + * Custom styles for the value element. + * @note not applied when value is a ReactNode. + */ + value?: React.CSSProperties; + /** + * Custom styles for the shape wrapper element. + */ + shapeWrapper?: React.CSSProperties; + /** + * Custom styles for the shape element. + */ + shape?: React.CSSProperties; + /** + * Custom styles for the label element. + * @note not applied when label is a ReactNode. + */ + label?: React.CSSProperties; + }; + }; + +export type ChartTooltipItemComponent = React.FC; + +export const DefaultChartTooltipItem = memo( + ({ + alignItems = 'center', + justifyContent = 'space-between', + series, + scrubberPosition, + valueFormatter, + LegendItemComponent = DefaultLegendItem, + ShapeComponent, + className, + classNames, + style, + styles, + testID, + ...props + }) => { + const { getSeriesData } = useCartesianChartContext(); + + const formattedValue: React.ReactNode = useMemo(() => { + const data = getSeriesData(series.id); + const dataPoint = data?.[scrubberPosition]; + let value: number | undefined; + + if (dataPoint && dataPoint !== null) { + const [start, end] = dataPoint; + value = end - start; + } else if (series.data) { + const rawPoint = series.data[scrubberPosition]; + if (rawPoint !== undefined && rawPoint !== null) { + value = Array.isArray(rawPoint) ? rawPoint.at(-1) : rawPoint; + } + } + + if (value === undefined || value === null || Number.isNaN(value)) return; + + return valueFormatter ? valueFormatter(value) : value; + }, [series.id, series.data, scrubberPosition, getSeriesData, valueFormatter]); + + if (formattedValue === undefined) return; + + return ( + + + {typeof formattedValue === 'string' || typeof formattedValue === 'number' ? ( + + {formattedValue} + + ) : ( + formattedValue + )} + + ); + }, +); diff --git a/packages/web-visualization/src/chart/PolarChart.tsx b/packages/web-visualization/src/chart/PolarChart.tsx index 2dcce4a55..2fae9c791 100644 --- a/packages/web-visualization/src/chart/PolarChart.tsx +++ b/packages/web-visualization/src/chart/PolarChart.tsx @@ -1,9 +1,12 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; import type { Rect } from '@coinbase/cds-common/types'; import { cx } from '@coinbase/cds-web'; import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout'; +import { css } from '@linaria/core'; +import { Legend } from './legend/Legend'; +import type { LegendPosition } from './CartesianChart'; import { PolarChartProvider } from './ChartProvider'; import { type AngularAxisConfig, @@ -24,6 +27,22 @@ import { type RadialAxisConfigProps, } from './utils'; +const rootCss = css` + display: flex; + overflow: hidden; +`; +const verticalCss = css` + flex-direction: column; +`; +const horizontalCss = css` + flex-direction: row; +`; +const chartContainerCss = css` + flex: 1; + min-height: 0; + min-width: 0; +`; + export type PolarChartBaseProps = BoxBaseProps & { /** * Configuration object that defines the data to visualize. @@ -97,6 +116,17 @@ export type PolarChartBaseProps = BoxBaseProps & { * Inset around the entire chart (outside the drawing area). */ inset?: number | Partial; + /** + * Whether to show a legend, or a custom legend element. + * When `true`, renders the default Legend component. + * When a ReactNode, renders the provided element. + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; }; export type PolarChartProps = Omit, 'title'> & @@ -117,6 +147,11 @@ export type PolarChartProps = Omit, 'title'> & * Custom class name for the chart SVG element. */ chart?: string; + /** + * Custom class name for the legend element. + * @note not used when legend is a ReactNode. + */ + legend?: string; }; /** * Custom styles for the root element. @@ -134,6 +169,11 @@ export type PolarChartProps = Omit, 'title'> & * Custom styles for the chart SVG element. */ chart?: React.CSSProperties; + /** + * Custom styles for the legend element. + * @note not used when legend is a ReactNode. + */ + legend?: React.CSSProperties; }; }; @@ -151,6 +191,8 @@ export const PolarChart = memo( angularAxis, radialAxis, inset: insetInput, + legend, + legendPosition = 'bottom', width = '100%', height = '100%', className, @@ -319,6 +361,7 @@ export const PolarChart = memo( const contextValue: PolarChartContextValue = useMemo( () => ({ + type: 'polar', series: series ?? [], getSeries, getSeriesData, @@ -350,35 +393,67 @@ export const PolarChart = memo( ], ); + const isVerticalLegend = useMemo( + () => legendPosition === 'top' || legendPosition === 'bottom', + [legendPosition], + ); + const isLegendBefore = useMemo( + () => legendPosition === 'top' || legendPosition === 'left', + [legendPosition], + ); + + const legendElement = useMemo(() => { + if (!legend) return; + if (typeof legend !== 'boolean') return legend; + return ( + + ); + }, [legend, isVerticalLegend, classNames?.legend, styles?.legend]); + const rootClassNames = useMemo( - () => cx(className, classNames?.root), - [className, classNames], + () => + cx(rootCss, isVerticalLegend ? verticalCss : horizontalCss, className, classNames?.root), + [className, classNames, isVerticalLegend], ); const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); return ( { - observe(node as unknown as HTMLElement); - }} className={rootClassNames} height={height} style={rootStyles} width={width} {...props} > + {isLegendBefore && legendElement} { + const svgElement = node as unknown as SVGSVGElement; + observe(node as unknown as HTMLElement); + // Forward the ref to the user + if (ref) { + if (typeof ref === 'function') { + ref(svgElement); + } else { + (ref as React.MutableRefObject).current = svgElement; + } + } + }} aria-live="polite" as="svg" - className={classNames?.chart} + className={cx(chartContainerCss, classNames?.chart)} height="100%" style={styles?.chart} width="100%" > {children} + {!isLegendBefore && legendElement} ); diff --git a/packages/web-visualization/src/chart/__stories__/ChartTooltip.stories.tsx b/packages/web-visualization/src/chart/__stories__/ChartTooltip.stories.tsx new file mode 100644 index 000000000..808327ca5 --- /dev/null +++ b/packages/web-visualization/src/chart/__stories__/ChartTooltip.stories.tsx @@ -0,0 +1,365 @@ +import { useCallback, useMemo } from 'react'; +import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { AreaChart } from '../area'; +import { XAxis, YAxis } from '../axis'; +import { BarPlot } from '../bar'; +import { CartesianChart } from '../CartesianChart'; +import { ChartTooltip } from '../ChartTooltip'; +import { Legend } from '../legend'; +import { LineChart } from '../line'; +import { Scrubber } from '../scrubber'; + +export default { + component: ChartTooltip, + title: 'Components/Chart/ChartTooltip', +}; + +const Example: React.FC> = ({ children, title }) => { + return ( + + + {title} + + {children} + + ); +}; + +const Basic = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const revenue = [120, 150, 180, 165, 190, 210]; + const expenses = [80, 95, 110, 105, 120, 130]; + + return ( + + + Hover over the chart to see the tooltip. The label defaults to the x-axis value. + + + + + + + ); +}; + +const WithValueFormatter = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const sales = [45000, 52000, 48000, 61000, 55000, 67000]; + const profit = [12000, 15000, 11000, 18000, 14000, 21000]; + + const currencyFormatter = useCallback( + (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + + Format tooltip values with a custom formatter function. + + + + + + + ); +}; + +const CustomLabel = () => { + const dates = ['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06']; + const displayDates = [ + 'January 2024', + 'February 2024', + 'March 2024', + 'April 2024', + 'May 2024', + 'June 2024', + ]; + const temperature = [32, 35, 45, 58, 68, 78]; + const humidity = [65, 60, 55, 50, 55, 60]; + + return ( + + + Use a function to customize the tooltip label based on the data index. + + + + ( + + {displayDates[dataIndex]} + + )} + /> + + + ); +}; + +const FilteredSeries = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const primary = [100, 120, 140, 130, 150, 170]; + const secondary = [60, 70, 80, 75, 85, 95]; + const tertiary = [30, 35, 40, 38, 42, 48]; + + return ( + + + Show only specific series in the tooltip using the seriesIds prop. + + + + + + + ); +}; + +const WithBarChart = () => { + const categories = ['Q1', 'Q2', 'Q3', 'Q4']; + const revenue = [250, 320, 280, 410]; + const costs = [180, 220, 200, 280]; + + const numberFormatter = useCallback((value: number) => `$${value}k`, []); + + return ( + + + ChartTooltip works with bar charts too. + + + + + + + + + ); +}; + +const StackedAreaTooltip = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug']; + const desktop = [4000, 4200, 3800, 4500, 4800, 5200, 5000, 5500]; + const mobile = [2400, 2800, 3000, 3200, 3500, 3800, 4000, 4200]; + const tablet = [1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]; + + const numberFormatter = useCallback((value: number) => value.toLocaleString(), []); + + return ( + + + Tooltip shows individual series values for stacked charts. + + + + + + + ); +}; + +const CustomValueDisplay = () => { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const steps = [8500, 12000, 7200, 9800, 11500, 15000, 6500]; + const goal = 10000; + + const stepsFormatter = useCallback((value: number) => { + const percentage = Math.round((value / goal) * 100); + const isGoalMet = value >= goal; + return ( + + + {value.toLocaleString()} steps + + + ({percentage}%) + + + ); + }, []); + + return ( + + + Return a custom ReactNode from valueFormatter for rich value display. + + + + + + + ); +}; + +const MultiAxisTooltip = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const revenue = [455, 520, 380, 455, 285, 235]; + const profitMargin = [23, 20, 16, 38, 12, 9]; + + return ( + + + Tooltip works with multiple y-axes, showing values from all series. + + + + `$${value}k`} + /> + `${value}%`} + /> + + { + // Simple formatter - in real usage you'd differentiate by series + return value < 100 ? `${value}%` : `$${value}k`; + }} + /> + + + ); +}; + +export const All = () => { + return ( + + + + + + + + + + + ); +}; diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 352534ef0..853c9c7c6 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -127,6 +127,7 @@ export const AreaChart = memo( yAxisId: s.yAxisId, stackId: s.stackId, gradient: s.gradient, + legendShape: s.legendShape, }), ); }, [series]); diff --git a/packages/web-visualization/src/chart/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx index e7f3f7b8b..0dc9223a2 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -7,6 +7,10 @@ import { getBarPath } from '../utils'; import { DefaultBar } from './'; export type BarBaseProps = { + /** + * The series ID this bar belongs to. + */ + seriesId?: string; /** * X coordinate of the bar (left edge). */ @@ -101,6 +105,7 @@ export type BarComponent = React.FC; */ export const Bar = memo( ({ + seriesId, x, y, width, @@ -124,9 +129,7 @@ export const Bar = memo( const effectiveOriginY = originY ?? y + height; - if (!barPath) { - return null; - } + if (!barPath) return; return ( ( originY={effectiveOriginY} roundBottom={roundBottom} roundTop={roundTop} + seriesId={seriesId} stroke={stroke} strokeWidth={strokeWidth} transition={transition} diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index 038a5a263..0370fe8f8 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -8,13 +8,13 @@ import { } from '../CartesianChart'; import { type CartesianAxisConfigProps, - type CartesianSeries, defaultChartInset, defaultStackId, getChartInset, } from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; +import type { BarSeries } from './BarStack'; export type BarChartBaseProps = Omit & Pick< @@ -35,7 +35,7 @@ export type BarChartBaseProps = Omit; + series?: Array; /** * Whether to stack the areas on top of each other. * When true, each series builds cumulative values on top of the previous series. diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 8b768a93f..d35a45898 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -11,6 +11,16 @@ import { DefaultBarStack } from './DefaultBarStack'; const EPSILON = 1e-4; +/** + * Extended series type that includes bar-specific properties. + */ +export type BarSeries = CartesianSeries & { + /** + * Custom component to render bars for this series. + */ + BarComponent?: BarComponent; +}; + export type BarStackBaseProps = Pick< BarProps, 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' @@ -18,7 +28,7 @@ export type BarStackBaseProps = Pick< /** * Array of series configurations that belong to this stack. */ - series: CartesianSeries[]; + series: BarSeries[]; /** * The category index for this stack. */ @@ -276,6 +286,7 @@ export const BarStack = memo( // Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding roundTop: roundBaseline || Math.abs(barTop - baseline) >= EPSILON, roundBottom: roundBaseline || Math.abs(barBottom - baseline) >= EPSILON, + BarComponent: s.BarComponent, shouldApplyGap, }); }); @@ -687,6 +698,7 @@ export const BarStack = memo( originY={baseline} roundBottom={bar.roundBottom} roundTop={bar.roundTop} + seriesId={bar.seriesId} stroke={bar.stroke ?? defaultStroke} strokeWidth={bar.strokeWidth ?? defaultStrokeWidth} transition={transition} diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 2265caa59..d88bf4e2a 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -4,8 +4,11 @@ export * from './axis/index'; export * from './bar/index'; export * from './CartesianChart'; export * from './ChartProvider'; +export * from './ChartTooltip'; +export * from './DefaultChartTooltipItem'; export * from './DonutChart'; export * from './gradient/index'; +export * from './legend/index'; export * from './line/index'; export * from './Path'; export * from './PeriodSelector'; diff --git a/packages/web-visualization/src/chart/legend/DefaultLegendItem.tsx b/packages/web-visualization/src/chart/legend/DefaultLegendItem.tsx new file mode 100644 index 000000000..450f7d9e5 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/DefaultLegendItem.tsx @@ -0,0 +1,147 @@ +import { memo } from 'react'; +import type { SharedProps } from '@coinbase/cds-common/types'; +import { cx } from '@coinbase/cds-web'; +import { + Box, + HStack, + type HStackBaseProps, + type HStackDefaultElement, + type HStackProps, +} from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; +import { css } from '@linaria/core'; + +import type { Series } from '../utils'; + +import { DefaultLegendShape, type LegendShapeComponent } from './DefaultLegendShape'; + +const shapeWrapperCss = css` + width: 10px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +`; + +const legendItemCss = css` + align-items: center; +`; + +export type LegendItemBaseProps = Omit & + SharedProps & { + /** + * Id of the series. + */ + seriesId: string; + /** + * Display label for the legend item. + * Can be a string or a custom ReactNode. + * If a ReactNode is provided, it replaces the default Text component. + */ + label: React.ReactNode; + /** + * Color associated with the series. + */ + color?: Series['color']; + /** + * Shape to display in the legend. + */ + shape?: Series['legendShape']; + /** + * Custom component to render the legend shape. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + }; + +export type LegendItemProps = Omit, 'children'> & + LegendItemBaseProps & { + /** + * Custom class names for the component parts. + */ + classNames?: { + /** + * Custom class name for the root element. + */ + root?: string; + /** + * Custom class name for the shape wrapper element. + */ + shapeWrapper?: string; + /** + * Custom class name for the shape element. + */ + shape?: string; + /** + * Custom class name for the label element. + * @note not applied when label is a ReactNode. + */ + label?: string; + }; + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: React.CSSProperties; + /** + * Custom styles for the shape wrapper element. + */ + shapeWrapper?: React.CSSProperties; + /** + * Custom styles for the shape element. + */ + shape?: React.CSSProperties; + /** + * Custom styles for the label element. + * @note not applied when label is a ReactNode. + */ + label?: React.CSSProperties; + }; + }; + +export type LegendItemComponent = React.FC; + +export const DefaultLegendItem = memo( + ({ + label, + color, + shape, + ShapeComponent = DefaultLegendShape, + gap = 1, + className, + classNames, + style, + styles, + testID, + ...props + }: LegendItemProps) => { + return ( + + + + + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + + ); + }, +); diff --git a/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx new file mode 100644 index 000000000..7d0c6e01b --- /dev/null +++ b/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx @@ -0,0 +1,77 @@ +import React, { memo } from 'react'; +import { cx } from '@coinbase/cds-web'; +import { Box, type BoxProps } from '@coinbase/cds-web/layout'; +import { css } from '@linaria/core'; + +import type { LegendShape, LegendShapeVariant } from '../utils/chart'; + +const pillCss = css` + width: 6px; + height: 24px; + border-radius: 3px; +`; + +const circleCss = css` + width: 10px; + height: 10px; + border-radius: 5px; +`; + +const squareCss = css` + width: 10px; + height: 10px; +`; + +const squircleCss = css` + width: 10px; + height: 10px; + border-radius: 2px; +`; + +const stylesByVariant: Record = { + pill: pillCss, + circle: circleCss, + square: squareCss, + squircle: squircleCss, +}; + +const isVariantShape = (shape: LegendShape): shape is LegendShapeVariant => + typeof shape === 'string' && shape in stylesByVariant; + +export type LegendShapeProps = BoxProps<'div'> & { + /** + * Color of the legend shape. + * @default 'var(--color-fgPrimary)' + */ + color?: string; + /** + * Shape to display. Can be a preset shape or a custom ReactNode. + * @default 'circle' + */ + shape?: LegendShape; +}; + +export type LegendShapeComponent = React.FC; + +/** + * Default shape component for chart legends. + * Renders a colored shape (pill, circle, square, or squircle) or a custom ReactNode. + */ +export const DefaultLegendShape = memo( + ({ color = 'var(--color-fgPrimary)', shape = 'circle', className, style, ...props }) => { + // If shape is a custom ReactNode, render it directly + if (!isVariantShape(shape)) { + return <>{shape}; + } + + const variantStyle = stylesByVariant[shape]; + + return ( + + ); + }, +); diff --git a/packages/web-visualization/src/chart/legend/Legend.tsx b/packages/web-visualization/src/chart/legend/Legend.tsx new file mode 100644 index 000000000..95fdf5527 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/Legend.tsx @@ -0,0 +1,156 @@ +import { forwardRef, memo, useMemo } from 'react'; +import { + Box, + type BoxBaseProps, + type BoxDefaultElement, + type BoxProps, +} from '@coinbase/cds-web/layout'; + +import { useChartContext } from '../ChartProvider'; + +import { DefaultLegendItem, type LegendItemComponent } from './DefaultLegendItem'; +import type { LegendShapeComponent } from './DefaultLegendShape'; + +export type LegendBaseProps = BoxBaseProps & { + /** + * Array of series IDs to display in the legend. + * By default, all series will be displayed. + */ + seriesIds?: string[]; + /** + * Custom component to render each legend item. + * @default DefaultLegendItem + */ + ItemComponent?: LegendItemComponent; + /** + * Custom component to render the legend shape within each item. + * Only used when ItemComponent is not provided or is DefaultLegendItem. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; +}; + +export type LegendProps = BoxProps & + LegendBaseProps & { + /** + * Custom class names for the component parts. + */ + classNames?: { + /** + * Custom class name for the root element. + */ + root?: string; + /** + * Custom class name for each item element. + */ + item?: string; + /** + * Custom class name for the shape wrapper element within each item. + */ + itemShapeWrapper?: string; + /** + * Custom class name for the shape element within each item. + */ + itemShape?: string; + /** + * Custom class name for the label element within each item. + * @note not applied when label is a ReactNode. + */ + itemLabel?: string; + }; + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: React.CSSProperties; + /** + * Custom styles for each item element. + */ + item?: React.CSSProperties; + /** + * Custom styles for the shape wrapper element within each item. + */ + itemShapeWrapper?: React.CSSProperties; + /** + * Custom styles for the shape element within each item. + */ + itemShape?: React.CSSProperties; + /** + * Custom styles for the label element within each item. + * @note not applied when label is a ReactNode. + */ + itemLabel?: React.CSSProperties; + }; + }; + +export const Legend = memo( + forwardRef( + ( + { + flexDirection = 'row', + justifyContent = 'center', + alignItems = flexDirection === 'row' ? 'center' : 'flex-start', + flexWrap = 'wrap', + gap = 1, + seriesIds, + ItemComponent = DefaultLegendItem, + ShapeComponent, + className, + classNames, + style, + styles, + ...props + }, + ref, + ) => { + const { series } = useChartContext(); + + const filteredSeries = useMemo(() => { + if (seriesIds === undefined) return series; + return series.filter((s) => seriesIds.includes(s.id)); + }, [series, seriesIds]); + + if (filteredSeries.length === 0) return; + + return ( + + {filteredSeries.map((s) => ( + + ))} + + ); + }, + ), +); diff --git a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx new file mode 100644 index 000000000..c54303ed6 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -0,0 +1,828 @@ +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Chip } from '@coinbase/cds-web/chips'; +import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { XAxis, YAxis } from '../../axis'; +import { BarChart, type BarComponentProps, BarPlot, type BarSeries, DefaultBar } from '../../bar'; +import { CartesianChart } from '../../CartesianChart'; +import { useCartesianChartContext } from '../../ChartProvider'; +import { ChartTooltip } from '../../ChartTooltip'; +import { DonutChart } from '../../DonutChart'; +import { LineChart } from '../../line'; +import { PieChart } from '../../pie'; +import { Scrubber } from '../../scrubber'; +import type { CartesianSeries, LegendShapeVariant } from '../../utils/chart'; +import { useScrubberContext } from '../../utils/context'; +import { type LegendItemProps } from '../DefaultLegendItem'; +import { DefaultLegendShape } from '../DefaultLegendShape'; +import { Legend } from '../Legend'; + +export default { + component: Legend, + title: 'Components/Chart/Legend', +}; + +const Example: React.FC> = ({ children, title }) => { + return ( + + + {title} + + {children} + + ); +}; + +const spectrumColors = [ + 'blue', + 'green', + 'orange', + 'yellow', + 'gray', + 'indigo', + 'pink', + 'purple', + 'red', + 'teal', + 'chartreuse', +]; + +const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square']; + +const Shapes = () => { + return ( + + + {shapes.map((shape) => ( + + {spectrumColors.map((color) => ( + + + + ))} + + ))} + + + ); +}; + +const Basic = () => { + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const numberFormatter = useCallback( + (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + + + + + ); +}; + +const AutoScale = () => { + const precipitationData = [ + { + id: 'northeast', + label: 'Northeast', + data: [5.14, 1.53, 5.73, 4.29, 3.78, 3.92, 4.19, 5.54, 2.03, 1.42, 2.95, 3.89], + color: 'rgb(var(--blue40))', + }, + { + id: 'upperMidwest', + label: 'Upper Midwest', + data: [1.44, 0.49, 2.16, 3.67, 5.44, 6.21, 4.02, 3.67, 0.92, 1.47, 3.05, 1.48], + color: 'rgb(var(--green40))', + }, + { + id: 'ohioValley', + label: 'Ohio Valley', + data: [4.74, 1.83, 3.1, 5.42, 5.69, 3.29, 5.02, 2.57, 4.13, 0.79, 4.31, 3.67], + color: 'rgb(var(--orange40))', + }, + { + id: 'southeast', + label: 'Southeast', + data: [5.48, 3.11, 5.73, 2.97, 5.45, 3.28, 7.18, 5.67, 7.93, 1.33, 2.69, 3.21], + color: 'rgb(var(--yellow40))', + }, + { + id: 'northernRockiesAndPlains', + label: 'Northern Rockies and Plains', + data: [0.64, 1.01, 1.06, 2.12, 3.34, 2.65, 1.54, 1.89, 0.95, 0.57, 1.23, 0.67], + color: 'rgb(var(--indigo40))', + }, + { + id: 'south', + label: 'South', + data: [4.19, 1.79, 2.93, 3.84, 5.25, 3.4, 4.27, 1.84, 3.08, 0.52, 4.5, 2.62], + color: 'rgb(var(--pink40))', + }, + { + id: 'southwest', + label: 'Southwest', + data: [1.12, 1.5, 1.52, 0.75, 0.76, 1.27, 1.44, 2.01, 0.62, 1.08, 1.23, 0.25], + color: 'rgb(var(--purple40))', + }, + { + id: 'northwest', + label: 'Northwest', + data: [5.69, 3.67, 3.32, 1.95, 2.08, 1.31, 0.28, 0.81, 0.95, 2.03, 5.45, 5.8], + color: 'rgb(var(--red40))', + }, + { + id: 'west', + label: 'West', + data: [3.39, 4.7, 3.09, 1.07, 0.55, 0.12, 0.23, 0.26, 0.22, 0.4, 2.7, 2.54], + color: 'rgb(var(--teal40))', + }, + ]; + + const xAxisData = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + return ( + + + + 2024 Precipitation by Climate Region + + + + `${value} in`} /> + + + + ); +}; + +const Position = () => { + return ( + + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: [455, 520, 380, 455, 285, 235], + yAxisId: 'revenue', + color: 'rgb(var(--yellow40))', + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: [23, 20, 16, 38, 12, 9], + yAxisId: 'profitMargin', + color: 'var(--color-fgPositive)', + legendShape: 'squircle', + }, + ]} + xAxis={{ + data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + scaleType: 'band', + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + + ); +}; + +const ShapeVariants = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + } + legendPosition="left" + series={[ + { + id: 'pill', + label: 'Pill', + data: [120, 150, 130, 170, 160, 190], + color: 'rgb(var(--blue40))', + legendShape: 'pill', + }, + { + id: 'circle', + label: 'Circle', + data: [80, 110, 95, 125, 115, 140], + color: 'rgb(var(--green40))', + legendShape: 'circle', + }, + { + id: 'square', + label: 'Square', + data: [60, 85, 70, 100, 90, 115], + color: 'rgb(var(--orange40))', + legendShape: 'square', + }, + { + id: 'squircle', + label: 'Squircle', + data: [40, 60, 50, 75, 65, 85], + color: 'rgb(var(--purple40))', + legendShape: 'squircle', + }, + ]} + xAxis={{ data: months }} + yAxis={{ domain: { min: 0 }, showGrid: true }} + /> + + ); +}; + +const DynamicData = () => { + const timeLabels = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const series: CartesianSeries[] = [ + { + id: 'candidate-a', + label: 'Candidate A', + data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38], + color: 'rgb(var(--blue40))', + legendShape: 'circle', + }, + { + id: 'candidate-b', + label: 'Candidate B', + data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35], + color: 'rgb(var(--orange40))', + legendShape: 'circle', + }, + { + id: 'candidate-c', + label: 'Candidate C', + data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27], + color: 'rgb(var(--gray40))', + legendShape: 'circle', + }, + ]; + + const ValueLegendItem = memo(function ValueLegendItem({ + seriesId, + label, + color, + shape, + }: LegendItemProps) { + const { scrubberPosition } = useScrubberContext(); + const { series, dataLength } = useCartesianChartContext(); + + const dataIndex = scrubberPosition ?? dataLength - 1; + + const seriesData = series.find((s) => s.id === seriesId); + const rawValue = seriesData?.data?.[dataIndex]; + + const formattedValue = + rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue as number)}%`; + + return ( + + + {label} + + {formattedValue} + + + ); + }); + + return ( + + + + Election Polls + + + } + legendPosition="top" + series={series} + xAxis={{ + data: timeLabels, + }} + yAxis={{ + domain: { max: 100, min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `${value}%`, + }} + > + + + + + ); +}; + +const Interactive = () => { + const [emphasizedId, setEmphasizedId] = useState(null); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'revenue', + label: 'Revenue', + data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350], + baseColor: '--blue', + }, + { + id: 'expenses', + label: 'Expenses', + data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195], + baseColor: '--orange', + }, + { + id: 'profit', + label: 'Profit', + data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155], + baseColor: '--green', + }, + ], + [], + ); + + const handleToggle = useCallback((seriesId: string) => { + setEmphasizedId((prev) => (prev === seriesId ? null : seriesId)); + }, []); + + const ChipLegendItem = memo(function ChipLegendItem({ seriesId, label }: LegendItemProps) { + const chipRef = useRef(null); + const isEmphasized = emphasizedId === seriesId; + const config = seriesConfig.find((s) => s.id === seriesId); + const baseColor = config?.baseColor ?? '--gray'; + + // Restore focus when chip becomes emphasized + useEffect(() => { + if (isEmphasized && chipRef.current) { + chipRef.current.focus(); + } + }, [isEmphasized]); + + return ( + handleToggle(seriesId)} + style={{ + backgroundColor: `rgb(var(${baseColor}10))`, + borderWidth: 0, + color: 'var(--color-fg)', + outlineColor: `rgb(var(${baseColor}50))`, + }} + > + + + {label} + + + ); + }); + + const series = useMemo(() => { + return seriesConfig.map((config) => { + const isEmphasized = emphasizedId === config.id; + const isDimmed = emphasizedId !== null && !isEmphasized; + + return { + id: config.id, + label: config.label, + data: config.data, + color: `rgb(var(${config.baseColor}40))`, + opacity: isDimmed ? 0.3 : 1, + }; + }); + }, [emphasizedId, seriesConfig]); + + return ( + + + + Financial Overview + + } + legendPosition="top" + series={series} + xAxis={{ + data: months, + }} + yAxis={{ + domain: { min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `$${value}k`, + }} + /> + + + ); +}; + +const PieChartLegend = () => { + const series = [ + { + id: 'stocks', + data: 45, + label: 'Stocks', + color: 'rgb(var(--blue40))', + legendShape: 'circle' as const, + }, + { + id: 'bonds', + data: 25, + label: 'Bonds', + color: 'rgb(var(--green40))', + legendShape: 'circle' as const, + }, + { + id: 'realEstate', + data: 15, + label: 'Real Estate', + color: 'rgb(var(--orange40))', + legendShape: 'circle' as const, + }, + { + id: 'commodities', + data: 10, + label: 'Commodities', + color: 'rgb(var(--purple40))', + legendShape: 'circle' as const, + }, + { + id: 'cash', + data: 5, + label: 'Cash', + color: 'rgb(var(--gray40))', + legendShape: 'circle' as const, + }, + ]; + + return ( + + + + Portfolio Allocation + + } + legendPosition="right" + series={series} + /> + + + ); +}; + +const LegendShapes = () => { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Actual revenue (first 9 months) + const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null]; + + // Forecasted revenue (last 3 months) + const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680]; + + const numberFormatter = useCallback( + (value: number) => + `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`, + [], + ); + + // Pattern settings for dotted fill + const patternSize = 4; + const dotSize = 1; + const patternId = useId(); + const maskId = useId(); + const legendPatternId = useId(); + + // Custom legend indicator that matches the dotted bar pattern + const DottedLegendIndicator = ( + + + + + + + + + + + + + + + ); + + // Custom bar component that renders bars with dotted pattern fill + const DottedBarComponent = memo((props) => { + const { dataX, x, y } = props; + // Create unique IDs per bar so patterns are scoped to each bar + const uniqueMaskId = `${maskId}-${dataX}`; + const uniquePatternId = `${patternId}-${dataX}`; + return ( + <> + + {/* Pattern positioned relative to this bar's origin */} + + + + + + + + + + + + + ); + }); + + return ( + + + + ); +}; + +const DonutChartLegend = () => { + const series = [ + { + id: 'completed', + data: 68, + label: 'Completed', + color: 'var(--color-fgPositive)', + legendShape: 'squircle' as const, + }, + { + id: 'inProgress', + data: 22, + label: 'In Progress', + color: 'rgb(var(--blue40))', + legendShape: 'squircle' as const, + }, + { + id: 'pending', + data: 10, + label: 'Pending', + color: 'rgb(var(--gray40))', + legendShape: 'squircle' as const, + }, + ]; + + return ( + + + + Task Status + + + + + ); +}; + +export const All = () => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/packages/web-visualization/src/chart/legend/index.ts b/packages/web-visualization/src/chart/legend/index.ts new file mode 100644 index 000000000..ba2be3549 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/index.ts @@ -0,0 +1,3 @@ +export * from './DefaultLegendItem'; +export * from './DefaultLegendShape'; +export * from './Legend'; diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index 5e3b71d00..753b50042 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -126,6 +126,7 @@ export const LineChart = memo( yAxisId: s.yAxisId, stackId: s.stackId, gradient: s.gradient, + legendShape: s.legendShape, }), ); }, [series]); diff --git a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx index 3d0386eb9..419934fe0 100644 --- a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx +++ b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx @@ -18,12 +18,9 @@ export type DefaultScrubberBeaconLabelProps = ScrubberBeaconLabelProps & */ export const DefaultScrubberBeaconLabel = memo( ({ - background = 'var(--color-bg', color = 'var(--color-fgPrimary)', elevated = true, - borderRadius = 4, font = 'label1', - verticalAlignment = 'middle', inset = { left: labelHorizontalInset, right: labelHorizontalInset, @@ -36,13 +33,10 @@ export const DefaultScrubberBeaconLabel = memo( return ( {label} diff --git a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx index cc4b1a05e..d8f32c94d 100644 --- a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx @@ -135,6 +135,10 @@ export type ScrubberBaseProps = SharedProps & * By default, all series will be highlighted. */ seriesIds?: string[]; + /** + * Hides the beacon labels while keeping the line label visible (if provided). + */ + hideBeaconLabels?: boolean; /** * Hides the scrubber line. * @note This hides Scrubber's ReferenceLine including the label. @@ -226,6 +230,7 @@ export const Scrubber = memo( ( { seriesIds, + hideBeaconLabels, hideLine, label, accessibilityLabel, @@ -383,7 +388,7 @@ export const Scrubber = memo( testID={testID} transitions={beaconTransitions} /> - {beaconLabels.length > 0 && ( + {!hideBeaconLabels && beaconLabels.length > 0 && ( > & { children: React.ReactNode; - /** - * A reference to the root SVG element, where interaction event handlers will be attached. - */ - svgRef: React.RefObject | null; }; /** @@ -19,7 +15,6 @@ export type ScrubberProviderProps = Partial< */ export const ScrubberProvider: React.FC = ({ children, - svgRef, enableScrubbing, onScrubberPositionChange, }) => { @@ -29,7 +24,7 @@ export const ScrubberProvider: React.FC = ({ throw new Error('ScrubberProvider must be used within a ChartContext'); } - const { getXScale, getXAxis, series } = chartContext; + const { getXScale, getXAxis, series, ref } = chartContext; const [scrubberPosition, setScrubberPosition] = useState(undefined); const getDataIndexFromX = useCallback( @@ -233,9 +228,9 @@ export const ScrubberProvider: React.FC = ({ // Attach event listeners to SVG element useEffect(() => { - if (!svgRef?.current || !enableScrubbing) return; + if (!ref?.current || !enableScrubbing) return; - const svg = svgRef.current; + const svg = ref.current; // Add event listeners svg.addEventListener('mousemove', handleMouseMove); @@ -258,7 +253,7 @@ export const ScrubberProvider: React.FC = ({ svg.removeEventListener('blur', handleBlur); }; }, [ - svgRef, + ref, enableScrubbing, handleMouseMove, handleMouseLeave, diff --git a/packages/web-visualization/src/chart/text/ChartText.tsx b/packages/web-visualization/src/chart/text/ChartText.tsx index 8e731cafb..d442503c7 100644 --- a/packages/web-visualization/src/chart/text/ChartText.tsx +++ b/packages/web-visualization/src/chart/text/ChartText.tsx @@ -175,8 +175,8 @@ export const ChartText = memo( fontWeight, elevated, color = 'var(--color-fgMuted)', - background = elevated ? 'var(--color-bg)' : 'transparent', - borderRadius, + background = elevated ? 'var(--color-bgElevation1)' : 'transparent', + borderRadius = 4, inset: insetInput, onDimensionsChange, style, diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index ca9330e37..df0be9f64 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -17,6 +17,10 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; +export type LegendShapeVariant = 'circle' | 'square' | 'squircle' | 'pill'; + +export type LegendShape = LegendShapeVariant | React.ReactNode; + export type Series = { /** * Id of the series. @@ -33,6 +37,12 @@ export type Series = { * Color will still be used by scrubber beacon labels */ color?: string; + /** + * Legend shape for this series. + * Can be a LegendShapeVariant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; }; export type CartesianSeries = Series & { diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index 95ed097f1..588014b1e 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -2,13 +2,27 @@ import { createContext, useContext } from 'react'; import type { Rect } from '@coinbase/cds-common/types'; import type { AngularAxisConfig, CartesianAxisConfig, RadialAxisConfig } from './axis'; -import type { CartesianSeries, PolarSeries } from './chart'; +import type { CartesianSeries, PolarSeries, Series } from './chart'; import type { ChartScaleFunction } from './scale'; +/** + * Chart context type discriminator. + */ +export type ChartType = 'cartesian' | 'polar'; + /** * Base context value for all chart types. */ export type ChartContextValue = { + /** + * The type of chart. + */ + type: ChartType; + /** + * The series data for the chart. + * Contains common series properties (id, label, color, legendShape). + */ + series: Series[]; /** * Whether to animate the chart. */ @@ -29,13 +43,21 @@ export type ChartContextValue = { * Length of the data domain. */ dataLength: number; + /** + * Reference to the chart's root element (SVG on web, Canvas on mobile). + */ + ref?: React.RefObject; }; /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = ChartContextValue & { +export type CartesianChartContextValue = Omit & { + /** + * The type of chart. + */ + type: 'cartesian'; /** * The series data for the chart. */ @@ -92,7 +114,11 @@ export type CartesianChartContextValue = ChartContextValue & { * Context value for Polar (Angular/Radial) coordinate charts. * Contains axis-specific methods and properties for polar coordinate systems. */ -export type PolarChartContextValue = ChartContextValue & { +export type PolarChartContextValue = Omit & { + /** + * The type of chart. + */ + type: 'polar'; /** * The series data for the chart. */ diff --git a/yarn.lock b/yarn.lock index 8a5d2380a..286d26860 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2609,6 +2609,7 @@ __metadata: "@coinbase/cds-lottie-files": "workspace:^" "@coinbase/cds-utils": "workspace:^" "@coinbase/cds-web": "workspace:^" + "@floating-ui/react-dom": ^2.1.1 react: ^18.3.1 react-dom: ^18.3.1 languageName: unknown