From 31f5950fdabc9687d13b092c1e34784df3c2254f Mon Sep 17 00:00:00 2001 From: Patrice Date: Sat, 7 Mar 2026 12:43:06 +0100 Subject: [PATCH 1/2] docs --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e74ddbd..4cf26e4 100644 --- a/README.md +++ b/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 Calls every link on a given website and produces an explorable graph visualization. From 84072c0956c721f8072b08dec8584d3f21c95b23 Mon Sep 17 00:00:00 2001 From: Patrice Date: Tue, 10 Mar 2026 16:48:18 +0100 Subject: [PATCH 2/2] add re layout button --- app.py | 90 +++++++++++++++++++++++++++++++++++++- config/sample_queries.json | 40 +++++++---------- requirements.txt | 1 + templates/index.html | 43 ++++++++++++++++-- 4 files changed, 145 insertions(+), 29 deletions(-) diff --git a/app.py b/app.py index 3234ca4..0192cdf 100644 --- a/app.py +++ b/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: diff --git a/config/sample_queries.json b/config/sample_queries.json index f1b6f97..30d5303 100644 --- a/config/sample_queries.json +++ b/config/sample_queries.json @@ -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, '') 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, '')) 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", diff --git a/requirements.txt b/requirements.txt index 7c3b28f..936792e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/templates/index.html b/templates/index.html index c681794..43e43d8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,7 +12,7 @@