From d43b2ef65807d3412e94dd6bc271c95d2d330daf Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 3 Oct 2026 03:28:27 -0400 Subject: [PATCH] feat(tech-tree): drag groups, snap to grid, and route edges around nodes Round out the canvas editing experience. - Dragging one card of a multi-selection now moves and persists the whole selection (React Flow hands the full dragged set to onNodeDragStop). - A category band is draggable by its header, which moves every technology in that band; the band body stays click-through. - A "Snap: On/Off" toolbar toggle (default on, remembered per browser) snaps dragged cards to a 46px grid that divides the column and row steps, so snapped cards line up with the automatic layout. - Connection lines are adjustable: select an edge to reveal "+" handles at each segment's midpoint (click to add a bend), drag bends to reroute, and double-click a bend to remove it. Routes persist per browser. - Edges route around technologies they are not connected to, and corners are rounded, so lines no longer cut through unrelated cards. - Ctrl/Cmd-clicking to multi-select no longer pops the side panel over the canvas. --- .../admin/tech-tree/CategoryBand.tsx | 11 +- src/components/admin/tech-tree/RoutedEdge.tsx | 204 +++++++++++++ .../admin/tech-tree/TechTreeCanvas.tsx | 275 ++++++++++++++++-- src/components/admin/tech-tree/edgeRoutes.ts | 85 ++++++ src/components/admin/tech-tree/edgeRouting.ts | 133 +++++++++ tests/e2e/tech-tree.e2e.spec.ts | 241 +++++++++++++++ tests/int/tech-tree-edge-routes.int.spec.ts | 86 ++++++ 7 files changed, 1001 insertions(+), 34 deletions(-) create mode 100644 src/components/admin/tech-tree/RoutedEdge.tsx create mode 100644 src/components/admin/tech-tree/edgeRoutes.ts create mode 100644 src/components/admin/tech-tree/edgeRouting.ts create mode 100644 tests/int/tech-tree-edge-routes.int.spec.ts diff --git a/src/components/admin/tech-tree/CategoryBand.tsx b/src/components/admin/tech-tree/CategoryBand.tsx index ec3974d..742ae66 100644 --- a/src/components/admin/tech-tree/CategoryBand.tsx +++ b/src/components/admin/tech-tree/CategoryBand.tsx @@ -7,13 +7,17 @@ import type { CategoryBandLayout } from "./types"; export type CategoryBandData = { band: CategoryBandLayout; techCount: number; + /** Technologies inside this band; dragging the header moves them all. */ + memberIds: number[]; }; export type CategoryBandNode = Node; /** - * Full-height column band behind a category's technologies. Non-interactive: - * it is a backdrop, not a node (no handles, no dragging, no selection). + * Full-height column band behind a category's technologies. The body is a + * backdrop (pointer events pass through so edges and the pane still work), but + * the header strip is a drag handle: dragging it moves the band and every card + * inside it. Non-selectable, so it never joins a multi-selection. */ export function CategoryBand({ data }: NodeProps) { const { band, techCount } = data; @@ -29,8 +33,9 @@ export function CategoryBand({ data }: NodeProps) { data-testid={`tech-band-${band.key}`} >
void; +}; + +/** + * A prerequisite edge. It draws a polyline from the source handle through any + * user waypoints to the target handle, bending around technologies it is not + * connected to, with rounded corners at each bend. + * + * Interaction (only when `editable`): + * - Select the edge to reveal "+" handles at each segment's midpoint; click one + * to add a bend there. + * - Drag a waypoint dot to move it. + * - Double-click a waypoint dot to remove it. + */ +export function RoutedEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + markerEnd, + style, + data, + selected, +}: EdgeProps) { + const route = data as unknown as RoutedEdgeData | undefined; + const waypoints = route?.waypoints ?? []; + const editable = route?.editable ?? false; + const { screenToFlowPosition } = useReactFlow(); + const dragIndexRef = React.useRef(null); + + const obstacles = (route?.obstacles ?? []).filter( + (r) => r.id !== route?.sourceId && r.id !== route?.targetId, + ); + + // Preferred shape: the user's waypoints if routed, else the automatic + // "out, across, in" path. If that path would cut through a node, fall back to + // the direct line so the router can bend around the obstacles (a midpoint + // sitting inside a box leaves the router nothing to work with). + const start: RoutePoint = { x: sourceX, y: sourceY }; + const end: RoutePoint = { x: targetX, y: targetY }; + const stepX = sourceX + (targetX - sourceX) * (route?.stepPosition ?? 0.5); + const orthogonal: RoutePoint[] = [ + start, + { x: stepX, y: sourceY }, + { x: stepX, y: targetY }, + end, + ]; + const hasCrossing = (pts: RoutePoint[]) => + pts.some( + (p, i) => i < pts.length - 1 && obstacles.some((r) => segmentCrossesRect(p, pts[i + 1], r)), + ); + + const base: RoutePoint[] = + waypoints.length > 0 + ? [start, ...waypoints, end] + : hasCrossing(orthogonal) + ? [start, end] + : orthogonal; + + const routed = routeAroundObstacles(base, obstacles); + const path = routed.length >= 2 ? buildRoutePath(routed) : ""; + + const commit = (next: RouteWaypoint[]) => route?.onRouteChange(route.routeId, next); + + const onHandlePointerDown = + (index: number) => (event: React.PointerEvent) => { + if (!editable) return; + event.stopPropagation(); + event.preventDefault(); + dragIndexRef.current = index; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const onHandlePointerMove = (event: React.PointerEvent) => { + const index = dragIndexRef.current; + if (index === null) return; + const point = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + commit( + waypoints.map((wp, i) => + i === index ? { x: Math.round(point.x), y: Math.round(point.y) } : wp, + ), + ); + }; + + const onHandlePointerUp = (event: React.PointerEvent) => { + if (dragIndexRef.current === null) return; + dragIndexRef.current = null; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // capture may already be released + } + }; + + const onHandleDoubleClick = (index: number) => (event: React.MouseEvent) => { + if (!editable) return; + event.stopPropagation(); + commit(waypoints.filter((_, i) => i !== index)); + }; + + // Midpoints of the UNROUTED segments, for the "add a bend" handles (a routed + // detour is not a place the user can insert a waypoint at). + const midpoints: { x: number; y: number; insertAt: number }[] = []; + for (let i = 0; i < base.length - 1; i += 1) { + midpoints.push({ + x: (base[i].x + base[i + 1].x) / 2, + y: (base[i].y + base[i + 1].y) / 2, + insertAt: i, + }); + } + + const addAt = (index: number, point: RouteWaypoint) => { + const next = [...waypoints]; + next.splice(index, 0, point); + commit(next); + }; + + return ( + <> + + + {selected && editable + ? midpoints.map((mid, i) => ( +
+ +
+ )) + : null} + {waypoints.map((wp, index) => ( +
+
+
+ ))} + + + ); +} diff --git a/src/components/admin/tech-tree/TechTreeCanvas.tsx b/src/components/admin/tech-tree/TechTreeCanvas.tsx index 193785d..0a34af0 100644 --- a/src/components/admin/tech-tree/TechTreeCanvas.tsx +++ b/src/components/admin/tech-tree/TechTreeCanvas.tsx @@ -10,25 +10,35 @@ import { 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, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react"; +import { LayoutGridIcon, 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 { @@ -55,11 +65,22 @@ export interface TechTreeData { 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")}`; } @@ -77,22 +98,29 @@ function buildFlowNodes( const categoryById = new Map(categories.map((c) => [c.id, c])); const bandNodes: CategoryBandNode[] = layout.bands.map((band) => { - const techCount = + const memberIds = band.key === "uncategorized" - ? techs.filter((t) => t.categoryId === null || !categoryById.has(t.categoryId)).length - : techs.filter((t) => t.categoryId === Number(band.key.replace("cat-", ""))).length; + ? 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: false, + // 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, - // Bands are pure backdrops: they must never swallow pointer events, - // otherwise edges under a band cannot be selected and pane clicks die. + // 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 }, + data: { band, techCount: memberIds.length, memberIds }, }; }); @@ -135,6 +163,9 @@ 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[] = []; @@ -157,18 +188,27 @@ function buildFlowEdges( 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: "smoothstep", - pathOptions: { stepPosition: visual.stepPosition, borderRadius: 8 }, + type: "routed", animated: or, zIndex: focus !== null && related ? 2 : 0, style: { @@ -184,7 +224,17 @@ function buildFlowEdges( labelBgStyle: { fill: "rgba(0,0,0,0.85)", fillOpacity: 0.9 }, } : {}), - data: { pathRoot: visual.pathRoot }, + data: { + pathRoot: visual.pathRoot, + waypoints: edgeRoutes[routeId] ?? [], + routeId, + sourceId: ref.source, + targetId: ref.target, + obstacles, + stepPosition: visual.stepPosition, + editable, + onRouteChange, + }, } as Edge; }); } @@ -207,14 +257,34 @@ function CanvasInner({ data }: { data: TechTreeData }) { const [createOpen, setCreateOpen] = React.useState(false); const [arranging, setArranging] = React.useState(false); + // 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), - [techs, layout, selectedId], + () => + 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 @@ -295,6 +365,33 @@ function CanvasInner({ data }: { data: TechTreeData }) { 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, @@ -377,21 +474,114 @@ function CanvasInner({ data }: { data: TechTreeData }) { [techs, categories, patchPrereqs, currentPositions, persistPins], ); - const persistPosition = React.useCallback(async (techId: number, x: number, y: number) => { - setTechs((prev) => - prev.map((t) => (t.id === techId ? { ...t, treePosition: { x, y } } : t)), - ); - try { - await fetch(`/api/technologies/${techId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ treePosition: { x, y } }), - }); - } catch { - // Optimistic position stays locally; a reload restores the stored value. + /** + * Persist one or more card positions to `treePosition` (optimistic local + * update first, then PATCH each). 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; + }), + ); + await Promise.all( + positions.map((p) => + fetch(`/api/technologies/${p.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ treePosition: { x: p.x, y: p.y } }), + }).catch(() => { + // Optimistic position stays locally; a reload restores the stored value. + }), + ), + ); + }, + [], + ); + + // 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 @@ -507,9 +697,12 @@ function CanvasInner({ data }: { data: TechTreeData }) { onNodesChange={handleNodesChange} onEdgesChange={handleEdgesChange} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} fitView fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }} minZoom={0.08} + snapToGrid={snapToGrid} + snapGrid={[SNAP_GRID, SNAP_GRID]} nodesConnectable={data.canUpdate} onConnect={(connection) => { void handleConnect(connection); @@ -534,19 +727,21 @@ function CanvasInner({ data }: { data: TechTreeData }) { onEdgesDelete={(deleted) => { void handleEdgesDelete(deleted); }} - onNodeClick={(_, node) => { + 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); }} - onNodeDragStop={(_, node) => { - if (node.type !== "techCard") return; - void persistPosition(Number(node.id), Math.round(node.position.x), Math.round(node.position.y)); - }} + onNodeDragStart={(_, node) => handleNodeDragStart(node)} + onNodeDrag={(_, node) => handleNodeDrag(node)} + onNodeDragStop={(_, node, draggedNodes) => handleNodeDragStop(node, draggedNodes)} onPaneClick={() => setSelectedId(null)} proOptions={{ hideAttribution: false }} > @@ -584,6 +779,24 @@ function CanvasInner({ data }: { data: TechTreeData }) { {saveStatus} ) : null} + {data.canUpdate ? ( + + ) : null} {data.canUpdate ? (