generated from coulomb/repo-seed
Harden graph explorer controls
This commit is contained in:
@@ -27,7 +27,7 @@ def graph_explorer_page() -> str:
|
|||||||
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
|
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: grid;
|
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;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -108,9 +108,18 @@ def graph_explorer_page() -> str:
|
|||||||
<section class="shell">
|
<section class="shell">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label>Search <input id="search" autocomplete="off"></label>
|
<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>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>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>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="fit">Fit</button>
|
||||||
<button type="button" data-action="focus">Focus</button>
|
<button type="button" data-action="focus">Focus</button>
|
||||||
<button type="button" data-action="clear" class="primary">Reset</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>
|
<button type="button" data-action="reset-overrides">Clear Overrides</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="section">
|
||||||
<p id="hidden-summary" class="meta">Hidden 0</p>
|
<p id="hidden-summary" class="meta">Hidden 0</p>
|
||||||
<div id="legend"></div>
|
<div id="legend"></div>
|
||||||
@@ -151,9 +168,13 @@ def graph_explorer_page() -> str:
|
|||||||
const canvas = document.getElementById("graph-canvas");
|
const canvas = document.getElementById("graph-canvas");
|
||||||
const popup = document.getElementById("popup");
|
const popup = document.getElementById("popup");
|
||||||
const searchInput = document.getElementById("search");
|
const searchInput = document.getElementById("search");
|
||||||
|
const modeSelect = document.getElementById("mode-select");
|
||||||
|
const layoutSelect = document.getElementById("layout-select");
|
||||||
const layerFilter = document.getElementById("layer-filter");
|
const layerFilter = document.getElementById("layer-filter");
|
||||||
const reviewFilter = document.getElementById("review-filter");
|
const reviewFilter = document.getElementById("review-filter");
|
||||||
const unresolvedFilter = document.getElementById("unresolved-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 counts = document.getElementById("counts");
|
||||||
const hiddenSummary = document.getElementById("hidden-summary");
|
const hiddenSummary = document.getElementById("hidden-summary");
|
||||||
const detailTitle = document.getElementById("detail-title");
|
const detailTitle = document.getElementById("detail-title");
|
||||||
@@ -166,6 +187,7 @@ def graph_explorer_page() -> str:
|
|||||||
let focusSet = null;
|
let focusSet = null;
|
||||||
let manualOverrides = {};
|
let manualOverrides = {};
|
||||||
let layerColors = {};
|
let layerColors = {};
|
||||||
|
let activeMode = "full";
|
||||||
|
|
||||||
const escapeHtml = (value) => String(value ?? "")
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
.replaceAll("&", "&").replaceAll("<", "<")
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
@@ -178,6 +200,8 @@ def graph_explorer_page() -> str:
|
|||||||
|
|
||||||
const matchesFilters = (element) => {
|
const matchesFilters = (element) => {
|
||||||
const data = element.data();
|
const data = element.data();
|
||||||
|
const modeSet = visibleSetForMode();
|
||||||
|
if (modeSet && !modeSet.has(element.id())) return false;
|
||||||
const text = searchInput.value.trim().toLowerCase();
|
const text = searchInput.value.trim().toLowerCase();
|
||||||
if (text && !elementText(data).includes(text)) return false;
|
if (text && !elementText(data).includes(text)) return false;
|
||||||
if (layerFilter.value && data.layer !== layerFilter.value) return false;
|
if (layerFilter.value && data.layer !== layerFilter.value) return false;
|
||||||
@@ -187,6 +211,30 @@ def graph_explorer_page() -> str:
|
|||||||
return true;
|
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 = () => {
|
const applyFilters = () => {
|
||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
const hiddenNodes = new Set();
|
const hiddenNodes = new Set();
|
||||||
@@ -249,9 +297,21 @@ def graph_explorer_page() -> str:
|
|||||||
if (!cy || !selected) return;
|
if (!cy || !selected) return;
|
||||||
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
|
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
|
||||||
focusSet = new Set(collection.map((item) => item.id()));
|
focusSet = new Set(collection.map((item) => item.id()));
|
||||||
|
modeSelect.value = "neighborhood";
|
||||||
|
activeMode = "neighborhood";
|
||||||
applyFilters();
|
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) => {
|
const renderLegend = (layers) => {
|
||||||
legend.innerHTML = layers.map((layer) =>
|
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>`
|
`<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(manifestUrl).then((response) => response.json()),
|
||||||
fetch(graphUrl).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]));
|
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
||||||
(manifest.layers || []).forEach((layer) => {
|
(manifest.layers || []).forEach((layer) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
@@ -278,7 +351,7 @@ def graph_explorer_page() -> str:
|
|||||||
cy = cytoscape({
|
cy = cytoscape({
|
||||||
container: canvas,
|
container: canvas,
|
||||||
elements,
|
elements,
|
||||||
layout: {name: "cose", animate: false, randomize: false},
|
layout: {name: layoutSelect.value || "cose", animate: false, randomize: false},
|
||||||
style: [
|
style: [
|
||||||
{selector: "node", style: {
|
{selector: "node", style: {
|
||||||
"background-color": "data(color)",
|
"background-color": "data(color)",
|
||||||
@@ -324,15 +397,24 @@ def graph_explorer_page() -> str:
|
|||||||
popup.style.display = "none";
|
popup.style.display = "none";
|
||||||
});
|
});
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
runLayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
|
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
|
||||||
control.addEventListener("input", () => { focusSet = null; applyFilters(); });
|
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='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";
|
||||||
|
activeMode = "full";
|
||||||
layerFilter.value = "";
|
layerFilter.value = "";
|
||||||
reviewFilter.value = "";
|
reviewFilter.value = "";
|
||||||
unresolvedFilter.value = "";
|
unresolvedFilter.value = "";
|
||||||
|
|||||||
@@ -187,9 +187,13 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert payload["metrics"]["registered_only_repo_count"] == 1
|
assert payload["metrics"]["registered_only_repo_count"] == 1
|
||||||
assert content_type.startswith("text/html")
|
assert content_type.startswith("text/html")
|
||||||
assert 'id="graph-canvas"' in page
|
assert 'id="graph-canvas"' in page
|
||||||
|
assert 'id="mode-select"' in page
|
||||||
|
assert 'id="layout-select"' in page
|
||||||
|
assert 'id="profile-select"' in page
|
||||||
assert "cytoscape.min.js" in page
|
assert "cytoscape.min.js" in page
|
||||||
assert "/exports/graph-explorer/manifest" in page
|
assert "/exports/graph-explorer/manifest" in page
|
||||||
assert 'data-override="hide"' in page
|
assert 'data-override="hide"' in page
|
||||||
|
assert 'data-profile-action="save"' in page
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
Reference in New Issue
Block a user