124 lines
3.7 KiB
TypeScript
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],
|
|
];
|
|
}
|