Merge upstream: add samples for E-List
This commit is contained in:
commit
0c0df5fe8d
|
|
@ -99,6 +99,7 @@ Make Neo4j graph data explorable and understandable through:
|
|||
|
||||
- `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).
|
||||
|
||||
|
|
@ -121,6 +122,7 @@ Environment variables used by `app.py`:
|
|||
- `NEO4J_USER` (default: `neo4j`)
|
||||
- `NEO4J_PASSWORD` (default: empty)
|
||||
- `NEO4J_DATABASE` (default: `neo4j`)
|
||||
- `SAMPLE_QUERIES_FILE` (default: `config/sample_queries.json`)
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
|
|||
134
app.py
134
app.py
|
|
@ -35,6 +35,10 @@ 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")
|
||||
SAMPLE_QUERIES_FILE = os.environ.get(
|
||||
"SAMPLE_QUERIES_FILE",
|
||||
os.path.join(os.path.dirname(__file__), "config", "sample_queries.json"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -158,6 +162,95 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -340,42 +433,7 @@ def api_layouts():
|
|||
|
||||
@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"])
|
||||
|
|
@ -478,9 +536,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
[
|
||||
{
|
||||
"name": "Focused Entity Relation Graph",
|
||||
"query": "MATCH (a:Entity)-[r:RELATION]-(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN a, r, b LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Persons <-> Events",
|
||||
"query": "MATCH (p:Entity)-[r:RELATION]-(e:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(e.type, '')) IN ['event','incident','meeting','occurrence'] RETURN p, r, e LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Events <-> Dates",
|
||||
"query": "MATCH (ev:Entity)-[r:RELATION]-(d:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(d.type, '')) IN ['date','time','datetime'] RETURN ev, r, d LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Events <-> Locations",
|
||||
"query": "MATCH (ev:Entity)-[r:RELATION]-(loc:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN ev, r, loc LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Persons <-> Locations",
|
||||
"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 1500"
|
||||
},
|
||||
{
|
||||
"name": "People Around Top Events (2 Hops)",
|
||||
"query": "MATCH (ev:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] MATCH (ev)-[r0:RELATION]-() WITH ev, count(r0) AS degree ORDER BY degree DESC LIMIT 20 MATCH path = (ev)-[:RELATION*1..2]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN path LIMIT 2000"
|
||||
},
|
||||
{
|
||||
"name": "Most Connected Focus Entities",
|
||||
"query": "MATCH (e:Entity)-[r:RELATION]-(:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] WITH e, count(r) AS degree ORDER BY degree DESC LIMIT 40 MATCH (e)-[r2:RELATION]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN e, r2, n LIMIT 1800"
|
||||
},
|
||||
{
|
||||
"name": "Relation Predicate Distribution (Focused Types)",
|
||||
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] RETURN coalesce(r.predicate_display, r.predicate, '<missing>') AS predicate, count(*) AS count ORDER BY count DESC LIMIT 50"
|
||||
},
|
||||
{
|
||||
"name": "Entity Type Counts (Focused)",
|
||||
"query": "MATCH (e:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN toLower(coalesce(e.type, '<missing>')) AS entity_type, count(*) AS count ORDER BY count DESC"
|
||||
},
|
||||
{
|
||||
"name": "Asymmetric Focused Relations",
|
||||
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','date','time','location','place','city','country','gpe'] AND NOT (b)-[:RELATION]->(a) RETURN a, r, b LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Schema Visualization",
|
||||
"query": "CALL db.schema.visualization()"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
[
|
||||
{
|
||||
"name": "Focused Entity Relation Graph",
|
||||
"query": "MATCH (a:Entity)-[r:RELATION]-(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN a, r, b LIMIT 2000"
|
||||
},
|
||||
{
|
||||
"name": "Entity Relation Leaders (Focused Types)",
|
||||
"query": "MATCH (e:Entity)-[r:RELATION]-(:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] WITH e, count(r) AS rel_degree RETURN e, rel_degree ORDER BY rel_degree DESC LIMIT 75"
|
||||
},
|
||||
{
|
||||
"name": "Person Event Bridges",
|
||||
"query": "MATCH (p:Entity)-[r:RELATION]-(ev:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] RETURN p, r, ev LIMIT 1800"
|
||||
},
|
||||
{
|
||||
"name": "Event Date Location Triads",
|
||||
"query": "MATCH (ev:Entity)-[r1:RELATION]-(d:Entity), (ev)-[r2:RELATION]-(loc:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(d.type, '')) IN ['date','time','datetime'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN ev, r1, d, r2, loc LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Persons by Location",
|
||||
"query": "MATCH (p:Entity)-[r:RELATION]-(loc:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN p, r, loc LIMIT 1800"
|
||||
},
|
||||
{
|
||||
"name": "Top Events 2-Hop Neighborhood",
|
||||
"query": "MATCH (ev:Entity)-[r0:RELATION]-() WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] WITH ev, count(r0) AS degree ORDER BY degree DESC LIMIT 25 MATCH path=(ev)-[:RELATION*1..2]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN path LIMIT 2200"
|
||||
},
|
||||
{
|
||||
"name": "Asymmetric Focused Relations",
|
||||
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND NOT (b)-[:RELATION]->(a) RETURN a, r, b LIMIT 1800"
|
||||
},
|
||||
{
|
||||
"name": "Reciprocal Focused Relations",
|
||||
"query": "MATCH (a:Entity)-[r1:RELATION]->(b:Entity), (b)-[r2:RELATION]->(a) WHERE id(a) < id(b) AND toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN a, r1, b, r2 LIMIT 1800"
|
||||
},
|
||||
{
|
||||
"name": "Predicate Distribution (Focused)",
|
||||
"query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN coalesce(r.predicate_display, r.predicate, '<missing>') AS predicate, count(*) AS count ORDER BY count DESC LIMIT 75"
|
||||
},
|
||||
{
|
||||
"name": "Entity Type Counts (Focused)",
|
||||
"query": "MATCH (e:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN toLower(coalesce(e.type, '<missing>')) AS entity_type, count(*) AS count ORDER BY count DESC"
|
||||
},
|
||||
{
|
||||
"name": "Potential Duplicate Entities (Canonical Key)",
|
||||
"query": "MATCH (e:Entity) WHERE e.canonical_key IS NOT NULL AND trim(toString(e.canonical_key)) <> '' WITH toLower(toString(e.canonical_key)) AS key, collect(e) AS ents WHERE size(ents) > 1 UNWIND ents AS e RETURN e, key LIMIT 1500"
|
||||
},
|
||||
{
|
||||
"name": "Schema Visualization",
|
||||
"query": "CALL db.schema.visualization()"
|
||||
}
|
||||
]
|
||||
|
|
@ -5,3 +5,5 @@ numpy>=1.24
|
|||
scipy>=1.10
|
||||
python-igraph>=0.11
|
||||
gunicorn>=21.0
|
||||
requests>=2.0
|
||||
pytest>=7.0
|
||||
|
|
|
|||
|
|
@ -800,6 +800,7 @@
|
|||
|
||||
.color-row input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
|
|
@ -949,10 +950,46 @@
|
|||
<label>Color</label>
|
||||
<input type="color" id="edge-uniform-color" value="#00d4ff" onchange="updateVisual('edgeColor',this.value)">
|
||||
</div>
|
||||
<div class="color-row">
|
||||
<label>Edge Labels</label>
|
||||
<select class="styled-select" id="edge-label-mode" onchange="updateVisual('edgeLabelMode',this.value)"
|
||||
style="flex:1">
|
||||
<option value="highlighted" selected>Highlighted Only</option>
|
||||
<option value="all">All Edges</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<label>Edge Label Zoom</label>
|
||||
<input type="range" id="sl-edge-label-zoom" min="0.2" max="6" step="0.1" value="1.0"
|
||||
oninput="updateVisual('edgeLabelZoom',this.value)">
|
||||
<span class="slider-val" id="sv-edge-label-zoom">1.00</span>
|
||||
</div>
|
||||
<div class="slider-row" id="edge-label-hop-row">
|
||||
<label>Edge Label Hops</label>
|
||||
<input type="range" id="sl-edge-label-max-hop" min="1" max="6" step="1" value="2"
|
||||
oninput="updateVisual('edgeLabelMaxHop',this.value)">
|
||||
<span class="slider-val" id="sv-edge-label-max-hop">2</span>
|
||||
</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="color-row">
|
||||
<label>Node Label</label>
|
||||
<select class="styled-select" id="node-label-source" onchange="updateVisual('nodeLabelSource',this.value)"
|
||||
style="flex:1">
|
||||
<option value="default" selected>Default</option>
|
||||
<option value="id">Node ID</option>
|
||||
<option value="primaryLabel">Primary Label</option>
|
||||
<option value="property">Property Key</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="color-row" id="node-label-property-row" style="display:none">
|
||||
<label>Property</label>
|
||||
<input type="text" id="node-label-property" class="styled-select" style="width:100%" placeholder="name"
|
||||
oninput="updateVisual('nodeLabelProperty',this.value)">
|
||||
</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"
|
||||
|
|
@ -971,6 +1008,16 @@
|
|||
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">
|
||||
Node Colors</div>
|
||||
<div class="controls-row" style="margin-top:0">
|
||||
<button class="btn btn-secondary btn-sm" onclick="resetNodeColorOverrides()">Reset Colors</button>
|
||||
</div>
|
||||
<div id="node-color-overrides"
|
||||
style="max-height:180px;overflow:auto;margin-top:8px;padding-right:4px;color:var(--text-dim);font-size:12px">
|
||||
<div style="color:var(--text-muted)">Run a query to edit label colors</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">
|
||||
|
|
@ -1088,12 +1135,18 @@
|
|||
edgeWidth: 1.0,
|
||||
edgeColorScheme: 'byType', // 'byType' | 'gradient' | 'uniform'
|
||||
edgeColor: '#00d4ff',
|
||||
edgeLabelMode: 'highlighted', // 'off' | 'highlighted' | 'all'
|
||||
edgeLabelZoom: 1.0,
|
||||
edgeLabelMaxHop: 2,
|
||||
nodeSize: 1.0,
|
||||
nodeLabelSource: 'default', // 'default' | 'id' | 'primaryLabel' | 'property'
|
||||
nodeLabelProperty: '',
|
||||
labelZoom: 1.2,
|
||||
labelSize: 1.0,
|
||||
spacing: 1.0,
|
||||
iterations: 300,
|
||||
},
|
||||
nodeColorOverrides: {},
|
||||
};
|
||||
|
||||
/* Canvas & rendering */
|
||||
|
|
@ -1301,10 +1354,11 @@
|
|||
const ttLabels = tooltip.querySelector('.tt-labels');
|
||||
const ttProps = tooltip.querySelector('.tt-props');
|
||||
|
||||
ttLabel.textContent = node.label;
|
||||
ttLabels.innerHTML = (node.labels || []).map(l =>
|
||||
`<span class="tag" style="background:${hexToAlpha(state.labelColors[l] || '#888', 0.2)};color:${state.labelColors[l] || '#888'};border-color:${hexToAlpha(state.labelColors[l] || '#888', 0.3)}">${l}</span>`
|
||||
).join(' ');
|
||||
ttLabel.textContent = node.displayLabel || node.label || node.id;
|
||||
ttLabels.innerHTML = (node.labels || []).map(l => {
|
||||
const c = getLabelColor(l, state.labelColors[l] || '#888888');
|
||||
return `<span class="tag" style="background:${hexToAlpha(c, 0.2)};color:${c};border-color:${hexToAlpha(c, 0.3)}">${l}</span>`;
|
||||
}).join(' ');
|
||||
|
||||
const props = node.properties || {};
|
||||
const propKeys = Object.keys(props).slice(0, 12);
|
||||
|
|
@ -1447,10 +1501,10 @@
|
|||
let strokeStyle;
|
||||
if (V.edgeColorScheme === 'uniform') {
|
||||
strokeStyle = hexToRgba(V.edgeColor, alpha);
|
||||
} else if (V.edgeColorScheme === 'gradient' && src.color && tgt.color) {
|
||||
} else if (V.edgeColorScheme === 'gradient' && src.renderColor && tgt.renderColor) {
|
||||
const grad = ctx.createLinearGradient(sx, sy, tx, ty);
|
||||
grad.addColorStop(0, hexToAlpha(src.color, alpha));
|
||||
grad.addColorStop(1, hexToAlpha(tgt.color, alpha));
|
||||
grad.addColorStop(0, hexToAlpha(src.renderColor, alpha));
|
||||
grad.addColorStop(1, hexToAlpha(tgt.renderColor, alpha));
|
||||
strokeStyle = grad;
|
||||
} else {
|
||||
strokeStyle = relColor(e.type || 'RELATED') + alpha + ')';
|
||||
|
|
@ -1470,7 +1524,17 @@
|
|||
ctx.stroke();
|
||||
|
||||
// Draw relationship type label on highlighted edges when zoomed in (first 2 hops only)
|
||||
if (isHighlighting && state.highlightedEdges.has(i) && state.highlightedEdges.get(i) <= 2 && transform.k > 1.0) {
|
||||
const edgeLabelMode = V.edgeLabelMode;
|
||||
const canDrawEdgeLabels = edgeLabelMode !== 'off' && transform.k >= V.edgeLabelZoom;
|
||||
const highlightedEligible = isHighlighting
|
||||
&& state.highlightedEdges.has(i)
|
||||
&& state.highlightedEdges.get(i) <= V.edgeLabelMaxHop;
|
||||
const shouldDrawEdgeLabel = canDrawEdgeLabels && (
|
||||
edgeLabelMode === 'all'
|
||||
|| (edgeLabelMode === 'highlighted' && highlightedEligible)
|
||||
);
|
||||
|
||||
if (shouldDrawEdgeLabel) {
|
||||
// Label at the curve midpoint
|
||||
const lx = (pairTotal > 1 || curvature > 0) ? (sx + 2 * cpx + tx) / 4 : mx;
|
||||
const ly = (pairTotal > 1 || curvature > 0) ? (sy + 2 * cpy + ty) / 4 : my;
|
||||
|
|
@ -1517,11 +1581,11 @@
|
|||
// Glow for hovered/selected/highlighted nodes
|
||||
if ((isHovered || isSelected || (isHighlighted && isHighlighting)) && alpha > 0.5) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = node.color;
|
||||
ctx.shadowColor = node.renderColor;
|
||||
ctx.shadowBlur = isHovered ? 25 : isSelected ? 20 : 12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r * 1.3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fillStyle = node.renderColor;
|
||||
ctx.globalAlpha = alpha * 0.35;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
|
@ -1533,8 +1597,8 @@
|
|||
node.x - r * 0.25, node.y - r * 0.25, 0,
|
||||
node.x, node.y, r
|
||||
);
|
||||
grad.addColorStop(0, lightenColor(node.color, 50));
|
||||
grad.addColorStop(1, node.color);
|
||||
grad.addColorStop(0, lightenColor(node.renderColor, 50));
|
||||
grad.addColorStop(1, node.renderColor);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
||||
|
|
@ -1559,7 +1623,7 @@
|
|||
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
||||
ctx.shadowBlur = 4;
|
||||
const lines = wrapLabel(node.label, 20);
|
||||
const lines = wrapLabel(node.displayLabel || node.label || node.id, 20);
|
||||
const lh = fontSize * 1.25;
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
ctx.fillText(lines[li], node.x, node.y + r + 3 + li * lh);
|
||||
|
|
@ -1661,7 +1725,7 @@
|
|||
for (const n of state.nodes) {
|
||||
const x = (n.x - minX) * scale + ox;
|
||||
const y = (n.y - minY) * scale + oy;
|
||||
mmCtx.fillStyle = n.color;
|
||||
mmCtx.fillStyle = n.renderColor;
|
||||
mmCtx.globalAlpha = 0.7;
|
||||
mmCtx.fillRect(x - dotSize / 2, y - dotSize / 2, dotSize, dotSize);
|
||||
}
|
||||
|
|
@ -1715,6 +1779,9 @@
|
|||
.y(d => d.y)
|
||||
.addAll(state.nodes);
|
||||
|
||||
renderNodeColorControls();
|
||||
applyNodeStyling();
|
||||
|
||||
// Update legend
|
||||
updateLegend();
|
||||
// Update stats
|
||||
|
|
@ -1738,9 +1805,10 @@
|
|||
return;
|
||||
}
|
||||
el.innerHTML = entries.map(([label, color]) => {
|
||||
const effectiveColor = getLabelColor(label, color);
|
||||
const count = state.nodes.filter(n => n.labels && n.labels.includes(label)).length;
|
||||
return `<div class="legend-item">
|
||||
<div class="legend-dot" style="background:${color};box-shadow:0 0 6px ${color}"></div>
|
||||
<div class="legend-dot" style="background:${effectiveColor};box-shadow:0 0 6px ${effectiveColor}"></div>
|
||||
<span>${escHtml(label)}</span>
|
||||
<span style="color:var(--text-muted);font-size:11px;margin-left:auto">${count}</span>
|
||||
</div>`;
|
||||
|
|
@ -1802,11 +1870,11 @@
|
|||
const el = document.getElementById('search-results');
|
||||
if (!query || query.length < 2) { el.innerHTML = ''; return; }
|
||||
const q = query.toLowerCase();
|
||||
const matches = state.nodes.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
|
||||
const matches = state.nodes.filter(n => (n.displayLabel || n.label || '').toLowerCase().includes(q)).slice(0, 20);
|
||||
el.innerHTML = matches.map(n => `
|
||||
<div class="sample-query" onclick="focusNode('${n.id}')" style="font-family:var(--mono);">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${n.color};margin-right:6px"></span>
|
||||
${escHtml(n.label)}
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${n.renderColor};margin-right:6px"></span>
|
||||
${escHtml(n.displayLabel || n.label || n.id)}
|
||||
</div>
|
||||
`).join('');
|
||||
};
|
||||
|
|
@ -2045,9 +2113,89 @@
|
|||
if (key === 'edgeColorScheme') {
|
||||
document.getElementById('uniform-color-row').style.display = value === 'uniform' ? 'flex' : 'none';
|
||||
}
|
||||
if (key === 'edgeLabelMode') {
|
||||
document.getElementById('edge-label-hop-row').style.display = value === 'highlighted' ? 'flex' : 'none';
|
||||
}
|
||||
if (key === 'nodeLabelSource') {
|
||||
document.getElementById('node-label-property-row').style.display = value === 'property' ? 'flex' : 'none';
|
||||
}
|
||||
if (key === 'nodeLabelSource' || key === 'nodeLabelProperty') {
|
||||
applyNodeStyling();
|
||||
updateLegend();
|
||||
}
|
||||
draw();
|
||||
};
|
||||
|
||||
window.setNodeLabelColor = function (label, color) {
|
||||
state.nodeColorOverrides[label] = color;
|
||||
applyNodeStyling();
|
||||
updateLegend();
|
||||
draw();
|
||||
};
|
||||
|
||||
window.resetNodeColorOverrides = function () {
|
||||
state.nodeColorOverrides = {};
|
||||
renderNodeColorControls();
|
||||
applyNodeStyling();
|
||||
updateLegend();
|
||||
draw();
|
||||
};
|
||||
|
||||
function getPrimaryNodeLabel(node) {
|
||||
if (node.labels && node.labels.length) return node.labels[0];
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function getLabelColor(label, fallbackColor) {
|
||||
return state.nodeColorOverrides[label] || fallbackColor || '#888888';
|
||||
}
|
||||
|
||||
function resolveNodeDisplayLabel(node) {
|
||||
const source = state.vis.nodeLabelSource;
|
||||
const fallback = node.label || node.id;
|
||||
|
||||
if (source === 'id') return String(node.id || fallback);
|
||||
if (source === 'primaryLabel') return String(getPrimaryNodeLabel(node));
|
||||
if (source === 'property') {
|
||||
const key = (state.vis.nodeLabelProperty || '').trim();
|
||||
if (!key) return String(fallback);
|
||||
const value = node.properties ? node.properties[key] : undefined;
|
||||
if (value === undefined || value === null || value === '') return String(fallback);
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return String(fallback);
|
||||
}
|
||||
|
||||
function applyNodeStyling() {
|
||||
for (const node of state.nodes) {
|
||||
const primary = getPrimaryNodeLabel(node);
|
||||
const baseColor = state.labelColors[primary] || node.color || '#888888';
|
||||
node.renderColor = getLabelColor(primary, baseColor);
|
||||
node.displayLabel = resolveNodeDisplayLabel(node);
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeColorControls() {
|
||||
const el = document.getElementById('node-color-overrides');
|
||||
const labels = Object.keys(state.labelColors || {}).sort();
|
||||
if (!labels.length) {
|
||||
el.innerHTML = '<div style="color:var(--text-muted)">Run a query to edit label colors</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = labels.map(label => {
|
||||
const color = getLabelColor(label, state.labelColors[label]);
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escAttr(label)}">${escHtml(label)}</span>
|
||||
<input type="color" value="${color}" data-label="${escAttr(label)}" onchange="setNodeLabelColor(this.dataset.label, this.value)">
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
UI HELPERS
|
||||
================================================================= */
|
||||
|
|
|
|||
Loading…
Reference in New Issue