395 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|