From a85864d8c34608789876fda413007074bcc67e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:47:47 +0000 Subject: [PATCH] Draw the vertical timeline structure A vertical line from the central topic with the top-level topics on it, alternating right and left, each with its descendants growing outward. Same-side stacks are packed so they never overlap, the line is drawn as one stretch per topic, and the up/down keys run along it. XMind's timeline.vertical now maps to it instead of opening as the horizontal one. 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 | 1 + src/model.ts | 40 ++++++++++++++++++++++++++++++---- src/structure.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++- src/xmind.ts | 1 + src/xmindxml.test.ts | 2 +- src/xmindxml.ts | 4 ++-- 8 files changed, 92 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7b16297..3a516af 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 -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 diff --git a/src/App.tsx b/src/App.tsx index be9d632..2e7af47 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"]] 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 857fe21..eeadb52 100644 --- a/src/export.ts +++ b/src/export.ts @@ -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 diff --git a/src/model.ts b/src/model.ts index 4f37ec1..848c3af 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" | "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 @@ -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 }; @@ -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 }; /** @@ -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 = { down: "down", tree: "tree", fish: "fish", line: "line", matrix: "matrix", brace: "brace" }; + const flows: Record = { 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 { @@ -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(); diff --git a/src/structure.test.ts b/src/structure.test.ts index 4e98f5e..2583193 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"] 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); } @@ -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); +}); diff --git a/src/xmind.ts b/src/xmind.ts index 1b02160..02bf9a7 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -251,6 +251,7 @@ const STRUCTURE: Record, 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", diff --git a/src/xmindxml.test.ts b/src/xmindxml.test.ts index 3c13b1c..78b1c47 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-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 () => { diff --git a/src/xmindxml.ts b/src/xmindxml.ts index cc71e63..288ed0c 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -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"; @@ -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.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", };