diff --git a/.gitignore b/.gitignore index bec1e59..3bb9baa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build labfy-investigation *.o +*.d # Executables de tests /tests/test_* diff --git a/Makefile b/Makefile index e0164c6..7869f24 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ - CC = gcc PKG_CONFIG = pkg-config @@ -11,6 +10,8 @@ CFLAGS = -std=c17 \ -Werror \ -g \ -Iinclude \ + -MMD \ + -MP \ $(shell $(PKG_CONFIG) --cflags gtk4 sqlite3) LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3) @@ -62,6 +63,8 @@ SRC := $(shell find src -name "*.c") OBJ := $(SRC:.c=.o) +DEP := $(OBJ:.o=.d) + TARGET = labfy-investigation TEST_NODE = tests/test_investigation_node @@ -649,7 +652,7 @@ run: $(TARGET) ./$(TARGET) clean: - rm -f $(OBJ) $(TARGET) \ + rm -f $(OBJ) $(DEP) $(TARGET) \ $(TEST_NODE) \ $(TEST_TREE_MODEL) \ $(TEST_TREE_BUILDER) \ @@ -695,4 +698,6 @@ clean: $(TEST_INVESTIGATION_GRAPH_LOAD_TASK) \ $(TEST_GRAPH_NODE_POSITION_DAO) +-include $(DEP) + .PHONY: clean run test diff --git a/README.md b/README.md index e363aa3..84c00a1 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,6 @@ Le projet vise à fournir un environnement local, modulaire et traçable pour or --- -Labfy Investigation est un poste de travail libre d’investigation numérique et d’OSINT, développé en **C17** avec **GTK4**. - -Le projet vise à fournir un environnement local, modulaire et traçable pour organiser une enquête, préserver les preuves originales, analyser des données, corréler des entités et produire des rapports exploitables. - -> **État du projet : développement actif** -> -> Le logiciel n’est pas encore prêt pour un usage opérationnel en production. Les formats internes, l’interface et les mécanismes d’intégration peuvent encore évoluer. - ---- - ## Objectifs Labfy Investigation doit permettre de : diff --git a/database/schema_current.sql b/database/schema_current.sql index 67a5304..f1a1571 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -57,3 +57,45 @@ CREATE TABLE IF NOT EXISTS graph_node_positions length(trim(updated_at)) > 0 ) ); + +/* + * Disposition générique du graphe. + * + * Contrairement à graph_node_positions, cette table accepte aussi les UUID + * des relations. L'ancienne table reste présente pour assurer la compatibilité + * avec les enquêtes créées avant l'introduction des nœuds de relation. + */ +CREATE TABLE IF NOT EXISTS graph_layout_positions +( + node_id TEXT PRIMARY KEY, + x REAL NOT NULL, + y REAL NOT NULL, + updated_at TEXT NOT NULL, + + CHECK (length(trim(node_id)) > 0), + CHECK (length(updated_at) = 20) +); + +/* Migration idempotente des positions d'entités déjà enregistrées. */ +INSERT OR IGNORE INTO graph_layout_positions(node_id, x, y, updated_at) +SELECT entity_id, x, y, updated_at +FROM graph_node_positions; + +/* Évite qu'une réinitialisation future ne réimporte des coordonnées obsolètes. */ +DELETE FROM graph_node_positions; + +/* Une clé étrangère polymorphe n'existe pas dans SQLite : ces triggers + * suppriment donc les positions devenues orphelines. */ +CREATE TRIGGER IF NOT EXISTS graph_layout_positions_delete_entity +AFTER DELETE ON entites +FOR EACH ROW +BEGIN + DELETE FROM graph_layout_positions WHERE node_id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS graph_layout_positions_delete_relation +AFTER DELETE ON relations +FOR EACH ROW +BEGIN + DELETE FROM graph_layout_positions WHERE node_id = OLD.id; +END; diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index 6b7da10..82cefef 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -2181,6 +2181,30 @@ des fichiers ou des notes. --- +## 11.5 Disposition générique du graphe + +La disposition visuelle du graphe est un état de présentation et non une +donnée métier. Elle est stockée dans `graph_layout_positions`, séparément des +tables `entites` et `relations`. + +Chaque ligne associe un UUID de nœud à des coordonnées logiques et à une date +UTC de mise à jour. Un nœud peut représenter une entité ou une relation. SQLite +ne proposant pas de clé étrangère polymorphe, l'intégrité de ce stockage est +assurée par deux triggers qui suppriment la position correspondante lors de la +suppression physique d'une entité ou d'une relation. + +La table historique `graph_node_positions` ne référençait que `entites(id)`. +À l'ouverture d'une enquête, ses lignes sont copiées de manière idempotente +vers `graph_layout_positions`, puis retirées de la table historique afin +qu'une réinitialisation volontaire de la disposition ne puisse pas restaurer +des coordonnées obsolètes. + +Cette stratégie préserve les positions existantes tout en permettant aux +nœuds de relation de suivre exactement le même cycle de chargement, +d'enregistrement et de réinitialisation que les nœuds d'entité. + +--- + # 12. Conclusion La base de données de Labfy Investigation constitue bien davantage qu'un simple diff --git a/include/dao/graph_node_position_dao.h b/include/dao/graph_node_position_dao.h index 136bac7..e869144 100644 --- a/include/dao/graph_node_position_dao.h +++ b/include/dao/graph_node_position_dao.h @@ -91,12 +91,12 @@ GPtrArray *graph_node_position_dao_list_all( ); /** - * @brief Insère ou met à jour la position d'une entité. + * @brief Insère ou met à jour la position d'un nœud. * * La date updated_at est générée en UTC par le DAO. * * @param position_dao DAO valide. - * @param entity_identifier UUID de l'entité. + * @param node_identifier UUID du nœud (entité ou relation). * @param x Coordonnée horizontale logique. * @param y Coordonnée verticale logique. * @param error Adresse recevant une éventuelle erreur. @@ -105,26 +105,26 @@ GPtrArray *graph_node_position_dao_list_all( */ gboolean graph_node_position_dao_upsert( GraphNodePositionDao *position_dao, - const char *entity_identifier, + const char *node_identifier, double x, double y, GError **error ); /** - * @brief Supprime la position persistée d'une entité. + * @brief Supprime la position persistée d'un nœud. * * L'absence de position n'est pas une erreur. * * @param position_dao DAO valide. - * @param entity_identifier UUID de l'entité. + * @param node_identifier UUID du nœud (entité ou relation). * @param error Adresse recevant une éventuelle erreur. * * @return TRUE si la requête réussit, sinon FALSE. */ gboolean graph_node_position_dao_delete( GraphNodePositionDao *position_dao, - const char *entity_identifier, + const char *node_identifier, GError **error ); diff --git a/include/models/graph_node_position.h b/include/models/graph_node_position.h index 6d5324b..c77f179 100644 --- a/include/models/graph_node_position.h +++ b/include/models/graph_node_position.h @@ -42,11 +42,11 @@ GQuark graph_node_position_error_quark(void); * * Toutes les chaînes sont copiées. * - * entity_identifier doit être un UUID valide. + * node_identifier doit être un UUID valide. * x et y doivent être des nombres finis. * updated_at doit respecter le format UTC YYYY-MM-DDTHH:MM:SSZ. * - * @param entity_identifier UUID de l'entité. + * @param node_identifier UUID du nœud (entité ou relation). * @param x Coordonnée horizontale logique. * @param y Coordonnée verticale logique. * @param updated_at Date UTC de dernière modification. @@ -55,7 +55,7 @@ GQuark graph_node_position_error_quark(void); * @return Nouvelle position, ou NULL lorsque les données sont invalides. */ GraphNodePosition *graph_node_position_new( - const char *entity_identifier, + const char *node_identifier, double x, double y, const char *updated_at, @@ -74,7 +74,7 @@ void graph_node_position_free( ); /** - * @brief Retourne l'UUID de l'entité. + * @brief Retourne l'UUID du nœud (entité ou relation). * * La chaîne retournée appartient au modèle. * @@ -82,7 +82,7 @@ void graph_node_position_free( * * @return UUID emprunté, ou NULL. */ -const char *graph_node_position_get_entity_identifier( +const char *graph_node_position_get_node_identifier( const GraphNodePosition *position ); diff --git a/include/widgets/investigation_graph_view.h b/include/widgets/investigation_graph_view.h index 85b6f91..931f6fc 100644 --- a/include/widgets/investigation_graph_view.h +++ b/include/widgets/investigation_graph_view.h @@ -47,16 +47,16 @@ typedef void (*InvestigationGraphViewSelectionCallback)( * @brief Callback appelé après le déplacement effectif d'un nœud. * * Les coordonnées sont exprimées dans l'espace logique du graphe. - * entity_identifier est emprunté au modèle et reste valable uniquement + * node_identifier est emprunté au modèle et reste valable uniquement * pendant l'appel. * - * @param entity_identifier UUID de l'entité déplacée. + * @param node_identifier UUID du nœud déplacé (entité ou relation). * @param x Coordonnée horizontale logique du coin supérieur gauche. * @param y Coordonnée verticale logique du coin supérieur gauche. * @param user_data Données empruntées fournies par l'appelant. */ typedef void (*InvestigationGraphViewNodeMovedCallback)( - const char *entity_identifier, + const char *node_identifier, double x, double y, gpointer user_data diff --git a/src/core/application.c b/src/core/application.c index cf710d8..ead615a 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -35,6 +35,7 @@ #include "widgets/evidence_category_model.h" #include "core/evidence_integrity_task.h" #include "core/evidence_integrity_verifier.h" +#include "core/relation_service.h" #include "database/database.h" #include @@ -3341,6 +3342,21 @@ static void application_on_create_relation_completed( Application *application = user_data; + const InvestigationProject *project = + NULL; + + Database *database = + NULL; + + RelationService *relation_service = + NULL; + + RelationRecord *relation_record = + NULL; + + GDateTime *current_date_time = + NULL; + const char *source_identifier = NULL; @@ -3350,9 +3366,21 @@ static void application_on_create_relation_completed( const char *relation_type = NULL; - char *status_message = + const char *database_path = NULL; + char *relation_identifier = + NULL; + + char *timestamp = + NULL; + + GError *error = + NULL; + + gboolean relation_created = + FALSE; + if (application == NULL || application->main_window == NULL) { @@ -3373,6 +3401,17 @@ static void application_on_create_relation_completed( return; } + if (application->session == NULL) + { + application_present_error( + application, + "Ajout de relation impossible", + "Aucune enquête n'est actuellement ouverte." + ); + + goto cleanup; + } + source_identifier = create_relation_dialog_result_get_source_identifier( result @@ -3388,41 +3427,225 @@ static void application_on_create_relation_completed( result ); - g_message( - "Relation préparée : %s -> %s (%s), confiance %d %%.", - source_identifier != NULL - ? source_identifier - : "(source absente)", - target_identifier != NULL - ? target_identifier - : "(cible absente)", - relation_type != NULL - ? relation_type - : "(type absent)", - create_relation_dialog_result_get_confidence( - result - ) - ); - - status_message = - g_strdup_printf( - "Relation préparée : %s. " - "L'enregistrement SQLite sera branché à l'étape suivante.", - relation_type != NULL && - relation_type[0] != '\0' - ? relation_type - : "(type inconnu)" + database = + investigation_session_get_database( + application->session ); + project = + investigation_session_get_project( + application->session + ); + + if (database == NULL || + project == NULL) + { + application_present_error( + application, + "Ajout de relation impossible", + "La session d'enquête est invalide." + ); + + goto cleanup; + } + + database_path = + investigation_project_get_database_path( + project + ); + + if (database_path == NULL || + database_path[0] == '\0') + { + application_present_error( + application, + "Ajout de relation impossible", + "Le chemin de la base SQLite est invalide." + ); + + goto cleanup; + } + + relation_identifier = + g_uuid_string_random(); + + current_date_time = + g_date_time_new_now_utc(); + + if (current_date_time != NULL) + { + timestamp = + g_date_time_format( + current_date_time, + "%Y-%m-%dT%H:%M:%SZ" + ); + } + + if (relation_identifier == NULL || + timestamp == NULL) + { + application_present_error( + application, + "Ajout de relation impossible", + "Impossible de préparer l'identifiant ou la date " + "de la nouvelle relation." + ); + + goto cleanup; + } + + relation_record = + relation_record_new( + relation_identifier, + source_identifier, + target_identifier, + relation_type, + create_relation_dialog_result_get_label( + result + ), + create_relation_dialog_result_get_justification( + result + ), + create_relation_dialog_result_get_confidence( + result + ), + timestamp, + timestamp, + RELATION_STATUS_ACTIVE, + &error + ); + + if (relation_record == NULL) + { + application_present_error( + application, + "Relation invalide", + error != NULL + ? error->message + : "Les informations de la relation sont invalides." + ); + + g_clear_error( + &error + ); + + goto cleanup; + } + + relation_service = + relation_service_new( + database, + &error + ); + + if (relation_service == NULL) + { + application_present_error( + application, + "Ajout de relation impossible", + error != NULL + ? error->message + : "Impossible de préparer le service des relations." + ); + + g_clear_error( + &error + ); + + goto cleanup; + } + + /* + * Aucune preuve n'est associée pendant ce premier parcours. + * Le service accepte donc un tableau NULL. + */ + if (!relation_service_create( + relation_service, + relation_record, + NULL, + &error + )) + { + g_warning( + "Impossible d'enregistrer la relation '%s' : %s", + relation_identifier, + error != NULL + ? error->message + : "erreur inconnue" + ); + + application_present_error( + application, + "Enregistrement de la relation impossible", + error != NULL + ? error->message + : "La relation n'a pas pu être enregistrée." + ); + + g_clear_error( + &error + ); + + goto cleanup; + } + + relation_created = + TRUE; + + g_message( + "Relation enregistrée : %s -> %s (%s), identifiant %s.", + source_identifier, + target_identifier, + relation_type, + relation_identifier + ); + main_window_set_status( application->main_window, - status_message != NULL - ? status_message - : "Relation préparée." + "Relation enregistrée. Actualisation du graphe…" + ); + + /* + * Le graphe n'est rechargé qu'après le COMMIT réussi. + * En cas d'échec SQLite, l'affichage courant reste intact. + */ + application_start_graph_loading( + application, + database_path + ); + +cleanup: + + if (!relation_created && + application->main_window != NULL) + { + main_window_set_status( + application->main_window, + "La relation n'a pas été enregistrée." + ); + } + + relation_service_free( + relation_service + ); + + relation_record_free( + relation_record + ); + + if (current_date_time != NULL) + { + g_date_time_unref( + current_date_time + ); + } + + g_free( + timestamp ); g_free( - status_message + relation_identifier ); create_relation_dialog_result_free( diff --git a/src/core/investigation_graph_load_task.c b/src/core/investigation_graph_load_task.c index b7a1976..1a4f0e5 100644 --- a/src/core/investigation_graph_load_task.c +++ b/src/core/investigation_graph_load_task.c @@ -946,7 +946,7 @@ static gboolean investigation_graph_load_task_worker( if (!investigation_graph_layout_set_position( graph_layout, - graph_node_position_get_entity_identifier( + graph_node_position_get_node_identifier( position ), graph_node_position_get_x( diff --git a/src/dao/graph_node_position_dao.c b/src/dao/graph_node_position_dao.c index 2e6cd8f..b2fb42c 100644 --- a/src/dao/graph_node_position_dao.c +++ b/src/dao/graph_node_position_dao.c @@ -27,20 +27,20 @@ struct GraphNodePositionDao */ static const char *const graph_node_position_dao_list_all_sql = "SELECT " - " entity_id," + " node_id," " x," " y," " updated_at " - "FROM graph_node_positions " - "ORDER BY entity_id ASC;"; + "FROM graph_layout_positions " + "ORDER BY node_id ASC;"; /** * @brief Requête d'insertion ou de mise à jour d'une position. */ static const char *const graph_node_position_dao_upsert_sql = - "INSERT INTO graph_node_positions" + "INSERT INTO graph_layout_positions" "(" - " entity_id," + " node_id," " x," " y," " updated_at" @@ -52,7 +52,7 @@ static const char *const graph_node_position_dao_upsert_sql = " ?," " ?" ")" - "ON CONFLICT(entity_id)" + "ON CONFLICT(node_id)" "DO UPDATE SET " " x = excluded.x," " y = excluded.y," @@ -62,14 +62,14 @@ static const char *const graph_node_position_dao_upsert_sql = * @brief Requête de suppression d'une position. */ static const char *const graph_node_position_dao_delete_sql = - "DELETE FROM graph_node_positions " - "WHERE entity_id = ?;"; + "DELETE FROM graph_layout_positions " + "WHERE node_id = ?;"; /** * @brief Requête de suppression de toutes les positions. */ static const char *const graph_node_position_dao_delete_all_sql = - "DELETE FROM graph_node_positions;"; + "DELETE FROM graph_layout_positions;"; /** * @brief Enregistre une erreur littérale. @@ -202,7 +202,7 @@ graph_node_position_dao_read_current_record( GraphNodePosition *position = NULL; - char *entity_identifier = + char *node_identifier = NULL; char *updated_at = @@ -233,7 +233,7 @@ graph_node_position_dao_read_current_record( if (!database_statement_column_text( statement, 0, - &entity_identifier + &node_identifier ) || !database_statement_column_double( statement, @@ -262,7 +262,7 @@ graph_node_position_dao_read_current_record( position = graph_node_position_new( - entity_identifier, + node_identifier, x, y, updated_at, @@ -302,7 +302,7 @@ cleanup: ); g_free( - entity_identifier + node_identifier ); return position; @@ -576,7 +576,7 @@ error: gboolean graph_node_position_dao_upsert( GraphNodePositionDao *position_dao, - const char *entity_identifier, + const char *node_identifier, double x, double y, GError **error @@ -598,9 +598,9 @@ gboolean graph_node_position_dao_upsert( if (position_dao == NULL || position_dao->database == NULL || - entity_identifier == NULL || + node_identifier == NULL || !g_uuid_string_is_valid( - entity_identifier + node_identifier ) || !isfinite(x) || !isfinite(y)) @@ -649,7 +649,7 @@ gboolean graph_node_position_dao_upsert( if (!database_statement_bind_text( statement, 1, - entity_identifier + node_identifier ) || !database_statement_bind_double( statement, @@ -707,7 +707,7 @@ cleanup: gboolean graph_node_position_dao_delete( GraphNodePositionDao *position_dao, - const char *entity_identifier, + const char *node_identifier, GError **error ) { @@ -724,15 +724,15 @@ gboolean graph_node_position_dao_delete( if (position_dao == NULL || position_dao->database == NULL || - entity_identifier == NULL || + node_identifier == NULL || !g_uuid_string_is_valid( - entity_identifier + node_identifier )) { graph_node_position_dao_set_error_literal( error, GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, - "L'identifiant de l'entité est invalide." + "L'identifiant du nœud est invalide." ); return FALSE; @@ -759,14 +759,14 @@ gboolean graph_node_position_dao_delete( if (!database_statement_bind_text( statement, 1, - entity_identifier + node_identifier )) { graph_node_position_dao_set_database_error( position_dao, error, GRAPH_NODE_POSITION_DAO_ERROR_BIND, - "Impossible de lier l'identifiant de l'entité" + "Impossible de lier l'identifiant du nœud" ); goto cleanup; diff --git a/src/models/graph_node_position.c b/src/models/graph_node_position.c index 9b6671e..2087dc1 100644 --- a/src/models/graph_node_position.c +++ b/src/models/graph_node_position.c @@ -14,7 +14,7 @@ */ struct GraphNodePosition { - char *entity_identifier; + char *node_identifier; double x; double y; @@ -154,7 +154,7 @@ GQuark graph_node_position_error_quark(void) } GraphNodePosition *graph_node_position_new( - const char *entity_identifier, + const char *node_identifier, double x, double y, const char *updated_at, @@ -164,7 +164,7 @@ GraphNodePosition *graph_node_position_new( GraphNodePosition *position = NULL; - char *entity_identifier_copy = + char *node_identifier_copy = NULL; char *updated_at_copy = @@ -175,22 +175,22 @@ GraphNodePosition *graph_node_position_new( NULL ); - entity_identifier_copy = + node_identifier_copy = graph_node_position_duplicate_trimmed( - entity_identifier + node_identifier ); - if (entity_identifier_copy == NULL || - entity_identifier_copy[0] == '\0' || + if (node_identifier_copy == NULL || + node_identifier_copy[0] == '\0' || !g_uuid_string_is_valid( - entity_identifier_copy + node_identifier_copy )) { g_set_error_literal( error, GRAPH_NODE_POSITION_ERROR, GRAPH_NODE_POSITION_ERROR_INVALID_IDENTIFIER, - "L'identifiant de l'entité n'est pas un UUID valide." + "L'identifiant du nœud n'est pas un UUID valide." ); goto cleanup; @@ -246,8 +246,8 @@ GraphNodePosition *graph_node_position_new( goto cleanup; } - position->entity_identifier = - entity_identifier_copy; + position->node_identifier = + node_identifier_copy; position->x = x; @@ -258,7 +258,7 @@ GraphNodePosition *graph_node_position_new( position->updated_at = updated_at_copy; - entity_identifier_copy = + node_identifier_copy = NULL; updated_at_copy = @@ -271,7 +271,7 @@ cleanup: ); g_free( - entity_identifier_copy + node_identifier_copy ); return position; @@ -291,7 +291,7 @@ void graph_node_position_free( ); g_free( - position->entity_identifier + position->node_identifier ); g_free( @@ -299,12 +299,12 @@ void graph_node_position_free( ); } -const char *graph_node_position_get_entity_identifier( +const char *graph_node_position_get_node_identifier( const GraphNodePosition *position ) { return position != NULL - ? position->entity_identifier + ? position->node_identifier : NULL; } diff --git a/src/models/investigation_graph_layout.c b/src/models/investigation_graph_layout.c index 2072e0f..edf4513 100644 --- a/src/models/investigation_graph_layout.c +++ b/src/models/investigation_graph_layout.c @@ -8,7 +8,7 @@ #include /** - * @brief Coordonnées privées associées à une entité. + * @brief Coordonnées privées associées à un nœud. */ typedef struct { @@ -22,7 +22,7 @@ typedef struct */ struct InvestigationGraphLayout { - GHashTable *positions_by_entity_identifier; + GHashTable *positions_by_node_identifier; }; /** @@ -48,16 +48,16 @@ static void investigation_graph_layout_set_error_literal( } /** - * @brief Vérifie un identifiant d'entité. + * @brief Vérifie un identifiant de nœud. */ static gboolean investigation_graph_layout_identifier_is_valid( - const char *entity_identifier + const char *node_identifier ) { - return entity_identifier != NULL && - entity_identifier[0] != '\0' && + return node_identifier != NULL && + node_identifier[0] != '\0' && g_uuid_string_is_valid( - entity_identifier + node_identifier ); } @@ -84,7 +84,7 @@ InvestigationGraphLayout *investigation_graph_layout_new(void) return NULL; } - layout->positions_by_entity_identifier = + layout->positions_by_node_identifier = g_hash_table_new_full( g_str_hash, g_str_equal, @@ -92,7 +92,7 @@ InvestigationGraphLayout *investigation_graph_layout_new(void) g_free ); - if (layout->positions_by_entity_identifier == NULL) + if (layout->positions_by_node_identifier == NULL) { investigation_graph_layout_free( layout @@ -106,7 +106,7 @@ InvestigationGraphLayout *investigation_graph_layout_new(void) gboolean investigation_graph_layout_set_position( InvestigationGraphLayout *layout, - const char *entity_identifier, + const char *node_identifier, double x, double y, GError **error @@ -115,7 +115,7 @@ gboolean investigation_graph_layout_set_position( InvestigationGraphLayoutPosition *position = NULL; - char *entity_identifier_copy = + char *node_identifier_copy = NULL; g_return_val_if_fail( @@ -124,7 +124,7 @@ gboolean investigation_graph_layout_set_position( ); if (layout == NULL || - layout->positions_by_entity_identifier == NULL) + layout->positions_by_node_identifier == NULL) { investigation_graph_layout_set_error_literal( error, @@ -136,13 +136,13 @@ gboolean investigation_graph_layout_set_position( } if (!investigation_graph_layout_identifier_is_valid( - entity_identifier + node_identifier )) { investigation_graph_layout_set_error_literal( error, INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_IDENTIFIER, - "L'identifiant de l'entité n'est pas un UUID valide." + "L'identifiant du nœud n'est pas un UUID valide." ); return FALSE; @@ -160,9 +160,9 @@ gboolean investigation_graph_layout_set_position( return FALSE; } - entity_identifier_copy = + node_identifier_copy = g_strdup( - entity_identifier + node_identifier ); position = @@ -171,7 +171,7 @@ gboolean investigation_graph_layout_set_position( 1 ); - if (entity_identifier_copy == NULL || + if (node_identifier_copy == NULL || position == NULL) { g_free( @@ -179,7 +179,7 @@ gboolean investigation_graph_layout_set_position( ); g_free( - entity_identifier_copy + node_identifier_copy ); investigation_graph_layout_set_error_literal( @@ -198,8 +198,8 @@ gboolean investigation_graph_layout_set_position( y; g_hash_table_replace( - layout->positions_by_entity_identifier, - entity_identifier_copy, + layout->positions_by_node_identifier, + node_identifier_copy, position ); @@ -208,7 +208,7 @@ gboolean investigation_graph_layout_set_position( gboolean investigation_graph_layout_get_position( const InvestigationGraphLayout *layout, - const char *entity_identifier, + const char *node_identifier, double *x, double *y ) @@ -217,9 +217,9 @@ gboolean investigation_graph_layout_get_position( NULL; if (layout == NULL || - layout->positions_by_entity_identifier == NULL || + layout->positions_by_node_identifier == NULL || !investigation_graph_layout_identifier_is_valid( - entity_identifier + node_identifier )) { return FALSE; @@ -227,8 +227,8 @@ gboolean investigation_graph_layout_get_position( position = g_hash_table_lookup( - layout->positions_by_entity_identifier, - entity_identifier + layout->positions_by_node_identifier, + node_identifier ); if (position == NULL) @@ -253,21 +253,21 @@ gboolean investigation_graph_layout_get_position( gboolean investigation_graph_layout_remove_position( InvestigationGraphLayout *layout, - const char *entity_identifier + const char *node_identifier ) { if (layout == NULL || - layout->positions_by_entity_identifier == NULL || + layout->positions_by_node_identifier == NULL || !investigation_graph_layout_identifier_is_valid( - entity_identifier + node_identifier )) { return FALSE; } return g_hash_table_remove( - layout->positions_by_entity_identifier, - entity_identifier + layout->positions_by_node_identifier, + node_identifier ); } @@ -276,13 +276,13 @@ void investigation_graph_layout_clear( ) { if (layout == NULL || - layout->positions_by_entity_identifier == NULL) + layout->positions_by_node_identifier == NULL) { return; } g_hash_table_remove_all( - layout->positions_by_entity_identifier + layout->positions_by_node_identifier ); } @@ -291,13 +291,13 @@ guint investigation_graph_layout_get_count( ) { if (layout == NULL || - layout->positions_by_entity_identifier == NULL) + layout->positions_by_node_identifier == NULL) { return 0U; } return g_hash_table_size( - layout->positions_by_entity_identifier + layout->positions_by_node_identifier ); } @@ -311,7 +311,7 @@ void investigation_graph_layout_free( } g_clear_pointer( - &layout->positions_by_entity_identifier, + &layout->positions_by_node_identifier, g_hash_table_unref ); diff --git a/src/views/create_relation_dialog.c b/src/views/create_relation_dialog.c index 460c753..795f630 100644 --- a/src/views/create_relation_dialog.c +++ b/src/views/create_relation_dialog.c @@ -1170,10 +1170,10 @@ gboolean create_relation_dialog_present( ) ); - g_object_unref( - target_labels - ); - + /* + * Le constructeur prend possession de la référence du modèle. + * Le dialogue ne doit donc pas appeler g_object_unref() ici. + */ target_labels = NULL; diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index 8cbff66..72ae648 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -16,11 +16,14 @@ #define INVESTIGATION_GRAPH_VIEW_MARGIN 32.0 #define INVESTIGATION_GRAPH_VIEW_NODE_WIDTH 190.0 #define INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT 78.0 +#define INVESTIGATION_GRAPH_VIEW_RELATION_NODE_WIDTH 170.0 +#define INVESTIGATION_GRAPH_VIEW_RELATION_NODE_HEIGHT 62.0 #define INVESTIGATION_GRAPH_VIEW_HORIZONTAL_GAP 28.0 #define INVESTIGATION_GRAPH_VIEW_VERTICAL_GAP 28.0 #define INVESTIGATION_GRAPH_VIEW_NODE_RADIUS 12.0 +#define INVESTIGATION_GRAPH_VIEW_RELATION_NODE_RADIUS 10.0 #define INVESTIGATION_GRAPH_VIEW_NODE_PADDING 12 -#define INVESTIGATION_GRAPH_VIEW_RELATION_OFFSET 10.0 +#define INVESTIGATION_GRAPH_VIEW_RELATION_NODE_GAP 72.0 #define INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH 9.0 #define INVESTIGATION_GRAPH_VIEW_ARROW_HALF_WIDTH 5.0 @@ -32,14 +35,29 @@ #define INVESTIGATION_GRAPH_VIEW_CLICK_TOLERANCE 4.0 /** - * @brief Position privée d'une entité dans la vue. + * @brief Nature d'un nœud visible dans le graphe. + */ +typedef enum +{ + INVESTIGATION_GRAPH_NODE_KIND_ENTITY, + INVESTIGATION_GRAPH_NODE_KIND_RELATION +} InvestigationGraphNodeKind; + +/** + * @brief Position privée d'un nœud dans la vue. */ typedef struct { + InvestigationGraphNodeKind kind; + const EntityRecord *entity_record; + const RelationRecord *relation_record; double x; double y; + + double width; + double height; } InvestigationGraphNodeLayout; /** @@ -106,6 +124,10 @@ struct InvestigationGraphView gboolean click_suppressed; }; +static const char *investigation_graph_view_get_node_identifier( + const InvestigationGraphNodeLayout *node_layout +); + /** * @brief Borne un facteur de zoom aux limites autorisées. */ @@ -576,11 +598,11 @@ investigation_graph_view_find_node_at( if (logical_x >= node_layout->x && logical_x <= node_layout->x + - INVESTIGATION_GRAPH_VIEW_NODE_WIDTH && + node_layout->width && logical_y >= node_layout->y && logical_y <= node_layout->y + - INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT) + node_layout->height) { return node_layout; } @@ -609,7 +631,9 @@ static void investigation_graph_view_set_selected_node( graph_view->selected_node = node_layout; - if (node_layout != NULL) + if (node_layout != NULL && + node_layout->kind == + INVESTIGATION_GRAPH_NODE_KIND_ENTITY) { entity_record = node_layout->entity_record; @@ -1055,30 +1079,29 @@ static void investigation_graph_view_notify_node_moved( const InvestigationGraphNodeLayout *node_layout ) { - const char *entity_identifier = + const char *node_identifier = NULL; if (graph_view == NULL || node_layout == NULL || - node_layout->entity_record == NULL || graph_view->node_moved_callback == NULL) { return; } - entity_identifier = - entity_record_get_identifier( - node_layout->entity_record + node_identifier = + investigation_graph_view_get_node_identifier( + node_layout ); - if (entity_identifier == NULL || - entity_identifier[0] == '\0') + if (node_identifier == NULL || + node_identifier[0] == '\0') { return; } graph_view->node_moved_callback( - entity_identifier, + node_identifier, node_layout->x, node_layout->y, graph_view->node_moved_user_data @@ -1194,6 +1217,63 @@ static void investigation_graph_view_on_drag_end( ); } +/** + * @brief Retourne l'identifiant métier d'un nœud. + * + * La chaîne retournée est empruntée au modèle. + */ +static const char *investigation_graph_view_get_node_identifier( + const InvestigationGraphNodeLayout *node_layout +) +{ + if (node_layout == NULL) + { + return NULL; + } + + if (node_layout->kind == + INVESTIGATION_GRAPH_NODE_KIND_ENTITY) + { + return entity_record_get_identifier( + node_layout->entity_record + ); + } + + if (node_layout->kind == + INVESTIGATION_GRAPH_NODE_KIND_RELATION) + { + return relation_record_get_identifier( + node_layout->relation_record + ); + } + + return NULL; +} + +/** + * @brief Retourne le centre horizontal d'un nœud. + */ +static double investigation_graph_view_get_node_center_x( + const InvestigationGraphNodeLayout *node_layout +) +{ + return node_layout != NULL + ? node_layout->x + (node_layout->width / 2.0) + : 0.0; +} + +/** + * @brief Retourne le centre vertical d'un nœud. + */ +static double investigation_graph_view_get_node_center_y( + const InvestigationGraphNodeLayout *node_layout +) +{ + return node_layout != NULL + ? node_layout->y + (node_layout->height / 2.0) + : 0.0; +} + /** * @brief Retourne le texte principal d'une entité. * @@ -1443,12 +1523,24 @@ static GPtrArray *investigation_graph_view_create_layout( double persisted_y = 0.0; + node_layout->kind = + INVESTIGATION_GRAPH_NODE_KIND_ENTITY; + node_layout->entity_record = g_ptr_array_index( active_entities, entity_index ); + node_layout->relation_record = + NULL; + + node_layout->width = + INVESTIGATION_GRAPH_VIEW_NODE_WIDTH; + + node_layout->height = + INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT; + node_layout->x = INVESTIGATION_GRAPH_VIEW_MARGIN + ((double) column * slot_width); @@ -1532,20 +1624,20 @@ static GHashTable *investigation_graph_view_create_layout_index( layout_index_position ); - const char *entity_identifier = - entity_record_get_identifier( - node_layout->entity_record + const char *node_identifier = + investigation_graph_view_get_node_identifier( + node_layout ); - if (entity_identifier == NULL || - entity_identifier[0] == '\0') + if (node_identifier == NULL || + node_identifier[0] == '\0') { continue; } g_hash_table_insert( layout_index, - (gpointer) entity_identifier, + (gpointer) node_identifier, node_layout ); } @@ -1553,6 +1645,14 @@ static GHashTable *investigation_graph_view_create_layout_index( return layout_index; } +/** + * @brief Construit une clé déterministe pour un couple source/cible. + */ +static char *investigation_graph_view_create_relation_pair_key( + const char *source_identifier, + const char *target_identifier +); + /** * @brief Compte les relations actives par couple source/cible. */ @@ -1561,6 +1661,392 @@ static GHashTable *investigation_graph_view_count_relation_pairs( GHashTable *layout_index ); +/** + * @brief Ajoute un nœud graphique pour chaque relation active. + * + * Les relations parallèles reçoivent un décalage perpendiculaire afin que + * leurs nœuds ne soient pas créés exactement au même endroit. + */ +static gboolean investigation_graph_view_append_relation_layouts( + GPtrArray *node_layouts, + GHashTable *layout_index, + const GPtrArray *relations, + GHashTable *pair_counts, + const InvestigationGraphLayout *graph_layout, + gboolean apply_persisted_positions +) +{ + GHashTable *pair_ordinals = + NULL; + + guint relation_index = + 0; + + if (node_layouts == NULL || + layout_index == NULL || + relations == NULL || + pair_counts == NULL) + { + return FALSE; + } + + pair_ordinals = + g_hash_table_new_full( + g_str_hash, + g_str_equal, + g_free, + NULL + ); + + if (pair_ordinals == NULL) + { + return FALSE; + } + + for (relation_index = 0; + relation_index < relations->len; + relation_index++) + { + const RelationRecord *relation_record = + g_ptr_array_index( + relations, + relation_index + ); + + const char *relation_identifier = + NULL; + + const char *source_identifier = + NULL; + + const char *target_identifier = + NULL; + + InvestigationGraphNodeLayout *source_layout = + NULL; + + InvestigationGraphNodeLayout *target_layout = + NULL; + + InvestigationGraphNodeLayout *relation_layout = + NULL; + + char *pair_key = + NULL; + + char *ordinal_key = + NULL; + + guint pair_count = + 0; + + guint pair_ordinal = + 0; + + double source_center_x = + 0.0; + + double source_center_y = + 0.0; + + double target_center_x = + 0.0; + + double target_center_y = + 0.0; + + double delta_x = + 0.0; + + double delta_y = + 0.0; + + double absolute_delta_x = + 0.0; + + double absolute_delta_y = + 0.0; + + double perpendicular_x = + 0.0; + + double perpendicular_y = + 0.0; + + double parallel_offset = + 0.0; + + double persisted_x = + 0.0; + + double persisted_y = + 0.0; + + if (relation_record == NULL || + relation_record_get_status( + relation_record + ) != RELATION_STATUS_ACTIVE) + { + continue; + } + + relation_identifier = + relation_record_get_identifier( + relation_record + ); + + source_identifier = + relation_record_get_source_entity_identifier( + relation_record + ); + + target_identifier = + relation_record_get_target_entity_identifier( + relation_record + ); + + if (relation_identifier == NULL || + !g_uuid_string_is_valid( + relation_identifier + ) || + source_identifier == NULL || + target_identifier == NULL || + g_strcmp0( + source_identifier, + target_identifier + ) == 0) + { + continue; + } + + source_layout = + g_hash_table_lookup( + layout_index, + source_identifier + ); + + target_layout = + g_hash_table_lookup( + layout_index, + target_identifier + ); + + if (source_layout == NULL || + target_layout == NULL) + { + continue; + } + + pair_key = + investigation_graph_view_create_relation_pair_key( + source_identifier, + target_identifier + ); + + if (pair_key == NULL) + { + g_hash_table_unref( + pair_ordinals + ); + + return FALSE; + } + + pair_count = + GPOINTER_TO_UINT( + g_hash_table_lookup( + pair_counts, + pair_key + ) + ); + + pair_ordinal = + GPOINTER_TO_UINT( + g_hash_table_lookup( + pair_ordinals, + pair_key + ) + ); + + ordinal_key = + g_strdup( + pair_key + ); + + if (ordinal_key == NULL) + { + g_free( + pair_key + ); + + g_hash_table_unref( + pair_ordinals + ); + + return FALSE; + } + + g_hash_table_replace( + pair_ordinals, + ordinal_key, + GUINT_TO_POINTER( + pair_ordinal + 1U + ) + ); + + relation_layout = + g_try_new0( + InvestigationGraphNodeLayout, + 1 + ); + + if (relation_layout == NULL) + { + g_free( + pair_key + ); + + g_hash_table_unref( + pair_ordinals + ); + + return FALSE; + } + + relation_layout->kind = + INVESTIGATION_GRAPH_NODE_KIND_RELATION; + + relation_layout->entity_record = + NULL; + + relation_layout->relation_record = + relation_record; + + relation_layout->width = + INVESTIGATION_GRAPH_VIEW_RELATION_NODE_WIDTH; + + relation_layout->height = + INVESTIGATION_GRAPH_VIEW_RELATION_NODE_HEIGHT; + + source_center_x = + investigation_graph_view_get_node_center_x( + source_layout + ); + + source_center_y = + investigation_graph_view_get_node_center_y( + source_layout + ); + + target_center_x = + investigation_graph_view_get_node_center_x( + target_layout + ); + + target_center_y = + investigation_graph_view_get_node_center_y( + target_layout + ); + + delta_x = + target_center_x - + source_center_x; + + delta_y = + target_center_y - + source_center_y; + + absolute_delta_x = + delta_x >= 0.0 + ? delta_x + : -delta_x; + + absolute_delta_y = + delta_y >= 0.0 + ? delta_y + : -delta_y; + + /* + * Un axe perpendiculaire simple suffit pour séparer les relations + * parallèles sans ajouter de dépendance mathématique au lien final. + */ + if (absolute_delta_x >= + absolute_delta_y) + { + perpendicular_y = + 1.0; + } + else + { + perpendicular_x = + 1.0; + } + + if (pair_count > 1U) + { + parallel_offset = + ( + (double) pair_ordinal - + ((double) (pair_count - 1U) / 2.0) + ) * + INVESTIGATION_GRAPH_VIEW_RELATION_NODE_GAP; + } + + relation_layout->x = + ( + (source_center_x + target_center_x) / + 2.0 + ) - + (relation_layout->width / 2.0) + + (perpendicular_x * parallel_offset); + + relation_layout->y = + ( + (source_center_y + target_center_y) / + 2.0 + ) - + (relation_layout->height / 2.0) + + (perpendicular_y * parallel_offset); + + if (apply_persisted_positions && + graph_layout != NULL && + investigation_graph_layout_get_position( + graph_layout, + relation_identifier, + &persisted_x, + &persisted_y + )) + { + relation_layout->x = + persisted_x; + + relation_layout->y = + persisted_y; + } + + g_ptr_array_add( + node_layouts, + relation_layout + ); + + g_hash_table_insert( + layout_index, + (gpointer) relation_identifier, + relation_layout + ); + + g_free( + pair_key + ); + } + + g_hash_table_unref( + pair_ordinals + ); + + return TRUE; +} + /** * @brief Supprime la disposition privée actuellement conservée. * @@ -1760,6 +2246,23 @@ static gboolean investigation_graph_view_build_layout( goto cleanup; } + if (!investigation_graph_view_append_relation_layouts( + node_layouts, + node_layouts_by_identifier, + relations, + relation_pair_counts, + graph_view->graph_layout, + apply_persisted_positions + )) + { + investigation_graph_view_set_layout_error( + error, + "Impossible d'allouer les nœuds de relation." + ); + + goto cleanup; + } + graph_view->node_layouts = node_layouts; @@ -2142,15 +2645,12 @@ static void investigation_graph_view_draw_text( } /** - * @brief Dessine une relation orientée entre deux nœuds. - * - * Les relations parallèles sont décalées perpendiculairement. + * @brief Dessine une arête orientée entre deux nœuds. */ -static void investigation_graph_view_draw_relation( +static void investigation_graph_view_draw_edge( cairo_t *cairo_context, const InvestigationGraphNodeLayout *source_layout, - const InvestigationGraphNodeLayout *target_layout, - double parallel_offset + const InvestigationGraphNodeLayout *target_layout ) { double source_center_x = @@ -2198,20 +2698,24 @@ static void investigation_graph_view_draw_relation( } source_center_x = - source_layout->x + - (INVESTIGATION_GRAPH_VIEW_NODE_WIDTH / 2.0); + investigation_graph_view_get_node_center_x( + source_layout + ); source_center_y = - source_layout->y + - (INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT / 2.0); + investigation_graph_view_get_node_center_y( + source_layout + ); target_center_x = - target_layout->x + - (INVESTIGATION_GRAPH_VIEW_NODE_WIDTH / 2.0); + investigation_graph_view_get_node_center_x( + target_layout + ); target_center_y = - target_layout->y + - (INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT / 2.0); + investigation_graph_view_get_node_center_y( + target_layout + ); delta_x = target_center_x - @@ -2253,21 +2757,23 @@ static void investigation_graph_view_draw_relation( start_x = source_center_x + - (direction * - (INVESTIGATION_GRAPH_VIEW_NODE_WIDTH / 2.0)); + ( + direction * + (source_layout->width / 2.0) + ); start_y = - source_center_y + - parallel_offset; + source_center_y; end_x = target_center_x - - (direction * - (INVESTIGATION_GRAPH_VIEW_NODE_WIDTH / 2.0)); + ( + direction * + (target_layout->width / 2.0) + ); end_y = - target_center_y + - parallel_offset; + target_center_y; cairo_move_to( cairo_context, @@ -2294,8 +2800,10 @@ static void investigation_graph_view_draw_relation( cairo_line_to( cairo_context, end_x - - (direction * - INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH), + ( + direction * + INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH + ), end_y - INVESTIGATION_GRAPH_VIEW_ARROW_HALF_WIDTH ); @@ -2309,8 +2817,10 @@ static void investigation_graph_view_draw_relation( cairo_line_to( cairo_context, end_x - - (direction * - INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH), + ( + direction * + INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH + ), end_y + INVESTIGATION_GRAPH_VIEW_ARROW_HALF_WIDTH ); @@ -2329,22 +2839,24 @@ static void investigation_graph_view_draw_relation( : -1.0; start_x = - source_center_x + - parallel_offset; + source_center_x; start_y = source_center_y + - (direction * - (INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT / 2.0)); + ( + direction * + (source_layout->height / 2.0) + ); end_x = - target_center_x + - parallel_offset; + target_center_x; end_y = target_center_y - - (direction * - (INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT / 2.0)); + ( + direction * + (target_layout->height / 2.0) + ); cairo_move_to( cairo_context, @@ -2373,8 +2885,10 @@ static void investigation_graph_view_draw_relation( end_x - INVESTIGATION_GRAPH_VIEW_ARROW_HALF_WIDTH, end_y - - (direction * - INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH) + ( + direction * + INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH + ) ); cairo_move_to( @@ -2388,8 +2902,10 @@ static void investigation_graph_view_draw_relation( end_x + INVESTIGATION_GRAPH_VIEW_ARROW_HALF_WIDTH, end_y - - (direction * - INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH) + ( + direction * + INVESTIGATION_GRAPH_VIEW_ARROW_LENGTH + ) ); cairo_stroke( @@ -2399,38 +2915,20 @@ static void investigation_graph_view_draw_relation( } /** - * @brief Dessine toutes les relations actives valides. + * @brief Dessine les deux arêtes de chaque relation active. */ static void investigation_graph_view_draw_relations( cairo_t *cairo_context, const GPtrArray *relations, - GHashTable *layout_index, - GHashTable *pair_counts + GHashTable *layout_index ) { - GHashTable *pair_ordinals = - NULL; - guint relation_index = 0; if (cairo_context == NULL || relations == NULL || - layout_index == NULL || - pair_counts == NULL) - { - return; - } - - pair_ordinals = - g_hash_table_new_full( - g_str_hash, - g_str_equal, - g_free, - NULL - ); - - if (pair_ordinals == NULL) + layout_index == NULL) { return; } @@ -2445,6 +2943,9 @@ static void investigation_graph_view_draw_relations( relation_index ); + const char *relation_identifier = + NULL; + const char *source_identifier = NULL; @@ -2454,28 +2955,25 @@ static void investigation_graph_view_draw_relations( InvestigationGraphNodeLayout *source_layout = NULL; + InvestigationGraphNodeLayout *relation_layout = + NULL; + InvestigationGraphNodeLayout *target_layout = NULL; - char *pair_key = - NULL; - - guint pair_count = - 0; - - guint pair_ordinal = - 0; - - double parallel_offset = - 0.0; - - if (relation_record_get_status( + if (relation_record == NULL || + relation_record_get_status( relation_record ) != RELATION_STATUS_ACTIVE) { continue; } + relation_identifier = + relation_record_get_identifier( + relation_record + ); + source_identifier = relation_record_get_source_entity_identifier( relation_record @@ -2486,12 +2984,9 @@ static void investigation_graph_view_draw_relations( relation_record ); - if (source_identifier == NULL || - target_identifier == NULL || - g_strcmp0( - source_identifier, - target_identifier - ) == 0) + if (relation_identifier == NULL || + source_identifier == NULL || + target_identifier == NULL) { continue; } @@ -2502,6 +2997,12 @@ static void investigation_graph_view_draw_relations( source_identifier ); + relation_layout = + g_hash_table_lookup( + layout_index, + relation_identifier + ); + target_layout = g_hash_table_lookup( layout_index, @@ -2509,73 +3010,26 @@ static void investigation_graph_view_draw_relations( ); if (source_layout == NULL || - target_layout == NULL) + relation_layout == NULL || + target_layout == NULL || + relation_layout->kind != + INVESTIGATION_GRAPH_NODE_KIND_RELATION) { continue; } - pair_key = - investigation_graph_view_create_relation_pair_key( - source_identifier, - target_identifier - ); - - if (pair_key == NULL) - { - continue; - } - - pair_count = - GPOINTER_TO_UINT( - g_hash_table_lookup( - pair_counts, - pair_key - ) - ); - - pair_ordinal = - GPOINTER_TO_UINT( - g_hash_table_lookup( - pair_ordinals, - pair_key - ) - ); - - g_hash_table_replace( - pair_ordinals, - g_strdup( - pair_key - ), - GUINT_TO_POINTER( - pair_ordinal + 1U - ) - ); - - if (pair_count > 1U) - { - parallel_offset = - ( - (double) pair_ordinal - - ((double) (pair_count - 1U) / 2.0) - ) * - INVESTIGATION_GRAPH_VIEW_RELATION_OFFSET; - } - - investigation_graph_view_draw_relation( + investigation_graph_view_draw_edge( cairo_context, source_layout, - target_layout, - parallel_offset + relation_layout ); - g_free( - pair_key + investigation_graph_view_draw_edge( + cairo_context, + relation_layout, + target_layout ); } - - g_hash_table_unref( - pair_ordinals - ); } /** @@ -2707,6 +3161,235 @@ static void investigation_graph_view_draw_entity( ); } +/** + * @brief Retourne le titre principal d'une relation. + */ +static const char *investigation_graph_view_get_relation_title( + const RelationRecord *relation_record +) +{ + const char *label = + NULL; + + const char *relation_type = + NULL; + + if (relation_record == NULL) + { + return "(relation invalide)"; + } + + label = + relation_record_get_label( + relation_record + ); + + if (label != NULL && + label[0] != '\0') + { + return label; + } + + relation_type = + relation_record_get_relation_type( + relation_record + ); + + return relation_type != NULL && + relation_type[0] != '\0' + ? relation_type + : "(relation sans type)"; +} + +/** + * @brief Dessine un nœud compact représentant une relation. + */ +static void investigation_graph_view_draw_relation_node( + cairo_t *cairo_context, + const RelationRecord *relation_record, + double x, + double y, + gboolean selected +) +{ + const char *title = + NULL; + + const char *relation_type = + NULL; + + char *details = + NULL; + + int text_width = + (int) INVESTIGATION_GRAPH_VIEW_RELATION_NODE_WIDTH - + (2 * INVESTIGATION_GRAPH_VIEW_NODE_PADDING); + + if (cairo_context == NULL || + relation_record == NULL) + { + return; + } + + investigation_graph_view_rounded_rectangle( + cairo_context, + x, + y, + INVESTIGATION_GRAPH_VIEW_RELATION_NODE_WIDTH, + INVESTIGATION_GRAPH_VIEW_RELATION_NODE_HEIGHT, + INVESTIGATION_GRAPH_VIEW_RELATION_NODE_RADIUS + ); + + if (selected) + { + cairo_set_source_rgb( + cairo_context, + 0.29, + 0.20, + 0.34 + ); + } + else + { + cairo_set_source_rgb( + cairo_context, + 0.21, + 0.16, + 0.25 + ); + } + + cairo_fill_preserve( + cairo_context + ); + + if (selected) + { + cairo_set_source_rgb( + cairo_context, + 0.83, + 0.65, + 1.00 + ); + + cairo_set_line_width( + cairo_context, + 3.0 + ); + } + else + { + cairo_set_source_rgb( + cairo_context, + 0.62, + 0.48, + 0.72 + ); + + cairo_set_line_width( + cairo_context, + 1.5 + ); + } + + cairo_stroke( + cairo_context + ); + + title = + investigation_graph_view_get_relation_title( + relation_record + ); + + relation_type = + relation_record_get_relation_type( + relation_record + ); + + details = + g_strdup_printf( + "%s — %d %%", + relation_type != NULL && + relation_type[0] != '\0' + ? relation_type + : "relation", + relation_record_get_confidence( + relation_record + ) + ); + + investigation_graph_view_draw_text( + cairo_context, + title, + x + INVESTIGATION_GRAPH_VIEW_NODE_PADDING, + y + 9.0, + text_width, + "Sans Bold 10", + 0.96, + 0.93, + 0.98 + ); + + investigation_graph_view_draw_text( + cairo_context, + details != NULL + ? details + : "relation", + x + INVESTIGATION_GRAPH_VIEW_NODE_PADDING, + y + 34.0, + text_width, + "Sans 8", + 0.78, + 0.70, + 0.84 + ); + + g_free( + details + ); +} + +/** + * @brief Dessine un nœud selon sa nature. + */ +static void investigation_graph_view_draw_node( + cairo_t *cairo_context, + const InvestigationGraphNodeLayout *node_layout, + gboolean selected +) +{ + if (node_layout == NULL) + { + return; + } + + if (node_layout->kind == + INVESTIGATION_GRAPH_NODE_KIND_ENTITY) + { + investigation_graph_view_draw_entity( + cairo_context, + node_layout->entity_record, + node_layout->x, + node_layout->y, + selected + ); + + return; + } + + if (node_layout->kind == + INVESTIGATION_GRAPH_NODE_KIND_RELATION) + { + investigation_graph_view_draw_relation_node( + cairo_context, + node_layout->relation_record, + node_layout->x, + node_layout->y, + selected + ); + } +} + /** * @brief Dessine un message centré dans la vue. */ @@ -2866,8 +3549,7 @@ static void investigation_graph_view_draw( if (graph_view->node_layouts == NULL || graph_view->node_layouts_by_identifier == NULL || - graph_view->relations == NULL || - graph_view->relation_pair_counts == NULL) + graph_view->relations == NULL) { investigation_graph_view_draw_message( cairo_context, @@ -2883,7 +3565,7 @@ static void investigation_graph_view_draw( { investigation_graph_view_draw_message( cairo_context, - "Aucune entité active dans cette enquête", + "Aucun nœud actif dans cette enquête", width, height ); @@ -2918,8 +3600,7 @@ static void investigation_graph_view_draw( investigation_graph_view_draw_relations( cairo_context, graph_view->relations, - graph_view->node_layouts_by_identifier, - graph_view->relation_pair_counts + graph_view->node_layouts_by_identifier ); for (layout_position = 0; @@ -2938,11 +3619,9 @@ static void investigation_graph_view_draw( continue; } - investigation_graph_view_draw_entity( + investigation_graph_view_draw_node( cairo_context, - node_layout->entity_record, - node_layout->x, - node_layout->y, + node_layout, FALSE ); } @@ -2952,11 +3631,9 @@ static void investigation_graph_view_draw( */ if (graph_view->selected_node != NULL) { - investigation_graph_view_draw_entity( + investigation_graph_view_draw_node( cairo_context, - graph_view->selected_node->entity_record, - graph_view->selected_node->x, - graph_view->selected_node->y, + graph_view->selected_node, TRUE ); } @@ -3475,7 +4152,9 @@ const EntityRecord *investigation_graph_view_get_selected_entity( ) { if (graph_view == NULL || - graph_view->selected_node == NULL) + graph_view->selected_node == NULL || + graph_view->selected_node->kind != + INVESTIGATION_GRAPH_NODE_KIND_ENTITY) { return NULL; } diff --git a/tests/test_graph_node_position_dao.c b/tests/test_graph_node_position_dao.c index 54120ee..2f80452 100644 --- a/tests/test_graph_node_position_dao.c +++ b/tests/test_graph_node_position_dao.c @@ -156,7 +156,7 @@ static void test_graph_node_position_dao_fixture_clear( */ static void test_graph_node_position_dao_insert_entity( Database *database, - const char *entity_identifier, + const char *node_identifier, const char *entity_value ) { @@ -168,7 +168,7 @@ static void test_graph_node_position_dao_insert_entity( ); g_assert_nonnull( - entity_identifier + node_identifier ); g_assert_nonnull( @@ -208,7 +208,7 @@ static void test_graph_node_position_dao_insert_entity( database_statement_bind_text( statement, 1, - entity_identifier + node_identifier ) ); @@ -254,7 +254,7 @@ static int64_t test_graph_node_position_dao_count_rows( database_statement_prepare( database, "SELECT COUNT(*) " - "FROM graph_node_positions;" + "FROM graph_layout_positions;" ); g_assert_nonnull( @@ -454,19 +454,16 @@ static void test_graph_node_position_dao_upsert_insert(void) GError *error = NULL; - const char *entity_identifier = + const char *node_identifier = "10000000-0000-4000-8000-000000000001"; - test_graph_node_position_dao_insert_entity( - fixture.database, - entity_identifier, - "entite-position-1" - ); + /* Un nœud générique peut être une relation et n'a pas à exister dans + * la table entites. */ g_assert_true( graph_node_position_dao_upsert( fixture.position_dao, - entity_identifier, + node_identifier, 120.5, -42.25, &error @@ -516,11 +513,11 @@ static void test_graph_node_position_dao_upsert_insert(void) ); g_assert_cmpstr( - graph_node_position_get_entity_identifier( + graph_node_position_get_node_identifier( position ), ==, - entity_identifier + node_identifier ); g_assert_cmpfloat( @@ -578,19 +575,19 @@ static void test_graph_node_position_dao_upsert_update(void) GError *error = NULL; - const char *entity_identifier = + const char *node_identifier = "20000000-0000-4000-8000-000000000002"; test_graph_node_position_dao_insert_entity( fixture.database, - entity_identifier, + node_identifier, "entite-position-2" ); g_assert_true( graph_node_position_dao_upsert( fixture.position_dao, - entity_identifier, + node_identifier, 10.0, 20.0, &error @@ -604,7 +601,7 @@ static void test_graph_node_position_dao_upsert_update(void) g_assert_true( graph_node_position_dao_upsert( fixture.position_dao, - entity_identifier, + node_identifier, -300.75, 450.5, &error @@ -855,7 +852,7 @@ static void test_graph_node_position_dao_list_order(void) ); g_assert_cmpstr( - graph_node_position_get_entity_identifier( + graph_node_position_get_node_identifier( first_position ), ==, @@ -863,7 +860,7 @@ static void test_graph_node_position_dao_list_order(void) ); g_assert_cmpstr( - graph_node_position_get_entity_identifier( + graph_node_position_get_node_identifier( second_position ), ==, @@ -887,19 +884,19 @@ static void test_graph_node_position_dao_delete_existing(void) GError *error = NULL; - const char *entity_identifier = + const char *node_identifier = "60000000-0000-4000-8000-000000000006"; test_graph_node_position_dao_insert_entity( fixture.database, - entity_identifier, + node_identifier, "entite-position-6" ); g_assert_true( graph_node_position_dao_upsert( fixture.position_dao, - entity_identifier, + node_identifier, 60.0, 70.0, &error @@ -913,7 +910,7 @@ static void test_graph_node_position_dao_delete_existing(void) g_assert_true( graph_node_position_dao_delete( fixture.position_dao, - entity_identifier, + node_identifier, &error ) );