diff --git a/README.md b/README.md index 3a516af..7891eeb 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ The ‹ chip on a node collapses its branch. The map autosaves to local storage; ## Not yet -Row-wise matrices, tree tables and the unbalanced map, which open as the nearest structure; XMind's smileys, arrows, font sizes and line styles. An import lists what it could not keep. +Tree tables and the unbalanced map, which open as the nearest structure; XMind's smileys, arrows, font sizes and line styles. An import lists what it could not keep. ## Added diff --git a/src/App.tsx b/src/App.tsx index 2e7af47..e259285 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -630,7 +630,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta ]} /> ({ + ...([["right", "Logic (right)"], ["left", "Logic (left)"], ["map", "Map (both sides)"], ["map-ccw", "Map (anticlockwise)"], ["down", "Org chart (down)"], ["up", "Org chart (up)"], ["tree", "Tree (right)"], ["tree-left", "Tree (left)"], ["fish", "Fishbone (head right)"], ["fish-left", "Fishbone (head left)"], ["line", "Timeline (horizontal)"], ["vline", "Timeline (vertical)"], ["brace", "Brace (right)"], ["brace-left", "Brace (left)"], ["matrix", "Matrix (columns)"], ["matrix-row", "Matrix (rows)"]] as const).map(([v, l]) => ({ label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }), })), { heading: "Theme" }, diff --git a/src/export.ts b/src/export.ts index eeadb52..5538c4e 100644 --- a/src/export.ts +++ b/src/export.ts @@ -19,6 +19,12 @@ export function branchD(b: Box, p: Box): string { return `M${x0} ${y0} C${x0} ${my} ${x1} ${my} ${x1} ${b.y}`; } if (b.flow === "brace") return ""; // drawn per parent by `braces` + if (b.flow === "rmatrix") { + const a = b.anchor; + if (a && b.depth === 1) return `M${a.x} ${a.y}V${b.y + b.h / 2}H${b.x}`; // down the line from the previous row header, then across + if (a) return `M${a.x} ${a.y}H${b.x}`; // along the row from the previous cell + return `M${p.x + 12} ${p.y + p.h}V${b.y + b.h / 2}H${b.x}`; // a stack: down from its cell, then across + } if (b.flow === "vline" && b.anchor) return `M${b.anchor.x} ${b.anchor.y}V${b.y + b.h / 2}H${b.side === 1 ? b.x : b.x + b.w}`; // down the line from the previous topic, then across to this one if (b.flow === "line" || b.flow === "matrix") { if (b.anchor) return `M${b.anchor.x} ${b.anchor.y}H${b.x}`; // along the line from the previous topic diff --git a/src/importreport.test.ts b/src/importreport.test.ts index 08eb075..fe7cd21 100644 --- a/src/importreport.test.ts +++ b/src/importreport.test.ts @@ -40,13 +40,13 @@ test("a clean file reports nothing", async () => { }); test("XML import lists what it drops", async () => { - const xml = `r + const xml = `r af `; const { lost } = await importXmindReport(zip({ "content.xml": xml })); expect(lost).toEqual(expect.arrayContaining([ - "structure spreadsheet.row opened as matrix", "markers with no counterpart dropped", "floating topics dropped", "relationships dropped", + "structure treetable opened as tree", "markers with no counterpart dropped", "floating topics dropped", "relationships dropped", ])); }); diff --git a/src/model.ts b/src/model.ts index 848c3af..4d944a5 100644 --- a/src/model.ts +++ b/src/model.ts @@ -24,7 +24,7 @@ export type Node = { shape?: Shape; float?: { x: number; y: number }; // top-level topics only: free-standing at this canvas position, no branch to the root // Document-level settings, only meaningful on the root node. - layout?: "right" | "left" | "map" | "down" | "tree" | "fish" | "line" | "brace" | "matrix" | "vline" | "up" | "tree-left" | "brace-left" | "fish-left" | "map-ccw"; // logic chart (right or left), two-sided map, org chart, tree chart, fishbone, timeline, brace map, matrix + layout?: "right" | "left" | "map" | "down" | "tree" | "fish" | "line" | "brace" | "matrix" | "matrix-row" | "vline" | "up" | "tree-left" | "brace-left" | "fish-left" | "map-ccw"; // logic chart (right or left), two-sided map, org chart, tree chart, fishbone, timeline, brace map, matrix theme?: string; themeColors?: string[]; // the "custom" theme: central topic and accent, then six branch colours (#rrggbb) title?: string; // sheet name (tab label); falls back to the central topic text @@ -37,7 +37,7 @@ export type Box = { parent?: string; depth: number; side: 1 | -1; // which side of the root the node grows towards branch: number; // index of the top-level ancestor (-1 for the root) - flow: "right" | "down" | "tree" | "fish" | "line" | "vline" | "brace" | "matrix"; // how branches run: sideways, top-down (org chart), an indented list (tree chart), along a fishbone or a timeline + flow: "right" | "down" | "tree" | "fish" | "line" | "vline" | "rmatrix" | "brace" | "matrix"; // how branches run: sideways, top-down (org chart), an indented list (tree chart), along a fishbone or a timeline anchor?: { x: number; y: number }; // fishbone and timeline: where this topic's connector starts or meets the line flip?: Flip; // the structure is a mirror image of its base (org chart up, tree left…): the box is already mirrored, `unflip` gives the base geometry }; @@ -285,6 +285,50 @@ function vtimeline(root: Node): (Raw & { side: 1 | -1 })[] { const TL = { GAP: 36, ROW: 14, NEST: 24 }; +const RM = { GAP: 28 }; + +/** + * Row matrix: the root heads a vertical line and each top-level topic is a row header on it. Its children are the cells of the row, + * side by side, and each cell's descendants stack beneath it. Anchors: a header's marks where its stretch of the line starts, + * a cell's where the previous cell (or the header) ends. + */ +function rowmatrix(root: Node): Raw[] { + const rowH = H + GAP_Y; + const out: Raw[] = [{ id: root.id, x: 0, y: 0, w: widthOf(root), h: H, depth: 0 }]; + let top = H + RM.GAP, prev = H; // top of the next row; where the previous stretch of the line ended + (root.collapsed ? [] : root.children).forEach((label) => { + const lw = widthOf(label); + const head: Raw = { id: label.id, x: RM.GAP, y: top, w: lw, h: H, parent: root.id, depth: 1, anchor: { x: RM.GAP / 2, y: prev } }; + out.push(head); + let bottom = top + H, cursor = RM.GAP + lw + RM.GAP, prevRight = RM.GAP + lw; + (label.collapsed ? [] : label.children).forEach((cell) => { + const cw = widthOf(cell); + const box: Raw = { id: cell.id, x: cursor, y: top, w: cw, h: H, parent: label.id, depth: 2, anchor: { x: prevRight, y: top + H / 2 } }; + out.push(box); + const at = new Map([[cell.id, box]]); + let right = cursor + cw, j = 0; + const walk = (n: Node, d: number) => { + if (n.collapsed) return; + for (const c of n.children) { + const w = widthOf(c), x = at.get(n.id)!.x + TL.NEST; + const b: Raw = { id: c.id, x, y: top + H + TL.ROW + j++ * rowH, w, h: H, parent: n.id, depth: d + 1 }; + out.push(b); + at.set(c.id, b); + right = Math.max(right, x + w); + bottom = Math.max(bottom, b.y + H); + walk(c, d + 1); + } + }; + walk(cell, 2); + prevRight = cursor + cw; + cursor = right + RM.GAP; + }); + prev = top + H / 2; + top = bottom + RM.GAP; + }); + return out; +} + /** * Timeline: the root starts a horizontal line and the top-level topics sit on it, in order. Each topic's * descendants stack in a column under it (first topic), above it (second), and so on, indented by depth. @@ -328,14 +372,14 @@ function timeline(root: Node, alternate = true): Raw[] { export function layout(root: Node): Box[] { const [base, flip] = VARIANTS[root.layout ?? "right"] ?? [root.layout ?? "right", undefined]; const kids = root.children.filter((c) => !c.float); // floating topics are placed on their own below - const flows: Record = { down: "down", tree: "tree", fish: "fish", line: "line", vline: "vline", matrix: "matrix", brace: "brace" }; + const flows: Record = { down: "down", tree: "tree", fish: "fish", line: "line", vline: "vline", matrix: "matrix", "matrix-row": "rmatrix", brace: "brace" }; const flow = flows[base] ?? "right"; const out: Box[] = []; if (flow === "vline") { out.push(...vtimeline({ ...root, children: kids }).map((b): Box => ({ ...b, branch: -1, flow }))); } else if (flow !== "right" && flow !== "brace") { - const raw = { down: vertical, tree: indented, fish: fishbone, line: timeline, matrix: (r: Node) => timeline(r, false) }[flow]({ ...root, children: kids }); + const raw = { down: vertical, tree: indented, fish: fishbone, line: timeline, matrix: (r: Node) => timeline(r, false), rmatrix: rowmatrix }[flow]({ ...root, children: kids }); out.push(...raw.map((b): Box => ({ ...b, side: 1, branch: -1, flow }))); } else { // Sideways structures share one geometry: "right" and "brace" grow right, "left" grows left, "map" splits both ways. @@ -397,6 +441,26 @@ export function neighbour(root: Node, boxes: Box[], id: string, dir: "left" | "r const stops = boxes.filter((b) => b.flow === "vline" && b.depth <= 1).sort((a, b) => a.y - b.y); // the head, then the topics down the line return (stops[stops.findIndex((b) => b.id === id) + (dir === "down" ? 1 : -1)] ?? me).id; } + if (me.flow === "rmatrix") { + const rows = boxes.filter((b) => b.flow === "rmatrix" && b.depth === 1).sort((a, b) => a.y - b.y); + if (me.depth === 0) return dir === "down" || dir === "right" ? (rows[0]?.id ?? id) : id; + if (me.depth === 1) { + if (dir === "left") return me.parent ?? id; + if (dir === "right") return firstKid(); + const next = dir === "down" ? rows[rows.findIndex((b) => b.id === id) + 1] : rows[rows.findIndex((b) => b.id === id) - 1] ?? boxes[0]; + return (next ?? me).id; + } + if (me.depth === 2) { + const cells = findNode(root, me.parent!)!.children, i = cells.findIndex((c) => c.id === id); + if (dir === "left") return (cells[i - 1] ?? findNode(root, me.parent!)!).id; + if (dir === "right") return (cells[i + 1] ?? cells[i]).id; + return dir === "down" ? firstKid() : id; + } + const order = boxes.filter((b) => b.flow === "rmatrix"); // document order: a cell's stack follows the cell + if (dir === "left" || dir === "up") return me.parent ?? id; + const next = order[order.findIndex((b) => b.id === id) + 1]; + return dir === "down" && next && next.depth >= 3 ? next.id : id; + } if (me.flow === "down") { if (dir === "up") return me.parent ?? id; if (dir === "down") return firstKid(); diff --git a/src/structure.test.ts b/src/structure.test.ts index 2583193..03e1cf2 100644 --- a/src/structure.test.ts +++ b/src/structure.test.ts @@ -71,7 +71,7 @@ test("summaries are only drawn in sideways layouts", () => { }); test("xmind export/import keeps the structure", async () => { - for (const kind of ["right", "left", "map", "down", "tree", "fish", "line", "brace", "matrix", "up", "tree-left", "brace-left", "fish-left", "map-ccw", "vline"] as const) { + for (const kind of ["right", "left", "map", "down", "tree", "fish", "line", "brace", "matrix", "up", "tree-left", "brace-left", "fish-left", "map-ccw", "vline", "matrix-row"] as const) { const back = await importXmindSheets(exportXmind({ ...mk("r"), layout: kind })); expect(back[0].layout ?? "right").toBe(kind); } @@ -338,3 +338,46 @@ test("vertical timeline: arrow keys run down the line and into the stacks", () = expect([go(t[0].id, "right"), go(r.id, "right"), go(r.id, "left")]).toEqual([kids[0].id, t[0].id, t[1].id]); // right-hand topic into its stack; root to the first topic on each side expect(go(kids[0].id, "left")).toBe(t[0].id); }); + +function rows() { + let r: Node = { ...mk("head"), layout: "matrix-row" }; + const [x, y] = [mk("row X"), mk("row Y")]; + r = addChild(addChild(r, r.id, x), r.id, y); + const cells = ["c1", "c2", "c3"].map(mk); + for (const c of cells) r = addChild(r, x.id, c); + const sub = ["s1", "s2"].map(mk); + for (const s of sub) r = addChild(r, cells[0].id, s); // the first cell is tall: it widens its column, not the row above + r = addChild(r, y.id, mk("lonely")); + return { r, x, y, cells, sub }; +} + +test("row matrix: headers down the line, cells side by side, stacks under cells", () => { + const { r, x, y, cells, sub } = rows(); + const boxes = layout(r), by = Object.fromEntries(boxes.map((b) => [b.id, b])); + expect(boxes.every((b) => b.flow === "rmatrix")).toBe(true); + expect(by[y.id].y).toBeGreaterThan(by[x.id].y + by[x.id].h); // rows below one another + expect(by[x.id].x).toBe(by[y.id].x); // headers share a column + const cy = cells.map((c) => by[c.id]); + expect(cy.every((b) => b.y === by[x.id].y)).toBe(true); // cells sit on the header's line + expect(cy[0].x).toBeGreaterThan(by[x.id].x + by[x.id].w); + expect(cy[1].x).toBeGreaterThan(cy[0].x + cy[0].w); + expect(cy[2].x).toBeGreaterThan(cy[1].x + cy[1].w); + expect(sub.map((s) => by[s.id].y)).toEqual([...sub.map((s) => by[s.id].y)].sort((a, b) => a - b)); // stacked downwards + expect(by[sub[0].id].y).toBeGreaterThan(cy[0].y + cy[0].h); + expect(by[y.id].y).toBeGreaterThan(by[sub[1].id].y + H_BOX); // the next row starts below the tallest stack + for (const a of boxes) for (const b of boxes) { + if (a.id < b.id) expect(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y, `${a.id} vs ${b.id}`).toBe(true); + } +}); +const H_BOX = 34; + +test("row matrix: connectors and arrow keys", () => { + const { r, x, y, cells, sub } = rows(); + const boxes = layout(r), by = Object.fromEntries(boxes.map((b) => [b.id, b])), go = (id: string, d: "left" | "right" | "up" | "down") => neighbour(r, boxes, id, d); + expect(branchD(by[x.id], boxes[0])).toMatch(/^M[\d.]+ [\d.]+V[\d.]+H[\d.]+$/); + expect(branchD(by[cells[1].id], by[x.id])).toMatch(/^M[\d.]+ [\d.]+H[\d.]+$/); + expect(branchD(by[sub[0].id], by[cells[0].id])).toMatch(/V/); + expect([go(r.id, "down"), go(x.id, "down"), go(y.id, "up"), go(x.id, "up")]).toEqual([x.id, y.id, x.id, r.id]); + expect([go(x.id, "right"), go(cells[0].id, "right"), go(cells[2].id, "right"), go(cells[1].id, "left"), go(cells[0].id, "left")]).toEqual([cells[0].id, cells[1].id, cells[2].id, cells[0].id, x.id]); + expect([go(cells[0].id, "down"), go(sub[0].id, "down"), go(sub[1].id, "down"), go(sub[1].id, "up")]).toEqual([sub[0].id, sub[1].id, sub[1].id, cells[0].id]); // up from a stacked topic goes to its parent +}); diff --git a/src/xmind.ts b/src/xmind.ts index 02bf9a7..539fc95 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -252,6 +252,7 @@ const STRUCTURE: Record, string> = { fish: "org.xmind.ui.fishbone.rightHeaded", line: "org.xmind.ui.timeline.horizontal", vline: "org.xmind.ui.timeline.vertical", + "matrix-row": "org.xmind.ui.spreadsheet.row", left: "org.xmind.ui.logic.left", brace: "org.xmind.ui.brace.right", matrix: "org.xmind.ui.spreadsheet.column", diff --git a/src/xmindxml.test.ts b/src/xmindxml.test.ts index 78b1c47..0ea9397 100644 --- a/src/xmindxml.test.ts +++ b/src/xmindxml.test.ts @@ -72,7 +72,8 @@ test("layoutOf covers the remaining XMind structure classes", () => { "org.xmind.ui.brace.right": "brace", "org.xmind.ui.brace.left": "brace-left", "org.xmind.ui.spreadsheet.column": "matrix", - "org.xmind.ui.structure.spreadsheet.row": "matrix", + "org.xmind.ui.structure.spreadsheet.row": "matrix-row", + "org.xmind.ui.spreadsheet.row": "matrix-row", "org.xmind.ui.treetable.toptitle": "tree", // nearest match "org.xmind.ui.map.unbalanced": "map", // XMind's default map "org.xmind.ui.logic-chart.right": undefined, diff --git a/src/xmindxml.ts b/src/xmindxml.ts index 288ed0c..734e794 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -83,7 +83,7 @@ const allText = (n: XNode): string => n.text.trim() || n.children.map(allText).f export function layoutOf(structureClass: string): Node["layout"] | undefined { if (structureClass.includes("logic.left")) return "left"; if (structureClass.includes("brace")) return structureClass.includes("brace.left") ? "brace-left" : "brace"; - if (structureClass.includes("spreadsheet")) return "matrix"; // XMind's matrix, row or column + if (structureClass.includes("spreadsheet")) return /row/i.test(structureClass) ? "matrix-row" : "matrix"; // XMind's matrix, by rows or by columns if (structureClass.includes("fishbone")) return structureClass.includes("leftHeaded") ? "fish-left" : "fish"; if (structureClass.includes("timeline")) return structureClass.includes("vertical") ? "vline" : "line"; if (structureClass.includes("org-chart")) return structureClass.includes("org-chart.up") ? "up" : "down"; @@ -98,7 +98,7 @@ export function layoutOf(structureClass: string): Node["layout"] | undefined { const EXACT: Record = { "org.xmind.ui.logic.right": "right", "org.xmind.ui.logic.left": "left", "org.xmind.ui.map.clockwise": "map", "org.xmind.ui.org-chart.down": "down", "org.xmind.ui.tree.right": "tree", "org.xmind.ui.fishbone.rightHeaded": "fish", - "org.xmind.ui.timeline.horizontal": "line", "org.xmind.ui.timeline.vertical": "vline", "org.xmind.ui.brace.right": "brace", "org.xmind.ui.spreadsheet.column": "matrix", + "org.xmind.ui.timeline.horizontal": "line", "org.xmind.ui.timeline.vertical": "vline", "org.xmind.ui.brace.right": "brace", "org.xmind.ui.spreadsheet.column": "matrix", "org.xmind.ui.spreadsheet.row": "matrix-row", "org.xmind.ui.org-chart.up": "up", "org.xmind.ui.tree.left": "tree-left", "org.xmind.ui.brace.left": "brace-left", "org.xmind.ui.fishbone.leftHeaded": "fish-left", "org.xmind.ui.map.anticlockwise": "map-ccw", };