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>
This commit is contained in:
152
frontend/src/features/editor/bilateral.ts
Normal file
152
frontend/src/features/editor/bilateral.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user