Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et `MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les cartes v1/v2/v3 restent ouvrables telles quelles. Une relation est une connexion purement visuelle : elle ne touche jamais parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue de deriver les liens de parentId et se contente de retirer les relations dont une extremite a disparu. Mind map bilaterale : repartition gloutonne et deterministe des branches selon la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul Reorganiser applique. Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant, et l'export Markdown de la hierarchie. Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles changent de cote, ce qui faisait disparaitre les liens concernes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
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<string, unknown> {
|
|
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<MindNodeData, 'mind'>;
|
|
|
|
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<NodeSide, HandleSpec>;
|
|
}
|
|
|
|
const SIDE_POSITION: Record<NodeSide, Position> = {
|
|
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<string, HandlePlan> {
|
|
const plans = new Map<string, HandlePlan>();
|
|
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<NodeSide>();
|
|
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<NodeSide, HandleSpec>();
|
|
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<BranchStyle, string> = {
|
|
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<string, NodeSize>;
|
|
}
|
|
|
|
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<string, string[]>, 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-<parent>-<enfant>`. */
|
|
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);
|
|
}
|