import { type Edge, type Node } from '@xyflow/react'; import { MarkerType, Position } from '@xyflow/react'; import { type BranchStyle, type MindMap, type MindMapNode, type NodeSide, } from '@/types/map'; import { resolveNodeSides } from '@/features/editor/bilateral'; import { relationColor, type MapTheme } from '@/features/editor/themes'; import { resolveNodeAppearance, type NodeAppearance, } from '@/features/editor/nodeStyleResolver'; import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout'; import { buildChildrenMap, buildNodeMap, computeDepths, getHiddenNodeIds } from '@/utils/tree'; import { type NodeSize } from '@/utils/nodeSize'; export { resolveNodeAppearance }; export type { NodeAppearance }; export interface MindNodeData extends Record { node: MindMapNode; depth: number; appearance: NodeAppearance; childCount: number; hiddenCount: number; isSearchHit: boolean; readOnly: boolean; isEditing: boolean; /** * Poignees sortantes. Une seule dans les dispositions classiques ; en mind map * bilaterale, la racine en porte deux puisque ses branches partent des deux * cotes. */ sourceHandles: HandleSpec[]; targetHandle: HandleSpec; } /** Une poignee de connexion : identifiant stable et cote d'ancrage. */ export interface HandleSpec { id: string; position: Position; } export type MindFlowNode = Node; function handlePositions(layout: MindMap['settings']['layout']): { source: Position; target: Position; } { if (!isHorizontalLayout(layout)) { return { source: Position.Bottom, target: Position.Top }; } return layoutDirection(layout) === -1 ? { source: Position.Left, target: Position.Right } : { source: Position.Right, target: Position.Left }; } function handleSpec(type: 's' | 't', position: Position): HandleSpec { return { id: `${type}-${position}`, position }; } /** Poignees d'un noeud : une entrante, une ou deux sortantes. */ export interface HandlePlan { sources: HandleSpec[]; target: HandleSpec; /** Poignee sortante a utiliser pour rejoindre un enfant, par cote. */ sourceBySide: Map; } const SIDE_POSITION: Record = { left: Position.Left, right: Position.Right, }; /** * Repartition des poignees, calculee une fois par carte. * * Hors bilaterale, tous les noeuds partagent la meme orientation, comme avant. * En bilaterale, chaque noeud regarde vers le centre et la racine porte les * deux poignees sortantes : c'est ce qui permet aux branches de partir a gauche * comme a droite sans que le trait fasse un detour. */ export function buildHandlePlans(map: MindMap): Map { const plans = new Map(); const bilateral = map.settings.layout === 'mindmap-both'; const positions = handlePositions(map.settings.layout); if (!bilateral) { const source = handleSpec('s', positions.source); const target = handleSpec('t', positions.target); const plan: HandlePlan = { sources: [source], target, sourceBySide: new Map([ ['left', source], ['right', source], ]), }; for (const node of map.nodes) plans.set(node.id, plan); return plans; } const sides = resolveNodeSides(map.nodes); const children = buildChildrenMap(map.nodes); for (const node of map.nodes) { const own = sides.get(node.id) ?? 'right'; // Le parent est toujours du cote du centre : l'entree lui fait face. const target = handleSpec('t', own === 'left' ? Position.Right : Position.Left); const childSides = new Set(); for (const childId of children.get(node.id) ?? []) { childSides.add(sides.get(childId) ?? 'right'); } // Un noeud sans enfant garde une poignee : on doit pouvoir tirer un lien. if (childSides.size === 0) childSides.add(own); const sources = (['left', 'right'] as NodeSide[]) .filter((side) => childSides.has(side)) .map((side) => handleSpec('s', SIDE_POSITION[side])); const sourceBySide = new Map(); for (const side of ['left', 'right'] as NodeSide[]) { const match = sources.find((handle) => handle.position === SIDE_POSITION[side]); sourceBySide.set(side, match ?? (sources[0] as HandleSpec)); } plans.set(node.id, { sources, target, sourceBySide }); } return plans; } const EDGE_TYPE_BY_BRANCH: Record = { bezier: 'default', curved: 'smoothstep', straight: 'straight', }; export interface AdapterOptions { theme: MapTheme; selectedIds: string[]; searchResults: string[]; editingNodeId: string | null; readOnly: boolean; /** Tailles reelles mesurees dans le DOM, requises par la minimap. */ measuredSizes?: Map; } export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode[] { const depths = computeDepths(map.nodes); const hidden = getHiddenNodeIds(map.nodes); const children = buildChildrenMap(map.nodes); const selected = new Set(options.selectedIds); const searchHits = new Set(options.searchResults); const plans = buildHandlePlans(map); const fallback: HandlePlan = { sources: [handleSpec('s', Position.Right)], target: handleSpec('t', Position.Left), sourceBySide: new Map(), }; return map.nodes.map((node) => { const depth = depths.get(node.id) ?? 0; const childIds = children.get(node.id) ?? []; const measured = options.measuredSizes?.get(node.id); const plan = plans.get(node.id) ?? fallback; return { id: node.id, type: 'mind' as const, position: node.position, ...(measured ? { measured } : {}), selected: selected.has(node.id), hidden: hidden.has(node.id), draggable: !options.readOnly, selectable: true, connectable: !options.readOnly, deletable: !options.readOnly, data: { node, depth, appearance: resolveNodeAppearance(node, depth, options.theme), childCount: childIds.length, hiddenCount: node.collapsed ? countBranch(children, node.id) : 0, isSearchHit: searchHits.has(node.id), readOnly: options.readOnly, isEditing: options.editingNodeId === node.id, sourceHandles: plan.sources, targetHandle: plan.target, }, }; }); } function countBranch(children: Map, rootId: string): number { let total = 0; 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; } /** * Les liens heritent du style de branche de la carte, sauf si le noeud enfant * definit sa propre personnalisation (`node.branch`). */ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] { const hidden = getHiddenNodeIds(map.nodes); const nodeMap = buildNodeMap(map.nodes); const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default'; const plans = buildHandlePlans(map); const sides = map.settings.layout === 'mindmap-both' ? resolveNodeSides(map.nodes) : null; return map.edges.map((edge) => { const target = nodeMap.get(edge.target); const branch = target?.branch ?? {}; const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType; const childSide: NodeSide = sides?.get(edge.target) ?? 'right'; return { id: edge.id, source: edge.source, target: edge.target, sourceHandle: plans.get(edge.source)?.sourceBySide.get(childSide)?.id, targetHandle: plans.get(edge.target)?.target.id, type, hidden: hidden.has(edge.source) || hidden.has(edge.target), deletable: !options.readOnly, style: { stroke: branch.color ?? edge.color ?? options.theme.branchColor, strokeWidth: branch.width ?? options.theme.branchWidth, }, }; }); } /** Apparence par defaut d'une relation : pointille flechee, pour la distinguer. */ export const RELATION_DEFAULTS = { style: 'bezier' as BranchStyle, width: 2, dashed: true, markerEnd: 'arrow' as const, markerStart: 'none' as const, }; /** Prefixe des identifiants de relation : ils ne peuvent pas etre confondus * avec un lien hierarchique, dont l'id est toujours `e--`. */ export const RELATION_ID_PREFIX = 'r-'; /** * Relations secondaires converties en aretes React Flow. * * Elles ne sont jamais melangees a `map.edges` dans le document : les liens * hierarchiques restent derives de `parentId`, les relations restent une donnee * propre. C'est seulement a l'affichage que les deux se rejoignent. * * Une relation dont une extremite est masquee par un repli est masquee elle * aussi : on ne trace pas un trait vers une position qui n'existe pas. Elle * reste dans le document et reapparait au deploiement. */ export function toFlowRelationEdges( map: MindMap, options: AdapterOptions & { selectedRelationId?: string | null }, ): Edge[] { if (map.relations.length === 0) return []; const hidden = getHiddenNodeIds(map.nodes); const nodeMap = buildNodeMap(map.nodes); const plans = buildHandlePlans(map); return map.relations .filter((relation) => nodeMap.has(relation.sourceId) && nodeMap.has(relation.targetId)) .map((relation) => { const source = nodeMap.get(relation.sourceId) as MindMapNode; const target = nodeMap.get(relation.targetId) as MindMapNode; const type = EDGE_TYPE_BY_BRANCH[relation.style ?? RELATION_DEFAULTS.style] ?? 'default'; const color = relation.color ?? relationColor(options.theme); const width = relation.width ?? RELATION_DEFAULTS.width; const dashed = relation.dashed ?? RELATION_DEFAULTS.dashed; // La poignee sortante la plus proche de la cible evite un trait qui // contourne le noeud avant de repartir. const towards: NodeSide = target.position.x >= source.position.x ? 'right' : 'left'; const sourcePlan = plans.get(relation.sourceId); const sourceHandle = sourcePlan?.sources.find( (handle) => handle.position === SIDE_POSITION[towards], )?.id ?? sourcePlan?.sources[0]?.id; return { id: relation.id, source: relation.sourceId, target: relation.targetId, sourceHandle, targetHandle: plans.get(relation.targetId)?.target.id, type, selected: options.selectedRelationId === relation.id, hidden: hidden.has(relation.sourceId) || hidden.has(relation.targetId), deletable: !options.readOnly, selectable: true, ...(relation.label ? { label: relation.label } : {}), labelStyle: { fill: color, fontSize: 11, fontWeight: 500 }, labelBgStyle: { fill: options.theme.canvasBackground, fillOpacity: 0.85 }, labelBgPadding: [4, 2] as [number, number], labelBgBorderRadius: 4, markerEnd: (relation.markerEnd ?? RELATION_DEFAULTS.markerEnd) === 'arrow' ? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 } : undefined, markerStart: (relation.markerStart ?? RELATION_DEFAULTS.markerStart) === 'arrow' ? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 } : undefined, style: { stroke: color, strokeWidth: width, ...(dashed ? { strokeDasharray: '6 5' } : {}), }, data: { relation }, }; }); } /** Vrai si cette arete affichee est une relation secondaire. */ export function isRelationEdgeId(map: MindMap, edgeId: string): boolean { return map.relations.some((relation) => relation.id === edgeId); }