generated from coulomb/repo-seed
Add graph label density controls
This commit is contained in:
@@ -121,6 +121,7 @@ def graph_explorer_page() -> str:
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -129,6 +130,18 @@ def graph_explorer_page() -> str:
|
|||||||
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
|
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
|
||||||
}
|
}
|
||||||
.map-controls button { min-width: 54px; }
|
.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 {
|
.selection-anchor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@@ -248,6 +261,14 @@ def graph_explorer_page() -> str:
|
|||||||
<div class="canvas-wrap">
|
<div class="canvas-wrap">
|
||||||
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
|
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
|
||||||
<div class="map-controls" aria-label="Map navigation controls">
|
<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="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="focus" title="Focus selected neighborhood">Focus</button>
|
||||||
<button type="button" data-action="clear" class="primary" title="Reset map controls">Reset</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 searchInput = document.getElementById("search");
|
||||||
const modeSelect = document.getElementById("mode-select");
|
const modeSelect = document.getElementById("mode-select");
|
||||||
const layoutSelect = document.getElementById("layout-select");
|
const layoutSelect = document.getElementById("layout-select");
|
||||||
|
const labelSelect = document.getElementById("label-select");
|
||||||
const nodeTypeFilter = document.getElementById("node-type-filter");
|
const nodeTypeFilter = document.getElementById("node-type-filter");
|
||||||
const nodeTypeSummary = document.getElementById("node-type-summary");
|
const nodeTypeSummary = document.getElementById("node-type-summary");
|
||||||
const edgeTypeFilter = document.getElementById("edge-type-filter");
|
const edgeTypeFilter = document.getElementById("edge-type-filter");
|
||||||
@@ -334,6 +356,7 @@ def graph_explorer_page() -> str:
|
|||||||
let allNodeTypes = [];
|
let allNodeTypes = [];
|
||||||
let allEdgeTypes = [];
|
let allEdgeTypes = [];
|
||||||
let activeMode = "full";
|
let activeMode = "full";
|
||||||
|
let activeLabelMode = "auto";
|
||||||
let profilePersistence = "none";
|
let profilePersistence = "none";
|
||||||
let profiles = [];
|
let profiles = [];
|
||||||
let currentProfileId = "";
|
let currentProfileId = "";
|
||||||
@@ -462,10 +485,33 @@ def graph_explorer_page() -> str:
|
|||||||
selectionAnchor.style.display = "block";
|
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 = () => ({
|
const currentViewState = () => ({
|
||||||
search: searchInput.value,
|
search: searchInput.value,
|
||||||
mode: modeSelect.value || "full",
|
mode: modeSelect.value || "full",
|
||||||
layout: layoutSelect.value || "cose",
|
layout: layoutSelect.value || "cose",
|
||||||
|
labelMode: labelSelect.value || "auto",
|
||||||
nodeTypes: Array.from(selectedNodeTypes()),
|
nodeTypes: Array.from(selectedNodeTypes()),
|
||||||
edgeTypes: Array.from(selectedEdgeTypes()),
|
edgeTypes: Array.from(selectedEdgeTypes()),
|
||||||
review: reviewFilter.value,
|
review: reviewFilter.value,
|
||||||
@@ -491,6 +537,7 @@ def graph_explorer_page() -> str:
|
|||||||
if (params.has("search")) state.search = params.get("search") || "";
|
if (params.has("search")) state.search = params.get("search") || "";
|
||||||
if (params.has("mode")) state.mode = params.get("mode") || "";
|
if (params.has("mode")) state.mode = params.get("mode") || "";
|
||||||
if (params.has("layout")) state.layout = params.get("layout") || "";
|
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("nodeTypes")) state.nodeTypes = parseListParam(params.get("nodeTypes"));
|
||||||
if (params.has("edgeTypes")) state.edgeTypes = parseListParam(params.get("edgeTypes"));
|
if (params.has("edgeTypes")) state.edgeTypes = parseListParam(params.get("edgeTypes"));
|
||||||
if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer"));
|
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.search) params.set("search", state.search);
|
||||||
if (state.mode && state.mode !== "full") params.set("mode", state.mode);
|
if (state.mode && state.mode !== "full") params.set("mode", state.mode);
|
||||||
if (state.layout && state.layout !== "cose") params.set("layout", state.layout);
|
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.nodeTypes.length !== allNodeTypes.length) params.set("nodeTypes", state.nodeTypes.join(","));
|
||||||
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
|
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
|
||||||
if (state.review) params.set("review", state.review);
|
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 ("search" in state) searchInput.value = state.search || "";
|
||||||
if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full";
|
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 ("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 ("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 ("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)));
|
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};
|
manualOverrides = {...state.manualOverrides};
|
||||||
}
|
}
|
||||||
activeMode = modeSelect.value || "full";
|
activeMode = modeSelect.value || "full";
|
||||||
|
activeLabelMode = labelSelect.value || "auto";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
syncFilterSummaries();
|
syncFilterSummaries();
|
||||||
applyFilters();
|
applyFilters();
|
||||||
@@ -711,6 +761,7 @@ def graph_explorer_page() -> str:
|
|||||||
const hidden = cy.elements().length - visibleNodes - visibleEdges;
|
const hidden = cy.elements().length - visibleNodes - visibleEdges;
|
||||||
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
||||||
hiddenSummary.textContent = `Hidden ${hidden}`;
|
hiddenSummary.textContent = `Hidden ${hidden}`;
|
||||||
|
updateLabelVisibility();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -723,6 +774,7 @@ def graph_explorer_page() -> str:
|
|||||||
detailList.innerHTML = "";
|
detailList.innerHTML = "";
|
||||||
orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo.";
|
orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo.";
|
||||||
orientationList.innerHTML = "";
|
orientationList.innerHTML = "";
|
||||||
|
updateLabelVisibility();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -749,6 +801,7 @@ def graph_explorer_page() -> str:
|
|||||||
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
||||||
.join("");
|
.join("");
|
||||||
renderOrientation(element);
|
renderOrientation(element);
|
||||||
|
updateLabelVisibility();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -866,9 +919,15 @@ def graph_explorer_page() -> str:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderLegend = (layers) => {
|
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>`
|
`<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 () => {
|
const boot = async () => {
|
||||||
@@ -949,7 +1008,8 @@ def graph_explorer_page() -> str:
|
|||||||
"z-index": 2
|
"z-index": 2
|
||||||
}},
|
}},
|
||||||
{selector: ".display-blur", style: {"opacity": .24, "label": ""}},
|
{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}}
|
{selector: ":selected", style: {"overlay-opacity": 0}}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -1015,12 +1075,23 @@ def graph_explorer_page() -> str:
|
|||||||
updateProfileSummary();
|
updateProfileSummary();
|
||||||
updateUrlState();
|
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='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
||||||
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
||||||
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
||||||
searchInput.value = "";
|
searchInput.value = "";
|
||||||
modeSelect.value = "full";
|
modeSelect.value = "full";
|
||||||
activeMode = "full";
|
activeMode = "full";
|
||||||
|
labelSelect.value = "auto";
|
||||||
|
activeLabelMode = "auto";
|
||||||
setCheckedValues(nodeTypeFilter);
|
setCheckedValues(nodeTypeFilter);
|
||||||
setCheckedValues(edgeTypeFilter);
|
setCheckedValues(edgeTypeFilter);
|
||||||
reviewFilter.value = "";
|
reviewFilter.value = "";
|
||||||
|
|||||||
@@ -206,12 +206,16 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert 'id="graph-canvas"' in page
|
assert 'id="graph-canvas"' in page
|
||||||
assert 'id="mode-select"' in page
|
assert 'id="mode-select"' in page
|
||||||
assert 'id="layout-select"' in page
|
assert 'id="layout-select"' in page
|
||||||
|
assert 'id="label-select"' in page
|
||||||
assert 'id="node-type-filter"' in page
|
assert 'id="node-type-filter"' in page
|
||||||
assert 'id="edge-type-filter"' in page
|
assert 'id="edge-type-filter"' in page
|
||||||
assert 'id="selection-anchor"' in page
|
assert 'id="selection-anchor"' in page
|
||||||
assert "updateSelectionAnchor" in page
|
assert "updateSelectionAnchor" in page
|
||||||
|
assert "updateLabelVisibility" in page
|
||||||
|
assert "label-hidden" in page
|
||||||
assert "Node Types" in page
|
assert "Node Types" in page
|
||||||
assert "Edge Types" in page
|
assert "Edge Types" in page
|
||||||
|
assert "Registered only" in page
|
||||||
assert 'id="layer-filter"' not in page
|
assert 'id="layer-filter"' not in page
|
||||||
assert '"border-width": 4' not in page
|
assert '"border-width": 4' not in page
|
||||||
assert 'id="profile-select"' in page
|
assert 'id="profile-select"' in page
|
||||||
|
|||||||
Reference in New Issue
Block a user