ComposedChart.tsx735 lines · main
| 1 | import { useTheme } from 'next-themes' |
| 2 | import { ComponentProps, useEffect, useMemo, useState } from 'react' |
| 3 | import { |
| 4 | Area, |
| 5 | Bar, |
| 6 | CartesianGrid, |
| 7 | Customized, |
| 8 | Label, |
| 9 | Line, |
| 10 | ComposedChart as RechartComposedChart, |
| 11 | ReferenceArea, |
| 12 | ReferenceLine, |
| 13 | Tooltip, |
| 14 | XAxis, |
| 15 | YAxis, |
| 16 | } from 'recharts' |
| 17 | import { CategoricalChartState } from 'recharts/types/chart/types' |
| 18 | import { cn } from 'ui' |
| 19 | |
| 20 | import { ChartHeader } from './ChartHeader' |
| 21 | import { ChartHighlightAction, ChartHighlightActions } from './ChartHighlightActions' |
| 22 | import { |
| 23 | CHART_COLORS, |
| 24 | DateTimeFormats, |
| 25 | STACKED_CHART_COLORS, |
| 26 | STACKED_CHART_FILLS, |
| 27 | updateStackedChartColors, |
| 28 | } from './Charts.constants' |
| 29 | import { CommonChartProps, Datum } from './Charts.types' |
| 30 | import { |
| 31 | computeYAxisDomain, |
| 32 | formatPercentage, |
| 33 | normalizeStackedSeriesData, |
| 34 | numberFormatter, |
| 35 | useChartSize, |
| 36 | } from './Charts.utils' |
| 37 | import { |
| 38 | calculateTotalChartAggregate, |
| 39 | CustomLabel, |
| 40 | CustomTooltip, |
| 41 | MultiAttribute, |
| 42 | } from './ComposedChart.utils' |
| 43 | import NoDataPlaceholder from './NoDataPlaceholder' |
| 44 | import { ChartHighlight } from './useChartHighlight' |
| 45 | import { useChartHoverState } from './useChartHoverState' |
| 46 | import { formatDateTime, useFormatDateTime } from '@/lib/datetime' |
| 47 | import { formatBytes, formatBytesMinMB } from '@/lib/helpers' |
| 48 | |
| 49 | export interface ComposedChartProps<D = Datum> extends CommonChartProps<D> { |
| 50 | chartId?: string |
| 51 | attributes: MultiAttribute[] |
| 52 | yAxisKey: string |
| 53 | xAxisKey: string |
| 54 | displayDateInUtc?: boolean |
| 55 | onBarClick?: (datum: Datum, tooltipData?: CategoricalChartState) => void |
| 56 | emptyStateMessage?: string |
| 57 | showLegend?: boolean |
| 58 | xAxisIsDate?: boolean |
| 59 | XAxisProps?: ComponentProps<typeof XAxis> |
| 60 | YAxisProps?: ComponentProps<typeof YAxis> |
| 61 | showGrid?: boolean |
| 62 | showTooltip?: boolean |
| 63 | showTotal?: boolean |
| 64 | showMaxValue?: boolean |
| 65 | chartHighlight?: ChartHighlight |
| 66 | hideChartType?: boolean |
| 67 | chartStyle?: string |
| 68 | onChartStyleChange?: (style: string) => void |
| 69 | updateDateRange: any |
| 70 | titleTooltip?: string |
| 71 | hideYAxis?: boolean |
| 72 | hideHighlightedValue?: boolean |
| 73 | hideHighlightedLabel?: boolean |
| 74 | hideHighlightArea?: boolean |
| 75 | syncId?: string |
| 76 | docsUrl?: string |
| 77 | sql?: string |
| 78 | highlightActions?: ChartHighlightAction[] |
| 79 | showNewBadge?: boolean |
| 80 | normalizeVisibleStackToPercent?: boolean |
| 81 | } |
| 82 | |
| 83 | interface CustomizedDotProps { |
| 84 | formattedGraphicalItems?: Array<{ |
| 85 | props?: { |
| 86 | points?: Array<{ x: number; y: number }> |
| 87 | dataKey?: string |
| 88 | } |
| 89 | item?: { |
| 90 | props?: { |
| 91 | points?: Array<{ x: number; y: number }> |
| 92 | dataKey?: string |
| 93 | } |
| 94 | } |
| 95 | points?: Array<{ x: number; y: number }> |
| 96 | }> |
| 97 | } |
| 98 | |
| 99 | export function ComposedChart({ |
| 100 | chartId, |
| 101 | data, |
| 102 | attributes, |
| 103 | yAxisKey, |
| 104 | xAxisKey, |
| 105 | format, |
| 106 | customDateFormat = DateTimeFormats.FULL, |
| 107 | title, |
| 108 | highlightedValue, |
| 109 | highlightedLabel, |
| 110 | displayDateInUtc, |
| 111 | minimalHeader, |
| 112 | valuePrecision, |
| 113 | className = '', |
| 114 | size = 'normal', |
| 115 | emptyStateMessage, |
| 116 | onBarClick, |
| 117 | showLegend = false, |
| 118 | xAxisIsDate = true, |
| 119 | XAxisProps, |
| 120 | YAxisProps, |
| 121 | showGrid = false, |
| 122 | showTooltip = false, |
| 123 | showTotal = true, |
| 124 | showMaxValue = false, |
| 125 | chartHighlight, |
| 126 | hideChartType, |
| 127 | chartStyle, |
| 128 | onChartStyleChange, |
| 129 | updateDateRange, |
| 130 | hideYAxis, |
| 131 | hideHighlightedValue, |
| 132 | hideHighlightedLabel = false, |
| 133 | hideHighlightArea = false, |
| 134 | syncId, |
| 135 | docsUrl, |
| 136 | sql, |
| 137 | highlightActions, |
| 138 | titleTooltip, |
| 139 | showNewBadge, |
| 140 | normalizeVisibleStackToPercent = false, |
| 141 | }: ComposedChartProps) { |
| 142 | const { resolvedTheme } = useTheme() |
| 143 | const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( |
| 144 | syncId || 'default' |
| 145 | ) |
| 146 | const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) |
| 147 | const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null) |
| 148 | const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false) |
| 149 | const [hiddenAttributes, setHiddenAttributes] = useState<Set<string>>(new Set()) |
| 150 | const isDarkMode = resolvedTheme?.includes('dark') |
| 151 | |
| 152 | useEffect(() => { |
| 153 | updateStackedChartColors(isDarkMode ?? false) |
| 154 | }, [isDarkMode]) |
| 155 | |
| 156 | const { Container } = useChartSize(size) |
| 157 | |
| 158 | // When `displayDateInUtc` is set the chart explicitly wants UTC labels. |
| 159 | // Otherwise honour the user's selected timezone via the picker. |
| 160 | const formatPickerDate = useFormatDateTime() |
| 161 | const formatChartDate = (value: number | string) => |
| 162 | displayDateInUtc |
| 163 | ? formatDateTime(value, { tz: 'UTC', format: customDateFormat }) |
| 164 | : formatPickerDate(value, customDateFormat) |
| 165 | |
| 166 | const formatTimestamp = (ts: unknown) => { |
| 167 | if (typeof ts !== 'number' && typeof ts !== 'string') { |
| 168 | return '' |
| 169 | } |
| 170 | |
| 171 | if (typeof ts === 'number' && ts > 1e14) { |
| 172 | // Microsecond timestamp; convert to milliseconds before formatting. |
| 173 | return formatChartDate(ts / 1000) |
| 174 | } |
| 175 | |
| 176 | return formatChartDate(ts) |
| 177 | } |
| 178 | |
| 179 | const _XAxisProps = XAxisProps || { |
| 180 | interval: data.length - 2, |
| 181 | angle: 0, |
| 182 | tick: false, |
| 183 | } |
| 184 | |
| 185 | const _YAxisProps = YAxisProps || { |
| 186 | tickFormatter: (value) => numberFormatter(value, valuePrecision), |
| 187 | tick: false, |
| 188 | width: 0, |
| 189 | } |
| 190 | const yAxisPadding = useMemo(() => { |
| 191 | const needsTopPadding = normalizeVisibleStackToPercent && chartStyle !== 'bar' |
| 192 | if (!needsTopPadding) return _YAxisProps.padding |
| 193 | |
| 194 | return { |
| 195 | ..._YAxisProps.padding, |
| 196 | top: Math.max(8, _YAxisProps.padding?.top ?? 0), |
| 197 | } |
| 198 | }, [_YAxisProps.padding, chartStyle, normalizeVisibleStackToPercent]) |
| 199 | |
| 200 | function getHeaderLabel() { |
| 201 | if (!xAxisIsDate) { |
| 202 | if (!focusDataIndex) return highlightedLabel |
| 203 | return data[focusDataIndex]?.[xAxisKey] |
| 204 | } |
| 205 | return ( |
| 206 | (focusDataIndex !== null && |
| 207 | data && |
| 208 | data[focusDataIndex] !== undefined && |
| 209 | (() => { |
| 210 | const ts = data[focusDataIndex][xAxisKey] |
| 211 | return formatTimestamp(ts) |
| 212 | })()) || |
| 213 | highlightedLabel |
| 214 | ) |
| 215 | } |
| 216 | |
| 217 | function formatHighlightedValue(value: any) { |
| 218 | if (typeof value !== 'number') { |
| 219 | return value |
| 220 | } |
| 221 | |
| 222 | if (shouldFormatBytes) { |
| 223 | const bytesValue = isNetworkChart ? Math.abs(value) : value |
| 224 | const formatted = isMemoryChart |
| 225 | ? formatBytesMinMB(bytesValue, valuePrecision) |
| 226 | : formatBytes(bytesValue, valuePrecision) |
| 227 | return format === 'bytes-per-second' ? `${formatted}/s` : formatted |
| 228 | } |
| 229 | |
| 230 | if (format === '%') { |
| 231 | return formatPercentage(value, valuePrecision) |
| 232 | } |
| 233 | |
| 234 | if (valuePrecision === 0 && value > 0 && value < 1) { |
| 235 | return '<1' |
| 236 | } |
| 237 | |
| 238 | const formatted = numberFormatter(value, valuePrecision) |
| 239 | if (typeof format === 'string' && format) { |
| 240 | return `${formatted}${format}` |
| 241 | } |
| 242 | return formatted |
| 243 | } |
| 244 | |
| 245 | function computeHighlightedValue() { |
| 246 | const referenceLines = attributes.filter( |
| 247 | (attribute) => attribute?.provider === 'reference-line' |
| 248 | ) |
| 249 | |
| 250 | const attributesToIgnore = |
| 251 | attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? [] |
| 252 | const attributesToIgnoreFromTotal = [ |
| 253 | ...attributesToIgnore, |
| 254 | ...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []), |
| 255 | ...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []), |
| 256 | ...Array.from(hiddenAttributes), |
| 257 | ] |
| 258 | |
| 259 | const lastDataPoint = data[data.length - 1] |
| 260 | ? Object.entries(data[data.length - 1]) |
| 261 | .map(([key, value]) => ({ |
| 262 | dataKey: key, |
| 263 | value: value as number, |
| 264 | })) |
| 265 | .filter( |
| 266 | (entry) => |
| 267 | entry.dataKey !== 'timestamp' && |
| 268 | entry.dataKey !== 'period_start' && |
| 269 | attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false) |
| 270 | ) |
| 271 | : undefined |
| 272 | |
| 273 | if (focusDataIndex !== null) { |
| 274 | const focusedDataPoint = data[focusDataIndex] |
| 275 | ? Object.entries(data[focusDataIndex]) |
| 276 | .map(([key, value]) => ({ |
| 277 | dataKey: key, |
| 278 | value: value as number, |
| 279 | })) |
| 280 | .filter( |
| 281 | (entry) => |
| 282 | entry.dataKey !== 'timestamp' && |
| 283 | entry.dataKey !== 'period_start' && |
| 284 | attributes.some( |
| 285 | (attr) => attr.attribute === entry.dataKey && attr.enabled !== false |
| 286 | ) |
| 287 | ) |
| 288 | : undefined |
| 289 | |
| 290 | return showTotal |
| 291 | ? calculateTotalChartAggregate(focusedDataPoint ?? [], attributesToIgnoreFromTotal) |
| 292 | : data[focusDataIndex]?.[yAxisKey] |
| 293 | } |
| 294 | |
| 295 | if (showTotal && lastDataPoint) { |
| 296 | return calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal) |
| 297 | } |
| 298 | |
| 299 | return highlightedValue |
| 300 | } |
| 301 | |
| 302 | const maxAttribute = attributes.find((a) => a.isMaxValue) |
| 303 | const maxAttributeData = { |
| 304 | name: maxAttribute?.attribute, |
| 305 | color: CHART_COLORS.REFERENCE_LINE, |
| 306 | } |
| 307 | |
| 308 | const referenceLines = attributes.filter((attribute) => { |
| 309 | return attribute?.provider === 'reference-line' |
| 310 | }) |
| 311 | |
| 312 | const resolvedHighlightedLabel = getHeaderLabel() |
| 313 | |
| 314 | const resolvedHighlightedValue = computeHighlightedValue() |
| 315 | |
| 316 | const showHighlightActions = |
| 317 | chartHighlight?.coordinates.left && |
| 318 | chartHighlight?.coordinates.right && |
| 319 | chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right |
| 320 | |
| 321 | const chartData = |
| 322 | data && !!data[0] |
| 323 | ? Object.entries(data[0]) |
| 324 | ?.map(([key, value]) => ({ |
| 325 | name: key, |
| 326 | value: value, |
| 327 | })) |
| 328 | .filter( |
| 329 | (att) => |
| 330 | att.name !== 'timestamp' && |
| 331 | att.name !== 'period_start' && |
| 332 | att.name !== maxAttribute?.attribute && |
| 333 | !referenceLines.map((a) => a.attribute).includes(att.name) && |
| 334 | attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false) |
| 335 | ) |
| 336 | .map((att, index) => { |
| 337 | const attribute = attributes.find((attr) => attr.attribute === att.name) |
| 338 | return { |
| 339 | ...att, |
| 340 | color: attribute?.color |
| 341 | ? isDarkMode |
| 342 | ? attribute.color.dark |
| 343 | : attribute.color.light |
| 344 | : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length], |
| 345 | fill: attribute?.fill |
| 346 | ? isDarkMode |
| 347 | ? attribute.fill.dark |
| 348 | : attribute.fill.light |
| 349 | : STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length], |
| 350 | } |
| 351 | }) |
| 352 | : [] |
| 353 | |
| 354 | const stackedAttributes = chartData.filter((att) => { |
| 355 | const attribute = attributes.find((attr) => attr.attribute === att.name) |
| 356 | return !attribute?.isMaxValue |
| 357 | }) |
| 358 | |
| 359 | const visibleAttributes = useMemo( |
| 360 | () => stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)), |
| 361 | [stackedAttributes, hiddenAttributes] |
| 362 | ) |
| 363 | const displayData = useMemo( |
| 364 | () => |
| 365 | normalizeVisibleStackToPercent |
| 366 | ? normalizeStackedSeriesData({ |
| 367 | data, |
| 368 | attributeNames: visibleAttributes.map((attribute) => attribute.name), |
| 369 | }) |
| 370 | : data, |
| 371 | [data, normalizeVisibleStackToPercent, visibleAttributes] |
| 372 | ) |
| 373 | |
| 374 | const isPercentage = format === '%' |
| 375 | const isRamChart = |
| 376 | !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') && |
| 377 | chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) |
| 378 | const isSwapChart = chartData?.some((att: any) => att.name.toLowerCase().includes('swap_')) |
| 379 | const isMemoryChart = isRamChart || isSwapChart |
| 380 | const isDiskSpaceChart = chartData?.some((att: any) => |
| 381 | att.name.toLowerCase().includes('disk_space_') |
| 382 | ) |
| 383 | const isDBSizeChart = chartData?.some((att: any) => |
| 384 | att.name.toLowerCase().includes('pg_database_size') |
| 385 | ) |
| 386 | const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_')) |
| 387 | const isBytesFormat = format === 'bytes' || format === 'bytes-per-second' |
| 388 | const shouldFormatBytes = |
| 389 | isBytesFormat || isMemoryChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart |
| 390 | const yMaxFromVisible = Math.max( |
| 391 | 0, |
| 392 | ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0)) |
| 393 | ) |
| 394 | |
| 395 | const yAxisDomain = useMemo( |
| 396 | () => |
| 397 | computeYAxisDomain({ |
| 398 | isPercentage, |
| 399 | showMaxValue, |
| 400 | yMaxFromVisible, |
| 401 | maxAttributeKey: maxAttribute?.attribute, |
| 402 | showMaxLine: _showMaxValue, |
| 403 | data, |
| 404 | visibleAttributeNames: visibleAttributes.map((a) => a.name), |
| 405 | }), |
| 406 | [ |
| 407 | isPercentage, |
| 408 | showMaxValue, |
| 409 | yMaxFromVisible, |
| 410 | maxAttribute, |
| 411 | _showMaxValue, |
| 412 | data, |
| 413 | visibleAttributes, |
| 414 | ] |
| 415 | ) |
| 416 | |
| 417 | if (data.length === 0) { |
| 418 | return ( |
| 419 | <NoDataPlaceholder |
| 420 | hideTotalPlaceholder={highlightedValue === undefined} |
| 421 | message={emptyStateMessage} |
| 422 | description="It may take up to 24 hours for data to refresh" |
| 423 | size={size} |
| 424 | className={className} |
| 425 | attribute={title} |
| 426 | format={format} |
| 427 | titleTooltip={titleTooltip} |
| 428 | /> |
| 429 | ) |
| 430 | } |
| 431 | |
| 432 | return ( |
| 433 | <div className={cn('flex flex-col gap-y-3', className)}> |
| 434 | <ChartHeader |
| 435 | hideHighlightedValue={hideHighlightedValue} |
| 436 | title={title} |
| 437 | showNewBadge={showNewBadge} |
| 438 | format={format} |
| 439 | hideHighlightedLabel={hideHighlightedLabel} |
| 440 | hideHighlightArea={hideHighlightArea} |
| 441 | titleTooltip={titleTooltip} |
| 442 | customDateFormat={customDateFormat} |
| 443 | highlightedValue={formatHighlightedValue(resolvedHighlightedValue)} |
| 444 | highlightedLabel={resolvedHighlightedLabel} |
| 445 | minimalHeader={minimalHeader} |
| 446 | hideChartType={hideChartType} |
| 447 | chartStyle={chartStyle} |
| 448 | onChartStyleChange={onChartStyleChange} |
| 449 | showMaxValue={_showMaxValue} |
| 450 | setShowMaxValue={maxAttribute ? setShowMaxValue : undefined} |
| 451 | docsUrl={docsUrl} |
| 452 | syncId={syncId} |
| 453 | data={data} |
| 454 | xAxisKey={xAxisKey} |
| 455 | yAxisKey={yAxisKey} |
| 456 | xAxisIsDate={xAxisIsDate} |
| 457 | displayDateInUtc={displayDateInUtc} |
| 458 | valuePrecision={valuePrecision} |
| 459 | shouldFormatBytes={shouldFormatBytes} |
| 460 | isNetworkChart={isNetworkChart} |
| 461 | isMemoryChart={isMemoryChart} |
| 462 | attributes={attributes} |
| 463 | sql={sql} |
| 464 | /> |
| 465 | <Container className="relative z-10"> |
| 466 | <RechartComposedChart |
| 467 | data={displayData} |
| 468 | syncId={syncId} |
| 469 | style={{ cursor: 'crosshair' }} |
| 470 | onMouseMove={({ activeLabel, activeTooltipIndex }) => { |
| 471 | if (activeTooltipIndex === undefined || activeTooltipIndex === null) return |
| 472 | |
| 473 | setIsActiveHoveredChart(true) |
| 474 | if (activeTooltipIndex !== focusDataIndex) { |
| 475 | setFocusDataIndex(activeTooltipIndex) |
| 476 | } |
| 477 | |
| 478 | setHover(activeTooltipIndex) |
| 479 | |
| 480 | const activeTimestamp = |
| 481 | data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp |
| 482 | chartHighlight?.handleMouseMove({ |
| 483 | activeLabel: activeTimestamp?.toString(), |
| 484 | coordinates: activeLabel, |
| 485 | }) |
| 486 | }} |
| 487 | onMouseDown={({ activeLabel, activeTooltipIndex }) => { |
| 488 | if (activeTooltipIndex === undefined || activeTooltipIndex === null) return |
| 489 | |
| 490 | const activeTimestamp = |
| 491 | data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp |
| 492 | chartHighlight?.handleMouseDown({ |
| 493 | activeLabel: activeTimestamp?.toString(), |
| 494 | coordinates: activeLabel, |
| 495 | }) |
| 496 | }} |
| 497 | onMouseUp={chartHighlight?.handleMouseUp} |
| 498 | onMouseLeave={() => { |
| 499 | setIsActiveHoveredChart(false) |
| 500 | setFocusDataIndex(null) |
| 501 | |
| 502 | clearHover() |
| 503 | }} |
| 504 | onClick={(tooltipData) => { |
| 505 | const datum = tooltipData?.activePayload?.[0]?.payload |
| 506 | if (onBarClick) onBarClick(datum, tooltipData) |
| 507 | }} |
| 508 | > |
| 509 | {showGrid && <CartesianGrid stroke={CHART_COLORS.AXIS} />} |
| 510 | <YAxis |
| 511 | {..._YAxisProps} |
| 512 | hide={hideYAxis} |
| 513 | axisLine={{ stroke: CHART_COLORS.AXIS }} |
| 514 | tickLine={{ stroke: CHART_COLORS.AXIS }} |
| 515 | domain={_YAxisProps.domain ?? yAxisDomain} |
| 516 | padding={yAxisPadding} |
| 517 | key={yAxisKey} |
| 518 | /> |
| 519 | <XAxis |
| 520 | {..._XAxisProps} |
| 521 | axisLine={{ stroke: CHART_COLORS.AXIS }} |
| 522 | tickLine={{ stroke: CHART_COLORS.AXIS }} |
| 523 | tickMargin={8} |
| 524 | minTickGap={3} |
| 525 | key={xAxisKey} |
| 526 | /> |
| 527 | |
| 528 | <defs> |
| 529 | {visibleAttributes.map((attribute) => ( |
| 530 | <linearGradient |
| 531 | key={`gradient-${attribute.name}`} |
| 532 | id={`gradient-${attribute.name}`} |
| 533 | x1="0" |
| 534 | y1="0" |
| 535 | x2="0" |
| 536 | y2="1" |
| 537 | > |
| 538 | <stop offset="5%" stopColor={attribute.color} stopOpacity={0.15} /> |
| 539 | <stop offset="95%" stopColor={isDarkMode ? '#131313' : '#FFFFFF'} stopOpacity={0} /> |
| 540 | </linearGradient> |
| 541 | ))} |
| 542 | </defs> |
| 543 | |
| 544 | {chartStyle === 'bar' |
| 545 | ? visibleAttributes.map((attribute) => ( |
| 546 | <Bar |
| 547 | key={attribute.name} |
| 548 | dataKey={attribute.name} |
| 549 | stackId={attributes?.find((a) => a.attribute === attribute?.name)?.stackId ?? '1'} |
| 550 | fill={attribute.color} |
| 551 | radius={0.75} |
| 552 | opacity={1} |
| 553 | name={ |
| 554 | attributes?.find((a) => a.attribute === attribute?.name)?.label || |
| 555 | attribute?.name |
| 556 | } |
| 557 | maxBarSize={24} |
| 558 | /> |
| 559 | )) |
| 560 | : visibleAttributes.map((attribute) => ( |
| 561 | <Area |
| 562 | key={attribute.name} |
| 563 | type="linear" |
| 564 | dataKey={attribute.name} |
| 565 | stackId="1" |
| 566 | fill={`url(#gradient-${attribute.name})`} |
| 567 | fillOpacity={1} |
| 568 | stroke={attribute.color} |
| 569 | radius={20} |
| 570 | animationDuration={375} |
| 571 | name={ |
| 572 | attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name |
| 573 | } |
| 574 | dot={false} |
| 575 | activeDot={false} |
| 576 | /> |
| 577 | ))} |
| 578 | {/* Max value, if available */} |
| 579 | {maxAttribute && _showMaxValue && ( |
| 580 | <Line |
| 581 | key={maxAttribute.attribute} |
| 582 | type="linear" |
| 583 | dataKey={maxAttribute.attribute} |
| 584 | stroke={CHART_COLORS.REFERENCE_LINE} |
| 585 | strokeWidth={2} |
| 586 | strokeDasharray={maxAttribute.strokeDasharray ?? '3 3'} |
| 587 | dot={false} |
| 588 | name={maxAttribute.label} |
| 589 | /> |
| 590 | )} |
| 591 | {referenceLines |
| 592 | .filter((line) => { |
| 593 | return line.isReferenceLine |
| 594 | }) |
| 595 | .map((line) => ( |
| 596 | <ReferenceLine |
| 597 | key={line.attribute} |
| 598 | y={line.value} |
| 599 | strokeWidth={1} |
| 600 | stroke={isDarkMode ? line.color?.dark : line.color?.light} |
| 601 | strokeDasharray={line.strokeDasharray ?? '3 3'} |
| 602 | label={undefined} |
| 603 | > |
| 604 | <Label |
| 605 | value={line.label} |
| 606 | position="insideTopRight" |
| 607 | fill={CHART_COLORS.REFERENCE_LINE_TEXT} |
| 608 | className="text-xs" |
| 609 | style={{ fill: CHART_COLORS.REFERENCE_LINE_TEXT }} |
| 610 | /> |
| 611 | </ReferenceLine> |
| 612 | ))} |
| 613 | |
| 614 | {/* Selection highlight */} |
| 615 | {showHighlightActions && ( |
| 616 | <ReferenceArea |
| 617 | x1={chartHighlight?.coordinates.left} |
| 618 | x2={chartHighlight?.coordinates.right} |
| 619 | strokeOpacity={0.5} |
| 620 | stroke={isDarkMode ? '#FFFFFF' : '#0C3925'} |
| 621 | fill={isDarkMode ? '#FFFFFF' : '#0C3925'} |
| 622 | fillOpacity={0.2} |
| 623 | /> |
| 624 | )} |
| 625 | <Tooltip |
| 626 | content={(props) => |
| 627 | showTooltip && !showHighlightActions ? ( |
| 628 | <CustomTooltip |
| 629 | {...props} |
| 630 | data={data} |
| 631 | format={format} |
| 632 | isPercentage={isPercentage} |
| 633 | label={resolvedHighlightedLabel} |
| 634 | attributes={attributes} |
| 635 | xAxisKey={xAxisKey} |
| 636 | valuePrecision={valuePrecision} |
| 637 | showTotal={showTotal} |
| 638 | isActiveHoveredChart={ |
| 639 | isActiveHoveredChart || (!!syncId && syncTooltip && hoveredIndex !== null) |
| 640 | } |
| 641 | /> |
| 642 | ) : null |
| 643 | } |
| 644 | cursor={{ |
| 645 | stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)', |
| 646 | strokeWidth: 1, |
| 647 | }} |
| 648 | /> |
| 649 | <Customized |
| 650 | component={(props: CustomizedDotProps) => { |
| 651 | const { formattedGraphicalItems } = props |
| 652 | if (!formattedGraphicalItems || focusDataIndex === null) return null |
| 653 | |
| 654 | return ( |
| 655 | <g> |
| 656 | {formattedGraphicalItems.map((item, index: number) => { |
| 657 | const points = item.props?.points || item.item?.props?.points || item.points |
| 658 | const dataKey = item.props?.dataKey || item.item?.props?.dataKey |
| 659 | |
| 660 | if (!points || !points[focusDataIndex]) return null |
| 661 | |
| 662 | const point = points[focusDataIndex] |
| 663 | const attribute = visibleAttributes.find((a) => a.name === dataKey) |
| 664 | if (!attribute) return null |
| 665 | |
| 666 | return ( |
| 667 | <circle |
| 668 | key={`custom-dot-${dataKey}-${index}`} |
| 669 | cx={point.x} |
| 670 | cy={point.y} |
| 671 | r={4} |
| 672 | fill={attribute.fill} |
| 673 | stroke={attribute.color} |
| 674 | strokeWidth={1} |
| 675 | /> |
| 676 | ) |
| 677 | })} |
| 678 | </g> |
| 679 | ) |
| 680 | }} |
| 681 | /> |
| 682 | </RechartComposedChart> |
| 683 | </Container> |
| 684 | <ChartHighlightActions |
| 685 | chartHighlight={chartHighlight} |
| 686 | updateDateRange={updateDateRange} |
| 687 | actions={highlightActions} |
| 688 | chartId={chartId} |
| 689 | /> |
| 690 | {data && ( |
| 691 | <div |
| 692 | className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs" |
| 693 | style={{ marginLeft: YAxisProps?.width }} |
| 694 | > |
| 695 | <span>{xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]}</span> |
| 696 | <span> |
| 697 | {xAxisIsDate |
| 698 | ? formatTimestamp(data[data.length - 1]?.[xAxisKey]) |
| 699 | : data[data.length - 1]?.[xAxisKey]} |
| 700 | </span> |
| 701 | </div> |
| 702 | )} |
| 703 | {showLegend && ( |
| 704 | <div className="relative z-0"> |
| 705 | <CustomLabel |
| 706 | payload={[maxAttributeData, ...chartData]} |
| 707 | attributes={attributes} |
| 708 | showMaxValue={_showMaxValue} |
| 709 | onToggleAttribute={(attribute, options) => { |
| 710 | setHiddenAttributes((prev) => { |
| 711 | if (options?.exclusive) { |
| 712 | // Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible. |
| 713 | const allNames = chartData.map((c) => c.name) |
| 714 | const allHiddenExcept = allNames.filter((n) => n !== attribute) |
| 715 | const isAlreadyExclusive = |
| 716 | allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute) |
| 717 | return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept) |
| 718 | } |
| 719 | |
| 720 | const next = new Set(prev) |
| 721 | if (next.has(attribute)) { |
| 722 | next.delete(attribute) |
| 723 | } else { |
| 724 | next.add(attribute) |
| 725 | } |
| 726 | return next |
| 727 | }) |
| 728 | }} |
| 729 | hiddenAttributes={hiddenAttributes} |
| 730 | /> |
| 731 | </div> |
| 732 | )} |
| 733 | </div> |
| 734 | ) |
| 735 | } |