Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The ‹ chip on a node collapses its branch. The map autosaves to local storage;

## Not yet

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.
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

Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta
]} />
<Menu label="View" items={[
{ heading: "Structure" },
...([["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]) => ({
...([["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"]] as const).map(([v, l]) => ({
label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }),
})),
{ heading: "Theme" },
Expand Down
1 change: 1 addition & 0 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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 === "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
return `M${p.x + 12} ${b.y > p.y ? p.y + p.h : p.y}V${b.y + b.h / 2}H${b.x}`; // a trunk from the parent, then across to the row
Expand Down
40 changes: 36 additions & 4 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" | "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" | "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
Expand All @@ -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" | "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" | "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
};
Expand Down Expand Up @@ -257,6 +257,32 @@ function fishbone(root: Node): Raw[] {
return out;
}

const VT = { GAP: 40 };

/**
* Vertical timeline: the root heads a vertical line and the top-level topics sit on it, in order, alternating right and left of the line.
* Each topic's descendants grow outward from it as a tidy tree. Every box carries its side; a topic's `anchor` is where its stretch of line starts.
*/
function vtimeline(root: Node): (Raw & { side: 1 | -1 })[] {
const rw = widthOf(root), spine = rw / 2;
const out: (Raw & { side: 1 | -1 })[] = [{ id: root.id, x: 0, y: 0, w: rw, h: H, depth: 0, side: 1 }];
const free = { 1: H + VT.GAP, [-1]: H + VT.GAP }; // first free y on each side of the line
let prev = H; // where the previous stretch of line ended: the bottom of the root, then the previous topic's centre
(root.collapsed ? [] : root.children).forEach((label, k) => {
const side = k % 2 === 0 ? 1 : -1, tree = half(label);
const top = Math.min(...tree.map((b) => b.y)), bottom = Math.max(...tree.map((b) => b.y + b.h)), cy = tree[0].y + H / 2;
const y = Math.max(free[side] + (cy - top), k === 0 ? H + VT.GAP + H / 2 : prev + H + GAP_Y); // the topic's centre on the line
const dy = y - cy;
for (const b of tree) {
const x = side === 1 ? spine + VT.GAP + b.x : spine - VT.GAP - b.x - b.w;
out.push({ ...b, x, y: b.y + dy, side, depth: b.depth + 1, ...(b === tree[0] ? { parent: root.id, anchor: { x: spine, y: prev } } : {}) });
}
free[side] = bottom + dy + GAP_Y;
prev = y;
});
return out;
}

const TL = { GAP: 36, ROW: 14, NEST: 24 };

/**
Expand Down Expand Up @@ -302,11 +328,13 @@ 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<string, Box["flow"]> = { down: "down", tree: "tree", fish: "fish", line: "line", matrix: "matrix", brace: "brace" };
const flows: Record<string, Box["flow"]> = { down: "down", tree: "tree", fish: "fish", line: "line", vline: "vline", matrix: "matrix", brace: "brace" };
const flow = flows[base] ?? "right";
const out: Box[] = [];

if (flow !== "right" && flow !== "brace") {
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 });
out.push(...raw.map((b): Box => ({ ...b, side: 1, branch: -1, flow })));
} else {
Expand Down Expand Up @@ -365,6 +393,10 @@ export function neighbour(root: Node, boxes: Box[], id: string, dir: "left" | "r
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 === "vline" && me.depth <= 1 && (dir === "up" || dir === "down")) {
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 === "down") {
if (dir === "up") return me.parent ?? id;
if (dir === "down") return firstKid();
Expand Down
50 changes: 49 additions & 1 deletion src/structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] 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"] as const) {
const back = await importXmindSheets(exportXmind({ ...mk("r"), layout: kind }));
expect(back[0].layout ?? "right").toBe(kind);
}
Expand Down Expand Up @@ -290,3 +290,51 @@ test("arrow keys follow a mirrored structure", () => {
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
});

function vertical() {
let r: Node = { ...mk("head"), layout: "vline" };
const t = ["one", "two", "three", "four"].map(mk);
for (const n of t) r = addChild(r, r.id, n);
const kids = ["x", "y", "z"].map(mk);
for (const k of kids) r = addChild(r, t[0].id, k); // topic one is tall: its stack must not collide with topic three on the same side
r = addChild(r, kids[0].id, mk("deep"));
return { r, t, kids };
}

test("vertical timeline: topics alternate sides down the line and never overlap", () => {
const { r, t } = vertical();
const boxes = layout(r), by = Object.fromEntries(boxes.map((b) => [b.id, b]));
const head = boxes[0], spine = head.x + head.w / 2;
expect(t.map((n) => by[n.id].side)).toEqual([1, -1, 1, -1]);
for (const n of t) expect(by[n.id].side === 1 ? by[n.id].x > spine : by[n.id].x + by[n.id].w < spine).toBe(true);
const ys = t.map((n) => by[n.id].y);
expect(ys).toEqual([...ys].sort((a, b) => a - b)); // in order down the line
expect(by[t[0].id].y).toBeGreaterThan(head.y + head.h);
// no two boxes on the same side overlap
for (const a of boxes) for (const b of boxes) {
if (a.id < b.id && a.side === b.side && a.id !== head.id && b.id !== head.id) {
const apart = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
expect(apart, `${a.id} vs ${b.id}`).toBe(true);
}
}
expect(boxes.every((b) => b.flow === "vline")).toBe(true);
});

test("vertical timeline: descendants grow outward and connectors run down the line", () => {
const { r, t, kids } = vertical();
const boxes = layout(r), by = Object.fromEntries(boxes.map((b) => [b.id, b]));
expect(by[kids[0].id].x).toBeGreaterThan(by[t[0].id].x + by[t[0].id].w); // on the right, to the right of the topic
const two = by[t[1].id];
expect(branchD(two, boxes[0])).toMatch(/^M-?[\d.]+ -?[\d.]+V-?[\d.]+H-?[\d.]+$/);
expect(branchD(two, boxes[0]).endsWith(`H${two.x + two.w}`)).toBe(true); // the left-hand topic is met at its right edge
expect(branchD(by[kids[0].id], by[t[0].id])).toMatch(/C/); // deeper topics hang off their parent with the usual curve
});

test("vertical timeline: arrow keys run down the line and into the stacks", () => {
const { r, t, kids } = vertical();
const boxes = layout(r), go = (id: string, d: "left" | "right" | "up" | "down") => neighbour(r, boxes, id, d);
expect([go(r.id, "down"), go(t[0].id, "down"), go(t[1].id, "down"), go(t[3].id, "down")]).toEqual([t[0].id, t[1].id, t[2].id, t[3].id]);
expect([go(t[1].id, "up"), go(t[0].id, "up")]).toEqual([t[0].id, r.id]);
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);
});
1 change: 1 addition & 0 deletions src/xmind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const STRUCTURE: Record<NonNullable<Node["layout"]>, string> = {
tree: "org.xmind.ui.tree.right",
fish: "org.xmind.ui.fishbone.rightHeaded",
line: "org.xmind.ui.timeline.horizontal",
vline: "org.xmind.ui.timeline.vertical",
left: "org.xmind.ui.logic.left",
brace: "org.xmind.ui.brace.right",
matrix: "org.xmind.ui.spreadsheet.column",
Expand Down
2 changes: 1 addition & 1 deletion src/xmindxml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-left", "down", "tree", "map", undefined, undefined, "line"]);
.toEqual(["fish-left", "down", "tree", "map", undefined, undefined, "vline"]);
});

test("a zip with only content.xml opens; content.json wins when both exist", async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/xmindxml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function layoutOf(structureClass: string): Node["layout"] | undefined {
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 structureClass.includes("leftHeaded") ? "fish-left" : "fish";
if (structureClass.includes("timeline")) return "line"; // every timeline variant opens as the horizontal one
if (structureClass.includes("timeline")) return structureClass.includes("vertical") ? "vline" : "line";
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";
Expand All @@ -98,7 +98,7 @@ export function layoutOf(structureClass: string): Node["layout"] | undefined {
const EXACT: Record<string, string> = {
"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.timeline.horizontal": "line", "org.xmind.ui.timeline.vertical": "vline", "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",
};
Expand Down
Loading