import type { GraphEdge } from '../../core/graph'; /** * Whether a folded edge shows its curve anyway. Folding hides the line across the graph, but not * the connection: selecting either end brings it back, and a pulse travelling the edge always * shows – a trace that skipped an edge would lose the thread it is there to draw. */ export function foldedEdgeShown(edge: GraphEdge, selected: ReadonlySet, hot: boolean): boolean { return hot || selected.has(edge.from.node) || selected.has(edge.to.node); } /** * One mark per port (per side on a collapsed node), so hidden links of one port don't stack on the same * pixels. The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c". */ export function foldMarkKey( side: { node: string; port: string }, isOutput: boolean, collapsed = false, ): string { return `${side.node}\0${collapsed ? '' : side.port}\0${isOutput ? 'out' : 'in'}`; } /** A collapsed node is as wide as its title needs, but never wider than an open one. */ export function collapsedNodeWidth(titleWidth: number, chrome: number, openWidth: number): number { return Math.min(openWidth, Math.ceil(titleWidth + chrome)); } /** One name plus a count: three names do not fit, and the count is what says "there is more here". */ export function foldMarkLabel(names: readonly string[]): string { if (names.length === 0) return ''; return names.length === 1 ? names[0]! : `${names[0]} +${names.length - 1}`; } /** One end of one hidden link, as the canvas measured it. */ export interface FoldMarkEnd { edgeId: string; key: string; /** The node at the far end. */ other: string; otherLabel: string; isOutput: boolean; portX: number; portY: number; color: number; hot: boolean; } /** The marks to draw: one per port, however many links it hides. */ export interface FoldMarkPlan { edgeIds: string[]; /** Far-end nodes, deduped – clicking the mark selects them all. */ others: string[]; names: string[]; isOutput: boolean; x: number; y: number; portX: number; color: number; hot: boolean; } /** A bundle is drawn slightly larger, so "more than one link hides here" is visible. */ export function foldMarkRadius(linkCount: number, base: number): number { return linkCount > 1 ? base + 2 : base; } /** * Groups the ends by port, in the order they were met. `stub` is how far the mark sits out of its * port – the direction follows `isOutput`. */ export function buildFoldMarks(ends: readonly FoldMarkEnd[], stub: number): FoldMarkPlan[] { const byKey = new Map(); for (const end of ends) { let mark = byKey.get(end.key); if (!mark) { mark = { edgeIds: [], others: [], names: [], isOutput: end.isOutput, x: end.portX + (end.isOutput ? stub : -stub), y: end.portY, portX: end.portX, color: end.color, hot: end.hot, }; byKey.set(end.key, mark); } mark.edgeIds.push(end.edgeId); mark.names.push(end.otherLabel); if (!mark.others.includes(end.other)) mark.others.push(end.other); // A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting. if (end.hot && !mark.hot) { mark.hot = true; mark.color = end.color; } } return [...byKey.values()]; } /** The mark under a point, or null. `slack` widens the dot so it stays clickable. */ export function foldMarkAt( marks: readonly T[], point: { x: number; y: number }, base: number, slack = 4, ): T | null { for (const mark of marks) { const radius = foldMarkRadius(mark.edgeIds.length, base); if (Math.hypot(point.x - mark.x, point.y - mark.y) <= radius + slack) return mark; } return null; }