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..09d8968 100644 --- a/README.md +++ b/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 -``` - 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. +```bash +# 1. Clone & enter the repo +git clone https://github.com/Askill/Star-Mapper.git +cd Star-Mapper - 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 +# 2. Create a virtual environment & install dependencies +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# 3. Run the app +python app.py ``` -## Examples: -### Google.de: -![google.de](./docs/google.png) \ No newline at end of file +Open **http://localhost:5555** in your browser. + +### Environment Variables (optional) + +| Variable | Default | Description | +|---|---|---| +| `NEO4J_HTTP_URL` | `https://neo4j.develop.cortex.cloud.otto.de` | Neo4j HTTP endpoint | +| `NEO4J_USER` | `neo4j` | Username | +| `NEO4J_PASSWORD` | _(empty)_ | Password | +| `NEO4J_DATABASE` | `neo4j` | Database name | + +```bash +# Example: connect to a different instance +NEO4J_HTTP_URL=https://my-neo4j.example.com NEO4J_USER=admin NEO4J_PASSWORD=secret python app.py +``` + +You can also change the connection at runtime via the **Connection Settings** panel in the sidebar. + +## Features + +- **Neo4j connectivity** via HTTP Transactional API (works behind ALB/HTTP proxies) +- **Python-precomputed layouts** using igraph (C-based) — auto-selects algorithm by graph size +- **Canvas 2D rendering** with D3 zoom/pan, quadtree hit testing, viewport frustum culling +- **Curved edges** with configurable curvature, multi-edge spreading +- **Recursive highlight diffusion** — click a node to BFS-highlight neighbors with decaying opacity +- **Visual settings sliders** — curvature, edge opacity/width/color, node size, label size/zoom, spacing, iterations +- **Schema browser**, sample queries, node search, minimap, dark glass-morphism theme + +## Performance + +| Nodes | Layout Time | +|-------|------------| +| 300 | ~10 ms | +| 3,000 | ~77 ms | +| 5,000 | ~313 ms | + +--- + +_Original Star-Mapper description below._ + +--- + +## Original: Website Mapper + +Star-Mapper is a Flask-based graph exploration service for Neo4j. + +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. \ No newline at end of file diff --git a/app.py b/app.py index ed60c2a..0192cdf 100644 --- a/app.py +++ b/app.py @@ -23,16 +23,25 @@ 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", "") NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j") NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "") 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.""" 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 +73,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 +117,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 @@ -148,14 +165,119 @@ def _execute_simple(cypher: str): 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 # --------------------------------------------------------------------------- _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,117 +290,216 @@ 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() nodes_dict, edges, records, keys = execute_cypher(cypher) t_query = time.time() - t0 + # Cache raw results for re-layout + _last_query_cache.clear() + _last_query_cache["nodes_dict"] = {k: dict(v) for k, v in nodes_dict.items()} + _last_query_cache["edges"] = [dict(e) for e in edges] + _last_query_cache["records"] = records + _last_query_cache["keys"] = keys + # Assign colours label_colors: dict[str, str] = {} for nd in nodes_dict.values(): - 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), + }, + } + ) + 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: 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), + "nodes": nodes_list, + "edges": unique_edges, + "label_colors": label_colors, + "records": records[:500], + "keys": keys, + "stats": { + "node_count": len(nodes_list), + "edge_count": len(unique_edges), + "labels": list(label_colors.keys()), + "query_time_ms": 0, + "layout_time_ms": round(t_layout * 1000), }, }) except Exception as exc: - logger.exception("Query failed") - return jsonify({'error': str(exc)}), 400 + logger.exception("Re-layout failed") + 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 +508,84 @@ 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()'}, - ] - return jsonify(queries) + return jsonify(_load_sample_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 +606,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 +624,20 @@ 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.keys(), key=lambda nid: degree[nid], reverse=True)[: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 +646,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/config/sample_queries.json b/config/sample_queries.json new file mode 100644 index 0000000..30d5303 --- /dev/null +++ b/config/sample_queries.json @@ -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()" + } +] \ No newline at end of file diff --git a/config/sample_queries_analytics.json b/config/sample_queries_analytics.json new file mode 100644 index 0000000..f18f3f6 --- /dev/null +++ b/config/sample_queries_analytics.json @@ -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, '') 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, '')) 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()" + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7303ed1..936792e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,8 @@ neo4j>=5.0 networkx>=3.0 numpy>=1.24 scipy>=1.10 +matplotlib>=3.10 python-igraph>=0.11 gunicorn>=21.0 +requests>=2.0 +pytest>=7.0 diff --git a/templates/index.html b/templates/index.html index d16dd3b..43e43d8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,1887 +1,2313 @@ + - - -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; + 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; + } + } + + - -