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:
parent
2f8e8e1b34
commit
d43b2ef658
7 changed files with 1001 additions and 34 deletions
|
|
@ -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"
|
||||
|
|
|
|||
204
src/components/admin/tech-tree/RoutedEdge.tsx
Normal file
204
src/components/admin/tech-tree/RoutedEdge.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
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<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"
|
||||
|
|
|
|||
85
src/components/admin/tech-tree/edgeRoutes.ts
Normal file
85
src/components/admin/tech-tree/edgeRoutes.ts
Normal 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}`;
|
||||
}
|
||||
133
src/components/admin/tech-tree/edgeRouting.ts
Normal file
133
src/components/admin/tech-tree/edgeRouting.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
86
tests/int/tech-tree-edge-routes.int.spec.ts
Normal file
86
tests/int/tech-tree-edge-routes.int.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue