Phase 2 : mind map bilaterale, relations, templates et Markdown
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>
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import { type Edge, type Node } from '@xyflow/react';
|
||||
import { Position } from '@xyflow/react';
|
||||
import { type BranchStyle, type MindMap, type MindMapNode } from '@/types/map';
|
||||
import { type MapTheme } from '@/features/editor/themes';
|
||||
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,
|
||||
@@ -22,8 +28,19 @@ export interface MindNodeData extends Record<string, unknown> {
|
||||
isSearchHit: boolean;
|
||||
readOnly: boolean;
|
||||
isEditing: boolean;
|
||||
sourcePosition: Position;
|
||||
targetPosition: Position;
|
||||
/**
|
||||
* 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'>;
|
||||
@@ -40,6 +57,81 @@ function handlePositions(layout: MindMap['settings']['layout']): {
|
||||
: { 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',
|
||||
@@ -62,12 +154,18 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
|
||||
const children = buildChildrenMap(map.nodes);
|
||||
const selected = new Set(options.selectedIds);
|
||||
const searchHits = new Set(options.searchResults);
|
||||
const positions = handlePositions(map.settings.layout);
|
||||
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,
|
||||
@@ -89,8 +187,8 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
|
||||
isSearchHit: searchHits.has(node.id),
|
||||
readOnly: options.readOnly,
|
||||
isEditing: options.editingNodeId === node.id,
|
||||
sourcePosition: positions.source,
|
||||
targetPosition: positions.target,
|
||||
sourceHandles: plan.sources,
|
||||
targetHandle: plan.target,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -116,16 +214,21 @@ 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,
|
||||
@@ -136,3 +239,95 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user