From f16c2cb4150a106333790520de79eac546ead38f Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 17 Oct 2025 12:38:30 +0200 Subject: [PATCH] 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 --- ui/components/graphs/BarChart.tsx | 162 ---------- ui/components/graphs/SankeyChart.tsx | 359 +++++++++++++++++++---- ui/components/graphs/index.ts | 1 - ui/components/graphs/shared/constants.ts | 11 + ui/styles/globals.css | 2 +- 5 files changed, 319 insertions(+), 216 deletions(-) delete mode 100644 ui/components/graphs/BarChart.tsx diff --git a/ui/components/graphs/BarChart.tsx b/ui/components/graphs/BarChart.tsx deleted file mode 100644 index 8ebb7eca3b..0000000000 --- a/ui/components/graphs/BarChart.tsx +++ /dev/null @@ -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 ( - - {percentage !== undefined - ? `${percentage}% • ${value.toLocaleString()}` - : value.toLocaleString()} - - ); -}; - -export function BarChart({ - data, - layout = LAYOUT_OPTIONS.horizontal, - xLabel, - yLabel, - height = 400, - showValues = true, -}: BarChartProps) { - const isHorizontal = layout === LAYOUT_OPTIONS.horizontal; - - return ( - - - - {isHorizontal ? ( - <> - - - - ) : ( - <> - - - - )} - } /> - ( - - ) - : false - } - > - {data.map((entry, index) => ( - - ))} - - - - ); -} diff --git a/ui/components/graphs/SankeyChart.tsx b/ui/components/graphs/SankeyChart.tsx index 8f85d5f7e6..75750c2a53 100644 --- a/ui/components/graphs/SankeyChart.tsx +++ b/ui/components/graphs/SankeyChart.tsx @@ -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 = { - 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 ( - + - - {nodeName} - - - {payload.value} - + {!isHidden && ( + <> + + {nodeName} + + + {payload.value} + + + )} ); }; @@ -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 ( ); }; export function SankeyChart({ data, height = 400 }: SankeyChartProps) { + const [hoveredLink, setHoveredLink] = useState(null); + const [linkTooltip, setLinkTooltip] = useState({ + show: false, + x: 0, + y: 0, + sourceName: "", + targetName: "", + value: 0, + color: "", + }); + + const [nodeTooltip, setNodeTooltip] = useState({ + show: false, + x: 0, + y: 0, + name: "", + value: 0, + color: "", + }); + + const handleLinkHover = ( + index: number, + data: Omit, + ) => { + 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) => { + 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 ( - - } - link={} - nodePadding={50} - margin={{ top: 20, right: 160, bottom: 20, left: 160 }} - > - } /> - - +
+ + + } + link={ + + } + nodePadding={50} + margin={{ top: 20, right: 160, bottom: 20, left: 160 }} + sort={false} + > + } /> + + + {linkTooltip.show && ( +
+ +
+ )} + {nodeTooltip.show && ( +
+ +
+ )} +
); } diff --git a/ui/components/graphs/index.ts b/ui/components/graphs/index.ts index 274182af28..2f3671c293 100644 --- a/ui/components/graphs/index.ts +++ b/ui/components/graphs/index.ts @@ -1,4 +1,3 @@ -export { BarChart } from "./BarChart"; export { DonutChart } from "./DonutChart"; export { HorizontalBarChart } from "./HorizontalBarChart"; export { LineChart } from "./LineChart"; diff --git a/ui/components/graphs/shared/constants.ts b/ui/components/graphs/shared/constants.ts index a61847e2be..44e2d50414 100644 --- a/ui/components/graphs/shared/constants.ts +++ b/ui/components/graphs/shared/constants.ts @@ -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)", diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 49fdc3a02e..f87555e889 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -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;