add re layout button

This commit is contained in:
Patrice 2026-03-10 16:48:18 +01:00
parent 0c0df5fe8d
commit 84072c0956
4 changed files with 145 additions and 29 deletions

90
app.py
View File

@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
# Neo4j HTTP API endpoint (not Bolt) # Neo4j HTTP API endpoint (not Bolt)
NEO4J_HTTP_URL = os.environ.get("NEO4J_HTTP_URL", "http://localhost") NEO4J_HTTP_URL = os.environ.get("NEO4J_HTTP_URL", "")
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j") NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "") NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "")
NEO4J_DATABASE = os.environ.get("NEO4J_DATABASE", "neo4j") NEO4J_DATABASE = os.environ.get("NEO4J_DATABASE", "neo4j")
@ -40,6 +40,9 @@ SAMPLE_QUERIES_FILE = os.environ.get(
os.path.join(os.path.dirname(__file__), "config", "sample_queries.json"), os.path.join(os.path.dirname(__file__), "config", "sample_queries.json"),
) )
# Cache for the last query result (avoids re-querying Neo4j for re-layout)
_last_query_cache: dict = {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Neo4j HTTP Transactional API helpers # Neo4j HTTP Transactional API helpers
@ -308,6 +311,13 @@ def api_query():
nodes_dict, edges, records, keys = execute_cypher(cypher) nodes_dict, edges, records, keys = execute_cypher(cypher)
t_query = time.time() - t0 t_query = time.time() - t0
# Cache raw results for re-layout
_last_query_cache.clear()
_last_query_cache["nodes_dict"] = {k: dict(v) for k, v in nodes_dict.items()}
_last_query_cache["edges"] = [dict(e) for e in edges]
_last_query_cache["records"] = records
_last_query_cache["keys"] = keys
# Assign colours # Assign colours
label_colors: dict[str, str] = {} label_colors: dict[str, str] = {}
for nd in nodes_dict.values(): for nd in nodes_dict.values():
@ -374,6 +384,84 @@ def api_query():
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
@app.route("/api/relayout", methods=["POST"])
def api_relayout():
"""Re-run layout on the cached query result without hitting Neo4j."""
if not _last_query_cache:
return jsonify({"error": "No cached query result. Run a query first."}), 400
data = request.get_json(force=True)
layout_algo = data.get("layout", "auto")
spacing = float(data.get("spacing", 1.0))
iterations = int(data.get("iterations", 300))
try:
# Deep-copy cached data so layout doesn't mutate the cache
nodes_dict = {k: dict(v) for k, v in _last_query_cache["nodes_dict"].items()}
edges = [dict(e) for e in _last_query_cache["edges"]]
records = _last_query_cache["records"]
keys = _last_query_cache["keys"]
# Assign colours
label_colors: dict[str, str] = {}
for nd in nodes_dict.values():
for lb in nd.get("labels", []):
if lb not in label_colors:
label_colors[lb] = color_for_label(lb)
# Compute layout
t1 = time.time()
positions = compute_layout(
nodes_dict, edges,
algorithm=layout_algo, spacing=spacing, iterations=iterations,
)
t_layout = time.time() - t1
# Degree for sizing
degree: dict[str, int] = defaultdict(int)
for e in edges:
degree[e["source"]] += 1
degree[e["target"]] += 1
max_deg = max(degree.values()) if degree else 1
nodes_list = []
for nid, nd in nodes_dict.items():
pos = positions.get(nid, {"x": 0, "y": 0})
primary = nd["labels"][0] if nd.get("labels") else "Unknown"
nd["x"] = pos["x"]
nd["y"] = pos["y"]
nd["color"] = label_colors.get(primary, "#888888")
d = degree.get(nid, 0)
nd["size"] = 3 + (d / max(max_deg, 1)) * 22
nodes_list.append(nd)
seen = set()
unique_edges = []
for e in edges:
key = (e["source"], e["target"], e["type"])
if key not in seen:
seen.add(key)
unique_edges.append(e)
return jsonify({
"nodes": nodes_list,
"edges": unique_edges,
"label_colors": label_colors,
"records": records[:500],
"keys": keys,
"stats": {
"node_count": len(nodes_list),
"edge_count": len(unique_edges),
"labels": list(label_colors.keys()),
"query_time_ms": 0,
"layout_time_ms": round(t_layout * 1000),
},
})
except Exception as exc:
logger.exception("Re-layout failed")
return jsonify({"error": str(exc)}), 400
@app.route("/api/schema") @app.route("/api/schema")
def api_schema(): def api_schema():
try: try:

View File

@ -1,43 +1,35 @@
[ [
{ {
"name": "Focused Entity Relation Graph", "name": "Sample Graph (100)",
"query": "MATCH (a:Entity)-[r:RELATION]-(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN a, r, b LIMIT 1500" "query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100"
}, },
{ {
"name": "Persons <-> Events", "name": "Sample Graph (500)",
"query": "MATCH (p:Entity)-[r:RELATION]-(e:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(e.type, '')) IN ['event','incident','meeting','occurrence'] RETURN p, r, e LIMIT 1500" "query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 500"
}, },
{ {
"name": "Events <-> Dates", "name": "Sample Graph (2000)",
"query": "MATCH (ev:Entity)-[r:RELATION]-(d:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(d.type, '')) IN ['date','time','datetime'] RETURN ev, r, d LIMIT 1500" "query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 2000"
}, },
{ {
"name": "Events <-> Locations", "name": "Node Label Counts",
"query": "MATCH (ev:Entity)-[r:RELATION]-(loc:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN ev, r, loc LIMIT 1500" "query": "MATCH (n) RETURN labels(n)[0] AS label, count(*) AS count ORDER BY count DESC LIMIT 25"
}, },
{ {
"name": "Persons <-> Locations", "name": "Relationship Type Counts",
"query": "MATCH (p:Entity)-[r:RELATION]-(loc:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN p, r, loc LIMIT 1500" "query": "MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS count ORDER BY count DESC LIMIT 25"
}, },
{ {
"name": "People Around Top Events (2 Hops)", "name": "High-Connectivity Nodes",
"query": "MATCH (ev:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] MATCH (ev)-[r0:RELATION]-() WITH ev, count(r0) AS degree ORDER BY degree DESC LIMIT 20 MATCH path = (ev)-[:RELATION*1..2]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN path LIMIT 2000" "query": "MATCH (n)-[r]-() WITH n, count(r) AS degree ORDER BY degree DESC LIMIT 20 MATCH (n)-[r2]->(m) RETURN n, r2, m LIMIT 300"
}, },
{ {
"name": "Most Connected Focus Entities", "name": "Shortest Path (sample)",
"query": "MATCH (e:Entity)-[r:RELATION]-(:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] WITH e, count(r) AS degree ORDER BY degree DESC LIMIT 40 MATCH (e)-[r2:RELATION]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN e, r2, n LIMIT 1800" "query": "MATCH (a), (b) WHERE a <> b WITH a, b LIMIT 1 MATCH path = shortestPath((a)-[*..5]-(b)) RETURN path"
}, },
{ {
"name": "Relation Predicate Distribution (Focused Types)", "name": "Connected Component (depth 3)",
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN coalesce(r.predicate_display, r.predicate, '<missing>') AS predicate, count(*) AS count ORDER BY count DESC LIMIT 50" "query": "MATCH (start) WITH start LIMIT 1 MATCH path = (start)-[*1..3]-(connected) RETURN path LIMIT 300"
},
{
"name": "Entity Type Counts (Focused)",
"query": "MATCH (e:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN toLower(coalesce(e.type, '<missing>')) AS entity_type, count(*) AS count ORDER BY count DESC"
},
{
"name": "Asymmetric Focused Relations",
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND NOT (b)-[:RELATION]->(a) RETURN a, r, b LIMIT 1500"
}, },
{ {
"name": "Schema Visualization", "name": "Schema Visualization",

View File

@ -3,6 +3,7 @@ neo4j>=5.0
networkx>=3.0 networkx>=3.0
numpy>=1.24 numpy>=1.24
scipy>=1.10 scipy>=1.10
matplotlib>=3.10
python-igraph>=0.11 python-igraph>=0.11
gunicorn>=21.0 gunicorn>=21.0
requests>=2.0 requests>=2.0

View File

@ -12,7 +12,7 @@
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://d3js.org/d3.v7.min.js"></script>
<style> <style>
/* ========================================================= /* =========================================================
CSS — Cortex Graph Explorer CSS — Graph Explorer
Dark theme with glass-morphism, glow accents Dark theme with glass-morphism, glow accents
========================================================= */ ========================================================= */
*, *,
@ -880,13 +880,13 @@
placeholder="MATCH (n)-[r]->(m)&#10;RETURN n, r, m&#10;LIMIT 100"></textarea> placeholder="MATCH (n)-[r]->(m)&#10;RETURN n, r, m&#10;LIMIT 100"></textarea>
<div class="controls-row"> <div class="controls-row">
<button class="btn btn-primary" id="run-btn" onclick="runQuery()">▶ Run</button> <button class="btn btn-primary" id="run-btn" onclick="runQuery()">▶ Run</button>
<button class="btn btn-secondary" onclick="runDemo()" title="Generate demo graph without Neo4j"> <button class="btn btn-secondary" onclick="reLayout()" title="Re-run layout on cached data (no DB query)">⟳ Re-Layout</button>
Demo</button>
<select class="styled-select" id="layout-select" title="Layout algorithm"> <select class="styled-select" id="layout-select" title="Layout algorithm">
<option value="auto">Auto Layout</option> <option value="auto">Auto Layout</option>
</select> </select>
</div> </div>
<div class="controls-row" style="margin-top:4px"> <div class="controls-row" style="margin-top:4px">
<button class="btn btn-secondary" onclick="runDemo()" title="Generate demo graph without Neo4j">✦ Demo</button>
<select class="styled-select" id="demo-size" title="Demo graph size"> <select class="styled-select" id="demo-size" title="Demo graph size">
<option value="100">Demo: 100 nodes</option> <option value="100">Demo: 100 nodes</option>
<option value="300" selected>Demo: 300 nodes</option> <option value="300" selected>Demo: 300 nodes</option>
@ -1083,6 +1083,7 @@
<button onclick="zoomOut()" title="Zoom out"></button> <button onclick="zoomOut()" title="Zoom out"></button>
<button onclick="fitGraph()" title="Fit to screen"></button> <button onclick="fitGraph()" title="Fit to screen"></button>
<button onclick="resetView()" title="Reset"></button> <button onclick="resetView()" title="Reset"></button>
<button onclick="exportPNG()" title="Export as PNG">📷</button>
</div> </div>
<div id="tooltip"> <div id="tooltip">
@ -1155,7 +1156,7 @@
const mmCanvas = document.getElementById('minimap-canvas'); const mmCanvas = document.getElementById('minimap-canvas');
const mmCtx = mmCanvas.getContext('2d'); const mmCtx = mmCanvas.getContext('2d');
let dpr = window.devicePixelRatio || 1; let dpr = Math.max(window.devicePixelRatio || 1, 2);
let width, height; let width, height;
let transform = d3.zoomIdentity; let transform = d3.zoomIdentity;
let zoomBehavior; let zoomBehavior;
@ -1241,6 +1242,18 @@
.call(zoomBehavior.transform, d3.zoomIdentity); .call(zoomBehavior.transform, d3.zoomIdentity);
}; };
window.exportPNG = function () {
// Export the current canvas at its full backing-store resolution (2x+)
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'graph-export.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');
};
/* ================================================================= /* =================================================================
HOVER / CLICK HOVER / CLICK
================================================================= */ ================================================================= */
@ -1920,6 +1933,28 @@
} }
}; };
window.reLayout = async function () {
const layout = document.getElementById('layout-select').value;
const spacing = state.vis.spacing;
const iterations = state.vis.iterations;
showLoading('Re-computing layout…');
try {
const resp = await fetch('/api/relayout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout, spacing, iterations }),
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
loadGraphData(data);
hideLoading();
} catch (err) {
hideLoading();
showError(err.message);
}
};
window.runDemo = async function () { window.runDemo = async function () {
const size = parseInt(document.getElementById('demo-size').value); const size = parseInt(document.getElementById('demo-size').value);
const layout = document.getElementById('layout-select').value; const layout = document.getElementById('layout-select').value;