This commit is contained in:
Askill 2026-03-07 14:24:32 +01:00
parent a515964e8b
commit 99c7badd72
4 changed files with 2319 additions and 1893 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"nuxt.isNuxtApp": false
}

100
README.md
View File

@ -1,21 +1,93 @@
# Star-Mapper
Calls every link on a given website and produces an explorable graph visualization.
Star-Mapper is a Flask-based graph exploration service for Neo4j.
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.
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.
```
Map any website. Only map websites you own, as this tool will open any link on a given
website, which can potentially incure high costs for the owner and be interpreted
as a small scale DOS attack.
## Current Goal
optional arguments:
-h, --help show this help message and exit
-url url to map
--plot-cached path to cached file
-limit maximum number of nodes on original site
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.
- `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`)
## Local Development
1. Install dependencies:
```bash
pip install -r requirements.txt
```
## Examples:
### Google.de:
![google.de](./docs/google.png)
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.

386
app.py
View File

@ -23,13 +23,15 @@ from layout_engine import compute_layout, get_available_algorithms
# ---------------------------------------------------------------------------
# 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__)
app = Flask(__name__)
# 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", "http://localhost")
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "")
NEO4J_DATABASE = os.environ.get("NEO4J_DATABASE", "neo4j")
@ -42,13 +44,17 @@ def _neo4j_auth_header():
"""Build Basic auth header for Neo4j HTTP API."""
cred = f"{NEO4J_USER}:{NEO4J_PASSWORD}"
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):
"""Build the transactional commit endpoint URL."""
db = database or NEO4J_DATABASE
base = NEO4J_HTTP_URL.rstrip('/')
base = NEO4J_HTTP_URL.rstrip("/")
return f"{base}/db/{db}/tx/commit"
@ -60,11 +66,13 @@ def execute_cypher(cypher: str, params: dict | None = None):
url = _neo4j_tx_url()
headers = _neo4j_auth_header()
payload = {
"statements": [{
"statements": [
{
"statement": cypher,
"parameters": params or {},
"resultDataContents": ["row", "graph"]
}]
"resultDataContents": ["row", "graph"],
}
]
}
resp = http_requests.post(url, json=payload, headers=headers, timeout=120)
@ -102,30 +110,32 @@ def execute_cypher(cypher: str, params: dict | None = None):
labels = node_data.get("labels", [])
props = node_data.get("properties", {})
display = (
props.get('name')
or props.get('title')
or props.get('id')
or props.get('sku')
props.get("name")
or props.get("title")
or props.get("id")
or props.get("sku")
or (labels[0] if labels else nid)
)
nodes[nid] = {
'id': nid,
'labels': labels,
'properties': props,
'label': str(display)[:80],
"id": nid,
"labels": labels,
"properties": props,
"label": str(display)[:80],
}
for rel_data in graph_data.get("relationships", []):
eid = str(rel_data["id"])
if eid not in seen_edges:
seen_edges.add(eid)
edges.append({
'id': eid,
'source': str(rel_data["startNode"]),
'target': str(rel_data["endNode"]),
'type': rel_data.get("type", "RELATED"),
'properties': rel_data.get("properties", {}),
})
edges.append(
{
"id": eid,
"source": str(rel_data["startNode"]),
"target": str(rel_data["endNode"]),
"type": rel_data.get("type", "RELATED"),
"properties": rel_data.get("properties", {}),
}
)
return nodes, edges, records_out, keys
@ -152,10 +162,26 @@ def _execute_simple(cypher: str):
# Color generation
# ---------------------------------------------------------------------------
_PALETTE = [
'#00d4ff', '#ff6b6b', '#ffd93d', '#6bcb77', '#9b59b6',
'#e67e22', '#1abc9c', '#e74c3c', '#3498db', '#f39c12',
'#2ecc71', '#e91e63', '#00bcd4', '#ff9800', '#8bc34a',
'#673ab7', '#009688', '#ff5722', '#607d8b', '#cddc39',
"#00d4ff",
"#ff6b6b",
"#ffd93d",
"#6bcb77",
"#9b59b6",
"#e67e22",
"#1abc9c",
"#e74c3c",
"#3498db",
"#f39c12",
"#2ecc71",
"#e91e63",
"#00bcd4",
"#ff9800",
"#8bc34a",
"#673ab7",
"#009688",
"#ff5722",
"#607d8b",
"#cddc39",
]
@ -168,21 +194,21 @@ def color_for_label(label: str) -> str:
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route('/')
@app.route("/")
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():
data = request.get_json(force=True)
cypher = data.get('query', '').strip()
layout_algo = data.get('layout', 'auto')
spacing = float(data.get('spacing', 1.0))
iterations = int(data.get('iterations', 300))
cypher = data.get("query", "").strip()
layout_algo = data.get("layout", "auto")
spacing = float(data.get("spacing", 1.0))
iterations = int(data.get("iterations", 300))
if not cypher:
return jsonify({'error': 'Empty query'}), 400
return jsonify({"error": "Empty query"}), 400
try:
t0 = time.time()
@ -192,93 +218,107 @@ def api_query():
# Assign colours
label_colors: dict[str, str] = {}
for nd in nodes_dict.values():
for lb in nd.get('labels', []):
for lb in nd.get("labels", []):
if lb not in label_colors:
label_colors[lb] = color_for_label(lb)
# Compute layout server-side
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
# Degree for sizing
degree: dict[str, int] = defaultdict(int)
for e in edges:
degree[e['source']] += 1
degree[e['target']] += 1
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')
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
nd["size"] = 3 + (d / max(max_deg, 1)) * 22
nodes_list.append(nd)
# Deduplicate edges (keep unique source-target-type combos)
seen = set()
unique_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),
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
return jsonify({"error": str(exc)}), 400
@app.route('/api/schema')
@app.route("/api/schema")
def api_schema():
try:
labels = [r[0] for r in _execute_simple("CALL db.labels()")]
rel_types = [r[0] for r in _execute_simple("CALL db.relationshipTypes()")]
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:
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():
try:
rows = _execute_simple("RETURN 1 AS ok")
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")
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():
global NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, NEO4J_DATABASE
data = request.get_json(force=True)
new_url = data.get('uri', '').strip()
new_user = data.get('user', '').strip()
new_pass = data.get('password', '')
new_url = data.get("uri", "").strip()
new_user = data.get("user", "").strip()
new_pass = data.get("password", "")
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_USER = new_user
@ -287,63 +327,119 @@ def api_reconnect():
try:
rows = _execute_simple("RETURN 1 AS ok")
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")
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():
return jsonify(get_available_algorithms())
@app.route('/api/sample-queries')
@app.route("/api/sample-queries")
def api_sample_queries():
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()'},
{
"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():
"""Generate a demo graph for testing the visualization without Neo4j."""
import random
data = request.get_json(force=True) if request.is_json else {}
size = min(int(data.get('size', 300)), 5000)
layout_algo = data.get('layout', 'auto')
spacing = float(data.get('spacing', 1.0))
iterations = int(data.get('iterations', 300))
size = min(int(data.get("size", 300)), 5000)
layout_algo = data.get("layout", "auto")
spacing = float(data.get("spacing", 1.0))
iterations = int(data.get("iterations", 300))
random.seed(42)
label_types = ['Product', 'Category', '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']
label_types = [
"Product",
"Category",
"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',
'Smart', 'Ultra', 'Compact', 'Deluxe']
noun_names = ['Widget', 'Gadget', 'Module', 'Unit', 'Element', 'Component',
'System', 'Kit', 'Bundle', 'Pack']
adj_names = [
"Premium",
"Eco",
"Organic",
"Classic",
"Modern",
"Vintage",
"Smart",
"Ultra",
"Compact",
"Deluxe",
]
noun_names = [
"Widget",
"Gadget",
"Module",
"Unit",
"Element",
"Component",
"System",
"Kit",
"Bundle",
"Pack",
]
nodes_dict = {}
edges = []
@ -364,10 +460,14 @@ def api_demo():
name = f"{random.choice(adj_names)} {random.choice(noun_names)} {i}"
nid = f"demo_{i}"
nodes_dict[nid] = {
'id': nid,
'labels': [chosen_label],
'properties': {'name': name, 'sku': f"SKU-{i:05d}", 'price': round(random.uniform(5, 500), 2)},
'label': name,
"id": nid,
"labels": [chosen_label],
"properties": {
"name": name,
"sku": f"SKU-{i:05d}",
"price": round(random.uniform(5, 500), 2),
},
"label": name,
}
# Create edges — mix of random & preferential attachment
@ -378,18 +478,22 @@ def api_demo():
src = random.choice(node_ids)
# Preferential attachment: higher-degree nodes more likely as targets
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, key=degree.get, reverse=True)[
: max(1, len(top) if "top" in dir() else 10)
]
tgt = random.choice(top)
else:
tgt = random.choice(node_ids)
if src != tgt:
edges.append({
'id': f"edge_{len(edges)}",
'source': src,
'target': tgt,
'type': random.choice(rel_types),
'properties': {},
})
edges.append(
{
"id": f"edge_{len(edges)}",
"source": src,
"target": tgt,
"type": random.choice(rel_types),
"properties": {},
}
)
degree[src] += 1
degree[tgt] += 1
@ -398,37 +502,41 @@ def api_demo():
# Layout
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
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]
nd['x'] = pos['x']
nd['y'] = pos['y']
nd['color'] = label_colors.get(primary, '#888')
pos = positions.get(nid, {"x": 0, "y": 0})
primary = nd["labels"][0]
nd["x"] = pos["x"]
nd["y"] = pos["y"]
nd["color"] = label_colors.get(primary, "#888")
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)
return jsonify({
'nodes': nodes_list,
'edges': edges,
'label_colors': label_colors,
'records': [],
'keys': [],
'stats': {
'node_count': len(nodes_list),
'edge_count': len(edges),
'labels': list(label_colors.keys()),
'query_time_ms': 0,
'layout_time_ms': round(t_layout * 1000),
return jsonify(
{
"nodes": nodes_list,
"edges": edges,
"label_colors": label_colors,
"records": [],
"keys": [],
"stats": {
"node_count": len(nodes_list),
"edge_count": len(edges),
"labels": list(label_colors.keys()),
"query_time_ms": 0,
"layout_time_ms": round(t_layout * 1000),
},
})
}
)
# ---------------------------------------------------------------------------
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5555)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5555)

View File

@ -1,18 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex Graph Explorer</title>
<title>Graph Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* =========================================================
CSS — Cortex Graph Explorer
Dark theme with glass-morphism, glow accents
========================================================= */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-deep: #060a1a;
@ -38,7 +47,12 @@
--transition: 0.25s cubic-bezier(.4, 0, .2, 1);
}
html, body { height: 100%; overflow: hidden; }
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font);
background: var(--bg-deep);
@ -61,7 +75,10 @@ body {
overflow: hidden;
transition: transform var(--transition);
}
#sidebar.collapsed { transform: translateX(calc(-1 * var(--sidebar-w) + 36px)); }
#sidebar.collapsed {
transform: translateX(calc(-1 * var(--sidebar-w) + 36px));
}
.sidebar-header {
padding: 20px 20px 14px;
@ -70,16 +87,32 @@ body {
align-items: center;
gap: 12px;
}
.sidebar-header .logo {
width: 32px; height: 32px;
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--purple));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 15px; color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 15px;
color: #fff;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
}
.sidebar-header h1 { font-size: 16px; font-weight: 600; letter-spacing: -.3px; }
.sidebar-header .subtitle { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.sidebar-header h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -.3px;
}
.sidebar-header .subtitle {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.sidebar-scroll {
flex: 1;
@ -89,9 +122,19 @@ body {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.sidebar-scroll::-webkit-scrollbar { width: 5px; }
.sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.sidebar-scroll::-webkit-scrollbar {
width: 5px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
/* ---- Panels / Cards ---- */
.panel {
@ -101,6 +144,7 @@ body {
margin-bottom: 12px;
overflow: hidden;
}
.panel-head {
padding: 10px 14px;
font-size: 11px;
@ -114,10 +158,23 @@ body {
justify-content: space-between;
user-select: none;
}
.panel-head .arrow { transition: transform var(--transition); font-size: 10px; }
.panel-head.collapsed .arrow { transform: rotate(-90deg); }
.panel-body { padding: 0 14px 14px; }
.panel-body.collapsed { display: none; }
.panel-head .arrow {
transition: transform var(--transition);
font-size: 10px;
}
.panel-head.collapsed .arrow {
transform: rotate(-90deg);
}
.panel-body {
padding: 0 14px 14px;
}
.panel-body.collapsed {
display: none;
}
/* ---- Connection indicator ---- */
#connection-status {
@ -132,14 +189,24 @@ body {
background: var(--bg-card);
border: 1px solid var(--border);
}
#connection-status .dot {
width: 8px; height: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
transition: background var(--transition), box-shadow var(--transition);
}
#connection-status.ok .dot { background: var(--success); box-shadow: 0 0 8px var(--success); }
#connection-status.err .dot { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
#connection-status.ok .dot {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
#connection-status.err .dot {
background: var(--danger);
box-shadow: 0 0 8px var(--danger);
}
/* ---- Query editor ---- */
#query-editor {
@ -159,11 +226,15 @@ body {
transition: border-color var(--transition), box-shadow var(--transition);
tab-size: 2;
}
#query-editor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
#query-editor::placeholder { color: var(--text-muted); }
#query-editor::placeholder {
color: var(--text-muted);
}
/* ---- Controls row ---- */
.controls-row {
@ -189,21 +260,44 @@ body {
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #0099cc);
color: #000;
box-shadow: 0 2px 12px rgba(0, 212, 255, 0.3);
}
.btn-primary:hover { box-shadow: 0 4px 20px rgba(0,212,255,0.5); transform: translateY(-1px); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-primary:hover {
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.5);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: .5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--bg-card-hover); border-color: var(--border-glow); }
.btn-sm { padding: 5px 10px; font-size: 11px; }
.btn-secondary:hover {
background: var(--bg-card-hover);
border-color: var(--border-glow);
}
.btn-sm {
padding: 5px 10px;
font-size: 11px;
}
/* ---- Select ---- */
.styled-select {
@ -219,8 +313,15 @@ body {
flex: 1;
min-width: 0;
}
.styled-select:focus { border-color: var(--accent); }
.styled-select option { background: #141428; color: var(--text); }
.styled-select:focus {
border-color: var(--accent);
}
.styled-select option {
background: #141428;
color: var(--text);
}
/* ---- Sample queries ---- */
.sample-query {
@ -234,6 +335,7 @@ body {
transition: all var(--transition);
border: 1px solid transparent;
}
.sample-query:hover {
background: var(--accent-dim);
color: var(--accent);
@ -241,7 +343,12 @@ body {
}
/* ---- Schema tags ---- */
.tag-list { display: flex; flex-wrap: wrap; gap: 5px; }
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.tag {
display: inline-block;
padding: 3px 9px;
@ -253,6 +360,7 @@ body {
font-family: var(--mono);
cursor: default;
}
.tag.rel {
background: rgba(255, 107, 107, 0.12);
color: var(--danger);
@ -269,7 +377,12 @@ body {
flex-wrap: wrap;
gap: 12px;
}
#stats-bar .stat-val { color: var(--accent); font-weight: 600; font-family: var(--mono); }
#stats-bar .stat-val {
color: var(--accent);
font-weight: 600;
font-family: var(--mono);
}
/* ---- Legend ---- */
.legend-item {
@ -280,8 +393,10 @@ body {
font-size: 12px;
color: var(--text-dim);
}
.legend-dot {
width: 10px; height: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
@ -293,13 +408,19 @@ body {
overflow: hidden;
background: radial-gradient(ellipse at 50% 50%, #0e1230 0%, #060a1a 70%);
}
#graph-canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: grab;
}
#graph-canvas:active { cursor: grabbing; }
#graph-canvas:active {
cursor: grabbing;
}
/* ---- Overlay controls ---- */
.graph-controls {
@ -311,8 +432,10 @@ body {
gap: 4px;
z-index: 5;
}
.graph-controls button {
width: 36px; height: 36px;
width: 36px;
height: 36px;
border: 1px solid var(--border);
background: var(--bg-surface);
backdrop-filter: blur(12px);
@ -325,6 +448,7 @@ body {
justify-content: center;
transition: all var(--transition);
}
.graph-controls button:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
@ -349,12 +473,36 @@ body {
transition: opacity .15s, transform .15s;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 212, 255, 0.08);
}
#tooltip.visible { opacity: 1; transform: translateY(0); }
#tooltip .tt-label { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
#tooltip .tt-labels { margin-bottom: 6px; }
#tooltip .tt-props { color: var(--text-dim); line-height: 1.7; }
#tooltip .tt-props .key { color: var(--accent); font-family: var(--mono); font-size: 11px; }
#tooltip .tt-props .val { color: var(--text); }
#tooltip.visible {
opacity: 1;
transform: translateY(0);
}
#tooltip .tt-label {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
#tooltip .tt-labels {
margin-bottom: 6px;
}
#tooltip .tt-props {
color: var(--text-dim);
line-height: 1.7;
}
#tooltip .tt-props .key {
color: var(--accent);
font-family: var(--mono);
font-size: 11px;
}
#tooltip .tt-props .val {
color: var(--text);
}
/* ---- Loading overlay ---- */
#loading-overlay {
@ -371,17 +519,32 @@ body {
pointer-events: none;
transition: opacity .3s;
}
#loading-overlay.active { opacity: 1; pointer-events: all; }
#loading-overlay.active {
opacity: 1;
pointer-events: all;
}
.spinner {
width: 40px; height: 40px;
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
margin-bottom: 14px;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading-text { font-size: 13px; color: var(--text-dim); }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#loading-text {
font-size: 13px;
color: var(--text-dim);
}
/* ---- Table view ---- */
#table-view {
@ -393,12 +556,17 @@ body {
overflow: auto;
padding: 20px;
}
#table-view.active { display: block; }
#table-view.active {
display: block;
}
#table-view table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
#table-view th {
padding: 10px 14px;
text-align: left;
@ -413,6 +581,7 @@ body {
text-transform: uppercase;
letter-spacing: .5px;
}
#table-view td {
padding: 8px 14px;
border-bottom: 1px solid var(--border);
@ -424,7 +593,11 @@ body {
font-family: var(--mono);
font-size: 11px;
}
#table-view tr:hover td { background: var(--bg-card); color: var(--text); }
#table-view tr:hover td {
background: var(--bg-card);
color: var(--text);
}
/* ---- View toggle ---- */
.view-toggle {
@ -440,6 +613,7 @@ body {
overflow: hidden;
z-index: 9;
}
.view-toggle button {
padding: 7px 18px;
border: none;
@ -451,11 +625,15 @@ body {
cursor: pointer;
transition: all var(--transition);
}
.view-toggle button.active {
background: var(--accent-dim);
color: var(--accent);
}
.view-toggle button:hover:not(.active) { color: var(--text); }
.view-toggle button:hover:not(.active) {
color: var(--text);
}
/* ---- Search box ---- */
#node-search {
@ -471,8 +649,14 @@ body {
margin-bottom: 8px;
transition: border-color var(--transition);
}
#node-search:focus { border-color: var(--accent); }
#node-search::placeholder { color: var(--text-muted); }
#node-search:focus {
border-color: var(--accent);
}
#node-search::placeholder {
color: var(--text-muted);
}
/* ---- Sidebar toggle ---- */
#sidebar-toggle {
@ -480,7 +664,8 @@ body {
top: 16px;
left: 16px;
z-index: 15;
width: 32px; height: 32px;
width: 32px;
height: 32px;
border: 1px solid var(--border);
background: var(--bg-surface);
backdrop-filter: blur(12px);
@ -492,7 +677,10 @@ body {
justify-content: center;
font-size: 14px;
}
#sidebar.collapsed ~ #graph-area #sidebar-toggle { display: flex; }
#sidebar.collapsed~#graph-area #sidebar-toggle {
display: flex;
}
/* ---- Error toast ---- */
#error-toast {
@ -513,7 +701,11 @@ body {
text-align: center;
backdrop-filter: blur(12px);
}
#error-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
#error-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---- Minimap ---- */
#minimap {
@ -528,7 +720,11 @@ body {
z-index: 5;
overflow: hidden;
}
#minimap-canvas { width: 100%; height: 100%; }
#minimap-canvas {
width: 100%;
height: 100%;
}
/* ---- Range sliders ---- */
.slider-row {
@ -537,12 +733,14 @@ body {
gap: 10px;
margin-bottom: 10px;
}
.slider-row label {
font-size: 11px;
color: var(--text-dim);
min-width: 90px;
white-space: nowrap;
}
.slider-row input[type="range"] {
flex: 1;
-webkit-appearance: none;
@ -553,26 +751,32 @@ body {
outline: none;
cursor: pointer;
}
.slider-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px rgba(0, 212, 255, 0.4);
cursor: pointer;
transition: box-shadow .2s;
}
.slider-row input[type="range"]::-webkit-slider-thumb:hover {
box-shadow: 0 0 14px rgba(0, 212, 255, 0.7);
}
.slider-row input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 8px rgba(0, 212, 255, 0.4);
cursor: pointer;
}
.slider-row .slider-val {
font-family: var(--mono);
font-size: 11px;
@ -580,38 +784,53 @@ body {
min-width: 32px;
text-align: right;
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.color-row label {
font-size: 11px;
color: var(--text-dim);
min-width: 90px;
}
.color-row input[type="color"] {
-webkit-appearance: none;
border: 1px solid var(--border);
border-radius: 6px;
width: 32px; height: 24px;
width: 32px;
height: 24px;
padding: 0;
cursor: pointer;
background: transparent;
}
.color-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
.color-row input[type="color"]::-webkit-color-swatch { border: none; border-radius: 4px; }
.color-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-row input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
.color-row select {
flex: 1;
}
/* ---- Responsive ---- */
@media (max-width: 900px) {
:root { --sidebar-w: 320px; }
:root {
--sidebar-w: 320px;
}
}
</style>
</head>
<body>
<!-- ============ SIDEBAR ============ -->
@ -619,7 +838,7 @@ body {
<div class="sidebar-header">
<div class="logo">G</div>
<div>
<h1>Cortex Graph Explorer</h1>
<h1>Graph Explorer</h1>
<div class="subtitle">Neo4j Product Graph Visualizer</div>
</div>
</div>
@ -630,18 +849,23 @@ body {
<div class="dot"></div>
<span id="conn-text">Connecting…</span>
</div>
<div id="conn-error-detail" style="display:none;font-size:11px;color:var(--danger);background:rgba(255,107,107,0.08);border:1px solid rgba(255,107,107,0.15);border-radius:var(--radius-sm);padding:8px 10px;margin-bottom:12px;word-break:break-word"></div>
<div id="conn-error-detail"
style="display:none;font-size:11px;color:var(--danger);background:rgba(255,107,107,0.08);border:1px solid rgba(255,107,107,0.15);border-radius:var(--radius-sm);padding:8px 10px;margin-bottom:12px;word-break:break-word">
</div>
<!-- Connection Settings -->
<div class="panel">
<div class="panel-head collapsed" data-panel="conn-settings">Connection Settings <span class="arrow"></span></div>
<div class="panel-head collapsed" data-panel="conn-settings">Connection Settings <span class="arrow"></span>
</div>
<div class="panel-body collapsed" data-panel="conn-settings">
<label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Neo4j HTTP URL</label>
<input type="text" id="conn-uri" class="styled-select" style="width:100%;margin-bottom:8px" value="https://neo4j.develop.cortex.cloud.otto.de">
<input type="text" id="conn-uri" class="styled-select" style="width:100%;margin-bottom:8px"
value="http://localhost:7474">
<label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Username</label>
<input type="text" id="conn-user" class="styled-select" style="width:100%;margin-bottom:8px" value="neo4j">
<label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Password</label>
<input type="password" id="conn-pass" class="styled-select" style="width:100%;margin-bottom:8px" value="" placeholder="(empty)">
<input type="password" id="conn-pass" class="styled-select" style="width:100%;margin-bottom:8px" value=""
placeholder="(empty)">
<button class="btn btn-secondary btn-sm" onclick="reconnect()" style="width:100%">Reconnect</button>
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">Uses Neo4j HTTP Transactional API</div>
</div>
@ -651,10 +875,12 @@ body {
<div class="panel">
<div class="panel-head" data-panel="query">Cypher Query <span class="arrow"></span></div>
<div class="panel-body" data-panel="query">
<textarea id="query-editor" spellcheck="false" placeholder="MATCH (n)-[r]->(m)&#10;RETURN n, r, m&#10;LIMIT 100"></textarea>
<textarea id="query-editor" spellcheck="false"
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="runDemo()" title="Generate demo graph without Neo4j">
Demo</button>
<select class="styled-select" id="layout-select" title="Layout algorithm">
<option value="auto">Auto Layout</option>
</select>
@ -689,25 +915,31 @@ body {
<div class="panel">
<div class="panel-head" data-panel="visuals">Visual Settings <span class="arrow"></span></div>
<div class="panel-body" data-panel="visuals">
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px;font-weight:600">Edges</div>
<div
style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px;font-weight:600">
Edges</div>
<div class="slider-row">
<label>Curvature</label>
<input type="range" id="sl-curvature" min="0" max="1" step="0.05" value="0.35" oninput="updateVisual('curvature',this.value)">
<input type="range" id="sl-curvature" min="0" max="1" step="0.05" value="0.35"
oninput="updateVisual('curvature',this.value)">
<span class="slider-val" id="sv-curvature">0.35</span>
</div>
<div class="slider-row">
<label>Edge Opacity</label>
<input type="range" id="sl-edge-opacity" min="0" max="1" step="0.02" value="0" oninput="updateVisual('edgeOpacity',this.value)">
<input type="range" id="sl-edge-opacity" min="0" max="1" step="0.02" value="0"
oninput="updateVisual('edgeOpacity',this.value)">
<span class="slider-val" id="sv-edge-opacity">auto</span>
</div>
<div class="slider-row">
<label>Edge Width</label>
<input type="range" id="sl-edge-width" min="0.3" max="5" step="0.1" value="1" oninput="updateVisual('edgeWidth',this.value)">
<input type="range" id="sl-edge-width" min="0.3" max="5" step="0.1" value="1"
oninput="updateVisual('edgeWidth',this.value)">
<span class="slider-val" id="sv-edge-width">1.0</span>
</div>
<div class="color-row">
<label>Edge Color</label>
<select class="styled-select" id="edge-color-scheme" onchange="updateVisual('edgeColorScheme',this.value)" style="flex:1">
<select class="styled-select" id="edge-color-scheme" onchange="updateVisual('edgeColorScheme',this.value)"
style="flex:1">
<option value="byType" selected>By Rel Type</option>
<option value="gradient">Gradient (src→tgt)</option>
<option value="uniform">Uniform Color</option>
@ -718,35 +950,45 @@ body {
<input type="color" id="edge-uniform-color" value="#00d4ff" onchange="updateVisual('edgeColor',this.value)">
</div>
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">Nodes &amp; Labels</div>
<div
style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">
Nodes &amp; Labels</div>
<div class="slider-row">
<label>Node Size</label>
<input type="range" id="sl-node-size" min="0.3" max="3" step="0.1" value="1" oninput="updateVisual('nodeSize',this.value)">
<input type="range" id="sl-node-size" min="0.3" max="3" step="0.1" value="1"
oninput="updateVisual('nodeSize',this.value)">
<span class="slider-val" id="sv-node-size">1.0</span>
</div>
<div class="slider-row">
<label>Label Zoom</label>
<input type="range" id="sl-label-zoom" min="0.3" max="5" step="0.1" value="1.2" oninput="updateVisual('labelZoom',this.value)">
<input type="range" id="sl-label-zoom" min="0.3" max="5" step="0.1" value="1.2"
oninput="updateVisual('labelZoom',this.value)">
<span class="slider-val" id="sv-label-zoom">1.2</span>
</div>
<div class="slider-row">
<label>Label Size</label>
<input type="range" id="sl-label-size" min="0.3" max="4" step="0.1" value="1" oninput="updateVisual('labelSize',this.value)">
<input type="range" id="sl-label-size" min="0.3" max="4" step="0.1" value="1"
oninput="updateVisual('labelSize',this.value)">
<span class="slider-val" id="sv-label-size">1.0</span>
</div>
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">Layout</div>
<div
style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">
Layout</div>
<div class="slider-row">
<label>Spacing</label>
<input type="range" id="sl-spacing" min="0.3" max="4" step="0.1" value="1" oninput="updateVisual('spacing',this.value)">
<input type="range" id="sl-spacing" min="0.3" max="4" step="0.1" value="1"
oninput="updateVisual('spacing',this.value)">
<span class="slider-val" id="sv-spacing">1.0</span>
</div>
<div class="slider-row">
<label>Iterations</label>
<input type="range" id="sl-iterations" min="50" max="1000" step="50" value="300" oninput="updateVisual('iterations',this.value)">
<input type="range" id="sl-iterations" min="50" max="1000" step="50" value="300"
oninput="updateVisual('iterations',this.value)">
<span class="slider-val" id="sv-iterations">300</span>
</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">Spacing &amp; Iterations apply on next query run</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">Spacing &amp; Iterations apply on next
query run</div>
</div>
</div>
@ -1884,4 +2126,5 @@ init();
})();
</script>
</body>
</html>