Add graph label density controls

This commit is contained in:
2026-05-19 00:27:09 +02:00
parent d9c5bf1bd4
commit 98a747dd5a
2 changed files with 78 additions and 3 deletions

View File

@@ -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:
<div class="canvas-wrap">
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
<div class="map-controls" aria-label="Map navigation controls">
<label class="map-control-field">Labels
<select id="label-select" title="Control node label density">
<option value="auto">Auto</option>
<option value="key">Key</option>
<option value="all">All</option>
<option value="none">None</option>
</select>
</label>
<button type="button" data-action="fit" title="Fit graph to view">Fit</button>
<button type="button" data-action="focus" title="Focus selected neighborhood">Focus</button>
<button type="button" data-action="clear" class="primary" title="Reset map controls">Reset</button>
@@ -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]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.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) =>
`<span class="pill"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(layer.color || "#667085")}"></span>${escapeHtml(layer.label)}</span>`
).join("");
);
const statusItems = [
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #b45309;border-radius:50%"></span>Unresolved</span>',
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #be123c;border-radius:3px"></span>Registered only</span>',
'<span class="pill"><span style="display:inline-block;width:18px;border-top:2px dotted #98a2b3"></span>Weak edge</span>',
];
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 = "";