/** * What a drag in the graph carries and how it is written back. A group takes the nodes whose centre * lies in it and the boxes inside it, a band may have caught nodes and boxes together, and either * way a thing must move exactly once – a node inside a selected group is claimed twice and offset * once. Pure over the graph and the drawn node boxes, so the rule is testable without a canvas. */ import { type AnnotationBox, SetAnnotationBoxCommand } from '../../core/commands/annotationCommands'; import type { Command } from '../../core/commands/Command'; import { MoveNodeCommand } from '../../core/commands/graphCommands'; import { type AnnotationKind, ANNOTATION_KINDS, annotationsOf, findAnnotation, type Graph, type GraphAnnotation, } from '../../core/graph'; import { type Box, boxesCentredIn, type Point } from '../framing'; /** A box as the drag found it. */ export interface BoxSeed { id: string; kind: AnnotationKind; start: AnnotationBox; } /** A node as the drag found it. */ export interface NodeSeed { nodeId: string; start: Point; } export interface Travellers { boxes: BoxSeed[]; nodes: NodeSeed[]; } export function boxOf(annotation: GraphAnnotation): AnnotationBox { return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h }; } /** Every group and comment as the rectangle it is drawn as – a box states its own size. */ export function annotationBoxes(graph: Graph): Box[] { return ANNOTATION_KINDS.flatMap((kind) => annotationsOf(graph, kind).map((a) => ({ id: a.id, ...boxOf(a) })), ); } /** The boxes with these ids as drag seeds, `exceptId` left out (it is the grabbed one). */ export function boxSeeds(graph: Graph, ids: readonly string[], exceptId?: string): BoxSeed[] { const seeds: BoxSeed[] = []; for (const id of ids) { if (id === exceptId) continue; const found = findAnnotation(graph, id); if (found) seeds.push({ id, kind: found.kind, start: boxOf(found.annotation) }); } return seeds; } /** * Everything one move gesture carries: the boxes it starts from, the boxes those hold, and every * node beneath any of them – each node exactly once, however many groups claim it. `nodeBoxes` * are the nodes as drawn, since a node's height is the view's to know. */ export function travellers( graph: Graph, nodeBoxes: readonly Box[], seedBoxes: readonly BoxSeed[], seedNodeIds: readonly string[], ): Travellers { const boxes = new Map(seedBoxes.map((b) => [b.id, b])); for (const seed of seedBoxes) { if (seed.kind !== 'group') continue; for (const nested of nestedMembers(graph, seed.id, seed.start)) if (!boxes.has(nested.id)) boxes.set(nested.id, nested); } const nodes = new Map(); for (const id of seedNodeIds) { const node = graph.nodes.find((n) => n.id === id); if (node) nodes.set(id, { nodeId: id, start: { x: node.x, y: node.y } }); } for (const box of boxes.values()) { if (box.kind !== 'group') continue; for (const member of nodeMembers(graph, nodeBoxes, box.start)) if (!nodes.has(member.nodeId)) nodes.set(member.nodeId, member); } return { boxes: [...boxes.values()], nodes: [...nodes.values()] }; } /** The nodes a group at this box carries – the centre rule, against what is drawn right now. */ function nodeMembers(graph: Graph, nodeBoxes: readonly Box[], box: AnnotationBox): NodeSeed[] { const ids = new Set(boxesCentredIn(nodeBoxes, box)); return graph.nodes.filter((n) => ids.has(n.id)).map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })); } /** Groups and comments inside a group: the outer one takes them, and with them their own nodes. */ function nestedMembers(graph: Graph, selfId: string, box: AnnotationBox): BoxSeed[] { const inside: BoxSeed[] = []; for (const kind of ANNOTATION_KINDS) { const list = annotationsOf(graph, kind).filter((a) => a.id !== selfId); const ids = new Set( boxesCentredIn( list.map((a) => ({ id: a.id, ...boxOf(a) })), box, ), ); for (const a of list) if (ids.has(a.id)) inside.push({ id: a.id, kind, start: boxOf(a) }); } return inside; } /** * Puts every traveller where the gesture has it now, for the preview. `offsetFor` answers per start * position, because with snapping on each thing lands on the grid for itself, as a node does. */ export function placeTravellers(graph: Graph, along: Travellers, offsetFor: (start: Point) => Point): void { for (const member of along.nodes) { const node = graph.nodes.find((n) => n.id === member.nodeId); if (!node) continue; const offset = offsetFor(member.start); node.x = member.start.x + offset.x; node.y = member.start.y + offset.y; } for (const member of along.boxes) { const box = findAnnotation(graph, member.id)?.annotation; if (!box) continue; const offset = offsetFor(member.start); box.x = member.start.x + offset.x; box.y = member.start.y + offset.y; } } /** * The commands that take everything from where the drag found it to where the preview left it. * The preview has already moved the model; each thing is put back first so the command records the * start, and the caller runs them as one step. `label` names a box command that is not a plain move. */ export function recordTravellers( graph: Graph, assetId: string, along: Travellers, label?: string, ): Command[] { const commands: Command[] = []; for (const member of along.boxes) { const box = findAnnotation(graph, member.id)?.annotation; if (!box) continue; const next = boxOf(box); Object.assign(box, member.start); commands.push(new SetAnnotationBoxCommand(assetId, member.kind, member.id, next, label)); } for (const member of along.nodes) { const node = graph.nodes.find((n) => n.id === member.nodeId); if (!node) continue; const next = { x: node.x, y: node.y }; node.x = member.start.x; node.y = member.start.y; commands.push(new MoveNodeCommand(assetId, member.nodeId, next.x, next.y)); } return commands; }