Add graph explorer bubble help

This commit is contained in:
2026-05-19 00:51:38 +02:00
parent 98a747dd5a
commit 4f4f28a6d5
3 changed files with 143 additions and 27 deletions

View File

@@ -67,6 +67,42 @@ def graph_explorer_page() -> str:
}
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;
@@ -122,7 +158,10 @@ def graph_explorer_page() -> str:
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;
@@ -220,48 +259,76 @@ def graph_explorer_page() -> str:
}
.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">
<label>Search <input id="search" autocomplete="off"></label>
<label>Mode <select id="mode-select"></select></label>
<label>Layout <select id="layout-select">
<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></label>
<div class="field"><span>Node Types</span>
</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>Edge Types</span>
<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>
<label>Review <select id="review-filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select></label>
<label>Unresolved <select id="unresolved-filter"><option value="">Any</option><option value="true">Only</option></select></label>
<label>Profile <select id="profile-select" disabled><option>Unsaved exploration</option></select></label>
<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"></div>
<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">Labels
<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>
@@ -269,14 +336,15 @@ def graph_explorer_page() -> str:
<option value="none">None</option>
</select>
</label>
<button type="button" data-action="fit" title="Fit graph to view">Fit</button>
<button type="button" data-action="focus" title="Focus selected neighborhood">Focus</button>
<button type="button" data-action="clear" class="primary" title="Reset map controls">Reset</button>
<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">
@@ -291,24 +359,27 @@ def graph_explorer_page() -> str:
</section>
<section class="section">
<div class="button-row">
<button type="button" data-override="show">Show</button>
<button type="button" data-override="blur">Blur</button>
<button type="button" data-override="hide">Hide</button>
<button type="button" data-action="reset-overrides">Clear Overrides</button>
<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>
<label>Profile Name <input id="profile-name" autocomplete="off" disabled></label>
<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" disabled>Save</button>
<button type="button" data-profile-action="duplicate" disabled>Duplicate</button>
<button type="button" data-profile-action="delete" disabled>Delete</button>
<button type="button" data-profile-action="copy" disabled>Copy State</button>
<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 id="hidden-summary" class="meta">Hidden 0</p>
<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>
@@ -321,6 +392,7 @@ def graph_explorer_page() -> str:
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");
@@ -365,6 +437,26 @@ def graph_explorer_page() -> str:
.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
@@ -960,6 +1052,13 @@ def graph_explorer_page() -> str:
...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,
@@ -1158,12 +1257,21 @@ def graph_explorer_page() -> str:
}
});
});
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='notice'>${escapeHtml(error.message)}</p>`;
canvas.innerHTML = `<p class='canvas-notice'>Could not load graph explorer data: ${escapeHtml(error.message)}</p>`;
});
})();
</script>