'use client' import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useState } from 'react' import { useChartHoverState } from './useChartHoverState' import { Area, Bar, CartesianGrid, Label, Line, ComposedChart as RechartComposedChart, ReferenceArea, ReferenceLine, Tooltip, XAxis, YAxis, } from 'recharts' import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' import ChartHeader from './ChartHeader' import ChartHighlightActions from './ChartHighlightActions' import { CHART_COLORS, DateTimeFormats, STACKED_CHART_COLORS, updateStackedChartColors, } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' import { calculateTotalChartAggregate, CustomLabel, CustomTooltip, MultiAttribute, } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' export interface ComposedChartProps extends CommonChartProps { attributes: MultiAttribute[] yAxisKey: string xAxisKey: string displayDateInUtc?: boolean onBarClick?: (datum: Datum, tooltipData?: CategoricalChartState) => void emptyStateMessage?: string showLegend?: boolean xAxisIsDate?: boolean XAxisProps?: ComponentProps YAxisProps?: ComponentProps showGrid?: boolean showTooltip?: boolean showTotal?: boolean showMaxValue?: boolean chartHighlight?: ChartHighlight hideChartType?: boolean chartStyle?: string onChartStyleChange?: (style: string) => void updateDateRange: any titleTooltip?: string hideYAxis?: boolean hideHighlightedValue?: boolean syncId?: string docsUrl?: string } export default function ComposedChart({ data, attributes, yAxisKey, xAxisKey, format, customDateFormat = DateTimeFormats.FULL, title, highlightedValue, highlightedLabel, displayDateInUtc, minimalHeader, valuePrecision, className = '', size = 'normal', emptyStateMessage, onBarClick, showLegend = false, xAxisIsDate = true, XAxisProps, YAxisProps, showGrid = false, showTooltip = false, showTotal = true, showMaxValue = false, chartHighlight, hideChartType, chartStyle, onChartStyleChange, updateDateRange, hideYAxis, hideHighlightedValue, syncId, docsUrl, }: ComposedChartProps) { const { resolvedTheme } = useTheme() const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( syncId || 'default' ) const [_activePayload, setActivePayload] = useState(null) const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) const [focusDataIndex, setFocusDataIndex] = useState(null) const [hoveredLabel, setHoveredLabel] = useState(null) const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false) const isDarkMode = resolvedTheme?.includes('dark') useEffect(() => { updateStackedChartColors(isDarkMode ?? false) }, [resolvedTheme]) const { Container } = useChartSize(size) const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) const formatTimestamp = (ts: unknown) => { if (typeof ts !== 'number' && typeof ts !== 'string') { return '' } if (typeof ts === 'number' && ts > 1e14) { return day(ts / 1000).format(customDateFormat) } return day(ts).format(customDateFormat) } const _XAxisProps = XAxisProps || { interval: data.length - 2, angle: 0, tick: false, } const _YAxisProps = YAxisProps || { tickFormatter: (value) => numberFormatter(value, valuePrecision), tick: false, width: 0, } function getHeaderLabel() { if (!xAxisIsDate) { if (!focusDataIndex) return highlightedLabel return data[focusDataIndex]?.[xAxisKey] } return ( (focusDataIndex !== null && data && data[focusDataIndex] !== undefined && (() => { const ts = data[focusDataIndex][xAxisKey] return formatTimestamp(ts) })()) || highlightedLabel ) } function computeHighlightedValue() { const maxAttribute = attributes.find((a) => a.isMaxValue) const referenceLines = attributes.filter( (attribute) => attribute?.provider === 'reference-line' ) const attributesToIgnore = attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? [] const attributesToIgnoreFromTotal = [ ...attributesToIgnore, ...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []), ...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []), ] const lastDataPoint = data[data.length - 1] ? Object.entries(data[data.length - 1]) .map(([key, value]) => ({ dataKey: key, value: value as number, })) .filter( (entry) => entry.dataKey !== 'timestamp' && entry.dataKey !== 'period_start' && attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false) ) : undefined if (focusDataIndex !== null) { return showTotal ? calculateTotalChartAggregate(_activePayload, attributesToIgnoreFromTotal) : data[focusDataIndex]?.[yAxisKey] } if (showTotal && lastDataPoint) { return calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal) } return highlightedValue } function formatHighlightedValue(value: any) { if (typeof value !== 'number') { return value } if (shouldFormatBytes) { const bytesValue = isNetworkChart ? Math.abs(value) : value return formatBytes(bytesValue, valuePrecision) } return numberFormatter(value, valuePrecision) } const maxAttribute = attributes.find((a) => a.isMaxValue) const maxAttributeData = { name: maxAttribute?.attribute, color: CHART_COLORS.REFERENCE_LINE, } const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line') const resolvedHighlightedLabel = getHeaderLabel() const resolvedHighlightedValue = computeHighlightedValue() const showHighlightActions = chartHighlight?.coordinates.left && chartHighlight?.coordinates.right && chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right const chartData = data && !!data[0] ? Object.entries(data[0]) ?.map(([key, value]) => ({ name: key, value: value, })) .filter( (att) => att.name !== 'timestamp' && att.name !== 'period_start' && att.name !== maxAttribute?.attribute && !referenceLines.map((a) => a.attribute).includes(att.name) && attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false) ) .map((att, index) => { const attribute = attributes.find((attr) => attr.attribute === att.name) return { ...att, color: attribute?.color ? resolvedTheme?.includes('dark') ? attribute.color.dark : attribute.color.light : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length], } }) : [] const stackedAttributes = chartData.filter((att) => { const attribute = attributes.find((attr) => attr.attribute === att.name) return !attribute?.isMaxValue }) const isPercentage = format === '%' const isRamChart = !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') && chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) const isDiskSpaceChart = chartData?.some((att: any) => att.name.toLowerCase().includes('disk_space_') ) const isDBSizeChart = chartData?.some((att: any) => att.name.toLowerCase().includes('pg_database_size') ) const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_')) const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart //* // Set the y-axis domain // to the highest value in the chart data for percentage charts // to vertically zoom in on the data // */ const yDomain = [ 0, Math.max(...chartData.map((att) => (typeof att.value === 'number' ? att.value : 0))), ] if (data.length === 0) { return ( ) } return (
{ setIsActiveHoveredChart(true) if (e.activeTooltipIndex !== focusDataIndex) { setFocusDataIndex(e.activeTooltipIndex) setActivePayload(e.activePayload) } setHover(e.activeTooltipIndex) const activeTimestamp = data[e.activeTooltipIndex]?.timestamp chartHighlight?.handleMouseMove({ activeLabel: activeTimestamp?.toString(), coordinates: e.activeLabel, }) }} onMouseDown={(e: any) => { const activeTimestamp = data[e.activeTooltipIndex]?.timestamp chartHighlight?.handleMouseDown({ activeLabel: activeTimestamp?.toString(), coordinates: e.activeLabel, }) }} onMouseUp={chartHighlight?.handleMouseUp} onMouseLeave={(e) => { setIsActiveHoveredChart(false) setFocusDataIndex(null) setActivePayload(null) clearHover() }} onClick={(tooltipData) => { const datum = tooltipData?.activePayload?.[0]?.payload if (onBarClick) onBarClick(datum, tooltipData) }} > {showGrid && } showTooltip ? ( ) : null } /> {chartStyle === 'bar' ? stackedAttributes.map((attribute) => ( a.attribute === attribute?.name)?.stackId ?? '1'} fill={attribute.color} fillOpacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.25 : 1} radius={0.75} opacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.5 : 1} name={ attributes?.find((a) => a.attribute === attribute?.name)?.label || attribute?.name } /> )) : stackedAttributes.map((attribute, i) => ( a.attribute === attribute.name)?.stackId ?? '1'} fill={attribute.color} strokeOpacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.4 : 1} stroke={attribute.color} radius={20} animationDuration={375} fillOpacity={ hoveredLabel && hoveredLabel !== attribute.name ? 0.075 : hoveredLabel === attribute.name ? 0.3 : 0.25 } name={ attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name } dot={false} /> ))} {/* Max value, if available */} {maxAttribute && _showMaxValue && ( )} {referenceLines .filter((line) => line.isReferenceLine) .map((line) => ( ))} {/* Selection highlight */} {showHighlightActions && ( )} {data && (
{xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]} {xAxisIsDate ? formatTimestamp(data[data.length - 1]?.[xAxisKey]) : data[data.length - 1]?.[xAxisKey]}
)} {showLegend && ( )}
) }