Merge upstream clean up
This commit is contained in:
commit
d8441ca85b
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"nuxt.isNuxtApp": false
|
||||
}
|
||||
100
README.md
100
README.md
|
|
@ -62,22 +62,94 @@ _Original Star-Mapper description below._
|
|||
|
||||
## Original: Website 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:
|
||||

|
||||
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
386
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": [{
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -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) RETURN n, r, m LIMIT 100"></textarea>
|
||||
<textarea id="query-editor" spellcheck="false"
|
||||
placeholder="MATCH (n)-[r]->(m) RETURN n, r, m 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 & 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 & 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 & Iterations apply on next query run</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">Spacing & Iterations apply on next
|
||||
query run</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1884,4 +2126,5 @@ init();
|
|||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in New Issue