feat(ui): add gradient to Risk Plot and refactor ScatterPlot as reusable component (#9664)

This commit is contained in:
Alan Buscaglia
2025-12-29 16:35:41 +01:00
committed by GitHub
parent 0c3c6aea0e
commit 49585ac6c7
7 changed files with 443 additions and 486 deletions

View File

@@ -43,11 +43,6 @@ export const DEFAULT_FILTER_BADGES: FilterBadgeConfig[] = [
label: "Check ID",
formatMultiple: (count) => `${count} Check IDs filtered`,
},
{
filterKey: "scan__in",
label: "Scan",
formatValue: (id) => `${id.slice(0, 8)}...`,
},
];
interface ActiveFilterBadgeProps {

View File

@@ -24,11 +24,19 @@ interface RadialChartProps {
outerRadius?: number;
startAngle?: number;
endAngle?: number;
hasDots?: boolean;
tooltipData?: TooltipItem[];
}
const CustomTooltip = ({ active, payload }: any) => {
interface RadialChartTooltipProps {
active?: boolean;
payload?: Array<{
payload?: {
tooltipData?: TooltipItem[];
};
}>;
}
const CustomTooltip = ({ active, payload }: RadialChartTooltipProps) => {
if (!active || !payload || !payload.length) return null;
const tooltipItems = payload[0]?.payload?.tooltipData;
@@ -72,7 +80,6 @@ export function RadialChart({
outerRadius = 100,
startAngle = 90,
endAngle = -270,
hasDots = false,
tooltipData,
}: RadialChartProps) {
// Calculate the real barSize based on the difference
@@ -83,15 +90,6 @@ export function RadialChart({
tooltipData,
},
];
const middleRadius = innerRadius;
const viewBoxWidth = height;
const viewBoxHeight = height;
const centerX = viewBoxWidth / 2;
const centerY = viewBoxHeight / 2;
const arcAngle = Math.abs(startAngle - endAngle);
const dotSpacing = 20; // 4px dot + 8px space
const arcCircumference = (arcAngle / 360) * (2 * Math.PI * middleRadius);
const numberOfDots = Math.floor(arcCircumference / dotSpacing);
return (
<ResponsiveContainer width="100%" height={height}>
@@ -128,34 +126,6 @@ export function RadialChart({
isAnimationActive={false}
/>
{hasDots &&
Array.from({ length: numberOfDots })
.slice(1, -1)
.map((_, i) => {
// Calculate the angle for this point
// Adjust the index since we now start from 1
const angleProgress = (i + 1) / (numberOfDots - 1 || 1);
const currentAngle =
startAngle - angleProgress * (startAngle - endAngle);
// Show dots only in the background part (after the percentage value)
const valueAngleEnd =
startAngle - (percentage / 100) * (startAngle - endAngle);
if (currentAngle > valueAngleEnd) {
return null; // Don't show dots in the part with value
}
const currentAngleRad = (currentAngle * Math.PI) / 180;
// Calculate absolute position in the viewBox
const x = centerX + middleRadius * Math.cos(currentAngleRad) + 22;
const y = centerY - middleRadius * Math.sin(currentAngleRad);
return (
<circle key={i} cx={x} cy={y} r={2} fill="var(--chart-dots)" />
);
})}
<text
x="50%"
y="38%"

View File

@@ -1,8 +1,20 @@
"use client";
/**
* ScatterPlot Component
*
* A reusable scatter chart component with provider-based coloring,
* point selection, legend filtering, and optional gradient background.
*
* NOTE: This component uses CSS variables (var()) for Recharts styling.
* Recharts SVG-based components do not support Tailwind classes and require
* raw color values or CSS variables. This is a documented limitation.
* @see https://recharts.org/en-US/api
*/
import {
CartesianGrid,
Legend,
ReferenceArea,
ResponsiveContainer,
Scatter,
ScatterChart,
@@ -13,128 +25,270 @@ import {
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { getSeverityColorByRiskScore } from "./shared/utils";
import { AXIS_FONT_SIZE, CustomXAxisTick } from "./shared/custom-axis-tick";
import type { ScatterDataPoint } from "./types";
interface ScatterPlotProps {
data: ScatterDataPoint[];
xLabel?: string;
yLabel?: string;
height?: number;
onSelectPoint?: (point: ScatterDataPoint | null) => void;
selectedPoint?: ScatterDataPoint | null;
}
const PROVIDER_COLORS = {
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
// Provider colors from globals.css
const PROVIDER_COLORS: Record<string, string> = {
AWS: "var(--bg-data-aws)",
Azure: "var(--bg-data-azure)",
"Google Cloud": "var(--bg-data-gcp)",
Kubernetes: "var(--bg-data-kubernetes)",
"Microsoft 365": "var(--bg-data-m365)",
GitHub: "var(--bg-data-github)",
"MongoDB Atlas": "var(--bg-data-azure)",
"Infrastructure as Code": "var(--bg-data-kubernetes)",
"Oracle Cloud Infrastructure": "var(--bg-data-gcp)",
Google: "var(--bg-data-gcp)",
Default: "var(--color-text-neutral-tertiary)",
};
interface ScatterTooltipProps {
active?: boolean;
payload?: Array<{ payload: ScatterDataPoint }>;
const SELECTED_COLOR = "var(--bg-button-primary)";
// Score color thresholds (0-100 scale, higher = better)
const SCORE_COLORS = {
DANGER: "var(--bg-fail-primary)", // 0-30
WARNING: "var(--bg-warning-primary)", // 31-60
SUCCESS: "var(--bg-pass-primary)", // 61-100
} as const;
function getScoreColor(score: number): string {
if (score > 60) return SCORE_COLORS.SUCCESS;
if (score > 30) return SCORE_COLORS.WARNING;
return SCORE_COLORS.DANGER;
}
const CustomTooltip = ({ active, payload }: ScatterTooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
interface GradientConfig {
/** Gradient ID (must be unique if multiple charts on page) */
id?: string;
/** Hex color for gradient (CSS variables don't work in SVG defs) */
color?: string;
/** Whether gradient goes from bottom (true) or top (false) */
fromBottom?: boolean;
}
return (
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: "var(--color-border-neutral-tertiary)",
backgroundColor: "var(--color-bg-neutral-secondary)",
}}
>
<p
className="text-sm font-semibold"
style={{ color: "var(--color-text-neutral-primary)" }}
>
{data.name}
</p>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-text-neutral-secondary)" }}
>
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
</div>
);
interface AxisConfig {
/** Axis label */
label: string;
/** Data key to use ('x' or 'y') */
dataKey: "x" | "y";
/** Fixed domain [min, max] - if not provided, auto-scales */
domain?: [number, number];
}
export interface ScatterPlotProps<
T extends ScatterDataPoint = ScatterDataPoint,
> {
/** Data points to render */
data: T[];
/** X-axis configuration */
xAxis?: AxisConfig;
/** Y-axis configuration */
yAxis?: AxisConfig;
/** Chart height */
height?: number | string;
/** Currently selected point */
selectedPoint?: T | null;
/** Callback when a point is selected/deselected */
onSelectPoint?: (point: T | null) => void;
/** Currently selected provider for filtering */
selectedProvider?: string | null;
/** Callback when a provider is selected/deselected in legend */
onProviderClick?: (provider: string | null) => void;
/** Whether to show the legend */
showLegend?: boolean;
/** Legend helper text */
legendHelperText?: string;
/** Gradient background configuration (null to disable) */
gradient?: GradientConfig | null;
/** Custom tooltip render function */
renderTooltip?: (point: T) => React.ReactNode;
}
interface TooltipProps<T extends ScatterDataPoint> {
active?: boolean;
payload?: Array<{ payload: T }>;
renderTooltip?: (point: T) => React.ReactNode;
}
function DefaultTooltip<T extends ScatterDataPoint>({
active,
payload,
renderTooltip,
}: TooltipProps<T>) {
if (!active || !payload?.length) return null;
const point = payload[0].payload;
if (renderTooltip) {
return <>{renderTooltip(point)}</>;
}
return null;
};
interface ScatterDotProps {
const scoreColor = getScoreColor(point.x);
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{point.name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
<span style={{ color: scoreColor, fontWeight: "bold" }}>
{point.x}%
</span>{" "}
Score
</p>
<div className="mt-2">
<AlertPill value={point.y} />
</div>
</div>
);
}
// Props that Recharts passes to the shape component
interface RechartsScatterDotProps<T extends ScatterDataPoint> {
cx: number;
cy: number;
payload: ScatterDataPoint;
selectedPoint?: ScatterDataPoint | null;
onSelectPoint?: (point: ScatterDataPoint) => void;
payload: T;
}
const CustomScatterDot = ({
// Extended props for our custom scatter dot component
interface ScatterDotProps<T extends ScatterDataPoint>
extends RechartsScatterDotProps<T> {
selectedPoint: T | null;
onSelectPoint: (point: T) => void;
allData: T[];
selectedProvider: string | null;
}
function CustomScatterDot<T extends ScatterDataPoint>({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
}: ScatterDotProps) => {
allData,
selectedProvider,
}: ScatterDotProps<T>) {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const fill = isSelected
? "#86DA26"
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
"var(--color-text-neutral-tertiary)";
? SELECTED_COLOR
: PROVIDER_COLORS[payload.provider] || PROVIDER_COLORS.Default;
const isFaded =
selectedProvider !== null && payload.provider !== selectedProvider;
const handleClick = () => {
const fullDataItem = allData?.find((d) => d.name === payload.name);
onSelectPoint?.(fullDataItem || payload);
};
return (
<circle
cx={cx}
cy={cy}
r={size / 2}
fill={fill}
stroke={isSelected ? "#86DA26" : "transparent"}
strokeWidth={2}
className={isSelected ? "drop-shadow-[0_0_8px_#86da26]" : ""}
style={{ cursor: "pointer" }}
onClick={() => onSelectPoint?.(payload)}
<g
style={{
cursor: "pointer",
opacity: isFaded ? 0.2 : 1,
transition: "opacity 0.2s",
}}
onClick={handleClick}
>
{isSelected && (
<>
<circle
cx={cx}
cy={cy}
r={size / 2 + 4}
fill="none"
stroke={SELECTED_COLOR}
strokeWidth={1}
opacity={0.4}
/>
<circle
cx={cx}
cy={cy}
r={size / 2 + 8}
fill="none"
stroke={SELECTED_COLOR}
strokeWidth={1}
opacity={0.2}
/>
</>
)}
<circle
cx={cx}
cy={cy}
r={size / 2}
fill={fill}
stroke={isSelected ? SELECTED_COLOR : "transparent"}
strokeWidth={2}
/>
</g>
);
}
/**
* Factory function that creates a scatter dot shape component with closure over selection state.
* Recharts shape prop types the callback parameter as `unknown` due to its flexible API.
* @see https://recharts.org/en-US/api/Scatter#shape
*/
function createScatterDotShape<T extends ScatterDataPoint>(
selectedPoint: T | null,
onSelectPoint: (point: T) => void,
allData: T[],
selectedProvider: string | null,
): (props: unknown) => React.JSX.Element {
const ScatterDotShape = (props: unknown) => (
<CustomScatterDot<T>
{...(props as RechartsScatterDotProps<T>)}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
selectedProvider={selectedProvider}
/>
);
};
interface LegendPayloadItem {
value: string;
color: string;
ScatterDotShape.displayName = "ScatterDotShape";
return ScatterDotShape;
}
interface LegendProps {
payload?: LegendPayloadItem[];
}
const CustomLegend = ({ payload }: LegendProps) => {
const items = (payload || []).map((entry) => ({
label: entry.value,
color: entry.color,
}));
return <ChartLegend items={items} />;
const DEFAULT_GRADIENT: GradientConfig = {
id: "scatterPlotGradient",
color: "#7D1A1A",
fromBottom: true,
};
export function ScatterPlot({
export function ScatterPlot<T extends ScatterDataPoint = ScatterDataPoint>({
data,
xLabel = "Risk Score",
yLabel = "Failed Findings",
height = 400,
xAxis = { label: "Fail Findings", dataKey: "y" },
yAxis = { label: "Score", dataKey: "x", domain: [0, 100] },
height = "100%",
selectedPoint = null,
onSelectPoint,
selectedPoint,
}: ScatterPlotProps) {
const handlePointClick = (point: ScatterDataPoint) => {
selectedProvider = null,
onProviderClick,
showLegend = true,
legendHelperText = "Click to filter by provider",
gradient = DEFAULT_GRADIENT,
renderTooltip,
}: ScatterPlotProps<T>) {
// Group data by provider for separate Scatter series
const dataByProvider = data.reduce<Record<string, T[]>>((acc, point) => {
(acc[point.provider] ??= []).push(point);
return acc;
}, {});
const providers = Object.keys(dataByProvider);
// Calculate domain for ReferenceArea
// For X axis: get max value from data (using the correct dataKey)
// For Y axis: use domain if provided, otherwise calculate from data
const xDataValues = data.length > 0 ? data.map((d) => d[xAxis.dataKey]) : [0];
const yDataValues = data.length > 0 ? data.map((d) => d[yAxis.dataKey]) : [0];
const minX = xAxis.domain?.[0] ?? 0;
const maxX = xAxis.domain?.[1] ?? Math.max(...xDataValues) * 1.031;
const minY = yAxis.domain?.[0] ?? 0;
const maxY = yAxis.domain?.[1] ?? Math.max(...yDataValues) * 1.031;
const handleSelectPoint = (point: T) => {
if (onSelectPoint) {
if (selectedPoint?.name === point.name) {
onSelectPoint(null);
@@ -144,75 +298,132 @@ export function ScatterPlot({
}
};
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[]>,
);
const handleProviderClick = (provider: string) => {
if (onProviderClick) {
onProviderClick(selectedProvider === provider ? null : provider);
}
};
const gradientId = gradient?.id ?? DEFAULT_GRADIENT.id;
return (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 20, right: 30, bottom: 60, left: 60 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--color-border-neutral-tertiary)"
/>
<XAxis
type="number"
dataKey="x"
name={xLabel}
label={{
value: xLabel,
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{ fill: "var(--color-text-neutral-secondary)" }}
domain={[0, 10]}
/>
<YAxis
type="number"
dataKey="y"
name={yLabel}
label={{
value: yLabel,
angle: -90,
position: "left",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{ fill: "var(--color-text-neutral-secondary)" }}
/>
<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] ||
PROVIDER_COLORS.Default
}
shape={(props: unknown) => {
const dotProps = props as ScatterDotProps;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
);
}}
<div className="flex h-full w-full flex-col">
<div className="relative min-h-[400px] w-full flex-1">
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 20, right: 30, bottom: 60, left: 60 }}>
{/* SVG gradient requires hex colors - CSS variables don't resolve properly in SVG defs */}
<defs>
{gradient && (
<linearGradient
id={gradientId}
x1="0"
y1={gradient.fromBottom ? "1" : "0"}
x2="0"
y2={gradient.fromBottom ? "0" : "1"}
>
<stop
offset="0%"
stopColor={gradient.color ?? DEFAULT_GRADIENT.color}
stopOpacity={0.6}
/>
<stop
offset="40%"
stopColor={gradient.color ?? DEFAULT_GRADIENT.color}
stopOpacity={0.3}
/>
<stop offset="100%" stopColor="transparent" stopOpacity={0} />
</linearGradient>
)}
</defs>
{gradient && (
<ReferenceArea
x1={minX}
x2={maxX}
y1={minY}
y2={maxY}
fill={`url(#${gradientId})`}
ifOverflow="extendDomain"
/>
)}
<CartesianGrid
horizontal={true}
vertical={true}
strokeOpacity={1}
stroke="var(--border-neutral-secondary)"
/>
<XAxis
type="number"
dataKey={xAxis.dataKey}
name={xAxis.label}
label={{
value: xAxis.label,
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={CustomXAxisTick}
tickLine={false}
axisLine={false}
domain={xAxis.domain}
/>
<YAxis
type="number"
dataKey={yAxis.dataKey}
name={yAxis.label}
label={{
value: yAxis.label,
angle: -90,
position: "left",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{
fill: "var(--color-text-neutral-secondary)",
fontSize: AXIS_FONT_SIZE,
}}
tickLine={false}
axisLine={false}
domain={yAxis.domain}
/>
<Tooltip
content={<DefaultTooltip<T> renderTooltip={renderTooltip} />}
/>
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={PROVIDER_COLORS[provider] || PROVIDER_COLORS.Default}
shape={createScatterDotShape<T>(
selectedPoint,
handleSelectPoint,
data,
selectedProvider,
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
{showLegend && (
<div className="mt-4 flex flex-col items-start gap-2">
{legendHelperText && (
<p className="text-text-neutral-tertiary pl-2 text-xs">
{legendHelperText}
</p>
)}
<ChartLegend
items={providers.map((p) => ({
label: p,
color: PROVIDER_COLORS[p] || PROVIDER_COLORS.Default,
dataKey: p,
}))}
selectedItem={selectedProvider}
onItemClick={handleProviderClick}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}

View File

@@ -39,10 +39,19 @@ export interface RadarDataPoint {
}
export interface ScatterDataPoint {
/** X-axis value (e.g., ThreatScore 0-100) */
x: number;
/** Y-axis value (e.g., Failed Findings count) */
y: number;
/** Provider type display name (AWS, Azure, Google Cloud, etc.) */
provider: string;
/** Display name (provider alias or identifier) */
name: string;
/** Optional provider ID for navigation/filtering */
providerId?: string;
/** Optional severity breakdown data for detail panel */
severityData?: BarDataPoint[];
/** Optional size for bubble chart variant */
size?: number;
}