From c97cf886ae913373f70e0c6e8d505da8f3c63314 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:20:05 +0000 Subject: [PATCH] Draw the mirrored XMind structures: org chart up, tree left, brace left, fishbone head left, map anticlockwise Each is its base structure flipped about the central topic. The layout mirrors the boxes and records the flip on them; connectors and braces are drawn in the base geometry and mirrored, and arrow-key navigation swaps directions where they follow the structure. They are in the View menu and round-trip through XMind instead of opening as the nearest structure. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G2FX9kDHnPYmocPxX3w7ov --- README.md | 2 +- src/App.tsx | 2 +- src/export.ts | 12 ++++---- src/importreport.test.ts | 4 +-- src/model.ts | 62 ++++++++++++++++++++++++++++++++++++++-- src/structure.test.ts | 62 +++++++++++++++++++++++++++++++++++++++- src/xmind.ts | 5 ++++ src/xmindxml.test.ts | 4 +-- src/xmindxml.ts | 14 +++++---- 9 files changed, 147 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4222466..7b16297 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 -Direction variants of XMind structures (up-pointing org chart, left-hand tree), which open as the nearest structure; XMind's smileys, arrows, font sizes and line styles. An import lists what it could not keep. +Vertical timelines, 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. ## Added diff --git a/src/App.tsx b/src/App.tsx index aad8ec7..be9d632 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"], ["brace", "Brace (right)"], ["brace-left", "Brace (left)"], ["matrix", "Matrix"]] 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 d334d07..857fe21 100644 --- a/src/export.ts +++ b/src/export.ts @@ -1,4 +1,4 @@ -import { Box, Node, findNode, thumbW, validImage, visibleIds } from "./model"; +import { Box, Node, findNode, mirrorPath, thumbW, unflip, validImage, visibleIds } from "./model"; import { summaryD, summaryGeo } from "./summary"; import { progressText } from "./task"; import { runsSvg, validRuns } from "./rich"; @@ -12,7 +12,8 @@ export function edgePoint(b: Box, tx: number, ty: number) { } /** Curve from parent `p` to child `b`, leaving on the side the child grows towards. */ -export function branchD(b: Box, p: Box) { +export function branchD(b: Box, p: Box): string { + if (b.flip) return mirrorPath(branchD(unflip(b), unflip(p)), b.flip); // drawn in the base structure, then mirrored if (b.flow === "down") { const x0 = p.x + p.w / 2, y0 = p.y + p.h, x1 = b.x + b.w / 2, my = (y0 + b.y) / 2; return `M${x0} ${y0} C${x0} ${my} ${x1} ${my} ${x1} ${b.y}`; @@ -35,7 +36,8 @@ export function branchD(b: Box, p: Box) { } /** Curly braces for the brace structure: one per parent, spanning its children, with a stem back to the parent. */ -export function braces(boxes: Box[]) { +export function braces(all: Box[]) { + const flip = all.find((b) => b.flip)?.flip, boxes = all.map(unflip); // drawn in the base structure, then mirrored const byId = Object.fromEntries(boxes.map((b) => [b.id, b])); const groups = new Map(); for (const b of boxes) if (b.flow === "brace" && b.parent) groups.set(b.parent, [...(groups.get(b.parent) ?? []), b]); @@ -43,11 +45,11 @@ export function braces(boxes: Box[]) { const p = byId[pid]; const xc = Math.min(...kids.map((k) => k.x)), y0 = Math.min(...kids.map((k) => k.y)), y1 = Math.max(...kids.map((k) => k.y + k.h)); const ym = (y0 + y1) / 2, x0 = p.x + p.w; - if (y1 - y0 < 70) return { id: kids[0].id, d: `M${x0} ${ym}H${xc}` }; // a row or two: a plain line will do + if (y1 - y0 < 70) return { id: kids[0].id, d: mirrorPath(`M${x0} ${ym}H${xc}`, flip) }; // a row or two: a plain line will do const xr = xc - 10, xl = xr - 12, xt = xl - 12; // right end, spine and tip of the brace return { id: kids[0].id, - d: `M${x0} ${ym}H${xt}M${xr} ${y0}Q${xl} ${y0} ${xl} ${y0 + 12}V${ym - 12}Q${xl} ${ym} ${xt} ${ym}Q${xl} ${ym} ${xl} ${ym + 12}V${y1 - 12}Q${xl} ${y1} ${xr} ${y1}`, + d: mirrorPath(`M${x0} ${ym}H${xt}M${xr} ${y0}Q${xl} ${y0} ${xl} ${y0 + 12}V${ym - 12}Q${xl} ${ym} ${xt} ${ym}Q${xl} ${ym} ${xl} ${ym + 12}V${y1 - 12}Q${xl} ${y1} ${xr} ${y1}`, flip), }; }); } diff --git a/src/importreport.test.ts b/src/importreport.test.ts index ccf2a9f..08eb075 100644 --- a/src/importreport.test.ts +++ b/src/importreport.test.ts @@ -13,7 +13,7 @@ const json = (rootTopic: object, extra: object = {}) => zip({ "content.json": JS test("JSON import lists what it drops", async () => { const bytes = json( { - id: "r", title: "root", structureClass: "org.xmind.ui.map.anticlockwise", + id: "r", title: "root", structureClass: "org.xmind.ui.map.unbalanced", markers: [{ markerId: "priority-1" }, { markerId: "flag-red" }, { markerId: "symbol-wrong" }], notes: { plain: { content: "n" }, html: { content: "n" } }, style: { properties: { "fo:color": "#ff0000", "line-color": "#00ff00" } }, @@ -26,7 +26,7 @@ test("JSON import lists what it drops", async () => { const { roots, lost } = await importXmindReport(bytes); expect(roots[0].children.map((c) => c.text)).toEqual(["kid", "float"]); // the floating topic is kept expect(lost).toEqual(expect.arrayContaining([ - "structure map.anticlockwise opened as map", + "structure map.unbalanced opened as map", "markers with no counterpart dropped", "rich-text notes flattened to plain text", "topic styles other than bold, italic, strike, text colour and fill dropped", diff --git a/src/model.ts b/src/model.ts index e8bef75..4f37ec1 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"; // 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" | "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 @@ -39,8 +39,44 @@ export type Box = { branch: number; // index of the top-level ancestor (-1 for the root) flow: "right" | "down" | "tree" | "fish" | "line" | "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 }; +export type Flip = { h?: boolean; v?: boolean }; // mirrored left-right and/or top-bottom, about the central topic's origin + +/** Structures drawn as the mirror image of another: the base layout and the mirror axes. */ +const VARIANTS: Partial, [NonNullable, Flip]>> = { + up: ["down", { v: true }], + "tree-left": ["tree", { h: true }], + "brace-left": ["brace", { h: true }], + "fish-left": ["fish", { h: true }], + "map-ccw": ["map", { h: true }], +}; + +/** The box in its base structure's own coordinates, before mirroring. */ +export const unflip = (b: Box): Box => { + const f = b.flip; + if (!f) return b; + const { flip: _flip, ...rest } = b; + return { + ...rest, + x: f.h ? -(b.x + b.w) : b.x, + y: f.v ? -(b.y + b.h) : b.y, + side: f.h ? (-b.side as 1 | -1) : b.side, + ...(b.anchor ? { anchor: { x: f.h ? -b.anchor.x : b.anchor.x, y: f.v ? -b.anchor.y : b.anchor.y } } : {}), + }; +}; + +/** Mirror an absolute SVG path (M L C Q H V, as the connector code writes them) the same way. */ +export function mirrorPath(d: string, f?: Flip) { + if (!f) return d; + return d.replace(/([MLCQHV])([^MLCQHV]*)/g, (_, c: string, args: string) => { + const nums = (args.match(/-?\d*\.?\d+(?:e-?\d+)?/gi) ?? []).map(Number); + const out = nums.map((n, i) => (c === "H" || (c !== "V" && i % 2 === 0) ? (f.h ? -n : n) : f.v ? -n : n)); + return c + out.join(" "); + }); +} + export const mk = (text = ""): Node => ({ id: crypto.randomUUID(), text, children: [] }); const map = (n: Node, id: string, f: (n: Node) => Node): Node => @@ -264,9 +300,10 @@ function timeline(root: Node, alternate = true): Raw[] { * "down": org chart. "tree": indented tree chart. "fish": fishbone. "line": timeline. */ 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", matrix: "matrix", brace: "brace" }; - const flow = flows[root.layout ?? ""] ?? "right"; + const flow = flows[base] ?? "right"; const out: Box[] = []; if (flow !== "right" && flow !== "brace") { @@ -274,7 +311,7 @@ export function layout(root: Node): Box[] { 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. - const cut = root.collapsed ? kids.length : root.layout === "map" ? Math.ceil(kids.length / 2) : root.layout === "left" ? 0 : kids.length; + const cut = root.collapsed ? kids.length : base === "map" ? Math.ceil(kids.length / 2) : base === "left" ? 0 : kids.length; const right = half({ ...root, children: kids.slice(0, cut) }); out.push(...right.map((b): Box => ({ ...b, side: 1, branch: -1, flow }))); @@ -286,6 +323,20 @@ export function layout(root: Node): Box[] { } } + if (flip) { + // The mirror image of the base structure; floating topics below keep their own absolute positions. + for (const [i, b] of out.entries()) { + out[i] = { + ...b, + x: flip.h ? -(b.x + b.w) : b.x, + y: flip.v ? -(b.y + b.h) : b.y, + side: flip.h ? (-b.side as 1 | -1) : b.side, + ...(b.anchor ? { anchor: { x: flip.h ? -b.anchor.x : b.anchor.x, y: flip.v ? -b.anchor.y : b.anchor.y } } : {}), + flip, + }; + } + } + for (const f of root.children.filter((c) => c.float)) { const raw = half(f); const dx = f.float!.x - raw[0].x, dy = f.float!.y - raw[0].y; @@ -308,6 +359,11 @@ export const toMarkdown = (n: Node, depth = 0): string => /** Arrow-key navigation over the visible layout. */ export function neighbour(root: Node, boxes: Box[], id: string, dir: "left" | "right" | "up" | "down"): string { const me = boxes.find((b) => b.id === id)!; + // Down, tree and fishbone arrows follow the structure itself (parent, first child), so a mirrored one swaps them. + if (me.flip && (me.flow === "down" || me.flow === "tree" || me.flow === "fish")) { + const swap = { left: "right", right: "left", up: "down", down: "up" } as const; + if ((me.flip.h && (dir === "left" || dir === "right")) || (me.flip.v && (dir === "up" || dir === "down"))) dir = swap[dir]; + } const firstKid = () => (findNode(root, id)!.collapsed ? undefined : findNode(root, id)!.children.find((c) => !c.float))?.id ?? id; if (me.flow === "down") { if (dir === "up") return me.parent ?? id; diff --git a/src/structure.test.ts b/src/structure.test.ts index 387e7c4..4e98f5e 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"] as const) { + for (const kind of ["right", "left", "map", "down", "tree", "fish", "line", "brace", "matrix", "up", "tree-left", "brace-left", "fish-left", "map-ccw"] as const) { const back = await importXmindSheets(exportXmind({ ...mk("r"), layout: kind })); expect(back[0].layout ?? "right").toBe(kind); } @@ -230,3 +230,63 @@ test("matrix: topics along the top, every stack below, nothing overlaps", () => expect(p.x + p.w <= q.x || q.x + q.w <= p.x || p.y + p.h <= q.y || q.y + q.h <= p.y, `${p.id} overlaps ${q.id}`).toBe(true); } }); + +// Mirrored structures: the base layout flipped about the central topic, with connectors, braces and arrow keys following. +import { branchD } from "./export"; +import { Flip, unflip } from "./model"; + +const sample = (kind: Node["layout"]) => { + let r: Node = { ...mk("root"), layout: kind }; + for (const t of ["a", "b", "c"]) r = addChild(r, r.id, mk(t)); + r = addChild(r, r.children[0].id, mk("a1")); + return r; +}; + +test.each([ + ["up", "down", { v: true }], + ["tree-left", "tree", { h: true }], + ["brace-left", "brace", { h: true }], + ["fish-left", "fish", { h: true }], + ["map-ccw", "map", { h: true }], +] as [Node["layout"], Node["layout"], Flip][])("%s is the mirror image of %s", (kind, base, flip) => { + const mirrored = layout(sample(kind)), plain = layout(sample(base)); + expect(mirrored).toHaveLength(plain.length); + for (const [i, m] of mirrored.entries()) { + const p = plain[i]; + expect(m.flip).toEqual(flip); + expect(m.w).toBe(p.w); + expect(m.h).toBe(p.h); + expect(Math.abs(unflip(m).x - p.x)).toBeLessThan(1e-9); + expect(Math.abs(unflip(m).y - p.y)).toBeLessThan(1e-9); + } + const lean = (bs: ReturnType, axis: "x" | "y") => { + const size = axis === "x" ? "w" : "h"; + return Math.sign(bs.slice(1).reduce((t, b) => t + b[axis] + b[size] / 2 - (bs[0][axis] + bs[0][size] / 2), 0)); + }; + // The branches lean to the opposite side of the central topic. + if (flip.h && kind !== "map-ccw") expect(lean(mirrored, "x")).toBe(-lean(plain, "x")); + if (flip.v) expect(lean(mirrored, "y")).toBe(-lean(plain, "y")); +}); + +test("connectors of a mirrored structure are the base connectors mirrored", () => { + const down = layout(sample("down")), up = layout(sample("up")); + const d0 = branchD(down[1], down[0]), d1 = branchD(up[1], up[0]); + const nums = (d: string) => d.match(/-?\d*\.?\d+/g)!.map(Number); + const [a, b] = [nums(d0), nums(d1)]; + expect(b.length).toBe(a.length); + b.forEach((n, i) => expect(Math.abs(n - (i % 2 === 1 ? -a[i] : a[i]))).toBeLessThan(1e-6)); // y mirrored, x kept + const leftBrace = braces(layout({ ...sample("brace-left"), children: sample("brace-left").children.concat([mk("d"), mk("e"), mk("f")]) })); + expect(leftBrace.length).toBeGreaterThan(0); + expect(leftBrace[0].d).toMatch(/-/); // drawn on the negative-x side +}); + +test("arrow keys follow a mirrored structure", () => { + const r = sample("tree-left"), boxes = layout(r), [a] = r.children; + expect(neighbour(r, boxes, a.id, "right")).toBe(r.id); // towards the central topic, which now sits to the right + expect(neighbour(r, boxes, a.id, "left")).toBe(a.children[0].id); // away from it, into the children + const up = sample("up"), ub = layout(up); + expect(neighbour(up, ub, up.children[0].id, "down")).toBe(up.id); // the parent is below + expect(neighbour(up, ub, up.id, "up")).toBe(up.children[0].id); + const ccw = sample("map-ccw"), cb = layout(ccw); + expect(cb.find((b) => b.id === ccw.children[0].id)!.side).toBe(-1); // the first half of a map now grows leftwards +}); diff --git a/src/xmind.ts b/src/xmind.ts index f443ae0..1b02160 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -254,6 +254,11 @@ const STRUCTURE: Record, string> = { left: "org.xmind.ui.logic.left", brace: "org.xmind.ui.brace.right", matrix: "org.xmind.ui.spreadsheet.column", + up: "org.xmind.ui.org-chart.up", + "tree-left": "org.xmind.ui.tree.left", + "brace-left": "org.xmind.ui.brace.left", + "fish-left": "org.xmind.ui.fishbone.leftHeaded", + "map-ccw": "org.xmind.ui.map.anticlockwise", }; const toSheet = (root: Node, i: number, out: Out): XSheet => { diff --git a/src/xmindxml.test.ts b/src/xmindxml.test.ts index f454af3..3c13b1c 100644 --- a/src/xmindxml.test.ts +++ b/src/xmindxml.test.ts @@ -54,7 +54,7 @@ test("sheetsFromXml maps an XMind 8 document", () => { test("layoutOf", () => { expect(["org.xmind.ui.fishbone.leftHeaded", "org.xmind.ui.org-chart.down", "org.xmind.ui.tree.right", "org.xmind.ui.map.clockwise", "org.xmind.ui.logic.right", "org.xmind.ui.matrix", "org.xmind.ui.timeline.vertical"].map(layoutOf)) - .toEqual(["fish", "down", "tree", "map", undefined, undefined, "line"]); + .toEqual(["fish-left", "down", "tree", "map", undefined, undefined, "line"]); }); test("a zip with only content.xml opens; content.json wins when both exist", async () => { @@ -70,7 +70,7 @@ test("layoutOf covers the remaining XMind structure classes", () => { const cases: Record> = { "org.xmind.ui.logic.left": "left", "org.xmind.ui.brace.right": "brace", - "org.xmind.ui.brace.left": "brace", + "org.xmind.ui.brace.left": "brace-left", "org.xmind.ui.spreadsheet.column": "matrix", "org.xmind.ui.structure.spreadsheet.row": "matrix", "org.xmind.ui.treetable.toptitle": "tree", // nearest match diff --git a/src/xmindxml.ts b/src/xmindxml.ts index 13071e6..cc71e63 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -79,15 +79,17 @@ export function parseXml(src: string): XNode { const kid = (n: XNode, name: string) => n.children.find((c) => c.name === name); const allText = (n: XNode): string => n.text.trim() || n.children.map(allText).filter(Boolean).join("\n"); -/** Logic, map, org chart, tree chart and fishbone; anything else (matrix, timeline…) stays the default logic chart. */ +/** The structure for an XMind structure class, or undefined for the default logic chart. Variants the editor lacks open as the nearest one. */ export function layoutOf(structureClass: string): Node["layout"] | undefined { if (structureClass.includes("logic.left")) return "left"; - if (structureClass.includes("brace")) return "brace"; // brace.left opens as the right-hand brace + 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("fishbone")) return "fish"; + if (structureClass.includes("fishbone")) return structureClass.includes("leftHeaded") ? "fish-left" : "fish"; if (structureClass.includes("timeline")) return "line"; // every timeline variant opens as the horizontal one - if (structureClass.includes("org-chart")) return "down"; - if (structureClass.includes("tree")) return "tree"; + if (structureClass.includes("org-chart")) return structureClass.includes("org-chart.up") ? "up" : "down"; + if (structureClass.includes("treetable")) return "tree"; // a table with tree rows: nearest is the indented tree + if (structureClass.includes("tree")) return structureClass.includes("tree.left") ? "tree-left" : "tree"; + if (structureClass.includes("map.anticlockwise")) return "map-ccw"; if (structureClass.includes("map")) return "map"; return undefined; } @@ -97,6 +99,8 @@ 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.brace.right": "brace", "org.xmind.ui.spreadsheet.column": "matrix", + "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", }; /** Records a structure XMind uses that opens as the nearest one here. An absent class is XMind's default and loses nothing. */