1
0
Fork 0
polaris-task-force/src/components/admin/tech-tree/TechTreeCanvas.tsx
Z8MB1E 80ad24580a fix(tech-tree): save canvas positions in one request so auto-arrange cannot overload the server
Every canvas position write (Auto-arrange, Save positions, drags, freeze pins)
used to send one PATCH per technology, all at once. On a large tree that was
dozens of concurrent full document updates, which exhausted the database pool
and could crash the server.

- Add POST /api/tech-tree/positions: validates `{ positions: [{ id, x, y }] }`
  (null x/y clears a pin, capped at 2000) and applies every position in a
  single UPDATE on the tree position columns. Access mirrors the technologies
  update rule (technologies:update or Intelligence membership).
- Route all canvas position writes through it; failures now surface in the
  toolbar instead of being swallowed.
- Auto-arrange is optimistic: the canvas re-lays out on click and the pins
  are restored if the save fails. A spinner status chip shows while a layout
  save runs, and layout controls and dragging are locked until it finishes.
- Integration tests for validation and the bulk write; the e2e checks the busy
  indicator and that no per-node PATCHes are sent.
2026-10-03 04:08:29 -04:00

949 lines
34 KiB
TypeScript

"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<typeof computeLayout>,
connectable: boolean,
): Node[] {
const warningsByTech = new Map<number, number>();
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<typeof computeLayout>,
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<string>();
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<void> {
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<T>(slug: string): Promise<T[]> {
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<string | null>(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<string | null>(null);
// Manual edge routing, loaded from this browser's storage on mount. Empty =
// the edge draws its automatic path.
const [edgeRoutes, setEdgeRoutes] = React.useState<EdgeRouteMap>({});
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<Node>([]);
React.useEffect(() => {
setNodes(flowNodes);
}, [flowNodes, setNodes]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
React.useEffect(() => {
setEdges(flowEdges);
}, [flowEdges, setEdges]);
// Latest rendered card positions, read by the freeze diff below.
const nodesRef = React.useRef<Node[]>([]);
nodesRef.current = nodes;
const currentPositions = React.useCallback((): Map<number, CanvasPosition> => {
const map = new Map<number, CanvasPosition>();
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<number, CanvasPosition>, skip: Set<number> = 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<Node>[]) => {
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<string | null>(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<string | null>(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<number, { and: number[]; or: number[] }>();
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<number, { x: number; y: number }>;
} | 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<number, { x: number; y: number }>();
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<number, CanvasPosition>();
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<Record<string, unknown>>("technologies"),
fetchCollection<Record<string, unknown>>("tech-categories"),
fetchCollection<Record<string, unknown>>("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 (
<div
className="relative tech-tree-canvas"
style={{
height: "calc(100dvh - 56px)",
backgroundColor: "#05070a",
}}
data-testid="tech-tree-canvas"
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
minZoom={0.08}
// Freeze dragging while a layout write is in flight so a drag cannot
// race (and be overwritten by) the bulk save.
nodesDraggable={layoutBusy === null}
snapToGrid={snapToGrid}
snapGrid={[SNAP_GRID, SNAP_GRID]}
nodesConnectable={data.canUpdate}
onConnect={(connection) => {
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 }}
>
<Background
variant={BackgroundVariant.Lines}
gap={SNAP_GRID}
size={1}
color="rgba(255,255,255,0.055)"
/>
<Controls showInteractive={false} />
</ReactFlow>
{/* Toolbar */}
<div className="absolute top-4 left-4 z-20 flex items-center gap-3 rounded-none border border-white/15 bg-black/60 px-3 py-2 backdrop-blur-sm">
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
TECH TREE // COMMAND
</span>
{layout.warnings.length > 0 ? (
<span
className="flex items-center gap-1 font-mono text-[10px] tracking-widest text-amber-400 uppercase"
title={layout.warnings.map((w) => w.message).join("\n")}
data-testid="tech-tree-warnings"
>
<TriangleAlertIcon className="size-3" />
{layout.warnings.length} TIMELINE WARNING{layout.warnings.length === 1 ? "" : "S"}
</span>
) : null}
{connectError ? (
<span
className="font-mono text-[10px] tracking-widest text-red-400 uppercase"
data-testid="tech-tree-connect-error"
>
{connectError}
</span>
) : null}
{layoutBusy ? (
<span
role="status"
aria-live="polite"
className="flex items-center gap-1.5 font-mono text-[10px] tracking-widest text-[#67e8f9] uppercase"
data-testid="tech-tree-layout-busy"
>
<Loader2Icon className="size-3 animate-spin" />
{layoutBusy}
</span>
) : saveStatus ? (
<span
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
data-testid="tech-tree-save-status"
>
{saveStatus}
</span>
) : null}
{data.canUpdate ? (
<Button
size="sm"
variant="ghost"
onClick={toggleSnapToGrid}
aria-pressed={snapToGrid}
title="Snap dragged cards to the grid so they line up with the layout"
data-testid="tech-tree-snap-toggle"
className={
snapToGrid
? `${TACTICAL_BUTTON} border-[#22d3ee]/60 bg-[#22d3ee]/15 text-[#a5f3fc] hover:border-[#22d3ee]/80 hover:bg-[#22d3ee]/20 hover:text-[#cffafe]`
: TACTICAL_BUTTON
}
>
<MagnetIcon className="size-3.5" />
Snap: {snapToGrid ? "On" : "Off"}
</Button>
) : null}
{data.canUpdate ? (
<Button
size="sm"
variant="ghost"
onClick={handleSavePositions}
disabled={layoutBusy !== null}
title="Save the current positions of every node so the layout stays the same on your next visit"
data-testid="tech-tree-save-positions"
className={TACTICAL_BUTTON}
>
<SaveIcon className="size-3.5" />
Save positions
</Button>
) : null}
{data.canUpdate && hasManualPositions ? (
<Button
size="sm"
variant="ghost"
onClick={handleAutoArrange}
disabled={layoutBusy !== null}
title="Clear manual node positions and restore the automatic layout"
data-testid="tech-tree-auto-arrange"
className={TACTICAL_BUTTON}
>
<LayoutGridIcon className="size-3.5" />
Auto-arrange
</Button>
) : null}
{data.canCreate ? (
<Button
size="sm"
variant="ghost"
onClick={() => setCreateOpen(true)}
data-testid="tech-tree-create"
className={`${TACTICAL_BUTTON} border-[#22d3ee]/40 text-[#67e8f9] hover:border-[#22d3ee]/70 hover:bg-[#22d3ee]/10 hover:text-[#a5f3fc]`}
>
<PlusIcon className="size-3.5" />
New Technology
</Button>
) : null}
{data.canUpdate ? (
<span className="hidden font-mono text-[9px] tracking-widest text-white/35 uppercase lg:inline">
DRAG NODE EDGE TO LINK A PREREQUISITE · SELECT AN EDGE + BACKSPACE TO REMOVE
</span>
) : null}
</div>
{/* Empty state */}
{techs.length === 0 ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="pointer-events-auto rounded-none border border-white/15 bg-black/70 px-6 py-5 text-center backdrop-blur-sm">
<p className="font-mono text-[10px] tracking-widest text-white/50 uppercase">
NO TECHNOLOGIES ON RECORD
</p>
<p className="mt-1 text-sm text-white/70">
Create the first technology to start the tree.
</p>
{data.canCreate ? (
<Button
className={`${TACTICAL_BUTTON} mt-3`}
size="sm"
variant="ghost"
onClick={() => setCreateOpen(true)}
>
<PlusIcon className="size-3.5" />
New Technology
</Button>
) : null}
</div>
</div>
) : null}
{/* Preview / edit panel */}
{selectedTech ? (
<TechPreviewPanel
tech={selectedTech}
categories={categories}
resources={resources}
allTechs={techs}
canUpdate={data.canUpdate}
canCreateCategories={data.canCreateCategories}
canSetFinalApproval={data.canSetFinalApproval}
warnings={selectedWarnings}
onClose={() => setSelectedId(null)}
onEdit={() => router.push(`/admin/collections/technologies/${selectedTech.id}`)}
onSaved={() => {
void refresh({ freeze: true });
}}
/>
) : null}
<QuickCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
categories={categories}
canCreateCategories={data.canCreateCategories}
onCreated={handleCreated}
/>
</div>
);
}
export function TechTreeCanvas({ data }: { data: TechTreeData }) {
return (
<ReactFlowProvider>
<CanvasInner data={data} />
</ReactFlowProvider>
);
}