Files
supabase/apps/studio/components/ui/Charts/ComposedChart.tsx
Jordi Enric c9033b1035 FE-1864 FE-1868 reports kaizen 2 (#38881)
* make opts configurable per report

* Minor nits

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2025-09-23 13:56:48 +02:00

549 lines
18 KiB
TypeScript

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 { ChartHighlightActions, ChartHighlightAction } 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<D = Datum> extends CommonChartProps<D> {
attributes: MultiAttribute[]
yAxisKey: string
xAxisKey: string
displayDateInUtc?: boolean
onBarClick?: (datum: Datum, tooltipData?: CategoricalChartState) => void
emptyStateMessage?: string
showLegend?: boolean
xAxisIsDate?: boolean
XAxisProps?: ComponentProps<typeof XAxis>
YAxisProps?: ComponentProps<typeof YAxis>
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
sql?: string
highlightActions?: ChartHighlightAction[]
}
export 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,
sql,
highlightActions,
}: ComposedChartProps) {
const { resolvedTheme } = useTheme()
const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
syncId || 'default'
)
const [_activePayload, setActivePayload] = useState<any>(null)
const [_showMaxValue, setShowMaxValue] = useState(showMaxValue)
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
const [hoveredLabel, setHoveredLabel] = useState<string | null>(null)
const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false)
const [hiddenAttributes, setHiddenAttributes] = useState<Set<string>>(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
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 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 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 yMaxFromVisible = Math.max(
0,
...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0))
)
const yDomain = [0, yMaxFromVisible]
if (data.length === 0) {
return (
<NoDataPlaceholder
message={emptyStateMessage}
description="It may take up to 24 hours for data to refresh"
size={size}
className={className}
attribute={title}
format={format}
/>
)
}
return (
<div className={cn('flex flex-col gap-y-3', className)}>
<ChartHeader
hideHighlightedValue={hideHighlightedValue}
title={title}
format={format}
customDateFormat={customDateFormat}
highlightedValue={formatHighlightedValue(resolvedHighlightedValue)}
highlightedLabel={resolvedHighlightedLabel}
minimalHeader={minimalHeader}
hideChartType={hideChartType}
chartStyle={chartStyle}
onChartStyleChange={onChartStyleChange}
showMaxValue={_showMaxValue}
setShowMaxValue={maxAttribute ? setShowMaxValue : undefined}
docsUrl={docsUrl}
syncId={syncId}
data={data}
xAxisKey={xAxisKey}
yAxisKey={yAxisKey}
xAxisIsDate={xAxisIsDate}
displayDateInUtc={displayDateInUtc}
valuePrecision={valuePrecision}
shouldFormatBytes={shouldFormatBytes}
isNetworkChart={isNetworkChart}
attributes={attributes}
sql={sql}
/>
<Container className="relative z-10">
<RechartComposedChart
data={data}
syncId={syncId}
style={{ cursor: 'crosshair' }}
onMouseMove={(e: any) => {
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 && <CartesianGrid stroke={CHART_COLORS.AXIS} />}
<YAxis
{..._YAxisProps}
hide={hideYAxis}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
domain={isPercentage && !showMaxValue ? yDomain : ['auto', 'auto']}
key={yAxisKey}
/>
<XAxis
{..._XAxisProps}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
tickMargin={8}
minTickGap={3}
key={xAxisKey}
/>
<Tooltip
content={(props) =>
showTooltip && !showHighlightActions ? (
<CustomTooltip
{...props}
format={format}
isPercentage={isPercentage}
label={resolvedHighlightedLabel}
attributes={attributes}
valuePrecision={valuePrecision}
showTotal={showTotal}
isActiveHoveredChart={
isActiveHoveredChart || (!!syncId && syncTooltip && hoveredIndex !== null)
}
/>
) : null
}
/>
{chartStyle === 'bar'
? visibleAttributes.map((attribute) => (
<Bar
key={attribute.name}
dataKey={attribute.name}
stackId={attributes?.find((a) => 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) => (
<Area
key={attribute.name}
type="step"
dataKey={attribute.name}
stackId={attributes?.find((a) => 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 && (
<Line
key={maxAttribute.attribute}
type="stepAfter"
dataKey={maxAttribute.attribute}
stroke={CHART_COLORS.REFERENCE_LINE}
strokeWidth={2}
strokeDasharray={maxAttribute.strokeDasharray ?? '3 3'}
dot={false}
name={maxAttribute.label}
/>
)}
{referenceLines
.filter((line) => line.isReferenceLine)
.map((line) => (
<ReferenceLine
key={line.attribute}
y={line.value}
strokeWidth={1}
{...line}
color={line.color?.dark}
strokeDasharray={line.strokeDasharray ?? '3 3'}
label={undefined}
>
<Label
value={line.label}
position="insideTopRight"
fill={CHART_COLORS.REFERENCE_LINE_TEXT}
className="text-xs"
style={{ fill: CHART_COLORS.REFERENCE_LINE_TEXT }}
/>
</ReferenceLine>
))}
{/* Selection highlight */}
{showHighlightActions && (
<ReferenceArea
x1={chartHighlight?.coordinates.left}
x2={chartHighlight?.coordinates.right}
strokeOpacity={0.5}
stroke={isDarkMode ? '#FFFFFF' : '#0C3925'}
fill={isDarkMode ? '#FFFFFF' : '#0C3925'}
fillOpacity={0.2}
/>
)}
</RechartComposedChart>
<ChartHighlightActions
chartHighlight={chartHighlight}
updateDateRange={updateDateRange}
actions={highlightActions}
/>
</Container>
{data && (
<div
className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs"
style={{ marginLeft: YAxisProps?.width }}
>
<span>{xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]}</span>
<span>
{xAxisIsDate
? formatTimestamp(data[data.length - 1]?.[xAxisKey])
: data[data.length - 1]?.[xAxisKey]}
</span>
</div>
)}
{showLegend && (
<CustomLabel
payload={[maxAttributeData, ...chartData]}
attributes={attributes}
showMaxValue={_showMaxValue}
onLabelHover={setHoveredLabel}
onToggleAttribute={(attribute, options) => {
setHiddenAttributes((prev) => {
if (options?.exclusive) {
const next = new Set<string>()
// 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}
/>
)}
</div>
)
}