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.
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|