|
|
|
|
@@ -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) {
|
|
|
|
|
|