Files
openmind/frontend/src/utils/tree.ts
snoopy 2afbf637ce Phase 1.2 : productivite de creation
Construire une grande carte demande beaucoup moins de gestes.

Ordre des freres (format v3)
`MindMapNode.order` devient la position parmi les freres. `parentId` reste la
seule source de verite de la relation hierarchique : une source par
preoccupation, jamais deux versions contradictoires. L'ordre est renumerote a
chaque modification structurelle dans `withEdges`, unique point de passage.
Le format passe en v3 : un lecteur v2 ignorerait `order` et reordonnerait
silencieusement la carte. Migration v2 -> v3 depuis la position dans le tableau,
v1 -> v2 -> v3 s'enchainent, et les noeuds sont ecrits en parcours de profondeur
pour rester corrects meme pour un lecteur anterieur.

Creation rapide
- Ajouter plusieurs enfants : une ligne = un noeud, avec apercu avant creation.
- Collage d'une liste multi-ligne sur un noeud : le presse-papiers interne reste
  prioritaire, l'edition de texte n'est jamais interceptee.
- Import d'une arborescence indentee : espaces ou tabulations, puces retirees,
  indentation incoherente signalee avec le numero de ligne plutot que devinee.
- Les nouveaux noeuds sont repartis autour du parent au lieu de se superposer.

Hierarchie
Monter, descendre, placer en premier ou en dernier, remonter d'un niveau,
"Deplacer vers..." avec recherche, suppression d'un noeud en conservant ses
enfants a la place qu'il occupait. Le re-parentage passe toujours par
`reparentNode` : la detection de cycle n'est ecrite qu'une fois.

Branches
Selectionner, dupliquer, copier, coller comme enfant, deplacer avec ses
descendants (reglage, ou Alt pour l'inverser le temps d'un glisser), replier et
developper une branche, tout replier, tout developper, replier au-dela d'un
niveau.

Clavier
Maj+Tab remonte d'un niveau, Maj+Entree ouvre la creation multiple, Alt+Haut et
Alt+Bas reordonnent. Tab, Entree et F2 gardent leur comportement.

Historique
Chaque operation logique reste une seule entree : creation multiple, import
d'outline, reordonnancement, deplacement de branche, duplication, collage,
suppression, tout replier.

Tests : 172 vitest (103 -> 172) et 36 pytest (31 -> 36).

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

308 lines
9.9 KiB
TypeScript

import { type MindMapEdge, type MindMapNode, type Position } from '@/types/map';
/**
* Compare deux freres.
*
* `order` fait foi ; a defaut, l'ordre d'apparition dans le tableau `nodes`
* sert de repli, ce qui reproduit exactement le comportement d'avant la v3 pour
* les cartes qui n'ont pas encore d'ordre explicite.
*/
export function compareSiblings(
a: MindMapNode,
b: MindMapNode,
indexById: Map<string, number>,
): number {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
}
function buildIndex(nodes: MindMapNode[]): Map<string, number> {
const index = new Map<string, number>();
nodes.forEach((node, position) => index.set(node.id, position));
return index;
}
/**
* Index parent -> enfants directs, tries par ordre de fratrie.
* Construit une seule fois par operation.
*/
export function buildChildrenMap(nodes: MindMapNode[]): Map<string, string[]> {
const byId = buildNodeMap(nodes);
const indexById = buildIndex(nodes);
const children = new Map<string, string[]>();
for (const node of nodes) {
if (!node.parentId) continue;
const list = children.get(node.parentId);
if (list) list.push(node.id);
else children.set(node.parentId, [node.id]);
}
for (const list of children.values()) {
if (list.length > 1) {
list.sort((a, b) =>
compareSiblings(byId.get(a) as MindMapNode, byId.get(b) as MindMapNode, indexById),
);
}
}
return children;
}
/** Enfants directs d'un noeud, dans l'ordre. `null` cible les racines. */
export function getOrderedChildren(
nodes: MindMapNode[],
parentId: string | null,
): MindMapNode[] {
const byId = buildNodeMap(nodes);
if (parentId === null) {
const indexById = buildIndex(nodes);
return getRootNodes(nodes).sort((a, b) => compareSiblings(a, b, indexById));
}
return (buildChildrenMap(nodes).get(parentId) ?? []).map((id) => byId.get(id) as MindMapNode);
}
/**
* Renumerote les freres en 0..n-1, pour chaque parent et pour les racines.
*
* Appelee apres toute modification structurelle : l'ordre reste ainsi dense et
* comparable, et les anciennes cartes recoivent un ordre explicite conforme a
* leur affichage precedent.
*/
export function normalizeChildOrders(nodes: MindMapNode[]): MindMapNode[] {
const indexById = buildIndex(nodes);
const groups = new Map<string | null, MindMapNode[]>();
const known = new Set(nodes.map((node) => node.id));
for (const node of nodes) {
// `null` regroupe les racines : aucune sentinelle textuelle a inventer.
const key = node.parentId && known.has(node.parentId) ? node.parentId : null;
const group = groups.get(key);
if (group) group.push(node);
else groups.set(key, [node]);
}
const orders = new Map<string, number>();
for (const group of groups.values()) {
group.sort((a, b) => compareSiblings(a, b, indexById));
group.forEach((node, position) => orders.set(node.id, position));
}
let changed = false;
const result = nodes.map((node) => {
const order = orders.get(node.id) ?? 0;
if (node.order === order) return node;
changed = true;
return { ...node, order };
});
return changed ? result : nodes;
}
/** Prochain rang libre parmi les enfants d'un parent. */
export function nextSiblingOrder(nodes: MindMapNode[], parentId: string | null): number {
const siblings = nodes.filter((node) =>
parentId === null ? !node.parentId : node.parentId === parentId,
);
if (siblings.length === 0) return 0;
return Math.max(...siblings.map((node) => node.order ?? 0)) + 1;
}
export function buildNodeMap(nodes: MindMapNode[]): Map<string, MindMapNode> {
return new Map(nodes.map((node) => [node.id, node]));
}
/** Racines de la carte : noeuds sans parent (ou dont le parent n'existe plus). */
export function getRootNodes(nodes: MindMapNode[]): MindMapNode[] {
const ids = new Set(nodes.map((node) => node.id));
return nodes.filter((node) => !node.parentId || !ids.has(node.parentId));
}
/** Profondeur de chaque noeud depuis sa racine (racine = 0). */
export function computeDepths(nodes: MindMapNode[]): Map<string, number> {
const nodeMap = buildNodeMap(nodes);
const depths = new Map<string, number>();
const resolve = (id: string, seen: Set<string>): number => {
const cached = depths.get(id);
if (cached !== undefined) return cached;
if (seen.has(id)) return 0; // securite : cycle impossible mais on ne boucle pas
seen.add(id);
const node = nodeMap.get(id);
const parentId = node?.parentId;
const depth = parentId && nodeMap.has(parentId) ? resolve(parentId, seen) + 1 : 0;
depths.set(id, depth);
return depth;
};
for (const node of nodes) resolve(node.id, new Set());
return depths;
}
/** Tous les descendants d'un noeud (lui-meme exclu). */
export function getDescendantIds(nodes: MindMapNode[], rootId: string): string[] {
const children = buildChildrenMap(nodes);
const result: string[] = [];
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
result.push(current);
const next = children.get(current);
if (next) queue.push(...next);
}
return result;
}
/** Un noeud plus tous ses descendants, sans doublon. */
export function withDescendants(nodes: MindMapNode[], ids: string[]): string[] {
const collected = new Set<string>();
for (const id of ids) {
collected.add(id);
for (const descendant of getDescendantIds(nodes, id)) collected.add(descendant);
}
return [...collected];
}
/** Noeuds masques parce qu'un ancetre est replie. */
export function getHiddenNodeIds(nodes: MindMapNode[]): Set<string> {
const hidden = new Set<string>();
const children = buildChildrenMap(nodes);
for (const node of nodes) {
if (!node.collapsed) continue;
const queue = [...(children.get(node.id) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
if (hidden.has(current)) continue;
hidden.add(current);
const next = children.get(current);
if (next) queue.push(...next);
}
}
return hidden;
}
/** Nombre de descendants masques par un noeud replie. */
export function countHiddenDescendants(nodes: MindMapNode[], nodeId: string): number {
return getDescendantIds(nodes, nodeId).length;
}
/** Empeche de rattacher un noeud a l'un de ses propres descendants. */
export function wouldCreateCycle(
nodes: MindMapNode[],
childId: string,
nextParentId: string,
): boolean {
if (childId === nextParentId) return true;
return getDescendantIds(nodes, childId).includes(nextParentId);
}
/**
* Recalcule les liens a partir de la hierarchie : parentId fait autorite.
* Les liens sont emis dans l'ordre de fratrie, ce dont dagre se sert pour
* disposer les enfants dans le bon ordre.
*/
export function rebuildEdges(nodes: MindMapNode[]): MindMapEdge[] {
const edges: MindMapEdge[] = [];
for (const [parentId, childIds] of buildChildrenMap(nodes)) {
for (const childId of childIds) {
edges.push({ id: `e-${parentId}-${childId}`, source: parentId, target: childId });
}
}
return edges;
}
/** Parcours en profondeur, racines et enfants dans l'ordre de fratrie. */
export function flattenInOrder(nodes: MindMapNode[]): MindMapNode[] {
const byId = buildNodeMap(nodes);
const children = buildChildrenMap(nodes);
const indexById = buildIndex(nodes);
const result: MindMapNode[] = [];
const seen = new Set<string>();
const visit = (node: MindMapNode): void => {
if (seen.has(node.id)) return;
seen.add(node.id);
result.push(node);
for (const childId of children.get(node.id) ?? []) {
const child = byId.get(childId);
if (child) visit(child);
}
};
for (const root of getRootNodes(nodes).sort((a, b) => compareSiblings(a, b, indexById))) {
visit(root);
}
// Securite : un noeud isole par une donnee incoherente reste present.
for (const node of nodes) if (!seen.has(node.id)) result.push(node);
return result;
}
/**
* Restaure `parentId` a partir des liens.
* Utilise a l'import, si un fichier fournit des liens mais pas de hierarchie.
*/
export function rebuildHierarchyFromEdges(
nodes: MindMapNode[],
edges: MindMapEdge[],
): MindMapNode[] {
const parentByChild = new Map<string, string>();
for (const edge of edges) {
if (!parentByChild.has(edge.target)) parentByChild.set(edge.target, edge.source);
}
return nodes.map((node) =>
node.parentId === undefined || node.parentId === null
? { ...node, parentId: parentByChild.get(node.id) ?? null }
: node,
);
}
/** Prochaine position libre pour un nouvel enfant, avant tout auto-layout. */
export function suggestChildPosition(
nodes: MindMapNode[],
parent: MindMapNode,
horizontal: boolean,
direction: 1 | -1,
): Position {
return suggestChildPositions(nodes, parent, horizontal, direction, 1)[0] as Position;
}
const CHILD_GAP_Y = 72;
const CHILD_GAP_X = 200;
/**
* Positions de depart pour `count` nouveaux enfants.
*
* Elles ne remplacent pas l'auto-layout : elles evitent seulement que plusieurs
* noeuds crees d'un coup se superposent, en les repartissant autour du parent.
*/
export function suggestChildPositions(
nodes: MindMapNode[],
parent: MindMapNode,
horizontal: boolean,
direction: 1 | -1,
count: number,
): Position[] {
const existing = nodes.filter((node) => node.parentId === parent.id).length;
const total = existing + count;
const positions: Position[] = [];
for (let index = 0; index < count; index += 1) {
const slot = existing + index;
if (horizontal) {
positions.push({
x: parent.position.x + direction * 220,
y: parent.position.y + (slot - (total - 1) / 2) * CHILD_GAP_Y,
});
} else {
positions.push({
x: parent.position.x + (slot - (total - 1) / 2) * CHILD_GAP_X,
y: parent.position.y + direction * 140,
});
}
}
return positions;
}