Merge pull request #50 from berkcangumusisik/feat/dashboard-export-filtering

Add filters, export, path finder & tooltips
This commit is contained in:
Yuxiang Lin
2026-03-29 11:18:44 +08:00
committed by GitHub
Unverified
12 changed files with 1307 additions and 37 deletions
@@ -8,6 +8,9 @@ import SearchBar from "./components/SearchBar";
import NodeInfo from "./components/NodeInfo";
import LayerLegend from "./components/LayerLegend";
import DiffToggle from "./components/DiffToggle";
import FilterPanel from "./components/FilterPanel";
import ExportMenu from "./components/ExportMenu";
import PathFinderModal from "./components/PathFinderModal";
import LearnPanel from "./components/LearnPanel";
import PersonaSelector from "./components/PersonaSelector";
import ProjectOverview from "./components/ProjectOverview";
@@ -72,6 +75,8 @@ function Dashboard({ accessToken }: { accessToken: string }) {
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter);
const [loadError, setLoadError] = useState<string | null>(null);
@@ -102,11 +107,17 @@ function Dashboard({ accessToken }: { accessToken: string }) {
// Navigation
{
key: "Escape",
description: "Close panels / go back to overview",
description: "Close panels and modals / go back to overview",
action: () => {
// Read from store at invocation time to avoid stale closures
const state = useDashboardStore.getState();
if (state.codeViewerOpen) {
if (state.pathFinderOpen) {
state.togglePathFinder();
} else if (state.filterPanelOpen) {
state.toggleFilterPanel();
} else if (state.exportMenuOpen) {
state.toggleExportMenu();
} else if (state.codeViewerOpen) {
state.closeCodeViewer();
} else if (state.selectedNodeId) {
state.selectNode(null);
@@ -164,6 +175,33 @@ function Dashboard({ accessToken }: { accessToken: string }) {
},
category: "View",
},
{
key: "f",
description: "Toggle filter panel",
action: () => {
const state = useDashboardStore.getState();
state.toggleFilterPanel();
},
category: "View",
},
{
key: "e",
description: "Toggle export menu",
action: () => {
const state = useDashboardStore.getState();
state.toggleExportMenu();
},
category: "View",
},
{
key: "p",
description: "Open path finder",
action: () => {
const state = useDashboardStore.getState();
state.togglePathFinder();
},
category: "View",
},
],
[]
);
@@ -242,46 +280,77 @@ function Dashboard({ accessToken }: { accessToken: string }) {
<ThemeProvider metaTheme={metaTheme}>
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Header */}
<header className="flex items-center justify-between px-5 py-3 bg-surface border-b border-border-subtle shrink-0">
<div className="flex items-center gap-5">
<header className="flex items-center px-5 py-3 bg-surface border-b border-border-subtle shrink-0 gap-4">
{/* Left — fixed */}
<div className="flex items-center gap-5 shrink-0">
<h1 className="font-serif text-lg text-text-primary tracking-wide">
{graph?.project.name ?? "Understand Anything"}
</h1>
<div className="w-px h-5 bg-border-subtle" />
<PersonaSelector />
</div>
<div className="flex items-center gap-4">
<DiffToggle />
<div className="flex items-center gap-1">
{([
{ key: "code", label: "Code", color: "var(--color-node-file)" },
{ key: "config", label: "Config", color: "var(--color-node-config)" },
{ key: "docs", label: "Docs", color: "var(--color-node-document)" },
{ key: "infra", label: "Infra", color: "var(--color-node-service)" },
{ key: "data", label: "Data", color: "var(--color-node-table)" },
] as const).map((cat) => (
<button
key={cat.key}
onClick={() => toggleNodeTypeFilter(cat.key)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 ${
nodeTypeFilters[cat.key] !== false
? "border-border-medium bg-elevated text-text-secondary hover:text-text-primary"
: "border-transparent bg-transparent text-text-muted/40 line-through hover:text-text-muted"
}`}
title={`${nodeTypeFilters[cat.key] !== false ? "Hide" : "Show"} ${cat.label} nodes`}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: cat.color,
opacity: nodeTypeFilters[cat.key] !== false ? 1 : 0.3,
}}
/>
{cat.label}
</button>
))}
{/* Middle — scrollable legends */}
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-4 w-max">
<DiffToggle />
<div className="flex items-center gap-1">
{([
{ key: "code", label: "Code", color: "var(--color-node-file)" },
{ key: "config", label: "Config", color: "var(--color-node-config)" },
{ key: "docs", label: "Docs", color: "var(--color-node-document)" },
{ key: "infra", label: "Infra", color: "var(--color-node-service)" },
{ key: "data", label: "Data", color: "var(--color-node-table)" },
] as const).map((cat) => (
<button
key={cat.key}
onClick={() => toggleNodeTypeFilter(cat.key)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 whitespace-nowrap ${
nodeTypeFilters[cat.key] !== false
? "border-border-medium bg-elevated text-text-secondary hover:text-text-primary"
: "border-transparent bg-transparent text-text-muted/40 line-through hover:text-text-muted"
}`}
title={`${nodeTypeFilters[cat.key] !== false ? "Hide" : "Show"} ${cat.label} nodes`}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: cat.color,
opacity: nodeTypeFilters[cat.key] !== false ? 1 : 0.3,
}}
/>
{cat.label}
</button>
))}
</div>
<LayerLegend />
</div>
<LayerLegend />
</div>
{/* Right — fixed actions */}
<div className="flex items-center gap-4 shrink-0">
<FilterPanel />
<ExportMenu />
<button
onClick={togglePathFinder}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
title="Find path between nodes (P)"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
Path
</button>
<ThemePicker />
<button
onClick={() => setShowKeyboardHelp(true)}
@@ -362,6 +431,12 @@ function Dashboard({ accessToken }: { accessToken: string }) {
onClose={() => setShowKeyboardHelp(false)}
/>
)}
{/* Path Finder Modal */}
<PathFinderModal
isOpen={pathFinderOpen}
onClose={togglePathFinder}
/>
</div>
</ThemeProvider>
);
@@ -57,6 +57,9 @@ export interface CustomNodeData extends Record<string, unknown> {
isNeighbor: boolean;
isSelectionFaded: boolean;
onNodeClick?: (nodeId: string) => void;
incomingCount?: number;
outgoingCount?: number;
tags?: string[];
}
export type CustomFlowNode = Node<CustomNodeData, "custom">;
@@ -0,0 +1,274 @@
import { useEffect, useRef } from "react";
import { useDashboardStore } from "../store";
import type { KnowledgeGraph } from "@understand-anything/core/types";
import { filterNodes, filterEdges } from "../utils/filters";
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export default function ExportMenu() {
const graph = useDashboardStore((s) => s.graph);
const filters = useDashboardStore((s) => s.filters);
const exportMenuOpen = useDashboardStore((s) => s.exportMenuOpen);
const toggleExportMenu = useDashboardStore((s) => s.toggleExportMenu);
const reactFlowInstance = useDashboardStore((s) => s.reactFlowInstance);
const persona = useDashboardStore((s) => s.persona);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (exportMenuOpen) {
toggleExportMenu();
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [exportMenuOpen, toggleExportMenu]);
const buildCleanSvg = () => {
if (!reactFlowInstance) return null;
const nodes = reactFlowInstance.getNodes();
const edges = reactFlowInstance.getEdges();
if (nodes.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach((node) => {
const x = node.position.x;
const y = node.position.y;
const width = (node.width ?? 200);
const height = (node.height ?? 80);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
const padding = 40;
const width = maxX - minX + padding * 2;
const height = maxY - minY + padding * 2;
const offsetX = -minX + padding;
const offsetY = -minY + padding;
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
svgContent += `<rect width="100%" height="100%" fill="#0a0a0a"/>`;
edges.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const sx = sourceNode.position.x + (sourceNode.width ?? 200) / 2 + offsetX;
const sy = sourceNode.position.y + (sourceNode.height ?? 80) / 2 + offsetY;
const tx = targetNode.position.x + (targetNode.width ?? 200) / 2 + offsetX;
const ty = targetNode.position.y + (targetNode.height ?? 80) / 2 + offsetY;
svgContent += `<line x1="${sx}" y1="${sy}" x2="${tx}" y2="${ty}" stroke="rgba(212,165,116,0.3)" stroke-width="1.5"/>`;
});
nodes.forEach((node) => {
if (node.type === "group") return;
const x = node.position.x + offsetX;
const y = node.position.y + offsetY;
const w = node.width ?? 200;
const h = node.height ?? 80;
svgContent += `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#1a1a1a" stroke="rgba(212,165,116,0.2)" stroke-width="1"/>`;
svgContent += `<text x="${x + w / 2}" y="${y + h / 2}" fill="#d4a574" text-anchor="middle" dominant-baseline="middle" font-size="12">${escapeXml(String(node.data.label ?? node.id))}</text>`;
});
svgContent += `</svg>`;
return { svgContent, width, height };
};
const exportPNG = async () => {
if (!reactFlowInstance) {
alert("Graph not ready for export");
return;
}
try {
const result = buildCleanSvg();
if (!result) {
alert("No nodes to export");
return;
}
const { svgContent, width, height } = result;
const svgBlob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onerror = () => {
URL.revokeObjectURL(url);
alert("Failed to export PNG: could not render graph as image.");
};
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
alert("Failed to create canvas context");
return;
}
ctx.drawImage(img, 0, 0, width * 2, height * 2);
URL.revokeObjectURL(url);
const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`;
canvas.toBlob((blob) => {
if (blob) {
downloadBlob(blob, filename);
toggleExportMenu();
} else {
alert("Failed to export PNG: image encoding failed.");
}
}, "image/png");
};
img.src = url;
} catch (error) {
console.error("PNG export failed:", error);
alert(`Failed to export PNG: ${error instanceof Error ? error.message : String(error)}`);
}
};
const exportSVG = () => {
if (!reactFlowInstance) {
alert("Graph not ready for export");
return;
}
try {
const result = buildCleanSvg();
if (!result) {
alert("No nodes to export");
return;
}
const blob = new Blob([result.svgContent], { type: "image/svg+xml;charset=utf-8" });
const filename = `${graph?.project.name ?? "knowledge-graph"}-export.svg`;
downloadBlob(blob, filename);
toggleExportMenu();
} catch (error) {
console.error("SVG export failed:", error);
alert(`Failed to export SVG: ${error instanceof Error ? error.message : String(error)}`);
}
};
const exportJSON = () => {
if (!graph) {
alert("No graph loaded");
return;
}
try {
// Apply persona and filters to create filtered graph
let filteredGraphNodes = persona === "non-technical"
? graph.nodes.filter((n) => n.type === "concept" || n.type === "module" || n.type === "file")
: graph.nodes;
filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters);
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
let filteredGraphEdges = graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target)
);
filteredGraphEdges = filterEdges(filteredGraphEdges, filteredNodeIds, filters);
const filteredGraph: KnowledgeGraph = {
...graph,
nodes: filteredGraphNodes,
edges: filteredGraphEdges,
};
const json = JSON.stringify(filteredGraph, null, 2);
const blob = new Blob([json], { type: "application/json" });
const filename = `${graph.project.name ?? "knowledge-graph"}-export.json`;
downloadBlob(blob, filename);
toggleExportMenu();
} catch (error) {
console.error("JSON export failed:", error);
alert(`Failed to export JSON: ${error instanceof Error ? error.message : String(error)}`);
}
};
return (
<div ref={containerRef} className="relative">
<button
onClick={toggleExportMenu}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
title="Export graph (E)"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Export
</button>
{exportMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-52 glass rounded-lg shadow-xl overflow-hidden animate-fade-slide-in z-50">
<div className="p-2">
<button
onClick={exportPNG}
disabled={!reactFlowInstance}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-text-primary hover:bg-elevated transition-colors rounded-lg text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Export as PNG</span>
</button>
<button
onClick={exportSVG}
disabled={!reactFlowInstance}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-text-primary hover:bg-elevated transition-colors rounded-lg text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
<span>Export as SVG</span>
</button>
<button
onClick={exportJSON}
disabled={!graph}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-text-primary hover:bg-elevated transition-colors rounded-lg text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span>Export as JSON</span>
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,217 @@
import { useEffect, useRef } from "react";
import { useDashboardStore, ALL_NODE_TYPES, ALL_COMPLEXITIES, ALL_EDGE_CATEGORIES } from "../store";
import type { NodeType, Complexity, EdgeCategory } from "../store";
export default function FilterPanel() {
const graph = useDashboardStore((s) => s.graph);
const filters = useDashboardStore((s) => s.filters);
const setFilters = useDashboardStore((s) => s.setFilters);
const resetFilters = useDashboardStore((s) => s.resetFilters);
const hasActiveFilters = useDashboardStore((s) => s.hasActiveFilters);
const filterPanelOpen = useDashboardStore((s) => s.filterPanelOpen);
const toggleFilterPanel = useDashboardStore((s) => s.toggleFilterPanel);
const containerRef = useRef<HTMLDivElement>(null);
const allNodeTypes = ALL_NODE_TYPES;
const allComplexities = ALL_COMPLEXITIES;
const allEdgeCategories = ALL_EDGE_CATEGORIES;
const layers = graph?.layers ?? [];
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (filterPanelOpen) {
toggleFilterPanel();
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [filterPanelOpen, toggleFilterPanel]);
const toggleNodeType = (type: NodeType) => {
const newTypes = new Set(filters.nodeTypes);
if (newTypes.has(type)) {
newTypes.delete(type);
} else {
newTypes.add(type);
}
setFilters({ nodeTypes: newTypes });
};
const toggleComplexity = (complexity: Complexity) => {
const newComplexities = new Set(filters.complexities);
if (newComplexities.has(complexity)) {
newComplexities.delete(complexity);
} else {
newComplexities.add(complexity);
}
setFilters({ complexities: newComplexities });
};
const toggleLayer = (layerId: string) => {
const newLayers = new Set(filters.layerIds);
if (newLayers.has(layerId)) {
newLayers.delete(layerId);
} else {
newLayers.add(layerId);
}
setFilters({ layerIds: newLayers });
};
const toggleEdgeCategory = (category: EdgeCategory) => {
const newCategories = new Set(filters.edgeCategories);
if (newCategories.has(category)) {
newCategories.delete(category);
} else {
newCategories.add(category);
}
setFilters({ edgeCategories: newCategories });
};
const isActive = hasActiveFilters();
return (
<div ref={containerRef} className="relative">
<button
onClick={toggleFilterPanel}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-gold/20 text-gold hover:bg-gold/30"
: "bg-elevated text-text-secondary hover:text-text-primary"
}`}
title="Filter graph (F)"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filter
</button>
{filterPanelOpen && (
<div className="absolute right-0 top-full mt-2 w-72 glass rounded-lg shadow-xl overflow-hidden animate-fade-slide-in z-50">
<div className="p-4 space-y-4">
{/* Node Types */}
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
Node Types
</h3>
<div className="space-y-1.5">
{allNodeTypes.map((type) => (
<label
key={type}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.nodeTypes.has(type)}
onChange={() => toggleNodeType(type)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">{type}</span>
</label>
))}
</div>
</div>
{/* Complexity */}
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
Complexity
</h3>
<div className="space-y-1.5">
{allComplexities.map((complexity) => (
<label
key={complexity}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.complexities.has(complexity)}
onChange={() => toggleComplexity(complexity)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">{complexity}</span>
</label>
))}
</div>
</div>
{/* Layers */}
{layers.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
Layers
</h3>
<div className="space-y-1.5">
{layers.map((layer) => (
<label
key={layer.id}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.layerIds.has(layer.id)}
onChange={() => toggleLayer(layer.id)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<div className="w-2 h-2 rounded-full bg-gold/50 shrink-0" />
<span className="text-sm text-text-primary">{layer.name}</span>
</label>
))}
</div>
</div>
)}
{/* Edge Categories */}
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
Edge Categories
</h3>
<div className="space-y-1.5">
{allEdgeCategories.map((category) => (
<label
key={category}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.edgeCategories.has(category)}
onChange={() => toggleEdgeCategory(category)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">
{category.replace(/-/g, " ")}
</span>
</label>
))}
</div>
</div>
{/* Reset Button */}
{isActive && (
<button
onClick={resetFilters}
className="w-full px-3 py-1.5 text-sm bg-elevated hover:bg-gold/20 text-text-secondary hover:text-gold rounded-lg transition-colors"
>
Reset All
</button>
)}
</div>
</div>
)}
</div>
);
}
@@ -484,6 +484,7 @@ function GraphViewInner() {
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance);
const { preset } = useTheme();
const overviewGraph = useOverviewGraph();
@@ -561,6 +562,7 @@ function GraphViewInner() {
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={setReactFlowInstance}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
@@ -29,7 +29,7 @@ export default function LayerLegend() {
return (
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-text-secondary">
<span className="text-[11px] font-medium text-text-secondary whitespace-nowrap">
{navigationLevel === "overview"
? `${layers.length} layers`
: activeLayer?.name ?? "Layer"}
@@ -40,7 +40,7 @@ export default function LayerLegend() {
const color = getLayerColor(i);
const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId;
return (
<div key={layer.id} className="flex items-center gap-1">
<div key={layer.id} className="flex items-center gap-1 whitespace-nowrap">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import type { CustomNodeData } from "./CustomNode";
interface NodeTooltipProps {
data: CustomNodeData;
nodeId: string;
incomingCount: number;
outgoingCount: number;
tags?: string[];
}
export default function NodeTooltip({
data,
nodeId,
incomingCount,
outgoingCount,
tags = [],
}: NodeTooltipProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
useEffect(() => {
const handleMouseMove = (e: Event) => {
const me = e as globalThis.MouseEvent;
setPosition({ x: me.clientX, y: me.clientY });
};
const showTooltip = () => setVisible(true);
const hideTooltip = () => setVisible(false);
// Find the node element via data-id (React Flow convention)
const nodeElement = document.querySelector(`[data-id="${CSS.escape(nodeId)}"]`);
if (nodeElement) {
nodeElement.addEventListener("mouseenter", showTooltip);
nodeElement.addEventListener("mouseleave", hideTooltip);
nodeElement.addEventListener("mousemove", handleMouseMove);
return () => {
nodeElement.removeEventListener("mouseenter", showTooltip);
nodeElement.removeEventListener("mouseleave", hideTooltip);
nodeElement.removeEventListener("mousemove", handleMouseMove);
};
}
}, [nodeId]);
if (!visible) return null;
const totalConnections = incomingCount + outgoingCount;
return (
<div
className="fixed z-[9999] pointer-events-none"
style={{
left: position.x + 16,
top: position.y + 16,
}}
>
<div className="glass-heavy rounded-lg shadow-2xl p-3 max-w-xs animate-fade-slide-in">
{/* Header */}
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-border-subtle">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gold">
{data.nodeType}
</span>
{data.complexity && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-elevated text-text-muted font-mono">
{data.complexity}
</span>
)}
</div>
{/* Name */}
<h4 className="text-sm font-serif text-text-primary mb-2 break-words">
{data.label}
</h4>
{/* Connections */}
<div className="flex items-center gap-4 mb-2 text-xs">
<div className="flex items-center gap-1">
<svg className="w-3 h-3 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
<span className="text-text-secondary">{incomingCount} in</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" transform="rotate(180 10 10)" />
</svg>
<span className="text-text-secondary">{outgoingCount} out</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-3 h-3 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-gold font-medium">{totalConnections}</span>
</div>
</div>
{/* Summary */}
{data.summary && (
<p className="text-xs text-text-secondary leading-relaxed mb-2">
{data.summary.length > 120 ? data.summary.slice(0, 120) + "..." : data.summary}
</p>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-2 border-t border-border-subtle">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-[9px] px-1.5 py-0.5 rounded-full bg-gold/10 text-gold border border-gold/30"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="text-[9px] text-text-muted">+{tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,306 @@
import { useEffect, useRef, useState } from "react";
import { useDashboardStore } from "../store";
interface PathFinderModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProps) {
const graph = useDashboardStore((s) => s.graph);
const selectNode = useDashboardStore((s) => s.selectNode);
const [fromNodeId, setFromNodeId] = useState("");
const [toNodeId, setToNodeId] = useState("");
const [path, setPath] = useState<string[] | null>(null);
const [searching, setSearching] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen || !graph) return null;
const nodes = graph.nodes;
const edges = graph.edges;
// BFS to find shortest path
const findPath = () => {
if (!fromNodeId || !toNodeId || fromNodeId === toNodeId) {
setPath(null);
return;
}
setSearching(true);
// Build adjacency list
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (!adjacency.has(edge.source)) {
adjacency.set(edge.source, []);
}
adjacency.get(edge.source)!.push(edge.target);
}
// BFS
const queue: Array<{ nodeId: string; path: string[] }> = [
{ nodeId: fromNodeId, path: [fromNodeId] },
];
const visited = new Set<string>([fromNodeId]);
while (queue.length > 0) {
const { nodeId, path: currentPath } = queue.shift()!;
if (nodeId === toNodeId) {
setPath(currentPath);
setSearching(false);
return;
}
const neighbors = adjacency.get(nodeId) ?? [];
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push({ nodeId: neighbor, path: [...currentPath, neighbor] });
}
}
}
// No path found
setPath([]);
setSearching(false);
};
const handleNodeClick = (nodeId: string) => {
selectNode(nodeId);
onClose();
};
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-root/80 backdrop-blur-sm">
<div
ref={modalRef}
className="glass-heavy rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden animate-fade-slide-in"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
<h2 className="font-serif text-xl text-text-primary">Dependency Path Finder</h2>
</div>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4 overflow-y-auto max-h-[calc(80vh-180px)]">
<p className="text-sm text-text-secondary">
Find the shortest path between two nodes in the dependency graph.
</p>
{/* From Node */}
<div>
<label className="block text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
From Node
</label>
<select
value={fromNodeId}
onChange={(e) => {
setFromNodeId(e.target.value);
setPath(null);
}}
className="w-full bg-elevated text-text-primary text-sm rounded-lg px-3 py-2 border border-border-subtle focus:outline-none focus:border-gold/50"
>
<option value="">Select a node...</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
</div>
{/* To Node */}
<div>
<label className="block text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
To Node
</label>
<select
value={toNodeId}
onChange={(e) => {
setToNodeId(e.target.value);
setPath(null);
}}
className="w-full bg-elevated text-text-primary text-sm rounded-lg px-3 py-2 border border-border-subtle focus:outline-none focus:border-gold/50"
>
<option value="">Select a node...</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
</div>
{/* Find Path Button */}
<button
onClick={findPath}
disabled={!fromNodeId || !toNodeId || fromNodeId === toNodeId || searching}
className="w-full bg-gold/10 border border-gold/30 text-gold text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-gold/20 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{searching ? "Searching..." : "Find Path"}
</button>
{/* Path Result */}
{path !== null && (
<div className="mt-4">
{path.length === 0 ? (
<div className="bg-red-900/20 border border-red-700/50 rounded-lg p-4 text-center">
<svg
className="w-8 h-8 text-red-400 mx-auto mb-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm text-red-200">No path found between these nodes.</p>
</div>
) : (
<div className="bg-elevated border border-border-subtle rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<svg
className="w-4 h-4 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="text-sm font-semibold text-text-primary">
Path Found ({path.length} nodes)
</h3>
</div>
<div className="space-y-2">
{path.map((nodeId, idx) => {
const node = nodeMap.get(nodeId);
if (!node) return null;
const isLast = idx === path.length - 1;
return (
<div key={nodeId}>
<button
onClick={() => handleNodeClick(nodeId)}
className="w-full flex items-center gap-3 p-2 bg-surface rounded-lg hover:bg-elevated transition-colors text-left"
>
<div className="w-6 h-6 shrink-0 rounded-full bg-gold/20 flex items-center justify-center text-xs font-bold text-gold">
{idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-text-primary truncate">{node.name}</div>
<div className="text-xs text-text-muted capitalize">{node.type}</div>
</div>
<svg
className="w-4 h-4 text-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{!isLast && (
<div className="flex items-center justify-center my-1">
<svg
className="w-4 h-4 text-gold"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-border-subtle">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}
@@ -21,6 +21,30 @@ export default function ProjectOverview() {
typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1;
}
// Count complexity
const complexityCounts: Record<string, number> = { simple: 0, moderate: 0, complex: 0 };
for (const node of nodes) {
if (node.complexity) {
complexityCounts[node.complexity] = (complexityCounts[node.complexity] ?? 0) + 1;
}
}
// Find top connected nodes
const nodeConnections = new Map<string, number>();
for (const edge of edges) {
nodeConnections.set(edge.source, (nodeConnections.get(edge.source) ?? 0) + 1);
nodeConnections.set(edge.target, (nodeConnections.get(edge.target) ?? 0) + 1);
}
const topNodes = Array.from(nodeConnections.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([nodeId, count]) => {
const node = nodes.find((n) => n.id === nodeId);
return { id: nodeId, name: node?.name ?? nodeId, count };
});
const avgConnections = nodes.length > 0 ? (edges.length * 2 / nodes.length).toFixed(1) : "0";
// Category breakdowns
const categoryBreakdown = [
{ label: "Code", color: "var(--color-node-file)", count: (typeCounts["file"] ?? 0) + (typeCounts["function"] ?? 0) + (typeCounts["class"] ?? 0) },
@@ -104,6 +128,82 @@ export default function ProjectOverview() {
</div>
)}
{/* Node Type Breakdown */}
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">Node Type Distribution</h3>
<div className="space-y-2">
{Object.entries(typeCounts)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => {
const percentage = ((count / nodes.length) * 100).toFixed(0);
return (
<div key={type}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-secondary capitalize">{type}</span>
<span className="text-text-muted font-mono">{count} ({percentage}%)</span>
</div>
<div className="w-full h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-accent/50 rounded-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
{/* Complexity Breakdown */}
{Object.values(complexityCounts).some((c) => c > 0) && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">Complexity Distribution</h3>
<div className="grid grid-cols-3 gap-2">
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-green-400">{complexityCounts.simple}</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mt-0.5">Simple</div>
</div>
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-yellow-400">{complexityCounts.moderate}</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mt-0.5">Moderate</div>
</div>
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-red-400">{complexityCounts.complex}</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mt-0.5">Complex</div>
</div>
</div>
</div>
)}
{/* Top Connected Nodes */}
{topNodes.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">Most Connected Nodes</h3>
<div className="space-y-2">
{topNodes.map((node, idx) => (
<div
key={node.id}
className="flex items-center gap-2 text-xs bg-elevated rounded-lg p-2 border border-border-subtle"
>
<div className="w-5 h-5 shrink-0 rounded-full bg-accent/20 flex items-center justify-center text-[10px] font-bold text-accent">
{idx + 1}
</div>
<span className="flex-1 text-text-primary truncate">{node.name}</span>
<span className="text-text-muted font-mono shrink-0">{node.count}</span>
</div>
))}
</div>
</div>
)}
{/* Average Connections */}
<div className="mb-5 bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="flex items-center justify-between">
<span className="text-xs text-text-secondary">Avg Connections per Node</span>
<span className="text-lg font-mono font-medium text-accent">{avgConnections}</span>
</div>
</div>
{/* Analyzed at */}
<div className="text-[11px] text-text-muted mb-6">
Analyzed: {new Date(project.analyzedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
@@ -199,6 +199,15 @@ body {
transition: opacity 0.3s ease, filter 0.3s ease;
}
/* Hide scrollbar but keep scroll functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom scrollbar for dark luxury theme */
::-webkit-scrollbar {
width: 6px;
@@ -5,9 +5,39 @@ import type {
KnowledgeGraph,
TourStep,
} from "@understand-anything/core/types";
import type { ReactFlowInstance } from "@xyflow/react";
export type Persona = "non-technical" | "junior" | "experienced";
export type NavigationLevel = "overview" | "layer-detail";
export type NodeType = "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource";
export type Complexity = "simple" | "moderate" | "complex";
export type EdgeCategory = "structural" | "behavioral" | "data-flow" | "dependencies" | "semantic";
export interface FilterState {
nodeTypes: Set<NodeType>;
complexities: Set<Complexity>;
layerIds: Set<string>;
edgeCategories: Set<EdgeCategory>;
}
export const ALL_NODE_TYPES: NodeType[] = ["file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"];
export const ALL_COMPLEXITIES: Complexity[] = ["simple", "moderate", "complex"];
export const ALL_EDGE_CATEGORIES: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic"];
export const EDGE_CATEGORY_MAP: Record<EdgeCategory, string[]> = {
structural: ["imports", "exports", "contains", "inherits", "implements"],
behavioral: ["calls", "subscribes", "publishes", "middleware"],
"data-flow": ["reads_from", "writes_to", "transforms", "validates"],
dependencies: ["depends_on", "tested_by", "configures"],
semantic: ["related", "similar_to"],
};
const DEFAULT_FILTERS: FilterState = {
nodeTypes: new Set<NodeType>(ALL_NODE_TYPES),
complexities: new Set<Complexity>(ALL_COMPLEXITIES),
layerIds: new Set<string>(),
edgeCategories: new Set<EdgeCategory>(ALL_EDGE_CATEGORIES),
};
/** Categories used for node type filter toggles. Single source of truth for NodeCategory. */
export type NodeCategory = "code" | "config" | "docs" | "infra" | "data";
@@ -55,6 +85,13 @@ interface DashboardStore {
// Sidebar navigation history (stack of visited node IDs)
nodeHistory: string[];
// Filter & Export features
filters: FilterState;
filterPanelOpen: boolean;
exportMenuOpen: boolean;
pathFinderOpen: boolean;
reactFlowInstance: ReactFlowInstance | null;
// Node type category filters
nodeTypeFilters: Record<NodeCategory, boolean>;
toggleNodeTypeFilter: (category: NodeCategory) => void;
@@ -77,6 +114,14 @@ interface DashboardStore {
toggleDiffMode: () => void;
clearDiffOverlay: () => void;
toggleFilterPanel: () => void;
toggleExportMenu: () => void;
togglePathFinder: () => void;
setReactFlowInstance: (instance: ReactFlowInstance | null) => void;
setFilters: (filters: Partial<FilterState>) => void;
resetFilters: () => void;
hasActiveFilters: () => boolean;
startTour: () => void;
stopTour: () => void;
setTourStep: (step: number) => void;
@@ -131,6 +176,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
focusNodeId: null,
nodeHistory: [],
filters: { ...DEFAULT_FILTERS, nodeTypes: new Set(DEFAULT_FILTERS.nodeTypes), complexities: new Set(DEFAULT_FILTERS.complexities), layerIds: new Set(DEFAULT_FILTERS.layerIds), edgeCategories: new Set(DEFAULT_FILTERS.edgeCategories) },
filterPanelOpen: false,
exportMenuOpen: false,
pathFinderOpen: false,
reactFlowInstance: null,
nodeTypeFilters: { code: true, config: true, docs: true, infra: true, data: true },
toggleNodeTypeFilter: (category) =>
@@ -291,6 +342,43 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
affectedNodeIds: new Set<string>(),
}),
toggleFilterPanel: () => set((state) => ({
filterPanelOpen: !state.filterPanelOpen,
exportMenuOpen: false,
})),
toggleExportMenu: () => set((state) => ({
exportMenuOpen: !state.exportMenuOpen,
filterPanelOpen: false,
})),
togglePathFinder: () => set((state) => ({
pathFinderOpen: !state.pathFinderOpen,
})),
setReactFlowInstance: (instance) => set({ reactFlowInstance: instance }),
setFilters: (newFilters) => set((state) => ({
filters: { ...state.filters, ...newFilters },
})),
resetFilters: () => set({
filters: {
nodeTypes: new Set<NodeType>(ALL_NODE_TYPES),
complexities: new Set<Complexity>(ALL_COMPLEXITIES),
layerIds: new Set<string>(),
edgeCategories: new Set<EdgeCategory>(ALL_EDGE_CATEGORIES),
},
}),
hasActiveFilters: () => {
const { filters } = get();
return filters.nodeTypes.size !== ALL_NODE_TYPES.length
|| filters.complexities.size !== ALL_COMPLEXITIES.length
|| filters.layerIds.size > 0
|| filters.edgeCategories.size !== ALL_EDGE_CATEGORIES.length;
},
startTour: () => {
const { graph } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
@@ -0,0 +1,72 @@
import type { GraphNode, GraphEdge, Layer } from "@understand-anything/core/types";
import type { FilterState, NodeType, Complexity, EdgeCategory } from "../store";
import { EDGE_CATEGORY_MAP } from "../store";
/**
* Filter nodes based on active filters
*/
export function filterNodes(
nodes: GraphNode[],
layers: Layer[],
filters: FilterState,
): GraphNode[] {
return nodes.filter((node) => {
// Filter by node type
if (!filters.nodeTypes.has(node.type as NodeType)) {
return false;
}
// Filter by complexity
if (node.complexity && !filters.complexities.has(node.complexity as Complexity)) {
return false;
}
// Filter by layer (if any layers are selected)
if (filters.layerIds.size > 0) {
const nodeInSelectedLayer = layers.some(
(layer) => filters.layerIds.has(layer.id) && layer.nodeIds.includes(node.id)
);
if (!nodeInSelectedLayer) {
return false;
}
}
return true;
});
}
/**
* Filter edges based on visible nodes and active edge category filters
*/
export function filterEdges(
edges: GraphEdge[],
visibleNodeIds: Set<string>,
filters: FilterState,
): GraphEdge[] {
return edges.filter((edge) => {
// Only keep edges between visible nodes
if (!visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)) {
return false;
}
// Filter by edge category
const edgeCategory = getEdgeCategory(edge.type);
if (edgeCategory && !filters.edgeCategories.has(edgeCategory)) {
return false;
}
return true;
});
}
/**
* Determine which category an edge type belongs to
*/
function getEdgeCategory(edgeType: string): EdgeCategory | null {
for (const [category, types] of Object.entries(EDGE_CATEGORY_MAP)) {
if (types.includes(edgeType)) {
return category as EdgeCategory;
}
}
return null;
}