1
0
Fork 0
polaris-task-force/src/components/admin/tech-tree/edgeRouting.ts
Z8MB1E d43b2ef658 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.
2026-10-03 03:28:27 -04:00

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;
}