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.
949 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|