Complete graph orientation workflows

This commit is contained in:
2026-05-18 20:14:07 +02:00
parent 43ed6e69e0
commit 55db37b1ca
6 changed files with 305 additions and 18 deletions

View File

@@ -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
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:
```json
@@ -140,13 +147,13 @@ The extracted `graph-explorer-engine` should own:
- filter and manual override UI
- hover popups and selection detail panels
- 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
Host repos should own:
- graph projection and metadata enrichment
- profile persistence
- host-side profile persistence, when a repo needs shared/team profiles
- authentication and authorization
- domain-specific graph modes
- deep links back to source systems

View File

@@ -175,10 +175,10 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
"description": "Highlight dependencies that have no accepted provider binding.",
},
],
"profile_persistence": "none",
"profile_persistence": "local",
"shareable_state": {
"url_parameters": True,
"profile_id": False,
"profile_id": True,
"state_blob": True,
},
}

View File

@@ -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("&", "&amp;").replaceAll("<", "&lt;")
@@ -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) {

View File

@@ -195,6 +195,7 @@ properties:
type: string
enum:
- none
- local
- host
shareable_state:
type: object

View File

@@ -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-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"]]
edges = [element for element in payload["elements"] if "source" in element["data"]]
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="layout-select"' in page
assert 'id="profile-select"' in page
assert 'id="profile-name"' in page
assert 'id="orientation-list"' in page
assert "Interface consumers" 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 'data-override="hide"' 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:
server.shutdown()
server.server_close()

View File

@@ -228,7 +228,7 @@ Acceptance notes:
```task
id: RAIL-FAB-WP-0008-T05
status: in_progress
status: done
priority: medium
state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67"
```