Skip to content

Commit b6eefe0

Browse files
author
test
committed
Add multi-galaxy UI layout and cross-repo architecture summary
Backend (http_server.c): - Layout API detects CROSS_* edges and loads linked project graphs - Each linked project computed at radial offset as satellite galaxy - Response includes linked_projects with nodes, edges, offset Frontend: - LinkedProject type in types.ts - GraphScene renders satellite galaxies at offset positions - NodeCloud/EdgeLines accept opacity prop for dimmed rendering Tool integration (mcp.c): - get_architecture includes cross_repo_links summary - Extract append_cross_repo_summary helper for linter compliance
1 parent c5ad6e8 commit b6eefe0

6 files changed

Lines changed: 223 additions & 13 deletions

File tree

graph-ui/src/components/EdgeLines.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface EdgeLinesProps {
66
nodes: GraphNode[];
77
edges: GraphEdge[];
88
highlightedIds: Set<number> | null;
9+
opacity?: number;
910
}
1011

1112
function getClusterKey(fp?: string): string {
@@ -33,7 +34,7 @@ const EDGE_TYPE_COLORS: Record<string, string> = {
3334

3435
const DEFAULT_EDGE_COLOR = "#1C8585";
3536

36-
export function EdgeLines({ nodes, edges, highlightedIds }: EdgeLinesProps) {
37+
export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeLinesProps) {
3738
const geometry = useMemo(() => {
3839
const idMap = new Map<number, number>();
3940
for (let i = 0; i < nodes.length; i++) {
@@ -105,7 +106,7 @@ export function EdgeLines({ nodes, edges, highlightedIds }: EdgeLinesProps) {
105106
<lineBasicMaterial
106107
vertexColors
107108
transparent
108-
opacity={1}
109+
opacity={opacity}
109110
blending={THREE.AdditiveBlending}
110111
depthWrite={false}
111112
toneMapped={false}

graph-ui/src/components/GraphScene.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { NodeCloud } from "./NodeCloud";
88
import { EdgeLines } from "./EdgeLines";
99
import { NodeLabels } from "./NodeLabels";
1010
import { NodeTooltip } from "./NodeTooltip";
11-
import type { GraphData, GraphNode } from "../lib/types";
11+
import type { GraphData, GraphNode, LinkedProject } from "../lib/types";
1212

1313
/* ── Camera fly-to animation ────────────────────────────── */
1414

@@ -133,6 +133,33 @@ export function GraphScene({
133133
/>
134134
{showLabels && <NodeLabels nodes={data.nodes} highlightedIds={highlightedIds} />}
135135

136+
{/* Satellite galaxies for cross-repo linked projects */}
137+
{data.linked_projects?.map((lp: LinkedProject) => {
138+
const offsetNodes = lp.nodes.map((n) => ({
139+
...n,
140+
x: n.x + lp.offset.x,
141+
y: n.y + lp.offset.y,
142+
z: n.z + lp.offset.z,
143+
}));
144+
return (
145+
<group key={lp.project}>
146+
<EdgeLines
147+
nodes={offsetNodes}
148+
edges={lp.edges}
149+
highlightedIds={null}
150+
opacity={0.3}
151+
/>
152+
<NodeCloud
153+
nodes={offsetNodes}
154+
highlightedIds={null}
155+
onHover={setHovered}
156+
onClick={onNodeClick}
157+
opacity={0.5}
158+
/>
159+
</group>
160+
);
161+
})}
162+
136163
{hovered && <NodeTooltip node={hovered} />}
137164

138165
<CameraAnimator target={cameraTarget} />

graph-ui/src/components/NodeCloud.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ interface NodeCloudProps {
88
highlightedIds: Set<number> | null;
99
onHover: (node: GraphNode | null) => void;
1010
onClick: (node: GraphNode) => void;
11+
opacity?: number;
1112
}
1213

1314
export function NodeCloud({
1415
nodes,
1516
highlightedIds,
1617
onHover,
1718
onClick,
19+
opacity = 1.0,
1820
}: NodeCloudProps) {
1921
const meshRef = useRef<THREE.InstancedMesh>(null);
2022
const tempObj = useMemo(() => new THREE.Object3D(), []);
@@ -36,12 +38,12 @@ export function NodeCloud({
3638
const boost = 1.2 + brightness * 0.8; /* 1.2x for red, 2.0x for white */
3739
tempColor.multiplyScalar(boost);
3840
}
39-
arr[i * 3] = tempColor.r;
40-
arr[i * 3 + 1] = tempColor.g;
41-
arr[i * 3 + 2] = tempColor.b;
41+
arr[i * 3] = tempColor.r * opacity;
42+
arr[i * 3 + 1] = tempColor.g * opacity;
43+
arr[i * 3 + 2] = tempColor.b * opacity;
4244
}
4345
return arr;
44-
}, [nodes, highlightedIds, tempColor]);
46+
}, [nodes, highlightedIds, tempColor, opacity]);
4547

4648
useFrame(() => {
4749
const mesh = meshRef.current;

graph-ui/src/lib/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,19 @@ export interface GraphEdge {
1818
type: string;
1919
}
2020

21+
export interface LinkedProject {
22+
project: string;
23+
nodes: GraphNode[];
24+
edges: GraphEdge[];
25+
offset: { x: number; y: number; z: number };
26+
cross_edges: GraphEdge[];
27+
}
28+
2129
export interface GraphData {
2230
nodes: GraphNode[];
2331
edges: GraphEdge[];
2432
total_nodes: number;
33+
linked_projects?: LinkedProject[];
2534
}
2635

2736
export interface Project {

src/mcp/mcp.c

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,6 +1668,32 @@ static bool aspect_wanted(yyjson_doc *aspects_doc, yyjson_val *aspects_arr, cons
16681668
return false;
16691669
}
16701670

1671+
/* Append cross_repo_links summary to architecture JSON if CROSS_* edges exist. */
1672+
static void append_cross_repo_summary(yyjson_mut_doc *doc, yyjson_mut_val *root,
1673+
const cbm_schema_info_t *schema) {
1674+
int cross_http = 0;
1675+
int cross_async = 0;
1676+
int cross_channel = 0;
1677+
for (int i = 0; i < schema->edge_type_count; i++) {
1678+
if (strcmp(schema->edge_types[i].type, "CROSS_HTTP_CALLS") == 0) {
1679+
cross_http = schema->edge_types[i].count;
1680+
} else if (strcmp(schema->edge_types[i].type, "CROSS_ASYNC_CALLS") == 0) {
1681+
cross_async = schema->edge_types[i].count;
1682+
} else if (strcmp(schema->edge_types[i].type, "CROSS_CHANNEL") == 0) {
1683+
cross_channel = schema->edge_types[i].count;
1684+
}
1685+
}
1686+
int cross_total = cross_http + cross_async + cross_channel;
1687+
if (cross_total > 0) {
1688+
yyjson_mut_val *cr = yyjson_mut_obj(doc);
1689+
yyjson_mut_obj_add_int(doc, cr, "cross_http_calls", cross_http);
1690+
yyjson_mut_obj_add_int(doc, cr, "cross_async_calls", cross_async);
1691+
yyjson_mut_obj_add_int(doc, cr, "cross_channel", cross_channel);
1692+
yyjson_mut_obj_add_int(doc, cr, "total", cross_total);
1693+
yyjson_mut_obj_add_val(doc, root, "cross_repo_links", cr);
1694+
}
1695+
}
1696+
16711697
static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
16721698
char *project = cbm_mcp_get_string_arg(args, "project");
16731699
cbm_store_t *store = resolve_store(srv, project);
@@ -1744,6 +1770,8 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
17441770
yyjson_mut_obj_add_val(doc, root, "relationship_patterns", pats);
17451771
}
17461772

1773+
append_cross_repo_summary(doc, root, &schema);
1774+
17471775
char *json = yy_doc_to_str(doc);
17481776
yyjson_mut_doc_free(doc);
17491777
cbm_store_schema_free(&schema);

src/ui/http_server.c

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
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+
893930
static 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

Comments
 (0)