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>
This commit is contained in:
Martin Vogelandsponger94 committed 2026-05-10 18:56:30 +02:00
1 parent 70839551d3
commit 7290c1fd2b
4 files changed
+178 -19

No files matched your search

+33 -7
View File
@@ -7,6 +7,10 @@ interface EdgeLinesProps {
edges: GraphEdge[];
highlightedIds: Set<number> | null;
opacity?: number;
/* When set, edge.target is looked up in this array instead of `nodes`.
* Used for cross-galaxy edges where source lives in the primary graph
* and target lives in a linked project's offset-adjusted nodes. */
targetNodes?: GraphNode[];
}
function getClusterKey(fp?: string): string {
@@ -28,17 +32,39 @@ const EDGE_TYPE_COLORS: Record<string, string> = {
IMPLEMENTS: "#f97316",
HTTP_CALLS: "#e11d48",
ASYNC_CALLS: "#ec4899",
GRPC_CALLS: "#f59e0b",
GRAPHQL_CALLS: "#e879f9",
TRPC_CALLS: "#a78bfa",
CROSS_HTTP_CALLS: "#fb923c",
CROSS_ASYNC_CALLS: "#fb7185",
CROSS_GRPC_CALLS: "#fbbf24",
CROSS_GRAPHQL_CALLS: "#f0abfc",
CROSS_TRPC_CALLS: "#c4b5fd",
CROSS_CHANNEL: "#fdba74",
MEMBER_OF: "#64748b",
TESTS_FILE: "#06b6d4",
};
const DEFAULT_EDGE_COLOR = "#1C8585";
export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeLinesProps) {
export function EdgeLines({
nodes,
edges,
highlightedIds,
opacity = 1.0,
targetNodes,
}: EdgeLinesProps) {
const geometry = useMemo(() => {
const idMap = new Map<number, number>();
const srcMap = new Map<number, number>();
for (let i = 0; i < nodes.length; i++) {
idMap.set(nodes[i].id, i);
srcMap.set(nodes[i].id, i);
}
const tgtArr = targetNodes ?? nodes;
const tgtMap = targetNodes ? new Map<number, number>() : srcMap;
if (targetNodes) {
for (let i = 0; i < targetNodes.length; i++) {
tgtMap.set(targetNodes[i].id, i);
}
}
const hasHighlight = highlightedIds && highlightedIds.size > 0;
@@ -47,12 +73,12 @@ export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeL
let validCount = 0;
for (const edge of edges) {
const si = idMap.get(edge.source);
const ti = idMap.get(edge.target);
const si = srcMap.get(edge.source);
const ti = tgtMap.get(edge.target);
if (si === undefined || ti === undefined) continue;
const s = nodes[si];
const t = nodes[ti];
const t = tgtArr[ti];
const sHL = !hasHighlight || highlightedIds.has(s.id);
const tHL = !hasHighlight || highlightedIds.has(t.id);
@@ -99,7 +125,7 @@ export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeL
new THREE.BufferAttribute(colors.slice(0, validCount * 6), 3),
);
return geo;
}, [nodes, edges, highlightedIds]);
}, [nodes, edges, highlightedIds, targetNodes]);
return (
<lineSegments geometry={geometry}>
+11
View File
@@ -156,6 +156,17 @@ export function GraphScene({
onClick={onNodeClick}
opacity={0.5}
/>
{/* Inter-galaxy CROSS_* edges: source is in primary, target in
* this linked project's offset nodes. */}
{lp.cross_edges && lp.cross_edges.length > 0 && (
<EdgeLines
nodes={data.nodes}
targetNodes={offsetNodes}
edges={lp.cross_edges}
highlightedIds={highlightedIds}
opacity={0.85}
/>
)}
</group>
);
})}
+29 -3
View File
@@ -48,6 +48,11 @@ export function GraphTab({ project }: GraphTabProps) {
if (!data) return;
const labels = new Set(data.nodes.map((n) => n.label));
const types = new Set(data.edges.map((e) => e.type));
for (const lp of data.linked_projects ?? []) {
for (const n of lp.nodes) labels.add(n.label);
for (const e of lp.edges) types.add(e.type);
for (const e of lp.cross_edges) types.add(e.type);
}
setEnabledLabels(labels);
setEnabledEdgeTypes(types);
}, [data]);
@@ -65,7 +70,21 @@ export function GraphTab({ project }: GraphTabProps) {
nodeIds.has(e.target),
);
return { nodes, edges, total_nodes: data.total_nodes };
const linked_projects = data.linked_projects?.map((lp) => {
const lpNodes = lp.nodes.filter((n) => enabledLabels.has(n.label));
const lpIds = new Set(lpNodes.map((n) => n.id));
const lpEdges = lp.edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) && lpIds.has(e.source) && lpIds.has(e.target),
);
const crossEdges = lp.cross_edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) && nodeIds.has(e.source) && lpIds.has(e.target),
);
return { ...lp, nodes: lpNodes, edges: lpEdges, cross_edges: crossEdges };
});
return { nodes, edges, total_nodes: data.total_nodes, linked_projects };
}, [data, enabledLabels, enabledEdgeTypes]);
useEffect(() => {
@@ -136,8 +155,15 @@ export function GraphTab({ project }: GraphTabProps) {
const enableAll = useCallback(() => {
if (!data) return;
setEnabledLabels(new Set(data.nodes.map((n) => n.label)));
setEnabledEdgeTypes(new Set(data.edges.map((e) => e.type)));
const labels = new Set(data.nodes.map((n) => n.label));
const types = new Set(data.edges.map((e) => e.type));
for (const lp of data.linked_projects ?? []) {
for (const n of lp.nodes) labels.add(n.label);
for (const e of lp.edges) types.add(e.type);
for (const e of lp.cross_edges) types.add(e.type);
}
setEnabledLabels(labels);
setEnabledEdgeTypes(types);
}, [data]);
const disableAll = useCallback(() => {
+105 -9
View File
@@ -952,6 +952,28 @@ static int find_cross_repo_targets(cbm_store_t *store, const char *project, char
enum { LAYOUT_MAX_LINKED = 16 };
#define LAYOUT_GALAXY_SPACING 600.0
#define LAYOUT_GALAXY_PAD 400.0
/* Bounding-radius of a layout result: max distance from origin across all
* nodes. Used to size galaxy spacing so satellites don't overlap the primary
* cluster. Layouts with a 1000-node cluster have radius ~1500; the previous
* fixed 600 spacing buried satellites inside the primary mass. */
static double layout_radius(const cbm_layout_result_t *r) {
if (!r || r->node_count == 0)
return 0.0;
double max_r2 = 0.0;
for (int i = 0; i < r->node_count; i++) {
double x = (double)r->nodes[i].x;
double y = (double)r->nodes[i].y;
double z = (double)r->nodes[i].z;
if (!isfinite(x) || !isfinite(y) || !isfinite(z))
continue;
double r2 = x * x + y * y + z * z;
if (r2 > max_r2)
max_r2 = r2;
}
return sqrt(max_r2);
}
static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
char project[256] = {0};
@@ -987,26 +1009,32 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
cbm_layout_result_t *layout =
cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
/* Find linked projects from CROSS_* edges */
/* Find linked projects from CROSS_* edges. Keep `store` open through the
* linked-projects loop below so we can resolve target Route QNs against
* the linked stores when populating cross_edges. */
char *linked[LAYOUT_MAX_LINKED];
int linked_count = find_cross_repo_targets(store, project, linked, LAYOUT_MAX_LINKED);
cbm_store_close(store);
if (!layout) {
cbm_store_close(store);
mg_http_reply(c, 500, g_cors_json, "{\"error\":\"layout computation failed\"}");
return;
}
/* Capture primary cluster radius before freeing the layout. */
double primary_radius = layout_radius(layout);
/* Build JSON: primary layout + linked_projects */
char *primary_json = cbm_layout_to_json(layout);
cbm_layout_free(layout);
if (!primary_json) {
cbm_store_close(store);
mg_http_reply(c, 500, g_cors_json, "{\"error\":\"JSON serialization failed\"}");
return;
}
if (linked_count == 0) {
cbm_store_close(store);
mg_http_reply(c, 200, g_cors_json, "%s", primary_json);
free(primary_json);
return;
@@ -1016,6 +1044,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0);
free(primary_json);
if (!pdoc) {
cbm_store_close(store);
mg_http_reply(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}");
return;
}
@@ -1040,18 +1069,21 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
continue;
}
/* Keep lp_store open through cross_edges resolution below. */
cbm_layout_result_t *lp_layout =
cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
cbm_store_close(lp_store);
if (!lp_layout) {
cbm_store_close(lp_store);
free(linked[li]);
continue;
}
double sat_radius = layout_radius(lp_layout);
char *lp_json = cbm_layout_to_json(lp_layout);
cbm_layout_free(lp_layout);
if (!lp_json) {
cbm_store_close(lp_store);
free(linked[li]);
continue;
}
@@ -1060,6 +1092,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0);
free(lp_json);
if (!lpdoc) {
cbm_store_close(lp_store);
free(linked[li]);
continue;
}
@@ -1082,22 +1115,85 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
yyjson_mut_obj_add_val(mdoc, entry, "edges", yyjson_mut_val_mut_copy(mdoc, le));
}
/* Compute galaxy offset: evenly spaced around primary */
/* Compute galaxy offset: evenly spaced around primary, far enough out
* that the primary cluster (radius primary_radius) and the satellite
* cluster (radius sat_radius) don't overlap. Bounded below by
* LAYOUT_GALAXY_SPACING for trivially small projects. */
double angle = (2.0 * 3.14159265358979) * (double)li / (double)linked_count;
double dist = primary_radius + sat_radius + LAYOUT_GALAXY_PAD;
if (dist < LAYOUT_GALAXY_SPACING) {
dist = LAYOUT_GALAXY_SPACING;
}
yyjson_mut_val *offset = yyjson_mut_obj(mdoc);
yyjson_mut_obj_add_real(mdoc, offset, "x", cos(angle) * LAYOUT_GALAXY_SPACING);
yyjson_mut_obj_add_real(mdoc, offset, "y", sin(angle) * LAYOUT_GALAXY_SPACING);
yyjson_mut_obj_add_real(mdoc, offset, "x", cos(angle) * dist);
yyjson_mut_obj_add_real(mdoc, offset, "y", sin(angle) * dist);
yyjson_mut_obj_add_real(mdoc, offset, "z", 0.0);
yyjson_mut_obj_add_val(mdoc, entry, "offset", offset);
/* TODO: cross_edges array with CROSS_* edges connecting the galaxies */
yyjson_mut_obj_add_val(mdoc, entry, "cross_edges", yyjson_mut_arr(mdoc));
/* Populate cross_edges connecting primary→this linked galaxy. Each
* entry: {source: <primary node id>, target: <linked node id>, type}.
*
* A CROSS_* edge in the source store points caller_id → local_route_id
* (a Route node in the source store). The Route's qualified_name is
* canonical and the same Route exists in the linked store too — that's
* the cross-repo matching contract. Join edges → nodes in source to
* pull the QN, then look it up in the linked store. */
yyjson_mut_val *cross_arr = yyjson_mut_arr(mdoc);
struct sqlite3 *src_db = cbm_store_get_db(store);
struct sqlite3 *lp_db = cbm_store_get_db(lp_store);
if (src_db && lp_db) {
sqlite3_stmt *eq = NULL;
if (sqlite3_prepare_v2(
src_db,
"SELECT e.source_id, e.type, n.qualified_name "
"FROM edges e JOIN nodes n "
" ON n.id = e.target_id AND n.project = e.project "
"WHERE e.project = ?1 AND e.type LIKE 'CROSS_%' "
" AND json_extract(e.properties, '$.target_project') = ?2 "
" AND n.qualified_name IS NOT NULL",
-1, &eq, NULL) == SQLITE_OK) {
sqlite3_bind_text(eq, 1, project, -1, SQLITE_STATIC);
sqlite3_bind_text(eq, 2, linked[li], -1, SQLITE_STATIC);
sqlite3_stmt *lookup = NULL;
sqlite3_prepare_v2(
lp_db, "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1", -1, &lookup,
NULL);
while (sqlite3_step(eq) == SQLITE_ROW) {
int64_t src_id = sqlite3_column_int64(eq, 0);
const char *etype = (const char *)sqlite3_column_text(eq, 1);
const char *qn = (const char *)sqlite3_column_text(eq, 2);
if (!qn || !etype || !lookup) {
continue;
}
sqlite3_reset(lookup);
sqlite3_clear_bindings(lookup);
sqlite3_bind_text(lookup, 1, qn, -1, SQLITE_STATIC);
if (sqlite3_step(lookup) != SQLITE_ROW) {
continue;
}
int64_t tgt_id = sqlite3_column_int64(lookup, 0);
yyjson_mut_val *ce = yyjson_mut_obj(mdoc);
yyjson_mut_obj_add_int(mdoc, ce, "source", src_id);
yyjson_mut_obj_add_int(mdoc, ce, "target", tgt_id);
yyjson_mut_obj_add_strcpy(mdoc, ce, "type", etype);
yyjson_mut_arr_append(cross_arr, ce);
}
if (lookup)
sqlite3_finalize(lookup);
sqlite3_finalize(eq);
}
}
yyjson_mut_obj_add_val(mdoc, entry, "cross_edges", cross_arr);
cbm_store_close(lp_store);
yyjson_mut_arr_append(lp_arr, entry);
yyjson_mut_doc_free(lm);
free(linked[li]);
}
cbm_store_close(store);
yyjson_mut_obj_add_val(mdoc, mroot, "linked_projects", lp_arr);
size_t len = 0;