From 99c7badd7257510d7ea61d672a1272d28f2564a1 Mon Sep 17 00:00:00 2001 From: Askill Date: Sat, 7 Mar 2026 14:24:32 +0100 Subject: [PATCH] clean up --- .vscode/settings.json | 3 + README.md | 100 +- app.py | 394 +++-- templates/index.html | 3715 ++++++++++++++++++++++------------------- 4 files changed, 2319 insertions(+), 1893 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..13ee2b0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "nuxt.isNuxtApp": false +} \ No newline at end of file diff --git a/README.md b/README.md index e74ddbd..29e357c 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file +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. \ No newline at end of file diff --git a/app.py b/app.py index ed60c2a..db72cad 100644 --- a/app.py +++ b/app.py @@ -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": [{ - "statement": cypher, - "parameters": params or {}, - "resultDataContents": ["row", "graph"] - }] + "statements": [ + { + "statement": cypher, + "parameters": params or {}, + "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) diff --git a/templates/index.html b/templates/index.html index d16dd3b..f03daf4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,1887 +1,2130 @@ + - - -Cortex Graph Explorer - - - - + /* ---- Controls row ---- */ + .controls-row { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; + } + + /* ---- Buttons ---- */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; + 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-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; + } + + /* ---- Select ---- */ + .styled-select { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + color: var(--text); + font-family: var(--font); + font-size: 12px; + outline: none; + cursor: pointer; + flex: 1; + min-width: 0; + } + + .styled-select:focus { + border-color: var(--accent); + } + + .styled-select option { + background: #141428; + color: var(--text); + } + + /* ---- Sample queries ---- */ + .sample-query { + display: block; + padding: 8px 10px; + margin-bottom: 4px; + font-size: 12px; + color: var(--text-dim); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); + border: 1px solid transparent; + } + + .sample-query:hover { + background: var(--accent-dim); + color: var(--accent); + border-color: var(--border-glow); + } + + /* ---- Schema tags ---- */ + .tag-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + + .tag { + display: inline-block; + padding: 3px 9px; + font-size: 11px; + border-radius: 20px; + background: var(--accent-dim); + color: var(--accent); + border: 1px solid rgba(0, 212, 255, 0.15); + font-family: var(--mono); + cursor: default; + } + + .tag.rel { + background: rgba(255, 107, 107, 0.12); + color: var(--danger); + border-color: rgba(255, 107, 107, 0.15); + } + + /* ---- Stats bar ---- */ + #stats-bar { + padding: 10px 14px; + font-size: 11px; + color: var(--text-dim); + border-top: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: 12px; + } + + #stats-bar .stat-val { + color: var(--accent); + font-weight: 600; + font-family: var(--mono); + } + + /* ---- Legend ---- */ + .legend-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 12px; + color: var(--text-dim); + } + + .legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + } + + /* ---- Graph area ---- */ + #graph-area { + flex: 1; + position: relative; + 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%; + cursor: grab; + } + + #graph-canvas:active { + cursor: grabbing; + } + + /* ---- Overlay controls ---- */ + .graph-controls { + position: absolute; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + gap: 4px; + z-index: 5; + } + + .graph-controls button { + width: 36px; + height: 36px; + border: 1px solid var(--border); + background: var(--bg-surface); + backdrop-filter: blur(12px); + color: var(--text); + border-radius: var(--radius-sm); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + } + + .graph-controls button:hover { + background: var(--bg-card-hover); + border-color: var(--accent); + color: var(--accent); + } + + /* ---- Tooltip ---- */ + #tooltip { + position: absolute; + pointer-events: none; + background: var(--bg-surface); + backdrop-filter: blur(16px); + border: 1px solid var(--border-glow); + border-radius: var(--radius); + padding: 12px 16px; + max-width: 360px; + min-width: 180px; + font-size: 12px; + z-index: 20; + opacity: 0; + transform: translateY(4px); + 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); + } + + /* ---- Loading overlay ---- */ + #loading-overlay { + position: absolute; + inset: 0; + background: rgba(6, 10, 26, 0.85); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 30; + opacity: 0; + pointer-events: none; + transition: opacity .3s; + } + + #loading-overlay.active { + opacity: 1; + pointer-events: all; + } + + .spinner { + 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); + } + + /* ---- Table view ---- */ + #table-view { + display: none; + position: absolute; + inset: 0; + background: var(--bg-deep); + z-index: 8; + overflow: auto; + padding: 20px; + } + + #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; + font-weight: 600; + color: var(--accent); + border-bottom: 2px solid var(--border); + position: sticky; + top: 0; + background: var(--bg-deep); + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: .5px; + } + + #table-view td { + padding: 8px 14px; + border-bottom: 1px solid var(--border); + color: var(--text-dim); + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 11px; + } + + #table-view tr:hover td { + background: var(--bg-card); + color: var(--text); + } + + /* ---- View toggle ---- */ + .view-toggle { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + background: var(--bg-surface); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 20px; + overflow: hidden; + z-index: 9; + } + + .view-toggle button { + padding: 7px 18px; + border: none; + background: transparent; + color: var(--text-dim); + font-family: var(--font); + font-size: 12px; + font-weight: 500; + 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); + } + + /* ---- Search box ---- */ + #node-search { + width: 100%; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 12px; + outline: none; + margin-bottom: 8px; + transition: border-color var(--transition); + } + + #node-search:focus { + border-color: var(--accent); + } + + #node-search::placeholder { + color: var(--text-muted); + } + + /* ---- Sidebar toggle ---- */ + #sidebar-toggle { + position: absolute; + top: 16px; + left: 16px; + z-index: 15; + width: 32px; + height: 32px; + border: 1px solid var(--border); + background: var(--bg-surface); + backdrop-filter: blur(12px); + color: var(--text); + border-radius: var(--radius-sm); + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + font-size: 14px; + } + + #sidebar.collapsed~#graph-area #sidebar-toggle { + display: flex; + } + + /* ---- Error toast ---- */ + #error-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: rgba(255, 107, 107, 0.15); + border: 1px solid var(--danger); + color: var(--danger); + padding: 12px 24px; + border-radius: var(--radius); + font-size: 13px; + z-index: 100; + opacity: 0; + transition: opacity .3s, transform .3s; + max-width: 600px; + text-align: center; + backdrop-filter: blur(12px); + } + + #error-toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + + /* ---- Minimap ---- */ + #minimap { + position: absolute; + bottom: 20px; + left: 20px; + width: 160px; + height: 120px; + background: rgba(6, 10, 26, 0.7); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + z-index: 5; + overflow: hidden; + } + + #minimap-canvas { + width: 100%; + height: 100%; + } + + /* ---- Range sliders ---- */ + .slider-row { + display: flex; + align-items: center; + 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; + appearance: none; + height: 4px; + background: var(--border); + border-radius: 2px; + outline: none; + cursor: pointer; + } + + .slider-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + 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; + 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; + color: var(--accent); + 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; + 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 select { + flex: 1; + } + + /* ---- Responsive ---- */ + @media (max-width: 900px) { + :root { + --sidebar-w: 320px; + } + } + + - -