Assessor_2.0/src/features/mitre-graph/ObsidianGraphView.tsx

599 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
}