391 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|