mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui): add gradient to Risk Plot and refactor ScatterPlot as reusable component (#9664)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user