diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index f9bd25f..6ca6e8a 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -121,6 +121,7 @@ def graph_explorer_page() -> str: right: 12px; z-index: 3; display: flex; + align-items: end; gap: 6px; padding: 6px; border: 1px solid var(--line); @@ -129,6 +130,18 @@ def graph_explorer_page() -> str: box-shadow: 0 12px 30px rgba(23, 32, 51, .12); } .map-controls button { min-width: 54px; } + .map-control-field { + display: grid; + gap: 2px; + color: var(--muted); + font-size: 11px; + line-height: 1; + } + .map-control-field select { + min-height: 30px; + min-width: 84px; + padding: 4px 6px; + } .selection-anchor { position: absolute; z-index: 5; @@ -248,6 +261,14 @@ def graph_explorer_page() -> str:
+ @@ -305,6 +326,7 @@ def graph_explorer_page() -> str: const searchInput = document.getElementById("search"); const modeSelect = document.getElementById("mode-select"); const layoutSelect = document.getElementById("layout-select"); + const labelSelect = document.getElementById("label-select"); const nodeTypeFilter = document.getElementById("node-type-filter"); const nodeTypeSummary = document.getElementById("node-type-summary"); const edgeTypeFilter = document.getElementById("edge-type-filter"); @@ -334,6 +356,7 @@ def graph_explorer_page() -> str: let allNodeTypes = []; let allEdgeTypes = []; let activeMode = "full"; + let activeLabelMode = "auto"; let profilePersistence = "none"; let profiles = []; let currentProfileId = ""; @@ -462,10 +485,33 @@ def graph_explorer_page() -> str: selectionAnchor.style.display = "block"; }; + const isKeyLabelNode = (node) => { + const layer = node.data("layer"); + return ( + ["repository", "server", "deployment", "service"].includes(layer) || + node.data("unresolved") === true || + node.data("lifecycle") === "registered-only" + ); + }; + + const updateLabelVisibility = () => { + if (!cy) return; + const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none"); + const mode = activeLabelMode === "auto" && visibleNodes.length > 70 ? "key" : activeLabelMode; + cy.nodes().forEach((node) => { + let showLabel = mode === "all" || mode === "auto"; + if (mode === "key") showLabel = isKeyLabelNode(node); + if (mode === "none") showLabel = false; + if (selected && node.id() === selected.id()) showLabel = false; + node.toggleClass("label-hidden", !showLabel); + }); + }; + const currentViewState = () => ({ search: searchInput.value, mode: modeSelect.value || "full", layout: layoutSelect.value || "cose", + labelMode: labelSelect.value || "auto", nodeTypes: Array.from(selectedNodeTypes()), edgeTypes: Array.from(selectedEdgeTypes()), review: reviewFilter.value, @@ -491,6 +537,7 @@ def graph_explorer_page() -> str: if (params.has("search")) state.search = params.get("search") || ""; if (params.has("mode")) state.mode = params.get("mode") || ""; if (params.has("layout")) state.layout = params.get("layout") || ""; + if (params.has("labelMode")) state.labelMode = params.get("labelMode") || ""; if (params.has("nodeTypes")) state.nodeTypes = parseListParam(params.get("nodeTypes")); if (params.has("edgeTypes")) state.edgeTypes = parseListParam(params.get("edgeTypes")); if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer")); @@ -513,6 +560,7 @@ def graph_explorer_page() -> str: if (state.search) params.set("search", state.search); if (state.mode && state.mode !== "full") params.set("mode", state.mode); if (state.layout && state.layout !== "cose") params.set("layout", state.layout); + if (state.labelMode && state.labelMode !== "auto") params.set("labelMode", state.labelMode); if (state.nodeTypes.length !== allNodeTypes.length) params.set("nodeTypes", state.nodeTypes.join(",")); if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(",")); if (state.review) params.set("review", state.review); @@ -535,6 +583,7 @@ def graph_explorer_page() -> str: if ("search" in state) searchInput.value = state.search || ""; if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full"; if ("layout" in state) layoutSelect.value = optionExists(layoutSelect, state.layout) ? state.layout : "cose"; + if ("labelMode" in state) labelSelect.value = optionExists(labelSelect, state.labelMode) ? state.labelMode : "auto"; if ("nodeTypes" in state) setCheckedValues(nodeTypeFilter, (state.nodeTypes || []).filter((value) => allNodeTypes.includes(value))); if ("layer" in state) setCheckedValues(nodeTypeFilter, allNodeTypes.includes(state.layer) ? [state.layer] : null); if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value))); @@ -544,6 +593,7 @@ def graph_explorer_page() -> str: manualOverrides = {...state.manualOverrides}; } activeMode = modeSelect.value || "full"; + activeLabelMode = labelSelect.value || "auto"; focusSet = null; syncFilterSummaries(); applyFilters(); @@ -711,6 +761,7 @@ def graph_explorer_page() -> str: const hidden = cy.elements().length - visibleNodes - visibleEdges; counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`; hiddenSummary.textContent = `Hidden ${hidden}`; + updateLabelVisibility(); updateSelectionAnchor(); }; @@ -723,6 +774,7 @@ def graph_explorer_page() -> str: detailList.innerHTML = ""; orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo."; orientationList.innerHTML = ""; + updateLabelVisibility(); updateSelectionAnchor(); return; } @@ -749,6 +801,7 @@ def graph_explorer_page() -> str: .map(([key, value]) => `
  • ${escapeHtml(key)} ${escapeHtml(value)}
  • `) .join(""); renderOrientation(element); + updateLabelVisibility(); updateSelectionAnchor(); }; @@ -866,9 +919,15 @@ def graph_explorer_page() -> str: }; const renderLegend = (layers) => { - legend.innerHTML = layers.map((layer) => + const nodeTypes = layers.map((layer) => `${escapeHtml(layer.label)}` - ).join(""); + ); + const statusItems = [ + 'Unresolved', + 'Registered only', + 'Weak edge', + ]; + legend.innerHTML = [...nodeTypes, ...statusItems].join(""); }; const boot = async () => { @@ -949,7 +1008,8 @@ def graph_explorer_page() -> str: "z-index": 2 }}, {selector: ".display-blur", style: {"opacity": .24, "label": ""}}, - {selector: ".display-blur:selected, .display-blur.hover", style: {"opacity": .78, "label": "data(label)"}}, + {selector: ".display-blur.hover", style: {"opacity": .78, "label": "data(label)"}}, + {selector: ".label-hidden", style: {"label": ""}}, {selector: ":selected", style: {"overlay-opacity": 0}} ] }); @@ -1015,12 +1075,23 @@ def graph_explorer_page() -> str: updateProfileSummary(); updateUrlState(); }); + labelSelect.addEventListener("input", () => { + activeLabelMode = labelSelect.value || "auto"; + currentProfileId = ""; + profileSelect.value = ""; + updateLabelVisibility(); + updateProfileControls(); + updateProfileSummary(); + updateUrlState(); + }); document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48)); document.querySelector("[data-action='focus']").addEventListener("click", applyFocus); document.querySelector("[data-action='clear']").addEventListener("click", () => { searchInput.value = ""; modeSelect.value = "full"; activeMode = "full"; + labelSelect.value = "auto"; + activeLabelMode = "auto"; setCheckedValues(nodeTypeFilter); setCheckedValues(edgeTypeFilter); reviewFilter.value = ""; diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 1177a39..ffc62a1 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -206,12 +206,16 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert 'id="graph-canvas"' in page assert 'id="mode-select"' in page assert 'id="layout-select"' in page + assert 'id="label-select"' in page assert 'id="node-type-filter"' in page assert 'id="edge-type-filter"' in page assert 'id="selection-anchor"' in page assert "updateSelectionAnchor" in page + assert "updateLabelVisibility" in page + assert "label-hidden" in page assert "Node Types" in page assert "Edge Types" in page + assert "Registered only" in page assert 'id="layer-filter"' not in page assert '"border-width": 4' not in page assert 'id="profile-select"' in page