diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index 961c24c..f0283eb 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -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 diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py index be166ea..e136e3d 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -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, }, } diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 74d2021..06d1c9e 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -151,10 +151,12 @@ def graph_explorer_page() -> str:

Profile persistence unavailable for this host.

+
+
@@ -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) { diff --git a/schemas/graph-explorer-manifest.schema.yaml b/schemas/graph-explorer-manifest.schema.yaml index 0fd51bb..0feab19 100644 --- a/schemas/graph-explorer-manifest.schema.yaml +++ b/schemas/graph-explorer-manifest.schema.yaml @@ -195,6 +195,7 @@ properties: type: string enum: - none + - local - host shareable_state: type: object diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index c5ede49..029e460 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -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() diff --git a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md index ec343a0..93a1f9b 100644 --- a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md +++ b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md @@ -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" ```