chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user