Files
openmind/frontend/src/features/editor/bilateral.ts
snoopy e3c530348e Phase 2 : mind map bilaterale, relations, templates et Markdown
Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et
`MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les
cartes v1/v2/v3 restent ouvrables telles quelles.

Une relation est une connexion purement visuelle : elle ne touche jamais
parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue
de deriver les liens de parentId et se contente de retirer les relations dont
une extremite a disparu.

Mind map bilaterale : repartition gloutonne et deterministe des branches selon
la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre
recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul
Reorganiser applique.

Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou
texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant,
et l'export Markdown de la hierarchie.

Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles
changent de cote, ce qui faisait disparaitre les liens concernes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:12:00 +02:00

153 lines
5.3 KiB
TypeScript

import { type MindMapNode, type NodeSide } from '@/types/map';
import { buildChildrenMap, buildNodeMap, getOrderedChildren, getRootNodes } from '@/utils/tree';
/**
* Cotes gauche / droite d'une mind map bilaterale.
*
* Le modele est volontairement minimal : seul un enfant direct d'une racine
* porte un `side`. Tous ses descendants heritent du cote de leur branche de
* niveau 1, ce qui evite d'entretenir la meme information sur chaque noeud et
* garantit qu'une branche ne peut pas se retrouver a cheval sur les deux cotes.
*
* `order` reste la seule source de verite de l'ordre : on filtre les enfants
* par cote, puis on respecte leur ordre existant. Il n'y a jamais de
* `leftOrder` / `rightOrder` a maintenir en parallele.
*/
/** Poids d'une branche : elle-meme plus tous ses descendants. */
function branchWeight(children: Map<string, string[]>, rootId: string): number {
let total = 1;
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
total += 1;
const next = children.get(current);
if (next) queue.push(...next);
}
return total;
}
/**
* Repartition gloutonne et deterministe.
*
* Les branches sans cote sont traitees de la plus lourde a la plus legere et
* placees du cote le moins charge. C'est suffisant pour equilibrer visuellement
* une carte reelle, sans construire un solveur : a poids egal, l'ordre de
* fratrie tranche, donc le resultat est reproductible.
*/
export function assignSides(
branches: Array<{ id: string; weight: number; order: number; side?: NodeSide }>,
): Map<string, NodeSide> {
const result = new Map<string, NodeSide>();
let leftLoad = 0;
let rightLoad = 0;
for (const branch of branches) {
if (!branch.side) continue;
result.set(branch.id, branch.side);
if (branch.side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
const pending = branches
.filter((branch) => !branch.side)
.sort((a, b) => (b.weight !== a.weight ? b.weight - a.weight : a.order - b.order));
for (const branch of pending) {
// A charge egale, la droite d'abord : c'est le cote de lecture naturel.
const side: NodeSide = rightLoad <= leftLoad ? 'right' : 'left';
result.set(branch.id, side);
if (side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
return result;
}
/** Branches de niveau 1 : les enfants directs des racines, dans l'ordre. */
export function getLevelOneBranches(nodes: MindMapNode[]): MindMapNode[] {
const branches: MindMapNode[] = [];
for (const root of getRootNodes(nodes)) {
branches.push(...getOrderedChildren(nodes, root.id));
}
return branches;
}
/**
* Cote effectif de chaque noeud.
*
* Fonction pure : elle ne modifie pas le document. Une branche sans cote defini
* en recoit un ici aussi, par le meme algorithme que l'attribution explicite :
* une branche ajoutee apres coup se place donc immediatement du bon cote, sans
* qu'il faille reecrire la carte.
*/
export function resolveNodeSides(nodes: MindMapNode[]): Map<string, NodeSide> {
const children = buildChildrenMap(nodes);
const branches = getLevelOneBranches(nodes).map((node) => ({
id: node.id,
weight: branchWeight(children, node.id),
order: node.order ?? 0,
side: node.side,
}));
const byBranch = assignSides(branches);
const sides = new Map<string, NodeSide>();
// Les racines restent au centre : leur cote ne sert qu'aux cas degenres.
for (const root of getRootNodes(nodes)) sides.set(root.id, 'right');
for (const [branchId, side] of byBranch) {
sides.set(branchId, side);
const queue = [...(children.get(branchId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
sides.set(current, side);
const next = children.get(current);
if (next) queue.push(...next);
}
}
// Securite : un noeud orphelin d'une donnee incoherente reste positionnable.
for (const node of nodes) if (!sides.has(node.id)) sides.set(node.id, 'right');
return sides;
}
/**
* Materialise les cotes manquants dans le document.
*
* Seules les branches de niveau 1 sans cote sont completees : un cote choisi a
* la main n'est jamais ecrase. Retourne le tableau d'origine si rien ne change.
*/
export function materializeSides(nodes: MindMapNode[]): MindMapNode[] {
const branches = getLevelOneBranches(nodes);
if (branches.every((branch) => branch.side)) return nodes;
const resolved = resolveNodeSides(nodes);
const branchIds = new Set(branches.map((branch) => branch.id));
return nodes.map((node) =>
branchIds.has(node.id) && !node.side
? { ...node, side: resolved.get(node.id) ?? 'right' }
: node,
);
}
/**
* Branche de niveau 1 dont depend un noeud, c'est-a-dire le noeud dont le
* `side` fait foi pour lui. `null` pour une racine.
*/
export function findBranchRoot(nodes: MindMapNode[], nodeId: string): MindMapNode | null {
const byId = buildNodeMap(nodes);
let current = byId.get(nodeId);
if (!current) return null;
const guard = new Set<string>();
while (current.parentId && byId.has(current.parentId) && !guard.has(current.id)) {
guard.add(current.id);
const parent = byId.get(current.parentId) as MindMapNode;
if (!parent.parentId || !byId.has(parent.parentId)) return current;
current = parent;
}
return null;
}