import { useEffect, useState, useCallback, useMemo, useRef } from 'react' import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useNodesState, useEdgesState, MarkerType, useReactFlow, getNodesBounds, getViewportForBounds, } from '@xyflow/react' import { toPng } from 'html-to-image' import '@xyflow/react/dist/style.css' import { normalizeGraph } from './normalize.js' import { layoutGraph } from './layout.js' import HandlerNode from './nodes/HandlerNode.jsx' import './App.css' const nodeTypes = { handler: HandlerNode } // ── Export helper ───────────────────────────────────────────────────────────── function useExport(exportRef) { const { getNodes } = useReactFlow() useEffect(() => { exportRef.current = () => { const nodes = getNodes() if (!nodes.length) return const bounds = getNodesBounds(nodes) const pad = 60 const imgW = Math.max(1920, bounds.width + pad * 2) const imgH = Math.max(1080, bounds.height + pad * 2) const vp = getViewportForBounds(bounds, imgW, imgH, 0.1, 4, pad) toPng(document.querySelector('.react-flow__viewport'), { backgroundColor: '#0f1117', width: imgW, height: imgH, style: { width: imgW + 'px', height: imgH + 'px', transform: `translate(${vp.x}px,${vp.y}px) scale(${vp.zoom})`, }, }).then(url => { const a = document.createElement('a') a.download = 'flash-routes.png' a.href = url a.click() }).catch(console.error) } }, [getNodes, exportRef]) } // ── FlowContent ─────────────────────────────────────────────────────────────── function FlowContent({ styledNodes, styledEdges, onNodesChange, onEdgesChange, onNodeMouseEnter, onNodeMouseLeave, showLambdas, searchQuery, exportRef, }) { const { fitView } = useReactFlow() useExport(exportRef) useEffect(() => { setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 50) }, [showLambdas, searchQuery, fitView]) return ( n.data?.isAbstract ? '#ef444499' : '#3b82f666'} /> ) } // ── Main ────────────────────────────────────────────────────────────────────── export default function App() { const [allNodes, setAllNodes] = useState([]) const [allEdges, setAllEdges] = useState([]) const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [stats, setStats] = useState(null) const [hoveredNode, setHoveredNode] = useState(null) const [highlighted, setHighlighted] = useState({ nodes: new Set(), edges: new Set() }) const [showLambdas, setShowLambdas] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [exporting, setExporting] = useState(false) const exportRef = useRef(null) // ── Load data ────────────────────────────────────────────────────────────── useEffect(() => { fetch('/routeviewer/data') .then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }) .then(raw => { const routeCount = raw.nodes.filter(n => n.type === 'route').length const { nodes: n, edges: e } = normalizeGraph(raw.nodes, raw.edges) const { nodes: ln, edges: le } = layoutGraph(n, e) setAllNodes(ln) setAllEdges(le) setStats({ routes: routeCount }) setLoading(false) }) .catch(err => { setError(err.message); setLoading(false) }) }, []) // ── Ancestor set builder ─────────────────────────────────────────────────── const getAncestors = useMemo(() => { const parentOf = new Map() allEdges.forEach(e => { if (e.edgeType === 'extends') parentOf.set(e.target, e.source) }) return (id) => { const set = new Set() let cur = parentOf.get(id) while (cur) { set.add(cur); cur = parentOf.get(cur) } return set } }, [allEdges]) // ── Filter (lambda toggle + search) ─────────────────────────────────────── useEffect(() => { const q = searchQuery.trim().toLowerCase() const lambdaIds = new Set(allNodes.filter(n => n.data?.isLambda).map(n => n.id)) let visibleIds = new Set(allNodes.map(n => n.id)) if (q) { const matched = new Set( allNodes.filter(n => (n.data?.name || '').toLowerCase().includes(q) || (n.data?.routes || []).some(r => r.path.toLowerCase().includes(q)) ).map(n => n.id) ) const withAncestors = new Set(matched) matched.forEach(id => getAncestors(id).forEach(a => withAncestors.add(a))) visibleIds = withAncestors } const filteredNodes = allNodes.filter(n => { if (lambdaIds.has(n.id) && !showLambdas) return false return visibleIds.has(n.id) }) const filteredIds = new Set(filteredNodes.map(n => n.id)) setNodes(filteredNodes) setEdges(allEdges.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))) }, [showLambdas, searchQuery, allNodes, allEdges, setNodes, setEdges, getAncestors]) // ── Hover ────────────────────────────────────────────────────────────────── const onNodeMouseEnter = useCallback((_, node) => { const parentOf = new Map() edges.forEach(e => { if (e.edgeType === 'extends') parentOf.set(e.target, { pid: e.source, eid: e.id }) }) const visited = new Set([node.id]) const connEdges = new Set() const q = [node.id] while (q.length) { const cur = q.shift() const p = parentOf.get(cur) if (p && !visited.has(p.pid)) { connEdges.add(p.eid); visited.add(p.pid); q.push(p.pid) } } setHoveredNode(node.id) setHighlighted({ nodes: visited, edges: connEdges }) }, [edges]) const onNodeMouseLeave = useCallback(() => { setHoveredNode(null) setHighlighted({ nodes: new Set(), edges: new Set() }) }, []) // ── Style pass ───────────────────────────────────────────────────────────── const styledNodes = nodes.map(n => ({ ...n, style: { opacity: hoveredNode && !highlighted.nodes.has(n.id) ? 0.1 : 1, transition: 'opacity 0.15s' }, })) const styledEdges = edges.map(e => { const base = { type: 'bezier', pathOptions: { curvature: 0.35 }, style: { stroke: '#1e2235', strokeWidth: 1.5 }, markerEnd: { type: MarkerType.ArrowClosed, width: 10, height: 10, color: '#1e2235' } } if (!hoveredNode) return { ...e, ...base } if (highlighted.edges.has(e.id)) return { ...e, type: 'bezier', pathOptions: { curvature: 0.35 }, style: { stroke: '#60a5fa', strokeWidth: 2.5 }, markerEnd: { type: MarkerType.ArrowClosed, width: 13, height: 13, color: '#60a5fa' }, } return { ...e, ...base, style: { ...base.style, opacity: 0.04 } } }) const lambdaCount = allNodes.filter(n => n.data?.isLambda).length const handlerCount = allNodes.filter(n => !n.data?.isLambda).length const handleExport = useCallback(() => { setExporting(true) setTimeout(() => { exportRef.current?.() setTimeout(() => setExporting(false), 1200) }, 50) }, []) // ── Render ───────────────────────────────────────────────────────────────── return (
{/* ── Sidebar ──────────────────────────────────────────────────────── */} {/* ── Graph area ────────────────────────────────────────────────────── */}
{/* Topbar */}
Flash Route Graph {[['#3b82f6','handler'],['#ef4444','abstract'],['#f6ad55','middleware']].map(([c,l]) => ( {l} ))}
{loading &&
Loading…
} {error &&
Error: {error}
} {!loading && !error && (
)}
) } // ── Section helper ───────────────────────────────────────────────────────────── function Section({ label, children }) { return (
{label}
{children}
) }