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>
153 lines
5.3 KiB
TypeScript
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;
|
|
}
|