Skip to content

Commit 5f84bdd

Browse files
authored
Merge pull request #33 from alphagov/acw-59/filter-graph-nodes
Add a filter for the visualisation graph
2 parents 09c9009 + 8be59ef commit 5f84bdd

2 files changed

Lines changed: 109 additions & 11 deletions

File tree

static/css/graph.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,53 @@ main {
1717
height: calc(100vh - 72px);
1818
}
1919

20+
.graph-container {
21+
position: relative;
22+
}
23+
2024
#graph {
2125
width: 100%;
2226
height: 100%;
2327
background: #ffffff;
2428
}
2529

30+
.graph-filter {
31+
position: absolute;
32+
top: 10px;
33+
left: 10px;
34+
z-index: 10;
35+
display: flex;
36+
flex-direction: column;
37+
gap: 10px;
38+
background: #ffffff;
39+
border: 1px solid #cecece;
40+
border-radius: 10px;
41+
padding: 10px;
42+
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
43+
min-width: 240px;
44+
}
45+
46+
.graph-filter label {
47+
font-size: 16px;
48+
color: #0b0c0c;
49+
font-weight: bold;
50+
}
51+
52+
.graph-filter input {
53+
width: 100%;
54+
padding: 10px 10px;
55+
font-size: 16px;
56+
border: 1px solid #d1d5db;
57+
border-radius: 5px;
58+
outline: none;
59+
}
60+
61+
.graph-filter input:focus {
62+
outline: rgb(255, 221, 0) solid 3px;
63+
outline-offset: 0px;
64+
box-shadow: 0px 0px 0px 2px inset;
65+
}
66+
2667
.sidebar {
2768
padding: 1rem;
2869
border-left: 1px solid #d1d5db;

templates/graph.html

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ <h1>Graph Viewer</h1>
1212
<p class="status">Loading graph from the /extract endpoint...</p>
1313
</header>
1414
<main>
15-
<div id="graph"></div>
15+
<div class="graph-container">
16+
<div id="graph"></div>
17+
<div class="graph-filter">
18+
<label for="graph-filter-input">Filter</label>
19+
<input id="graph-filter-input" type="search" placeholder="Filter nodes by label..." aria-label="Filter graph nodes" />
20+
</div>
21+
</div>
1622
<aside class="sidebar">
1723
<h2 id="sidebar-title">No node selected</h2>
1824
<div id="node-details"><p>Select a node to view details.</p></div>
@@ -24,6 +30,7 @@ <h2 id="sidebar-title">No node selected</h2>
2430
const status = document.querySelector('.status');
2531
const sidebarTitle = document.getElementById('sidebar-title');
2632
const details = document.getElementById('node-details');
33+
const filterInput = document.getElementById('graph-filter-input');
2734

2835
const BASE_NODE_SIZE = 10;
2936

@@ -43,6 +50,10 @@ <h2 id="sidebar-title">No node selected</h2>
4350
}
4451

4552
function ensureNodeInView(node) {
53+
if (node.hidden()) {
54+
return;
55+
}
56+
4657
if (!isNodeInViewport(node)) {
4758
node.cy().animate({
4859
center: { eles: node },
@@ -51,6 +62,15 @@ <h2 id="sidebar-title">No node selected</h2>
5162
}
5263
}
5364

65+
function fitViewToNodes(nodes) {
66+
if (nodes.length > 0) {
67+
nodes.cy().animate({
68+
fit: { eles: nodes },
69+
duration: 500,
70+
});
71+
}
72+
}
73+
5474
function renderEntityDetails(node) {
5575
const data = node.data();
5676

@@ -71,17 +91,21 @@ <h2 id="sidebar-title">No node selected</h2>
7191
}).join('');
7292
details.innerHTML = `<ul>${aliasList}</ul>`;
7393

94+
function switchSelectedNode(nodeIdToSelect) {
95+
const cy = node.cy();
96+
const nodeToSelect = cy.getElementById(nodeIdToSelect);
97+
if (nodeToSelect.length > 0) {
98+
cy.elements().unselect();
99+
nodeToSelect.select();
100+
ensureNodeInView(nodeToSelect);
101+
}
102+
}
103+
74104
document.querySelectorAll('#node-details li a[data-alias-id]').forEach(item => {
75105
item.addEventListener('click', (e) => {
76106
const aliasId = e.target.getAttribute('data-alias-id');
77107
if (aliasId) {
78-
const cy = node.cy();
79-
const aliasNode = cy.getElementById(aliasId);
80-
if (aliasNode.length > 0) {
81-
cy.elements().unselect();
82-
aliasNode.select();
83-
ensureNodeInView(aliasNode);
84-
}
108+
switchSelectedNode(aliasId);
85109
}
86110
});
87111
});
@@ -134,6 +158,32 @@ <h2 id="sidebar-title">No node selected</h2>
134158
return BASE_NODE_SIZE + (occurrences.length * 2);
135159
}
136160

161+
function resetSidebar() {
162+
sidebarTitle.textContent = 'No node selected';
163+
details.innerHTML = '<p>Select a node to view details.</p>';
164+
}
165+
166+
function filterGraph(cy, filterTerm) {
167+
cy.nodes().forEach((node) => {
168+
const label = node.data('label');
169+
const shouldShowNode =
170+
!filterTerm || label.toLowerCase().includes(filterTerm);
171+
172+
if (shouldShowNode) {
173+
node.show();
174+
} else {
175+
node.hide();
176+
}
177+
});
178+
179+
const selectedNodes = cy.$('node:selected');
180+
if (selectedNodes.every((node) => node.hidden())) {
181+
selectedNodes.unselect();
182+
resetSidebar();
183+
}
184+
fitViewToNodes(cy.nodes(':visible'));
185+
}
186+
137187
async function loadGraph() {
138188
try {
139189
const response = await fetch('/graph-viewmodel?run_path={{ run_path }}');
@@ -209,7 +259,7 @@ <h2 id="sidebar-title">No node selected</h2>
209259
selector: ':selected',
210260
style: {
211261
'border-width': 1.5,
212-
'border-color': '#ffdd00'
262+
'border-color': '#ffdd00',
213263
}
214264
}
215265
],
@@ -223,14 +273,21 @@ <h2 id="sidebar-title">No node selected</h2>
223273
}
224274
});
225275

276+
let filterUpdateTimeout;
277+
filterInput.addEventListener('input', (event) => {
278+
const filterTerm = event.target.value.trim().toLowerCase();
279+
280+
clearTimeout(filterUpdateTimeout);
281+
filterUpdateTimeout = setTimeout(() => { filterGraph(cy, filterTerm); }, 500);
282+
});
283+
226284
cy.on('select', 'node', (event) => {
227285
renderNodeDetails(event.target);
228286
});
229287

230288
cy.on('tap', (event) => {
231289
if (event.target === cy) {
232-
sidebarTitle.textContent = 'No node selected';
233-
details.innerHTML = '<p>Select a node to view details.</p>';
290+
resetSidebar();
234291
}
235292
});
236293
} catch (error) {

0 commit comments

Comments
 (0)