feat: reusable graph components (#8873)

Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
This commit is contained in:
Alan Buscaglia
2025-10-13 13:53:28 +02:00
committed by GitHub
parent 741217ce80
commit 42e816081e
18 changed files with 1764 additions and 9 deletions

View File

@@ -0,0 +1,162 @@
"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>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
import { ChartLegend } from "./shared/ChartLegend";
import { DonutDataPoint } from "./types";
interface DonutChartProps {
data: DonutDataPoint[];
height?: number;
innerRadius?: number;
outerRadius?: number;
showLegend?: boolean;
centerLabel?: {
value: string | number;
label: string;
};
}
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: data.color }}
/>
<span className="text-sm font-semibold text-white">
{data.percentage}% {data.name}
</span>
</div>
{data.change !== undefined && (
<p className="mt-2 text-xs text-slate-400">
<span className="font-bold">
{data.change > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since last scan
</p>
)}
</div>
);
}
return null;
};
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
label: `${entry.value} (${entry.payload.percentage}%)`,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function DonutChart({
data,
innerRadius = 80,
outerRadius = 120,
showLegend = true,
centerLabel,
}: DonutChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartConfig = data.reduce(
(config, item) => ({
...config,
[item.name]: {
label: item.name,
color: item.color,
},
}),
{} as ChartConfig,
);
const chartData = data.map((item) => ({
name: item.name,
value: item.value,
fill: item.color,
color: item.color,
percentage: item.percentage,
change: item.change,
}));
const legendPayload = chartData.map((entry) => ({
value: entry.name,
color: entry.color,
payload: {
percentage: entry.percentage,
},
}));
return (
<div>
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[350px]"
>
<PieChart>
<Tooltip content={<CustomTooltip />} />
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={innerRadius}
outerRadius={outerRadius}
strokeWidth={0}
paddingAngle={2}
>
{chartData.map((entry, index) => {
const opacity =
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
return (
<Cell
key={`cell-${index}`}
fill={entry.fill}
opacity={opacity}
style={{ transition: "opacity 0.2s" }}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
})}
{centerLabel && (
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
const formattedValue =
typeof centerLabel.value === "number"
? centerLabel.value.toLocaleString()
: centerLabel.value;
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-white text-3xl font-bold"
>
{formattedValue}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-slate-400"
>
{centerLabel.label}
</tspan>
</text>
);
}
}}
/>
)}
</Pie>
</PieChart>
</ChartContainer>
{showLegend && <CustomLegend payload={legendPayload} />}
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { Bell } from "lucide-react";
import { useState } from "react";
import { CHART_COLORS, SEVERITY_ORDER } from "./shared/constants";
import { getSeverityColorByName } from "./shared/utils";
import { BarDataPoint } from "./types";
interface HorizontalBarChartProps {
data: BarDataPoint[];
height?: number;
title?: string;
}
export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const sortedData = [...data].sort((a, b) => {
const orderA = SEVERITY_ORDER[a.name as keyof typeof SEVERITY_ORDER] ?? 999;
const orderB = SEVERITY_ORDER[b.name as keyof typeof SEVERITY_ORDER] ?? 999;
return orderA - orderB;
});
return (
<div className="w-full">
{title && (
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
</div>
)}
<div className="space-y-6">
{sortedData.map((item, index) => {
const isHovered = hoveredIndex === index;
const isFaded = hoveredIndex !== null && !isHovered;
const barColor =
item.color ||
getSeverityColorByName(item.name) ||
CHART_COLORS.defaultColor;
return (
<div
key={index}
className="relative flex items-center gap-4"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div className="w-24 text-right">
<span
className="text-sm text-white"
style={{
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
{item.name}
</span>
</div>
<div className="relative flex-1">
<div className="absolute inset-0 h-8 w-full rounded-lg bg-slate-700/50" />
<div
className="relative h-8 rounded-lg transition-all duration-300"
style={{
width: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
backgroundColor: barColor,
opacity: isFaded ? 0.5 : 1,
}}
/>
{isHovered && (
<div className="absolute top-10 left-0 z-10 min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: barColor }}
/>
<span className="font-semibold text-white">
{item.value.toLocaleString()} {item.name} Risk
</span>
</div>
{item.newFindings !== undefined && (
<div className="mt-2 flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-sm text-slate-400">
{item.newFindings} New Findings
</span>
</div>
)}
{item.change !== undefined && (
<p className="mt-1 text-sm text-slate-400">
<span className="font-bold">
{item.change > 0 ? "+" : ""}
{item.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
)}
</div>
<div
className="flex w-40 items-center gap-2 text-sm text-white"
style={{
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
<span className="font-semibold">{item.percentage}%</span>
<span className="text-slate-400"></span>
<span>{item.value.toLocaleString()}</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { Bell } from "lucide-react";
import { useState } from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart as RechartsLine,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis,
} from "recharts";
import { AlertPill } from "./shared/AlertPill";
import { ChartLegend } from "./shared/ChartLegend";
import { CHART_COLORS } from "./shared/constants";
import { LineConfig, LineDataPoint } from "./types";
interface LineChartProps {
data: LineDataPoint[];
lines: LineConfig[];
xLabel?: string;
yLabel?: string;
height?: number;
}
interface TooltipPayloadItem {
dataKey: string;
value: number;
stroke: string;
name: string;
payload: LineDataPoint;
}
const CustomLineTooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const typedPayload = payload as unknown as TooltipPayloadItem[];
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="mb-3 text-xs text-slate-400">{label}</p>
<div className="mb-3">
<AlertPill value={totalValue} textSize="sm" />
</div>
<div className="space-y-3">
{typedPayload.map((item) => {
const newFindings = item.payload[`${item.dataKey}_newFindings`];
const change = item.payload[`${item.dataKey}_change`];
return (
<div key={item.dataKey} className="space-y-1">
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.stroke }}
/>
<span className="text-sm text-white">{item.value}</span>
</div>
{newFindings !== undefined && (
<div className="flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">
{newFindings} New Findings
</span>
</div>
)}
{change !== undefined && typeof change === "number" && (
<p className="text-xs text-slate-400">
<span className="font-bold">
{change > 0 ? "+" : ""}
{change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
);
})}
</div>
</div>
);
};
const CustomLegend = ({ payload }: any) => {
const severityOrder = [
"Informational",
"Low",
"Medium",
"High",
"Critical",
"Muted",
];
const sortedPayload = [...payload].sort((a, b) => {
const indexA = severityOrder.indexOf(a.value);
const indexB = severityOrder.indexOf(b.value);
return indexA - indexB;
});
const items = sortedPayload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function LineChart({
data,
lines,
xLabel,
yLabel,
height = 400,
}: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsLine
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
dataKey="date"
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<YAxis
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<Tooltip content={<CustomLineTooltip />} />
<Legend content={<CustomLegend />} />
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
return (
<Line
key={line.dataKey}
type="monotone"
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
name={line.label}
dot={{ fill: line.color, r: 4, opacity: isFaded ? 0.5 : 1 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
})}
</RechartsLine>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import {
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart as RechartsRadar,
} from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart/Chart";
import { AlertPill } from "./shared/AlertPill";
import { CHART_COLORS } from "./shared/constants";
import { RadarDataPoint } from "./types";
interface RadarChartProps {
data: RadarDataPoint[];
height?: number;
dataKey?: string;
onSelectPoint?: (point: RadarDataPoint | null) => void;
selectedPoint?: RadarDataPoint | null;
}
const chartConfig = {
value: {
label: "Findings",
color: "var(--color-magenta)",
},
} satisfies ChartConfig;
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0];
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="text-sm font-semibold text-white">
{data.payload.category}
</p>
<div className="mt-1">
<AlertPill value={data.value} />
</div>
{data.payload.change !== undefined && (
<p className="mt-1 text-xs text-slate-400">
<span className="font-bold">
{data.payload.change > 0 ? "+" : ""}
{data.payload.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
);
}
return null;
};
const CustomDot = (props: any) => {
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
const currentCategory = payload.category || payload.name;
const isSelected = selectedPoint?.category === currentCategory;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onSelectPoint) {
if (isSelected) {
onSelectPoint(null);
} else {
const point = {
category: currentCategory,
value: payload.value,
change: payload.change,
};
onSelectPoint(point);
}
}
};
return (
<circle
cx={cx}
cy={cy}
r={isSelected ? 9 : 6}
fill={isSelected ? "var(--color-success)" : "var(--color-purple-dark)"}
fillOpacity={1}
style={{
cursor: onSelectPoint ? "pointer" : "default",
pointerEvents: "all",
}}
onClick={onSelectPoint ? handleClick : undefined}
/>
);
};
export function RadarChart({
data,
height = 400,
dataKey = "value",
onSelectPoint,
selectedPoint,
}: RadarChartProps) {
return (
<ChartContainer
config={chartConfig}
className="mx-auto w-full"
style={{ height }}
>
<RechartsRadar data={data}>
<ChartTooltip cursor={false} content={<CustomTooltip />} />
<PolarAngleAxis
dataKey="category"
tick={{ fill: CHART_COLORS.textPrimary }}
/>
<PolarGrid strokeOpacity={0.3} />
<Radar
dataKey={dataKey}
fill="var(--color-magenta)"
fillOpacity={0.2}
activeDot={false}
dot={
onSelectPoint
? (dotProps: any) => {
const { key, ...rest } = dotProps;
return (
<CustomDot
key={key}
{...rest}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
/>
);
}
: {
r: 6,
fill: "var(--color-purple-dark)",
fillOpacity: 1,
}
}
/>
</RechartsRadar>
</ChartContainer>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import {
PolarAngleAxis,
RadialBar,
RadialBarChart,
ResponsiveContainer,
} from "recharts";
import { CHART_COLORS } from "./shared/constants";
interface RadialChartProps {
percentage: number;
label?: string;
color?: string;
backgroundColor?: string;
height?: number;
innerRadius?: number;
outerRadius?: number;
startAngle?: number;
endAngle?: number;
}
export function RadialChart({
percentage,
label = "Score",
color = "var(--color-success)",
backgroundColor = CHART_COLORS.tooltipBackground,
height = 250,
innerRadius = 60,
outerRadius = 100,
startAngle = 90,
endAngle = -270,
}: RadialChartProps) {
const data = [
{
name: label,
value: percentage,
fill: color,
},
];
return (
<ResponsiveContainer width="100%" height={height}>
<RadialBarChart
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
barSize={20}
data={data}
startAngle={startAngle}
endAngle={endAngle}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background={{ fill: backgroundColor }}
dataKey="value"
cornerRadius={10}
fill={color}
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
className="fill-white text-4xl font-bold"
>
{percentage}%
</text>
</RadialBarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants";
interface SankeyNode {
name: string;
}
interface SankeyLink {
source: number;
target: number;
value: number;
}
interface SankeyChartProps {
data: {
nodes: SankeyNode[];
links: SankeyLink[];
};
height?: number;
}
const COLORS: Record<string, string> = {
Success: "var(--color-success)",
Fail: "var(--color-destructive)",
AWS: "var(--color-orange)",
Azure: "var(--color-cyan)",
Google: "var(--color-red)",
...SEVERITY_COLORS,
};
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="text-sm font-semibold text-white">{data.name}</p>
{data.value && (
<p className="text-xs text-slate-400">Value: {data.value}</p>
)}
</div>
);
}
return null;
};
const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => {
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
return (
<g>
<Rectangle
x={x}
y={y}
width={width}
height={height}
fill={color}
fillOpacity="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>
</g>
);
};
const CustomLink = (props: any) => {
const {
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
} = props;
const sourceName = props.payload.source?.name || "";
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
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
`}
fill={color}
fillOpacity="0.4"
stroke="none"
/>
</g>
);
};
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
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>
);
}

View File

@@ -0,0 +1,181 @@
"use client";
import {
CartesianGrid,
Legend,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { AlertPill } from "./shared/AlertPill";
import { ChartLegend } from "./shared/ChartLegend";
import { CHART_COLORS } from "./shared/constants";
import { getSeverityColorByRiskScore } from "./shared/utils";
interface ScatterDataPoint {
x: number;
y: number;
provider: string;
name: string;
size?: number;
}
interface ScatterPlotProps {
data: ScatterDataPoint[];
xLabel?: string;
yLabel?: string;
height?: number;
onSelectPoint?: (point: ScatterDataPoint | null) => void;
selectedPoint?: ScatterDataPoint | null;
}
const PROVIDER_COLORS = {
AWS: "var(--color-orange)",
Azure: "var(--color-cyan)",
Google: "var(--color-red)",
};
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="text-sm font-semibold text-white">{data.name}</p>
<p className="mt-1 text-xs text-slate-400">
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
</div>
);
}
return null;
};
const CustomScatterDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
}: any) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const fill = isSelected
? "var(--color-success)"
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
CHART_COLORS.defaultColor;
return (
<circle
cx={cx}
cy={cy}
r={size / 2}
fill={fill}
stroke={isSelected ? "var(--color-success)" : "transparent"}
strokeWidth={2}
style={{ cursor: "pointer" }}
onClick={() => onSelectPoint?.(payload)}
/>
);
};
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function ScatterPlot({
data,
xLabel = "Risk Score",
yLabel = "Failed Findings",
height = 400,
onSelectPoint,
selectedPoint,
}: ScatterPlotProps) {
const handlePointClick = (point: ScatterDataPoint) => {
if (onSelectPoint) {
if (selectedPoint?.name === point.name) {
onSelectPoint(null);
} else {
onSelectPoint(point);
}
}
};
const dataByProvider = data.reduce(
(acc, point) => {
const provider = point.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(point);
return acc;
},
{} as Record<string, ScatterDataPoint[]>,
);
return (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
type="number"
dataKey="x"
name={xLabel}
label={{
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}}
tick={{ fill: CHART_COLORS.textSecondary }}
domain={[0, 10]}
/>
<YAxis
type="number"
dataKey="y"
name={yLabel}
label={{
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}}
tick={{ fill: CHART_COLORS.textSecondary }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend content={<CustomLegend />} />
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
CHART_COLORS.defaultColor
}
shape={(props: any) => (
<CustomScatterDot
{...props}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,33 @@
import { useState } from "react";
import { DEFAULT_SORT_OPTION, SORT_OPTIONS } from "../shared/constants";
type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
interface SortableItem {
name: string;
value: number;
}
export function useSortableData<T extends SortableItem>(data: T[]) {
const [sortBy, setSortBy] = useState<SortOption>(DEFAULT_SORT_OPTION);
const sortedData = [...data].sort((a, b) => {
switch (sortBy) {
case SORT_OPTIONS.highLow:
return b.value - a.value;
case SORT_OPTIONS.lowHigh:
return a.value - b.value;
case SORT_OPTIONS.alphabetical:
return a.name.localeCompare(b.name);
default:
return 0;
}
});
return {
sortBy,
setSortBy,
sortedData,
};
}

View File

@@ -0,0 +1,9 @@
export { BarChart } from "./BarChart";
export { DonutChart } from "./DonutChart";
export { HorizontalBarChart } from "./HorizontalBarChart";
export { LineChart } from "./LineChart";
export { RadarChart } from "./RadarChart";
export { RadialChart } from "./RadialChart";
export { SankeyChart } from "./SankeyChart";
export { ScatterPlot } from "./ScatterPlot";
export { ChartLegend, type ChartLegendItem } from "./shared/ChartLegend";

View File

@@ -0,0 +1,34 @@
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface AlertPillProps {
value: number;
label?: string;
iconSize?: number;
textSize?: "xs" | "sm" | "base";
}
export function AlertPill({
value,
label = "Fail Findings",
iconSize = 12,
textSize = "xs",
}: AlertPillProps) {
return (
<div className="flex items-center gap-2">
<div className="bg-alert-pill-bg flex items-center gap-1 rounded-full px-2 py-1">
<AlertTriangle size={iconSize} className="text-alert-pill-text" />
<span
className={cn(
`text-${textSize}`,
"text-alert-pill-text font-semibold",
)}
>
{value}
</span>
</div>
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export interface ChartLegendItem {
label: string;
color: string;
}
interface ChartLegendProps {
items: ChartLegendItem[];
}
export function ChartLegend({ items }: ChartLegendProps) {
return (
<div className="bg-card-border mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]">
{items.map((item, index) => (
<div key={`legend-${index}`} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-gray-300">{item.label}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { Bell, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { TooltipData } from "../types";
interface ChartTooltipProps {
active?: boolean;
payload?: any[];
label?: string;
showColorIndicator?: boolean;
colorIndicatorShape?: "circle" | "square";
}
export function ChartTooltip({
active,
payload,
label,
showColorIndicator = true,
colorIndicatorShape = "square",
}: ChartTooltipProps) {
if (!active || !payload || payload.length === 0) {
return null;
}
const data: TooltipData = payload[0].payload || payload[0];
const color = payload[0].color || data.color;
return (
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<div className="flex items-center gap-2">
{showColorIndicator && color && (
<div
className={cn(
"h-3 w-3",
colorIndicatorShape === "circle" ? "rounded-full" : "rounded-sm",
)}
style={{ backgroundColor: color }}
/>
)}
<p className="text-sm font-semibold text-white">{label || data.name}</p>
</div>
<p className="mt-1 text-xs text-white">
{typeof data.value === "number"
? data.value.toLocaleString()
: data.value}
{data.percentage !== undefined && ` (${data.percentage}%)`}
</p>
{data.newFindings !== undefined && data.newFindings > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">
{data.newFindings} New Findings
</span>
</div>
)}
{data.new !== undefined && data.new > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">{data.new} New</span>
</div>
)}
{data.muted !== undefined && data.muted > 0 && (
<div className="mt-1 flex items-center gap-2">
<VolumeX size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">{data.muted} Muted</span>
</div>
)}
{data.change !== undefined && (
<p className="mt-1 text-xs text-slate-400">
<span className="font-bold">
{data.change > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
);
}
/**
* Tooltip for charts with multiple data series (like LineChart)
*/
export function MultiSeriesChartTooltip({
active,
payload,
label,
}: ChartTooltipProps) {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="mb-2 text-sm font-semibold text-white">{label}</p>
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-white">{entry.name}:</span>
<span className="text-xs font-semibold text-white">
{entry.value}
</span>
{entry.payload[`${entry.dataKey}_change`] && (
<span className="text-xs text-slate-400">
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
{entry.payload[`${entry.dataKey}_change`]}%)
</span>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,46 @@
export const SEVERITY_COLORS = {
Informational: "var(--color-info)",
Low: "var(--color-warning)",
Medium: "var(--color-warning-emphasis)",
High: "var(--color-danger)",
Critical: "var(--color-danger-emphasis)",
} as const;
export const CHART_COLORS = {
tooltipBorder: "var(--color-slate-700)",
tooltipBackground: "var(--color-slate-800)",
textPrimary: "var(--color-white)",
textSecondary: "var(--color-slate-400)",
gridLine: "var(--color-slate-700)",
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
alertPillBg: "var(--color-alert-pill-bg)",
alertPillText: "var(--color-alert-pill-text)",
defaultColor: "var(--color-slate-500)", // Default fallback color for charts
} as const;
export const CHART_DIMENSIONS = {
defaultHeight: 400,
tooltipMinWidth: "200px",
borderRadius: "8px",
} as const;
export const SORT_OPTIONS = {
highLow: "high-low",
lowHigh: "low-high",
alphabetical: "alphabetical",
} as const;
export const DEFAULT_SORT_OPTION = SORT_OPTIONS.highLow;
export const SEVERITY_ORDER = {
Critical: 0,
High: 1,
Medium: 2,
Low: 3,
Informational: 4,
} as const;
export const LAYOUT_OPTIONS = {
horizontal: "horizontal",
vertical: "vertical",
} as const;

View File

@@ -0,0 +1,13 @@
import { SEVERITY_COLORS } from "./constants";
export function getSeverityColorByRiskScore(riskScore: number): string {
if (riskScore >= 7) return SEVERITY_COLORS.Critical;
if (riskScore >= 5) return SEVERITY_COLORS.High;
if (riskScore >= 3) return SEVERITY_COLORS.Medium;
if (riskScore >= 1) return SEVERITY_COLORS.Low;
return SEVERITY_COLORS.Informational;
}
export function getSeverityColorByName(name: string): string | undefined {
return SEVERITY_COLORS[name as keyof typeof SEVERITY_COLORS];
}

View File

@@ -0,0 +1,55 @@
import { LAYOUT_OPTIONS, SORT_OPTIONS } from "./shared/constants";
export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
export type LayoutOption = (typeof LAYOUT_OPTIONS)[keyof typeof LAYOUT_OPTIONS];
export interface BaseDataPoint {
name: string;
value: number;
percentage?: number;
color?: string;
change?: number;
newFindings?: number;
}
export interface BarDataPoint extends BaseDataPoint {}
export interface DonutDataPoint {
name: string;
value: number;
color: string;
percentage?: number;
new?: number;
muted?: number;
change?: number;
}
export interface LineDataPoint {
date: string;
[key: string]: string | number;
}
export interface RadarDataPoint {
category: string;
value: number;
change?: number;
}
export interface LineConfig {
dataKey: string;
color: string;
label: string;
}
export interface TooltipData {
name: string;
value: number | string;
color?: string;
percentage?: number;
newFindings?: number;
new?: number;
muted?: number;
change?: number;
[key: string]: any;
}

View File

@@ -1,12 +1,10 @@
import {
Fira_Code as FontMono,
Plus_Jakarta_Sans as FontSans,
} from "next/font/google";
import { Fira_Code as FontMono, Work_Sans as FontSans } from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
preload: false,
display: "swap",
});
export const fontMono = FontMono({

View File

@@ -1,5 +1,240 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@import "tailwindcss";
@config "../tailwind.config.js";
@theme {
/* Font Families are injected by Next.js from config/fonts.ts */
/* --font-sans: Work Sans (from Next.js) */
/* --font-mono: Fira Code (from Next.js) */
/* Text Sizes with Line Heights - Exact from Figma Design System */
--text-xs: 0.625rem; /* 10px */
--text-xs--line-height: 1.6;
--text-sm: 0.75rem; /* 12px */
--text-sm--line-height: 1.667;
--text-base: 0.875rem; /* 14px */
--text-base--line-height: 1.714;
--text-lg: 1rem; /* 16px */
--text-lg--line-height: 1.75;
--text-xl: 1.125rem; /* 18px */
--text-xl--line-height: 1.556;
--text-2xl: 1.25rem; /* 20px */
--text-2xl--line-height: 1.6;
--text-3xl: 1.5rem; /* 24px */
--text-3xl--line-height: 1.5;
--text-4xl: 1.875rem; /* 30px */
--text-4xl--line-height: 1.333;
--text-5xl: 2.25rem; /* 36px */
--text-5xl--line-height: 1.333;
--text-6xl: 3rem; /* 48px */
--text-6xl--line-height: 1.25;
--text-7xl: 3.75rem; /* 60px */
--text-7xl--line-height: 1.2;
--text-8xl: 4.5rem; /* 72px */
--text-8xl--line-height: 1.333;
--text-9xl: 6rem; /* 96px */
--text-9xl--line-height: 1.333;
/* Font Weights - Complete Inter Scale */
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
/* Info & Primary Colors */
--color-info: #2e51b2;
--color-primary: #2e51b2;
/* Warning Colors */
--color-warning: #fdd34f;
--color-warning-emphasis: #ff7d19;
/* Danger & Destructive Colors */
--color-danger: #ff3077;
--color-danger-emphasis: #971348;
--color-destructive: #db2b49;
/* Success Colors */
--color-success: #86da26;
--color-success-emphasis: #20b853;
/* Provider-specific Colors */
--color-orange: #ff9800;
--color-cyan: #06b6d4;
--color-red: #ef4444;
/* Accent Colors */
--color-magenta: #b51c80;
--color-purple-dark: #5f1551;
/* Chart Card Colors - From Figma */
--color-card-bg: #080f23;
--color-card-border: #171d30;
--color-text-accent: #fface2;
--color-page-bg: #1a2034;
/* Alert Pill Colors */
--color-alert-pill-bg: #432232;
--color-alert-pill-text: #f54280;
/* ===========================
Border Radius
=========================== */
/* Widget Border Radius - From Figma */
--radius-widget: 12px;
/* White & Black */
--color-white: #ffffff;
--color-black: #000000;
/* Slate Scale */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
/* Gray Scale */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* Zinc Scale */
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a1a1aa;
--color-zinc-500: #71717a;
--color-zinc-600: #52525b;
--color-zinc-700: #3f3f46;
--color-zinc-800: #27272a;
--color-zinc-900: #18181b;
--color-zinc-950: #09090b;
/* Stone Scale */
--color-stone-50: #fafaf9;
--color-stone-100: #f5f5f4;
--color-stone-200: #e7e5e4;
--color-stone-300: #d6d3d1;
--color-stone-400: #a8a29e;
--color-stone-500: #78716c;
--color-stone-600: #57534e;
--color-stone-700: #44403c;
--color-stone-800: #292524;
--color-stone-900: #1c1917;
--color-stone-950: #0c0a09;
/* Red Scale */
--color-red-50: #fef2f2;
--color-red-100: #fee2e2;
--color-red-200: #fecaca;
--color-red-300: #fca5a5;
--color-red-400: #f87171;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
--color-red-700: #b91c1c;
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
--color-red-950: #450a0a;
/* Rose Scale */
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
--color-rose-950: #4c0519;
/* Pink Scale */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce7f3;
--color-pink-200: #fbcfe8;
--color-pink-300: #f9a8d4;
--color-pink-400: #f472b6;
--color-pink-500: #ec4899;
--color-pink-600: #db2777;
--color-pink-700: #be185d;
--color-pink-800: #9d174d;
--color-pink-900: #831843;
--color-pink-950: #500724;
/* Fuchsia Scale */
--color-fuchsia-50: #fdf4ff;
--color-fuchsia-100: #fae8ff;
--color-fuchsia-200: #f5d0fe;
--color-fuchsia-300: #f0abfc;
--color-fuchsia-400: #e879f9;
--color-fuchsia-500: #d946ef;
--color-fuchsia-600: #c026d3;
--color-fuchsia-700: #a21caf;
--color-fuchsia-800: #86198f;
--color-fuchsia-900: #701a75;
--color-fuchsia-950: #4a044e;
/* Purple Scale */
--color-purple-50: #faf5ff;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d5ff;
--color-purple-300: #d8b4fe;
--color-purple-400: #c084fc;
--color-purple-500: #a855f7;
--color-purple-600: #9333ea;
--color-purple-700: #7e22ce;
--color-purple-800: #6b21a8;
--color-purple-900: #581c87;
--color-purple-950: #3b0764;
/* Violet Scale */
--color-violet-50: #f5f3ff;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
--color-violet-300: #c4b5fd;
--color-violet-400: #a78bfa;
--color-violet-500: #8b5cf6;
--color-violet-600: #7c3aed;
--color-violet-700: #6d28d9;
--color-violet-800: #5b21b6;
--color-violet-900: #4c1d95;
--color-violet-950: #2e1065;
/* Indigo Scale */
--color-indigo-50: #eef2ff;
--color-indigo-100: #e0e7ff;
--color-indigo-200: #c7d2fe;
--color-indigo-300: #a5b4fc;
--color-indigo-400: #818cf8;
--color-indigo-500: #6366f1;
--color-indigo-600: #4f46e5;
--color-indigo-700: #4338ca;
--color-indigo-800: #3730a3;
--color-indigo-900: #312e81;
--color-indigo-950: #1e1b4b;
}
@layer base {
:root {
@@ -44,8 +279,8 @@
}
.checkbox-update {
margin-right: 0.5rem;
background-color: var(--background);
margin-right: 0.5rem;
background-color: var(--background);
}
}
@@ -64,4 +299,4 @@
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
}