Skip to content

Commit 9715647

Browse files
authored
Merge pull request #4 from alphagov/acw-17/graph-visualiser
Graph visualiser page
2 parents 13cb226 + 52410ca commit 9715647

6 files changed

Lines changed: 260 additions & 6 deletions

File tree

Dockerfile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Use a slim Python image
2-
FROM python:3.12-slim
2+
FROM python:3.13-slim
33

44

55
RUN pip install uv
@@ -14,13 +14,13 @@ COPY pyproject.toml uv.lock ./
1414

1515
RUN uv sync --no-dev --no-install-project
1616

17-
1817
COPY src/ ./src/
18+
COPY static/ ./static/
19+
COPY templates/ ./templates/
20+
COPY graph-viewmodel.json ./graph-viewmodel.json
1921
COPY app.py ./app.py
2022
# COPY graph.json ./
2123

22-
23-
2424
# Set environment variables
2525
ENV PATH="/app/.venv/bin:$PATH"
2626
ENV PORT=3000

app.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import os
22
import logging
3-
from flask import Flask, request, jsonify
3+
from flask import Flask, request, jsonify, render_template
44
from dotenv import load_dotenv
5-
from src.generate_graph import generate_graph
5+
from src.generate_graph import generate_graph, load_graph_viewmodel
66

77
load_dotenv()
88

@@ -19,6 +19,23 @@
1919
def create_app():
2020
app = Flask(__name__)
2121

22+
@app.route('/graph', methods=['GET'])
23+
def graph_page():
24+
"""Serve the Cytoscape graph viewer page."""
25+
return render_template('graph.html')
26+
27+
@app.route('/graph-viewmodel', methods=['GET'])
28+
async def graph_viewmodel():
29+
"""Serve the graph data as JSON for the frontend."""
30+
try:
31+
logger.info('Loading graph data for viewmodel endpoint...')
32+
graph_data = load_graph_viewmodel("graph-viewmodel.json")
33+
logger.info('Graph data loaded successfully.')
34+
return jsonify(graph_data), 200
35+
except Exception as e:
36+
app.logger.error(f"Error loading graph data: {str(e)}")
37+
return jsonify({"error": str(e)}), 500
38+
2239
@app.route('/healthcheck/ready', methods=['GET'])
2340
def health_check():
2441
"""Simple health check endpoint."""

graph-viewmodel.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

src/generate_graph.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,5 +156,17 @@ async def generate_graph(input_data: Union[str, Dict[str, Any]], output_path: Op
156156

157157
return cy_json
158158

159+
def load_json_file(file_path: str) -> Dict[str, Any]:
160+
"""Utility function to load JSON data from a file."""
161+
if not os.path.exists(file_path):
162+
logger.error(f"File {file_path} not found.")
163+
return {}
164+
with open(file_path, "r") as f:
165+
return json.load(f)
166+
167+
def load_graph_viewmodel(file_path: str) -> Dict[str, Any]:
168+
"""Loads the graph viewmodel JSON for the frontend."""
169+
return load_json_file(file_path)
170+
159171
if __name__ == "__main__":
160172
asyncio.run(generate_graph("graph.json", "outputs/graphNode.json"))

static/css/graph.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
body {
2+
margin: 0;
3+
font-family: Arial, sans-serif;
4+
background: #f3f4f6;
5+
color: #111827;
6+
}
7+
8+
header {
9+
padding: 1rem;
10+
background: #111827;
11+
color: #ffffff;
12+
}
13+
14+
main {
15+
display: grid;
16+
grid-template-columns: 1fr 320px;
17+
height: calc(100vh - 72px);
18+
}
19+
20+
#graph {
21+
width: 100%;
22+
height: 100%;
23+
background: #ffffff;
24+
}
25+
26+
.sidebar {
27+
padding: 1rem;
28+
border-left: 1px solid #d1d5db;
29+
background: #f9fafb;
30+
overflow-y: auto;
31+
}
32+
33+
.sidebar h2 {
34+
margin-top: 0;
35+
font-size: 1rem;
36+
}
37+
38+
.sidebar #node-details {
39+
white-space: pre-wrap;
40+
word-break: break-word;
41+
background: #ffffff;
42+
border: 1px solid #d1d5db;
43+
padding: 0.75rem;
44+
border-radius: 0.375rem;
45+
}
46+
47+
.sidebar #node-details section {
48+
margin-bottom: 1rem;
49+
}
50+
51+
.sidebar #node-details a {
52+
color: #2563eb;
53+
text-decoration: none;
54+
}
55+
56+
.sidebar #node-details a:hover {
57+
text-decoration: underline;
58+
}
59+
60+
.status {
61+
margin-top: 0.5rem;
62+
font-size: 0.9rem;
63+
color: #374151;
64+
}
65+
66+
.occurrence {
67+
background-color: #ffeb3b;
68+
padding: 0.2em 0.4em;
69+
border-radius: 4px;
70+
font-weight: bold;
71+
}

templates/graph.html

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Graph Viewer</title>
7+
<link rel="stylesheet" href="{{ url_for('static', filename='css/graph.css') }}" />
8+
</head>
9+
<body>
10+
<header>
11+
<h1>Graph Viewer</h1>
12+
<p class="status">Loading graph from the /extract endpoint...</p>
13+
</header>
14+
<main>
15+
<div id="graph"></div>
16+
<aside class="sidebar">
17+
<h2 id="sidebar-title">No node selected</h2>
18+
<div id="node-details">Select a node to view details.</div>
19+
</aside>
20+
</main>
21+
22+
<script src="https://unpkg.com/cytoscape@3.4.0/dist/cytoscape.min.js"></script>
23+
<script>
24+
const status = document.querySelector('.status');
25+
const sidebarTitle = document.getElementById('sidebar-title');
26+
const details = document.getElementById('node-details');
27+
28+
function escapeHtml(value) {
29+
return String(value || '')
30+
.replace(/&/g, '&amp;')
31+
.replace(/</g, '&lt;')
32+
.replace(/>/g, '&gt;')
33+
.replace(/"/g, '&quot;')
34+
.replace(/'/g, '&#39;');
35+
}
36+
37+
function renderNodeDetails(node) {
38+
const data = node.data();
39+
sidebarTitle.textContent = data.label || 'Selected node';
40+
41+
const occurrences = Array.isArray(data.occurrences) ? data.occurrences : [];
42+
if (!occurrences.length) {
43+
details.innerHTML = '<p>No occurrences available.</p>';
44+
return;
45+
}
46+
47+
details.innerHTML = occurrences.map(item => {
48+
const link = escapeHtml(item.link);
49+
const context = item.context;
50+
return `
51+
<section>
52+
<p><a href="${link}" target="_blank" rel="noopener noreferrer">View on GOV.UK</a></p>
53+
<p>${context}</p>
54+
</section>
55+
`;
56+
}).join('');
57+
}
58+
59+
async function loadGraph() {
60+
try {
61+
const response = await fetch('/graph-viewmodel');
62+
if (!response.ok) {
63+
throw new Error(`Failed to load graph: ${response.status} ${response.statusText}`);
64+
}
65+
66+
const graphData = await response.json();
67+
68+
status.textContent = 'Graph loaded successfully.';
69+
70+
const cy = cytoscape({
71+
container: document.getElementById('graph'),
72+
boxSelectionEnabled: false,
73+
autoungrabify: true,
74+
elements: graphData,
75+
style: [
76+
{
77+
selector: 'node[type="entity"]',
78+
style: {
79+
'background-color': '#2563eb',
80+
'label': 'data(label)',
81+
'color': '#ffffff',
82+
'text-valign': 'center',
83+
'text-halign': 'center',
84+
'width': 'label',
85+
'padding': '12px',
86+
'shape': 'roundrectangle'
87+
}
88+
},
89+
{
90+
selector: 'node[type="alias"]',
91+
style: {
92+
'background-color': '#f59e0b',
93+
'label': 'data(label)',
94+
'color': '#111827',
95+
'text-valign': 'center',
96+
'text-halign': 'center',
97+
'width': 'label',
98+
'padding': '10px',
99+
'shape': 'ellipse'
100+
}
101+
},
102+
{
103+
selector: 'edge',
104+
style: {
105+
'width': 2,
106+
'line-color': '#9ca3af',
107+
'target-arrow-color': '#9ca3af',
108+
'target-arrow-shape': 'triangle',
109+
'curve-style': 'bezier',
110+
'label': 'data(label)',
111+
'font-size': 10,
112+
'text-rotation': 'autorotate',
113+
'text-margin-y': -8
114+
}
115+
},
116+
{
117+
selector: ':selected',
118+
style: {
119+
'border-width': 3,
120+
'border-color': '#10b981'
121+
}
122+
}
123+
],
124+
layout: {
125+
name: 'cose',
126+
idealEdgeLength: 120,
127+
nodeOverlap: 20,
128+
refresh: 20,
129+
fit: true,
130+
}
131+
});
132+
133+
cy.on('tap', 'node', (event) => {
134+
renderNodeDetails(event.target);
135+
});
136+
137+
cy.on('tap', (event) => {
138+
if (event.target === cy) {
139+
sidebarTitle.textContent = 'No node selected';
140+
details.innerHTML = '<p>Select a node to view details.</p>';
141+
}
142+
});
143+
} catch (error) {
144+
status.textContent = error.message;
145+
details.textContent = 'Unable to display graph.';
146+
console.error(error);
147+
}
148+
}
149+
150+
loadGraph();
151+
</script>
152+
</body>
153+
</html>

0 commit comments

Comments
 (0)