diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py
index 9a36c2d..4c841de 100644
--- a/railiance_fabric/graph_explorer_ui.py
+++ b/railiance_fabric/graph_explorer_ui.py
@@ -245,6 +245,50 @@ def graph_explorer_page() -> str:
}
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.detail-list li { overflow-wrap: anywhere; }
+ .rule-panel {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfcfe;
+ }
+ .rule-panel summary {
+ cursor: pointer;
+ list-style: none;
+ padding: 10px;
+ }
+ .rule-panel summary::-webkit-details-marker { display: none; }
+ .rule-editor {
+ display: grid;
+ gap: 8px;
+ padding: 0 10px 10px;
+ }
+ .rule-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ }
+ .rule-list {
+ display: grid;
+ gap: 8px;
+ margin: 10px 0 0;
+ padding: 0;
+ list-style: none;
+ }
+ .rule-item {
+ display: grid;
+ gap: 6px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #ffffff;
+ padding: 8px;
+ }
+ .rule-title {
+ align-items: center;
+ display: flex;
+ gap: 6px;
+ justify-content: space-between;
+ }
+ .rule-actions { display: flex; flex-wrap: wrap; gap: 6px; }
+ .rule-actions button { min-height: 28px; padding: 4px 7px; }
.popup {
position: absolute;
z-index: 4;
@@ -274,6 +318,7 @@ def graph_explorer_page() -> str:
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
.side { border-left: 0; border-top: 1px solid var(--line); }
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
+ .rule-grid { grid-template-columns: 1fr; }
}
@@ -319,7 +364,7 @@ def graph_explorer_page() -> str:
- Profile
+ Profile
@@ -365,6 +410,46 @@ def graph_explorer_page() -> str:
+
+
+ Rules
+
+
+
+
+
+
+
+
+
Rules are applied top to bottom; later matching rules refine earlier ones.
+
+
+
+
+
+
+
+
+
Profile persistence unavailable for this host.
@@ -372,10 +457,10 @@ def graph_explorer_page() -> str:
-
+
-
+
@@ -405,6 +490,13 @@ def graph_explorer_page() -> str:
const edgeTypeSummary = document.getElementById("edge-type-summary");
const reviewFilter = document.getElementById("review-filter");
const unresolvedFilter = document.getElementById("unresolved-filter");
+ const ruleTarget = document.getElementById("rule-target");
+ const ruleType = document.getElementById("rule-type");
+ const ruleAttribute = document.getElementById("rule-attribute");
+ const ruleValue = document.getElementById("rule-value");
+ const ruleAction = document.getElementById("rule-action");
+ const ruleContext = document.getElementById("rule-context");
+ const ruleList = document.getElementById("rule-list");
const profileSelect = document.getElementById("profile-select");
const profileNameInput = document.getElementById("profile-name");
const profileSummary = document.getElementById("profile-summary");
@@ -432,6 +524,8 @@ def graph_explorer_page() -> str:
let profilePersistence = "none";
let profiles = [];
let currentProfileId = "";
+ let filterRules = [];
+ let editingRuleId = "";
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&").replaceAll("<", "<")
@@ -524,6 +618,159 @@ def graph_explorer_page() -> str:
}).join("");
};
+ const ruleActionLabels = {
+ show: "Show",
+ hide: "Hide",
+ blur: "Blur",
+ highlight: "Highlight",
+ remove: "Remove and redraw",
+ };
+
+ const ruleAttributeLabels = {
+ any: "Any attribute",
+ repo: "Repo",
+ kind: "Kind",
+ reviewState: "Review state",
+ unresolved: "Unresolved",
+ lifecycle: "Lifecycle",
+ strength: "Strength",
+ sameRepo: "Same repo",
+ layoutAffinity: "Layout affinity",
+ };
+
+ const ruleAttributeCandidates = {
+ node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle"],
+ edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity"],
+ };
+
+ const renderOptions = (select, options, current = "") => {
+ select.innerHTML = options.map((option) =>
+ ``
+ ).join("");
+ if (options.some((option) => option.value === current)) select.value = current;
+ };
+
+ const ruleElementType = (element, target) =>
+ target === "edge" ? element.data("edgeType") : element.data("layer");
+
+ const currentRuleElements = () => {
+ if (!cy) return [];
+ const target = ruleTarget.value || "node";
+ const type = ruleType.value || "";
+ const elements = target === "edge" ? cy.edges() : cy.nodes();
+ return elements.filter((element) => !type || ruleElementType(element, target) === type).toArray();
+ };
+
+ const valuesForRuleAttribute = (attribute) => {
+ if (attribute === "any") return [{value: "", label: "Any"}];
+ const values = Array.from(new Set(currentRuleElements()
+ .map((element) => element.data(attribute))
+ .filter((value) => value !== undefined && value !== null && value !== "")
+ .map((value) => String(value))
+ )).sort((left, right) => left.localeCompare(right));
+ return values.length
+ ? values.map((value) => ({value, label: humanize(value)}))
+ : [{value: "", label: "No values"}];
+ };
+
+ const availableRuleAttributes = () => {
+ const elements = currentRuleElements();
+ return (ruleAttributeCandidates[ruleTarget.value || "node"] || ["any"])
+ .filter((attribute) => attribute === "any" || elements.some((element) => {
+ const value = element.data(attribute);
+ return value !== undefined && value !== null && value !== "";
+ }))
+ .map((attribute) => ({value: attribute, label: ruleAttributeLabels[attribute] || humanize(attribute)}));
+ };
+
+ const refreshRuleBuilder = (state = {}) => {
+ const target = state.target || ruleTarget.value || "node";
+ ruleTarget.value = target;
+ const types = target === "edge" ? allEdgeTypes : allNodeTypes;
+ const labels = target === "edge" ? {} : nodeTypeLabels;
+ renderOptions(ruleType, [
+ {value: "", label: target === "edge" ? "All edge types" : "All node types"},
+ ...types.map((type) => ({value: type, label: labels[type] || humanize(type)})),
+ ], state.type ?? ruleType.value);
+ renderOptions(ruleAttribute, availableRuleAttributes(), state.attribute ?? ruleAttribute.value);
+ renderOptions(ruleValue, valuesForRuleAttribute(ruleAttribute.value || "any"), state.value ?? ruleValue.value);
+ if (state.action && ruleActionLabels[state.action]) ruleAction.value = state.action;
+ ruleValue.disabled = (ruleAttribute.value || "any") === "any" || ruleValue.options.length === 0;
+ const attributeNames = Array.from(ruleAttribute.options).map((option) => option.textContent).join(", ");
+ ruleContext.textContent = attributeNames
+ ? `Available here: ${attributeNames}. Rules are applied top to bottom; later matches refine earlier ones.`
+ : "No attributes are available for this type yet.";
+ };
+
+ const normalizeRules = (rules) => Array.isArray(rules)
+ ? rules
+ .filter((rule) => rule && (rule.target === "node" || rule.target === "edge") && ruleActionLabels[rule.action])
+ .map((rule, index) => ({
+ id: String(rule.id || `rule-${Date.now().toString(36)}-${index}`),
+ target: rule.target,
+ type: String(rule.type || ""),
+ attribute: String(rule.attribute || "any"),
+ value: String(rule.value || ""),
+ action: rule.action,
+ }))
+ : [];
+
+ const ruleDescription = (rule) => {
+ const target = rule.target === "edge" ? "edges" : "nodes";
+ const typeLabel = rule.type
+ ? (rule.target === "edge" ? humanize(rule.type) : nodeTypeLabels[rule.type] || humanize(rule.type))
+ : `all ${target}`;
+ const attribute = rule.attribute && rule.attribute !== "any"
+ ? `${ruleAttributeLabels[rule.attribute] || humanize(rule.attribute)} = ${humanize(rule.value)}`
+ : "any attribute";
+ return `${ruleActionLabels[rule.action]} ${typeLabel} where ${attribute}`;
+ };
+
+ const renderRules = () => {
+ const saveButton = document.querySelector("[data-rule-action='save']");
+ const cancelButton = document.querySelector("[data-rule-action='cancel']");
+ saveButton.textContent = editingRuleId ? "Update Rule" : "Add Rule";
+ cancelButton.disabled = !editingRuleId;
+ ruleList.innerHTML = filterRules.length
+ ? filterRules.map((rule, index) => `
+
+ ${escapeHtml(ruleDescription(rule))}${index + 1}
+
+
+
+
+
+
+
+ `).join("")
+ : 'No rules yet.';
+ };
+
+ const matchesRule = (element, rule) => {
+ if (rule.target === "node" && !element.isNode()) return false;
+ if (rule.target === "edge" && !element.isEdge()) return false;
+ if (rule.type && ruleElementType(element, rule.target) !== rule.type) return false;
+ if (!rule.attribute || rule.attribute === "any") return true;
+ const value = element.data(rule.attribute);
+ return value !== undefined && value !== null && String(value) === String(rule.value);
+ };
+
+ const ruleActionFor = (element) => {
+ let action = "";
+ filterRules.forEach((rule) => {
+ if (matchesRule(element, rule)) action = rule.action;
+ });
+ return action;
+ };
+
+ const ruleRemovalSignature = () => cy
+ ? cy.elements()
+ .filter((element) => element.data("displayState") === "remove")
+ .map((element) => element.id())
+ .sort()
+ .join("|")
+ : "";
+
const isFinitePoint = (position) =>
position && Number.isFinite(position.x) && Number.isFinite(position.y);
@@ -608,6 +855,7 @@ def graph_explorer_page() -> str:
edgeTypes: Array.from(selectedEdgeTypes()),
review: reviewFilter.value,
unresolved: unresolvedFilter.value,
+ rules: filterRules.map((rule) => ({...rule})),
manualOverrides: {...manualOverrides},
});
@@ -658,7 +906,9 @@ def graph_explorer_page() -> str:
if (state.review) params.set("review", state.review);
if (state.unresolved) params.set("unresolved", state.unresolved);
if (currentProfileId) params.set("profile", currentProfileId);
- if (includeStateBlob || hasManualOverrides()) params.set("state", encodeStateBlob(state));
+ if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
+ params.set("state", encodeStateBlob(state));
+ }
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
};
@@ -684,6 +934,12 @@ def graph_explorer_page() -> str:
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
manualOverrides = {...state.manualOverrides};
}
+ if ("rules" in state) {
+ filterRules = normalizeRules(state.rules);
+ editingRuleId = "";
+ refreshRuleBuilder();
+ renderRules();
+ }
activeMode = modeSelect.value || "full";
activeLabelMode = labelSelect.value || "auto";
focusSet = null;
@@ -826,35 +1082,54 @@ def graph_explorer_page() -> str:
return null;
};
- const applyFilters = () => {
+ const applyFilters = (options = {}) => {
if (!cy) return;
+ const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
syncFilterSummaries();
const hiddenNodes = new Set();
+ const removedNodes = new Set();
cy.nodes().forEach((node) => {
let state = matchesFilters(node) ? "show" : "hide";
- const override = manualOverrideFor(node);
- if (override) state = override;
+ const ruleAction = ruleActionFor(node);
+ if (ruleAction) state = ruleAction;
+ if (state !== "remove") {
+ const override = manualOverrideFor(node);
+ if (override) state = override;
+ }
node.data("displayState", state);
node.toggleClass("display-blur", state === "blur");
- node.style("display", state === "hide" ? "none" : "element");
+ node.toggleClass("rule-highlight", state === "highlight");
+ node.style("display", state === "hide" || state === "remove" ? "none" : "element");
+ if (state === "remove") removedNodes.add(node.id());
if (state === "hide") hiddenNodes.add(node.id());
});
cy.edges().forEach((edge) => {
let state = matchesFilters(edge) ? "show" : "hide";
- if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
- const override = manualOverrideFor(edge);
- if (override) state = override;
+ const ruleAction = ruleActionFor(edge);
+ if (ruleAction) state = ruleAction;
+ if (removedNodes.has(edge.data("source")) || removedNodes.has(edge.data("target"))) {
+ state = "remove";
+ } else if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) {
+ state = "hide";
+ }
+ if (state !== "remove") {
+ const override = manualOverrideFor(edge);
+ if (override) state = override;
+ }
edge.data("displayState", state);
edge.toggleClass("display-blur", state === "blur");
- edge.style("display", state === "hide" ? "none" : "element");
+ edge.toggleClass("rule-highlight", state === "highlight");
+ edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
});
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
- const hidden = cy.elements().length - visibleNodes - visibleEdges;
+ const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
+ const hidden = cy.elements().length - visibleNodes - visibleEdges - removed;
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
- hiddenSummary.textContent = `Hidden ${hidden}`;
+ hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
updateLabelVisibility();
updateSelectionAnchor();
+ if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
};
const showDetails = (element) => {
@@ -989,6 +1264,7 @@ def graph_explorer_page() -> str:
const runLayout = () => {
if (!cy) return;
cy.elements().stop();
+ const layoutElements = cy.elements().filter((element) => element.data("displayState") !== "remove");
const name = layoutSelect.value || "cose";
const options = name === "breadthfirst"
? {name, directed: true, padding: 48, animate: false}
@@ -1006,7 +1282,7 @@ def graph_explorer_page() -> str:
numIter: 1400,
}
: {name, padding: 48, animate: false};
- cy.layout(options).run();
+ layoutElements.layout(options).run();
updateSelectionAnchor();
};
@@ -1098,6 +1374,17 @@ def graph_explorer_page() -> str:
}},
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
+ {selector: "node.rule-highlight", style: {
+ "border-color": "#2563eb",
+ "border-width": 3,
+ "z-index": 3
+ }},
+ {selector: "edge.rule-highlight", style: {
+ "line-color": "#2563eb",
+ "target-arrow-color": "#2563eb",
+ "width": 5,
+ "z-index": 3
+ }},
{selector: "edge.hover", style: {
"label": "data(edgeType)",
"font-size": 10,
@@ -1112,6 +1399,8 @@ def graph_explorer_page() -> str:
{selector: ":selected", style: {"overlay-opacity": 0}}
]
});
+ refreshRuleBuilder();
+ renderRules();
cy.on("tap", "node, edge", (event) => showDetails(event.target));
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
cy.on("pan zoom resize render layoutstop", updateSelectionAnchor);
@@ -1183,6 +1472,81 @@ def graph_explorer_page() -> str:
updateProfileSummary();
updateUrlState();
});
+ ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
+ ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
+ ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
+ target: ruleTarget.value,
+ type: ruleType.value,
+ attribute: ruleAttribute.value,
+ }));
+ document.querySelector("[data-rule-action='save']").addEventListener("click", () => {
+ const rule = {
+ id: editingRuleId || `rule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
+ target: ruleTarget.value || "node",
+ type: ruleType.value || "",
+ attribute: ruleAttribute.value || "any",
+ value: ruleAttribute.value === "any" ? "" : ruleValue.value || "",
+ action: ruleAction.value || "highlight",
+ };
+ filterRules = editingRuleId
+ ? filterRules.map((existing) => existing.id === editingRuleId ? rule : existing)
+ : [...filterRules, rule];
+ editingRuleId = "";
+ renderRules();
+ applyFilters({redrawOnRemove: true});
+ currentProfileId = "";
+ profileSelect.value = "";
+ updateProfileControls();
+ updateProfileSummary();
+ updateUrlState();
+ });
+ document.querySelector("[data-rule-action='cancel']").addEventListener("click", () => {
+ editingRuleId = "";
+ refreshRuleBuilder();
+ renderRules();
+ });
+ document.querySelector("[data-rule-action='reset']").addEventListener("click", () => {
+ filterRules = [];
+ editingRuleId = "";
+ refreshRuleBuilder();
+ renderRules();
+ applyFilters({redrawOnRemove: true});
+ currentProfileId = "";
+ profileSelect.value = "";
+ updateProfileControls();
+ updateProfileSummary();
+ updateUrlState();
+ });
+ ruleList.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-rule-list-action]");
+ if (!button) return;
+ const item = button.closest("[data-rule-id]");
+ const index = filterRules.findIndex((rule) => rule.id === item?.dataset?.ruleId);
+ if (index < 0) return;
+ const action = button.dataset.ruleListAction;
+ if (action === "edit") {
+ const rule = filterRules[index];
+ editingRuleId = rule.id;
+ refreshRuleBuilder(rule);
+ renderRules();
+ return;
+ }
+ if (action === "delete") {
+ filterRules = filterRules.filter((_, ruleIndex) => ruleIndex !== index);
+ } else if (action === "up" && index > 0) {
+ [filterRules[index - 1], filterRules[index]] = [filterRules[index], filterRules[index - 1]];
+ } else if (action === "down" && index < filterRules.length - 1) {
+ [filterRules[index + 1], filterRules[index]] = [filterRules[index], filterRules[index + 1]];
+ }
+ editingRuleId = "";
+ renderRules();
+ applyFilters({redrawOnRemove: true});
+ currentProfileId = "";
+ profileSelect.value = "";
+ updateProfileControls();
+ updateProfileSummary();
+ updateUrlState();
+ });
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
document.querySelector("[data-action='clear']").addEventListener("click", () => {
@@ -1197,9 +1561,13 @@ def graph_explorer_page() -> str:
unresolvedFilter.value = "";
focusSet = null;
manualOverrides = {};
+ filterRules = [];
+ editingRuleId = "";
+ refreshRuleBuilder();
+ renderRules();
currentProfileId = "";
profileSelect.value = "";
- applyFilters();
+ applyFilters({redrawOnRemove: true});
updateProfileControls();
updateProfileSummary();
updateUrlState();
diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py
index 02e85f5..d8a2067 100644
--- a/tests/test_graph_explorer.py
+++ b/tests/test_graph_explorer.py
@@ -209,10 +209,17 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert 'id="label-select"' in page
assert 'id="node-type-filter"' in page
assert 'id="edge-type-filter"' in page
+ assert 'id="rule-panel"' in page
+ assert 'id="rule-target"' in page
+ assert 'id="rule-list"' in page
assert 'id="selection-anchor"' in page
assert 'id="help-popup"' in page
assert "updateSelectionAnchor" in page
assert "updateLabelVisibility" in page
+ assert "ruleActionFor" in page
+ assert "ruleRemovalSignature" in page
+ assert "Remove and redraw" in page
+ assert "Rules are applied top to bottom" in page
assert "showHelp" in page
assert "label-hidden" in page
assert "Loading graph..." in page
diff --git a/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md b/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md
index 7112e79..eb4982d 100644
--- a/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md
+++ b/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md
@@ -151,7 +151,7 @@ Acceptance notes:
```task
id: RAIL-FAB-WP-0009-T04
-status: todo
+status: done
priority: high
state_hub_task_id: "1b446ac8-eeba-4de6-9a32-d7f980d70b93"
```