diff --git a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx index 575c884b..698a9a1e 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx @@ -745,6 +745,12 @@ function buildEffectAvailability( ? { enabled: true, available: true, reason: "Panel enabled" } : { enabled: false, available: false, reason: "Disabled by toggle" }; + // #1009: edge labels are immediately available once the graph is loaded — + // they have no async analytics or zoom-tier dependency. + const edgeLabels = effectsState.edgeLabelsEnabled + ? { enabled: true, available: true, reason: "Ready" } + : { enabled: false, available: false, reason: "Disabled by toggle" }; + const diagnostics = !GRAPH_THEME.effects.diagnostics.enabledInDev ? { enabled: false, available: false, reason: "Disabled in production" } : effectsState.diagnosticsEnabled @@ -762,6 +768,7 @@ function buildEffectAvailability( communities, centrality, legend, + edgeLabels, diagnostics, }; } @@ -1218,7 +1225,9 @@ function applySceneState( // #1009: Sigma's edge label renderer draws data.label — the graph // stores the relationship type in edgeType, which the renderer never // saw, so enabling renderEdgeLabels alone left edges blank. - label: resolvedStyle.hidden ? undefined : String(attrs.edgeType ?? data.label ?? ""), + // Use || rather than ?? so that an empty-string edgeType (possible + // when the API returns type: "") does not produce a blank label. + label: resolvedStyle.hidden ? undefined : String(attrs.edgeType || data.label || ""), }; }); diff --git a/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts index a6ae7b7c..eb468bf3 100644 --- a/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +++ b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts @@ -2150,7 +2150,7 @@ function aggregateDisplayGraph(graphRef: GraphRef): Graph collectRawEdgeIds(attrs, edgeId)); const typeCounts = new Map(); entries.forEach(({ attrs }) => { - const edgeType = String(attrs.edgeType ?? "related_to"); + const edgeType = String(attrs.edgeType || "related_to"); typeCounts.set(edgeType, (typeCounts.get(edgeType) ?? 0) + 1); }); const dominantEdgeType = [...typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? representative.attrs.edgeType ?? "related_to"; @@ -2170,7 +2170,7 @@ function aggregateDisplayGraph(graphRef: GraphRef): Graph { + addNode("a"); + addNode("b"); + batchMergeEdges([ + { id: "e1", source: "a", target: "b", attributes: { edgeType: "causes", weight: 1, properties: {} } }, + { id: "e2", source: "a", target: "b", attributes: { edgeType: "causes", weight: 2, properties: {} } }, + ]); + + const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true }); + assert.equal(displayGraph.size, 1); + + const edgeId = displayGraph.edges()[0]; + const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string; isAggregated?: boolean }; + assert.equal(attrs.isAggregated, true); + // The aggregated representative must carry the relationship text through to + // the edgeReducer's label assignment. + assert.equal(typeof attrs.edgeType, "string"); + assert.ok((attrs.edgeType ?? "").length > 0, "aggregated edge must have a non-empty edgeType"); +}); + +test("resolveDisplayGraph parallel-bundle picks dominant edgeType across mixed types", () => { + addNode("a"); + addNode("b"); + batchMergeEdges([ + { id: "e1", source: "a", target: "b", attributes: { edgeType: "inhibits", weight: 1, properties: {} } }, + { id: "e2", source: "a", target: "b", attributes: { edgeType: "inhibits", weight: 1, properties: {} } }, + { id: "e3", source: "a", target: "b", attributes: { edgeType: "activates", weight: 1, properties: {} } }, + ]); + + const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true }); + const edgeId = displayGraph.edges()[0]; + const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string; dominantEdgeType?: string }; + // "inhibits" appears twice so it must be the dominant type. + assert.equal(attrs.edgeType, "inhibits"); + assert.equal(attrs.dominantEdgeType, "inhibits"); +}); + +test("resolveDisplayGraph grouped view community edges carry non-empty edgeType", () => { + const left = ["g1", "g2", "g3", "g4"]; + const right = ["h1", "h2", "h3", "h4"]; + [...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right")); + + let edgeIndex = 0; + for (let i = 0; i < left.length; i += 1) { + for (let j = 0; j < left.length; j += 1) { + if (i !== j) { + batchMergeEdges([{ + id: `lg-${edgeIndex++}`, + source: left[i], + target: left[j], + attributes: { edgeType: "co_occurs", weight: 3, properties: {} }, + }]); + } + } + } + for (let i = 0; i < right.length; i += 1) { + for (let j = 0; j < right.length; j += 1) { + if (i !== j) { + batchMergeEdges([{ + id: `rg-${edgeIndex++}`, + source: right[i], + target: right[j], + attributes: { edgeType: "co_occurs", weight: 3, properties: {} }, + }]); + } + } + } + batchMergeEdges([{ id: "bridge-g", source: "g1", target: "h1", attributes: { edgeType: "interacts_with", weight: 0.1, properties: {} } }]); + + const { graph: displayGraph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true }); + assert.equal(state.groupedViewAvailable, true); + + const communityEdges = displayGraph.edges().filter((edgeId) => { + const attrs = displayGraph.getEdgeAttributes(edgeId) as { bundleKind?: string }; + return attrs.bundleKind === "community"; + }); + assert.ok(communityEdges.length > 0, "expected at least one community bundle edge"); + + for (const edgeId of communityEdges) { + const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string }; + assert.equal(typeof attrs.edgeType, "string"); + assert.ok((attrs.edgeType ?? "").length > 0, `community edge ${edgeId} must have a non-empty edgeType`); + } +}); + +test("resolveDisplayGraph raw edge preserves exact edgeType string for label rendering", () => { + addNode("src"); + addNode("tgt"); + batchMergeEdges([{ + id: "raw-1", + source: "src", + target: "tgt", + attributes: { edgeType: "works_for", weight: 1, properties: {} }, + }]); + + // In full view without aggregation the edge passes through unchanged. + const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false }); + assert.equal(displayGraph.size, 1); + + const edgeId = displayGraph.edges()[0]; + const attrs = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string }; + assert.equal(attrs.edgeType, "works_for"); +}); + +test("resolveDisplayGraph does not produce empty-string edgeType on aggregated edges when source has empty type", () => { + addNode("a"); + addNode("b"); + // Simulate an API response where type is empty string — the aggregation + // path must not propagate a blank label. + batchMergeEdges([ + { id: "e-empty-1", source: "a", target: "b", attributes: { edgeType: "", weight: 1, properties: {} } }, + { id: "e-empty-2", source: "a", target: "b", attributes: { edgeType: "", weight: 1, properties: {} } }, + ]); + + const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true }); + const edgeId = displayGraph.edges()[0]; + const attrs = displayGraph.getEdgeAttributes(edgeId) as { + edgeType?: string; + isAggregated?: boolean; + }; + assert.equal(attrs.isAggregated, true); + // The aggregation falls back to "related_to" when all source edgeTypes are + // empty, so the rendered label should never be an empty string. + assert.equal(attrs.edgeType, "related_to"); +}); + +test("resolveEdgeElementStyle hidden class produces hidden:true for suppressed edges", () => { + // Verify the data condition the edgeReducer relies on: hidden-classified + // edges must have hidden:true so that the label assignment sets undefined. + const style = resolveEdgeElementStyle( + GRAPH_THEME, + "overview", + "inactive", + { + edgeType: "causes", + weight: 1, + properties: {}, + edgeVariant: "line", + visualPriority: 0.05, + baseSize: 0.3, + }, + "source", + "target", + "full", + "inactive-edge", + "hidden", + ); + assert.equal(style.hidden, true); +});