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 ) { 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(); 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["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; } }