import type { PlaitElement, Point } from "@plait/core"; export type MindAnnotation = | { kind: "relationship"; sourceId: string; targetId: string } | { kind: "boundary"; topicIds: string[]; padding: number }; export type AnnotatedElement = PlaitElement & { mindAnnotation: MindAnnotation; }; export type TopicRectangle = { x: number; y: number; width: number; height: number; }; export function isMindAnnotation( element: PlaitElement ): element is AnnotatedElement { const value = element.mindAnnotation as MindAnnotation | undefined; return ( (element.type === "arrow-line" && value?.kind === "relationship" && typeof value.sourceId === "string" && typeof value.targetId === "string") || (element.type === "geometry" && value?.kind === "boundary" && typeof value.padding === "number" && Number.isFinite(value.padding) && value.padding >= 0 && Array.isArray(value.topicIds) && value.topicIds.every((id) => typeof id === "string")) ); } /** XMind ranges must not silently include unselected, intervening siblings. */ export function validateTopicRange( paths: number[][], rightNodeCount?: number ): string | null { if (paths.length === 0) return "请先选择主题"; const parent = paths[0].slice(0, -1).join("/"); if (paths.some((path) => path.slice(0, -1).join("/") !== parent)) { return "请选择同一个父主题下的连续主题"; } const indices = [...new Set(paths.map((path) => path[path.length - 1]))].sort( (a, b) => a - b ); if (indices[indices.length - 1] - indices[0] + 1 !== indices.length) { return "请选择连续的主题,避免包含未选中的分支"; } if ( rightNodeCount && indices[0] < rightNodeCount && indices[indices.length - 1] >= rightNodeCount ) { return "请选择中心主题同一侧的分支"; } return null; } export function getBoundaryPoints( rectangles: TopicRectangle[], padding = 18 ): [Point, Point] | null { if (!rectangles.length) return null; return [ [ Math.min(...rectangles.map((r) => r.x)) - padding, Math.min(...rectangles.map((r) => r.y)) - padding, ], [ Math.max(...rectangles.map((r) => r.x + r.width)) + padding, Math.max(...rectangles.map((r) => r.y + r.height)) + padding, ], ]; } /** Bow aligned links around topics instead of drawing through intervening siblings. */ export function getRelationshipPoints( source: TopicRectangle, target: TopicRectangle ): Point[] { const a: Point = [source.x + source.width / 2, source.y + source.height / 2]; const b: Point = [target.x + target.width / 2, target.y + target.height / 2]; const dx = b[0] - a[0]; const dy = b[1] - a[1]; if (Math.abs(dx) < (source.width + target.width) / 2) { return [ [source.x + source.width, a[1]], [ Math.max(source.x + source.width, target.x + target.width) + 72, (a[1] + b[1]) / 2, ], [target.x + target.width, b[1]], ]; } if (Math.abs(dy) < (source.height + target.height) / 2) { return [ [a[0], source.y], [(a[0] + b[0]) / 2, Math.min(source.y, target.y) - 72], [b[0], target.y], ]; } if ( Math.abs(dx) / Math.max(1, (source.width + target.width) / 2) >= Math.abs(dy) / Math.max(1, (source.height + target.height) / 2) ) { const sign = dx >= 0 ? 1 : -1; return [ [a[0] + (sign * source.width) / 2, a[1]], [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 48], [b[0] - (sign * target.width) / 2, b[1]], ]; } const sign = dy >= 0 ? 1 : -1; return [ [a[0], a[1] + (sign * source.height) / 2], [Math.max(a[0], b[0]) + 72, (a[1] + b[1]) / 2], [b[0], b[1] - (sign * target.height) / 2], ]; }