generated from coulomb/repo-seed
feat: add draggable graph zones
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
110
workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md
Normal file
110
workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md
Normal 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.
|
||||
Reference in New Issue
Block a user