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();