Harden graph explorer controls

This commit is contained in:
2026-05-18 18:31:25 +02:00
parent eea1156fd6
commit 8a31258785
2 changed files with 88 additions and 2 deletions

View File

@@ -27,7 +27,7 @@ def graph_explorer_page() -> str:
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
.toolbar {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(120px, .6fr)) auto auto auto;
grid-template-columns: minmax(180px, 1.2fr) repeat(6, minmax(110px, .55fr)) auto auto auto;
gap: 8px;
align-items: center;
padding: 10px 12px;
@@ -108,9 +108,18 @@ def graph_explorer_page() -> str:
<section class="shell">
<div class="toolbar">
<label>Search <input id="search" autocomplete="off"></label>
<label>Mode <select id="mode-select"></select></label>
<label>Layout <select id="layout-select">
<option value="cose">Cose</option>
<option value="concentric">Concentric</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
<option value="breadthfirst">Breadthfirst</option>
</select></label>
<label>Layer <select id="layer-filter"><option value="">Any</option></select></label>
<label>Review <select id="review-filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select></label>
<label>Unresolved <select id="unresolved-filter"><option value="">Any</option><option value="true">Only</option></select></label>
<label>Profile <select id="profile-select" disabled><option>Unsaved exploration</option></select></label>
<button type="button" data-action="fit">Fit</button>
<button type="button" data-action="focus">Focus</button>
<button type="button" data-action="clear" class="primary">Reset</button>
@@ -136,6 +145,14 @@ def graph_explorer_page() -> str:
<button type="button" data-action="reset-overrides">Clear Overrides</button>
</div>
</section>
<section class="section">
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
<div class="button-row">
<button type="button" data-profile-action="save" disabled>Save</button>
<button type="button" data-profile-action="duplicate" disabled>Duplicate</button>
<button type="button" data-profile-action="delete" disabled>Delete</button>
</div>
</section>
<section class="section">
<p id="hidden-summary" class="meta">Hidden 0</p>
<div id="legend"></div>
@@ -151,9 +168,13 @@ def graph_explorer_page() -> str:
const canvas = document.getElementById("graph-canvas");
const popup = document.getElementById("popup");
const searchInput = document.getElementById("search");
const modeSelect = document.getElementById("mode-select");
const layoutSelect = document.getElementById("layout-select");
const layerFilter = document.getElementById("layer-filter");
const reviewFilter = document.getElementById("review-filter");
const unresolvedFilter = document.getElementById("unresolved-filter");
const profileSelect = document.getElementById("profile-select");
const profileSummary = document.getElementById("profile-summary");
const counts = document.getElementById("counts");
const hiddenSummary = document.getElementById("hidden-summary");
const detailTitle = document.getElementById("detail-title");
@@ -166,6 +187,7 @@ def graph_explorer_page() -> str:
let focusSet = null;
let manualOverrides = {};
let layerColors = {};
let activeMode = "full";
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
@@ -178,6 +200,8 @@ def graph_explorer_page() -> str:
const matchesFilters = (element) => {
const data = element.data();
const modeSet = visibleSetForMode();
if (modeSet && !modeSet.has(element.id())) return false;
const text = searchInput.value.trim().toLowerCase();
if (text && !elementText(data).includes(text)) return false;
if (layerFilter.value && data.layer !== layerFilter.value) return false;
@@ -187,6 +211,30 @@ def graph_explorer_page() -> str:
return true;
};
const visibleSetForMode = () => {
if (!cy) return null;
if (focusSet) return focusSet;
if (activeMode === "full") return null;
if (activeMode === "onboarding-gaps") {
return new Set(cy.elements().filter((element) =>
element.data("lifecycle") === "registered-only" || element.data("unresolved") === true
).map((element) => element.id()));
}
if (activeMode === "unresolved") {
return new Set(cy.elements().filter((element) => element.data("unresolved") === true).map((element) => element.id()));
}
if (!selected) return null;
if (activeMode === "selected-path") {
const collection = selected.union(selected.predecessors()).union(selected.successors());
return new Set(collection.map((element) => element.id()));
}
if (activeMode === "neighborhood") {
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
return new Set(collection.map((element) => element.id()));
}
return null;
};
const applyFilters = () => {
if (!cy) return;
const hiddenNodes = new Set();
@@ -249,9 +297,21 @@ def graph_explorer_page() -> str:
if (!cy || !selected) return;
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
focusSet = new Set(collection.map((item) => item.id()));
modeSelect.value = "neighborhood";
activeMode = "neighborhood";
applyFilters();
};
const runLayout = () => {
if (!cy) return;
cy.elements().stop();
const name = layoutSelect.value || "cose";
const options = name === "breadthfirst"
? {name, directed: true, padding: 48, animate: false}
: {name, padding: 48, animate: false};
cy.layout(options).run();
};
const renderLegend = (layers) => {
legend.innerHTML = 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>`
@@ -263,6 +323,19 @@ def graph_explorer_page() -> str:
fetch(manifestUrl).then((response) => response.json()),
fetch(graphUrl).then((response) => response.json())
]);
(manifest.modes || [{id: "full", label: "Full"}]).forEach((mode) => {
const option = document.createElement("option");
option.value = mode.id;
option.textContent = mode.label;
modeSelect.appendChild(option);
});
profileSelect.disabled = manifest.profile_persistence !== "host";
document.querySelectorAll("[data-profile-action]").forEach((button) => {
button.disabled = manifest.profile_persistence !== "host";
});
profileSummary.textContent = manifest.profile_persistence === "host"
? "Profiles are persisted by this host."
: "Profile persistence unavailable for this host; use filters and overrides for local exploration.";
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
(manifest.layers || []).forEach((layer) => {
const option = document.createElement("option");
@@ -278,7 +351,7 @@ def graph_explorer_page() -> str:
cy = cytoscape({
container: canvas,
elements,
layout: {name: "cose", animate: false, randomize: false},
layout: {name: layoutSelect.value || "cose", animate: false, randomize: false},
style: [
{selector: "node", style: {
"background-color": "data(color)",
@@ -324,15 +397,24 @@ def graph_explorer_page() -> str:
popup.style.display = "none";
});
applyFilters();
runLayout();
};
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
control.addEventListener("input", () => { focusSet = null; applyFilters(); });
});
modeSelect.addEventListener("input", () => {
activeMode = modeSelect.value || "full";
focusSet = null;
applyFilters();
});
layoutSelect.addEventListener("input", runLayout);
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";
layerFilter.value = "";
reviewFilter.value = "";
unresolvedFilter.value = "";