1
0
Fork 0

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.
This commit is contained in:
Jason Fraley 2026-10-03 03:28:27 -04:00
parent 2f8e8e1b34
commit d43b2ef658
7 changed files with 1001 additions and 34 deletions

View file

@ -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<CategoryBandData, "techBand">;
/**
* 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<CategoryBandNode>) {
const { band, techCount } = data;
@ -29,8 +33,9 @@ export function CategoryBand({ data }: NodeProps<CategoryBandNode>) {
data-testid={`tech-band-${band.key}`}
>
<div
className="flex items-center justify-between border-b bg-black/50 px-3 py-2"
className="tech-band-drag-handle pointer-events-auto flex cursor-grab items-center justify-between border-b bg-black/50 px-3 py-2 active:cursor-grabbing"
style={{ borderColor: `${accent}33` }}
title={`Drag to move ${band.label} and its technologies`}
>
<span
className="font-mono text-[9px] tracking-widest uppercase"

View file

@ -0,0 +1,204 @@
"use client";
import { BaseEdge, EdgeLabelRenderer, useReactFlow, type EdgeProps } from "@xyflow/react";
import React from "react";
import { buildRoutePath, type RouteWaypoint } from "./edgeRoutes";
import {
routeAroundObstacles,
segmentCrossesRect,
type RoutePoint,
type RouteRect,
} from "./edgeRouting";
export type RoutedEdgeData = {
/** User-placed waypoints for this edge (empty = automatic routing). */
waypoints: RouteWaypoint[];
/** "source:target" key used to persist the route. */
routeId: string;
/** Technology ids at each end, so the edge may connect to them. */
sourceId: number;
targetId: number;
/** Node rectangles the edge should route around. */
obstacles: RouteRect[];
/** Where the automatic vertical run sits (0..1). */
stepPosition: number;
editable: boolean;
onRouteChange: (routeId: string, waypoints: RouteWaypoint[]) => 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<number | null>(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<HTMLDivElement>) => {
if (!editable) return;
event.stopPropagation();
event.preventDefault();
dragIndexRef.current = index;
event.currentTarget.setPointerCapture(event.pointerId);
};
const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={style} />
<EdgeLabelRenderer>
{selected && editable
? midpoints.map((mid, i) => (
<div
key={`${route?.routeId}-mid-${i}`}
className="nodrag nopan"
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${mid.x}px, ${mid.y}px)`,
pointerEvents: "all",
}}
>
<button
type="button"
aria-label="Add route bend"
title="Add a bend here"
data-testid="tech-edge-add-bend"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
addAt(mid.insertAt, { x: Math.round(mid.x), y: Math.round(mid.y) });
}}
className="flex h-4 w-4 items-center justify-center rounded-full border border-[#a5f3fc]/70 bg-black/80 font-mono text-[10px] leading-none text-[#a5f3fc] hover:border-[#a5f3fc] hover:bg-[#22d3ee]/25"
>
+
</button>
</div>
))
: null}
{waypoints.map((wp, index) => (
<div
key={`${route?.routeId}-wp-${index}`}
className="nodrag nopan"
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${wp.x}px, ${wp.y}px)`,
pointerEvents: "all",
}}
>
<div
role="button"
aria-label="Route waypoint"
title="Drag to move, double-click to remove"
data-testid="tech-edge-waypoint"
onPointerDown={onHandlePointerDown(index)}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onDoubleClick={onHandleDoubleClick(index)}
className="h-3 w-3 rounded-full border border-[#a5f3fc]/80 bg-[#22d3ee]/90 shadow-[0_0_6px_rgba(34,211,238,0.7)]"
style={{ cursor: "grab" }}
/>
</div>
))}
</EdgeLabelRenderer>
</>
);
}

View file

@ -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<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[] = [];
@ -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<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),
[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) => {
/**
* 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) => (t.id === techId ? { ...t, treePosition: { x, y } } : t)),
prev.map((t) => {
const p = byId.get(t.id);
return p ? { ...t, treePosition: { x: p.x, y: p.y } } : t;
}),
);
try {
await fetch(`/api/technologies/${techId}`, {
await Promise.all(
positions.map((p) =>
fetch(`/api/technologies/${p.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: { x, y } }),
});
} catch {
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<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
@ -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 }}
>
<Background
variant={BackgroundVariant.Lines}
gap={28}
gap={SNAP_GRID}
size={1}
color="rgba(255,255,255,0.055)"
/>
@ -584,6 +779,24 @@ function CanvasInner({ data }: { data: TechTreeData }) {
{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"

View file

@ -0,0 +1,85 @@
/**
* Manual routing for Tech Tree connection lines.
*
* A prerequisite edge (source -> target) can carry a list of waypoints the
* user has placed on it. The edge renders as a polyline through those points
* instead of the automatic smoothstep path.
*
* Storage is per-browser (localStorage): routing is a personal viewing aid
* rather than shared tree data. Promoting it to a persisted field on the
* target technology is a small change once the schema settles.
*/
export type RouteWaypoint = { x: number; y: number };
export type EdgeRouteMap = Record<string, RouteWaypoint[]>;
const STORAGE_KEY = "ptf:tech-tree:edge-routes";
/** Stable key for the edge between two technologies. */
export function routeKey(source: number | string, target: number | string): string {
return `${source}:${target}`;
}
export function loadEdgeRoutes(): EdgeRouteMap {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as EdgeRouteMap) : {};
} catch {
return {};
}
}
export function saveEdgeRoutes(routes: EdgeRouteMap): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(routes));
} catch {
// Storage can be unavailable (private mode); routing just won't persist.
}
}
/** Corner rounding radius (px) at each waypoint. */
export const ROUTE_CORNER_RADIUS = 14;
function distance(a: RouteWaypoint, b: RouteWaypoint): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/**
* SVG path through the given points. Each interior corner is cut with a
* quadratic curve so bends read as smooth corners instead of hard angles; the
* radius shrinks automatically on short segments so curves never overshoot.
*/
export function buildRoutePath(points: RouteWaypoint[], radius = ROUTE_CORNER_RADIUS): string {
if (points.length < 2) return "";
const start = points[0];
let d = `M ${start.x},${start.y}`;
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1];
const cur = points[i];
const next = points[i + 1];
const inLen = distance(prev, cur);
const outLen = distance(cur, next);
if (inLen === 0 || outLen === 0) {
d += ` L ${cur.x},${cur.y}`;
continue;
}
const r = Math.min(radius, inLen / 2, outLen / 2);
const a = {
x: cur.x - ((cur.x - prev.x) / inLen) * r,
y: cur.y - ((cur.y - prev.y) / inLen) * r,
};
const b = {
x: cur.x + ((next.x - cur.x) / outLen) * r,
y: cur.y + ((next.y - cur.y) / outLen) * r,
};
d += ` L ${a.x},${a.y} Q ${cur.x},${cur.y} ${b.x},${b.y}`;
}
const end = points[points.length - 1];
return `${d} L ${end.x},${end.y}`;
}

View file

@ -0,0 +1,133 @@
/**
* Obstacle-avoiding routing for Tech Tree edges.
*
* A prerequisite edge should not cut through technologies it is not connected
* to. Given the polyline an edge would take (source -> optional waypoints ->
* target) and the rectangles of every OTHER node, this inserts detour points
* so each segment swings around the boxes it would otherwise cross.
*
* The detour is greedy: for the first box a segment hits, it tries that box's
* four (inflated) corners and picks the one with the shortest total bend,
* recursing on the two halves. Bounded depth keeps it cheap and terminating.
*/
export type RouteRect = { id: number; x: number; y: number; width: number; height: number };
export type RoutePoint = { x: number; y: number };
/** Clearance kept around a node when routing around it. */
export const ROUTE_CLEARANCE = 22;
const MAX_DEPTH = 3;
const EPS = 1;
/** Does segment a->b cross the interior of rect r? (exported for tests) */
export function segmentCrossesRect(a: RoutePoint, b: RoutePoint, r: RouteRect): boolean {
const x1 = r.x + EPS;
const y1 = r.y + EPS;
const x2 = r.x + r.width - EPS;
const y2 = r.y + r.height - EPS;
if (x2 <= x1 || y2 <= y1) return false;
// Liang-Barsky clip of the segment against the (slightly inset) box.
let t0 = 0;
let t1 = 1;
const dx = b.x - a.x;
const dy = b.y - a.y;
const p = [-dx, dx, -dy, dy];
const q = [a.x - x1, x2 - a.x, a.y - y1, y2 - a.y];
for (let i = 0; i < 4; i += 1) {
if (p[i] === 0) {
if (q[i] < 0) return false;
} else {
const t = q[i] / p[i];
if (p[i] < 0) {
if (t > t1) return false;
if (t > t0) t0 = t;
} else {
if (t < t0) return false;
if (t < t1) t1 = t;
}
}
}
return true;
}
const crosses = segmentCrossesRect;
function corners(r: RouteRect, margin: number): RoutePoint[] {
const x1 = r.x - margin;
const y1 = r.y - margin;
const x2 = r.x + r.width + margin;
const y2 = r.y + r.height + margin;
return [
{ x: x1, y: y1 },
{ x: x2, y: y1 },
{ x: x2, y: y2 },
{ x: x1, y: y2 },
];
}
function inside(p: RoutePoint, r: RouteRect, margin: number): boolean {
return (
p.x > r.x - margin && p.x < r.x + r.width + margin && p.y > r.y - margin && p.y < r.y + r.height + margin
);
}
function dist(a: RoutePoint, b: RoutePoint): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/** Detour points from a to b (excluding a, ending with b). */
function detour(
a: RoutePoint,
b: RoutePoint,
obstacles: RouteRect[],
margin: number,
depth: number,
): RoutePoint[] {
if (depth >= MAX_DEPTH) return [b];
const hit = obstacles.find((r) => crosses(a, b, r));
if (!hit) return [b];
let best: RoutePoint | null = null;
let bestLen = Number.POSITIVE_INFINITY;
for (const c of corners(hit, margin)) {
// Skip a corner inside another box, or one not reachable from `a` without
// still cutting through the box. The second half is left to the recursion,
// which chains a second corner for wide boxes.
if (obstacles.some((r) => r.id !== hit.id && inside(c, r, 0))) continue;
if (crosses(a, c, hit)) continue;
// A corner at the current point or the destination makes no progress.
if (dist(a, c) < 1 || dist(c, b) < 1) continue;
const len = dist(a, c) + dist(c, b);
if (len < bestLen) {
bestLen = len;
best = c;
}
}
if (!best) return [b];
return [
...detour(a, best, obstacles, margin, depth + 1),
...detour(best, b, obstacles, margin, depth + 1),
];
}
/**
* Route a polyline around the given obstacle boxes. Returns a new point list;
* returns the input unchanged when there is nothing to avoid.
*/
export function routeAroundObstacles(
points: RoutePoint[],
obstacles: RouteRect[],
margin = ROUTE_CLEARANCE,
): RoutePoint[] {
if (points.length < 2 || obstacles.length === 0) return points;
const out: RoutePoint[] = [points[0]];
for (let i = 0; i < points.length - 1; i += 1) {
for (const p of detour(points[i], points[i + 1], obstacles, margin, 0)) {
const last = out[out.length - 1];
if (Math.abs(p.x - last.x) > 0.5 || Math.abs(p.y - last.y) > 0.5) out.push(p);
}
}
return out;
}

View file

@ -35,6 +35,26 @@ test.describe("Tech Tree admin view", () => {
await expect(page.getByTestId("tech-tree-canvas")).toBeVisible();
}
/**
* Delete every technology and category so a test starts from a predictable,
* empty tree. The suite shares one database and only truncates at global
* setup, so without this the drag tests inherit a large tree from earlier
* tests and their targets land off-screen or under the side panel.
*/
async function clearTree(page: import("@playwright/test").Page) {
const techRes = await page.request.get("/api/technologies?limit=0&depth=0");
const techs = ((await techRes.json()).docs ?? []) as { id: number }[];
// Two passes: a prerequisite deleted before its dependents can fail, so a
// second pass removes whatever survived the first.
for (const pass of [0, 1]) {
for (const t of techs) await page.request.delete(`/api/technologies/${t.id}`);
void pass;
}
const catRes = await page.request.get("/api/tech-categories?limit=0&depth=0");
const cats = ((await catRes.json()).docs ?? []) as { id: number }[];
for (const c of cats) await page.request.delete(`/api/tech-categories/${c.id}`);
}
test("nav entry, tree render, preview panel, edit navigation, quick-create", async ({
page,
}) => {
@ -205,6 +225,10 @@ test.describe("Tech Tree admin view", () => {
const storedRes = await page.request.get(`/api/technologies/${draggable.id}`);
const stored = (await storedRes.json()).treePosition as { x: number; y: number };
expect(stored.y).toBeGreaterThan(0);
// Snap-to-grid is on by default, so the stored position lands on the 46px
// grid (which divides the layout steps, keeping cards aligned).
expect(stored.x % 46).toBe(0);
expect(stored.y % 46).toBe(0);
// A reload renders the node at the stored coordinates.
await gotoTree(page);
@ -309,6 +333,223 @@ test.describe("Tech Tree admin view", () => {
}
});
test("dragging a multi-selection persists every moved node", async ({ page }) => {
await login(page);
await clearTree(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const a = await createDoc(page, "technologies", {
name: "Tech Tree Multi A",
type: "upgrade",
summary: "Multi-drag A.",
category: null,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Multi B",
type: "upgrade",
summary: "Multi-drag B.",
category: null,
researchCosts: costs,
});
await gotoTree(page);
const nodeA = page.getByTestId(`tech-node-${a.id}`);
const nodeB = page.getByTestId(`tech-node-${b.id}`);
await expect(nodeA).toBeVisible();
await expect(nodeB).toBeVisible();
const transform = (id: number) =>
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
const m = (el as HTMLElement).style.transform.match(
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
);
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
});
const beforeA = (await transform(a.id))!;
const beforeB = (await transform(b.id))!;
// Multi-select with the platform multi-select key (Control on Linux).
await nodeA.click({ modifiers: ["Control"] });
await nodeB.click({ modifiers: ["Control"] });
await expect(page.locator(".react-flow__node.selected")).toHaveCount(2);
// Drag one selected node; React Flow moves the whole selection.
const boxA = (await nodeA.boundingBox())!;
await page.mouse.move(boxA.x + boxA.width / 2, boxA.y + boxA.height / 2);
await page.mouse.down();
await page.mouse.move(boxA.x + boxA.width / 2 + 220, boxA.y + boxA.height / 2 + 140, {
steps: 15,
});
await page.mouse.up();
// Both cards moved on screen...
await expect.poll(async () => (await transform(a.id))!.x, { timeout: 10_000 }).not.toBe(beforeA.x);
await expect.poll(async () => (await transform(b.id))!.x, { timeout: 10_000 }).not.toBe(beforeB.x);
// ...and both persisted (the bug was that only the node under the pointer did).
for (const id of [a.id, b.id]) {
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${id}?depth=0`);
return (await res.json())?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
for (const id of [a.id, b.id]) {
const del = await page.request.delete(`/api/technologies/${id}`);
expect(del.ok()).toBe(true);
}
});
test("dragging a category header moves all of its technologies", async ({ page }) => {
await login(page);
await clearTree(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const cat = await createDoc(page, "tech-categories", { name: "Band Drag (E2E)", position: 9 });
const a = await createDoc(page, "technologies", {
name: "Tech Tree Band A",
type: "upgrade",
summary: "Band-dragged A.",
category: cat.id,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Band B",
type: "upgrade",
summary: "Band-dragged B.",
category: cat.id,
researchCosts: costs,
});
await gotoTree(page);
await expect(page.getByTestId(`tech-node-${a.id}`)).toBeVisible();
await expect(page.getByTestId(`tech-node-${b.id}`)).toBeVisible();
const transform = (id: number) =>
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
const m = (el as HTMLElement).style.transform.match(
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
);
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
});
const beforeA = (await transform(a.id))!;
const beforeB = (await transform(b.id))!;
// Drag the band's header (its drag handle); the whole category moves with it.
const header = page.locator(
`.react-flow__node[data-id="cat-${cat.id}"] .tech-band-drag-handle`,
);
await expect(header).toBeVisible();
const box = (await header.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 180, box.y + box.height / 2 + 120, {
steps: 15,
});
await page.mouse.up();
// Both member cards shifted by (about) the same delta.
await expect
.poll(async () => (await transform(a.id))!.x - beforeA.x, { timeout: 10_000 })
.toBeGreaterThan(50);
await expect
.poll(async () => (await transform(b.id))!.x - beforeB.x, { timeout: 10_000 })
.toBeGreaterThan(50);
const afterA = (await transform(a.id))!;
const afterB = (await transform(b.id))!;
expect(Math.abs(afterA.x - beforeA.x - (afterB.x - beforeB.x))).toBeLessThanOrEqual(2);
// Positions persisted.
for (const id of [a.id, b.id]) {
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${id}?depth=0`);
return (await res.json())?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
for (const id of [a.id, b.id]) {
await page.request.delete(`/api/technologies/${id}`);
}
const delCat = await page.request.delete(`/api/tech-categories/${cat.id}`);
expect(delCat.ok()).toBe(true);
});
test("adjusting a connection line adds a draggable bend that persists", async ({ page }) => {
await login(page);
await clearTree(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const a = await createDoc(page, "technologies", {
name: "Tech Tree Route A",
type: "upgrade",
summary: "Route source.",
category: null,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Route B",
type: "upgrade",
summary: "Route target.",
category: null,
requirements: { prerequisiteTechnologies: [{ requiresAnd: { technology: [a.id] } }] },
researchCosts: costs,
});
await gotoTree(page);
const edgeId = `and-${a.id}-${b.id}`;
const edgePath = () =>
page
.locator(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`)
.getAttribute("d");
const before = await edgePath();
// Select the edge (click its path) to reveal the "+" bend handles.
const point = await page.evaluate((id) => {
const p = document.querySelector(
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`,
) as SVGPathElement | null;
if (!p) return null;
const local = p.getPointAtLength(p.getTotalLength() / 2);
const svg = p.ownerSVGElement!;
const pt = svg.createSVGPoint();
pt.x = local.x;
pt.y = local.y;
const screen = pt.matrixTransform(p.getScreenCTM()!);
return { x: screen.x, y: screen.y };
}, edgeId);
expect(point).not.toBeNull();
await page.mouse.click(point!.x, point!.y);
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"].selected`)).toHaveCount(1);
// Add a bend, then drag it to reroute the line.
await page.getByTestId("tech-edge-add-bend").first().click();
await expect(page.getByTestId("tech-edge-waypoint")).toHaveCount(1);
const wp = page.getByTestId("tech-edge-waypoint").first();
const box = (await wp.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2 - 90, { steps: 10 });
await page.mouse.up();
await expect.poll(edgePath, { timeout: 10_000 }).not.toBe(before);
// The route is stored for this browser and survives a reload.
await gotoTree(page);
await expect(page.getByTestId("tech-edge-waypoint")).toHaveCount(1);
for (const id of [a.id, b.id]) await page.request.delete(`/api/technologies/${id}`);
});
test("connections and the lite editor persist through the REST API", async ({ page }) => {
await login(page);

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { buildRoutePath, ROUTE_CORNER_RADIUS } from "@/components/admin/tech-tree/edgeRoutes";
import {
routeAroundObstacles,
segmentCrossesRect,
type RouteRect,
} from "@/components/admin/tech-tree/edgeRouting";
describe("tech tree edge route path", () => {
it("returns nothing for fewer than two points", () => {
expect(buildRoutePath([])).toBe("");
expect(buildRoutePath([{ x: 0, y: 0 }])).toBe("");
});
it("draws a straight line for two points", () => {
expect(buildRoutePath([{ x: 0, y: 0 }, { x: 100, y: 0 }])).toBe("M 0,0 L 100,0");
});
it("rounds each interior corner with a quadratic curve", () => {
const d = buildRoutePath([
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
]);
expect(d.startsWith("M 0,0")).toBe(true);
expect(d).toContain("Q 100,0");
expect(d.endsWith("L 100,100")).toBe(true);
// The straight run stops short of the corner by the radius.
expect(d).toContain(`L ${100 - ROUTE_CORNER_RADIUS},0`);
});
it("shrinks the radius on short segments so it never overshoots", () => {
const d = buildRoutePath([
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
]);
// Half of the 10px segment, not the full default radius.
expect(d).toContain("L 5,0");
expect(d).toContain("Q 10,0 10,5");
});
});
describe("tech tree edge obstacle routing", () => {
const box = (id: number, x: number, y: number, width: number, height: number): RouteRect => ({
id,
x,
y,
width,
height,
});
it("leaves a clear path unchanged", () => {
const points = [
{ x: 0, y: 0 },
{ x: 200, y: 0 },
];
// The box sits well below the line.
expect(routeAroundObstacles(points, [box(1, 0, 100, 60, 40)])).toEqual(points);
});
it("returns the input untouched when there are no obstacles", () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
];
expect(routeAroundObstacles(points, [])).toBe(points);
});
it("detours around a box that blocks the segment", () => {
const obstacle = box(1, 80, -20, 40, 40);
const points = [
{ x: 0, y: 0 },
{ x: 200, y: 0 },
];
const routed = routeAroundObstacles(points, [obstacle]);
expect(routed.length).toBeGreaterThan(2);
expect(routed[0]).toEqual({ x: 0, y: 0 });
expect(routed[routed.length - 1]).toEqual({ x: 200, y: 0 });
// Every resulting segment clears the box.
for (let i = 0; i < routed.length - 1; i += 1) {
expect(segmentCrossesRect(routed[i], routed[i + 1], obstacle)).toBe(false);
}
});
});