import React from 'react'
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
Tooltip,
XAxis,
YAxis,
PieChart,
Pie,
Cell,
ScatterChart,
Scatter,
AreaChart,
Area,
ComposedChart,
} from 'recharts'
import {
ChartType,
RenderChartProps,
TickProps,
AxisType,
AxisIntervalType,
SankeyData,
DotItemDotProps,
FlattenedDataPoint,
} from './types'
import SankeyChartWrapper from './SankeyChartWrapper'
import FunnelChart from './FunnelChart'
import {
formatNumber,
getAxisFormatter,
lightenHexColor,
transformScatterData,
generateConsistentDateTimeTicks,
} from './ChartUtils'
import { parseTimestamp } from './DateTimeFormatter'
import { CustomTooltip } from './CustomTooltip'
import { FOUNDATION_THEME } from '../../tokens'
import Text from '../Text/Text'
import Block from '../Primitives/Block/Block'
import { Button, ButtonType } from '../Button'
import { DEFAULT_COLORS } from './utils'
export const renderChart = ({
chartName,
flattenedData,
chartType,
hoveredKey,
lineKeys,
colors,
setHoveredKey,
data: originalData,
selectedKeys,
isSmallScreen = false,
barsize,
xAxis,
yAxis,
tooltip,
funnelConfig,
noData,
height,
CustomizedDot,
lineSeriesKeys,
onKeyClick,
chartTokens,
}: RenderChartProps) => {
const finalXAxis = {
label: xAxis?.label,
showLabel: xAxis?.showLabel ?? false,
interval: xAxis?.interval,
show: xAxis?.show ?? true,
...xAxis,
}
const finalYAxis = {
label: yAxis?.label,
showLabel: yAxis?.showLabel ?? false,
interval: yAxis?.interval,
show: yAxis?.show ?? true,
...yAxis,
}
const isDateTimeAxis = finalXAxis.type === AxisType.DATE_TIME
// Auto-generate consistent ticks for DATE_TIME axes (like Highcharts)
if (
isDateTimeAxis &&
finalXAxis.autoConsistentTicks !== false &&
!finalXAxis.ticks &&
originalData.length > 0
) {
// Only apply maxTicks if explicitly set, otherwise let the formatter decide
const { ticks } = generateConsistentDateTimeTicks(originalData, {
maxTicks: finalXAxis.maxTicks
? isSmallScreen
? Math.max(3, Math.floor(finalXAxis.maxTicks / 2))
: finalXAxis.maxTicks
: undefined, // Don't limit ticks when maxTicks is not explicitly set
dateOnly: finalXAxis.dateOnly,
timeOnly: finalXAxis.timeOnly,
showYear: finalXAxis.showYear,
useUTC: finalXAxis.useUTC,
formatString: finalXAxis.formatString,
})
finalXAxis.ticks = isDateTimeAxis
? ticks.map((tick) => parseTimestamp(tick) ?? Number(tick))
: ticks
// Enable smart date/time format by default (alternates between date and time)
// Only if user hasn't specified dateOnly, timeOnly, or formatString
if (
finalXAxis.smartDateTimeFormat === undefined &&
!finalXAxis.dateOnly &&
!finalXAxis.timeOnly &&
!finalXAxis.formatString
) {
finalXAxis.smartDateTimeFormat = true
}
}
// When using custom ticks, set interval to 0 to show all ticks.
// On small screens use preserveStartEnd so recharts auto-hides
if (finalXAxis.ticks && finalXAxis.interval === undefined) {
finalXAxis.interval = isSmallScreen
? AxisIntervalType.PRESERVE_START_END
: 0
}
const getColor = (key: string, chartType: ChartType) => {
// Try to find color by matching key first
const colorByKey = colors.find((color) => color.key === key)
// If not found by key, use backup color from DEFAULT_COLORS
const originalIndex = lineKeys.indexOf(key)
const backupColor =
DEFAULT_COLORS[originalIndex % DEFAULT_COLORS.length]
const baseColor = colorByKey || backupColor
const colorValue = baseColor.color
if (chartType === ChartType.BAR) {
return hoveredKey === null
? colorValue
: hoveredKey === key
? colorValue
: lightenHexColor(colorValue, 0.3)
}
if (hoveredKey && hoveredKey !== key) {
return lightenHexColor(colorValue, 0.3)
}
return colorValue
}
const chartConfig = {
tickFill:
chartTokens?.axis.labelColor ?? FOUNDATION_THEME.colors.gray[400],
tickFontSize: 14,
tickFontWeight: FOUNDATION_THEME.font.weight[500],
axisLine: false,
tickLine: false,
labelFill:
chartTokens?.axis.labelColor ?? FOUNDATION_THEME.colors.gray[400],
labelFontSize: 14,
labelFontWeight: FOUNDATION_THEME.font.weight[500],
gridStroke:
chartTokens?.axis.gridLineColor ??
FOUNDATION_THEME.colors.gray[150],
}
const processedData = isDateTimeAxis
? flattenedData.map((item) => {
const numericTimestamp =
typeof item.name === 'number'
? item.name
: parseTimestamp(item.name)
return {
...item,
__blendTimestamp:
numericTimestamp ??
(typeof item.name === 'string'
? Number(item.name)
: item.name),
}
})
: flattenedData
const xAxisDataKey = isDateTimeAxis ? '__blendTimestamp' : 'name'
if (processedData.length === 0) {
return (
{noData?.slot && noData?.slot}
{noData?.title || 'No data available'}
{noData?.subtitle ||
'Data will appear here once available'}
{noData?.button && (
)}
)
}
switch (chartType) {
case ChartType.LINE: {
return (
setHoveredKey(null)}
>
formatNumber(value)
}
tick={
(!finalXAxis.show
? false
: finalXAxis.customTick
? finalXAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: isSmallScreen ? 10 : 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
// textAnchor: 'end',
}) as TickProps
}
label={
finalXAxis.label &&
finalXAxis.showLabel &&
finalXAxis.show &&
!isSmallScreen
? {
value: finalXAxis.label,
position: 'bottom',
offset: 25,
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
{!isSmallScreen && finalYAxis.show && (
formatNumber(value)
}
tick={
(finalYAxis.customTick
? finalYAxis.customTick
: {
fill: FOUNDATION_THEME.colors
.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalYAxis.label && finalYAxis.showLabel
? {
value: finalYAxis.label,
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle' },
offset: -15,
fill: FOUNDATION_THEME.colors
.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
)}
{
const mergedProps = {
...props,
originalData,
chartType,
selectedKeys,
xAxis: finalXAxis,
yAxis: finalYAxis,
}
return tooltip?.content ? (
tooltip.content(mergedProps)
) : (
)
}}
/>
{[...lineKeys]
.sort((a, b) => {
// Put hovered key last so it renders on top
if (a === hoveredKey) return 1
if (b === hoveredKey) return -1
return 0
})
.map((key) => {
// Determine opacity for dots based on hover state
const hasOtherHovered =
hoveredKey !== null && hoveredKey !== key
const dotOpacity = hasOtherHovered ? 0.3 : 1
// Wrap CustomizedDot to pass hover context and apply opacity
const dotRenderer = CustomizedDot
? (props: DotItemDotProps) => {
const dotElement = CustomizedDot(props)
// Apply opacity if it's a React element
if (
dotElement &&
React.isValidElement(dotElement)
) {
const elementProps =
dotElement.props as unknown as Record<
string,
unknown
>
const existingStyle =
elementProps?.style as
| Record
| undefined
return React.cloneElement(
dotElement,
{
...elementProps,
opacity: dotOpacity,
style: {
...existingStyle,
opacity: dotOpacity,
},
} as React.Attributes
)
}
return dotElement
}
: false
return (
}) => {
const dataPoint = originalData.find(
(d) =>
d.name === props.payload?.name
)
const hasError =
!!dataPoint?.data?.[key]?.error
return (
)
}}
animationDuration={350}
onMouseOver={() => setHoveredKey(key)}
dot={dotRenderer}
/>
)
})}
)
}
case ChartType.BAR:
return (
setHoveredKey(null)}
>
formatNumber(value)
}
tick={
(!finalXAxis.show
? false
: finalXAxis.customTick
? finalXAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: isSmallScreen ? 10 : 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalXAxis.label &&
finalXAxis.showLabel &&
finalXAxis.show &&
!isSmallScreen
? {
value: finalXAxis.label,
position: 'bottom',
offset: 35,
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
{!isSmallScreen && finalYAxis.show && (
formatNumber(value)
}
tick={
(finalYAxis.customTick
? finalYAxis.customTick
: {
fill: FOUNDATION_THEME.colors
.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalYAxis.label && finalYAxis.showLabel
? {
value: finalYAxis.label,
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle' },
offset: -15,
fill: FOUNDATION_THEME.colors
.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
)}
{
const mergedProps = {
...props,
originalData,
chartType,
selectedKeys,
}
return tooltip?.content ? (
tooltip.content(mergedProps)
) : (
)
}}
/>
{lineKeys.map((key) => (
setHoveredKey(key)}
onClick={() => onKeyClick?.(key)}
/>
))}
)
case ChartType.LINE_BAR: {
return (
setHoveredKey(null)}
>
formatNumber(value)
}
tick={
(!finalXAxis.show
? false
: finalXAxis.customTick
? finalXAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: isSmallScreen ? 10 : 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
/>
formatNumber(value)
}
tick={
(finalYAxis.customTick
? finalYAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
/>
{
const mergedProps = {
...props,
originalData,
chartType,
selectedKeys,
xAxis: finalXAxis,
yAxis: finalYAxis,
}
return tooltip?.content ? (
tooltip.content(mergedProps)
) : (
)
}}
/>
{(() => {
const barSeries = lineKeys.filter(
(k) =>
!lineSeriesKeys || !lineSeriesKeys.includes(k)
)
return lineKeys.map((key, index) => {
const isLine = lineSeriesKeys
? lineSeriesKeys.includes(key)
: index === lineKeys.length - 1
const color = getColor(
key,
isLine ? ChartType.LINE : ChartType.BAR
)
if (isLine) {
const hasOtherHovered =
hoveredKey !== null && hoveredKey !== key
const dotOpacity = hasOtherHovered ? 0.3 : 1
return (
}) => {
const dataPoint = originalData.find(
(d) =>
d.name ===
props.payload?.name
)
const hasError =
!!dataPoint?.data?.[key]?.error
return (
)
}}
dot={
CustomizedDot
? (props: DotItemDotProps) => {
const dotElement =
CustomizedDot(props)
if (
dotElement &&
React.isValidElement(
dotElement
)
) {
const elementProps =
dotElement.props as unknown as Record<
string,
unknown
>
const existingStyle =
elementProps?.style as
| Record<
string,
unknown
>
| undefined
return React.cloneElement(
dotElement,
{
...elementProps,
opacity:
dotOpacity,
style: {
...existingStyle,
opacity:
dotOpacity,
},
} as React.Attributes
)
}
return dotElement
}
: false
}
animationDuration={350}
onMouseOver={() => setHoveredKey(key)}
onClick={() => onKeyClick?.(key)}
hide={
selectedKeys.length > 0 &&
!selectedKeys.includes(key)
}
/>
)
}
const barIndex = barSeries.indexOf(key)
const isTopBar =
barIndex !== -1 &&
barIndex === barSeries.length - 1
const radius: [number, number, number, number] =
isTopBar ? [4, 4, 0, 0] : [0, 0, 0, 0]
return (
0 &&
!selectedKeys.includes(key)
}
onMouseOver={() => setHoveredKey(key)}
onClick={() => onKeyClick?.(key)}
>
{processedData.map((d, i) => {
// find top non-zero bar series at this index (scan from last to first)
let topSeries: string | null = null
for (
let j = barSeries.length - 1;
j >= 0;
j--
) {
const seriesKey = barSeries[j]
const val = (
d as FlattenedDataPoint
)[seriesKey]
if (
typeof val === 'number'
? val !== 0
: Boolean(val)
) {
topSeries = seriesKey
break
}
}
const cellRadius =
topSeries === key
? [4, 4, 0, 0]
: [0, 0, 0, 0]
return (
| )}
/>
)
})}
|
)
})
})()}
)
}
case ChartType.PIE: {
const keysToInclude =
selectedKeys.length > 0
? lineKeys.filter((key) => selectedKeys.includes(key))
: lineKeys
const pieData = keysToInclude.map((key) => ({
name: key,
value: originalData[0].data[key]?.primary.val || 0,
}))
return (
setHoveredKey(pieData[index].name)
}
onMouseLeave={() => setHoveredKey(null)}
>
{pieData.map((entry, index) => (
|
))}
{
const mergedProps = {
...props,
originalData,
chartType: ChartType.PIE,
selectedKeys,
}
return tooltip?.content ? (
tooltip.content(mergedProps)
) : (
)
}}
/>
)
}
case ChartType.SCATTER: {
const scatterData = transformScatterData(originalData, selectedKeys)
// Group scatter points by series
const seriesByKey: {
[key: string]: Array<{ x: number; y: number; name: string }>
} = {}
scatterData.forEach((point) => {
if (!seriesByKey[point.seriesKey]) {
seriesByKey[point.seriesKey] = []
}
seriesByKey[point.seriesKey].push({
x: point.x,
y: point.y,
name: point.name,
})
})
return (
setHoveredKey(null)}
>
formatNumber(value)
}
tick={
(!finalXAxis.show
? false
: finalXAxis.customTick
? finalXAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: isSmallScreen ? 10 : 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalXAxis.label &&
finalXAxis.showLabel &&
finalXAxis.show &&
!isSmallScreen
? {
value: finalXAxis.label,
position: 'bottom',
offset: 35,
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
formatNumber(value)
}
tick={
(finalYAxis.customTick
? finalYAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalYAxis.label &&
finalYAxis.showLabel &&
!isSmallScreen
? {
value: finalYAxis.label,
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle' },
offset: -15,
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
{
const mergedProps = {
...props,
originalData,
chartType: ChartType.SCATTER,
selectedKeys,
xAxis: finalXAxis,
yAxis: finalYAxis,
}
return tooltip?.content ? (
tooltip.content(mergedProps)
) : (
)
}}
/>
{Object.keys(seriesByKey).map((key) => (
setHoveredKey(key)}
/>
))}
)
}
case ChartType.SANKEY: {
const sankeyData = originalData[0]?.data?.sankeyData
?.primary as unknown as SankeyData
if (!sankeyData || !sankeyData.nodes || !sankeyData.links) {
return (
Invalid Sankey data format
Please provide nodes and links
)
}
// Transform data to handle both numeric indices and string IDs
const nodeMap = new Map()
sankeyData.nodes.forEach((node, index) => {
const nodeId = node.id || node.name || index.toString()
nodeMap.set(nodeId, index)
})
// Extract node and link colors if provided
const nodeColors = sankeyData.nodes
.map((node) => node.color)
.filter(Boolean) as string[]
const linkColors = sankeyData.links
.map((link) => link.color)
.filter(Boolean) as string[]
// Transform links to use numeric indices if they're strings
const transformedData = {
nodes: sankeyData.nodes.map((node) => ({
name: node.name,
})),
links: sankeyData.links.map((link) => {
const sourceIndex =
typeof link.source === 'number'
? link.source
: (nodeMap.get(link.source as string) ?? 0)
const targetIndex =
typeof link.target === 'number'
? link.target
: (nodeMap.get(link.target as string) ?? 0)
return {
source: sourceIndex,
target: targetIndex,
value: link.value,
color: link.color,
hoverColor: link.hoverColor,
// Add source and target names for tooltip
sourceName: sankeyData.nodes[sourceIndex]?.name,
targetName: sankeyData.nodes[targetIndex]?.name,
}
}),
}
const sankeyWidth = isSmallScreen ? 800 : 1200
const defaultHeight = isSmallScreen ? 400 : 600
const sankeyHeight =
typeof height === 'number' ? height : defaultHeight
return (
)
}
case ChartType.FUNNEL: {
return (
)
}
case ChartType.AREA: {
return (
setHoveredKey(null)}
>
formatNumber(value)
}
tick={
(!finalXAxis.show
? false
: finalXAxis.customTick
? finalXAxis.customTick
: {
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: isSmallScreen ? 10 : 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalXAxis.label &&
finalXAxis.showLabel &&
finalXAxis.show &&
!isSmallScreen
? {
value: finalXAxis.label,
position: 'bottom',
offset: 35,
fill: FOUNDATION_THEME.colors.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
{!isSmallScreen && finalYAxis.show && (
formatNumber(value)
}
tick={
(finalYAxis.customTick
? finalYAxis.customTick
: {
fill: FOUNDATION_THEME.colors
.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}) as TickProps
}
label={
finalYAxis.label && finalYAxis.showLabel
? {
value: finalYAxis.label,
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle' },
offset: -15,
fill: FOUNDATION_THEME.colors
.gray[400],
fontSize: 12,
fontWeight:
FOUNDATION_THEME.font.weight[500],
}
: undefined
}
/>
)}
{
const mergedProps = {
...props,
originalData,
chartType,
selectedKeys,
}
return tooltip?.content ? (
tooltip.content(mergedProps)
) : (
)
}}
/>
{lineKeys.map((key) => (
))}
)
}
default:
return Unsupported chart type
}
}