ai-chatbot/lib/flow-board/mind-special-layout.ts

395 lines
12 KiB
TypeScript

export const SPECIAL_MIND_LAYOUTS = {
fishbone: {
structure: "org.xmind.ui.fishbone.leftHeaded",
base: "right",
label: "鱼骨图",
preview: "fishbone",
},
timeline: {
structure: "org.xmind.ui.timeline.sided.horizontal",
base: "right",
label: "水平时间轴",
preview: "timeline",
},
verticalTimeline: {
structure: "org.xmind.ui.timeline.vertical",
base: "right",
label: "垂直时间轴",
preview: "verticalTimeline",
},
treeTable: {
structure: "org.xmind.ui.treetable.toptitle",
base: "right",
label: "树形表格",
preview: "treeTable",
},
treeTableSide: {
structure: "org.xmind.ui.treetable",
base: "right",
label: "树形表格(侧标题)",
preview: "treeTableSide",
},
spreadsheet: {
structure: "org.xmind.ui.spreadsheet",
base: "right",
label: "矩阵",
preview: "spreadsheet",
},
brace: {
structure: "org.xmind.ui.brace.right",
base: "right",
label: "括号图",
preview: "brace",
},
} as const;
export type SpecialMindLayout = keyof typeof SPECIAL_MIND_LAYOUTS;
export function isSpecialMindLayout(
value: unknown
): value is SpecialMindLayout {
return (
typeof value === "string" && Object.hasOwn(SPECIAL_MIND_LAYOUTS, value)
);
}
export function specialLayoutForStructure(structure: string) {
return (Object.keys(SPECIAL_MIND_LAYOUTS) as SpecialMindLayout[]).find(
(key) => SPECIAL_MIND_LAYOUTS[key].structure === structure
);
}
export type SpecialLayoutNode = {
x: number;
y: number;
width: number;
height: number;
hGap: number;
vGap: number;
origin: {
id?: string;
start?: number;
end?: number;
fill?: string;
spreadsheetColumns?: string[];
xmind?: { labels?: string[] };
};
children: SpecialLayoutNode[];
tableCell?: {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
tableGrid?: {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
};
export const specialNodeRect = (node: SpecialLayoutNode) => ({
x: node.x + node.hGap,
y: node.y + node.vGap,
width: node.width - 2 * node.hGap,
height: node.height - 2 * node.vGap,
});
export const isSummaryNode = (node: SpecialLayoutNode) =>
Number.isInteger(node.origin.start) && Number.isInteger(node.origin.end);
export function specialSubtreeBounds(node: SpecialLayoutNode) {
const nodes: SpecialLayoutNode[] = [];
const visit = (item: SpecialLayoutNode) => {
nodes.push(item);
item.children.forEach(visit);
};
visit(node);
const rects = nodes.map(specialNodeRect);
const x = Math.min(...rects.map((rect) => rect.x));
const y = Math.min(...rects.map((rect) => rect.y));
return {
x,
y,
width: Math.max(...rects.map((rect) => rect.x + rect.width)) - x,
height: Math.max(...rects.map((rect) => rect.y + rect.height)) - y,
};
}
function translate(node: SpecialLayoutNode, dx: number, dy: number) {
node.x += dx;
node.y += dy;
node.children.forEach((child) => translate(child, dx, dy));
}
function mirrorHorizontally(node: SpecialLayoutNode, axis: number) {
node.x = 2 * axis - node.x - node.width;
node.children.forEach((child) => mirrorHorizontally(child, axis));
}
function placeInTableCell(
node: SpecialLayoutNode,
cell: NonNullable<SpecialLayoutNode["tableCell"]>
) {
node.x = cell.x - node.hGap;
node.y = cell.y - node.vGap;
node.width = cell.width + node.hGap * 2;
node.height = cell.height + node.vGap * 2;
node.tableCell = cell;
}
function arrangeTreeTable(root: SpecialLayoutNode, title: "top" | "side") {
const branches = root.children.filter((node) => !isSummaryNode(node));
if (!branches.length) return;
let maxDepth = 1;
const visit = (node: SpecialLayoutNode, depth: number) => {
maxDepth = Math.max(maxDepth, depth);
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
visit(child, depth + 1)
);
};
branches.forEach((node) => visit(node, 1));
const columnWidths = Array.from({ length: maxDepth }, () => 180);
const measure = (node: SpecialLayoutNode, depth: number) => {
const rect = specialNodeRect(node);
columnWidths[depth - 1] = Math.min(
360,
Math.max(columnWidths[depth - 1], rect.width + 48)
);
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
measure(child, depth + 1)
);
};
branches.forEach((node) => measure(node, 1));
const spans = new Map<SpecialLayoutNode, [number, number]>();
let nextRow = 0;
const assignRows = (node: SpecialLayoutNode): [number, number] => {
const children = node.children.filter((child) => !isSummaryNode(child));
if (!children.length) {
const row = nextRow++;
spans.set(node, [row, row]);
return [row, row];
}
const childSpans = children.map(assignRows);
const span: [number, number] = [
childSpans[0][0],
childSpans[childSpans.length - 1][1],
];
spans.set(node, span);
return span;
};
branches.forEach(assignRows);
const sourceRoot = specialNodeRect(root);
const rootWidth = Math.min(420, Math.max(220, sourceRoot.width + 48));
const branchWidth = columnWidths.reduce((sum, width) => sum + width, 0);
const tableWidth = branchWidth + (title === "side" ? rootWidth : 0);
const rowHeight = 78;
const headerHeight = title === "top" ? Math.max(96, sourceRoot.height + 36) : 0;
const tableX = sourceRoot.x;
const tableY = sourceRoot.y;
placeInTableCell(root, {
x: tableX,
y: tableY,
width: title === "top" ? tableWidth : rootWidth,
height: title === "top" ? headerHeight : Math.max(rowHeight, nextRow * rowHeight),
depth: 0,
branch: -1,
});
const position = (
node: SpecialLayoutNode,
depth: number,
branch: number
) => {
const [start, end] = spans.get(node)!;
const x =
tableX + (title === "side" ? rootWidth : 0) +
columnWidths.slice(0, depth - 1).reduce((sum, width) => sum + width, 0);
placeInTableCell(node, {
x,
y: tableY + headerHeight + start * rowHeight,
width: columnWidths[depth - 1],
height: (end - start + 1) * rowHeight,
depth,
branch,
});
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
position(child, depth + 1, branch)
);
};
branches.forEach((node, index) => position(node, 1, index));
}
function labelsOf(node: SpecialLayoutNode) {
return Array.isArray(node.origin.xmind?.labels)
? node.origin.xmind.labels.filter((label) => typeof label === "string")
: [];
}
function arrangeSpreadsheet(root: SpecialLayoutNode) {
const rows = root.children.filter((node) => !isSummaryNode(node));
if (!rows.length) return;
const configured = Array.isArray(root.origin.spreadsheetColumns)
? root.origin.spreadsheetColumns.filter(Boolean)
: [];
const derived = rows.flatMap((row) =>
row.children.flatMap((child) => labelsOf(child))
);
const columns = [...new Set(configured.length ? configured : derived)];
if (!columns.length) columns.push("列 1", "列 2", "列 3");
const sourceRoot = specialNodeRect(root);
const headerHeight = Math.max(86, sourceRoot.height + 30);
const columnHeaderHeight = 54;
const rowHeaderWidth = Math.min(
320,
Math.max(180, ...rows.map((row) => specialNodeRect(row).width + 42))
);
const entries = rows.flatMap((row) => row.children.filter((node) => !isSummaryNode(node)));
const columnWidth = Math.min(
300,
Math.max(170, ...entries.map((entry) => specialNodeRect(entry).width + 34))
);
const tableX = sourceRoot.x;
const tableY = sourceRoot.y;
const tableWidth = rowHeaderWidth + columns.length * columnWidth;
const rowGeometry: NonNullable<SpecialLayoutNode["tableGrid"]>["rows"] = [];
let y = tableY + headerHeight + columnHeaderHeight;
placeInTableCell(root, {
x: tableX,
y: tableY,
width: tableWidth,
height: headerHeight,
depth: 0,
branch: -1,
});
rows.forEach((row, branch) => {
const children = row.children.filter((node) => !isSummaryNode(node));
const buckets = columns.map(() => [] as SpecialLayoutNode[]);
let fallback = 0;
for (const child of children) {
const labels = labelsOf(child);
const labelled = labels
.map((label) => columns.indexOf(label))
.find((index) => index >= 0);
const column = labelled ?? fallback++ % columns.length;
buckets[column].push(child);
}
const count = Math.max(1, ...buckets.map((bucket) => bucket.length));
const rowHeight = Math.max(88, 18 + count * 42);
rowGeometry.push({ y, height: rowHeight, branch });
placeInTableCell(row, {
x: tableX,
y,
width: rowHeaderWidth,
height: rowHeight,
depth: 1,
branch,
});
buckets.forEach((bucket, column) => {
bucket.forEach((child, index) => {
placeInTableCell(child, {
x: tableX + rowHeaderWidth + column * columnWidth + 8,
y: y + 8 + index * 42,
width: columnWidth - 16,
height: 34,
depth: 2,
branch,
});
});
});
y += rowHeight;
});
root.tableGrid = {
kind: "spreadsheet",
x: tableX,
y: tableY,
width: tableWidth,
height: y - tableY,
columns,
headerHeight,
columnHeaderHeight,
rowHeaderWidth,
columnWidth,
rows: rowGeometry,
};
}
/** Pack whole subtrees in alternating rows, preserving their native local layout. */
export function arrangeSpecialMind(
root: SpecialLayoutNode,
kind: SpecialMindLayout
) {
if (kind === "brace") return;
if (kind === "treeTable" || kind === "treeTableSide") {
arrangeTreeTable(root, kind === "treeTable" ? "top" : "side");
return;
}
if (kind === "spreadsheet") {
arrangeSpreadsheet(root);
return;
}
const center = specialNodeRect(root);
const axisY = center.y + center.height / 2;
let columnX = center.x + center.width + 110;
const branches = root.children.filter((node) => !isSummaryNode(node));
if (kind === "verticalTimeline") {
const axisX = center.x + center.width / 2;
let rowY = center.y + center.height + 70;
branches.forEach((node, index) => {
const bounds = specialSubtreeBounds(node);
translate(node, axisX + 70 - bounds.x, rowY - bounds.y);
if (index % 2 === 1) mirrorHorizontally(node, axisX);
rowY += bounds.height + 40;
});
} else
for (let i = 0; i < branches.length; i += 2) {
const pair = branches.slice(i, i + 2);
let columnWidth = 0;
pair.forEach((node, side) => {
const bounds = specialSubtreeBounds(node);
const stagger =
side === 1
? kind === "fishbone"
? 48
: Math.max(80, bounds.width * 0.55)
: 0;
const y = side === 0 ? axisY - 76 - bounds.height : axisY + 76;
translate(node, columnX + stagger - bounds.x, y - bounds.y);
columnWidth = Math.max(columnWidth, bounds.width + stagger);
});
columnX += columnWidth + 100;
}
// Root-level summaries follow the full geometry of their covered branches.
let summaryX =
Math.max(
center.x + center.width,
...branches.map((node) => {
const rect = specialSubtreeBounds(node);
return rect.x + rect.width;
})
) + 80;
for (const node of root.children.filter(isSummaryNode)) {
const covered = branches
.slice(node.origin.start, Number(node.origin.end) + 1)
.map(specialSubtreeBounds);
if (!covered.length) continue;
const top = Math.min(...covered.map((rect) => rect.y));
const bottom = Math.max(...covered.map((rect) => rect.y + rect.height));
const rect = specialNodeRect(node);
translate(
node,
summaryX - rect.x,
(top + bottom - rect.height) / 2 - rect.y
);
summaryX += specialSubtreeBounds(node).width + 60;
}
}