From 60e480195150fcc55eab6db1869bae47a5272f9c Mon Sep 17 00:00:00 2001 From: Askill Date: Sat, 7 Mar 2026 16:17:52 +0100 Subject: [PATCH] add samples for E-List --- README.md | 2 + app.py | 134 +++++++++++++------ config/sample_queries.json | 46 +++++++ config/sample_queries_analytics.json | 50 ++++++++ requirements.txt | 2 + templates/index.html | 184 ++++++++++++++++++++++++--- 6 files changed, 361 insertions(+), 57 deletions(-) create mode 100644 config/sample_queries.json create mode 100644 config/sample_queries_analytics.json diff --git a/README.md b/README.md index 29e357c..8c0ef48 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,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). @@ -59,6 +60,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 diff --git a/app.py b/app.py index db72cad..3234ca4 100644 --- a/app.py +++ b/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) diff --git a/config/sample_queries.json b/config/sample_queries.json new file mode 100644 index 0000000..f1b6f97 --- /dev/null +++ b/config/sample_queries.json @@ -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, '') 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, '')) 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()" + } +] \ No newline at end of file diff --git a/config/sample_queries_analytics.json b/config/sample_queries_analytics.json new file mode 100644 index 0000000..f18f3f6 --- /dev/null +++ b/config/sample_queries_analytics.json @@ -0,0 +1,50 @@ +[ + { + "name": "Focused Entity Relation Graph", + "query": "MATCH (a:Entity)-[r:RELATION]-(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN a, r, b LIMIT 2000" + }, + { + "name": "Entity Relation Leaders (Focused Types)", + "query": "MATCH (e:Entity)-[r:RELATION]-(:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] WITH e, count(r) AS rel_degree RETURN e, rel_degree ORDER BY rel_degree DESC LIMIT 75" + }, + { + "name": "Person Event Bridges", + "query": "MATCH (p:Entity)-[r:RELATION]-(ev:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] RETURN p, r, ev LIMIT 1800" + }, + { + "name": "Event Date Location Triads", + "query": "MATCH (ev:Entity)-[r1:RELATION]-(d:Entity), (ev)-[r2:RELATION]-(loc:Entity) WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] AND toLower(coalesce(d.type, '')) IN ['date','time','datetime'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN ev, r1, d, r2, loc LIMIT 1500" + }, + { + "name": "Persons by Location", + "query": "MATCH (p:Entity)-[r:RELATION]-(loc:Entity) WHERE toLower(coalesce(p.type, '')) IN ['person','people'] AND toLower(coalesce(loc.type, '')) IN ['location','place','city','country','gpe'] RETURN p, r, loc LIMIT 1800" + }, + { + "name": "Top Events 2-Hop Neighborhood", + "query": "MATCH (ev:Entity)-[r0:RELATION]-() WHERE toLower(coalesce(ev.type, '')) IN ['event','incident','meeting','occurrence'] WITH ev, count(r0) AS degree ORDER BY degree DESC LIMIT 25 MATCH path=(ev)-[:RELATION*1..2]-(n:Entity) WHERE toLower(coalesce(n.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN path LIMIT 2200" + }, + { + "name": "Asymmetric Focused Relations", + "query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND NOT (b)-[:RELATION]->(a) RETURN a, r, b LIMIT 1800" + }, + { + "name": "Reciprocal Focused Relations", + "query": "MATCH (a:Entity)-[r1:RELATION]->(b:Entity), (b)-[r2:RELATION]->(a) WHERE id(a) < id(b) AND toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN a, r1, b, r2 LIMIT 1800" + }, + { + "name": "Predicate Distribution (Focused)", + "query": "MATCH (a:Entity)-[r:RELATION]->(b:Entity) WHERE toLower(coalesce(a.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] AND toLower(coalesce(b.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN coalesce(r.predicate_display, r.predicate, '') AS predicate, count(*) AS count ORDER BY count DESC LIMIT 75" + }, + { + "name": "Entity Type Counts (Focused)", + "query": "MATCH (e:Entity) WHERE toLower(coalesce(e.type, '')) IN ['person','people','event','incident','meeting','occurrence','date','time','datetime','location','place','city','country','gpe'] RETURN toLower(coalesce(e.type, '')) AS entity_type, count(*) AS count ORDER BY count DESC" + }, + { + "name": "Potential Duplicate Entities (Canonical Key)", + "query": "MATCH (e:Entity) WHERE e.canonical_key IS NOT NULL AND trim(toString(e.canonical_key)) <> '' WITH toLower(toString(e.canonical_key)) AS key, collect(e) AS ents WHERE size(ents) > 1 UNWIND ents AS e RETURN e, key LIMIT 1500" + }, + { + "name": "Schema Visualization", + "query": "CALL db.schema.visualization()" + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7303ed1..7c3b28f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ numpy>=1.24 scipy>=1.10 python-igraph>=0.11 gunicorn>=21.0 +requests>=2.0 +pytest>=7.0 diff --git a/templates/index.html b/templates/index.html index f03daf4..c681794 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 @@ +
+ + +
+
+ + + 1.00 +
+
+ + + 2 +
Nodes & Labels
+
+ + +
+
1.0
+
+ Node Colors
+
+ +
+
+
Run a query to edit label colors
+
@@ -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 => - `${l}` - ).join(' '); + ttLabel.textContent = node.displayLabel || node.label || node.id; + ttLabels.innerHTML = (node.labels || []).map(l => { + const c = getLabelColor(l, state.labelColors[l] || '#888888'); + return `${l}`; + }).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 `
-
+
${escHtml(label)} ${count}
`; @@ -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 => `
- - ${escHtml(n.label)} + + ${escHtml(n.displayLabel || n.label || n.id)}
`).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 = '
Run a query to edit label colors
'; + return; + } + + el.innerHTML = labels.map(label => { + const color = getLabelColor(label, state.labelColors[label]); + return ` +
+ ${escHtml(label)} + +
+ `; + }).join(''); + } + /* ================================================================= UI HELPERS ================================================================= */