ADR 0082 – Canvas de nœuds : une toile libre dont les flèches créent de vrais liens
Accepté (2026-10-16, mode autonome)
ADR 0082 – Canvas de nœuds : une toile libre dont les flèches créent de vrais liens
Statut : Accepté (2026-10-16, mode autonome)
Vague 4 du document d’idées : une toile libre où l’on pose des nœuds et des cartes de texte, et où une flèche tracée entre deux cartes crée un vrai lien typé. Une première tranche a livré le backend (modèle, API, événement, export) ; la seconde, l’interface (section « Interface »).
Contexte
Les nœuds se retrouvent par les vues, les liens et la recherche, mais rien ne permet de les disposer dans l’espace pour réfléchir (cartographier un projet, relier des idées). Le modèle à suivre est celui du journal de suivi (ADR 0057) et des annotations de fichiers (ADR 0073) : une sous-ressource d’un nœud, écrite sous le verrou du nœud, avec un événement dans la même transaction.
Décision
- Une toile est un nœud ordinaire qui porte une disposition. Aucun type n’est codé en dur : n’importe quel nœud peut avoir une toile, lue et écrite par la même API. Un nœud sans toile en a une vide.
- Table
node_canvas(migrationV15__toiles.sql) :node_id(clé primaire,on delete cascade),data(JSONB),version(entier, 1 à la première écriture),updated_at,actor. Une ligne par nœud ; la disposition est un seul document. - Contenu de
data:{ cards: [...], arrows: [...] }.- Carte :
id(texte court, 40 caractères au plus, lettres, chiffres,_et-, unique dans la toile),kind(nodeoutext),nodeId(obligatoire pournode, nœud existant au moment où la carte est ajoutée),text(textseulement, 2000 caractères au plus),x,y(nombres finis entre -100000 et 100000),w,h(entre 40 et 2000),color(absente ouyellow,green,blue,pink,gray). - Flèche :
id(unique),frometto(identifiants de cartes de la toile),label(100 caractères au plus, facultatif),linkType(nom d’un lien deconfig/links/, facultatif),linkId(UUID du lien créé, facultatif). - Limites : 300 cartes, 600 flèches, 512 Ko de JSON une fois enregistré.
- Carte :
- API sous
/api/nodes/{id}/canvas(tagcanvas) :GET(canvas) : la toile ;{ cards: [], arrows: [], version: 0 }si elle n’a jamais été écrite. Les cartesnodeportentnodeTitleetnodeTypede la cible, résolus à la lecture ;missing: truesi la cible est supprimée ou en corbeille (le titre est alors absent). Rien ne change dans le stockage : la carte reprend sa place si la cible est restaurée.PUT(saveCanvas) : remplace toute la disposition. Le corps porteexpectedVersion(0 pour une première écriture),cardsetarrows. Si la version courante est différente : 409 avec un message qui invite à recharger. Sinon la version est incrémentée et la toile renvoyée, titres résolus.- 404 si le nœud est absent ou en corbeille (lecture comme écriture) ; 400 avec des erreurs par chemin (
cards[2].nodeId,arrows[0].to,expectedVersion…) pour : identifiants en double ou invalides, flèche vers une carte absente, cartenodesansnodeIdou vers un nœud introuvable, genre ou couleur inconnus, positions et dimensions hors bornes, texte ou libellé trop longs,linkTypeinconnu, limites dépassées. linkId: pour unlinkIdnouveau ou dont la flèche a changé de cartes ou de type, le lien doit exister, aller du nœud de la carte de départ à celui de la carte d’arrivée (deux cartesnode) et être dulinkTypeindiqué ; sinon 400arrows[i].linkId. Une flèche dont lelinkIdest inchangé n’est pas recontrôlée (un lien retiré depuis ne bloque pas). Une flèche d’une carte vers elle-même est refusée (400arrows[i].to). Les valeurs recopiées dans les messages d’erreur sont tronquées à 40 caractères.- Valider à l’écriture, tolérer à la lecture : seules les cibles de nœud et les types de lien nouveaux sont contrôlés. Une cible purgée ou un lien retiré de
config/depuis le dernier enregistrement ne bloquent pas l’enregistrement d’une toile.
- Les flèches créent de vrais liens, côté client. Le serveur de la toile ne crée aucun lien : l’interface tracera la flèche puis appellera l’API des liens existante (
relie-aou un autre type), avec ses contraintes et ses événementslink.added/link.removed, et rangera lelinkIddans la flèche. La toile ne stocke qu’une référence ; le lien reste la source de vérité. - Lien
relie-a(config/links/relie-a.yaml) : « Relié à » / « Relié depuis », cardinalitémany, sans contrainte de source ni de cible. C’est le type proposé par défaut aux flèches libres. - Événement
canvas.updatedà chaque écriture, enregistré avec le verrou du nœud dans la même transaction. Le payload ne contient que des compteurs :beforeetafteravecversion,cardsetarrows. Jamais la disposition (taille, confidentialité des textes des cartes). Il ne compte pas comme une modification locale pour les connecteurs (ADR 0047), commejournal.*etannotation.*. - Export : le frontmatter Markdown d’un nœud dont la toile n’est pas vide porte
canvas:cards(nombre de cartes) etarrows(liste « titre -> titre (libellé) », le titre d’une carte de nœud étant celui du nœud, celui d’une carte de texte son début). Les positions n’y figurent pas.
Interface
Page paresseuse /canvas/:id (features/canvas/canvas-page.ts, chunk à part), ouverte par le bouton « Toile » de la fiche d’un nœud et par « Ouvrir la toile » dans la palette Ctrl+K (seulement quand une fiche est affichée). Le titre du nœud est rappelé, avec un lien de retour vers la fiche. Aucun nom de type ou d’aspect n’est connu du code : les badges de type et les types de lien viennent des définitions.
-
Surface : cartes en
transform: translate(x, y)dans un monde mis à l’échelle ; molette = zoom autour du curseur, boutons « Zoom − / + » et « Ajuster », glisser le fond pour se déplacer. Déplacement des cartes avec accrochage à une grille de 8 px, redimensionnement par la poignée du coin, cinq couleurs, flèches en SVG (ancrées sur le bord des cartes, libellé au milieu). Carte nœud : titre, badge de type, double-clic ou bouton pour ouvrir la fiche, « Nœud introuvable ou dans la corbeille » simissing. Carte texte : édition en place (double-clic, Entrée ou F2 ; Échap ou Ctrl+Entrée valide). -
Une flèche est un vrai lien : la poignée à droite d’une carte se glisse vers une autre carte, ce qui ouvre le formulaire « Nouvelle flèche » (type de lien,
relie-apar défaut, ou « Aucun » pour une flèche simple ; libellé facultatif). Entre deux cartes nœud, « Créer » appellePOST /api/nodes/{source}/linkspuis rangelinkIdetlinkTypedans la flèche ; si l’API refuse (cardinalité, contrainte), l’erreur est affichée et rien n’est créé. Une carte texte au bout d’une flèche la rend simple, sans lien. « Supprimer la flèche » retire la flèche seulement (le lien reste, source de vérité) ; « Supprimer aussi le lien » appelleDELETE /api/nodes/{source}/links/{linkId}et propose « Annuler » pendant 10 secondes (le lien est recréé avec un nouvel identifiant et la flèche rétablie). -
Enregistrement : toute la disposition est envoyée par
PUTaprès 800 ms de calme (SaveQueue,core/canvas/save-queue.ts), avecexpectedVersion. Un seul envoi à la fois ; un geste pendant un envoi en provoque un seul autre ensuite. États visibles : « Enregistré », « Enregistrement… », « Modifications en attente », « Conflit », « Échec de l’enregistrement » (avec « Réessayer »). Sur 409, plus aucun envoi : un bandeau explique que la toile a été modifiée ailleurs et propose « Recharger la toile » ; rien n’est écrasé en silence. À la sortie de la page, une modification en attente ou un échec est envoyé une dernière fois (sans minuteur) ; une garde de navigation (confirmation) etbeforeunloadpréviennent tant que quelque chose n’est pas enregistré (en attente, échec, conflit). -
Cohérence lien et flèche : le lien est créé par
POSTavant que sonlinkIdparte dans l’enregistrement différé. Tant que l’état est « Conflit », créer une flèche liée ou supprimer un lien est refusé (rechargez d’abord). LePOSTn’est pas interrompu par la sortie de la page : la flèche est alors poussée dans la disposition et enregistrée aussitôt. Un 409 « existe déjà » à la création reprend le lien existant (même type, même cible) au lieu d’échouer. « Supprimer aussi le lien » traite un 404 (lien déjà retiré) comme un succès. Un échec de chargement (nœud en corbeille, réseau) masque la surface et les outils. -
Accessibilité : bascule « Toile / Liste ». La vue Liste est un couple de tableaux (cartes, flèches) avec les mêmes actions : ajouter, éditer le texte, changer la couleur, supprimer, ouvrir le nœud, créer une flèche par les sélecteurs « De » et « Vers », libellé, retrait avec ou sans le lien. Sur la toile, les cartes (et les flèches) se parcourent avec Tab ; flèches du clavier = déplacement d’un pas de 8 px, Maj = 64 px, Ctrl = redimensionnement ; Entrée ouvre le nœud ou édite le texte, Suppr supprime. Après une suppression au clavier, le focus passe à la carte suivante ou à la surface ; l’inspecteur de sélection est posé sur la surface (il ne décale pas les cartes) ; les consignes clavier sont reliées à la surface par
aria-describedby. Chaque carte porte unaria-label(type, titre, couleur, position), les changements sont annoncés dans une régionrole="status", les couleurs et contours utilisent les variables--sb-*(clair et sombre), sans animation sousprefers-reduced-motion. -
Code testé isolément : géométrie (
core/canvas/canvas-geometry.ts: grille, zoom, déplacement, ancrage des flèches, ajustement), modèle et sérialisation versCanvasWriteRequest(canvas-model.ts), file d’enregistrement (save-queue.ts). -
Taille du corps : un filtre (
CanvasBodyLimitFilter) refuse lePUTdont la longueur annoncée dépasse 1 Mo (413) et interrompt la lecture au-delà si elle n’est pas annoncée, avant que Jackson ne lise le corps. Le plafond de 512 Ko s’applique ensuite au JSON enregistré.
Limites assumées
-
Interface : pas de temps réel ni de fusion (un onglet ou une fenêtre à la fois : le second doit recharger), pas d’export en image, pas d’annulation générale des gestes (seul le retrait d’un lien propose « Annuler »), pas de sélection multiple, pas de dépôt depuis le panneau Arborescence (le glisser-déposer n’y existe pas), et la création d’une flèche au clavier passe par la vue Liste. Un lien retiré par ailleurs n’est pas détecté : la flèche garde son
linkIdet l’étiquette du lien. -
Un seul rédacteur à la fois : le remplacement complet avec
expectedVersionévite d’écraser sans le savoir, mais la seconde fenêtre doit recharger et refaire sa modification. Pas de fusion, pas de temps réel, pas de curseurs partagés. -
La toile entière est relue et réécrite à chaque enregistrement (jusqu’à 512 Ko) : l’interface devra regrouper les gestes (enregistrement différé).
-
Les textes des cartes ne sont pas indexés en recherche plein texte, et une toile n’a pas d’historique de versions (seulement les compteurs du journal).
-
Une flèche dont le lien a été retiré par ailleurs garde son
linkId: c’est à l’interface de le constater (le lien n’existe plus) ; le serveur ne synchronise pas les deux. -
Les positions ne sont pas réexportées : l’export est une lecture, pas une sauvegarde (la sauvegarde de la base les contient).
Alternatives écartées
- Un aspect JSON sur le nœud (un champ
canvasdans un aspect) : la disposition passerait par le schéma d’aspect, la recherche plein texte, les vues et l’historiqueaspect.updated(avec l’avant/après complet à chaque déplacement de carte), et le domaine (« aspect toile ») serait supposé par le code. Une table dédiée garde ces mécanismes à l’écart. - Une ligne par carte et par flèche : écritures fines (déplacer une carte = un
UPDATE) et pas de conflit global, mais des dizaines de requêtes à la lecture, un ordre et une cohérence (flèches orphelines) à maintenir, une API plus bavarde et un événement par geste. Les toiles restent petites (300 cartes) : un document remplacé en bloc est plus simple et plus sûr. - Fusion automatique ou temps réel (CRDT, WebSocket) : hors périmètre pour un outil auto-hébergé à un utilisateur ; le contrôle de version suffit.