@@ -952,6 +952,28 @@ static int find_cross_repo_targets(cbm_store_t *store, const char *project, char
952952
953953enum { LAYOUT_MAX_LINKED = 16 };
954954#define LAYOUT_GALAXY_SPACING 600.0
955+ #define LAYOUT_GALAXY_PAD 400.0
956+
957+ /* Bounding-radius of a layout result: max distance from origin across all
958+ * nodes. Used to size galaxy spacing so satellites don't overlap the primary
959+ * cluster. Layouts with a 1000-node cluster have radius ~1500; the previous
960+ * fixed 600 spacing buried satellites inside the primary mass. */
961+ static double layout_radius (const cbm_layout_result_t * r ) {
962+ if (!r || r -> node_count == 0 )
963+ return 0.0 ;
964+ double max_r2 = 0.0 ;
965+ for (int i = 0 ; i < r -> node_count ; i ++ ) {
966+ double x = (double )r -> nodes [i ].x ;
967+ double y = (double )r -> nodes [i ].y ;
968+ double z = (double )r -> nodes [i ].z ;
969+ if (!isfinite (x ) || !isfinite (y ) || !isfinite (z ))
970+ continue ;
971+ double r2 = x * x + y * y + z * z ;
972+ if (r2 > max_r2 )
973+ max_r2 = r2 ;
974+ }
975+ return sqrt (max_r2 );
976+ }
955977
956978static void handle_layout (struct mg_connection * c , struct mg_http_message * hm ) {
957979 char project [256 ] = {0 };
@@ -987,26 +1009,32 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
9871009 cbm_layout_result_t * layout =
9881010 cbm_layout_compute (store , project , CBM_LAYOUT_OVERVIEW , NULL , 0 , max_nodes );
9891011
990- /* Find linked projects from CROSS_* edges */
1012+ /* Find linked projects from CROSS_* edges. Keep `store` open through the
1013+ * linked-projects loop below so we can resolve target Route QNs against
1014+ * the linked stores when populating cross_edges. */
9911015 char * linked [LAYOUT_MAX_LINKED ];
9921016 int linked_count = find_cross_repo_targets (store , project , linked , LAYOUT_MAX_LINKED );
9931017
994- cbm_store_close (store );
995-
9961018 if (!layout ) {
1019+ cbm_store_close (store );
9971020 mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"layout computation failed\"}" );
9981021 return ;
9991022 }
10001023
1024+ /* Capture primary cluster radius before freeing the layout. */
1025+ double primary_radius = layout_radius (layout );
1026+
10011027 /* Build JSON: primary layout + linked_projects */
10021028 char * primary_json = cbm_layout_to_json (layout );
10031029 cbm_layout_free (layout );
10041030 if (!primary_json ) {
1031+ cbm_store_close (store );
10051032 mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"JSON serialization failed\"}" );
10061033 return ;
10071034 }
10081035
10091036 if (linked_count == 0 ) {
1037+ cbm_store_close (store );
10101038 mg_http_reply (c , 200 , g_cors_json , "%s" , primary_json );
10111039 free (primary_json );
10121040 return ;
@@ -1016,6 +1044,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
10161044 yyjson_doc * pdoc = yyjson_read (primary_json , strlen (primary_json ), 0 );
10171045 free (primary_json );
10181046 if (!pdoc ) {
1047+ cbm_store_close (store );
10191048 mg_http_reply (c , 500 , g_cors_json , "{\"error\":\"JSON parse failed\"}" );
10201049 return ;
10211050 }
@@ -1040,18 +1069,21 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
10401069 continue ;
10411070 }
10421071
1072+ /* Keep lp_store open through cross_edges resolution below. */
10431073 cbm_layout_result_t * lp_layout =
10441074 cbm_layout_compute (lp_store , linked [li ], CBM_LAYOUT_OVERVIEW , NULL , 0 , max_nodes );
1045- cbm_store_close (lp_store );
10461075
10471076 if (!lp_layout ) {
1077+ cbm_store_close (lp_store );
10481078 free (linked [li ]);
10491079 continue ;
10501080 }
10511081
1082+ double sat_radius = layout_radius (lp_layout );
10521083 char * lp_json = cbm_layout_to_json (lp_layout );
10531084 cbm_layout_free (lp_layout );
10541085 if (!lp_json ) {
1086+ cbm_store_close (lp_store );
10551087 free (linked [li ]);
10561088 continue ;
10571089 }
@@ -1060,6 +1092,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
10601092 yyjson_doc * lpdoc = yyjson_read (lp_json , strlen (lp_json ), 0 );
10611093 free (lp_json );
10621094 if (!lpdoc ) {
1095+ cbm_store_close (lp_store );
10631096 free (linked [li ]);
10641097 continue ;
10651098 }
@@ -1082,22 +1115,85 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
10821115 yyjson_mut_obj_add_val (mdoc , entry , "edges" , yyjson_mut_val_mut_copy (mdoc , le ));
10831116 }
10841117
1085- /* Compute galaxy offset: evenly spaced around primary */
1118+ /* Compute galaxy offset: evenly spaced around primary, far enough out
1119+ * that the primary cluster (radius primary_radius) and the satellite
1120+ * cluster (radius sat_radius) don't overlap. Bounded below by
1121+ * LAYOUT_GALAXY_SPACING for trivially small projects. */
10861122 double angle = (2.0 * 3.14159265358979 ) * (double )li / (double )linked_count ;
1123+ double dist = primary_radius + sat_radius + LAYOUT_GALAXY_PAD ;
1124+ if (dist < LAYOUT_GALAXY_SPACING ) {
1125+ dist = LAYOUT_GALAXY_SPACING ;
1126+ }
10871127 yyjson_mut_val * offset = yyjson_mut_obj (mdoc );
1088- yyjson_mut_obj_add_real (mdoc , offset , "x" , cos (angle ) * LAYOUT_GALAXY_SPACING );
1089- yyjson_mut_obj_add_real (mdoc , offset , "y" , sin (angle ) * LAYOUT_GALAXY_SPACING );
1128+ yyjson_mut_obj_add_real (mdoc , offset , "x" , cos (angle ) * dist );
1129+ yyjson_mut_obj_add_real (mdoc , offset , "y" , sin (angle ) * dist );
10901130 yyjson_mut_obj_add_real (mdoc , offset , "z" , 0.0 );
10911131 yyjson_mut_obj_add_val (mdoc , entry , "offset" , offset );
10921132
1093- /* TODO: cross_edges array with CROSS_* edges connecting the galaxies */
1094- yyjson_mut_obj_add_val (mdoc , entry , "cross_edges" , yyjson_mut_arr (mdoc ));
1133+ /* Populate cross_edges connecting primary→this linked galaxy. Each
1134+ * entry: {source: <primary node id>, target: <linked node id>, type}.
1135+ *
1136+ * A CROSS_* edge in the source store points caller_id → local_route_id
1137+ * (a Route node in the source store). The Route's qualified_name is
1138+ * canonical and the same Route exists in the linked store too — that's
1139+ * the cross-repo matching contract. Join edges → nodes in source to
1140+ * pull the QN, then look it up in the linked store. */
1141+ yyjson_mut_val * cross_arr = yyjson_mut_arr (mdoc );
1142+ struct sqlite3 * src_db = cbm_store_get_db (store );
1143+ struct sqlite3 * lp_db = cbm_store_get_db (lp_store );
1144+ if (src_db && lp_db ) {
1145+ sqlite3_stmt * eq = NULL ;
1146+ if (sqlite3_prepare_v2 (
1147+ src_db ,
1148+ "SELECT e.source_id, e.type, n.qualified_name "
1149+ "FROM edges e JOIN nodes n "
1150+ " ON n.id = e.target_id AND n.project = e.project "
1151+ "WHERE e.project = ?1 AND e.type LIKE 'CROSS_%' "
1152+ " AND json_extract(e.properties, '$.target_project') = ?2 "
1153+ " AND n.qualified_name IS NOT NULL" ,
1154+ -1 , & eq , NULL ) == SQLITE_OK ) {
1155+ sqlite3_bind_text (eq , 1 , project , -1 , SQLITE_STATIC );
1156+ sqlite3_bind_text (eq , 2 , linked [li ], -1 , SQLITE_STATIC );
1157+
1158+ sqlite3_stmt * lookup = NULL ;
1159+ sqlite3_prepare_v2 (
1160+ lp_db , "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1" , -1 , & lookup ,
1161+ NULL );
1162+
1163+ while (sqlite3_step (eq ) == SQLITE_ROW ) {
1164+ int64_t src_id = sqlite3_column_int64 (eq , 0 );
1165+ const char * etype = (const char * )sqlite3_column_text (eq , 1 );
1166+ const char * qn = (const char * )sqlite3_column_text (eq , 2 );
1167+ if (!qn || !etype || !lookup ) {
1168+ continue ;
1169+ }
1170+ sqlite3_reset (lookup );
1171+ sqlite3_clear_bindings (lookup );
1172+ sqlite3_bind_text (lookup , 1 , qn , -1 , SQLITE_STATIC );
1173+ if (sqlite3_step (lookup ) != SQLITE_ROW ) {
1174+ continue ;
1175+ }
1176+ int64_t tgt_id = sqlite3_column_int64 (lookup , 0 );
1177+ yyjson_mut_val * ce = yyjson_mut_obj (mdoc );
1178+ yyjson_mut_obj_add_int (mdoc , ce , "source" , src_id );
1179+ yyjson_mut_obj_add_int (mdoc , ce , "target" , tgt_id );
1180+ yyjson_mut_obj_add_strcpy (mdoc , ce , "type" , etype );
1181+ yyjson_mut_arr_append (cross_arr , ce );
1182+ }
1183+ if (lookup )
1184+ sqlite3_finalize (lookup );
1185+ sqlite3_finalize (eq );
1186+ }
1187+ }
1188+ yyjson_mut_obj_add_val (mdoc , entry , "cross_edges" , cross_arr );
10951189
1190+ cbm_store_close (lp_store );
10961191 yyjson_mut_arr_append (lp_arr , entry );
10971192 yyjson_mut_doc_free (lm );
10981193 free (linked [li ]);
10991194 }
11001195
1196+ cbm_store_close (store );
11011197 yyjson_mut_obj_add_val (mdoc , mroot , "linked_projects" , lp_arr );
11021198
11031199 size_t len = 0 ;
0 commit comments