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__)
# 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_PASSWORD = os.environ.get("NEO4J_PASSWORD", "")
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"),
)
# Cache for the last query result (avoids re-querying Neo4j for re-layout)
_last_query_cache: dict = {}
# ---------------------------------------------------------------------------
# Neo4j HTTP Transactional API helpers
@ -308,6 +311,13 @@ def api_query():
nodes_dict, edges, records, keys = execute_cypher(cypher)
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
label_colors: dict[str, str] = {}
for nd in nodes_dict.values():
@ -374,6 +384,84 @@ def api_query():
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")
def api_schema():
try:

View File

@ -1,43 +1,35 @@
[
{
"name": "Focused Entity Relation Graph",
"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"
"name": "Sample Graph (100)",
"query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100"
},
{
"name": "Persons <-> Events",
"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"
"name": "Sample Graph (500)",
"query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 500"
},
{
"name": "Events <-> Dates",
"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"
"name": "Sample Graph (2000)",
"query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 2000"
},
{
"name": "Events <-> Locations",
"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"
"name": "Node Label Counts",
"query": "MATCH (n) RETURN labels(n)[0] AS label, count(*) AS count ORDER BY count DESC LIMIT 25"
},
{
"name": "Persons <-> Locations",
"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"
"name": "Relationship Type Counts",
"query": "MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS count ORDER BY count DESC LIMIT 25"
},
{
"name": "People Around Top Events (2 Hops)",
"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"
"name": "High-Connectivity Nodes",
"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",
"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"
"name": "Shortest Path (sample)",
"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)",
"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"
},
{
"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": "Connected Component (depth 3)",
"query": "MATCH (start) WITH start LIMIT 1 MATCH path = (start)-[*1..3]-(connected) RETURN path LIMIT 300"
},
{
"name": "Schema Visualization",

View File

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

View File

@ -12,7 +12,7 @@
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* =========================================================
CSS — Cortex Graph Explorer
CSS — Graph Explorer
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>
<div class="controls-row">
<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">
Demo</button>
<button class="btn btn-secondary" onclick="reLayout()" title="Re-run layout on cached data (no DB query)">⟳ Re-Layout</button>
<select class="styled-select" id="layout-select" title="Layout algorithm">
<option value="auto">Auto Layout</option>
</select>
</div>
<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">
<option value="100">Demo: 100 nodes</option>
<option value="300" selected>Demo: 300 nodes</option>
@ -1083,6 +1083,7 @@
<button onclick="zoomOut()" title="Zoom out"></button>
<button onclick="fitGraph()" title="Fit to screen"></button>
<button onclick="resetView()" title="Reset"></button>
<button onclick="exportPNG()" title="Export as PNG">📷</button>
</div>
<div id="tooltip">
@ -1155,7 +1156,7 @@
const mmCanvas = document.getElementById('minimap-canvas');
const mmCtx = mmCanvas.getContext('2d');
let dpr = window.devicePixelRatio || 1;
let dpr = Math.max(window.devicePixelRatio || 1, 2);
let width, height;
let transform = d3.zoomIdentity;
let zoomBehavior;
@ -1241,6 +1242,18 @@
.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
================================================================= */
@ -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 () {
const size = parseInt(document.getElementById('demo-size').value);
const layout = document.getElementById('layout-select').value;