import React, { useMemo } from "react"; import type * as RechartsPrimitive from "recharts"; import type { AggregationType } from "~/components/metrics/QueryWidget"; import { ChartContainer, type ChartConfig, type ChartState } from "./Chart"; import { ChartProvider, useChartContext, type LabelFormatter } from "./ChartContext"; import { ChartLegendCompound } from "./ChartLegendCompound"; import type { ZoomRange } from "./hooks/useZoomSelection"; import { cn } from "~/utils/cn"; export type ChartRootProps = { config: ChartConfig; data: any[]; dataKey: string; /** Series keys to render (if not provided, derived from config keys) */ series?: string[]; /** Subset of series to render as SVG elements on the chart (legend still shows all series) */ visibleSeries?: string[]; state?: ChartState; /** Function to format the x-axis label (used in legend, tooltips, etc.) */ labelFormatter?: LabelFormatter; /** Enable zoom functionality */ enableZoom?: boolean; /** Callback when zoom range changes */ onZoomChange?: (range: ZoomRange) => void; /** Minimum height for the chart */ minHeight?: string; /** Additional className for the container */ className?: string; /** Show the compound legend below the chart */ showLegend?: boolean; /** Maximum items in the legend before showing "view more" */ maxLegendItems?: number; /** Label for the total row in the legend */ legendTotalLabel?: string; /** Aggregation method used by the legend to compute totals (defaults to sum behavior) */ legendAggregation?: AggregationType; /** Optional formatter for numeric legend values (e.g. bytes, duration) */ legendValueFormatter?: (value: number) => string; /** Callback when "View all" legend button is clicked */ onViewAllLegendItems?: () => void; /** When true, constrains legend to max 50% height with scrolling */ legendScrollable?: boolean; /** Additional className for the legend */ legendClassName?: string; /** When true, chart fills its parent container height and distributes space between chart and legend */ fillContainer?: boolean; /** Content rendered between the chart and the legend */ beforeLegend?: React.ReactNode; children: React.ComponentProps["children"]; }; /** * Root component for the chart compound component system. * Provides shared context for all child chart components. * * @example Simple bar chart * ```tsx * * * * * ``` * * @example Chart with zoom * ```tsx * * * * * * ``` */ export function ChartRoot({ config, data, dataKey, series, visibleSeries, state, labelFormatter, enableZoom = false, onZoomChange, minHeight, className, showLegend = false, maxLegendItems = 5, legendTotalLabel, legendAggregation, legendValueFormatter, onViewAllLegendItems, legendScrollable = false, legendClassName, fillContainer = false, beforeLegend, children, }: ChartRootProps) { return ( {children} ); } type ChartRootInnerProps = { minHeight?: string; className?: string; showLegend?: boolean; maxLegendItems?: number; legendTotalLabel?: string; legendAggregation?: AggregationType; legendValueFormatter?: (value: number) => string; onViewAllLegendItems?: () => void; legendScrollable?: boolean; legendClassName?: string; fillContainer?: boolean; beforeLegend?: React.ReactNode; children: React.ComponentProps["children"]; }; function ChartRootInner({ minHeight, className, showLegend = false, maxLegendItems = 5, legendTotalLabel, legendAggregation, legendValueFormatter, onViewAllLegendItems, legendScrollable = false, legendClassName, fillContainer = false, beforeLegend, children, }: ChartRootInnerProps) { const { config, zoom } = useChartContext(); const enableZoom = zoom !== null; return (
{children}
{beforeLegend} {/* Legend rendered outside the chart container */} {showLegend && ( )}
); } /** * Hook to check if all data in the visible range is empty (null or undefined). * Zero values are considered valid data and will render. * Useful for rendering "no data" states. */ export function useHasNoData(): boolean { const { data, dataKey: _dataKey, dataKeys } = useChartContext(); return useMemo(() => { if (data.length === 0) return true; return data.every((item) => { return dataKeys.every((key) => { const value = item[key]; return value === null || value === undefined; }); }); }, [data, dataKeys]); } /** * Hook to calculate aggregated values for each series across all data points. * When no aggregation is provided, defaults to sum (original behavior). * Useful for legend displays. */ export function useSeriesTotal(aggregation?: AggregationType): Record { const { data, dataKeys } = useChartContext(); return useMemo(() => { // Sum (default) and count use additive accumulation if (!aggregation || aggregation === "sum" || aggregation === "count") { return data.reduce( (acc, item) => { for (const seriesKey of dataKeys) { acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0); } return acc; }, {} as Record ); } if (aggregation === "avg") { const sums: Record = {}; const counts: Record = {}; for (const item of data) { for (const seriesKey of dataKeys) { const rawVal = item[seriesKey]; if (rawVal == null) continue; // skip gap-filled nulls const val = Number(rawVal); sums[seriesKey] = (sums[seriesKey] || 0) + val; counts[seriesKey] = (counts[seriesKey] || 0) + 1; } } const result: Record = {}; for (const key of dataKeys) { result[key] = counts[key] ? sums[key]! / counts[key]! : 0; } return result; } if (aggregation === "min") { const result: Record = {}; for (const item of data) { for (const seriesKey of dataKeys) { if (item[seriesKey] == null) continue; // skip gap-filled nulls const val = Number(item[seriesKey]); if (result[seriesKey] === undefined || val < result[seriesKey]) { result[seriesKey] = val; } } } // Default to 0 for series with no data for (const key of dataKeys) { if (result[key] === undefined) result[key] = 0; } return result; } // aggregation === "max" const result: Record = {}; for (const item of data) { for (const seriesKey of dataKeys) { if (item[seriesKey] == null) continue; // skip gap-filled nulls const val = Number(item[seriesKey]); if (result[seriesKey] === undefined || val > result[seriesKey]) { result[seriesKey] = val; } } } // Default to 0 for series with no data for (const key of dataKeys) { if (result[key] === undefined) result[key] = 0; } return result; }, [data, dataKeys, aggregation]); }