refactor(ui): enhance SankeyChart with interactive tooltips and remove BarChart

- Remove BarChart component (no longer needed)
- Add interactive hover states and tooltips to SankeyChart
- Implement link and node highlighting on hover
- Add PROVIDER_COLORS and STATUS_COLORS constants
- Update Google provider color to official brand color
This commit is contained in:
Alan Buscaglia
2025-10-17 12:38:30 +02:00
parent b9747dc604
commit f16c2cb415
5 changed files with 319 additions and 216 deletions
-162
View File
@@ -1,162 +0,0 @@
"use client";
import {
Bar,
BarChart as RechartsBar,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartTooltip } from "./shared/ChartTooltip";
import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants";
import { getSeverityColorByName } from "./shared/utils";
import { BarDataPoint, LayoutOption } from "./types";
interface BarChartProps {
data: BarDataPoint[];
layout?: LayoutOption;
xLabel?: string;
yLabel?: string;
height?: number;
showValues?: boolean;
}
const CustomLabel = ({ x, y, width, height, value, data }: any) => {
const percentage = data.percentage;
return (
<text
x={x + width + 10}
y={y + height / 2}
fill={CHART_COLORS.textSecondary}
fontSize={12}
textAnchor="start"
dominantBaseline="middle"
>
{percentage !== undefined
? `${percentage}% • ${value.toLocaleString()}`
: value.toLocaleString()}
</text>
);
};
export function BarChart({
data,
layout = LAYOUT_OPTIONS.horizontal,
xLabel,
yLabel,
height = 400,
showValues = true,
}: BarChartProps) {
const isHorizontal = layout === LAYOUT_OPTIONS.horizontal;
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsBar
data={data}
layout={layout}
margin={{ top: 20, right: showValues ? 100 : 30, left: 20, bottom: 20 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={CHART_COLORS.gridLine}
horizontal={isHorizontal}
vertical={!isHorizontal}
/>
{isHorizontal ? (
<>
<XAxis
type="number"
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
<YAxis
dataKey="name"
type="category"
width={100}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
</>
) : (
<>
<XAxis
dataKey="name"
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
<YAxis
type="number"
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
</>
)}
<Tooltip content={<ChartTooltip />} />
<Bar
dataKey="value"
radius={4}
label={
showValues && isHorizontal
? (props: any) => (
<CustomLabel {...props} data={data[props.index]} />
)
: false
}
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
entry.color ||
getSeverityColorByName(entry.name) ||
CHART_COLORS.defaultColor
}
opacity={1}
className="transition-opacity hover:opacity-80"
/>
))}
</Bar>
</RechartsBar>
</ResponsiveContainer>
);
}
+307 -52
View File
@@ -1,11 +1,15 @@
"use client";
import { useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants";
import { CHART_COLORS } from "./shared/constants";
import { ChartTooltip } from "./shared/ChartTooltip";
interface SankeyNode {
name: string;
newFindings?: number;
change?: number;
}
interface SankeyLink {
@@ -22,13 +26,40 @@ interface SankeyChartProps {
height?: number;
}
interface LinkTooltipState {
show: boolean;
x: number;
y: number;
sourceName: string;
targetName: string;
value: number;
color: string;
}
interface NodeTooltipState {
show: boolean;
x: number;
y: number;
name: string;
value: number;
color: string;
newFindings?: number;
change?: number;
}
// Note: Using hex colors directly because Recharts SVG fill doesn't resolve CSS variables
const COLORS: Record<string, string> = {
Success: "var(--chart-success-color)",
Fail: "var(--chart-fail)",
AWS: "var(--chart-provider-aws)",
Azure: "var(--chart-provider-azure)",
Google: "var(--chart-provider-google)",
...SEVERITY_COLORS,
Success: "#86da26",
Fail: "#db2b49",
AWS: "#ff9900",
Azure: "#00bcd4",
Google: "#EA4335",
Critical: "#971348",
High: "#ff3077",
Medium: "#ff7d19",
Low: "#fdd34f",
Info: "#2e51b2",
Informational: "#2e51b2",
};
const CustomTooltip = ({ active, payload }: any) => {
@@ -46,40 +77,88 @@ const CustomTooltip = ({ active, payload }: any) => {
return null;
};
const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => {
const CustomNode = (props: any) => {
const { x, y, width, height, payload, containerWidth } = props;
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
const handleMouseEnter = (e: React.MouseEvent) => {
if (!hasTooltip) return;
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
props.onNodeHover?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
name: nodeName,
value: payload.value,
color,
newFindings: payload.newFindings,
change: payload.change,
});
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasTooltip) return;
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
props.onNodeMove?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
});
}
};
const handleMouseLeave = () => {
if (!hasTooltip) return;
props.onNodeLeave?.();
};
return (
<g>
<g
style={{ cursor: hasTooltip ? "pointer" : "default" }}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<Rectangle
x={x}
y={y}
width={width}
height={height}
fill={color}
fillOpacity="1"
fillOpacity={isHidden ? "0" : "1"}
/>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="14"
className="fill-white stroke-white"
>
{nodeName}
</text>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
className="fill-slate-400 stroke-slate-400"
strokeOpacity="0.5"
>
{payload.value}
</text>
{!isHidden && (
<>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="14"
className="fill-white stroke-white"
>
{nodeName}
</text>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
className="fill-slate-400 stroke-slate-400"
strokeOpacity="0.5"
>
{payload.value}
</text>
</>
)}
</g>
);
};
@@ -93,45 +172,221 @@ const CustomLink = (props: any) => {
sourceControlX,
targetControlX,
linkWidth,
index,
} = props;
const sourceName = props.payload.source?.name || "";
const targetName = props.payload.target?.name || "";
const value = props.payload.value || 0;
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
const isHidden = targetName === "";
const isHovered =
props.hoveredLink !== null && props.hoveredLink === index;
const hasHoveredLink = props.hoveredLink !== null;
const pathD = `
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
Z
`;
const getOpacity = () => {
if (isHidden) return "0";
if (!hasHoveredLink) return "0.4";
return isHovered ? "0.8" : "0.1";
};
const handleMouseEnter = (e: React.MouseEvent) => {
const rect = e.currentTarget.parentElement?.parentElement
?.parentElement as unknown as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
props.onLinkHover?.(index, {
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
sourceName,
targetName,
value,
color,
});
}
};
const handleMouseMove = (e: React.MouseEvent) => {
const rect = e.currentTarget.parentElement?.parentElement
?.parentElement as unknown as SVGSVGElement;
if (rect && isHovered) {
const bbox = rect.getBoundingClientRect();
props.onLinkMove?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
});
}
};
const handleMouseLeave = () => {
props.onLinkLeave?.();
};
return (
<g>
<path
d={`
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
Z
`}
d={pathD}
fill={color}
fillOpacity="0.4"
fillOpacity={getOpacity()}
stroke="none"
style={{ cursor: "pointer", transition: "fill-opacity 0.2s" }}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
</g>
);
};
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
show: false,
x: 0,
y: 0,
sourceName: "",
targetName: "",
value: 0,
color: "",
});
const [nodeTooltip, setNodeTooltip] = useState<NodeTooltipState>({
show: false,
x: 0,
y: 0,
name: "",
value: 0,
color: "",
});
const handleLinkHover = (
index: number,
data: Omit<LinkTooltipState, "show">,
) => {
setHoveredLink(index);
setLinkTooltip({ show: true, ...data });
};
const handleLinkMove = (position: { x: number; y: number }) => {
setLinkTooltip((prev) => ({
...prev,
x: position.x,
y: position.y,
}));
};
const handleLinkLeave = () => {
setHoveredLink(null);
setLinkTooltip((prev) => ({ ...prev, show: false }));
};
const handleNodeHover = (data: Omit<NodeTooltipState, "show">) => {
setNodeTooltip({ show: true, ...data });
};
const handleNodeMove = (position: { x: number; y: number }) => {
setNodeTooltip((prev) => ({
...prev,
x: position.x,
y: position.y,
}));
};
const handleNodeLeave = () => {
setNodeTooltip((prev) => ({ ...prev, show: false }));
};
return (
<ResponsiveContainer width="100%" height={height}>
<Sankey
data={data}
node={<CustomNode />}
link={<CustomLink />}
nodePadding={50}
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
>
<Tooltip content={<CustomTooltip />} />
</Sankey>
</ResponsiveContainer>
<div className="relative">
<ResponsiveContainer width="100%" height={height}>
<Sankey
data={data}
node={
<CustomNode
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
/>
}
link={
<CustomLink
hoveredLink={hoveredLink}
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
/>
}
nodePadding={50}
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
sort={false}
>
<Tooltip content={<CustomTooltip />} />
</Sankey>
</ResponsiveContainer>
{linkTooltip.show && (
<div
className="pointer-events-none absolute z-50"
style={{
left: `${Math.max(125, Math.min(linkTooltip.x, window.innerWidth - 125))}px`,
top: `${Math.max(linkTooltip.y - 80, 10)}px`,
transform: "translate(-50%, -100%)",
}}
>
<ChartTooltip
active={true}
payload={[
{
payload: {
name: linkTooltip.targetName,
value: linkTooltip.value,
color: linkTooltip.color,
},
color: linkTooltip.color,
},
]}
label={`${linkTooltip.sourceName}${linkTooltip.targetName}`}
/>
</div>
)}
{nodeTooltip.show && (
<div
className="pointer-events-none absolute z-50"
style={{
left: `${Math.max(125, Math.min(nodeTooltip.x, window.innerWidth - 125))}px`,
top: `${Math.max(nodeTooltip.y - 80, 10)}px`,
transform: "translate(-50%, -100%)",
}}
>
<ChartTooltip
active={true}
payload={[
{
payload: {
name: nodeTooltip.name,
value: nodeTooltip.value,
color: nodeTooltip.color,
newFindings: nodeTooltip.newFindings,
change: nodeTooltip.change,
},
color: nodeTooltip.color,
},
]}
/>
</div>
)}
</div>
);
}
-1
View File
@@ -1,4 +1,3 @@
export { BarChart } from "./BarChart";
export { DonutChart } from "./DonutChart";
export { HorizontalBarChart } from "./HorizontalBarChart";
export { LineChart } from "./LineChart";
+11
View File
@@ -7,6 +7,17 @@ export const SEVERITY_COLORS = {
Critical: "var(--chart-danger-emphasis)",
} as const;
export const PROVIDER_COLORS = {
AWS: "var(--chart-provider-aws)",
Azure: "var(--chart-provider-azure)",
Google: "var(--chart-provider-google)",
} as const;
export const STATUS_COLORS = {
Success: "var(--chart-success-color)",
Fail: "var(--chart-fail)",
} as const;
export const CHART_COLORS = {
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
+1 -1
View File
@@ -20,7 +20,7 @@
/* Chart Provider Colors */
--chart-provider-aws: #ff9900;
--chart-provider-azure: #00bcd4;
--chart-provider-google: #db2b49;
--chart-provider-google: #EA4335;
/* Chart UI Colors */
--chart-text-secondary: #94a3b8;