Files
railiance-fabric/railiance_fabric/graph_explorer_ui.py

1281 lines
59 KiB
Python

from __future__ import annotations
def graph_explorer_page() -> str:
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fabric Map</title>
<style>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #f7f8fa;
--panel: #ffffff;
--line: #d6dce5;
--text: #172033;
--muted: #667085;
--accent: #0f766e;
--accent-2: #2563eb;
--warn: #b45309;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-size: 14px; }
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
.toolbar {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(7, minmax(108px, .55fr)) auto;
gap: 8px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
#graph-canvas { position: absolute; inset: 0; }
.side {
border-left: 1px solid var(--line);
background: var(--panel);
min-height: 0;
overflow: auto;
padding: 14px;
}
.side h1 { font-size: 17px; line-height: 1.2; margin: 0 0 10px; }
.section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
.section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
label, .field { color: var(--muted); display: grid; gap: 4px; font-size: 12px; }
input, select, button {
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
font: inherit;
min-height: 34px;
padding: 6px 8px;
background: #ffffff;
}
button {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
button.primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
button:disabled { color: #98a2b3; cursor: default; }
.field-label {
display: inline-flex;
align-items: center;
gap: 5px;
}
.help-tip {
width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
border-radius: 50%;
color: var(--muted);
font-size: 11px;
line-height: 1;
background: #f8fafc;
}
.help-tip:hover, .help-tip:focus-visible {
border-color: var(--accent);
color: var(--accent);
outline: none;
}
.help-popup {
position: fixed;
z-index: 30;
display: none;
max-width: 300px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 32, 51, .16);
color: var(--text);
padding: 10px;
pointer-events: none;
}
.help-popup strong { display: block; margin-bottom: 4px; }
.help-popup p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.35; }
.filter-menu {
position: relative;
min-width: 0;
}
.filter-menu summary {
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
cursor: pointer;
list-style: none;
min-height: 34px;
overflow: hidden;
padding: 6px 8px;
text-overflow: ellipsis;
white-space: nowrap;
background: #ffffff;
}
.filter-menu summary::-webkit-details-marker { display: none; }
.filter-menu[open] summary { border-color: var(--accent); }
.check-list {
position: absolute;
z-index: 8;
display: grid;
gap: 4px;
width: max-content;
min-width: 210px;
max-width: 280px;
max-height: 300px;
overflow: auto;
margin-top: 4px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
}
.check-list label {
grid-template-columns: auto 1fr;
align-items: center;
color: var(--text);
font-size: 13px;
gap: 8px;
min-height: 26px;
}
.check-list input {
min-height: 0;
padding: 0;
}
.map-controls {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
display: flex;
align-items: end;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
max-width: min(460px, calc(100% - 24px));
padding: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, .94);
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
}
.map-controls button { min-width: 54px; }
.map-control-field {
display: grid;
gap: 2px;
color: var(--muted);
font-size: 11px;
line-height: 1;
}
.map-control-field select {
min-height: 30px;
min-width: 84px;
padding: 4px 6px;
}
.selection-anchor {
position: absolute;
z-index: 5;
display: none;
width: 0;
height: 0;
pointer-events: none;
transform: translate(-50%, -100%);
}
.selection-anchor::before {
content: "";
position: absolute;
left: -1px;
top: -36px;
width: 2px;
height: 28px;
border-radius: 999px;
background: #111827;
box-shadow: 0 1px 2px rgba(23, 32, 51, .28);
}
.selection-anchor::after {
content: "";
position: absolute;
left: -6px;
top: -10px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 10px solid #111827;
filter: drop-shadow(0 1px 1px rgba(23, 32, 51, .24));
}
.selection-anchor-label {
position: absolute;
left: 12px;
top: -48px;
max-width: 220px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, .96);
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
color: var(--text);
font-size: 12px;
line-height: 1.25;
padding: 6px 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.button-row { display: flex; flex-wrap: wrap; gap: 8px; }
.meta { color: var(--muted); font-size: 12px; margin: 0; }
.pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
min-height: 24px;
padding: 2px 8px;
margin: 0 4px 4px 0;
font-size: 12px;
}
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.detail-list li { overflow-wrap: anywhere; }
.popup {
position: absolute;
z-index: 4;
max-width: 260px;
display: none;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
padding: 10px;
pointer-events: none;
}
.popup strong { display: block; margin-bottom: 4px; }
.notice { color: var(--warn); padding: 14px; }
.canvas-notice {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
padding: 24px;
text-align: center;
}
@media (max-width: 900px) {
.shell { min-height: 760px; }
.toolbar { grid-template-columns: 1fr 1fr; }
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
.side { border-left: 0; border-top: 1px solid var(--line); }
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
}
</style>
</head>
<body>
<section class="shell">
<div class="toolbar">
<div class="field">
<span class="field-label">Search <button type="button" class="help-tip" aria-label="Search help" data-help-title="Search" data-help="Search matches labels, ids, repos, node types, edge types, and descriptions. It hides non-matching nodes and edges without redrawing the layout.">?</button></span>
<input id="search" autocomplete="off" aria-label="Search graph">
</div>
<div class="field">
<span class="field-label">Mode <button type="button" class="help-tip" aria-label="Mode help" data-help-title="Mode" data-help="Modes are predefined map views. Some modes use the selected item as context; changing modes changes which graph entities are visible, but keeps the layout controls separate.">?</button></span>
<select id="mode-select" aria-label="Graph mode"></select>
</div>
<div class="field">
<span class="field-label">Layout <button type="button" class="help-tip" aria-label="Layout help" data-help-title="Layout" data-help="Layout redraws the map arrangement. Cose uses relationship strength and repo affinity; circle, grid, concentric, and breadthfirst are simpler alternate arrangements.">?</button></span>
<select id="layout-select" aria-label="Graph layout">
<option value="cose">Cose</option>
<option value="concentric">Concentric</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
<option value="breadthfirst">Breadthfirst</option>
</select>
</div>
<div class="field"><span class="field-label">Node Types <button type="button" class="help-tip" aria-label="Node types help" data-help-title="Node Types" data-help="Nodes are the entities drawn on the map: repositories, services, deployments, servers, capabilities, interfaces, dependencies, bindings, and libraries. This filter hides unchecked node types while preserving the current layout.">?</button></span>
<details id="node-type-menu" class="filter-menu">
<summary id="node-type-summary">All node types</summary>
<div id="node-type-filter" class="check-list"></div>
</details>
</div>
<div class="field"><span class="field-label">Edge Types <button type="button" class="help-tip" aria-label="Edge types help" data-help-title="Edge Types" data-help="Edges are relationships between nodes, such as provides, consumes, runs_on, or binds. This filter hides unchecked relationship types while preserving the current layout.">?</button></span>
<details id="edge-type-menu" class="filter-menu">
<summary id="edge-type-summary">All edge types</summary>
<div id="edge-type-filter" class="check-list"></div>
</details>
</div>
<div class="field">
<span class="field-label">Review <button type="button" class="help-tip" aria-label="Review help" data-help-title="Review" data-help="Review state applies only to entities that carry review metadata. Accepted means trusted data; candidate means registered or inferred data that still needs context.">?</button></span>
<select id="review-filter" aria-label="Review state filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select>
</div>
<div class="field">
<span class="field-label">Unresolved <button type="button" class="help-tip" aria-label="Unresolved help" data-help-title="Unresolved" data-help="Unresolved marks missing provider bindings or registered repositories without accepted graph snapshots. It only applies to node and edge types that can express those gaps.">?</button></span>
<select id="unresolved-filter" aria-label="Unresolved filter"><option value="">Any</option><option value="true">Only</option></select>
</div>
<div class="field">
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
<select id="profile-select" aria-label="Saved view profile" disabled><option>Unsaved exploration</option></select>
</div>
<span id="counts" class="meta"></span>
</div>
<main class="main">
<div class="canvas-wrap">
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div>
<div class="map-controls" aria-label="Map navigation controls">
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
<select id="label-select" title="Control node label density">
<option value="auto">Auto</option>
<option value="key">Key</option>
<option value="all">All</option>
<option value="none">None</option>
</select>
</label>
<button type="button" data-action="fit" title="Fit graph to view" data-help-title="Fit" data-help="Fit pans and zooms the current visible graph into the map viewport. It does not change filters, labels, or layout.">Fit</button>
<button type="button" data-action="focus" title="Focus selected neighborhood" data-help-title="Focus" data-help="Focus narrows the map to the selected entity and its nearby context. It changes visibility, not the underlying registry data.">Focus</button>
<button type="button" data-action="clear" class="primary" title="Reset map controls" data-help-title="Reset" data-help="Reset clears search, mode, node and edge type filters, label density, unresolved/review filters, focus, and manual overrides.">Reset</button>
</div>
<div id="selection-anchor" class="selection-anchor" aria-hidden="true">
<span id="selection-anchor-label" class="selection-anchor-label"></span>
</div>
<div id="popup" class="popup"></div>
<div id="help-popup" class="help-popup" role="tooltip"></div>
</div>
<aside class="side">
<section class="section">
<h1 id="detail-title">Fabric Map</h1>
<p id="detail-summary" class="meta">No selection</p>
<div id="detail-pills"></div>
<ul id="detail-list" class="detail-list"></ul>
</section>
<section class="section">
<p id="orientation-title" class="meta">Select a service, interface, dependency, or registered-only repo.</p>
<ul id="orientation-list" class="detail-list"></ul>
</section>
<section class="section">
<div class="button-row">
<button type="button" data-override="show" data-help-title="Show" data-help="Show pins the selected entity visible even when other filters would hide it. It does not redraw the layout.">Show</button>
<button type="button" data-override="blur" data-help-title="Blur" data-help="Blur keeps the selected entity in place but reduces emphasis. It is useful for context that should stay visible without competing for attention.">Blur</button>
<button type="button" data-override="hide" data-help-title="Hide" data-help="Hide makes the selected entity invisible while preserving layout context. Remove is reserved for the upcoming rule panel and will redraw the graph without the entity.">Hide</button>
<button type="button" data-action="reset-overrides" data-help-title="Clear Overrides" data-help="Clear Overrides removes manual Show, Blur, and Hide choices. Filters, mode, layout, and label density stay as they are.">Clear Overrides</button>
</div>
</section>
<section class="section">
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
<div class="field">
<span class="field-label">Profile Name <button type="button" class="help-tip" aria-label="Profile name help" data-help-title="Profile Name" data-help="Profile Name labels a saved browser-local map view. It does not rename graph entities or registry data.">?</button></span>
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
</div>
<div class="button-row">
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, layout, label density, and manual overrides." disabled>Save</button>
<button type="button" data-profile-action="duplicate" data-help-title="Duplicate" data-help="Duplicate creates a new saved view from the currently loaded profile and current map state." disabled>Duplicate</button>
<button type="button" data-profile-action="delete" data-help-title="Delete" data-help="Delete removes the selected browser-local saved view. It does not delete graph data." disabled>Delete</button>
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters and manual overrides." disabled>Copy State</button>
</div>
</section>
<section class="section">
<p class="meta"><span id="hidden-summary">Hidden 0</span> <button type="button" class="help-tip" aria-label="Hidden entities help" data-help-title="Hidden Entities" data-help="Hidden counts entities currently not shown because of filters, mode, or manual Hide overrides. Hidden keeps layout context; Remove will redraw without the entity in the rule panel.">?</button></p>
<div id="legend"></div>
</section>
</aside>
</main>
</section>
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
<script>
(() => {
const manifestUrl = "/exports/graph-explorer/manifest";
const graphUrl = "/exports/graph-explorer";
const canvas = document.getElementById("graph-canvas");
const popup = document.getElementById("popup");
const helpPopup = document.getElementById("help-popup");
const selectionAnchor = document.getElementById("selection-anchor");
const selectionAnchorLabel = document.getElementById("selection-anchor-label");
const searchInput = document.getElementById("search");
const modeSelect = document.getElementById("mode-select");
const layoutSelect = document.getElementById("layout-select");
const labelSelect = document.getElementById("label-select");
const nodeTypeFilter = document.getElementById("node-type-filter");
const nodeTypeSummary = document.getElementById("node-type-summary");
const edgeTypeFilter = document.getElementById("edge-type-filter");
const edgeTypeSummary = document.getElementById("edge-type-summary");
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");
const detailTitle = document.getElementById("detail-title");
const detailSummary = document.getElementById("detail-summary");
const detailPills = document.getElementById("detail-pills");
const detailList = document.getElementById("detail-list");
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 selectedAnchor = null;
let focusSet = null;
let manualOverrides = {};
let layerColors = {};
let nodeTypeLabels = {};
let allNodeTypes = [];
let allEdgeTypes = [];
let activeMode = "full";
let activeLabelMode = "auto";
let profilePersistence = "none";
let profiles = [];
let currentProfileId = "";
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const showHelp = (target) => {
if (!helpPopup || !target?.dataset?.help) return;
const bounds = target.getBoundingClientRect();
const title = target.dataset.helpTitle || target.textContent || "Help";
helpPopup.innerHTML = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(target.dataset.help)}</p>`;
helpPopup.style.display = "block";
const width = Math.min(helpPopup.offsetWidth || 300, 300);
const left = Math.min(Math.max(12, bounds.left), window.innerWidth - width - 12);
const top = bounds.bottom + 8 < window.innerHeight - 80
? bounds.bottom + 8
: Math.max(12, bounds.top - helpPopup.offsetHeight - 8);
helpPopup.style.left = `${left}px`;
helpPopup.style.top = `${top}px`;
};
const hideHelp = () => {
if (!helpPopup) return;
helpPopup.style.display = "none";
};
const elementText = (data) => [
data.id, data.stableKey, data.label, data.name, data.description,
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 humanize = (value) => String(value || "")
.replaceAll("_", " ")
.replaceAll(":", ": ")
.replace(/\\b\\w/g, (letter) => letter.toUpperCase());
const parseListParam = (value) => String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const checkedValues = (container) => new Set(
Array.from(container.querySelectorAll("input[type='checkbox']"))
.filter((input) => input.checked)
.map((input) => input.value)
);
const setCheckedValues = (container, values = null) => {
const explicit = Array.isArray(values);
const selectedValues = new Set(values || []);
container.querySelectorAll("input[type='checkbox']").forEach((input) => {
input.checked = explicit ? selectedValues.has(input.value) : true;
});
};
const selectedNodeTypes = () => checkedValues(nodeTypeFilter);
const selectedEdgeTypes = () => checkedValues(edgeTypeFilter);
const summarizeSelection = (selected, allValues, labels, noun) => {
if (selected.size === 0) return `No ${noun}`;
if (selected.size === allValues.length) return `All ${noun}`;
const names = allValues
.filter((value) => selected.has(value))
.map((value) => labels[value] || humanize(value));
return names.length <= 2 ? names.join(", ") : `${names.length} ${noun}`;
};
const syncFilterSummaries = () => {
nodeTypeSummary.textContent = summarizeSelection(
selectedNodeTypes(), allNodeTypes, nodeTypeLabels, "node types"
);
edgeTypeSummary.textContent = summarizeSelection(
selectedEdgeTypes(), allEdgeTypes, {}, "edge types"
);
};
const renderChecklist = (container, values, labels, name) => {
container.innerHTML = values.map((value) => {
const label = labels[value] || humanize(value);
return `<label><input type="checkbox" name="${escapeHtml(name)}" value="${escapeHtml(value)}" checked> ${escapeHtml(label)}</label>`;
}).join("");
};
const isFinitePoint = (position) =>
position && Number.isFinite(position.x) && Number.isFinite(position.y);
const elementLabel = (element) => {
if (!element) return "";
const data = element.data();
return data.name || data.label || data.edgeType || data.id;
};
const renderedElementPosition = (element) => {
if (!element || !cy) return null;
if (element.isNode && element.isNode()) return element.renderedPosition();
if (element.isEdge && element.isEdge()) {
if (typeof element.renderedMidpoint === "function") {
const midpoint = element.renderedMidpoint();
if (isFinitePoint(midpoint)) return midpoint;
}
const source = cy.getElementById(element.data("source"));
const target = cy.getElementById(element.data("target"));
if (source.length > 0 && target.length > 0) {
const sourcePosition = source.renderedPosition();
const targetPosition = target.renderedPosition();
if (isFinitePoint(sourcePosition) && isFinitePoint(targetPosition)) {
return {
x: (sourcePosition.x + targetPosition.x) / 2,
y: (sourcePosition.y + targetPosition.y) / 2,
};
}
}
}
return null;
};
const updateSelectionAnchor = () => {
if (!selected || !selectionAnchor) {
selectedAnchor = null;
selectionAnchor.style.display = "none";
return;
}
const position = renderedElementPosition(selected);
if (isFinitePoint(position)) {
selectedAnchor = {x: position.x, y: position.y, label: elementLabel(selected)};
}
if (!selectedAnchor) {
selectionAnchor.style.display = "none";
return;
}
selectionAnchor.style.left = `${selectedAnchor.x}px`;
selectionAnchor.style.top = `${selectedAnchor.y}px`;
selectionAnchorLabel.textContent = selectedAnchor.label;
selectionAnchor.style.display = "block";
};
const isKeyLabelNode = (node) => {
const layer = node.data("layer");
return (
["repository", "server", "deployment", "service"].includes(layer) ||
node.data("unresolved") === true ||
node.data("lifecycle") === "registered-only"
);
};
const updateLabelVisibility = () => {
if (!cy) return;
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none");
const mode = activeLabelMode === "auto" && visibleNodes.length > 70 ? "key" : activeLabelMode;
cy.nodes().forEach((node) => {
let showLabel = mode === "all" || mode === "auto";
if (mode === "key") showLabel = isKeyLabelNode(node);
if (mode === "none") showLabel = false;
if (selected && node.id() === selected.id()) showLabel = false;
node.toggleClass("label-hidden", !showLabel);
});
};
const currentViewState = () => ({
search: searchInput.value,
mode: modeSelect.value || "full",
layout: layoutSelect.value || "cose",
labelMode: labelSelect.value || "auto",
nodeTypes: Array.from(selectedNodeTypes()),
edgeTypes: Array.from(selectedEdgeTypes()),
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("labelMode")) state.labelMode = params.get("labelMode") || "";
if (params.has("nodeTypes")) state.nodeTypes = parseListParam(params.get("nodeTypes"));
if (params.has("edgeTypes")) state.edgeTypes = parseListParam(params.get("edgeTypes"));
if (params.has("layer")) state.nodeTypes = parseListParam(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.labelMode && state.labelMode !== "auto") params.set("labelMode", state.labelMode);
if (state.nodeTypes.length !== allNodeTypes.length) params.set("nodeTypes", state.nodeTypes.join(","));
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
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 ("labelMode" in state) labelSelect.value = optionExists(labelSelect, state.labelMode) ? state.labelMode : "auto";
if ("nodeTypes" in state) setCheckedValues(nodeTypeFilter, (state.nodeTypes || []).filter((value) => allNodeTypes.includes(value)));
if ("layer" in state) setCheckedValues(nodeTypeFilter, allNodeTypes.includes(state.layer) ? [state.layer] : null);
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
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";
activeLabelMode = labelSelect.value || "auto";
focusSet = null;
syncFilterSummaries();
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();
if (modeSet && !modeSet.has(element.id())) return false;
const text = searchInput.value.trim().toLowerCase();
if (text && !elementText(data).includes(text)) return false;
if (element.isNode() && !selectedNodeTypes().has(data.layer)) return false;
if (element.isEdge() && !selectedEdgeTypes().has(data.edgeType)) return false;
if (reviewFilter.value && data.reviewState !== reviewFilter.value) return false;
if (unresolvedFilter.value === "true" && data.unresolved !== true) return false;
if (focusSet && !focusSet.has(element.id())) return false;
return true;
};
const visibleSetForMode = () => {
if (!cy) return null;
if (focusSet) return focusSet;
if (activeMode === "full") return null;
if (activeMode === "onboarding-gaps") {
return new Set(cy.elements().filter((element) =>
element.data("lifecycle") === "registered-only" || element.data("unresolved") === true
).map((element) => element.id()));
}
if (activeMode === "unresolved") {
return new Set(cy.elements().filter((element) => element.data("unresolved") === true).map((element) => element.id()));
}
if (!selected) return null;
if (activeMode === "selected-path") {
const collection = selected.union(selected.predecessors()).union(selected.successors());
return new Set(collection.map((element) => element.id()));
}
if (activeMode === "neighborhood") {
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
return new Set(collection.map((element) => element.id()));
}
return null;
};
const applyFilters = () => {
if (!cy) return;
syncFilterSummaries();
const hiddenNodes = new Set();
cy.nodes().forEach((node) => {
let state = matchesFilters(node) ? "show" : "hide";
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");
if (state === "hide") hiddenNodes.add(node.id());
});
cy.edges().forEach((edge) => {
let state = matchesFilters(edge) ? "show" : "hide";
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
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");
});
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
const hidden = cy.elements().length - visibleNodes - visibleEdges;
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
hiddenSummary.textContent = `Hidden ${hidden}`;
updateLabelVisibility();
updateSelectionAnchor();
};
const showDetails = (element) => {
selected = element || null;
if (!element) {
detailTitle.textContent = "Fabric Map";
detailSummary.textContent = "No selection";
detailPills.innerHTML = "";
detailList.innerHTML = "";
orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo.";
orientationList.innerHTML = "";
updateLabelVisibility();
updateSelectionAnchor();
return;
}
const data = element.data();
detailTitle.textContent = data.name || data.label || data.id;
detailSummary.textContent = data.description || data.id;
const nodeType = data.layer ? nodeTypeLabels[data.layer] || humanize(data.layer) : "";
detailPills.innerHTML = [data.kind, nodeType, data.repo, data.reviewState, data.displayState]
.map((value) => value ? `<span class="pill">${escapeHtml(value)}</span>` : "")
.join("");
const links = data.deepLinks || {};
const refs = data.sourceReferences || [];
const rows = [
["id", data.id],
["source", data.source],
["target", data.target],
["edge", data.edgeType],
["strength", data.strength],
...Object.entries(links),
...refs.map((ref) => [ref.label || ref.kind || "source", ref.path || ref.url || ref.ref || ""])
];
detailList.innerHTML = rows
.filter(([, value]) => value)
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.join("");
renderOrientation(element);
updateLabelVisibility();
updateSelectionAnchor();
};
const renderOrientation = (element) => {
const data = element.data();
const rows = [];
if (data.kind === "Repository" && data.lifecycle === "registered-only") {
orientationTitle.textContent = "Onboarding gap";
rows.push(["repo", data.repo], ["next", "sync Fabric declarations or register a graph snapshot"]);
} else if (data.kind === "InterfaceDeclaration") {
orientationTitle.textContent = "Interface consumers";
cy.edges().filter((edge) =>
edge.data("target") === data.id && edge.data("edgeType") === "uses_interface"
).forEach((edge) => {
const dependency = edge.data("source");
const consumerEdge = cy.edges().filter((candidate) =>
candidate.data("target") === dependency && candidate.data("edgeType") === "consumes"
)[0];
const consumerId = consumerEdge ? consumerEdge.data("source") : "";
const consumer = consumerId ? cy.getElementById(consumerId).data("name") || consumerId : "unknown";
rows.push(["consumer", `${consumer} -> ${dependency}`]);
});
if (rows.length === 0) rows.push(["consumer", "no accepted consumers in current graph"]);
} else if (data.kind === "ServiceDeclaration") {
orientationTitle.textContent = "Dependency path";
cy.edges().filter((edge) =>
edge.data("source") === data.id && edge.data("edgeType") === "consumes"
).forEach((edge) => {
const dependency = edge.data("target");
const providerEdges = cy.edges().filter((candidate) =>
candidate.data("source") === dependency && String(candidate.data("edgeType")).startsWith("binds:")
);
if (providerEdges.length === 0) {
rows.push(["requires", `${dependency} -> unresolved`]);
return;
}
providerEdges.forEach((providerEdge) => {
const target = providerEdge.data("target");
const provider = cy.getElementById(target).data("name") || target;
rows.push(["requires", `${dependency} -> ${provider} (${providerEdge.data("edgeType")})`]);
});
});
if (rows.length === 0) rows.push(["requires", "no declared dependencies"]);
} else if (data.kind === "DependencyDeclaration") {
orientationTitle.textContent = "Dependency binding";
cy.edges().filter((edge) =>
edge.data("source") === data.id && (
String(edge.data("edgeType")).startsWith("binds:") ||
edge.data("edgeType") === "uses_interface"
)
).forEach((edge) => {
const target = edge.data("target");
const targetName = cy.getElementById(target).data("name") || target;
rows.push([edge.data("edgeType"), targetName]);
});
if (rows.length === 0) rows.push(["binding", "no provider binding in current graph"]);
} else if (data.kind === "CapabilityDeclaration") {
orientationTitle.textContent = "Provider surface";
cy.edges().filter((edge) =>
edge.data("target") === data.id && edge.data("edgeType") === "provides"
).forEach((edge) => rows.push(["service", cy.getElementById(edge.data("source")).data("name") || edge.data("source")]));
cy.edges().filter((edge) =>
edge.data("source") === data.id && edge.data("edgeType") === "available_via"
).forEach((edge) => rows.push(["interface", cy.getElementById(edge.data("target")).data("name") || edge.data("target")]));
if (rows.length === 0) rows.push(["surface", "no provider surface in current graph"]);
} else {
orientationTitle.textContent = "Graph context";
rows.push(["neighbors", `${element.neighborhood().nodes().length} connected nodes`]);
}
orientationList.innerHTML = rows
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.join("");
};
const applyFocus = () => {
if (!cy || !selected) return;
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
focusSet = new Set(collection.map((item) => item.id()));
modeSelect.value = "neighborhood";
activeMode = "neighborhood";
currentProfileId = "";
profileSelect.value = "";
applyFilters();
updateProfileControls();
updateProfileSummary();
updateUrlState();
};
const edgeIdealLength = (edge) => Number(edge.data("layoutIdealLength")) || 110;
const edgeElasticity = (edge) => Number(edge.data("layoutElasticity")) || 80;
const runLayout = () => {
if (!cy) return;
cy.elements().stop();
const name = layoutSelect.value || "cose";
const options = name === "breadthfirst"
? {name, directed: true, padding: 48, animate: false}
: name === "cose"
? {
name,
padding: 48,
animate: false,
randomize: false,
nodeOverlap: 12,
idealEdgeLength: edgeIdealLength,
edgeElasticity,
nodeRepulsion: 5000,
gravity: 1,
numIter: 1400,
}
: {name, padding: 48, animate: false};
cy.layout(options).run();
updateSelectionAnchor();
};
const renderLegend = (layers) => {
const nodeTypes = layers.map((layer) =>
`<span class="pill"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(layer.color || "#667085")}"></span>${escapeHtml(layer.label)}</span>`
);
const statusItems = [
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #b45309;border-radius:50%"></span>Unresolved</span>',
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #be123c;border-radius:3px"></span>Registered only</span>',
'<span class="pill"><span style="display:inline-block;width:18px;border-top:2px dotted #98a2b3"></span>Weak edge</span>',
];
legend.innerHTML = [...nodeTypes, ...statusItems].join("");
};
const boot = async () => {
const [manifest, payload] = await Promise.all([
fetch(manifestUrl).then((response) => response.json()),
fetch(graphUrl).then((response) => response.json())
]);
(manifest.modes || [{id: "full", label: "Full"}]).forEach((mode) => {
const option = document.createElement("option");
option.value = mode.id;
option.textContent = mode.label;
modeSelect.appendChild(option);
});
profilePersistence = manifest.profile_persistence || "none";
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
nodeTypeLabels = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.label]));
allNodeTypes = (manifest.layers || []).map((layer) => layer.id);
allEdgeTypes = Array.from(new Set((payload.elements || [])
.map((element) => element.data || {})
.filter((data) => data.source && data.target && data.edgeType)
.map((data) => data.edgeType)
)).sort((left, right) => left.localeCompare(right));
renderChecklist(nodeTypeFilter, allNodeTypes, nodeTypeLabels, "node-type");
renderChecklist(edgeTypeFilter, allEdgeTypes, {}, "edge-type");
syncFilterSummaries();
renderLegend(manifest.layers || []);
profiles = loadProfiles();
renderProfiles();
const elements = (payload.elements || []).map((element) => ({
...element,
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
}));
if (elements.length === 0) {
canvas.innerHTML = "<p class='canvas-notice'>No graph entities were returned by the registry.</p>";
counts.textContent = "0 nodes / 0 edges";
hiddenSummary.textContent = "Hidden 0";
return;
}
canvas.innerHTML = "";
cy = cytoscape({
container: canvas,
elements,
layout: {name: layoutSelect.value || "cose", animate: false, randomize: false},
style: [
{selector: "node", style: {
"background-color": "data(color)",
"border-color": "#172033",
"border-width": 1,
"color": "#172033",
"font-size": 11,
"height": "data(visualSize)",
"label": "data(label)",
"text-background-color": "#ffffff",
"text-background-opacity": .85,
"text-background-padding": 2,
"text-wrap": "wrap",
"text-max-width": 130,
"width": "data(visualSize)"
}},
{selector: "node[layer = 'repository']", style: {"shape": "round-rectangle"}},
{selector: "node[layer = 'server']", style: {"shape": "pentagon"}},
{selector: "node[layer = 'deployment']", style: {"shape": "diamond"}},
{selector: "node[layer = 'service']", style: {"shape": "hexagon"}},
{selector: "node[layer = 'capability']", style: {"shape": "vee"}},
{selector: "node[layer = 'interface']", style: {"shape": "rectangle"}},
{selector: "node[layer = 'dependency']", style: {"shape": "triangle"}},
{selector: "node[layer = 'binding']", style: {"shape": "rhomboid"}},
{selector: "node[unresolved = true]", style: {"border-color": "#b45309", "border-style": "dashed", "border-width": 3}},
{selector: "node[lifecycle = 'registered-only']", style: {"border-color": "#be123c", "border-style": "dashed", "border-width": 3}},
{selector: "edge", style: {
"curve-style": "bezier",
"line-color": "#98a2b3",
"target-arrow-color": "#98a2b3",
"target-arrow-shape": "triangle",
"width": "data(edgeWidth)"
}},
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
{selector: "edge.hover", style: {
"label": "data(edgeType)",
"font-size": 10,
"text-background-color": "#ffffff",
"text-background-opacity": .9,
"text-background-padding": 2,
"z-index": 2
}},
{selector: ".display-blur", style: {"opacity": .24, "label": ""}},
{selector: ".display-blur.hover", style: {"opacity": .78, "label": "data(label)"}},
{selector: ".label-hidden", style: {"label": ""}},
{selector: ":selected", style: {"overlay-opacity": 0}}
]
});
cy.on("tap", "node, edge", (event) => showDetails(event.target));
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
cy.on("pan zoom resize render layoutstop", updateSelectionAnchor);
cy.on("position", "node", updateSelectionAnchor);
cy.on("mouseover", "node, edge", (event) => {
event.target.addClass("hover");
const data = event.target.data();
const nodeType = data.layer ? nodeTypeLabels[data.layer] || humanize(data.layer) : "";
popup.innerHTML = `<strong>${escapeHtml(data.name || data.label || data.id)}</strong><span class="meta">${escapeHtml(data.kind)} / ${escapeHtml(nodeType || data.edgeType || "")}</span>`;
popup.style.left = `${Math.min(event.renderedPosition.x + 14, canvas.clientWidth - 270)}px`;
popup.style.top = `${Math.max(10, event.renderedPosition.y + 14)}px`;
popup.style.display = "block";
});
cy.on("mouseout", "node, edge", (event) => {
event.target.removeClass("hover");
popup.style.display = "none";
});
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();
};
const handleFilterChange = () => {
focusSet = null;
currentProfileId = "";
profileSelect.value = "";
applyFilters();
updateProfileControls();
updateProfileSummary();
updateUrlState();
};
[searchInput, reviewFilter, unresolvedFilter].forEach((control) => {
control.addEventListener("input", handleFilterChange);
});
nodeTypeFilter.addEventListener("change", handleFilterChange);
edgeTypeFilter.addEventListener("change", handleFilterChange);
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();
});
labelSelect.addEventListener("input", () => {
activeLabelMode = labelSelect.value || "auto";
currentProfileId = "";
profileSelect.value = "";
updateLabelVisibility();
updateProfileControls();
updateProfileSummary();
updateUrlState();
});
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", () => {
searchInput.value = "";
modeSelect.value = "full";
activeMode = "full";
labelSelect.value = "auto";
activeLabelMode = "auto";
setCheckedValues(nodeTypeFilter);
setCheckedValues(edgeTypeFilter);
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[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.");
}
});
});
document.querySelectorAll("[data-help]").forEach((target) => {
target.addEventListener("mouseenter", () => showHelp(target));
target.addEventListener("focus", () => showHelp(target));
target.addEventListener("mouseleave", hideHelp);
target.addEventListener("blur", hideHelp);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") hideHelp();
});
if (!window.cytoscape) {
canvas.innerHTML = "<p class='notice'>Cytoscape.js could not be loaded.</p>";
return;
}
boot().catch((error) => {
canvas.innerHTML = `<p class='canvas-notice'>Could not load graph explorer data: ${escapeHtml(error.message)}</p>`;
});
})();
</script>
</body>
</html>
"""