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
|
as `private-dev`, `collaborator-test`, `production-public`, or
|
||||||
`production-admin`.
|
`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:
|
Useful warnings for the graph explorer include:
|
||||||
|
|
||||||
- control surfaces in user-facing access zones;
|
- 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
|
the synthetic elements and restores the original graph elements without
|
||||||
changing the underlying payload.
|
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 Compatibility
|
||||||
|
|
||||||
Repo-scoping can adapt without a rewrite because its current graph payload
|
Repo-scoping can adapt without a rewrite because its current graph payload
|
||||||
|
|||||||
@@ -65,17 +65,20 @@ def graph_explorer_page() -> str:
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
max-width: calc(100% - 20px);
|
max-width: calc(100% - 20px);
|
||||||
border-color: var(--zone-color, #2563eb);
|
border: 0;
|
||||||
color: var(--zone-color, #2563eb);
|
color: var(--zone-color, #2563eb);
|
||||||
background: rgba(255, 255, 255, .96);
|
background: transparent;
|
||||||
box-shadow: 0 8px 18px rgba(23, 32, 51, .1);
|
box-shadow: none;
|
||||||
|
cursor: grab;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 3px 8px;
|
padding: 0;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.zone-label:focus-visible {
|
||||||
outline: 2px solid var(--zone-color, #2563eb);
|
outline: 2px solid var(--zone-color, #2563eb);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -616,6 +619,8 @@ def graph_explorer_page() -> str:
|
|||||||
let selectedZoneId = "";
|
let selectedZoneId = "";
|
||||||
let zoneOverlayFrame = 0;
|
let zoneOverlayFrame = 0;
|
||||||
let zoneSummaries = new Map();
|
let zoneSummaries = new Map();
|
||||||
|
let zoneDragState = null;
|
||||||
|
let suppressZoneClick = false;
|
||||||
let collapsedZoneSnapshots = new Map();
|
let collapsedZoneSnapshots = new Map();
|
||||||
const zoneCollapsePrefix = "zone-collapse:";
|
const zoneCollapsePrefix = "zone-collapse:";
|
||||||
|
|
||||||
@@ -1266,8 +1271,7 @@ def graph_explorer_page() -> str:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const zoneLabelTop = (zone, bounds, index) => {
|
const zoneLabelTop = (zone, bounds, index) => {
|
||||||
const rank = zone.field === "deploymentEnvironment" ? zoneLabelRank(zone, index) : index % 4;
|
return 8;
|
||||||
return Math.min(8 + rank * 28, Math.max(8, Math.round(bounds.height - 32)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoneForData = (data, grouping = activeZoneGrouping) => {
|
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 = () => {
|
const scheduleZoneOverlayUpdate = () => {
|
||||||
if (!zoneOverlay || zoneOverlayFrame) return;
|
if (!zoneOverlay || zoneOverlayFrame) return;
|
||||||
zoneOverlayFrame = window.requestAnimationFrame(() => {
|
zoneOverlayFrame = window.requestAnimationFrame(() => {
|
||||||
@@ -2649,6 +2712,10 @@ def graph_explorer_page() -> str:
|
|||||||
updateUrlState();
|
updateUrlState();
|
||||||
});
|
});
|
||||||
zoneOverlay.addEventListener("click", (event) => {
|
zoneOverlay.addEventListener("click", (event) => {
|
||||||
|
if (suppressZoneClick) {
|
||||||
|
suppressZoneClick = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const button = event.target.closest("[data-zone-id]");
|
const button = event.target.closest("[data-zone-id]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const zone = zoneSummaries.get(button.dataset.zoneId);
|
const zone = zoneSummaries.get(button.dataset.zoneId);
|
||||||
@@ -2657,6 +2724,14 @@ def graph_explorer_page() -> str:
|
|||||||
selectedZoneId = zone.id;
|
selectedZoneId = zone.id;
|
||||||
renderZoneOverlay();
|
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}));
|
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
|
||||||
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
|
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
|
||||||
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
|
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
|
||||||
|
|||||||
@@ -428,6 +428,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert "zoneDefinitionSets" in page
|
assert "zoneDefinitionSets" in page
|
||||||
assert "fabric-default" in page
|
assert "fabric-default" in page
|
||||||
assert "collapsedZoneSnapshots" 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 "collapseZone" in page
|
||||||
assert "expandZone" in page
|
assert "expandZone" in page
|
||||||
assert "zoneCollapseNodeId" 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