ai-chatbot/lib/flow-board/mind-annotations.ts

124 lines
3.7 KiB
TypeScript

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