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; }
|
||||
.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("&", "&").replaceAll("<", "<")
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user