"use client"; import { Background, BackgroundVariant, Controls, MarkerType, ReactFlow, useEdgesState, useNodesState, type Edge, type EdgeChange, type EdgeTypes, type Node, type NodeChange, type NodeTypes, ReactFlowProvider, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { LayoutGridIcon, Loader2Icon, MagnetIcon, PlusIcon, SaveIcon, TriangleAlertIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import React from "react"; import { Button } from "@/components/ui/button"; import { CategoryBand, type CategoryBandData, type CategoryBandNode } from "./CategoryBand"; import { loadEdgeRoutes, routeKey, saveEdgeRoutes, type EdgeRouteMap, type RouteWaypoint, } from "./edgeRoutes"; import { type RouteRect } from "./edgeRouting"; import { edgeVisuals, type EdgeRef } from "./edgeVisuals"; import { applyPins, pinsToFreeze, type CanvasPosition } from "./freeze"; import { computeLayout } from "./layout"; import { mapCategoryDoc, mapTechDoc } from "./mappers"; import { addPrerequisite, buildPrereqRows, removePrerequisite, withPrereqs } from "./prereqEdits"; import { QuickCreateDialog, type QuickCreateResult } from "./QuickCreateDialog"; import { RoutedEdge } from "./RoutedEdge"; import { TechNodeCard, type TechNodeData, type TechNodeModel } from "./TechNode"; import { TechPreviewPanel } from "./TechPreviewPanel"; import { COLUMN_STEP, NODE_HEIGHT, NODE_WIDTH, type TreeCategory, type TreeTech, } from "./types"; type ResourceEntry = { id: number; name: string }; export interface TechTreeData { techs: TreeTech[]; categories: TreeCategory[]; resources: ResourceEntry[]; canCreate: boolean; canCreateCategories: boolean; canUpdate: boolean; canSetFinalApproval: boolean; } /** Tactical chrome for the canvas toolbar controls. */ const TACTICAL_BUTTON = "rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white"; /** * Snap-to-grid size (px). Divides COLUMN_STEP (368) and ROW_STEP (184), so a * snapped card lines up with the automatic layout instead of drifting off it. */ const SNAP_GRID = 46; const SNAP_STORAGE_KEY = "ptf:tech-tree:snap-to-grid"; const nodeTypes: NodeTypes = { techCard: TechNodeCard, techBand: CategoryBand, }; const edgeTypes: EdgeTypes = { routed: RoutedEdge, }; function refLabel(id: number): string { return `REF ${id.toString().padStart(3, "0")}`; } function buildFlowNodes( techs: TreeTech[], categories: TreeCategory[], layout: ReturnType, connectable: boolean, ): Node[] { const warningsByTech = new Map(); for (const warning of layout.warnings) { warningsByTech.set(warning.techId, (warningsByTech.get(warning.techId) ?? 0) + 1); } const categoryById = new Map(categories.map((c) => [c.id, c])); const bandNodes: CategoryBandNode[] = layout.bands.map((band) => { const memberIds = band.key === "uncategorized" ? techs .filter((t) => t.categoryId === null || !categoryById.has(t.categoryId)) .map((t) => t.id) : techs .filter((t) => t.categoryId === Number(band.key.replace("cat-", ""))) .map((t) => t.id); return { id: band.key, type: "techBand", position: { x: band.x, y: band.y }, // Draggable only from its header (dragHandle), so the band body stays a // click-through backdrop while the header moves the whole category. draggable: true, dragHandle: ".tech-band-drag-handle", selectable: false, deletable: false, zIndex: 0, // The band body must never swallow pointer events, otherwise edges under a // band cannot be selected and pane clicks die. The header re-enables them. style: { width: band.width, height: band.height, pointerEvents: "none" }, data: { band, techCount: memberIds.length, memberIds }, }; }); const techById = new Map(techs.map((t) => [t.id, t])); const techNodes: TechNodeModel[] = layout.nodes.map((node) => { const tech = techById.get(node.techId)!; return { id: String(tech.id), type: "techCard", position: { x: node.x, y: node.y }, zIndex: 1, style: { width: NODE_WIDTH, height: NODE_HEIGHT }, data: { tech, categoryName: tech.categoryId ? categoryById.get(tech.categoryId)?.name ?? null : null, refLabel: refLabel(tech.id), hasWarning: warningsByTech.has(tech.id), connectable, }, }; }); return [...bandNodes, ...techNodes]; } function withAlpha(hex: string, alpha: number): string { const value = hex.replace("#", ""); const r = parseInt(value.slice(0, 2), 16); const g = parseInt(value.slice(2, 4), 16); const b = parseInt(value.slice(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; } /** * Prerequisite edges. Each edge takes its progression path's color and its own * lane (see edgeVisuals). With a technology selected, edges that do not touch * it fade back so its connections stand out. */ function buildFlowEdges( techs: TreeTech[], layout: ReturnType, selectedId: string | null, edgeRoutes: EdgeRouteMap, editable: boolean, onRouteChange: (routeId: string, waypoints: RouteWaypoint[]) => void, ): Edge[] { const known = new Set(techs.map((t) => t.id)); const refs: EdgeRef[] = []; const isOr = new Set(); for (const tech of techs) { for (const prereqId of tech.prereqAnd) { if (prereqId === tech.id || !known.has(prereqId)) continue; refs.push({ id: `and-${prereqId}-${tech.id}`, source: prereqId, target: tech.id }); } for (const prereqId of tech.prereqOr) { if (prereqId === tech.id || !known.has(prereqId)) continue; const id = `or-${prereqId}-${tech.id}`; if (refs.some((r) => r.id === id)) continue; refs.push({ id, source: prereqId, target: tech.id }); isOr.add(id); } } const positions = new Map(layout.nodes.map((n) => [n.techId, { x: n.x, y: n.y }])); const visuals = edgeVisuals(techs, refs, positions, COLUMN_STEP); const focus = selectedId ? Number(selectedId) : null; // Node rectangles for obstacle avoidance; each edge excludes its own ends. const obstacles: RouteRect[] = layout.nodes.map((n) => ({ id: n.techId, x: n.x, y: n.y, width: NODE_WIDTH, height: NODE_HEIGHT, })); return refs.map((ref) => { const visual = visuals.get(ref.id)!; const or = isOr.has(ref.id); const related = focus === null || ref.source === focus || ref.target === focus; const alpha = focus === null ? 0.8 : related ? 1 : 0.12; const stroke = withAlpha(visual.color, alpha); const routeId = routeKey(ref.source, ref.target); return { id: ref.id, source: String(ref.source), target: String(ref.target), type: "routed", animated: or, zIndex: focus !== null && related ? 2 : 0, style: { stroke, strokeWidth: focus !== null && related ? 2.25 : 1.5, ...(or ? { strokeDasharray: "6 4" } : {}), }, markerEnd: { type: MarkerType.ArrowClosed, color: stroke }, ...(or ? { label: "OR", labelStyle: { fill: stroke, fontSize: 9, fontFamily: "monospace" }, labelBgStyle: { fill: "rgba(0,0,0,0.85)", fillOpacity: 0.9 }, } : {}), data: { pathRoot: visual.pathRoot, waypoints: edgeRoutes[routeId] ?? [], routeId, sourceId: ref.source, targetId: ref.target, obstacles, stepPosition: visual.stepPosition, editable, onRouteChange, }, } as Edge; }); } /** * Persist canvas positions in ONE request (one SQL statement server-side). * Never PATCH per node: dozens of concurrent document updates exhausted the * database pool and could take the server down. Null x/y clears a pin. */ async function saveTreePositions( updates: { id: number; x: number | null; y: number | null }[], ): Promise { if (updates.length === 0) return; const res = await fetch("/api/tech-tree/positions", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ positions: updates }), }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.error || `Could not save positions (HTTP ${res.status}).`); } } async function fetchCollection(slug: string): Promise { const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, { credentials: "same-origin", }); if (!res.ok) return []; const body = await res.json(); return body?.docs ?? []; } function CanvasInner({ data }: { data: TechTreeData }) { const router = useRouter(); const [techs, setTechs] = React.useState(data.techs); const [categories, setCategories] = React.useState(data.categories); const [resources, setResources] = React.useState(data.resources); const [selectedId, setSelectedId] = React.useState(null); const [createOpen, setCreateOpen] = React.useState(false); // Label of the layout write in flight ("Arranging...", "Saving positions..."); // null when idle. Drives the toolbar status chip and locks layout controls. const [layoutBusy, setLayoutBusy] = React.useState(null); // Manual edge routing, loaded from this browser's storage on mount. Empty = // the edge draws its automatic path. const [edgeRoutes, setEdgeRoutes] = React.useState({}); React.useEffect(() => { setEdgeRoutes(loadEdgeRoutes()); }, []); const handleRouteChange = React.useCallback( (routeId: string, waypoints: RouteWaypoint[]) => { setEdgeRoutes((prev) => { const next = { ...prev }; if (waypoints.length === 0) delete next[routeId]; else next[routeId] = waypoints; saveEdgeRoutes(next); return next; }); }, [], ); const layout = React.useMemo(() => computeLayout(techs, categories), [techs, categories]); const flowNodes = React.useMemo( () => buildFlowNodes(techs, categories, layout, data.canUpdate), [techs, categories, layout, data.canUpdate], ); const flowEdges = React.useMemo( () => buildFlowEdges(techs, layout, selectedId, edgeRoutes, data.canUpdate, handleRouteChange), [techs, layout, selectedId, edgeRoutes, data.canUpdate, handleRouteChange], ); // Controlled nodes/edges: React Flow needs the change handlers to apply // drag and selection updates in controlled mode. const [nodes, setNodes, onNodesChange] = useNodesState([]); React.useEffect(() => { setNodes(flowNodes); }, [flowNodes, setNodes]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); React.useEffect(() => { setEdges(flowEdges); }, [flowEdges, setEdges]); // Latest rendered card positions, read by the freeze diff below. const nodesRef = React.useRef([]); nodesRef.current = nodes; const currentPositions = React.useCallback((): Map => { const map = new Map(); for (const node of nodesRef.current) { if (node.type !== "techCard") continue; map.set(Number(node.id), { x: node.position.x, y: node.position.y }); } return map; }, []); /** Persist freeze pins (skipping ids whose own PATCH already carries one). */ const persistPins = React.useCallback( async (pins: Map, skip: Set = new Set()) => { await saveTreePositions( [...pins].filter(([id]) => !skip.has(id)).map(([id, pin]) => ({ id, x: pin.x, y: pin.y })), ); }, [], ); // Nodes are data-driven: block React Flow's own remove changes so Backspace // can never blank a node out of the canvas (positions/selection still flow). const handleNodesChange = React.useCallback( (changes: NodeChange[]) => { const allowed = changes.filter((change) => change.type !== "remove"); if (allowed.length > 0) onNodesChange(allowed); }, [onNodesChange], ); // Edge removal is a data change (it drops a prerequisite), handled in // onEdgesDelete; selection changes still flow so edges can be picked. const handleEdgesChange = React.useCallback( (changes: EdgeChange[]) => { const allowed = changes.filter((change) => change.type !== "remove"); if (allowed.length > 0) onEdgesChange(allowed); }, [onEdgesChange], ); const [connectError, setConnectError] = React.useState(null); React.useEffect(() => { if (!connectError) return; const timer = window.setTimeout(() => setConnectError(null), 6000); return () => window.clearTimeout(timer); }, [connectError]); const hasManualPositions = techs.some((t) => t.treePosition !== null); const [saveStatus, setSaveStatus] = React.useState(null); React.useEffect(() => { if (!saveStatus) return; const timer = window.setTimeout(() => setSaveStatus(null), 3000); return () => window.clearTimeout(timer); }, [saveStatus]); // Snap-to-grid setting (default on). Persisted per browser so it survives a // reload; read in an effect to avoid an SSR/client hydration mismatch. const [snapToGrid, setSnapToGrid] = React.useState(true); React.useEffect(() => { try { const stored = window.localStorage.getItem(SNAP_STORAGE_KEY); if (stored !== null) setSnapToGrid(stored === "true"); } catch { // Storage can be unavailable (private mode); keep the default. } }, []); const toggleSnapToGrid = React.useCallback(() => { setSnapToGrid((prev) => { const next = !prev; try { window.localStorage.setItem(SNAP_STORAGE_KEY, String(next)); } catch { // ignore } return next; }); }, []); const snapValue = React.useCallback( (v: number) => (snapToGrid ? Math.round(v / SNAP_GRID) * SNAP_GRID : Math.round(v)), [snapToGrid], ); const patchPrereqs = React.useCallback( async ( targetId: number, lists: { and: number[]; or: number[] }, pin?: { x: number; y: number }, ) => { const res = await fetch(`/api/technologies/${targetId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requirements: { prerequisiteTechnologies: buildPrereqRows(lists) }, ...(pin ? { treePosition: pin } : {}), }), }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`); } }, [], ); const handleConnect = React.useCallback( async (connection: { source: string | null; target: string | null }) => { if (!connection.source || !connection.target) return; const sourceId = Number(connection.source); const targetId = Number(connection.target); const result = addPrerequisite(techs, targetId, sourceId, "and"); if (!result.ok) { setConnectError(result.error); return; } setConnectError(null); // Stability: nothing on screen moves because of a connection. Every card // the ordered layout would relocate (the target, its dependents, column // neighbours) is pinned where it sits; "Auto-arrange" re-orders them. const updated = techs.map((t) => t.id === targetId ? withPrereqs(t, result.lists) : t, ); const pins = pinsToFreeze(currentPositions(), updated, categories); setTechs(applyPins(updated, pins)); try { await patchPrereqs(targetId, result.lists, pins.get(targetId)); await persistPins(pins, new Set([targetId])); } catch (e) { setConnectError(e instanceof Error ? e.message : "Could not save the connection."); await refresh(); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [techs, categories, patchPrereqs, currentPositions, persistPins], ); const handleEdgesDelete = React.useCallback( async (deleted: Edge[]) => { let next = techs; const listsByTarget = new Map(); for (const edge of deleted) { const targetId = Number(edge.target); const result = removePrerequisite(next, targetId, Number(edge.source)); if (!result.ok) continue; listsByTarget.set(targetId, result.lists); next = next.map((t) => (t.id === targetId ? withPrereqs(t, result.lists) : t)); } if (listsByTarget.size === 0) return; const pins = pinsToFreeze(currentPositions(), next, categories); setTechs(applyPins(next, pins)); try { for (const [targetId, lists] of listsByTarget) { await patchPrereqs(targetId, lists, pins.get(targetId)); } await persistPins(pins, new Set(listsByTarget.keys())); } catch (e) { setConnectError(e instanceof Error ? e.message : "Could not remove the prerequisite."); await refresh(); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [techs, categories, patchPrereqs, currentPositions, persistPins], ); /** * Persist one or more card positions to `treePosition` (optimistic local * update first, then one bulk write). Used by card drags (a multi-selection * moves several cards at once) and by category-band drags (every member). */ const persistPositions = React.useCallback( async (positions: { id: number; x: number; y: number }[]) => { if (positions.length === 0) return; const byId = new Map(positions.map((p) => [p.id, p])); setTechs((prev) => prev.map((t) => { const p = byId.get(t.id); return p ? { ...t, treePosition: { x: p.x, y: p.y } } : t; }), ); try { await saveTreePositions(positions); } catch (e) { // The optimistic position stays on screen; a reload shows what is stored. setConnectError(e instanceof Error ? e.message : "Could not save positions."); } }, [], ); // Live session for a category-band drag: the header moves the band and every // member card moves with it (positions are committed on drag stop). const bandDragRef = React.useRef<{ bandId: string; start: { x: number; y: number }; members: Map; } | null>(null); const handleNodeDragStart = React.useCallback((node: Node) => { if (node.type !== "techBand") { bandDragRef.current = null; return; } // The drag callbacks only hand us the DRAGGED set (just this band), so read // the member cards' start positions from the full rendered node list. const memberIds = (node.data as CategoryBandData).memberIds ?? []; const members = new Map(); for (const id of memberIds) { const card = nodesRef.current.find((candidate) => candidate.id === String(id)); if (card) members.set(id, { x: card.position.x, y: card.position.y }); } bandDragRef.current = { bandId: node.id, start: { x: node.position.x, y: node.position.y }, members, }; }, []); const handleNodeDrag = React.useCallback( (node: Node) => { const session = bandDragRef.current; if (!session || node.type !== "techBand" || session.bandId !== node.id) return; const dx = node.position.x - session.start.x; const dy = node.position.y - session.start.y; setNodes((prev) => prev.map((n) => { if (n.type !== "techCard") return n; const start = session.members.get(Number(n.id)); if (!start) return n; return { ...n, position: { x: start.x + dx, y: start.y + dy } }; }), ); }, [setNodes], ); const handleNodeDragStop = React.useCallback( (node: Node, allNodes: Node[]) => { if (node.type === "techBand") { const session = bandDragRef.current; bandDragRef.current = null; if (!session) return; const dx = node.position.x - session.start.x; const dy = node.position.y - session.start.y; void persistPositions( [...session.members].map(([id, start]) => ({ id, x: snapValue(start.x + dx), y: snapValue(start.y + dy), })), ); return; } // A card drag can move a whole multi-selection: persist every dragged card // (React Flow hands us the full set, not just the one under the pointer). const cards = allNodes.filter((n) => n.type === "techCard"); if (cards.length === 0) return; void persistPositions( cards.map((n) => ({ id: Number(n.id), x: snapValue(n.position.x), y: snapValue(n.position.y), })), ); }, [persistPositions, snapValue], ); const handleSavePositions = React.useCallback(async () => { // Commit the current on-screen position of every node to treePosition, so // the exact arrangement survives a reload. Unlike a drag (one node) or a // connection (only what would move), this pins the whole tree to whatever // React Flow actually rendered. const pins = new Map(); const current = currentPositions(); for (const tech of techs) { const pos = current.get(tech.id); if (pos) { pins.set(tech.id, { x: Math.round(pos.x), y: Math.round(pos.y) }); } } if (pins.size === 0) { setSaveStatus("Nothing to save."); return; } if (layoutBusy) return; setTechs((prev) => prev.map((t) => { const pin = pins.get(t.id); return pin ? { ...t, treePosition: pin } : t; }), ); setLayoutBusy("Saving positions..."); setConnectError(null); try { await persistPins(pins); setSaveStatus(`Positions saved. ${pins.size} node(s).`); } catch (e) { setConnectError(e instanceof Error ? e.message : "Could not save positions."); } finally { setLayoutBusy(null); } }, [techs, currentPositions, persistPins, layoutBusy]); const handleAutoArrange = React.useCallback(async () => { const pinned = techs.filter((t) => t.treePosition !== null); if (pinned.length === 0 || layoutBusy) return; const previous = new Map(pinned.map((t) => [t.id, t.treePosition])); // Optimistic: re-lay the canvas out at once so the click visibly does // something, then persist every cleared pin in a single request. setLayoutBusy("Arranging..."); setConnectError(null); setTechs((prev) => prev.map((t) => (t.treePosition ? { ...t, treePosition: null } : t))); try { await saveTreePositions(pinned.map((t) => ({ id: t.id, x: null, y: null }))); setSaveStatus(`Layout reset. ${pinned.length} node(s) re-arranged.`); } catch (e) { // Put the pins back so the canvas matches what is actually stored. setTechs((prev) => prev.map((t) => (previous.has(t.id) ? { ...t, treePosition: previous.get(t.id) ?? null } : t)), ); setConnectError(e instanceof Error ? e.message : "Could not reset the layout."); } finally { setLayoutBusy(null); } }, [techs, layoutBusy]); const techById = React.useMemo( () => new Map(techs.map((t) => [String(t.id), t])), [techs], ); const selectedTech = selectedId ? techById.get(selectedId) ?? null : null; /** * Re-read the tree. With `freeze`, cards the fresh data would relocate are * pinned at their current spot first (panel saves, quick-create), so the * canvas stays still. Error recovery calls it without freeze to show truth. */ const refresh = React.useCallback( async (options: { freeze?: boolean } = {}) => { const [techDocs, categoryDocs, resourceDocs] = await Promise.all([ fetchCollection>("technologies"), fetchCollection>("tech-categories"), fetchCollection>("resources"), ]); let nextTechs = techDocs.map(mapTechDoc); const nextCategories = categoryDocs.map(mapCategoryDoc); if (options.freeze) { const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories); nextTechs = applyPins(nextTechs, pins); persistPins(pins).catch((e: unknown) => setConnectError(e instanceof Error ? e.message : "Could not save positions."), ); } setTechs(nextTechs); setCategories(nextCategories); setResources(resourceDocs.map((d) => ({ id: d.id as number, name: String(d.name ?? "") }))); }, [currentPositions, persistPins], ); const handleCreated = React.useCallback( async (result: QuickCreateResult) => { await refresh({ freeze: true }); setSelectedId(String(result.techId)); router.push(`/admin/collections/technologies/${result.techId}`); }, [refresh, router], ); const selectedWarnings = React.useMemo( () => selectedTech ? layout.warnings.filter((w) => w.techId === selectedTech.id).map((w) => w.message) : [], [selectedTech, layout], ); return (
{ void handleConnect(connection); }} onConnectEnd={(event, connectionState) => { // React Flow only resolves drops onto a handle. If the pointer was // released over a node card instead, resolve that card ourselves so // "drag onto the technology" works the way people expect. Validation // (duplicates, cycles) lives in handleConnect, which reports errors. if (!data.canUpdate) return; if (!("fromNode" in connectionState)) return; if (connectionState.toHandle) return; // dropped on a handle: onConnect leads const sourceId = connectionState.fromNode?.id; if (!sourceId) return; const point = event as MouseEvent; const element = document.elementFromPoint(point.clientX ?? 0, point.clientY ?? 0); const nodeElement = element?.closest?.(".react-flow__node") as HTMLElement | null; const targetId = nodeElement?.getAttribute("data-id"); if (!targetId || targetId === sourceId) return; void handleConnect({ source: sourceId, target: targetId }); }} onEdgesDelete={(deleted) => { void handleEdgesDelete(deleted); }} onNodeClick={(event, node) => { // Ctrl/Cmd-click is a multi-select gesture, not an edit gesture: do not // pop the side panel, which would cover the canvas mid-selection. if (event.ctrlKey || event.metaKey) return; if (node.type === "techCard") setSelectedId(node.id); }} onNodeDragStart={(_, node) => handleNodeDragStart(node)} onNodeDrag={(_, node) => handleNodeDrag(node)} onNodeDragStop={(_, node, draggedNodes) => handleNodeDragStop(node, draggedNodes)} onPaneClick={() => setSelectedId(null)} proOptions={{ hideAttribution: false }} > {/* Toolbar */}
TECH TREE // COMMAND {layout.warnings.length > 0 ? ( w.message).join("\n")} data-testid="tech-tree-warnings" > {layout.warnings.length} TIMELINE WARNING{layout.warnings.length === 1 ? "" : "S"} ) : null} {connectError ? ( {connectError} ) : null} {layoutBusy ? ( {layoutBusy} ) : saveStatus ? ( {saveStatus} ) : null} {data.canUpdate ? ( ) : null} {data.canUpdate ? ( ) : null} {data.canUpdate && hasManualPositions ? ( ) : null} {data.canCreate ? ( ) : null} {data.canUpdate ? ( DRAG NODE EDGE TO LINK A PREREQUISITE ยท SELECT AN EDGE + BACKSPACE TO REMOVE ) : null}
{/* Empty state */} {techs.length === 0 ? (

NO TECHNOLOGIES ON RECORD

Create the first technology to start the tree.

{data.canCreate ? ( ) : null}
) : null} {/* Preview / edit panel */} {selectedTech ? ( setSelectedId(null)} onEdit={() => router.push(`/admin/collections/technologies/${selectedTech.id}`)} onSaved={() => { void refresh({ freeze: true }); }} /> ) : null}
); } export function TechTreeCanvas({ data }: { data: TechTreeData }) { return ( ); }