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 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

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.", "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,
}, },
} }

View File

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

View File

@@ -195,6 +195,7 @@ properties:
type: string type: string
enum: enum:
- none - none
- local
- host - host
shareable_state: shareable_state:
type: object 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-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()

View File

@@ -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"
``` ```