mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #50 from berkcangumusisik/feat/dashboard-export-filtering
Add filters, export, path finder & tooltips
This commit is contained in:
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user