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, ): 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 { const index = new Map(); 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 { const byId = buildNodeMap(nodes); const indexById = buildIndex(nodes); const children = new Map(); 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(); 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(); 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 { 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 { const nodeMap = buildNodeMap(nodes); const depths = new Map(); const resolve = (id: string, seen: Set): 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(); 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 { const hidden = new Set(); 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(); 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(); 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; }