Second Brain documentation

ADR 0082 – Canvas de nœuds : une toile libre dont les flèches créent de vrais liens

Accepté (2026-10-16, mode autonome)

← Toutes les décisions

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 (migration V15__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 (node ou text), nodeId (obligatoire pour node, nœud existant au moment où la carte est ajoutée), text (text seulement, 2000 caractères au plus), x, y (nombres finis entre -100000 et 100000), w, h (entre 40 et 2000), color (absente ou yellow, green, blue, pink, gray).
    • Flèche : id (unique), from et to (identifiants de cartes de la toile), label (100 caractères au plus, facultatif), linkType (nom d’un lien de config/links/, facultatif), linkId (UUID du lien créé, facultatif).
    • Limites : 300 cartes, 600 flèches, 512 Ko de JSON une fois enregistré.
  • API sous /api/nodes/{id}/canvas (tag canvas) :
    • GET (canvas) : la toile ; { cards: [], arrows: [], version: 0 } si elle n’a jamais été écrite. Les cartes node portent nodeTitle et nodeType de la cible, résolus à la lecture ; missing: true si 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 porte expectedVersion (0 pour une première écriture), cards et arrows. 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, carte node sans nodeId ou vers un nœud introuvable, genre ou couleur inconnus, positions et dimensions hors bornes, texte ou libellé trop longs, linkType inconnu, limites dépassées.
    • linkId : pour un linkId nouveau 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 cartes node) et être du linkType indiqué ; sinon 400 arrows[i].linkId. Une flèche dont le linkId est 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 (400 arrows[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-a ou un autre type), avec ses contraintes et ses événements link.added / link.removed, et rangera le linkId dans 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 : before et after avec version, cards et arrows. Jamais la disposition (taille, confidentialité des textes des cartes). Il ne compte pas comme une modification locale pour les connecteurs (ADR 0047), comme journal.* et annotation.*.
  • Export : le frontmatter Markdown d’un nœud dont la toile n’est pas vide porte canvas : cards (nombre de cartes) et arrows (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 » si missing. 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-a par défaut, ou « Aucun » pour une flèche simple ; libellé facultatif). Entre deux cartes nœud, « Créer » appelle POST /api/nodes/{source}/links puis range linkId et linkType dans 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 » appelle DELETE /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 PUT après 800 ms de calme (SaveQueue, core/canvas/save-queue.ts), avec expectedVersion. 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) et beforeunload préviennent tant que quelque chose n’est pas enregistré (en attente, échec, conflit).

  • Cohérence lien et flèche : le lien est créé par POST avant que son linkId parte 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). Le POST n’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 un aria-label (type, titre, couleur, position), les changements sont annoncés dans une région role="status", les couleurs et contours utilisent les variables --sb-* (clair et sombre), sans animation sous prefers-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 vers CanvasWriteRequest (canvas-model.ts), file d’enregistrement (save-queue.ts).

  • Taille du corps : un filtre (CanvasBodyLimitFilter) refuse le PUT dont 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 linkId et 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 canvas dans un aspect) : la disposition passerait par le schéma d’aspect, la recherche plein texte, les vues et l’historique aspect.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.