add re layout button
This commit is contained in:
parent
0c0df5fe8d
commit
84072c0956
90
app.py
90
app.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) RETURN n, r, m 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue