chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,191 @@
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
import { Hash } from "lucide-react";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { createValueFormatter } from "~/utils/columnFormat";
import { AnimatedNumber } from "../AnimatedNumber";
import { ChartBlankState } from "./ChartBlankState";
import { Spinner } from "../Spinner";
interface BigNumberCardProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: BigNumberConfiguration;
isLoading?: boolean;
}
/**
* Extracts numeric values from a specific column across all rows,
* optionally sorting them first.
*/
function extractColumnValues(
rows: Record<string, unknown>[],
column: string,
sortDirection?: "asc" | "desc"
): number[] {
const values: number[] = [];
const sortedRows = sortDirection
? [...rows].sort((a, b) => {
const aVal = toNumber(a[column]);
const bVal = toNumber(b[column]);
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
})
: rows;
for (const row of sortedRows) {
const val = row[column];
if (typeof val === "number") {
values.push(val);
} else if (typeof val === "string") {
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
values.push(parsed);
}
}
}
return values;
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: BigNumberAggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
case "first":
return values[0];
case "last":
return values[values.length - 1];
}
}
/**
* Computes the display value and unit suffix for abbreviated display.
* Returns the divided-down number (e.g. 1.5 for 1500) and the suffix (e.g. "K"),
* along with the appropriate decimal places for formatting.
*/
function abbreviateValue(value: number): {
displayValue: number;
unitSuffix?: string;
decimalPlaces: number;
} {
if (Math.abs(value) >= 1_000_000_000) {
const v = value / 1_000_000_000;
return { displayValue: v, unitSuffix: "B", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000_000) {
const v = value / 1_000_000;
return { displayValue: v, unitSuffix: "M", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000) {
const v = value / 1_000;
return { displayValue: v, unitSuffix: "K", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
return { displayValue: value, decimalPlaces: getDecimalPlaces(value) };
}
/**
* Determines decimal places for plain (non-abbreviated) display.
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const abs = Math.abs(value);
if (abs >= 100) return 0;
if (abs >= 10) return 1;
if (abs >= 1) return 2;
if (abs >= 0.01) return 3;
return 4;
}
export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) {
const { column, aggregation, sortDirection, abbreviate = true, prefix, suffix } = config;
const result = useMemo(() => {
if (rows.length === 0) return null;
const values = extractColumnValues(rows, column, sortDirection);
if (values.length === 0) return null;
return aggregateValues(values, aggregation);
}, [rows, column, aggregation, sortDirection]);
// Look up column format for format-aware display
const columnValueFormatter = useMemo(() => {
const columnMeta = columns.find((c) => c.name === column);
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
| ColumnFormatType
| undefined;
return createValueFormatter(formatType);
}, [columns, column]);
if (isLoading) {
return (
<div className="grid h-full place-items-center @container-size">
<Spinner className="size-6" />
</div>
);
}
if (result === null) {
return <ChartBlankState icon={Hash} message="No data to display" />;
}
// Use format-aware formatter when available
if (columnValueFormatter) {
return (
<div className="h-full w-full @container-size">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<span>{columnValueFormatter(result)}</span>
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
</div>
</div>
</div>
);
}
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
? abbreviateValue(result)
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
return (
<div className="h-full w-full @container-size">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
{(unitSuffix || suffix) && (
<span className="text-[0.4em] text-text-dimmed">
{unitSuffix}
{unitSuffix && suffix ? " " : ""}
{suffix}
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../Headers";
export const Card = ({ children, className }: { children: ReactNode; className?: string }) => {
return (
<div
className={cn(
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-1.5 pt-3",
className
)}
>
{children}
</div>
);
};
const CardHeader = ({ children, draggable }: { children: ReactNode; draggable?: boolean }) => {
return (
<Header3
className={cn(
"drag-handle mb-3 flex items-center justify-between gap-2 pl-4 pr-3",
draggable && "cursor-grab active:cursor-grabbing"
)}
>
{children}
</Header3>
);
};
const CardContent = ({ children, className }: { children: ReactNode; className?: string }) => {
return <div className={cn("px-2", className)}>{children}</div>;
};
const CardAccessory = ({ children }: { children: ReactNode }) => {
return <div className="flex items-center gap-2">{children}</div>;
};
Card.Header = CardHeader;
Card.Content = CardContent;
Card.Accessory = CardAccessory;
@@ -0,0 +1,464 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import TooltipPortal from "../TooltipPortal";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: '[data-theme="dark"]' } as const;
export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
value?: number;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
valueFormatter,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<TooltipPortal active={active}>
<div
ref={ref}
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-grid-bright bg-background-bright px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{valueFormatter && typeof item.value === "number"
? valueFormatter(item.value)
: item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
</TooltipPortal>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
type ExtendedLegendPayload = Parameters<
NonNullable<RechartsPrimitive.LegendProps["formatter"]>
>[0] & {
payload?: { remainingCount?: number };
};
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
const ChartLegendContentRows = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Omit<Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign">, "payload"> & {
payload?: ExtendedLegendPayload[];
hideIcon?: boolean;
nameKey?: string;
onMouseEnter?: (e: any) => void;
onMouseLeave?: (e: any) => void;
data?: Record<string, number>;
activeKey?: string | null;
renderViewMore?: (remainingCount: number) => React.ReactNode;
}
>(
(
{
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
onMouseEnter,
onMouseLeave,
data,
activeKey,
renderViewMore,
},
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex flex-col items-start justify-between",
verticalAlign === "top" ? "pb-4" : "pt-4",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
// Handle view more button
if (item.dataKey === "view-more" && renderViewMore && item.payload?.remainingCount) {
return renderViewMore(item.payload.remainingCount);
}
const total = item.dataKey && data ? data[item.dataKey as string] : undefined;
return (
<div
key={key}
className={cn(
"relative flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
total === 0 && "opacity-50"
)}
onMouseEnter={() => onMouseEnter?.(item)}
onMouseLeave={() => onMouseLeave?.(item)}
>
{activeKey === item.dataKey && item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-2">
<div
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-dimmed"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
item.color && (
<div
className="h-3 w-1 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)
)}
{itemConfig?.label && (
<span
className={
activeKey === item.dataKey ? "text-text-bright" : "text-text-dimmed"
}
>
{itemConfig.label}
</span>
)}
</div>
{total !== undefined && (
<span
className={cn(
"tabular-nums",
activeKey === item.dataKey ? "text-text-bright" : "text-text-dimmed"
)}
>
<AnimatedNumber value={total} duration={0.25} />
</span>
)}
</div>
</div>
);
})}
</div>
);
}
);
ChartLegendContentRows.displayName = "ChartLegendRows";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartLegendContentRows,
ChartStyle,
};
@@ -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>
);
}
@@ -0,0 +1,23 @@
import { cn } from "~/utils/cn";
import { Paragraph } from "../Paragraph";
export function ChartBlankState({
icon: Icon,
message,
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
message: string;
className?: string;
}) {
return (
<div className={cn("flex h-full w-full items-center justify-center", className)}>
<div className="-mt-3 flex flex-col items-center gap-2">
{Icon && <Icon className="size-12 text-tertiary" />}
<Paragraph variant="small" className="text-text-dimmed/70">
{message}
</Paragraph>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import { Maximize2 } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { Button } from "~/components/primitives/Buttons";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { Dialog, DialogContent, DialogHeader } from "../Dialog";
import { Card } from "./Card";
type ChartCardProps = {
/** Title shown in the card header (and the fullscreen dialog header). */
title: ReactNode;
/** Chart content. Also used in the fullscreen dialog unless `fullscreenChildren` is set. */
children: ReactNode;
/** Optional distinct content for the fullscreen dialog (defaults to `children`). */
fullscreenChildren?: ReactNode;
/** Show the maximize button + enable the fullscreen dialog. Defaults to true. */
maximizable?: boolean;
/** Extra classes for the inner Card. */
className?: string;
};
/**
* Chart card with a title and an optional "Maximize" button that opens the chart
* fullscreen. Mirrors the dashboard QueryWidget (hover-revealed button + "v" shortcut).
*/
export function ChartCard({
title,
children,
fullscreenChildren,
maximizable = true,
className,
}: ChartCardProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// "v" toggles fullscreen for the hovered card.
useShortcutKeys({
shortcut: { key: "v" },
action: useCallback(() => {
const isHovered = containerRef.current?.matches(":hover");
if (!isFullscreen && !isHovered) return;
setIsFullscreen((prev) => !prev);
}, [isFullscreen]),
disabled: !maximizable,
});
return (
<div ref={containerRef} className="group h-full min-h-0 overflow-hidden">
<Card className={cn("h-full overflow-hidden px-0 pb-2 pt-3", className)}>
<Card.Header>
<div className="flex items-center gap-1.5">{title}</div>
{maximizable && (
<Card.Accessory>
<SimpleTooltip
button={
<span className="opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Button
variant="minimal/small"
LeadingIcon={Maximize2}
aria-label="Maximize chart"
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
onClick={() => setIsFullscreen(true)}
className="px-1!"
/>
</span>
}
content={
<span className="flex items-center gap-1">
Maximize
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
</span>
}
asChild
/>
</Card.Accessory>
)}
</Card.Header>
<div className="min-h-0 flex-1 px-2">{children}</div>
</Card>
{maximizable && (
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
{fullscreenChildren ?? children}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,106 @@
/**
* Compound Chart Component System
*
* This module exports a unified Chart compound component that can render
* different chart types (bar, line, etc.) with optional features like
* zoom and legends.
*
* Note: Due to recharts' component hierarchy requirements, the Legend must be
* rendered inside the chart component (BarChart, LineChart). Use the showLegend
* prop on Chart.Bar or Chart.Line instead of Chart.Legend as a sibling.
*
* @example Simple bar chart with legend
* ```tsx
* import { Chart } from "~/components/primitives/charts/ChartCompound";
*
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Bar stackId="a" showLegend maxLegendItems={5} />
* </Chart.Root>
* ```
*
* @example Line chart
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Line lineType="step" showLegend />
* </Chart.Root>
* ```
*
* @example Bar chart with zoom (callback-based)
* ```tsx
* function MyChart() {
* const handleZoomChange = (range: ZoomRange) => {
* // Fetch new data based on range, update state
* };
*
* return (
* <Chart.Root
* config={config}
* data={data}
* dataKey="day"
* enableZoom
* onZoomChange={handleZoomChange}
* >
* <Chart.Bar stackId="a" showLegend />
* </Chart.Root>
* );
* }
* ```
*
* @example Dashboard with synced charts (using DateRangeContext)
* ```tsx
* <DateRangeProvider defaultStartDate={start} defaultEndDate={end}>
* <DashboardControls />
*
* <Chart.Root config={config1} data={data1} dataKey="day" enableZoom onZoomChange={handleZoom1}>
* <Chart.Bar showLegend />
* </Chart.Root>
*
* <Chart.Root config={config2} data={data2} dataKey="day" enableZoom onZoomChange={handleZoom2}>
* <Chart.Line showLegend />
* </Chart.Root>
* </DateRangeProvider>
* ```
*/
import { ChartRoot } from "./ChartRoot";
import { ChartBarRenderer } from "./ChartBar";
import { ChartLineRenderer } from "./ChartLine";
import { ChartLegendCompound } from "./ChartLegendCompound";
import { ChartZoom } from "./ChartZoom";
// Re-export types
export type { ChartConfig, ChartState } from "./Chart";
export type { ZoomRange } from "./hooks/useZoomSelection";
export type { ChartRootProps } from "./ChartRoot";
export type { ChartBarRendererProps } from "./ChartBar";
export type { ChartLineRendererProps } from "./ChartLine";
export type { ChartLegendCompoundProps } from "./ChartLegendCompound";
export type { ChartZoomProps } from "./ChartZoom";
/**
* Chart compound component for building flexible, composable charts.
*
* Components:
* - `Chart.Root` - Main wrapper that provides context
* - `Chart.Bar` - Bar chart renderer (use showLegend prop for legend)
* - `Chart.Line` - Line/area chart renderer (use showLegend prop for legend)
* - `Chart.Zoom` - Optional zoom overlay (rendered internally when enableZoom is set)
*
* Note: Chart.Legend is exported for advanced use cases but should typically
* be enabled via the showLegend prop on Chart.Bar or Chart.Line.
*/
export const Chart = {
Root: ChartRoot,
Bar: ChartBarRenderer,
Line: ChartLineRenderer,
Legend: ChartLegendCompound,
Zoom: ChartZoom,
};
// Also export individual components for direct imports
export { ChartRoot, ChartBarRenderer, ChartLineRenderer, ChartLegendCompound, ChartZoom };
// Re-export context hook for advanced usage
export { useChartContext } from "./ChartContext";
export { useHasNoData, useSeriesTotal } from "./ChartRoot";
export { useZoomHandlers, ZoomTooltip } from "./ChartZoom";
@@ -0,0 +1,174 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { ChartConfig, ChartState } from "./Chart";
import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState";
import {
useZoomSelection,
type UseZoomSelectionReturn,
type ZoomRange,
} from "./hooks/useZoomSelection";
/** Function to format the x-axis label for display */
export type LabelFormatter = (value: string) => string;
export type ChartContextValue = {
// Core data
config: ChartConfig;
data: any[];
dataKey: string;
/** Computed series keys (all config keys except dataKey) */
dataKeys: string[];
/** Subset of dataKeys actually rendered as SVG elements (defaults to dataKeys) */
visibleSeries: string[];
// Display state
state?: ChartState;
// Formatters
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
// Highlight state (does NOT include activePayload — see PayloadContext)
highlight: UseHighlightStateReturn;
/** Update the active payload for the legend. Pass tooltipIndex to skip redundant updates. */
setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void;
// Zoom state (only present when zoom is enabled)
zoom: UseZoomSelectionReturn | null;
// Zoom callback (only present when zoom is enabled)
onZoomChange?: (range: ZoomRange) => void;
// Whether the compound legend is shown (disables tooltip when true)
showLegend: boolean;
};
const ChartCompoundContext = createContext<ChartContextValue | null>(null);
/**
* Separate context for activePayload so that frequent payload updates
* only re-render the legend, not the entire chart (bars, lines, etc.).
*/
const PayloadContext = createContext<any[] | null>(null);
export function useChartContext(): ChartContextValue {
const context = useContext(ChartCompoundContext);
if (!context) {
throw new Error("useChartContext must be used within a Chart.Root component");
}
return context;
}
/** Read the active payload (only re-renders when payload changes). */
export function useActivePayload(): any[] | null {
return useContext(PayloadContext);
}
export type ChartProviderProps = {
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;
/** Whether the compound legend is shown (disables tooltip when true) */
showLegend?: boolean;
children: React.ReactNode;
};
export function ChartProvider({
config,
data,
dataKey,
series,
visibleSeries: visibleSeriesProp,
state,
labelFormatter,
enableZoom = false,
onZoomChange,
showLegend = false,
children,
}: ChartProviderProps) {
const highlight = useHighlightState();
const zoomState = useZoomSelection();
// activePayload lives in its own state + context so updates don't re-render bars
const [activePayload, setActivePayloadRaw] = useState<any[] | null>(null);
const activeTooltipIndexRef = useRef<number | null>(null);
const setActivePayload = useCallback((payload: any[] | null, tooltipIndex?: number | null) => {
const idx = tooltipIndex ?? null;
if (idx !== null && idx === activeTooltipIndexRef.current) {
return;
}
activeTooltipIndexRef.current = idx;
setActivePayloadRaw(payload);
}, []);
// Reset the tooltip index ref when highlight resets (mouse leaves chart)
const originalReset = highlight.reset;
const resetWithPayload = useCallback(() => {
activeTooltipIndexRef.current = null;
setActivePayloadRaw(null);
originalReset();
}, [originalReset]);
const highlightWithReset = useMemo(
() => ({ ...highlight, reset: resetWithPayload }),
[highlight, resetWithPayload]
);
// Compute series keys (use provided series or derive from config)
const dataKeys = useMemo(
() => series ?? Object.keys(config).filter((k) => k !== dataKey),
[series, config, dataKey]
);
const visibleSeries = useMemo(() => visibleSeriesProp ?? dataKeys, [visibleSeriesProp, dataKeys]);
const value = useMemo<ChartContextValue>(
() => ({
config,
data,
dataKey,
dataKeys,
visibleSeries,
state,
labelFormatter,
highlight: highlightWithReset,
setActivePayload,
zoom: enableZoom ? zoomState : null,
onZoomChange: enableZoom ? onZoomChange : undefined,
showLegend,
}),
[
config,
data,
dataKey,
dataKeys,
visibleSeries,
state,
labelFormatter,
highlightWithReset,
setActivePayload,
zoomState,
enableZoom,
onZoomChange,
showLegend,
]
);
return (
<ChartCompoundContext.Provider value={value}>
<PayloadContext.Provider value={activePayload}>{children}</PayloadContext.Provider>
</ChartCompoundContext.Provider>
);
}
@@ -0,0 +1,375 @@
import React, { useMemo } from "react";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { useActivePayload, useChartContext } from "./ChartContext";
import { useSeriesTotal } from "./ChartRoot";
import { aggregateValues } from "./aggregation";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { SimpleTooltip } from "../Tooltip";
const aggregationLabels: Record<AggregationType, string> = {
sum: "Sum",
avg: "Average",
count: "Count",
min: "Min",
max: "Max",
};
export type ChartLegendCompoundProps = {
/** Maximum number of legend items to show before collapsing */
maxItems?: number;
/** Hide the legend entirely (useful for conditional rendering) */
hidden?: boolean;
/** Additional className */
className?: string;
/** Label for the total row (derived from aggregation when not provided) */
totalLabel?: string;
/** Aggregation method controls the header label and how totals are computed */
aggregation?: AggregationType;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
/** Callback when "View all" button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
scrollable?: boolean;
};
/**
* Legend component for the chart compound system.
* Renders as a permanent element below the chart (not inside recharts).
* Automatically connects to chart context for highlighting.
*
* @example Using via Chart.Root showLegend prop (recommended)
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day" showLegend maxLegendItems={5}>
* <Chart.Bar />
* </Chart.Root>
* ```
*/
export function ChartLegendCompound({
maxItems = Infinity,
hidden = false,
className,
totalLabel,
aggregation,
valueFormatter,
onViewAllLegendItems,
scrollable = false,
}: ChartLegendCompoundProps) {
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
const activePayload = useActivePayload();
const totals = useSeriesTotal(aggregation);
// Derive the effective label from the aggregation type when no explicit label is provided
const effectiveTotalLabel =
totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total");
// Calculate grand total by aggregating across all per-series values
const grandTotal = useMemo(() => {
const values = dataKeys.map((key) => totals[key] || 0);
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [totals, dataKeys, aggregation]);
// Calculate current total based on hover state (null when hovering a gap-filled point)
const currentTotal = useMemo((): number | null => {
if (!activePayload?.length) return grandTotal;
// Use the full data row so the total covers ALL dataKeys, not just visibleSeries
const dataRow = activePayload[0]?.payload;
if (!dataRow) return grandTotal;
const rawValues = dataKeys.map((key) => dataRow[key]);
const values = rawValues.filter((v): v is number => v != null).map((v) => Number(v) || 0);
// All null → gap-filled point, return null to show dash
if (values.length === 0) return null;
if (!aggregation) {
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [activePayload, grandTotal, dataKeys, aggregation]);
// Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise
const currentTotalLabel = useMemo(() => {
if (!activePayload?.length) return effectiveTotalLabel;
// Get the x-axis label from the payload's original data
const firstPayloadItem = activePayload[0];
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
if (xAxisValue === undefined) return effectiveTotalLabel;
// Apply the formatter if provided, otherwise just stringify the value
const stringValue = String(xAxisValue);
return labelFormatter ? labelFormatter(stringValue) : stringValue;
}, [activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
// Get current data for the legend based on hover state (values may be null for gap-filled points)
const currentData = useMemo((): Record<string, number | null> => {
if (!activePayload?.length) return totals;
// Use the full data row so ALL dataKeys are resolved from the hovered point,
// not just the visibleSeries present in activePayload.
const dataRow = activePayload[0]?.payload;
if (!dataRow) return totals;
const hoverData: Record<string, number | null> = {};
for (const key of dataKeys) {
const value = dataRow[key];
if (value !== undefined) {
hoverData[key] = value != null ? Number(value) || 0 : null;
}
}
return {
...totals,
...hoverData,
};
}, [activePayload, totals, dataKeys]);
// Prepare legend items with capped display
const legendItems = useMemo(() => {
const allItems = dataKeys.map((key) => ({
dataKey: key,
color: config[key]?.color,
label: config[key]?.label ?? key,
}));
if (allItems.length <= maxItems) {
return { visible: allItems, remaining: 0, hoveredHiddenItem: undefined };
}
const visibleItems = allItems.slice(0, maxItems);
const remainingCount = allItems.length - maxItems;
// If we're hovering over an item that's not visible in the legend,
// pass it separately to replace the "view more" row
let hoveredHiddenItem: (typeof allItems)[0] | undefined;
if (
highlight.activeBarKey &&
!visibleItems.some((item) => item.dataKey === highlight.activeBarKey)
) {
hoveredHiddenItem = allItems.find((item) => item.dataKey === highlight.activeBarKey);
}
return { visible: visibleItems, remaining: remainingCount, hoveredHiddenItem };
}, [config, dataKeys, maxItems, highlight.activeBarKey]);
if (hidden || dataKeys.length === 0) {
return null;
}
const isHovering = (activePayload?.length ?? 0) > 0;
return (
<div
className={cn(
"flex flex-col px-2 pb-2 pt-4 text-sm",
scrollable && "max-h-[50%] min-h-0",
className
)}
>
{/* Total row */}
<div
className={cn(
"flex w-full shrink-0 items-center justify-between gap-2 rounded px-2 py-1 transition",
isHovering ? "text-text-bright" : "text-text-dimmed"
)}
>
<span className="font-medium">{currentTotalLabel}</span>
<span className="shrink-0 font-medium tabular-nums">
{currentTotal != null ? (
valueFormatter ? (
valueFormatter(currentTotal)
) : (
<AnimatedNumber value={currentTotal} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
{/* Separator */}
<div className="mx-2 my-1 shrink-0 border-t border-grid-dimmed" />
{/* Legend items - scrollable when scrollable prop is true */}
<div
className={cn(
"flex flex-col",
scrollable &&
"min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
)}
>
{legendItems.visible.map((item) => {
const total = currentData[item.dataKey] ?? null;
const isActive = highlight.activeBarKey === item.dataKey;
return (
<div
key={item.dataKey}
className={cn(
"relative flex w-full cursor-default items-center justify-between gap-2 rounded px-2 py-1 transition",
(total == null || total === 0) && "opacity-50"
)}
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
onMouseLeave={() => highlight.reset()}
>
{/* Active highlight background */}
{isActive && item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<SimpleTooltip
button={
<div className="flex min-w-0 items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span
className={cn(
"truncate",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
{item.label}
</span>
</div>
}
content={item.label}
side="top"
disableHoverableContent
className="max-w-xs wrap-break-word"
buttonClassName="cursor-default min-w-0"
/>
<span
className={cn(
"shrink-0 self-start tabular-nums",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
{total != null ? (
valueFormatter ? (
valueFormatter(total)
) : (
<AnimatedNumber value={total} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
);
})}
{/* View more row - replaced by hovered hidden item when applicable */}
{legendItems.remaining > 0 &&
(legendItems.hoveredHiddenItem ? (
<HoveredHiddenItemRow
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
remainingCount={legendItems.remaining - 1}
valueFormatter={valueFormatter}
/>
) : (
<ViewAllDataRow
remainingCount={legendItems.remaining}
onViewAll={onViewAllLegendItems}
/>
))}
</div>
</div>
);
}
type ViewAllDataRowProps = {
remainingCount: number;
onViewAll?: () => void;
};
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
return (
<div
className="relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition hover:bg-background-dimmed"
onClick={onViewAll}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onViewAll?.();
}
}}
>
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<div className="w-1 shrink-0 self-stretch rounded-[2px] border border-border-bright" />
<span className="text-text-dimmed tabular-nums">{remainingCount} more</span>
</div>
<span className="self-start text-indigo-500">View all</span>
</div>
</div>
);
}
type HoveredHiddenItemRowProps = {
item: { dataKey: string; color?: string; label: React.ReactNode };
value: number | null;
remainingCount: number;
valueFormatter?: (value: number) => string;
};
function HoveredHiddenItemRow({
item,
value,
remainingCount,
valueFormatter,
}: HoveredHiddenItemRowProps) {
return (
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
{/* Active highlight background */}
{item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className="text-text-bright">{item.label}</span>
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
</div>
<span className="shrink-0 tabular-nums text-text-bright">
{value != null ? (
valueFormatter ? (
valueFormatter(value)
) : (
<AnimatedNumber value={value} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,239 @@
import {
Area,
AreaChart,
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
type XAxisProps,
type YAxisProps,
} from "recharts";
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
import { CHART_MARGIN } from "./ChartBar";
import { useChartContext } from "./ChartContext";
import { ChartLineInvalid, ChartLineLoading, ChartLineNoData } from "./ChartLoading";
import { useHasNoData } from "./ChartRoot";
import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth";
// Legend is now rendered by ChartRoot outside the chart container
type CurveType =
| "basis"
| "basisClosed"
| "basisOpen"
| "linear"
| "linearClosed"
| "natural"
| "monotoneX"
| "monotoneY"
| "monotone"
| "step"
| "stepBefore"
| "stepAfter";
// ============================================================================
// COMPOUND COMPONENT API
// ============================================================================
export type ChartLineRendererProps = {
/** Line curve type */
lineType?: CurveType;
/** Custom X-axis props to merge with defaults */
xAxisProps?: Partial<XAxisProps>;
/** Custom Y-axis props to merge with defaults */
yAxisProps?: Partial<YAxisProps>;
/** Render as stacked area chart instead of line chart */
stacked?: boolean;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
height?: number;
};
/**
* Line 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.Line type="step" />
* <Chart.Legend simple />
* </Chart.Root>
* ```
*/
export function ChartLineRenderer({
lineType = "step",
xAxisProps: xAxisPropsProp,
yAxisProps: yAxisPropsProp,
stacked = false,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartLineRendererProps) {
const {
config,
data,
dataKey,
dataKeys: _dataKeys,
visibleSeries,
state,
highlight,
setActivePayload,
showLegend,
} = useChartContext();
const hasNoData = useHasNoData();
const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter;
const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter);
// Render loading/error states
if (state === "loading") {
return <ChartLineLoading />;
} else if (state === "noData" || hasNoData) {
return <ChartLineNoData />;
} else if (state === "invalid") {
return <ChartLineInvalid />;
}
// Get the x-axis ticks based on tooltip state
const xAxisTicks =
highlight.tooltipActive && data.length > 2
? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]]
: undefined;
const xAxisConfig = {
dataKey,
tickLine: false,
axisLine: false,
tickMargin: 10,
ticks: xAxisTicks,
interval: "preserveStartEnd" as const,
tick: {
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
},
...xAxisPropsProp,
};
const yAxisConfig = {
axisLine: false,
tickLine: false,
tickMargin: 8,
width: computedYAxisWidth,
tick: {
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
},
tickFormatter: yAxisTickFormatter,
...yAxisPropsProp,
};
// Handle mouse leave to also reset highlight
const handleMouseLeave = () => {
highlight.setTooltipActive(false);
highlight.reset();
};
// Render stacked area chart if stacked prop is true
if (stacked && visibleSeries.length > 1) {
return (
<AreaChart
data={data}
width={width}
height={height}
stackOffset="none"
margin={CHART_MARGIN}
onMouseMove={(e: any) => {
if (e?.activePayload?.length) {
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
}
}}
onMouseLeave={handleMouseLeave}
>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={
showLegend ? (
() => null
) : (
<ChartTooltipContent indicator="line" valueFormatter={tooltipValueFormatter} />
)
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{visibleSeries.map((key) => (
<Area
key={key}
type={lineType}
dataKey={key}
stroke={config[key]?.color}
fill={config[key]?.color}
fillOpacity={0.6}
strokeWidth={1}
stackId="stack"
isAnimationActive={false}
/>
))}
</AreaChart>
);
}
return (
<LineChart
accessibilityLayer
data={data}
width={width}
height={height}
margin={CHART_MARGIN}
onMouseMove={(e: any) => {
if (e?.activePayload?.length) {
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
}
}}
onMouseLeave={handleMouseLeave}
>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={
showLegend ? () => null : <ChartTooltipContent valueFormatter={tooltipValueFormatter} />
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{visibleSeries.map((key) => (
<Line
key={key}
dataKey={key}
type={lineType}
stroke={config[key]?.color}
strokeWidth={1}
dot={{ r: 1.5, fill: config[key]?.color, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
))}
</LineChart>
);
}
@@ -0,0 +1,341 @@
import { motion } from "framer-motion";
import { Button } from "../Buttons";
import { Paragraph } from "../Paragraph";
import { Spinner } from "../Spinner";
import { useDateRange } from "./DateRangeContext";
import { useMemo } from "react";
import { ClientOnly } from "remix-utils/client-only";
export function ChartBarLoading() {
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartBarLoadingBackground />
<div className="absolute z-10 mb-16 flex items-center gap-2">
<Spinner className="size-6" />
<Paragraph variant="base/bright">Loading data</Paragraph>
</div>
</div>
);
}
export function ChartBarNoData() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartBarLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">No data</Paragraph>
<Paragraph variant="small" spacing>
There's no data available for the filters you've set.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
export function ChartBarInvalid() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartBarLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">Chart not applicable</Paragraph>
<Paragraph variant="small" spacing>
Your current filter settings don't apply to this chart's data type.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
export function ChartLineLoading() {
return (
<div className="relative mb-16 grid h-full place-items-center p-2 pt-0">
<ChartLineLoadingBackground />
<div className="absolute z-10 flex items-center gap-2">
<Spinner className="size-6" />
<Paragraph variant="base/bright">Loading data</Paragraph>
</div>
</div>
);
}
export function ChartLineNoData() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartLineLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">No data</Paragraph>
<Paragraph variant="small" spacing>
There's no data available for the filters you've set.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
export function ChartLineInvalid() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartLineLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">Chart not applicable</Paragraph>
<Paragraph variant="small" spacing>
Your current filter settings don't apply to this chart's data type.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
function ChartBarLoadingBackground() {
return (
<motion.div
className="flex h-full w-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<ClientOnly fallback={<div />}>
{() => (
<>
<motion.div
className="flex flex-1 items-end gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
{Array.from({ length: 30 }).map((_, i) => {
const height = Math.max(15, Math.floor(Math.random() * 100));
return (
<motion.div
key={i}
className="flex-1 rounded-sm bg-background-hover/50"
style={{ height: `${height}%` }}
initial={{ height: 0, opacity: 0 }}
animate={{ height: `${height}%`, opacity: 1 }}
transition={{
type: "spring",
stiffness: 120,
damping: 14,
mass: 1,
delay: 0.1 + i * 0.02,
}}
/>
);
})}
</motion.div>
<motion.div
className="mt-2 flex flex-col justify-center gap-1"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 15,
delay: 0.2,
}}
>
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
className="flex items-center gap-1"
key={i}
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 12,
delay: 0.3 + i * 0.08,
}}
>
<div className="h-5 w-2 rounded-sm bg-background-hover/50" />
<div className="h-5 w-full rounded-sm bg-background-hover/50" />
</motion.div>
))}
</motion.div>
</>
)}
</ClientOnly>
</motion.div>
);
}
function ChartLineLoadingBackground() {
// Generate line points with configurable starting position and constraints
const generateLinePoints = (startY: number, minY: number, maxY: number) => {
const numPoints = 10;
const points = [];
let lastY = startY;
for (let i = 0; i < numPoints; i++) {
// Calculate x value that spreads points across the full width
const x = i * (9 / (numPoints - 1));
// Create less extreme variations that move smoothly
const change = Math.random() * 6 - 3; // Range from -3 to +3
const y = Math.max(minY, Math.min(maxY, lastY + change)); // Apply constraints
points.push({ x, y });
lastY = y;
}
return points;
};
// Generate points for both lines
const points = useMemo(() => generateLinePoints(30, 10, 90), []);
const secondPoints = useMemo(() => generateLinePoints(40, 30, 90), []);
const generateSmoothPath = (points: Array<{ x: number; y: number }>) => {
if (points.length < 2) return "";
let path = `M0,${50 - points[0].y}`;
// Use curve command for smooth lines
for (let i = 0; i < points.length - 1; i++) {
const x1 = points[i].x;
const y1 = 50 - points[i].y;
const x2 = points[i + 1].x;
const y2 = 50 - points[i + 1].y;
// Bezier control points (create smooth curve)
const cx1 = (x1 + x2) / 2;
const cy1 = y1;
const cx2 = (x1 + x2) / 2;
const cy2 = y2;
path += ` C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`;
}
return path;
};
const generateAreaPath = (points: Array<{ x: number; y: number }>) => {
const curvePath = generateSmoothPath(points);
const lastX = 9;
return `${curvePath} L${lastX},50 L0,50 Z`;
};
// Component to render a line with area fill and animation
const AnimatedLine = ({
points,
gradientId,
delay = 0,
}: {
points: Array<{ x: number; y: number }>;
gradientId: string;
delay?: number;
}) => (
<>
<motion.path
d={generateAreaPath(points)}
fill={`url(#${gradientId})`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, ease: "easeInOut", delay }}
/>
<motion.path
d={generateSmoothPath(points)}
stroke="var(--color-background-hover)"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
initial={{ opacity: 0 }}
animate={{ opacity: 0.8 }}
transition={{ duration: 1.5, ease: "easeInOut", delay }}
vectorEffect="non-scaling-stroke"
/>
</>
);
return (
<motion.div
className="flex h-full w-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="relative flex-1">
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 9 50"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-background-hover)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--color-background-hover)" stopOpacity={0} />
</linearGradient>
<linearGradient id="secondAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-background-hover)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--color-background-hover)" stopOpacity={0} />
</linearGradient>
</defs>
<g>
<AnimatedLine points={points} gradientId="areaGradient" />
<AnimatedLine points={secondPoints} gradientId="secondAreaGradient" delay={0.2} />
</g>
</svg>
</div>
<motion.div
className="mt-2 flex flex-col justify-center gap-1"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 15,
delay: 0.2,
}}
>
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
className="flex items-center gap-1"
key={i}
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 12,
delay: 0.3 + i * 0.08,
}}
>
<div className="h-5 w-2 rounded-sm bg-background-hover/50" />
<div className="h-5 w-full rounded-sm bg-background-hover/50" />
</motion.div>
))}
</motion.div>
</motion.div>
);
}
@@ -0,0 +1,299 @@
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<typeof RechartsPrimitive.ResponsiveContainer>["children"];
};
/**
* Root component for the chart compound component system.
* Provides shared context for all child chart components.
*
* @example Simple bar chart
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Bar stackId="a" />
* <Chart.Legend />
* </Chart.Root>
* ```
*
* @example Chart with zoom
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
* <Chart.Bar stackId="a" />
* <Chart.Zoom />
* <Chart.Legend />
* </Chart.Root>
* ```
*/
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 (
<ChartProvider
config={config}
data={data}
dataKey={dataKey}
series={series}
visibleSeries={visibleSeries}
state={state}
labelFormatter={labelFormatter}
enableZoom={enableZoom}
onZoomChange={onZoomChange}
showLegend={showLegend}
>
<ChartRootInner
minHeight={minHeight}
className={className}
showLegend={showLegend}
maxLegendItems={maxLegendItems}
legendTotalLabel={legendTotalLabel}
legendAggregation={legendAggregation}
legendValueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
legendClassName={legendClassName}
fillContainer={fillContainer}
beforeLegend={beforeLegend}
>
{children}
</ChartRootInner>
</ChartProvider>
);
}
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<typeof RechartsPrimitive.ResponsiveContainer>["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 (
<div className={cn("relative flex w-full flex-col", fillContainer && "h-full", className)}>
<div
className={cn(
fillContainer ? "min-h-0 flex-1" : "h-full w-full",
enableZoom && "mt-8 cursor-crosshair"
)}
style={{ touchAction: "none", userSelect: "none" }}
>
<ChartContainer
config={config}
className={cn(
"h-full w-full",
fillContainer && "aspect-auto!",
enableZoom &&
"[&_.recharts-surface]:cursor-crosshair [&_.recharts-wrapper]:cursor-crosshair"
)}
style={fillContainer ? undefined : minHeight ? { minHeight } : undefined}
>
{children}
</ChartContainer>
</div>
{beforeLegend}
{/* Legend rendered outside the chart container */}
{showLegend && (
<ChartLegendCompound
maxItems={maxLegendItems}
totalLabel={legendTotalLabel}
aggregation={legendAggregation}
valueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
className={legendClassName}
/>
)}
</div>
);
}
/**
* 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<string, number> {
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<string, number>
);
}
if (aggregation === "avg") {
const sums: Record<string, number> = {};
const counts: Record<string, number> = {};
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<string, number> = {};
for (const key of dataKeys) {
result[key] = counts[key] ? sums[key]! / counts[key]! : 0;
}
return result;
}
if (aggregation === "min") {
const result: Record<string, number> = {};
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<string, number> = {};
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]);
}
@@ -0,0 +1,121 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
/**
* Cross-chart sync for a group of charts sharing an x-axis domain. Wrap them in one
* <ChartSyncProvider>; both behaviours mirror across every chart: a hover indicator
* (vertical line at the hovered x), and drag-to-zoom (a selection rectangle that
* commits the range via `onZoom`, e.g. to set the Time/Date filter).
*
* Chart.Bar reads this via useChartSync, a no-op when no provider is present.
* Drag-to-zoom is active only when `onZoom` is provided.
*/
type ChartSyncXValue = number | string | null;
/** Committed zoom range (epoch ms). */
export type ChartZoomRange = { start: number; end: number };
/** In-progress drag selection (raw x values; not yet ordered). */
type ZoomSelection = { start: number | string; current: number | string };
type ChartSyncContextValue = {
/** The x-axis value currently hovered in any chart in the group. */
activeX: ChartSyncXValue;
setActiveX: (x: ChartSyncXValue) => void;
/** Whether drag-to-zoom is active (an onZoom handler was provided). */
zoomEnabled: boolean;
/** Current drag selection, mirrored across charts; null when not dragging. */
zoomSelection: ZoomSelection | null;
startZoom: (x: number | string) => void;
updateZoom: (x: number | string) => void;
/** Finish the drag and commit the range (adds `bucketWidthMs` so the last bucket is included). */
endZoom: (bucketWidthMs?: number) => void;
cancelZoom: () => void;
};
const ChartSyncContext = createContext<ChartSyncContextValue | null>(null);
/**
* Turn a raw drag selection into an ordered, inclusive range. Returns null for
* a non-drag (start === current) or non-numeric selection.
*/
export function computeZoomRange(
start: number | string,
current: number | string,
bucketWidthMs = 0
): ChartZoomRange | null {
const a = Number(start);
const b = Number(current);
if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return null;
const width = Number.isFinite(bucketWidthMs) ? bucketWidthMs : 0;
return { start: Math.min(a, b), end: Math.max(a, b) + width };
}
export function ChartSyncProvider({
children,
onZoom,
}: {
children: React.ReactNode;
/** Called with the selected range when a drag-to-zoom completes. */
onZoom?: (range: ChartZoomRange) => void;
}) {
const [activeX, setActiveXState] = useState<ChartSyncXValue>(null);
const [zoomSelection, setZoomSelection] = useState<ZoomSelection | null>(null);
// Track selection synchronously so endZoom (fired on mouseup) reads the latest.
const selectionRef = useRef<ZoomSelection | null>(null);
const setActiveX = useCallback((x: ChartSyncXValue) => setActiveXState(x), []);
const startZoom = useCallback((x: number | string) => {
const next = { start: x, current: x };
selectionRef.current = next;
setZoomSelection(next);
}, []);
const updateZoom = useCallback((x: number | string) => {
const prev = selectionRef.current;
if (!prev) return;
const next = { start: prev.start, current: x };
selectionRef.current = next;
setZoomSelection(next);
}, []);
const cancelZoom = useCallback(() => {
selectionRef.current = null;
setZoomSelection(null);
}, []);
const endZoom = useCallback(
(bucketWidthMs = 0) => {
const sel = selectionRef.current;
selectionRef.current = null;
setZoomSelection(null);
if (!sel) return;
const range = computeZoomRange(sel.start, sel.current, bucketWidthMs);
if (range) onZoom?.(range);
},
[onZoom]
);
const value = useMemo<ChartSyncContextValue>(
() => ({
activeX,
setActiveX,
zoomEnabled: onZoom != null,
zoomSelection,
startZoom,
updateZoom,
endZoom,
cancelZoom,
}),
[activeX, setActiveX, onZoom, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom]
);
return <ChartSyncContext.Provider value={value}>{children}</ChartSyncContext.Provider>;
}
/** Returns the sync context, or null when not inside a ChartSyncProvider. */
export function useChartSync(): ChartSyncContextValue | null {
return useContext(ChartSyncContext);
}
@@ -0,0 +1,244 @@
import React, { useCallback } from "react";
import { ReferenceArea, ReferenceLine } from "recharts";
import { useChartContext } from "./ChartContext";
import { useDateRange } from "./DateRangeContext";
import { cn } from "~/utils/cn";
export type ChartZoomProps = {
/** Sync zoom with DateRangeContext (for dashboard-level syncing) */
syncWithDateRange?: boolean;
/** Minimum number of data points required for a valid zoom selection */
minDataPoints?: number;
};
/**
* Zoom overlay component for charts.
* Renders the zoom selection area and inspection line.
*
* Must be used within a Chart.Root with enableZoom={true}.
*
* @example Basic zoom
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
* <Chart.Bar />
* <Chart.Zoom />
* </Chart.Root>
* ```
*
* @example Synced with DateRangeContext
* ```tsx
* <DateRangeProvider>
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
* <Chart.Bar />
* <Chart.Zoom syncWithDateRange />
* </Chart.Root>
* </DateRangeProvider>
* ```
*/
export function ChartZoom({ syncWithDateRange = false, minDataPoints = 3 }: ChartZoomProps) {
const { zoom, data: _data, dataKey: _dataKey, onZoomChange: _onZoomChange } = useChartContext();
const _globalDateRange = useDateRange();
if (!zoom) {
console.warn("ChartZoom: zoom is not enabled. Add enableZoom to Chart.Root.");
return null;
}
const { inspectionLine, refAreaLeft, refAreaRight } = zoom;
return (
<>
{/* Inspection line (click to inspect) */}
{inspectionLine && (
<ReferenceLine
x={inspectionLine}
stroke="var(--color-text-bright)"
strokeWidth={2}
isFront={true}
onClick={(e: any) => {
e?.stopPropagation?.();
zoom.clearInspectionLine();
}}
/>
)}
{/* Zoom selection area */}
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.4}
fill="var(--color-pending)"
fillOpacity={0.2}
/>
)}
</>
);
}
/**
* Tooltip component for showing zoom selection feedback.
* Can be used inside ChartTooltip content prop.
*
* Note: This component receives zoom state as props because recharts
* may render tooltips outside the normal React tree where context isn't available.
*/
export type ZoomTooltipProps = {
active?: boolean;
payload?: any[];
label?: string;
viewBox?: {
height: number;
width: number;
};
coordinate?: {
x: number;
y: number;
};
// Zoom state passed as props (since context might not be available in tooltip)
isSelecting?: boolean;
refAreaLeft?: string | null;
refAreaRight?: string | null;
invalidSelection?: boolean;
};
export function ZoomTooltip({
active,
payload,
label,
viewBox,
coordinate,
isSelecting,
refAreaLeft,
refAreaRight,
invalidSelection,
}: ZoomTooltipProps) {
if (!active) return null;
// Show zoom range when selecting
if (isSelecting && refAreaLeft && refAreaRight) {
const message = invalidSelection
? "Zoom: Drag a wider range"
: `Zoom: ${refAreaLeft} to ${refAreaRight}`;
return (
<div
className={cn(
"absolute whitespace-nowrap rounded border px-2 py-1 text-xxs tabular-nums",
invalidSelection
? "border-amber-800 bg-amber-950 text-amber-400"
: "border-blue-800 bg-[#1B2334] text-blue-400"
)}
style={{
left: coordinate?.x,
top: viewBox?.height ? viewBox.height + 14 : 0,
transform: "translateX(-50%)",
}}
>
{message}
<div
className={cn(
"absolute top-[-5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45",
invalidSelection
? "border-l border-t border-amber-800 bg-amber-950"
: "border-l border-t border-blue-800 bg-[#1B2334]"
)}
/>
</div>
);
}
// Default tooltip behavior (show label)
if (!payload?.length) return null;
return (
<div
className="absolute whitespace-nowrap rounded border border-border-bright bg-background-raised px-2 py-1 text-xxs tabular-nums text-text-bright"
style={{
left: coordinate?.x,
top: viewBox?.height ? viewBox.height + 13 : 0,
transform: "translateX(-50%)",
}}
>
{label}
<div className="absolute top-[-5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 border-l border-t border-border-bright bg-background-raised" />
</div>
);
}
/**
* Hook that returns event handlers for zoom interactions.
* Use these on your chart component (BarChart, LineChart, etc.).
*/
export function useZoomHandlers(
options: { minDataPoints?: number; syncWithDateRange?: boolean } = {}
) {
const { minDataPoints = 3, syncWithDateRange = false } = options;
const { zoom, data, dataKey, onZoomChange } = useChartContext();
const globalDateRange = useDateRange();
const handleMouseDown = useCallback(
(e: any) => {
if (!zoom || !e?.activeLabel) return;
zoom.startSelection(e.activeLabel);
},
[zoom]
);
const handleMouseMove = useCallback(
(e: any) => {
if (!zoom || !e?.activeLabel) return;
if (zoom.isSelecting) {
zoom.updateSelection(e.activeLabel, data, dataKey, minDataPoints);
}
},
[zoom, data, dataKey, minDataPoints]
);
const handleMouseUp = useCallback(() => {
if (!zoom) return;
const range = zoom.finishSelection(data, dataKey, minDataPoints);
if (range) {
// If syncing with DateRangeContext, update it
if (syncWithDateRange && globalDateRange) {
globalDateRange.setDateRange(range.start, range.end);
}
// Call the onZoomChange callback
onZoomChange?.(range);
}
}, [zoom, data, dataKey, minDataPoints, syncWithDateRange, globalDateRange, onZoomChange]);
const handleClick = useCallback(
(e: any) => {
if (!zoom || zoom.isSelecting || !e?.activeLabel) return;
zoom.toggleInspectionLine(e.activeLabel);
},
[zoom]
);
const handleMouseLeave = useCallback(() => {
if (!zoom) return;
zoom.cancelSelection();
}, [zoom]);
if (!zoom) {
return {
onMouseDown: undefined,
onMouseMove: undefined,
onMouseUp: undefined,
onClick: undefined,
onMouseLeave: undefined,
};
}
return {
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
onMouseUp: handleMouseUp,
onClick: handleClick,
onMouseLeave: handleMouseLeave,
};
}
@@ -0,0 +1,120 @@
import React, { createContext, useState, useContext, type ReactNode } from "react";
type DateRangeContextType = {
/** Start date as ISO string (YYYY-MM-DD) or custom format */
startDate: string;
/** End date as ISO string (YYYY-MM-DD) or custom format */
endDate: string;
setDateRange: (startDate: string, endDate: string) => void;
resetDateRange: () => void;
};
// Formatters for displaying dates
const shortDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
const longDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
/**
* Format a Date object as a short date string (e.g., "Nov 1")
*/
export function formatChartDate(date: Date): string {
return shortDateFormatter.format(date);
}
/**
* Format a Date object as a long date string (e.g., "Nov 1, 2023")
*/
export function formatChartDateLong(date: Date): string {
return longDateFormatter.format(date);
}
/**
* Convert a Date to ISO date string (YYYY-MM-DD) using local date components
*/
export function toISODateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Parse an ISO date string (YYYY-MM-DD) to a local Date object
*/
export function parseISODateString(isoString: string): Date {
const [year, month, day] = isoString.split("-").map(Number);
return new Date(year, month - 1, day);
}
/**
* Format an ISO date string for display (e.g., "2023-11-01" -> "Nov 1")
*/
export function formatISODate(isoString: string): string {
const date = parseISODateString(isoString);
return formatChartDate(date);
}
/**
* Format an ISO date string for display with year (e.g., "2023-11-01" -> "Nov 1, 2023")
*/
export function formatISODateLong(isoString: string): string {
const date = parseISODateString(isoString);
return formatChartDateLong(date);
}
const DateRangeContext = createContext<DateRangeContextType | null>(null);
export function DateRangeProvider({
children,
defaultStartDate,
defaultEndDate,
}: {
children: ReactNode;
defaultStartDate: Date;
defaultEndDate: Date;
}) {
// Store dates as ISO strings for consistent data matching
const defaultStartISO = toISODateString(defaultStartDate);
const defaultEndISO = toISODateString(defaultEndDate);
const [startDate, setStartDate] = useState<string>(defaultStartISO);
const [endDate, setEndDate] = useState<string>(defaultEndISO);
const setDateRange = (start: string, end: string) => {
setStartDate(start);
setEndDate(end);
};
const resetDateRange = () => {
setStartDate(defaultStartISO);
setEndDate(defaultEndISO);
};
return (
<DateRangeContext.Provider
value={{
startDate,
endDate,
setDateRange,
resetDateRange,
}}
>
{children}
</DateRangeContext.Provider>
);
}
export function useDateRange(): DateRangeContextType | null {
const context = useContext(DateRangeContext);
if (!context) {
return null;
}
return context;
}
@@ -0,0 +1,63 @@
/**
* X-axis tick + tooltip label formatters for the task/agent activity charts.
* ClickHouse buckets are UTC-aligned, so labels are formatted in UTC (local time
* causes off-by-one day labels). Tick selection itself lives in useXAxisTicks.
*/
const ONE_MINUTE = 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
type ActivityPoint = { bucket: number };
export function buildActivityTimeAxis(data: ActivityPoint[]) {
const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0;
const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0;
// ≤ 1 day range → clock time, otherwise date.
const showTime = range <= ONE_DAY;
// Sub-minute buckets need seconds, or adjacent ticks collapse to the same "HH:MM".
const showSeconds = bucketMs > 0 && bucketMs < ONE_MINUTE;
const isSubDayBucket = bucketMs > 0 && bucketMs < ONE_DAY;
const tickFormatter = (value: number) => {
const date = new Date(value);
if (showTime) {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
...(showSeconds ? { second: "2-digit" } : {}),
hour12: false,
timeZone: "UTC",
});
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
};
const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => {
const ts = payload?.[0]?.payload?.bucket;
if (typeof ts !== "number" || !Number.isFinite(ts)) return _label;
const date = new Date(ts);
return isSubDayBucket
? date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
...(showSeconds ? { second: "2-digit" } : {}),
hour12: false,
timeZone: "UTC",
})
: date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
};
return { tickFormatter, tooltipLabelFormatter };
}
@@ -0,0 +1,23 @@
import type { AggregationType } from "~/components/metrics/QueryWidget";
/**
* Aggregate an array of numbers using the specified aggregation function.
*
* Shared utility so both QueryResultsChart (data transformation) and chart
* legend components can reuse the same logic without circular imports.
*/
export function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
@@ -0,0 +1,106 @@
import { useCallback, useMemo, useState } from "react";
export type HighlightState = {
/** The currently highlighted series key (e.g., "completed", "failed") */
activeBarKey: string | null;
/** The index of the specific data point being hovered (null when hovering legend) */
activeDataPointIndex: number | null;
/** Whether the tooltip is currently active */
tooltipActive: boolean;
};
export type HighlightActions = {
/** Set the hovered bar (specific data point) */
setHoveredBar: (key: string, index: number) => void;
/** Set the hovered legend item (highlights all bars of that type) */
setHoveredLegendItem: (key: string) => void;
/** Set tooltip active state */
setTooltipActive: (active: boolean) => void;
/** Reset all highlight state */
reset: () => void;
};
export type UseHighlightStateReturn = HighlightState & HighlightActions;
const initialState: HighlightState = {
activeBarKey: null,
activeDataPointIndex: null,
tooltipActive: false,
};
/**
* Hook to manage highlight state for chart elements.
* Handles both bar hover (specific data point) and legend hover (all bars of a type).
*
* activePayload is intentionally NOT managed here — it lives in a separate context
* so that payload updates (frequent during mouse movement) don't cause bar re-renders.
*/
export function useHighlightState(): UseHighlightStateReturn {
const [state, setState] = useState<HighlightState>(initialState);
const setHoveredBar = useCallback((key: string, index: number) => {
setState({
activeBarKey: key,
activeDataPointIndex: index,
tooltipActive: true,
});
}, []);
const setHoveredLegendItem = useCallback((key: string) => {
setState((prev) => {
if (prev.activeBarKey === key && prev.activeDataPointIndex === null) return prev;
return { ...prev, activeBarKey: key, activeDataPointIndex: null };
});
}, []);
const setTooltipActive = useCallback((active: boolean) => {
setState((prev) => {
if (prev.tooltipActive === active) return prev;
return { ...prev, tooltipActive: active };
});
}, []);
const reset = useCallback(() => {
setState(initialState);
}, []);
return useMemo(
() => ({
...state,
setHoveredBar,
setHoveredLegendItem,
setTooltipActive,
reset,
}),
[state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset]
);
}
/**
* Calculate the opacity for a bar based on highlight state.
* @param key - The series key of this bar
* @param dataIndex - The data point index of this bar
* @param highlight - The current highlight state
* @param dimmedOpacity - The opacity to use for dimmed bars (default 0.2)
*/
export function getBarOpacity(
key: string,
dataIndex: number,
highlight: HighlightState,
dimmedOpacity = 0.2
): number {
const { activeBarKey, activeDataPointIndex } = highlight;
// No highlight active - full opacity
if (activeBarKey === null) {
return 1;
}
// Hovering a specific bar (from chart)
if (activeDataPointIndex !== null) {
return key === activeBarKey && dataIndex === activeDataPointIndex ? 1 : dimmedOpacity;
}
// Hovering a legend item (all bars of this type)
return key === activeBarKey ? 1 : dimmedOpacity;
}
@@ -0,0 +1,200 @@
import { useCallback, useMemo, useRef, useState } from "react";
export type ZoomRange = {
start: string;
end: string;
};
export type ZoomSelectionState = {
/** Starting point of drag selection (x-axis value) */
refAreaLeft: string | null;
/** Ending point of drag selection (x-axis value) */
refAreaRight: string | null;
/** Whether user is currently dragging to select */
isSelecting: boolean;
/** Whether the current selection is too small to be valid */
invalidSelection: boolean;
/** X-axis value for the inspection line (click to inspect) */
inspectionLine: string | null;
};
export type ZoomSelectionActions = {
/** Start a new selection at the given x-axis value */
startSelection: (label: string) => void;
/** Update the selection as the user drags */
updateSelection: (label: string, data: any[], dataKey: string, minDataPoints?: number) => void;
/** Finish the selection and return the range if valid */
finishSelection: (data: any[], dataKey: string, minDataPoints?: number) => ZoomRange | null;
/** Cancel the current selection */
cancelSelection: () => void;
/** Toggle the inspection line at the given x-axis value */
toggleInspectionLine: (label: string) => void;
/** Clear the inspection line */
clearInspectionLine: () => void;
/** Reset all zoom state */
reset: () => void;
};
export type UseZoomSelectionReturn = ZoomSelectionState & ZoomSelectionActions;
const initialState: ZoomSelectionState = {
refAreaLeft: null,
refAreaRight: null,
isSelecting: false,
invalidSelection: false,
inspectionLine: null,
};
/**
* Hook to manage zoom selection state for charts.
* Handles drag-to-zoom and click-to-inspect functionality.
*/
export function useZoomSelection(): UseZoomSelectionReturn {
const [state, setState] = useState<ZoomSelectionState>(initialState);
// Ref to track current state synchronously (needed for finishSelection)
const stateRef = useRef<ZoomSelectionState>(state);
// Keep ref in sync with state
stateRef.current = state;
const startSelection = useCallback((label: string) => {
const next = {
...stateRef.current,
refAreaLeft: label,
refAreaRight: null,
isSelecting: true,
invalidSelection: false,
};
// Update ref synchronously first
stateRef.current = next;
setState(next);
}, []);
const updateSelection = useCallback(
(label: string, data: any[], dataKey: string, minDataPoints = 3) => {
const prev = stateRef.current;
if (!prev.isSelecting || !prev.refAreaLeft) {
return;
}
// Check if selection is valid (has enough data points)
const allLabels = data.map((item) => item[dataKey] as string).filter(Boolean);
const leftIndex = allLabels.indexOf(prev.refAreaLeft);
const rightIndex = allLabels.indexOf(label);
let invalidSelection = false;
if (leftIndex !== -1 && rightIndex !== -1) {
const [start, end] = [leftIndex, rightIndex].sort((a, b) => a - b);
invalidSelection = end - start < minDataPoints - 1;
} else {
invalidSelection = true;
}
const next = {
...prev,
refAreaRight: label,
invalidSelection,
};
// Update ref synchronously first
stateRef.current = next;
setState(next);
},
[]
);
const finishSelection = useCallback(
(data: any[], dataKey: string, minDataPoints = 3): ZoomRange | null => {
// Get current state synchronously to calculate result
const currentState = stateRef.current;
if (!currentState.refAreaLeft || !currentState.refAreaRight) {
const next = { ...initialState, inspectionLine: currentState.inspectionLine };
stateRef.current = next;
setState(next);
return null;
}
const allLabels = data.map((item) => item[dataKey] as string).filter(Boolean);
const leftIndex = allLabels.indexOf(currentState.refAreaLeft);
const rightIndex = allLabels.indexOf(currentState.refAreaRight);
let result: ZoomRange | null = null;
if (leftIndex !== -1 && rightIndex !== -1) {
const [startIdx, endIdx] = [leftIndex, rightIndex].sort((a, b) => a - b);
// Only create a valid range if we have enough data points
if (endIdx - startIdx >= minDataPoints - 1) {
result = {
start: allLabels[startIdx],
end: allLabels[endIdx],
};
}
}
// Reset the state (preserve inspection line)
const next = { ...initialState, inspectionLine: currentState.inspectionLine };
stateRef.current = next;
setState(next);
return result;
},
[]
);
const cancelSelection = useCallback(() => {
const next = {
...initialState,
inspectionLine: stateRef.current.inspectionLine,
};
stateRef.current = next;
setState(next);
}, []);
const toggleInspectionLine = useCallback((label: string) => {
const prev = stateRef.current;
const next = {
...prev,
inspectionLine: prev.inspectionLine === label ? null : label,
};
stateRef.current = next;
setState(next);
}, []);
const clearInspectionLine = useCallback(() => {
const next = {
...stateRef.current,
inspectionLine: null,
};
stateRef.current = next;
setState(next);
}, []);
const reset = useCallback(() => {
stateRef.current = initialState;
setState(initialState);
}, []);
return useMemo(
() => ({
...state,
startSelection,
updateSelection,
finishSelection,
cancelSelection,
toggleInspectionLine,
clearInspectionLine,
reset,
}),
[
state,
startSelection,
updateSelection,
finishSelection,
cancelSelection,
toggleInspectionLine,
clearInspectionLine,
reset,
]
);
}
@@ -0,0 +1,19 @@
/** Shared status → color map for the task/agent activity charts.
* Values are CSS variables so they follow the theme; CSS contexts only. */
export const STATUS_COLOR: Record<string, string> = {
// Run-status groups
COMPLETED: "var(--color-success)",
RUNNING: "var(--color-pending)",
FAILED: "var(--color-error)",
CANCELED: "var(--color-text-dimmed)",
// Agent session statuses
ACTIVE: "var(--color-pending)",
CLOSED: "var(--color-success)",
EXPIRED: "var(--color-text-dimmed)",
};
export const STATUS_COLOR_FALLBACK = "var(--color-text-dimmed)";
export function statusColor(status: string): string {
return STATUS_COLOR[status] ?? STATUS_COLOR_FALLBACK;
}
@@ -0,0 +1,99 @@
import { useMemo } from "react";
// At 11px tabular-nums, 1 char ≈ 6.5px, so character count is a width proxy (see useYAxisWidth).
const PX_PER_CH = 6.5;
// Minimum gap between adjacent labels.
const LABEL_GAP_PX = 16;
// Floor so very short labels still get some space.
const MIN_LABEL_PX = 24;
/** Pick `count` indices evenly spaced across [0, n), always including the first and last. */
export function selectEvenlySpacedIndices(n: number, count: number): number[] {
if (n <= 0) return [];
if (count <= 1) return [0];
if (count >= n) return Array.from({ length: n }, (_, i) => i);
if (count === 2) return [0, n - 1];
const step = (n - 1) / (count - 1);
const out: number[] = [];
const seen = new Set<number>();
for (let i = 0; i < count; i++) {
const idx = Math.round(i * step);
if (!seen.has(idx)) {
seen.add(idx);
out.push(idx);
}
}
// Rounding can drop the final index; force it in.
if (!seen.has(n - 1)) out.push(n - 1);
return out;
}
/** Pick `maxLabels` values evenly spaced across `values`, always including the first and last. */
export function selectEvenlySpacedTicks<T>(values: T[], maxLabels: number): T[] {
return selectEvenlySpacedIndices(values.length, maxLabels).map((i) => values[i]);
}
/** How many labels of `maxLabelChars` width fit in `width` pixels. */
export function estimateMaxLabels(width: number, maxLabelChars: number): number {
if (!width || width <= 0) return 0;
const labelPx = Math.max(MIN_LABEL_PX, maxLabelChars * PX_PER_CH) + LABEL_GAP_PX;
return Math.max(1, Math.floor(width / labelPx));
}
/**
* From `indices` (into `labels`/`values`), return the tick values with adjacent
* duplicate labels dropped. The final index is always kept: if it repeats the
* previous label it replaces that tick instead of being skipped, so the
* end-of-range label never disappears (honoring the "first + last" contract).
*/
export function dedupeTicksByLabel<T>(indices: number[], labels: string[], values: T[]): T[] {
const lastIndex = values.length - 1;
const ticks: T[] = [];
let lastLabel: string | null = null;
for (const idx of indices) {
if (labels[idx] === lastLabel) {
if (idx === lastIndex && ticks.length > 0) ticks[ticks.length - 1] = values[idx];
continue;
}
lastLabel = labels[idx];
ticks.push(values[idx]);
}
return ticks;
}
/**
* Explicit x-axis tick values: evenly spaced across the plot, including first +
* last, bounded by how many fit in `plotWidth` and by the count of distinct
* labels (so nothing overlaps or repeats). `plotWidth` excludes the y-axis and
* margins. Returns `undefined` until a width is known (first paint).
*/
export function useXAxisTicks(
data: Array<Record<string, any>>,
dataKey: string,
plotWidth: number | undefined,
tickFormatter?: (value: any, index: number) => string
): any[] | undefined {
return useMemo(() => {
if (!data?.length || !plotWidth || plotWidth <= 0) return undefined;
const n = data.length;
const fmt = tickFormatter ?? ((v: any) => String(v));
const labels = data.map((d, i) => fmt(d[dataKey], i) ?? "");
let maxChars = 0;
for (const label of labels) {
if (label.length > maxChars) maxChars = label.length;
}
// Cap at distinct labels: laying out N evenly-spaced labels (vs one-per-period)
// keeps spacing even when the first/last period is partial.
const fit = estimateMaxLabels(plotWidth, maxChars);
const distinct = new Set(labels).size;
const target = Math.min(fit, distinct, n);
// Evenly spaced on screen, then drop any that repeat the previous label.
const values = data.map((d) => d[dataKey]);
return dedupeTicksByLabel(selectEvenlySpacedIndices(n, target), labels, values);
}, [data, dataKey, plotWidth, tickFormatter]);
}
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import { formatNumberCompact } from "~/utils/numberFormatter";
/**
* Default y-axis tick formatter: compact notation (8000 → "8K", 1_200_000 → "1.2M").
* Chart.Bar / Chart.Line use it unless the caller supplies its own tickFormatter.
*/
export const defaultYAxisTickFormatter = (value: any): string =>
typeof value === "number" ? formatNumberCompact(value) : String(value);
// 1ch at 11px tabular-nums system-ui ≈ 6.5px. Recharts' YAxis.width prop is a
// raw number (pixels), so we can't use the CSS `ch` unit directly — but tabular-nums
// guarantees uniform char width, which makes `chars * pxPerCh` a faithful proxy.
const PX_PER_CH = 6.5;
const PADDING_PX = 16;
const MIN_WIDTH = 32;
const MAX_WIDTH = 120;
export function useYAxisWidth(
data: Array<Record<string, any>> | undefined,
visibleSeries: string[],
tickFormatter?: (value: any, index: number) => string
): number {
return useMemo(() => {
if (!data?.length || !visibleSeries.length) return MIN_WIDTH;
let max = 0;
for (const point of data) {
for (const key of visibleSeries) {
const v = Number(point[key]);
if (Number.isFinite(v) && v > max) max = v;
}
}
const fmt =
tickFormatter ?? ((v: any) => (typeof v === "number" ? v.toLocaleString() : String(v)));
const label = fmt(max, 0);
// Add one char of slack because recharts "nices" the domain up beyond data max.
const charCount = label.length + 1;
const width = Math.ceil(charCount * PX_PER_CH) + PADDING_PX;
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
}, [data, visibleSeries, tickFormatter]);
}