import React, { useCallback } from "react"; import { Bar, BarChart, CartesianGrid, ReferenceArea, ReferenceLine, XAxis, YAxis, type XAxisProps, type YAxisProps, } from "recharts"; import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart"; import TooltipPortal from "~/components/primitives/TooltipPortal"; import { useChartContext } from "./ChartContext"; import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading"; import { useHasNoData } from "./ChartRoot"; import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; import { useXAxisTicks } from "./useXAxisTicks"; import { useChartSync } from "./ChartSyncContext"; import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; // Dashed line mirroring the hovered x across synced charts. const SYNC_LINE_COLOR = "var(--color-text-faint)"; // Shared with ChartLine so bar/line align when toggling. Right margin keeps the // centered last x-axis label from clipping; bottom gives angled labels room. export const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; /** While drag-to-zooming, show the selected From/To range instead of hovered values. */ function ZoomRangeTooltip({ active, from, to }: { active?: boolean; from: string; to: string }) { if (!active) return null; return (
From: {from} To: {to}
); } //TODO: fix the first and last bars in a stack not having rounded corners type ReferenceLineProps = { value: number; label: string; }; // ============================================================================ // COMPOUND COMPONENT API // ============================================================================ export type ChartBarRendererProps = { /** Stack ID for stacked bar charts */ stackId?: string; /** Custom X-axis props to merge with defaults */ xAxisProps?: Partial; /** Custom Y-axis props to merge with defaults */ yAxisProps?: Partial; /** Reference line (horizontal) */ referenceLine?: ReferenceLineProps; /** Custom tooltip label formatter */ tooltipLabelFormatter?: (label: string, payload: any[]) => string; /** Optional formatter for numeric tooltip values (e.g. bytes, duration) */ tooltipValueFormatter?: (value: number) => string; /** Corner radius for the outermost bars in each stack (defaults to 2). Pass 0 for square corners. */ barRadius?: number; /** Width injected by ResponsiveContainer */ width?: number; /** Height injected by ResponsiveContainer */ height?: number; }; /** * Bar chart renderer for the compound component system. * Must be used within a Chart.Root. * * @example * ```tsx * * * * * ``` */ export function ChartBarRenderer({ stackId, xAxisProps: xAxisPropsProp, yAxisProps: yAxisPropsProp, referenceLine, tooltipLabelFormatter, tooltipValueFormatter, barRadius = 2, width, height, }: ChartBarRendererProps) { const { config, data, dataKey, dataKeys: _dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend, } = useChartContext(); const hasNoData = useHasNoData(); const zoomHandlers = useZoomHandlers(); const sync = useChartSync(); const enableZoom = zoom !== null; const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); // Width-aware horizontal labels, but only when the caller isn't already // controlling ticks/interval/angle (e.g. the query widget's angled axes). const callerControlsXTicks = xAxisPropsProp?.ticks !== undefined || xAxisPropsProp?.interval !== undefined || xAxisPropsProp?.angle !== undefined; // Plot width = full width minus the y-axis and horizontal margins. const xAxisPlotWidth = width != null ? Math.max(0, width - computedYAxisWidth - CHART_MARGIN.left - CHART_MARGIN.right) : undefined; const autoXTicks = useXAxisTicks( data, dataKey, xAxisPlotWidth, xAxisPropsProp?.tickFormatter as ((value: any, index: number) => string) | undefined ); const handleBarClick = useCallback( (barData: any, e: React.MouseEvent) => { if (!enableZoom || !zoom) return; e.stopPropagation(); if (!zoom.isSelecting) { zoom.toggleInspectionLine(barData[dataKey]); } }, [enableZoom, zoom, dataKey] ); // Reset highlight and cancel any in-progress zoom drag on leave. const handleMouseLeave = useCallback(() => { zoomHandlers.onMouseLeave?.(); highlight.reset(); sync?.setActiveX(null); sync?.cancelZoom(); }, [zoomHandlers, highlight, sync]); // Render loading/error states if (state === "loading") { return ; } else if (state === "noData" || hasNoData) { return ; } else if (state === "invalid") { return ; } // When zoom is enabled, collapse to first/last ticks during hover to make room // for the zoom instructions. const zoomXAxisTicks = enableZoom && highlight.tooltipActive && data.length > 2 ? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]] : undefined; // Zoom ticks win; otherwise use width-aware auto ticks unless the caller // controls tick placement. const useAutoXTicks = !callerControlsXTicks && !zoomXAxisTicks && autoXTicks != null; const baseXTicks = zoomXAxisTicks ?? (useAutoXTicks ? autoXTicks : undefined); const baseXInterval = useAutoXTicks ? 0 : ("preserveStartEnd" as const); const syncActiveX = sync?.activeX ?? null; const syncZoomSelection = sync?.zoomSelection ?? null; // Bucket width so the committed zoom range includes the last selected bucket. const bucketWidthMs = data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; // Reuse the tooltip label formatter for the From/To edges (it reads `bucket` off the payload). const formatZoomEdge = (v: number): string => tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { bucket: v } }]) : String(v); let zoomFrom: string | null = null; let zoomTo: string | null = null; if (syncZoomSelection) { const a = Number(syncZoomSelection.start); const b = Number(syncZoomSelection.current); if (Number.isFinite(a) && Number.isFinite(b)) { zoomFrom = formatZoomEdge(Math.min(a, b)); zoomTo = formatZoomEdge(Math.max(a, b)); } } return ( { zoomHandlers.onMouseDown?.(e); if (sync?.zoomEnabled && e?.activeLabel != null) sync.startZoom(e.activeLabel); }} onMouseMove={(e: any) => { zoomHandlers.onMouseMove?.(e); if (sync?.zoomEnabled && sync.zoomSelection && e?.activeLabel != null) { sync.updateZoom(e.activeLabel); } if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); sync?.setActiveX(e.activeLabel ?? null); } else { highlight.setTooltipActive(false); sync?.setActiveX(null); } }} onMouseUp={() => { zoomHandlers.onMouseUp?.(); if (sync?.zoomEnabled) sync.endZoom(bucketWidthMs); }} onClick={zoomHandlers.onClick} onMouseLeave={handleMouseLeave} > dataMax * 1.15]} {...yAxisPropsProp} /> {/* When legend is shown below the chart, render tooltip with cursor only (no content popup). Otherwise render the full tooltip with zoom instructions. */} ) : showLegend ? ( () => null ) : tooltipLabelFormatter ? ( ) : ( ) } labelFormatter={tooltipLabelFormatter} allowEscapeViewBox={{ x: false, y: true }} animationDuration={0} /> {/* Zoom selection area - rendered before bars to appear behind them */} {enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && ( )} {visibleSeries.map((key, index, array) => { const dimmed = !zoom?.isSelecting && highlight.activeBarKey !== null && highlight.activeBarKey !== key; return ( handleBarClick(data, e)} onMouseEnter={(entry, index) => { if (entry.tooltipPayload?.[0]) { const { dataKey: hoveredKey } = entry.tooltipPayload[0]; highlight.setHoveredBar(hoveredKey, index); } }} onMouseLeave={highlight.reset} isAnimationActive={false} /> ); })} {/* Synced drag-to-zoom selection — mirrored across charts in the same group. */} {syncZoomSelection && ( )} {/* Synced hover indicator: drawn on the *other* charts only (the hovered one shows its own cursor); pointer-events-none so it never steals hover. */} {syncActiveX != null && !highlight.tooltipActive && ( )} {/* Horizontal reference line */} {referenceLine && ( )} {/* Zoom inspection line - rendered after bars to appear on top */} {enableZoom && zoom?.inspectionLine && ( { e?.stopPropagation?.(); zoom.clearInspectionLine(); }} /> )} {/* Note: Legend is now rendered by ChartRoot outside the chart container */} ); }