mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-10 18:43:18 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
19
packages/layout-engine/src/d3-flextree.d.ts
vendored
Normal file
19
packages/layout-engine/src/d3-flextree.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
declare module "d3-flextree" {
|
||||
type Accessor<T, R> = ((node: T) => R) | R;
|
||||
|
||||
interface FlexLayout {
|
||||
(root: unknown): unknown;
|
||||
nodeSize(arg: Accessor<{ data: unknown }, [number, number]>): FlexLayout;
|
||||
spacing(arg: Accessor<unknown, number> | ((a: unknown, b: unknown) => number)): FlexLayout;
|
||||
children(arg: (d: unknown) => unknown[] | null | undefined): FlexLayout;
|
||||
hierarchy(data: unknown, children?: (d: unknown) => unknown[] | null | undefined): {
|
||||
each: (fn: (d: { depth: number; x: number; y: number; data: unknown }) => void) => void;
|
||||
x: number;
|
||||
y: number;
|
||||
depth: number;
|
||||
data: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function flextree(options?: Record<string, unknown>): FlexLayout;
|
||||
}
|
||||
236
packages/layout-engine/src/index.test.ts
Normal file
236
packages/layout-engine/src/index.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hierarchyFingerprint, layout, nodeLayoutSize } from "./index.js";
|
||||
import type { MindEdge, MindNode } from "@mind-map/shared";
|
||||
|
||||
function node(id: string, text: string, sidePref: -1 | 0 | 1 = 0): MindNode {
|
||||
return {
|
||||
id,
|
||||
canvasId: "c",
|
||||
text,
|
||||
collapsed: false,
|
||||
pinned: false,
|
||||
sidePref,
|
||||
stylePreset: "default",
|
||||
version: 1,
|
||||
tags: [],
|
||||
links: [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function edge(from: string, to: string, order = 0): MindEdge {
|
||||
return {
|
||||
id: `e_${from}_${to}`,
|
||||
canvasId: "c",
|
||||
from,
|
||||
to,
|
||||
kind: "hierarchy",
|
||||
isPrimaryParent: true,
|
||||
order,
|
||||
tags: [],
|
||||
version: 1,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("layout-engine", () => {
|
||||
it("places focus at origin and children left/right", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [edge("root", "a", 0), edge("root", "b", 1)];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.root).toEqual({ x: 0, y: 0 });
|
||||
expect(pos.a!.x).toBeLessThan(0);
|
||||
expect(pos.b!.x).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("nested left subtree stays on negative x", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("a1", "A1", 0),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "a", 0),
|
||||
edge("a", "a1", 0),
|
||||
edge("root", "b", 1),
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.a!.x).toBeLessThan(0);
|
||||
expect(pos.a1!.x).toBeLessThan(pos.a!.x);
|
||||
expect(pos.b!.x).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects pinned positions", () => {
|
||||
const n = node("a", "A", 1);
|
||||
n.pinned = true;
|
||||
n.pos = { x: 999, y: -50 };
|
||||
const nodes = [node("root", "R"), n];
|
||||
const edges = [edge("root", "a", 0)];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.a).toEqual({ x: 999, y: -50 });
|
||||
});
|
||||
|
||||
it("re-roots on focus so ancestors are not stacked as orphans", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("a1", "A1"),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "a", 0),
|
||||
edge("a", "a1", 0),
|
||||
edge("root", "b", 1),
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "a1" });
|
||||
expect(pos.a1).toEqual({ x: 0, y: 0 });
|
||||
// former parent `a` becomes outward neighbor — not at x=0 tower
|
||||
expect(pos.a).toBeTruthy();
|
||||
expect(Math.abs(pos.a!.x)).toBeGreaterThan(50);
|
||||
// siblings of the path should also be placed off-center
|
||||
expect(pos.root).toBeTruthy();
|
||||
expect(pos.b).toBeTruthy();
|
||||
expect(Math.abs(pos.b!.x) + Math.abs(pos.b!.y)).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("does not overlap sibling boxes after separation", () => {
|
||||
const kids = Array.from({ length: 10 }, (_, i) => node(`k${i}`, `很长的标题节点${i}`));
|
||||
const nodes = [node("root", "中心"), ...kids];
|
||||
const edges = kids.map((k, i) => edge("root", k.id, i));
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
const box = { w: 200, h: 70 };
|
||||
const pad = 12;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
for (let j = i + 1; j < kids.length; j++) {
|
||||
const a = pos[kids[i]!.id]!;
|
||||
const b = pos[kids[j]!.id]!;
|
||||
const ox = box.w + pad - Math.abs(b.x - a.x);
|
||||
const oy = box.h + pad - Math.abs(b.y - a.y);
|
||||
expect(ox <= 0 || oy <= 0).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hides primary descendants of collapsed nodes and re-packs siblings", () => {
|
||||
const branch = node("core", "核心矛盾", -1);
|
||||
branch.collapsed = true;
|
||||
const nodes = [
|
||||
node("root", "雷雨"),
|
||||
branch,
|
||||
node("c1", "周萍↔繁漪"),
|
||||
node("c2", "矿工劳资矛盾"),
|
||||
node("era", "时代背景", 1),
|
||||
node("e1", "半封建半殖民地"),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "core", 0),
|
||||
edge("core", "c1", 0),
|
||||
edge("core", "c2", 1),
|
||||
edge("root", "era", 1),
|
||||
edge("era", "e1", 0),
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.core).toBeTruthy();
|
||||
expect(pos.c1).toBeUndefined();
|
||||
expect(pos.c2).toBeUndefined();
|
||||
expect(pos.era).toBeTruthy();
|
||||
expect(pos.e1).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides hierarchy subtree even without isPrimaryParent", () => {
|
||||
const branch = node("theme", "余华主题", -1);
|
||||
branch.collapsed = true;
|
||||
const child = node("c", "苦难");
|
||||
const nodes = [node("root", "活着"), branch, child];
|
||||
const edges: MindEdge[] = [
|
||||
{
|
||||
...edge("root", "theme", 0),
|
||||
isPrimaryParent: true,
|
||||
},
|
||||
{
|
||||
...edge("theme", "c", 0),
|
||||
isPrimaryParent: false,
|
||||
},
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.theme).toBeTruthy();
|
||||
expect(pos.c).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pin-only with prevPositions keeps unrelated nodes stable", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("a1", "A1"),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "a", 0),
|
||||
edge("a", "a1", 0),
|
||||
edge("root", "b", 1),
|
||||
];
|
||||
const first = layout({ nodes, edges, focusNodeId: "root" });
|
||||
const fp = hierarchyFingerprint(edges);
|
||||
const pinned = nodes.map((n) =>
|
||||
n.id === "a"
|
||||
? { ...n, pinned: true, pos: { x: -400, y: 120 } }
|
||||
: n,
|
||||
);
|
||||
const second = layout({
|
||||
nodes: pinned,
|
||||
edges,
|
||||
focusNodeId: "root",
|
||||
prevPositions: first,
|
||||
prevHierarchyFp: fp,
|
||||
});
|
||||
expect(second.a).toEqual({ x: -400, y: 120 });
|
||||
// sibling branch barely moves
|
||||
expect(Math.abs((second.b?.x ?? 0) - (first.b?.x ?? 0))).toBeLessThan(30);
|
||||
expect(Math.abs((second.b?.y ?? 0) - (first.b?.y ?? 0))).toBeLessThan(30);
|
||||
// single-node pin: child stays at previous layout pos (not dragged with parent)
|
||||
expect(second.a1!.x).toBeCloseTo(first.a1!.x, 0);
|
||||
expect(second.a1!.y).toBeCloseTo(first.a1!.y, 0);
|
||||
});
|
||||
|
||||
it("card preset height grows with visible fields", () => {
|
||||
const dens = { gapX: 72, gapY: 36, nodeW: 200, nodeH: 56, pad: 20 };
|
||||
const bare = nodeLayoutSize(
|
||||
{
|
||||
stylePreset: "default",
|
||||
imageUrl: undefined,
|
||||
tags: [],
|
||||
links: [],
|
||||
note: undefined,
|
||||
dueAt: undefined,
|
||||
startAt: undefined,
|
||||
pinned: false,
|
||||
},
|
||||
dens,
|
||||
{ asCard: true },
|
||||
);
|
||||
const rich = nodeLayoutSize(
|
||||
{
|
||||
stylePreset: "default",
|
||||
imageUrl: "https://example.com/a.jpg",
|
||||
tags: ["a"],
|
||||
links: [{ url: "https://ex.com" }],
|
||||
note: "hello",
|
||||
dueAt: 1,
|
||||
startAt: undefined,
|
||||
pinned: true,
|
||||
},
|
||||
dens,
|
||||
{ asCard: true },
|
||||
);
|
||||
expect(bare.w).toBe(288);
|
||||
expect(rich.w).toBe(288);
|
||||
expect(rich.h).toBeGreaterThan(bare.h);
|
||||
});
|
||||
});
|
||||
579
packages/layout-engine/src/index.ts
Normal file
579
packages/layout-engine/src/index.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
import { flextree } from "d3-flextree";
|
||||
import type {
|
||||
Density,
|
||||
MindEdge,
|
||||
MindNode,
|
||||
NodeViewMode,
|
||||
StylePreset,
|
||||
} from "@mind-map/shared";
|
||||
|
||||
export type LayoutInput = {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
focusNodeId: string;
|
||||
density?: Density;
|
||||
/** Canvas-wide bubble vs card chrome (affects box sizes). */
|
||||
nodeViewMode?: NodeViewMode;
|
||||
/** Previous frame positions — reused when hierarchy structure is unchanged. */
|
||||
prevPositions?: PositionMap;
|
||||
/** Fingerprint of edges used to produce prevPositions. */
|
||||
prevHierarchyFp?: string;
|
||||
/** Force full flextree (e.g. after move_node / create / delete). */
|
||||
structureChanged?: boolean;
|
||||
};
|
||||
|
||||
export type PositionMap = Record<string, { x: number; y: number }>;
|
||||
|
||||
type Dens = {
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
nodeW: number;
|
||||
nodeH: number;
|
||||
pad: number;
|
||||
};
|
||||
|
||||
const DENSITY: Record<Density, Dens> = {
|
||||
comfortable: { gapX: 80, gapY: 40, nodeW: 212, nodeH: 60, pad: 20 },
|
||||
compact: { gapX: 52, gapY: 28, nodeW: 168, nodeH: 48, pad: 12 },
|
||||
};
|
||||
|
||||
/** Actual layout box — must match canvas hit/draw sizing intent. */
|
||||
export function nodeLayoutSize(
|
||||
node: Pick<
|
||||
MindNode,
|
||||
| "stylePreset"
|
||||
| "imageUrl"
|
||||
| "tags"
|
||||
| "links"
|
||||
| "note"
|
||||
| "dueAt"
|
||||
| "startAt"
|
||||
| "pinned"
|
||||
>,
|
||||
dens: Dens,
|
||||
opts?: { asCard?: boolean },
|
||||
): { w: number; h: number } {
|
||||
let w = dens.nodeW;
|
||||
let h = dens.nodeH;
|
||||
// meta strip (badges / tags / due)
|
||||
const hasMeta =
|
||||
Boolean(node.tags?.length) ||
|
||||
Boolean(node.links?.length) ||
|
||||
Boolean(node.dueAt) ||
|
||||
Boolean(node.note);
|
||||
if (hasMeta) h += dens.nodeH * 0.4;
|
||||
else h += dens.nodeH * 0.25;
|
||||
|
||||
const preset: StylePreset = node.stylePreset ?? "default";
|
||||
const asCard = Boolean(opts?.asCard) || preset === "card";
|
||||
if (asCard) {
|
||||
// Keep in sync with apps/web/src/node-props/sizing.ts
|
||||
const w = dens.nodeW >= 200 ? 288 : 248;
|
||||
const gap = 10;
|
||||
let body = 0;
|
||||
if (node.note) body += 32 + gap;
|
||||
if (node.imageUrl) body += 68 + gap;
|
||||
if (node.tags?.length) body += 32 + gap;
|
||||
if (node.links?.length) body += 32 + gap;
|
||||
if (node.startAt != null) body += 32 + gap;
|
||||
if (node.dueAt != null) body += 32 + gap;
|
||||
if (node.pinned) body += 32 + gap;
|
||||
if (body > 0) body -= gap;
|
||||
const h = 36 + 24 + Math.max(body, 12);
|
||||
return { w, h: Math.max(h, dens.nodeW >= 200 ? 100 : 84) };
|
||||
}
|
||||
if (preset === "title") {
|
||||
w *= 1.2;
|
||||
h *= 1.2;
|
||||
} else if (preset === "decision") {
|
||||
w *= 1.1;
|
||||
h *= 1.15;
|
||||
} else if (preset === "note") {
|
||||
h *= 1.15;
|
||||
}
|
||||
if (node.imageUrl) {
|
||||
w = Math.max(w, dens.nodeW * 1.2);
|
||||
h += dens.nodeH * 1.7;
|
||||
}
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
type FlexDatum = {
|
||||
id: string;
|
||||
size: [number, number];
|
||||
children?: FlexDatum[];
|
||||
};
|
||||
|
||||
function buildAdj(edges: MindEdge[]): {
|
||||
hierarchy: Map<string, { other: string; primaryTo: boolean; order: number }[]>;
|
||||
relation: Map<string, string[]>;
|
||||
} {
|
||||
const hierarchy = new Map<string, { other: string; primaryTo: boolean; order: number }[]>();
|
||||
const relation = new Map<string, string[]>();
|
||||
const addH = (from: string, to: string, primaryTo: boolean, order: number) => {
|
||||
const list = hierarchy.get(from) ?? [];
|
||||
list.push({ other: to, primaryTo, order });
|
||||
hierarchy.set(from, list);
|
||||
};
|
||||
const addR = (a: string, b: string) => {
|
||||
const la = relation.get(a) ?? [];
|
||||
la.push(b);
|
||||
relation.set(a, la);
|
||||
};
|
||||
for (const e of edges) {
|
||||
if (e.deletedAt) continue;
|
||||
if (e.kind === "hierarchy") {
|
||||
addH(e.from, e.to, e.isPrimaryParent, e.order);
|
||||
addH(e.to, e.from, false, e.order);
|
||||
} else if (e.kind === "relation") {
|
||||
addR(e.from, e.to);
|
||||
addR(e.to, e.from);
|
||||
}
|
||||
}
|
||||
for (const [, list] of hierarchy) {
|
||||
list.sort((a, b) => {
|
||||
if (a.primaryTo !== b.primaryTo) return a.primaryTo ? -1 : 1;
|
||||
return a.order - b.order;
|
||||
});
|
||||
}
|
||||
return { hierarchy, relation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hierarchy subtree under any collapsed node — must not be laid out or rendered.
|
||||
* Walks all hierarchy edges (not only primary-parent), so AI-built trees still collapse.
|
||||
* The collapsed node itself stays visible.
|
||||
*/
|
||||
export function nodesHiddenByCollapse(
|
||||
nodes: MindNode[],
|
||||
edges: MindEdge[],
|
||||
): Set<string> {
|
||||
const children = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (e.deletedAt || e.kind !== "hierarchy") continue;
|
||||
const list = children.get(e.from) ?? [];
|
||||
list.push(e.to);
|
||||
children.set(e.from, list);
|
||||
}
|
||||
|
||||
const hidden = new Set<string>();
|
||||
const queue: string[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.deletedAt || !n.collapsed) continue;
|
||||
for (const c of children.get(n.id) ?? []) queue.push(c);
|
||||
}
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
if (hidden.has(id)) continue;
|
||||
hidden.add(id);
|
||||
for (const c of children.get(id) ?? []) queue.push(c);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function spanningChildren(
|
||||
focusId: string,
|
||||
adj: ReturnType<typeof buildAdj>,
|
||||
nodesById: Map<string, MindNode>,
|
||||
hidden: Set<string>,
|
||||
): Map<string, string[]> {
|
||||
const children = new Map<string, string[]>();
|
||||
const seen = new Set<string>([focusId]);
|
||||
const queue = [focusId];
|
||||
|
||||
const tryEnqueue = (other: string, kids: string[]) => {
|
||||
if (seen.has(other) || hidden.has(other)) return;
|
||||
const node = nodesById.get(other);
|
||||
if (!node || node.deletedAt) return;
|
||||
seen.add(other);
|
||||
kids.push(other);
|
||||
queue.push(other);
|
||||
};
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
const curNode = nodesById.get(cur);
|
||||
// Collapsed: keep the node, do not expand its neighborhood into the tree.
|
||||
if (curNode?.collapsed) continue;
|
||||
const kids: string[] = [];
|
||||
for (const n of adj.hierarchy.get(cur) ?? []) tryEnqueue(n.other, kids);
|
||||
for (const other of adj.relation.get(cur) ?? []) tryEnqueue(other, kids);
|
||||
if (kids.length) children.set(cur, kids);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function assignSides(
|
||||
focusId: string,
|
||||
nodesById: Map<string, MindNode>,
|
||||
children: Map<string, string[]>,
|
||||
): Map<string, -1 | 1> {
|
||||
const sideOf = new Map<string, -1 | 1>();
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
for (const id of children.get(focusId) ?? []) {
|
||||
const node = nodesById.get(id);
|
||||
if (!node || node.deletedAt) continue;
|
||||
let side: -1 | 1;
|
||||
if (node.sidePref === -1) side = -1;
|
||||
else if (node.sidePref === 1) side = 1;
|
||||
else side = left <= right ? -1 : 1;
|
||||
sideOf.set(id, side);
|
||||
if (side === -1) left++;
|
||||
else right++;
|
||||
}
|
||||
return sideOf;
|
||||
}
|
||||
|
||||
function buildSubtree(
|
||||
id: string,
|
||||
nodesById: Map<string, MindNode>,
|
||||
children: Map<string, string[]>,
|
||||
dens: Dens,
|
||||
path: Set<string>,
|
||||
asCard: boolean,
|
||||
childFilter?: (childId: string) => boolean,
|
||||
): FlexDatum | null {
|
||||
if (path.has(id)) return null;
|
||||
const node = nodesById.get(id);
|
||||
if (!node || node.deletedAt) return null;
|
||||
const nextPath = new Set(path);
|
||||
nextPath.add(id);
|
||||
const kids: FlexDatum[] = [];
|
||||
if (!node.collapsed) {
|
||||
for (const cid of children.get(id) ?? []) {
|
||||
if (childFilter && !childFilter(cid)) continue;
|
||||
const child = buildSubtree(
|
||||
cid,
|
||||
nodesById,
|
||||
children,
|
||||
dens,
|
||||
nextPath,
|
||||
asCard,
|
||||
);
|
||||
if (child) kids.push(child);
|
||||
}
|
||||
}
|
||||
const box = nodeLayoutSize(node, dens, { asCard });
|
||||
return {
|
||||
id,
|
||||
// flextree size = full box + gutters (claim real space, not a rigid grid cell)
|
||||
size: [box.w + dens.gapX, box.h + dens.gapY],
|
||||
children: kids.length ? kids : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* flextree: y = depth, x = sibling.
|
||||
* Mind map: depth → ±X, sibling → Y.
|
||||
* nodeSize [xSize,ySize] = [sibling span ≈ height, depth step ≈ width].
|
||||
*/
|
||||
function layoutSideTree(
|
||||
tree: FlexDatum,
|
||||
dens: Dens,
|
||||
side: -1 | 1,
|
||||
positions: PositionMap,
|
||||
) {
|
||||
const lay = flextree()
|
||||
.children((d) => (d as FlexDatum).children ?? null)
|
||||
.nodeSize((d) => {
|
||||
const [boxW, boxH] = (d as { data: FlexDatum }).data.size;
|
||||
// data.size already includes gutters as [w+gapX, h+gapY]
|
||||
return [boxH, boxW];
|
||||
})
|
||||
.spacing(() => dens.gapY * 0.5);
|
||||
|
||||
const root = lay.hierarchy(tree);
|
||||
lay(root);
|
||||
|
||||
root.each((d) => {
|
||||
if (d.depth === 0) return;
|
||||
const data = d.data as FlexDatum;
|
||||
positions[data.id] = {
|
||||
x: side * d.y,
|
||||
y: d.x,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative AABB separation: nodes push apart until boxes (+pad) no longer overlap.
|
||||
* Pinned / focus stay fixed; others move. This is soft — not a locked grid.
|
||||
*/
|
||||
function resolveOverlaps(
|
||||
positions: PositionMap,
|
||||
sizes: Map<string, { w: number; h: number }>,
|
||||
fixed: Set<string>,
|
||||
pad: number,
|
||||
iterations = 60,
|
||||
/** If set, only pairs involving at least one of these ids are separated. */
|
||||
active?: Set<string>,
|
||||
) {
|
||||
const ids = Object.keys(positions).filter((id) => sizes.has(id));
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const a = ids[i]!;
|
||||
const pa = positions[a]!;
|
||||
const sa = sizes.get(a)!;
|
||||
const aw = sa.w / 2 + pad;
|
||||
const ah = sa.h / 2 + pad;
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const b = ids[j]!;
|
||||
if (active && !active.has(a) && !active.has(b)) continue;
|
||||
const pb = positions[b]!;
|
||||
const sb = sizes.get(b)!;
|
||||
const bw = sb.w / 2 + pad;
|
||||
const bh = sb.h / 2 + pad;
|
||||
const dx = pb.x - pa.x;
|
||||
const dy = pb.y - pa.y;
|
||||
const ox = aw + bw - Math.abs(dx);
|
||||
const oy = ah + bh - Math.abs(dy);
|
||||
if (ox <= 0 || oy <= 0) continue;
|
||||
|
||||
// Push along the axis of least penetration
|
||||
const aFixed = fixed.has(a);
|
||||
const bFixed = fixed.has(b);
|
||||
if (aFixed && bFixed) continue;
|
||||
|
||||
if (ox < oy) {
|
||||
const push = (ox / 2) * (dx === 0 ? (a < b ? 1 : -1) : Math.sign(dx) || 1);
|
||||
if (!aFixed && !bFixed) {
|
||||
pa.x -= push;
|
||||
pb.x += push;
|
||||
} else if (aFixed) {
|
||||
pb.x += push * 2;
|
||||
} else {
|
||||
pa.x -= push * 2;
|
||||
}
|
||||
} else {
|
||||
const push = (oy / 2) * (dy === 0 ? (a < b ? 1 : -1) : Math.sign(dy) || 1);
|
||||
if (!aFixed && !bFixed) {
|
||||
pa.y -= push;
|
||||
pb.y += push;
|
||||
} else if (aFixed) {
|
||||
pb.y += push * 2;
|
||||
} else {
|
||||
pa.y -= push * 2;
|
||||
}
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable fingerprint of hierarchy topology + sibling order (ignores relation edges). */
|
||||
export function hierarchyFingerprint(edges: MindEdge[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const e of edges) {
|
||||
if (e.deletedAt || e.kind !== "hierarchy") continue;
|
||||
parts.push(
|
||||
`${e.from}>${e.to}:${e.isPrimaryParent ? 1 : 0}:${e.order}`,
|
||||
);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
export function layout(input: LayoutInput): PositionMap {
|
||||
const dens = DENSITY[input.density ?? "comfortable"];
|
||||
const asCard = input.nodeViewMode === "card";
|
||||
const nodesById = new Map(input.nodes.map((n) => [n.id, n]));
|
||||
if (!nodesById.has(input.focusNodeId)) return {};
|
||||
|
||||
const hidden = nodesHiddenByCollapse(input.nodes, input.edges);
|
||||
// Focus inside a collapsed branch: still show the focus path (rare UI case).
|
||||
hidden.delete(input.focusNodeId);
|
||||
|
||||
const sizes = new Map<string, { w: number; h: number }>();
|
||||
for (const n of input.nodes) {
|
||||
if (n.deletedAt || hidden.has(n.id)) continue;
|
||||
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard }));
|
||||
}
|
||||
|
||||
const prev = input.prevPositions;
|
||||
const fp = hierarchyFingerprint(input.edges);
|
||||
const structureUnchanged =
|
||||
!input.structureChanged &&
|
||||
Boolean(prev) &&
|
||||
Boolean(input.prevHierarchyFp) &&
|
||||
input.prevHierarchyFp === fp;
|
||||
|
||||
const canReusePrev =
|
||||
structureUnchanged &&
|
||||
input.nodes.some((n) => !n.deletedAt && !hidden.has(n.id) && prev![n.id]);
|
||||
|
||||
if (canReusePrev && prev) {
|
||||
const positions: PositionMap = {};
|
||||
for (const n of input.nodes) {
|
||||
if (n.deletedAt || hidden.has(n.id)) continue;
|
||||
const p = prev[n.id];
|
||||
if (p) positions[n.id] = { ...p };
|
||||
}
|
||||
|
||||
const fixed = new Set<string>();
|
||||
const active = new Set<string>();
|
||||
for (const n of input.nodes) {
|
||||
if (hidden.has(n.id) || n.deletedAt) continue;
|
||||
if (!n.pinned) continue;
|
||||
const pinPos = n.pos ?? prev[n.id];
|
||||
if (!pinPos) continue;
|
||||
const before = positions[n.id];
|
||||
positions[n.id] = { ...pinPos };
|
||||
fixed.add(n.id);
|
||||
if (
|
||||
!before ||
|
||||
Math.abs(before.x - pinPos.x) > 0.5 ||
|
||||
Math.abs(before.y - pinPos.y) > 0.5
|
||||
) {
|
||||
active.add(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus stays centered unless it is pinned (pinned never moves).
|
||||
const focus = nodesById.get(input.focusNodeId);
|
||||
if (!(focus?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
fixed.add(input.focusNodeId);
|
||||
} else {
|
||||
fixed.add(input.focusNodeId);
|
||||
}
|
||||
|
||||
// Place brand-new nodes (no prev) near focus, then local-separate only.
|
||||
const orphans = input.nodes.filter(
|
||||
(n) => !n.deletedAt && !hidden.has(n.id) && !positions[n.id],
|
||||
);
|
||||
orphans.forEach((n, i) => {
|
||||
const angle = (i / Math.max(1, orphans.length)) * Math.PI * 2;
|
||||
const r = dens.nodeW + dens.gapX * 2;
|
||||
positions[n.id] = {
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
};
|
||||
active.add(n.id);
|
||||
});
|
||||
|
||||
if (active.size) {
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad, 24, active);
|
||||
if (!(focus?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad, 12, active);
|
||||
} else if (!(focus?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
for (const id of hidden) delete positions[id];
|
||||
return positions;
|
||||
}
|
||||
|
||||
// Full flextree path (structure changed or first layout).
|
||||
const adj = buildAdj(input.edges);
|
||||
const children = spanningChildren(input.focusNodeId, adj, nodesById, hidden);
|
||||
const sideOf = assignSides(input.focusNodeId, nodesById, children);
|
||||
const positions: PositionMap = {
|
||||
[input.focusNodeId]: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
for (const side of [-1, 1] as const) {
|
||||
const tree = buildSubtree(
|
||||
input.focusNodeId,
|
||||
nodesById,
|
||||
children,
|
||||
dens,
|
||||
new Set(),
|
||||
asCard,
|
||||
(childId) => sideOf.get(childId) === side,
|
||||
);
|
||||
if (!tree?.children?.length) continue;
|
||||
layoutSideTree(tree, dens, side, positions);
|
||||
}
|
||||
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
|
||||
// Soft blend from prev — never override pinned nodes.
|
||||
if (prev) {
|
||||
for (const [id, p] of Object.entries(prev)) {
|
||||
if (hidden.has(id) || id === input.focusNodeId) continue;
|
||||
if (!positions[id]) continue;
|
||||
const node = nodesById.get(id);
|
||||
if (node?.pinned) continue;
|
||||
const sug = positions[id]!;
|
||||
const dist = Math.hypot(p.x - sug.x, p.y - sug.y);
|
||||
if (dist < dens.nodeW * 4) {
|
||||
positions[id] = { ...p };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixed = new Set<string>();
|
||||
for (const n of input.nodes) {
|
||||
if (hidden.has(n.id) || n.deletedAt) continue;
|
||||
if (!n.pinned) continue;
|
||||
const pinPos = n.pos ?? prev?.[n.id] ?? positions[n.id];
|
||||
if (!pinPos) continue;
|
||||
positions[n.id] = { ...pinPos };
|
||||
fixed.add(n.id);
|
||||
}
|
||||
|
||||
const focusNode = nodesById.get(input.focusNodeId);
|
||||
if (!(focusNode?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
fixed.add(input.focusNodeId);
|
||||
|
||||
// Disconnected visible nodes only — never re-seed collapsed descendants.
|
||||
const placed = new Set(Object.keys(positions));
|
||||
const orphans = input.nodes.filter(
|
||||
(n) => !n.deletedAt && !hidden.has(n.id) && !placed.has(n.id),
|
||||
);
|
||||
orphans.forEach((n, i) => {
|
||||
if (prev?.[n.id]) {
|
||||
positions[n.id] = { ...prev[n.id]! };
|
||||
return;
|
||||
}
|
||||
const angle = (i / Math.max(1, orphans.length)) * Math.PI * 2;
|
||||
const r = dens.nodeW + dens.gapX * 2 + Math.floor(i / 8) * dens.nodeW;
|
||||
positions[n.id] = {
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
};
|
||||
});
|
||||
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad);
|
||||
|
||||
// Keep focus centered after pushes — but never yank a pinned focus off its pos.
|
||||
if (!(focusNode?.pinned && (focusNode.pos || prev?.[focusNode.id]))) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad, 20);
|
||||
|
||||
// Ensure collapsed descendants never leak into the position map.
|
||||
for (const id of hidden) delete positions[id];
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function layoutDiff(
|
||||
prev: PositionMap,
|
||||
next: PositionMap,
|
||||
): { id: string; x: number; y: number }[] {
|
||||
const out: { id: string; x: number; y: number }[] = [];
|
||||
for (const [id, p] of Object.entries(next)) {
|
||||
const o = prev[id];
|
||||
if (!o || o.x !== p.x || o.y !== p.y) out.push({ id, x: p.x, y: p.y });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function primaryParentMap(edges: MindEdge[]): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of edges) {
|
||||
if (e.kind === "hierarchy" && e.isPrimaryParent && !e.deletedAt) {
|
||||
m.set(e.to, e.from);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue