feat: add bar chart toggle to dashboard

Added visx bar chart component with rounded corners and grow animation.
Dashboard now has area/bar toggle buttons next to the export icon.
This commit is contained in:
Usman Baig
2026-03-21 22:55:19 +01:00
parent 9e128c4945
commit 830da49c5f
4 changed files with 966 additions and 35 deletions

View File

@@ -3,6 +3,8 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip, type TooltipRow } from '@/components/ui/area-chart'
import { BarChart as VisxBarChart, Bar as VisxBar, Grid as VisxBarGrid, BarXAxis as VisxBarXAxis, ChartTooltip as VisxBarChartTooltip } from '@/components/ui/bar-chart'
import { ChartLine, ChartBar } from '@phosphor-icons/react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
@@ -132,6 +134,7 @@ export default function Chart({
onDeleteAnnotation,
}: ChartProps) {
const [metric, setMetric] = useState<MetricType>('visitors')
const [chartType, setChartType] = useState<'area' | 'bar'>('area')
const chartContainerRef = useRef<HTMLDivElement>(null)
const { resolvedTheme } = useTheme()
const [showComparison, setShowComparison] = useState(false)
@@ -392,6 +395,23 @@ export default function Chart({
/>
) : null}
<div className="flex items-center rounded-lg border border-neutral-800 overflow-hidden">
<button
onClick={() => setChartType('area')}
className={`p-1.5 transition-colors cursor-pointer ${chartType === 'area' ? 'bg-neutral-800 text-white' : 'text-neutral-500 hover:text-neutral-300'}`}
title="Area chart"
>
<ChartLine className="w-4 h-4" />
</button>
<button
onClick={() => setChartType('bar')}
className={`p-1.5 transition-colors cursor-pointer ${chartType === 'bar' ? 'bg-neutral-800 text-white' : 'text-neutral-500 hover:text-neutral-300'}`}
title="Bar chart"
>
<ChartBar className="w-4 h-4" />
</button>
</div>
<button
onClick={handleExportChart}
disabled={!hasData}
@@ -421,41 +441,70 @@ export default function Chart({
</div>
) : (
<div className="w-full" onContextMenu={handleChartContextMenu}>
<VisxAreaChart
data={chartData as Record<string, unknown>[]}
xDataKey="dateObj"
aspectRatio="2.5 / 1"
margin={{ top: 20, right: 20, bottom: 40, left: 50 }}
>
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
<VisxArea
dataKey={metric}
fill={CHART_COLORS[metric]}
fillOpacity={0.15}
stroke={CHART_COLORS[metric]}
strokeWidth={2}
gradientToOpacity={0}
/>
<VisxXAxis numTicks={6} />
<VisxYAxis
numTicks={6}
formatValue={(v) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(v) : v.toString()
}}
/>
<VisxChartTooltip
rows={(point) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
const value = point[metric] as number
return [{
color: CHART_COLORS[metric],
label: config?.label || metric,
value: config ? config.format(value) : value,
}]
}}
/>
</VisxAreaChart>
{chartType === 'area' ? (
<VisxAreaChart
data={chartData as Record<string, unknown>[]}
xDataKey="dateObj"
aspectRatio="2.5 / 1"
margin={{ top: 20, right: 20, bottom: 40, left: 50 }}
>
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
<VisxArea
dataKey={metric}
fill={CHART_COLORS[metric]}
fillOpacity={0.15}
stroke={CHART_COLORS[metric]}
strokeWidth={2}
gradientToOpacity={0}
/>
<VisxXAxis numTicks={6} />
<VisxYAxis
numTicks={6}
formatValue={(v) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(v) : v.toString()
}}
/>
<VisxChartTooltip
rows={(point) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
const value = point[metric] as number
return [{
color: CHART_COLORS[metric],
label: config?.label || metric,
value: config ? config.format(value) : value,
}]
}}
/>
</VisxAreaChart>
) : (
<VisxBarChart
data={chartData as Record<string, unknown>[]}
xDataKey="date"
aspectRatio="2.5 / 1"
margin={{ top: 20, right: 20, bottom: 40, left: 50 }}
barGap={0.3}
>
<VisxBarGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
<VisxBar
dataKey={metric}
fill={CHART_COLORS[metric]}
lineCap="round"
/>
<VisxBarXAxis maxLabels={8} />
<VisxBarChartTooltip
rows={(point) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
const value = point[metric] as number
return [{
color: CHART_COLORS[metric],
label: config?.label || metric,
value: config ? config.format(value) : value,
}]
}}
/>
</VisxBarChart>
)}
</div>
)}
</CardContent>

867
components/ui/bar-chart.tsx Normal file
View File

@@ -0,0 +1,867 @@
"use client";
import { localPoint } from "@visx/event";
import { LinearGradient as VisxLinearGradient } from "@visx/gradient";
import { GridColumns, GridRows } from "@visx/grid";
import { ParentSize } from "@visx/responsive";
import { scaleBand, scaleLinear } from "@visx/scale";
import {
AnimatePresence,
motion,
useSpring,
} from "motion/react";
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactElement,
type ReactNode,
type RefObject,
type SetStateAction,
} from "react";
import useMeasure from "react-use-measure";
import { createPortal } from "react-dom";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// ─── Utils ───────────────────────────────────────────────────────────────────
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// ─── CSS Vars ────────────────────────────────────────────────────────────────
export const chartCssVars = {
background: "var(--chart-background)",
foreground: "var(--chart-foreground)",
foregroundMuted: "var(--chart-foreground-muted)",
label: "var(--chart-label)",
linePrimary: "var(--chart-line-primary)",
lineSecondary: "var(--chart-line-secondary)",
crosshair: "var(--chart-crosshair)",
grid: "var(--chart-grid)",
};
// ─── Types ───────────────────────────────────────────────────────────────────
export interface Margin {
top: number;
right: number;
bottom: number;
left: number;
}
export interface TooltipData {
point: Record<string, unknown>;
index: number;
x: number;
yPositions: Record<string, number>;
xPositions?: Record<string, number>;
}
export interface TooltipRow {
color: string;
label: string;
value: string | number;
}
export interface LineConfig {
dataKey: string;
stroke: string;
strokeWidth: number;
}
// ─── Bar Chart Context ───────────────────────────────────────────────────────
type ScaleLinearType<Output> = ReturnType<typeof scaleLinear<Output>>;
type ScaleBandType<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
export interface BarChartContextValue {
data: Record<string, unknown>[];
xScale: ScaleBandType<string>;
yScale: ScaleLinearType<number>;
width: number;
height: number;
innerWidth: number;
innerHeight: number;
margin: Margin;
bandWidth: number;
tooltipData: TooltipData | null;
setTooltipData: Dispatch<SetStateAction<TooltipData | null>>;
containerRef: RefObject<HTMLDivElement | null>;
bars: BarConfig[];
isLoaded: boolean;
animationDuration: number;
xDataKey: string;
hoveredBarIndex: number | null;
setHoveredBarIndex: (index: number | null) => void;
orientation: "vertical" | "horizontal";
stacked: boolean;
stackGap: number;
stackOffsets: Map<number, Map<string, number>>;
barGap: number;
barWidth?: number;
}
interface BarConfig {
dataKey: string;
fill: string;
stroke?: string;
}
const BarChartContext = createContext<BarChartContextValue | null>(null);
function BarChartProvider({
children,
value,
}: {
children: ReactNode;
value: BarChartContextValue;
}) {
return (
<BarChartContext.Provider value={value}>
{children}
</BarChartContext.Provider>
);
}
export function useChart(): BarChartContextValue {
const context = useContext(BarChartContext);
if (!context) {
throw new Error(
"useChart must be used within a BarChartProvider. " +
"Make sure your component is wrapped in <BarChart>."
);
}
return context;
}
// ─── Tooltip Components ──────────────────────────────────────────────────────
interface TooltipDotProps {
x: number;
y: number;
visible: boolean;
color: string;
size?: number;
strokeColor?: string;
strokeWidth?: number;
}
function TooltipDot({
x,
y,
visible,
color,
size = 5,
strokeColor = chartCssVars.background,
strokeWidth = 2,
}: TooltipDotProps) {
const springConfig = { stiffness: 300, damping: 30 };
const animatedX = useSpring(x, springConfig);
const animatedY = useSpring(y, springConfig);
useEffect(() => {
animatedX.set(x);
animatedY.set(y);
}, [x, y, animatedX, animatedY]);
if (!visible) return null;
return (
<motion.circle
cx={animatedX}
cy={animatedY}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
TooltipDot.displayName = "TooltipDot";
interface TooltipIndicatorProps {
x: number;
height: number;
visible: boolean;
width?: number;
colorEdge?: string;
colorMid?: string;
fadeEdges?: boolean;
gradientId?: string;
}
function TooltipIndicator({
x,
height,
visible,
width = 1,
colorEdge = chartCssVars.crosshair,
colorMid = chartCssVars.crosshair,
fadeEdges = true,
gradientId = "bar-tooltip-indicator-gradient",
}: TooltipIndicatorProps) {
const springConfig = { stiffness: 300, damping: 30 };
const animatedX = useSpring(x - width / 2, springConfig);
useEffect(() => {
animatedX.set(x - width / 2);
}, [x, animatedX, width]);
if (!visible) return null;
const edgeOpacity = fadeEdges ? 0 : 1;
return (
<g>
<defs>
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }} />
<stop offset="10%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="50%" style={{ stopColor: colorMid, stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }} />
</linearGradient>
</defs>
<motion.rect fill={`url(#${gradientId})`} height={height} width={width} x={animatedX} y={0} />
</g>
);
}
TooltipIndicator.displayName = "TooltipIndicator";
interface TooltipContentProps {
title?: string;
rows: TooltipRow[];
children?: ReactNode;
}
function TooltipContent({ title, rows, children }: TooltipContentProps) {
const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false });
const [committedHeight, setCommittedHeight] = useState<number | null>(null);
const committedChildrenStateRef = useRef<boolean | null>(null);
const frameRef = useRef<number | null>(null);
const hasChildren = !!children;
const markerKey = hasChildren ? "has-marker" : "no-marker";
const isWaitingForSettlement = committedChildrenStateRef.current !== null && committedChildrenStateRef.current !== hasChildren;
useEffect(() => {
if (bounds.height <= 0) return;
if (frameRef.current) { cancelAnimationFrame(frameRef.current); frameRef.current = null; }
if (isWaitingForSettlement) {
frameRef.current = requestAnimationFrame(() => {
frameRef.current = requestAnimationFrame(() => {
setCommittedHeight(bounds.height);
committedChildrenStateRef.current = hasChildren;
});
});
} else {
setCommittedHeight(bounds.height);
committedChildrenStateRef.current = hasChildren;
}
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); };
}, [bounds.height, hasChildren, isWaitingForSettlement]);
const shouldAnimate = committedHeight !== null;
return (
<motion.div
animate={committedHeight !== null ? { height: committedHeight } : undefined}
className="overflow-hidden"
initial={false}
transition={shouldAnimate ? { type: "spring", stiffness: 500, damping: 35, mass: 0.8 } : { duration: 0 }}
>
<div className="px-3 py-2.5" ref={measureRef}>
{title && <div className="mb-2 font-medium text-neutral-400 text-xs">{title}</div>}
<div className="space-y-1.5">
{rows.map((row) => (
<div className="flex items-center justify-between gap-4" key={`${row.label}-${row.color}`}>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
<span className="text-neutral-400 text-sm">{row.label}</span>
</div>
<span className="font-medium text-white text-sm tabular-nums">
{typeof row.value === "number" ? row.value.toLocaleString() : row.value}
</span>
</div>
))}
</div>
<AnimatePresence mode="wait">
{children && (
<motion.div animate={{ opacity: 1, filter: "blur(0px)" }} className="mt-2" exit={{ opacity: 0, filter: "blur(4px)" }} initial={{ opacity: 0, filter: "blur(4px)" }} key={markerKey} transition={{ duration: 0.2, ease: "easeOut" }}>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
TooltipContent.displayName = "TooltipContent";
interface TooltipBoxProps {
x: number;
y: number;
visible: boolean;
containerRef: RefObject<HTMLDivElement | null>;
containerWidth: number;
containerHeight: number;
offset?: number;
className?: string;
children: ReactNode;
top?: number | ReturnType<typeof useSpring>;
}
function TooltipBox({
x, y, visible, containerRef, containerWidth, containerHeight, offset = 16, className = "", children, top: topOverride,
}: TooltipBoxProps) {
const tooltipRef = useRef<HTMLDivElement>(null);
const [tooltipWidth, setTooltipWidth] = useState(180);
const [tooltipHeight, setTooltipHeight] = useState(80);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
useLayoutEffect(() => {
if (tooltipRef.current) {
const w = tooltipRef.current.offsetWidth;
const h = tooltipRef.current.offsetHeight;
if (w > 0 && w !== tooltipWidth) setTooltipWidth(w);
if (h > 0 && h !== tooltipHeight) setTooltipHeight(h);
}
}, [tooltipWidth, tooltipHeight]);
const shouldFlipX = x + tooltipWidth + offset > containerWidth;
const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset;
const targetY = Math.max(offset, Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset));
const prevFlipRef = useRef(shouldFlipX);
const [flipKey, setFlipKey] = useState(0);
useEffect(() => {
if (prevFlipRef.current !== shouldFlipX) { setFlipKey((k) => k + 1); prevFlipRef.current = shouldFlipX; }
}, [shouldFlipX]);
const springConfig = { stiffness: 100, damping: 20 };
const animatedLeft = useSpring(targetX, springConfig);
const animatedTop = useSpring(targetY, springConfig);
useEffect(() => { animatedLeft.set(targetX); }, [targetX, animatedLeft]);
useEffect(() => { animatedTop.set(targetY); }, [targetY, animatedTop]);
const finalTop = topOverride ?? animatedTop;
const transformOrigin = shouldFlipX ? "right top" : "left top";
const container = containerRef.current;
if (!(mounted && container)) return null;
if (!visible) return null;
return createPortal(
<motion.div animate={{ opacity: 1 }} className={cn("pointer-events-none absolute z-50", className)} exit={{ opacity: 0 }} initial={{ opacity: 0 }} ref={tooltipRef} style={{ left: animatedLeft, top: finalTop }} transition={{ duration: 0.1 }}>
<motion.div animate={{ scale: 1, opacity: 1, x: 0 }} className="min-w-[140px] overflow-hidden rounded-lg bg-neutral-800/90 text-white shadow-lg backdrop-blur-md" initial={{ scale: 0.85, opacity: 0, x: shouldFlipX ? 20 : -20 }} key={flipKey} style={{ transformOrigin }} transition={{ type: "spring", stiffness: 300, damping: 25 }}>
{children}
</motion.div>
</motion.div>,
container
);
}
TooltipBox.displayName = "TooltipBox";
// ─── ChartTooltip ────────────────────────────────────────────────────────────
export interface ChartTooltipProps {
showCrosshair?: boolean;
showDots?: boolean;
content?: (props: { point: Record<string, unknown>; index: number }) => ReactNode;
rows?: (point: Record<string, unknown>) => TooltipRow[];
children?: ReactNode;
className?: string;
}
export function ChartTooltip({ showCrosshair = true, showDots = true, content, rows: rowsRenderer, children, className = "" }: ChartTooltipProps) {
const { tooltipData, width, height, innerHeight, margin, bars, xDataKey, containerRef, orientation, yScale } = useChart();
const isHorizontal = orientation === "horizontal";
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const visible = tooltipData !== null;
const x = tooltipData?.x ?? 0;
const xWithMargin = x + margin.left;
const firstBarDataKey = bars[0]?.dataKey;
const firstBarY = firstBarDataKey ? (tooltipData?.yPositions[firstBarDataKey] ?? 0) : 0;
const yWithMargin = firstBarY + margin.top;
const springConfig = { stiffness: 300, damping: 30 };
const animatedX = useSpring(xWithMargin, springConfig);
useEffect(() => { animatedX.set(xWithMargin); }, [xWithMargin, animatedX]);
const tooltipRows = useMemo(() => {
if (!tooltipData) return [];
if (rowsRenderer) return rowsRenderer(tooltipData.point);
return bars.map((bar) => ({ color: bar.stroke || bar.fill, label: bar.dataKey, value: (tooltipData.point[bar.dataKey] as number) ?? 0 }));
}, [tooltipData, bars, rowsRenderer]);
const title = useMemo(() => {
if (!tooltipData) return undefined;
return String(tooltipData.point[xDataKey] ?? "");
}, [tooltipData, xDataKey]);
const container = containerRef.current;
if (!(mounted && container)) return null;
const tooltipContent = (
<>
{showCrosshair && !isHorizontal && (
<svg aria-hidden="true" className="pointer-events-none absolute inset-0" height="100%" width="100%">
<g transform={`translate(${margin.left},${margin.top})`}>
<TooltipIndicator height={innerHeight} visible={visible} width={1} x={x} />
</g>
</svg>
)}
{showDots && visible && !isHorizontal && (
<svg aria-hidden="true" className="pointer-events-none absolute inset-0" height="100%" width="100%">
<g transform={`translate(${margin.left},${margin.top})`}>
{bars.map((bar) => (
<TooltipDot color={bar.stroke || bar.fill} key={bar.dataKey} strokeColor={chartCssVars.background} visible={visible} x={tooltipData?.xPositions?.[bar.dataKey] ?? x} y={tooltipData?.yPositions[bar.dataKey] ?? 0} />
))}
</g>
</svg>
)}
<TooltipBox className={className} containerHeight={height} containerRef={containerRef} containerWidth={width} top={isHorizontal ? undefined : margin.top} visible={visible} x={xWithMargin} y={isHorizontal ? yWithMargin : margin.top}>
{content ? content({ point: tooltipData?.point ?? {}, index: tooltipData?.index ?? 0 }) : (
<TooltipContent rows={tooltipRows} title={title}>{children}</TooltipContent>
)}
</TooltipBox>
</>
);
return createPortal(tooltipContent, container);
}
ChartTooltip.displayName = "ChartTooltip";
// ─── Grid ────────────────────────────────────────────────────────────────────
export interface GridProps {
horizontal?: boolean;
vertical?: boolean;
numTicksRows?: number;
numTicksColumns?: number;
rowTickValues?: number[];
stroke?: string;
strokeOpacity?: number;
strokeWidth?: number;
strokeDasharray?: string;
fadeHorizontal?: boolean;
fadeVertical?: boolean;
}
export function Grid({
horizontal = true, vertical = false, numTicksRows = 5, numTicksColumns = 10, rowTickValues,
stroke = chartCssVars.grid, strokeOpacity = 1, strokeWidth = 1, strokeDasharray = "4,4",
fadeHorizontal = true, fadeVertical = false,
}: GridProps) {
const { xScale, yScale, innerWidth, innerHeight, orientation } = useChart();
const isHorizontalBar = orientation === "horizontal";
const columnScale = isHorizontalBar ? yScale : xScale;
const uniqueId = useId();
const hMaskId = `grid-rows-fade-${uniqueId}`;
const hGradientId = `${hMaskId}-gradient`;
const vMaskId = `grid-cols-fade-${uniqueId}`;
const vGradientId = `${vMaskId}-gradient`;
return (
<g className="chart-grid">
{horizontal && fadeHorizontal && (
<defs>
<linearGradient id={hGradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: "white", stopOpacity: 0 }} />
</linearGradient>
<mask id={hMaskId}><rect fill={`url(#${hGradientId})`} height={innerHeight} width={innerWidth} x="0" y="0" /></mask>
</defs>
)}
{vertical && fadeVertical && (
<defs>
<linearGradient id={vGradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: "white", stopOpacity: 0 }} />
</linearGradient>
<mask id={vMaskId}><rect fill={`url(#${vGradientId})`} height={innerHeight} width={innerWidth} x="0" y="0" /></mask>
</defs>
)}
{horizontal && (
<g mask={fadeHorizontal ? `url(#${hMaskId})` : undefined}>
<GridRows numTicks={rowTickValues ? undefined : numTicksRows} scale={yScale} stroke={stroke} strokeDasharray={strokeDasharray} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} tickValues={rowTickValues} width={innerWidth} />
</g>
)}
{vertical && columnScale && typeof columnScale === "function" && (
<g mask={fadeVertical ? `url(#${vMaskId})` : undefined}>
<GridColumns height={innerHeight} numTicks={numTicksColumns} scale={columnScale} stroke={stroke} strokeDasharray={strokeDasharray} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} />
</g>
)}
</g>
);
}
Grid.displayName = "Grid";
// ─── BarXAxis ────────────────────────────────────────────────────────────────
export interface BarXAxisProps {
tickerHalfWidth?: number;
showAllLabels?: boolean;
maxLabels?: number;
}
export function BarXAxis({ tickerHalfWidth = 50, showAllLabels = false, maxLabels = 12 }: BarXAxisProps) {
const { xScale, margin, tooltipData, containerRef, bandWidth } = useChart();
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const labelsToShow = useMemo(() => {
const domain = xScale.domain();
if (domain.length === 0) return [];
let labels = domain.map((label) => ({ label, x: (xScale(label) ?? 0) + bandWidth / 2 + margin.left }));
if (!showAllLabels && labels.length > maxLabels) {
const step = Math.ceil(labels.length / maxLabels);
labels = labels.filter((_, i) => i % step === 0);
}
return labels;
}, [xScale, margin.left, bandWidth, showAllLabels, maxLabels]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
const container = containerRef.current;
if (!(mounted && container)) return null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => {
let opacity = 1;
if (isHovering && crosshairX !== null) {
const fadeBuffer = 20;
const fadeRadius = tickerHalfWidth + fadeBuffer;
const distance = Math.abs(item.x - crosshairX);
if (distance < tickerHalfWidth) opacity = 0;
else if (distance < fadeRadius) opacity = (distance - tickerHalfWidth) / fadeBuffer;
}
return (
<div className="absolute" key={item.label} style={{ left: item.x, bottom: 12, width: 0, display: "flex", justifyContent: "center" }}>
<motion.span animate={{ opacity }} className="whitespace-nowrap text-neutral-500 text-xs" initial={{ opacity: 1 }} transition={{ duration: 0.4, ease: "easeInOut" }}>
{item.label}
</motion.span>
</div>
);
})}
</div>,
container
);
}
BarXAxis.displayName = "BarXAxis";
// ─── Bar ─────────────────────────────────────────────────────────────────────
export interface BarProps {
dataKey: string;
fill?: string;
stroke?: string;
lineCap?: "round" | "butt" | number;
animate?: boolean;
animationType?: "grow" | "fade";
fadedOpacity?: number;
staggerDelay?: number;
stackGap?: number;
}
function resolveRadius(lineCap: "round" | "butt" | number, barWidth: number): number {
if (lineCap === "butt") return 0;
if (lineCap === "round") return barWidth / 2;
return lineCap;
}
export function Bar({
dataKey, fill = chartCssVars.linePrimary, stroke, lineCap = "round", animate = true,
animationType = "grow", fadedOpacity = 0.3, staggerDelay, stackGap = 0,
}: BarProps) {
const {
data, xScale, yScale, innerHeight, innerWidth, bandWidth, hoveredBarIndex, isLoaded, animationDuration,
xDataKey, orientation, stacked, stackOffsets, bars, barWidth: fixedBarWidth,
} = useChart();
const isHorizontal = orientation === "horizontal";
const barIndex = bars.findIndex((b) => b.dataKey === dataKey);
const barCount = bars.length;
const singleBarWidth = stacked ? bandWidth : bandWidth / barCount;
const actualBarWidth = fixedBarWidth ?? singleBarWidth;
const radius = resolveRadius(lineCap, actualBarWidth);
const autoStagger = staggerDelay ?? Math.min(0.06, 0.8 / data.length);
return (
<>
{data.map((d, i) => {
const category = String(d[xDataKey] ?? "");
const value = typeof d[dataKey] === "number" ? (d[dataKey] as number) : 0;
const bandStart = xScale(category) ?? 0;
const stackOffset = stacked ? stackOffsets.get(i)?.get(dataKey) ?? 0 : 0;
let barX: number, barY: number, barW: number, barH: number;
if (isHorizontal) {
const barLength = innerWidth - (yScale(value) ?? innerWidth);
barY = bandStart + (stacked ? 0 : barIndex * singleBarWidth);
barH = actualBarWidth;
barW = barLength;
barX = stacked ? stackOffset : 0;
if (stacked && stackGap > 0 && barIndex > 0) { barX += stackGap; barW = Math.max(0, barW - stackGap); }
} else {
const scaledY = yScale(value) ?? innerHeight;
barX = bandStart + (stacked ? 0 : barIndex * singleBarWidth);
barW = actualBarWidth;
barH = innerHeight - scaledY;
barY = stacked ? scaledY - stackOffset : scaledY;
if (stacked && stackGap > 0 && barIndex > 0) { barY += stackGap; barH = Math.max(0, barH - stackGap); }
}
if (barW <= 0 || barH <= 0) return null;
const isHovered = hoveredBarIndex === i;
const someoneHovered = hoveredBarIndex !== null;
const barOpacity = someoneHovered ? (isHovered ? 1 : fadedOpacity) : 1;
const delay = i * autoStagger;
const r = Math.min(radius, barW / 2, barH / 2);
let path: string;
if (isHorizontal) {
path = `M${barX},${barY} L${barX + barW - r},${barY} Q${barX + barW},${barY} ${barX + barW},${barY + r} L${barX + barW},${barY + barH - r} Q${barX + barW},${barY + barH} ${barX + barW - r},${barY + barH} L${barX},${barY + barH}Z`;
} else {
path = `M${barX},${barY + barH} L${barX},${barY + r} Q${barX},${barY} ${barX + r},${barY} L${barX + barW - r},${barY} Q${barX + barW},${barY} ${barX + barW},${barY + r} L${barX + barW},${barY + barH}Z`;
}
const originX = isHorizontal ? barX : barX + barW / 2;
const originY = isHorizontal ? barY + barH / 2 : innerHeight;
const shouldAnimateEntry = animate && !isLoaded;
const growInitial = isHorizontal ? { scaleX: 0, opacity: 0 } : { scaleY: 0, opacity: 0 };
const growAnimate = isHorizontal ? { scaleX: 1, opacity: barOpacity } : { scaleY: 1, opacity: barOpacity };
const growTransition = {
[isHorizontal ? "scaleX" : "scaleY"]: { duration: animationDuration / 1000, ease: [0.85, 0, 0.15, 1] as [number, number, number, number], delay },
opacity: { duration: 0.3, ease: "easeInOut" as const },
};
return (
<motion.path
key={`${category}-${dataKey}`}
d={path}
fill={fill}
style={{ transformOrigin: `${originX}px ${originY}px` }}
initial={shouldAnimateEntry && animationType === "grow" ? growInitial : shouldAnimateEntry && animationType === "fade" ? { opacity: 0, filter: "blur(4px)" } : { opacity: barOpacity }}
animate={shouldAnimateEntry && animationType === "grow" ? growAnimate : shouldAnimateEntry && animationType === "fade" ? { opacity: barOpacity, filter: "blur(0px)" } : { opacity: barOpacity }}
transition={shouldAnimateEntry && animationType === "grow" ? growTransition : shouldAnimateEntry && animationType === "fade" ? { duration: 0.5, delay, ease: "easeOut" } : { opacity: { duration: 0.3, ease: "easeInOut" } }}
/>
);
})}
</>
);
}
Bar.displayName = "Bar";
// ─── Re-exports ──────────────────────────────────────────────────────────────
export { VisxLinearGradient as LinearGradient };
// ─── BarChart ────────────────────────────────────────────────────────────────
function extractBarConfigs(children: ReactNode): BarConfig[] {
const configs: BarConfig[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
const childType = child.type as { displayName?: string; name?: string };
const componentName = typeof child.type === "function" ? childType.displayName || childType.name || "" : "";
const props = child.props as BarProps | undefined;
const isBarComponent = componentName === "Bar" || child.type === Bar || (props && typeof props.dataKey === "string" && props.dataKey.length > 0);
if (isBarComponent && props?.dataKey) {
configs.push({ dataKey: props.dataKey, fill: props.fill || "var(--chart-line-primary)", stroke: props.stroke });
}
});
return configs;
}
export interface BarChartProps {
data: Record<string, unknown>[];
xDataKey?: string;
margin?: Partial<Margin>;
animationDuration?: number;
aspectRatio?: string;
barGap?: number;
barWidth?: number;
orientation?: "vertical" | "horizontal";
stacked?: boolean;
stackGap?: number;
className?: string;
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
interface BarChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
barGap: number;
barWidth?: number;
orientation: "vertical" | "horizontal";
stacked: boolean;
stackGap: number;
children: ReactNode;
containerRef: RefObject<HTMLDivElement | null>;
}
function BarChartInner({
width, height, data, xDataKey, margin, animationDuration, barGap, barWidth, orientation, stacked, stackGap, children, containerRef,
}: BarChartInnerProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState<number | null>(null);
const bars = useMemo(() => extractBarConfigs(children), [children]);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const isHorizontal = orientation === "horizontal";
const xScale = useMemo(() => {
const domain = data.map((d) => String(d[xDataKey] ?? ""));
return scaleBand<string>({ range: isHorizontal ? [0, innerHeight] : [0, innerWidth], domain, padding: barGap });
}, [data, xDataKey, innerWidth, innerHeight, barGap, isHorizontal]);
const bandWidth = xScale.bandwidth();
const yScale = useMemo(() => {
let maxValue = 0;
if (stacked) {
for (const d of data) { let sum = 0; for (const bar of bars) { const v = d[bar.dataKey]; if (typeof v === "number") sum += v; } if (sum > maxValue) maxValue = sum; }
} else {
for (const bar of bars) { for (const d of data) { const v = d[bar.dataKey]; if (typeof v === "number" && v > maxValue) maxValue = v; } }
}
if (maxValue === 0) maxValue = 100;
return scaleLinear<number>({ range: isHorizontal ? [innerWidth, 0] : [innerHeight, 0], domain: [0, maxValue * 1.1], nice: true });
}, [data, bars, innerWidth, innerHeight, stacked, isHorizontal]);
const stackOffsets = useMemo(() => {
if (!stacked) return new Map<number, Map<string, number>>();
const offsets = new Map<number, Map<string, number>>();
for (let i = 0; i < data.length; i++) {
const d = data[i]!;
let cumulative = 0;
const barOffsets = new Map<string, number>();
for (const bar of bars) {
barOffsets.set(bar.dataKey, cumulative);
const v = d[bar.dataKey];
if (typeof v === "number") { cumulative += isHorizontal ? innerWidth - (yScale(v) ?? innerWidth) : innerHeight - (yScale(v) ?? innerHeight); }
}
offsets.set(i, barOffsets);
}
return offsets;
}, [data, bars, stacked, yScale, innerHeight, innerWidth, isHorizontal]);
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);
useEffect(() => { const timer = setTimeout(() => setIsLoaded(true), animationDuration); return () => clearTimeout(timer); }, [animationDuration]);
const handleMouseMove = useCallback((event: React.MouseEvent<SVGGElement>) => {
const point = localPoint(event);
if (!point) return;
const chartX = point.x - margin.left;
const chartY = point.y - margin.top;
const domain = xScale.domain();
let foundIndex = -1;
for (let i = 0; i < domain.length; i++) {
const cat = domain[i]!;
const bandStart = xScale(cat) ?? 0;
const bandEnd = bandStart + bandWidth;
if (isHorizontal ? (chartY >= bandStart && chartY <= bandEnd) : (chartX >= bandStart && chartX <= bandEnd)) { foundIndex = i; break; }
}
if (foundIndex >= 0) {
setHoveredBarIndex(foundIndex);
const d = data[foundIndex]!;
const yPositions: Record<string, number> = {};
const xPositions: Record<string, number> = {};
for (const bar of bars) {
const v = d[bar.dataKey];
if (typeof v === "number") {
if (isHorizontal) { xPositions[bar.dataKey] = innerWidth - (yScale(v) ?? innerWidth); yPositions[bar.dataKey] = (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; }
else { yPositions[bar.dataKey] = yScale(v) ?? 0; xPositions[bar.dataKey] = (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; }
}
}
const tooltipX = isHorizontal ? innerWidth - (yScale(Number(d[bars[0]?.dataKey ?? ""] ?? 0)) ?? 0) : (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2;
setTooltipData({ point: d, index: foundIndex, x: tooltipX, yPositions, xPositions });
} else { setHoveredBarIndex(null); setTooltipData(null); }
}, [xScale, yScale, data, bars, margin, bandWidth, isHorizontal, innerWidth]);
const handleMouseLeave = useCallback(() => { setHoveredBarIndex(null); setTooltipData(null); }, []);
if (width < 10 || height < 10) return null;
const contextValue: BarChartContextValue = {
data, xScale, yScale, width, height, innerWidth, innerHeight, margin, bandWidth, tooltipData, setTooltipData, containerRef, bars, isLoaded, animationDuration, xDataKey, hoveredBarIndex, setHoveredBarIndex, orientation, stacked, stackGap, stackOffsets, barGap, barWidth,
};
return (
<BarChartProvider value={contextValue}>
<svg aria-hidden="true" height={height} width={width}>
<rect fill="transparent" height={height} width={width} x={0} y={0} />
<g onMouseMove={isLoaded ? handleMouseMove : undefined} onMouseLeave={isLoaded ? handleMouseLeave : undefined} style={{ cursor: isLoaded ? "crosshair" : "default", touchAction: "none" }} transform={`translate(${margin.left},${margin.top})`}>
<rect fill="transparent" height={innerHeight} width={innerWidth} x={0} y={0} />
{children}
</g>
</svg>
</BarChartProvider>
);
}
export function BarChart({
data, xDataKey = "name", margin: marginProp, animationDuration = 1100, aspectRatio = "2 / 1",
barGap = 0.2, barWidth, orientation = "vertical", stacked = false, stackGap = 0, className = "", children,
}: BarChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
return (
<div className={cn("relative w-full", className)} ref={containerRef} style={{ aspectRatio, touchAction: "none" }}>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<BarChartInner animationDuration={animationDuration} barGap={barGap} barWidth={barWidth} containerRef={containerRef} data={data} height={height} margin={margin} orientation={orientation} stacked={stacked} stackGap={stackGap} width={width} xDataKey={xDataKey}>
{children}
</BarChartInner>
)}
</ParentSize>
</div>
);
}
export default BarChart;

14
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"@types/d3": "^7.4.3",
"@visx/curve": "^3.12.0",
"@visx/event": "^3.12.0",
"@visx/gradient": "^3.12.0",
"@visx/grid": "^3.12.0",
"@visx/responsive": "^3.12.0",
"@visx/scale": "^3.12.0",
@@ -6769,6 +6770,19 @@
"@visx/point": "3.12.0"
}
},
"node_modules/@visx/gradient": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@visx/gradient/-/gradient-3.12.0.tgz",
"integrity": "sha512-QRatjjdUEPbcp4pqRca1JlChpAnmmIAO3r3ZscLK7D1xEIANlIjzjl3uNgrmseYmBAYyPCcJH8Zru07R97ovOg==",
"license": "MIT",
"dependencies": {
"@types/react": "*",
"prop-types": "^15.5.7"
},
"peerDependencies": {
"react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
}
},
"node_modules/@visx/grid": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-3.12.0.tgz",

View File

@@ -25,6 +25,7 @@
"@types/d3": "^7.4.3",
"@visx/curve": "^3.12.0",
"@visx/event": "^3.12.0",
"@visx/gradient": "^3.12.0",
"@visx/grid": "^3.12.0",
"@visx/responsive": "^3.12.0",
"@visx/scale": "^3.12.0",