import { useMemo, useRef } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { HeatmapTechnique } from "../../api/heatmap"; import HeatmapCell from "./HeatmapCell"; // MITRE ATT&CK Enterprise tactics in canonical order const TACTIC_ORDER = [ "reconnaissance", "resource-development", "initial-access", "execution", "persistence", "privilege-escalation", "defense-evasion", "credential-access", "discovery", "lateral-movement", "collection", "command-and-control", "exfiltration", "impact", ]; const formatTacticName = (tactic: string): string => tactic .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); interface AdvancedHeatmapProps { techniques: HeatmapTechnique[]; onCellClick: (techniqueId: string) => void; zoom: "compact" | "normal" | "expanded"; } /** Virtualised tactic column — renders only visible rows. */ function TacticColumn({ tactic, techniques, zoom, onCellClick, }: { tactic: string; techniques: HeatmapTechnique[]; zoom: "compact" | "normal" | "expanded"; onCellClick: (techniqueId: string) => void; }) { const parentRef = useRef(null); const rowHeight = zoom === "compact" ? 28 : zoom === "normal" ? 40 : 60; const rowVirtualizer = useVirtualizer({ count: techniques.length, getScrollElement: () => parentRef.current, estimateSize: () => rowHeight, overscan: 10, }); const columnWidth = zoom === "compact" ? "w-32" : zoom === "normal" ? "w-44" : "w-56"; return (
{/* Tactic header */}

{formatTacticName(tactic)}

{techniques.length} techniques

{/* Virtualised list */}
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const tech = techniques[virtualRow.index]; return (
); })}
); } export default function AdvancedHeatmap({ techniques, onCellClick, zoom, }: AdvancedHeatmapProps) { // Group techniques by tactic const groupedByTactic = useMemo(() => { const groups: Record = {}; for (const tech of techniques) { // Normalize tactic names const tacticRaw = tech.tactic || "unknown"; const tacticNormalized = tacticRaw .trim() .toLowerCase() .replace(/\s+/g, "-") .replace(/_/g, "-"); if (!groups[tacticNormalized]) { groups[tacticNormalized] = []; } groups[tacticNormalized].push(tech); } // Sort techniques within each tactic by techniqueID for (const tactic of Object.keys(groups)) { groups[tactic].sort((a, b) => a.techniqueID.localeCompare(b.techniqueID), ); } return groups; }, [techniques]); // Get ordered tactics const orderedTactics = useMemo(() => { const tacticSet = new Set(Object.keys(groupedByTactic)); const ordered = TACTIC_ORDER.filter((t) => tacticSet.has(t)); const remaining = Array.from(tacticSet).filter( (t) => !TACTIC_ORDER.includes(t), ); return [...ordered, ...remaining]; }, [groupedByTactic]); if (techniques.length === 0) { return (

No techniques found for the selected layer

); } return (
{orderedTactics.map((tactic) => ( ))}
); }