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