mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[eric] canvas: the pure tether geometry moves into its own module, the hook keeps the link collection
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xkMseod9vJ3hCrE43ctDo (cherry picked from commit 4955505dac2250c4ab3030137d1ba1655d669d7d)
This commit is contained in:
@@ -5,22 +5,11 @@ import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import { agentCardHeight } from './agentCardHeight';
|
||||
|
||||
const ELBOW_RADIUS = 16;
|
||||
// Screen pixels, divided by the committed zoom so the head and the label hold their size at any
|
||||
// camera; the line itself uses a non-scaling stroke in the layer.
|
||||
export const HEAD_PX = 9;
|
||||
export const LABEL_FONT_PX = 11;
|
||||
// Below this a label would be under 5px tall; the line still shows where the link goes.
|
||||
export const LABEL_MIN_ZOOM = 0.4;
|
||||
// A lone line leaves an edge at the historical 54% spot; several share the band below so they
|
||||
// never stack on top of each other for their first segment.
|
||||
const SINGLE_AT = 0.54;
|
||||
const FAN_FROM = 0.4;
|
||||
const FAN_TO = 0.72;
|
||||
import { HEAD_PX, SINGLE_AT, anchorAt, center, fanFractions, pickSides, route } from './tetherGeometry';
|
||||
import type { Rect, Side } from './tetherTypes';
|
||||
|
||||
export type Side = 'left' | 'right' | 'top' | 'bottom';
|
||||
export interface Anchor { x: number; y: number; side: Side }
|
||||
export interface Rect { x: number; y: number; width: number; height: number }
|
||||
export { HEAD_PX, LABEL_FONT_PX, LABEL_MIN_ZOOM, pickSides, fanFractions, headPath, route, elbowPath, verticalElbowPath } from './tetherGeometry';
|
||||
export type { Rect, Side, Anchor, Routed } from './tetherTypes';
|
||||
|
||||
export interface Tether {
|
||||
key: string;
|
||||
@@ -45,122 +34,6 @@ export interface LiveDragInfo { cardId: string; dx: number; dy: number }
|
||||
/** The few session facts the tethers read. A projection, so streamed messages never re-run the geometry. */
|
||||
export type TetherSession = Pick<AgentSession, 'id' | 'mode' | 'status' | 'browser_id' | 'parent_session_id' | 'workflow_edit_id'>;
|
||||
|
||||
// ---------- pure geometry ----------
|
||||
|
||||
function center(r: Rect): { x: number; y: number } {
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair of FACING edges. The axis with the wider gap between the two cards decides, so a line
|
||||
* always leaves perpendicular to one card and enters perpendicular to the other, and can never
|
||||
* loop back across the card it started from (the nearest-anchor search could pair a right edge
|
||||
* with a top edge and then drive a horizontal segment sideways into that top edge).
|
||||
*/
|
||||
export function pickSides(src: Rect, dst: Rect): { src: Side; dst: Side } {
|
||||
const sc = center(src);
|
||||
const dc = center(dst);
|
||||
const dx = dc.x - sc.x;
|
||||
const dy = dc.y - sc.y;
|
||||
const gapX = dx >= 0 ? dst.x - (src.x + src.width) : src.x - (dst.x + dst.width);
|
||||
const gapY = dy >= 0 ? dst.y - (src.y + src.height) : src.y - (dst.y + dst.height);
|
||||
if (gapX >= gapY) return dx >= 0 ? { src: 'right', dst: 'left' } : { src: 'left', dst: 'right' };
|
||||
return dy >= 0 ? { src: 'bottom', dst: 'top' } : { src: 'top', dst: 'bottom' };
|
||||
}
|
||||
|
||||
function anchorAt(r: Rect, side: Side, fraction: number): Anchor {
|
||||
switch (side) {
|
||||
case 'left': return { x: r.x, y: r.y + r.height * fraction, side };
|
||||
case 'right': return { x: r.x + r.width, y: r.y + r.height * fraction, side };
|
||||
case 'top': return { x: r.x + r.width * fraction, y: r.y, side };
|
||||
default: return { x: r.x + r.width * fraction, y: r.y + r.height, side };
|
||||
}
|
||||
}
|
||||
|
||||
/** Where along an edge each of n lines leaves it: one line at the middle spot, several spread across the band. */
|
||||
export function fanFractions(n: number): number[] {
|
||||
if (n <= 1) return [SINGLE_AT];
|
||||
return Array.from({ length: n }, (_, i) => FAN_FROM + ((FAN_TO - FAN_FROM) * i) / (n - 1));
|
||||
}
|
||||
|
||||
/** Left/right pairs: out, across, in. Rounded at both bends. */
|
||||
export function elbowPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const midX = x1 + dx / 2;
|
||||
const r = (Math.abs(dy) < 1 || Math.abs(dx) < ELBOW_RADIUS * 2)
|
||||
? 0
|
||||
: Math.min(ELBOW_RADIUS, Math.abs(dy) / 2, Math.abs(dx) / 4);
|
||||
const sy = dy >= 0 ? 1 : -1;
|
||||
const sx = dx >= 0 ? 1 : -1;
|
||||
return [
|
||||
`M ${x1},${y1}`,
|
||||
`H ${midX - sx * r}`,
|
||||
`Q ${midX},${y1} ${midX},${y1 + sy * r}`,
|
||||
`V ${y2 - sy * r}`,
|
||||
`Q ${midX},${y2} ${midX + sx * r},${y2}`,
|
||||
`H ${x2}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Top/bottom pairs: the same elbow turned on its side. */
|
||||
export function verticalElbowPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const midY = y1 + dy / 2;
|
||||
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
|
||||
? 0
|
||||
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
|
||||
const sx = dx >= 0 ? 1 : -1;
|
||||
const sy = dy >= 0 ? 1 : -1;
|
||||
return [
|
||||
`M ${x1},${y1}`,
|
||||
`V ${midY - sy * r}`,
|
||||
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
|
||||
`H ${x2 - sx * r}`,
|
||||
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
|
||||
`V ${y2}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Unit direction the line travels as it enters a card through `side`. */
|
||||
function entryDirection(side: Side): { x: number; y: number } {
|
||||
switch (side) {
|
||||
case 'left': return { x: 1, y: 0 };
|
||||
case 'right': return { x: -1, y: 0 };
|
||||
case 'top': return { x: 0, y: 1 };
|
||||
default: return { x: 0, y: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled triangle whose tip sits on the card edge, pointing the way the line travels. */
|
||||
export function headPath(tip: Anchor, size: number): string {
|
||||
const d = entryDirection(tip.side);
|
||||
const bx = tip.x - d.x * size;
|
||||
const by = tip.y - d.y * size;
|
||||
const px = -d.y * size * 0.5;
|
||||
const py = d.x * size * 0.5;
|
||||
return `M ${tip.x},${tip.y} L ${bx + px},${by + py} L ${bx - px},${by - py} Z`;
|
||||
}
|
||||
|
||||
export interface Routed { path: string; head: string; labelX: number; labelY: number }
|
||||
|
||||
/** The line stops short of the tip so the stroke never pokes through the head. */
|
||||
export function route(a: Anchor, b: Anchor, headSize: number): Routed {
|
||||
const d = entryDirection(b.side);
|
||||
const shorten = headSize * 0.7;
|
||||
const ex = b.x - d.x * shorten;
|
||||
const ey = b.y - d.y * shorten;
|
||||
const horizontal = a.side === 'left' || a.side === 'right';
|
||||
const path = horizontal ? elbowPath(a.x, a.y, ex, ey) : verticalElbowPath(a.x, a.y, ex, ey);
|
||||
return {
|
||||
path,
|
||||
head: headPath(b, headSize),
|
||||
labelX: a.x + (b.x - a.x) / 2,
|
||||
labelY: a.y + (b.y - a.y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- links ----------
|
||||
|
||||
interface Link {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Rect, Side, Anchor, Routed } from './tetherTypes';
|
||||
|
||||
export type { Rect, Side, Anchor, Routed } from './tetherTypes';
|
||||
|
||||
const ELBOW_RADIUS = 16;
|
||||
// Screen pixels, divided by the committed zoom so the head and the label hold their size at any
|
||||
// camera; the line itself uses a non-scaling stroke in the layer.
|
||||
export const HEAD_PX = 9;
|
||||
export const LABEL_FONT_PX = 11;
|
||||
// Below this a label would be under 5px tall; the line still shows where the link goes.
|
||||
export const LABEL_MIN_ZOOM = 0.4;
|
||||
// A lone line leaves an edge at the historical 54% spot; several share the band below so they
|
||||
// never stack on top of each other for their first segment.
|
||||
export const SINGLE_AT = 0.54;
|
||||
const FAN_FROM = 0.4;
|
||||
const FAN_TO = 0.72;
|
||||
|
||||
export function center(r: Rect): { x: number; y: number } {
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair of FACING edges. The axis with the wider gap between the two cards decides, so a line
|
||||
* always leaves perpendicular to one card and enters perpendicular to the other, and can never
|
||||
* loop back across the card it started from (the nearest-anchor search could pair a right edge
|
||||
* with a top edge and then drive a horizontal segment sideways into that top edge).
|
||||
*/
|
||||
export function pickSides(src: Rect, dst: Rect): { src: Side; dst: Side } {
|
||||
const sc = center(src);
|
||||
const dc = center(dst);
|
||||
const dx = dc.x - sc.x;
|
||||
const dy = dc.y - sc.y;
|
||||
const gapX = dx >= 0 ? dst.x - (src.x + src.width) : src.x - (dst.x + dst.width);
|
||||
const gapY = dy >= 0 ? dst.y - (src.y + src.height) : src.y - (dst.y + dst.height);
|
||||
if (gapX >= gapY) return dx >= 0 ? { src: 'right', dst: 'left' } : { src: 'left', dst: 'right' };
|
||||
return dy >= 0 ? { src: 'bottom', dst: 'top' } : { src: 'top', dst: 'bottom' };
|
||||
}
|
||||
|
||||
export function anchorAt(r: Rect, side: Side, fraction: number): Anchor {
|
||||
switch (side) {
|
||||
case 'left': return { x: r.x, y: r.y + r.height * fraction, side };
|
||||
case 'right': return { x: r.x + r.width, y: r.y + r.height * fraction, side };
|
||||
case 'top': return { x: r.x + r.width * fraction, y: r.y, side };
|
||||
default: return { x: r.x + r.width * fraction, y: r.y + r.height, side };
|
||||
}
|
||||
}
|
||||
|
||||
/** Where along an edge each of n lines leaves it: one line at the middle spot, several spread across the band. */
|
||||
export function fanFractions(n: number): number[] {
|
||||
if (n <= 1) return [SINGLE_AT];
|
||||
return Array.from({ length: n }, (_, i) => FAN_FROM + ((FAN_TO - FAN_FROM) * i) / (n - 1));
|
||||
}
|
||||
|
||||
/** Left/right pairs: out, across, in. Rounded at both bends. */
|
||||
export function elbowPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const midX = x1 + dx / 2;
|
||||
const r = (Math.abs(dy) < 1 || Math.abs(dx) < ELBOW_RADIUS * 2)
|
||||
? 0
|
||||
: Math.min(ELBOW_RADIUS, Math.abs(dy) / 2, Math.abs(dx) / 4);
|
||||
const sy = dy >= 0 ? 1 : -1;
|
||||
const sx = dx >= 0 ? 1 : -1;
|
||||
return [
|
||||
`M ${x1},${y1}`,
|
||||
`H ${midX - sx * r}`,
|
||||
`Q ${midX},${y1} ${midX},${y1 + sy * r}`,
|
||||
`V ${y2 - sy * r}`,
|
||||
`Q ${midX},${y2} ${midX + sx * r},${y2}`,
|
||||
`H ${x2}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Top/bottom pairs: the same elbow turned on its side. */
|
||||
export function verticalElbowPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const midY = y1 + dy / 2;
|
||||
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
|
||||
? 0
|
||||
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
|
||||
const sx = dx >= 0 ? 1 : -1;
|
||||
const sy = dy >= 0 ? 1 : -1;
|
||||
return [
|
||||
`M ${x1},${y1}`,
|
||||
`V ${midY - sy * r}`,
|
||||
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
|
||||
`H ${x2 - sx * r}`,
|
||||
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
|
||||
`V ${y2}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Unit direction the line travels as it enters a card through `side`. */
|
||||
function entryDirection(side: Side): { x: number; y: number } {
|
||||
switch (side) {
|
||||
case 'left': return { x: 1, y: 0 };
|
||||
case 'right': return { x: -1, y: 0 };
|
||||
case 'top': return { x: 0, y: 1 };
|
||||
default: return { x: 0, y: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled triangle whose tip sits on the card edge, pointing the way the line travels. */
|
||||
export function headPath(tip: Anchor, size: number): string {
|
||||
const d = entryDirection(tip.side);
|
||||
const bx = tip.x - d.x * size;
|
||||
const by = tip.y - d.y * size;
|
||||
const px = -d.y * size * 0.5;
|
||||
const py = d.x * size * 0.5;
|
||||
return `M ${tip.x},${tip.y} L ${bx + px},${by + py} L ${bx - px},${by - py} Z`;
|
||||
}
|
||||
|
||||
/** The line stops short of the tip so the stroke never pokes through the head. */
|
||||
export function route(a: Anchor, b: Anchor, headSize: number): Routed {
|
||||
const d = entryDirection(b.side);
|
||||
const shorten = headSize * 0.7;
|
||||
const ex = b.x - d.x * shorten;
|
||||
const ey = b.y - d.y * shorten;
|
||||
const horizontal = a.side === 'left' || a.side === 'right';
|
||||
const path = horizontal ? elbowPath(a.x, a.y, ex, ey) : verticalElbowPath(a.x, a.y, ex, ey);
|
||||
return {
|
||||
path,
|
||||
head: headPath(b, headSize),
|
||||
labelX: a.x + (b.x - a.x) / 2,
|
||||
labelY: a.y + (b.y - a.y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Side = 'left' | 'right' | 'top' | 'bottom';
|
||||
export interface Anchor { x: number; y: number; side: Side }
|
||||
export interface Rect { x: number; y: number; width: number; height: number }
|
||||
export interface Routed { path: string; head: string; labelX: number; labelY: number }
|
||||
Reference in New Issue
Block a user