Merge pull request #2 from PatriceMatz7549/feature/neo4j-graph-explorer12
Feature/neo4j graph explorer12
This commit is contained in:
commit
6f0719980b
64
README.md
64
README.md
|
|
@ -1,4 +1,66 @@
|
||||||
# Star-Mapper
|
# Star-Mapper — Cortex Graph Explorer
|
||||||
|
|
||||||
|
A high-performance Neo4j graph visualization app with Python-precomputed layouts and a Canvas 2D frontend.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone & enter the repo
|
||||||
|
git clone https://github.com/Askill/Star-Mapper.git
|
||||||
|
cd Star-Mapper
|
||||||
|
|
||||||
|
# 2. Create a virtual environment & install dependencies
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 3. Run the app
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:5555** in your browser.
|
||||||
|
|
||||||
|
### Environment Variables (optional)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `NEO4J_HTTP_URL` | `https://neo4j.develop.cortex.cloud.otto.de` | Neo4j HTTP endpoint |
|
||||||
|
| `NEO4J_USER` | `neo4j` | Username |
|
||||||
|
| `NEO4J_PASSWORD` | _(empty)_ | Password |
|
||||||
|
| `NEO4J_DATABASE` | `neo4j` | Database name |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: connect to a different instance
|
||||||
|
NEO4J_HTTP_URL=https://my-neo4j.example.com NEO4J_USER=admin NEO4J_PASSWORD=secret python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also change the connection at runtime via the **Connection Settings** panel in the sidebar.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Neo4j connectivity** via HTTP Transactional API (works behind ALB/HTTP proxies)
|
||||||
|
- **Python-precomputed layouts** using igraph (C-based) — auto-selects algorithm by graph size
|
||||||
|
- **Canvas 2D rendering** with D3 zoom/pan, quadtree hit testing, viewport frustum culling
|
||||||
|
- **Curved edges** with configurable curvature, multi-edge spreading
|
||||||
|
- **Recursive highlight diffusion** — click a node to BFS-highlight neighbors with decaying opacity
|
||||||
|
- **Visual settings sliders** — curvature, edge opacity/width/color, node size, label size/zoom, spacing, iterations
|
||||||
|
- **Schema browser**, sample queries, node search, minimap, dark glass-morphism theme
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Nodes | Layout Time |
|
||||||
|
|-------|------------|
|
||||||
|
| 300 | ~10 ms |
|
||||||
|
| 3,000 | ~77 ms |
|
||||||
|
| 5,000 | ~313 ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Original Star-Mapper description below._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Original: Website Mapper
|
||||||
|
|
||||||
Star-Mapper is a Flask-based graph exploration service for Neo4j.
|
Star-Mapper is a Flask-based graph exploration service for Neo4j.
|
||||||
|
|
||||||
|
|
|
||||||
90
app.py
90
app.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) RETURN n, r, m LIMIT 100"></textarea>
|
placeholder="MATCH (n)-[r]->(m) RETURN n, r, m 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue