Add graph selection anchors

This commit is contained in:
2026-05-19 00:16:57 +02:00
parent 88ccc973e4
commit d9c5bf1bd4
2 changed files with 136 additions and 1 deletions

View File

@@ -129,6 +129,55 @@ def graph_explorer_page() -> str:
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
}
.map-controls button { min-width: 54px; }
.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 {
@@ -203,6 +252,9 @@ def graph_explorer_page() -> str:
<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>
</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>
<aside class="side">
@@ -248,6 +300,8 @@ def graph_explorer_page() -> str:
const graphUrl = "/exports/graph-explorer";
const canvas = document.getElementById("graph-canvas");
const popup = document.getElementById("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");
@@ -272,6 +326,7 @@ def graph_explorer_page() -> str:
const profileStorageKey = "railiance.fabric.graphExplorer.profiles";
let cy = null;
let selected = null;
let selectedAnchor = null;
let focusSet = null;
let manualOverrides = {};
let layerColors = {};
@@ -354,6 +409,59 @@ def graph_explorer_page() -> str:
}).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 currentViewState = () => ({
search: searchInput.value,
mode: modeSelect.value || "full",
@@ -603,6 +711,7 @@ def graph_explorer_page() -> str:
const hidden = cy.elements().length - visibleNodes - visibleEdges;
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
hiddenSummary.textContent = `Hidden ${hidden}`;
updateSelectionAnchor();
};
const showDetails = (element) => {
@@ -614,6 +723,7 @@ def graph_explorer_page() -> str:
detailList.innerHTML = "";
orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo.";
orientationList.innerHTML = "";
updateSelectionAnchor();
return;
}
const data = element.data();
@@ -639,6 +749,7 @@ def graph_explorer_page() -> str:
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.join("");
renderOrientation(element);
updateSelectionAnchor();
};
const renderOrientation = (element) => {
@@ -751,6 +862,7 @@ def graph_explorer_page() -> str:
}
: {name, padding: 48, animate: false};
cy.layout(options).run();
updateSelectionAnchor();
};
const renderLegend = (layers) => {
@@ -809,6 +921,16 @@ def graph_explorer_page() -> str:
"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",
@@ -818,13 +940,23 @@ def graph_explorer_page() -> str:
}},
{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:selected, .display-blur.hover", style: {"opacity": .78, "label": "data(label)"}},
{selector: ":selected", style: {"border-width": 4, "border-color": "#111827", "line-color": "#111827", "target-arrow-color": "#111827"}}
{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();

View File

@@ -208,9 +208,12 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert 'id="layout-select"' in page
assert 'id="node-type-filter"' in page
assert 'id="edge-type-filter"' in page
assert 'id="selection-anchor"' in page
assert "updateSelectionAnchor" in page
assert "Node Types" in page
assert "Edge Types" in page
assert 'id="layer-filter"' not in page
assert '"border-width": 4' not in page
assert 'id="profile-select"' in page
assert 'id="profile-name"' in page
assert 'id="orientation-list"' in page