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 (
)
}