generated from coulomb/repo-seed
Compare commits
5 Commits
1ad432270b
...
558e0dc157
| Author | SHA1 | Date | |
|---|---|---|---|
| 558e0dc157 | |||
| 0f7b7d1fed | |||
| 9b612447ca | |||
| a7a22a673f | |||
| 296ac051a7 |
@@ -169,6 +169,65 @@ introduce a two-phase layout:
|
|||||||
This will likely require an internal view model that separates fabric graph data
|
This will likely require an internal view model that separates fabric graph data
|
||||||
from rendered graph coordinates.
|
from rendered graph coordinates.
|
||||||
|
|
||||||
|
### Per-Zone Layout Preparation
|
||||||
|
|
||||||
|
The current graph explorer should not immediately run independent Cytoscape
|
||||||
|
layouts inside each zone rectangle. Cytoscape layouts operate on collections in
|
||||||
|
one coordinate space, while the current zone overlay is a view layer drawn over
|
||||||
|
the already-laid-out graph. Running nested layouts directly against visible
|
||||||
|
nodes would make pan/zoom, filters, collapse state, and edge routing fragile.
|
||||||
|
|
||||||
|
The safer path is a two-phase view layout:
|
||||||
|
|
||||||
|
1. Resolve zones from the current graph and view filters.
|
||||||
|
2. Place zone containers and unzoned nodes in the global canvas.
|
||||||
|
3. For each zone, compute local coordinates for its assigned nodes using the
|
||||||
|
zone's configured layout algorithm.
|
||||||
|
4. Project local zone coordinates into global graph coordinates.
|
||||||
|
5. Route internal edges inside the zone and boundary edges through the zone
|
||||||
|
perimeter or collapsed zone node.
|
||||||
|
|
||||||
|
The implementation needs these pieces before per-zone layouts become safe:
|
||||||
|
|
||||||
|
- a resolved zone view model that survives filtering and saved profiles;
|
||||||
|
- a stable assignment invariant so a visible node belongs to no more than one
|
||||||
|
zone;
|
||||||
|
- a zone container model with size, position, padding, and height;
|
||||||
|
- a local coordinate projection layer from zone space to Cytoscape space;
|
||||||
|
- explicit boundary-edge routing rules;
|
||||||
|
- collapse state that can replace a zone subgraph with a representative node;
|
||||||
|
- diagnostics when a configured zone layout cannot be applied.
|
||||||
|
|
||||||
|
The present implementation already establishes the first, second, and sixth
|
||||||
|
pieces. A follow-up should introduce a zone container placement phase before
|
||||||
|
attempting per-zone node layout. That follow-up can keep Cytoscape as the final
|
||||||
|
renderer while moving layout decisions into a Fabric-owned view model.
|
||||||
|
|
||||||
|
### Stable Zone Containers
|
||||||
|
|
||||||
|
The first container implementation keeps zone surfaces as view state keyed by
|
||||||
|
stable zone id. When a zone first appears, the global graph layout supplies its
|
||||||
|
initial center. Once created, the container owns the zone surface position while
|
||||||
|
the global layout continues to arrange the base canvas and unzoned nodes.
|
||||||
|
|
||||||
|
Dragging a zone moves the container and its currently assigned visible member
|
||||||
|
nodes together. Rerunning layout or switching the layout algorithm should keep
|
||||||
|
the container in its stored graph coordinates and then project the zone's
|
||||||
|
visible subgraph back into that container.
|
||||||
|
|
||||||
|
Container state belongs in saved or copied graph view state, not in the Fabric
|
||||||
|
payload. It is an operator workspace preference, similar to manual visibility
|
||||||
|
overrides.
|
||||||
|
|
||||||
|
### Context Edges
|
||||||
|
|
||||||
|
Display-only context edges are not zone connectivity. Repository `declares`
|
||||||
|
edges, for example, show which repository declared a node, but they should not
|
||||||
|
create boundary diagnostics, attraction paths, or collapsed-zone boundary
|
||||||
|
edges. A host can still show them as explanatory evidence in details, but a
|
||||||
|
zone boundary should only react to canonical or host-promoted graph
|
||||||
|
relationships.
|
||||||
|
|
||||||
## Layer Height And Overlap
|
## Layer Height And Overlap
|
||||||
|
|
||||||
Zone presentation includes a height. Height is a visual stacking concept, not a
|
Zone presentation includes a height. Height is a visual stacking concept, not a
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -187,6 +192,39 @@ initial diagnostic set includes empty zone seed sets, visible nodes matched by
|
|||||||
multiple zone definitions, and edges crossing zone boundaries. Attraction
|
multiple zone definitions, and edges crossing zone boundaries. Attraction
|
||||||
diagnostics such as multiple attraction candidates or depth-limit stops belong
|
diagnostics such as multiple attraction candidates or depth-limit stops belong
|
||||||
to the same resolver diagnostic channel when attraction rules are enabled.
|
to the same resolver diagnostic channel when attraction rules are enabled.
|
||||||
|
Display-only context edges, such as repository `declares` edges, are evidence
|
||||||
|
for where declarations came from. They must not count as zone boundary
|
||||||
|
connectivity, attraction paths, or collapsed-zone boundary edges unless a host
|
||||||
|
explicitly promotes them to canonical graph relationships.
|
||||||
|
|
||||||
|
Saved graph profiles should persist zone view state as an explicit nested
|
||||||
|
`zone` object. The initial fields are `visible`, `grouping`, `definitionSet`,
|
||||||
|
`presentation`, and `containers`. URL parameters may continue to expose
|
||||||
|
compatibility aliases such as `zoneBoundaries`, `zoneGrouping`, and
|
||||||
|
`zoneDefinitionSet`, but saved profiles should prefer the nested object so
|
||||||
|
future zone definition sets, presentation preferences, and operator-placed zone
|
||||||
|
surfaces can be restored without another state migration.
|
||||||
|
|
||||||
|
Zone containers are view state, not fabric data. A container stores a stable
|
||||||
|
zone surface position and size in graph coordinates. Global graph layout may
|
||||||
|
place unzoned nodes and provide an initial center for new zones, but existing
|
||||||
|
zone containers should keep their operator-chosen positions when the layout
|
||||||
|
algorithm changes. After the global layout pass, each zone may project its
|
||||||
|
assigned visible nodes into local coordinates inside its container. The first
|
||||||
|
local layout may be a deterministic compact layout; later engines can replace
|
||||||
|
that with per-zone Cytoscape or engine-owned algorithms.
|
||||||
|
|
||||||
|
Zone collapse is a view-only operation. A collapsed zone should hide its visible
|
||||||
|
member nodes, replace them with a synthetic zone node, and draw synthetic
|
||||||
|
boundary edges from that zone node to visible external neighbors. Internal edges
|
||||||
|
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 Compatibility
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -606,6 +609,7 @@ def graph_explorer_page() -> str:
|
|||||||
let activeMode = "full";
|
let activeMode = "full";
|
||||||
let activeLabelMode = "auto";
|
let activeLabelMode = "auto";
|
||||||
let activeZoneGrouping = "deploymentEnvironment";
|
let activeZoneGrouping = "deploymentEnvironment";
|
||||||
|
let activeZoneDefinitionSet = "fabric-default";
|
||||||
let profilePersistence = "none";
|
let profilePersistence = "none";
|
||||||
let profiles = [];
|
let profiles = [];
|
||||||
let currentProfileId = "";
|
let currentProfileId = "";
|
||||||
@@ -615,6 +619,11 @@ def graph_explorer_page() -> str:
|
|||||||
let selectedZoneId = "";
|
let selectedZoneId = "";
|
||||||
let zoneOverlayFrame = 0;
|
let zoneOverlayFrame = 0;
|
||||||
let zoneSummaries = new Map();
|
let zoneSummaries = new Map();
|
||||||
|
let zoneContainerState = new Map();
|
||||||
|
let zoneDragState = null;
|
||||||
|
let suppressZoneClick = false;
|
||||||
|
let collapsedZoneSnapshots = new Map();
|
||||||
|
const zoneCollapsePrefix = "zone-collapse:";
|
||||||
|
|
||||||
const escapeHtml = (value) => String(value ?? "")
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
.replaceAll("&", "&").replaceAll("<", "<")
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
@@ -1045,6 +1054,14 @@ def graph_explorer_page() -> str:
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zoneDefinitionSets = {
|
||||||
|
"fabric-default": {
|
||||||
|
id: "fabric-default",
|
||||||
|
label: "Fabric defaults",
|
||||||
|
groupings: ["deploymentEnvironment", "accessZone"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const zoneElementData = (element) => {
|
const zoneElementData = (element) => {
|
||||||
if (!element) return {};
|
if (!element) return {};
|
||||||
if (typeof element.data === "function") return element.data();
|
if (typeof element.data === "function") return element.data();
|
||||||
@@ -1086,12 +1103,24 @@ def graph_explorer_page() -> str:
|
|||||||
return String(actual) === String(rule.value ?? "");
|
return String(actual) === String(rule.value ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTrueish = (value) => value === true || String(value).toLowerCase() === "true";
|
||||||
|
|
||||||
|
const isZoneContextOnlyEdge = (element) => {
|
||||||
|
const data = zoneElementData(element);
|
||||||
|
const edgeType = String(data.edgeType || data.dependencyType || "").trim();
|
||||||
|
return isTrueish(data.displayOnly) || edgeType === "declares";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isZoneConnectivityEdge = (element) =>
|
||||||
|
element && element.isEdge && element.isEdge() && !isZoneContextOnlyEdge(element);
|
||||||
|
|
||||||
const zoneDescriptorFromDefinition = (definition) => ({
|
const zoneDescriptorFromDefinition = (definition) => ({
|
||||||
field: definition.field,
|
field: definition.field,
|
||||||
id: definition.id,
|
id: definition.id,
|
||||||
label: definition.label || definition.id,
|
label: definition.label || definition.id,
|
||||||
value: definition.value || definition.label || definition.id,
|
value: definition.value || definition.label || definition.id,
|
||||||
rank: definition.rank,
|
rank: definition.rank,
|
||||||
|
layout: definition.layout || {},
|
||||||
presentation: definition.presentation || {},
|
presentation: definition.presentation || {},
|
||||||
membership: definition.membership || {},
|
membership: definition.membership || {},
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@@ -1120,6 +1149,7 @@ def graph_explorer_page() -> str:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
|
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
|
||||||
|
if (!zoneDefinitionSets[activeZoneDefinitionSet]) activeZoneDefinitionSet = "fabric-default";
|
||||||
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
||||||
if (grouping === "accessZone") {
|
if (grouping === "accessZone") {
|
||||||
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
|
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
|
||||||
@@ -1129,6 +1159,7 @@ def graph_explorer_page() -> str:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
|
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
|
||||||
|
if (!zoneDefinitionSets[activeZoneDefinitionSet]) activeZoneDefinitionSet = "fabric-default";
|
||||||
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
||||||
if (grouping === "accessZone") {
|
if (grouping === "accessZone") {
|
||||||
const values = new Set();
|
const values = new Set();
|
||||||
@@ -1207,15 +1238,18 @@ def graph_explorer_page() -> str:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
|
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
|
||||||
|
const isConnectivity = isZoneConnectivityEdge(edge);
|
||||||
const data = zoneElementData(edge);
|
const data = zoneElementData(edge);
|
||||||
const zoneIds = new Set(
|
const zoneIds = new Set(
|
||||||
definitions
|
isConnectivity
|
||||||
.filter((definition) => zoneRuleMatches(data, definition.membership))
|
? definitions
|
||||||
.map((definition) => definition.id)
|
.filter((definition) => zoneRuleMatches(data, definition.membership))
|
||||||
|
.map((definition) => definition.id)
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
const sourceZoneId = assignments.get(edge.data("source"));
|
const sourceZoneId = assignments.get(edge.data("source"));
|
||||||
const targetZoneId = assignments.get(edge.data("target"));
|
const targetZoneId = assignments.get(edge.data("target"));
|
||||||
if ((sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
|
if (isConnectivity && (sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
|
||||||
const diagnostic = {
|
const diagnostic = {
|
||||||
severity: "info",
|
severity: "info",
|
||||||
code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
|
code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
|
||||||
@@ -1225,8 +1259,8 @@ def graph_explorer_page() -> str:
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
|
.forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
|
||||||
}
|
}
|
||||||
if (sourceZoneId) zoneIds.add(sourceZoneId);
|
if (isConnectivity && sourceZoneId) zoneIds.add(sourceZoneId);
|
||||||
if (targetZoneId) zoneIds.add(targetZoneId);
|
if (isConnectivity && targetZoneId) zoneIds.add(targetZoneId);
|
||||||
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
|
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
|
||||||
});
|
});
|
||||||
return zones;
|
return zones;
|
||||||
@@ -1253,8 +1287,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) => {
|
||||||
@@ -1310,6 +1343,221 @@ def graph_explorer_page() -> str:
|
|||||||
return {left, top, width, height};
|
return {left, top, width, height};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zoneNodeSortKey = (node) =>
|
||||||
|
String(node.data("stableKey") || node.data("label") || node.id());
|
||||||
|
|
||||||
|
const zoneCenterForNodes = (nodes) => {
|
||||||
|
const positions = nodes
|
||||||
|
.map((node) => node.position())
|
||||||
|
.filter((position) => Number.isFinite(position.x) && Number.isFinite(position.y));
|
||||||
|
if (!positions.length) return null;
|
||||||
|
return {
|
||||||
|
x: positions.reduce((total, position) => total + position.x, 0) / positions.length,
|
||||||
|
y: positions.reduce((total, position) => total + position.y, 0) / positions.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const zonePreferredSize = (zone) => {
|
||||||
|
const count = Math.max(1, (zone.nodes || []).length);
|
||||||
|
const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35)));
|
||||||
|
const rows = Math.max(1, Math.ceil(count / columns));
|
||||||
|
const cellWidth = Number(zone.layout?.options?.cellWidth) || 118;
|
||||||
|
const cellHeight = Number(zone.layout?.options?.cellHeight) || 94;
|
||||||
|
const padding = Number(zone.layout?.options?.padding) || 60;
|
||||||
|
return {
|
||||||
|
width: Math.max(180, columns * cellWidth + padding * 2),
|
||||||
|
height: Math.max(128, rows * cellHeight + padding * 2),
|
||||||
|
padding,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureZoneContainer = (zone) => {
|
||||||
|
if (!zone || !(zone.nodes || []).length) return null;
|
||||||
|
const preferred = zonePreferredSize(zone);
|
||||||
|
const existing = zoneContainerState.get(zone.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.width = preferred.width;
|
||||||
|
existing.height = preferred.height;
|
||||||
|
existing.padding = preferred.padding;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const center = zoneCenterForNodes(zone.nodes) || {x: 0, y: 0};
|
||||||
|
const container = {
|
||||||
|
id: zone.id,
|
||||||
|
x: center.x,
|
||||||
|
y: center.y,
|
||||||
|
width: preferred.width,
|
||||||
|
height: preferred.height,
|
||||||
|
padding: preferred.padding,
|
||||||
|
userPlaced: false,
|
||||||
|
};
|
||||||
|
zoneContainerState.set(zone.id, container);
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
const packZoneContainers = (zones) => {
|
||||||
|
const items = Array.from(zones.values())
|
||||||
|
.filter((zone) => (zone.nodes || []).length > 0)
|
||||||
|
.map((zone) => ({zone, container: ensureZoneContainer(zone)}))
|
||||||
|
.filter((item) => item.container);
|
||||||
|
if (items.length < 2 || items.some((item) => item.container.userPlaced)) return;
|
||||||
|
items.sort((left, right) =>
|
||||||
|
zoneRank(left.zone) - zoneRank(right.zone) || left.zone.label.localeCompare(right.zone.label)
|
||||||
|
);
|
||||||
|
const gap = 42;
|
||||||
|
const start = Math.min(...items.map((item) => item.container.x - item.container.width / 2));
|
||||||
|
const maxRowWidth = cy
|
||||||
|
? Math.max(720, (cy.width() || 960) / Math.max(cy.zoom() || 1, 0.2) - 96)
|
||||||
|
: Infinity;
|
||||||
|
let cursor = start;
|
||||||
|
let top = Math.min(...items.map((item) => item.container.y - item.container.height / 2));
|
||||||
|
let rowHeight = 0;
|
||||||
|
items.forEach(({container}) => {
|
||||||
|
if (cursor > start && cursor + container.width > start + maxRowWidth) {
|
||||||
|
cursor = start;
|
||||||
|
top += rowHeight + gap;
|
||||||
|
rowHeight = 0;
|
||||||
|
}
|
||||||
|
container.x = cursor + container.width / 2;
|
||||||
|
container.y = top + container.height / 2;
|
||||||
|
cursor += container.width + gap;
|
||||||
|
rowHeight = Math.max(rowHeight, container.height);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncZoneContainers = (zones, options = {}) => {
|
||||||
|
let created = false;
|
||||||
|
zones.forEach((zone) => {
|
||||||
|
if ((zone.nodes || []).length > 0 && !zoneContainerState.has(zone.id)) created = true;
|
||||||
|
ensureZoneContainer(zone);
|
||||||
|
});
|
||||||
|
if (options.packNew && created) packZoneContainers(zones);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneContainerBounds = (container) => ({
|
||||||
|
left: container.x - container.width / 2,
|
||||||
|
top: container.y - container.height / 2,
|
||||||
|
width: container.width,
|
||||||
|
height: container.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zoneRenderedBoundsFromContainer = (container) => {
|
||||||
|
const bounds = zoneContainerBounds(container);
|
||||||
|
const zoom = cy ? cy.zoom() || 1 : 1;
|
||||||
|
const pan = cy ? cy.pan() || {x: 0, y: 0} : {x: 0, y: 0};
|
||||||
|
return {
|
||||||
|
left: bounds.left * zoom + pan.x,
|
||||||
|
top: bounds.top * zoom + pan.y,
|
||||||
|
width: bounds.width * zoom,
|
||||||
|
height: bounds.height * zoom,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutZoneNodesInGrid = (nodes, container) => {
|
||||||
|
const padding = Number(container.padding) || 60;
|
||||||
|
const count = Math.max(1, nodes.length);
|
||||||
|
const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35)));
|
||||||
|
const rows = Math.max(1, Math.ceil(count / columns));
|
||||||
|
const innerWidth = Math.max(1, container.width - padding * 2);
|
||||||
|
const innerHeight = Math.max(1, container.height - padding * 2);
|
||||||
|
const cellWidth = innerWidth / columns;
|
||||||
|
const cellHeight = innerHeight / rows;
|
||||||
|
const left = container.x - container.width / 2 + padding;
|
||||||
|
const top = container.y - container.height / 2 + padding;
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const column = index % columns;
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
node.position({
|
||||||
|
x: left + cellWidth * (column + 0.5),
|
||||||
|
y: top + cellHeight * (row + 0.5),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutZoneNodesInCircle = (nodes, container) => {
|
||||||
|
if (nodes.length === 1) {
|
||||||
|
nodes[0].position({x: container.x, y: container.y});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const padding = Number(container.padding) || 60;
|
||||||
|
const radius = Math.max(20, Math.min(container.width, container.height) / 2 - padding);
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / nodes.length;
|
||||||
|
node.position({
|
||||||
|
x: container.x + Math.cos(angle) * radius,
|
||||||
|
y: container.y + Math.sin(angle) * radius,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutZoneSubgraph = (zone, container) => {
|
||||||
|
const nodes = (zone.nodes || [])
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right)));
|
||||||
|
if (!nodes.length || !container) return;
|
||||||
|
const algorithm = String(zone.layout?.algorithm || "compact-grid");
|
||||||
|
if (algorithm === "circle") {
|
||||||
|
layoutZoneNodesInCircle(nodes, container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layoutZoneNodesInGrid(nodes, container);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyZoneContainerLayout = () => {
|
||||||
|
if (!cy || zoneDragState) return;
|
||||||
|
const zones = collectZoneSummaries();
|
||||||
|
syncZoneContainers(zones, {packNew: true});
|
||||||
|
zones.forEach((zone) => {
|
||||||
|
if (!(zone.nodes || []).length) return;
|
||||||
|
layoutZoneSubgraph(zone, ensureZoneContainer(zone));
|
||||||
|
});
|
||||||
|
zoneSummaries = zones;
|
||||||
|
updateSelectionAnchor();
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitVisibleGraph = () => {
|
||||||
|
if (!cy) return;
|
||||||
|
const visible = cy.elements().filter((element) => element.style("display") !== "none");
|
||||||
|
if (visible.length) cy.fit(visible, 72);
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializedZoneContainers = () => {
|
||||||
|
const entries = Array.from(zoneContainerState.entries())
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([id, container]) => [id, {
|
||||||
|
x: Math.round(container.x * 100) / 100,
|
||||||
|
y: Math.round(container.y * 100) / 100,
|
||||||
|
width: Math.round(container.width * 100) / 100,
|
||||||
|
height: Math.round(container.height * 100) / 100,
|
||||||
|
userPlaced: container.userPlaced === true,
|
||||||
|
}]);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedZoneContainers = (containers) => {
|
||||||
|
const normalized = new Map();
|
||||||
|
if (!containers || typeof containers !== "object") return normalized;
|
||||||
|
Object.entries(containers).forEach(([id, value]) => {
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
const x = Number(value.x);
|
||||||
|
const y = Number(value.y);
|
||||||
|
const width = Number(value.width);
|
||||||
|
const height = Number(value.height);
|
||||||
|
if (![x, y, width, height].every(Number.isFinite)) return;
|
||||||
|
normalized.set(id, {
|
||||||
|
id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.max(1, width),
|
||||||
|
height: Math.max(1, height),
|
||||||
|
padding: Number(value.padding) || 60,
|
||||||
|
userPlaced: value.userPlaced === true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
const collectZoneSummaries = () => {
|
const collectZoneSummaries = () => {
|
||||||
if (!cy) return new Map();
|
if (!cy) return new Map();
|
||||||
const visibleElements = cy.elements()
|
const visibleElements = cy.elements()
|
||||||
@@ -1335,6 +1583,7 @@ def graph_explorer_page() -> str:
|
|||||||
selectedZoneId = zone.id;
|
selectedZoneId = zone.id;
|
||||||
const visibleElements = zone.elements || [];
|
const visibleElements = zone.elements || [];
|
||||||
const visibleNodes = zone.nodes || [];
|
const visibleNodes = zone.nodes || [];
|
||||||
|
const isCollapsed = collapsedZoneSnapshots.has(zone.id);
|
||||||
detailTitle.textContent = zone.label;
|
detailTitle.textContent = zone.label;
|
||||||
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
|
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
|
||||||
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
|
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
|
||||||
@@ -1386,6 +1635,7 @@ def graph_explorer_page() -> str:
|
|||||||
</li>
|
</li>
|
||||||
`).join("");
|
`).join("");
|
||||||
orientationActions.innerHTML = `
|
orientationActions.innerHTML = `
|
||||||
|
<button type="button" data-orientation-action="${isCollapsed ? "expand-zone" : "collapse-zone"}" data-zone-id="${escapeHtml(zone.id)}">${isCollapsed ? "Expand Zone" : "Collapse Zone"}</button>
|
||||||
<button type="button" data-orientation-action="focus">Focus Context</button>
|
<button type="button" data-orientation-action="focus">Focus Context</button>
|
||||||
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
||||||
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
||||||
@@ -1396,6 +1646,155 @@ def graph_explorer_page() -> str:
|
|||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zoneCollapseNodeId = (zoneId) => `${zoneCollapsePrefix}node:${zoneId}`;
|
||||||
|
|
||||||
|
const isZoneCollapseElement = (element) => String(element.id()).startsWith(zoneCollapsePrefix);
|
||||||
|
|
||||||
|
const removeZoneCollapseElements = () => {
|
||||||
|
if (!cy) return;
|
||||||
|
if (selected && isZoneCollapseElement(selected)) selected = null;
|
||||||
|
cy.elements().filter(isZoneCollapseElement).remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneCollapsePosition = (nodes) => {
|
||||||
|
const positions = nodes.map((node) => node.position());
|
||||||
|
if (!positions.length) return {x: 0, y: 0};
|
||||||
|
return {
|
||||||
|
x: positions.reduce((total, position) => total + position.x, 0) / positions.length,
|
||||||
|
y: positions.reduce((total, position) => total + position.y, 0) / positions.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneCollapseSnapshot = (zone) => {
|
||||||
|
const nodeIds = new Set((zone.nodes || []).map((node) => node.id()));
|
||||||
|
const boundaryEdges = [];
|
||||||
|
let internalEdgeCount = 0;
|
||||||
|
(zone.elements || [])
|
||||||
|
.filter(isZoneConnectivityEdge)
|
||||||
|
.forEach((edge) => {
|
||||||
|
const source = edge.data("source");
|
||||||
|
const target = edge.data("target");
|
||||||
|
const sourceInside = nodeIds.has(source);
|
||||||
|
const targetInside = nodeIds.has(target);
|
||||||
|
if (sourceInside && targetInside) {
|
||||||
|
internalEdgeCount += 1;
|
||||||
|
} else if (sourceInside || targetInside) {
|
||||||
|
boundaryEdges.push({
|
||||||
|
id: edge.id(),
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
edgeType: edge.data("edgeType") || "zone_boundary",
|
||||||
|
sourceInside,
|
||||||
|
targetInside,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: zone.id,
|
||||||
|
label: zone.label,
|
||||||
|
field: zone.field,
|
||||||
|
value: zone.value,
|
||||||
|
nodeIds: Array.from(nodeIds),
|
||||||
|
boundaryEdges,
|
||||||
|
internalEdgeCount,
|
||||||
|
position: zoneCollapsePosition(zone.nodes || []),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addZoneCollapseElements = () => {
|
||||||
|
if (!cy || !collapsedZoneSnapshots.size) return;
|
||||||
|
collapsedZoneSnapshots.forEach((snapshot) => {
|
||||||
|
const zoneNodeId = zoneCollapseNodeId(snapshot.id);
|
||||||
|
if (!cy.getElementById(zoneNodeId).length) {
|
||||||
|
cy.add({
|
||||||
|
group: "nodes",
|
||||||
|
data: {
|
||||||
|
id: zoneNodeId,
|
||||||
|
stableKey: zoneNodeId,
|
||||||
|
kind: "Zone",
|
||||||
|
layer: "zone",
|
||||||
|
label: snapshot.label,
|
||||||
|
name: `${snapshot.label} zone`,
|
||||||
|
description: `Collapsed zone with ${snapshot.nodeIds.length} hidden node${snapshot.nodeIds.length === 1 ? "" : "s"}.`,
|
||||||
|
displayState: "show",
|
||||||
|
visualSize: 72,
|
||||||
|
zoneCollapse: true,
|
||||||
|
collapsedZoneId: snapshot.id,
|
||||||
|
collapsedZoneLabel: snapshot.label,
|
||||||
|
containedNodeCount: snapshot.nodeIds.length,
|
||||||
|
containedInternalEdgeCount: snapshot.internalEdgeCount,
|
||||||
|
boundaryEdgeCount: snapshot.boundaryEdges.length,
|
||||||
|
},
|
||||||
|
classes: "zone-collapse-node",
|
||||||
|
position: snapshot.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
snapshot.boundaryEdges.forEach((edge, index) => {
|
||||||
|
const source = edge.sourceInside ? zoneNodeId : edge.source;
|
||||||
|
const target = edge.targetInside ? zoneNodeId : edge.target;
|
||||||
|
if (source === target || !cy.getElementById(source).length || !cy.getElementById(target).length) return;
|
||||||
|
const edgeId = `${zoneCollapsePrefix}edge:${snapshot.id}:${index}:${edge.id}`;
|
||||||
|
if (cy.getElementById(edgeId).length) return;
|
||||||
|
cy.add({
|
||||||
|
group: "edges",
|
||||||
|
data: {
|
||||||
|
id: edgeId,
|
||||||
|
stableKey: edgeId,
|
||||||
|
kind: "edge",
|
||||||
|
layer: "zone",
|
||||||
|
label: edge.edgeType,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
edgeType: edge.edgeType,
|
||||||
|
strength: "medium",
|
||||||
|
edgeWidth: 3,
|
||||||
|
displayState: "show",
|
||||||
|
zoneCollapse: true,
|
||||||
|
collapsedZoneId: snapshot.id,
|
||||||
|
originalEdgeId: edge.id,
|
||||||
|
},
|
||||||
|
classes: "zone-collapse-edge",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyZoneCollapseVisibility = (hiddenNodes) => {
|
||||||
|
if (!cy || !collapsedZoneSnapshots.size) return;
|
||||||
|
collapsedZoneSnapshots.forEach((snapshot) => {
|
||||||
|
snapshot.nodeIds.forEach((nodeId) => {
|
||||||
|
const node = cy.getElementById(nodeId);
|
||||||
|
if (!node.length) return;
|
||||||
|
node.data("displayState", "hide");
|
||||||
|
node.style("display", "none");
|
||||||
|
hiddenNodes.add(nodeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseZone = (zone) => {
|
||||||
|
if (!cy || !zone || !(zone.nodes || []).length) return;
|
||||||
|
collapsedZoneSnapshots.set(zone.id, zoneCollapseSnapshot(zone));
|
||||||
|
selected = null;
|
||||||
|
selectedZoneId = "";
|
||||||
|
applyFilters({redrawOnRemove: true});
|
||||||
|
runLayout();
|
||||||
|
updateProfileSummary(`Collapsed zone "${zone.label}".`);
|
||||||
|
updateUrlState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandZone = (zoneId) => {
|
||||||
|
if (!cy || !zoneId) return;
|
||||||
|
const snapshot = collapsedZoneSnapshots.get(zoneId);
|
||||||
|
collapsedZoneSnapshots.delete(zoneId);
|
||||||
|
selected = null;
|
||||||
|
selectedZoneId = "";
|
||||||
|
applyFilters({redrawOnRemove: true});
|
||||||
|
runLayout();
|
||||||
|
updateProfileSummary(snapshot ? `Expanded zone "${snapshot.label}".` : "");
|
||||||
|
updateUrlState();
|
||||||
|
};
|
||||||
|
|
||||||
const renderZoneOverlay = () => {
|
const renderZoneOverlay = () => {
|
||||||
if (!zoneOverlay || !cy) return;
|
if (!zoneOverlay || !cy) return;
|
||||||
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
|
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
|
||||||
@@ -1406,11 +1805,13 @@ def graph_explorer_page() -> str:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
zoneSummaries = collectZoneSummaries();
|
zoneSummaries = collectZoneSummaries();
|
||||||
|
syncZoneContainers(zoneSummaries);
|
||||||
const boundaries = Array.from(zoneSummaries.values())
|
const boundaries = Array.from(zoneSummaries.values())
|
||||||
.filter((zone) => zone.nodes.length > 0)
|
.filter((zone) => zone.nodes.length > 0)
|
||||||
.sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label))
|
.sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label))
|
||||||
.map((zone, index) => {
|
.map((zone, index) => {
|
||||||
const bounds = zoneBoundsForNodes(zone.nodes);
|
const container = ensureZoneContainer(zone);
|
||||||
|
const bounds = container ? zoneRenderedBoundsFromContainer(container) : zoneBoundsForNodes(zone.nodes);
|
||||||
if (!bounds) return "";
|
if (!bounds) return "";
|
||||||
const style = zoneStyle(zone);
|
const style = zoneStyle(zone);
|
||||||
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
|
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
|
||||||
@@ -1429,6 +1830,76 @@ def graph_explorer_page() -> str:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startZoneDrag = (event, zoneId) => {
|
||||||
|
const zone = zoneSummaries.get(zoneId);
|
||||||
|
if (!cy || !zone || !(zone.nodes || []).length) return;
|
||||||
|
const container = ensureZoneContainer(zone);
|
||||||
|
if (!container) 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,
|
||||||
|
container: {...container},
|
||||||
|
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});
|
||||||
|
});
|
||||||
|
const container = zoneContainerState.get(zoneDragState.zoneId);
|
||||||
|
if (container && zoneDragState.container) {
|
||||||
|
container.x = zoneDragState.container.x + dx;
|
||||||
|
container.y = zoneDragState.container.y + dy;
|
||||||
|
container.userPlaced = true;
|
||||||
|
}
|
||||||
|
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}".`);
|
||||||
|
updateProfileControls();
|
||||||
|
updateUrlState();
|
||||||
|
window.setTimeout(() => { suppressZoneClick = false; }, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleZoneOverlayUpdate = () => {
|
const scheduleZoneOverlayUpdate = () => {
|
||||||
if (!zoneOverlay || zoneOverlayFrame) return;
|
if (!zoneOverlay || zoneOverlayFrame) return;
|
||||||
zoneOverlayFrame = window.requestAnimationFrame(() => {
|
zoneOverlayFrame = window.requestAnimationFrame(() => {
|
||||||
@@ -1557,20 +2028,43 @@ def graph_explorer_page() -> str:
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentViewState = () => ({
|
const currentZoneViewState = () => {
|
||||||
search: searchInput.value,
|
const visible = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
|
||||||
mode: modeSelect.value || "full",
|
const grouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
|
||||||
layout: layoutSelect.value || "cose",
|
const definitionSet = zoneDefinitionSets[activeZoneDefinitionSet]
|
||||||
labelMode: labelSelect.value || "auto",
|
? activeZoneDefinitionSet
|
||||||
nodeTypes: Array.from(selectedNodeTypes()),
|
: "fabric-default";
|
||||||
edgeTypes: Array.from(selectedEdgeTypes()),
|
return {
|
||||||
review: reviewFilter.value,
|
visible,
|
||||||
unresolved: unresolvedFilter.value,
|
grouping,
|
||||||
zoneBoundaries: zoneBoundaryToggle ? zoneBoundaryToggle.checked : true,
|
definitionSet,
|
||||||
zoneGrouping: zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment",
|
presentation: {
|
||||||
rules: filterRules.map((rule) => ({...rule})),
|
boundaries: visible,
|
||||||
manualOverrides: {...manualOverrides},
|
labels: true,
|
||||||
});
|
},
|
||||||
|
containers: serializedZoneContainers(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentViewState = () => {
|
||||||
|
const zone = currentZoneViewState();
|
||||||
|
return {
|
||||||
|
search: searchInput.value,
|
||||||
|
mode: modeSelect.value || "full",
|
||||||
|
layout: layoutSelect.value || "cose",
|
||||||
|
labelMode: labelSelect.value || "auto",
|
||||||
|
nodeTypes: Array.from(selectedNodeTypes()),
|
||||||
|
edgeTypes: Array.from(selectedEdgeTypes()),
|
||||||
|
review: reviewFilter.value,
|
||||||
|
unresolved: unresolvedFilter.value,
|
||||||
|
zone,
|
||||||
|
zoneBoundaries: zone.visible,
|
||||||
|
zoneGrouping: zone.grouping,
|
||||||
|
zoneDefinitionSet: zone.definitionSet,
|
||||||
|
rules: filterRules.map((rule) => ({...rule})),
|
||||||
|
manualOverrides: {...manualOverrides},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const encodeStateBlob = (state) => {
|
const encodeStateBlob = (state) => {
|
||||||
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
||||||
@@ -1598,6 +2092,7 @@ def graph_explorer_page() -> str:
|
|||||||
if (params.has("unresolved")) state.unresolved = params.get("unresolved") || "";
|
if (params.has("unresolved")) state.unresolved = params.get("unresolved") || "";
|
||||||
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
|
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
|
||||||
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
|
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
|
||||||
|
if (params.has("zoneDefinitionSet")) state.zoneDefinitionSet = params.get("zoneDefinitionSet") || "";
|
||||||
if (params.has("profile")) state.profile = params.get("profile") || "";
|
if (params.has("profile")) state.profile = params.get("profile") || "";
|
||||||
if (params.has("state")) {
|
if (params.has("state")) {
|
||||||
try {
|
try {
|
||||||
@@ -1622,6 +2117,7 @@ def graph_explorer_page() -> str:
|
|||||||
if (state.unresolved) params.set("unresolved", state.unresolved);
|
if (state.unresolved) params.set("unresolved", state.unresolved);
|
||||||
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
|
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
|
||||||
if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping);
|
if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping);
|
||||||
|
if (state.zoneDefinitionSet && state.zoneDefinitionSet !== "fabric-default") params.set("zoneDefinitionSet", state.zoneDefinitionSet);
|
||||||
if (currentProfileId) params.set("profile", currentProfileId);
|
if (currentProfileId) params.set("profile", currentProfileId);
|
||||||
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
|
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
|
||||||
params.set("state", encodeStateBlob(state));
|
params.set("state", encodeStateBlob(state));
|
||||||
@@ -1638,6 +2134,35 @@ def graph_explorer_page() -> str:
|
|||||||
const optionExists = (select, value) =>
|
const optionExists = (select, value) =>
|
||||||
Array.from(select.options).some((option) => option.value === value);
|
Array.from(select.options).some((option) => option.value === value);
|
||||||
|
|
||||||
|
const normalizeZoneViewState = (state) => {
|
||||||
|
const nested = state.zone && typeof state.zone === "object" ? state.zone : {};
|
||||||
|
const presentation = nested.presentation && typeof nested.presentation === "object"
|
||||||
|
? nested.presentation
|
||||||
|
: {};
|
||||||
|
const containers = nested.containers && typeof nested.containers === "object"
|
||||||
|
? nested.containers
|
||||||
|
: state.zoneContainers;
|
||||||
|
const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment";
|
||||||
|
const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet]
|
||||||
|
? nested.definitionSet || state.zoneDefinitionSet
|
||||||
|
: "fabric-default";
|
||||||
|
const visible = "visible" in nested
|
||||||
|
? nested.visible !== false
|
||||||
|
: "zoneBoundaries" in state
|
||||||
|
? state.zoneBoundaries !== false
|
||||||
|
: true;
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
grouping: zoneGroupSelect && optionExists(zoneGroupSelect, grouping) ? grouping : "deploymentEnvironment",
|
||||||
|
definitionSet,
|
||||||
|
presentation: {
|
||||||
|
boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible,
|
||||||
|
labels: "labels" in presentation ? presentation.labels !== false : true,
|
||||||
|
},
|
||||||
|
containers: normalizedZoneContainers(containers),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const applyViewState = (state, options = {}) => {
|
const applyViewState = (state, options = {}) => {
|
||||||
if ("search" in state) searchInput.value = state.search || "";
|
if ("search" in state) searchInput.value = state.search || "";
|
||||||
if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full";
|
if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full";
|
||||||
@@ -1648,9 +2173,12 @@ def graph_explorer_page() -> str:
|
|||||||
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
|
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
|
||||||
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
|
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
|
||||||
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
|
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
|
||||||
if ("zoneBoundaries" in state && zoneBoundaryToggle) zoneBoundaryToggle.checked = state.zoneBoundaries !== false;
|
if ("zone" in state || "zoneBoundaries" in state || "zoneGrouping" in state || "zoneDefinitionSet" in state) {
|
||||||
if ("zoneGrouping" in state && zoneGroupSelect) {
|
const zone = normalizeZoneViewState(state);
|
||||||
zoneGroupSelect.value = optionExists(zoneGroupSelect, state.zoneGrouping) ? state.zoneGrouping : "deploymentEnvironment";
|
activeZoneDefinitionSet = zone.definitionSet;
|
||||||
|
if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible;
|
||||||
|
if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping;
|
||||||
|
zoneContainerState = zone.containers;
|
||||||
}
|
}
|
||||||
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
||||||
manualOverrides = {...state.manualOverrides};
|
manualOverrides = {...state.manualOverrides};
|
||||||
@@ -1665,6 +2193,7 @@ def graph_explorer_page() -> str:
|
|||||||
activeLabelMode = labelSelect.value || "auto";
|
activeLabelMode = labelSelect.value || "auto";
|
||||||
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
|
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
|
||||||
selectedZoneId = "";
|
selectedZoneId = "";
|
||||||
|
collapsedZoneSnapshots = new Map();
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
syncFilterSummaries();
|
syncFilterSummaries();
|
||||||
applyFilters();
|
applyFilters();
|
||||||
@@ -1832,6 +2361,7 @@ def graph_explorer_page() -> str:
|
|||||||
|
|
||||||
const applyFilters = (options = {}) => {
|
const applyFilters = (options = {}) => {
|
||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
|
removeZoneCollapseElements();
|
||||||
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
|
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
|
||||||
syncFilterSummaries();
|
syncFilterSummaries();
|
||||||
const hiddenNodes = new Set();
|
const hiddenNodes = new Set();
|
||||||
@@ -1851,6 +2381,7 @@ def graph_explorer_page() -> str:
|
|||||||
if (state === "remove") removedNodes.add(node.id());
|
if (state === "remove") removedNodes.add(node.id());
|
||||||
if (state === "hide") hiddenNodes.add(node.id());
|
if (state === "hide") hiddenNodes.add(node.id());
|
||||||
});
|
});
|
||||||
|
applyZoneCollapseVisibility(hiddenNodes);
|
||||||
cy.edges().forEach((edge) => {
|
cy.edges().forEach((edge) => {
|
||||||
let state = matchesFilters(edge) ? "show" : "hide";
|
let state = matchesFilters(edge) ? "show" : "hide";
|
||||||
const ruleAction = ruleActionFor(edge);
|
const ruleAction = ruleActionFor(edge);
|
||||||
@@ -1869,6 +2400,8 @@ def graph_explorer_page() -> str:
|
|||||||
edge.toggleClass("rule-highlight", state === "highlight");
|
edge.toggleClass("rule-highlight", state === "highlight");
|
||||||
edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
|
edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
|
||||||
});
|
});
|
||||||
|
addZoneCollapseElements();
|
||||||
|
updateLabelVisibility();
|
||||||
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
|
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
|
||||||
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
|
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
|
||||||
const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
|
const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
|
||||||
@@ -1915,6 +2448,10 @@ def graph_explorer_page() -> str:
|
|||||||
["mapping", data.mappingFit],
|
["mapping", data.mappingFit],
|
||||||
["display only", data.displayOnly === true ? "yes" : ""],
|
["display only", data.displayOnly === true ? "yes" : ""],
|
||||||
["strength", data.strength],
|
["strength", data.strength],
|
||||||
|
["collapsed zone", data.collapsedZoneLabel],
|
||||||
|
["contained nodes", data.containedNodeCount],
|
||||||
|
["internal edges", data.containedInternalEdgeCount],
|
||||||
|
["boundary edges", data.boundaryEdgeCount],
|
||||||
["deployment environment", data.deploymentEnvironment],
|
["deployment environment", data.deploymentEnvironment],
|
||||||
["deployment scenario", data.deploymentScenario],
|
["deployment scenario", data.deploymentScenario],
|
||||||
["routing authority", data.routingAuthority],
|
["routing authority", data.routingAuthority],
|
||||||
@@ -1939,9 +2476,24 @@ def graph_explorer_page() -> str:
|
|||||||
const data = element.data();
|
const data = element.data();
|
||||||
const contextIds = new Set([element.id()]);
|
const contextIds = new Set([element.id()]);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
const extraOrientationActions = [];
|
||||||
let title = "Graph context";
|
let title = "Graph context";
|
||||||
let profileName = `Fabric context: ${elementLabel(element)}`;
|
let profileName = `Fabric context: ${elementLabel(element)}`;
|
||||||
if (data.kind === "Repository" && data.lifecycle === "registered-only") {
|
if (data.kind === "Zone" && data.zoneCollapse === true) {
|
||||||
|
title = "Collapsed zone";
|
||||||
|
profileName = `Zone: ${data.collapsedZoneLabel || elementLabel(element)}`;
|
||||||
|
const neighborhood = element.neighborhood();
|
||||||
|
neighborhood.forEach((item) => addContextElement(contextIds, item));
|
||||||
|
rows.push(
|
||||||
|
{label: "zone", value: data.collapsedZoneLabel || data.id, state: "good"},
|
||||||
|
{label: "contained nodes", value: String(data.containedNodeCount || 0)},
|
||||||
|
{label: "internal edges", value: String(data.containedInternalEdgeCount || 0)},
|
||||||
|
{label: "boundary edges", value: String(data.boundaryEdgeCount || 0)}
|
||||||
|
);
|
||||||
|
extraOrientationActions.push(
|
||||||
|
`<button type="button" data-orientation-action="expand-zone" data-zone-id="${escapeHtml(data.collapsedZoneId)}">Expand Zone</button>`
|
||||||
|
);
|
||||||
|
} else if (data.kind === "Repository" && data.lifecycle === "registered-only") {
|
||||||
title = "Onboarding gap";
|
title = "Onboarding gap";
|
||||||
profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`;
|
profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`;
|
||||||
rows.push(
|
rows.push(
|
||||||
@@ -2098,6 +2650,7 @@ def graph_explorer_page() -> str:
|
|||||||
`)
|
`)
|
||||||
.join("");
|
.join("");
|
||||||
orientationActions.innerHTML = `
|
orientationActions.innerHTML = `
|
||||||
|
${extraOrientationActions.join("")}
|
||||||
<button type="button" data-orientation-action="focus">Focus Context</button>
|
<button type="button" data-orientation-action="focus">Focus Context</button>
|
||||||
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
||||||
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
||||||
@@ -2175,6 +2728,8 @@ def graph_explorer_page() -> str:
|
|||||||
}
|
}
|
||||||
: {name, padding: 48, animate: false};
|
: {name, padding: 48, animate: false};
|
||||||
layoutElements.layout(options).run();
|
layoutElements.layout(options).run();
|
||||||
|
applyZoneContainerLayout();
|
||||||
|
fitVisibleGraph();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
scheduleZoneOverlayUpdate();
|
scheduleZoneOverlayUpdate();
|
||||||
};
|
};
|
||||||
@@ -2258,6 +2813,20 @@ def graph_explorer_page() -> str:
|
|||||||
{selector: "node[layer = 'binding']", style: {"shape": "rhomboid"}},
|
{selector: "node[layer = 'binding']", style: {"shape": "rhomboid"}},
|
||||||
{selector: "node[unresolved = true]", style: {"border-color": "#b45309", "border-style": "dashed", "border-width": 3}},
|
{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: "node[lifecycle = 'registered-only']", style: {"border-color": "#be123c", "border-style": "dashed", "border-width": 3}},
|
||||||
|
{selector: "node[zoneCollapse = true]", style: {
|
||||||
|
"shape": "round-rectangle",
|
||||||
|
"background-color": "#334155",
|
||||||
|
"border-color": "#0f766e",
|
||||||
|
"border-width": 3,
|
||||||
|
"border-style": "double",
|
||||||
|
"color": "#172033",
|
||||||
|
"font-weight": 700,
|
||||||
|
"text-background-color": "#ffffff",
|
||||||
|
"text-background-opacity": .86,
|
||||||
|
"text-background-padding": 3,
|
||||||
|
"width": "data(visualSize)",
|
||||||
|
"height": "data(visualSize)"
|
||||||
|
}},
|
||||||
{selector: "edge", style: {
|
{selector: "edge", style: {
|
||||||
"curve-style": "bezier",
|
"curve-style": "bezier",
|
||||||
"line-color": "#98a2b3",
|
"line-color": "#98a2b3",
|
||||||
@@ -2267,6 +2836,7 @@ def graph_explorer_page() -> str:
|
|||||||
}},
|
}},
|
||||||
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
||||||
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
||||||
|
{selector: "edge[zoneCollapse = true]", style: {"line-style": "dashed", "line-color": "#0f766e", "target-arrow-color": "#0f766e"}},
|
||||||
{selector: "node.rule-highlight", style: {
|
{selector: "node.rule-highlight", style: {
|
||||||
"border-color": "#2563eb",
|
"border-color": "#2563eb",
|
||||||
"border-width": 3,
|
"border-width": 3,
|
||||||
@@ -2296,7 +2866,12 @@ def graph_explorer_page() -> str:
|
|||||||
renderRules();
|
renderRules();
|
||||||
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
||||||
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
||||||
cy.on("pan zoom resize render layoutstop", () => {
|
cy.on("pan zoom resize render", () => {
|
||||||
|
updateSelectionAnchor();
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
|
});
|
||||||
|
cy.on("layoutstop", () => {
|
||||||
|
applyZoneContainerLayout();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
scheduleZoneOverlayUpdate();
|
scheduleZoneOverlayUpdate();
|
||||||
});
|
});
|
||||||
@@ -2384,6 +2959,7 @@ def graph_explorer_page() -> str:
|
|||||||
zoneGroupSelect.addEventListener("input", () => {
|
zoneGroupSelect.addEventListener("input", () => {
|
||||||
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
|
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
|
||||||
selectedZoneId = "";
|
selectedZoneId = "";
|
||||||
|
collapsedZoneSnapshots = new Map();
|
||||||
currentProfileId = "";
|
currentProfileId = "";
|
||||||
profileSelect.value = "";
|
profileSelect.value = "";
|
||||||
renderZoneOverlay();
|
renderZoneOverlay();
|
||||||
@@ -2393,6 +2969,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);
|
||||||
@@ -2401,6 +2981,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({
|
||||||
@@ -2479,6 +3067,15 @@ def graph_explorer_page() -> str:
|
|||||||
orientationActions.addEventListener("click", (event) => {
|
orientationActions.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest("[data-orientation-action]");
|
const button = event.target.closest("[data-orientation-action]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
if (button.dataset.orientationAction === "collapse-zone") {
|
||||||
|
const zone = zoneSummaries.get(button.dataset.zoneId || selectedZoneId);
|
||||||
|
if (zone) collapseZone(zone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button.dataset.orientationAction === "expand-zone") {
|
||||||
|
expandZone(button.dataset.zoneId || selected?.data("collapsedZoneId") || selectedZoneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
applyOrientationContext(button.dataset.orientationAction);
|
applyOrientationContext(button.dataset.orientationAction);
|
||||||
});
|
});
|
||||||
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
||||||
@@ -2492,7 +3089,9 @@ def graph_explorer_page() -> str:
|
|||||||
zoneBoundaryToggle.checked = true;
|
zoneBoundaryToggle.checked = true;
|
||||||
zoneGroupSelect.value = "deploymentEnvironment";
|
zoneGroupSelect.value = "deploymentEnvironment";
|
||||||
activeZoneGrouping = "deploymentEnvironment";
|
activeZoneGrouping = "deploymentEnvironment";
|
||||||
|
activeZoneDefinitionSet = "fabric-default";
|
||||||
selectedZoneId = "";
|
selectedZoneId = "";
|
||||||
|
collapsedZoneSnapshots = new Map();
|
||||||
setCheckedValues(nodeTypeFilter);
|
setCheckedValues(nodeTypeFilter);
|
||||||
setCheckedValues(edgeTypeFilter);
|
setCheckedValues(edgeTypeFilter);
|
||||||
reviewFilter.value = "";
|
reviewFilter.value = "";
|
||||||
|
|||||||
@@ -401,6 +401,8 @@ def resolve_zones(
|
|||||||
internal_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
|
internal_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
|
||||||
boundary_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
|
boundary_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
|
||||||
for edge in edge_records:
|
for edge in edge_records:
|
||||||
|
if _is_context_only_edge(edge):
|
||||||
|
continue
|
||||||
source_zone_id = assignments.get(edge.source).zone_id if edge.source in assignments else None
|
source_zone_id = assignments.get(edge.source).zone_id if edge.source in assignments else None
|
||||||
target_zone_id = assignments.get(edge.target).zone_id if edge.target in assignments else None
|
target_zone_id = assignments.get(edge.target).zone_id if edge.target in assignments else None
|
||||||
if source_zone_id and source_zone_id == target_zone_id:
|
if source_zone_id and source_zone_id == target_zone_id:
|
||||||
@@ -484,6 +486,8 @@ def _attraction_candidates(
|
|||||||
nodes_by_id = {node.id: node for node in node_records}
|
nodes_by_id = {node.id: node for node in node_records}
|
||||||
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
|
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
|
||||||
for edge in edge_records:
|
for edge in edge_records:
|
||||||
|
if _is_context_only_edge(edge):
|
||||||
|
continue
|
||||||
adjacency[edge.source].append(edge)
|
adjacency[edge.source].append(edge)
|
||||||
adjacency[edge.target].append(edge)
|
adjacency[edge.target].append(edge)
|
||||||
|
|
||||||
@@ -619,6 +623,14 @@ def _edge_matches_attraction_rule(edge: _EdgeRecord, rule: ZoneAttractionRule) -
|
|||||||
return _rule_matches(edge.data, rule.edge_filter, empty_matches=True)
|
return _rule_matches(edge.data, rule.edge_filter, empty_matches=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_context_only_edge(edge: _EdgeRecord) -> bool:
|
||||||
|
return _trueish(edge.data.get("displayOnly", edge.data.get("display_only"))) or edge.edge_type == "declares"
|
||||||
|
|
||||||
|
|
||||||
|
def _trueish(value: Any) -> bool:
|
||||||
|
return value is True or str(value).lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
def _neighbor_for_direction(node_id: str, edge: _EdgeRecord, direction: str) -> str:
|
def _neighbor_for_direction(node_id: str, edge: _EdgeRecord, direction: str) -> str:
|
||||||
if direction in {"out", "both"} and edge.source == node_id:
|
if direction in {"out", "both"} and edge.source == node_id:
|
||||||
return edge.target
|
return edge.target
|
||||||
|
|||||||
@@ -422,6 +422,34 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
|
assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
|
||||||
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
|
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
|
||||||
assert "zone.diagnostics" in page
|
assert "zone.diagnostics" in page
|
||||||
|
assert "isZoneContextOnlyEdge" in page
|
||||||
|
assert "isZoneConnectivityEdge" in page
|
||||||
|
assert "currentZoneViewState" in page
|
||||||
|
assert "normalizeZoneViewState" in page
|
||||||
|
assert "zoneDefinitionSet" in page
|
||||||
|
assert "zoneDefinitionSets" in page
|
||||||
|
assert "fabric-default" in page
|
||||||
|
assert "zoneContainerState" in page
|
||||||
|
assert "ensureZoneContainer" in page
|
||||||
|
assert "packZoneContainers" in page
|
||||||
|
assert "applyZoneContainerLayout" in page
|
||||||
|
assert "layoutZoneSubgraph" in page
|
||||||
|
assert "serializedZoneContainers" in page
|
||||||
|
assert "normalizedZoneContainers" 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
|
||||||
|
assert "Collapse Zone" in page
|
||||||
|
assert "Expand Zone" in page
|
||||||
assert 'normalize: "deploymentEnvironment"' in page
|
assert 'normalize: "deploymentEnvironment"' in page
|
||||||
assert "zoneBoundsForNodes" in page
|
assert "zoneBoundsForNodes" in page
|
||||||
assert "renderZoneOverlay" in page
|
assert "renderZoneOverlay" in page
|
||||||
|
|||||||
@@ -96,6 +96,43 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_ignores_context_only_edges_for_boundaries_and_attraction() -> None:
|
||||||
|
resolution = resolve_zones(
|
||||||
|
nodes=[
|
||||||
|
_node("repo", kind="Repository"),
|
||||||
|
_node("svc.prod", deploymentEnvironment="prod"),
|
||||||
|
_node("svc.context", kind="service"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
_edge("edge.repo-prod", "repo", "svc.prod", "declares", displayOnly=True),
|
||||||
|
_edge("edge.prod-context", "svc.prod", "svc.context", "declares", displayOnly=True),
|
||||||
|
],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "prod",
|
||||||
|
"label": "prod",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
"attraction": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"edge_type": "*",
|
||||||
|
"direction": "both",
|
||||||
|
"depth": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.zone_by_id("prod").boundary_edge_ids == ()
|
||||||
|
assert resolution.zone_by_id("prod").internal_edge_ids == ()
|
||||||
|
assert "svc.context" not in resolution.node_assignments
|
||||||
|
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" not in {
|
||||||
|
diagnostic.code for diagnostic in resolution.diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
|
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
|
||||||
resolution = resolve_zones(
|
resolution = resolve_zones(
|
||||||
nodes=[
|
nodes=[
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Promote graph zones to first-class visualization entities"
|
title: "Promote graph zones to first-class visualization entities"
|
||||||
domain: railiance
|
domain: railiance
|
||||||
repo: railiance-fabric
|
repo: railiance-fabric
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: railiance-fabric
|
topic_slug: railiance-fabric
|
||||||
created: "2026-05-24"
|
created: "2026-05-24"
|
||||||
@@ -156,7 +156,7 @@ resolver path.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0022-T05
|
id: RAIL-FAB-WP-0022-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "765caa50-f372-4ab4-adb4-87660e684c54"
|
state_hub_task_id: "765caa50-f372-4ab4-adb4-87660e684c54"
|
||||||
```
|
```
|
||||||
@@ -173,11 +173,18 @@ At minimum, preserve:
|
|||||||
Expected result: saved views restore the same zone mode and default definition
|
Expected result: saved views restore the same zone mode and default definition
|
||||||
set after reload.
|
set after reload.
|
||||||
|
|
||||||
|
Result: Added explicit nested zone state to graph explorer profiles with
|
||||||
|
`visible`, `grouping`, `definitionSet`, and `presentation` fields while keeping
|
||||||
|
legacy URL aliases for `zoneBoundaries`, `zoneGrouping`, and
|
||||||
|
`zoneDefinitionSet`. Saved and copied views now preserve the active zone
|
||||||
|
definition set and presentation state, and profile restore normalizes unknown
|
||||||
|
future definition sets back to the Fabric default.
|
||||||
|
|
||||||
## Task 6: Prototype Collapse-To-Zone-Node
|
## Task 6: Prototype Collapse-To-Zone-Node
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0022-T06
|
id: RAIL-FAB-WP-0022-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "7f3676cb-3d2e-417c-a385-f95545bcd738"
|
state_hub_task_id: "7f3676cb-3d2e-417c-a385-f95545bcd738"
|
||||||
```
|
```
|
||||||
@@ -196,11 +203,18 @@ The prototype should:
|
|||||||
Expected result: collapse behavior works for one zone at a time and is covered
|
Expected result: collapse behavior works for one zone at a time and is covered
|
||||||
by focused tests. Multi-zone hierarchy can remain future work.
|
by focused tests. Multi-zone hierarchy can remain future work.
|
||||||
|
|
||||||
|
Result: Added a view-only zone collapse prototype to the graph explorer. Zone
|
||||||
|
detail panels now offer `Collapse Zone`; collapsed zones hide member nodes,
|
||||||
|
render a synthetic zone node with node/internal-edge/boundary-edge summaries,
|
||||||
|
draw synthetic boundary edges to visible external neighbors, and expose
|
||||||
|
`Expand Zone` from the collapsed zone node. Expanding removes synthetic elements
|
||||||
|
and restores the original graph view without changing the underlying payload.
|
||||||
|
|
||||||
## Task 7: Prepare For Per-Zone Layout
|
## Task 7: Prepare For Per-Zone Layout
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0022-T07
|
id: RAIL-FAB-WP-0022-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "4b6f0b7e-a066-490d-8160-ba23b03cf820"
|
state_hub_task_id: "4b6f0b7e-a066-490d-8160-ba23b03cf820"
|
||||||
```
|
```
|
||||||
@@ -215,6 +229,12 @@ a follow-up workplan for two-phase layout.
|
|||||||
Expected result: the codebase has a documented path toward per-zone layouts
|
Expected result: the codebase has a documented path toward per-zone layouts
|
||||||
without destabilizing the current graph explorer.
|
without destabilizing the current graph explorer.
|
||||||
|
|
||||||
|
Result: Documented the per-zone layout preparation path in
|
||||||
|
`docs/ZoneEntityVisualization.md`. The recommended direction is a two-phase
|
||||||
|
layout: resolve zones and containers first, then compute local zone coordinates
|
||||||
|
and project them into Cytoscape space. The note identifies the prerequisites
|
||||||
|
already established by WP-0022 and avoids a premature nested-layout rewrite.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- Zone entity behavior is documented in `docs/ZoneEntityVisualization.md`.
|
- Zone entity behavior is documented in `docs/ZoneEntityVisualization.md`.
|
||||||
|
|||||||
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.
|
||||||
129
workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md
Normal file
129
workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
id: RAIL-FAB-WP-0024
|
||||||
|
type: workplan
|
||||||
|
title: "Stabilize zone containers and layout zone subgraphs"
|
||||||
|
domain: railiance
|
||||||
|
repo: railiance-fabric
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: railiance-fabric
|
||||||
|
created: "2026-05-25"
|
||||||
|
updated: "2026-05-25"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stabilize Zone Containers And Layout Zone Subgraphs
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RAIL-FAB-WP-0022 made zones first-class visualization entities and
|
||||||
|
RAIL-FAB-WP-0023 added plain labels plus view-only zone dragging. The current
|
||||||
|
prototype still derives a zone rectangle directly from the current positions of
|
||||||
|
its member nodes. When the operator changes the layout algorithm or reruns
|
||||||
|
layout, the zone surface moves with the global graph layout instead of staying
|
||||||
|
where the operator placed it.
|
||||||
|
|
||||||
|
The current resolver also treats every edge attached to a zoned node as zone
|
||||||
|
context. That includes display-only repository declaration edges. Those edges
|
||||||
|
help explain where graph declarations came from, but they should not be read as
|
||||||
|
direct deployment or operational connections across a zone boundary.
|
||||||
|
|
||||||
|
This workplan moves the graph explorer toward the intended model: a zone is a
|
||||||
|
stable drawing surface, and the assigned subgraph is laid out inside that
|
||||||
|
surface as a separate view concern.
|
||||||
|
|
||||||
|
## Task 1: Separate Context Edges From Zone Boundary Connectivity
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0024-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Exclude display-only/context edges, especially repository `declares` edges,
|
||||||
|
from zone boundary diagnostics and collapsed-zone boundary edges.
|
||||||
|
|
||||||
|
Expected result: zone detail may still mention contextual evidence when useful,
|
||||||
|
but display-only declaration edges do not make it look as if every zoned node is
|
||||||
|
directly connected to an unzoned repository.
|
||||||
|
|
||||||
|
Result: Display-only edges and repository `declares` edges are now treated as
|
||||||
|
context-only in both the shared zone resolver and the browser view. They do not
|
||||||
|
create boundary diagnostics, attraction paths, or collapsed-zone boundary
|
||||||
|
edges.
|
||||||
|
|
||||||
|
## Task 2: Persist Stable Zone Container Positions
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0024-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Introduce a view-level zone container state keyed by stable zone id.
|
||||||
|
|
||||||
|
The implementation should:
|
||||||
|
|
||||||
|
- remember zone container center and size after layout and drag;
|
||||||
|
- keep user-moved zone positions stable when the global layout algorithm
|
||||||
|
changes;
|
||||||
|
- keep saved/copied view state compatible with the nested `zone` object;
|
||||||
|
- avoid mutating the Fabric graph payload.
|
||||||
|
|
||||||
|
Expected result: switching between layout algorithms keeps zone papers in the
|
||||||
|
same operator-chosen positions.
|
||||||
|
|
||||||
|
Result: The graph explorer now stores stable zone containers in view state.
|
||||||
|
Containers remember graph-coordinate position and size, survive global layout
|
||||||
|
reruns, can be saved/copied through the nested `zone` state, and remain separate
|
||||||
|
from Fabric graph payload data.
|
||||||
|
|
||||||
|
## Task 3: Layout Zone Subgraphs Inside Containers
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0024-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a per-zone layout pass that positions assigned visible nodes inside their
|
||||||
|
zone container after the global layout places the surrounding graph.
|
||||||
|
|
||||||
|
The first implementation may use a deterministic compact layout rather than a
|
||||||
|
full nested Cytoscape layout, as long as it:
|
||||||
|
|
||||||
|
- only moves visible nodes assigned to the zone;
|
||||||
|
- keeps each node inside the zone's drawing surface;
|
||||||
|
- treats unzoned nodes as part of the base canvas;
|
||||||
|
- keeps edge routing intact through Cytoscape's normal renderer.
|
||||||
|
|
||||||
|
Expected result: the visible subgraph inside each zone is arranged by the zone
|
||||||
|
view model, not merely enclosed after the global layout.
|
||||||
|
|
||||||
|
Result: After global layout, each visible zone projects its assigned nodes into
|
||||||
|
a local compact layout inside its stable container. New, not-yet-moved zones are
|
||||||
|
packed into readable rows so the default deployment-environment papers start as
|
||||||
|
separate drawing surfaces.
|
||||||
|
|
||||||
|
## Task 4: Verify And Document Zone Layout Semantics
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0024-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the graph explorer contract and run focused validation.
|
||||||
|
|
||||||
|
Verification should cover:
|
||||||
|
|
||||||
|
- static UI assertions for context-edge filtering and zone container helpers;
|
||||||
|
- JavaScript syntax validation;
|
||||||
|
- focused graph explorer and zone resolver tests;
|
||||||
|
- a visual smoke check of the graph explorer.
|
||||||
|
|
||||||
|
Expected result: documentation and tests describe the new semantics, and the
|
||||||
|
running UI shows stable zone containers with locally arranged member nodes.
|
||||||
|
|
||||||
|
Result: Updated the graph explorer contract and zone entity documentation.
|
||||||
|
Focused tests, generated JavaScript syntax validation, full tests, CLI
|
||||||
|
validation, and a headless Edge visual smoke check all passed.
|
||||||
Reference in New Issue
Block a user