import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useMemo, useState } from 'react' import { Area, Bar, CartesianGrid, Customized, 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, STACKED_CHART_FILLS, updateStackedChartColors, } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' import { computeYAxisDomain, formatPercentage, normalizeStackedSeriesData, 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' import { formatDateTime, useFormatDateTime } from '@/lib/datetime' import { formatBytes, formatBytesMinMB } from '@/lib/helpers' 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 normalizeVisibleStackToPercent?: boolean } interface CustomizedDotProps { formattedGraphicalItems?: Array<{ props?: { points?: Array<{ x: number; y: number }> dataKey?: string } item?: { props?: { points?: Array<{ x: number; y: number }> dataKey?: string } } points?: Array<{ x: number; y: number }> }> } 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, normalizeVisibleStackToPercent = false, }: ComposedChartProps) { const { resolvedTheme } = useTheme() const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( syncId || 'default' ) const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) const [focusDataIndex, setFocusDataIndex] = 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) // When `displayDateInUtc` is set the chart explicitly wants UTC labels. // Otherwise honour the user's selected timezone via the picker. const formatPickerDate = useFormatDateTime() const formatChartDate = (value: number | string) => displayDateInUtc ? formatDateTime(value, { tz: 'UTC', format: customDateFormat }) : formatPickerDate(value, customDateFormat) const formatTimestamp = (ts: unknown) => { if (typeof ts !== 'number' && typeof ts !== 'string') { return '' } if (typeof ts === 'number' && ts > 1e14) { // Microsecond timestamp; convert to milliseconds before formatting. return formatChartDate(ts / 1000) } return formatChartDate(ts) } const _XAxisProps = XAxisProps || { interval: data.length - 2, angle: 0, tick: false, } const _YAxisProps = YAxisProps || { tickFormatter: (value) => numberFormatter(value, valuePrecision), tick: false, width: 0, } const yAxisPadding = useMemo(() => { const needsTopPadding = normalizeVisibleStackToPercent && chartStyle !== 'bar' if (!needsTopPadding) return _YAxisProps.padding return { ..._YAxisProps.padding, top: Math.max(8, _YAxisProps.padding?.top ?? 0), } }, [_YAxisProps.padding, chartStyle, normalizeVisibleStackToPercent]) 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 formatHighlightedValue(value: any) { if (typeof value !== 'number') { return value } if (shouldFormatBytes) { const bytesValue = isNetworkChart ? Math.abs(value) : value const formatted = isMemoryChart ? formatBytesMinMB(bytesValue, valuePrecision) : formatBytes(bytesValue, valuePrecision) return format === 'bytes-per-second' ? `${formatted}/s` : formatted } if (format === '%') { return formatPercentage(value, valuePrecision) } if (valuePrecision === 0 && value > 0 && value < 1) { return '<1' } const formatted = numberFormatter(value, valuePrecision) if (typeof format === 'string' && format) { return `${formatted}${format}` } return formatted } function computeHighlightedValue() { 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) { const focusedDataPoint = data[focusDataIndex] ? Object.entries(data[focusDataIndex]) .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 return showTotal ? calculateTotalChartAggregate(focusedDataPoint ?? [], attributesToIgnoreFromTotal) : data[focusDataIndex]?.[yAxisKey] } if (showTotal && lastDataPoint) { return calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal) } return highlightedValue } const maxAttribute = attributes.find((a) => a.isMaxValue) const maxAttributeData = { name: maxAttribute?.attribute, color: CHART_COLORS.REFERENCE_LINE, } const referenceLines = attributes.filter((attribute) => { return 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 ? isDarkMode ? attribute.color.dark : attribute.color.light : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length], fill: attribute?.fill ? isDarkMode ? attribute.fill.dark : attribute.fill.light : STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length], } }) : [] const stackedAttributes = chartData.filter((att) => { const attribute = attributes.find((attr) => attr.attribute === att.name) return !attribute?.isMaxValue }) const visibleAttributes = useMemo( () => stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)), [stackedAttributes, hiddenAttributes] ) const displayData = useMemo( () => normalizeVisibleStackToPercent ? normalizeStackedSeriesData({ data, attributeNames: visibleAttributes.map((attribute) => attribute.name), }) : data, [data, normalizeVisibleStackToPercent, visibleAttributes] ) const isPercentage = format === '%' const isRamChart = !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') && chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) const isSwapChart = chartData?.some((att: any) => att.name.toLowerCase().includes('swap_')) const isMemoryChart = isRamChart || isSwapChart 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 || isMemoryChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart const yMaxFromVisible = Math.max( 0, ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0)) ) const yAxisDomain = useMemo( () => computeYAxisDomain({ isPercentage, showMaxValue, yMaxFromVisible, maxAttributeKey: maxAttribute?.attribute, showMaxLine: _showMaxValue, data, visibleAttributeNames: visibleAttributes.map((a) => a.name), }), [ isPercentage, showMaxValue, yMaxFromVisible, maxAttribute, _showMaxValue, data, visibleAttributes, ] ) if (data.length === 0) { return ( ) } return (
{ if (activeTooltipIndex === undefined || activeTooltipIndex === null) return setIsActiveHoveredChart(true) if (activeTooltipIndex !== focusDataIndex) { setFocusDataIndex(activeTooltipIndex) } setHover(activeTooltipIndex) const activeTimestamp = data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp chartHighlight?.handleMouseMove({ activeLabel: activeTimestamp?.toString(), coordinates: activeLabel, }) }} onMouseDown={({ activeLabel, activeTooltipIndex }) => { if (activeTooltipIndex === undefined || activeTooltipIndex === null) return const activeTimestamp = data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp chartHighlight?.handleMouseDown({ activeLabel: activeTimestamp?.toString(), coordinates: activeLabel, }) }} onMouseUp={chartHighlight?.handleMouseUp} onMouseLeave={() => { setIsActiveHoveredChart(false) setFocusDataIndex(null) clearHover() }} onClick={(tooltipData) => { const datum = tooltipData?.activePayload?.[0]?.payload if (onBarClick) onBarClick(datum, tooltipData) }} > {showGrid && } {visibleAttributes.map((attribute) => ( ))} {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) => ( a.attribute === attribute.name)?.label || attribute.name } dot={false} activeDot={false} /> ))} {/* Max value, if available */} {maxAttribute && _showMaxValue && ( )} {referenceLines .filter((line) => { return line.isReferenceLine }) .map((line) => ( ))} {/* Selection highlight */} {showHighlightActions && ( )} showTooltip && !showHighlightActions ? ( ) : null } cursor={{ stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)', strokeWidth: 1, }} /> { const { formattedGraphicalItems } = props if (!formattedGraphicalItems || focusDataIndex === null) return null return ( {formattedGraphicalItems.map((item, index: number) => { const points = item.props?.points || item.item?.props?.points || item.points const dataKey = item.props?.dataKey || item.item?.props?.dataKey if (!points || !points[focusDataIndex]) return null const point = points[focusDataIndex] const attribute = visibleAttributes.find((a) => a.name === dataKey) if (!attribute) return null return ( ) })} ) }} /> {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) { // 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} />
)}
) }