feat: implement Finding Severity Over Time chart with time range selector (#9106)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alan Buscaglia
2025-11-12 14:33:20 +01:00
committed by GitHub
co-authored by Claude
parent 98f8ef1b4b
commit 07ac96661e
54 changed files with 3190 additions and 1892 deletions
@@ -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;
+2 -2
View File
@@ -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,
},
+5 -15
View File
@@ -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>
+1
View File
@@ -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";
+91 -119
View File
@@ -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>
);
}
+18 -18
View File
@@ -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 (
+72 -40
View File
@@ -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,
}
}
+205 -83
View File
@@ -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
+59 -30
View File
@@ -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>
+17 -6
View File
@@ -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>
);
}
+6 -9
View File
@@ -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>
+32 -28
View File
@@ -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>
)}
-31
View File
@@ -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" },
);
+9 -1
View File
@@ -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;
+570
View File
@@ -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>
);
}
+1
View File
@@ -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;
+2 -2
View File
@@ -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;