599 lines
37 KiB
TypeScript
599 lines
37 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react';
|
||
import { useApp } from '../../app/AppProvider';
|
||
import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
|
||
import { linkResultSheets } from '../../profiles/purple-knight/result-linker';
|
||
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
|
||
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
|
||
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, resultStatusFieldFor } from '../linked-results/result-message';
|
||
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
||
import { SearchFilterBar, SearchTextControl } from '../search-filter/SearchFilterBar';
|
||
import { deriveDynamicFacets } from '../search-filter/dynamic-facets';
|
||
import { ViewBookmarks } from '../view-bookmarks/ViewBookmarks';
|
||
import type { PathSnapshotState, ViewBookmark } from '../view-bookmarks/view-bookmarks';
|
||
import { toolIdForRecord } from '../../importers/status-normalization';
|
||
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
||
import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph';
|
||
import { buildObsidianGraph, type ObsidianGraphNode } from './obsidian-graph';
|
||
import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail';
|
||
import { FILTERED_GRAPH_SELECTION_ID, graphSelectionUnionNodeIds, selectedPathNodeIds } from './category-branch';
|
||
import { graphFacetSelectionForNode, relationNodeIdForFacetFilters, type GraphFacetSelection } from './graph-filter-bridge';
|
||
|
||
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
|
||
const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const;
|
||
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
|
||
// Enthielten zwei violette Toene (#7c3aed, #6d28d9); ersetzt, weil violett als
|
||
// UI-Rolle vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
|
||
// "UI segments and tiles").
|
||
const resultColors = ['#0e7490', '#0f766e', '#b45309', '#be123c', '#1d4ed8', '#b91c6c'];
|
||
|
||
interface PathSnapshotRequest { id: string; path: PathSnapshotState; }
|
||
interface ObsidianGraphViewProps {
|
||
active: boolean;
|
||
onOpenFinding(): void;
|
||
pathSnapshotRequest?: PathSnapshotRequest;
|
||
onPathSnapshotApplied?(): void;
|
||
}
|
||
interface ResultWindowState { recordId: string; toolId?: string; title: string; color: string; zIndex: number; minimized: boolean; }
|
||
interface CameraState { x: number; y: number; scale: number; }
|
||
interface PointerOperation {
|
||
kind: 'pan' | 'node';
|
||
pointerId: number;
|
||
moved: boolean;
|
||
nodeId?: string;
|
||
startX: number;
|
||
startY: number;
|
||
camera: CameraState;
|
||
nodeStart?: { x: number; y: number };
|
||
}
|
||
interface ExtractViewState { camera: CameraState; pinnedNodeIds: string[]; selectedNodeId: string; markedSelectionIds: string[]; }
|
||
|
||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value));
|
||
const graphLabel = (label: string) => label.length > 44 ? `${label.slice(0, 41).trimEnd()}…` : label;
|
||
function fittedCameraFor(graphNodes: Array<{ x: number; y: number }>, width: number, height: number): CameraState {
|
||
if (!graphNodes.length) return { x: 0, y: 0, scale: 1 };
|
||
const xs = graphNodes.map(node => node.x);
|
||
const ys = graphNodes.map(node => node.y);
|
||
const minX = Math.min(...xs) - 170;
|
||
const maxX = Math.max(...xs) + 170;
|
||
const minY = Math.min(...ys) - 90;
|
||
const maxY = Math.max(...ys) + 90;
|
||
const scale = clamp(Math.min(width / (maxX - minX), height / (maxY - minY)) * 0.88, 0.12, 1.8);
|
||
return { x: -((minX + maxX) / 2) * scale, y: -((minY + maxY) / 2) * scale, scale };
|
||
}
|
||
export const ObsidianGraphView = memo(ObsidianGraphContent);
|
||
|
||
function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPathSnapshotApplied }: ObsidianGraphViewProps) {
|
||
const app = useApp();
|
||
const { dataset, activeSheet, activeSheetId, activeProfile, titleColumn, reviews, actionOnly, facetFilters, pathFindingIds, search: workSearch, statusFilter, setFacetFilter, setSelectedRecord, setView, toggleWorkspace, updateReview } = app;
|
||
const [graphData, setGraphData] = useState<MitreGraphData>(activeMitreMatrix);
|
||
useEffect(() => {
|
||
const refreshMatrix = () => setGraphData(activeMitreMatrix());
|
||
globalThis.addEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
|
||
return () => globalThis.removeEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
|
||
}, []);
|
||
const [selectedNodeId, setSelectedNodeId] = useState('');
|
||
const [pinnedNodeIds, setPinnedNodeIds] = useState<string[]>([]);
|
||
const [hoveredNodeId, setHoveredNodeId] = useState('');
|
||
const [showText, setShowText] = useState(true);
|
||
const [camera, setCamera] = useState<CameraState>({ x: 0, y: 0, scale: 1 });
|
||
const [dimensions, setDimensions] = useState({ width: 1200, height: 720 });
|
||
const [nodeOverrides, setNodeOverrides] = useState<Record<string, { x: number; y: number }>>({});
|
||
const [markedSelectionIds, setMarkedSelectionIds] = useState<string[]>([]);
|
||
const [extractedSelectionIds, setExtractedSelectionIds] = useState<string[]>([]);
|
||
const [resultWindows, setResultWindows] = useState<ResultWindowState[]>([]);
|
||
const [queuedPathSnapshot, setQueuedPathSnapshot] = useState<PathSnapshotRequest>();
|
||
const [resetViewRevision, setResetViewRevision] = useState(0);
|
||
const surfaceRef = useRef<SVGSVGElement>(null);
|
||
const pointerOperation = useRef<PointerOperation | null>(null);
|
||
const nextColor = useRef(0);
|
||
const nextZIndex = useRef(2100);
|
||
const lastFittedGraphSignature = useRef('');
|
||
const extractViewBefore = useRef<ExtractViewState | undefined>(undefined);
|
||
const fittedExtractSignature = useRef('');
|
||
const graphFacetSelectionRef = useRef<GraphFacetSelection | undefined>(undefined);
|
||
const handledResetViewRevision = useRef(0);
|
||
const cameraRef = useRef<CameraState>(camera);
|
||
const cameraAnimationFrame = useRef<number | undefined>(undefined);
|
||
cameraRef.current = camera;
|
||
const marked = Object.values(reviews).filter(review => review.include).length;
|
||
|
||
const visibleRecords = useFilteredRecords('deviation');
|
||
const graphFacets = useMemo(
|
||
() => deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews),
|
||
[activeProfile, activeSheet, dataset, reviews]
|
||
);
|
||
const items = useMemo(
|
||
() => buildFindingPathItems(visibleRecords, graphData, titleColumn, activeSheet?.toolId ?? dataset?.profileId),
|
||
[activeSheet?.toolId, dataset?.profileId, graphData, titleColumn, visibleRecords]
|
||
);
|
||
const graph = useMemo(() => buildObsidianGraph(items, graphData), [items, graphData]);
|
||
const graphSignature = useMemo(() => graph.nodes.map(node => node.id).join('|'), [graph.nodes]);
|
||
const nodes = useMemo(() => graph.nodes.map(node => ({ ...node, ...(nodeOverrides[node.id] ?? {}) })), [graph.nodes, nodeOverrides]);
|
||
const markedBranchNodeIds = useMemo(
|
||
() => graphSelectionUnionNodeIds(graph, markedSelectionIds),
|
||
[graph, markedSelectionIds]
|
||
);
|
||
const extractedNodeIds = useMemo(
|
||
() => extractedSelectionIds.length ? new Set(selectedPathNodeIds(graph, extractedSelectionIds[0])) : undefined,
|
||
[extractedSelectionIds, graph]
|
||
);
|
||
const displayedNodes = useMemo(
|
||
() => extractedNodeIds ? nodes.filter(node => extractedNodeIds.has(node.id)) : nodes,
|
||
[extractedNodeIds, nodes]
|
||
);
|
||
const displayedEdges = useMemo(
|
||
() => extractedSelectionIds.length
|
||
? graph.edges.filter(edge => edge.source === extractedSelectionIds[0] || edge.target === extractedSelectionIds[0])
|
||
: graph.edges,
|
||
[extractedSelectionIds, graph.edges]
|
||
);
|
||
const labelOffsets = useMemo(() => {
|
||
const placed: Array<{ left: number; right: number; top: number; bottom: number }> = [];
|
||
const offsets: Record<string, number> = {};
|
||
if (!showText) return offsets;
|
||
const candidates = [0, -22, 22, -44, 44, -66, 66, -88, 88, -110, 110, -132, 132, -154, 154, -176, 176, -198, 198, -220, 220];
|
||
const ordered = displayedNodes.filter(node => {
|
||
const screenX = dimensions.width / 2 + camera.x + node.x * camera.scale;
|
||
const screenY = dimensions.height / 2 + camera.y + node.y * camera.scale;
|
||
return screenX >= 6 && screenX <= dimensions.width - 6 && screenY >= 6 && screenY <= dimensions.height - 6;
|
||
}).sort((left, right) => Number(left.kind === 'finding') - Number(right.kind === 'finding') || left.y - right.y);
|
||
for (const node of ordered) {
|
||
const label = graphLabel(node.label);
|
||
const width = Math.max(48, Math.min(352, label.length * 8.6));
|
||
const anchorX = dimensions.width / 2 + camera.x + node.x * camera.scale;
|
||
const anchorY = dimensions.height / 2 + camera.y + node.y * camera.scale;
|
||
const gap = node.radius * camera.scale + 6;
|
||
const left = node.x > 0 ? anchorX - gap - width : anchorX + gap;
|
||
const right = left + width;
|
||
const offset = candidates.find(candidate => {
|
||
const top = anchorY + candidate - 15;
|
||
const bottom = top + 20;
|
||
return top >= 6 && bottom <= dimensions.height - 6
|
||
&& placed.every(rect => right + 4 <= rect.left || left >= rect.right + 4 || bottom + 3 <= rect.top || top >= rect.bottom + 3);
|
||
}) ?? 0;
|
||
offsets[node.id] = offset;
|
||
placed.push({ left, right, top: anchorY + offset - 15, bottom: anchorY + offset + 5 });
|
||
}
|
||
return offsets;
|
||
}, [camera.scale, camera.x, camera.y, dimensions.height, dimensions.width, displayedNodes, showText]);
|
||
const nodeLookup = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes]);
|
||
const findingLookup = useMemo(() => new Map(items.map(item => [item.id, item])), [items]);
|
||
const selectedNode = nodeLookup.get(selectedNodeId);
|
||
const filteredRelationNodeId = useMemo(
|
||
() => relationNodeIdForFacetFilters(nodes, graphFacets, facetFilters),
|
||
[facetFilters, graphFacets, nodes]
|
||
);
|
||
const defaultStatusFilter = activeProfile.defaults(activeSheet).statusFilter;
|
||
const hasActiveGraphFilter = Boolean(
|
||
workSearch
|
||
|| Object.keys(facetFilters).length
|
||
|| statusFilter !== defaultStatusFilter
|
||
|| actionOnly
|
||
|| pathFindingIds.length
|
||
);
|
||
const selectedRelationNodeId = [...pinnedNodeIds].reverse()
|
||
.find(nodeId => nodeLookup.get(nodeId)?.kind !== 'finding') ?? '';
|
||
const actionBranchNodeId = extractedSelectionIds[0]
|
||
|| selectedRelationNodeId
|
||
|| filteredRelationNodeId
|
||
|| (hasActiveGraphFilter && nodes.length ? FILTERED_GRAPH_SELECTION_ID : '');
|
||
const actionSelectionNodeIds = actionBranchNodeId ? [actionBranchNodeId] : [];
|
||
const actionBranchNode = nodeLookup.get(actionBranchNodeId);
|
||
const actionSelectionIsMarked = actionSelectionNodeIds.length > 0
|
||
&& actionSelectionNodeIds.length === markedSelectionIds.length
|
||
&& actionSelectionNodeIds.every(nodeId => markedSelectionIds.includes(nodeId));
|
||
const hoveredNode = nodeLookup.get(hoveredNodeId);
|
||
const selectedFinding = selectedNode?.itemId ? findingLookup.get(selectedNode.itemId) : undefined;
|
||
const detailNode = selectedNode ?? hoveredNode;
|
||
const detailFinding = detailNode?.itemId ? findingLookup.get(detailNode.itemId) : undefined;
|
||
const detailToolId = detailFinding ? toolIdForRecord(detailFinding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined;
|
||
const detailToolName = toolProfileDefinitions.find(profile => profile.id === detailToolId)?.name;
|
||
const detailStatus = detailFinding ? resultStatusFieldFor(detailFinding.record, detailToolId) : undefined;
|
||
const detailRuleId = detailFinding?.record.fields.find(field => field.label.trim().toLowerCase() === 'source rule id' && field.value.trim());
|
||
const activePathNodeIds = hoveredNodeId ? [hoveredNodeId] : pinnedNodeIds;
|
||
const pathSnapshot = useMemo<PathSnapshotState>(() => {
|
||
const nodeIds = new Set(pinnedNodeIds);
|
||
for (const edge of graph.edges) {
|
||
if (pinnedNodeIds.includes(edge.source) || pinnedNodeIds.includes(edge.target)) {
|
||
nodeIds.add(edge.source);
|
||
nodeIds.add(edge.target);
|
||
}
|
||
}
|
||
return { activeNodeIds: [...pinnedNodeIds], nodeIds: [...nodeIds] };
|
||
}, [graph.edges, pinnedNodeIds]);
|
||
const connectionCount = detailNode ? graph.edges.filter(edge => edge.source === detailNode.id || edge.target === detailNode.id).length : 0;
|
||
const updateGraphFacetSelection = (selection: GraphFacetSelection, selected: boolean) => {
|
||
const previous = graphFacetSelectionRef.current;
|
||
if (!selected) {
|
||
const currentValues = facetFilters[selection.facetId]?.values;
|
||
if (currentValues?.length === 1 && currentValues[0] === selection.value) setFacetFilter(selection.facetId, null);
|
||
graphFacetSelectionRef.current = undefined;
|
||
return;
|
||
}
|
||
if (previous && previous.facetId !== selection.facetId) {
|
||
const previousValues = facetFilters[previous.facetId]?.values;
|
||
if (previousValues?.length === 1 && previousValues[0] === previous.value) setFacetFilter(previous.facetId, null);
|
||
}
|
||
setFacetFilter(selection.facetId, { values: [selection.value] });
|
||
graphFacetSelectionRef.current = selection;
|
||
};
|
||
const togglePinnedNode = (nodeId: string) => {
|
||
const node = nodeLookup.get(nodeId);
|
||
if (!node) return;
|
||
const isCurrentlyPinned = pinnedNodeIds.includes(nodeId);
|
||
const graphFacetSelection = node.kind !== 'finding' && !extractedSelectionIds.length
|
||
? graphFacetSelectionForNode(node, graphFacets)
|
||
: undefined;
|
||
if (graphFacetSelection) {
|
||
updateGraphFacetSelection(graphFacetSelection, !isCurrentlyPinned);
|
||
}
|
||
setPinnedNodeIds(current => {
|
||
const isPinned = current.includes(nodeId);
|
||
const next = isPinned
|
||
? current.filter(id => id !== nodeId)
|
||
: node.kind !== 'finding' && !extractedSelectionIds.length
|
||
? [...current.filter(id => nodeLookup.get(id)?.kind === 'finding'), nodeId]
|
||
: [...current, nodeId];
|
||
if (!isPinned && node.kind !== 'finding' && !extractedSelectionIds.length) setMarkedSelectionIds([]);
|
||
setSelectedNodeId(active => isPinned
|
||
? (active === nodeId ? (next.at(-1) ?? '') : active)
|
||
: nodeId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
const surface = surfaceRef.current;
|
||
if (!active || !surface || typeof ResizeObserver === 'undefined') return;
|
||
const observer = new ResizeObserver(entries => {
|
||
const rect = entries[0]?.contentRect;
|
||
if (rect) setDimensions(current => {
|
||
const width = Math.max(1, rect.width);
|
||
const height = Math.max(1, rect.height);
|
||
return current.width === width && current.height === height ? current : { width, height };
|
||
});
|
||
});
|
||
observer.observe(surface);
|
||
return () => observer.disconnect();
|
||
}, [active]);
|
||
|
||
const stopCameraAnimation = useCallback(() => {
|
||
if (cameraAnimationFrame.current !== undefined) globalThis.cancelAnimationFrame(cameraAnimationFrame.current);
|
||
cameraAnimationFrame.current = undefined;
|
||
}, []);
|
||
const animateCameraTo = useCallback((target: CameraState, duration = 620) => {
|
||
stopCameraAnimation();
|
||
const start = cameraRef.current;
|
||
if (typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
cameraRef.current = target;
|
||
setCamera(target);
|
||
return;
|
||
}
|
||
const startedAt = globalThis.performance.now();
|
||
const tick = (timestamp: number) => {
|
||
const progress = clamp((timestamp - startedAt) / duration, 0, 1);
|
||
const eased = progress * progress * progress * (progress * (progress * 6 - 15) + 10);
|
||
const breath = Math.sin(Math.PI * progress) * 0.032;
|
||
const logarithmicScale = Math.exp(Math.log(start.scale) + (Math.log(target.scale) - Math.log(start.scale)) * eased);
|
||
const next = {
|
||
x: start.x + (target.x - start.x) * eased,
|
||
y: start.y + (target.y - start.y) * eased,
|
||
scale: logarithmicScale * (1 - breath)
|
||
};
|
||
cameraRef.current = next;
|
||
setCamera(next);
|
||
if (progress < 1) cameraAnimationFrame.current = globalThis.requestAnimationFrame(tick);
|
||
else cameraAnimationFrame.current = undefined;
|
||
};
|
||
cameraAnimationFrame.current = globalThis.requestAnimationFrame(tick);
|
||
}, [stopCameraAnimation]);
|
||
const fitGraphTo = useCallback((width: number, height: number) => {
|
||
setCamera(fittedCameraFor(displayedNodes, width, height));
|
||
}, [displayedNodes]);
|
||
const fitGraph = useCallback(
|
||
() => animateCameraTo(fittedCameraFor(displayedNodes, dimensions.width, dimensions.height), 520),
|
||
[animateCameraTo, dimensions.height, dimensions.width, displayedNodes]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setNodeOverrides({});
|
||
setPinnedNodeIds(current => current.filter(id => graph.nodes.some(node => node.id === id)));
|
||
setSelectedNodeId(current => graph.nodes.some(node => node.id === current) ? current : '');
|
||
setMarkedSelectionIds(current => current.filter(id => id === FILTERED_GRAPH_SELECTION_ID || graph.nodes.some(node => node.id === id)));
|
||
setExtractedSelectionIds(current => current.filter(id => graph.nodes.some(node => node.id === id)));
|
||
}, [graphSignature]);
|
||
useEffect(() => {
|
||
if (active && pathSnapshotRequest) setQueuedPathSnapshot(pathSnapshotRequest);
|
||
}, [active, pathSnapshotRequest]);
|
||
useEffect(() => {
|
||
if (!active || !queuedPathSnapshot) return;
|
||
const availableNodeIds = new Set(graph.nodes.map(node => node.id));
|
||
const restored = queuedPathSnapshot.path.activeNodeIds.filter(id => availableNodeIds.has(id));
|
||
setPinnedNodeIds(restored);
|
||
setSelectedNodeId(restored.at(-1) ?? '');
|
||
setQueuedPathSnapshot(undefined);
|
||
onPathSnapshotApplied?.();
|
||
}, [active, graph.nodes, graphSignature, onPathSnapshotApplied, queuedPathSnapshot]);
|
||
useEffect(() => {
|
||
if (!active || lastFittedGraphSignature.current === graphSignature) return;
|
||
const frame = globalThis.requestAnimationFrame(() => {
|
||
const rect = surfaceRef.current?.getBoundingClientRect();
|
||
if (!rect || rect.width <= 1 || rect.height <= 1) return;
|
||
lastFittedGraphSignature.current = graphSignature;
|
||
setDimensions({ width: rect.width, height: rect.height });
|
||
fitGraphTo(rect.width, rect.height);
|
||
});
|
||
return () => globalThis.cancelAnimationFrame(frame);
|
||
}, [active, fitGraphTo, graphSignature]);
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
const extractSignature = extractedSelectionIds.join('|');
|
||
if (!extractSignature) {
|
||
fittedExtractSignature.current = '';
|
||
return;
|
||
}
|
||
if (fittedExtractSignature.current === extractSignature) return;
|
||
fittedExtractSignature.current = extractSignature;
|
||
const frame = globalThis.requestAnimationFrame(() => animateCameraTo(fittedCameraFor(displayedNodes, dimensions.width, dimensions.height), 520));
|
||
return () => globalThis.cancelAnimationFrame(frame);
|
||
}, [active, animateCameraTo, dimensions.height, dimensions.width, displayedNodes, extractedSelectionIds]);
|
||
useEffect(() => {
|
||
if (!active || !resetViewRevision || handledResetViewRevision.current === resetViewRevision) return;
|
||
handledResetViewRevision.current = resetViewRevision;
|
||
const frame = globalThis.requestAnimationFrame(() => animateCameraTo(fittedCameraFor(displayedNodes, dimensions.width, dimensions.height)));
|
||
return () => globalThis.cancelAnimationFrame(frame);
|
||
}, [active, animateCameraTo, dimensions.height, dimensions.width, displayedNodes, resetViewRevision]);
|
||
useEffect(() => { if (!active) stopCameraAnimation(); }, [active, stopCameraAnimation]);
|
||
useEffect(() => stopCameraAnimation, [stopCameraAnimation]);
|
||
const linksFor = (item: FindingPathItem) => dataset && activeSheet ? [
|
||
...linkResultSheets(item.record, dataset.sheets, activeSheetId),
|
||
...linkPingCastleSectionSheets(item.record, dataset.sheets),
|
||
...(resultMessageFor(item.record, activeSheet.toolId ?? dataset.profileId) ? [] : inlineResultTableSheets(item.record))
|
||
] : [];
|
||
const sectionsFor = (item: FindingPathItem) => resultSectionsFor(item.record, activeSheet?.toolId ?? dataset?.profileId);
|
||
const resultWindowFor = (recordId: string) => resultWindows.find(item => item.recordId === recordId);
|
||
const activateWindow = (recordId: string) => setResultWindows(current => current.map(item => item.recordId === recordId ? { ...item, zIndex: ++nextZIndex.current } : item));
|
||
const openResults = (item: FindingPathItem) => {
|
||
if (resultWindowFor(item.id)) return setResultWindows(current => current.filter(candidate => candidate.recordId !== item.id));
|
||
const sheets = linksFor(item);
|
||
if (!sheets.length && !sectionsFor(item).length) return;
|
||
const total = sheets.reduce((sum, sheet) => sum + sheet.analysis.records.length, 0);
|
||
setResultWindows(current => [...current, {
|
||
recordId: item.id,
|
||
toolId: toolIdForRecord(item.record, activeSheet?.toolId ?? dataset?.profileId),
|
||
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) – ${item.title}` : `Result – ${item.title}`,
|
||
color: resultColors[nextColor.current++ % resultColors.length],
|
||
zIndex: ++nextZIndex.current,
|
||
minimized: false
|
||
}]);
|
||
};
|
||
const openFinding = (item: FindingPathItem) => {
|
||
setSelectedRecord(item.id);
|
||
setView('records');
|
||
onOpenFinding();
|
||
};
|
||
|
||
const zoomBy = (factor: number) => {
|
||
const current = cameraRef.current;
|
||
animateCameraTo({ ...current, scale: clamp(current.scale * factor, 0.08, 4) }, 280);
|
||
};
|
||
const handleWheel = (event: ReactWheelEvent<SVGSVGElement>) => {
|
||
event.preventDefault();
|
||
stopCameraAnimation();
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
const nextScale = clamp(camera.scale * Math.exp(-event.deltaY * 0.0012), 0.08, 4);
|
||
const pointerX = event.clientX - rect.left - dimensions.width / 2;
|
||
const pointerY = event.clientY - rect.top - dimensions.height / 2;
|
||
const graphX = (pointerX - camera.x) / camera.scale;
|
||
const graphY = (pointerY - camera.y) / camera.scale;
|
||
setCamera({ x: pointerX - graphX * nextScale, y: pointerY - graphY * nextScale, scale: nextScale });
|
||
};
|
||
const startPan = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||
if (event.button !== 0) return;
|
||
stopCameraAnimation();
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
pointerOperation.current = { kind: 'pan', pointerId: event.pointerId, moved: false, startX: event.clientX, startY: event.clientY, camera };
|
||
};
|
||
const startNodeDrag = (event: ReactPointerEvent<SVGGElement>, node: ObsidianGraphNode) => {
|
||
if (event.button !== 0) return;
|
||
stopCameraAnimation();
|
||
event.stopPropagation();
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
pointerOperation.current = { kind: 'node', pointerId: event.pointerId, moved: false, nodeId: node.id, startX: event.clientX, startY: event.clientY, camera, nodeStart: { x: node.x, y: node.y } };
|
||
};
|
||
const movePointer = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||
const operation = pointerOperation.current;
|
||
if (!operation || operation.pointerId !== event.pointerId) return;
|
||
const dx = event.clientX - operation.startX;
|
||
const dy = event.clientY - operation.startY;
|
||
if (Math.hypot(dx, dy) > 3) operation.moved = true;
|
||
if (operation.kind === 'pan') setCamera({ ...operation.camera, x: operation.camera.x + dx, y: operation.camera.y + dy });
|
||
else if (operation.nodeId && operation.nodeStart) {
|
||
const graphDx = dx / operation.camera.scale;
|
||
const graphDy = dy / operation.camera.scale;
|
||
setNodeOverrides(current => ({ ...current, [operation.nodeId!]: {
|
||
x: operation.nodeStart!.x + graphDx,
|
||
y: operation.nodeStart!.y + graphDy
|
||
} }));
|
||
}
|
||
};
|
||
const endPointer = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||
const operation = pointerOperation.current;
|
||
if (!operation || operation.pointerId !== event.pointerId) return;
|
||
if (operation.kind === 'node' && operation.nodeId && !operation.moved) {
|
||
togglePinnedNode(operation.nodeId);
|
||
}
|
||
pointerOperation.current = null;
|
||
};
|
||
const toggleMarkedSelection = () => {
|
||
if (!actionSelectionNodeIds.length) return;
|
||
setMarkedSelectionIds(actionSelectionIsMarked ? [] : [...actionSelectionNodeIds]);
|
||
};
|
||
const extractSelection = () => {
|
||
if (!selectedNodeId) return;
|
||
const pathNodeIds = selectedPathNodeIds(graph, selectedNodeId);
|
||
if (!pathNodeIds.length) return;
|
||
extractViewBefore.current = { camera: cameraRef.current, pinnedNodeIds, selectedNodeId, markedSelectionIds };
|
||
setPinnedNodeIds([selectedNodeId]);
|
||
setMarkedSelectionIds([]);
|
||
setExtractedSelectionIds([selectedNodeId]);
|
||
};
|
||
const exitSelectionExtract = () => {
|
||
setExtractedSelectionIds([]);
|
||
const savedView = extractViewBefore.current;
|
||
extractViewBefore.current = undefined;
|
||
if (savedView) {
|
||
animateCameraTo(savedView.camera, 520);
|
||
setPinnedNodeIds(savedView.pinnedNodeIds);
|
||
setSelectedNodeId(savedView.selectedNodeId);
|
||
setMarkedSelectionIds(savedView.markedSelectionIds);
|
||
}
|
||
};
|
||
const resetGraphView = () => {
|
||
setPinnedNodeIds([]);
|
||
setSelectedNodeId('');
|
||
setHoveredNodeId('');
|
||
setMarkedSelectionIds([]);
|
||
setExtractedSelectionIds([]);
|
||
setNodeOverrides({});
|
||
setShowText(true);
|
||
extractViewBefore.current = undefined;
|
||
fittedExtractSignature.current = '';
|
||
setResetViewRevision(current => current + 1);
|
||
};
|
||
|
||
return <div className="obsidian-graph" data-testid="obsidian-graph">
|
||
<div className="obsidian-graph-layout">
|
||
<aside className="obsidian-graph-filter-panel" aria-label="Shared Work and Graph filters">
|
||
<header><strong>Filters</strong><span>{items.length.toLocaleString('en-US')} findings</span></header>
|
||
<div className="obsidian-graph-filter-scroll">
|
||
<section className="obsidian-graph-filter-section"><span className="obsidian-panel-label">Search</span><SearchTextControl /></section>
|
||
<SearchFilterBar hiddenFacetLabels={hiddenPathfinderFacetLabels} />
|
||
<section className="obsidian-graph-workspace-tools" aria-label="Workspace and snapshots">
|
||
<label className="obsidian-graph-workspace-check">
|
||
<input type="checkbox" checked={actionOnly} disabled={!marked} onChange={() => toggleWorkspace()} />
|
||
<span>My Workspace</span>
|
||
</label>
|
||
<ViewBookmarks
|
||
mode="path"
|
||
path={pathSnapshot}
|
||
onApplyPathSnapshot={(bookmark: ViewBookmark) => bookmark.path && setQueuedPathSnapshot({ id: bookmark.id, path: bookmark.path })}
|
||
/>
|
||
</section>
|
||
</div>
|
||
</aside>
|
||
<section className="obsidian-graph-stage" aria-label="Graph workspace">
|
||
<div className="obsidian-graph-toolbar" aria-label="Graph controls">
|
||
<button className={`btn${showText ? ' active' : ''}`} type="button" aria-pressed={showText} title="Show or hide node text" onClick={() => setShowText(current => !current)}>Text</button>
|
||
<button className="btn obsidian-graph-reset-view" type="button" title="Clear all marked nodes and restore the default graph view" onClick={resetGraphView}><span aria-hidden="true">○</span>Reset view</button>
|
||
<span className="obsidian-graph-count">{displayedNodes.length.toLocaleString('en-US')} nodes · {displayedEdges.length.toLocaleString('en-US')} links</span>
|
||
<div className="obsidian-graph-branch-actions" role="group" aria-label="Selected graph branch actions">
|
||
<span>{actionBranchNode ? graphNodeKindLabels[actionBranchNode.kind] : actionBranchNodeId === FILTERED_GRAPH_SELECTION_ID ? 'Filters' : 'Selection'}</span>
|
||
<button
|
||
className={`btn${actionSelectionIsMarked ? ' active' : ''}`}
|
||
type="button"
|
||
disabled={!actionSelectionNodeIds.length}
|
||
aria-pressed={actionSelectionIsMarked}
|
||
onClick={toggleMarkedSelection}
|
||
>{actionSelectionIsMarked ? 'Unmark all' : 'Mark all'}</button>
|
||
<button
|
||
className={`btn${extractedSelectionIds.length ? ' active' : ''}`}
|
||
type="button"
|
||
disabled={!extractedSelectionIds.length && !selectedNodeId}
|
||
aria-pressed={Boolean(extractedSelectionIds.length)}
|
||
onClick={extractedSelectionIds.length ? exitSelectionExtract : extractSelection}
|
||
>{extractedSelectionIds.length ? 'Exit Extract Path' : 'Extract Path'}</button>
|
||
</div>
|
||
</div>
|
||
<div className="obsidian-graph-zoom" aria-label="Zoom controls">
|
||
<button type="button" onClick={() => zoomBy(1.2)} title="Zoom in" aria-label="Zoom in">+</button>
|
||
<button type="button" onClick={fitGraph} title="Fit graph" aria-label="Fit graph">◎</button>
|
||
<button type="button" onClick={() => zoomBy(1 / 1.2)} title="Zoom out" aria-label="Zoom out">−</button>
|
||
</div>
|
||
<svg ref={surfaceRef} className="obsidian-graph-surface" role="img" aria-label="Interactive network of deviation findings, categories, targets, MITRE techniques, tactics and D3FEND mappings" onWheel={handleWheel} onPointerDown={startPan} onPointerMove={movePointer} onPointerUp={endPointer} onPointerCancel={endPointer}>
|
||
<g transform={`translate(${dimensions.width / 2 + camera.x} ${dimensions.height / 2 + camera.y}) scale(${camera.scale})`}>
|
||
<g className="obsidian-graph-edges" aria-hidden="true">{displayedEdges.map(edge => {
|
||
const source = nodeLookup.get(edge.source);
|
||
const target = nodeLookup.get(edge.target);
|
||
const isDirectPath = activePathNodeIds.includes(edge.source) || activePathNodeIds.includes(edge.target);
|
||
const isMarkedBranch = markedBranchNodeIds.includes(edge.source) && markedBranchNodeIds.includes(edge.target);
|
||
const isMuted = markedBranchNodeIds.length > 0 && !isMarkedBranch && !isDirectPath;
|
||
const isSelectionMuted = !markedBranchNodeIds.length && pinnedNodeIds.length > 0
|
||
&& !pinnedNodeIds.includes(edge.source) && !pinnedNodeIds.includes(edge.target);
|
||
return source && target ? <line className={`obsidian-edge-${edge.kind}${isDirectPath ? ' is-direct-path' : ''}${isMarkedBranch ? ' is-branch-path' : ''}${isMuted ? ' is-branch-muted' : ''}${isSelectionMuted ? ' is-selection-muted' : ''}`} key={edge.id} x1={source.x} y1={source.y} x2={target.x} y2={target.y} /> : null;
|
||
})}</g>
|
||
<g className="obsidian-graph-nodes">{displayedNodes.map(node => {
|
||
const isSelected = pinnedNodeIds.includes(node.id);
|
||
const isMarkedBranch = markedBranchNodeIds.includes(node.id);
|
||
const isMuted = markedBranchNodeIds.length > 0 && !isMarkedBranch && !isSelected;
|
||
const isSelectionMuted = !markedBranchNodeIds.length && pinnedNodeIds.length > 0 && !isSelected;
|
||
const labelOnLeft = node.x > 0;
|
||
const labelOffsetY = labelOffsets[node.id] ?? 0;
|
||
const labelText = graphLabel(node.label);
|
||
const labelNodeScreenX = dimensions.width / 2 + camera.x + node.x * camera.scale;
|
||
const labelNodeScreenY = dimensions.height / 2 + camera.y + node.y * camera.scale;
|
||
const labelWidth = Math.max(32, labelText.length * 7.2);
|
||
const labelAnchorX = labelNodeScreenX + (labelOnLeft ? -1 : 1) * (node.radius * camera.scale + 6);
|
||
const labelLeft = labelOnLeft ? labelAnchorX - labelWidth : labelAnchorX;
|
||
const labelRight = labelOnLeft ? labelAnchorX : labelAnchorX + labelWidth;
|
||
const labelTop = labelNodeScreenY + labelOffsetY - 15;
|
||
const labelBottom = labelNodeScreenY + labelOffsetY + 5;
|
||
const viewportInset = 6;
|
||
const nodeIsInViewport = labelNodeScreenX >= viewportInset && labelNodeScreenX <= dimensions.width - viewportInset
|
||
&& labelNodeScreenY >= viewportInset && labelNodeScreenY <= dimensions.height - viewportInset;
|
||
const labelIsFullyVisible = labelLeft >= viewportInset && labelRight <= dimensions.width - viewportInset
|
||
&& labelTop >= viewportInset && labelBottom <= dimensions.height - viewportInset;
|
||
const showInlineLabel = showText && nodeIsInViewport && labelIsFullyVisible;
|
||
const finding = node.itemId ? findingLookup.get(node.itemId) : undefined;
|
||
return <g key={node.id} className={`obsidian-node obsidian-node-${node.kind}${node.severity ? ` severity-${node.severity}` : ''}${isSelected ? ' is-selected' : ''}${isMarkedBranch ? ' is-branch-marked' : ''}${isMuted ? ' is-branch-muted' : ''}${isSelectionMuted ? ' is-selection-muted' : ''}`} transform={`translate(${node.x} ${node.y})`} tabIndex={0} role="button" aria-pressed={isSelected} aria-label={node.label} onPointerDown={event => startNodeDrag(event, node)} onPointerEnter={() => setHoveredNodeId(node.id)} onPointerLeave={() => setHoveredNodeId(current => current === node.id ? '' : current)} onDoubleClick={() => finding && openFinding(finding)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') togglePinnedNode(node.id); }}>
|
||
{isMarkedBranch && !isSelected && <circle className="obsidian-node-branch-ring" r={node.radius + 4} />}
|
||
{isSelected && <circle className="obsidian-node-selection-ring" r={node.radius + 5} />}
|
||
{node.kind !== 'finding' && <circle className="obsidian-node-role-ring" r={node.radius + 4} />}
|
||
<circle className="obsidian-node-core" r={node.radius} />
|
||
{showInlineLabel && <g transform={`scale(${1 / camera.scale})`}><text className="obsidian-node-inline-label" x={(labelOnLeft ? -1 : 1) * (node.radius * camera.scale + 6)} y={labelOffsetY + 4} textAnchor={labelOnLeft ? 'end' : 'start'}>{labelText}</text></g>}
|
||
</g>;
|
||
})}</g>
|
||
</g>
|
||
</svg>
|
||
{!nodes.length && <div className="obsidian-graph-empty"><strong>No matching graph nodes.</strong><span>Change the shared filters or open a scan with MITRE mappings.</span></div>}
|
||
</section>
|
||
<aside className={`obsidian-graph-inspector${selectedNode ? ' is-pinned' : ''}`} aria-live="polite">
|
||
<header><strong>Details</strong>{selectedNode && <button className="obsidian-inspector-close" type="button" onClick={() => togglePinnedNode(selectedNode.id)} aria-label="Close details">×</button>}</header>
|
||
{detailNode ? <div className="obsidian-graph-inspector-content">
|
||
<div className={`obsidian-graph-inspector-title${detailFinding ? ` severity-${detailFinding.severity}` : ''}`}>
|
||
{detailFinding && <span className="data-tile-hex" aria-hidden="true" />}
|
||
<div><small>{graphNodeKindLabels[detailNode.kind]}</small><strong>{detailNode.label}</strong></div>
|
||
</div>
|
||
<span>{selectedNode ? `${connectionCount} direct connections` : 'Click the node to keep these details open'}</span>
|
||
{detailFinding && <>
|
||
<dl>
|
||
{detailStatus && <div><dt>Source status</dt><dd><span className="outcome-mark" data-outcome={resultOutcomeFor(detailFinding.record, detailToolId)}>{detailStatus.value}</span></dd></div>}
|
||
{detailToolName && <div><dt>Tool</dt><dd>{detailToolName}</dd></div>}
|
||
{detailRuleId && <div><dt>Source Rule ID</dt><dd>{detailRuleId.value}</dd></div>}
|
||
<div><dt>Severity</dt><dd>{severityLabels[detailFinding.severity]}</dd></div>
|
||
{detailFinding.categories.length > 0 && <div><dt>Category</dt><dd>{detailFinding.categories.join(', ')}</dd></div>}
|
||
{detailFinding.techniqueIds.length > 0 && <div><dt>MITRE ATT&CK</dt><dd>{detailFinding.techniqueIds.join(', ')}</dd></div>}
|
||
<div><dt>Mapping</dt><dd>{detailFinding.mappingOrigin}</dd></div>
|
||
<div><dt>Graph risk</dt><dd>{Math.round(detailFinding.risk)}</dd></div>
|
||
</dl>
|
||
</>}
|
||
{selectedFinding && <>
|
||
<div className="obsidian-graph-inspector-actions">
|
||
<button className="btn primary" type="button" onClick={() => openFinding(selectedFinding)}>Open in Work</button>
|
||
<button className="btn" type="button" disabled={!linksFor(selectedFinding).length && !sectionsFor(selectedFinding).length} onClick={() => openResults(selectedFinding)}>Results</button>
|
||
<button className={`btn${reviews[selectedFinding.id]?.include ? ' active' : ''}`} type="button" onClick={() => updateReview(selectedFinding.id, { include: !reviews[selectedFinding.id]?.include })}>{reviews[selectedFinding.id]?.include ? 'In Workspace' : 'Add to Workspace'}</button>
|
||
</div>
|
||
</>}
|
||
</div> : <div className="obsidian-graph-inspector-empty"><strong>Select a node</strong><span>Hover for a preview or click to keep details and actions visible.</span></div>}
|
||
</aside>
|
||
</div>
|
||
{resultWindows.map((item, index) => {
|
||
if (item.minimized && resultWindows.some(window => !window.minimized)) return null;
|
||
const finding = items.find(candidate => candidate.id === item.recordId);
|
||
const sheets = finding ? linksFor(finding) : [];
|
||
const sections = finding ? sectionsFor(finding) : [];
|
||
return <FloatingResultsWindow key={item.recordId} id={`graph-results-${item.recordId}`} title={item.title} sheets={sheets} toolId={item.toolId} outcome={finding ? resultOutcomeFor(finding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined} sections={sections} color={item.color} zIndex={item.zIndex} offset={index * 24} search={workSearch} onActivate={() => activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => setResultWindows(current => current.map(candidate => candidate.recordId === item.recordId ? { ...candidate, minimized: !candidate.minimized } : candidate))} />;
|
||
})}
|
||
</div>;
|
||
}
|