feat: add draggable graph zones

This commit is contained in:
2026-05-25 01:09:05 +02:00
parent 9b612447ca
commit 0f7b7d1fed
4 changed files with 209 additions and 6 deletions

View File

@@ -173,6 +173,11 @@ When access-zone grouping is selected, boundaries use `accessZone` values such
as `private-dev`, `collaborator-test`, `production-public`, or
`production-admin`.
Zone labels should be rendered as plain text in the upper-left corner of the
zone rectangle, without badge frames or white backgrounds. The label may still
act as the zone's focus/drag handle as long as it visually reads as text drawn
on the zone surface.
Useful warnings for the graph explorer include:
- control surfaces in user-facing access zones;
@@ -202,6 +207,11 @@ are summarized on the zone node rather than rendered. Expanding the zone removes
the synthetic elements and restores the original graph elements without
changing the underlying payload.
Zone dragging is also view-only. Dragging a zone handle should translate the
currently assigned visible member nodes by the same delta and then recompute the
overlay bounds from the new node positions. The operation updates Cytoscape view
coordinates only; it does not change Fabric graph data.
## Repo-Scoping Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload

View File

@@ -65,17 +65,20 @@ def graph_explorer_page() -> str:
z-index: 1;
min-height: 26px;
max-width: calc(100% - 20px);
border-color: var(--zone-color, #2563eb);
border: 0;
color: var(--zone-color, #2563eb);
background: rgba(255, 255, 255, .96);
box-shadow: 0 8px 18px rgba(23, 32, 51, .1);
background: transparent;
box-shadow: none;
cursor: grab;
font-size: 12px;
font-weight: 700;
overflow: hidden;
padding: 3px 8px;
padding: 0;
pointer-events: auto;
text-overflow: ellipsis;
text-shadow: 0 1px 0 rgba(255, 255, 255, .78), 0 0 8px rgba(255, 255, 255, .72);
}
.zone-label.dragging { cursor: grabbing; }
.zone-label:focus-visible {
outline: 2px solid var(--zone-color, #2563eb);
outline-offset: 2px;
@@ -616,6 +619,8 @@ def graph_explorer_page() -> str:
let selectedZoneId = "";
let zoneOverlayFrame = 0;
let zoneSummaries = new Map();
let zoneDragState = null;
let suppressZoneClick = false;
let collapsedZoneSnapshots = new Map();
const zoneCollapsePrefix = "zone-collapse:";
@@ -1266,8 +1271,7 @@ def graph_explorer_page() -> str:
};
const zoneLabelTop = (zone, bounds, index) => {
const rank = zone.field === "deploymentEnvironment" ? zoneLabelRank(zone, index) : index % 4;
return Math.min(8 + rank * 28, Math.max(8, Math.round(bounds.height - 32)));
return 8;
};
const zoneForData = (data, grouping = activeZoneGrouping) => {
@@ -1593,6 +1597,65 @@ def graph_explorer_page() -> str:
}
};
const startZoneDrag = (event, zoneId) => {
const zone = zoneSummaries.get(zoneId);
if (!cy || !zone || !(zone.nodes || []).length) return;
event.preventDefault();
event.stopPropagation();
selected = null;
selectedZoneId = zone.id;
const handle = event.target.closest(".zone-label");
handle?.classList.add("dragging");
handle?.setPointerCapture?.(event.pointerId);
zoneDragState = {
zoneId: zone.id,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
moved: false,
handle,
nodes: zone.nodes.map((node) => ({
node,
position: {...node.position()},
})),
};
renderZoneDetails(zone);
};
const moveZoneDrag = (event) => {
if (!zoneDragState || event.pointerId !== zoneDragState.pointerId || !cy) return;
event.preventDefault();
const zoom = cy.zoom() || 1;
const dx = (event.clientX - zoneDragState.startX) / zoom;
const dy = (event.clientY - zoneDragState.startY) / zoom;
if (Math.abs(event.clientX - zoneDragState.startX) + Math.abs(event.clientY - zoneDragState.startY) > 3) {
zoneDragState.moved = true;
suppressZoneClick = true;
}
zoneDragState.nodes.forEach(({node, position}) => {
if (!node || !node.length) return;
node.position({x: position.x + dx, y: position.y + dy});
});
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
const finishZoneDrag = (event) => {
if (!zoneDragState || event.pointerId !== zoneDragState.pointerId) return;
zoneDragState.handle?.classList.remove("dragging");
zoneDragState.handle?.releasePointerCapture?.(event.pointerId);
const draggedZoneId = zoneDragState.zoneId;
const moved = zoneDragState.moved;
zoneDragState = null;
renderZoneOverlay();
if (moved) {
const zone = zoneSummaries.get(draggedZoneId);
if (zone) renderZoneDetails(zone);
updateProfileSummary(`Moved zone "${zone?.label || draggedZoneId}".`);
window.setTimeout(() => { suppressZoneClick = false; }, 0);
}
};
const scheduleZoneOverlayUpdate = () => {
if (!zoneOverlay || zoneOverlayFrame) return;
zoneOverlayFrame = window.requestAnimationFrame(() => {
@@ -2649,6 +2712,10 @@ def graph_explorer_page() -> str:
updateUrlState();
});
zoneOverlay.addEventListener("click", (event) => {
if (suppressZoneClick) {
suppressZoneClick = false;
return;
}
const button = event.target.closest("[data-zone-id]");
if (!button) return;
const zone = zoneSummaries.get(button.dataset.zoneId);
@@ -2657,6 +2724,14 @@ def graph_explorer_page() -> str:
selectedZoneId = zone.id;
renderZoneOverlay();
});
zoneOverlay.addEventListener("pointerdown", (event) => {
const handle = event.target.closest(".zone-label[data-zone-id]");
if (!handle || event.button !== 0) return;
startZoneDrag(event, handle.dataset.zoneId);
});
window.addEventListener("pointermove", moveZoneDrag);
window.addEventListener("pointerup", finishZoneDrag);
window.addEventListener("pointercancel", finishZoneDrag);
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({

View File

@@ -428,6 +428,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "zoneDefinitionSets" in page
assert "fabric-default" in page
assert "collapsedZoneSnapshots" in page
assert "zoneDragState" in page
assert "startZoneDrag" in page
assert "moveZoneDrag" in page
assert "finishZoneDrag" in page
assert "Moved zone" in page
assert "cursor: grab" in page
assert "background: transparent" in page
assert "box-shadow: none" in page
assert "collapseZone" in page
assert "expandZone" in page
assert "zoneCollapseNodeId" in page

View File

@@ -0,0 +1,110 @@
---
id: RAIL-FAB-WP-0023
type: workplan
title: "Improve zone labels and dragging"
domain: railiance
repo: railiance-fabric
status: active
owner: codex
topic_slug: railiance-fabric
created: "2026-05-25"
updated: "2026-05-25"
---
# Improve Zone Labels And Dragging
## Context
RAIL-FAB-WP-0021 introduced zone boundary rectangles and RAIL-FAB-WP-0022
promoted zones to first-class graph explorer view entities. The current zone
labels still look like small framed buttons with a white background. That makes
the overlay read more like controls than like labels printed on a drawing
surface.
The next interaction step is to let the operator grab a zone and move it around,
dragging all currently attached visible nodes with it. This makes zones behave
more like the intended "piece of paper" view entity while keeping the underlying
Fabric graph unchanged.
## Task 1: Simplify Zone Labels
```task
id: RAIL-FAB-WP-0023-T01
status: done
priority: high
```
Change zone labels from framed button badges to plain text in the upper-left
corner of the zone rectangle.
The label should:
- have no frame;
- have no white background;
- remain readable against the zone fill;
- still be keyboard-focusable/clickable enough to open zone details;
- avoid adding visual clutter to dense graph views.
Expected result: graph explorer zone labels look like text drawn on the zone
surface rather than separate UI controls.
Result: Updated graph explorer zone labels to render as transparent, unframed
text in the upper-left corner of each zone rectangle. Labels remain
keyboard-focusable and clickable, and the visual smoke screenshot confirms the
labels read as text on the zone surface.
## Task 2: Drag Zones With Member Nodes
```task
id: RAIL-FAB-WP-0023-T02
status: done
priority: high
```
Add a zone drag interaction that moves all currently attached visible member
nodes with the zone.
The interaction should:
- start from the zone label or zone boundary affordance;
- move assigned visible nodes by the drag delta;
- keep Cytoscape node positions and overlay bounds in sync;
- update zone details and labels during/after the drag;
- avoid mutating the underlying Fabric payload;
- not interfere with normal node dragging more than necessary.
Expected result: grabbing a zone lets the operator reposition the visible zone
subgraph as a unit.
Result: Added a view-only zone drag interaction using the plain zone label as
the grab handle. Dragging translates the currently assigned visible zone member
nodes by the pointer delta, recomputes overlay bounds, refreshes zone details,
and leaves the Fabric graph payload unchanged.
## Task 3: Verify Interaction Behavior
```task
id: RAIL-FAB-WP-0023-T03
status: blocked
priority: medium
```
Verify the visual and interaction behavior with focused tests and a browser
smoke check.
The verification should cover:
- static UI test assertions for the new label/drag helpers;
- JavaScript syntax validation;
- graph explorer focused tests;
- a visual smoke screenshot showing plain labels;
- manual or scripted confirmation that zone dragging moves member nodes.
Expected result: the UI renders clean labels and the zone drag interaction works
without breaking existing graph explorer behavior.
Result: Static UI assertions, JavaScript syntax validation, focused graph tests,
full test suite, Fabric CLI validation, and a headless Edge screenshot pass.
Automated drag smoke verification is blocked because Edge's remote debugging
endpoint was not reachable from WSL in this session. Manual verification is
needed by grabbing a zone label in the running graph explorer.