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>
158 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|