import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useState } from 'react' 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 { ChartHighlightAction, 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' import { useChartHoverState } from './useChartHoverState' export interface ComposedChartProps extends CommonChartProps { chartId?: string 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 hideHighlightedLabel?: boolean hideHighlightArea?: boolean syncId?: string docsUrl?: string sql?: string highlightActions?: ChartHighlightAction[] showNewBadge?: boolean } export function ComposedChart({ chartId, 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, hideHighlightedLabel = false, hideHighlightArea = false, syncId, docsUrl, sql, highlightActions, titleTooltip, showNewBadge, }: 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 [hiddenAttributes, setHiddenAttributes] = useState>(new Set()) const isDarkMode = resolvedTheme?.includes('dark') useEffect(() => { updateStackedChartColors(isDarkMode ?? false) }, [isDarkMode]) 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] : []), ...Array.from(hiddenAttributes), ] 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 const formatted = formatBytes(bytesValue, valuePrecision) return format === 'bytes-per-second' ? `${formatted}/s` : formatted } if (valuePrecision === 0 && value > 0 && value < 1) { return '<1' } 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 visibleAttributes = stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)) 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 isBytesFormat = format === 'bytes' || format === 'bytes-per-second' const shouldFormatBytes = isBytesFormat || 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 yMaxFromVisible = Math.max( 0, ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0)) ) const yDomain = [0, yMaxFromVisible] if (data.length === 0) { return ( ) } return (
{ if (!activeTooltipIndex) return setIsActiveHoveredChart(true) if (activeTooltipIndex !== focusDataIndex) { setFocusDataIndex(activeTooltipIndex) setActivePayload(activePayload ?? []) } setHover(activeTooltipIndex) const activeTimestamp = data[activeTooltipIndex]?.timestamp chartHighlight?.handleMouseMove({ activeLabel: activeTimestamp?.toString(), coordinates: activeLabel, }) }} onMouseDown={({ activeLabel, activeTooltipIndex }) => { if (!activeTooltipIndex) return const activeTimestamp = data[activeTooltipIndex]?.timestamp chartHighlight?.handleMouseDown({ activeLabel: activeTimestamp?.toString(), coordinates: activeLabel, }) }} onMouseUp={chartHighlight?.handleMouseUp} onMouseLeave={() => { setIsActiveHoveredChart(false) setFocusDataIndex(null) setActivePayload(null) clearHover() }} onClick={(tooltipData) => { const datum = tooltipData?.activePayload?.[0]?.payload if (onBarClick) onBarClick(datum, tooltipData) }} > {showGrid && } showTooltip && !showHighlightActions ? ( ) : null } /> {chartStyle === 'bar' ? visibleAttributes.map((attribute) => ( a.attribute === attribute?.name)?.stackId ?? '1'} fill={attribute.color} radius={0.75} opacity={1} name={ attributes?.find((a) => a.attribute === attribute?.name)?.label || attribute?.name } maxBarSize={24} /> )) : visibleAttributes.map((attribute, i) => ( a.attribute === attribute.name)?.stackId ?? '1'} fill={attribute.color} 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 && (
{ setHiddenAttributes((prev) => { if (options?.exclusive) { const next = new Set() // Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible. const allNames = chartData.map((c) => c.name) const allHiddenExcept = allNames.filter((n) => n !== attribute) const isAlreadyExclusive = allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute) return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept) } const next = new Set(prev) if (next.has(attribute)) { next.delete(attribute) } else { next.add(attribute) } return next }) }} hiddenAttributes={hiddenAttributes} />
)}
) }