generated from coulomb/repo-seed
Add graph explorer filter rules
This commit is contained in:
@@ -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 { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||||
.detail-list li { overflow-wrap: anywhere; }
|
.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 {
|
.popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
@@ -274,6 +318,7 @@ def graph_explorer_page() -> str:
|
|||||||
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
|
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
|
||||||
.side { border-left: 0; border-top: 1px solid var(--line); }
|
.side { border-left: 0; border-top: 1px solid var(--line); }
|
||||||
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
|
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
|
||||||
|
.rule-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</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>
|
<select id="unresolved-filter" aria-label="Unresolved filter"><option value="">Any</option><option value="true">Only</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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>
|
<select id="profile-select" aria-label="Saved view profile" disabled><option>Unsaved exploration</option></select>
|
||||||
</div>
|
</div>
|
||||||
<span id="counts" class="meta"></span>
|
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="section">
|
||||||
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
|
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -372,10 +457,10 @@ def graph_explorer_page() -> str:
|
|||||||
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
|
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<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="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="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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -405,6 +490,13 @@ def graph_explorer_page() -> str:
|
|||||||
const edgeTypeSummary = document.getElementById("edge-type-summary");
|
const edgeTypeSummary = document.getElementById("edge-type-summary");
|
||||||
const reviewFilter = document.getElementById("review-filter");
|
const reviewFilter = document.getElementById("review-filter");
|
||||||
const unresolvedFilter = document.getElementById("unresolved-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 profileSelect = document.getElementById("profile-select");
|
||||||
const profileNameInput = document.getElementById("profile-name");
|
const profileNameInput = document.getElementById("profile-name");
|
||||||
const profileSummary = document.getElementById("profile-summary");
|
const profileSummary = document.getElementById("profile-summary");
|
||||||
@@ -432,6 +524,8 @@ def graph_explorer_page() -> str:
|
|||||||
let profilePersistence = "none";
|
let profilePersistence = "none";
|
||||||
let profiles = [];
|
let profiles = [];
|
||||||
let currentProfileId = "";
|
let currentProfileId = "";
|
||||||
|
let filterRules = [];
|
||||||
|
let editingRuleId = "";
|
||||||
|
|
||||||
const escapeHtml = (value) => String(value ?? "")
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
.replaceAll("&", "&").replaceAll("<", "<")
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
@@ -524,6 +618,159 @@ def graph_explorer_page() -> str:
|
|||||||
}).join("");
|
}).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) =>
|
const isFinitePoint = (position) =>
|
||||||
position && Number.isFinite(position.x) && Number.isFinite(position.y);
|
position && Number.isFinite(position.x) && Number.isFinite(position.y);
|
||||||
|
|
||||||
@@ -608,6 +855,7 @@ def graph_explorer_page() -> str:
|
|||||||
edgeTypes: Array.from(selectedEdgeTypes()),
|
edgeTypes: Array.from(selectedEdgeTypes()),
|
||||||
review: reviewFilter.value,
|
review: reviewFilter.value,
|
||||||
unresolved: unresolvedFilter.value,
|
unresolved: unresolvedFilter.value,
|
||||||
|
rules: filterRules.map((rule) => ({...rule})),
|
||||||
manualOverrides: {...manualOverrides},
|
manualOverrides: {...manualOverrides},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,7 +906,9 @@ def graph_explorer_page() -> str:
|
|||||||
if (state.review) params.set("review", state.review);
|
if (state.review) params.set("review", state.review);
|
||||||
if (state.unresolved) params.set("unresolved", state.unresolved);
|
if (state.unresolved) params.set("unresolved", state.unresolved);
|
||||||
if (currentProfileId) params.set("profile", currentProfileId);
|
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();
|
const query = params.toString();
|
||||||
return `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
|
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") {
|
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
||||||
manualOverrides = {...state.manualOverrides};
|
manualOverrides = {...state.manualOverrides};
|
||||||
}
|
}
|
||||||
|
if ("rules" in state) {
|
||||||
|
filterRules = normalizeRules(state.rules);
|
||||||
|
editingRuleId = "";
|
||||||
|
refreshRuleBuilder();
|
||||||
|
renderRules();
|
||||||
|
}
|
||||||
activeMode = modeSelect.value || "full";
|
activeMode = modeSelect.value || "full";
|
||||||
activeLabelMode = labelSelect.value || "auto";
|
activeLabelMode = labelSelect.value || "auto";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
@@ -826,35 +1082,54 @@ def graph_explorer_page() -> str:
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = (options = {}) => {
|
||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
|
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
|
||||||
syncFilterSummaries();
|
syncFilterSummaries();
|
||||||
const hiddenNodes = new Set();
|
const hiddenNodes = new Set();
|
||||||
|
const removedNodes = new Set();
|
||||||
cy.nodes().forEach((node) => {
|
cy.nodes().forEach((node) => {
|
||||||
let state = matchesFilters(node) ? "show" : "hide";
|
let state = matchesFilters(node) ? "show" : "hide";
|
||||||
|
const ruleAction = ruleActionFor(node);
|
||||||
|
if (ruleAction) state = ruleAction;
|
||||||
|
if (state !== "remove") {
|
||||||
const override = manualOverrideFor(node);
|
const override = manualOverrideFor(node);
|
||||||
if (override) state = override;
|
if (override) state = override;
|
||||||
|
}
|
||||||
node.data("displayState", state);
|
node.data("displayState", state);
|
||||||
node.toggleClass("display-blur", state === "blur");
|
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());
|
if (state === "hide") hiddenNodes.add(node.id());
|
||||||
});
|
});
|
||||||
cy.edges().forEach((edge) => {
|
cy.edges().forEach((edge) => {
|
||||||
let state = matchesFilters(edge) ? "show" : "hide";
|
let state = matchesFilters(edge) ? "show" : "hide";
|
||||||
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
|
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);
|
const override = manualOverrideFor(edge);
|
||||||
if (override) state = override;
|
if (override) state = override;
|
||||||
|
}
|
||||||
edge.data("displayState", state);
|
edge.data("displayState", state);
|
||||||
edge.toggleClass("display-blur", state === "blur");
|
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 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 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`;
|
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
||||||
hiddenSummary.textContent = `Hidden ${hidden}`;
|
hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
|
||||||
updateLabelVisibility();
|
updateLabelVisibility();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
|
if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetails = (element) => {
|
const showDetails = (element) => {
|
||||||
@@ -989,6 +1264,7 @@ def graph_explorer_page() -> str:
|
|||||||
const runLayout = () => {
|
const runLayout = () => {
|
||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
cy.elements().stop();
|
cy.elements().stop();
|
||||||
|
const layoutElements = cy.elements().filter((element) => element.data("displayState") !== "remove");
|
||||||
const name = layoutSelect.value || "cose";
|
const name = layoutSelect.value || "cose";
|
||||||
const options = name === "breadthfirst"
|
const options = name === "breadthfirst"
|
||||||
? {name, directed: true, padding: 48, animate: false}
|
? {name, directed: true, padding: 48, animate: false}
|
||||||
@@ -1006,7 +1282,7 @@ def graph_explorer_page() -> str:
|
|||||||
numIter: 1400,
|
numIter: 1400,
|
||||||
}
|
}
|
||||||
: {name, padding: 48, animate: false};
|
: {name, padding: 48, animate: false};
|
||||||
cy.layout(options).run();
|
layoutElements.layout(options).run();
|
||||||
updateSelectionAnchor();
|
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 = '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: "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: {
|
{selector: "edge.hover", style: {
|
||||||
"label": "data(edgeType)",
|
"label": "data(edgeType)",
|
||||||
"font-size": 10,
|
"font-size": 10,
|
||||||
@@ -1112,6 +1399,8 @@ def graph_explorer_page() -> str:
|
|||||||
{selector: ":selected", style: {"overlay-opacity": 0}}
|
{selector: ":selected", style: {"overlay-opacity": 0}}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
refreshRuleBuilder();
|
||||||
|
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", updateSelectionAnchor);
|
cy.on("pan zoom resize render layoutstop", updateSelectionAnchor);
|
||||||
@@ -1183,6 +1472,81 @@ def graph_explorer_page() -> str:
|
|||||||
updateProfileSummary();
|
updateProfileSummary();
|
||||||
updateUrlState();
|
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='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
||||||
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
||||||
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
||||||
@@ -1197,9 +1561,13 @@ def graph_explorer_page() -> str:
|
|||||||
unresolvedFilter.value = "";
|
unresolvedFilter.value = "";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
manualOverrides = {};
|
manualOverrides = {};
|
||||||
|
filterRules = [];
|
||||||
|
editingRuleId = "";
|
||||||
|
refreshRuleBuilder();
|
||||||
|
renderRules();
|
||||||
currentProfileId = "";
|
currentProfileId = "";
|
||||||
profileSelect.value = "";
|
profileSelect.value = "";
|
||||||
applyFilters();
|
applyFilters({redrawOnRemove: true});
|
||||||
updateProfileControls();
|
updateProfileControls();
|
||||||
updateProfileSummary();
|
updateProfileSummary();
|
||||||
updateUrlState();
|
updateUrlState();
|
||||||
|
|||||||
@@ -209,10 +209,17 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert 'id="label-select"' in page
|
assert 'id="label-select"' in page
|
||||||
assert 'id="node-type-filter"' in page
|
assert 'id="node-type-filter"' in page
|
||||||
assert 'id="edge-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="selection-anchor"' in page
|
||||||
assert 'id="help-popup"' in page
|
assert 'id="help-popup"' in page
|
||||||
assert "updateSelectionAnchor" in page
|
assert "updateSelectionAnchor" in page
|
||||||
assert "updateLabelVisibility" 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 "showHelp" in page
|
||||||
assert "label-hidden" in page
|
assert "label-hidden" in page
|
||||||
assert "Loading graph..." in page
|
assert "Loading graph..." in page
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ Acceptance notes:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0009-T04
|
id: RAIL-FAB-WP-0009-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1b446ac8-eeba-4de6-9a32-d7f980d70b93"
|
state_hub_task_id: "1b446ac8-eeba-4de6-9a32-d7f980d70b93"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user