generated from coulomb/repo-seed
Complete graph orientation workflows
This commit is contained in:
@@ -151,10 +151,12 @@ def graph_explorer_page() -> str:
|
||||
</section>
|
||||
<section class="section">
|
||||
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
|
||||
<label>Profile Name <input id="profile-name" autocomplete="off" disabled></label>
|
||||
<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>
|
||||
<button type="button" data-profile-action="copy" disabled>Copy State</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
@@ -178,6 +180,7 @@ def graph_explorer_page() -> str:
|
||||
const reviewFilter = document.getElementById("review-filter");
|
||||
const unresolvedFilter = document.getElementById("unresolved-filter");
|
||||
const profileSelect = document.getElementById("profile-select");
|
||||
const profileNameInput = document.getElementById("profile-name");
|
||||
const profileSummary = document.getElementById("profile-summary");
|
||||
const counts = document.getElementById("counts");
|
||||
const hiddenSummary = document.getElementById("hidden-summary");
|
||||
@@ -188,12 +191,16 @@ def graph_explorer_page() -> str:
|
||||
const orientationTitle = document.getElementById("orientation-title");
|
||||
const orientationList = document.getElementById("orientation-list");
|
||||
const legend = document.getElementById("legend");
|
||||
const profileStorageKey = "railiance.fabric.graphExplorer.profiles";
|
||||
let cy = null;
|
||||
let selected = null;
|
||||
let focusSet = null;
|
||||
let manualOverrides = {};
|
||||
let layerColors = {};
|
||||
let activeMode = "full";
|
||||
let profilePersistence = "none";
|
||||
let profiles = [];
|
||||
let currentProfileId = "";
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
@@ -204,6 +211,192 @@ def graph_explorer_page() -> str:
|
||||
data.repo, data.domain, data.kind, data.layer, data.edgeType
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
const overrideKey = (element) => element.data("stableKey") || element.id();
|
||||
|
||||
const manualOverrideFor = (element) =>
|
||||
manualOverrides[overrideKey(element)] || manualOverrides[element.id()];
|
||||
|
||||
const hasManualOverrides = () => Object.keys(manualOverrides).length > 0;
|
||||
|
||||
const supportsLocalProfiles = () => profilePersistence === "local";
|
||||
|
||||
const currentViewState = () => ({
|
||||
search: searchInput.value,
|
||||
mode: modeSelect.value || "full",
|
||||
layout: layoutSelect.value || "cose",
|
||||
layer: layerFilter.value,
|
||||
review: reviewFilter.value,
|
||||
unresolved: unresolvedFilter.value,
|
||||
manualOverrides: {...manualOverrides},
|
||||
});
|
||||
|
||||
const encodeStateBlob = (state) => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const decodeStateBlob = (blob) => {
|
||||
const bytes = Uint8Array.from(atob(blob), (char) => char.charCodeAt(0));
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
};
|
||||
|
||||
const readUrlState = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = {};
|
||||
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("layer")) state.layer = params.get("layer") || "";
|
||||
if (params.has("review")) state.review = params.get("review") || "";
|
||||
if (params.has("unresolved")) state.unresolved = params.get("unresolved") || "";
|
||||
if (params.has("profile")) state.profile = params.get("profile") || "";
|
||||
if (params.has("state")) {
|
||||
try {
|
||||
Object.assign(state, decodeStateBlob(params.get("state") || ""));
|
||||
} catch (error) {
|
||||
profileSummary.textContent = `Could not load copied map state: ${error.message}`;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const viewUrl = (includeStateBlob = false) => {
|
||||
const params = new URLSearchParams();
|
||||
const state = currentViewState();
|
||||
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.layer) params.set("layer", state.layer);
|
||||
if (state.review) params.set("review", state.review);
|
||||
if (state.unresolved) params.set("unresolved", state.unresolved);
|
||||
if (currentProfileId) params.set("profile", currentProfileId);
|
||||
if (includeStateBlob || hasManualOverrides()) params.set("state", encodeStateBlob(state));
|
||||
const query = params.toString();
|
||||
return `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
|
||||
};
|
||||
|
||||
const updateUrlState = () => {
|
||||
if (!window.history || !window.history.replaceState) return;
|
||||
window.history.replaceState({}, "", viewUrl(false));
|
||||
};
|
||||
|
||||
const optionExists = (select, value) =>
|
||||
Array.from(select.options).some((option) => option.value === value);
|
||||
|
||||
const applyViewState = (state, options = {}) => {
|
||||
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 ("layer" in state) layerFilter.value = optionExists(layerFilter, state.layer) ? state.layer : "";
|
||||
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
|
||||
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
|
||||
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
||||
manualOverrides = {...state.manualOverrides};
|
||||
}
|
||||
activeMode = modeSelect.value || "full";
|
||||
focusSet = null;
|
||||
applyFilters();
|
||||
if (!options.skipLayout) runLayout();
|
||||
if (!options.skipUrl) updateUrlState();
|
||||
};
|
||||
|
||||
const loadProfiles = () => {
|
||||
if (!supportsLocalProfiles()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(profileStorageKey) || "[]");
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((profile) => profile && typeof profile.id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const persistProfiles = () => {
|
||||
if (!supportsLocalProfiles()) return;
|
||||
window.localStorage.setItem(profileStorageKey, JSON.stringify(profiles));
|
||||
};
|
||||
|
||||
const selectedProfile = () => profiles.find((profile) => profile.id === currentProfileId) || null;
|
||||
|
||||
const updateProfileSummary = (message = "") => {
|
||||
if (!supportsLocalProfiles()) {
|
||||
profileSummary.textContent = "Profile persistence unavailable for this host.";
|
||||
return;
|
||||
}
|
||||
const profile = selectedProfile();
|
||||
const prefix = profile ? `Loaded "${profile.name}".` : "Unsaved exploration.";
|
||||
profileSummary.textContent = message || `${prefix} ${profiles.length} saved profile${profiles.length === 1 ? "" : "s"}.`;
|
||||
};
|
||||
|
||||
const updateProfileControls = () => {
|
||||
const enabled = supportsLocalProfiles();
|
||||
profileSelect.disabled = !enabled;
|
||||
profileNameInput.disabled = !enabled;
|
||||
document.querySelectorAll("[data-profile-action]").forEach((button) => {
|
||||
const action = button.dataset.profileAction;
|
||||
button.disabled = !enabled || ((action === "duplicate" || action === "delete") && !currentProfileId);
|
||||
});
|
||||
};
|
||||
|
||||
const renderProfiles = () => {
|
||||
profileSelect.innerHTML = "";
|
||||
const unsaved = document.createElement("option");
|
||||
unsaved.value = "";
|
||||
unsaved.textContent = "Unsaved exploration";
|
||||
profileSelect.appendChild(unsaved);
|
||||
profiles
|
||||
.slice()
|
||||
.sort((left, right) => String(left.name).localeCompare(String(right.name)))
|
||||
.forEach((profile) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name || profile.id;
|
||||
profileSelect.appendChild(option);
|
||||
});
|
||||
if (!profiles.some((profile) => profile.id === currentProfileId)) currentProfileId = "";
|
||||
profileSelect.value = currentProfileId;
|
||||
const profile = selectedProfile();
|
||||
profileNameInput.value = profile ? profile.name : "";
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
};
|
||||
|
||||
const saveCurrentProfile = (name, sourceProfile = null) => {
|
||||
const now = new Date().toISOString();
|
||||
const profileName = name.trim() || sourceProfile?.name || `Fabric view ${profiles.length + 1}`;
|
||||
if (sourceProfile) {
|
||||
const profile = {
|
||||
...sourceProfile,
|
||||
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: profileName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
profiles = [...profiles, profile];
|
||||
currentProfileId = profile.id;
|
||||
} else if (currentProfileId) {
|
||||
profiles = profiles.map((profile) => profile.id === currentProfileId
|
||||
? {...profile, name: profileName, state: currentViewState(), updatedAt: now}
|
||||
: profile);
|
||||
} else {
|
||||
const profile = {
|
||||
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: profileName,
|
||||
state: currentViewState(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
profiles = [...profiles, profile];
|
||||
currentProfileId = profile.id;
|
||||
}
|
||||
persistProfiles();
|
||||
renderProfiles();
|
||||
updateUrlState();
|
||||
};
|
||||
|
||||
const matchesFilters = (element) => {
|
||||
const data = element.data();
|
||||
const modeSet = visibleSetForMode();
|
||||
@@ -246,7 +439,8 @@ def graph_explorer_page() -> str:
|
||||
const hiddenNodes = new Set();
|
||||
cy.nodes().forEach((node) => {
|
||||
let state = matchesFilters(node) ? "show" : "hide";
|
||||
if (manualOverrides[node.id()]) state = manualOverrides[node.id()];
|
||||
const override = manualOverrideFor(node);
|
||||
if (override) state = override;
|
||||
node.data("displayState", state);
|
||||
node.toggleClass("display-blur", state === "blur");
|
||||
node.style("display", state === "hide" ? "none" : "element");
|
||||
@@ -255,7 +449,8 @@ def graph_explorer_page() -> str:
|
||||
cy.edges().forEach((edge) => {
|
||||
let state = matchesFilters(edge) ? "show" : "hide";
|
||||
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
|
||||
if (manualOverrides[edge.id()]) state = manualOverrides[edge.id()];
|
||||
const override = manualOverrideFor(edge);
|
||||
if (override) state = override;
|
||||
edge.data("displayState", state);
|
||||
edge.toggleClass("display-blur", state === "blur");
|
||||
edge.style("display", state === "hide" ? "none" : "element");
|
||||
@@ -379,7 +574,12 @@ def graph_explorer_page() -> str:
|
||||
focusSet = new Set(collection.map((item) => item.id()));
|
||||
modeSelect.value = "neighborhood";
|
||||
activeMode = "neighborhood";
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
};
|
||||
|
||||
const runLayout = () => {
|
||||
@@ -409,13 +609,7 @@ def graph_explorer_page() -> str:
|
||||
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.";
|
||||
profilePersistence = manifest.profile_persistence || "none";
|
||||
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
||||
(manifest.layers || []).forEach((layer) => {
|
||||
const option = document.createElement("option");
|
||||
@@ -424,6 +618,8 @@ def graph_explorer_page() -> str:
|
||||
layerFilter.appendChild(option);
|
||||
});
|
||||
renderLegend(manifest.layers || []);
|
||||
profiles = loadProfiles();
|
||||
renderProfiles();
|
||||
const elements = (payload.elements || []).map((element) => ({
|
||||
...element,
|
||||
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
|
||||
@@ -476,19 +672,48 @@ def graph_explorer_page() -> str:
|
||||
event.target.removeClass("hover");
|
||||
popup.style.display = "none";
|
||||
});
|
||||
applyFilters();
|
||||
const urlState = readUrlState();
|
||||
if (urlState.profile && profiles.some((profile) => profile.id === urlState.profile)) {
|
||||
currentProfileId = urlState.profile;
|
||||
profileSelect.value = currentProfileId;
|
||||
const profile = selectedProfile();
|
||||
profileNameInput.value = profile ? profile.name : "";
|
||||
if (profile) applyViewState(profile.state || {}, {skipLayout: true, skipUrl: true});
|
||||
}
|
||||
applyViewState(urlState, {skipUrl: true});
|
||||
runLayout();
|
||||
updateUrlState();
|
||||
};
|
||||
|
||||
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
|
||||
control.addEventListener("input", () => { focusSet = null; applyFilters(); });
|
||||
control.addEventListener("input", () => {
|
||||
focusSet = null;
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
});
|
||||
modeSelect.addEventListener("input", () => {
|
||||
activeMode = modeSelect.value || "full";
|
||||
focusSet = null;
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
layoutSelect.addEventListener("input", () => {
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
runLayout();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
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", () => {
|
||||
@@ -499,17 +724,65 @@ def graph_explorer_page() -> str:
|
||||
reviewFilter.value = "";
|
||||
unresolvedFilter.value = "";
|
||||
focusSet = null;
|
||||
manualOverrides = {};
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
|
||||
manualOverrides = {};
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
document.querySelectorAll("[data-override]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (!selected) return;
|
||||
manualOverrides[selected.id()] = button.dataset.override;
|
||||
manualOverrides[overrideKey(selected)] = button.dataset.override;
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
});
|
||||
profileSelect.addEventListener("input", () => {
|
||||
currentProfileId = profileSelect.value;
|
||||
const profile = selectedProfile();
|
||||
profileNameInput.value = profile ? profile.name : "";
|
||||
if (profile) applyViewState(profile.state || {});
|
||||
if (!profile) updateUrlState();
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
});
|
||||
document.querySelectorAll("[data-profile-action]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
if (!supportsLocalProfiles()) return;
|
||||
const action = button.dataset.profileAction;
|
||||
if (action === "save") {
|
||||
saveCurrentProfile(profileNameInput.value);
|
||||
} else if (action === "duplicate") {
|
||||
const profile = selectedProfile();
|
||||
if (!profile) return;
|
||||
saveCurrentProfile(`Copy of ${profile.name}`, {...profile, state: currentViewState()});
|
||||
} else if (action === "delete") {
|
||||
profiles = profiles.filter((profile) => profile.id !== currentProfileId);
|
||||
currentProfileId = "";
|
||||
persistProfiles();
|
||||
renderProfiles();
|
||||
updateUrlState();
|
||||
} else if (action === "copy") {
|
||||
const url = `${window.location.origin}${viewUrl(true)}`;
|
||||
if (window.navigator?.clipboard?.writeText) await window.navigator.clipboard.writeText(url);
|
||||
updateProfileSummary("Copied the current map state URL.");
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!window.cytoscape) {
|
||||
|
||||
Reference in New Issue
Block a user