Persister la position des nœuds du graphe #66

Closed
opened 2026-07-21 21:42:40 +02:00 by fy59 · 0 comments
Owner

Persister la position des nœuds du graphe

Contexte

La vue graphique permet actuellement :

  • d’afficher les entités et leurs relations ;
  • de zoomer ;
  • de déplacer le canvas ;
  • de déplacer individuellement les nœuds ;
  • de sélectionner une entité ;
  • d’ouvrir un volet détaillé pour l’entité sélectionnée.

Les positions des nœuds restent uniquement en mémoire et sont perdues à la fermeture de l’application.

Objectif

Enregistrer les positions des nœuds dans la base SQLite de l’enquête et les restaurer automatiquement lors du prochain chargement.

Version du schéma

La version courante reste :

#define DATABASE_SCHEMA_VERSION_CURRENT 2
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "2"

Pourquoi ne pas modifier uniquement schema_v2.sql

Une base déjà enregistrée avec :

metadata.schema_version = 2

ne rejoue pas la migration V1 vers V2.

Ajouter uniquement la nouvelle table à schema_v2.sql ne mettrait donc à jour que les nouvelles bases et les anciennes bases V1.

Les bases V2 existantes resteraient privées de la table de positions.

Extension idempotente du schéma courant

Créer :

database/schema_current.sql

Ce fichier contient uniquement des opérations pouvant être rejouées sans erreur :

CREATE TABLE IF NOT EXISTS graph_node_positions
(
    entity_id TEXT PRIMARY KEY,

    x REAL NOT NULL,
    y REAL NOT NULL,

    updated_at TEXT NOT NULL,

    FOREIGN KEY (entity_id)
        REFERENCES entites(id)
        ON UPDATE CASCADE
        ON DELETE CASCADE,

    CHECK (x = x),
    CHECK (y = y)
);

Les coordonnées sont un état de présentation et ne doivent pas être ajoutées à entites.

Aucun index supplémentaire n’est nécessaire : la clé primaire sur entity_id crée déjà l’index utile.

Installation du schéma courant

Ajouter dans schema.h :

bool schema_ensure_current(
    Database *database
);

Ajouter dans schema.c :

bool schema_ensure_current(
    Database *database
)
{
    return schema_execute_file(
        database,
        "database/schema_current.sql",
        "les extensions du schéma SQLite courant"
    );
}

La connexion doit être ouverte et une transaction doit déjà être active.

La fonction ne réalise ni COMMIT ni ROLLBACK.

Nouvelles bases

Dans database_initialize() :

  1. commencer la transaction ;
  2. installer V1 ;
  3. installer V2 ;
  4. appliquer schema_ensure_current() ;
  5. insérer les métadonnées ;
  6. insérer l’enquête ;
  7. valider la transaction.

La version enregistrée reste 2.

Bases existantes

Dans database_migrate_to_latest() :

  1. lire la version ;
  2. appliquer les éventuelles migrations versionnées ;
  3. ouvrir une transaction dédiée ;
  4. exécuter schema_ensure_current() ;
  5. valider la transaction ;
  6. conserver schema_version = 2.

Une base déjà en V2 recevra ainsi la table manquante sans changement de version.

L’opération doit être rejouable à chaque ouverture grâce à CREATE TABLE IF NOT EXISTS.

Modèle de position

Créer :

include/models/graph_node_position.h
src/models/graph_node_position.c

API minimale :

typedef struct GraphNodePosition GraphNodePosition;

GraphNodePosition *graph_node_position_new(
    const char *entity_identifier,
    double x,
    double y,
    const char *updated_at
);

const char *graph_node_position_get_entity_identifier(
    const GraphNodePosition *position
);

double graph_node_position_get_x(
    const GraphNodePosition *position
);

double graph_node_position_get_y(
    const GraphNodePosition *position
);

const char *graph_node_position_get_updated_at(
    const GraphNodePosition *position
);

void graph_node_position_free(
    GraphNodePosition *position
);

Le modèle possède ses chaînes.

Les UUID vides, NaN et les valeurs infinies doivent être refusés.

DAO des positions

Créer :

include/dao/graph_node_position_dao.h
src/dao/graph_node_position_dao.c

API minimale :

GPtrArray *graph_node_position_dao_list_all(
    Database *database
);

bool graph_node_position_dao_upsert(
    Database *database,
    const char *entity_identifier,
    double x,
    double y
);

bool graph_node_position_dao_delete(
    Database *database,
    const char *entity_identifier
);

L’UPSERT doit utiliser une requête préparée :

INSERT INTO graph_node_positions
(
    entity_id,
    x,
    y,
    updated_at
)
VALUES
(
    ?,
    ?,
    ?,
    ?
)
ON CONFLICT(entity_id)
DO UPDATE SET
    x = excluded.x,
    y = excluded.y,
    updated_at = excluded.updated_at;

Aucune donnée utilisateur ne doit être concaténée dans du SQL.

Modèle de disposition

Créer :

include/models/investigation_graph_layout.h
src/models/investigation_graph_layout.c

Responsabilités :

  • posséder les positions chargées ;
  • indexer les positions par UUID ;
  • permettre une lecture rapide ;
  • permettre une mise à jour après déplacement ;
  • rester indépendant de GTK et SQLite.

API indicative :

typedef struct InvestigationGraphLayout InvestigationGraphLayout;

InvestigationGraphLayout *investigation_graph_layout_new(void);

bool investigation_graph_layout_set_position(
    InvestigationGraphLayout *layout,
    const char *entity_identifier,
    double x,
    double y
);

bool investigation_graph_layout_get_position(
    const InvestigationGraphLayout *layout,
    const char *entity_identifier,
    double *x,
    double *y
);

void investigation_graph_layout_free(
    InvestigationGraphLayout *layout
);

Chargement

Lors de l’ouverture d’une enquête :

  1. ouvrir et mettre à niveau la base V2 ;
  2. charger le graphe métier ;
  3. charger les positions ;
  4. construire InvestigationGraphLayout ;
  5. transmettre le graphe et la disposition à la vue.

Une entité sans position persistée conserve le placement automatique actuel.

Notification de déplacement

Ajouter à InvestigationGraphView :

typedef void (*InvestigationGraphViewNodeMovedCallback)(
    const char *entity_identifier,
    double x,
    double y,
    gpointer user_data
);

void investigation_graph_view_set_node_moved_callback(
    InvestigationGraphView *graph_view,
    InvestigationGraphViewNodeMovedCallback callback,
    gpointer user_data
);

Le callback doit être déclenché uniquement à la fin d’un déplacement réel de nœud.

Il ne doit pas être appelé :

  • pendant chaque drag-update ;
  • lors d’un clic ;
  • lors du déplacement du canvas ;
  • lorsque la position n’a pas changé ;
  • lors de la construction automatique du graphe.

Coordination

InvestigationGraphView
        ↓ fin du glisser
Workspace
        ↓ relais
MainWindow
        ↓ relais
Application
        ↓
InvestigationGraphLayout
        ↓
GraphNodePositionDao
        ↓
SQLite

Aucun widget ne doit accéder directement à SQLite.

Gestion des erreurs

En cas d’échec d’enregistrement :

  • conserver la position à l’écran ;
  • remonter une erreur structurée ;
  • ne pas fermer le graphe ;
  • ne pas corrompre la base ;
  • autoriser un nouvel essai au déplacement suivant.

Hors périmètre

Ce ticket n’inclut pas :

  • la persistance du zoom ;
  • la persistance du déplacement du canvas ;
  • plusieurs dispositions nommées ;
  • l’annulation/rétablissement ;
  • l’enrichissement du volet détaillé.

L’enrichissement des fiches sera traité après la persistance.

Tests du schéma

  • La version courante reste 2.
  • Une nouvelle base contient graph_node_positions.
  • Une base V1 est migrée vers V2 puis reçoit la table.
  • Une base V2 existante reçoit la table.
  • Une seconde ouverture ne provoque aucune erreur.
  • La suppression d’une entité supprime sa position.
  • Une erreur pendant l’installation provoque un rollback.

Tests fonctionnels

  • Déplacer un nœud.
  • Fermer l’application.
  • Rouvrir la même enquête.
  • Vérifier que la position est restaurée.
  • Une entité sans position reste visible.
  • Le déplacement du canvas ne crée aucune position.
  • Un clic simple ne crée aucune position.
  • Deux enquêtes ne partagent aucune disposition.

Critères d’acceptation

  • DATABASE_SCHEMA_VERSION_CURRENT reste à 2.
  • Les bases V2 existantes sont prises en charge.
  • Les coordonnées ne sont pas stockées dans entites.
  • La vue n’accède jamais directement à SQLite.
  • Une écriture est effectuée uniquement en fin de glisser.
  • Les positions sont restaurées après redémarrage.
  • make clean && make réussit avec -Werror -Wpedantic.
  • make test réussit.
  • git diff --check ne signale aucune erreur.

Ordre d’implémentation

  1. Créer schema_current.sql.
  2. Ajouter schema_ensure_current().
  3. L’intégrer aux nouvelles bases.
  4. L’intégrer aux bases V2 existantes.
  5. Tester l’installation idempotente.
  6. Créer GraphNodePosition.
  7. Créer son DAO.
  8. Créer InvestigationGraphLayout.
  9. Charger les positions.
  10. Ajouter le callback de fin de déplacement.
  11. Relayer l’événement jusqu’à Application.
  12. Réaliser l’UPSERT.
  13. Tester fermeture et réouverture.
# Persister la position des nœuds du graphe ## Contexte La vue graphique permet actuellement : - d’afficher les entités et leurs relations ; - de zoomer ; - de déplacer le canvas ; - de déplacer individuellement les nœuds ; - de sélectionner une entité ; - d’ouvrir un volet détaillé pour l’entité sélectionnée. Les positions des nœuds restent uniquement en mémoire et sont perdues à la fermeture de l’application. ## Objectif Enregistrer les positions des nœuds dans la base SQLite de l’enquête et les restaurer automatiquement lors du prochain chargement. ## Version du schéma La version courante reste : ```c #define DATABASE_SCHEMA_VERSION_CURRENT 2 #define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "2" ``` ## Pourquoi ne pas modifier uniquement schema_v2.sql Une base déjà enregistrée avec : ```text metadata.schema_version = 2 ``` ne rejoue pas la migration V1 vers V2. Ajouter uniquement la nouvelle table à `schema_v2.sql` ne mettrait donc à jour que les nouvelles bases et les anciennes bases V1. Les bases V2 existantes resteraient privées de la table de positions. ## Extension idempotente du schéma courant Créer : ```text database/schema_current.sql ``` Ce fichier contient uniquement des opérations pouvant être rejouées sans erreur : ```sql CREATE TABLE IF NOT EXISTS graph_node_positions ( entity_id TEXT PRIMARY KEY, x REAL NOT NULL, y REAL NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (entity_id) REFERENCES entites(id) ON UPDATE CASCADE ON DELETE CASCADE, CHECK (x = x), CHECK (y = y) ); ``` Les coordonnées sont un état de présentation et ne doivent pas être ajoutées à `entites`. Aucun index supplémentaire n’est nécessaire : la clé primaire sur `entity_id` crée déjà l’index utile. ## Installation du schéma courant Ajouter dans `schema.h` : ```c bool schema_ensure_current( Database *database ); ``` Ajouter dans `schema.c` : ```c bool schema_ensure_current( Database *database ) { return schema_execute_file( database, "database/schema_current.sql", "les extensions du schéma SQLite courant" ); } ``` La connexion doit être ouverte et une transaction doit déjà être active. La fonction ne réalise ni `COMMIT` ni `ROLLBACK`. ## Nouvelles bases Dans `database_initialize()` : 1. commencer la transaction ; 2. installer V1 ; 3. installer V2 ; 4. appliquer `schema_ensure_current()` ; 5. insérer les métadonnées ; 6. insérer l’enquête ; 7. valider la transaction. La version enregistrée reste `2`. ## Bases existantes Dans `database_migrate_to_latest()` : 1. lire la version ; 2. appliquer les éventuelles migrations versionnées ; 3. ouvrir une transaction dédiée ; 4. exécuter `schema_ensure_current()` ; 5. valider la transaction ; 6. conserver `schema_version = 2`. Une base déjà en V2 recevra ainsi la table manquante sans changement de version. L’opération doit être rejouable à chaque ouverture grâce à `CREATE TABLE IF NOT EXISTS`. ## Modèle de position Créer : ```text include/models/graph_node_position.h src/models/graph_node_position.c ``` API minimale : ```c typedef struct GraphNodePosition GraphNodePosition; GraphNodePosition *graph_node_position_new( const char *entity_identifier, double x, double y, const char *updated_at ); const char *graph_node_position_get_entity_identifier( const GraphNodePosition *position ); double graph_node_position_get_x( const GraphNodePosition *position ); double graph_node_position_get_y( const GraphNodePosition *position ); const char *graph_node_position_get_updated_at( const GraphNodePosition *position ); void graph_node_position_free( GraphNodePosition *position ); ``` Le modèle possède ses chaînes. Les UUID vides, `NaN` et les valeurs infinies doivent être refusés. ## DAO des positions Créer : ```text include/dao/graph_node_position_dao.h src/dao/graph_node_position_dao.c ``` API minimale : ```c GPtrArray *graph_node_position_dao_list_all( Database *database ); bool graph_node_position_dao_upsert( Database *database, const char *entity_identifier, double x, double y ); bool graph_node_position_dao_delete( Database *database, const char *entity_identifier ); ``` L’UPSERT doit utiliser une requête préparée : ```sql INSERT INTO graph_node_positions ( entity_id, x, y, updated_at ) VALUES ( ?, ?, ?, ? ) ON CONFLICT(entity_id) DO UPDATE SET x = excluded.x, y = excluded.y, updated_at = excluded.updated_at; ``` Aucune donnée utilisateur ne doit être concaténée dans du SQL. ## Modèle de disposition Créer : ```text include/models/investigation_graph_layout.h src/models/investigation_graph_layout.c ``` Responsabilités : - posséder les positions chargées ; - indexer les positions par UUID ; - permettre une lecture rapide ; - permettre une mise à jour après déplacement ; - rester indépendant de GTK et SQLite. API indicative : ```c typedef struct InvestigationGraphLayout InvestigationGraphLayout; InvestigationGraphLayout *investigation_graph_layout_new(void); bool investigation_graph_layout_set_position( InvestigationGraphLayout *layout, const char *entity_identifier, double x, double y ); bool investigation_graph_layout_get_position( const InvestigationGraphLayout *layout, const char *entity_identifier, double *x, double *y ); void investigation_graph_layout_free( InvestigationGraphLayout *layout ); ``` ## Chargement Lors de l’ouverture d’une enquête : 1. ouvrir et mettre à niveau la base V2 ; 2. charger le graphe métier ; 3. charger les positions ; 4. construire `InvestigationGraphLayout` ; 5. transmettre le graphe et la disposition à la vue. Une entité sans position persistée conserve le placement automatique actuel. ## Notification de déplacement Ajouter à `InvestigationGraphView` : ```c typedef void (*InvestigationGraphViewNodeMovedCallback)( const char *entity_identifier, double x, double y, gpointer user_data ); void investigation_graph_view_set_node_moved_callback( InvestigationGraphView *graph_view, InvestigationGraphViewNodeMovedCallback callback, gpointer user_data ); ``` Le callback doit être déclenché uniquement à la fin d’un déplacement réel de nœud. Il ne doit pas être appelé : - pendant chaque `drag-update` ; - lors d’un clic ; - lors du déplacement du canvas ; - lorsque la position n’a pas changé ; - lors de la construction automatique du graphe. ## Coordination ```text InvestigationGraphView ↓ fin du glisser Workspace ↓ relais MainWindow ↓ relais Application ↓ InvestigationGraphLayout ↓ GraphNodePositionDao ↓ SQLite ``` Aucun widget ne doit accéder directement à SQLite. ## Gestion des erreurs En cas d’échec d’enregistrement : - conserver la position à l’écran ; - remonter une erreur structurée ; - ne pas fermer le graphe ; - ne pas corrompre la base ; - autoriser un nouvel essai au déplacement suivant. ## Hors périmètre Ce ticket n’inclut pas : - la persistance du zoom ; - la persistance du déplacement du canvas ; - plusieurs dispositions nommées ; - l’annulation/rétablissement ; - l’enrichissement du volet détaillé. L’enrichissement des fiches sera traité après la persistance. ## Tests du schéma - [x] La version courante reste 2. - [x] Une nouvelle base contient `graph_node_positions`. - [x] Une base V1 est migrée vers V2 puis reçoit la table. - [x] Une base V2 existante reçoit la table. - [x] Une seconde ouverture ne provoque aucune erreur. - [x] La suppression d’une entité supprime sa position. - [x] Une erreur pendant l’installation provoque un rollback. ## Tests fonctionnels - [x] Déplacer un nœud. - [x] Fermer l’application. - [x] Rouvrir la même enquête. - [x] Vérifier que la position est restaurée. - [x] Une entité sans position reste visible. - [x] Le déplacement du canvas ne crée aucune position. - [x] Un clic simple ne crée aucune position. - [x] Deux enquêtes ne partagent aucune disposition. ## Critères d’acceptation - [x] `DATABASE_SCHEMA_VERSION_CURRENT` reste à 2. - [x] Les bases V2 existantes sont prises en charge. - [x] Les coordonnées ne sont pas stockées dans `entites`. - [x] La vue n’accède jamais directement à SQLite. - [x] Une écriture est effectuée uniquement en fin de glisser. - [x] Les positions sont restaurées après redémarrage. - [x] `make clean && make` réussit avec `-Werror -Wpedantic`. - [x] `make test` réussit. - [x] `git diff --check` ne signale aucune erreur. ## Ordre d’implémentation 1. Créer `schema_current.sql`. 2. Ajouter `schema_ensure_current()`. 3. L’intégrer aux nouvelles bases. 4. L’intégrer aux bases V2 existantes. 5. Tester l’installation idempotente. 6. Créer `GraphNodePosition`. 7. Créer son DAO. 8. Créer `InvestigationGraphLayout`. 9. Charger les positions. 10. Ajouter le callback de fin de déplacement. 11. Relayer l’événement jusqu’à `Application`. 12. Réaliser l’UPSERT. 13. Tester fermeture et réouverture.
fy59 closed this issue 2026-07-22 06:46:02 +02:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: fy59/labfy-investigation#66
No description provided.