Files
2026-07-13 13:32:57 +08:00

391 lines
13 KiB
TypeScript

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 (
<TooltipPortal active={active}>
<div className="grid grid-cols-[auto_auto] gap-x-2 gap-y-1 rounded-lg border border-grid-bright bg-background-bright px-2.5 py-1.5 text-xs shadow-xl">
<span className="text-right text-text-dimmed">From:</span>
<span className="tabular-nums text-text-bright">{from}</span>
<span className="text-right text-text-dimmed">To:</span>
<span className="tabular-nums text-text-bright">{to}</span>
</div>
</TooltipPortal>
);
}
//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<XAxisProps>;
/** Custom Y-axis props to merge with defaults */
yAxisProps?: Partial<YAxisProps>;
/** 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
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Bar stackId="a" />
* <Chart.Legend />
* </Chart.Root>
* ```
*/
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 <ChartBarLoading />;
} else if (state === "noData" || hasNoData) {
return <ChartBarNoData />;
} else if (state === "invalid") {
return <ChartBarInvalid />;
}
// 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 (
<BarChart
data={data}
width={width}
height={height}
barCategoryGap={1}
margin={CHART_MARGIN}
className={sync?.zoomEnabled ? "cursor-crosshair select-none" : undefined}
onMouseDown={(e: any) => {
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}
>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" />
<XAxis
dataKey={dataKey}
tickLine={false}
tickMargin={10}
axisLine={false}
ticks={baseXTicks}
interval={baseXInterval}
tick={{
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
}}
{...xAxisPropsProp}
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
width={computedYAxisWidth}
tick={{
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
}}
tickFormatter={yAxisTickFormatter}
domain={["auto", (dataMax: number) => 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. */}
<ChartTooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={
syncZoomSelection && zoomFrom != null && zoomTo != null ? (
<ZoomRangeTooltip from={zoomFrom} to={zoomTo} />
) : showLegend ? (
() => null
) : tooltipLabelFormatter ? (
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
refAreaLeft={zoom?.refAreaLeft}
refAreaRight={zoom?.refAreaRight}
invalidSelection={zoom?.invalidSelection}
/>
)
}
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 && (
<ReferenceArea
x1={zoom.refAreaLeft}
x2={zoom.refAreaRight}
strokeOpacity={0.4}
fill="var(--color-pending)"
fillOpacity={0.3}
/>
)}
{visibleSeries.map((key, index, array) => {
const dimmed =
!zoom?.isSelecting && highlight.activeBarKey !== null && highlight.activeBarKey !== key;
return (
<Bar
key={key}
dataKey={key}
stackId={stackId}
fill={config[key]?.color}
radius={
[
index === array.length - 1 ? barRadius : 0,
index === array.length - 1 ? barRadius : 0,
index === 0 ? barRadius : 0,
index === 0 ? barRadius : 0,
] as [number, number, number, number]
}
activeBar={false}
fillOpacity={dimmed ? 0.2 : 1}
onClick={(data, index, e) => 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 && (
<ReferenceArea
x1={syncZoomSelection.start}
x2={syncZoomSelection.current}
isFront
stroke="var(--color-pending)"
strokeOpacity={0.3}
fill="var(--color-pending)"
fillOpacity={0.15}
className="pointer-events-none"
/>
)}
{/* 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 && (
<ReferenceLine
x={syncActiveX}
stroke={SYNC_LINE_COLOR}
strokeWidth={1}
strokeDasharray="4 4"
isFront
className="pointer-events-none"
/>
)}
{/* Horizontal reference line */}
{referenceLine && (
<ReferenceLine
y={referenceLine.value}
label={{
position: "top",
value: referenceLine.label,
fill: "var(--color-text-dimmed)",
fontSize: 11,
}}
isFront={true}
stroke="var(--color-border-bright)"
strokeDasharray="4 4"
className="pointer-events-none"
/>
)}
{/* Zoom inspection line - rendered after bars to appear on top */}
{enableZoom && zoom?.inspectionLine && (
<ReferenceLine
x={zoom.inspectionLine}
stroke="var(--color-text-bright)"
strokeWidth={2}
isFront={true}
onClick={(e: any) => {
e?.stopPropagation?.();
zoom.clearInspectionLine();
}}
/>
)}
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
</BarChart>
);
}