Add graph explorer filter rules

This commit is contained in:
2026-05-19 01:16:18 +02:00
parent 99993c7cb2
commit 28511db909
3 changed files with 392 additions and 17 deletions

View File

@@ -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; }
}
</style>
</head>
@@ -319,7 +364,7 @@ def graph_explorer_page() -> str:
<select id="unresolved-filter" aria-label="Unresolved filter"><option value="">Any</option><option value="true">Only</option></select>
</div>
<div class="field">
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, rules, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
<select id="profile-select" aria-label="Saved view profile" disabled><option>Unsaved exploration</option></select>
</div>
<span id="counts" class="meta"></span>
@@ -365,6 +410,46 @@ def graph_explorer_page() -> str:
<button type="button" data-action="reset-overrides" data-help-title="Clear Overrides" data-help="Clear Overrides removes manual Show, Blur, and Hide choices. Filters, mode, layout, and label density stay as they are.">Clear Overrides</button>
</div>
</section>
<section class="section">
<details id="rule-panel" class="rule-panel">
<summary><span class="field-label">Rules <button type="button" class="help-tip" aria-label="Rule builder help" data-help-title="Rule Builder" data-help="Rules select nodes or edges by type and available attributes, then apply a modifier. Hide preserves the current layout; Remove redraws the map without matching entities.">?</button></span></summary>
<div class="rule-editor">
<div class="rule-grid">
<label>Target
<select id="rule-target">
<option value="node">Nodes</option>
<option value="edge">Edges</option>
</select>
</label>
<label>Type
<select id="rule-type"></select>
</label>
<label>Attribute
<select id="rule-attribute"></select>
</label>
<label>Value
<select id="rule-value"></select>
</label>
<label>Modifier
<select id="rule-action">
<option value="highlight">Highlight</option>
<option value="show">Show</option>
<option value="blur">Blur</option>
<option value="hide">Hide</option>
<option value="remove">Remove and redraw</option>
</select>
</label>
</div>
<p id="rule-context" class="meta">Rules are applied top to bottom; later matching rules refine earlier ones.</p>
<div class="button-row">
<button type="button" data-rule-action="save" class="primary">Add Rule</button>
<button type="button" data-rule-action="cancel">Cancel Edit</button>
<button type="button" data-rule-action="reset">Reset Rules</button>
</div>
<ul id="rule-list" class="rule-list"></ul>
</div>
</details>
</section>
<section class="section">
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
<div class="field">
@@ -372,10 +457,10 @@ def graph_explorer_page() -> str:
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
</div>
<div class="button-row">
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, layout, label density, and manual overrides." disabled>Save</button>
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, rules, layout, label density, and manual overrides." disabled>Save</button>
<button type="button" data-profile-action="duplicate" data-help-title="Duplicate" data-help="Duplicate creates a new saved view from the currently loaded profile and current map state." disabled>Duplicate</button>
<button type="button" data-profile-action="delete" data-help-title="Delete" data-help="Delete removes the selected browser-local saved view. It does not delete graph data." disabled>Delete</button>
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters and manual overrides." disabled>Copy State</button>
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters, rules, and manual overrides." disabled>Copy State</button>
</div>
</section>
<section class="section">
@@ -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("&", "&amp;").replaceAll("<", "&lt;")
@@ -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) =>
`<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</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) => `
<li class="rule-item" data-rule-id="${escapeHtml(rule.id)}">
<div class="rule-title"><strong>${escapeHtml(ruleDescription(rule))}</strong><span class="meta">${index + 1}</span></div>
<div class="rule-actions">
<button type="button" data-rule-list-action="up" ${index === 0 ? "disabled" : ""}>Up</button>
<button type="button" data-rule-list-action="down" ${index === filterRules.length - 1 ? "disabled" : ""}>Down</button>
<button type="button" data-rule-list-action="edit">Edit</button>
<button type="button" data-rule-list-action="delete">Delete</button>
</div>
</li>
`).join("")
: '<li class="meta">No rules yet.</li>';
};
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();

View File

@@ -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

View File

@@ -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"
```