generated from coulomb/repo-seed
Complete graph orientation workflows
This commit is contained in:
@@ -34,6 +34,13 @@ The manifest tells a graph shell where to load data, which fields are stable,
|
|||||||
which layers exist, which filter fields are available, and which modes the host
|
which layers exist, which filter fields are available, and which modes the host
|
||||||
supports.
|
supports.
|
||||||
|
|
||||||
|
Fabric currently declares `profile_persistence: local`. That means the shell
|
||||||
|
stores named map views in browser `localStorage`, supports duplicate/delete
|
||||||
|
inside that browser, and can copy a URL with the current query parameters and a
|
||||||
|
state blob. Local profile ids can be reopened in the same browser profile; the
|
||||||
|
copied state blob is the portable sharing path until a host-backed profile API
|
||||||
|
is added.
|
||||||
|
|
||||||
The payload is compatible with Cytoscape-style element arrays:
|
The payload is compatible with Cytoscape-style element arrays:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -140,13 +147,13 @@ The extracted `graph-explorer-engine` should own:
|
|||||||
- filter and manual override UI
|
- filter and manual override UI
|
||||||
- hover popups and selection detail panels
|
- hover popups and selection detail panels
|
||||||
- profile UI when the host declares profile endpoints
|
- profile UI when the host declares profile endpoints
|
||||||
- URL state and copied state blobs
|
- browser-local profiles, URL state, and copied state blobs
|
||||||
- schema definitions and compatibility tests
|
- schema definitions and compatibility tests
|
||||||
|
|
||||||
Host repos should own:
|
Host repos should own:
|
||||||
|
|
||||||
- graph projection and metadata enrichment
|
- graph projection and metadata enrichment
|
||||||
- profile persistence
|
- host-side profile persistence, when a repo needs shared/team profiles
|
||||||
- authentication and authorization
|
- authentication and authorization
|
||||||
- domain-specific graph modes
|
- domain-specific graph modes
|
||||||
- deep links back to source systems
|
- deep links back to source systems
|
||||||
|
|||||||
@@ -175,10 +175,10 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
|||||||
"description": "Highlight dependencies that have no accepted provider binding.",
|
"description": "Highlight dependencies that have no accepted provider binding.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"profile_persistence": "none",
|
"profile_persistence": "local",
|
||||||
"shareable_state": {
|
"shareable_state": {
|
||||||
"url_parameters": True,
|
"url_parameters": True,
|
||||||
"profile_id": False,
|
"profile_id": True,
|
||||||
"state_blob": True,
|
"state_blob": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,10 +151,12 @@ def graph_explorer_page() -> str:
|
|||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
|
<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">
|
<div class="button-row">
|
||||||
<button type="button" data-profile-action="save" disabled>Save</button>
|
<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="duplicate" disabled>Duplicate</button>
|
||||||
<button type="button" data-profile-action="delete" disabled>Delete</button>
|
<button type="button" data-profile-action="delete" disabled>Delete</button>
|
||||||
|
<button type="button" data-profile-action="copy" disabled>Copy State</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -178,6 +180,7 @@ def graph_explorer_page() -> str:
|
|||||||
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 profileSelect = document.getElementById("profile-select");
|
||||||
|
const profileNameInput = document.getElementById("profile-name");
|
||||||
const profileSummary = document.getElementById("profile-summary");
|
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");
|
||||||
@@ -188,12 +191,16 @@ def graph_explorer_page() -> str:
|
|||||||
const orientationTitle = document.getElementById("orientation-title");
|
const orientationTitle = document.getElementById("orientation-title");
|
||||||
const orientationList = document.getElementById("orientation-list");
|
const orientationList = document.getElementById("orientation-list");
|
||||||
const legend = document.getElementById("legend");
|
const legend = document.getElementById("legend");
|
||||||
|
const profileStorageKey = "railiance.fabric.graphExplorer.profiles";
|
||||||
let cy = null;
|
let cy = null;
|
||||||
let selected = null;
|
let selected = null;
|
||||||
let focusSet = null;
|
let focusSet = null;
|
||||||
let manualOverrides = {};
|
let manualOverrides = {};
|
||||||
let layerColors = {};
|
let layerColors = {};
|
||||||
let activeMode = "full";
|
let activeMode = "full";
|
||||||
|
let profilePersistence = "none";
|
||||||
|
let profiles = [];
|
||||||
|
let currentProfileId = "";
|
||||||
|
|
||||||
const escapeHtml = (value) => String(value ?? "")
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
.replaceAll("&", "&").replaceAll("<", "<")
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
@@ -204,6 +211,192 @@ def graph_explorer_page() -> str:
|
|||||||
data.repo, data.domain, data.kind, data.layer, data.edgeType
|
data.repo, data.domain, data.kind, data.layer, data.edgeType
|
||||||
].join(" ").toLowerCase();
|
].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 matchesFilters = (element) => {
|
||||||
const data = element.data();
|
const data = element.data();
|
||||||
const modeSet = visibleSetForMode();
|
const modeSet = visibleSetForMode();
|
||||||
@@ -246,7 +439,8 @@ def graph_explorer_page() -> str:
|
|||||||
const hiddenNodes = new Set();
|
const hiddenNodes = new Set();
|
||||||
cy.nodes().forEach((node) => {
|
cy.nodes().forEach((node) => {
|
||||||
let state = matchesFilters(node) ? "show" : "hide";
|
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.data("displayState", state);
|
||||||
node.toggleClass("display-blur", state === "blur");
|
node.toggleClass("display-blur", state === "blur");
|
||||||
node.style("display", state === "hide" ? "none" : "element");
|
node.style("display", state === "hide" ? "none" : "element");
|
||||||
@@ -255,7 +449,8 @@ def graph_explorer_page() -> str:
|
|||||||
cy.edges().forEach((edge) => {
|
cy.edges().forEach((edge) => {
|
||||||
let state = matchesFilters(edge) ? "show" : "hide";
|
let state = matchesFilters(edge) ? "show" : "hide";
|
||||||
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "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.data("displayState", state);
|
||||||
edge.toggleClass("display-blur", state === "blur");
|
edge.toggleClass("display-blur", state === "blur");
|
||||||
edge.style("display", state === "hide" ? "none" : "element");
|
edge.style("display", state === "hide" ? "none" : "element");
|
||||||
@@ -379,7 +574,12 @@ def graph_explorer_page() -> str:
|
|||||||
focusSet = new Set(collection.map((item) => item.id()));
|
focusSet = new Set(collection.map((item) => item.id()));
|
||||||
modeSelect.value = "neighborhood";
|
modeSelect.value = "neighborhood";
|
||||||
activeMode = "neighborhood";
|
activeMode = "neighborhood";
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
updateProfileControls();
|
||||||
|
updateProfileSummary();
|
||||||
|
updateUrlState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const runLayout = () => {
|
const runLayout = () => {
|
||||||
@@ -409,13 +609,7 @@ def graph_explorer_page() -> str:
|
|||||||
option.textContent = mode.label;
|
option.textContent = mode.label;
|
||||||
modeSelect.appendChild(option);
|
modeSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
profileSelect.disabled = manifest.profile_persistence !== "host";
|
profilePersistence = manifest.profile_persistence || "none";
|
||||||
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");
|
||||||
@@ -424,6 +618,8 @@ def graph_explorer_page() -> str:
|
|||||||
layerFilter.appendChild(option);
|
layerFilter.appendChild(option);
|
||||||
});
|
});
|
||||||
renderLegend(manifest.layers || []);
|
renderLegend(manifest.layers || []);
|
||||||
|
profiles = loadProfiles();
|
||||||
|
renderProfiles();
|
||||||
const elements = (payload.elements || []).map((element) => ({
|
const elements = (payload.elements || []).map((element) => ({
|
||||||
...element,
|
...element,
|
||||||
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
|
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
|
||||||
@@ -476,19 +672,48 @@ def graph_explorer_page() -> str:
|
|||||||
event.target.removeClass("hover");
|
event.target.removeClass("hover");
|
||||||
popup.style.display = "none";
|
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();
|
runLayout();
|
||||||
|
updateUrlState();
|
||||||
};
|
};
|
||||||
|
|
||||||
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
|
[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", () => {
|
modeSelect.addEventListener("input", () => {
|
||||||
activeMode = modeSelect.value || "full";
|
activeMode = modeSelect.value || "full";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
applyFilters();
|
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='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", () => {
|
||||||
@@ -499,17 +724,65 @@ def graph_explorer_page() -> str:
|
|||||||
reviewFilter.value = "";
|
reviewFilter.value = "";
|
||||||
unresolvedFilter.value = "";
|
unresolvedFilter.value = "";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
|
manualOverrides = {};
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
updateProfileControls();
|
||||||
|
updateProfileSummary();
|
||||||
|
updateUrlState();
|
||||||
});
|
});
|
||||||
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
|
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
|
||||||
manualOverrides = {};
|
manualOverrides = {};
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
updateProfileControls();
|
||||||
|
updateProfileSummary();
|
||||||
|
updateUrlState();
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-override]").forEach((button) => {
|
document.querySelectorAll("[data-override]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
manualOverrides[selected.id()] = button.dataset.override;
|
manualOverrides[overrideKey(selected)] = button.dataset.override;
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
applyFilters();
|
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) {
|
if (!window.cytoscape) {
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ properties:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- none
|
- none
|
||||||
|
- local
|
||||||
- host
|
- host
|
||||||
shareable_state:
|
shareable_state:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ def test_graph_explorer_manifest_and_payload_validate() -> None:
|
|||||||
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
|
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
|
||||||
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
|
|
||||||
|
assert manifest["profile_persistence"] == "local"
|
||||||
|
assert manifest["shareable_state"]["profile_id"] is True
|
||||||
nodes = [element for element in payload["elements"] if "source" not in element["data"]]
|
nodes = [element for element in payload["elements"] if "source" not in element["data"]]
|
||||||
edges = [element for element in payload["elements"] if "source" in element["data"]]
|
edges = [element for element in payload["elements"] if "source" in element["data"]]
|
||||||
registered_only = next(
|
registered_only = next(
|
||||||
@@ -190,6 +192,7 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert 'id="mode-select"' in page
|
assert 'id="mode-select"' in page
|
||||||
assert 'id="layout-select"' in page
|
assert 'id="layout-select"' in page
|
||||||
assert 'id="profile-select"' in page
|
assert 'id="profile-select"' in page
|
||||||
|
assert 'id="profile-name"' in page
|
||||||
assert 'id="orientation-list"' in page
|
assert 'id="orientation-list"' in page
|
||||||
assert "Interface consumers" in page
|
assert "Interface consumers" in page
|
||||||
assert "Dependency path" in page
|
assert "Dependency path" in page
|
||||||
@@ -197,6 +200,9 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
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
|
assert 'data-profile-action="save"' in page
|
||||||
|
assert 'data-profile-action="copy"' in page
|
||||||
|
assert "railiance.fabric.graphExplorer.profiles" in page
|
||||||
|
assert "URLSearchParams" in page
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ Acceptance notes:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0008-T05
|
id: RAIL-FAB-WP-0008-T05
|
||||||
status: in_progress
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67"
|
state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user