Merge commit '115e0cac5dcaa9f66fcc4fd9345443120ec027ed' into feature/neo4j-graph-explorer12
This commit is contained in:
commit
49b215841e
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"nuxt.isNuxtApp": false
|
||||||
|
}
|
||||||
166
README.md
166
README.md
|
|
@ -1,21 +1,157 @@
|
||||||
# Star-Mapper
|
# Star-Mapper — Cortex Graph Explorer
|
||||||
|
|
||||||
Calls every link on a given website and produces an explorable graph visualization.
|
A high-performance Neo4j graph visualization app with Python-precomputed layouts and a Canvas 2D frontend.
|
||||||
|
|
||||||
Please note that the graph layout can take a long time since it is JS based. Loading a graph with 3000 Nodes may take 5 minutes or more.
|
## Quick Start
|
||||||
|
|
||||||
```
|
```bash
|
||||||
Map any website. Only map websites you own, as this tool will open any link on a given
|
# 1. Clone & enter the repo
|
||||||
website, which can potentially incure high costs for the owner and be interpreted
|
git clone https://github.com/Askill/Star-Mapper.git
|
||||||
as a small scale DOS attack.
|
cd Star-Mapper
|
||||||
|
|
||||||
optional arguments:
|
# 2. Create a virtual environment & install dependencies
|
||||||
-h, --help show this help message and exit
|
python3 -m venv .venv
|
||||||
-url url to map
|
source .venv/bin/activate
|
||||||
--plot-cached path to cached file
|
pip install -r requirements.txt
|
||||||
-limit maximum number of nodes on original site
|
|
||||||
|
# 3. Run the app
|
||||||
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples:
|
Open **http://localhost:5555** in your browser.
|
||||||
### Google.de:
|
|
||||||

|
### 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.
|
||||||
|
|
||||||
|
It provides an interactive browser UI where you can run Cypher queries, visualize large graph results, inspect schema metadata, and tune layout/visual settings in real time. Layout computation is performed server-side in Python (igraph/networkx) for better performance on larger graphs.
|
||||||
|
|
||||||
|
## Current Goal
|
||||||
|
|
||||||
|
Make Neo4j graph data explorable and understandable through:
|
||||||
|
|
||||||
|
- Fast query-to-visualization workflow.
|
||||||
|
- Multiple layout algorithms with automatic selection by graph size.
|
||||||
|
- Interactive graph navigation (zoom/pan/highlight/search) plus a tabular result view.
|
||||||
|
|
||||||
|
## Core Functionality
|
||||||
|
|
||||||
|
- Neo4j HTTP Transactional API integration.
|
||||||
|
- Cypher execution endpoint with graph extraction (`nodes`, `relationships`) and tabular rows.
|
||||||
|
- Server-side layout precomputation with algorithms such as:
|
||||||
|
- `auto`
|
||||||
|
- `force_directed`
|
||||||
|
- `force_directed_hq`
|
||||||
|
- `community`
|
||||||
|
- `circle`
|
||||||
|
- `drl` / `kamada_kawai` (when `python-igraph` is available)
|
||||||
|
- `spectral` (fallback when igraph is unavailable)
|
||||||
|
- Node coloring by label and size scaling by degree.
|
||||||
|
- Client features:
|
||||||
|
- Graph/table view toggle.
|
||||||
|
- Hover/select neighborhood highlighting.
|
||||||
|
- Node search and focus.
|
||||||
|
- Minimap.
|
||||||
|
- Visual controls (edge style, node/label size, spacing, iterations).
|
||||||
|
- Built-in demo graph generation (`/api/demo`) so UI can be tested without Neo4j data.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `app.py`: Flask app and API endpoints.
|
||||||
|
- `layout_engine.py`: Graph layout computation and algorithm selection.
|
||||||
|
- `config/sample_queries.json`: Sample Cypher query definitions loaded by `/api/sample-queries`.
|
||||||
|
- `templates/index.html`: Frontend UI (canvas rendering with D3-powered interactions).
|
||||||
|
- `src/Star-Mapper/`: Legacy website crawler code (kept in repository, not the primary current service path).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /`: Serves the explorer UI.
|
||||||
|
- `POST /api/query`: Execute Cypher and return graph + records + stats.
|
||||||
|
- `GET /api/schema`: Return labels, relationship types, property keys.
|
||||||
|
- `GET /api/connection-test`: Verify Neo4j connectivity.
|
||||||
|
- `POST /api/reconnect`: Update Neo4j connection settings at runtime.
|
||||||
|
- `GET /api/layouts`: Return available layout algorithms.
|
||||||
|
- `GET /api/sample-queries`: Return built-in sample Cypher queries.
|
||||||
|
- `POST /api/demo`: Generate synthetic graph data for demo/testing.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables used by `app.py`:
|
||||||
|
|
||||||
|
- `NEO4J_HTTP_URL` (default: `http://localhost`)
|
||||||
|
- `NEO4J_USER` (default: `neo4j`)
|
||||||
|
- `NEO4J_PASSWORD` (default: empty)
|
||||||
|
- `NEO4J_DATABASE` (default: `neo4j`)
|
||||||
|
- `SAMPLE_QUERIES_FILE` (default: `config/sample_queries.json`)
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Optionally set Neo4j connection details:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set NEO4J_HTTP_URL=https://your-neo4j-host
|
||||||
|
set NEO4J_USER=neo4j
|
||||||
|
set NEO4J_PASSWORD=your-password
|
||||||
|
set NEO4J_DATABASE=neo4j
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open:
|
||||||
|
|
||||||
|
`http://localhost:5555`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The current service is the Flask app in `app.py`.
|
||||||
|
- Legacy crawler functionality still exists in `src/Star-Mapper/main.py`, but the existing web UI and API are designed for Neo4j graph exploration.
|
||||||
534
app.py
534
app.py
|
|
@ -23,16 +23,25 @@ from layout_engine import compute_layout, get_available_algorithms
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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", "https://neo4j.develop.cortex.cloud.otto.de")
|
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")
|
||||||
|
SAMPLE_QUERIES_FILE = os.environ.get(
|
||||||
|
"SAMPLE_QUERIES_FILE",
|
||||||
|
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 = {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -42,13 +51,17 @@ def _neo4j_auth_header():
|
||||||
"""Build Basic auth header for Neo4j HTTP API."""
|
"""Build Basic auth header for Neo4j HTTP API."""
|
||||||
cred = f"{NEO4J_USER}:{NEO4J_PASSWORD}"
|
cred = f"{NEO4J_USER}:{NEO4J_PASSWORD}"
|
||||||
b64 = base64.b64encode(cred.encode()).decode()
|
b64 = base64.b64encode(cred.encode()).decode()
|
||||||
return {"Authorization": f"Basic {b64}", "Content-Type": "application/json", "Accept": "application/json;charset=UTF-8"}
|
return {
|
||||||
|
"Authorization": f"Basic {b64}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json;charset=UTF-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _neo4j_tx_url(database=None):
|
def _neo4j_tx_url(database=None):
|
||||||
"""Build the transactional commit endpoint URL."""
|
"""Build the transactional commit endpoint URL."""
|
||||||
db = database or NEO4J_DATABASE
|
db = database or NEO4J_DATABASE
|
||||||
base = NEO4J_HTTP_URL.rstrip('/')
|
base = NEO4J_HTTP_URL.rstrip("/")
|
||||||
return f"{base}/db/{db}/tx/commit"
|
return f"{base}/db/{db}/tx/commit"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,11 +73,13 @@ def execute_cypher(cypher: str, params: dict | None = None):
|
||||||
url = _neo4j_tx_url()
|
url = _neo4j_tx_url()
|
||||||
headers = _neo4j_auth_header()
|
headers = _neo4j_auth_header()
|
||||||
payload = {
|
payload = {
|
||||||
"statements": [{
|
"statements": [
|
||||||
|
{
|
||||||
"statement": cypher,
|
"statement": cypher,
|
||||||
"parameters": params or {},
|
"parameters": params or {},
|
||||||
"resultDataContents": ["row", "graph"]
|
"resultDataContents": ["row", "graph"],
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = http_requests.post(url, json=payload, headers=headers, timeout=120)
|
resp = http_requests.post(url, json=payload, headers=headers, timeout=120)
|
||||||
|
|
@ -102,30 +117,32 @@ def execute_cypher(cypher: str, params: dict | None = None):
|
||||||
labels = node_data.get("labels", [])
|
labels = node_data.get("labels", [])
|
||||||
props = node_data.get("properties", {})
|
props = node_data.get("properties", {})
|
||||||
display = (
|
display = (
|
||||||
props.get('name')
|
props.get("name")
|
||||||
or props.get('title')
|
or props.get("title")
|
||||||
or props.get('id')
|
or props.get("id")
|
||||||
or props.get('sku')
|
or props.get("sku")
|
||||||
or (labels[0] if labels else nid)
|
or (labels[0] if labels else nid)
|
||||||
)
|
)
|
||||||
nodes[nid] = {
|
nodes[nid] = {
|
||||||
'id': nid,
|
"id": nid,
|
||||||
'labels': labels,
|
"labels": labels,
|
||||||
'properties': props,
|
"properties": props,
|
||||||
'label': str(display)[:80],
|
"label": str(display)[:80],
|
||||||
}
|
}
|
||||||
|
|
||||||
for rel_data in graph_data.get("relationships", []):
|
for rel_data in graph_data.get("relationships", []):
|
||||||
eid = str(rel_data["id"])
|
eid = str(rel_data["id"])
|
||||||
if eid not in seen_edges:
|
if eid not in seen_edges:
|
||||||
seen_edges.add(eid)
|
seen_edges.add(eid)
|
||||||
edges.append({
|
edges.append(
|
||||||
'id': eid,
|
{
|
||||||
'source': str(rel_data["startNode"]),
|
"id": eid,
|
||||||
'target': str(rel_data["endNode"]),
|
"source": str(rel_data["startNode"]),
|
||||||
'type': rel_data.get("type", "RELATED"),
|
"target": str(rel_data["endNode"]),
|
||||||
'properties': rel_data.get("properties", {}),
|
"type": rel_data.get("type", "RELATED"),
|
||||||
})
|
"properties": rel_data.get("properties", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return nodes, edges, records_out, keys
|
return nodes, edges, records_out, keys
|
||||||
|
|
||||||
|
|
@ -148,14 +165,119 @@ def _execute_simple(cypher: str):
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sample_queries():
|
||||||
|
"""Fallback sample queries when no external file is available."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "Product Neighborhood (200)",
|
||||||
|
"query": "MATCH (p) WHERE 'Product' IN labels(p) WITH p LIMIT 200 MATCH (p)-[r]-(n) RETURN p, r, n LIMIT 1000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Products by Category",
|
||||||
|
"query": "MATCH (p)-[r]-(c) WHERE 'Product' IN labels(p) AND 'Category' IN labels(c) RETURN p, r, c LIMIT 800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Products by Brand",
|
||||||
|
"query": "MATCH (p)-[r]-(b) WHERE 'Product' IN labels(p) AND 'Brand' IN labels(b) RETURN p, r, b LIMIT 800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Supplier to Product Network",
|
||||||
|
"query": "MATCH (s)-[r]-(p) WHERE 'Supplier' IN labels(s) AND 'Product' IN labels(p) RETURN s, r, p LIMIT 800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Product Attributes",
|
||||||
|
"query": "MATCH (p)-[r]-(a) WHERE 'Product' IN labels(p) AND any(lbl IN labels(a) WHERE lbl IN ['Attribute','Color','Material','Tag']) RETURN p, r, a LIMIT 1000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Most Connected Products",
|
||||||
|
"query": "MATCH (p)-[r]-() WHERE 'Product' IN labels(p) WITH p, count(r) AS degree ORDER BY degree DESC LIMIT 25 MATCH (p)-[r2]-(n) RETURN p, r2, n LIMIT 1200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Category Graph (Depth 2)",
|
||||||
|
"query": "MATCH (c) WHERE 'Category' IN labels(c) WITH c LIMIT 20 MATCH path=(c)-[*1..2]-(related) RETURN path LIMIT 500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Review Connections",
|
||||||
|
"query": "MATCH (p)-[r]-(rv) WHERE 'Product' IN labels(p) AND 'Review' IN labels(rv) RETURN p, r, rv LIMIT 800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Relationship Type Counts",
|
||||||
|
"query": "MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS count ORDER BY count DESC LIMIT 25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Node Label Counts",
|
||||||
|
"query": "MATCH (n) UNWIND labels(n) AS label RETURN label, count(*) AS count ORDER BY count DESC LIMIT 25",
|
||||||
|
},
|
||||||
|
{"name": "Schema Visualization", "query": "CALL db.schema.visualization()"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sample_queries():
|
||||||
|
"""Load sample queries from JSON, falling back to sensible defaults."""
|
||||||
|
try:
|
||||||
|
with open(SAMPLE_QUERIES_FILE, "r", encoding="utf-8") as fh:
|
||||||
|
payload = json.load(fh)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("Sample query file not found: %s", SAMPLE_QUERIES_FILE)
|
||||||
|
return _default_sample_queries()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load sample queries from %s: %s", SAMPLE_QUERIES_FILE, exc
|
||||||
|
)
|
||||||
|
return _default_sample_queries()
|
||||||
|
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
logger.warning(
|
||||||
|
"Sample query file must contain a JSON array: %s", SAMPLE_QUERIES_FILE
|
||||||
|
)
|
||||||
|
return _default_sample_queries()
|
||||||
|
|
||||||
|
valid_queries = []
|
||||||
|
for idx, item in enumerate(payload):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
logger.warning("Skipping sample query #%d: expected object", idx)
|
||||||
|
continue
|
||||||
|
name = item.get("name")
|
||||||
|
query = item.get("query")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
logger.warning("Skipping sample query #%d: missing non-empty 'name'", idx)
|
||||||
|
continue
|
||||||
|
if not isinstance(query, str) or not query.strip():
|
||||||
|
logger.warning("Skipping sample query #%d: missing non-empty 'query'", idx)
|
||||||
|
continue
|
||||||
|
valid_queries.append({"name": name.strip(), "query": query.strip()})
|
||||||
|
|
||||||
|
if not valid_queries:
|
||||||
|
logger.warning("No valid sample queries found in %s", SAMPLE_QUERIES_FILE)
|
||||||
|
return _default_sample_queries()
|
||||||
|
|
||||||
|
return valid_queries
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Color generation
|
# Color generation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_PALETTE = [
|
_PALETTE = [
|
||||||
'#00d4ff', '#ff6b6b', '#ffd93d', '#6bcb77', '#9b59b6',
|
"#00d4ff",
|
||||||
'#e67e22', '#1abc9c', '#e74c3c', '#3498db', '#f39c12',
|
"#ff6b6b",
|
||||||
'#2ecc71', '#e91e63', '#00bcd4', '#ff9800', '#8bc34a',
|
"#ffd93d",
|
||||||
'#673ab7', '#009688', '#ff5722', '#607d8b', '#cddc39',
|
"#6bcb77",
|
||||||
|
"#9b59b6",
|
||||||
|
"#e67e22",
|
||||||
|
"#1abc9c",
|
||||||
|
"#e74c3c",
|
||||||
|
"#3498db",
|
||||||
|
"#f39c12",
|
||||||
|
"#2ecc71",
|
||||||
|
"#e91e63",
|
||||||
|
"#00bcd4",
|
||||||
|
"#ff9800",
|
||||||
|
"#8bc34a",
|
||||||
|
"#673ab7",
|
||||||
|
"#009688",
|
||||||
|
"#ff5722",
|
||||||
|
"#607d8b",
|
||||||
|
"#cddc39",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -168,117 +290,216 @@ def color_for_label(label: str) -> str:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@app.route('/')
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/query', methods=['POST'])
|
@app.route("/api/query", methods=["POST"])
|
||||||
def api_query():
|
def api_query():
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
cypher = data.get('query', '').strip()
|
cypher = data.get("query", "").strip()
|
||||||
layout_algo = data.get('layout', 'auto')
|
layout_algo = data.get("layout", "auto")
|
||||||
spacing = float(data.get('spacing', 1.0))
|
spacing = float(data.get("spacing", 1.0))
|
||||||
iterations = int(data.get('iterations', 300))
|
iterations = int(data.get("iterations", 300))
|
||||||
|
|
||||||
if not cypher:
|
if not cypher:
|
||||||
return jsonify({'error': 'Empty query'}), 400
|
return jsonify({"error": "Empty query"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
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():
|
||||||
for lb in nd.get('labels', []):
|
for lb in nd.get("labels", []):
|
||||||
if lb not in label_colors:
|
if lb not in label_colors:
|
||||||
label_colors[lb] = color_for_label(lb)
|
label_colors[lb] = color_for_label(lb)
|
||||||
|
|
||||||
# Compute layout server-side
|
# Compute layout server-side
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
positions = compute_layout(nodes_dict, edges, algorithm=layout_algo, spacing=spacing, iterations=iterations)
|
positions = compute_layout(
|
||||||
|
nodes_dict,
|
||||||
|
edges,
|
||||||
|
algorithm=layout_algo,
|
||||||
|
spacing=spacing,
|
||||||
|
iterations=iterations,
|
||||||
|
)
|
||||||
t_layout = time.time() - t1
|
t_layout = time.time() - t1
|
||||||
|
|
||||||
# Degree for sizing
|
# Degree for sizing
|
||||||
degree: dict[str, int] = defaultdict(int)
|
degree: dict[str, int] = defaultdict(int)
|
||||||
for e in edges:
|
for e in edges:
|
||||||
degree[e['source']] += 1
|
degree[e["source"]] += 1
|
||||||
degree[e['target']] += 1
|
degree[e["target"]] += 1
|
||||||
max_deg = max(degree.values()) if degree else 1
|
max_deg = max(degree.values()) if degree else 1
|
||||||
|
|
||||||
nodes_list = []
|
nodes_list = []
|
||||||
for nid, nd in nodes_dict.items():
|
for nid, nd in nodes_dict.items():
|
||||||
pos = positions.get(nid, {'x': 0, 'y': 0})
|
pos = positions.get(nid, {"x": 0, "y": 0})
|
||||||
primary = nd['labels'][0] if nd.get('labels') else 'Unknown'
|
primary = nd["labels"][0] if nd.get("labels") else "Unknown"
|
||||||
nd['x'] = pos['x']
|
nd["x"] = pos["x"]
|
||||||
nd['y'] = pos['y']
|
nd["y"] = pos["y"]
|
||||||
nd['color'] = label_colors.get(primary, '#888888')
|
nd["color"] = label_colors.get(primary, "#888888")
|
||||||
d = degree.get(nid, 0)
|
d = degree.get(nid, 0)
|
||||||
nd['size'] = 3 + (d / max(max_deg, 1)) * 22
|
nd["size"] = 3 + (d / max(max_deg, 1)) * 22
|
||||||
nodes_list.append(nd)
|
nodes_list.append(nd)
|
||||||
|
|
||||||
# Deduplicate edges (keep unique source-target-type combos)
|
# Deduplicate edges (keep unique source-target-type combos)
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_edges = []
|
unique_edges = []
|
||||||
for e in edges:
|
for e in edges:
|
||||||
key = (e['source'], e['target'], e['type'])
|
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], # cap tabular results
|
||||||
|
"keys": keys,
|
||||||
|
"stats": {
|
||||||
|
"node_count": len(nodes_list),
|
||||||
|
"edge_count": len(unique_edges),
|
||||||
|
"labels": list(label_colors.keys()),
|
||||||
|
"query_time_ms": round(t_query * 1000),
|
||||||
|
"layout_time_ms": round(t_layout * 1000),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Query failed")
|
||||||
|
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:
|
if key not in seen:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
unique_edges.append(e)
|
unique_edges.append(e)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'nodes': nodes_list,
|
"nodes": nodes_list,
|
||||||
'edges': unique_edges,
|
"edges": unique_edges,
|
||||||
'label_colors': label_colors,
|
"label_colors": label_colors,
|
||||||
'records': records[:500], # cap tabular results
|
"records": records[:500],
|
||||||
'keys': keys,
|
"keys": keys,
|
||||||
'stats': {
|
"stats": {
|
||||||
'node_count': len(nodes_list),
|
"node_count": len(nodes_list),
|
||||||
'edge_count': len(unique_edges),
|
"edge_count": len(unique_edges),
|
||||||
'labels': list(label_colors.keys()),
|
"labels": list(label_colors.keys()),
|
||||||
'query_time_ms': round(t_query * 1000),
|
"query_time_ms": 0,
|
||||||
'layout_time_ms': round(t_layout * 1000),
|
"layout_time_ms": round(t_layout * 1000),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Query failed")
|
logger.exception("Re-layout failed")
|
||||||
return jsonify({'error': str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/schema')
|
@app.route("/api/schema")
|
||||||
def api_schema():
|
def api_schema():
|
||||||
try:
|
try:
|
||||||
labels = [r[0] for r in _execute_simple("CALL db.labels()")]
|
labels = [r[0] for r in _execute_simple("CALL db.labels()")]
|
||||||
rel_types = [r[0] for r in _execute_simple("CALL db.relationshipTypes()")]
|
rel_types = [r[0] for r in _execute_simple("CALL db.relationshipTypes()")]
|
||||||
prop_keys = [r[0] for r in _execute_simple("CALL db.propertyKeys()")]
|
prop_keys = [r[0] for r in _execute_simple("CALL db.propertyKeys()")]
|
||||||
return jsonify({'labels': labels, 'relationship_types': rel_types, 'property_keys': prop_keys})
|
return jsonify(
|
||||||
|
{
|
||||||
|
"labels": labels,
|
||||||
|
"relationship_types": rel_types,
|
||||||
|
"property_keys": prop_keys,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'error': str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/connection-test')
|
@app.route("/api/connection-test")
|
||||||
def api_connection_test():
|
def api_connection_test():
|
||||||
try:
|
try:
|
||||||
rows = _execute_simple("RETURN 1 AS ok")
|
rows = _execute_simple("RETURN 1 AS ok")
|
||||||
if rows and rows[0][0] == 1:
|
if rows and rows[0][0] == 1:
|
||||||
return jsonify({'status': 'connected', 'uri': NEO4J_HTTP_URL})
|
return jsonify({"status": "connected", "uri": NEO4J_HTTP_URL})
|
||||||
raise RuntimeError("Unexpected response")
|
raise RuntimeError("Unexpected response")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'status': 'error', 'message': str(exc)}), 500
|
return jsonify({"status": "error", "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/reconnect', methods=['POST'])
|
@app.route("/api/reconnect", methods=["POST"])
|
||||||
def api_reconnect():
|
def api_reconnect():
|
||||||
global NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, NEO4J_DATABASE
|
global NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, NEO4J_DATABASE
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
new_url = data.get('uri', '').strip()
|
new_url = data.get("uri", "").strip()
|
||||||
new_user = data.get('user', '').strip()
|
new_user = data.get("user", "").strip()
|
||||||
new_pass = data.get('password', '')
|
new_pass = data.get("password", "")
|
||||||
|
|
||||||
if not new_url:
|
if not new_url:
|
||||||
return jsonify({'status': 'error', 'message': 'URL is required'}), 400
|
return jsonify({"status": "error", "message": "URL is required"}), 400
|
||||||
|
|
||||||
NEO4J_HTTP_URL = new_url
|
NEO4J_HTTP_URL = new_url
|
||||||
NEO4J_USER = new_user
|
NEO4J_USER = new_user
|
||||||
|
|
@ -287,63 +508,84 @@ def api_reconnect():
|
||||||
try:
|
try:
|
||||||
rows = _execute_simple("RETURN 1 AS ok")
|
rows = _execute_simple("RETURN 1 AS ok")
|
||||||
if rows and rows[0][0] == 1:
|
if rows and rows[0][0] == 1:
|
||||||
return jsonify({'status': 'connected', 'uri': NEO4J_HTTP_URL})
|
return jsonify({"status": "connected", "uri": NEO4J_HTTP_URL})
|
||||||
raise RuntimeError("Unexpected response")
|
raise RuntimeError("Unexpected response")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'status': 'error', 'message': str(exc)}), 500
|
return jsonify({"status": "error", "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/layouts')
|
@app.route("/api/layouts")
|
||||||
def api_layouts():
|
def api_layouts():
|
||||||
return jsonify(get_available_algorithms())
|
return jsonify(get_available_algorithms())
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/sample-queries')
|
@app.route("/api/sample-queries")
|
||||||
def api_sample_queries():
|
def api_sample_queries():
|
||||||
queries = [
|
return jsonify(_load_sample_queries())
|
||||||
{'name': 'Sample Graph (100)',
|
|
||||||
'query': 'MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100'},
|
|
||||||
{'name': 'Sample Graph (500)',
|
|
||||||
'query': 'MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 500'},
|
|
||||||
{'name': 'Sample Graph (2000)',
|
|
||||||
'query': 'MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 2000'},
|
|
||||||
{'name': 'Node Label Counts',
|
|
||||||
'query': 'MATCH (n) RETURN labels(n)[0] AS label, count(*) AS count ORDER BY count DESC LIMIT 25'},
|
|
||||||
{'name': 'Relationship Type Counts',
|
|
||||||
'query': 'MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS count ORDER BY count DESC LIMIT 25'},
|
|
||||||
{'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': 'Shortest Path (sample)',
|
|
||||||
'query': 'MATCH (a), (b) WHERE a <> b WITH a, b LIMIT 1 MATCH path = shortestPath((a)-[*..5]-(b)) RETURN path'},
|
|
||||||
{'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',
|
|
||||||
'query': 'CALL db.schema.visualization()'},
|
|
||||||
]
|
|
||||||
return jsonify(queries)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/demo', methods=['POST'])
|
@app.route("/api/demo", methods=["POST"])
|
||||||
def api_demo():
|
def api_demo():
|
||||||
"""Generate a demo graph for testing the visualization without Neo4j."""
|
"""Generate a demo graph for testing the visualization without Neo4j."""
|
||||||
import random
|
import random
|
||||||
|
|
||||||
data = request.get_json(force=True) if request.is_json else {}
|
data = request.get_json(force=True) if request.is_json else {}
|
||||||
size = min(int(data.get('size', 300)), 5000)
|
size = min(int(data.get("size", 300)), 5000)
|
||||||
layout_algo = data.get('layout', 'auto')
|
layout_algo = data.get("layout", "auto")
|
||||||
spacing = float(data.get('spacing', 1.0))
|
spacing = float(data.get("spacing", 1.0))
|
||||||
iterations = int(data.get('iterations', 300))
|
iterations = int(data.get("iterations", 300))
|
||||||
|
|
||||||
random.seed(42)
|
random.seed(42)
|
||||||
|
|
||||||
label_types = ['Product', 'Category', 'Brand', 'Supplier', 'Attribute',
|
label_types = [
|
||||||
'Color', 'Material', 'Tag', 'Collection', 'Review']
|
"Product",
|
||||||
rel_types = ['BELONGS_TO', 'MADE_BY', 'SUPPLIED_BY', 'HAS_ATTRIBUTE',
|
"Category",
|
||||||
'HAS_COLOR', 'MADE_OF', 'TAGGED_WITH', 'PART_OF', 'REVIEWED_IN', 'SIMILAR_TO']
|
"Brand",
|
||||||
|
"Supplier",
|
||||||
|
"Attribute",
|
||||||
|
"Color",
|
||||||
|
"Material",
|
||||||
|
"Tag",
|
||||||
|
"Collection",
|
||||||
|
"Review",
|
||||||
|
]
|
||||||
|
rel_types = [
|
||||||
|
"BELONGS_TO",
|
||||||
|
"MADE_BY",
|
||||||
|
"SUPPLIED_BY",
|
||||||
|
"HAS_ATTRIBUTE",
|
||||||
|
"HAS_COLOR",
|
||||||
|
"MADE_OF",
|
||||||
|
"TAGGED_WITH",
|
||||||
|
"PART_OF",
|
||||||
|
"REVIEWED_IN",
|
||||||
|
"SIMILAR_TO",
|
||||||
|
]
|
||||||
|
|
||||||
adj_names = ['Premium', 'Eco', 'Organic', 'Classic', 'Modern', 'Vintage',
|
adj_names = [
|
||||||
'Smart', 'Ultra', 'Compact', 'Deluxe']
|
"Premium",
|
||||||
noun_names = ['Widget', 'Gadget', 'Module', 'Unit', 'Element', 'Component',
|
"Eco",
|
||||||
'System', 'Kit', 'Bundle', 'Pack']
|
"Organic",
|
||||||
|
"Classic",
|
||||||
|
"Modern",
|
||||||
|
"Vintage",
|
||||||
|
"Smart",
|
||||||
|
"Ultra",
|
||||||
|
"Compact",
|
||||||
|
"Deluxe",
|
||||||
|
]
|
||||||
|
noun_names = [
|
||||||
|
"Widget",
|
||||||
|
"Gadget",
|
||||||
|
"Module",
|
||||||
|
"Unit",
|
||||||
|
"Element",
|
||||||
|
"Component",
|
||||||
|
"System",
|
||||||
|
"Kit",
|
||||||
|
"Bundle",
|
||||||
|
"Pack",
|
||||||
|
]
|
||||||
|
|
||||||
nodes_dict = {}
|
nodes_dict = {}
|
||||||
edges = []
|
edges = []
|
||||||
|
|
@ -364,10 +606,14 @@ def api_demo():
|
||||||
name = f"{random.choice(adj_names)} {random.choice(noun_names)} {i}"
|
name = f"{random.choice(adj_names)} {random.choice(noun_names)} {i}"
|
||||||
nid = f"demo_{i}"
|
nid = f"demo_{i}"
|
||||||
nodes_dict[nid] = {
|
nodes_dict[nid] = {
|
||||||
'id': nid,
|
"id": nid,
|
||||||
'labels': [chosen_label],
|
"labels": [chosen_label],
|
||||||
'properties': {'name': name, 'sku': f"SKU-{i:05d}", 'price': round(random.uniform(5, 500), 2)},
|
"properties": {
|
||||||
'label': name,
|
"name": name,
|
||||||
|
"sku": f"SKU-{i:05d}",
|
||||||
|
"price": round(random.uniform(5, 500), 2),
|
||||||
|
},
|
||||||
|
"label": name,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create edges — mix of random & preferential attachment
|
# Create edges — mix of random & preferential attachment
|
||||||
|
|
@ -378,18 +624,20 @@ def api_demo():
|
||||||
src = random.choice(node_ids)
|
src = random.choice(node_ids)
|
||||||
# Preferential attachment: higher-degree nodes more likely as targets
|
# Preferential attachment: higher-degree nodes more likely as targets
|
||||||
if random.random() < 0.3 and degree:
|
if random.random() < 0.3 and degree:
|
||||||
top = sorted(degree, key=degree.get, reverse=True)[:max(1, len(top) if 'top' in dir() else 10)]
|
top = sorted(degree.keys(), key=lambda nid: degree[nid], reverse=True)[:10]
|
||||||
tgt = random.choice(top)
|
tgt = random.choice(top)
|
||||||
else:
|
else:
|
||||||
tgt = random.choice(node_ids)
|
tgt = random.choice(node_ids)
|
||||||
if src != tgt:
|
if src != tgt:
|
||||||
edges.append({
|
edges.append(
|
||||||
'id': f"edge_{len(edges)}",
|
{
|
||||||
'source': src,
|
"id": f"edge_{len(edges)}",
|
||||||
'target': tgt,
|
"source": src,
|
||||||
'type': random.choice(rel_types),
|
"target": tgt,
|
||||||
'properties': {},
|
"type": random.choice(rel_types),
|
||||||
})
|
"properties": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
degree[src] += 1
|
degree[src] += 1
|
||||||
degree[tgt] += 1
|
degree[tgt] += 1
|
||||||
|
|
||||||
|
|
@ -398,37 +646,41 @@ def api_demo():
|
||||||
|
|
||||||
# Layout
|
# Layout
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
positions = compute_layout(nodes_dict, edges, algorithm=layout_algo, spacing=spacing, iterations=iterations)
|
positions = compute_layout(
|
||||||
|
nodes_dict, edges, algorithm=layout_algo, spacing=spacing, iterations=iterations
|
||||||
|
)
|
||||||
t_layout = time.time() - t1
|
t_layout = time.time() - t1
|
||||||
|
|
||||||
max_deg = max(degree.values()) if degree else 1
|
max_deg = max(degree.values()) if degree else 1
|
||||||
nodes_list = []
|
nodes_list = []
|
||||||
for nid, nd in nodes_dict.items():
|
for nid, nd in nodes_dict.items():
|
||||||
pos = positions.get(nid, {'x': 0, 'y': 0})
|
pos = positions.get(nid, {"x": 0, "y": 0})
|
||||||
primary = nd['labels'][0]
|
primary = nd["labels"][0]
|
||||||
nd['x'] = pos['x']
|
nd["x"] = pos["x"]
|
||||||
nd['y'] = pos['y']
|
nd["y"] = pos["y"]
|
||||||
nd['color'] = label_colors.get(primary, '#888')
|
nd["color"] = label_colors.get(primary, "#888")
|
||||||
d = degree.get(nid, 0)
|
d = degree.get(nid, 0)
|
||||||
nd['size'] = 3 + (d / max(max_deg, 1)) * 22
|
nd["size"] = 3 + (d / max(max_deg, 1)) * 22
|
||||||
nodes_list.append(nd)
|
nodes_list.append(nd)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(
|
||||||
'nodes': nodes_list,
|
{
|
||||||
'edges': edges,
|
"nodes": nodes_list,
|
||||||
'label_colors': label_colors,
|
"edges": edges,
|
||||||
'records': [],
|
"label_colors": label_colors,
|
||||||
'keys': [],
|
"records": [],
|
||||||
'stats': {
|
"keys": [],
|
||||||
'node_count': len(nodes_list),
|
"stats": {
|
||||||
'edge_count': len(edges),
|
"node_count": len(nodes_list),
|
||||||
'labels': list(label_colors.keys()),
|
"edge_count": len(edges),
|
||||||
'query_time_ms': 0,
|
"labels": list(label_colors.keys()),
|
||||||
'layout_time_ms': round(t_layout * 1000),
|
"query_time_ms": 0,
|
||||||
|
"layout_time_ms": round(t_layout * 1000),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host='0.0.0.0', port=5555)
|
app.run(debug=True, host="0.0.0.0", port=5555)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Sample Graph (100)",
|
||||||
|
"query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sample Graph (500)",
|
||||||
|
"query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sample Graph (2000)",
|
||||||
|
"query": "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 2000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Node Label Counts",
|
||||||
|
"query": "MATCH (n) RETURN labels(n)[0] AS label, count(*) AS count ORDER BY count DESC LIMIT 25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Relationship Type Counts",
|
||||||
|
"query": "MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS count ORDER BY count DESC LIMIT 25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "Shortest Path (sample)",
|
||||||
|
"query": "MATCH (a), (b) WHERE a <> b WITH a, b LIMIT 1 MATCH path = shortestPath((a)-[*..5]-(b)) RETURN path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"query": "CALL db.schema.visualization()"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Focused Entity Relation Graph",
|
||||||
|
"query": "MATCH (a:Entity)-[r:RELATION]-(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN a, r, b LIMIT 2000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Entity Relation Leaders (Focused Types)",
|
||||||
|
"query": "MATCH (e:Entity)-[r:RELATION]-(:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] WITH e, count(r) AS rel_degree RETURN e, rel_degree ORDER BY rel_degree DESC LIMIT 75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Person Event Bridges",
|
||||||
|
"query": "MATCH (p:Entity)-[r:RELATION]-(ev:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] RETURN p, r, ev LIMIT 1800"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Event Date Location Triads",
|
||||||
|
"query": "MATCH (ev:Entity)-[r1:RELATION]-(d:Entity), (ev)-[r2:RELATION]-(loc:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(d.type, '')) IN ['date','time','datetime'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN ev, r1, d, r2, loc LIMIT 1500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Persons by Location",
|
||||||
|
"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 1800"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Top Events 2-Hop Neighborhood",
|
||||||
|
"query": "MATCH (ev:Entity)-[r0:RELATION]-() WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] WITH ev, count(r0) AS degree ORDER BY degree DESC LIMIT 25 MATCH path=(ev)-[:RELATION*1..2]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN path LIMIT 2200"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Asymmetric Focused Relations",
|
||||||
|
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND NOT (b)-[:RELATION]->(a) RETURN a, r, b LIMIT 1800"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reciprocal Focused Relations",
|
||||||
|
"query": "MATCH (a:Entity)-[r1:RELATION]->(b:Entity), (b)-[r2:RELATION]->(a) WHERE id(a) < id(b) AND toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN a, r1, b, r2 LIMIT 1800"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Predicate Distribution (Focused)",
|
||||||
|
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN coalesce(r.predicate_display, r.predicate, '<missing>') AS predicate, count(*) AS count ORDER BY count DESC LIMIT 75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "Potential Duplicate Entities (Canonical Key)",
|
||||||
|
"query": "MATCH (e:Entity) WHERE e.canonical_key IS NOT NULL AND trim(toString(e.canonical_key)) <> '' WITH toLower(toString(e.canonical_key)) AS key, collect(e) AS ents WHERE size(ents) > 1 UNWIND ents AS e RETURN e, key LIMIT 1500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schema Visualization",
|
||||||
|
"query": "CALL db.schema.visualization()"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -3,5 +3,8 @@ 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
|
||||||
|
pytest>=7.0
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue