Merge upstream: add samples for E-List

This commit is contained in:
Patrice 2026-03-09 11:41:19 +01:00
commit 0c0df5fe8d
6 changed files with 361 additions and 57 deletions

View File

@ -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
View File

@ -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)

View File

@ -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()"
}
]

View File

@ -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()"
}
]

View File

@ -5,3 +5,5 @@ numpy>=1.24
scipy>=1.10
python-igraph>=0.11
gunicorn>=21.0
requests>=2.0
pytest>=7.0

View File

@ -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 &amp; 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
================================================================= */