Skip to content

Commit 7290c1f

Browse files
DeusDatasponger94
andcommitted
feat(ui): satellite galaxy radius spacing + cross-galaxy edge rendering
Two visualization gaps in the cross-repo graph view: 1. Fixed LAYOUT_GALAXY_SPACING=600 buried satellite galaxies inside the primary cluster on any non-trivial project — a 1000-node primary cluster has bounding radius ~1500. Added layout_radius() (max distance from origin across all node positions) and compute the per-satellite distance as primary_radius + sat_radius + LAYOUT_GALAXY_PAD, with the old constant retained as a lower bound for tiny projects. 2. cross_edges was hard-coded to an empty array (TODO placeholder), so inter-galaxy CROSS_* edges never rendered even when the matcher had produced them. Populate from a join on the source store's CROSS_* edges → Route node qualified_name, then resolve the canonical QN in the linked store to get its node id. The Route QN is the cross-repo matching contract; properties.target_function isn't unique. Required keeping both `store` and `lp_store` open through the linked- projects loop instead of closing them right after layout compute. Added cbm_store_close to all early-error paths to balance the lifetime. Frontend side: - EdgeLines accepts an optional targetNodes prop so a single component can render edges with source in one node array and target in another (offset-adjusted satellite nodes). - GraphScene renders an EdgeLines layer per linked project for its cross_edges, using primary nodes as source and offsetNodes as target. - GraphTab folds linked-project labels and edge types into the filter init / enableAll / filteredData paths so cross_edges respect the current filter state. - Edge color palette extended with GRPC/GRAPHQL/TRPC/CROSS_* tones. Co-authored-by: sponger94 <45746997+sponger94@users.noreply.github.com>
1 parent 7083955 commit 7290c1f

4 files changed

Lines changed: 178 additions & 19 deletions

File tree

graph-ui/src/components/EdgeLines.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ interface EdgeLinesProps {
77
edges: GraphEdge[];
88
highlightedIds: Set<number> | null;
99
opacity?: number;
10+
/* When set, edge.target is looked up in this array instead of `nodes`.
11+
* Used for cross-galaxy edges where source lives in the primary graph
12+
* and target lives in a linked project's offset-adjusted nodes. */
13+
targetNodes?: GraphNode[];
1014
}
1115

1216
function getClusterKey(fp?: string): string {
@@ -28,17 +32,39 @@ const EDGE_TYPE_COLORS: Record<string, string> = {
2832
IMPLEMENTS: "#f97316",
2933
HTTP_CALLS: "#e11d48",
3034
ASYNC_CALLS: "#ec4899",
35+
GRPC_CALLS: "#f59e0b",
36+
GRAPHQL_CALLS: "#e879f9",
37+
TRPC_CALLS: "#a78bfa",
38+
CROSS_HTTP_CALLS: "#fb923c",
39+
CROSS_ASYNC_CALLS: "#fb7185",
40+
CROSS_GRPC_CALLS: "#fbbf24",
41+
CROSS_GRAPHQL_CALLS: "#f0abfc",
42+
CROSS_TRPC_CALLS: "#c4b5fd",
43+
CROSS_CHANNEL: "#fdba74",
3144
MEMBER_OF: "#64748b",
3245
TESTS_FILE: "#06b6d4",
3346
};
3447

3548
const DEFAULT_EDGE_COLOR = "#1C8585";
3649

37-
export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeLinesProps) {
50+
export function EdgeLines({
51+
nodes,
52+
edges,
53+
highlightedIds,
54+
opacity = 1.0,
55+
targetNodes,
56+
}: EdgeLinesProps) {
3857
const geometry = useMemo(() => {
39-
const idMap = new Map<number, number>();
58+
const srcMap = new Map<number, number>();
4059
for (let i = 0; i < nodes.length; i++) {
41-
idMap.set(nodes[i].id, i);
60+
srcMap.set(nodes[i].id, i);
61+
}
62+
const tgtArr = targetNodes ?? nodes;
63+
const tgtMap = targetNodes ? new Map<number, number>() : srcMap;
64+
if (targetNodes) {
65+
for (let i = 0; i < targetNodes.length; i++) {
66+
tgtMap.set(targetNodes[i].id, i);
67+
}
4268
}
4369

4470
const hasHighlight = highlightedIds && highlightedIds.size > 0;
@@ -47,12 +73,12 @@ export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeL
4773
let validCount = 0;
4874

4975
for (const edge of edges) {
50-
const si = idMap.get(edge.source);
51-
const ti = idMap.get(edge.target);
76+
const si = srcMap.get(edge.source);
77+
const ti = tgtMap.get(edge.target);
5278
if (si === undefined || ti === undefined) continue;
5379

5480
const s = nodes[si];
55-
const t = nodes[ti];
81+
const t = tgtArr[ti];
5682

5783
const sHL = !hasHighlight || highlightedIds.has(s.id);
5884
const tHL = !hasHighlight || highlightedIds.has(t.id);
@@ -99,7 +125,7 @@ export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeL
99125
new THREE.BufferAttribute(colors.slice(0, validCount * 6), 3),
100126
);
101127
return geo;
102-
}, [nodes, edges, highlightedIds]);
128+
}, [nodes, edges, highlightedIds, targetNodes]);
103129

104130
return (
105131
<lineSegments geometry={geometry}>

graph-ui/src/components/GraphScene.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ export function GraphScene({
156156
onClick={onNodeClick}
157157
opacity={0.5}
158158
/>
159+
{/* Inter-galaxy CROSS_* edges: source is in primary, target in
160+
* this linked project's offset nodes. */}
161+
{lp.cross_edges && lp.cross_edges.length > 0 && (
162+
<EdgeLines
163+
nodes={data.nodes}
164+
targetNodes={offsetNodes}
165+
edges={lp.cross_edges}
166+
highlightedIds={highlightedIds}
167+
opacity={0.85}
168+
/>
169+
)}
159170
</group>
160171
);
161172
})}

graph-ui/src/components/GraphTab.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export function GraphTab({ project }: GraphTabProps) {
4848
if (!data) return;
4949
const labels = new Set(data.nodes.map((n) => n.label));
5050
const types = new Set(data.edges.map((e) => e.type));
51+
for (const lp of data.linked_projects ?? []) {
52+
for (const n of lp.nodes) labels.add(n.label);
53+
for (const e of lp.edges) types.add(e.type);
54+
for (const e of lp.cross_edges) types.add(e.type);
55+
}
5156
setEnabledLabels(labels);
5257
setEnabledEdgeTypes(types);
5358
}, [data]);
@@ -65,7 +70,21 @@ export function GraphTab({ project }: GraphTabProps) {
6570
nodeIds.has(e.target),
6671
);
6772

68-
return { nodes, edges, total_nodes: data.total_nodes };
73+
const linked_projects = data.linked_projects?.map((lp) => {
74+
const lpNodes = lp.nodes.filter((n) => enabledLabels.has(n.label));
75+
const lpIds = new Set(lpNodes.map((n) => n.id));
76+
const lpEdges = lp.edges.filter(
77+
(e) =>
78+
enabledEdgeTypes.has(e.type) && lpIds.has(e.source) && lpIds.has(e.target),
79+
);
80+
const crossEdges = lp.cross_edges.filter(
81+
(e) =>
82+
enabledEdgeTypes.has(e.type) && nodeIds.has(e.source) && lpIds.has(e.target),
83+
);
84+
return { ...lp, nodes: lpNodes, edges: lpEdges, cross_edges: crossEdges };
85+
});
86+
87+
return { nodes, edges, total_nodes: data.total_nodes, linked_projects };
6988
}, [data, enabledLabels, enabledEdgeTypes]);
7089

7190
useEffect(() => {
@@ -136,8 +155,15 @@ export function GraphTab({ project }: GraphTabProps) {
136155

137156
const enableAll = useCallback(() => {
138157
if (!data) return;
139-
setEnabledLabels(new Set(data.nodes.map((n) => n.label)));
140-
setEnabledEdgeTypes(new Set(data.edges.map((e) => e.type)));
158+
const labels = new Set(data.nodes.map((n) => n.label));
159+
const types = new Set(data.edges.map((e) => e.type));
160+
for (const lp of data.linked_projects ?? []) {
161+
for (const n of lp.nodes) labels.add(n.label);
162+
for (const e of lp.edges) types.add(e.type);
163+
for (const e of lp.cross_edges) types.add(e.type);
164+
}
165+
setEnabledLabels(labels);
166+
setEnabledEdgeTypes(types);
141167
}, [data]);
142168

143169
const disableAll = useCallback(() => {

src/ui/http_server.c

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,28 @@ static int find_cross_repo_targets(cbm_store_t *store, const char *project, char
952952

953953
enum { 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

956978
static 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

Comments
 (0)