feat(ui): add Finding Severity Over Time chart to overview page (#9405)

This commit is contained in:
Alan Buscaglia
2025-12-04 13:19:15 +01:00
committed by GitHub
parent 9c387d5742
commit 7f12832808
100 changed files with 1267 additions and 948 deletions
+108 -18
View File
@@ -19,16 +19,20 @@ import {
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot";
import {
AXIS_FONT_SIZE,
CustomXAxisTickWithToday,
} from "./shared/custom-axis-tick";
import { CustomDot } from "./shared/custom-dot";
import { LineConfig, LineDataPoint } from "./types";
interface LineChartProps {
data: LineDataPoint[];
lines: LineConfig[];
height?: number;
xAxisInterval?: number | "preserveStart" | "preserveEnd" | "preserveStartEnd";
onPointClick?: (data: PointClickData) => void;
}
interface TooltipPayloadItem {
@@ -39,28 +43,54 @@ interface TooltipPayloadItem {
payload: LineDataPoint;
}
const formatTooltipDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
interface CustomLineTooltipProps extends TooltipProps<number, string> {
filterLine?: string | null;
}
const CustomLineTooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
filterLine,
}: CustomLineTooltipProps) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const typedPayload = payload as unknown as TooltipPayloadItem[];
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
// Filter payload if a line is selected or hovered
const displayPayload = filterLine
? typedPayload.filter((item) => item.dataKey === filterLine)
: typedPayload;
if (displayPayload.length === 0) {
return null;
}
const totalValue = displayPayload.reduce((sum, item) => sum + item.value, 0);
const formattedDate = formatTooltipDate(String(label));
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-secondary mb-3 text-xs">{label}</p>
<p className="text-text-neutral-secondary mb-3 text-xs">
{formattedDate}
</p>
<div className="mb-3">
<AlertPill value={totalValue} textSize="sm" />
</div>
<div className="space-y-3">
{typedPayload.map((item) => {
{displayPayload.map((item) => {
const newFindings = item.payload[`${item.dataKey}_newFindings`];
const change = item.payload[`${item.dataKey}_change`];
@@ -106,14 +136,30 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function LineChart({ data, lines, height = 400 }: LineChartProps) {
export function LineChart({
data,
lines,
height = 400,
xAxisInterval = "preserveStartEnd",
onPointClick,
}: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
const [selectedLine, setSelectedLine] = useState<string | null>(null);
// Active line is either selected (persistent) or hovered (temporary)
const activeLine = selectedLine ?? hoveredLine;
const legendItems = lines.map((line) => ({
label: line.label,
color: line.color,
dataKey: line.dataKey,
}));
const handleLegendClick = (dataKey: string) => {
// Toggle selection: if already selected, deselect; otherwise select
setSelectedLine((current) => (current === dataKey ? null : dataKey));
};
return (
<div className="w-full">
<ChartContainer
@@ -126,9 +172,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
margin={{
top: 10,
left: 0,
right: 8,
bottom: 20,
right: 30,
bottom: 40,
}}
style={{ cursor: onPointClick ? "pointer" : "default" }}
>
<CartesianGrid
vertical={false}
@@ -140,7 +187,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
tickLine={false}
axisLine={false}
tickMargin={8}
tick={CustomXAxisTickWithToday}
interval={xAxisInterval}
tick={(props) => (
<CustomXAxisTickWithToday {...props} data={data} />
)}
/>
<YAxis
tickLine={false}
@@ -151,10 +201,17 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
fontSize: AXIS_FONT_SIZE,
}}
/>
<ChartTooltip cursor={false} content={<CustomLineTooltip />} />
<ChartTooltip
cursor={{
stroke: "var(--color-text-neutral-tertiary)",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={<CustomLineTooltip filterLine={activeLine} />}
/>
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
const isActive = activeLine === line.dataKey;
const isFaded = activeLine !== null && !isActive;
return (
<Line
key={line.dataKey}
@@ -162,12 +219,38 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
strokeOpacity={isFaded ? 0.2 : 1}
name={line.label}
dot={{ fill: line.color, r: 4 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
dot={({
key,
...props
}: {
key?: string;
cx?: number;
cy?: number;
}) => (
<CustomDot
key={key}
{...props}
color={line.color}
isFaded={isFaded}
/>
)}
activeDot={(props: {
cx?: number;
cy?: number;
payload?: LineDataPoint;
}) => (
<CustomActiveDot
{...props}
dataKey={line.dataKey}
color={line.color}
isFaded={isFaded}
onPointClick={onPointClick}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
/>
)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
@@ -175,8 +258,15 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
</RechartsLine>
</ChartContainer>
<div className="mt-4">
<ChartLegend items={legendItems} />
<div className="mt-4 flex flex-col items-start gap-2">
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity.
</p>
<ChartLegend
items={legendItems}
selectedItem={selectedLine}
onItemClick={handleLegendClick}
/>
</div>
</div>
);
+41 -15
View File
@@ -1,29 +1,55 @@
export interface ChartLegendItem {
label: string;
color: string;
dataKey?: string;
}
interface ChartLegendProps {
items: ChartLegendItem[];
selectedItem?: string | null;
onItemClick?: (dataKey: string) => void;
}
export function ChartLegend({ items }: ChartLegendProps) {
export function ChartLegend({
items,
selectedItem,
onItemClick,
}: ChartLegendProps) {
const isInteractive = !!onItemClick;
return (
<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-2 px-4 py-3"
>
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</div>
))}
{items.map((item, index) => {
const dataKey = item.dataKey ?? item.label.toLowerCase();
const isSelected = selectedItem === dataKey;
const isFaded = selectedItem !== null && !isSelected;
return (
<button
key={`legend-${index}`}
type="button"
className={`flex items-center gap-2 px-4 py-3 transition-opacity duration-200 ${
isInteractive
? "cursor-pointer hover:opacity-80"
: "cursor-default"
} ${isFaded ? "opacity-30" : "opacity-100"}`}
onClick={() => onItemClick?.(dataKey)}
disabled={!isInteractive}
>
<div
className={`h-3 w-3 rounded ${isSelected ? "ring-2 ring-offset-1" : ""}`}
style={{
backgroundColor: item.color,
// @ts-expect-error ring-color is a valid Tailwind CSS variable
"--tw-ring-color": item.color,
}}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,54 @@
import { Dot } from "recharts";
import { LineDataPoint } from "../types";
export interface PointClickData {
point: LineDataPoint;
dataKey?: string;
}
interface CustomActiveDotProps {
cx?: number;
cy?: number;
payload?: LineDataPoint;
dataKey: string;
color: string;
isFaded: boolean;
onPointClick?: (data: PointClickData) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export const CustomActiveDot = ({
cx,
cy,
payload,
dataKey,
color,
isFaded,
onPointClick,
onMouseEnter,
onMouseLeave,
}: CustomActiveDotProps) => {
if (cx === undefined || cy === undefined) return null;
// Don't render active dot for faded lines
if (isFaded) return null;
return (
<Dot
cx={cx}
cy={cy}
r={6}
fill={color}
style={{ cursor: onPointClick ? "pointer" : "default" }}
onClick={() => {
if (onPointClick && payload) {
onPointClick({ point: payload, dataKey });
}
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
};
@@ -1,27 +1,55 @@
export const AXIS_FONT_SIZE = 14;
const TODAY_FONT_SIZE = 12;
const MONTH_FONT_SIZE = 11;
interface CustomXAxisTickProps {
x: number;
y: number;
index?: number;
payload: {
value: string | number;
};
visibleTicksCount?: number;
}
const getTodayFormatted = () => {
const getTodayISO = () => {
const today = new Date();
return today.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return today.toISOString().split("T")[0];
};
const getMonthName = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", { month: "short" });
};
const getDayNumber = (dateStr: string) => {
const date = new Date(dateStr);
return date.getDate();
};
const getMonthFromDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.getMonth();
};
export const CustomXAxisTickWithToday = Object.assign(
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
const todayFormatted = getTodayFormatted();
const isToday = String(payload.value) === todayFormatted;
function CustomXAxisTickWithToday(
props: CustomXAxisTickProps & { data?: Array<{ date: string }> },
) {
const { x, y, payload, index = 0, data = [] } = props;
const dateStr = String(payload.value);
const todayISO = getTodayISO();
const isToday = dateStr === todayISO;
const dayNumber = getDayNumber(dateStr);
const currentMonth = getMonthFromDate(dateStr);
// Show month name if it's the first tick or if the month changed from previous tick
const isFirstTick = index === 0;
const previousDate = index > 0 && data[index - 1]?.date;
const previousMonth = previousDate ? getMonthFromDate(previousDate) : -1;
const monthChanged = currentMonth !== previousMonth;
const showMonth = isFirstTick || monthChanged;
return (
<g transform={`translate(${x},${y})`}>
@@ -33,12 +61,23 @@ export const CustomXAxisTickWithToday = Object.assign(
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
{dayNumber}
</text>
{showMonth && (
<text
x={0}
y={42}
textAnchor="middle"
fill="var(--color-text-neutral-tertiary)"
fontSize={MONTH_FONT_SIZE}
>
{getMonthName(dateStr)}
</text>
)}
{isToday && (
<text
x={0}
y={36}
y={showMonth ? 56 : 42}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={TODAY_FONT_SIZE}
@@ -0,0 +1,14 @@
import { Dot } from "recharts";
interface CustomDotProps {
cx?: number;
cy?: number;
color: string;
isFaded: boolean;
}
export const CustomDot = ({ cx, cy, color, isFaded }: CustomDotProps) => {
if (cx === undefined || cy === undefined) return null;
return <Dot cx={cx} cy={cy} r={4} fill={color} opacity={isFaded ? 0.2 : 1} />;
};
+2 -2
View File
@@ -27,7 +27,7 @@ export interface DonutDataPoint {
export interface LineDataPoint {
date: string;
[key: string]: string | number;
[key: string]: string | number | string[];
}
export interface RadarDataPoint {
@@ -60,5 +60,5 @@ export interface TooltipData {
new?: number;
muted?: number;
change?: number;
[key: string]: any;
[key: string]: string | number | boolean | string[] | undefined;
}