mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat: implement Finding Severity Over Time chart with time range selector (#9106)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,15 +23,15 @@ const chartConfig = {
|
||||
},
|
||||
pass: {
|
||||
label: "Pass",
|
||||
color: "hsl(var(--chart-success))",
|
||||
color: "var(--color-bg-pass)",
|
||||
},
|
||||
fail: {
|
||||
label: "Fail",
|
||||
color: "hsl(var(--chart-fail))",
|
||||
color: "var(--color-bg-fail)",
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: "hsl(var(--chart-warning))",
|
||||
color: "var(--color-bg-warning)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ export function DonutChart({
|
||||
{
|
||||
name: "No data",
|
||||
value: 1,
|
||||
fill: "var(--chart-border-emphasis)",
|
||||
color: "var(--chart-border-emphasis)",
|
||||
fill: "var(--border-neutral-tertiary)",
|
||||
color: "var(--border-neutral-tertiary)",
|
||||
percentage: 0,
|
||||
change: undefined,
|
||||
},
|
||||
|
||||
@@ -37,10 +37,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
<div className="w-full space-y-6">
|
||||
{title && (
|
||||
<div>
|
||||
<h3
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text-neutral-primary)" }}
|
||||
>
|
||||
<h3 className="text-text-neutral-primary text-lg font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -59,16 +56,15 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex gap-6"
|
||||
className="flex items-center gap-10"
|
||||
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
|
||||
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="w-20 shrink-0">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
className="text-text-neutral-secondary text-sm font-medium"
|
||||
style={{
|
||||
color: "var(--text-neutral-secondary)",
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
@@ -138,9 +134,8 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
|
||||
{/* Percentage and Count */}
|
||||
<div
|
||||
className="flex w-[90px] shrink-0 items-center gap-2 text-sm"
|
||||
className="text-text-neutral-secondary ml-6 flex w-[90px] shrink-0 items-center gap-2 text-sm"
|
||||
style={{
|
||||
color: "var(--text-neutral-secondary)",
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
@@ -148,12 +143,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
<span className="w-[26px] text-right font-medium">
|
||||
{isEmpty ? "0" : item.percentage}%
|
||||
</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: "var(--text-neutral-secondary)" }}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
<span className="font-medium">•</span>
|
||||
<span className="font-bold">
|
||||
{isEmpty ? "0" : item.value.toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -7,3 +7,4 @@ export { RadialChart } from "./radial-chart";
|
||||
export { SankeyChart } from "./sankey-chart";
|
||||
export { ScatterPlot } from "./scatter-plot";
|
||||
export { ChartLegend, type ChartLegendItem } from "./shared/chart-legend";
|
||||
export { ThreatMap } from "./threat-map";
|
||||
|
||||
@@ -4,26 +4,30 @@ import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart as RechartsLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart/Chart";
|
||||
|
||||
import { AlertPill } from "./shared/alert-pill";
|
||||
import { ChartLegend } from "./shared/chart-legend";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import {
|
||||
AXIS_FONT_SIZE,
|
||||
CustomXAxisTickWithToday,
|
||||
} from "./shared/custom-axis-tick";
|
||||
import { LineConfig, LineDataPoint } from "./types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: LineDataPoint[];
|
||||
lines: LineConfig[];
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
@@ -48,19 +52,8 @@ const CustomLineTooltip = ({
|
||||
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3 shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "var(--chart-background)",
|
||||
borderColor: "var(--chart-border-emphasis)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="mb-3 text-xs"
|
||||
style={{ color: "var(--chart-text-secondary)" }}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<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-secondary mb-3 text-xs">{label}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<AlertPill value={totalValue} textSize="sm" />
|
||||
@@ -78,31 +71,22 @@ const CustomLineTooltip = ({
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.stroke }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--chart-text-primary)" }}
|
||||
>
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
{newFindings !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--chart-text-secondary)" }}
|
||||
>
|
||||
<Bell size={14} className="text-text-neutral-secondary" />
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{change !== undefined && typeof change === "number" && (
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--chart-text-secondary)" }}
|
||||
>
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
<span className="font-bold">
|
||||
{change > 0 ? "+" : ""}
|
||||
{(change as number) > 0 ? "+" : ""}
|
||||
{change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
@@ -116,96 +100,84 @@ const CustomLineTooltip = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const severityOrder = [
|
||||
"Informational",
|
||||
"Low",
|
||||
"Medium",
|
||||
"High",
|
||||
"Critical",
|
||||
"Muted",
|
||||
];
|
||||
const chartConfig = {
|
||||
default: {
|
||||
color: "var(--color-bg-data-azure)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
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) {
|
||||
export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
|
||||
|
||||
const legendItems = lines.map((line) => ({
|
||||
label: line.label,
|
||||
color: line.color,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLine
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
|
||||
<div className="w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height, aspectRatio: "auto" }}
|
||||
>
|
||||
<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>
|
||||
<RechartsLine
|
||||
data={data}
|
||||
margin={{
|
||||
top: 10,
|
||||
left: 0,
|
||||
right: 8,
|
||||
bottom: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
strokeOpacity={1}
|
||||
stroke="var(--border-neutral-secondary)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={CustomXAxisTickWithToday}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={{
|
||||
fill: "var(--color-text-neutral-secondary)",
|
||||
fontSize: AXIS_FONT_SIZE,
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<CustomLineTooltip />} />
|
||||
{lines.map((line) => {
|
||||
const isHovered = hoveredLine === line.dataKey;
|
||||
const isFaded = hoveredLine !== null && !isHovered;
|
||||
return (
|
||||
<Line
|
||||
key={line.dataKey}
|
||||
type="natural"
|
||||
dataKey={line.dataKey}
|
||||
stroke={line.color}
|
||||
strokeWidth={2}
|
||||
strokeOpacity={isFaded ? 0.5 : 1}
|
||||
name={line.label}
|
||||
dot={{ fill: line.color, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
onMouseEnter={() => setHoveredLine(line.dataKey)}
|
||||
onMouseLeave={() => setHoveredLine(null)}
|
||||
style={{ transition: "stroke-opacity 0.2s" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RechartsLine>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-4">
|
||||
<ChartLegend items={legendItems} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ const MAP_CONFIG = {
|
||||
} as const;
|
||||
|
||||
const MAP_COLORS = {
|
||||
landFill: "var(--chart-border-emphasis)",
|
||||
landStroke: "var(--chart-border)",
|
||||
pointDefault: "#DB2B49",
|
||||
pointSelected: "#86DA26",
|
||||
pointHover: "#DB2B49",
|
||||
landFill: "var(--border-neutral-tertiary)",
|
||||
landStroke: "var(--border-neutral-secondary)",
|
||||
pointDefault: "var(--bg-fail)",
|
||||
pointSelected: "var(--bg-pass)",
|
||||
pointHover: "var(--bg-fail)",
|
||||
} as const;
|
||||
|
||||
const RISK_LEVELS = {
|
||||
@@ -119,10 +119,10 @@ function MapTooltip({
|
||||
position: { x: number; y: number };
|
||||
}) {
|
||||
const CHART_COLORS = {
|
||||
tooltipBorder: "var(--chart-border-emphasis)",
|
||||
tooltipBackground: "var(--chart-background)",
|
||||
textPrimary: "var(--chart-text-primary)",
|
||||
textSecondary: "var(--chart-text-secondary)",
|
||||
tooltipBorder: "var(--border-neutral-tertiary)",
|
||||
tooltipBackground: "var(--bg-neutral-secondary)",
|
||||
textPrimary: "var(--text-neutral-primary)",
|
||||
textSecondary: "var(--text-neutral-secondary)",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -169,14 +169,14 @@ function MapTooltip({
|
||||
|
||||
function EmptyState() {
|
||||
const CHART_COLORS = {
|
||||
tooltipBorder: "var(--chart-border-emphasis)",
|
||||
tooltipBackground: "var(--chart-background)",
|
||||
textSecondary: "var(--chart-text-secondary)",
|
||||
tooltipBorder: "var(--border-neutral-tertiary)",
|
||||
tooltipBackground: "var(--bg-neutral-secondary)",
|
||||
textSecondary: "var(--text-neutral-secondary)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-[400px] items-center justify-center rounded-lg border p-6"
|
||||
className="flex h-full min-h-[400px] w-full items-center justify-center rounded-lg border p-6"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
@@ -198,7 +198,7 @@ function EmptyState() {
|
||||
|
||||
function LoadingState({ height }: { height: number }) {
|
||||
const CHART_COLORS = {
|
||||
textSecondary: "var(--chart-text-secondary)",
|
||||
textSecondary: "var(--text-neutral-secondary)",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -382,10 +382,10 @@ export function MapChart({
|
||||
]);
|
||||
|
||||
const CHART_COLORS = {
|
||||
tooltipBorder: "var(--chart-border-emphasis)",
|
||||
tooltipBackground: "var(--chart-background)",
|
||||
textPrimary: "var(--chart-text-primary)",
|
||||
textSecondary: "var(--chart-text-secondary)",
|
||||
tooltipBorder: "var(--border-neutral-tertiary)",
|
||||
tooltipBackground: "var(--bg-neutral-secondary)",
|
||||
textPrimary: "var(--text-neutral-primary)",
|
||||
textSecondary: "var(--text-neutral-secondary)",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { type MouseEvent } from "react";
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
} from "@/components/ui/chart/Chart";
|
||||
|
||||
import { AlertPill } from "./shared/alert-pill";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { RadarDataPoint } from "./types";
|
||||
|
||||
interface RadarChartProps {
|
||||
@@ -32,36 +32,41 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
interface TooltipPayloadItem {
|
||||
payload: RadarDataPoint;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3 shadow-lg"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: CHART_COLORS.textPrimary }}
|
||||
>
|
||||
<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 text-sm font-semibold">
|
||||
{data.payload.category}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<AlertPill value={data.value} />
|
||||
<AlertPill value={data.payload.value} />
|
||||
</div>
|
||||
{data.payload.change !== undefined && (
|
||||
<p
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
>
|
||||
<span className="font-bold">
|
||||
{data.payload.change > 0 ? "+" : ""}
|
||||
{data.payload.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
data.payload.change > 0
|
||||
? "var(--bg-pass-primary)"
|
||||
: "var(--bg-data-critical)",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{(data.payload.change as number) > 0 ? "+" : ""}
|
||||
{data.payload.change}%{" "}
|
||||
</span>
|
||||
since last scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -70,21 +75,46 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomDot = (props: any) => {
|
||||
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
|
||||
const currentCategory = payload.category || payload.name;
|
||||
interface DotShapeProps {
|
||||
cx: number;
|
||||
cy: number;
|
||||
payload: RadarDataPoint & { name?: string };
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface CustomDotProps extends DotShapeProps {
|
||||
selectedPoint?: RadarDataPoint | null;
|
||||
onSelectPoint?: (point: RadarDataPoint | null) => void;
|
||||
data?: RadarDataPoint[];
|
||||
}
|
||||
|
||||
const CustomDot = ({
|
||||
cx,
|
||||
cy,
|
||||
payload,
|
||||
selectedPoint,
|
||||
onSelectPoint,
|
||||
data,
|
||||
}: CustomDotProps) => {
|
||||
const currentCategory = payload.name || payload.category;
|
||||
const isSelected = selectedPoint?.category === currentCategory;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onSelectPoint) {
|
||||
if (isSelected) {
|
||||
// Re-evaluate selection status at click time, not from closure
|
||||
const currentlySelected = selectedPoint?.category === currentCategory;
|
||||
if (currentlySelected) {
|
||||
onSelectPoint(null);
|
||||
} else {
|
||||
const point = {
|
||||
const fullDataItem = data?.find(
|
||||
(d: RadarDataPoint) => d.category === currentCategory,
|
||||
);
|
||||
const point: RadarDataPoint = {
|
||||
category: currentCategory,
|
||||
value: payload.value,
|
||||
change: payload.change,
|
||||
severityData: fullDataItem?.severityData || payload.severityData,
|
||||
};
|
||||
onSelectPoint(point);
|
||||
}
|
||||
@@ -96,12 +126,11 @@ const CustomDot = (props: any) => {
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={isSelected ? 9 : 6}
|
||||
fill={
|
||||
isSelected ? "var(--chart-success-color)" : "var(--chart-radar-primary)"
|
||||
}
|
||||
fillOpacity={1}
|
||||
className={isSelected ? "drop-shadow-[0_0_8px_#86da26]" : ""}
|
||||
style={{
|
||||
fill: isSelected
|
||||
? "var(--bg-button-primary)"
|
||||
: "var(--bg-radar-button)",
|
||||
cursor: onSelectPoint ? "pointer" : "default",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
@@ -127,30 +156,33 @@ export function RadarChart({
|
||||
<ChartTooltip cursor={false} content={<CustomTooltip />} />
|
||||
<PolarAngleAxis
|
||||
dataKey="category"
|
||||
tick={{ fill: CHART_COLORS.textPrimary }}
|
||||
tick={{ fill: "var(--color-text-neutral-primary)" }}
|
||||
/>
|
||||
<PolarGrid strokeOpacity={0.3} />
|
||||
<Radar
|
||||
dataKey={dataKey}
|
||||
fill="var(--chart-radar-primary)"
|
||||
fillOpacity={0.2}
|
||||
fill="var(--bg-radar-map)"
|
||||
fillOpacity={1}
|
||||
activeDot={false}
|
||||
dot={
|
||||
onSelectPoint
|
||||
? (dotProps: any) => {
|
||||
const { key, ...rest } = dotProps;
|
||||
? (dotProps: DotShapeProps) => {
|
||||
const { key, cx, cy, payload } = dotProps;
|
||||
return (
|
||||
<CustomDot
|
||||
key={key}
|
||||
{...rest}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
payload={payload}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={onSelectPoint}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: {
|
||||
r: 6,
|
||||
fill: "var(--chart-radar-primary)",
|
||||
fill: "var(--bg-radar-map)",
|
||||
fillOpacity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
|
||||
|
||||
import { ChartTooltip } from "./shared/chart-tooltip";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
|
||||
interface SankeyNode {
|
||||
name: string;
|
||||
@@ -47,54 +46,148 @@ interface NodeTooltipState {
|
||||
change?: number;
|
||||
}
|
||||
|
||||
// Note: Using hex colors directly because Recharts SVG fill doesn't resolve CSS variables
|
||||
const COLORS: Record<string, string> = {
|
||||
Success: "#86da26",
|
||||
Fail: "#db2b49",
|
||||
AWS: "#ff9900",
|
||||
Azure: "#00bcd4",
|
||||
Google: "#EA4335",
|
||||
Critical: "#971348",
|
||||
High: "#ff3077",
|
||||
Medium: "#ff7d19",
|
||||
Low: "#fdd34f",
|
||||
Info: "#2e51b2",
|
||||
Informational: "#2e51b2",
|
||||
const TOOLTIP_OFFSET_PX = 10;
|
||||
|
||||
// Map color names to CSS variable names defined in globals.css
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
Success: "--color-bg-pass",
|
||||
Fail: "--color-bg-fail",
|
||||
AWS: "--color-bg-data-aws",
|
||||
Azure: "--color-bg-data-azure",
|
||||
"Google Cloud": "--color-bg-data-gcp",
|
||||
Critical: "--color-bg-data-critical",
|
||||
High: "--color-bg-data-high",
|
||||
Medium: "--color-bg-data-medium",
|
||||
Low: "--color-bg-data-low",
|
||||
Info: "--color-bg-data-info",
|
||||
Informational: "--color-bg-data-info",
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
/**
|
||||
* Compute color value from CSS variable name at runtime.
|
||||
* SVG fill attributes cannot directly resolve CSS variables,
|
||||
* so we extract computed values from globals.css CSS variables.
|
||||
* Falls back to black (#000000) if variable not found or access fails.
|
||||
*
|
||||
* @param colorName - Key in COLOR_MAP (e.g., "AWS", "Fail")
|
||||
* @returns Computed CSS variable value or fallback color
|
||||
*/
|
||||
const getColorVariable = (colorName: string): string => {
|
||||
const varName = COLOR_MAP[colorName];
|
||||
if (!varName) return "#000000";
|
||||
|
||||
try {
|
||||
if (typeof document === "undefined") {
|
||||
// SSR context - return fallback
|
||||
return "#000000";
|
||||
}
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim() || "#000000"
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// CSS variables not loaded or access failed - return fallback
|
||||
return "#000000";
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize all color variables from CSS
|
||||
const initializeColors = (): Record<string, string> => {
|
||||
const colors: Record<string, string> = {};
|
||||
for (const [colorName] of Object.entries(COLOR_MAP)) {
|
||||
colors[colorName] = getColorVariable(colorName);
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
interface TooltipPayload {
|
||||
payload: {
|
||||
source?: { name: string };
|
||||
target?: { name: string };
|
||||
value?: number;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayload[];
|
||||
}
|
||||
|
||||
interface CustomNodeProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
payload: SankeyNode & {
|
||||
value: number;
|
||||
newFindings?: number;
|
||||
change?: number;
|
||||
};
|
||||
containerWidth: number;
|
||||
colors: Record<string, string>;
|
||||
onNodeHover?: (data: Omit<NodeTooltipState, "show">) => void;
|
||||
onNodeMove?: (position: { x: number; y: number }) => void;
|
||||
onNodeLeave?: () => void;
|
||||
}
|
||||
|
||||
interface CustomLinkProps {
|
||||
sourceX: number;
|
||||
targetX: number;
|
||||
sourceY: number;
|
||||
targetY: number;
|
||||
sourceControlX: number;
|
||||
targetControlX: number;
|
||||
linkWidth: number;
|
||||
index: number;
|
||||
payload: {
|
||||
source?: { name: string };
|
||||
target?: { name: string };
|
||||
value?: number;
|
||||
};
|
||||
hoveredLink: number | null;
|
||||
colors: Record<string, string>;
|
||||
onLinkHover?: (index: number, data: Omit<LinkTooltipState, "show">) => void;
|
||||
onLinkMove?: (position: { x: number; y: number }) => void;
|
||||
onLinkLeave?: () => void;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const sourceName = data.source?.name || data.name;
|
||||
const targetName = data.target?.name;
|
||||
const value = data.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3 shadow-lg"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: CHART_COLORS.textPrimary }}
|
||||
>
|
||||
{data.name}
|
||||
<div className="chart-tooltip">
|
||||
<p className="chart-tooltip-title">
|
||||
{sourceName}
|
||||
{targetName ? ` → ${targetName}` : ""}
|
||||
</p>
|
||||
{data.value && (
|
||||
<p className="text-xs" style={{ color: CHART_COLORS.textSecondary }}>
|
||||
Value: {data.value}
|
||||
</p>
|
||||
)}
|
||||
{value && <p className="chart-tooltip-subtitle">{value}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomNode = (props: any) => {
|
||||
const { x, y, width, height, payload, containerWidth } = props;
|
||||
const CustomNode = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
payload,
|
||||
containerWidth,
|
||||
colors,
|
||||
onNodeHover,
|
||||
onNodeMove,
|
||||
onNodeLeave,
|
||||
}: CustomNodeProps) => {
|
||||
const isOut = x + width + 6 > containerWidth;
|
||||
const nodeName = payload.name;
|
||||
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
|
||||
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
|
||||
const isHidden = nodeName === "";
|
||||
const hasTooltip = !isHidden && payload.newFindings;
|
||||
|
||||
@@ -104,7 +197,7 @@ const CustomNode = (props: any) => {
|
||||
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
|
||||
if (rect) {
|
||||
const bbox = rect.getBoundingClientRect();
|
||||
props.onNodeHover?.({
|
||||
onNodeHover?.({
|
||||
x: e.clientX - bbox.left,
|
||||
y: e.clientY - bbox.top,
|
||||
name: nodeName,
|
||||
@@ -122,7 +215,7 @@ const CustomNode = (props: any) => {
|
||||
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
|
||||
if (rect) {
|
||||
const bbox = rect.getBoundingClientRect();
|
||||
props.onNodeMove?.({
|
||||
onNodeMove?.({
|
||||
x: e.clientX - bbox.left,
|
||||
y: e.clientY - bbox.top,
|
||||
});
|
||||
@@ -131,7 +224,7 @@ const CustomNode = (props: any) => {
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasTooltip) return;
|
||||
props.onNodeLeave?.();
|
||||
onNodeLeave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -156,7 +249,7 @@ const CustomNode = (props: any) => {
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2}
|
||||
fontSize="14"
|
||||
fill={CHART_COLORS.textPrimary}
|
||||
fill="var(--color-text-neutral-primary)"
|
||||
>
|
||||
{nodeName}
|
||||
</text>
|
||||
@@ -165,7 +258,7 @@ const CustomNode = (props: any) => {
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2 + 13}
|
||||
fontSize="12"
|
||||
fill={CHART_COLORS.textSecondary}
|
||||
fill="var(--color-text-neutral-secondary)"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
@@ -175,26 +268,30 @@ const CustomNode = (props: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLink = (props: any) => {
|
||||
const {
|
||||
sourceX,
|
||||
targetX,
|
||||
sourceY,
|
||||
targetY,
|
||||
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 CustomLink = ({
|
||||
sourceX,
|
||||
targetX,
|
||||
sourceY,
|
||||
targetY,
|
||||
sourceControlX,
|
||||
targetControlX,
|
||||
linkWidth,
|
||||
index,
|
||||
payload,
|
||||
hoveredLink,
|
||||
colors,
|
||||
onLinkHover,
|
||||
onLinkMove,
|
||||
onLinkLeave,
|
||||
}: CustomLinkProps) => {
|
||||
const sourceName = payload.source?.name || "";
|
||||
const targetName = payload.target?.name || "";
|
||||
const value = payload.value || 0;
|
||||
const color = colors[sourceName] || "var(--color-text-neutral-tertiary)";
|
||||
const isHidden = targetName === "";
|
||||
|
||||
const isHovered = props.hoveredLink !== null && props.hoveredLink === index;
|
||||
const hasHoveredLink = props.hoveredLink !== null;
|
||||
const isHovered = hoveredLink !== null && hoveredLink === index;
|
||||
const hasHoveredLink = hoveredLink !== null;
|
||||
|
||||
const pathD = `
|
||||
M${sourceX},${sourceY + linkWidth / 2}
|
||||
@@ -219,7 +316,7 @@ const CustomLink = (props: any) => {
|
||||
?.parentElement as unknown as SVGSVGElement;
|
||||
if (rect) {
|
||||
const bbox = rect.getBoundingClientRect();
|
||||
props.onLinkHover?.(index, {
|
||||
onLinkHover?.(index, {
|
||||
x: e.clientX - bbox.left,
|
||||
y: e.clientY - bbox.top,
|
||||
sourceName,
|
||||
@@ -235,7 +332,7 @@ const CustomLink = (props: any) => {
|
||||
?.parentElement as unknown as SVGSVGElement;
|
||||
if (rect && isHovered) {
|
||||
const bbox = rect.getBoundingClientRect();
|
||||
props.onLinkMove?.({
|
||||
onLinkMove?.({
|
||||
x: e.clientX - bbox.left,
|
||||
y: e.clientY - bbox.top,
|
||||
});
|
||||
@@ -243,7 +340,7 @@ const CustomLink = (props: any) => {
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
props.onLinkLeave?.();
|
||||
onLinkLeave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -264,6 +361,7 @@ const CustomLink = (props: any) => {
|
||||
|
||||
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
const [colors, setColors] = useState<Record<string, string>>({});
|
||||
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
|
||||
show: false,
|
||||
x: 0,
|
||||
@@ -283,6 +381,11 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
color: "",
|
||||
});
|
||||
|
||||
// Initialize colors from CSS variables on mount
|
||||
useEffect(() => {
|
||||
setColors(initializeColors());
|
||||
}, []);
|
||||
|
||||
const handleLinkHover = (
|
||||
index: number,
|
||||
data: Omit<LinkTooltipState, "show">,
|
||||
@@ -320,26 +423,45 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
setNodeTooltip((prev) => ({ ...prev, show: false }));
|
||||
};
|
||||
|
||||
// Create callback references that wrap custom props and Recharts-injected props
|
||||
const wrappedCustomNode = (
|
||||
props: Omit<
|
||||
CustomNodeProps,
|
||||
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave"
|
||||
>,
|
||||
) => (
|
||||
<CustomNode
|
||||
{...props}
|
||||
colors={colors}
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeMove={handleNodeMove}
|
||||
onNodeLeave={handleNodeLeave}
|
||||
/>
|
||||
);
|
||||
|
||||
const wrappedCustomLink = (
|
||||
props: Omit<
|
||||
CustomLinkProps,
|
||||
"colors" | "hoveredLink" | "onLinkHover" | "onLinkMove" | "onLinkLeave"
|
||||
>,
|
||||
) => (
|
||||
<CustomLink
|
||||
{...props}
|
||||
colors={colors}
|
||||
hoveredLink={hoveredLink}
|
||||
onLinkHover={handleLinkHover}
|
||||
onLinkMove={handleLinkMove}
|
||||
onLinkLeave={handleLinkLeave}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
node={wrappedCustomNode}
|
||||
link={wrappedCustomLink}
|
||||
nodePadding={50}
|
||||
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
|
||||
sort={false}
|
||||
@@ -351,9 +473,9 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
<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%)",
|
||||
left: `${Math.max(TOOLTIP_OFFSET_PX, linkTooltip.x)}px`,
|
||||
top: `${Math.max(TOOLTIP_OFFSET_PX, linkTooltip.y)}px`,
|
||||
transform: `translate(${TOOLTIP_OFFSET_PX}px, -100%)`,
|
||||
}}
|
||||
>
|
||||
<ChartTooltip
|
||||
@@ -376,9 +498,9 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
<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%)",
|
||||
left: `${Math.max(TOOLTIP_OFFSET_PX, nodeTooltip.x)}px`,
|
||||
top: `${Math.max(TOOLTIP_OFFSET_PX, nodeTooltip.y)}px`,
|
||||
transform: `translate(${TOOLTIP_OFFSET_PX}px, -100%)`,
|
||||
}}
|
||||
>
|
||||
<ChartTooltip
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
|
||||
import { AlertPill } from "./shared/alert-pill";
|
||||
import { ChartLegend } from "./shared/chart-legend";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { getSeverityColorByRiskScore } from "./shared/utils";
|
||||
import type { ScatterDataPoint } from "./types";
|
||||
|
||||
@@ -27,12 +26,18 @@ interface ScatterPlotProps {
|
||||
}
|
||||
|
||||
const PROVIDER_COLORS = {
|
||||
AWS: "var(--chart-provider-aws)",
|
||||
Azure: "var(--chart-provider-azure)",
|
||||
Google: "var(--chart-provider-google)",
|
||||
AWS: "var(--color-bg-data-aws)",
|
||||
Azure: "var(--color-bg-data-azure)",
|
||||
Google: "var(--color-bg-data-gcp)",
|
||||
Default: "var(--color-text-neutral-tertiary)",
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
interface ScatterTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ScatterDataPoint }>;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: ScatterTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const severityColor = getSeverityColorByRiskScore(data.x);
|
||||
@@ -41,19 +46,19 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
<div
|
||||
className="rounded-lg border p-3 shadow-lg"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
borderColor: "var(--color-border-neutral-tertiary)",
|
||||
backgroundColor: "var(--color-bg-neutral-secondary)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: CHART_COLORS.textPrimary }}
|
||||
style={{ color: "var(--color-text-neutral-primary)" }}
|
||||
>
|
||||
{data.name}
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
style={{ color: "var(--color-text-neutral-secondary)" }}
|
||||
>
|
||||
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
|
||||
</p>
|
||||
@@ -66,19 +71,27 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ScatterDotProps {
|
||||
cx: number;
|
||||
cy: number;
|
||||
payload: ScatterDataPoint;
|
||||
selectedPoint?: ScatterDataPoint | null;
|
||||
onSelectPoint?: (point: ScatterDataPoint) => void;
|
||||
}
|
||||
|
||||
const CustomScatterDot = ({
|
||||
cx,
|
||||
cy,
|
||||
payload,
|
||||
selectedPoint,
|
||||
onSelectPoint,
|
||||
}: any) => {
|
||||
}: ScatterDotProps) => {
|
||||
const isSelected = selectedPoint?.name === payload.name;
|
||||
const size = isSelected ? 18 : 8;
|
||||
const fill = isSelected
|
||||
? "#86DA26"
|
||||
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
|
||||
CHART_COLORS.defaultColor;
|
||||
"var(--color-text-neutral-tertiary)";
|
||||
|
||||
return (
|
||||
<circle
|
||||
@@ -95,8 +108,17 @@ const CustomScatterDot = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const items = payload.map((entry: any) => ({
|
||||
interface LegendPayloadItem {
|
||||
value: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface LegendProps {
|
||||
payload?: LegendPayloadItem[];
|
||||
}
|
||||
|
||||
const CustomLegend = ({ payload }: LegendProps) => {
|
||||
const items = (payload || []).map((entry) => ({
|
||||
label: entry.value,
|
||||
color: entry.color,
|
||||
}));
|
||||
@@ -136,19 +158,22 @@ export function ScatterPlot({
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
|
||||
<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: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
position: "bottom",
|
||||
offset: 10,
|
||||
fill: "var(--color-text-neutral-secondary)",
|
||||
}}
|
||||
tick={{ fill: CHART_COLORS.textSecondary }}
|
||||
tick={{ fill: "var(--color-text-neutral-secondary)" }}
|
||||
domain={[0, 10]}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -158,10 +183,11 @@ export function ScatterPlot({
|
||||
label={{
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
position: "left",
|
||||
offset: 10,
|
||||
fill: "var(--color-text-neutral-secondary)",
|
||||
}}
|
||||
tick={{ fill: CHART_COLORS.textSecondary }}
|
||||
tick={{ fill: "var(--color-text-neutral-secondary)" }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
@@ -172,15 +198,18 @@ export function ScatterPlot({
|
||||
data={points}
|
||||
fill={
|
||||
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
|
||||
CHART_COLORS.defaultColor
|
||||
PROVIDER_COLORS.Default
|
||||
}
|
||||
shape={(props: any) => (
|
||||
<CustomScatterDot
|
||||
{...props}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={handlePointClick}
|
||||
/>
|
||||
)}
|
||||
shape={(props: unknown) => {
|
||||
const dotProps = props as ScatterDotProps;
|
||||
return (
|
||||
<CustomScatterDot
|
||||
{...dotProps}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={handlePointClick}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScatterChart>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib";
|
||||
|
||||
interface AlertPillProps {
|
||||
value: number;
|
||||
@@ -9,30 +9,41 @@ interface AlertPillProps {
|
||||
textSize?: "xs" | "sm" | "base";
|
||||
}
|
||||
|
||||
const TEXT_SIZE_CLASSES = {
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
xs: "text-xs",
|
||||
} as const;
|
||||
|
||||
export function AlertPill({
|
||||
value,
|
||||
label = "Fail Findings",
|
||||
iconSize = 12,
|
||||
textSize = "xs",
|
||||
}: AlertPillProps) {
|
||||
const textSizeClass = TEXT_SIZE_CLASSES[textSize];
|
||||
|
||||
// Chart alert colors are theme-aware variables from globals.css
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-full px-2 py-1"
|
||||
style={{ backgroundColor: "var(--chart-alert-bg)" }}
|
||||
style={{ backgroundColor: "var(--color-bg-fail-secondary)" }}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={iconSize}
|
||||
style={{ color: "var(--chart-alert-text)" }}
|
||||
style={{ color: "var(--color-text-error)" }}
|
||||
/>
|
||||
<span
|
||||
className={cn(`text-${textSize}`, "font-semibold")}
|
||||
style={{ color: "var(--chart-alert-text)" }}
|
||||
className={cn(textSizeClass, "font-semibold")}
|
||||
style={{ color: "var(--color-text-error)" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,20 +9,17 @@ interface ChartLegendProps {
|
||||
|
||||
export function ChartLegend({ items }: ChartLegendProps) {
|
||||
return (
|
||||
<div
|
||||
className="mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]"
|
||||
style={{ borderColor: "var(--chart-border)" }}
|
||||
>
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
|
||||
{items.map((item, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-1">
|
||||
<div
|
||||
key={`legend-${index}`}
|
||||
className="flex items-center gap-2 px-4 py-3"
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--chart-text-secondary)" }}
|
||||
>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,18 @@ import { Bell, VolumeX } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { TooltipData } from "../types";
|
||||
import { CHART_COLORS } from "./constants";
|
||||
|
||||
interface MultiSeriesPayloadEntry {
|
||||
color?: string;
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
dataKey?: string;
|
||||
payload?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: any[];
|
||||
payload?: MultiSeriesPayloadEntry[];
|
||||
label?: string;
|
||||
showColorIndicator?: boolean;
|
||||
colorIndicatorShape?: "circle" | "square";
|
||||
@@ -24,17 +31,11 @@ export function ChartTooltip({
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: TooltipData = payload[0].payload || payload[0];
|
||||
const data: TooltipData = (payload[0].payload || payload[0]) as TooltipData;
|
||||
const color = payload[0].color || data.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
}}
|
||||
>
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{showColorIndicator && color && (
|
||||
<div
|
||||
@@ -45,12 +46,12 @@ export function ChartTooltip({
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
<p className="text-text-neutral-primary text-sm font-semibold">
|
||||
{label || data.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-900 dark:text-white">
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
|
||||
{typeof data.value === "number"
|
||||
? data.value.toLocaleString()
|
||||
: data.value}
|
||||
@@ -59,8 +60,8 @@ export function ChartTooltip({
|
||||
|
||||
{data.newFindings !== undefined && data.newFindings > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
<Bell size={14} className="text-text-neutral-secondary" />
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{data.newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
@@ -68,8 +69,8 @@ export function ChartTooltip({
|
||||
|
||||
{data.new !== undefined && data.new > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
<Bell size={14} className="text-text-neutral-secondary" />
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{data.new} New
|
||||
</span>
|
||||
</div>
|
||||
@@ -77,17 +78,17 @@ export function ChartTooltip({
|
||||
|
||||
{data.muted !== undefined && data.muted > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<VolumeX size={14} className="text-slate-600 dark:text-slate-400" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
<VolumeX size={14} className="text-text-neutral-secondary" />
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{data.muted} Muted
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.change !== undefined && (
|
||||
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{(data.change as number) > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
@@ -110,26 +111,29 @@ export function MultiSeriesChartTooltip({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
<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">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
{payload.map((entry: any, index: number) => (
|
||||
{payload.map((entry: MultiSeriesPayloadEntry, 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-slate-900 dark:text-white">
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{entry.name}:
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-slate-900 dark:text-white">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
{entry.value}
|
||||
</span>
|
||||
{entry.payload[`${entry.dataKey}_change`] && (
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
|
||||
{entry.payload && entry.payload[`${entry.dataKey}_change`] && (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
(
|
||||
{(entry.payload[`${entry.dataKey}_change`] as number) > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{entry.payload[`${entry.dataKey}_change`]}%)
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,34 +1,3 @@
|
||||
export const SEVERITY_COLORS = {
|
||||
Informational: "var(--bg-data-info)",
|
||||
Low: "var(--bg-data-low)",
|
||||
Medium: "var(--bg-data-medium)",
|
||||
High: "var(--bg-data-high)",
|
||||
Critical: "var(--bg-data-critical)",
|
||||
} 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)",
|
||||
textPrimary: "var(--chart-text-primary)",
|
||||
textSecondary: "var(--chart-text-secondary)",
|
||||
gridLine: "var(--chart-border-emphasis)",
|
||||
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
|
||||
alertPillBg: "var(--chart-alert-bg)",
|
||||
alertPillText: "var(--chart-alert-text)",
|
||||
defaultColor: "#64748b", // slate-500
|
||||
} as const;
|
||||
|
||||
export const CHART_DIMENSIONS = {
|
||||
defaultHeight: 400,
|
||||
tooltipMinWidth: "200px",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export const AXIS_FONT_SIZE = 14;
|
||||
const TODAY_FONT_SIZE = 12;
|
||||
|
||||
interface CustomXAxisTickProps {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: {
|
||||
value: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
const getTodayFormatted = () => {
|
||||
const today = new Date();
|
||||
return today.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export const CustomXAxisTickWithToday = Object.assign(
|
||||
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
|
||||
const { x, y, payload } = props;
|
||||
const todayFormatted = getTodayFormatted();
|
||||
const isToday = String(payload.value) === todayFormatted;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={20}
|
||||
dy={4}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-neutral-secondary)"
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
{isToday && (
|
||||
<text
|
||||
x={0}
|
||||
y={36}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-neutral-secondary)"
|
||||
fontSize={TODAY_FONT_SIZE}
|
||||
fontWeight={400}
|
||||
>
|
||||
(today)
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
},
|
||||
{ displayName: "CustomXAxisTickWithToday" },
|
||||
);
|
||||
|
||||
export const CustomXAxisTick = Object.assign(
|
||||
function CustomXAxisTick(props: CustomXAxisTickProps) {
|
||||
const { x, y, payload } = props;
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={20}
|
||||
dy={4}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-neutral-secondary)"
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
},
|
||||
{ displayName: "CustomXAxisTick" },
|
||||
);
|
||||
@@ -1,4 +1,12 @@
|
||||
import { SEVERITY_COLORS } from "./constants";
|
||||
const SEVERITY_COLORS = {
|
||||
Critical: "var(--color-bg-data-critical)",
|
||||
High: "var(--color-bg-data-high)",
|
||||
Medium: "var(--color-bg-data-medium)",
|
||||
Low: "var(--color-bg-data-low)",
|
||||
Informational: "var(--color-bg-data-info)",
|
||||
Info: "var(--color-bg-data-info)",
|
||||
Muted: "var(--color-bg-data-muted)",
|
||||
};
|
||||
|
||||
export function getSeverityColorByRiskScore(riskScore: number): string {
|
||||
if (riskScore >= 7) return SEVERITY_COLORS.Critical;
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
"use client";
|
||||
|
||||
import * as d3 from "d3";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
GeoJsonProperties,
|
||||
Geometry,
|
||||
} from "geojson";
|
||||
import { AlertTriangle, ChevronDown, Info, MapPin } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { feature } from "topojson-client";
|
||||
import type {
|
||||
GeometryCollection,
|
||||
Objects,
|
||||
Topology,
|
||||
} from "topojson-specification";
|
||||
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
|
||||
import { HorizontalBarChart } from "./horizontal-bar-chart";
|
||||
import { BarDataPoint } from "./types";
|
||||
|
||||
// Constants
|
||||
const MAP_CONFIG = {
|
||||
defaultWidth: 688,
|
||||
defaultHeight: 400,
|
||||
pointRadius: 6,
|
||||
selectedPointRadius: 8,
|
||||
transitionDuration: 300,
|
||||
} as const;
|
||||
|
||||
// SVG-specific colors: must use actual color values, not Tailwind classes
|
||||
// as SVG fill/stroke attributes don't support class-based styling
|
||||
// Retrieves computed CSS variable values from globals.css theme variables at runtime
|
||||
// Fallback hex colors are used only when CSS variables cannot be computed (SSR context)
|
||||
interface MapColorsConfig {
|
||||
landFill: string;
|
||||
landStroke: string;
|
||||
pointDefault: string;
|
||||
pointSelected: string;
|
||||
pointHover: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAP_COLORS: MapColorsConfig = {
|
||||
// Fallback: gray-300 (neutral-300) - used for map land fill in light theme
|
||||
landFill: "#d1d5db",
|
||||
// Fallback: slate-300 - used for map borders
|
||||
landStroke: "#cbd5e1",
|
||||
// Fallback: red-600 - error color for points
|
||||
pointDefault: "#dc2626",
|
||||
// Fallback: emerald-500 - success color for selected points
|
||||
pointSelected: "#10b981",
|
||||
// Fallback: red-600 - error color for hover points
|
||||
pointHover: "#dc2626",
|
||||
};
|
||||
|
||||
function getMapColors(): MapColorsConfig {
|
||||
if (typeof document === "undefined") return DEFAULT_MAP_COLORS;
|
||||
|
||||
const root = document.documentElement;
|
||||
const style = getComputedStyle(root);
|
||||
const getVar = (varName: string): string => {
|
||||
const value = style.getPropertyValue(varName).trim();
|
||||
return value && value.length > 0 ? value : "";
|
||||
};
|
||||
|
||||
const colors: MapColorsConfig = {
|
||||
landFill: getVar("--bg-neutral-map") || DEFAULT_MAP_COLORS.landFill,
|
||||
landStroke:
|
||||
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
|
||||
pointDefault:
|
||||
getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointDefault,
|
||||
pointSelected:
|
||||
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.pointSelected,
|
||||
pointHover: getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointHover,
|
||||
};
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
const RISK_LEVELS = {
|
||||
LOW_HIGH: "low-high",
|
||||
HIGH: "high",
|
||||
CRITICAL: "critical",
|
||||
} as const;
|
||||
|
||||
type RiskLevel = (typeof RISK_LEVELS)[keyof typeof RISK_LEVELS];
|
||||
|
||||
interface LocationPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
coordinates: [number, number];
|
||||
totalFindings: number;
|
||||
riskLevel: RiskLevel;
|
||||
severityData: BarDataPoint[];
|
||||
change?: number;
|
||||
}
|
||||
|
||||
interface ThreatMapData {
|
||||
locations: LocationPoint[];
|
||||
regions: string[];
|
||||
}
|
||||
|
||||
interface ThreatMapProps {
|
||||
data: ThreatMapData;
|
||||
height?: number;
|
||||
onLocationSelect?: (location: LocationPoint | null) => void;
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function createProjection(width: number, height: number) {
|
||||
return d3
|
||||
.geoNaturalEarth1()
|
||||
.fitExtent(
|
||||
[
|
||||
[1, 1],
|
||||
[width - 1, height - 1],
|
||||
],
|
||||
{ type: "Sphere" },
|
||||
)
|
||||
.precision(0.2);
|
||||
}
|
||||
|
||||
async function fetchWorldData(): Promise<FeatureCollection | null> {
|
||||
try {
|
||||
const worldAtlasModule = await import("world-atlas/countries-110m.json");
|
||||
const worldData = worldAtlasModule.default || worldAtlasModule;
|
||||
const topology = worldData as unknown as Topology<Objects>;
|
||||
return feature(
|
||||
topology,
|
||||
topology.objects.countries as GeometryCollection,
|
||||
) as FeatureCollection;
|
||||
} catch (error) {
|
||||
console.error("Error loading world map data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Create SVG element
|
||||
function createSVGElement<T extends SVGElement>(
|
||||
type: string,
|
||||
attributes: Record<string, string>,
|
||||
): T {
|
||||
const element = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
type,
|
||||
) as T;
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element.setAttribute(key, value);
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
// Components
|
||||
function MapTooltip({
|
||||
location,
|
||||
position,
|
||||
}: {
|
||||
location: LocationPoint;
|
||||
position: { x: number; y: number };
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none absolute z-50 min-w-[200px] rounded-xl border p-3 shadow-lg"
|
||||
style={{
|
||||
left: `${position.x + 15}px`,
|
||||
top: `${position.y + 15}px`,
|
||||
transform: "translate(0, -50%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={14} className="text-text-neutral-secondary" />
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
{location.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-bg-data-critical" />
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{location.totalFindings.toLocaleString()} Fail Findings
|
||||
</span>
|
||||
</div>
|
||||
{location.change !== undefined && (
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{
|
||||
color:
|
||||
location.change > 0
|
||||
? "var(--bg-pass-primary)"
|
||||
: "var(--bg-fail-primary)",
|
||||
}}
|
||||
>
|
||||
{location.change > 0 ? "+" : ""}
|
||||
{location.change}%{" "}
|
||||
</span>
|
||||
since last scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Info size={48} className="mx-auto mb-2 text-slate-500" />
|
||||
<p className="text-sm text-slate-400">
|
||||
Select a location on the map to view details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({ height }: { height: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ height }}>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-slate-400">Loading map...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreatMap({
|
||||
data,
|
||||
height = MAP_CONFIG.defaultHeight,
|
||||
}: ThreatMapProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedLocation, setSelectedLocation] =
|
||||
useState<LocationPoint | null>(null);
|
||||
const [hoveredLocation, setHoveredLocation] = useState<LocationPoint | null>(
|
||||
null,
|
||||
);
|
||||
const [tooltipPosition, setTooltipPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>("All Regions");
|
||||
const [worldData, setWorldData] = useState<FeatureCollection | null>(null);
|
||||
const [isLoadingMap, setIsLoadingMap] = useState(true);
|
||||
const [dimensions, setDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>({
|
||||
width: MAP_CONFIG.defaultWidth,
|
||||
height,
|
||||
});
|
||||
const [mapColors, setMapColors] =
|
||||
useState<MapColorsConfig>(DEFAULT_MAP_COLORS);
|
||||
|
||||
const filteredLocations =
|
||||
selectedRegion === "All Regions"
|
||||
? data.locations
|
||||
: data.locations.filter((loc) => loc.region === selectedRegion);
|
||||
|
||||
// Monitor theme changes and update colors
|
||||
useEffect(() => {
|
||||
const updateColors = () => {
|
||||
setMapColors(getMapColors());
|
||||
};
|
||||
|
||||
// Update colors immediately
|
||||
updateColors();
|
||||
|
||||
// Watch for theme changes (dark class on document)
|
||||
const observer = new MutationObserver(() => {
|
||||
updateColors();
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Fetch world data once on mount
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
fetchWorldData()
|
||||
.then((data) => {
|
||||
if (isMounted && data) setWorldData(data);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
if (isMounted) setIsLoadingMap(false);
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update dimensions on resize
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
setDimensions({ width: containerRef.current.clientWidth, height });
|
||||
}
|
||||
};
|
||||
updateDimensions();
|
||||
window.addEventListener("resize", updateDimensions);
|
||||
return () => window.removeEventListener("resize", updateDimensions);
|
||||
}, [height]);
|
||||
|
||||
// Render the map
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !worldData || isLoadingMap) return;
|
||||
|
||||
const svg = svgRef.current;
|
||||
const { width, height } = dimensions;
|
||||
svg.innerHTML = "";
|
||||
|
||||
const projection = createProjection(width, height);
|
||||
const path = d3.geoPath().projection(projection);
|
||||
const colors = mapColors;
|
||||
|
||||
// Render countries
|
||||
const mapGroup = createSVGElement<SVGGElement>("g", {
|
||||
class: "map-countries",
|
||||
});
|
||||
worldData.features?.forEach(
|
||||
(feature: Feature<Geometry, GeoJsonProperties>) => {
|
||||
const pathData = path(feature);
|
||||
if (pathData) {
|
||||
const pathElement = createSVGElement<SVGPathElement>("path", {
|
||||
d: pathData,
|
||||
fill: colors.landFill,
|
||||
stroke: colors.landStroke,
|
||||
"stroke-width": "0.5",
|
||||
});
|
||||
mapGroup.appendChild(pathElement);
|
||||
}
|
||||
},
|
||||
);
|
||||
svg.appendChild(mapGroup);
|
||||
|
||||
// Helper to update tooltip position
|
||||
const updateTooltip = (e: MouseEvent) => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
setTooltipPosition({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to create glow rings
|
||||
const createGlowRing = (
|
||||
cx: string,
|
||||
cy: string,
|
||||
radiusOffset: number,
|
||||
color: string,
|
||||
opacity: string,
|
||||
): SVGCircleElement => {
|
||||
return createSVGElement<SVGCircleElement>("circle", {
|
||||
cx,
|
||||
cy,
|
||||
r: radiusOffset.toString(),
|
||||
fill: "none",
|
||||
stroke: color,
|
||||
"stroke-width": "1",
|
||||
opacity,
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to create circle with glow
|
||||
const createCircle = (location: LocationPoint) => {
|
||||
const projected = projection(location.coordinates);
|
||||
if (!projected) return null;
|
||||
|
||||
const [x, y] = projected;
|
||||
if (x < 0 || x > width || y < 0 || y > height) return null;
|
||||
|
||||
const isSelected = selectedLocation?.id === location.id;
|
||||
const isHovered = hoveredLocation?.id === location.id;
|
||||
|
||||
const group = createSVGElement<SVGGElement>("g", {
|
||||
class: "cursor-pointer",
|
||||
});
|
||||
|
||||
const radius = isSelected
|
||||
? MAP_CONFIG.selectedPointRadius
|
||||
: MAP_CONFIG.pointRadius;
|
||||
const color = isSelected ? colors.pointSelected : colors.pointDefault;
|
||||
|
||||
// Add glow rings for all points (unselected and selected)
|
||||
group.appendChild(
|
||||
createGlowRing(x.toString(), y.toString(), radius + 4, color, "0.4"),
|
||||
);
|
||||
group.appendChild(
|
||||
createGlowRing(x.toString(), y.toString(), radius + 8, color, "0.2"),
|
||||
);
|
||||
|
||||
const circle = createSVGElement<SVGCircleElement>("circle", {
|
||||
cx: x.toString(),
|
||||
cy: y.toString(),
|
||||
r: radius.toString(),
|
||||
fill: color,
|
||||
class: isHovered && !isSelected ? "opacity-70" : "",
|
||||
});
|
||||
group.appendChild(circle);
|
||||
|
||||
group.addEventListener("click", () =>
|
||||
setSelectedLocation(isSelected ? null : location),
|
||||
);
|
||||
group.addEventListener("mouseenter", (e) => {
|
||||
setHoveredLocation(location);
|
||||
updateTooltip(e);
|
||||
});
|
||||
group.addEventListener("mousemove", updateTooltip);
|
||||
group.addEventListener("mouseleave", () => {
|
||||
setHoveredLocation(null);
|
||||
setTooltipPosition(null);
|
||||
});
|
||||
|
||||
return group;
|
||||
};
|
||||
|
||||
// Render points
|
||||
const pointsGroup = createSVGElement<SVGGElement>("g", {
|
||||
class: "threat-points",
|
||||
});
|
||||
|
||||
// Unselected points first
|
||||
filteredLocations.forEach((location) => {
|
||||
if (selectedLocation?.id !== location.id) {
|
||||
const circle = createCircle(location);
|
||||
if (circle) pointsGroup.appendChild(circle);
|
||||
}
|
||||
});
|
||||
|
||||
// Selected point last (on top)
|
||||
if (selectedLocation) {
|
||||
const selectedData = filteredLocations.find(
|
||||
(loc) => loc.id === selectedLocation.id,
|
||||
);
|
||||
if (selectedData) {
|
||||
const circle = createCircle(selectedData);
|
||||
if (circle) pointsGroup.appendChild(circle);
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(pointsGroup);
|
||||
}, [
|
||||
dimensions,
|
||||
filteredLocations,
|
||||
selectedLocation,
|
||||
hoveredLocation,
|
||||
worldData,
|
||||
isLoadingMap,
|
||||
mapColors,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<div className="flex flex-1 gap-12 overflow-hidden">
|
||||
{/* Map Section - in Card */}
|
||||
<div className="flex basis-[70%] flex-col overflow-hidden">
|
||||
<Card
|
||||
ref={containerRef}
|
||||
variant="base"
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-text-neutral-primary text-lg font-semibold">
|
||||
Threat Map
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<select
|
||||
aria-label="Filter threat map by region"
|
||||
value={selectedRegion}
|
||||
onChange={(e) => setSelectedRegion(e.target.value)}
|
||||
className="border-border-neutral-primary bg-bg-neutral-secondary text-text-neutral-primary appearance-none rounded-lg border px-4 py-2 pr-10 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="All Regions">All Regions</option>
|
||||
{data.regions.map((region) => (
|
||||
<option key={region} value={region}>
|
||||
{region}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 right-3 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full flex-1">
|
||||
{isLoadingMap ? (
|
||||
<LoadingState height={dimensions.height} />
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-full w-full">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
className="h-full w-full"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
{hoveredLocation && tooltipPosition && (
|
||||
<MapTooltip
|
||||
location={hoveredLocation}
|
||||
position={tooltipPosition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mt-3 flex items-center gap-2"
|
||||
role="status"
|
||||
aria-label={`${filteredLocations.length} threat locations on map`}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: "var(--bg-data-critical)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--text-neutral-tertiary)" }}
|
||||
>
|
||||
{filteredLocations.length} Locations
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Details Section - No Card */}
|
||||
<div className="flex basis-[30%] items-center overflow-hidden">
|
||||
{selectedLocation ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="mb-1 flex items-center gap-2"
|
||||
aria-label={`Selected location: ${selectedLocation.name}`}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-pass-primary h-2 w-2 rounded-full"
|
||||
/>
|
||||
<h4 className="text-neutral-primary text-base font-semibold">
|
||||
{selectedLocation.name}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-neutral-tertiary text-xs">
|
||||
{selectedLocation.totalFindings.toLocaleString()} Total
|
||||
Findings
|
||||
</p>
|
||||
</div>
|
||||
<HorizontalBarChart data={selectedLocation.severityData} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface RadarDataPoint {
|
||||
category: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
severityData?: BarDataPoint[];
|
||||
}
|
||||
|
||||
export interface ScatterDataPoint {
|
||||
|
||||
@@ -23,27 +23,27 @@ export interface ChartConfig {
|
||||
const chartConfig = {
|
||||
critical: {
|
||||
label: "Critical",
|
||||
color: "hsl(var(--chart-critical))",
|
||||
color: "var(--color-bg-data-critical)",
|
||||
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=critical",
|
||||
},
|
||||
high: {
|
||||
label: "High",
|
||||
color: "hsl(var(--chart-fail))",
|
||||
color: "var(--color-bg-data-high)",
|
||||
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=high",
|
||||
},
|
||||
medium: {
|
||||
label: "Medium",
|
||||
color: "hsl(var(--chart-medium))",
|
||||
color: "var(--color-bg-data-medium)",
|
||||
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=medium",
|
||||
},
|
||||
low: {
|
||||
label: "Low",
|
||||
color: "hsl(var(--chart-low))",
|
||||
color: "var(--color-bg-data-low)",
|
||||
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=low",
|
||||
},
|
||||
informational: {
|
||||
label: "Informational",
|
||||
color: "hsl(var(--chart-informational))",
|
||||
color: "var(--color-bg-data-info)",
|
||||
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=informational",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
@@ -49,15 +49,15 @@ const chartConfig = {
|
||||
},
|
||||
success: {
|
||||
label: "Success",
|
||||
color: "hsl(var(--chart-success))",
|
||||
color: "var(--color-bg-pass)",
|
||||
},
|
||||
fail: {
|
||||
label: "Fail",
|
||||
color: "hsl(var(--chart-fail))",
|
||||
color: "var(--color-bg-fail)",
|
||||
},
|
||||
muted: {
|
||||
label: "Muted",
|
||||
color: "hsl(var(--chart-muted))",
|
||||
color: "var(--color-bg-neutral-tertiary)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ const TRIGGER_STYLES = {
|
||||
active:
|
||||
"data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
|
||||
underline:
|
||||
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-[#20B853] after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
|
||||
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
|
||||
focus:
|
||||
"focus-visible:ring-2 focus-visible:ring-[#20B853] focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
|
||||
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
|
||||
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user