Files
openmind/frontend/src/utils/tree.ts
snoopy 0b2e5e326d OpenMind v1 : cartes mentales libres, local-first et dockerisees
Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

Architecture
- Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react,
  etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre.
- Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les
  fichiers statiques du frontend compile.
- Image Docker multi-stage, un seul service, bind mount ./data pour SQLite.

Local-first
Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur
demande explicite de partage.

Partage
Deux secrets distincts : share_token public en lecture seule dans l'URL, et
management_token conserve uniquement cote client, transmis en en-tete pour les
modifications et stocke hashe en base.

Fonctionnalites
Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis,
branches repliables, recherche), 9 themes, 4 dispositions automatiques,
panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression,
page publique en lecture seule, PWA.

Qualite
25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur,
TypeScript strict sans erreur.

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

158 lines
5.1 KiB
TypeScript

import { type MindMapEdge, type MindMapNode } from '@/types/map';
/** Index enfant -> parents directs, construit une seule fois par operation. */
export function buildChildrenMap(nodes: MindMapNode[]): Map<string, string[]> {
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]);
}
return children;
}
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. */
export function rebuildEdges(nodes: MindMapNode[]): MindMapEdge[] {
const ids = new Set(nodes.map((node) => node.id));
return nodes
.filter((node) => node.parentId && ids.has(node.parentId))
.map((node) => ({
id: `e-${node.parentId}-${node.id}`,
source: node.parentId as string,
target: node.id,
}));
}
/**
* 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,
): { x: number; y: number } {
const siblings = nodes.filter((node) => node.parentId === parent.id);
const offset = siblings.length;
if (horizontal) {
return {
x: parent.position.x + direction * 220,
y: parent.position.y + offset * 72 - (siblings.length > 0 ? 36 : 0),
};
}
return {
x: parent.position.x + offset * 200 - (siblings.length > 0 ? 100 : 0),
y: parent.position.y + direction * 140,
};
}