Skip to content

Commit 3daec2b

Browse files
SimplicityGuyclaude
andcommitted
fix: correct Cypher queries for actual graph model and improve explore UI
All Neo4j queries were using incorrect relationship directions and nonexistent relationships. Fixed to match the actual graph model: - (Release)-[:BY]->(Artist) not (Artist)-[:BY]->(Release) - (Release)-[:IS]->(Genre/Style) not (Artist)-[:IS]->(Genre) - Year data from Master nodes via DERIVED_FROM, not Release.year - Artist aliases via ALIAS_OF relationship - Removed nonexistent HAS_GENRE/HAS_STYLE relationships UI improvements: - Add zoom in/out/reset/fullscreen controls to graph - Add loading spinner overlay during search and category expansion - Add info panel with node details on click - Add node expansion on double-click (re-centers graph on that entity) - Fix graph rendering to batch all category expansions into single render - Reset zoom on new searches - Fix year display in info panel (no thousands separator) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9ee53d6 commit 3daec2b

5 files changed

Lines changed: 372 additions & 113 deletions

File tree

explore/neo4j_queries.py

Lines changed: 88 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
"""Neo4j Cypher queries for Explore service.
22
33
All database queries are defined here to keep the API layer thin.
4+
5+
Graph model reference:
6+
(Release)-[:BY]->(Artist) Release is by Artist
7+
(Release)-[:ON]->(Label) Release is on Label
8+
(Release)-[:IS]->(Genre) Release is genre
9+
(Release)-[:IS]->(Style) Release is style
10+
(Release)-[:DERIVED_FROM]->(Master) Release derived from Master (Master has year)
11+
(Artist)-[:ALIAS_OF]->(Artist) Artist alias
12+
(Artist)-[:MEMBER_OF]->(Artist) Artist is member of group
413
"""
514

615
from functools import lru_cache
@@ -61,16 +70,18 @@ async def explore_artist(driver: AsyncResilientNeo4jDriver, name: str) -> dict[s
6170
"""Get artist center node with category counts."""
6271
cypher = """
6372
MATCH (a:Artist {name: $name})
64-
OPTIONAL MATCH (a)-[:BY]->(r:Release)
73+
OPTIONAL MATCH (r:Release)-[:BY]->(a)
6574
WITH a, count(DISTINCT r) AS release_count
66-
OPTIONAL MATCH (a)-[:BY]->(r2:Release)-[:ON]->(l:Label)
75+
OPTIONAL MATCH (r2:Release)-[:BY]->(a), (r2)-[:ON]->(l:Label)
6776
WITH a, release_count, count(DISTINCT l) AS label_count
68-
OPTIONAL MATCH (a)-[:MEMBER_OF]->(g:Artist)
69-
WITH a, release_count, label_count, count(DISTINCT g) AS alias_count
70-
OPTIONAL MATCH (a2:Artist)-[:MEMBER_OF]->(a)
71-
WITH a, release_count, label_count, alias_count + count(DISTINCT a2) AS alias_count
77+
OPTIONAL MATCH (a)-[:ALIAS_OF]->(alias:Artist)
78+
WITH a, release_count, label_count, count(DISTINCT alias) AS alias_count
79+
OPTIONAL MATCH (a)-[:MEMBER_OF]->(grp:Artist)
80+
WITH a, release_count, label_count, alias_count, count(DISTINCT grp) AS group_count
81+
OPTIONAL MATCH (m:Artist)-[:MEMBER_OF]->(a)
82+
WITH a, release_count, label_count, alias_count, group_count, count(DISTINCT m) AS member_count
7283
RETURN a.id AS id, a.name AS name,
73-
release_count, label_count, alias_count
84+
release_count, label_count, alias_count + group_count + member_count AS alias_count
7485
"""
7586
async with await driver.session() as session:
7687
result = await session.run(cypher, name=name)
@@ -84,11 +95,11 @@ async def explore_genre(driver: AsyncResilientNeo4jDriver, name: str) -> dict[st
8495
"""Get genre center node with category counts."""
8596
cypher = """
8697
MATCH (g:Genre {name: $name})
87-
OPTIONAL MATCH (a:Artist)-[:IS]->(g)
98+
OPTIONAL MATCH (r:Release)-[:IS]->(g), (r)-[:BY]->(a:Artist)
8899
WITH g, count(DISTINCT a) AS artist_count
89-
OPTIONAL MATCH (a2:Artist)-[:IS]->(g), (a2)-[:BY]->(r:Release)-[:ON]->(l:Label)
100+
OPTIONAL MATCH (r2:Release)-[:IS]->(g), (r2)-[:ON]->(l:Label)
90101
WITH g, artist_count, count(DISTINCT l) AS label_count
91-
OPTIONAL MATCH (a3:Artist)-[:IS]->(g), (a3)-[:IS]->(s:Style)
102+
OPTIONAL MATCH (r3:Release)-[:IS]->(g), (r3)-[:IS]->(s:Style)
92103
WITH g, artist_count, label_count, count(DISTINCT s) AS style_count
93104
RETURN g.name AS id, g.name AS name,
94105
artist_count, label_count, style_count
@@ -107,7 +118,7 @@ async def explore_label(driver: AsyncResilientNeo4jDriver, name: str) -> dict[st
107118
MATCH (l:Label {name: $name})
108119
OPTIONAL MATCH (r:Release)-[:ON]->(l)
109120
WITH l, count(DISTINCT r) AS release_count
110-
OPTIONAL MATCH (a:Artist)-[:BY]->(r2:Release)-[:ON]->(l)
121+
OPTIONAL MATCH (r2:Release)-[:ON]->(l), (r2)-[:BY]->(a:Artist)
111122
WITH l, release_count, count(DISTINCT a) AS artist_count
112123
RETURN l.id AS id, l.name AS name,
113124
release_count, artist_count
@@ -126,9 +137,12 @@ async def explore_label(driver: AsyncResilientNeo4jDriver, name: str) -> dict[st
126137
async def expand_artist_releases(driver: AsyncResilientNeo4jDriver, artist_name: str, limit: int = 50) -> list[dict[str, Any]]:
127138
"""Get releases by an artist."""
128139
cypher = """
129-
MATCH (a:Artist {name: $name})-[:BY]->(r:Release)
130-
RETURN r.id AS id, r.title AS name, 'release' AS type, r.year AS year
131-
ORDER BY r.year DESC
140+
MATCH (r:Release)-[:BY]->(a:Artist {name: $name})
141+
OPTIONAL MATCH (r)-[:DERIVED_FROM]->(m:Master)
142+
WITH r, m.year AS year
143+
RETURN r.id AS id, r.title AS name, 'release' AS type,
144+
CASE WHEN toInteger(year) > 0 THEN toInteger(year) ELSE null END AS year
145+
ORDER BY year DESC
132146
LIMIT $limit
133147
"""
134148
async with await driver.session() as session:
@@ -139,8 +153,8 @@ async def expand_artist_releases(driver: AsyncResilientNeo4jDriver, artist_name:
139153
async def expand_artist_labels(driver: AsyncResilientNeo4jDriver, artist_name: str, limit: int = 50) -> list[dict[str, Any]]:
140154
"""Get labels associated with an artist via their releases."""
141155
cypher = """
142-
MATCH (a:Artist {name: $name})-[:BY]->(r:Release)-[:ON]->(l:Label)
143-
RETURN DISTINCT l.id AS id, l.name AS name, 'label' AS type, count(r) AS release_count
156+
MATCH (r:Release)-[:BY]->(a:Artist {name: $name}), (r)-[:ON]->(l:Label)
157+
RETURN l.id AS id, l.name AS name, 'label' AS type, count(DISTINCT r) AS release_count
144158
ORDER BY release_count DESC
145159
LIMIT $limit
146160
"""
@@ -150,15 +164,18 @@ async def expand_artist_labels(driver: AsyncResilientNeo4jDriver, artist_name: s
150164

151165

152166
async def expand_artist_aliases(driver: AsyncResilientNeo4jDriver, artist_name: str, limit: int = 50) -> list[dict[str, Any]]:
153-
"""Get aliases and group memberships for an artist."""
167+
"""Get aliases, group memberships, and members for an artist."""
154168
cypher = """
155169
MATCH (a:Artist {name: $name})
156-
OPTIONAL MATCH (a)-[:MEMBER_OF]->(g:Artist)
157-
WITH a, collect(DISTINCT {id: g.id, name: g.name, type: 'artist'}) AS groups
170+
OPTIONAL MATCH (a)-[:ALIAS_OF]->(alias:Artist)
171+
WITH a, collect(DISTINCT {id: alias.id, name: alias.name, type: 'artist'}) AS aliases
172+
OPTIONAL MATCH (a)-[:MEMBER_OF]->(grp:Artist)
173+
WITH a, aliases, collect(DISTINCT {id: grp.id, name: grp.name, type: 'artist'}) AS groups
158174
OPTIONAL MATCH (m:Artist)-[:MEMBER_OF]->(a)
159-
WITH groups, collect(DISTINCT {id: m.id, name: m.name, type: 'artist'}) AS members
160-
UNWIND (groups + members) AS alias
161-
RETURN DISTINCT alias.id AS id, alias.name AS name, alias.type AS type
175+
WITH aliases, groups, collect(DISTINCT {id: m.id, name: m.name, type: 'artist'}) AS members
176+
UNWIND (aliases + groups + members) AS item
177+
WITH item WHERE item.id IS NOT NULL
178+
RETURN DISTINCT item.id AS id, item.name AS name, item.type AS type
162179
LIMIT $limit
163180
"""
164181
async with await driver.session() as session:
@@ -167,9 +184,9 @@ async def expand_artist_aliases(driver: AsyncResilientNeo4jDriver, artist_name:
167184

168185

169186
async def expand_genre_artists(driver: AsyncResilientNeo4jDriver, genre_name: str, limit: int = 50) -> list[dict[str, Any]]:
170-
"""Get artists in a genre."""
187+
"""Get artists in a genre (via releases)."""
171188
cypher = """
172-
MATCH (a:Artist)-[:IS]->(g:Genre {name: $name})
189+
MATCH (r:Release)-[:IS]->(g:Genre {name: $name}), (r)-[:BY]->(a:Artist)
173190
RETURN DISTINCT a.id AS id, a.name AS name, 'artist' AS type
174191
ORDER BY a.name
175192
LIMIT $limit
@@ -180,11 +197,10 @@ async def expand_genre_artists(driver: AsyncResilientNeo4jDriver, genre_name: st
180197

181198

182199
async def expand_genre_labels(driver: AsyncResilientNeo4jDriver, genre_name: str, limit: int = 50) -> list[dict[str, Any]]:
183-
"""Get labels associated with a genre via artists and releases."""
200+
"""Get labels associated with a genre via releases."""
184201
cypher = """
185-
MATCH (a:Artist)-[:IS]->(g:Genre {name: $name}),
186-
(a)-[:BY]->(r:Release)-[:ON]->(l:Label)
187-
RETURN DISTINCT l.id AS id, l.name AS name, 'label' AS type, count(DISTINCT r) AS release_count
202+
MATCH (r:Release)-[:IS]->(g:Genre {name: $name}), (r)-[:ON]->(l:Label)
203+
RETURN l.id AS id, l.name AS name, 'label' AS type, count(DISTINCT r) AS release_count
188204
ORDER BY release_count DESC
189205
LIMIT $limit
190206
"""
@@ -194,12 +210,11 @@ async def expand_genre_labels(driver: AsyncResilientNeo4jDriver, genre_name: str
194210

195211

196212
async def expand_genre_styles(driver: AsyncResilientNeo4jDriver, genre_name: str, limit: int = 50) -> list[dict[str, Any]]:
197-
"""Get styles (subgenres) associated with a genre."""
213+
"""Get styles (subgenres) associated with a genre via releases."""
198214
cypher = """
199-
MATCH (a:Artist)-[:IS]->(g:Genre {name: $name}),
200-
(a)-[:IS]->(s:Style)
201-
RETURN DISTINCT s.name AS id, s.name AS name, 'style' AS type, count(DISTINCT a) AS artist_count
202-
ORDER BY artist_count DESC
215+
MATCH (r:Release)-[:IS]->(g:Genre {name: $name}), (r)-[:IS]->(s:Style)
216+
RETURN s.name AS id, s.name AS name, 'style' AS type, count(DISTINCT r) AS release_count
217+
ORDER BY release_count DESC
203218
LIMIT $limit
204219
"""
205220
async with await driver.session() as session:
@@ -211,8 +226,11 @@ async def expand_label_releases(driver: AsyncResilientNeo4jDriver, label_name: s
211226
"""Get releases on a label."""
212227
cypher = """
213228
MATCH (r:Release)-[:ON]->(l:Label {name: $name})
214-
RETURN r.id AS id, r.title AS name, 'release' AS type, r.year AS year
215-
ORDER BY r.year DESC
229+
OPTIONAL MATCH (r)-[:DERIVED_FROM]->(m:Master)
230+
WITH r, m.year AS year
231+
RETURN r.id AS id, r.title AS name, 'release' AS type,
232+
CASE WHEN toInteger(year) > 0 THEN toInteger(year) ELSE null END AS year
233+
ORDER BY year DESC
216234
LIMIT $limit
217235
"""
218236
async with await driver.session() as session:
@@ -223,8 +241,8 @@ async def expand_label_releases(driver: AsyncResilientNeo4jDriver, label_name: s
223241
async def expand_label_artists(driver: AsyncResilientNeo4jDriver, label_name: str, limit: int = 50) -> list[dict[str, Any]]:
224242
"""Get artists on a label."""
225243
cypher = """
226-
MATCH (a:Artist)-[:BY]->(r:Release)-[:ON]->(l:Label {name: $name})
227-
RETURN DISTINCT a.id AS id, a.name AS name, 'artist' AS type, count(DISTINCT r) AS release_count
244+
MATCH (r:Release)-[:ON]->(l:Label {name: $name}), (r)-[:BY]->(a:Artist)
245+
RETURN a.id AS id, a.name AS name, 'artist' AS type, count(DISTINCT r) AS release_count
228246
ORDER BY release_count DESC
229247
LIMIT $limit
230248
"""
@@ -240,12 +258,12 @@ async def get_artist_details(driver: AsyncResilientNeo4jDriver, node_id: str) ->
240258
"""Get full details for an artist node."""
241259
cypher = """
242260
MATCH (a:Artist) WHERE a.id = $id OR a.name = $id
243-
OPTIONAL MATCH (a)-[:IS]->(g:Genre)
261+
OPTIONAL MATCH (r:Release)-[:BY]->(a), (r)-[:IS]->(g:Genre)
244262
WITH a, collect(DISTINCT g.name) AS genres
245-
OPTIONAL MATCH (a)-[:IS]->(s:Style)
263+
OPTIONAL MATCH (r2:Release)-[:BY]->(a), (r2)-[:IS]->(s:Style)
246264
WITH a, genres, collect(DISTINCT s.name) AS styles
247-
OPTIONAL MATCH (a)-[:BY]->(r:Release)
248-
WITH a, genres, styles, count(DISTINCT r) AS release_count
265+
OPTIONAL MATCH (r3:Release)-[:BY]->(a)
266+
WITH a, genres, styles, count(DISTINCT r3) AS release_count
249267
OPTIONAL MATCH (a)-[:MEMBER_OF]->(grp:Artist)
250268
WITH a, genres, styles, release_count, collect(DISTINCT grp.name) AS groups
251269
RETURN a.id AS id, a.name AS name, genres, styles, release_count, groups
@@ -262,15 +280,18 @@ async def get_release_details(driver: AsyncResilientNeo4jDriver, node_id: str) -
262280
"""Get full details for a release node."""
263281
cypher = """
264282
MATCH (r:Release) WHERE r.id = $id OR r.title = $id
265-
OPTIONAL MATCH (a:Artist)-[:BY]->(r)
283+
OPTIONAL MATCH (r)-[:BY]->(a:Artist)
266284
WITH r, collect(DISTINCT a.name) AS artists
267285
OPTIONAL MATCH (r)-[:ON]->(l:Label)
268286
WITH r, artists, collect(DISTINCT l.name) AS labels
269-
OPTIONAL MATCH (r)-[:HAS_GENRE]->(g:Genre)
287+
OPTIONAL MATCH (r)-[:IS]->(g:Genre)
270288
WITH r, artists, labels, collect(DISTINCT g.name) AS genres
271-
OPTIONAL MATCH (r)-[:HAS_STYLE]->(s:Style)
289+
OPTIONAL MATCH (r)-[:IS]->(s:Style)
272290
WITH r, artists, labels, genres, collect(DISTINCT s.name) AS styles
273-
RETURN r.id AS id, r.title AS name, r.year AS year, r.country AS country,
291+
OPTIONAL MATCH (r)-[:DERIVED_FROM]->(m:Master)
292+
WITH r, artists, labels, genres, styles,
293+
CASE WHEN toInteger(m.year) > 0 THEN toInteger(m.year) ELSE null END AS year
294+
RETURN r.id AS id, r.title AS name, year,
274295
artists, labels, genres, styles
275296
"""
276297
async with await driver.session() as session:
@@ -301,7 +322,7 @@ async def get_genre_details(driver: AsyncResilientNeo4jDriver, node_id: str) ->
301322
"""Get full details for a genre node."""
302323
cypher = """
303324
MATCH (g:Genre {name: $id})
304-
OPTIONAL MATCH (a:Artist)-[:IS]->(g)
325+
OPTIONAL MATCH (r:Release)-[:IS]->(g), (r)-[:BY]->(a:Artist)
305326
WITH g, count(DISTINCT a) AS artist_count
306327
RETURN g.name AS id, g.name AS name, artist_count
307328
"""
@@ -317,38 +338,44 @@ async def get_genre_details(driver: AsyncResilientNeo4jDriver, node_id: str) ->
317338

318339

319340
async def trends_artist(driver: AsyncResilientNeo4jDriver, name: str) -> list[dict[str, Any]]:
320-
"""Get release count by year for an artist."""
341+
"""Get release count by year for an artist (year from Master)."""
321342
cypher = """
322-
MATCH (a:Artist {name: $name})-[:BY]->(r:Release)
323-
WHERE r.year IS NOT NULL AND r.year > 0
324-
RETURN r.year AS year, count(r) AS count
325-
ORDER BY r.year
343+
MATCH (r:Release)-[:BY]->(a:Artist {name: $name}),
344+
(r)-[:DERIVED_FROM]->(m:Master)
345+
WHERE toInteger(m.year) > 0
346+
WITH toInteger(m.year) AS year, count(DISTINCT r) AS count
347+
RETURN year, count
348+
ORDER BY year
326349
"""
327350
async with await driver.session() as session:
328351
result = await session.run(cypher, name=name)
329352
return [dict(record) async for record in result]
330353

331354

332355
async def trends_genre(driver: AsyncResilientNeo4jDriver, name: str) -> list[dict[str, Any]]:
333-
"""Get release count by year for a genre."""
356+
"""Get release count by year for a genre (year from Master)."""
334357
cypher = """
335-
MATCH (r:Release)-[:HAS_GENRE]->(g:Genre {name: $name})
336-
WHERE r.year IS NOT NULL AND r.year > 0
337-
RETURN r.year AS year, count(r) AS count
338-
ORDER BY r.year
358+
MATCH (r:Release)-[:IS]->(g:Genre {name: $name}),
359+
(r)-[:DERIVED_FROM]->(m:Master)
360+
WHERE toInteger(m.year) > 0
361+
WITH toInteger(m.year) AS year, count(DISTINCT r) AS count
362+
RETURN year, count
363+
ORDER BY year
339364
"""
340365
async with await driver.session() as session:
341366
result = await session.run(cypher, name=name)
342367
return [dict(record) async for record in result]
343368

344369

345370
async def trends_label(driver: AsyncResilientNeo4jDriver, name: str) -> list[dict[str, Any]]:
346-
"""Get release count by year for a label."""
371+
"""Get release count by year for a label (year from Master)."""
347372
cypher = """
348-
MATCH (r:Release)-[:ON]->(l:Label {name: $name})
349-
WHERE r.year IS NOT NULL AND r.year > 0
350-
RETURN r.year AS year, count(r) AS count
351-
ORDER BY r.year
373+
MATCH (r:Release)-[:ON]->(l:Label {name: $name}),
374+
(r)-[:DERIVED_FROM]->(m:Master)
375+
WHERE toInteger(m.year) > 0
376+
WITH toInteger(m.year) AS year, count(DISTINCT r) AS count
377+
RETURN year, count
378+
ORDER BY year
352379
"""
353380
async with await driver.session() as session:
354381
result = await session.run(cypher, name=name)

explore/static/css/styles.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,67 @@ body {
145145
display: none;
146146
}
147147

148+
/* Loading overlay */
149+
.loading-overlay {
150+
display: none;
151+
position: absolute;
152+
top: 0;
153+
left: 0;
154+
right: 0;
155+
bottom: 0;
156+
background-color: rgba(10, 14, 39, 0.85);
157+
z-index: 15;
158+
justify-content: center;
159+
align-items: center;
160+
flex-direction: column;
161+
}
162+
163+
.loading-overlay.active {
164+
display: flex;
165+
}
166+
167+
/* Graph controls */
168+
.graph-controls {
169+
position: absolute;
170+
top: 20px;
171+
right: 20px;
172+
display: flex;
173+
flex-direction: column;
174+
gap: 6px;
175+
z-index: 10;
176+
}
177+
178+
.graph-control-btn {
179+
width: 36px;
180+
height: 36px;
181+
border: 1px solid var(--border-color);
182+
border-radius: 6px;
183+
background-color: rgba(30, 33, 57, 0.9);
184+
color: var(--text-primary);
185+
cursor: pointer;
186+
display: flex;
187+
align-items: center;
188+
justify-content: center;
189+
font-size: 14px;
190+
transition: background-color 0.15s, border-color 0.15s;
191+
}
192+
193+
.graph-control-btn:hover {
194+
background-color: var(--bg-card);
195+
border-color: var(--accent-blue);
196+
}
197+
198+
/* Fullscreen mode */
199+
.graph-container.fullscreen {
200+
position: fixed;
201+
top: 0;
202+
left: 0;
203+
width: 100vw;
204+
height: 100vh;
205+
z-index: 2000;
206+
background-color: var(--bg-primary);
207+
}
208+
148209
/* Graph legend */
149210
.graph-legend {
150211
position: absolute;

explore/static/index.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@
6565
<div class="legend-item"><span class="legend-dot" style="background:#ffc107"></span>Genre/Style</div>
6666
<div class="legend-item"><span class="legend-dot legend-rect" style="background:#4A90D9"></span>Category</div>
6767
</div>
68+
<div class="graph-controls" id="graphControls">
69+
<button class="graph-control-btn" id="zoomInBtn" title="Zoom in">
70+
<i class="fas fa-plus"></i>
71+
</button>
72+
<button class="graph-control-btn" id="zoomOutBtn" title="Zoom out">
73+
<i class="fas fa-minus"></i>
74+
</button>
75+
<button class="graph-control-btn" id="zoomResetBtn" title="Reset zoom">
76+
<i class="fas fa-compress-arrows-alt"></i>
77+
</button>
78+
<button class="graph-control-btn" id="fullscreenBtn" title="Fullscreen">
79+
<i class="fas fa-expand"></i>
80+
</button>
81+
</div>
82+
<div class="loading-overlay" id="graphLoading">
83+
<div class="spinner-border text-primary" role="status"></div>
84+
<p class="mt-2 text-muted">Loading graph...</p>
85+
</div>
6886
<div class="graph-placeholder" id="graphPlaceholder">
6987
<i class="fas fa-project-diagram fa-3x mb-3 text-muted"></i>
7088
<p class="text-muted">Search for an artist, genre, or label to start exploring</p>
@@ -88,6 +106,10 @@ <h5 id="infoPanelTitle">Details</h5>
88106
<div class="pane" id="trendsPane">
89107
<div class="trends-container">
90108
<div id="trendsChart"></div>
109+
<div class="loading-overlay" id="trendsLoading">
110+
<div class="spinner-border text-primary" role="status"></div>
111+
<p class="mt-2 text-muted">Loading trends...</p>
112+
</div>
91113
<div class="trends-placeholder" id="trendsPlaceholder">
92114
<i class="fas fa-chart-line fa-3x mb-3 text-muted"></i>
93115
<p class="text-muted">Search for an entity to see release trends over time</p>

0 commit comments

Comments
 (0)