2323#include "foundation/compat_thread.h"
2424
2525#include <mongoose/mongoose.h>
26+ #include <sqlite3/sqlite3.h>
2627#include <yyjson/yyjson.h>
2728
29+ #include <math.h>
2830#include <stdatomic.h>
2931#include <stdio.h>
3032#include <stdlib.h>
@@ -890,6 +892,41 @@ static bool get_query_param(struct mg_str query, const char *name, char *buf, in
890892
891893/* ── Handle GET /api/layout ───────────────────────────────────── */
892894
895+ /* Find distinct target_project values from CROSS_* edges in a store.
896+ * Writes up to max_out project names (heap-allocated). Returns count. */
897+ static int find_cross_repo_targets (cbm_store_t * store , const char * project , char * * out ,
898+ int max_out ) {
899+ struct sqlite3 * db = cbm_store_get_db (store );
900+ if (!db ) {
901+ return 0 ;
902+ }
903+ sqlite3_stmt * s = NULL ;
904+ if (sqlite3_prepare_v2 (
905+ db ,
906+ "SELECT DISTINCT json_extract(properties, '$.target_project') FROM edges "
907+ "WHERE project = ?1 AND type LIKE 'CROSS_%' "
908+ "AND json_extract(properties, '$.target_project') IS NOT NULL" ,
909+ -1 , & s , NULL ) != SQLITE_OK ) {
910+ return 0 ;
911+ }
912+ sqlite3_bind_text (s , 1 , project , -1 , SQLITE_STATIC );
913+ int count = 0 ;
914+ while (sqlite3_step (s ) == SQLITE_ROW && count < max_out ) {
915+ const char * tp = (const char * )sqlite3_column_text (s , 0 );
916+ if (tp && tp [0 ]) {
917+ size_t len = strlen (tp );
918+ out [count ] = malloc (len + 1 );
919+ memcpy (out [count ], tp , len + 1 );
920+ count ++ ;
921+ }
922+ }
923+ sqlite3_finalize (s );
924+ return count ;
925+ }
926+
927+ enum { LAYOUT_MAX_LINKED = 16 };
928+ #define LAYOUT_GALAXY_SPACING 600.0
929+
893930static void handle_layout (struct mg_connection * c , struct mg_http_message * hm ) {
894931 char project [256 ] = {0 };
895932 char max_str [32 ] = {0 };
@@ -907,7 +944,6 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
907944 max_nodes = v ;
908945 }
909946
910- /* Open a read-only store for this project */
911947 char db_path [1024 ];
912948 db_path_for_project (project , db_path , sizeof (db_path ));
913949
@@ -924,23 +960,130 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
924960
925961 cbm_layout_result_t * layout =
926962 cbm_layout_compute (store , project , CBM_LAYOUT_OVERVIEW , NULL , 0 , max_nodes );
963+
964+ /* Find linked projects from CROSS_* edges */
965+ char * linked [LAYOUT_MAX_LINKED ];
966+ int linked_count = find_cross_repo_targets (store , project , linked , LAYOUT_MAX_LINKED );
967+
927968 cbm_store_close (store );
928969
929970 if (!layout ) {
930971 mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"layout computation failed\"}" );
931972 return ;
932973 }
933974
934- char * json = cbm_layout_to_json (layout );
975+ /* Build JSON: primary layout + linked_projects */
976+ char * primary_json = cbm_layout_to_json (layout );
935977 cbm_layout_free (layout );
936-
937- if (!json ) {
978+ if (!primary_json ) {
938979 mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"JSON serialization failed\"}" );
939980 return ;
940981 }
941982
942- mg_http_reply (c , 200 , g_cors_json , "%s" , json );
943- free (json );
983+ if (linked_count == 0 ) {
984+ mg_http_reply (c , 200 , g_cors_json , "%s" , primary_json );
985+ free (primary_json );
986+ return ;
987+ }
988+
989+ /* Parse primary JSON and append linked_projects array */
990+ yyjson_doc * pdoc = yyjson_read (primary_json , strlen (primary_json ), 0 );
991+ free (primary_json );
992+ if (!pdoc ) {
993+ mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"JSON parse failed\"}" );
994+ return ;
995+ }
996+
997+ yyjson_mut_doc * mdoc = yyjson_doc_mut_copy (pdoc , NULL );
998+ yyjson_doc_free (pdoc );
999+ yyjson_mut_val * mroot = yyjson_mut_doc_get_root (mdoc );
1000+
1001+ yyjson_mut_val * lp_arr = yyjson_mut_arr (mdoc );
1002+
1003+ for (int li = 0 ; li < linked_count ; li ++ ) {
1004+ char lp_path [1024 ];
1005+ db_path_for_project (linked [li ], lp_path , sizeof (lp_path ));
1006+ if (!cbm_file_exists (lp_path )) {
1007+ free (linked [li ]);
1008+ continue ;
1009+ }
1010+
1011+ cbm_store_t * lp_store = cbm_store_open_path (lp_path );
1012+ if (!lp_store ) {
1013+ free (linked [li ]);
1014+ continue ;
1015+ }
1016+
1017+ cbm_layout_result_t * lp_layout =
1018+ cbm_layout_compute (lp_store , linked [li ], CBM_LAYOUT_OVERVIEW , NULL , 0 , max_nodes );
1019+ cbm_store_close (lp_store );
1020+
1021+ if (!lp_layout ) {
1022+ free (linked [li ]);
1023+ continue ;
1024+ }
1025+
1026+ char * lp_json = cbm_layout_to_json (lp_layout );
1027+ cbm_layout_free (lp_layout );
1028+ if (!lp_json ) {
1029+ free (linked [li ]);
1030+ continue ;
1031+ }
1032+
1033+ /* Parse linked project layout */
1034+ yyjson_doc * lpdoc = yyjson_read (lp_json , strlen (lp_json ), 0 );
1035+ free (lp_json );
1036+ if (!lpdoc ) {
1037+ free (linked [li ]);
1038+ continue ;
1039+ }
1040+
1041+ yyjson_mut_doc * lm = yyjson_doc_mut_copy (lpdoc , NULL );
1042+ yyjson_doc_free (lpdoc );
1043+ yyjson_mut_val * lmroot = yyjson_mut_doc_get_root (lm );
1044+
1045+ /* Build linked project entry */
1046+ yyjson_mut_val * entry = yyjson_mut_obj (mdoc );
1047+ yyjson_mut_obj_add_strcpy (mdoc , entry , "project" , linked [li ]);
1048+
1049+ /* Copy nodes and edges from linked layout */
1050+ yyjson_mut_val * ln = yyjson_mut_obj_get (lmroot , "nodes" );
1051+ yyjson_mut_val * le = yyjson_mut_obj_get (lmroot , "edges" );
1052+ if (ln ) {
1053+ yyjson_mut_obj_add_val (mdoc , entry , "nodes" , yyjson_mut_val_mut_copy (mdoc , ln ));
1054+ }
1055+ if (le ) {
1056+ yyjson_mut_obj_add_val (mdoc , entry , "edges" , yyjson_mut_val_mut_copy (mdoc , le ));
1057+ }
1058+
1059+ /* Compute galaxy offset: evenly spaced around primary */
1060+ double angle = (2.0 * 3.14159265358979 ) * (double )li / (double )linked_count ;
1061+ yyjson_mut_val * offset = yyjson_mut_obj (mdoc );
1062+ yyjson_mut_obj_add_real (mdoc , offset , "x" , cos (angle ) * LAYOUT_GALAXY_SPACING );
1063+ yyjson_mut_obj_add_real (mdoc , offset , "y" , sin (angle ) * LAYOUT_GALAXY_SPACING );
1064+ yyjson_mut_obj_add_real (mdoc , offset , "z" , 0.0 );
1065+ yyjson_mut_obj_add_val (mdoc , entry , "offset" , offset );
1066+
1067+ /* TODO: cross_edges array with CROSS_* edges connecting the galaxies */
1068+ yyjson_mut_obj_add_val (mdoc , entry , "cross_edges" , yyjson_mut_arr (mdoc ));
1069+
1070+ yyjson_mut_arr_append (lp_arr , entry );
1071+ yyjson_mut_doc_free (lm );
1072+ free (linked [li ]);
1073+ }
1074+
1075+ yyjson_mut_obj_add_val (mdoc , mroot , "linked_projects" , lp_arr );
1076+
1077+ size_t len = 0 ;
1078+ char * final_json = yyjson_mut_write (mdoc , 0 , & len );
1079+ yyjson_mut_doc_free (mdoc );
1080+
1081+ if (final_json ) {
1082+ mg_http_reply (c , 200 , g_cors_json , "%s" , final_json );
1083+ free (final_json );
1084+ } else {
1085+ mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"JSON write failed\"}" );
1086+ }
9441087}
9451088
9461089/* ── Handle JSON-RPC request ──────────────────────────────────── */
0 commit comments